From c315e0643b9f7e3ca00ec39cc1e112e886312d4b Mon Sep 17 00:00:00 2001 From: hyper Date: Wed, 1 Apr 2026 19:04:09 +0800 Subject: [PATCH] feat: add inline-schema for command schema --- package-lock.json | 334 +++++++++++++++++++++- package.json | 3 +- src/utils/command.ts | 280 ++++++++++++++++-- tests/utils/command-inline-schema.test.ts | 227 +++++++++++++++ 4 files changed, 819 insertions(+), 25 deletions(-) create mode 100644 tests/utils/command-inline-schema.test.ts diff --git a/package-lock.json b/package-lock.json index e18a3da..0356dab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e5c3f6d..c29c181 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/utils/command.ts b/src/utils/command.ts index 05942e7..9470ce9 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -1,8 +1,10 @@ -export type Command = { +import { defineSchema, type ParsedSchema, ParseError } from 'inline-schema'; + +export type Command = { name: string; flags: Record; - options: Record; - params: string[]; + options: Record; + params: unknown[]; } /** @@ -15,6 +17,8 @@ export type CommandParamSchema = { required: boolean; /** 是否可变参数(可以接收多个值) */ variadic: boolean; + /** 参数类型 schema(用于解析和验证) */ + schema?: ParsedSchema; } /** @@ -28,7 +32,9 @@ export type CommandOptionSchema = { /** 是否必需 */ required: boolean; /** 默认值 */ - defaultValue?: string; + defaultValue?: unknown; + /** 选项类型 schema(用于解析和验证) */ + schema?: ParsedSchema; } /** @@ -75,9 +81,9 @@ export function parseCommand(input: string): Command { } const name = tokens[0]; - const params: string[] = []; + const params: unknown[] = []; const flags: Record = {}; - const options: Record = {}; + const options: Record = {}; let i = 1; while (i < tokens.length) { @@ -181,13 +187,24 @@ function tokenize(input: string): string[] { * - [param] 可选参数 * - 必需可变参数 * - [param...] 可选可变参数 + * - 带类型定义的必需参数 + * - [param: type] 带类型定义的可选参数 * - --flag 长格式标志 * - -f 短格式标志 * - --option 长格式选项 + * - --option: type 带类型的长格式选项 * - -o 短格式选项 + * - -o: type 带类型的短格式选项 + * + * 类型语法使用 inline-schema 格式(使用 ; 而非 ,): + * - string, number, boolean + * - [string; number] 元组 + * - string[] 数组 + * - [string; number][] 元组数组 * * @example * parseCommandSchema('move [to...] [--force] [-f] [--speed ]') + * parseCommandSchema('move [--all: boolean]') */ export function parseCommandSchema(schemaStr: string): CommandSchema { const schema: CommandSchema = { @@ -212,18 +229,40 @@ export function parseCommandSchema(schemaStr: string): CommandSchema { 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) { - // 可选选项 + + // 检查是否有类型定义(如 --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 ) + const valueToken = parts[1]; + let typeStr = valueToken; + // 如果是 格式,提取类型 + 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 { // 可选标志 @@ -233,14 +272,35 @@ export function parseCommandSchema(schemaStr: string): CommandSchema { // 可选短格式标志或选项 const parts = inner.split(/\s+/); const short = parts[0].slice(1); - - // 如果有额外的部分,则是选项 - if (parts.length > 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 { // 可选标志 @@ -249,12 +309,25 @@ export function parseCommandSchema(schemaStr: string): CommandSchema { } else { // 可选参数 const isVariadic = inner.endsWith('...'); - const name = isVariadic ? inner.slice(0, -3) : inner; + 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, + name: paramContent, required: false, variadic: isVariadic, + schema: parsedSchema, }); } i++; @@ -263,11 +336,30 @@ export function parseCommandSchema(schemaStr: string): CommandSchema { const name = token.slice(2); const nextToken = tokens[i + 1]; - // 如果下一个 token 是 格式,则是选项 - if (nextToken && nextToken.startsWith('<') && nextToken.endsWith('>')) { + // 检查是否有类型定义(如 --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 + 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 { @@ -280,12 +372,32 @@ export function parseCommandSchema(schemaStr: string): CommandSchema { const short = token.slice(1); const nextToken = tokens[i + 1]; - // 如果下一个 token 是 格式,则是选项 - if (nextToken && nextToken.startsWith('<') && nextToken.endsWith('>')) { + // 检查是否有类型定义 + 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 { @@ -296,12 +408,27 @@ export function parseCommandSchema(schemaStr: string): CommandSchema { } else if (token.startsWith('<') && token.endsWith('>')) { // 必需参数 const isVariadic = token.endsWith('...>'); - const name = token.replace(/^[<]+|[>.>]+$/g, ''); + let paramContent = token.replace(/^[<]+|[>.>]+$/g, ''); + let parsedSchema: ParsedSchema | undefined; + + // 检查是否有类型定义(如 ) + 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, + name: paramContent, required: true, variadic: isVariadic, + schema: parsedSchema, }); i++; } else { @@ -450,3 +577,110 @@ export function validateCommand( return { valid: true }; } + +/** + * 根据 schema 解析并验证命令,返回类型化的命令对象 + * 如果 schema 中定义了类型,会自动解析参数和选项的值 + * + * @param input 命令行输入字符串 + * @param schemaStr 命令 schema 字符串 + * @returns 解析后的命令对象和验证结果 + * + * @example + * const result = parseCommandWithSchema( + * 'move [1; 2] region1 --all true', + * 'move [--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 = { ...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, + }; +} diff --git a/tests/utils/command-inline-schema.test.ts b/tests/utils/command-inline-schema.test.ts new file mode 100644 index 0000000..c62e3c7 --- /dev/null +++ b/tests/utils/command-inline-schema.test.ts @@ -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 '); + 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 [--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 '); + 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 '); + 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 '); + 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 [--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 [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 [--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 ' + ); + 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 [--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]' + ); + 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 ' + ); + 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 ' + ); + 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 [--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 [--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 ' + ); + 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 ' + ); + 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 ' + ); + 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 '); + 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 [--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 '); + 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('参数不足') + ); + } + }); +});