clap-ts 0.3.0 → 0.4.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.
Files changed (43) hide show
  1. package/dist/parser.js +5 -5
  2. package/dist/types.d.ts +14 -1
  3. package/package.json +17 -1
  4. package/src/__tests__/arg-options.test.ts +687 -0
  5. package/src/__tests__/argfile.test.ts +127 -0
  6. package/src/__tests__/clap-parity.test.ts +682 -0
  7. package/src/__tests__/command-options.test.ts +713 -0
  8. package/src/__tests__/completions.test.ts +423 -0
  9. package/src/__tests__/config.test.ts +261 -0
  10. package/src/__tests__/deprecation.test.ts +104 -0
  11. package/src/__tests__/help.test.ts +312 -0
  12. package/src/__tests__/install.test.ts +120 -0
  13. package/src/__tests__/log.test.ts +189 -0
  14. package/src/__tests__/man.test.ts +135 -0
  15. package/src/__tests__/markdown.test.ts +114 -0
  16. package/src/__tests__/output.test.ts +249 -0
  17. package/src/__tests__/parser.test.ts +627 -0
  18. package/src/__tests__/plugins.test.ts +182 -0
  19. package/src/__tests__/progress.test.ts +221 -0
  20. package/src/__tests__/prompt.test.ts +265 -0
  21. package/src/__tests__/runner.test.ts +459 -0
  22. package/src/__tests__/spec.test.ts +107 -0
  23. package/src/__tests__/testing.test.ts +93 -0
  24. package/src/__tests__/validation.test.ts +267 -0
  25. package/src/argfile.ts +188 -0
  26. package/src/completions.ts +865 -0
  27. package/src/config.ts +184 -0
  28. package/src/help.ts +779 -0
  29. package/src/index.ts +58 -0
  30. package/src/install.ts +226 -0
  31. package/src/log.ts +225 -0
  32. package/src/man.ts +289 -0
  33. package/src/markdown.ts +210 -0
  34. package/src/output.ts +453 -0
  35. package/src/parser.ts +1240 -0
  36. package/src/plugins.ts +193 -0
  37. package/src/progress.ts +295 -0
  38. package/src/prompt.ts +388 -0
  39. package/src/runner.ts +769 -0
  40. package/src/spec.ts +197 -0
  41. package/src/testing.ts +159 -0
  42. package/src/types.ts +618 -0
  43. package/src/validation.ts +627 -0
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Validation tests - tests for constraint enforcement after parsing.
3
+ */
4
+
5
+ import { describe, test, expect } from 'bun:test';
6
+ import { parseArgs } from '../parser.js';
7
+ import { validate } from '../validation.js';
8
+ import type { CommandDef, ArgsDef } from '../types.js';
9
+
10
+ /** Helper to create a CommandDef from ArgsDef for testing. */
11
+ function cmd(args: ArgsDef): CommandDef {
12
+ return { meta: { name: 'test' }, args };
13
+ }
14
+
15
+ /** Parse and validate in one step. */
16
+ function parseAndValidate(rawArgs: string[], command: CommandDef): void {
17
+ const result = parseArgs(rawArgs, command);
18
+ validate(result, command);
19
+ }
20
+
21
+ // ---- Required Args Missing ----
22
+
23
+ describe('required args missing', () => {
24
+ test('throws when required arg is not provided', () => {
25
+ const command = cmd({ port: { type: 'number', required: true } });
26
+ expect(() => parseAndValidate([], command)).toThrow('required arguments were not provided');
27
+ });
28
+
29
+ test('required arg with default does not throw', () => {
30
+ const command = cmd({ port: { type: 'number', required: true, default: 3000 } });
31
+ expect(() => parseAndValidate([], command)).not.toThrow();
32
+ });
33
+
34
+ test('required positional throws', () => {
35
+ const command = cmd({
36
+ file: { type: 'positional', valueName: 'FILE', required: true },
37
+ });
38
+ expect(() => parseAndValidate([], command)).toThrow('required arguments were not provided');
39
+ });
40
+
41
+ test('required arg provided does not throw', () => {
42
+ const command = cmd({ port: { type: 'number', required: true } });
43
+ expect(() => parseAndValidate(['--port', '3003'], command)).not.toThrow();
44
+ });
45
+ });
46
+
47
+ // ---- conflictsWith Violation ----
48
+
49
+ describe('conflictsWith violation', () => {
50
+ test('throws when conflicting args are both provided', () => {
51
+ const command = cmd({
52
+ json: { type: 'boolean', conflictsWith: ['csv'] },
53
+ csv: { type: 'boolean' },
54
+ });
55
+ expect(() => parseAndValidate(['--json', '--csv'], command)).toThrow('cannot be used with');
56
+ });
57
+
58
+ test('does not throw when only one is provided', () => {
59
+ const command = cmd({
60
+ json: { type: 'boolean', conflictsWith: ['csv'] },
61
+ csv: { type: 'boolean' },
62
+ });
63
+ expect(() => parseAndValidate(['--json'], command)).not.toThrow();
64
+ });
65
+
66
+ test('does not throw when neither is provided', () => {
67
+ const command = cmd({
68
+ json: { type: 'boolean', conflictsWith: ['csv'] },
69
+ csv: { type: 'boolean' },
70
+ });
71
+ expect(() => parseAndValidate([], command)).not.toThrow();
72
+ });
73
+ });
74
+
75
+ // ---- requires Violation ----
76
+
77
+ describe('requires violation', () => {
78
+ test('throws when required companion is missing', () => {
79
+ const command = cmd({
80
+ 'tls-cert': { type: 'string', long: 'tls-cert', requires: ['tls'] },
81
+ tls: { type: 'boolean' },
82
+ });
83
+ expect(() => parseAndValidate(['--tls-cert', './cert.pem'], command)).toThrow(
84
+ 'required arguments were not provided',
85
+ );
86
+ });
87
+
88
+ test('does not throw when companion is present', () => {
89
+ const command = cmd({
90
+ 'tls-cert': { type: 'string', long: 'tls-cert', requires: ['tls'] },
91
+ tls: { type: 'boolean' },
92
+ });
93
+ expect(() => parseAndValidate(['--tls-cert', './cert.pem', '--tls'], command)).not.toThrow();
94
+ });
95
+
96
+ test('does not throw when the arg itself is not provided', () => {
97
+ const command = cmd({
98
+ 'tls-cert': { type: 'string', long: 'tls-cert', requires: ['tls'] },
99
+ tls: { type: 'boolean' },
100
+ });
101
+ expect(() => parseAndValidate([], command)).not.toThrow();
102
+ });
103
+ });
104
+
105
+ // ---- valueParser Enum Violation ----
106
+
107
+ describe('valueParser enum violation', () => {
108
+ test('throws on invalid enum value', () => {
109
+ const command = cmd({
110
+ env: { type: 'string', valueParser: ['dev', 'staging', 'prod'] },
111
+ });
112
+ expect(() => parseAndValidate(['--env', 'invalid'], command)).toThrow(
113
+ "invalid value 'invalid'",
114
+ );
115
+ });
116
+
117
+ test('error message includes possible values', () => {
118
+ const command = cmd({
119
+ env: { type: 'string', valueParser: ['dev', 'staging', 'prod'] },
120
+ });
121
+ try {
122
+ parseAndValidate(['--env', 'invalid'], command);
123
+ expect.unreachable('should have thrown');
124
+ } catch (error) {
125
+ expect((error as Error).message).toContain('possible values: dev, staging, prod');
126
+ }
127
+ });
128
+
129
+ test('valid enum value passes', () => {
130
+ const command = cmd({
131
+ env: { type: 'string', valueParser: ['dev', 'staging', 'prod'] },
132
+ });
133
+ expect(() => parseAndValidate(['--env', 'dev'], command)).not.toThrow();
134
+ });
135
+ });
136
+
137
+ // ---- Typo Suggestions (Levenshtein) ----
138
+
139
+ describe('typo suggestions', () => {
140
+ test('suggests close match for typo', () => {
141
+ const command = cmd({
142
+ verbose: { type: 'boolean' },
143
+ });
144
+ const result = parseArgs(['--verbos'], command);
145
+ try {
146
+ validate(result, command);
147
+ expect.unreachable('should have thrown');
148
+ } catch (error) {
149
+ const msg = (error as Error).message;
150
+ expect(msg).toContain("unexpected argument '--verbos' found");
151
+ expect(msg).toContain('tip: a similar argument exists');
152
+ expect(msg).toContain('verbose');
153
+ }
154
+ });
155
+
156
+ test('no suggestion when distance is too large', () => {
157
+ const command = cmd({
158
+ verbose: { type: 'boolean' },
159
+ });
160
+ const result = parseArgs(['--xyzzy'], command);
161
+ try {
162
+ validate(result, command);
163
+ expect.unreachable('should have thrown');
164
+ } catch (error) {
165
+ const msg = (error as Error).message;
166
+ expect(msg).toContain("unexpected argument '--xyzzy' found");
167
+ expect(msg).not.toContain('tip');
168
+ }
169
+ });
170
+ });
171
+
172
+ // ---- numArgs Validation ----
173
+
174
+ describe('numArgs validation', () => {
175
+ test('respects min constraint', () => {
176
+ const command = cmd({
177
+ files: {
178
+ type: 'string',
179
+ action: 'append',
180
+ numArgs: { min: 2, max: 10 },
181
+ },
182
+ });
183
+ expect(() => parseAndValidate(['--files', 'a.txt'], command)).toThrow(
184
+ 'requires at least 2 values',
185
+ );
186
+ });
187
+ });
188
+
189
+ describe('exclusive', () => {
190
+ test('exclusive arg cannot be used with any other', () => {
191
+ const command = cmd({
192
+ init: { type: 'boolean', long: 'init', exclusive: true },
193
+ verbose: { type: 'boolean', long: 'verbose' },
194
+ });
195
+ expect(() => parseAndValidate(['--init', '--verbose'], command)).toThrow(
196
+ /cannot be used with/,
197
+ );
198
+ });
199
+
200
+ test('exclusive arg alone is fine', () => {
201
+ const command = cmd({
202
+ init: { type: 'boolean', long: 'init', exclusive: true },
203
+ verbose: { type: 'boolean', long: 'verbose' },
204
+ });
205
+ expect(() => parseAndValidate(['--init'], command)).not.toThrow();
206
+ });
207
+ });
208
+
209
+ describe('requiredUnlessPresent', () => {
210
+ test('not required when alternative is present', () => {
211
+ const command = cmd({
212
+ file: { type: 'string', long: 'file', required: true, requiredUnlessPresent: 'stdin' },
213
+ stdin: { type: 'boolean', long: 'stdin' },
214
+ });
215
+ expect(() => parseAndValidate(['--stdin'], command)).not.toThrow();
216
+ });
217
+
218
+ test('required when alternative is absent', () => {
219
+ const command = cmd({
220
+ file: { type: 'string', long: 'file', required: true, requiredUnlessPresent: 'stdin' },
221
+ stdin: { type: 'boolean', long: 'stdin' },
222
+ });
223
+ expect(() => parseAndValidate([], command)).toThrow('required arguments');
224
+ });
225
+
226
+ test('works with array of alternatives', () => {
227
+ const command = cmd({
228
+ file: {
229
+ type: 'string',
230
+ long: 'file',
231
+ required: true,
232
+ requiredUnlessPresent: ['stdin', 'generate'],
233
+ },
234
+ stdin: { type: 'boolean', long: 'stdin' },
235
+ generate: { type: 'boolean', long: 'generate' },
236
+ });
237
+ expect(() => parseAndValidate(['--generate'], command)).not.toThrow();
238
+ });
239
+ });
240
+
241
+ describe('requiredIfEq', () => {
242
+ test('required when condition met', () => {
243
+ const command = cmd({
244
+ format: { type: 'string', long: 'format' },
245
+ output: { type: 'string', long: 'output', requiredIfEq: ['format', 'file'] },
246
+ });
247
+ expect(() => parseAndValidate(['--format', 'file'], command)).toThrow('required arguments');
248
+ });
249
+
250
+ test('not required when condition not met', () => {
251
+ const command = cmd({
252
+ format: { type: 'string', long: 'format' },
253
+ output: { type: 'string', long: 'output', requiredIfEq: ['format', 'file'] },
254
+ });
255
+ expect(() => parseAndValidate(['--format', 'stdout'], command)).not.toThrow();
256
+ });
257
+
258
+ test('passes when arg is provided', () => {
259
+ const command = cmd({
260
+ format: { type: 'string', long: 'format' },
261
+ output: { type: 'string', long: 'output', requiredIfEq: ['format', 'file'] },
262
+ });
263
+ expect(() =>
264
+ parseAndValidate(['--format', 'file', '--output', '/tmp/out'], command),
265
+ ).not.toThrow();
266
+ });
267
+ });
package/src/argfile.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Response files and stdin, the `@file` convention git, gcc and java use for
3
+ * command lines too long to type or to pass through the shell's ARG_MAX.
4
+ *
5
+ * ```ts
6
+ * import { expandArgFiles, readStdin } from 'clap-ts/argfile';
7
+ *
8
+ * await runMain(main, { argv: expandArgFiles() });
9
+ * ```
10
+ *
11
+ * clap has no equivalent, so the shape here follows gcc: one argument per line,
12
+ * `#` comments and blank lines skipped, quoted runs kept together, and a
13
+ * literal `@` escaped as `@@`.
14
+ */
15
+
16
+ import { readFileSync } from 'node:fs';
17
+ import { getRawArgs } from './parser.js';
18
+
19
+ export interface ArgFileOptions {
20
+ /** Prefix marking a response file (default '@'). */
21
+ readonly prefix?: string;
22
+ /** How deep a response file may reference another (default 5). */
23
+ readonly maxDepth?: number;
24
+ /** Read a file's text. Defaults to reading UTF-8 from disk. */
25
+ readonly read?: (path: string) => string;
26
+ }
27
+
28
+ /**
29
+ * Split response-file text into arguments.
30
+ *
31
+ * Whitespace separates, `#` at the start of a line comments the rest of it out,
32
+ * and single or double quotes group a run containing spaces. A backslash
33
+ * escapes the next character inside or outside quotes.
34
+ */
35
+ export function parseArgFile(text: string): string[] {
36
+ const args: string[] = [];
37
+ let current = '';
38
+ let quote: '"' | "'" | undefined;
39
+ let hasToken = false;
40
+
41
+ for (let i = 0; i < text.length; i++) {
42
+ const ch = text[i]!;
43
+
44
+ if (ch === '\\' && i + 1 < text.length) {
45
+ current += text[i + 1]!;
46
+ hasToken = true;
47
+ i++;
48
+ continue;
49
+ }
50
+
51
+ if (quote !== undefined) {
52
+ if (ch === quote) {
53
+ quote = undefined;
54
+ } else {
55
+ current += ch;
56
+ }
57
+ continue;
58
+ }
59
+
60
+ if (ch === '"' || ch === "'") {
61
+ quote = ch;
62
+ hasToken = true;
63
+ continue;
64
+ }
65
+
66
+ // A comment runs to the end of its line, and only starts a token boundary.
67
+ if (ch === '#' && !hasToken) {
68
+ while (i < text.length && text[i] !== '\n') {
69
+ i++;
70
+ }
71
+ continue;
72
+ }
73
+
74
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
75
+ if (hasToken) {
76
+ args.push(current);
77
+ current = '';
78
+ hasToken = false;
79
+ }
80
+ continue;
81
+ }
82
+
83
+ current += ch;
84
+ hasToken = true;
85
+ }
86
+
87
+ if (quote !== undefined) {
88
+ throw new Error(`unterminated ${quote === '"' ? 'double' : 'single'} quote in response file`);
89
+ }
90
+ if (hasToken) {
91
+ args.push(current);
92
+ }
93
+ return args;
94
+ }
95
+
96
+ /**
97
+ * Replace every `@file` in argv with that file's arguments.
98
+ *
99
+ * Reads `process.argv` when given nothing. A response file may reference
100
+ * another up to `maxDepth`; `@@` is a literal argument starting with `@`, and
101
+ * everything after a bare `--` is left alone.
102
+ */
103
+ export function expandArgFiles(
104
+ argv: readonly string[] = getRawArgs(),
105
+ opts?: ArgFileOptions,
106
+ ): string[] {
107
+ const prefix = opts?.prefix ?? '@';
108
+ const maxDepth = opts?.maxDepth ?? 5;
109
+ const read = opts?.read ?? ((path: string) => readFileSync(path, 'utf8'));
110
+
111
+ const expand = (tokens: readonly string[], depth: number): string[] => {
112
+ const out: string[] = [];
113
+ let escaped = false;
114
+
115
+ for (const token of tokens) {
116
+ if (escaped || !token.startsWith(prefix) || token.length === prefix.length) {
117
+ out.push(token);
118
+ if (token === '--') {
119
+ escaped = true;
120
+ }
121
+ continue;
122
+ }
123
+
124
+ // `@@file` means a literal argument that happens to start with `@`.
125
+ if (token.startsWith(prefix + prefix)) {
126
+ out.push(token.slice(prefix.length));
127
+ continue;
128
+ }
129
+
130
+ if (depth >= maxDepth) {
131
+ throw new Error(`response files nested more than ${String(maxDepth)} deep at '${token}'`);
132
+ }
133
+
134
+ const path = token.slice(prefix.length);
135
+ let text: string;
136
+ try {
137
+ text = read(path);
138
+ } catch (error) {
139
+ const message = error instanceof Error ? error.message : String(error);
140
+ throw new Error(`cannot read response file '${path}': ${message}`);
141
+ }
142
+ out.push(...expand(parseArgFile(text), depth + 1));
143
+ }
144
+
145
+ return out;
146
+ };
147
+
148
+ return expand(argv, 0);
149
+ }
150
+
151
+ /**
152
+ * Read all of stdin as text, for the `-` convention meaning "read from stdin".
153
+ *
154
+ * Resolves to undefined when stdin is a terminal, so an interactive run does
155
+ * not hang waiting for input that is never coming.
156
+ */
157
+ export async function readStdin(): Promise<string | undefined> {
158
+ if (process.stdin.isTTY === true) {
159
+ return undefined;
160
+ }
161
+ const chunks: Buffer[] = [];
162
+ for await (const chunk of process.stdin) {
163
+ chunks.push(chunk as Buffer);
164
+ }
165
+ return Buffer.concat(chunks).toString('utf8');
166
+ }
167
+
168
+ /**
169
+ * Resolve a path argument, reading stdin when it is `-`.
170
+ *
171
+ * ```ts
172
+ * const source = await readPathOrStdin(args.input);
173
+ * ```
174
+ */
175
+ export async function readPathOrStdin(
176
+ path: string,
177
+ opts?: { readonly read?: (path: string) => string },
178
+ ): Promise<string> {
179
+ if (path === '-') {
180
+ const text = await readStdin();
181
+ if (text === undefined) {
182
+ throw new Error('reading from stdin was requested but stdin is a terminal');
183
+ }
184
+ return text;
185
+ }
186
+ const read = opts?.read ?? ((p: string) => readFileSync(p, 'utf8'));
187
+ return read(path);
188
+ }