clap-ts 0.2.0 → 0.3.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.
@@ -4,7 +4,59 @@
4
4
  * valueParser, numArgs, requiredUnlessPresent, requiredIfEq, groups.
5
5
  * Includes typo suggestion via Levenshtein distance.
6
6
  */
7
- import { CliParseError } from './parser.js';
7
+ import { CliParseError, matchesPossibleValue, possibleValues, subCommandsOf, } from './parser.js';
8
+ const specCache = new WeakMap();
9
+ function buildValidationSpec(command) {
10
+ const entries = [];
11
+ const exclusive = [];
12
+ const required = [];
13
+ const requiredIfEq = [];
14
+ const valueParser = [];
15
+ const conflicts = [];
16
+ const requires = [];
17
+ const numArgs = [];
18
+ const argsDef = command.args ?? {};
19
+ for (const key of Object.keys(argsDef)) {
20
+ const def = argsDef[key];
21
+ const entry = [key, def];
22
+ entries.push(entry);
23
+ if (def.exclusive) {
24
+ exclusive.push(entry);
25
+ }
26
+ if (def.required && def.default === undefined) {
27
+ required.push(entry);
28
+ }
29
+ if (def.requiredIfEq || def.requiredIfEqAny || def.requiredIfEqAll) {
30
+ requiredIfEq.push(entry);
31
+ }
32
+ if (def.valueParser) {
33
+ valueParser.push(entry);
34
+ }
35
+ if (def.conflictsWith && def.conflictsWith.length > 0) {
36
+ conflicts.push(entry);
37
+ }
38
+ if ((def.requires && def.requires.length > 0) ||
39
+ def.requiresIf !== undefined ||
40
+ def.requiresIfs !== undefined) {
41
+ requires.push(entry);
42
+ }
43
+ // The parser enforces numArgs per occurrence while consuming tokens, the
44
+ // way clap does. Only positionals and delimiter-split values reach here
45
+ // without having been counted.
46
+ if (def.numArgs && (def.type === 'positional' || def.valueDelimiter !== undefined)) {
47
+ numArgs.push(entry);
48
+ }
49
+ }
50
+ return { entries, exclusive, required, requiredIfEq, valueParser, conflicts, requires, numArgs };
51
+ }
52
+ function getValidationSpec(command) {
53
+ let spec = specCache.get(command);
54
+ if (spec === undefined) {
55
+ spec = buildValidationSpec(command);
56
+ specCache.set(command, spec);
57
+ }
58
+ return spec;
59
+ }
8
60
  // ---- Levenshtein Distance ----
9
61
  /**
10
62
  * Calculate Levenshtein distance between two strings.
@@ -91,10 +143,7 @@ function collectKnownFlags(argsDef) {
91
143
  */
