diff --git a/src/index.ts b/src/index.ts index 29ae477..5486f4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/utils/command.ts b/src/utils/command.ts index 66e5d1a..05942e7 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -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...] 可选可变参数 + * - --flag 长格式标志 + * - -f 短格式标志 + * - --option 长格式选项 + * - -o 短格式选项 + * + * @example + * parseCommandSchema('move [to...] [--force] [-f] [--speed ]') + */ +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 ) + 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 是 格式,则是选项 + 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 是 格式,则是选项 + 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]) + */ +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 中的 ) + // 这种情况应该由选项处理逻辑处理,不作为独立参数 + 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 }; +} diff --git a/tests/utils/command-schema.test.ts b/tests/utils/command-schema.test.ts new file mode 100644 index 0000000..6592cc9 --- /dev/null +++ b/tests/utils/command-schema.test.ts @@ -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 '); + 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 [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 [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 '); + 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 [--y value]'); + expect(schema.options).toEqual([ + { name: 'x', required: true }, + { name: 'y', required: false }, + ]); + }); + + it('should parse short options', () => { + const schema = parseCommandSchema('move -x [-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 [--force] [-f] [--speed ] [-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 [x...] [--rotate ] [--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 '); + 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 '); + 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 '); + 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 [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 '); + 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 [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 --speed '); + 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 --speed '); + 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 [--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 [--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 -s '); + 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 --rotate '); + 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 [--x ] [--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); + }); +});