Compare commits

..

No commits in common. "033fb6c89427338b5583e04bce5c22f804615802" and "c315e0643b9f7e3ca00ec39cc1e112e886312d4b" have entirely different histories.

3 changed files with 199 additions and 254 deletions

View File

@ -133,9 +133,7 @@ export function parseCommand(input: string): Command {
function tokenize(input: string): string[] {
const tokens: string[] = [];
let current = '';
let inQuote: string | null = null;
let inBracket = false;
let bracketDepth = 0;
let inQuote: string | null = null; // ' 或 " 或 null
let escaped = false;
let i = 0;
@ -143,57 +141,38 @@ function tokenize(input: string): string[] {
const char = input[i];
if (escaped) {
// 转义字符:直接添加到当前 token
current += char;
escaped = false;
} else if (char === '\\') {
// 开始转义
escaped = true;
} else if (inQuote) {
// 在引号内
if (char === inQuote) {
// 结束引号
inQuote = null;
} else {
current += char;
}
} else if (char === '"' || char === "'") {
// 开始引号
inQuote = char;
} else if (char === '[') {
if (inBracket) {
bracketDepth++;
current += char;
} else {
if (current.length > 0) {
tokens.push(current);
current = '';
}
inBracket = true;
bracketDepth = 1;
current = '[';
}
} else if (char === ']') {
if (inBracket) {
bracketDepth--;
current += char;
if (bracketDepth === 0) {
tokens.push(current);
current = '';
inBracket = false;
}
} else {
current += char;
}
} else if (/\s/.test(char)) {
if (inBracket) {
current += char;
} else if (current.length > 0) {
// 空白字符
if (current.length > 0) {
tokens.push(current);
current = '';
}
} else {
// 普通字符
current += char;
}
i++;
}
// 处理未闭合的引号
if (current.length > 0) {
tokens.push(current);
}
@ -210,13 +189,11 @@ function tokenize(input: string): string[] {
* - [param...]
* - <param: type>
* - [param: type]
* - --flag
* - --flag: boolean
* - --flag
* - -f
* - --option <value>
* - --option: type
* - --option: type = default
* - --option: type -o
* - --option: type -o = default
* - -o <value>
* - -o: type
*
* 使 inline-schema 使 ; ,
@ -226,9 +203,8 @@ function tokenize(input: string): string[] {
* - [string; number][]
*
* @example
* parseCommandSchema('move <from> [to...] [--force] [-f] [--speed: number]')
* parseCommandSchema('move <from: [x: string; y: string]> <to: string> [--all]')
* parseCommandSchema('move <from> <to> [--speed: number = 10 -s]')
* parseCommandSchema('move <from> [to...] [--force] [-f] [--speed <val>]')
* parseCommandSchema('move <from: [x: string; y: string]> <to: string> [--all: boolean]')
*/
export function parseCommandSchema(schemaStr: string): CommandSchema {
const schema: CommandSchema = {
@ -243,6 +219,7 @@ export function parseCommandSchema(schemaStr: string): CommandSchema {
return schema;
}
// 第一个 token 是命令名称
schema.name = tokens[0];
let i = 1;
@ -250,39 +227,92 @@ 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 result = parseOptionToken(inner.slice(2), false);
if (result.isFlag) {
schema.flags.push({ name: result.name, short: result.short });
} else {
// 可选长格式标志或选项
const parts = inner.split(/\s+/);
const name = parts[0].slice(2);
// 检查是否有类型定义(如 --flag: boolean 或 --opt: string[]
if (name.includes(':')) {
const [optName, typeStr] = name.split(':').map(s => s.trim());
const parsedSchema = defineSchema(typeStr);
schema.options.push({
name: result.name,
short: result.short,
name: optName,
required: false,
defaultValue: result.defaultValue,
schema: result.schema,
schema: parsedSchema,
});
} else if (parts.length > 1) {
// 可选选项(旧语法:--opt <value>
const valueToken = parts[1];
let typeStr = valueToken;
// 如果是 <value> 格式,提取类型
if (valueToken.startsWith('<') && valueToken.endsWith('>')) {
typeStr = valueToken.slice(1, -1);
}
// 尝试解析为 inline-schema 类型
let parsedSchema: ParsedSchema | undefined;
try {
parsedSchema = defineSchema(typeStr);
} catch {
// 不是有效的 schema使用默认字符串
}
schema.options.push({
name,
required: false,
schema: parsedSchema,
});
} else {
// 可选标志
schema.flags.push({ name });
}
} 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 {
} else if (inner.startsWith('-') && inner.length > 1) {
// 可选短格式标志或选项
const parts = inner.split(/\s+/);
const short = parts[0].slice(1);
// 检查是否有类型定义
if (short.includes(':')) {
const [optName, typeStr] = short.split(':').map(s => s.trim());
const parsedSchema = defineSchema(typeStr);
schema.options.push({
name: result.name,
short: result.short || result.name,
name: optName,
short: optName,
required: false,
defaultValue: result.defaultValue,
schema: result.schema,
schema: parsedSchema,
});
} else if (parts.length > 1) {
// 可选选项(旧语法)
const valueToken = parts[1];
let typeStr = valueToken;
if (valueToken.startsWith('<') && valueToken.endsWith('>')) {
typeStr = valueToken.slice(1, -1);
}
let parsedSchema: ParsedSchema | undefined;
try {
parsedSchema = defineSchema(typeStr);
} catch {
// 不是有效的 schema使用默认字符串
}
schema.options.push({
name: short,
short,
required: false,
schema: parsedSchema,
});
} else {
// 可选标志
schema.flags.push({ name: short, short });
}
} 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 {
@ -302,38 +332,86 @@ export function parseCommandSchema(schemaStr: string): CommandSchema {
}
i++;
} else if (token.startsWith('--')) {
const result = parseOptionToken(token.slice(2), true);
if (result.isFlag) {
schema.flags.push({ name: result.name, short: result.short });
} else {
// 长格式标志或选项(必需的,因为不在方括号内)
const name = token.slice(2);
const nextToken = tokens[i + 1];
// 检查是否有类型定义(如 --flag: boolean
if (name.includes(':')) {
const [optName, typeStr] = name.split(':').map(s => s.trim());
const parsedSchema = defineSchema(typeStr);
schema.options.push({
name: result.name,
short: result.short,
name: optName,
required: true,
defaultValue: result.defaultValue,
schema: result.schema,
schema: parsedSchema,
});
i++;
} else if (nextToken && nextToken.startsWith('<') && nextToken.endsWith('>')) {
// 旧语法:--opt <value>
const valueToken = nextToken;
const typeStr = valueToken.slice(1, -1);
let parsedSchema: ParsedSchema | undefined;
try {
parsedSchema = defineSchema(typeStr);
} catch {
// 不是有效的 schema
}
schema.options.push({
name,
required: true,
schema: parsedSchema,
});
i += 2;
} else {
// 否则是标志
schema.flags.push({ name });
i++;
}
i++;
} else if (token.startsWith('-') && token.length > 1 && !/^-?\d+$/.test(token)) {
const result = parseOptionToken(token.slice(1), true);
if (result.isFlag) {
schema.flags.push({ name: result.name, short: result.short || result.name });
} else {
// 短格式标志或选项(必需的,因为不在方括号内)
const short = token.slice(1);
const nextToken = tokens[i + 1];
// 检查是否有类型定义
if (short.includes(':')) {
const [optName, typeStr] = short.split(':').map(s => s.trim());
const parsedSchema = defineSchema(typeStr);
schema.options.push({
name: result.name,
short: result.short || result.name,
name: optName,
short: optName,
required: true,
defaultValue: result.defaultValue,
schema: result.schema,
schema: parsedSchema,
});
i++;
} else if (nextToken && nextToken.startsWith('<') && nextToken.endsWith('>')) {
// 旧语法
const valueToken = nextToken;
const typeStr = valueToken.slice(1, -1);
let parsedSchema: ParsedSchema | undefined;
try {
parsedSchema = defineSchema(typeStr);
} catch {
// 不是有效的 schema
}
schema.options.push({
name: short,
short,
required: true,
schema: parsedSchema,
});
i += 2;
} else {
// 否则是标志
schema.flags.push({ name: short, short });
i++;
}
i++;
} else if (token.startsWith('<') && token.endsWith('>')) {
// 必需参数
const isVariadic = token.endsWith('...>');
let paramContent = token.replace(/^[<]+|[>.>]+$/g, '');
let parsedSchema: ParsedSchema | undefined;
// 检查是否有类型定义(如 <from: [x: string; y: string]>
if (paramContent.includes(':')) {
const colonIndex = paramContent.indexOf(':');
const name = paramContent.slice(0, colonIndex).trim();
@ -354,6 +432,7 @@ export function parseCommandSchema(schemaStr: string): CommandSchema {
});
i++;
} else {
// 跳过无法识别的 token
i++;
}
}
@ -361,92 +440,6 @@ 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> [value]
*/
@ -471,7 +464,6 @@ function isParamPlaceholder(token: string): boolean {
/**
* schema tokens
* [...args] [--flag]
* <param> <param: type>
*/
function tokenizeSchema(input: string): string[] {
const tokens: string[] = [];
@ -485,11 +477,13 @@ 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;
@ -507,6 +501,7 @@ function tokenizeSchema(input: string): string[] {
inBracket = true;
bracketContent = '';
} else if (char === '<') {
// 尖括号内容作为一个整体
let angleContent = '<';
i++;
while (i < input.length && input[i] !== '>') {
@ -526,6 +521,7 @@ function tokenizeSchema(input: string): string[] {
tokens.push(current);
}
// 处理未闭合的括号
if (bracketContent.length > 0) {
tokens.push(`[${bracketContent}`);
}

View File

@ -21,11 +21,11 @@ describe('parseCommandSchema with inline-schema', () => {
it('should parse schema with typed options', () => {
const schema = parseCommandSchema('move <from> <to> [--all: boolean] [--count: number]');
expect(schema.name).toBe('move');
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).toHaveLength(2);
expect(schema.options[0].name).toBe('all');
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 <from: [x: string; y: string]> <to: string> [--count: number]'
'move <from: [x: string; y: string]> <to: string> [--all: boolean] [--count: number]'
);
expect(schema.name).toBe('move');
expect(schema.params).toHaveLength(2);
expect(schema.options).toHaveLength(1);
expect(schema.options).toHaveLength(2);
});
it('should parse schema with optional typed param', () => {
@ -94,6 +94,17 @@ describe('parseCommandWithSchema', () => {
});
it('should parse and validate command with boolean option', () => {
const result = parseCommandWithSchema(
'move meeple1 region1 --all true',
'move <from> <to> [--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 <from> <to> [--count: number]'
@ -104,17 +115,6 @@ describe('parseCommandWithSchema', () => {
}
});
it('should parse and validate command with number option', () => {
const result = parseCommandWithSchema(
'move meeple1 region1 --speed 100',
'move <from> <to> [--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,21 +144,22 @@ describe('parseCommandWithSchema', () => {
it('should fail validation with missing required option', () => {
const result = parseCommandWithSchema(
'move meeple1 region1',
'move <from> <to> [--force]'
'move <from> <to> [--force: boolean]'
);
// 可选标志,应该通过验证
// 可选选项,应该通过验证
expect(result.valid).toBe(true);
});
it('should parse complex command with typed params and options', () => {
const result = parseCommandWithSchema(
'move [1; 2] region1 --count 3',
'move <from: [x: string; y: string]> <to: string> [--count: number]'
'move [1; 2] region1 --all true --count 3',
'move <from: [x: string; y: string]> <to: string> [--all: boolean] [--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);
}
});
@ -206,8 +207,8 @@ describe('validateCommand with schema types', () => {
});
it('should validate command with typed options', () => {
const schema = parseCommandSchema('move <from> <to> [--count: number]');
const command = parseCommand('move meeple1 region1 --count 5');
const schema = parseCommandSchema('move <from> <to> [--all: boolean]');
const command = parseCommand('move meeple1 region1 --all true');
const result = validateCommand(command, schema);
expect(result.valid).toBe(true);
});

View File

@ -70,41 +70,42 @@ describe('parseCommandSchema', () => {
});
it('should parse long options', () => {
const schema = parseCommandSchema('move --x: string [--y: string]');
const schema = parseCommandSchema('move --x <value> [--y value]');
expect(schema.options).toEqual([
{ name: 'x', required: true, schema: expect.any(Object) },
{ name: 'y', required: false, schema: expect.any(Object) },
{ name: 'x', required: true },
{ name: 'y', required: false },
]);
});
it('should parse short options', () => {
const schema = parseCommandSchema('move -x: string [-y: string]');
const schema = parseCommandSchema('move -x <value> [-y value]');
expect(schema.options).toEqual([
{ name: 'x', short: 'x', required: true, schema: expect.any(Object) },
{ name: 'y', short: 'y', required: false, schema: expect.any(Object) },
{ 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: string -s]');
const schema = parseCommandSchema('move <from> <to> [--force] [-f] [--speed <val>] [-s val]');
expect(schema).toEqual({
name: 'move',
params: [
{ name: 'from', required: true, variadic: false, schema: undefined },
{ name: 'to', required: true, variadic: false, schema: undefined },
{ name: 'from', required: true, variadic: false },
{ name: 'to', required: true, variadic: false },
],
flags: [
{ name: 'force' },
{ name: 'f', short: 'f' },
],
options: [
{ name: 'speed', short: 's', required: false, schema: expect.any(Object), defaultValue: undefined },
{ name: 'speed', required: false },
{ name: 's', short: 's', required: false },
],
});
});
it('should handle complex schema', () => {
const schema = parseCommandSchema('place <piece> <region> [x...] [--rotate: number] [--force] [-f]');
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);
@ -171,7 +172,7 @@ describe('validateCommand', () => {
});
it('should reject missing required option', () => {
const schema = parseCommandSchema('move <from> --speed: string');
const schema = parseCommandSchema('move <from> --speed <val>');
const command = parseCommand('move meeple1');
const result = validateCommand(command, schema);
expect(result).toEqual({
@ -183,14 +184,14 @@ describe('validateCommand', () => {
});
it('should accept present required option', () => {
const schema = parseCommandSchema('move <from> --speed: string');
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: string]');
const schema = parseCommandSchema('move <from> [--speed [val]]');
const command = parseCommand('move meeple1');
const result = validateCommand(command, schema);
expect(result).toEqual({ valid: true });
@ -205,14 +206,14 @@ describe('validateCommand', () => {
});
it('should validate short form option', () => {
const schema = parseCommandSchema('move <from> -s: string');
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: string');
const schema = parseCommandSchema('place <piece> <region> --rotate <angle>');
const command = parseCommand('place meeple1');
const result = validateCommand(command, schema);
expect(result.valid).toBe(false);
@ -224,7 +225,7 @@ describe('validateCommand', () => {
describe('integration', () => {
it('should work together parse and validate', () => {
const schemaStr = 'place <piece> <region> [--x: string] [--y: string] [--force] [-f]';
const schemaStr = 'place <piece> <region> [--x <val>] [--y [val]] [--force] [-f]';
const schema = parseCommandSchema(schemaStr);
const validCmd = parseCommand('place meeple1 board --x 5 --force');
@ -234,57 +235,4 @@ describe('integration', () => {
const result = validateCommand(invalidCmd, schema);
expect(result.valid).toBe(false);
});
it('should parse short alias syntax', () => {
const schema = parseCommandSchema('move <from> [--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 <from> [--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 <from> [--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 <from> [--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 <from> [--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 <from> [--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 <from> [--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');
});
});