Compare commits

..

2 Commits

Author SHA1 Message Date
hyper c315e0643b feat: add inline-schema for command schema 2026-04-01 19:04:09 +08:00
hyper 95015b090c feat: add command schema 2026-04-01 18:54:02 +08:00
6 changed files with 1368 additions and 9 deletions

334
package-lock.json generated
View File

@ -10,7 +10,8 @@
"license": "MIT",
"dependencies": {
"@preact/signals-core": "^1.5.1",
"boardgame-core": "file:"
"boardgame-core": "file:",
"inline-schema": "git+https://gitea.ayi-games.online/hypercross/inline-schema"
},
"devDependencies": {
"tsup": "^8.0.2",
@ -18,6 +19,40 @@
"vitest": "^1.3.1"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.0",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
@ -512,6 +547,78 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@module-federation/error-codes": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz",
"integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==",
"license": "MIT",
"peer": true
},
"node_modules/@module-federation/runtime": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz",
"integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@module-federation/error-codes": "0.22.0",
"@module-federation/runtime-core": "0.22.0",
"@module-federation/sdk": "0.22.0"
}
},
"node_modules/@module-federation/runtime-core": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz",
"integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@module-federation/error-codes": "0.22.0",
"@module-federation/sdk": "0.22.0"
}
},
"node_modules/@module-federation/runtime-tools": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz",
"integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@module-federation/runtime": "0.22.0",
"@module-federation/webpack-bundler-runtime": "0.22.0"
}
},
"node_modules/@module-federation/sdk": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz",
"integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==",
"license": "MIT",
"peer": true
},
"node_modules/@module-federation/webpack-bundler-runtime": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz",
"integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@module-federation/runtime": "0.22.0",
"@module-federation/sdk": "0.22.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz",
"integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@preact/signals-core": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.0.tgz",
@ -872,6 +979,195 @@
"win32"
]
},
"node_modules/@rspack/binding": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.11.tgz",
"integrity": "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q==",
"license": "MIT",
"peer": true,
"optionalDependencies": {
"@rspack/binding-darwin-arm64": "1.7.11",
"@rspack/binding-darwin-x64": "1.7.11",
"@rspack/binding-linux-arm64-gnu": "1.7.11",
"@rspack/binding-linux-arm64-musl": "1.7.11",
"@rspack/binding-linux-x64-gnu": "1.7.11",
"@rspack/binding-linux-x64-musl": "1.7.11",
"@rspack/binding-wasm32-wasi": "1.7.11",
"@rspack/binding-win32-arm64-msvc": "1.7.11",
"@rspack/binding-win32-ia32-msvc": "1.7.11",
"@rspack/binding-win32-x64-msvc": "1.7.11"
}
},
"node_modules/@rspack/binding-darwin-arm64": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.11.tgz",
"integrity": "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@rspack/binding-darwin-x64": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.11.tgz",
"integrity": "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@rspack/binding-linux-arm64-gnu": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.11.tgz",
"integrity": "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rspack/binding-linux-arm64-musl": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.11.tgz",
"integrity": "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rspack/binding-linux-x64-gnu": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.11.tgz",
"integrity": "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rspack/binding-linux-x64-musl": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.11.tgz",
"integrity": "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@rspack/binding-wasm32-wasi": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.11.tgz",
"integrity": "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@napi-rs/wasm-runtime": "1.0.7"
}
},
"node_modules/@rspack/binding-win32-arm64-msvc": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.11.tgz",
"integrity": "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@rspack/binding-win32-ia32-msvc": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.11.tgz",
"integrity": "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@rspack/binding-win32-x64-msvc": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.11.tgz",
"integrity": "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@rspack/core": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.11.tgz",
"integrity": "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==",
"license": "MIT",
"peer": true,
"dependencies": {
"@module-federation/runtime-tools": "0.22.0",
"@rspack/binding": "1.7.11",
"@rspack/lite-tapable": "1.1.0"
},
"engines": {
"node": ">=18.12.0"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.1"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/@rspack/lite-tapable": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz",
"integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==",
"license": "MIT",
"peer": true
},
"node_modules/@sinclair/typebox": {
"version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
@ -879,6 +1175,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1150,6 +1457,12 @@
"node": ">= 8"
}
},
"node_modules/csv-parse": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
"integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -1345,6 +1658,17 @@
"node": ">=16.17.0"
}
},
"node_modules/inline-schema": {
"version": "1.0.0",
"resolved": "git+https://gitea.ayi-games.online/hypercross/inline-schema#cf55295ce79a2dcf3114e2910cc6a28ce872be90",
"license": "ISC",
"dependencies": {
"csv-parse": "^5.5.6"
},
"peerDependencies": {
"@rspack/core": "^1.x"
}
},
"node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
@ -2040,6 +2364,14 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true,
"peer": true
},
"node_modules/tsup": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz",

