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/output.ts ADDED
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Structured terminal output: tables, key-value blocks and trees.
3
+ *
4
+ * ```ts
5
+ * import { table, keyValue, tree } from 'clap-ts/output';
6
+ *
7
+ * ctx.stdout.write(table(rows, { columns: [{ key: 'name' }, { key: 'size', align: 'right' }] }));
8
+ * ```
9
+ *
10
+ * Everything returns a string rather than writing, so the same call works
11
+ * against `ctx.stdout`, a file, or an assertion. Widths follow the terminal,
12
+ * and colour is only emitted when the destination can show it.
13
+ */
14
+
15
+ import { styleText } from 'node:util';
16
+
17
+ /**
18
+ * Character width tables.
19
+ *
20
+ * Flat sorted [start, end, ...] pairs searched by bisection: a handful of
21
+ * comparisons per non-ASCII code point, and no allocation. Covers the East
22
+ * Asian Wide and Fullwidth ranges plus emoji, which is what actually breaks
23
+ * column alignment in a terminal.
24
+ */
25
+ const WIDE = Int32Array.from([
26
+ 0x1100, 0x115f, 0x2329, 0x232a, 0x2e80, 0x303e, 0x3041, 0x33ff,
27
+ 0x3400, 0x4dbf, 0x4e00, 0x9fff, 0xa000, 0xa4cf, 0xa960, 0xa97f,
28
+ 0xac00, 0xd7a3, 0xf900, 0xfaff, 0xfe10, 0xfe19, 0xfe30, 0xfe6f,
29
+ 0xff00, 0xff60, 0xffe0, 0xffe6,
30
+ 0x1f004, 0x1f004, 0x1f0cf, 0x1f0cf, 0x1f18e, 0x1f18e, 0x1f191, 0x1f19a,
31
+ 0x1f200, 0x1f320, 0x1f32d, 0x1f335, 0x1f337, 0x1f37c, 0x1f37e, 0x1f393,
32
+ 0x1f3a0, 0x1f3ca, 0x1f3cf, 0x1f3d3, 0x1f3e0, 0x1f3f0, 0x1f3f4, 0x1f3f4,
33
+ 0x1f3f8, 0x1f43e, 0x1f440, 0x1f440, 0x1f442, 0x1f4fc, 0x1f4ff, 0x1f53d,
34
+ 0x1f54b, 0x1f54e, 0x1f550, 0x1f567, 0x1f57a, 0x1f57a, 0x1f595, 0x1f596,
35
+ 0x1f5a4, 0x1f5a4, 0x1f5fb, 0x1f64f, 0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc,
36
+ 0x1f6d0, 0x1f6d2, 0x1f6eb, 0x1f6ec, 0x1f6f4, 0x1f6fc, 0x1f7e0, 0x1f7eb,
37
+ 0x1f90c, 0x1f93a, 0x1f93c, 0x1f945, 0x1f947, 0x1f9ff, 0x1fa70, 0x1faff,
38
+ 0x20000, 0x3fffd,
39
+ ]);
40
+
41
+ /** Marks, joiners and selectors that occupy no column of their own. */
42
+ const ZERO = Int32Array.from([
43
+ 0x0300, 0x036f, 0x0483, 0x0489, 0x0591, 0x05bd, 0x0610, 0x061a,
44
+ 0x064b, 0x065f, 0x0670, 0x0670, 0x06d6, 0x06dc, 0x0730, 0x074a,
45
+ 0x07eb, 0x07f3, 0x0816, 0x0819, 0x081b, 0x0823, 0x0825, 0x0827,
46
+ 0x0829, 0x082d, 0x0859, 0x085b, 0x08e3, 0x0903, 0x093a, 0x093c,
47
+ 0x0941, 0x0948, 0x094d, 0x094d, 0x0951, 0x0957, 0x1ab0, 0x1aff,
48
+ 0x1dc0, 0x1dff, 0x200b, 0x200f, 0x2028, 0x202e, 0x2060, 0x2064,
49
+ 0x20d0, 0x20f0, 0xfe00, 0xfe0f, 0xfe20, 0xfe2f, 0xfeff, 0xfeff,
50
+ 0x1f3fb, 0x1f3ff, 0xe0100, 0xe01ef,
51
+ ]);
52
+
53
+ /** Whether a code point falls inside a flat sorted range table. */
54
+ function inRanges(table: Int32Array, cp: number): boolean {
55
+ let low = 0;
56
+ let high = table.length / 2 - 1;
57
+ while (low <= high) {
58
+ const mid = (low + high) >> 1;
59
+ if (cp < table[mid * 2]!) {
60
+ high = mid - 1;
61
+ } else if (cp > table[mid * 2 + 1]!) {
62
+ low = mid + 1;
63
+ } else {
64
+ return true;
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+
70
+ /** Columns a single code point occupies: 0, 1 or 2. */
71
+ export function codePointWidth(cp: number): number {
72
+ if (cp < 0x7f) {
73
+ // C0 controls take no space; everything else printable takes one.
74
+ return cp < 0x20 ? 0 : 1;
75
+ }
76
+ if (cp < 0xa0) {
77
+ return 0;
78
+ }
79
+ if (inRanges(ZERO, cp)) {
80
+ return 0;
81
+ }
82
+ return inRanges(WIDE, cp) ? 2 : 1;
83
+ }
84
+
85
+ const ESC = 0x1b;
86
+
87
+ /**
88
+ * Visible width of a string: ANSI escapes skipped, wide characters counted as
89
+ * two columns, combining marks as none.
90
+ *
91
+ * A single pass with no allocation. Measuring by `String.length` lines a table
92
+ * up wrongly the moment a cell holds CJK or an emoji, and stripping escapes
93
+ * with a replace allocates a string per cell.
94
+ */
95
+ export function displayWidth(text: string): number {
96
+ let width = 0;
97
+ for (let i = 0; i < text.length; i++) {
98
+ const code = text.charCodeAt(i);
99
+
100
+ if (code === ESC) {
101
+ i = skipEscape(text, i);
102
+ continue;
103
+ }
104
+ if (code < 0x7f) {
105
+ if (code >= 0x20) {
106
+ width++;
107
+ }
108
+ continue;
109
+ }
110
+
111
+ // Combine a surrogate pair into one code point before measuring.
112
+ let cp = code;
113
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
114
+ const low = text.charCodeAt(i + 1);
115
+ if (low >= 0xdc00 && low <= 0xdfff) {
116
+ cp = (code - 0xd800) * 0x400 + low - 0xdc00 + 0x10000;
117
+ i++;
118
+ }
119
+ }
120
+ width += codePointWidth(cp);
121
+ }
122
+ return width;
123
+ }
124
+
125
+ /** Index of the last character of a CSI sequence starting at `start`. */
126
+ function skipEscape(text: string, start: number): number {
127
+ if (text.charCodeAt(start + 1) !== 0x5b) {
128
+ return start;
129
+ }
130
+ let i = start + 2;
131
+ while (i < text.length) {
132
+ const code = text.charCodeAt(i);
133
+ // Parameter and intermediate bytes run until a final byte in @ to ~.
134
+ if (code >= 0x40 && code <= 0x7e) {
135
+ return i;
136
+ }
137
+ i++;
138
+ }
139
+ return text.length;
140
+ }
141
+
142
+ const ANSI = /\x1b\[[0-9;]*m/g;
143
+
144
+ function stripAnsi(text: string): string {
145
+ return text.replace(ANSI, '');
146
+ }
147
+
148
+ /**
149
+ * Pad to a visible width, so styled text lines up with plain text.
150
+ *
151
+ * `known` skips re-measuring when the caller already has the width, which the
152
+ * table always does after truncating.
153
+ */
154
+ function pad(text: string, width: number, align: Align, known?: number): string {
155
+ const filler = ' '.repeat(Math.max(0, width - (known ?? displayWidth(text))));
156
+ if (align === 'right') {
157
+ return filler + text;
158
+ }
159
+ if (align === 'center') {
160
+ const left = Math.floor(filler.length / 2);
161
+ return ' '.repeat(left) + text + ' '.repeat(filler.length - left);
162
+ }
163
+ return text + filler;
164
+ }
165
+
166
+ /**
167
+ * Truncate to a visible width, ending in an ellipsis when it does not fit.
168
+ *
169
+ * Walks code points rather than code units, so a surrogate pair is never split
170
+ * into a lone half, and a wide character is never counted as one column.
171
+ */
172
+ export function truncate(text: string, width: number): string {
173
+ return fit(text, width).text;
174
+ }
175
+
176
+ /** Truncate and report the resulting visible width, measuring only once. */
177
+ function fit(text: string, width: number): { text: string; width: number } {
178
+ const actual = displayWidth(text);
179
+ if (width <= 0) {
180
+ return { text: '', width: 0 };
181
+ }
182
+ if (actual <= width) {
183
+ return { text, width: actual };
184
+ }
185
+ if (width === 1) {
186
+ return { text: '…', width: 1 };
187
+ }
188
+
189
+ const budget = width - 1;
190
+ let used = 0;
191
+ let out = '';
192
+ for (const char of stripAnsi(text)) {
193
+ const cost = codePointWidth(char.codePointAt(0)!);
194
+ if (used + cost > budget) {
195
+ break;
196
+ }
197
+ used += cost;
198
+ out += char;
199
+ }
200
+ return { text: `${out}…`, width: used + 1 };
201
+ }
202
+
203
+ export type Align = 'left' | 'right' | 'center';
204
+
205
+ export interface Column<T> {
206
+ /** Property to read from each row. */
207
+ readonly key: keyof T & string;
208
+ /** Header text; defaults to the key. */
209
+ readonly header?: string;
210
+ /** Column alignment (default left). */
211
+ readonly align?: Align;
212
+ /** Cap this column's width, truncating what does not fit. */
213
+ readonly maxWidth?: number;
214
+ /** Render a cell; defaults to String(value), with undefined and null as ''. */
215
+ readonly render?: (value: T[keyof T & string], row: T) => string;
216
+ }
217
+
218
+ export interface TableOptions<T> {
219
+ readonly columns: readonly Column<T>[];
220
+ /** Show the header row (default true). */
221
+ readonly header?: boolean;
222
+ /** Gap between columns (default 2 spaces). */
223
+ readonly gap?: number;
224
+ /** Total width to fit; defaults to the terminal width. */
225
+ readonly width?: number;
226
+ /** Draw a rule under the header (default false). */
227
+ readonly rule?: boolean;
228
+ /** Left indent for every line. */
229
+ readonly indent?: number;
230
+ /** Style the header; defaults to bold when colour is on. */
231
+ readonly headerStyle?: (text: string) => string;
232
+ }
233
+
234
+ function terminalWidth(): number {
235
+ const columns = process.stdout?.columns;
236
+ return typeof columns === 'number' && columns > 0 ? columns : 80;
237
+ }
238
+
239
+ /** Whether the destination can show colour, matching how help decides. */
240
+ export function colorEnabled(): boolean {
241
+ return styleText('red', 'x') !== 'x';
242
+ }
243
+
244
+ function defaultCell(value: unknown): string {
245
+ return value === undefined || value === null ? '' : String(value);
246
+ }
247
+
248
+ /**
249
+ * Render rows as aligned columns.
250
+ *
251
+ * Columns are sized to their widest cell, then shrunk from the widest down
252
+ * until the whole table fits the available width. Nothing is truncated while
253
+ * anything still fits, so a narrow terminal costs the sprawling column first
254
+ * rather than every column equally.
255
+ */
256
+ export function table<T extends Record<string, unknown>>(
257
+ rows: readonly T[],
258
+ opts: TableOptions<T>,
259
+ ): string {
260
+ const { columns } = opts;
261
+ if (columns.length === 0) {
262
+ return '';
263
+ }
264
+
265
+ const gap = opts.gap ?? 2;
266
+ const indent = opts.indent ?? 0;
267
+ const showHeader = opts.header !== false;
268
+ const headerStyle =
269
+ opts.headerStyle ?? (colorEnabled() ? (t: string) => styleText('bold', t) : (t: string) => t);
270
+
271
+ const headers = columns.map((c) => c.header ?? c.key);
272
+ const cells = rows.map((row) =>
273
+ columns.map((c) => (c.render ? c.render(row[c.key] as never, row) : defaultCell(row[c.key]))),
274
+ );
275
+
276
+ const widths = columns.map((c, i) => {
277
+ let width = showHeader ? displayWidth(headers[i]!) : 0;
278
+ for (const line of cells) {
279
+ width = Math.max(width, displayWidth(line[i]!));
280
+ }
281
+ return c.maxWidth === undefined ? width : Math.min(width, c.maxWidth);
282
+ });
283
+
284
+ // Shrink the widest column repeatedly until the table fits.
285
+ const available = (opts.width ?? terminalWidth()) - indent;
286
+ const overhead = gap * (columns.length - 1);
287
+ let total = widths.reduce((sum, w) => sum + w, 0) + overhead;
288
+ while (total > available) {
289
+ let widest = 0;
290
+ for (let i = 1; i < widths.length; i++) {
291
+ if (widths[i]! > widths[widest]!) {
292
+ widest = i;
293
+ }
294
+ }
295
+ if (widths[widest]! <= 3) {
296
+ break;
297
+ }
298
+ widths[widest]!--;
299
+ total--;
300
+ }
301
+
302
+ const spacer = ' '.repeat(gap);
303
+ const prefix = ' '.repeat(indent);
304
+ const lines: string[] = [];
305
+
306
+ const renderRow = (values: readonly string[], style?: (t: string) => string): string => {
307
+ const parts = values.map((value, i) => {
308
+ const cut = fit(value, widths[i]!);
309
+ // Styling adds escapes but no columns, so the measured width still holds.
310
+ const shown = style ? style(cut.text) : cut.text;
311
+ return pad(shown, widths[i]!, columns[i]?.align ?? 'left', cut.width);
312
+ });
313
+ return (prefix + parts.join(spacer)).trimEnd();
314
+ };
315
+
316
+ if (showHeader) {
317
+ lines.push(renderRow(headers, headerStyle));
318
+ if (opts.rule === true) {
319
+ lines.push(prefix + widths.map((w) => '─'.repeat(w)).join(spacer));
320
+ }
321
+ }
322
+ for (const line of cells) {
323
+ lines.push(renderRow(line));
324
+ }
325
+
326
+ return `${lines.join('\n')}\n`;
327
+ }
328
+
329
+ export interface KeyValueOptions {
330
+ /** Left indent for every line. */
331
+ readonly indent?: number;
332
+ /** Separator between key and value (default ': '). */
333
+ readonly separator?: string;
334
+ /** Total width to wrap within; defaults to the terminal width. */
335
+ readonly width?: number;
336
+ /** Style the keys; defaults to bold when colour is on. */
337
+ readonly keyStyle?: (text: string) => string;
338
+ }
339
+
340
+ /**
341
+ * Render pairs as an aligned block, wrapping long values under their key.
342
+ *
343
+ * ```
344
+ * Name: clap-ts
345
+ * Version: 0.3.0
346
+ * ```
347
+ */
348
+ export function keyValue(
349
+ pairs: readonly (readonly [string, string])[] | Record<string, string>,
350
+ opts?: KeyValueOptions,
351
+ ): string {
352
+ const entries = Array.isArray(pairs)
353
+ ? (pairs as readonly (readonly [string, string])[])
354
+ : Object.entries(pairs as Record<string, string>);
355
+ if (entries.length === 0) {
356
+ return '';
357
+ }
358
+
359
+ const separator = opts?.separator ?? ': ';
360
+ const indent = opts?.indent ?? 0;
361
+ const keyStyle =
362
+ opts?.keyStyle ?? (colorEnabled() ? (t: string) => styleText('bold', t) : (t: string) => t);
363
+
364
+ const keyWidth = Math.max(...entries.map(([key]) => displayWidth(key)));
365
+ const valueColumn = indent + keyWidth + separator.length;
366
+ const available = Math.max(20, (opts?.width ?? terminalWidth()) - valueColumn);
367
+
368
+ const lines: string[] = [];
369
+ for (const [key, value] of entries) {
370
+ const label = ' '.repeat(indent) + keyStyle(key + separator) + ' '.repeat(keyWidth - displayWidth(key));
371
+ const wrapped = wrap(value, available);
372
+ lines.push((label + wrapped[0]!).trimEnd());
373
+ for (const rest of wrapped.slice(1)) {
374
+ lines.push(' '.repeat(valueColumn) + rest);
375
+ }
376
+ }
377
+ return `${lines.join('\n')}\n`;
378
+ }
379
+
380
+ /** Break text into lines no wider than `width`, on whitespace. */
381
+ function wrap(text: string, width: number): string[] {
382
+ if (displayWidth(text) <= width) {
383
+ return [text];
384
+ }
385
+ const lines: string[] = [];
386
+ let current = '';
387
+ for (const word of text.split(/\s+/)) {
388
+ if (current.length === 0) {
389
+ current = word;
390
+ } else if (displayWidth(current) + 1 + displayWidth(word) <= width) {
391
+ current += ` ${word}`;
392
+ } else {
393
+ lines.push(current);
394
+ current = word;
395
+ }
396
+ }
397
+ if (current.length > 0) {
398
+ lines.push(current);
399
+ }
400
+ return lines;
401
+ }
402
+
403
+ /** A node in a tree, with whatever children it has. */
404
+ export interface TreeNode {
405
+ readonly label: string;
406
+ readonly children?: readonly TreeNode[];
407
+ }
408
+
409
+ export interface TreeOptions {
410
+ /** Use ASCII connectors instead of box-drawing characters. */
411
+ readonly ascii?: boolean;
412
+ /** Left indent for every line. */
413
+ readonly indent?: number;
414
+ }
415
+
416
+ /**
417
+ * Render a tree with connector lines.
418
+ *
419
+ * ```
420
+ * root
421
+ * ├── one
422
+ * │ └── nested
423
+ * └── two
424
+ * ```
425
+ */
426
+ export function tree(root: TreeNode | readonly TreeNode[], opts?: TreeOptions): string {
427
+ const ascii = opts?.ascii === true;
428
+ const glyphs = ascii
429
+ ? { branch: '|-- ', last: '`-- ', pipe: '| ', blank: ' ' }
430
+ : { branch: '├── ', last: '└── ', pipe: '│ ', blank: ' ' };
431
+ const prefix = ' '.repeat(opts?.indent ?? 0);
432
+ const lines: string[] = [];
433
+
434
+ const walk = (node: TreeNode, ancestry: string, isLast: boolean, isRoot: boolean): void => {
435
+ if (isRoot) {
436
+ lines.push(prefix + node.label);
437
+ } else {
438
+ lines.push(prefix + ancestry + (isLast ? glyphs.last : glyphs.branch) + node.label);
439
+ }
440
+ const children = node.children ?? [];
441
+ const childAncestry = isRoot ? '' : ancestry + (isLast ? glyphs.blank : glyphs.pipe);
442
+ children.forEach((child, i) => {
443
+ walk(child, childAncestry, i === children.length - 1, false);
444
+ });
445
+ };
446
+
447
+ const roots = Array.isArray(root) ? (root as readonly TreeNode[]) : [root as TreeNode];
448
+ roots.forEach((node) => {
449
+ walk(node, '', true, true);
450
+ });
451
+
452
+ return `${lines.join('\n')}\n`;
453
+ }