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,189 @@
1
+ /**
2
+ * Tests for the levelled logger and how it reads verbosity arguments.
3
+ */
4
+
5
+ import { describe, test, expect } from 'bun:test';
6
+ import { createLogger, levelFromArgs, loggerFrom, LEVELS } from '../log.js';
7
+ import { defineCommand } from '../runner.js';
8
+ import { runCli } from '../testing.js';
9
+ import type { OutputSink } from '../types.js';
10
+
11
+ function collector(): OutputSink & { text(): string } {
12
+ const chunks: string[] = [];
13
+ return {
14
+ write(chunk: string) {
15
+ chunks.push(chunk);
16
+ },
17
+ text: () => chunks.join(''),
18
+ };
19
+ }
20
+
21
+ const plain = { color: false };
22
+
23
+ describe('createLogger', () => {
24
+ test('info is the default level', () => {
25
+ const sink = collector();
26
+ const log = createLogger({ sink, ...plain });
27
+ log.info('shown');
28
+ log.debug('hidden');
29
+ expect(sink.text()).toBe('info: shown\n');
30
+ });
31
+
32
+ test('a higher level lets more through', () => {
33
+ const sink = collector();
34
+ createLogger({ sink, level: 'trace', ...plain }).trace('deep');
35
+ expect(sink.text()).toBe('trace: deep\n');
36
+ });
37
+
38
+ test('silent writes nothing at all', () => {
39
+ const sink = collector();
40
+ const log = createLogger({ sink, level: 'silent', ...plain });
41
+ for (const level of LEVELS) {
42
+ if (level !== 'silent') {
43
+ log[level]('anything');
44
+ }
45
+ }
46
+ expect(sink.text()).toBe('');
47
+ });
48
+
49
+ test('enabled answers without writing', () => {
50
+ const log = createLogger({ sink: collector(), level: 'warn', ...plain });
51
+ expect(log.enabled('error')).toBe(true);
52
+ expect(log.enabled('warn')).toBe(true);
53
+ expect(log.enabled('info')).toBe(false);
54
+ });
55
+
56
+ test('extra values are appended, objects as JSON', () => {
57
+ const sink = collector();
58
+ createLogger({ sink, ...plain }).info('msg', 'a', { b: 1 });
59
+ expect(sink.text()).toBe('info: msg a {"b":1}\n');
60
+ });
61
+
62
+ test('a prefix leads every line', () => {
63
+ const sink = collector();
64
+ createLogger({ sink, prefix: '[tool]', ...plain }).info('hi');
65
+ expect(sink.text()).toBe('[tool] info: hi\n');
66
+ });
67
+
68
+ test('withLevel keeps the sink', () => {
69
+ const sink = collector();
70
+ createLogger({ sink, ...plain }).withLevel('debug').debug('now shown');
71
+ expect(sink.text()).toBe('debug: now shown\n');
72
+ });
73
+
74
+ test('colour is off when asked', () => {
75
+ const sink = collector();
76
+ createLogger({ sink, color: false }).error('e');
77
+ expect(sink.text()).not.toContain('\x1b[');
78
+ });
79
+
80
+ test('colour is emitted when forced', () => {
81
+ const sink = collector();
82
+ createLogger({ sink, color: true }).error('e');
83
+ expect(sink.text()).toContain('\x1b[');
84
+ });
85
+ });
86
+
87
+ describe('levelFromArgs', () => {
88
+ test('no flags means info', () => {
89
+ expect(levelFromArgs({})).toBe('info');
90
+ });
91
+
92
+ test('each -v climbs a step', () => {
93
+ expect(levelFromArgs({ verbose: 1 })).toBe('debug');
94
+ expect(levelFromArgs({ verbose: 2 })).toBe('trace');
95
+ });
96
+
97
+ test('a boolean verbose counts as one step', () => {
98
+ expect(levelFromArgs({ verbose: true })).toBe('debug');
99
+ });
100
+
101
+ test('climbing stops at the loudest level', () => {
102
+ expect(levelFromArgs({ verbose: 99 })).toBe('trace');
103
+ });
104
+
105
+ test('quiet drops to errors only', () => {
106
+ expect(levelFromArgs({ quiet: true })).toBe('error');
107
+ });
108
+
109
+ test('quiet wins over verbose', () => {
110
+ expect(levelFromArgs({ quiet: true, verbose: 3 })).toBe('error');
111
+ });
112
+
113
+ test('an explicit level beats both', () => {
114
+ expect(levelFromArgs({ logLevel: 'warn', quiet: true, verbose: 3 })).toBe('warn');
115
+ });
116
+
117
+ test('an unknown explicit level is ignored', () => {
118
+ expect(levelFromArgs({ logLevel: 'nonsense' })).toBe('info');
119
+ });
120
+
121
+ test('the key names are configurable', () => {
122
+ expect(levelFromArgs({ loud: 2 }, { verboseKey: 'loud' })).toBe('trace');
123
+ });
124
+ });
125
+
126
+ describe('loggerFrom', () => {
127
+ test('takes its level from the args and writes to the context stderr', async () => {
128
+ const command = defineCommand({
129
+ meta: { name: 'tool' },
130
+ args: {
131
+ verbose: { type: 'boolean', short: 'v', action: 'count' },
132
+ quiet: { type: 'boolean', short: 'q' },
133
+ },
134
+ run(ctx) {
135
+ const log = loggerFrom(ctx, { color: false });
136
+ log.info('info line');
137
+ log.debug('debug line');
138
+ },
139
+ });
140
+
141
+ const quiet = await runCli(command, []);
142
+ expect(quiet.plainStderr).toContain('info line');
143
+ expect(quiet.plainStderr).not.toContain('debug line');
144
+
145
+ const loud = await runCli(command, ['-v']);
146
+ expect(loud.plainStderr).toContain('debug line');
147
+
148
+ const silent = await runCli(command, ['-q']);
149
+ expect(silent.plainStderr).toBe('');
150
+ });
151
+
152
+ test('nothing reaches stdout', async () => {
153
+ const command = defineCommand({
154
+ meta: { name: 'tool' },
155
+ run(ctx) {
156
+ loggerFrom(ctx, { color: false }).info('to stderr');
157
+ },
158
+ });
159
+ const { stdout, plainStderr } = await runCli(command, []);
160
+ expect(stdout).toBe('');
161
+ expect(plainStderr).toContain('to stderr');
162
+ });
163
+ });
164
+
165
+ describe('values that break a naive serialiser', () => {
166
+ test('an Error keeps its message rather than becoming {}', () => {
167
+ const sink = collector();
168
+ createLogger({ sink, ...plain }).error('failed', new Error('boom'));
169
+ expect(sink.text()).toContain('boom');
170
+ expect(sink.text()).not.toContain('{}');
171
+ });
172
+
173
+ test('a cyclic object is named, not thrown on', () => {
174
+ const sink = collector();
175
+ const circular: Record<string, unknown> = { a: 1 };
176
+ circular['self'] = circular;
177
+ expect(() => createLogger({ sink, ...plain }).info('cycle', circular)).not.toThrow();
178
+ expect(sink.text()).toContain('[Circular]');
179
+ expect(sink.text()).toContain('"a":1');
180
+ });
181
+
182
+ test('undefined, bigint and symbol survive', () => {
183
+ const sink = collector();
184
+ createLogger({ sink, ...plain }).info('values', undefined, 10n, Symbol('s'));
185
+ expect(sink.text()).toContain('undefined');
186
+ expect(sink.text()).toContain('10');
187
+ expect(sink.text()).toContain('Symbol(s)');
188
+ });
189
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Tests for man page generation. Where groff is installed the output is also
3
+ * run through it, so a malformed request fails the suite rather than showing
4
+ * up in someone's terminal.
5
+ */
6
+
7
+ import { describe, test, expect } from 'bun:test';
8
+ import { renderManPage, generateManPages } from '../man.js';
9
+ import type { CommandDef } from '../types.js';
10
+
11
+ const command: CommandDef = {
12
+ meta: {
13
+ name: 'my-app',
14
+ version: '3.0',
15
+ description: 'Tests man pages',
16
+ author: 'Salama Ashoush',
17
+ afterHelp: "Don't forget the apostrophe.",
18
+ },
19
+ args: {
20
+ config: { type: 'string', short: 'c', description: 'some config file', action: 'append' },
21
+ mode: {
22
+ type: 'string',
23
+ description: 'Build mode',
24
+ valueParser: [{ name: 'fast', help: 'Skip checks' }, { name: 'safe' }],
25
+ },
26
+ port: { type: 'number', default: 8080, env: 'APP_PORT', description: 'Port to bind' },
27
+ secret: { type: 'string', hidden: true },
28
+ file: { type: 'positional', required: true, description: 'some input file' },
29
+ choice: { type: 'positional', description: 'a choice' },
30
+ },
31
+ subCommands: {
32
+ test: {
33
+ meta: { name: 'test', description: 'tests things' },
34
+ args: { case: { type: 'string', description: 'the case to test' } },
35
+ },
36
+ ghost: { meta: { name: 'ghost', hidden: true } },
37
+ },
38
+ };
39
+
40
+ const page = renderManPage(command);
41
+
42
+ describe('man page structure', () => {
43
+ test('opens with the apostrophe preamble and a title', () => {
44
+ expect(page.startsWith('.ie \\n(.g .ds Aq \\(aq\n')).toBe(true);
45
+ expect(page).toContain('.TH my\\-app 1');
46
+ });
47
+
48
+ test('carries the standard sections in order', () => {
49
+ const order = ['.SH NAME', '.SH SYNOPSIS', '.SH DESCRIPTION', '.SH OPTIONS', '.SH SUBCOMMANDS', '.SH VERSION', '.SH AUTHORS'];
50
+ let at = -1;
51
+ for (const section of order) {
52
+ const found = page.indexOf(section);
53
+ expect(found).toBeGreaterThan(at);
54
+ at = found;
55
+ }
56
+ });
57
+
58
+ test('escapes hyphens and apostrophes', () => {
59
+ expect(page).toContain('my\\-app');
60
+ expect(page).toContain('Don\\*(Aqt');
61
+ // The two preamble lines define \\*(Aq and must keep their literal quotes.
62
+ const body = page.split('\n').slice(2).join('\n');
63
+ expect(body).not.toContain("'");
64
+ });
65
+
66
+ test('marks repeatable options and required positionals', () => {
67
+ expect(page).toContain('\\fB\\-c\\fR|\\fB\\-\\-config\\fR=\\fICONFIG\\fR]...');
68
+ expect(page).toContain('\\fIfile\\fR');
69
+ expect(page).toContain('[\\fIchoice\\fR]');
70
+ });
71
+
72
+ test('renders possible values as a bullet list with their help', () => {
73
+ expect(page).toContain('\\fIPossible values:\\fR');
74
+ expect(page).toContain('.IP \\(bu 2');
75
+ expect(page).toContain('fast: Skip checks');
76
+ });
77
+
78
+ test('notes defaults and environment variables', () => {
79
+ expect(page).toContain('\\fIDefault value:\\fR 8080');
80
+ expect(page).toContain('\\fIEnvironment:\\fR APP_PORT');
81
+ });
82
+
83
+ test('omits hidden args and subcommands', () => {
84
+ expect(page).not.toContain('secret');
85
+ expect(page).not.toContain('ghost');
86
+ });
87
+
88
+ test('lists subcommands by their own page name', () => {
89
+ expect(page).toContain('my\\-app\\-test(1)');
90
+ });
91
+ });
92
+
93
+ describe('generateManPages', () => {
94
+ const pages = generateManPages(command);
95
+
96
+ test('produces one page per visible command', () => {
97
+ expect([...pages.keys()].sort()).toEqual(['my-app-test.1', 'my-app.1']);
98
+ });
99
+
100
+ test('honours a custom section number', () => {
101
+ const section8 = generateManPages(command, { section: '8' });
102
+ expect([...section8.keys()]).toContain('my-app.8');
103
+ expect(section8.get('my-app.8')).toContain('.TH my\\-app 8');
104
+ });
105
+
106
+ test('a subcommand page describes only that subcommand', () => {
107
+ const sub = pages.get('my-app-test.1')!;
108
+ expect(sub).toContain('my\\-app\\-test \\- tests things');
109
+ expect(sub).toContain('\\-\\-case');
110
+ expect(sub).not.toContain('\\-\\-config');
111
+ });
112
+ });
113
+
114
+ describe('groff accepts the output', () => {
115
+ const groff = Bun.spawnSync(['sh', '-c', 'command -v groff']).exitCode === 0;
116
+
117
+ test.if(groff)('renders every page with no warnings', () => {
118
+ for (const [name, roff] of generateManPages(command)) {
119
+ const result = Bun.spawnSync(['groff', '-man', '-Tascii', '-ww'], {
120
+ stdin: Buffer.from(roff),
121
+ });
122
+ const warnings = new TextDecoder().decode(result.stderr).trim();
123
+ expect(`${name}: ${warnings}`).toBe(`${name}: `);
124
+ expect(result.exitCode).toBe(0);
125
+ }
126
+ });
127
+
128
+ test.if(groff)('the rendered text reads back correctly', () => {
129
+ const result = Bun.spawnSync(['groff', '-man', '-Tascii'], { stdin: Buffer.from(page) });
130
+ const text = new TextDecoder().decode(result.stdout).replace(/.\x08/g, '');
131
+ expect(text).toContain('my-app - Tests man pages');
132
+ expect(text).toContain('Port to bind');
133
+ expect(text).toContain("Don't forget the apostrophe.");
134
+ });
135
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Tests for markdown documentation output.
3
+ */
4
+
5
+ import { describe, test, expect } from 'bun:test';
6
+ import { renderMarkdownHelp } from '../markdown.js';
7
+ import type { CommandDef } from '../types.js';
8
+
9
+ const command: CommandDef = {
10
+ meta: { name: 'my-app', version: '3.0', description: 'Does things' },
11
+ args: {
12
+ config: { type: 'string', short: 'c', description: 'Config file', env: 'APP_CONFIG' },
13
+ mode: {
14
+ type: 'string',
15
+ description: 'Build mode',
16
+ valueParser: [{ name: 'fast', help: 'Skip checks' }, { name: 'safe' }],
17
+ },
18
+ port: { type: 'number', default: 8080, description: 'Port' },
19
+ plain: { type: 'string', valueParser: ['a', 'b'], description: 'Plain choices' },
20
+ secret: { type: 'string', hidden: true },
21
+ file: { type: 'positional', required: true, description: 'Input file' },
22
+ },
23
+ subCommands: {
24
+ test: {
25
+ meta: { name: 'test', description: 'Tests things', aliases: ['t'] },
26
+ args: { case: { type: 'string', description: 'Case' } },
27
+ },
28
+ ghost: { meta: { name: 'ghost', hidden: true } },
29
+ },
30
+ };
31
+
32
+ const doc = renderMarkdownHelp(command);
33
+
34
+ describe('markdown structure', () => {
35
+ test('heads each command with its full path', () => {
36
+ expect(doc).toContain('# `my-app`');
37
+ expect(doc).toContain('## `my-app test`');
38
+ });
39
+
40
+ test('nests sections one level under their command', () => {
41
+ expect(doc).toContain('## Options');
42
+ expect(doc).toContain('### Options');
43
+ });
44
+
45
+ test('gives a usage line per command', () => {
46
+ expect(doc).toContain('**Usage:** `my-app [OPTIONS] <FILE> [COMMAND]`');
47
+ expect(doc).toContain('**Usage:** `my-app test [OPTIONS]`');
48
+ });
49
+
50
+ test('lists subcommands with their aliases', () => {
51
+ expect(doc).toContain('* `test` (`t`) - Tests things');
52
+ });
53
+
54
+ test('pairs the long flag and its placeholder in one code span', () => {
55
+ expect(doc).toContain('* `-c`, `--config <CONFIG>` - Config file');
56
+ });
57
+
58
+ test('includes the built-in flags', () => {
59
+ expect(doc).toContain('* `-h`, `--help` - Print help');
60
+ expect(doc).toContain('* `-V`, `--version` - Print version');
61
+ });
62
+ });
63
+
64
+ describe('markdown argument notes', () => {
65
+ test('spells out possible values that carry help', () => {
66
+ expect(doc).toContain('Possible values:');
67
+ expect(doc).toContain('- `fast`: Skip checks');
68
+ });
69
+
70
+ test('keeps plain possible values on one line', () => {
71
+ expect(doc).toContain('Possible values: `a`, `b`');
72
+ });
73
+
74
+ test('reports defaults, environment and required', () => {
75
+ expect(doc).toContain('Default value: `8080`');
76
+ expect(doc).toContain('Environment: `APP_CONFIG`');
77
+ expect(doc).toContain('Required.');
78
+ });
79
+
80
+ test('omits hidden args and commands', () => {
81
+ expect(doc).not.toContain('secret');
82
+ expect(doc).not.toContain('ghost');
83
+ });
84
+ });
85
+
86
+ describe('markdown formatting', () => {
87
+ test('never leaves three blank lines in a row', () => {
88
+ expect(doc).not.toContain('\n\n\n');
89
+ });
90
+
91
+ test('ends with exactly one newline', () => {
92
+ expect(doc.endsWith('\n')).toBe(true);
93
+ expect(doc.endsWith('\n\n')).toBe(false);
94
+ });
95
+
96
+ test('escapes markdown that would break a list item', () => {
97
+ const tricky: CommandDef = {
98
+ meta: { name: 'x' },
99
+ args: { a: { type: 'boolean', description: 'Uses *stars* and _score_ and <tags>' } },
100
+ };
101
+ const out = renderMarkdownHelp(tricky);
102
+ expect(out).toContain('\\*stars\\*');
103
+ expect(out).toContain('\\_score\\_');
104
+ expect(out).toContain('\\<tags\\>');
105
+ });
106
+
107
+ test('a title demotes the command headings by one level', () => {
108
+ const out = renderMarkdownHelp(command, { title: 'CLI Reference', footer: 'Bye.' });
109
+ expect(out.startsWith('# CLI Reference\n')).toBe(true);
110
+ expect(out).toContain('## `my-app`');
111
+ expect(out).toContain('### `my-app test`');
112
+ expect(out.trimEnd().endsWith('Bye.')).toBe(true);
113
+ });
114
+ });
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Tests for structured terminal output: tables, key-value blocks and trees.
3
+ */
4
+
5
+ import { describe, test, expect } from 'bun:test';
6
+ import { table, keyValue, tree, truncate, displayWidth } from '../output.js';
7
+ import { stripAnsi } from '../testing.js';
8
+
9
+ const rows = [
10
+ { name: 'parser.ts', size: 32832, kind: 'source' },
11
+ { name: 'completions.ts', size: 26550, kind: 'source' },
12
+ { name: 'index.ts', size: 828, kind: 'entry' },
13
+ ];
14
+
15
+ const plainHeader = { headerStyle: (t: string) => t };
16
+
17
+ describe('displayWidth and truncate', () => {
18
+ test('width ignores ANSI escapes', () => {
19
+ expect(displayWidth('\x1b[1mbold\x1b[22m')).toBe(4);
20
+ });
21
+
22
+ test('truncate leaves short text alone', () => {
23
+ expect(truncate('abc', 10)).toBe('abc');
24
+ });
25
+
26
+ test('truncate ends in an ellipsis', () => {
27
+ expect(truncate('abcdefgh', 4)).toBe('abc…');
28
+ });
29
+
30
+ test('truncate handles degenerate widths', () => {
31
+ expect(truncate('abc', 1)).toBe('…');
32
+ expect(truncate('abc', 0)).toBe('');
33
+ });
34
+ });
35
+
36
+ describe('table', () => {
37
+ const opts = { columns: [{ key: 'name' as const }, { key: 'size' as const }], ...plainHeader };
38
+
39
+ test('aligns columns to their widest cell', () => {
40
+ const lines = table(rows, opts).trimEnd().split('\n');
41
+ // 'completions.ts' is the widest name, so every size starts two past it.
42
+ const column = 'completions.ts'.length + 2;
43
+ expect(lines[0]!.indexOf('size')).toBe(column);
44
+ expect(lines[1]!.indexOf('32832')).toBe(column);
45
+ expect(lines[3]!.indexOf('828')).toBe(column);
46
+ });
47
+
48
+ test('emits a header by default and drops it on request', () => {
49
+ expect(table(rows, opts)).toContain('name');
50
+ expect(table(rows, { ...opts, header: false })).not.toContain('name ');
51
+ });
52
+
53
+ test('right alignment pushes values to the column edge', () => {
54
+ const out = table(rows, {
55
+ columns: [{ key: 'name' }, { key: 'size', align: 'right' }],
56
+ ...plainHeader,
57
+ });
58
+ const lines = out.trimEnd().split('\n');
59
+ const ends = lines.slice(1).map((l) => l.length);
60
+ expect(new Set(ends).size).toBe(1);
61
+ });
62
+
63
+ test('a render function formats the cell', () => {
64
+ const out = table(rows, {
65
+ columns: [{ key: 'name' }, { key: 'size', render: (v) => `${String(v)} B` }],
66
+ ...plainHeader,
67
+ });
68
+ expect(out).toContain('32832 B');
69
+ });
70
+
71
+ test('never exceeds the requested width', () => {
72
+ for (const width of [80, 40, 24, 16]) {
73
+ const out = table(rows, {
74
+ columns: [{ key: 'name' }, { key: 'size' }, { key: 'kind' }],
75
+ width,
76
+ ...plainHeader,
77
+ });
78
+ for (const line of out.trimEnd().split('\n')) {
79
+ expect(stripAnsi(line).length).toBeLessThanOrEqual(width);
80
+ }
81
+ }
82
+ });
83
+
84
+ test('maxWidth truncates a single column', () => {
85
+ const out = table(rows, {
86
+ columns: [{ key: 'name', maxWidth: 6 }],
87
+ ...plainHeader,
88
+ });
89
+ expect(out).toContain('compl…');
90
+ });
91
+
92
+ test('indent shifts every line', () => {
93
+ const out = table(rows, { ...opts, indent: 4 });
94
+ for (const line of out.trimEnd().split('\n')) {
95
+ expect(line.startsWith(' ')).toBe(true);
96
+ }
97
+ });
98
+
99
+ test('a rule sits under the header', () => {
100
+ expect(table(rows, { ...opts, rule: true }).split('\n')[1]).toMatch(/^─+\s+─+$/);
101
+ });
102
+
103
+ test('no columns yields nothing', () => {
104
+ expect(table(rows, { columns: [] })).toBe('');
105
+ });
106
+
107
+ test('no rows still yields the header', () => {
108
+ expect(table([], opts).trimEnd()).toBe('name size');
109
+ });
110
+
111
+ test('undefined and null cells render empty', () => {
112
+ const sparse = [{ a: undefined, b: null, c: 'x' }];
113
+ const out = table(sparse as never, {
114
+ columns: [{ key: 'a' }, { key: 'b' }, { key: 'c' }],
115
+ ...plainHeader,
116
+ });
117
+ expect(out.trimEnd().split('\n')[1]?.trim()).toBe('x');
118
+ });
119
+ });
120
+
121
+ describe('keyValue', () => {
122
+ const plainKey = { keyStyle: (t: string) => t };
123
+
124
+ test('aligns values past the widest key', () => {
125
+ const [first, second] = keyValue({ Name: 'clap-ts', Version: '0.3.0' }, plainKey)
126
+ .trimEnd()
127
+ .split('\n');
128
+ expect(first!.indexOf('clap-ts')).toBe(second!.indexOf('0.3.0'));
129
+ });
130
+
131
+ test('accepts pairs as well as an object', () => {
132
+ expect(keyValue([['a', '1']], plainKey)).toBe(keyValue({ a: '1' }, plainKey));
133
+ });
134
+
135
+ test('wraps a long value under its key', () => {
136
+ const out = keyValue({ Key: 'one two three four five six seven eight' }, { width: 20, ...plainKey });
137
+ const lines = out.trimEnd().split('\n');
138
+ expect(lines.length).toBeGreaterThan(1);
139
+ expect(lines[1]!.startsWith(' ')).toBe(true);
140
+ });
141
+
142
+ test('nothing in, nothing out', () => {
143
+ expect(keyValue({})).toBe('');
144
+ });
145
+
146
+ test('the separator is configurable', () => {
147
+ expect(keyValue({ a: '1' }, { separator: ' = ', ...plainKey })).toBe('a = 1\n');
148
+ });
149
+ });
150
+
151
+ describe('tree', () => {
152
+ const sample = {
153
+ label: 'demo',
154
+ children: [{ label: 'serve', children: [{ label: '--port' }] }, { label: 'build' }],
155
+ };
156
+
157
+ test('draws connectors, with the last child closing the branch', () => {
158
+ expect(tree(sample)).toBe('demo\n├── serve\n│ └── --port\n└── build\n');
159
+ });
160
+
161
+ test('ascii mode avoids box-drawing characters', () => {
162
+ const out = tree(sample, { ascii: true });
163
+ expect(out).toContain('|-- serve');
164
+ expect(out).toContain('`-- build');
165
+ expect(out).not.toMatch(/[├└│─]/);
166
+ });
167
+
168
+ test('a leaf is just its label', () => {
169
+ expect(tree({ label: 'only' })).toBe('only\n');
170
+ });
171
+
172
+ test('several roots each start at column zero', () => {
173
+ expect(tree([{ label: 'a' }, { label: 'b' }])).toBe('a\nb\n');
174
+ });
175
+
176
+ test('indent shifts every line', () => {
177
+ for (const line of tree(sample, { indent: 2 }).trimEnd().split('\n')) {
178
+ expect(line.startsWith(' ')).toBe(true);
179
+ }
180
+ });
181
+ });
182
+
183
+ // ---- Character width ----
184
+
185
+ describe('width of characters a terminal renders differently', () => {
186
+ test('wide East Asian characters count as two columns', () => {
187
+ expect(displayWidth('日本語')).toBe(6);
188
+ expect(displayWidth('한국어')).toBe(6);
189
+ expect(displayWidth('abc')).toBe(6); // fullwidth latin
190
+ });
191
+
192
+ test('emoji count as two columns', () => {
193
+ expect(displayWidth('\u{1f389}')).toBe(2);
194
+ expect(displayWidth('\u{1f680}\u{1f680}')).toBe(4);
195
+ });
196
+
197
+ test('a skin tone modifier adds nothing of its own', () => {
198
+ expect(displayWidth('\u{1f44d}\u{1f3fd}')).toBe(2);
199
+ });
200
+
201
+ test('combining marks add nothing', () => {
202
+ expect(displayWidth('é')).toBe(1); // decomposed
203
+ expect(displayWidth('é')).toBe(1); // precomposed
204
+ });
205
+
206
+ test('zero width and control characters add nothing', () => {
207
+ expect(displayWidth('a​b')).toBe(2);
208
+ expect(displayWidth('ab')).toBe(2);
209
+ });
210
+
211
+ test('ANSI escapes are skipped, including non-colour ones', () => {
212
+ expect(displayWidth('\x1b[1m\x1b[32mhi\x1b[39m\x1b[22m')).toBe(2);
213
+ expect(displayWidth('\x1b[2Khi')).toBe(2);
214
+ });
215
+
216
+ test('a table of wide text still aligns', () => {
217
+ const out = table(
218
+ [
219
+ { a: '日本語のファイル', b: '1' },
220
+ { a: 'ascii.ts', b: '22' },
221
+ ],
222
+ { columns: [{ key: 'a' }, { key: 'b' }], headerStyle: (t) => t },
223
+ );
224
+ const [, wide, plain] = out.trimEnd().split('\n');
225
+ // Both second columns start at the same visible column.
226
+ expect(displayWidth(wide!.slice(0, wide!.indexOf('1')))).toBe(
227
+ displayWidth(plain!.slice(0, plain!.indexOf('22'))),
228
+ );
229
+ });
230
+ });
231
+
232
+ describe('truncate respects character boundaries', () => {
233
+ test('never leaves half a surrogate pair', () => {
234
+ for (const width of [1, 2, 3, 4, 5]) {
235
+ const out = truncate('\u{1f389}\u{1f389}\u{1f389}', width);
236
+ expect(out).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/);
237
+ expect(displayWidth(out)).toBeLessThanOrEqual(width);
238
+ }
239
+ });
240
+
241
+ test('the result never exceeds the requested width', () => {
242
+ const samples = ['abcdefghij', '日本語のフ', '\u{1f389} party', 'éé'];
243
+ for (const text of samples) {
244
+ for (const width of [1, 2, 3, 5, 8]) {
245
+ expect(displayWidth(truncate(text, width))).toBeLessThanOrEqual(width);
246
+ }
247
+ }
248
+ });
249
+ });