View File

@ -18,7 +18,8 @@
},
"dependencies": {
"@preact/signals-core": "^1.5.1",
"boardgame-core": "file:"
"boardgame-core": "file:",
"inline-schema": "git+https://gitea.ayi-games.online/hypercross/inline-schema"
},
"devDependencies": {
"tsup": "^8.0.2",

View File

@ -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';

View File

@ -1,8 +1,64 @@
export type Command = {
import { defineSchema, type ParsedSchema, ParseError } from 'inline-schema';
export type Command = {
name: string;
flags: Record<string, true>;
options: Record<string, string>;
params: string[];
options: Record<string, unknown>;
params: unknown[];
}
/**
* schema
*/
export type CommandParamSchema = {
/** 参数名称 */
name: string;
/** 是否必需 */
required: boolean;
/** 是否可变参数(可以接收多个值) */
variadic: boolean;
/** 参数类型 schema用于解析和验证 */
schema?: ParsedSchema;
}
/**
* schema
*/
export type CommandOptionSchema = {
/** 选项名称(长格式,不含 -- */
name: string;
/** 短格式名称(不含 - */
short?: string;
/** 是否必需 */
required: boolean;
/** 默认值 */
defaultValue?: unknown;
/** 选项类型 schema用于解析和验证 */
schema?: ParsedSchema;
}
/**
* schema
*/
export type CommandFlagSchema = {
/** 标志名称(长格式,不含 -- */
name: string;
/** 短格式名称(不含 - */
short?: string;
}
/**
* schema
*/
export type CommandSchema = {
/** 命令名称 */
name: string;
/** 参数定义列表 */
params: CommandParamSchema[];
/** 选项定义列表 */
options: CommandOptionSchema[];
/** 标志定义列表 */
flags: CommandFlagSchema[];
}
/**
@ -25,9 +81,9 @@ export function parseCommand(input: string): Command {
}
const name = tokens[0];
const params: string[] = [];
const params: unknown[] = [];
const flags: Record<string, true> = {};
const options: Record<string, string> = {};
const options: Record<string, unknown> = {};
let i = 1;
while (i < tokens.length) {
@ -123,3 +179,508 @@ function tokenize(input: string): string[] {
return tokens;
}
/**
* schema CommandSchema
*
* - <param>
* - [param]
* - <param...>
* - [param...]
* - <param: type>
* - [param: type]
* - --flag
* - -f
* - --option <value>
* - --option: type
* - -o <value>
* - -o: type
*
* 使 inline-schema 使 ; ,
* - string, number, boolean
* - [string; number]
* - string[]
* - [string; number][]
*
* @example
* 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 = {
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);
// 检查是否有类型定义(如 --flag: boolean 或 --opt: string[]
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) {
// 可选选项(旧语法:--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) {
// 可选短格式标志或选项
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: optName,
short: optName,
required: false,
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 {
parsedSchema = defineSchema(typeStr);
} catch {
// 不是有效的 schema
}
paramContent = name;
}
schema.params.push({
name: paramContent,
required: false,
variadic: isVariadic,
schema: parsedSchema,
});
}
i++;
} else if (token.startsWith('--')) {
// 长格式标志或选项(必需的,因为不在方括号内)
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: optName,
required: true,
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++;
}
} else if (token.startsWith('-') && token.length > 1 && !/^-?\d+$/.test(token)) {
// 短格式标志或选项(必需的,因为不在方括号内)
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: optName,
short: optName,
required: true,
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++;
}
} 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();
const typeStr = paramContent.slice(colonIndex + 1).trim();
try {
parsedSchema = defineSchema(typeStr);
} catch (e) {
// 不是有效的 schema
}
paramContent = name;
}
schema.params.push({
name: paramContent,
required: true,
variadic: isVariadic,
schema: parsedSchema,
});
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 };
}
/**
* schema
* schema
*
* @param input
* @param schemaStr schema
* @returns
*
* @example
* const result = parseCommandWithSchema(
* 'move [1; 2] region1 --all true',
* 'move <from: [x: string; y: string]> <to: string> [--all: boolean]'
* );
* // result.command.params[0] = ['1', '2'] (已解析为元组)
* // result.command.options.all = true (已解析为布尔值)
*/
export function parseCommandWithSchema(
input: string,
schemaStr: string
): { command: Command; valid: true } | { command: Command; valid: false; errors: string[] } {
const schema = parseCommandSchema(schemaStr);
const command = parseCommand(input);
// 验证命令名称
if (command.name !== schema.name) {
return {
command,
valid: false,
errors: [`命令名称不匹配:期望 "${schema.name}",实际 "${command.name}"`],
};
}
const errors: string[] = [];
// 验证参数数量
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}`);
return { command, valid: false, errors };
}
if (!variadicParam && command.params.length > schema.params.length) {
errors.push(`参数过多:最多 ${schema.params.length} 个参数,实际 ${command.params.length}`);
return { command, valid: false, errors };
}
// 验证必需的选项
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 { command, valid: false, errors };
}
// 使用 schema 解析参数值
const parsedParams: unknown[] = [];
for (let i = 0; i < command.params.length; i++) {
const paramValue = command.params[i];
const paramSchema = schema.params[i]?.schema;
if (paramSchema) {
try {
// 如果是字符串值,使用 schema 解析
const parsed = typeof paramValue === 'string'
? paramSchema.parse(paramValue)
: paramValue;
parsedParams.push(parsed);
} catch (e) {
const err = e as ParseError;
errors.push(`参数 "${schema.params[i]?.name}" 解析失败:${err.message}`);
}
} else {
parsedParams.push(paramValue);
}
}
// 使用 schema 解析选项值
const parsedOptions: Record<string, unknown> = { ...command.options };
for (const [key, value] of Object.entries(command.options)) {
const optSchema = schema.options.find(o => o.name === key || o.short === key);
if (optSchema?.schema && typeof value === 'string') {
try {
parsedOptions[key] = optSchema.schema.parse(value);
} catch (e) {
const err = e as ParseError;
errors.push(`选项 "--${key}" 解析失败:${err.message}`);
}
}
}
if (errors.length > 0) {
return { command: { ...command, params: parsedParams, options: parsedOptions }, valid: false, errors };
}
return {
command: { ...command, params: parsedParams, options: parsedOptions },
valid: true,
};
}

View File

@ -0,0 +1,227 @@
import { describe, it, expect } from 'vitest';
import {
parseCommandSchema,
parseCommandWithSchema,
validateCommand,
parseCommand,
type CommandSchema,
} from '../../src/utils/command';
describe('parseCommandSchema with inline-schema', () => {
it('should parse schema with typed params', () => {
const schema = parseCommandSchema('move <from: [x: string; y: string]> <to: string>');
expect(schema.name).toBe('move');
expect(schema.params).toHaveLength(2);
expect(schema.params[0].name).toBe('from');
expect(schema.params[0].schema).toBeDefined();
expect(schema.params[1].name).toBe('to');
expect(schema.params[1].schema).toBeDefined();
});
it('should parse schema with typed options', () => {
const schema = parseCommandSchema('move <from> <to> [--all: boolean] [--count: number]');
expect(schema.name).toBe('move');
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', () => {
const schema = parseCommandSchema('place <coords: [number; number]>');
expect(schema.name).toBe('place');
expect(schema.params).toHaveLength(1);
expect(schema.params[0].name).toBe('coords');
expect(schema.params[0].schema).toBeDefined();
});
it('should parse schema with array type', () => {
const schema = parseCommandSchema('select <ids: string[]>');
expect(schema.name).toBe('select');
expect(schema.params).toHaveLength(1);
expect(schema.params[0].name).toBe('ids');
expect(schema.params[0].schema).toBeDefined();
});
it('should parse schema with tuple array type', () => {
const schema = parseCommandSchema('place <coords: [number; number][]>');
expect(schema.name).toBe('place');
expect(schema.params).toHaveLength(1);
expect(schema.params[0].name).toBe('coords');
expect(schema.params[0].schema).toBeDefined();
});
it('should parse schema with mixed types', () => {
const schema = parseCommandSchema(
'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(2);
});
it('should parse schema with optional typed param', () => {
const schema = parseCommandSchema('move <from> [to: string]');
expect(schema.name).toBe('move');
expect(schema.params).toHaveLength(2);
expect(schema.params[0].required).toBe(true);
expect(schema.params[1].required).toBe(false);
expect(schema.params[1].schema).toBeDefined();
});
it('should parse schema with optional typed option', () => {
const schema = parseCommandSchema('move <from> [--speed: number]');
expect(schema.name).toBe('move');
expect(schema.options).toHaveLength(1);
expect(schema.options[0].required).toBe(false);
expect(schema.options[0].schema).toBeDefined();
});
});
describe('parseCommandWithSchema', () => {
it('should parse and validate command with tuple param', () => {
const result = parseCommandWithSchema(
'move [1; 2] region1',
'move <from: [x: string; y: string]> <to: string>'
);
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.command.params).toHaveLength(2);
expect(result.command.params[0]).toEqual(['1', '2']);
expect(result.command.params[1]).toBe('region1');
}
});
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]'
);
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.command.options.count).toBe(5);
}
});
it('should fail validation with wrong command name', () => {
const result = parseCommandWithSchema(
'jump meeple1 region1',
'move <from> <to>'
);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors).toContainEqual(
expect.stringContaining('命令名称不匹配')
);
}
});
it('should fail validation with missing required param', () => {
const result = parseCommandWithSchema(
'move meeple1',
'move <from> <to>'
);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors).toContainEqual(
expect.stringContaining('参数不足')
);
}
});
it('should fail validation with missing required option', () => {
const result = parseCommandWithSchema(
'move meeple1 region1',
'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 --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);
}
});
it('should handle number parsing in tuple', () => {
const result = parseCommandWithSchema(
'place [10; 20]',
'place <coords: [number; number]>'
);
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.command.params[0]).toEqual([10, 20]);
}
});
it('should handle string array parsing', () => {
const result = parseCommandWithSchema(
'select [alice; bob; charlie]',
'select <ids: string[]>'
);
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.command.params[0]).toEqual(['alice', 'bob', 'charlie']);
}
});
it('should handle tuple array parsing', () => {
const result = parseCommandWithSchema(
'place [[1; 2]; [3; 4]; [5; 6]]',
'place <coords: [number; number][]>'
);
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.command.params[0]).toEqual([[1, 2], [3, 4], [5, 6]]);
}
});
});
describe('validateCommand with schema types', () => {
it('should validate command with typed params', () => {
const schema = parseCommandSchema('move <from: [x: string; y: string]> <to: string>');
const command = parseCommand('move [1; 2] region1');
const result = validateCommand(command, schema);
expect(result.valid).toBe(true);
});
it('should validate command with typed options', () => {
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);
});
it('should fail validation with insufficient params', () => {
const schema = parseCommandSchema('move <from: [x: string; y: string]> <to: string>');
const command = parseCommand('move [1; 2]');
const result = validateCommand(command, schema);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors).toContainEqual(
expect.stringContaining('参数不足')
);
}
});
});

View File

@ -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);
});
});