feat: add command runner and registry
This commit is contained in:
parent
281cbf845d
commit
3bc35df63c
|
|
@ -20,6 +20,9 @@ export { createRule, dispatchCommand } from './core/rule';
|
||||||
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
||||||
export { parseCommand, parseCommandSchema, validateCommand, parseCommandWithSchema, applyCommandSchema } from './utils/command';
|
export { parseCommand, parseCommandSchema, validateCommand, parseCommandWithSchema, applyCommandSchema } from './utils/command';
|
||||||
|
|
||||||
|
export type { CommandRunner, CommandRunnerHandler, CommandRegistry, CommandRunnerContext } from './utils/command';
|
||||||
|
export { createCommandRegistry, registerCommand, unregisterCommand, hasCommand, getCommand, runCommand, createCommandRunnerContext } from './utils/command';
|
||||||
|
|
||||||
export type { Entity, EntityAccessor } from './utils/entity';
|
export type { Entity, EntityAccessor } from './utils/entity';
|
||||||
export { createEntityCollection } from './utils/entity';
|
export { createEntityCollection } from './utils/entity';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import type { Command } from './types.js';
|
||||||
|
import type { CommandRunner, CommandRunnerContext as RunnerContext } from './command-runner.js';
|
||||||
|
import { parseCommand } from './command-parse.js';
|
||||||
|
import { applyCommandSchema } from './command-apply.js';
|
||||||
|
|
||||||
|
export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>;
|
||||||
|
|
||||||
|
export function createCommandRegistry<TContext>(): CommandRegistry<TContext> {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCommand<TContext, TResult>(
|
||||||
|
registry: CommandRegistry<TContext>,
|
||||||
|
runner: CommandRunner<TContext, TResult>
|
||||||
|
): void {
|
||||||
|
registry.set(runner.schema.name, runner as CommandRunner<TContext, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterCommand<TContext>(
|
||||||
|
registry: CommandRegistry<TContext>,
|
||||||
|
name: string
|
||||||
|
): void {
|
||||||
|
registry.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasCommand<TContext>(
|
||||||
|
registry: CommandRegistry<TContext>,
|
||||||
|
name: string
|
||||||
|
): boolean {
|
||||||
|
return registry.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCommand<TContext>(
|
||||||
|
registry: CommandRegistry<TContext>,
|
||||||
|
name: string
|
||||||
|
): CommandRunner<TContext, unknown> | undefined {
|
||||||
|
return registry.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeWithRunnerContext<TContext>(
|
||||||
|
registry: CommandRegistry<TContext>,
|
||||||
|
context: TContext,
|
||||||
|
runner: CommandRunner<TContext, unknown>,
|
||||||
|
command: Command
|
||||||
|
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
|
||||||
|
const runnerCtx: RunnerContext<TContext> = {
|
||||||
|
context,
|
||||||
|
run: (input: string) => runCommand(registry, context, input),
|
||||||
|
runParsed: (cmd: Command) => runCommandParsed(registry, context, cmd),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const result = await runner.run.call(runnerCtx, command);
|
||||||
|
return { success: true, result };
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as Error;
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCommand<TContext>(
|
||||||
|
registry: CommandRegistry<TContext>,
|
||||||
|
context: TContext,
|
||||||
|
input: string
|
||||||
|
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
|
||||||
|
const command = parseCommand(input);
|
||||||
|
return await runCommandParsed(registry, context, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCommandParsed<TContext>(
|
||||||
|
registry: CommandRegistry<TContext>,
|
||||||
|
context: TContext,
|
||||||
|
command: Command
|
||||||
|
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
|
||||||
|
const runner = registry.get(command.name);
|
||||||
|
if (!runner) {
|
||||||
|
return { success: false, error: `Unknown command: ${command.name}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationResult = applyCommandSchema(command, runner.schema);
|
||||||
|
if (!validationResult.valid) {
|
||||||
|
return { success: false, error: validationResult.errors.join('; ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
return await executeWithRunnerContext(registry, context, runner, validationResult.command);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandRunnerContext<TContext> = RunnerContext<TContext> & {
|
||||||
|
registry: CommandRegistry<TContext>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createCommandRunnerContext<TContext>(
|
||||||
|
registry: CommandRegistry<TContext>,
|
||||||
|
context: TContext
|
||||||
|
): CommandRunnerContext<TContext> {
|
||||||
|
return {
|
||||||
|
registry,
|
||||||
|
context,
|
||||||
|
run: (input: string) => runCommand(registry, context, input),
|
||||||
|
runParsed: (command: Command) => runCommandParsed(registry, context, command),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Command, CommandSchema } from './types.js';
|
||||||
|
|
||||||
|
export type CommandRunnerContext<TContext> = {
|
||||||
|
context: TContext;
|
||||||
|
run: (input: string) => Promise<{ success: true; result: unknown } | { success: false; error: string }>;
|
||||||
|
runParsed: (command: Command) => Promise<{ success: true; result: unknown } | { success: false; error: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandRunnerHandler<TContext, TResult> = (
|
||||||
|
this: CommandRunnerContext<TContext>,
|
||||||
|
command: Command
|
||||||
|
) => Promise<TResult>;
|
||||||
|
|
||||||
|
export type CommandRunner<TContext, TResult = unknown> = {
|
||||||
|
schema: CommandSchema;
|
||||||
|
run: CommandRunnerHandler<TContext, TResult>;
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
export { parseCommand } from './command-parse';
|
export { parseCommand } from './command-parse';
|
||||||
export { parseCommandSchema } from './schema-parse';
|
export { parseCommandSchema } from './schema-parse';
|
||||||
export { validateCommand, parseCommandWithSchema, applyCommandSchema } from './command-validate';
|
export { validateCommand, parseCommandWithSchema, applyCommandSchema } from './command-validate';
|
||||||
|
export {
|
||||||
|
createCommandRegistry,
|
||||||
|
registerCommand,
|
||||||
|
unregisterCommand,
|
||||||
|
hasCommand,
|
||||||
|
getCommand,
|
||||||
|
runCommand,
|
||||||
|
runCommandParsed,
|
||||||
|
createCommandRunnerContext,
|
||||||
|
} from './command-registry';
|
||||||
export type {
|
export type {
|
||||||
Command,
|
Command,
|
||||||
CommandParamSchema,
|
CommandParamSchema,
|
||||||
|
|
@ -8,3 +18,5 @@ export type {
|
||||||
CommandFlagSchema,
|
CommandFlagSchema,
|
||||||
CommandSchema,
|
CommandSchema,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
export type { CommandRunner, CommandRunnerHandler } from './command-runner';
|
||||||
|
export type { CommandRegistry, CommandRunnerContext } from './command-registry';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseCommandSchema } from '../../src/utils/command/schema-parse';
|
||||||
|
import {
|
||||||
|
createCommandRegistry,
|
||||||
|
registerCommand,
|
||||||
|
unregisterCommand,
|
||||||
|
hasCommand,
|
||||||
|
getCommand,
|
||||||
|
runCommand,
|
||||||
|
createCommandRunnerContext,
|
||||||
|
type CommandRegistry,
|
||||||
|
type CommandRunnerContext,
|
||||||
|
} from '../../src/utils/command/command-registry';
|
||||||
|
import type { CommandRunner } from '../../src/utils/command/command-runner';
|
||||||
|
|
||||||
|
type TestContext = {
|
||||||
|
counter: number;
|
||||||
|
log: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CommandRegistry', () => {
|
||||||
|
it('should create an empty registry', () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
expect(registry.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register a command', () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
const runner: CommandRunner<TestContext, number> = {
|
||||||
|
schema: parseCommandSchema('add <a> <b>'),
|
||||||
|
run: async function (cmd) {
|
||||||
|
return Number(cmd.params[0]) + Number(cmd.params[1]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
registerCommand(registry, runner);
|
||||||
|
expect(registry.size).toBe(1);
|
||||||
|
expect(hasCommand(registry, 'add')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unregister a command', () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
const runner: CommandRunner<TestContext> = {
|
||||||
|
schema: parseCommandSchema('remove'),
|
||||||
|
run: async () => {},
|
||||||
|
};
|
||||||
|
registerCommand(registry, runner);
|
||||||
|
expect(hasCommand(registry, 'remove')).toBe(true);
|
||||||
|
unregisterCommand(registry, 'remove');
|
||||||
|
expect(hasCommand(registry, 'remove')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get a command runner', () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
const runner: CommandRunner<TestContext> = {
|
||||||
|
schema: parseCommandSchema('get'),
|
||||||
|
run: async () => {},
|
||||||
|
};
|
||||||
|
registerCommand(registry, runner);
|
||||||
|
const retrieved = getCommand(registry, 'get');
|
||||||
|
expect(retrieved).toBe(runner);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for unknown command', () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
const retrieved = getCommand(registry, 'unknown');
|
||||||
|
expect(retrieved).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runCommand', () => {
|
||||||
|
it('should run a command successfully', async () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
const runner: CommandRunner<TestContext, number> = {
|
||||||
|
schema: parseCommandSchema('add <a> <b>'),
|
||||||
|
run: async function (cmd) {
|
||||||
|
return Number(cmd.params[0]) + Number(cmd.params[1]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
registerCommand(registry, runner);
|
||||||
|
|
||||||
|
const result = await runCommand(registry, { counter: 0, log: [] }, 'add 1 2');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.result).toBe(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for unknown command', async () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
const result = await runCommand(registry, { counter: 0, log: [] }, 'unknown');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error).toContain('Unknown command');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for invalid command params', async () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
const runner: CommandRunner<TestContext> = {
|
||||||
|
schema: parseCommandSchema('add <a> <b>'),
|
||||||
|
run: async () => {},
|
||||||
|
};
|
||||||
|
registerCommand(registry, runner);
|
||||||
|
|
||||||
|
const result = await runCommand(registry, { counter: 0, log: [] }, 'add 1');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error).toContain('参数不足');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should access context via this.context', async () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
const runner: CommandRunner<TestContext, number> = {
|
||||||
|
schema: parseCommandSchema('increment'),
|
||||||
|
run: async function () {
|
||||||
|
this.context.counter++;
|
||||||
|
return this.context.counter;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
registerCommand(registry, runner);
|
||||||
|
|
||||||
|
const ctx = { counter: 0, log: [] };
|
||||||
|
await runCommand(registry, ctx, 'increment');
|
||||||
|
expect(ctx.counter).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle async errors', async () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
const runner: CommandRunner<TestContext> = {
|
||||||
|
schema: parseCommandSchema('fail'),
|
||||||
|
run: async () => {
|
||||||
|
throw new Error('Something went wrong');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
registerCommand(registry, runner);
|
||||||
|
|
||||||
|
const result = await runCommand(registry, { counter: 0, log: [] }, 'fail');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error).toBe('Something went wrong');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CommandRunnerContext', () => {
|
||||||
|
it('should create a runner context', () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
const ctx = { counter: 0, log: [] };
|
||||||
|
const runnerCtx = createCommandRunnerContext(registry, ctx);
|
||||||
|
expect(runnerCtx.registry).toBe(registry);
|
||||||
|
expect(runnerCtx.context).toBe(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should run commands via runner context', async () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
const runner: CommandRunner<TestContext, string> = {
|
||||||
|
schema: parseCommandSchema('greet <name>'),
|
||||||
|
run: async function (cmd) {
|
||||||
|
this.context.log.push(`Hello, ${cmd.params[0]}!`);
|
||||||
|
return `Hello, ${cmd.params[0]}!`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
registerCommand(registry, runner);
|
||||||
|
|
||||||
|
const ctx = { counter: 0, log: [] };
|
||||||
|
const runnerCtx = createCommandRunnerContext(registry, ctx);
|
||||||
|
const result = await runnerCtx.run('greet World');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.result).toBe('Hello, World!');
|
||||||
|
}
|
||||||
|
expect(ctx.log).toEqual(['Hello, World!']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow commands to call other commands via this.run', async () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
|
||||||
|
const addRunner: CommandRunner<TestContext, number> = {
|
||||||
|
schema: parseCommandSchema('add <a> <b>'),
|
||||||
|
run: async function (cmd) {
|
||||||
|
return Number(cmd.params[0]) + Number(cmd.params[1]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registerCommand(registry, addRunner);
|
||||||
|
|
||||||
|
const multiplyRunner: CommandRunner<TestContext, number> = {
|
||||||
|
schema: parseCommandSchema('multiply <a> <b>'),
|
||||||
|
run: async function (cmd) {
|
||||||
|
const a = Number(cmd.params[0]);
|
||||||
|
const b = Number(cmd.params[1]);
|
||||||
|
const addResult = await this.run(`add ${a} ${a}`);
|
||||||
|
if (!addResult.success) throw new Error('add failed');
|
||||||
|
return (addResult.result as number) * b;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registerCommand(registry, multiplyRunner);
|
||||||
|
|
||||||
|
const ctx = { counter: 0, log: [] };
|
||||||
|
const result = await runCommand(registry, ctx, 'multiply 3 4');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.result).toBe(24);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow commands to call other commands via this.runParsed', async () => {
|
||||||
|
const registry = createCommandRegistry<TestContext>();
|
||||||
|
|
||||||
|
const doubleRunner: CommandRunner<TestContext, number> = {
|
||||||
|
schema: parseCommandSchema('double <n>'),
|
||||||
|
run: async function (cmd) {
|
||||||
|
return Number(cmd.params[0]) * 2;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registerCommand(registry, doubleRunner);
|
||||||
|
|
||||||
|
const quadrupleRunner: CommandRunner<TestContext, number> = {
|
||||||
|
schema: parseCommandSchema('quadruple <n>'),
|
||||||
|
run: async function (cmd) {
|
||||||
|
const n = Number(cmd.params[0]);
|
||||||
|
const doubleResult = await this.runParsed({ name: 'double', params: [String(n)], options: {}, flags: {} });
|
||||||
|
if (!doubleResult.success) throw new Error('double failed');
|
||||||
|
return (doubleResult.result as number) * 2;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registerCommand(registry, quadrupleRunner);
|
||||||
|
|
||||||
|
const ctx = { counter: 0, log: [] };
|
||||||
|
const result = await runCommand(registry, ctx, 'quadruple 5');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.result).toBe(20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue