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
package/src/man.ts ADDED
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Man page generation, matching the roff clap_mangen produces.
3
+ *
4
+ * A page carries the sections `man` expects in order: NAME, SYNOPSIS,
5
+ * DESCRIPTION, OPTIONS, SUBCOMMANDS, then the extra text, VERSION and AUTHORS.
6
+ * Subcommands get their own pages, named `parent-child.1` the way clap does.
7
+ */
8
+
9
+ import type { ArgDef, CommandDef, ManOptions } from './types.js';
10
+ import { hasSubCommands, possibleValues, subCommandsOf } from './parser.js';
11
+
12
+ export type { ManOptions } from './types.js';
13
+
14
+ // ---- Escaping ----
15
+
16
+ /**
17
+ * Escape text for roff. A leading dot would start a request, a backslash starts
18
+ * an escape, a hyphen renders as a soft hyphen unless escaped, and an
19
+ * apostrophe goes through the \*(Aq string defined in the preamble.
20
+ */
21
+ function esc(text: string): string {
22
+ return text
23
+ .replaceAll('\\', '\\e')
24
+ .replaceAll("'", '\\*(Aq')
25
+ .replaceAll('-', '\\-')
26
+ .replaceAll(/^\./gm, '\\&.');
27
+ }
28
+
29
+ function bold(text: string): string {
30
+ return `\\fB${esc(text)}\\fR`;
31
+ }
32
+
33
+ function italic(text: string): string {
34
+ return `\\fI${esc(text)}\\fR`;
35
+ }
36
+
37
+ // ---- Arg helpers ----
38
+
39
+ function valuePlaceholder(key: string, def: ArgDef): string {
40
+ if (def.valueNames && def.valueNames.length > 0) {
41
+ return def.valueNames.map((n) => italic(n)).join(' ');
42
+ }
43
+ return italic(def.valueName ?? (def.long ?? key).toUpperCase());
44
+ }
45
+
46
+ /** The bracket pair around an option in the synopsis: required or optional. */
47
+ function markers(def: ArgDef): [string, string] {
48
+ return def.required ? ['', ''] : ['[', ']'];
49
+ }
50
+
51
+ /** `\fB-c\fR|\fB--config\fR` for one option, with its value if it takes one. */
52
+ function optionForms(key: string, def: ArgDef): string {
53
+ const forms: string[] = [];
54
+ if (def.short) {
55
+ forms.push(bold(`-${def.short}`));
56
+ }
57
+ forms.push(bold(`--${def.long ?? key}`));
58
+ let rendered = forms.join('|');
59
+ if (def.type !== 'boolean' && def.action !== 'count') {
60
+ rendered += `=${valuePlaceholder(key, def)}`;
61
+ }
62
+ return rendered;
63
+ }
64
+
65
+ function isVisible(def: ArgDef): boolean {
66
+ return !def.hidden && !def.hideLongHelp;
67
+ }
68
+
69
+ // ---- Sections ----
70
+
71
+ function renderSynopsis(name: string, command: CommandDef): string {
72
+ const argsDef: Record<string, ArgDef> = command.args ?? {};
73
+ const parts: string[] = [bold(name)];
74
+
75
+ for (const [key, def] of Object.entries(argsDef)) {
76
+ if (def.type === 'positional' || !isVisible(def)) {
77
+ continue;
78
+ }
79
+ const [open, close] = markers(def);
80
+ const repeat = def.action === 'append' || def.action === 'count' ? '...' : '';
81
+ parts.push(`${open}${optionForms(key, def)}${close}${repeat}`);
82
+ }
83
+
84
+ if (command.meta.disableHelpFlag !== true) {
85
+ parts.push(`[${bold('-h')}|${bold('--help')}]`);
86
+ }
87
+ if (command.meta.version && command.meta.disableVersionFlag !== true) {
88
+ parts.push(`[${bold('-V')}|${bold('--version')}]`);
89
+ }
90
+
91
+ for (const [key, def] of Object.entries(argsDef)) {
92
+ if (def.type !== 'positional' || !isVisible(def)) {
93
+ continue;
94
+ }
95
+ const name_ = italic(def.valueName ?? key);
96
+ const repeat = def.trailingVarArg ? '...' : '';
97
+ parts.push(def.required ? `${name_}${repeat}` : `[${name_}]${repeat}`);
98
+ }
99
+
100
+ if (hasSubCommands(command)) {
101
+ parts.push(`[${italic('subcommands')}]`);
102
+ }
103
+
104
+ return parts.join(' ');
105
+ }
106
+
107
+ /** A `.TP` entry: the term, its description, then any trailing notes. */
108
+ function renderEntry(term: string, def: ArgDef, key: string, lines: string[]): void {
109
+ lines.push('.TP');
110
+ lines.push(term);
111
+
112
+ const description = def.longDescription ?? def.description ?? '';
113
+ lines.push(description ? esc(description) : '');
114
+
115
+ const notes: string[] = [];
116
+ if (def.default !== undefined && !def.hideDefaultValue) {
117
+ const shown = Array.isArray(def.default) ? def.default.join(', ') : String(def.default);
118
+ notes.push(`${italic('Default value:')} ${esc(shown)}`);
119
+ }
120
+ if (def.env && !def.hideEnv) {
121
+ notes.push(`${italic('Environment:')} ${esc(def.env)}`);
122
+ }
123
+ for (const note of notes) {
124
+ lines.push('.br');
125
+ lines.push(note);
126
+ }
127
+
128
+ const values = def.hidePossibleValues ? [] : possibleValues(def).filter((v) => !v.hidden);
129
+ if (values.length > 0) {
130
+ lines.push('.br');
131
+ lines.push(`${italic('Possible values:')}`);
132
+ lines.push('.RS 14');
133
+ for (const value of values) {
134
+ lines.push('.IP \\(bu 2');
135
+ lines.push(value.help ? `${esc(value.name)}: ${esc(value.help)}` : esc(value.name));
136
+ }
137
+ lines.push('.RE');
138
+ }
139
+
140
+ void key;
141
+ }
142
+
143
+ function renderOptions(command: CommandDef, lines: string[]): boolean {
144
+ const argsDef: Record<string, ArgDef> = command.args ?? {};
145
+ const options = Object.entries(argsDef).filter(
146
+ ([, def]) => def.type !== 'positional' && isVisible(def),
147
+ );
148
+ const positionals = Object.entries(argsDef).filter(
149
+ ([, def]) => def.type === 'positional' && isVisible(def),
150
+ );
151
+
152
+ const hasHelp = command.meta.disableHelpFlag !== true;
153
+ const hasVersion = command.meta.version !== undefined && command.meta.disableVersionFlag !== true;
154
+
155
+ if (options.length === 0 && positionals.length === 0 && !hasHelp && !hasVersion) {
156
+ return false;
157
+ }
158
+
159
+ lines.push('.SH OPTIONS');
160
+ for (const [key, def] of options) {
161
+ const forms: string[] = [];
162
+ if (def.short) {
163
+ forms.push(bold(`-${def.short}`));
164
+ }
165
+ forms.push(bold(`--${def.long ?? key}`));
166
+ let term = forms.join(', ');
167
+ if (def.type !== 'boolean' && def.action !== 'count') {
168
+ term += `=${valuePlaceholder(key, def)}`;
169
+ }
170
+ renderEntry(term, def, key, lines);
171
+ }
172
+
173
+ if (hasHelp) {
174
+ lines.push('.TP', `${bold('-h')}, ${bold('--help')}`, 'Print help');
175
+ }
176
+ if (hasVersion) {
177
+ lines.push('.TP', `${bold('-V')}, ${bold('--version')}`, 'Print version');
178
+ }
179
+
180
+ for (const [key, def] of positionals) {
181
+ const name = italic(def.valueName ?? key);
182
+ renderEntry(def.required ? name : `[${name}]`, def, key, lines);
183
+ }
184
+
185
+ return true;
186
+ }
187
+
188
+ function renderSubcommands(name: string, command: CommandDef, lines: string[]): void {
189
+ const subs = Object.entries(subCommandsOf(command)).filter(([, def]) => !def.meta.hidden);
190
+ if (subs.length === 0) {
191
+ return;
192
+ }
193
+
194
+ lines.push('.SH SUBCOMMANDS');
195
+ for (const [subName, def] of subs) {
196
+ lines.push('.TP');
197
+ lines.push(esc(`${name}-${subName}(1)`));
198
+ lines.push(esc(def.meta.description ?? def.meta.about ?? ''));
199
+ }
200
+ }
201
+
202
+ // ---- Public API ----
203
+
204
+ /**
205
+ * Render a man page for one command as roff source.
206
+ *
207
+ * ```ts
208
+ * writeFileSync('my-tool.1', renderManPage(command));
209
+ * ```
210
+ */
211
+ export function renderManPage(command: CommandDef, opts?: ManOptions): string {
212
+ const { meta } = command;
213
+ const name = opts?.name ?? meta.binName ?? meta.displayName ?? meta.name;
214
+ const section = opts?.section ?? '1';
215
+ const manual = opts?.manual ?? '';
216
+ const title = meta.version ? `${name} ${meta.version}` : name;
217
+
218
+ const lines: string[] = [
219
+ '.ie \\n(.g .ds Aq \\(aq',
220
+ ".el .ds Aq '",
221
+ `.TH ${esc(name)} ${esc(section)} "${esc(manual)}" "${esc(title)}"`,
222
+ ];
223
+
224
+ lines.push('.SH NAME');
225
+ const summary = meta.description ?? meta.about ?? '';
226
+ lines.push(summary ? `${esc(name)} \\- ${esc(summary)}` : esc(name));
227
+
228
+ lines.push('.SH SYNOPSIS');
229
+ lines.push(renderSynopsis(name, command));
230
+
231
+ lines.push('.SH DESCRIPTION');
232
+ const description = meta.longAbout ?? meta.about ?? meta.description ?? '';
233
+ if (description) {
234
+ lines.push(esc(description));
235
+ }
236
+
237
+ renderOptions(command, lines);
238
+ renderSubcommands(name, command, lines);
239
+
240
+ const extra = meta.afterLongHelp ?? meta.afterHelp;
241
+ if (extra) {
242
+ lines.push('.SH EXTRA');
243
+ lines.push(esc(extra));
244
+ }
245
+
246
+ if (meta.version) {
247
+ lines.push('.SH VERSION');
248
+ lines.push(`v${esc(meta.longVersion ?? meta.version)}`);
249
+ }
250
+
251
+ if (meta.author) {
252
+ lines.push('.SH AUTHORS');
253
+ lines.push(esc(meta.author));
254
+ }
255
+
256
+ return `${lines.join('\n')}\n`;
257
+ }
258
+
259
+ /**
260
+ * Render a man page for the command and every subcommand beneath it, keyed by
261
+ * file name. Nested pages are named `parent-child.1`, as clap_mangen does.
262
+ *
263
+ * ```ts
264
+ * for (const [file, roff] of generateManPages(command)) {
265
+ * writeFileSync(path.join(outDir, file), roff);
266
+ * }
267
+ * ```
268
+ */
269
+ export function generateManPages(
270
+ command: CommandDef,
271
+ opts?: ManOptions,
272
+ ): Map<string, string> {
273
+ const pages = new Map<string, string>();
274
+ const section = opts?.section ?? '1';
275
+
276
+ const walk = (node: CommandDef, name: string): void => {
277
+ pages.set(`${name}.${section}`, renderManPage(node, { ...opts, name }));
278
+ for (const [subName, sub] of Object.entries(subCommandsOf(node))) {
279
+ if (sub.meta.hidden) {
280
+ continue;
281
+ }
282
+ walk(sub, `${name}-${subName}`);
283
+ }
284
+ };
285
+
286
+ const rootName = opts?.name ?? command.meta.binName ?? command.meta.name;
287
+ walk(command, rootName);
288
+ return pages;
289
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Markdown documentation, shaped like the clap-markdown crate's output: one
3
+ * heading per command, its usage line, then Commands, Arguments and Options
4
+ * lists, with nested commands appended as deeper headings.
5
+ *
6
+ * Suitable for committing next to a README or feeding a docs site.
7
+ */
8
+
9
+ import type { ArgDef, CommandDef, MarkdownOptions } from './types.js';
10
+ import { hasSubCommands, possibleValues, subCommandsOf } from './parser.js';
11
+
12
+ export type { MarkdownOptions } from './types.js';
13
+
14
+ /** Escape the markdown that could break a list item or table cell. */
15
+ function esc(text: string): string {
16
+ return text.replaceAll(/([\\`*_[\]<>|])/g, '\\$1');
17
+ }
18
+
19
+ function isVisible(def: ArgDef): boolean {
20
+ return !def.hidden && !def.hideLongHelp;
21
+ }
22
+
23
+ function valuePlaceholder(key: string, def: ArgDef): string {
24
+ if (def.valueNames && def.valueNames.length > 0) {
25
+ return def.valueNames.map((n) => `<${n}>`).join(' ');
26
+ }
27
+ return `<${def.valueName ?? (def.long ?? key).toUpperCase()}>`;
28
+ }
29
+
30
+ function usageLine(path: readonly string[], command: CommandDef): string {
31
+ const argsDef: Record<string, ArgDef> = command.args ?? {};
32
+ const parts = [...path];
33
+
34
+ if (Object.values(argsDef).some((def) => def.type !== 'positional' && isVisible(def))) {
35
+ parts.push('[OPTIONS]');
36
+ }
37
+ for (const [key, def] of Object.entries(argsDef)) {
38
+ if (def.type !== 'positional' || !isVisible(def)) {
39
+ continue;
40
+ }
41
+ const name = `<${def.valueName ?? key.toUpperCase()}>`;
42
+ parts.push(def.required ? name : `[${name}]`);
43
+ }
44
+ if (hasSubCommands(command)) {
45
+ parts.push(`[${command.meta.subcommandValueName ?? 'COMMAND'}]`);
46
+ }
47
+
48
+ return parts.join(' ');
49
+ }
50
+
51
+ /** Trailing notes for one argument: default, env, possible values, required. */
52
+ function notes(def: ArgDef): string[] {
53
+ const out: string[] = [];
54
+
55
+ const values = def.hidePossibleValues ? [] : possibleValues(def).filter((v) => !v.hidden);
56
+ if (values.length > 0) {
57
+ if (values.some((v) => v.help)) {
58
+ out.push('Possible values:');
59
+ for (const value of values) {
60
+ out.push(` - \`${value.name}\`${value.help ? `: ${esc(value.help)}` : ''}`);
61
+ }
62
+ } else {
63
+ out.push(`Possible values: ${values.map((v) => `\`${v.name}\``).join(', ')}`);
64
+ }
65
+ }
66
+ if (def.default !== undefined && !def.hideDefaultValue) {
67
+ const shown = Array.isArray(def.default) ? def.default.join(', ') : String(def.default);
68
+ out.push(`Default value: \`${shown}\``);
69
+ }
70
+ if (def.env && !def.hideEnv) {
71
+ out.push(`Environment: \`${def.env}\``);
72
+ }
73
+ if (def.required) {
74
+ out.push('Required.');
75
+ }
76
+ return out;
77
+ }
78
+
79
+ function renderArgList(
80
+ entries: (readonly [string, ArgDef])[],
81
+ label: (key: string, def: ArgDef) => string,
82
+ lines: string[],
83
+ ): void {
84
+ for (const [key, def] of entries) {
85
+ const description = def.longDescription ?? def.description ?? '';
86
+ lines.push(`* ${label(key, def)}${description ? ` - ${esc(description)}` : ''}`);
87
+ for (const note of notes(def)) {
88
+ lines.push(` ${note}`);
89
+ }
90
+ }
91
+ lines.push('');
92
+ }
93
+
94
+ function renderCommand(
95
+ command: CommandDef,
96
+ path: readonly string[],
97
+ depth: number,
98
+ lines: string[],
99
+ ): void {
100
+ const { meta } = command;
101
+ const argsDef: Record<string, ArgDef> = command.args ?? {};
102
+ const heading = '#'.repeat(Math.min(depth, 6));
103
+
104
+ lines.push(`${heading} \`${path.join(' ')}\``);
105
+ lines.push('');
106
+
107
+ const about = meta.longAbout ?? meta.about ?? meta.description;
108
+ if (about) {
109
+ lines.push(esc(about));
110
+ lines.push('');
111
+ }
112
+
113
+ lines.push(`**Usage:** \`${usageLine(path, command)}\``);
114
+ lines.push('');
115
+
116
+ const subs = Object.entries(subCommandsOf(command)).filter(([, def]) => !def.meta.hidden);
117
+ if (subs.length > 0) {
118
+ lines.push(`${heading}# Commands`);
119
+ lines.push('');
120
+ for (const [name, def] of subs) {
121
+ const aliases = def.meta.aliases ?? [];
122
+ const alias = aliases.length > 0 ? ` (${aliases.map((a) => `\`${a}\``).join(', ')})` : '';
123
+ const description = def.meta.description ?? def.meta.about ?? '';
124
+ lines.push(`* \`${name}\`${alias}${description ? ` - ${esc(description)}` : ''}`);
125
+ }
126
+ lines.push('');
127
+ }
128
+
129
+ const positionals = Object.entries(argsDef).filter(
130
+ ([, def]) => def.type === 'positional' && isVisible(def),
131
+ );
132
+ if (positionals.length > 0) {
133
+ lines.push(`${heading}# Arguments`);
134
+ lines.push('');
135
+ renderArgList(positionals, (key, def) => `\`<${def.valueName ?? key.toUpperCase()}>\``, lines);
136
+ }
137
+
138
+ const options = Object.entries(argsDef).filter(
139
+ ([, def]) => def.type !== 'positional' && isVisible(def),
140
+ );
141
+ const hasHelp = meta.disableHelpFlag !== true;
142
+ const hasVersion = meta.version !== undefined && meta.disableVersionFlag !== true;
143
+
144
+ if (options.length > 0 || hasHelp || hasVersion) {
145
+ lines.push(`${heading}# Options`);
146
+ lines.push('');
147
+ renderArgList(
148
+ options,
149
+ (key, def) => {
150
+ // The long form and its placeholder share one code span, as
151
+ // clap-markdown renders them: `--config <CONFIG>`.
152
+ const value =
153
+ def.type !== 'boolean' && def.action !== 'count'
154
+ ? ` ${valuePlaceholder(key, def)}`
155
+ : '';
156
+ const long = `\`--${def.long ?? key}${value}\``;
157
+ return def.short ? `\`-${def.short}\`, ${long}` : long;
158
+ },
159
+ lines,
160
+ );
161
+ const builtins: string[] = [];
162
+ if (hasHelp) {
163
+ builtins.push('* `-h`, `--help` - Print help');
164
+ }
165
+ if (hasVersion) {
166
+ builtins.push('* `-V`, `--version` - Print version');
167
+ }
168
+ if (builtins.length > 0) {
169
+ // The list above already ended with a blank line, so reopen it.
170
+ lines.splice(lines.length - 1, 0, ...builtins);
171
+ }
172
+ }
173
+
174
+ const after = meta.afterLongHelp ?? meta.afterHelp;
175
+ if (after) {
176
+ lines.push(esc(after));
177
+ lines.push('');
178
+ }
179
+
180
+ for (const [name, sub] of subs) {
181
+ renderCommand(sub, [...path, name], depth + 1, lines);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Render the whole command tree as one markdown document.
187
+ *
188
+ * ```ts
189
+ * writeFileSync('docs/cli.md', renderMarkdownHelp(command));
190
+ * ```
191
+ */
192
+ export function renderMarkdownHelp(command: CommandDef, opts?: MarkdownOptions): string {
193
+ const name = opts?.name ?? command.meta.binName ?? command.meta.name;
194
+ const lines: string[] = [];
195
+
196
+ if (opts?.title !== undefined) {
197
+ lines.push(`# ${opts.title}`);
198
+ lines.push('');
199
+ }
200
+
201
+ renderCommand(command, [name], opts?.title === undefined ? 1 : 2, lines);
202
+
203
+ if (opts?.footer !== undefined) {
204
+ lines.push(opts.footer);
205
+ lines.push('');
206
+ }
207
+
208
+ // Collapse the runs of blank lines the section joins leave behind.
209
+ return `${lines.join('\n').replaceAll(/\n{3,}/g, '\n\n').trimEnd()}\n`;
210
+ }