refactor: refactor slash command parsing to a util function. (#8381)

This commit is contained in:
James
2025-09-15 23:56:07 +00:00
committed by GitHub
parent bee5b638dd
commit c0794215d3
3 changed files with 217 additions and 41 deletions

View File

@@ -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,

View File

@@ -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([]);
});
});

View File

@@ -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 };
};