From c0794215d36f4c15500a7b2a61d29f5f979411a1 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 15 Sep 2025 23:56:07 +0000 Subject: [PATCH] refactor: refactor slash command parsing to a util function. (#8381) --- .../cli/src/ui/hooks/slashCommandProcessor.ts | 47 +----- packages/cli/src/utils/commands.test.ts | 140 ++++++++++++++++++ packages/cli/src/utils/commands.ts | 71 +++++++++ 3 files changed, 217 insertions(+), 41 deletions(-) create mode 100644 packages/cli/src/utils/commands.test.ts create mode 100644 packages/cli/src/utils/commands.ts diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 16ddb7042e..5b22f5d198 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -33,6 +33,7 @@ import { CommandService } from '../../services/CommandService.js'; import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; +import { parseSlashCommand } from '../../utils/commands.js'; import type { ExtensionUpdateState } from '../state/extensions.js'; interface SlashCommandProcessorActions { @@ -287,47 +288,13 @@ export const useSlashCommandProcessor = ( const userMessageTimestamp = Date.now(); addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp); - const parts = trimmed.substring(1).trim().split(/\s+/); - const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add'] - - let currentCommands = commands; - let commandToExecute: SlashCommand | undefined; - let pathIndex = 0; let hasError = false; - const canonicalPath: string[] = []; + const { + commandToExecute, + args, + canonicalPath: resolvedCommandPath, + } = parseSlashCommand(trimmed, commands); - for (const part of commandPath) { - // TODO: For better performance and architectural clarity, this two-pass - // search could be replaced. A more optimal approach would be to - // pre-compute a single lookup map in `CommandService.ts` that resolves - // all name and alias conflicts during the initial loading phase. The - // processor would then perform a single, fast lookup on that map. - - // First pass: check for an exact match on the primary command name. - let foundCommand = currentCommands.find((cmd) => cmd.name === part); - - // Second pass: if no primary name matches, check for an alias. - if (!foundCommand) { - foundCommand = currentCommands.find((cmd) => - cmd.altNames?.includes(part), - ); - } - - if (foundCommand) { - commandToExecute = foundCommand; - canonicalPath.push(foundCommand.name); - pathIndex++; - if (foundCommand.subCommands) { - currentCommands = foundCommand.subCommands; - } else { - break; - } - } else { - break; - } - } - - const resolvedCommandPath = canonicalPath; const subcommand = resolvedCommandPath.length > 1 ? resolvedCommandPath.slice(1).join(' ') @@ -335,8 +302,6 @@ export const useSlashCommandProcessor = ( try { if (commandToExecute) { - const args = parts.slice(pathIndex).join(' '); - if (commandToExecute.action) { const fullCommandContext: CommandContext = { ...commandContext, diff --git a/packages/cli/src/utils/commands.test.ts b/packages/cli/src/utils/commands.test.ts new file mode 100644 index 0000000000..30040a0350 --- /dev/null +++ b/packages/cli/src/utils/commands.test.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { parseSlashCommand } from './commands.js'; +import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; + +// Mock command structure for testing +const mockCommands: readonly SlashCommand[] = [ + { + name: 'help', + description: 'Show help', + action: async () => {}, + kind: CommandKind.BUILT_IN, + }, + { + name: 'commit', + description: 'Commit changes', + action: async () => {}, + kind: CommandKind.FILE, + }, + { + name: 'memory', + description: 'Manage memory', + altNames: ['mem'], + subCommands: [ + { + name: 'add', + description: 'Add to memory', + action: async () => {}, + kind: CommandKind.BUILT_IN, + }, + { + name: 'clear', + description: 'Clear memory', + altNames: ['c'], + action: async () => {}, + kind: CommandKind.BUILT_IN, + }, + ], + kind: CommandKind.BUILT_IN, + }, +]; + +describe('parseSlashCommand', () => { + it('should parse a simple command without arguments', () => { + const result = parseSlashCommand('/help', mockCommands); + expect(result.commandToExecute?.name).toBe('help'); + expect(result.args).toBe(''); + expect(result.canonicalPath).toEqual(['help']); + }); + + it('should parse a simple command with arguments', () => { + const result = parseSlashCommand( + '/commit -m "Initial commit"', + mockCommands, + ); + expect(result.commandToExecute?.name).toBe('commit'); + expect(result.args).toBe('-m "Initial commit"'); + expect(result.canonicalPath).toEqual(['commit']); + }); + + it('should parse a subcommand', () => { + const result = parseSlashCommand('/memory add', mockCommands); + expect(result.commandToExecute?.name).toBe('add'); + expect(result.args).toBe(''); + expect(result.canonicalPath).toEqual(['memory', 'add']); + }); + + it('should parse a subcommand with arguments', () => { + const result = parseSlashCommand( + '/memory add some important data', + mockCommands, + ); + expect(result.commandToExecute?.name).toBe('add'); + expect(result.args).toBe('some important data'); + expect(result.canonicalPath).toEqual(['memory', 'add']); + }); + + it('should handle a command alias', () => { + const result = parseSlashCommand('/mem add some data', mockCommands); + expect(result.commandToExecute?.name).toBe('add'); + expect(result.args).toBe('some data'); + expect(result.canonicalPath).toEqual(['memory', 'add']); + }); + + it('should handle a subcommand alias', () => { + const result = parseSlashCommand('/memory c', mockCommands); + expect(result.commandToExecute?.name).toBe('clear'); + expect(result.args).toBe(''); + expect(result.canonicalPath).toEqual(['memory', 'clear']); + }); + + it('should return undefined for an unknown command', () => { + const result = parseSlashCommand('/unknown', mockCommands); + expect(result.commandToExecute).toBeUndefined(); + expect(result.args).toBe('unknown'); + expect(result.canonicalPath).toEqual([]); + }); + + it('should return the parent command if subcommand is unknown', () => { + const result = parseSlashCommand( + '/memory unknownsub some args', + mockCommands, + ); + expect(result.commandToExecute?.name).toBe('memory'); + expect(result.args).toBe('unknownsub some args'); + expect(result.canonicalPath).toEqual(['memory']); + }); + + it('should handle extra whitespace', () => { + const result = parseSlashCommand( + ' /memory add some data ', + mockCommands, + ); + expect(result.commandToExecute?.name).toBe('add'); + expect(result.args).toBe('some data'); + expect(result.canonicalPath).toEqual(['memory', 'add']); + }); + + it('should return undefined if query does not start with a slash', () => { + const result = parseSlashCommand('help', mockCommands); + expect(result.commandToExecute).toBeUndefined(); + }); + + it('should handle an empty query', () => { + const result = parseSlashCommand('', mockCommands); + expect(result.commandToExecute).toBeUndefined(); + }); + + it('should handle a query with only a slash', () => { + const result = parseSlashCommand('/', mockCommands); + expect(result.commandToExecute).toBeUndefined(); + expect(result.args).toBe(''); + expect(result.canonicalPath).toEqual([]); + }); +}); diff --git a/packages/cli/src/utils/commands.ts b/packages/cli/src/utils/commands.ts new file mode 100644 index 0000000000..c96c8c6ef7 --- /dev/null +++ b/packages/cli/src/utils/commands.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type SlashCommand } from '../ui/commands/types.js'; + +export type ParsedSlashCommand = { + commandToExecute: SlashCommand | undefined; + args: string; + canonicalPath: string[]; +}; + +/** + * Parses a raw slash command string into its command, arguments, and canonical path. + * If no valid command is found, the `commandToExecute` property will be `undefined`. + * + * @param query The raw input string, e.g., "/memory add some data" or "/help". + * @param commands The list of available top-level slash commands. + * @returns An object containing the resolved command, its arguments, and its canonical path. + */ +export const parseSlashCommand = ( + query: string, + commands: readonly SlashCommand[], +): ParsedSlashCommand => { + const trimmed = query.trim(); + + const parts = trimmed.substring(1).trim().split(/\s+/); + const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add'] + + let currentCommands = commands; + let commandToExecute: SlashCommand | undefined; + let pathIndex = 0; + const canonicalPath: string[] = []; + + for (const part of commandPath) { + // TODO: For better performance and architectural clarity, this two-pass + // search could be replaced. A more optimal approach would be to + // pre-compute a single lookup map in `CommandService.ts` that resolves + // all name and alias conflicts during the initial loading phase. The + // processor would then perform a single, fast lookup on that map. + + // First pass: check for an exact match on the primary command name. + let foundCommand = currentCommands.find((cmd) => cmd.name === part); + + // Second pass: if no primary name matches, check for an alias. + if (!foundCommand) { + foundCommand = currentCommands.find((cmd) => + cmd.altNames?.includes(part), + ); + } + + if (foundCommand) { + commandToExecute = foundCommand; + canonicalPath.push(foundCommand.name); + pathIndex++; + if (foundCommand.subCommands) { + currentCommands = foundCommand.subCommands; + } else { + break; + } + } else { + break; + } + } + + const args = parts.slice(pathIndex).join(' '); + + return { commandToExecute, args, canonicalPath }; +};