@zhin.js/command 1.1.17 → 1.2.0

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