From 033fb6c89427338b5583e04bce5c22f804615802 Mon Sep 17 00:00:00 2001 From: hypercross Date: Wed, 1 Apr 2026 21:18:58 +0800 Subject: [PATCH] refactor: command syntax improvement --- src/utils/command.ts | 277 +++++++++++----------- tests/utils/command-inline-schema.test.ts | 47 ++-- tests/utils/command-schema.test.ts | 88 +++++-- 3 files changed, 231 insertions(+), 181 deletions(-) diff --git a/src/utils/command.ts b/src/utils/command.ts index 947cbe3..4789cfa 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -210,11 +210,13 @@ function tokenize(input: string): string[] { * - [param...] 可选可变参数 * - 带类型定义的必需参数 * - [param: type] 带类型定义的可选参数 - * - --flag 长格式标志 + * - --flag 长格式标志(布尔类型) + * - --flag: boolean 长格式标志(布尔类型,与上面等价) * - -f 短格式标志 - * - --option 长格式选项 * - --option: type 带类型的长格式选项 - * - -o 短格式选项 + * - --option: type = default 带默认值的选项 + * - --option: type -o 带短别名的选项 + * - --option: type -o = default 带短别名和默认值的选项 * - -o: type 带类型的短格式选项 * * 类型语法使用 inline-schema 格式(使用 ; 而非 ,): @@ -224,8 +226,9 @@ function tokenize(input: string): string[] { * - [string; number][] 元组数组 * * @example - * parseCommandSchema('move [to...] [--force] [-f] [--speed ]') - * parseCommandSchema('move [--all: boolean]') + * parseCommandSchema('move [to...] [--force] [-f] [--speed: number]') + * parseCommandSchema('move [--all]') + * parseCommandSchema('move [--speed: number = 10 -s]') */ export function parseCommandSchema(schemaStr: string): CommandSchema { const schema: CommandSchema = { @@ -240,7 +243,6 @@ export function parseCommandSchema(schemaStr: string): CommandSchema { return schema; } - // 第一个 token 是命令名称 schema.name = tokens[0]; let i = 1; @@ -248,77 +250,39 @@ export function parseCommandSchema(schemaStr: string): CommandSchema { 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); - - if (name.endsWith(':')) { - const optName = name.slice(0, -1).trim(); - const typeStr = parts[1] || ''; - const parsedSchema = defineSchema(typeStr); - schema.options.push({ - name: optName, - required: false, - schema: parsedSchema, - }); - } else if (name.includes(':')) { - const [optName, typeStr] = name.split(':').map(s => s.trim()); - const parsedSchema = defineSchema(typeStr); - schema.options.push({ - name: optName, - required: false, - schema: parsedSchema, - }); - } else if (parts.length > 1) { - schema.options.push({ - name, - required: false, - }); + const result = parseOptionToken(inner.slice(2), false); + if (result.isFlag) { + schema.flags.push({ name: result.name, short: result.short }); } else { - schema.flags.push({ name }); + schema.options.push({ + name: result.name, + short: result.short, + required: false, + defaultValue: result.defaultValue, + schema: result.schema, + }); } - } else if (inner.startsWith('-') && inner.length > 1) { - const parts = inner.split(/\s+/); - const short = parts[0].slice(1); - - if (short.endsWith(':')) { - const optName = short.slice(0, -1).trim(); - const typeStr = parts[1] || ''; - const parsedSchema = defineSchema(typeStr); - schema.options.push({ - name: optName, - short: optName, - required: false, - schema: parsedSchema, - }); - } else if (short.includes(':')) { - const [optName, typeStr] = short.split(':').map(s => s.trim()); - const parsedSchema = defineSchema(typeStr); - schema.options.push({ - name: optName, - short: optName, - required: false, - schema: parsedSchema, - }); - } else if (parts.length > 1) { - schema.options.push({ - name: short, - short, - required: false, - }); + } else if (inner.startsWith('-') && inner.length > 1 && !inner.includes('--')) { + const result = parseOptionToken(inner.slice(1), false); + if (result.isFlag) { + schema.flags.push({ name: result.name, short: result.short || result.name }); } else { - schema.flags.push({ name: short, short }); + schema.options.push({ + name: result.name, + short: result.short || result.name, + required: false, + defaultValue: result.defaultValue, + schema: result.schema, + }); } } else { - // 可选参数 const isVariadic = inner.endsWith('...'); let paramContent = isVariadic ? inner.slice(0, -3) : inner; let parsedSchema: ParsedSchema | undefined; - // 检查是否有类型定义(如 [name: string]) if (paramContent.includes(':')) { const [name, typeStr] = paramContent.split(':').map(s => s.trim()); try { @@ -338,85 +302,38 @@ export function parseCommandSchema(schemaStr: string): CommandSchema { } i++; } else if (token.startsWith('--')) { - // 长格式标志或选项(必需的,因为不在方括号内) - const name = token.slice(2); - const nextToken = tokens[i + 1]; - - if (name.endsWith(':')) { - const optName = name.slice(0, -1).trim(); - const nextPart = tokens[i + 1]; - const typeStr = nextPart || ''; - const parsedSchema = defineSchema(typeStr); - schema.options.push({ - name: optName, - required: true, - schema: parsedSchema, - }); - i += 2; - } else if (name.includes(':')) { - const [optName, typeStr] = name.split(':').map(s => s.trim()); - const parsedSchema = defineSchema(typeStr); - schema.options.push({ - name: optName, - required: true, - schema: parsedSchema, - }); - i++; - } else if (nextToken && nextToken.startsWith('<') && nextToken.endsWith('>')) { - schema.options.push({ - name, - required: true, - }); - i += 2; + const result = parseOptionToken(token.slice(2), true); + if (result.isFlag) { + schema.flags.push({ name: result.name, short: result.short }); } else { - schema.flags.push({ name }); - i++; + schema.options.push({ + name: result.name, + short: result.short, + required: true, + defaultValue: result.defaultValue, + schema: result.schema, + }); } + i++; } else if (token.startsWith('-') && token.length > 1 && !/^-?\d+$/.test(token)) { - // 短格式标志或选项(必需的,因为不在方括号内) - const short = token.slice(1); - const nextToken = tokens[i + 1]; - - if (short.endsWith(':')) { - const optName = short.slice(0, -1).trim(); - const nextPart = tokens[i + 1]; - const typeStr = nextPart || ''; - const parsedSchema = defineSchema(typeStr); - schema.options.push({ - name: optName, - short: optName, - required: true, - schema: parsedSchema, - }); - i += 2; - } else if (short.includes(':')) { - const [optName, typeStr] = short.split(':').map(s => s.trim()); - const parsedSchema = defineSchema(typeStr); - schema.options.push({ - name: optName, - short: optName, - required: true, - schema: parsedSchema, - }); - i++; - } else if (nextToken && nextToken.startsWith('<') && nextToken.endsWith('>')) { - schema.options.push({ - name: short, - short, - required: true, - }); - i += 2; + const result = parseOptionToken(token.slice(1), true); + if (result.isFlag) { + schema.flags.push({ name: result.name, short: result.short || result.name }); } else { - schema.flags.push({ name: short, short }); - i++; + schema.options.push({ + name: result.name, + short: result.short || result.name, + required: true, + defaultValue: result.defaultValue, + schema: result.schema, + }); } + i++; } else if (token.startsWith('<') && token.endsWith('>')) { - // 必需参数 const isVariadic = token.endsWith('...>'); let paramContent = token.replace(/^[<]+|[>.>]+$/g, ''); let parsedSchema: ParsedSchema | undefined; - // 检查是否有类型定义(如 ) if (paramContent.includes(':')) { const colonIndex = paramContent.indexOf(':'); const name = paramContent.slice(0, colonIndex).trim(); @@ -437,7 +354,6 @@ export function parseCommandSchema(schemaStr: string): CommandSchema { }); i++; } else { - // 跳过无法识别的 token i++; } } @@ -445,6 +361,92 @@ export function parseCommandSchema(schemaStr: string): CommandSchema { return schema; } +/** + * 解析选项/标志 token 的结果 + */ +interface ParsedOptionResult { + name: string; + short?: string; + isFlag: boolean; + schema?: ParsedSchema; + defaultValue?: unknown; +} + +/** + * 解析单个选项/标志 token + * 支持格式: + * - flag → 标志 + * - flag: boolean → 标志(统一处理) + * - option: type → 选项 + * - option: type -s → 选项带短别名 + * - option: type = default → 选项带默认值 + * - option: type -s = default → 选项带短别名和默认值 + */ +function parseOptionToken(token: string, required: boolean): ParsedOptionResult { + const parts = token.split(/\s+/); + const mainPart = parts[0]; + + let name: string; + let typeStr: string | undefined; + let isFlag = false; + + if (mainPart.endsWith(':')) { + name = mainPart.slice(0, -1).trim(); + typeStr = parts[1] || 'string'; + } else if (mainPart.includes(':')) { + const [optName, optType] = mainPart.split(':').map(s => s.trim()); + name = optName; + typeStr = optType; + } else { + name = mainPart; + isFlag = true; + } + + if (typeStr === 'boolean') { + isFlag = true; + typeStr = undefined; + } + + let short: string | undefined; + let defaultValue: unknown; + let schema: ParsedSchema | undefined; + + for (let i = 1; i < parts.length; i++) { + const part = parts[i]; + + if (part.startsWith('-') && part.length === 2) { + short = part.slice(1); + } else if (part === '=') { + const valuePart = parts[i + 1]; + if (valuePart) { + try { + defaultValue = JSON.parse(valuePart); + } catch { + defaultValue = valuePart; + } + i++; + } + } else if (part.startsWith('=')) { + const valuePart = part.slice(1); + try { + defaultValue = JSON.parse(valuePart); + } catch { + defaultValue = valuePart; + } + } + } + + if (typeStr && !isFlag) { + try { + schema = defineSchema(typeStr); + } catch { + // 不是有效的 schema + } + } + + return { name, short, isFlag, schema, defaultValue }; +} + /** * 检查 token 是否是值占位符(如 或 [value]) */ @@ -469,6 +471,7 @@ function isParamPlaceholder(token: string): boolean { /** * 将 schema 字符串分解为 tokens * 支持方括号分组:[...args] [--flag] 等 + * 支持尖括号分组: 等 */ function tokenizeSchema(input: string): string[] { const tokens: string[] = []; @@ -482,13 +485,11 @@ function tokenizeSchema(input: string): string[] { if (inBracket) { if (char === ']') { - // 结束括号,将内容加上括号作为一个 token tokens.push(`[${bracketContent}]`); inBracket = false; bracketContent = ''; current = ''; } else if (char === '[') { - // 嵌套括号(不支持) bracketContent += char; } else { bracketContent += char; @@ -506,7 +507,6 @@ function tokenizeSchema(input: string): string[] { inBracket = true; bracketContent = ''; } else if (char === '<') { - // 尖括号内容作为一个整体 let angleContent = '<'; i++; while (i < input.length && input[i] !== '>') { @@ -526,7 +526,6 @@ function tokenizeSchema(input: string): string[] { tokens.push(current); } - // 处理未闭合的括号 if (bracketContent.length > 0) { tokens.push(`[${bracketContent}`); } diff --git a/tests/utils/command-inline-schema.test.ts b/tests/utils/command-inline-schema.test.ts index c62e3c7..57e5c65 100644 --- a/tests/utils/command-inline-schema.test.ts +++ b/tests/utils/command-inline-schema.test.ts @@ -21,11 +21,11 @@ describe('parseCommandSchema with inline-schema', () => { it('should parse schema with typed options', () => { const schema = parseCommandSchema('move [--all: boolean] [--count: number]'); expect(schema.name).toBe('move'); - expect(schema.options).toHaveLength(2); - expect(schema.options[0].name).toBe('all'); + expect(schema.flags).toHaveLength(1); + expect(schema.options).toHaveLength(1); + expect(schema.flags[0].name).toBe('all'); + expect(schema.options[0].name).toBe('count'); expect(schema.options[0].schema).toBeDefined(); - expect(schema.options[1].name).toBe('count'); - expect(schema.options[1].schema).toBeDefined(); }); it('should parse schema with tuple type', () => { @@ -54,11 +54,11 @@ describe('parseCommandSchema with inline-schema', () => { it('should parse schema with mixed types', () => { const schema = parseCommandSchema( - 'move [--all: boolean] [--count: number]' + 'move [--count: number]' ); expect(schema.name).toBe('move'); expect(schema.params).toHaveLength(2); - expect(schema.options).toHaveLength(2); + expect(schema.options).toHaveLength(1); }); it('should parse schema with optional typed param', () => { @@ -94,17 +94,6 @@ describe('parseCommandWithSchema', () => { }); it('should parse and validate command with boolean option', () => { - const result = parseCommandWithSchema( - 'move meeple1 region1 --all true', - 'move [--all: boolean]' - ); - expect(result.valid).toBe(true); - if (result.valid) { - expect(result.command.options.all).toBe(true); - } - }); - - it('should parse and validate command with number option', () => { const result = parseCommandWithSchema( 'move meeple1 region1 --count 5', 'move [--count: number]' @@ -115,6 +104,17 @@ describe('parseCommandWithSchema', () => { } }); + it('should parse and validate command with number option', () => { + const result = parseCommandWithSchema( + 'move meeple1 region1 --speed 100', + 'move [--speed: number]' + ); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.command.options.speed).toBe(100); + } + }); + it('should fail validation with wrong command name', () => { const result = parseCommandWithSchema( 'jump meeple1 region1', @@ -144,22 +144,21 @@ describe('parseCommandWithSchema', () => { it('should fail validation with missing required option', () => { const result = parseCommandWithSchema( 'move meeple1 region1', - 'move [--force: boolean]' + 'move [--force]' ); - // 可选选项,应该通过验证 + // 可选标志,应该通过验证 expect(result.valid).toBe(true); }); it('should parse complex command with typed params and options', () => { const result = parseCommandWithSchema( - 'move [1; 2] region1 --all true --count 3', - 'move [--all: boolean] [--count: number]' + 'move [1; 2] region1 --count 3', + 'move [--count: number]' ); expect(result.valid).toBe(true); if (result.valid) { expect(result.command.params[0]).toEqual(['1', '2']); expect(result.command.params[1]).toBe('region1'); - expect(result.command.options.all).toBe(true); expect(result.command.options.count).toBe(3); } }); @@ -207,8 +206,8 @@ describe('validateCommand with schema types', () => { }); it('should validate command with typed options', () => { - const schema = parseCommandSchema('move [--all: boolean]'); - const command = parseCommand('move meeple1 region1 --all true'); + const schema = parseCommandSchema('move [--count: number]'); + const command = parseCommand('move meeple1 region1 --count 5'); const result = validateCommand(command, schema); expect(result.valid).toBe(true); }); diff --git a/tests/utils/command-schema.test.ts b/tests/utils/command-schema.test.ts index 6592cc9..8047e48 100644 --- a/tests/utils/command-schema.test.ts +++ b/tests/utils/command-schema.test.ts @@ -70,42 +70,41 @@ describe('parseCommandSchema', () => { }); it('should parse long options', () => { - const schema = parseCommandSchema('move --x [--y value]'); + const schema = parseCommandSchema('move --x: string [--y: string]'); expect(schema.options).toEqual([ - { name: 'x', required: true }, - { name: 'y', required: false }, + { name: 'x', required: true, schema: expect.any(Object) }, + { name: 'y', required: false, schema: expect.any(Object) }, ]); }); it('should parse short options', () => { - const schema = parseCommandSchema('move -x [-y value]'); + const schema = parseCommandSchema('move -x: string [-y: string]'); expect(schema.options).toEqual([ - { name: 'x', short: 'x', required: true }, - { name: 'y', short: 'y', required: false }, + { name: 'x', short: 'x', required: true, schema: expect.any(Object) }, + { name: 'y', short: 'y', required: false, schema: expect.any(Object) }, ]); }); it('should parse mixed schema', () => { - const schema = parseCommandSchema('move [--force] [-f] [--speed ] [-s val]'); + const schema = parseCommandSchema('move [--force] [-f] [--speed: string -s]'); expect(schema).toEqual({ name: 'move', params: [ - { name: 'from', required: true, variadic: false }, - { name: 'to', required: true, variadic: false }, + { name: 'from', required: true, variadic: false, schema: undefined }, + { name: 'to', required: true, variadic: false, schema: undefined }, ], flags: [ { name: 'force' }, { name: 'f', short: 'f' }, ], options: [ - { name: 'speed', required: false }, - { name: 's', short: 's', required: false }, + { name: 'speed', short: 's', required: false, schema: expect.any(Object), defaultValue: undefined }, ], }); }); it('should handle complex schema', () => { - const schema = parseCommandSchema('place [x...] [--rotate ] [--force] [-f]'); + const schema = parseCommandSchema('place [x...] [--rotate: number] [--force] [-f]'); expect(schema.name).toBe('place'); expect(schema.params).toHaveLength(3); expect(schema.flags).toHaveLength(2); @@ -172,7 +171,7 @@ describe('validateCommand', () => { }); it('should reject missing required option', () => { - const schema = parseCommandSchema('move --speed '); + const schema = parseCommandSchema('move --speed: string'); const command = parseCommand('move meeple1'); const result = validateCommand(command, schema); expect(result).toEqual({ @@ -184,14 +183,14 @@ describe('validateCommand', () => { }); it('should accept present required option', () => { - const schema = parseCommandSchema('move --speed '); + const schema = parseCommandSchema('move --speed: string'); 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 schema = parseCommandSchema('move [--speed: string]'); const command = parseCommand('move meeple1'); const result = validateCommand(command, schema); expect(result).toEqual({ valid: true }); @@ -206,14 +205,14 @@ describe('validateCommand', () => { }); it('should validate short form option', () => { - const schema = parseCommandSchema('move -s '); + const schema = parseCommandSchema('move -s: string'); 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 schema = parseCommandSchema('place --rotate: string'); const command = parseCommand('place meeple1'); const result = validateCommand(command, schema); expect(result.valid).toBe(false); @@ -225,7 +224,7 @@ describe('validateCommand', () => { describe('integration', () => { it('should work together parse and validate', () => { - const schemaStr = 'place [--x ] [--y [val]] [--force] [-f]'; + const schemaStr = 'place [--x: string] [--y: string] [--force] [-f]'; const schema = parseCommandSchema(schemaStr); const validCmd = parseCommand('place meeple1 board --x 5 --force'); @@ -235,4 +234,57 @@ describe('integration', () => { const result = validateCommand(invalidCmd, schema); expect(result.valid).toBe(false); }); + + it('should parse short alias syntax', () => { + const schema = parseCommandSchema('move [--verbose: boolean -v]'); + expect(schema.flags).toHaveLength(1); + expect(schema.flags[0]).toEqual({ name: 'verbose', short: 'v' }); + }); + + it('should parse short alias for options', () => { + const schema = parseCommandSchema('move [--speed: number -s]'); + expect(schema.options).toHaveLength(1); + expect(schema.options[0]).toEqual({ + name: 'speed', + short: 's', + required: false, + schema: expect.any(Object), + defaultValue: undefined, + }); + }); + + it('should parse default value syntax', () => { + const schema = parseCommandSchema('move [--speed: number = 10]'); + expect(schema.options).toHaveLength(1); + expect(schema.options[0].defaultValue).toBe(10); + }); + + it('should parse default string value', () => { + const schema = parseCommandSchema('move [--name: string = "default"]'); + expect(schema.options).toHaveLength(1); + expect(schema.options[0].defaultValue).toBe('default'); + }); + + it('should parse short alias with default value', () => { + const schema = parseCommandSchema('move [--speed: number -s = 5]'); + expect(schema.options).toHaveLength(1); + expect(schema.options[0].short).toBe('s'); + expect(schema.options[0].defaultValue).toBe(5); + }); + + it('should parse command with short alias', () => { + const schema = parseCommandSchema('move [--verbose -v]'); + const command = parseCommand('move meeple1 -v'); + const result = validateCommand(command, schema); + expect(result.valid).toBe(true); + expect(command.flags.v).toBe(true); + }); + + it('should parse command with short alias option', () => { + const schema = parseCommandSchema('move [--speed: number -s]'); + const command = parseCommand('move meeple1 -s 100'); + const result = validateCommand(command, schema); + expect(result.valid).toBe(true); + expect(command.options.s).toBe('100'); + }); });