@zhin.js/command 1.1.5 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,280 @@
1
+ import type {
2
+ CommandDefinition, CommandParams, CommandOptions, CommandParamSchema,
3
+ CommandParameterValue, CommandSegment, CommandSession,
4
+ } from './definition.js';
5
+
6
+ export interface CommandValidationIssue {
7
+ readonly field: string;
8
+ readonly message: string;
9
+ }
10
+ export interface ParsedCommandArguments {
11
+ readonly params: Readonly<Record<string, CommandParameterValue>>;
12
+ readonly options: Readonly<Record<string, CommandParameterValue>>;
13
+ readonly remaining: readonly Readonly<CommandSegment>[];
14
+ readonly issues: readonly CommandValidationIssue[];
15
+ }
16
+ const scalarTypes = new Set(['string', 'word', 'text', 'number', 'integer', 'float', 'boolean']);
17
+
18
+ /** Validate the single declaration used by parsing, help and execution. */
19
+ export function validateArgumentSchemas(params?: CommandParams, options?: CommandOptions): void {
20
+ let optional = false;
21
+ let rest = false;
22
+ for (const [name, schema] of Object.entries(params ?? {})) {
23
+ validateSchema(name, schema);
24
+ if (rest) throw new TypeError('Command rest parameter must be last');
25
+ if (optional && !schema.optional && schema.default === undefined) {
26
+ throw new TypeError(`Required Command parameter ${name} cannot follow an optional parameter`);
27
+ }
28
+ optional ||= schema.optional === true || schema.default !== undefined;
29
+ rest = schema.rest === true;
30
+ }
31
+ if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options))) {
32
+ throw new TypeError('Command options must be a record');
33
+ }
34
+ const aliases = new Set<string>();
35
+ for (const [name, schema] of Object.entries(options ?? {})) {
36
+ validateSchema(name, schema);
37
+ if (!scalarTypes.has(schema.type) || schema.type === 'text') {
38
+ throw new TypeError(`Command option ${name} requires a scalar type`);
39
+ }
40
+ if (name === 'help') throw new TypeError('Command option help is reserved');
41
+ if (schema.alias !== undefined) {
42
+ if (!/^[a-zA-Z]$/u.test(schema.alias) || schema.alias === 'h' || aliases.has(schema.alias)) {
43
+ throw new TypeError(`Invalid or duplicate Command option alias ${schema.alias}`);
44
+ }
45
+ aliases.add(schema.alias);
46
+ }
47
+ }
48
+ }
49
+ function validateSchema(name: string, schema: CommandParamSchema): void {
50
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/u.test(name)) throw new TypeError(`Invalid Command argument name ${name}`);
51
+ if (!schema || typeof schema !== 'object') throw new TypeError(`Invalid Command argument ${name}`);
52
+ if (schema.min !== undefined && !Number.isFinite(schema.min)
53
+ || schema.max !== undefined && !Number.isFinite(schema.max)
54
+ || schema.min !== undefined && schema.max !== undefined && schema.min > schema.max) {
55
+ throw new TypeError(`Invalid Command argument bounds for ${name}`);
56
+ }
57
+ if (schema.pattern !== undefined && !(schema.pattern instanceof RegExp)) {
58
+ throw new TypeError(`Command argument ${name}.pattern must be a RegExp`);
59
+ }
60
+ if (schema.validate !== undefined && typeof schema.validate !== 'function') {
61
+ throw new TypeError(`Command argument ${name}.validate must be a function`);
62
+ }
63
+ if (schema.choices !== undefined && !Array.isArray(schema.choices)) {
64
+ throw new TypeError(`Command argument ${name}.choices must be an array`);
65
+ }
66
+ if (schema.default !== undefined && !scalarTypes.has(schema.type)) throw new TypeError(`Structured Command argument ${name} cannot declare a default`);
67
+ if (schema.default !== undefined && typeof schema.default !== 'function') {
68
+ if (Array.isArray(schema.default) && !schema.rest) throw new TypeError(`Command argument ${name} requires rest: true for an array default`);
69
+ const values = schema.rest ? schema.default : [schema.default];
70
+ if (!Array.isArray(values) || values.some((value) => !valueMatches(schema, value))) {
71
+ throw new TypeError(`Invalid Command argument default for ${name}`);
72
+ }
73
+ const defaultSchema = { ...schema, validate: undefined };
74
+ const issue = validateValue(name, defaultSchema, schema.default, {});
75
+ if (issue) throw new TypeError(`Invalid Command argument default for ${name}: ${issue.message}`);
76
+ }
77
+ }
78
+
79
+ interface TextToken {
80
+ readonly value: string;
81
+ readonly quoted: boolean;
82
+ readonly raw?: string;
83
+ readonly before?: string;
84
+ readonly first?: boolean;
85
+ readonly after?: string;
86
+ /** Original message segment, retained while options are removed. */
87
+ readonly segment?: number;
88
+ }
89
+ type Token = TextToken | Readonly<CommandSegment>;
90
+ function isTextToken(value: Token | undefined): value is TextToken {
91
+ return value !== undefined && 'value' in value;
92
+ }
93
+ function tokenize(segments: readonly Readonly<CommandSegment>[]): Token[] {
94
+ return segments.flatMap<Token>((segment, segmentIndex) => {
95
+ if (segment.type !== 'text' || typeof segment.data.text !== 'string') return [segment];
96
+ const tokens: Token[] = [];
97
+ const pattern = /(--?[a-zA-Z][a-zA-Z0-9_-]*=)(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')|"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\S+)/gu;
98
+ let end = 0;
99
+ for (const match of segment.data.text.matchAll(pattern)) {
100
+ const value = match[1] !== undefined ? match[1] + (match[2] ?? match[3] ?? '') : match[4] ?? match[5] ?? match[6] ?? '';
101
+ tokens.push({ value: value.replace(/\\(["'\\])/gu, '$1'), quoted: match[1] === undefined && match[6] === undefined, raw: match[0], before: segment.data.text.slice(end, match.index), first: tokens.length === 0, segment: segmentIndex });
102
+ end = (match.index ?? 0) + match[0].length;
103
+ }
104
+ const last = tokens.at(-1);
105
+ if (isTextToken(last)) tokens[tokens.length - 1] = { ...last, after: segment.data.text.slice(end) };
106
+ return tokens;
107
+ });
108
+ }
109
+ /** Reserved flags use the same quoted-token and option-terminator rules as arguments. */
110
+ export function requestsCommandHelp(segments: readonly Readonly<CommandSegment>[]): boolean {
111
+ for (const token of tokenize(segments)) {
112
+ if (!isTextToken(token) || token.quoted) continue;
113
+ if (token.value === '--') return false;
114
+ if (token.value === '--help') return true;
115
+ }
116
+ return false;
117
+ }
118
+ function segmentsFromTokens(tokens: readonly Token[]): readonly Readonly<CommandSegment>[] {
119
+ const result: CommandSegment[] = [];
120
+ let lastTextSegment: number | undefined;
121
+ for (const token of tokens) {
122
+ if (isTextToken(token)) {
123
+ const last = result.at(-1);
124
+ const text = (token.raw ?? token.value) + (token.after ?? '');
125
+ if (last?.type === 'text' && lastTextSegment === token.segment) result[result.length - 1] = { type: 'text', data: { text: `${last.data.text}${token.before || ' '}${text}` } };
126
+ else result.push({ type: 'text', data: { text: (token.first ? token.before ?? '' : '') + text } });
127
+ lastTextSegment = token.segment;
128
+ } else { result.push(token); lastTextSegment = undefined; }
129
+ }
130
+ return Object.freeze(result);
131
+ }
132
+ function convert(schema: CommandParamSchema, token: Token | undefined): CommandParameterValue | undefined {
133
+ if (!token) return undefined;
134
+ if (isTextToken(token)) {
135
+ const text = token.value;
136
+ if (schema.type === 'string' || schema.type === 'word' || schema.type === 'text') return text;
137
+ if (schema.type === 'boolean') return text === 'true' ? true : text === 'false' ? false : undefined;
138
+ if (schema.type === 'number' || schema.type === 'integer' || schema.type === 'float') {
139
+ const value = Number(text);
140
+ return text !== '' && Number.isFinite(value) && (schema.type !== 'integer' || Number.isInteger(value))
141
+ && (schema.type !== 'float' || text.includes('.')) ? value : undefined;
142
+ }
143
+ return undefined;
144
+ }
145
+ const type = typeof token.type === 'string' ? token.type : token.type.name;
146
+ if (type !== schema.type && !(schema.type === 'mention' && type === 'at')) return undefined;
147
+ const data = token.data;
148
+ const value = schema.type === 'image' ? data.media ?? data.file ?? data.url ?? data.src
149
+ : schema.type === 'mention' ? data.target ?? data.user_id ?? data.qq
150
+ : schema.type === 'reply' ? data.message_id ?? data.reply_id ?? data.id
151
+ : schema.type === 'forward' ? data.forward_id ?? data.res_id ?? data.message_id ?? data.id
152
+ : schema.type === 'dice' || schema.type === 'rps' ? data.result : data.id;
153
+ return value as CommandParameterValue | undefined;
154
+ }
155
+ function valueMatches(schema: CommandParamSchema, value: unknown): boolean {
156
+ if (schema.type === 'number' || schema.type === 'integer' || schema.type === 'float' || schema.type === 'dice' || schema.type === 'rps') {
157
+ return typeof value === 'number' && Number.isFinite(value) && (schema.type !== 'integer' || Number.isInteger(value));
158
+ }
159
+ if (schema.type === 'mention' || schema.type === 'face' || schema.type === 'reply' || schema.type === 'forward') return typeof value === 'string' || typeof value === 'number' && Number.isFinite(value);
160
+ if (schema.type === 'boolean') return typeof value === 'boolean';
161
+ if (schema.type === 'image') return typeof value === 'string' || !!value && typeof value === 'object';
162
+ return typeof value === 'string';
163
+ }
164
+ function validateValue(field: string, schema: CommandParamSchema, value: CommandParameterValue, session: CommandSession): CommandValidationIssue | undefined {
165
+ if (schema.rest && !Array.isArray(value)) return { field, message: `${field}: expected an array of ${schema.type}` };
166
+ const values = schema.rest ? (Array.isArray(value) ? value : [value]) : [value];
167
+ for (const item of values) {
168
+ if (!valueMatches(schema, item)) return { field, message: `${field}: expected ${schema.type}` };
169
+ const size = typeof item === 'number' ? item : typeof item === 'string' ? item.length : undefined;
170
+ if (schema.min !== undefined && (size === undefined || size < schema.min)) return { field, message: `${field}: minimum ${schema.min}` };
171
+ if (schema.max !== undefined && (size === undefined || size > schema.max)) return { field, message: `${field}: maximum ${schema.max}` };
172
+ if (schema.choices && !schema.choices.includes(item)) return { field, message: `${field}: expected one of ${schema.choices.join(', ')}` };
173
+ if (schema.pattern) {
174
+ const pattern = new RegExp(schema.pattern.source, schema.pattern.flags.replace(/[gy]/gu, ''));
175
+ if (typeof item !== 'string' || !pattern.test(item)) return { field, message: `${field}: invalid format` };
176
+ }
177
+ }
178
+ const result = schema.validate?.(value, session);
179
+ return result === false || typeof result === 'string' ? { field, message: typeof result === 'string' ? result : `${field}: invalid value` } : undefined;
180
+ }
181
+
182
+ /** Parse arguments after a route match; the CommandIndex remains the only dispatcher. */
183
+ export function parseCommandArguments(
184
+ definition: CommandDefinition,
185
+ remaining: readonly Readonly<CommandSegment>[],
186
+ initial: Readonly<Record<string, CommandParameterValue>>,
187
+ session: CommandSession,
188
+ ): ParsedCommandArguments {
189
+ const params: Record<string, CommandParameterValue> = { __proto__: null, ...initial };
190
+ const options: Record<string, CommandParameterValue> = { __proto__: null };
191
+ const issues: CommandValidationIssue[] = [];
192
+ const tokens = tokenize(remaining);
193
+ const positional: Token[] = [];
194
+ const schemas: CommandOptions | undefined = definition.options ?? (definition.params === undefined ? undefined : {});
195
+ const optionNames = new Map(Object.entries(schemas ?? {}).flatMap<[string, string]>(([name, schema]) => [[`--${name}`, name], ...(schema.alias ? [[`-${schema.alias}`, name] as [string, string]] : [])]));
196
+ let terminated = false;
197
+ for (let index = 0; index < tokens.length; index++) {
198
+ const token = tokens[index];
199
+ if (!token) continue;
200
+ if (!isTextToken(token) || token.quoted || terminated || schemas === undefined) { positional.push(token); continue; }
201
+ if (token.value === '--') { terminated = true; continue; }
202
+ if (!/^--?[a-zA-Z]/u.test(token.value)) { positional.push(token); continue; }
203
+ const [rawFlag, inline] = token.value.split(/=(.*)/u);
204
+ const flag = rawFlag ?? token.value;
205
+ const name = optionNames.get(flag);
206
+ if (!name) { issues.push({ field: flag, message: `Unknown option ${flag}` }); continue; }
207
+ const schema = schemas[name];
208
+ if (!schema) continue;
209
+ if (Object.hasOwn(options, name)) { issues.push({ field: name, message: `Duplicate option --${name}` }); continue; }
210
+ let value: CommandParameterValue | undefined;
211
+ if (inline !== undefined) value = convert(schema, { value: inline, quoted: false });
212
+ else if (schema.type === 'boolean') value = true;
213
+ else {
214
+ const next = tokens[index + 1];
215
+ if (isTextToken(next) && !(!next.quoted && /^--?[a-zA-Z]/u.test(next.value))) { index++; value = convert(schema, next); }
216
+ }
217
+ if (value === undefined) issues.push({ field: name, message: `--${name}: expected ${schema.type}` });
218
+ else options[name] = value;
219
+ }
220
+ for (const [name, schema] of Object.entries(schemas ?? {})) {
221
+ if (!Object.hasOwn(options, name) && schema.default !== undefined) options[name] = typeof schema.default === 'function' ? schema.default(session) : schema.default;
222
+ if (Object.hasOwn(options, name) && options[name] === undefined) issues.push({ field: name, message: `--${name}: default returned undefined` });
223
+ else if (options[name] !== undefined) { const issue = validateValue(`--${name}`, schema, options[name], session); if (issue) issues.push(issue); }
224
+ else if (schema.optional === false) issues.push({ field: name, message: `Missing option --${name}` });
225
+ }
226
+ const declared = Object.entries(definition.params ?? {});
227
+ for (const [name, declaredSchema] of declared) {
228
+ const schema = definition.$parameter?.name === name ? { ...declaredSchema, optional: definition.$parameter.optional ?? declaredSchema.optional, rest: definition.$parameter.rest ?? declaredSchema.rest } : declaredSchema;
229
+ if (!Object.hasOwn(params, name)) {
230
+ let value: CommandParameterValue | undefined;
231
+ if (schema.rest) {
232
+ const raw = positional.splice(0);
233
+ const converted = schema.type === 'text'
234
+ ? segmentsFromTokens(raw).map((segment) => segment.type === 'text' && typeof segment.data.text === 'string' ? segment.data.text : undefined)
235
+ : raw.map((token) => convert(schema, token));
236
+ if (converted.some((item) => item === undefined)) issues.push({ field: name, message: `${name}: expected ${schema.type}` });
237
+ else if (converted.length > 0 || schema.optional) value = converted as readonly (string | number | boolean | Readonly<Record<string, unknown>>)[];
238
+ } else if (schema.type === 'text') {
239
+ const raw: string[] = [];
240
+ while (isTextToken(positional[0])) {
241
+ const token = positional.shift() as Extract<Token, { value: string }>;
242
+ raw.push(`${raw.length > 0 ? token.before || ' ' : ''}${token.raw ?? token.value}${token.after ?? ''}`);
243
+ }
244
+ if (raw.length > 0) value = raw.join('');
245
+ } else if (positional.length > 0) {
246
+ value = convert(schema, positional.shift());
247
+ // Defaults apply to omitted input, never to a supplied value of the wrong type.
248
+ if (value === undefined) {
249
+ issues.push({ field: name, message: `${name}: expected ${schema.type}` });
250
+ continue;
251
+ }
252
+ }
253
+ if (value !== undefined) params[name] = value;
254
+ else if (schema.default !== undefined) params[name] = typeof schema.default === 'function' ? schema.default(session) : schema.default;
255
+ else if (!schema.optional && !(definition.$parameter?.name === name && definition.$parameter.optional)) {
256
+ issues.push({ field: name, message: `${name}: expected ${schema.type}` });
257
+ }
258
+ }
259
+ if (Object.hasOwn(params, name) && params[name] === undefined) issues.push({ field: name, message: `${name}: default or shortcut returned undefined` });
260
+ else if (params[name] !== undefined) { const effective = definition.$parameter?.name === name && definition.$parameter.rest ? { ...schema, rest: true } : schema; const issue = validateValue(name, effective, params[name], session); if (issue) issues.push(issue); }
261
+ }
262
+ if (declared.length > 0 && !definition.$parameter && positional.length > 0) issues.push({ field: 'arguments', message: 'Unexpected arguments' });
263
+ return Object.freeze({ params: Object.freeze(params), options: Object.freeze(options), remaining: schemas === undefined && declared.every(([name]) => Object.hasOwn(initial, name)) ? remaining : segmentsFromTokens(positional), issues: Object.freeze(issues) });
264
+ }
265
+
266
+ export function commandUsage(name: string, definition: CommandDefinition): string {
267
+ const parameters = Object.entries(definition.params ?? {}).filter(([key]) => key !== definition.$parameter?.name)
268
+ .map(([key, schema]) => `${schema.optional || schema.default !== undefined ? '[' : '<'}${schema.rest ? '...' : ''}${key}${schema.optional || schema.default !== undefined ? ']' : '>'}`);
269
+ const options = Object.entries(definition.options ?? {}).map(([key, schema]) => `[--${key}${schema.type === 'boolean' ? '' : ` <${schema.type}>`}]`);
270
+ return [name, ...parameters, ...options].join(' ');
271
+ }
272
+ export function commandHelp(name: string, definition: CommandDefinition, prefix = ''): string {
273
+ return [prefix + commandUsage(name, definition), definition.description,
274
+ ...Object.entries(definition.params ?? {}).map(([key, schema]) => argumentHelp(key, schema)),
275
+ ...Object.entries(definition.options ?? {}).map(([key, schema]) => argumentHelp(`--${key}${schema.alias ? ` (-${schema.alias})` : ''}`, schema)),
276
+ ].filter(Boolean).join('\n');
277
+ }
278
+ function argumentHelp(name: string, schema: CommandParamSchema): string {
279
+ return ` ${name}: ${schema.type}${schema.description ? ` — ${schema.description}` : ''}${schema.default !== undefined ? ` (default: ${typeof schema.default === 'function' ? 'dynamic' : JSON.stringify(schema.default)})` : ''}${schema.choices ? ` (${schema.choices.join(', ')})` : ''}${schema.min !== undefined ? ` min=${schema.min}` : ''}${schema.max !== undefined ? ` max=${schema.max}` : ''}`;
280
+ }