92
144
  function collectKnownSubcommands(command) {
93
145
  const names = [];
94
- if (!command.subCommands) {
95
- return names;
96
- }
97
- for (const [name, def] of Object.entries(command.subCommands)) {
146
+ for (const [name, def] of Object.entries(subCommandsOf(command))) {
98
147
  names.push(name);
99
148
  if (def.meta.aliases) {
100
149
  for (const alias of def.meta.aliases) {
@@ -135,11 +184,8 @@ function validateUnknownFlags(unknown, argsDef, command) {
135
184
  }
136
185
  }
137
186
  /** 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
- }
187
+ function validateExclusive(spec, argsDef, explicitlySet) {
188
+ for (const [key, def] of spec.exclusive) {
143
189
  if (!explicitlySet.has(key)) {
144
190
  continue;
145
191
  }
@@ -162,19 +208,13 @@ function validateExclusive(argsDef, args, explicitlySet) {
162
208
  * Validate that all required args are present.
163
209
  * Respects requiredUnlessPresent and subcommandNegatesReqs.
164
210
  */
165
- function validateRequired(argsDef, args, skipRequired) {
211
+ function validateRequired(spec, argsDef, args, skipRequired) {
166
212
  if (skipRequired) {
167
213
  return;
168
214
  }
169
215
  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
216
+ for (const [key, def] of spec.required) {
217
+ // requiredUnlessPresent: skip if any of the named args is present
178
218
  if (def.requiredUnlessPresent) {
179
219
  const unlessArgs = Array.isArray(def.requiredUnlessPresent)
180
220
  ? def.requiredUnlessPresent
@@ -183,6 +223,12 @@ function validateRequired(argsDef, args, skipRequired) {
183
223
  continue;
184
224
  }
185
225
  }
226
+ // requiredUnlessPresentAll: skip only when every named arg is present
227
+ if (def.requiredUnlessPresentAll &&
228
+ def.requiredUnlessPresentAll.length > 0 &&
229
+ def.requiredUnlessPresentAll.every((name) => isArgSet(args[name], argsDef[name]?.type))) {
230
+ continue;
231
+ }
186
232
  const value = args[key];
187
233
  if (value === undefined) {
188
234
  const displayName = def.type === 'positional' ? `<${def.valueName ?? key}>` : `--${def.long ?? key}`;
@@ -195,28 +241,28 @@ function validateRequired(argsDef, args, skipRequired) {
195
241
  }
196
242
  }
197
243
  /** 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
- }
244
+ function validateRequiredIfEq(spec, args) {
245
+ const holds = (args, [otherKey, otherValue]) => {
246
+ const actual = args[otherKey];
247
+ return actual !== undefined && String(actual) === otherValue;
248
+ };
249
+ for (const [key, def] of spec.requiredIfEq) {
250
+ let isRequired = def.requiredIfEq !== undefined && holds(args, def.requiredIfEq);
251
+ if (!isRequired && def.requiredIfEqAny) {
252
+ isRequired = def.requiredIfEqAny.some((condition) => holds(args, condition));
253
+ }
254
+ if (!isRequired && def.requiredIfEqAll && def.requiredIfEqAll.length > 0) {
255
+ isRequired = def.requiredIfEqAll.every((condition) => holds(args, condition));
256
+ }
257
+ if (isRequired && !isArgSet(args[key], def.type)) {
258
+ const displayName = def.type === 'positional' ? `<${def.valueName ?? key}>` : `--${def.long ?? key}`;
259
+ throw new CliParseError(`the following required arguments were not provided:\n ${displayName}`);
211
260
  }
212
261
  }
213
262
  }
214
263
  /** 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
- }
264
+ function validateValueParser(spec, args) {
265
+ for (const [key, def] of spec.valueParser) {
220
266
  const value = args[key];
221
267
  if (value === undefined) {
222
268
  continue;
@@ -227,15 +273,22 @@ function validateValueParser(argsDef, args) {
227
273
  continue;
228
274
  }
229
275
  // Enum-style value parser: validate against allowed values
230
- if (def.valueParser.length === 0) {
276
+ const possible = possibleValues(def);
277
+ if (possible.length === 0) {
231
278
  continue;
232
279
  }
280
+ const ignoreCase = def.ignoreCase === true;
233
281
  const valueName = def.valueName ?? longName.toUpperCase();
282
+ // A positional has no flag to name, so it is shown as just its placeholder.
283
+ const target = def.type === 'positional' ? `<${valueName}>` : `--${longName} <${valueName}>`;
234
284
  const values = Array.isArray(value) ? value : [String(value)];
235
285
  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}]`);
286
+ if (!possible.some((candidate) => matchesPossibleValue(candidate, v, ignoreCase))) {
287
+ const possibleStr = possible
288
+ .filter((candidate) => !candidate.hidden)
289
+ .map((candidate) => candidate.name)
290
+ .join(', ');
291
+ throw new CliParseError(`invalid value '${v}' for '${target}'\n [possible values: ${possibleStr}]`);
239
292
  }
240
293
  }
241
294
  }
@@ -250,11 +303,8 @@ function formatFlagForError(key, def) {
250
303
  return `'--${longName}'`;
251
304
  }
252
305
  /** 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
- }
306
+ function validateConflicts(spec, argsDef, args) {
307
+ for (const [key, def] of spec.conflicts) {
258
308
  if (!isArgSet(args[key], def.type)) {
259
309
  continue;
260
310
  }
@@ -272,16 +322,28 @@ function validateConflicts(argsDef, args) {
272
322
  }
273
323
  }
274
324
  /** 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) {
325
+ function validateRequires(spec, argsDef, args) {
326
+ for (const [key, def] of spec.requires) {
327
+ const own = args[key];
328
+ if (!isArgSet(own, def.type)) {
278
329
  continue;
279
330
  }
280
- if (!isArgSet(args[key], def.type)) {
281
- continue;
331
+ // requires applies whenever the arg is present; requiresIf only when it
332
+ // holds a particular value.
333
+ const needed = [...(def.requires ?? [])];
334
+ const ownValue = String(own);
335
+ if (def.requiresIf && def.requiresIf[0] === ownValue) {
336
+ needed.push(def.requiresIf[1]);
337
+ }
338
+ if (def.requiresIfs) {
339
+ for (const [whenValue, requiredKey] of def.requiresIfs) {
340
+ if (whenValue === ownValue) {
341
+ needed.push(requiredKey);
342
+ }
343
+ }
282
344
  }
283
345
  const missing = [];
284
- for (const requiredKey of def.requires) {
346
+ for (const requiredKey of needed) {
285
347
  const requiredDef = argsDef[requiredKey];
286
348
  if (!isArgSet(args[requiredKey], requiredDef?.type)) {
287
349
  missing.push(`--${requiredDef?.long ?? requiredKey}`);
@@ -294,11 +356,8 @@ function validateRequires(argsDef, args) {
294
356
  }
295
357
  }
296
358
  /** Validate numArgs constraints. */
297
- function validateNumArgs(argsDef, args) {
298
- for (const [key, def] of Object.entries(argsDef)) {
299
- if (!def.numArgs) {
300
- continue;
301
- }
359
+ function validateNumArgs(spec, args) {
360
+ for (const [key, def] of spec.numArgs) {
302
361
  const value = args[key];
303
362
  if (value === undefined) {
304
363
  continue;
@@ -312,21 +371,81 @@ function validateNumArgs(argsDef, args) {
312
371
  }
313
372
  }
314
373
  }
374
+ /**
375
+ * Collect the effective groups: those declared on the command, widened by any
376
+ * arg that names the group itself via `groups`.
377
+ */
378
+ function collectGroups(command, argsDef) {
379
+ const declared = command.groups ?? [];
380
+ const byName = new Map();
381
+ for (const key of Object.keys(argsDef)) {
382
+ const def = argsDef[key];
383
+ if (def.group === undefined && def.groups === undefined) {
384
+ continue;
385
+ }
386
+ const memberships = def.group === undefined ? def.groups : [def.group, ...(def.groups ?? [])];
387
+ for (const name of memberships) {
388
+ const members = byName.get(name);
389
+ if (members === undefined) {
390
+ byName.set(name, [key]);
391
+ }
392
+ else {
393
+ members.push(key);
394
+ }
395
+ }
396
+ }
397
+ if (byName.size === 0) {
398
+ return declared;
399
+ }
400
+ const merged = [];
401
+ for (const group of declared) {
402
+ const extra = byName.get(group.name);
403
+ if (extra === undefined) {
404
+ merged.push(group);
405
+ continue;
406
+ }
407
+ byName.delete(group.name);
408
+ merged.push({ ...group, args: [...group.args, ...extra.filter((a) => !group.args.includes(a))] });
409
+ }
410
+ for (const [name, members] of byName) {
411
+ merged.push({ name, args: members });
412
+ }
413
+ return merged;
414
+ }
315
415
  /** Validate argument groups. */
316
416
  function validateGroups(command, argsDef, args) {
317
- if (!command.groups) {
417
+ const groups = collectGroups(command, argsDef);
418
+ if (groups.length === 0) {
318
419
  return;
319
420
  }
320
- for (const group of command.groups) {
421
+ const flagName = (name) => `'--${argsDef[name]?.long ?? name}'`;
422
+ for (const group of groups) {
321
423
  const setArgs = group.args.filter((argName) => isArgSet(args[argName], argsDef[argName]?.type));
322
424
  if (group.required && setArgs.length === 0) {
323
- const argList = group.args.map((a) => `'--${argsDef[a]?.long ?? a}'`).join(', ');
425
+ const argList = group.args.map(flagName).join(', ');
324
426
  throw new CliParseError(`one of the following arguments must be provided: ${argList}`);
325
427
  }
326
428
  if (!group.multiple && setArgs.length > 1) {
327
- const argList = setArgs.map((a) => `'--${argsDef[a]?.long ?? a}'`).join(', ');
429
+ const argList = setArgs.map(flagName).join(', ');
328
430
  throw new CliParseError(`the following arguments cannot be used together: ${argList}`);
329
431
  }
432
+ if (setArgs.length === 0) {
433
+ continue;
434
+ }
435
+ if (group.conflictsWith) {
436
+ for (const other of group.conflictsWith) {
437
+ if (isArgSet(args[other], argsDef[other]?.type)) {
438
+ throw new CliParseError(`the argument ${flagName(setArgs[0])} cannot be used with ${flagName(other)}`);
439
+ }
440
+ }
441
+ }
442
+ if (group.requires) {
443
+ const missing = group.requires.filter((other) => !isArgSet(args[other], argsDef[other]?.type));
444
+ if (missing.length > 0) {
445
+ const lines = missing.map((m) => ` --${argsDef[m]?.long ?? m}`).join('\n');
446
+ throw new CliParseError(`the following required arguments were not provided:\n${lines}`);
447
+ }
448
+ }
330
449
  }
331
450
  }
332
451
  /**
@@ -338,15 +457,18 @@ export function validate(parseResult, command) {
338
457
  const { args, unknown, explicitlySet } = parseResult;
339
458
  // Determine if required validation should be skipped (subcommandNegatesReqs)
340
459
  const skipRequired = command.meta.subcommandNegatesReqs === true && parseResult.subCommand !== undefined;
341
- validateUnknownFlags(unknown, argsDef, command);
342
- validateExclusive(argsDef, args, explicitlySet);
343
- validateRequired(argsDef, args, skipRequired);
460
+ const spec = getValidationSpec(command);
461
+ if (unknown.length > 0) {
462
+ validateUnknownFlags(unknown, argsDef, command);
463
+ }
464
+ validateExclusive(spec, argsDef, explicitlySet);
465
+ validateRequired(spec, argsDef, args, skipRequired);
344
466
  if (!skipRequired) {
345
- validateRequiredIfEq(argsDef, args);
467
+ validateRequiredIfEq(spec, args);
346
468
  }
347
- validateValueParser(argsDef, args);
348
- validateConflicts(argsDef, args);
349
- validateRequires(argsDef, args);
350
- validateNumArgs(argsDef, args);
469
+ validateValueParser(spec, args);
470
+ validateConflicts(spec, argsDef, args);
471
+ validateRequires(spec, argsDef, args);
472
+ validateNumArgs(spec, args);
351
473
  validateGroups(command, argsDef, args);
352
474
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clap-ts",
3
- "version": "0.2.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.",
3
+ "version": "0.3.0",
4
+ "description": "A type-safe CLI argument parser for TypeScript, inspired by Rust's clap crate. Full clap-style parsing, validation, help, subcommands, shell completions, man pages and markdown docs, with zero dependencies.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -9,6 +9,58 @@
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
11
  "import": "./dist/index.js"
12
+ },
13
+ "./completions": {
14
+ "types": "./dist/completions.d.ts",
15
+ "import": "./dist/completions.js"
16
+ },
17
+ "./man": {
18
+ "types": "./dist/man.d.ts",
19
+ "import": "./dist/man.js"
20
+ },
21
+ "./markdown": {
22
+ "types": "./dist/markdown.d.ts",
23
+ "import": "./dist/markdown.js"
24
+ },
25
+ "./testing": {
26
+ "types": "./dist/testing.d.ts",
27
+ "import": "./dist/testing.js"
28
+ },
29
+ "./config": {
30
+ "types": "./dist/config.d.ts",
31
+ "import": "./dist/config.js"
32
+ },
33
+ "./argfile": {
34
+ "types": "./dist/argfile.d.ts",
35
+ "import": "./dist/argfile.js"
36
+ },
37
+ "./spec": {
38
+ "types": "./dist/spec.d.ts",
39
+ "import": "./dist/spec.js"
40
+ },
41
+ "./install": {
42
+ "types": "./dist/install.d.ts",
43
+ "import": "./dist/install.js"
44
+ },
45
+ "./plugins": {
46
+ "types": "./dist/plugins.d.ts",
47
+ "import": "./dist/plugins.js"
48
+ },
49
+ "./output": {
50
+ "types": "./dist/output.d.ts",
51
+ "import": "./dist/output.js"
52
+ },
53
+ "./log": {
54
+ "types": "./dist/log.d.ts",
55
+ "import": "./dist/log.js"
56
+ },
57
+ "./progress": {
58
+ "types": "./dist/progress.d.ts",
59
+ "import": "./dist/progress.js"
60
+ },
61
+ "./prompt": {
62
+ "types": "./dist/prompt.d.ts",
63
+ "import": "./dist/prompt.js"
12
64
  }
13
65
  },
14
66
  "files": [
@@ -20,8 +72,11 @@
20
72
  "build": "tsc",
21
73
  "test": "bun test",
22
74
  "bench": "bun run bench/parse.bench.ts",
23
- "typecheck": "tsc --noEmit",
24
- "prepublishOnly": "npm run build"
75
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
76
+ "prepublishOnly": "npm run build",
77
+ "check:readme": "python3 scripts/check-readme.py",
78
+ "check:package": "node scripts/check-package.mjs",
79
+ "ready": "bun run typecheck && bun test && bun run build && bun run check:readme && bun run check:package"
25
80
  },
26
81
  "keywords": [
27
82
  "cli",
@@ -33,6 +88,8 @@
33
88
  "subcommands",
34
89
  "arg-parser",
35
90
  "flag-parser",
91
+ "shell-completions",
92
+ "man-page",
36
93
  "bun",
37
94
  "node"
38
95
  ],
@@ -50,8 +107,8 @@
50
107
  "node": ">=20.0.0"
51
108
  },
52
109
  "devDependencies": {
53
- "@types/bun": "latest",
110
+ "@types/bun": "^1.4.0",
54
111
  "mitata": "^1.0.34",
55
- "typescript": "^6.0.2"
112
+ "typescript": "^7.0.2"
56
113
  }
57
114
  }