clap-ts 0.1.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/LICENSE +21 -0
- package/README.md +752 -0
- package/dist/help.d.ts +31 -0
- package/dist/help.js +414 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +13 -0
- package/dist/parser.d.ts +42 -0
- package/dist/parser.js +646 -0
- package/dist/runner.d.ts +60 -0
- package/dist/runner.js +370 -0
- package/dist/types.d.ts +252 -0
- package/dist/types.js +5 -0
- package/dist/validation.d.ts +12 -0
- package/dist/validation.js +352 -0
- package/package.json +57 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument validation - enforces constraints after parsing.
|
|
3
|
+
* Matches clap's validation: required, exclusive, conflicts, requires,
|
|
4
|
+
* valueParser, numArgs, requiredUnlessPresent, requiredIfEq, groups.
|
|
5
|
+
* Includes typo suggestion via Levenshtein distance.
|
|
6
|
+
*/
|
|
7
|
+
import { CliParseError } from './parser.js';
|
|
8
|
+
// ---- Levenshtein Distance ----
|
|
9
|
+
/**
|
|
10
|
+
* Calculate Levenshtein distance between two strings.
|
|
11
|
+
* Used for "did you mean?" typo suggestions (like clap).
|
|
12
|
+
*/
|
|
13
|
+
function levenshteinDistance(a, b) {
|
|
14
|
+
const aLen = a.length;
|
|
15
|
+
const bLen = b.length;
|
|
16
|
+
if (aLen === 0) {
|
|
17
|
+
return bLen;
|
|
18
|
+
}
|
|
19
|
+
if (bLen === 0) {
|
|
20
|
+
return aLen;
|
|
21
|
+
}
|
|
22
|
+
// Use single-row DP for memory efficiency
|
|
23
|
+
const row = Array.from({ length: bLen + 1 });
|
|
24
|
+
for (let j = 0; j <= bLen; j++) {
|
|
25
|
+
row[j] = j;
|
|
26
|
+
}
|
|
27
|
+
for (let i = 1; i <= aLen; i++) {
|
|
28
|
+
let prev = i - 1;
|
|
29
|
+
row[0] = i;
|
|
30
|
+
for (let j = 1; j <= bLen; j++) {
|
|
31
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
32
|
+
const current = row[j];
|
|
33
|
+
const val = Math.min(current + 1, // deletion
|
|
34
|
+
row[j - 1] + 1, // insertion
|
|
35
|
+
prev + cost);
|
|
36
|
+
prev = current;
|
|
37
|
+
row[j] = val;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return row[bLen];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Find the closest match for a string from candidates.
|
|
44
|
+
* Returns undefined if no candidate is within maxDistance.
|
|
45
|
+
*/
|
|
46
|
+
function findClosestMatch(target, candidates, maxDistance = 3) {
|
|
47
|
+
let bestMatch;
|
|
48
|
+
let bestDist = maxDistance + 1;
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
const dist = levenshteinDistance(target, candidate);
|
|
51
|
+
if (dist < bestDist) {
|
|
52
|
+
bestDist = dist;
|
|
53
|
+
bestMatch = candidate;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return bestMatch;
|
|
57
|
+
}
|
|
58
|
+
// ---- Build Known Flags ----
|
|
59
|
+
/**
|
|
60
|
+
* Collect all known long flag names for a command (for typo suggestions).
|
|
61
|
+
*/
|
|
62
|
+
function collectKnownFlags(argsDef) {
|
|
63
|
+
const flags = [];
|
|
64
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
65
|
+
const longName = def.long ?? key;
|
|
66
|
+
flags.push(longName);
|
|
67
|
+
if (def.type === 'boolean') {
|
|
68
|
+
flags.push(`no-${longName}`);
|
|
69
|
+
}
|
|
70
|
+
if (def.alias) {
|
|
71
|
+
for (const alias of def.alias) {
|
|
72
|
+
if (alias.length > 1) {
|
|
73
|
+
flags.push(alias);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (def.visibleAlias) {
|
|
78
|
+
for (const alias of def.visibleAlias) {
|
|
79
|
+
if (alias.length > 1) {
|
|
80
|
+
flags.push(alias);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Always include built-in flags
|
|
86
|
+
flags.push('help', 'version');
|
|
87
|
+
return flags;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Collect all known subcommand names (including aliases) for typo suggestions.
|
|
91
|
+
*/
|
|
92
|
+
function collectKnownSubcommands(command) {
|
|
93
|
+
const names = [];
|
|
94
|
+
if (!command.subCommands) {
|
|
95
|
+
return names;
|
|
96
|
+
}
|
|
97
|
+
for (const [name, def] of Object.entries(command.subCommands)) {
|
|
98
|
+
names.push(name);
|
|
99
|
+
if (def.meta.aliases) {
|
|
100
|
+
for (const alias of def.meta.aliases) {
|
|
101
|
+
names.push(alias);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return names;
|
|
106
|
+
}
|
|
107
|
+
/** Check if an arg value is "set" (not undefined, and not false for booleans). */
|
|
108
|
+
function isArgSet(value, typeName) {
|
|
109
|
+
if (value === undefined) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
if (typeName === 'boolean' && value === false) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
// ---- Validation ----
|
|
118
|
+
/** Validate unknown flags and suggest corrections. */
|
|
119
|
+
function validateUnknownFlags(unknown, argsDef, command) {
|
|
120
|
+
if (unknown.length === 0) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const knownFlags = collectKnownFlags(argsDef);
|
|
124
|
+
const knownSubcommands = collectKnownSubcommands(command);
|
|
125
|
+
const allKnown = [...knownFlags, ...knownSubcommands];
|
|
126
|
+
for (const flag of unknown) {
|
|
127
|
+
const stripped = flag.replace(/^-+/, '');
|
|
128
|
+
const suggestion = findClosestMatch(stripped, allKnown);
|
|
129
|
+
let msg = `unexpected argument '${flag}' found`;
|
|
130
|
+
if (suggestion) {
|
|
131
|
+
const prefix = suggestion.length === 1 ? '-' : '--';
|
|
132
|
+
msg += `\n\n tip: a similar argument exists: '${prefix}${suggestion}'`;
|
|
133
|
+
}
|
|
134
|
+
throw new CliParseError(msg);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Validate exclusive args -- cannot be used with any other arg. */
|
|
138
|
+
function validateExclusive(argsDef, args, explicitlySet) {
|
|
139
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
140
|
+
if (!def.exclusive) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!explicitlySet.has(key)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// Check if any other arg was explicitly set
|
|
147
|
+
for (const otherKey of explicitlySet) {
|
|
148
|
+
if (otherKey === key) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const otherDef = argsDef[otherKey];
|
|
152
|
+
if (!otherDef) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const flagName = formatFlagForError(key, def);
|
|
156
|
+
const otherFlagName = formatFlagForError(otherKey, otherDef);
|
|
157
|
+
throw new CliParseError(`the argument ${flagName} cannot be used with ${otherFlagName}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Validate that all required args are present.
|
|
163
|
+
* Respects requiredUnlessPresent and subcommandNegatesReqs.
|
|
164
|
+
*/
|
|
165
|
+
function validateRequired(argsDef, args, skipRequired) {
|
|
166
|
+
if (skipRequired) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const missing = [];
|
|
170
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
171
|
+
if (!def.required) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (def.default !== undefined) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// requiredUnlessPresent: skip if the named arg(s) are present
|
|
178
|
+
if (def.requiredUnlessPresent) {
|
|
179
|
+
const unlessArgs = Array.isArray(def.requiredUnlessPresent)
|
|
180
|
+
? def.requiredUnlessPresent
|
|
181
|
+
: [def.requiredUnlessPresent];
|
|
182
|
+
if (unlessArgs.some((name) => isArgSet(args[name], argsDef[name]?.type))) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const value = args[key];
|
|
187
|
+
if (value === undefined) {
|
|
188
|
+
const displayName = def.type === 'positional' ? `<${def.valueName ?? key}>` : `--${def.long ?? key}`;
|
|
189
|
+
missing.push(displayName);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (missing.length > 0) {
|
|
193
|
+
const lines = missing.map((f) => ` ${f}`).join('\n');
|
|
194
|
+
throw new CliParseError(`the following required arguments were not provided:\n${lines}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Validate requiredIfEq -- arg required when another arg equals a specific value. */
|
|
198
|
+
function validateRequiredIfEq(argsDef, args) {
|
|
199
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
200
|
+
if (!def.requiredIfEq) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const [otherKey, otherValue] = def.requiredIfEq;
|
|
204
|
+
const otherArgValue = args[otherKey];
|
|
205
|
+
// Check if the other arg's value matches the condition
|
|
206
|
+
if (otherArgValue !== undefined && String(otherArgValue) === otherValue) {
|
|
207
|
+
if (!isArgSet(args[key], def.type)) {
|
|
208
|
+
const displayName = def.type === 'positional' ? `<${def.valueName ?? key}>` : `--${def.long ?? key}`;
|
|
209
|
+
throw new CliParseError(`the following required arguments were not provided:\n ${displayName}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Validate valueParser (enum-like restricted values). */
|
|
215
|
+
function validateValueParser(argsDef, args) {
|
|
216
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
217
|
+
if (!def.valueParser) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const value = args[key];
|
|
221
|
+
if (value === undefined) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const longName = def.long ?? key;
|
|
225
|
+
// Function-based value parser: already applied in parser, skip enum check
|
|
226
|
+
if (typeof def.valueParser === 'function') {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// Enum-style value parser: validate against allowed values
|
|
230
|
+
if (def.valueParser.length === 0) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const valueName = def.valueName ?? longName.toUpperCase();
|
|
234
|
+
const values = Array.isArray(value) ? value : [String(value)];
|
|
235
|
+
for (const v of values) {
|
|
236
|
+
if (!def.valueParser.includes(v)) {
|
|
237
|
+
const possibleStr = def.valueParser.join(', ');
|
|
238
|
+
throw new CliParseError(`invalid value '${v}' for '--${longName} <${valueName}>'\n [possible values: ${possibleStr}]`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Format a flag name for error display, including value name for non-booleans. */
|
|
244
|
+
function formatFlagForError(key, def) {
|
|
245
|
+
const longName = def.long ?? key;
|
|
246
|
+
if (def.type !== 'boolean') {
|
|
247
|
+
const valueName = def.valueName ?? longName.toUpperCase();
|
|
248
|
+
return `'--${longName} <${valueName}>'`;
|
|
249
|
+
}
|
|
250
|
+
return `'--${longName}'`;
|
|
251
|
+
}
|
|
252
|
+
/** Validate conflictsWith constraints. */
|
|
253
|
+
function validateConflicts(argsDef, args) {
|
|
254
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
255
|
+
if (!def.conflictsWith || def.conflictsWith.length === 0) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!isArgSet(args[key], def.type)) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
for (const conflictKey of def.conflictsWith) {
|
|
262
|
+
const conflictDef = argsDef[conflictKey];
|
|
263
|
+
if (!isArgSet(args[conflictKey], conflictDef?.type)) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const flagA = formatFlagForError(key, def);
|
|
267
|
+
const flagB = conflictDef
|
|
268
|
+
? formatFlagForError(conflictKey, conflictDef)
|
|
269
|
+
: `'--${conflictKey}'`;
|
|
270
|
+
throw new CliParseError(`the argument ${flagA} cannot be used with ${flagB}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Validate requires constraints. */
|
|
275
|
+
function validateRequires(argsDef, args) {
|
|
276
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
277
|
+
if (!def.requires || def.requires.length === 0) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (!isArgSet(args[key], def.type)) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const missing = [];
|
|
284
|
+
for (const requiredKey of def.requires) {
|
|
285
|
+
const requiredDef = argsDef[requiredKey];
|
|
286
|
+
if (!isArgSet(args[requiredKey], requiredDef?.type)) {
|
|
287
|
+
missing.push(`--${requiredDef?.long ?? requiredKey}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (missing.length > 0) {
|
|
291
|
+
const lines = missing.map((f) => ` ${f}`).join('\n');
|
|
292
|
+
throw new CliParseError(`the following required arguments were not provided:\n${lines}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Validate numArgs constraints. */
|
|
297
|
+
function validateNumArgs(argsDef, args) {
|
|
298
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
299
|
+
if (!def.numArgs) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const value = args[key];
|
|
303
|
+
if (value === undefined) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const count = Array.isArray(value) ? value.length : 1;
|
|
307
|
+
if (count < def.numArgs.min) {
|
|
308
|
+
throw new CliParseError(`the argument '--${def.long ?? key}' requires at least ${String(def.numArgs.min)} values but ${String(count)} were provided`);
|
|
309
|
+
}
|
|
310
|
+
if (count > def.numArgs.max) {
|
|
311
|
+
throw new CliParseError(`the argument '--${def.long ?? key}' accepts at most ${String(def.numArgs.max)} values but ${String(count)} were provided`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** Validate argument groups. */
|
|
316
|
+
function validateGroups(command, argsDef, args) {
|
|
317
|
+
if (!command.groups) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
for (const group of command.groups) {
|
|
321
|
+
const setArgs = group.args.filter((argName) => isArgSet(args[argName], argsDef[argName]?.type));
|
|
322
|
+
if (group.required && setArgs.length === 0) {
|
|
323
|
+
const argList = group.args.map((a) => `'--${argsDef[a]?.long ?? a}'`).join(', ');
|
|
324
|
+
throw new CliParseError(`one of the following arguments must be provided: ${argList}`);
|
|
325
|
+
}
|
|
326
|
+
if (!group.multiple && setArgs.length > 1) {
|
|
327
|
+
const argList = setArgs.map((a) => `'--${argsDef[a]?.long ?? a}'`).join(', ');
|
|
328
|
+
throw new CliParseError(`the following arguments cannot be used together: ${argList}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Validate parsed results against the command definition.
|
|
334
|
+
* Throws CliParseError with clap-style error messages.
|
|
335
|
+
*/
|
|
336
|
+
export function validate(parseResult, command) {
|
|
337
|
+
const argsDef = command.args ?? {};
|
|
338
|
+
const { args, unknown, explicitlySet } = parseResult;
|
|
339
|
+
// Determine if required validation should be skipped (subcommandNegatesReqs)
|
|
340
|
+
const skipRequired = command.meta.subcommandNegatesReqs === true && parseResult.subCommand !== undefined;
|
|
341
|
+
validateUnknownFlags(unknown, argsDef, command);
|
|
342
|
+
validateExclusive(argsDef, args, explicitlySet);
|
|
343
|
+
validateRequired(argsDef, args, skipRequired);
|
|
344
|
+
if (!skipRequired) {
|
|
345
|
+
validateRequiredIfEq(argsDef, args);
|
|
346
|
+
}
|
|
347
|
+
validateValueParser(argsDef, args);
|
|
348
|
+
validateConflicts(argsDef, args);
|
|
349
|
+
validateRequires(argsDef, args);
|
|
350
|
+
validateNumArgs(argsDef, args);
|
|
351
|
+
validateGroups(command, argsDef, args);
|
|
352
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clap-ts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A type-safe CLI argument parser for TypeScript, inspired by Rust's clap crate. Full clap-style parsing, validation, help generation, and subcommand support with zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"test": "bun test",
|
|
22
|
+
"bench": "bun run bench/parse.bench.ts",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"cli",
|
|
28
|
+
"clap",
|
|
29
|
+
"argument-parser",
|
|
30
|
+
"command-line",
|
|
31
|
+
"typescript",
|
|
32
|
+
"type-safe",
|
|
33
|
+
"subcommands",
|
|
34
|
+
"arg-parser",
|
|
35
|
+
"flag-parser",
|
|
36
|
+
"bun",
|
|
37
|
+
"node"
|
|
38
|
+
],
|
|
39
|
+
"author": "Salama Ashoush <salamaashoush@gmail.com>",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/salamaashoush/clap-ts.git"
|
|
44
|
+
},
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/salamaashoush/clap-ts/issues"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/salamaashoush/clap-ts#readme",
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20.0.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/bun": "latest",
|
|
54
|
+
"mitata": "^1.0.34",
|
|
55
|
+
"typescript": "^6.0.2"
|
|
56
|
+
}
|
|
57
|
+
}
|