feat: add command schema
This commit is contained in:
parent
00bed92d46
commit
95015b090c
|
|
@ -17,8 +17,8 @@ export type { RuleContext } from './core/rule';
|
|||
export { invokeRuleContext, createRule } from './core/rule';
|
||||
|
||||
// Utils
|
||||
export type { Command } from './utils/command';
|
||||
export { parseCommand } from './utils/command';
|
||||
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
||||
export { parseCommand, parseCommandSchema, validateCommand } from './utils/command';
|
||||
|
||||
export type { Entity, EntityAccessor } from './utils/entity';
|
||||
export { createEntityCollection } from './utils/entity';
|
||||
|
|
|
|||
|
|
@ -5,6 +5,56 @@
|
|||
params: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令参数 schema 定义
|
||||
*/
|
||||
export type CommandParamSchema = {
|
||||
/** 参数名称 */
|
||||
name: string;
|
||||
/** 是否必需 */
|
||||
required: boolean;
|
||||
/** 是否可变参数(可以接收多个值) */
|
||||
variadic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令选项 schema 定义
|
||||
*/
|
||||
export type CommandOptionSchema = {
|
||||
/** 选项名称(长格式,不含 --) */
|
||||
name: string;
|
||||
/** 短格式名称(不含 -) */
|
||||
short?: string;
|
||||
/** 是否必需 */
|
||||
required: boolean;
|
||||
/** 默认值 */
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令标志 schema 定义
|
||||
*/
|
||||
export type CommandFlagSchema = {
|
||||
/** 标志名称(长格式,不含 --) */
|
||||
name: string;
|
||||
/** 短格式名称(不含 -) */
|
||||
short?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令完整 schema 定义
|
||||
*/
|
||||
export type CommandSchema = {
|
||||
/** 命令名称 */
|
||||
name: string;
|
||||
/** 参数定义列表 */
|
||||
params: CommandParamSchema[];
|
||||
/** 选项定义列表 */
|
||||
options: CommandOptionSchema[];
|
||||
/** 标志定义列表 */
|
||||
flags: CommandFlagSchema[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析命令行输入字符串为 Command 对象
|
||||
* 支持格式:commandName [params...] [--flags...] [-o value...]
|
||||
|
|
@ -123,3 +173,280 @@ function tokenize(input: string): string[] {
|
|||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析命令 schema 字符串为 CommandSchema 对象
|
||||
* 支持语法:
|
||||
* - <param> 必需参数
|
||||
* - [param] 可选参数
|
||||
* - <param...> 必需可变参数
|
||||
* - [param...] 可选可变参数
|
||||
* - --flag 长格式标志
|
||||
* - -f 短格式标志
|
||||
* - --option <value> 长格式选项
|
||||
* - -o <value> 短格式选项
|
||||
*
|
||||
* @example
|
||||
* parseCommandSchema('move <from> [to...] [--force] [-f] [--speed <val>]')
|
||||
*/
|
||||
export function parseCommandSchema(schemaStr: string): CommandSchema {
|
||||
const schema: CommandSchema = {
|
||||
name: '',
|
||||
params: [],
|
||||
options: [],
|
||||
flags: [],
|
||||
};
|
||||
|
||||
const tokens = tokenizeSchema(schemaStr);
|
||||
if (tokens.length === 0) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
// 第一个 token 是命令名称
|
||||
schema.name = tokens[0];
|
||||
|
||||
let i = 1;
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
|
||||
if (token.startsWith('[') && token.endsWith(']')) {
|
||||
// 可选参数/标志/选项(方括号内的内容)
|
||||
const inner = token.slice(1, -1).trim();
|
||||
|
||||
if (inner.startsWith('--')) {
|
||||
// 可选长格式标志或选项
|
||||
const parts = inner.split(/\s+/);
|
||||
const name = parts[0].slice(2);
|
||||
|
||||
// 如果有额外的部分,则是选项(如 --opt value 或 --opt <value>)
|
||||
if (parts.length > 1) {
|
||||
// 可选选项
|
||||
schema.options.push({
|
||||
name,
|
||||
required: false,
|
||||
});
|
||||
} else {
|
||||
// 可选标志
|
||||
schema.flags.push({ name });
|
||||
}
|
||||
} else if (inner.startsWith('-') && inner.length > 1) {
|
||||
// 可选短格式标志或选项
|
||||
const parts = inner.split(/\s+/);
|
||||
const short = parts[0].slice(1);
|
||||
|
||||
// 如果有额外的部分,则是选项
|
||||
if (parts.length > 1) {
|
||||
// 可选选项
|
||||
schema.options.push({
|
||||
name: short,
|
||||
short,
|
||||
required: false,
|
||||
});
|
||||
} else {
|
||||
// 可选标志
|
||||
schema.flags.push({ name: short, short });
|
||||
}
|
||||
} else {
|
||||
// 可选参数
|
||||
const isVariadic = inner.endsWith('...');
|
||||
const name = isVariadic ? inner.slice(0, -3) : inner;
|
||||
|
||||
schema.params.push({
|
||||
name,
|
||||
required: false,
|
||||
variadic: isVariadic,
|
||||
});
|
||||
}
|
||||
i++;
|
||||
} else if (token.startsWith('--')) {
|
||||
// 长格式标志或选项(必需的,因为不在方括号内)
|
||||
const name = token.slice(2);
|
||||
const nextToken = tokens[i + 1];
|
||||
|
||||
// 如果下一个 token 是 <value> 格式,则是选项
|
||||
if (nextToken && nextToken.startsWith('<') && nextToken.endsWith('>')) {
|
||||
schema.options.push({
|
||||
name,
|
||||
required: true,
|
||||
});
|
||||
i += 2;
|
||||
} else {
|
||||
// 否则是标志
|
||||
schema.flags.push({ name });
|
||||
i++;
|
||||
}
|
||||
} else if (token.startsWith('-') && token.length > 1 && !/^-?\d+$/.test(token)) {
|
||||
// 短格式标志或选项(必需的,因为不在方括号内)
|
||||
const short = token.slice(1);
|
||||
const nextToken = tokens[i + 1];
|
||||
|
||||
// 如果下一个 token 是 <value> 格式,则是选项
|
||||
if (nextToken && nextToken.startsWith('<') && nextToken.endsWith('>')) {
|
||||
schema.options.push({
|
||||
name: short,
|
||||
short,
|
||||
required: true,
|
||||
});
|
||||
i += 2;
|
||||
} else {
|
||||
// 否则是标志
|
||||
schema.flags.push({ name: short, short });
|
||||
i++;
|
||||
}
|
||||
} else if (token.startsWith('<') && token.endsWith('>')) {
|
||||
// 必需参数
|
||||
const isVariadic = token.endsWith('...>');
|
||||
const name = token.replace(/^[<]+|[>.>]+$/g, '');
|
||||
|
||||
schema.params.push({
|
||||
name,
|
||||
required: true,
|
||||
variadic: isVariadic,
|
||||
});
|
||||
i++;
|
||||
} else {
|
||||
// 跳过无法识别的 token
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 token 是否是值占位符(如 <value> 或 [value])
|
||||
*/
|
||||
function isValuePlaceholder(token: string): boolean {
|
||||
return (token.startsWith('<') && token.endsWith('>')) ||
|
||||
(token.startsWith('[') && token.endsWith(']'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 token 是否是参数占位符
|
||||
*/
|
||||
function isParamPlaceholder(token: string): boolean {
|
||||
// 参数占位符必须以 < 或 [ 开头
|
||||
if (!token.startsWith('<') && !token.startsWith('[')) {
|
||||
return false;
|
||||
}
|
||||
// 检查是否是选项的值占位符(如 <--opt <val> 中的 <val>)
|
||||
// 这种情况应该由选项处理逻辑处理,不作为独立参数
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 schema 字符串分解为 tokens
|
||||
* 支持方括号分组:[...args] [--flag] 等
|
||||
*/
|
||||
function tokenizeSchema(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
let inBracket = false;
|
||||
let bracketContent = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < input.length) {
|
||||
const char = input[i];
|
||||
|
||||
if (inBracket) {
|
||||
if (char === ']') {
|
||||
// 结束括号,将内容加上括号作为一个 token
|
||||
tokens.push(`[${bracketContent}]`);
|
||||
inBracket = false;
|
||||
bracketContent = '';
|
||||
current = '';
|
||||
} else if (char === '[') {
|
||||
// 嵌套括号(不支持)
|
||||
bracketContent += char;
|
||||
} else {
|
||||
bracketContent += char;
|
||||
}
|
||||
} else if (/\s/.test(char)) {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
} else if (char === '[') {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
inBracket = true;
|
||||
bracketContent = '';
|
||||
} else if (char === '<') {
|
||||
// 尖括号内容作为一个整体
|
||||
let angleContent = '<';
|
||||
i++;
|
||||
while (i < input.length && input[i] !== '>') {
|
||||
angleContent += input[i];
|
||||
i++;
|
||||
}
|
||||
angleContent += '>';
|
||||
tokens.push(angleContent);
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
// 处理未闭合的括号
|
||||
if (bracketContent.length > 0) {
|
||||
tokens.push(`[${bracketContent}`);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 schema 验证命令
|
||||
* @returns 验证结果,valid 为 true 表示通过,否则包含错误信息
|
||||
*/
|
||||
export function validateCommand(
|
||||
command: Command,
|
||||
schema: CommandSchema
|
||||
): { valid: true } | { valid: false; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 验证命令名称
|
||||
if (command.name !== schema.name) {
|
||||
errors.push(`命令名称不匹配:期望 "${schema.name}",实际 "${command.name}"`);
|
||||
}
|
||||
|
||||
// 验证参数数量
|
||||
const requiredParams = schema.params.filter(p => p.required);
|
||||
const variadicParam = schema.params.find(p => p.variadic);
|
||||
|
||||
if (command.params.length < requiredParams.length) {
|
||||
errors.push(`参数不足:至少需要 ${requiredParams.length} 个参数,实际 ${command.params.length} 个`);
|
||||
}
|
||||
|
||||
// 如果有可变参数,参数数量可以超过必需参数数量
|
||||
// 否则,检查是否有多余参数
|
||||
if (!variadicParam && command.params.length > schema.params.length) {
|
||||
errors.push(`参数过多:最多 ${schema.params.length} 个参数,实际 ${command.params.length} 个`);
|
||||
}
|
||||
|
||||
// 验证必需的选项
|
||||
const requiredOptions = schema.options.filter(o => o.required);
|
||||
for (const opt of requiredOptions) {
|
||||
// 检查长格式或短格式
|
||||
const hasOption = opt.name in command.options || (opt.short && opt.short in command.options);
|
||||
if (!hasOption) {
|
||||
errors.push(`缺少必需选项:--${opt.name}${opt.short ? ` 或 -${opt.short}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证标志(标志都是可选的,除非未来扩展支持必需标志)
|
||||
// 目前只检查是否有未定义的标志(可选的严格模式)
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCommand, parseCommandSchema, validateCommand } from '../../src/utils/command';
|
||||
|
||||
describe('parseCommandSchema', () => {
|
||||
it('should parse empty schema', () => {
|
||||
const schema = parseCommandSchema('');
|
||||
expect(schema).toEqual({
|
||||
name: '',
|
||||
params: [],
|
||||
options: [],
|
||||
flags: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse command name only', () => {
|
||||
const schema = parseCommandSchema('move');
|
||||
expect(schema).toEqual({
|
||||
name: 'move',
|
||||
params: [],
|
||||
options: [],
|
||||
flags: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse required params', () => {
|
||||
const schema = parseCommandSchema('move <from> <to>');
|
||||
expect(schema.params).toEqual([
|
||||
{ name: 'from', required: true, variadic: false },
|
||||
{ name: 'to', required: true, variadic: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse optional params', () => {
|
||||
const schema = parseCommandSchema('move <from> [to]');
|
||||
expect(schema.params).toEqual([
|
||||
{ name: 'from', required: true, variadic: false },
|
||||
{ name: 'to', required: false, variadic: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse variadic params', () => {
|
||||
const schema = parseCommandSchema('move <from> [targets...]');
|
||||
expect(schema.params).toEqual([
|
||||
{ name: 'from', required: true, variadic: false },
|
||||
{ name: 'targets', required: false, variadic: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse required variadic params', () => {
|
||||
const schema = parseCommandSchema('move <targets...>');
|
||||
expect(schema.params).toEqual([
|
||||
{ name: 'targets', required: true, variadic: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse long flags', () => {
|
||||
const schema = parseCommandSchema('move [--force] [--quiet]');
|
||||
expect(schema.flags).toEqual([
|
||||
{ name: 'force' },
|
||||
{ name: 'quiet' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse short flags', () => {
|
||||
const schema = parseCommandSchema('move [-f] [-q]');
|
||||
expect(schema.flags).toEqual([
|
||||
{ name: 'f', short: 'f' },
|
||||
{ name: 'q', short: 'q' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse long options', () => {
|
||||
const schema = parseCommandSchema('move --x <value> [--y value]');
|
||||
expect(schema.options).toEqual([
|
||||
{ name: 'x', required: true },
|
||||
{ name: 'y', required: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse short options', () => {
|
||||
const schema = parseCommandSchema('move -x <value> [-y value]');
|
||||
expect(schema.options).toEqual([
|
||||
{ name: 'x', short: 'x', required: true },
|
||||
{ name: 'y', short: 'y', required: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse mixed schema', () => {
|
||||
const schema = parseCommandSchema('move <from> <to> [--force] [-f] [--speed <val>] [-s val]');
|
||||
expect(schema).toEqual({
|
||||
name: 'move',
|
||||
params: [
|
||||
{ name: 'from', required: true, variadic: false },
|
||||
{ name: 'to', required: true, variadic: false },
|
||||
],
|
||||
flags: [
|
||||
{ name: 'force' },
|
||||
{ name: 'f', short: 'f' },
|
||||
],
|
||||
options: [
|
||||
{ name: 'speed', required: false },
|
||||
{ name: 's', short: 's', required: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex schema', () => {
|
||||
const schema = parseCommandSchema('place <piece> <region> [x...] [--rotate <angle>] [--force] [-f]');
|
||||
expect(schema.name).toBe('place');
|
||||
expect(schema.params).toHaveLength(3);
|
||||
expect(schema.flags).toHaveLength(2);
|
||||
expect(schema.options).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCommand', () => {
|
||||
it('should validate correct command', () => {
|
||||
const schema = parseCommandSchema('move <from> <to>');
|
||||
const command = parseCommand('move meeple1 region1');
|
||||
const result = validateCommand(command, schema);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should reject wrong command name', () => {
|
||||
const schema = parseCommandSchema('move <from>');
|
||||
const command = parseCommand('place meeple1');
|
||||
const result = validateCommand(command, schema);
|
||||
expect(result).toEqual({
|
||||
valid: false,
|
||||
errors: expect.arrayContaining([
|
||||
expect.stringContaining('命令名称不匹配'),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject missing required params', () => {
|
||||
const schema = parseCommandSchema('move <from> <to>');
|
||||
const command = parseCommand('move meeple1');
|
||||
const result = validateCommand(command, schema);
|
||||
expect(result).toEqual({
|
||||
valid: false,
|
||||
errors: expect.arrayContaining([
|
||||
expect.stringContaining('参数不足'),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept optional params missing', () => {
|
||||
const schema = parseCommandSchema('move <from> [to]');
|
||||
const command = parseCommand('move meeple1');
|
||||
const result = validateCommand(command, schema);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should reject extra params without variadic', () => {
|
||||
const schema = parseCommandSchema('move <from> <to>');
|
||||
const command = parseCommand('move meeple1 region1 extra');
|
||||
const result = validateCommand(command, schema);
|
||||
expect(result).toEqual({
|
||||
valid: false,
|
||||
errors: expect.arrayContaining([
|
||||
expect.stringContaining('参数过多'),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept extra params with variadic', () => {
|
||||
const schema = parseCommandSchema('move <from> [targets...]');
|
||||
const command = parseCommand('move meeple1 region1 region2 region3');
|
||||
const result = validateCommand(command, schema);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should reject missing required option', () => {
|
||||
const schema = parseCommandSchema('move <from> --speed <val>');
|
||||
const command = parseCommand('move meeple1');
|
||||
const result = validateCommand(command, schema);
|
||||
expect(result).toEqual({
|
||||
valid: false,
|
||||
errors: expect.arrayContaining([
|
||||
expect.stringContaining('缺少必需选项'),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept present required option', () => {
|
||||
const schema = parseCommandSchema('move <from> --speed <val>');
|
||||
const command = parseCommand('move meeple1 --speed 10');
|
||||
const result = validateCommand(command, schema);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should accept optional option missing', () => {
|
||||
const schema = parseCommandSchema('move <from> [--speed [val]]');
|
||||
const command = parseCommand('move meeple1');
|
||||
const result = validateCommand(command, schema);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should accept flags present or not', () => {
|
||||
const schema = parseCommandSchema('move <from> [--force]');
|
||||
const cmd1 = parseCommand('move meeple1');
|
||||
const cmd2 = parseCommand('move meeple1 --force');
|
||||
expect(validateCommand(cmd1, schema)).toEqual({ valid: true });
|
||||
expect(validateCommand(cmd2, schema)).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should validate short form option', () => {
|
||||
const schema = parseCommandSchema('move <from> -s <val>');
|
||||
const command = parseCommand('move meeple1 -s 10');
|
||||
const result = validateCommand(command, schema);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should provide detailed error messages', () => {
|
||||
const schema = parseCommandSchema('place <piece> <region> --rotate <angle>');
|
||||
const command = parseCommand('place meeple1');
|
||||
const result = validateCommand(command, schema);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration', () => {
|
||||
it('should work together parse and validate', () => {
|
||||
const schemaStr = 'place <piece> <region> [--x <val>] [--y [val]] [--force] [-f]';
|
||||
const schema = parseCommandSchema(schemaStr);
|
||||
|
||||
const validCmd = parseCommand('place meeple1 board --x 5 --force');
|
||||
expect(validateCommand(validCmd, schema)).toEqual({ valid: true });
|
||||
|
||||
const invalidCmd = parseCommand('place meeple1');
|
||||
const result = validateCommand(invalidCmd, schema);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue