@cplieger/deadset-ts 8.0.0-dev.18 → 8.0.0-dev.19

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.
@@ -3,11 +3,12 @@
3
3
  * its program word and the options it names its configuration file by, each as the tool
4
4
  * documents them, and one reader per shell builtin that moves the shell's directory. A reader
5
5
  * never guesses: an option it does not handle, or a dynamic word where it needs a value, leaves
6
- * the command unread.
6
+ * the command unread. The same rows and readers decide, for layer 3, which words of a command
7
+ * may write the file a runtime runs, so the two layers never read one command apart.
7
8
  */
8
9
 
9
10
  import { hasURLScheme, isPathSpecifier, resolvePath } from "../paths.ts";
10
- import type { Word } from "./shell.ts";
11
+ import { PART, type Word } from "./shell.ts";
11
12
 
12
13
  /** One module a runtime loads before its entry, and the resolution its option names it by. */
13
14
  export interface Preload {
@@ -26,70 +27,219 @@ export type Reading =
26
27
  /** `node` where the entry runs under Node itself. */
27
28
  readonly runtime: "node" | undefined;
28
29
  }
29
- /** It runs the command `words`. */
30
- | { readonly kind: "command"; readonly words: readonly Word[] }
30
+ /** It runs the command `words`, whose program word stands at `at` of the words read, as read. */
31
+ | { readonly kind: "command"; readonly words: readonly Word[]; readonly at: number }
31
32
  /** It runs `text` as a command line of its own. */
32
33
  | { readonly kind: "line"; readonly text: string };
33
34
 
34
- type Reader = (words: readonly Word[]) => Reading | undefined;
35
+ /**
36
+ * A reader's answer where its tool's table does not model the command: an option the table
37
+ * does not list, or a construct no row reads. Layer 2 reads it as not understood; for layer 3
38
+ * the command may run a file no word writes, and every later word may still be a program word.
39
+ */
40
+ interface Unknown {
41
+ readonly kind: "unknown";
42
+ }
43
+
44
+ const UNKNOWN: Unknown = { kind: "unknown" };
45
+
46
+ function isUnknown(value: unknown): value is Unknown {
47
+ return value === UNKNOWN;
48
+ }
49
+
50
+ type Reader = (words: readonly Word[]) => Reading | Unknown | undefined;
35
51
 
36
52
  /** The options a tool takes before its first operand. */
37
- interface Options {
53
+ export interface Options {
38
54
  /** The options that take a value, the next word or, for a long one, the text after `=`. */
39
55
  readonly valued: ReadonlySet<string>;
40
56
  /** The options that take none. */
41
57
  readonly flags: ReadonlySet<string>;
42
- /** Whether a one-letter valued option may carry its value in the same word, as `-uNAME`. */
58
+ /**
59
+ * Whether its one-letter options read as getopt reads them: a valued one's value may stand in
60
+ * the same word, as `-uNAME`, and several may share one word, the last of them valued or not,
61
+ * as `-iS'…'` and `-iu NAME`: https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap12.html
62
+ */
43
63
  readonly attached?: boolean;
64
+ /** The options whose value is optional and only written in the same word, as xargs's `-i[R]`. */
65
+ readonly optional?: ReadonlySet<string>;
66
+ }
67
+
68
+ /**
69
+ * One tool's options as its documentation gives them: its `options`, the object its reader
70
+ * parses with; what follows them, a command, the file it runs, a command line, command lines
71
+ * among its options, or the command it hands the paths it finds; the options that give a
72
+ * command line, a valued one's value or, for a flag, its operands; its subcommands, each with
73
+ * its own table; and whether it reads options after its first operand, up to a `--`.
74
+ * Production reads `options`, `lines` and `later` only, through {@link linesOf}; the
75
+ * enumerated property spells every field.
76
+ */
77
+ export interface Grammar {
78
+ readonly options: Options;
79
+ readonly operand: "command" | "file" | "line" | "lines" | "appended";
80
+ readonly lines?: ReadonlySet<string>;
81
+ readonly subcommands?: ReadonlyMap<string, Grammar>;
82
+ readonly later?: boolean;
83
+ /** Whether its command stands only after a `--`, as `dotenvx run`'s. */
84
+ readonly ended?: boolean;
85
+ /** For a runtime, the options that run their value, or its test runner, in its file's place. */
86
+ readonly inline?: ReadonlySet<string>;
87
+ /** For a runtime, the words that pick a mode that runs no file, and the options that may stand before one. */
88
+ readonly modes?: ReadonlySet<string>;
89
+ readonly modeAfter?: ReadonlySet<string>;
90
+ readonly doc: string;
44
91
  }
45
92
 
46
93
  /** The options before the first operand, with their values, and the operand's index. */
47
94
  interface Read {
48
95
  readonly values: ReadonlyMap<string, readonly string[]>;
96
+ /** The name of each option read, a flag's too. */
97
+ readonly named: ReadonlySet<string>;
49
98
  readonly at: number;
50
99
  /** Whether a `--` ended the options. */
51
100
  readonly ended: boolean;
52
101
  }
53
102
 
54
- /** The options `words` start with, or `undefined` where one is unhandled or a needed word is dynamic. */
55
- function optionsOf(words: readonly Word[], options: Options): Read | undefined {
56
- const values = new Map<string, string[]>();
57
- const keep = (name: string, value: string): void => {
58
- values.set(name, [...(values.get(name) ?? []), value]);
59
- };
60
- for (let at = 0; at < words.length; at += 1) {
61
- const word = words[at]?.value;
62
- if (word === undefined) {
103
+ /** The text `word` writes before its first part only the run knows: all of it where it is static. */
104
+ function startOf(word: Word): string {
105
+ return word.value ?? word.written?.split(PART)[0] ?? "";
106
+ }
107
+
108
+ /** One option {@link scanOptions} reads, with where its value is written: in the word at `at`, after `prefix`. */
109
+ interface Scanned {
110
+ readonly name: string;
111
+ readonly value?: { readonly at: number; readonly prefix: string };
112
+ }
113
+
114
+ /**
115
+ * The options {@link scanOptions} reads, and where they end: at an operand, after a `--`, at
116
+ * the words' end, at an option `options` does not hold, or at a dynamic word that may be one.
117
+ */
118
+ interface Scan {
119
+ readonly options: readonly Scanned[];
120
+ readonly at: number;
121
+ readonly stop: "operand" | "ended" | "end" | "unread" | "dynamic";
122
+ }
123
+
124
+ /**
125
+ * The options one word `word`, starting with `-`, writes, the word at `at`, or `undefined` where
126
+ * `options` does not hold one. A valued one's value is the next word's unless the word writes
127
+ * it, and always where it is `dynamic`, whose written text goes on past `word`.
128
+ */
129
+ function optionsInWord(
130
+ word: string,
131
+ at: number,
132
+ options: Options,
133
+ dynamic: boolean,
134
+ ): readonly Scanned[] | undefined {
135
+ const inWord = (name: string, prefix: string): Scanned => ({ name, value: { at, prefix } });
136
+ if (word.startsWith("--")) {
137
+ const equals = word.indexOf("=");
138
+ const name = equals < 0 ? word : word.slice(0, equals);
139
+ if (equals >= 0) {
140
+ return options.valued.has(name) || options.optional?.has(name) === true
141
+ ? [inWord(name, `${name}=`)]
142
+ : undefined;
143
+ }
144
+ if (options.optional?.has(word) === true) {
145
+ return [inWord(word, word)];
146
+ }
147
+ return options.valued.has(word) && !dynamic
148
+ ? [{ name: word, value: { at: at + 1, prefix: "" } }]
149
+ : undefined;
150
+ }
151
+ if (!dynamic && options.valued.has(word)) {
152
+ return [{ name: word, value: { at: at + 1, prefix: "" } }];
153
+ }
154
+ if (options.attached !== true) {
155
+ return options.optional?.has(word) === true ? [inWord(word, word)] : undefined;
156
+ }
157
+ const found: Scanned[] = [];
158
+ for (let letter = 1; letter < word.length; letter += 1) {
159
+ const name = `-${word.charAt(letter)}`;
160
+ const prefix = word.slice(0, letter + 1);
161
+ if (options.valued.has(name)) {
162
+ const next = letter + 1 === word.length && !dynamic;
163
+ return [...found, next ? { name, value: { at: at + 1, prefix: "" } } : inWord(name, prefix)];
164
+ }
165
+ if (options.optional?.has(name) === true) {
166
+ return [...found, inWord(name, prefix)];
167
+ }
168
+ if (!options.flags.has(name)) {
63
169
  return undefined;
64
170
  }
65
- if (word === "--") {
66
- return { values, at: at + 1, ended: true };
171
+ found.push({ name });
172
+ }
173
+ return found.length === 0 || dynamic ? undefined : found;
174
+ }
175
+
176
+ /**
177
+ * The options `words` write from `from` on, read by `options`, the one reading of a table's
178
+ * options that {@link optionsOf}, {@link optionLines} and {@link envSplit} share. A dynamic
179
+ * word that starts with written text other than `-` is no option; one whose written start is a
180
+ * valued option's ends them with that option's value in it, as `-S"$CMD"` does.
181
+ */
182
+ function scanOptions(words: readonly Word[], options: Options, from = 0): Scan {
183
+ const found: Scanned[] = [];
184
+ for (let at = from; at < words.length; at += 1) {
185
+ const one = words[at];
186
+ const dynamic = one?.value === undefined;
187
+ const word = one === undefined ? "" : startOf(one);
188
+ if (!dynamic && word === "--") {
189
+ return { options: found, at: at + 1, stop: "ended" };
67
190
  }
68
- if (!word.startsWith("-") || options.flags.has(word)) {
69
- if (!word.startsWith("-")) {
70
- return { values, at, ended: false };
71
- }
191
+ if (!dynamic && options.flags.has(word)) {
192
+ found.push({ name: word });
72
193
  continue;
73
194
  }
74
- const equals = word.startsWith("--") ? word.indexOf("=") : -1;
75
- const name = equals < 0 ? word : word.slice(0, equals);
76
- if (options.valued.has(name)) {
77
- const value = equals < 0 ? words[(at += 1)]?.value : word.slice(equals + 1);
78
- if (value === undefined) {
79
- return undefined;
80
- }
81
- keep(name, value);
82
- } else if (
83
- options.attached === true &&
84
- options.valued.has(word.slice(0, 2)) &&
85
- !word.startsWith("--")
86
- ) {
87
- keep(word.slice(0, 2), word.slice(2));
88
- } else {
195
+ if (!word.startsWith("-")) {
196
+ return { options: found, at, stop: dynamic && word === "" ? "dynamic" : "operand" };
197
+ }
198
+ const read = optionsInWord(word, at, options, dynamic);
199
+ if (dynamic || read === undefined) {
200
+ return {
201
+ options: [...found, ...(read ?? [])],
202
+ at,
203
+ stop: dynamic ? "dynamic" : "unread",
204
+ };
205
+ }
206
+ found.push(...read);
207
+ at = read.at(-1)?.value?.at ?? at;
208
+ }
209
+ return { options: found, at: words.length, stop: "end" };
210
+ }
211
+
212
+ /**
213
+ * The options `words` start with, {@link UNKNOWN} where `options` does not hold one, or
214
+ * `undefined` where a needed word is dynamic.
215
+ */
216
+ function optionsOf(words: readonly Word[], options: Options): Read | Unknown | undefined {
217
+ const scan = scanOptions(words, options);
218
+ if (scan.stop === "unread") {
219
+ return UNKNOWN;
220
+ }
221
+ if (scan.stop === "dynamic") {
222
+ return undefined;
223
+ }
224
+ const values = new Map<string, string[]>();
225
+ const named = new Set<string>();
226
+ for (const { name, value } of scan.options) {
227
+ named.add(name);
228
+ const text = value === undefined ? undefined : words[value.at]?.value;
229
+ if (value !== undefined && text === undefined) {
89
230
  return undefined;
90
231
  }
232
+ if (value !== undefined && text !== undefined) {
233
+ values.set(name, [...(values.get(name) ?? []), text.slice(value.prefix.length)]);
234
+ }
91
235
  }
92
- return { values, at: words.length, ended: false };
236
+ return { values, named, at: scan.at, ended: scan.stop === "ended" };
237
+ }
238
+
239
+ /** The place of the first operand after the options `words` start with, `undefined` where {@link optionsOf} reads none. */
240
+ function operandAt(words: readonly Word[], options: Options): number | undefined {
241
+ const read = optionsOf(words, options);
242
+ return read === undefined || isUnknown(read) ? undefined : read.at;
93
243
  }
94
244
 
95
245
  /**
@@ -140,77 +290,255 @@ export function preloadTarget(
140
290
  return path === undefined ? undefined : { path: resolvePath(dir, path) };
141
291
  }
142
292
 
143
- /** A runtime: its preload options, then its entry, and every later word handed to the program. */
144
- function runtime(
145
- preloads: ReadonlyMap<string, Preload["resolution"]>,
146
- flags: readonly string[],
147
- runsNode: boolean,
148
- subcommand?: string,
149
- ): Reader {
150
- const options = { valued: new Set(preloads.keys()), flags: new Set(flags) };
293
+ /** A runtime's command line: its preload options, then its entry, and every later word handed to the program. */
294
+ interface Runtime {
295
+ readonly preloads: ReadonlyMap<string, Preload["resolution"]>;
296
+ readonly flags: readonly string[];
297
+ /** The other options that take a value, the next word or the text after `=`. */
298
+ readonly valued?: readonly string[];
299
+ /** The options whose value is only written after `=`, as V8's `--stack-size=`. */
300
+ readonly optional?: readonly string[];
301
+ /** Whether the entry runs under Node itself. */
302
+ readonly runsNode: boolean;
303
+ /** The words that pick a mode that runs the entry, after its options or before them, as tsx's `watch`. */
304
+ readonly subcommands?: ReadonlySet<string>;
305
+ /** The words that pick a mode that runs no entry, as `bun install`. */
306
+ readonly modes?: ReadonlySet<string>;
307
+ /** The only options that may stand before a word that picks a mode, where not every one may. */
308
+ readonly modeAfter?: ReadonlySet<string>;
309
+ /** The options whose value it runs in its entry's place, as Node's `--eval`. */
310
+ readonly inline?: readonly string[];
311
+ /** Whether it runs its standard input as its entry where none is written, as Node does. */
312
+ readonly stdin?: boolean;
313
+ /** The options that run its test runner in its entry's place, whose files `entries/test-runners.ts` reads. */
314
+ readonly runner?: readonly string[];
315
+ }
316
+
317
+ /** Where a runtime's words, those after its program word, write its entry, and what it preloads. */
318
+ interface RuntimeLine {
319
+ /** The entry's place among the words, `undefined` where none is written. */
320
+ readonly entry: number | undefined;
321
+ readonly preloads: readonly Preload[];
322
+ /** Whether it runs its standard input in its entry's place. */
323
+ readonly stdin: boolean;
324
+ /** Where an option runs its test runner, the place of the first word naming the files it runs. */
325
+ readonly tests: number | undefined;
326
+ /** The name of each option read. */
327
+ readonly named: ReadonlySet<string>;
328
+ }
329
+
330
+ /** The options of a runtime's command line, as {@link optionsOf} reads them. */
331
+ function runtimeOptions(line: Runtime): Options {
332
+ return {
333
+ valued: new Set([...line.preloads.keys(), ...(line.inline ?? []), ...(line.valued ?? [])]),
334
+ flags: new Set([...line.flags, ...(line.stdin === true ? ["-"] : [])]),
335
+ optional: new Set(line.optional),
336
+ };
337
+ }
338
+
339
+ /** The entries that name the runtime's own standard input: https://man7.org/linux/man-pages/man4/fd.4.html */
340
+ const STDIN_PATHS: ReadonlySet<string> = new Set(["/dev/stdin", "/dev/fd/0"]);
341
+
342
+ /**
343
+ * How a runtime reads its words, those after its program word, {@link UNKNOWN} where one of
344
+ * its options is unhandled, or `undefined` where a word it needs is dynamic: the one reading
345
+ * layer 2 runs a runtime by and layer 3 finds the word that may write its entry by.
346
+ */
347
+ function runtimeLine(line: Runtime, words: readonly Word[]): RuntimeLine | Unknown | undefined {
348
+ const options = runtimeOptions(line);
349
+ const before = optionsOf(words, options);
350
+ if (before === undefined || isUnknown(before)) {
351
+ return before;
352
+ }
353
+ const picked = words[before.at]?.value;
354
+ const modal =
355
+ line.modes?.has(picked ?? "") === true &&
356
+ words
357
+ .slice(0, before.at)
358
+ .every((word) => line.modeAfter === undefined || line.modeAfter.has(word.value ?? ""));
359
+ const from = picked !== undefined && line.subcommands?.has(picked) === true ? before.at + 1 : 0;
360
+ const read = from === 0 ? before : optionsOf(words.slice(from), options);
361
+ if (read === undefined || isUnknown(read)) {
362
+ return read;
363
+ }
364
+ const values = (name: string): readonly string[] => [
365
+ ...(from === 0 ? [] : (before.values.get(name) ?? [])),
366
+ ...(read.values.get(name) ?? []),
367
+ ];
368
+ const named = new Set([...(from === 0 ? [] : before.named), ...read.named]);
369
+ const runner = (line.runner ?? []).some((name) => named.has(name));
370
+ const inline = runner || (line.inline ?? []).some((name) => values(name).length > 0);
371
+ // `-` runs its standard input, and hands every later word to it.
372
+ const dash = words.slice(0, from + read.at).some((word) => word.value === "-");
373
+ const entry = dash ? words.length : from + read.at;
374
+ const piped = STDIN_PATHS.has(words[entry]?.value ?? "");
375
+ return {
376
+ entry: inline || piped || entry >= words.length || (from === 0 && modal) ? undefined : entry,
377
+ stdin: !inline && (piped || (line.stdin === true && entry >= words.length)),
378
+ tests: runner ? from + read.at : undefined,
379
+ named,
380
+ preloads: [...line.preloads].flatMap(([name, resolution]) =>
381
+ values(name).map((specifier) => ({ specifier, resolution })),
382
+ ),
383
+ };
384
+ }
385
+
386
+ /** The reader of a runtime's command line. */
387
+ function runtime(line: Runtime): Reader {
151
388
  return (words) => {
152
- const from = subcommand !== undefined && words[0]?.value === subcommand ? 1 : 0;
153
- const read = optionsOf(words.slice(from), options);
154
- if (read === undefined) {
155
- return undefined;
389
+ const read = runtimeLine(line, words);
390
+ if (isUnknown(read)) {
391
+ return UNKNOWN;
156
392
  }
157
- const entry = words[from + read.at];
158
- const loads = [...preloads].flatMap(([name, resolution]) =>
159
- (read.values.get(name) ?? []).map((specifier) => ({ specifier, resolution })),
160
- );
393
+ const entry = read?.entry === undefined ? undefined : words[read.entry];
161
394
  if (
395
+ read === undefined ||
396
+ (read.stdin && words.some((word) => word.value === "-")) ||
162
397
  (entry !== undefined && entry.value === undefined) ||
163
- loads.some((one) => preloadTarget(one, "/") === undefined)
398
+ read.preloads.some((one) => preloadTarget(one, "/") === undefined)
164
399
  ) {
165
400
  return undefined;
166
401
  }
167
402
  return {
168
403
  kind: "run",
169
404
  entry: entry?.value,
170
- preloads: loads,
171
- runtime: runsNode && entry !== undefined ? "node" : undefined,
405
+ preloads: read.preloads,
406
+ runtime: line.runsNode && entry !== undefined ? "node" : undefined,
172
407
  };
173
408
  };
174
409
  }
175
410
 
411
+ /** The options that run their value, code or a script's name, in a runtime's entry's place. */
412
+ const INLINE = ["-e", "--eval", "-p", "--print"];
413
+
176
414
  /**
177
415
  * `node [options] [entry] [arguments]`, https://nodejs.org/api/cli.html: the options that load
178
- * a module before the entry, `-r` as `require` does and the rest as an ECMAScript module.
179
- * `-e`, `-p` and `-` run text that is no file, so they are unhandled.
416
+ * a module before the entry, `-r` as `require` does and the rest as an ECMAScript module;
417
+ * `--run`, which runs a script of the manifest; and the options that leave the entry where it
418
+ * is, `--inspect` and its kin and V8's taking their value only after `=`. With no entry, or
419
+ * `-`, it runs its standard input, which the second layer does not read, but where `--test`
420
+ * runs its test runner on the files its operands, or its default patterns, name.
180
421
  */
181
- const node = runtime(
182
- new Map([
422
+ const NODE: Runtime = {
423
+ preloads: new Map([
183
424
  ["-r", "require"],
184
425
  ["--require", "require"],
185
426
  ["--import", "import"],
186
427
  ["--loader", "import"],
187
428
  ["--experimental-loader", "import"],
188
429
  ]),
189
- [],
190
- true,
191
- );
430
+ flags: long(
431
+ ...["no-warnings", "no-deprecation", "enable-source-maps", "experimental-vm-modules"],
432
+ ...["experimental-strip-types", "no-experimental-strip-types", "experimental-transform-types"],
433
+ ...["experimental-detect-module", "no-experimental-detect-module"],
434
+ ...["experimental-require-module", "no-experimental-require-module", "trace-warnings"],
435
+ ...["trace-deprecation", "trace-uncaught", "throw-deprecation", "pending-deprecation"],
436
+ ...["expose-gc", "preserve-symlinks", "preserve-symlinks-main", "frozen-intrinsics"],
437
+ ...["abort-on-uncaught-exception", "watch", "watch-preserve-output", "jitless"],
438
+ ...["openssl-legacy-provider", "use-openssl-ca", "use-system-ca", "use-bundled-ca"],
439
+ ...["no-addons", "insecure-http-parser", "version", "help", "v8-options", "test"],
440
+ ...["test-only", "test-force-exit", "test-update-snapshots", "test-randomize"],
441
+ ...["experimental-test-coverage", "experimental-test-module-mocks", "no-strip-types"],
442
+ ...["experimental-test-snapshots"],
443
+ ).concat(["-v", "-h"]),
444
+ valued: [
445
+ "-C",
446
+ ...long("conditions", "input-type", "env-file", "env-file-if-exists", "title"),
447
+ ...long("disable-warning", "unhandled-rejections", "watch-path", "dns-result-order"),
448
+ ...long("icu-data-dir", "redirect-warnings", "diagnostic-dir", "max-http-header-size"),
449
+ ...long("test-reporter", "test-reporter-destination", "test-concurrency", "test-timeout"),
450
+ ...long("test-name-pattern", "test-skip-pattern", "test-shard", "test-isolation"),
451
+ ...long("test-coverage-include", "test-coverage-exclude", "test-coverage-branches"),
452
+ ...long("test-coverage-functions", "test-coverage-lines", "test-global-setup"),
453
+ ],
454
+ optional: long("inspect", "inspect-brk", "inspect-wait", "max-old-space-size", "stack-size"),
455
+ runsNode: true,
456
+ inline: [...INLINE, "--run"],
457
+ stdin: true,
458
+ runner: ["--test"],
459
+ };
192
460
 
193
- /** `tsx [watch] [options] <file> [arguments]`, https://tsx.is/usage, https://tsx.is/node-enhancement */
194
- const tsx = runtime(
195
- new Map([
461
+ /**
462
+ * `tsx [watch] [options] <file> [arguments]`, https://tsx.is/usage, which takes Node's options
463
+ * too: https://tsx.is/node-enhancement
464
+ */
465
+ const TSX: Runtime = {
466
+ preloads: new Map([
196
467
  ["-r", "require"],
197
468
  ["--require", "require"],
198
469
  ["--import", "import"],
199
470
  ]),
200
- [],
201
- false,
202
- "watch",
203
- );
471
+ // `tsx --test` runs Node's test runner on files only `node --test` is read for, so it caps.
472
+ flags: [...NODE.flags.filter((name) => name !== "--test"), "--no-cache"],
473
+ valued: [...(NODE.valued ?? []), "--tsconfig"],
474
+ optional: NODE.optional ?? [],
475
+ runsNode: false,
476
+ subcommands: new Set(["watch"]),
477
+ stdin: true,
478
+ };
204
479
 
205
480
  /** `ts-node [options] [script] [arguments]`, https://typestrong.org/ts-node/docs/options; `--esm` picks no file. */
206
- const tsNode = runtime(
207
- new Map([
481
+ const TS_NODE: Runtime = {
482
+ preloads: new Map([
208
483
  ["-r", "require"],
209
484
  ["--require", "require"],
210
485
  ]),
211
- ["--esm"],
212
- false,
213
- );
486
+ flags: ["--esm", "-v", "--version", "-h", "--help"],
487
+ runsNode: false,
488
+ inline: INLINE,
489
+ };
490
+
491
+ /**
492
+ * `bun [options] [run] [options] <file> [arguments]`, https://bun.com/docs/cli/run; its package
493
+ * manager, test runner, bundler and `bun x` subcommands run no entry.
494
+ */
495
+ const BUN: Runtime = {
496
+ preloads: new Map([
497
+ ["-r", "require"],
498
+ ["--preload", "require"],
499
+ ]),
500
+ flags: [
501
+ "--watch",
502
+ "--hot",
503
+ "--smol",
504
+ "--bun",
505
+ "--silent",
506
+ "--no-install",
507
+ "-v",
508
+ "--version",
509
+ ].concat(["-h", "--help"]),
510
+ runsNode: false,
511
+ subcommands: new Set(["run"]),
512
+ modes: new Set(
513
+ ["install", "i", "add", "a", "remove", "rm", "update", "outdated", "link", "unlink", "pm"]
514
+ .concat(["test", "build", "init", "create", "c", "upgrade", "publish", "patch", "audit"])
515
+ .concat(["info", "repl", "exec", "x"]),
516
+ ),
517
+ inline: INLINE,
518
+ };
519
+
520
+ /**
521
+ * `deno [run|serve] [options] <file> [arguments]`, https://docs.deno.com/runtime/reference/cli/run/
522
+ * and https://docs.deno.com/runtime/reference/cli/serve/, with the permission flags written
523
+ * whole; its other subcommands run no entry: https://docs.deno.com/runtime/reference/cli/,
524
+ * found past its global options only, so `deno -A lint` runs `lint`:
525
+ * https://github.com/denoland/deno/blob/main/libs/cli_parser/src/parse.rs
526
+ */
527
+ const DENO: Runtime = {
528
+ preloads: new Map(),
529
+ flags: ["-A", "--allow-all", "-q", "--quiet", "--watch", "--no-check", "-V", "--version"].concat(
530
+ ["-h", "--help"],
531
+ ["read", "write", "net", "env", "run", "sys", "ffi", "import"].map((name) => `--allow-${name}`),
532
+ ),
533
+ runsNode: false,
534
+ subcommands: new Set(["run", "serve"]),
535
+ modeAfter: new Set(["-q", "--quiet"]),
536
+ modes: new Set(
537
+ ["task", "test", "fmt", "lint", "compile", "install", "uninstall", "eval", "repl", "bench"]
538
+ .concat(["check", "doc", "info", "init", "upgrade", "cache", "coverage", "types", "add"])
539
+ .concat(["remove", "outdated", "publish", "jupyter", "clean", "completions", "help"]),
540
+ ),
541
+ };
214
542
 
215
543
  /** Hands on the command `words` from `at`, after the assignments `assigns` accepts. */
216
544
  function afterAssignments(
@@ -219,8 +547,12 @@ function afterAssignments(
219
547
  assigns: (value: string) => boolean,
220
548
  ): Reading | undefined {
221
549
  let end = at;
222
- while (end < words.length && words[end]?.value?.includes("=") === true) {
223
- if (!assigns(words[end]?.value ?? "")) {
550
+ for (
551
+ let word = words[end];
552
+ word !== undefined && startOf(word).includes("=");
553
+ word = words[end]
554
+ ) {
555
+ if (!assigns(startOf(word))) {
224
556
  return undefined;
225
557
  }
226
558
  end += 1;
@@ -231,66 +563,140 @@ function afterAssignments(
231
563
  }
232
564
  return program === undefined
233
565
  ? { kind: "run", entry: undefined, preloads: [], runtime: undefined }
234
- : { kind: "command", words: words.slice(end) };
566
+ : { kind: "command", words: words.slice(end), at: end };
567
+ }
568
+
569
+ /**
570
+ * GNU env's options, https://www.gnu.org/software/coreutils/manual/html_node/env-invocation.html,
571
+ * whose `-S` and `--split-string` give a command line:
572
+ * https://www.gnu.org/software/coreutils/manual/html_node/env-invocation.html#g_t_002dS_002f_002d_002dsplit_002dstring-usage-in-scripts
573
+ */
574
+ const ENV: Options = {
575
+ valued: new Set(["-u", "--unset", "-S", "--split-string", "-C", "--chdir"]),
576
+ flags: new Set([
577
+ ...["-i", "--ignore-environment", "-", "-v", "--debug", "-0", "--null"],
578
+ "--list-signal-handling",
579
+ ]),
580
+ attached: true,
581
+ optional: new Set(long("default-signal", "ignore-signal", "block-signal")),
582
+ };
583
+
584
+ /** env's options that give a command line, and the one that moves it to another directory. */
585
+ const SPLIT: ReadonlySet<string> = new Set(["-S", "--split-string"]);
586
+ const CHDIR: ReadonlySet<string> = new Set(["-C", "--chdir"]);
587
+
588
+ /**
589
+ * The command line env runs where a `-S` or `--split-string` stands: GNU env splits its value
590
+ * on blanks, puts those words in place of the option and its value, before env's later words,
591
+ * and reads its options again from the first of them, parse_split_string of
592
+ * https://github.com/coreutils/coreutils/blob/master/src/env.c. {@link UNKNOWN} where the value
593
+ * is dynamic, writes a quote, an escape, a `$` or a `#`, which env reads otherwise, or follows
594
+ * a `-C`; `undefined` where none stands.
595
+ */
596
+ function envSplit(words: readonly Word[]): string | Unknown | undefined {
597
+ const { options } = scanOptions(words, ENV);
598
+ const split = options.findIndex(({ name }) => SPLIT.has(name));
599
+ const value = options[split]?.value;
600
+ if (value === undefined) {
601
+ return undefined;
602
+ }
603
+ const text = words[value.at]?.value?.slice(value.prefix.length);
604
+ if (
605
+ text === undefined ||
606
+ /['"\\$#]/u.test(text) ||
607
+ options.slice(0, split).some(({ name }) => CHDIR.has(name))
608
+ ) {
609
+ return UNKNOWN;
610
+ }
611
+ const spliced = text.split(/\s+/u).filter((one) => one !== "");
612
+ const later = words.slice(value.at + 1).map((word) => word.text);
613
+ return ["env", ...spliced.map((one) => `'${one}'`), ...later].join(" ");
235
614
  }
236
615
 
237
616
  /**
238
- * `env [option]... [name=value]... [command [argument]...]`,
239
- * https://www.gnu.org/software/coreutils/manual/html_node/env-invocation.html: the options that
240
- * change only the environment, `-i`, `-u` and `-v` in each spelling. GNU env takes any word
241
- * holding an `=` after its first character as an assignment, and fails before running anything
242
- * on a `-u` name that is empty or holds an `=`.
617
+ * `env [option]... [name=value]... [command [argument]...]`: the options that change only the
618
+ * environment, `-i`, `-u` and `-v` in each spelling, and `-S`, read by {@link envSplit}. GNU env
619
+ * takes any word holding an `=` after its first character as an assignment, and fails before
620
+ * running anything on a `-u` name that is empty or holds an `=`. A dynamic word whose written
621
+ * start is an assignment's is one, as env reads the text it expands to.
243
622
  */
244
623
  const env: Reader = (words) => {
245
- const read = optionsOf(words, {
246
- valued: new Set(["-u", "--unset"]),
247
- flags: new Set(["-i", "--ignore-environment", "-", "-v", "--debug"]),
248
- attached: true,
249
- });
250
- const unset = [...(read?.values.get("-u") ?? []), ...(read?.values.get("--unset") ?? [])];
251
- return read === undefined || unset.some((name) => name === "" || name.includes("="))
624
+ const split = envSplit(words);
625
+ if (split !== undefined) {
626
+ return typeof split === "string" ? { kind: "line", text: split } : split;
627
+ }
628
+ const read = optionsOf(words, ENV);
629
+ if (read === undefined || isUnknown(read) || [...CHDIR].some((name) => read.named.has(name))) {
630
+ return read === undefined ? undefined : UNKNOWN;
631
+ }
632
+ const unset = [...(read.values.get("-u") ?? []), ...(read.values.get("--unset") ?? [])];
633
+ return unset.some((name) => name === "" || name.includes("="))
252
634
  ? undefined
253
635
  : afterAssignments(words, read.at, (value) => !value.startsWith("="));
254
636
  };
255
637
 
638
+ /** The command line env runs where a `-S` stands, as {@link envSplit} reads it. */
639
+ function envLines(words: readonly Word[]): Lines {
640
+ const split = envSplit(words);
641
+ return typeof split === "string" ? { ...NO_LINES, texts: [split] } : NO_LINES;
642
+ }
643
+
256
644
  /** `cross-env [name=value]... command [argument]...`, https://github.com/kentcdodds/cross-env#usage */
257
645
  const crossEnv: Reader = (words) => {
258
646
  const reading = afterAssignments(words, 0, (value) => /^\w+=/u.test(value));
259
647
  return reading?.kind === "command" ? reading : undefined;
260
648
  };
261
649
 
650
+ /** The command a package runner's operand names: a package specifier's, whose version picks no other command. */
651
+ function commandName(operand: string, packaged: boolean): string {
652
+ return packaged ? operand.replace(/(?<=.)@[^/@]*$/u, "") : operand;
653
+ }
654
+
262
655
  /** The command a package runner hands on: its first word a package's or a command's name. */
263
656
  function runnerCommand(words: readonly Word[], packaged: boolean): Reading | undefined {
264
657
  const [first, ...rest] = words;
265
658
  if (first?.value === undefined || first.value.startsWith("-")) {
266
659
  return undefined;
267
660
  }
268
- // A package specifier names the package's command; a version picks no other command.
269
- const name = packaged ? first.value.replace(/(?<=.)@[^/@]*$/u, "") : first.value;
270
- return { kind: "command", words: [{ value: name, text: first.text }, ...rest] };
661
+ const name = commandName(first.value, packaged);
662
+ return { kind: "command", words: [{ value: name, text: first.text }, ...rest], at: 0 };
663
+ }
664
+
665
+ /**
666
+ * npx's and npm exec's options, https://docs.npmjs.com/cli/v11/commands/npx and
667
+ * https://docs.npmjs.com/cli/v11/commands/npm-exec: `--package` (and npx's `-p`), `-c` and
668
+ * `--call`, which give a command line, `--yes` and `--no`.
669
+ */
670
+ function packageExecOptions(shortPackage: boolean): Options {
671
+ return {
672
+ valued: new Set(["--package", "-c", "--call", ...(shortPackage ? ["-p"] : [])]),
673
+ flags: new Set(["-y", "--yes", "--no"]),
674
+ };
271
675
  }
272
676
 
677
+ const NPX = packageExecOptions(true);
678
+ const NPM_EXEC = packageExecOptions(false);
679
+ const CALL: ReadonlySet<string> = new Set(["-c", "--call"]);
680
+
273
681
  /**
274
- * npx and npm exec, https://docs.npmjs.com/cli/v11/commands/npx and
275
- * https://docs.npmjs.com/cli/v11/commands/npm-exec: `--package` (and npx's `-p`), `--call`,
276
- * `--yes` and `--no`, before the first positional word, which is the package whose command
277
- * runs unless a `--package` names one. npm exec reads options after it too, unless a `--` ends them.
682
+ * npx and npm exec, by `options`, before the first positional word, which is the package whose
683
+ * command runs unless a `--package` names one. npm exec reads options after it too, unless a
684
+ * `--` ends them.
278
685
  */
279
- function packageExec(shortPackage: boolean, laterOptions: boolean): Reader {
280
- const valued = new Set(["--package", "-c", "--call", ...(shortPackage ? ["-p"] : [])]);
686
+ function packageExec(options: Options, laterOptions: boolean): Reader {
281
687
  return (words) => {
282
- const read = optionsOf(words, { valued, flags: new Set(["-y", "--yes", "--no"]) });
283
- if (read === undefined) {
284
- return undefined;
688
+ const read = optionsOf(words, options);
689
+ if (read === undefined || isUnknown(read)) {
690
+ return read;
285
691
  }
286
692
  const operands = words.slice(read.at);
287
- if (
288
- laterOptions &&
289
- !read.ended &&
290
- operands.some((word) => word.value?.startsWith("-") !== false)
291
- ) {
693
+ if (laterOptions && !read.ended && operands.some((word) => word.value === undefined)) {
292
694
  return undefined;
293
695
  }
696
+ // npm exec reads an option after its command as its own, whose table no row places.
697
+ if (laterOptions && !read.ended && operands.some((word) => word.value?.startsWith("-"))) {
698
+ return UNKNOWN;
699
+ }
294
700
  const call = [...(read.values.get("-c") ?? []), ...(read.values.get("--call") ?? [])];
295
701
  if (call.length > 0) {
296
702
  return call.length === 1 && operands.length === 0 && call[0] !== undefined
@@ -298,12 +704,12 @@ function packageExec(shortPackage: boolean, laterOptions: boolean): Reader {
298
704
  : undefined;
299
705
  }
300
706
  const packages = [...(read.values.get("--package") ?? []), ...(read.values.get("-p") ?? [])];
301
- return runnerCommand(operands, packages.length === 0);
707
+ return after(read.at, runnerCommand(operands, packages.length === 0));
302
708
  };
303
709
  }
304
710
 
305
- const npx = packageExec(true, false);
306
- const npmExec = packageExec(false, true);
711
+ const npx = packageExec(NPX, false);
712
+ const npmExec = packageExec(NPM_EXEC, true);
307
713
 
308
714
  /**
309
715
  * `npm run <script> [-- <arguments>]`, https://docs.npmjs.com/cli/v11/commands/npm-run-script:
@@ -323,12 +729,37 @@ const npmRun: Reader = (words) => {
323
729
  return { kind: "run", entry: undefined, preloads: [], runtime: undefined };
324
730
  };
325
731
 
326
- /** A tool whose second word picks the reader of the rest. */
327
- function subcommands(readers: ReadonlyMap<string, Reader>): Reader {
328
- return ([first, ...rest]) =>
329
- first?.value === undefined ? undefined : readers.get(first.value)?.(rest);
732
+ /** `reading`, of the words after `skipped` others, as a reading of those words and them. */
733
+ function after<Read extends Reading | Unknown | undefined>(skipped: number, reading: Read): Read {
734
+ return reading?.kind === "command" ? { ...reading, at: reading.at + skipped } : reading;
735
+ }
736
+
737
+ /**
738
+ * A tool whose second word picks the reader of the rest. Where its own options `options` stand
739
+ * before that word, it is not read, as they may run the command in another directory, and
740
+ * {@link UNKNOWN} where `options` does not hold one.
741
+ */
742
+ function subcommands(readers: ReadonlyMap<string, Reader>, options?: Options): Reader {
743
+ return (words) => {
744
+ const [first, ...rest] = words;
745
+ if (options !== undefined && first?.value?.startsWith("-") === true) {
746
+ return isUnknown(optionsOf(words, options)) ? UNKNOWN : undefined;
747
+ }
748
+ return first?.value === undefined ? undefined : after(1, readers.get(first.value)?.(rest));
749
+ };
330
750
  }
331
751
 
752
+ /** npm's own options before its subcommand: https://docs.npmjs.com/cli/v11/using-npm/config */
753
+ const NPM: Options = {
754
+ valued: new Set(["-w", ...long("workspace", "prefix", "loglevel")]),
755
+ flags: new Set([
756
+ ...["-ws", ...letters("sqdy")],
757
+ ...long("workspaces", "no-workspaces", "include-workspace-root", "if-present", "silent"),
758
+ ...long("quiet", "verbose", "yes", "no"),
759
+ ...["-v", "-h", ...long("version", "help")],
760
+ ]),
761
+ };
762
+
332
763
  /** `npm exec`, `npm x` and `npm run` with `run-script`. */
333
764
  const npm = subcommands(
334
765
  new Map([
@@ -337,58 +768,125 @@ const npm = subcommands(
337
768
  ["run", npmRun],
338
769
  ["run-script", npmRun],
339
770
  ]),
771
+ NPM,
340
772
  );
341
773
 
774
+ /** `yarn dlx`'s options: https://yarnpkg.com/cli/dlx */
775
+ const YARN_DLX: Options = {
776
+ valued: new Set(["-p", "--package"]),
777
+ flags: new Set(["-q", "--quiet"]),
778
+ };
779
+
780
+ /** `bunx`'s options: https://bun.com/docs/pm/bunx */
781
+ const BUNX: Options = { valued: new Set(["-p", "--package"]), flags: new Set() };
782
+
783
+ /**
784
+ * pnpm's own options, which it reads before or after its subcommand, `exec`'s `-c` among them:
785
+ * https://pnpm.io/pnpm-cli#options, https://pnpm.io/filtering, https://pnpm.io/cli/recursive,
786
+ * https://pnpm.io/cli/exec
787
+ */
788
+ const PNPM: Options = {
789
+ valued: new Set([
790
+ ...["-C", "-F", ...long("dir", "filter", "filter-prod", "test-pattern", "resume-from")],
791
+ ...long("changed-files-ignore-pattern", "workspace-concurrency", "reporter", "loglevel"),
792
+ ]),
793
+ flags: new Set([
794
+ ...letters("wrscvh"),
795
+ ...long("version", "help"),
796
+ ...long("workspace-root", "recursive", "parallel", "stream", "aggregate-output", "bail"),
797
+ ...long("no-bail", "sort", "no-sort", "reverse", "report-summary", "fail-if-no-match"),
798
+ ...long("silent", "color", "no-color", "shell-mode"),
799
+ ]),
800
+ attached: true,
801
+ };
802
+
803
+ /** The options of `pnpm exec` and `pnpm dlx` that run their operands in a shell: https://pnpm.io/cli/exec, https://pnpm.io/cli/dlx */
804
+ const PNPM_LINES: ReadonlySet<string> = new Set(["-c", "--shell-mode"]);
805
+
806
+ /** pnpm's options after `dlx`, its own and `--package`. */
807
+ const PNPM_DLX_OPTIONS: Options = { ...PNPM, valued: new Set([...PNPM.valued, "--package"]) };
808
+
809
+ /**
810
+ * `pnpm exec <command>`, https://pnpm.io/cli/exec, which hands on every later word, read where
811
+ * no option of pnpm's stands before it, which may run it in another directory or a shell.
812
+ */
813
+ const pnpmExec: Reader = (words) => {
814
+ const read = optionsOf(words, PNPM);
815
+ if (read === undefined || isUnknown(read)) {
816
+ return read;
817
+ }
818
+ return read.at > 0 ? undefined : runnerCommand(words, false);
819
+ };
820
+
342
821
  /**
343
- * `pnpm exec <command>`, https://pnpm.io/cli/exec, which hands on every later word, and
344
- * `pnpm dlx [--package <name>]... <command>`, https://pnpm.io/cli/dlx.
822
+ * `pnpm dlx [--package <name>]... <command>`, https://pnpm.io/cli/dlx, and `pnpx`, its alias:
823
+ * https://pnpm.io/cli/dlx#pnpx. Another option of pnpm's stands only unread.
345
824
  */
825
+ const pnpmDlx: Reader = (words) => {
826
+ const read = optionsOf(words, PNPM_DLX_OPTIONS);
827
+ if (read === undefined || isUnknown(read)) {
828
+ return read;
829
+ }
830
+ return [...read.named].some((name) => name !== "--package")
831
+ ? undefined
832
+ : after(read.at, runnerCommand(words.slice(read.at), !read.values.has("--package")));
833
+ };
834
+
346
835
  const pnpm = subcommands(
347
836
  new Map<string, Reader>([
348
- ["exec", (words) => runnerCommand(words, false)],
349
- [
350
- "dlx",
351
- (words) => {
352
- const read = optionsOf(words, { valued: new Set(["--package"]), flags: new Set() });
353
- return read === undefined
354
- ? undefined
355
- : runnerCommand(words.slice(read.at), !read.values.has("--package"));
356
- },
357
- ],
837
+ ["exec", pnpmExec],
838
+ ["dlx", pnpmDlx],
358
839
  ]),
840
+ PNPM,
359
841
  );
360
842
 
843
+ /** yarn's own options before its subcommand: https://yarnpkg.com/cli */
844
+ const YARN: Options = {
845
+ valued: new Set(["--cwd"]),
846
+ flags: new Set(["-v", "-h", ...long("version", "help")]),
847
+ };
848
+
361
849
  /**
362
850
  * `yarn node`, https://yarnpkg.com/cli/node, and `yarn dlx [-p <name>]... <command>`,
363
851
  * https://yarnpkg.com/cli/dlx, whose command runs in the current directory.
364
852
  */
365
853
  const yarn = subcommands(
366
854
  new Map<string, Reader>([
367
- ["node", (words) => ({ kind: "command", words: [{ value: "node", text: "node" }, ...words] })],
855
+ [
856
+ "node",
857
+ (words) => ({ kind: "command", words: [{ value: "node", text: "node" }, ...words], at: -1 }),
858
+ ],
368
859
  [
369
860
  "dlx",
370
861
  (words) => {
371
- const read = optionsOf(words, {
372
- valued: new Set(["-p", "--package"]),
373
- flags: new Set(["-q", "--quiet"]),
374
- });
375
- return read === undefined
376
- ? undefined
377
- : runnerCommand(
378
- words.slice(read.at),
379
- !read.values.has("-p") && !read.values.has("--package"),
862
+ const read = optionsOf(words, YARN_DLX);
863
+ return read === undefined || isUnknown(read)
864
+ ? read
865
+ : after(
866
+ read.at,
867
+ runnerCommand(
868
+ words.slice(read.at),
869
+ !read.values.has("-p") && !read.values.has("--package"),
870
+ ),
380
871
  );
381
872
  },
382
873
  ],
383
874
  ]),
875
+ YARN,
384
876
  );
385
877
 
386
878
  /** `bunx [-p <name>] <command>[@version] [arguments]`, https://bun.com/docs/pm/bunx */
387
879
  const bunx: Reader = (words) => {
388
- const read = optionsOf(words, { valued: new Set(["-p", "--package"]), flags: new Set() });
389
- return read === undefined ? undefined : runnerCommand(words.slice(read.at), true);
880
+ const read = optionsOf(words, BUNX);
881
+ return read === undefined || isUnknown(read)
882
+ ? read
883
+ : after(read.at, runnerCommand(words.slice(read.at), true));
390
884
  };
391
885
 
886
+ /** `bun x`, which is `bunx`, and else bun's own command line. */
887
+ const bun: Reader = (words) =>
888
+ words[0]?.value === "x" ? after(1, bunx(words.slice(1))) : runtime(BUN)(words);
889
+
392
890
  /**
393
891
  * The options a tool documents for naming its configuration file, each taking the file as its
394
892
  * value, the next word or, for a long one, the text after `=`.
@@ -399,80 +897,1352 @@ interface ConfigurationOptions {
399
897
  readonly beforeOperand?: ReadonlySet<string>;
400
898
  }
401
899
 
402
- /** One tool's row: the reader of its command, and the options it names its configuration file by. */
403
- interface Row {
404
- readonly read?: Reader;
405
- readonly configurations?: ConfigurationOptions;
900
+ /** Where the words after a tool's program word write the command lines it hands to a shell. */
901
+ interface Lines {
902
+ /** The places of the words that may each be one, each with the option text it starts with. */
903
+ readonly at: ReadonlyMap<number, string>;
904
+ /** Whether it reads command lines from its standard input. */
905
+ readonly input: boolean;
906
+ /** Whether it runs the first word a tool before it appends, as `xargs sh -c` and `xargs bash` do. */
907
+ readonly appended?: boolean;
908
+ /**
909
+ * Whether it may run a file no word writes: a script none writes, as `bash -- "$1"` runs, or
910
+ * one after an option its table does not list.
911
+ */
912
+ readonly unwritten?: boolean;
913
+ /** The places, each from one word up to before another, whose words joined by blanks are one command line. */
914
+ readonly joined?: readonly (readonly [number, number])[];
915
+ /** The command lines it runs that no word writes whole, as env runs its `-S` value's words before its later ones. */
916
+ readonly texts?: readonly string[];
917
+ /** The texts it replaces, in each word at {@link at}, with a part only the run knows. */
918
+ readonly replaced?: RegExp;
406
919
  }
407
920
 
408
- /** The row of a tool whose reader is `read`. */
409
- function reading(read: Reader): Row {
410
- return { read };
921
+ const NO_LINES: Lines = { at: new Map(), input: false };
922
+
923
+ /** The places `at`, each a whole word that is a command line. */
924
+ function wholeWords(at: readonly number[]): ReadonlyMap<number, string> {
925
+ return new Map(at.map((one) => [one, ""]));
411
926
  }
412
927
 
413
- /** The row of a tool that names its configuration file by `options` anywhere before a `--`. */
414
- function configuredBy(...options: readonly string[]): Row {
415
- return { configurations: { valued: new Set(options) } };
928
+ /** How the words after the program word of a tool that hands its commands the paths it finds read. */
929
+ interface Appending {
930
+ /** Where each command it runs starts, `undefined` where it has an option or a construct no row reads. */
931
+ readonly starts: readonly number[] | undefined;
932
+ /** The texts it replaces with each path, in each word from `from` on. */
933
+ readonly replaced: RegExp | undefined;
934
+ readonly from: number;
935
+ /** Where its command's words end and the paths it is handed begin, as at `parallel`'s `:::`. */
936
+ readonly end?: number;
416
937
  }
417
938
 
418
- /** The row of each tool, by its program word: its name, or a path to it in `node_modules/.bin`. */
419
- const TOOLS: ReadonlyMap<string, Row> = new Map<string, Row>([
420
- ["node", reading(node)],
421
- ["tsx", reading(tsx)],
422
- ["ts-node", reading(tsNode)],
423
- ["env", reading(env)],
424
- ["cross-env", reading(crossEnv)],
425
- [
426
- "npx",
427
- {
428
- read: npx,
429
- // `-c` takes a command line, which is read as a configuration file's name as well, so
430
- // every configuration `fixtures/commands/observed.json` records stays named.
431
- configurations: {
432
- valued: new Set(["-c"]),
433
- beforeOperand: new Set(["--package", "-p", "--call"]),
434
- },
435
- },
436
- ],
437
- ["npm", reading(npm)],
438
- ["pnpm", reading(pnpm)],
439
- ["yarn", reading(yarn)],
440
- ["bunx", reading(bunx)],
441
- // https://eslint.org/docs/latest/use/command-line-interface
442
- ["eslint", configuredBy("-c", "--config")],
443
- // https://prettier.io/docs/cli, whose `-c` is `--check`.
444
- ["prettier", configuredBy("--config")],
445
- // https://stylelint.io/user-guide/cli
446
- ["stylelint", configuredBy("-c", "--config")],
447
- // https://jestjs.io/docs/cli
448
- ["jest", configuredBy("-c", "--config")],
449
- // https://vitest.dev/guide/cli
450
- ["vitest", configuredBy("-c", "--config")],
451
- // https://vite.dev/guide/cli
452
- ["vite", configuredBy("-c", "--config")],
453
- // https://webpack.js.org/api/cli/
454
- ["webpack", configuredBy("-c", "--config")],
455
- // https://mochajs.org/#command-line-usage, whose `-c` is `--color`.
456
- ["mocha", configuredBy("--config")],
457
- ]);
939
+ /** How a tool runs a command written after it. */
940
+ interface Launch {
941
+ /** The subcommands it runs one after, where it runs one only after a subcommand. */
942
+ readonly after?: ReadonlySet<string>;
943
+ /** Its own options, which may stand before its subcommand. */
944
+ readonly options?: Options;
945
+ /** For a tool that hands each command it runs the paths it finds, how its words read. */
946
+ readonly appends?: (words: readonly Word[]) => Appending;
947
+ /** For a tool that hands command lines to a shell, where its words write them. */
948
+ readonly lines?: (words: readonly Word[]) => Lines;
949
+ /** Whether it runs a command only as a line {@link lines} places, as a shell does. */
950
+ readonly line?: boolean;
951
+ }
458
952
 
459
- /** The row of a simple command's program word, by its name or a path to it in `node_modules/.bin`. */
460
- function rowOf(program: Word | undefined): Row | undefined {
461
- const name =
462
- program?.value === undefined
463
- ? undefined
464
- : /^(?:.*\/)?node_modules\/\.bin\/([^/]+)$|^([^/]+)$/u.exec(program.value);
465
- const tool = name?.[1] ?? name?.[2];
466
- return tool === undefined ? undefined : TOOLS.get(tool);
953
+ /** Whether a word's written text, the text beside its parts, writes a path: text other than `/`, `\`, `.` and `..`. */
954
+ function writesPath(written: string | undefined): boolean {
955
+ return (written ?? "")
956
+ .split(/[/\\]/u)
957
+ .some((segment) => !["", ".", ".."].includes(segment.replaceAll(PART, "")));
467
958
  }
468
959
 
469
960
  /**
470
- * What the reader of one simple command's program reads it as, or `undefined` where none
471
- * understands it. A program word that is any other path runs that file, whatever its name.
961
+ * A shell's options: POSIX sh's one-letter ones and the `set` ones bash takes, which may share a
962
+ * word and may start with `+`, https://pubs.opengroup.org/onlinepubs/9799919799/utilities/sh.html,
963
+ * and bash's `-o`, `-O`, `--rcfile` and `--init-file`, which take the next word, and its long
964
+ * flags: https://www.gnu.org/software/bash/manual/html_node/Invoking-Bash.html
472
965
  */
473
- export function readTool(words: readonly Word[]): Reading | undefined {
474
- const [program, ...rest] = words;
475
- return rowOf(program)?.read?.(rest);
966
+ const SHELL_OPTIONS: Options = {
967
+ valued: new Set(["-o", "-O", "--rcfile", "--init-file"]),
968
+ flags: new Set([
969
+ ...letters("abCefhimnuvxlsrBEHPTckptD"),
970
+ ...long("login", "norc", "noprofile", "posix", "restricted", "verbose", "noediting"),
971
+ ...long("debugger", "dump-po-strings", "dump-strings", "help", "version"),
972
+ ]),
973
+ attached: true,
974
+ };
975
+
976
+ /** A shell's one-letter options that give its operand as a command line, and the one that has it read its standard input. */
977
+ const SHELL_LINES: ReadonlySet<string> = new Set(["-c"]);
978
+ const SHELL_INPUT = "-s";
979
+
980
+ /**
981
+ * A shell's command lines: with an option holding `c`, or a dynamic word before its first
982
+ * operand whose written text is only letters, `-` and `+`, each such word and that operand; its
983
+ * standard input where neither stands and no operand names a script, or an option holding `s`
984
+ * or such a word may say so; else its first operand is its script, appended where none stands,
985
+ * its options read by {@link SHELL_OPTIONS}. This is the shell's own scan, not
986
+ * {@link scanOptions}: `-c` is a flag whose operand is the command line, never an attached value.
987
+ */
988
+ function shellLines(words: readonly Word[]): Lines {
989
+ const dynamic: number[] = [];
990
+ let command = false;
991
+ let input = false;
992
+ let unread = false;
993
+ let operand = words.length;
994
+ for (let at = 0; at < words.length; at += 1) {
995
+ const value = words[at]?.value;
996
+ const written = words[at]?.written?.replaceAll(PART, "");
997
+ if (value === undefined && (written === undefined || /^[A-Za-z+-]*$/u.test(written))) {
998
+ dynamic.push(at);
999
+ } else if (value === undefined) {
1000
+ operand = at;
1001
+ break;
1002
+ } else if (value === "--" || value === "-" || !/^[-+]/u.test(value)) {
1003
+ operand = value === "--" || value === "-" ? at + 1 : at;
1004
+ break;
1005
+ } else {
1006
+ const named = value.startsWith("--")
1007
+ ? [value]
1008
+ : Array.from(value.slice(1), (one) => `-${one}`);
1009
+ command ||= value.startsWith("-") && named.some((one) => SHELL_LINES.has(one));
1010
+ input ||= value.startsWith("-") && named.includes(SHELL_INPUT);
1011
+ unread ||= named.some(
1012
+ (one) => !SHELL_OPTIONS.flags.has(one) && !SHELL_OPTIONS.valued.has(one),
1013
+ );
1014
+ at += SHELL_OPTIONS.valued.has(named.at(-1) ?? "") ? 1 : 0;
1015
+ }
1016
+ }
1017
+ const lines = command || dynamic.length > 0;
1018
+ const script = command || input ? undefined : words[operand];
1019
+ return {
1020
+ at: wholeWords(lines ? [...dynamic, ...(operand < words.length ? [operand] : [])] : []),
1021
+ input: !command && (input || dynamic.length > 0 || operand >= words.length),
1022
+ appended: !input && operand >= words.length,
1023
+ unwritten:
1024
+ unread || (script !== undefined && script.value === undefined && !writesPath(script.written)),
1025
+ };
1026
+ }
1027
+
1028
+ /** Every word after the program word, each a command line. */
1029
+ function everyWord(words: readonly Word[]): Lines {
1030
+ return { at: wholeWords(words.map((_, at) => at)), input: false };
1031
+ }
1032
+
1033
+ /**
1034
+ * The values of the options `lines` among a tool's options `options`, each a command line, as
1035
+ * {@link scanOptions} reads them before its first operand or, where `later`, before a `--`,
1036
+ * each with the text its word writes before it. Where an option `options` does not hold
1037
+ * stands, every later word may be one.
1038
+ */
1039
+ function optionLines(
1040
+ options: Options,
1041
+ lines: ReadonlySet<string>,
1042
+ later = false,
1043
+ ): (words: readonly Word[]) => Lines {
1044
+ return (words) => {
1045
+ const at = new Map<number, string>();
1046
+ for (let from = 0; from < words.length;) {
1047
+ const scan = scanOptions(words, options, from);
1048
+ for (const { name, value } of scan.options) {
1049
+ if (value !== undefined && value.at < words.length && lines.has(name)) {
1050
+ at.set(value.at, value.prefix);
1051
+ }
1052
+ }
1053
+ if (scan.stop === "unread") {
1054
+ for (let one = scan.at; one < words.length; one += 1) {
1055
+ at.set(one, "");
1056
+ }
1057
+ }
1058
+ if (scan.stop !== "operand" || !later) {
1059
+ break;
1060
+ }
1061
+ from = scan.at + 1;
1062
+ }
1063
+ return { at, input: false };
1064
+ };
1065
+ }
1066
+
1067
+ /**
1068
+ * The lines of a tool whose subcommand `names`, after its own options `options`, reads its
1069
+ * words, and those options, as `read` does; where one of those options is unread, any word may be one.
1070
+ */
1071
+ function subcommandLines(
1072
+ names: ReadonlySet<string>,
1073
+ read: (words: readonly Word[], before: readonly Word[]) => Lines,
1074
+ options?: Options,
1075
+ ): (words: readonly Word[]) => Lines {
1076
+ return (words) => {
1077
+ const sub = options === undefined ? 0 : operandAt(words, options);
1078
+ if (sub === undefined) {
1079
+ return everyWord(words);
1080
+ }
1081
+ const name = words[sub]?.value;
1082
+ if (name === undefined || !names.has(name)) {
1083
+ return NO_LINES;
1084
+ }
1085
+ const found = read(words.slice(sub + 1), words.slice(0, sub)).at;
1086
+ return { at: new Map([...found].map(([at, prefix]) => [at + sub + 1, prefix])), input: false };
1087
+ };
1088
+ }
1089
+
1090
+ /** The lines of `pnpm dlx` or `pnpx`, its words, after pnpm's options `before`, as {@link pnpmLines} reads them. */
1091
+ function dlxLines(words: readonly Word[], before: readonly Word[] = []): Lines {
1092
+ const scans = [scanOptions(before, PNPM), scanOptions(words, PNPM_DLX_OPTIONS)];
1093
+ return scans.some(
1094
+ (scan) => scan.stop === "unread" || scan.options.some(({ name }) => PNPM_LINES.has(name)),
1095
+ )
1096
+ ? everyWord(words)
1097
+ : NO_LINES;
1098
+ }
1099
+
1100
+ /**
1101
+ * The lines of `pnpm exec` and `pnpm dlx`: every operand where one of {@link PNPM_LINES}
1102
+ * stands among pnpm's options before or after the subcommand, or where one of them is unread.
1103
+ */
1104
+ const pnpmLines = subcommandLines(new Set(["exec", "dlx"]), dlxLines, PNPM);
1105
+
1106
+ /** Each of `texts` as a pattern of each place it stands in a word, or `undefined` where none is. */
1107
+ function textsPattern(texts: readonly string[]): RegExp | undefined {
1108
+ const written = texts.filter((text) => text !== "");
1109
+ return written.length === 0
1110
+ ? undefined
1111
+ : new RegExp(
1112
+ written.map((text) => text.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&")).join("|"),
1113
+ "gu",
1114
+ );
1115
+ }
1116
+
1117
+ /** The words of an appending tool with an option or a construct no row reads. */
1118
+ const UNREAD: Appending = { starts: undefined, replaced: undefined, from: 0 };
1119
+
1120
+ /** The long options named `names`. */
1121
+ function long(...names: readonly string[]): readonly string[] {
1122
+ return names.map((name) => `--${name}`);
1123
+ }
1124
+
1125
+ /** The one-letter options `letters` name. */
1126
+ function letters(written: string): readonly string[] {
1127
+ return Array.from(written, (letter) => `-${letter}`);
1128
+ }
1129
+
1130
+ /** https://www.gnu.org/software/findutils/manual/html_node/find_html/xargs-options.html */
1131
+ const XARGS: Options = {
1132
+ valued: new Set([
1133
+ ...letters("adEILnPs"),
1134
+ ...long("arg-file", "delimiter", "max-args", "max-procs", "max-chars", "process-slot-var"),
1135
+ ]),
1136
+ flags: new Set([
1137
+ ...letters("0prtxo"),
1138
+ ...long("null", "interactive", "no-run-if-empty", "verbose", "exit", "open-tty", "show-limits"),
1139
+ ]),
1140
+ attached: true,
1141
+ optional: new Set(["-e", "-i", "-l", ...long("eof", "replace", "max-lines")]),
1142
+ };
1143
+
1144
+ /**
1145
+ * `xargs [option]... [command [argument]...]`: its command starts after its options, and `-I`,
1146
+ * `-i` and `--replace` name the text it replaces with each path it reads, `{}` where `-i` or
1147
+ * `--replace` names none. With no command it runs `echo`.
1148
+ */
1149
+ function xargs(words: readonly Word[]): Appending {
1150
+ const read = optionsOf(words, XARGS);
1151
+ if (read === undefined || isUnknown(read)) {
1152
+ return UNREAD;
1153
+ }
1154
+ const defaulted = [...(read.values.get("-i") ?? []), ...(read.values.get("--replace") ?? [])];
1155
+ return {
1156
+ starts: [read.at],
1157
+ replaced: textsPattern([
1158
+ ...(read.values.get("-I") ?? []),
1159
+ ...defaulted.map((text) => (text === "" ? "{}" : text)),
1160
+ ]),
1161
+ from: read.at,
1162
+ };
1163
+ }
1164
+
1165
+ /** `find`'s actions that run a command up to a `;` or `+`, each `{}` in its words a path it finds: https://www.gnu.org/software/findutils/manual/html_node/find_html/Single-File.html */
1166
+ const FIND_ACTIONS = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
1167
+
1168
+ /**
1169
+ * GNU find's options and primaries that take the next word, two for `-fprintf`:
1170
+ * https://www.gnu.org/software/findutils/manual/html_node/find_html/Primary-Index.html
1171
+ */
1172
+ const FIND_VALUED = new Set([
1173
+ ...["-D", "-name", "-iname", "-path", "-ipath", "-wholename", "-iwholename", "-regex"],
1174
+ ...["-iregex", "-lname", "-ilname", "-type", "-xtype", "-user", "-group", "-uid", "-gid"],
1175
+ ...["-perm", "-size", "-mtime", "-atime", "-ctime", "-mmin", "-amin", "-cmin", "-newer"],
1176
+ ...["-anewer", "-cnewer", "-inum", "-links", "-samefile", "-used", "-fstype", "-context"],
1177
+ ...["-maxdepth", "-mindepth", "-printf", "-fprint", "-fprint0", "-fls", "-regextype"],
1178
+ ...["-files0-from", "-fprintf"],
1179
+ ]);
1180
+
1181
+ /** find's `-newerXY` primaries, which take the next word too. */
1182
+ const FIND_NEWER = /^-newer[aBcm][aBcmt]$/u;
1183
+
1184
+ /**
1185
+ * `find`'s words: its paths, up to the first that starts with `-` or is `(` or `!`, then its
1186
+ * expression. A dynamic word that is no primary's value, nor a path the expression follows,
1187
+ * may be an action, a construct no row reads.
1188
+ */
1189
+ function find(words: readonly Word[]): Appending {
1190
+ const starts: number[] = [];
1191
+ let paths = true;
1192
+ for (let at = 0; at < words.length; at += 1) {
1193
+ const value = words[at]?.value;
1194
+ paths &&= value === undefined || !(value.startsWith("-") || value === "(" || value === "!");
1195
+ const next = words[at + 1]?.value;
1196
+ if (value === undefined) {
1197
+ if (paths && (next === undefined ? at + 1 === words.length : /^[-(!]/u.test(next))) {
1198
+ continue;
1199
+ }
1200
+ return UNREAD;
1201
+ }
1202
+ if (FIND_VALUED.has(value) || FIND_NEWER.test(value)) {
1203
+ at += value === "-fprintf" ? 2 : 1;
1204
+ continue;
1205
+ }
1206
+ if (FIND_ACTIONS.has(value)) {
1207
+ starts.push(at + 1);
1208
+ at = words.findIndex(
1209
+ (word, end) =>
1210
+ end > at &&
1211
+ (word.value === ";" || (word.value === "+" && words[end - 1]?.value === "{}")),
1212
+ );
1213
+ at = at < 0 ? words.length : at;
1214
+ }
1215
+ }
1216
+ return { starts, replaced: /\{\}/gu, from: starts[0] ?? words.length };
1217
+ }
1218
+
1219
+ /** GNU parallel's options naming a text other than a `{…}` that it replaces with a part of each path. */
1220
+ const PARALLEL_REPLACING = [
1221
+ "-I",
1222
+ ...long("er", "extensionreplace", "bnr", "basenamereplace", "dnr", "dirnamereplace"),
1223
+ ...long("bner", "basenameextensionreplace", "seqreplace", "slotreplace"),
1224
+ ];
1225
+
1226
+ /** https://www.gnu.org/software/parallel/parallel.html */
1227
+ const PARALLEL: Options = {
1228
+ valued: new Set([
1229
+ ...PARALLEL_REPLACING,
1230
+ ...letters("jNnLaCdSE"),
1231
+ ...long("jobs", "max-args", "max-lines", "arg-file", "colsep", "delimiter", "sshlogin"),
1232
+ ...long("joblog", "results", "delay", "timeout", "halt", "tmpdir", "workdir", "eof"),
1233
+ ]),
1234
+ flags: new Set([
1235
+ ...letters("kvq0uXmr"),
1236
+ ...long("keep-order", "verbose", "bar", "progress", "eta", "tag", "dry-run", "null"),
1237
+ ...long("quote", "ungroup", "group", "line-buffer", "lb", "xargs", "no-notice"),
1238
+ ...long("will-cite", "shuf", "plus", "no-run-if-empty", "pipe"),
1239
+ ]),
1240
+ attached: true,
1241
+ };
1242
+
1243
+ /**
1244
+ * `parallel [option]... [command [argument]...] [::: argument... | :::: file...]`: its command
1245
+ * starts after its options and ends at the first `:::` or `::::`, and every `{…}` in it, each
1246
+ * one of the replacement strings it documents, and each text its replacing options name
1247
+ * stand for a part of each path it is handed. A `{=…=}`, the path a perl expression computes,
1248
+ * is a construct no row reads.
1249
+ */
1250
+ function parallel(words: readonly Word[]): Appending {
1251
+ const read = optionsOf(words, PARALLEL);
1252
+ if (read === undefined || isUnknown(read)) {
1253
+ return UNREAD;
1254
+ }
1255
+ const end = words.findIndex((word, at) => at >= read.at && /^::::?\+?$/u.test(word.value ?? ""));
1256
+ const command = words.slice(read.at, end < 0 ? undefined : end);
1257
+ // The word parallel is handed, its quotes removed, as `'{''=…=}'` writes `{=…=}`.
1258
+ if (command.some((word) => (word.value ?? word.written ?? word.text).includes("{="))) {
1259
+ return UNREAD;
1260
+ }
1261
+ const texts = textsPattern(PARALLEL_REPLACING.flatMap((name) => read.values.get(name) ?? []));
1262
+ return {
1263
+ starts: [read.at],
1264
+ replaced: new RegExp(
1265
+ [/\{[^{}\s]*\}/u.source, ...(texts === undefined ? [] : [texts.source])].join("|"),
1266
+ "gu",
1267
+ ),
1268
+ from: read.at,
1269
+ ...(end < 0 ? {} : { end }),
1270
+ };
1271
+ }
1272
+
1273
+ /**
1274
+ * `parallel` hands a shell its command's words joined by blanks as one command line; with no
1275
+ * command before its `:::` each word after it, and with none at all each line it reads.
1276
+ */
1277
+ function parallelLines(words: readonly Word[]): Lines {
1278
+ const { starts, end } = parallel(words);
1279
+ const [start] = starts ?? [];
1280
+ if (start === undefined) {
1281
+ return everyWord(words);
1282
+ }
1283
+ if (start === end) {
1284
+ return { at: wholeWords(words.slice(start + 1).map((_, at) => start + 1 + at)), input: false };
1285
+ }
1286
+ return start === words.length
1287
+ ? { at: new Map(), input: true }
1288
+ : { at: new Map(), input: false, joined: [[start, end ?? words.length]] };
1289
+ }
1290
+
1291
+ /**
1292
+ * concurrently's options, read by yargs before and after its commands, each a long one's
1293
+ * `=` spelling too: https://github.com/open-cli-tools/concurrently/blob/main/bin/index.ts
1294
+ */
1295
+ const CONCURRENTLY: Options = {
1296
+ valued: new Set([
1297
+ ...["-m", "-n", "-s", "-p", "-c", "-l", "-t"],
1298
+ ...long("max-processes", "names", "success", "hide", "shell", "teardown", "kill-signal"),
1299
+ ...long("kill-timeout", "prefix", "prefix-colors", "prefix-length", "timestamp-format"),
1300
+ ...long("restart-tries", "restart-after", "default-input-target", "name-separator"),
1301
+ ]),
1302
+ flags: new Set([
1303
+ ...["-r", "-g", "-P", "-k", "-i", "-h", "-v", "-V"],
1304
+ ...long("raw", "no-color", "group", "timings", "passthrough-arguments", "kill-others"),
1305
+ ...long("kill-others-on-fail", "pad-prefix", "handle-input", "help", "version"),
1306
+ ]),
1307
+ };
1308
+
1309
+ /** concurrently's `--teardown`, whose value is a command line it runs before it exits. */
1310
+ const TEARDOWN = "--teardown";
1311
+
1312
+ /**
1313
+ * The words with a `{N}`, `{@}` or `{*}`, which concurrently replaces with the arguments after
1314
+ * its `--` under `-P`, a part only the run knows: a `CONCURRENTLY_PASSTHROUGH_ARGUMENTS`
1315
+ * variable, which yargs's `.env` reads, sets `-P` where no word writes it.
1316
+ */
1317
+ const PLACEHOLDER = /\{(?:\d+|@|\*)\}/gu;
1318
+
1319
+ /**
1320
+ * concurrently's command lines: each word that is no option nor an option's value, each
1321
+ * `--teardown` value, and the words after a `--` but under `-P`, where they are its commands'
1322
+ * arguments. From an option {@link CONCURRENTLY} does not list, every word may be one.
1323
+ */
1324
+ function concurrentlyLines(words: readonly Word[]): Lines {
1325
+ const at = new Map<number, string>();
1326
+ let passthrough = false;
1327
+ for (let one = 0; one < words.length; one += 1) {
1328
+ const value = words[one]?.value;
1329
+ if (value === "--") {
1330
+ for (let later = one + 1; !passthrough && later < words.length; later += 1) {
1331
+ at.set(later, "");
1332
+ }
1333
+ break;
1334
+ }
1335
+ if (value === undefined || value === "-" || !value.startsWith("-")) {
1336
+ at.set(one, "");
1337
+ continue;
1338
+ }
1339
+ const equals = value.startsWith("--") ? value.indexOf("=") : -1;
1340
+ const name = equals < 0 ? value : value.slice(0, equals);
1341
+ if (equals < 0 && CONCURRENTLY.flags.has(name)) {
1342
+ passthrough ||= name === "-P" || name === "--passthrough-arguments";
1343
+ } else if (CONCURRENTLY.valued.has(name)) {
1344
+ const valueAt = equals < 0 ? (one += 1) : one;
1345
+ if (name === TEARDOWN && valueAt < words.length) {
1346
+ at.set(valueAt, equals < 0 ? "" : `${TEARDOWN}=`);
1347
+ }
1348
+ } else {
1349
+ for (let later = one; later < words.length; later += 1) {
1350
+ at.set(later, "");
1351
+ }
1352
+ return { at, input: false, unwritten: true, replaced: PLACEHOLDER };
1353
+ }
1354
+ }
1355
+ return { at, input: false, replaced: PLACEHOLDER };
1356
+ }
1357
+
1358
+ /** `eval`'s operands joined by blanks, one command line: https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#eval */
1359
+ function evalLines(words: readonly Word[]): Lines {
1360
+ const from = words[0]?.value === "--" ? 1 : 0;
1361
+ return from >= words.length ? NO_LINES : { ...NO_LINES, joined: [[from, words.length]] };
1362
+ }
1363
+
1364
+ /**
1365
+ * The script `.` and `source` run in the shell itself, their first operand: one written with
1366
+ * no path runs a file no word writes, as `bash "$F"` does, and so does an option, which POSIX
1367
+ * gives them none of: https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#dot
1368
+ */
1369
+ function sourceLines(words: readonly Word[]): Lines {
1370
+ const script = words[words[0]?.value === "--" ? 1 : 0];
1371
+ return {
1372
+ ...NO_LINES,
1373
+ unwritten:
1374
+ script !== undefined &&
1375
+ (script.value === undefined
1376
+ ? !writesPath(script.written)
1377
+ : script.value.startsWith("-") && script.value !== "--"),
1378
+ };
1379
+ }
1380
+
1381
+ /** One tool's row: the reader of its command, and the options it names its configuration file by. */
1382
+ interface Row {
1383
+ readonly read?: Reader;
1384
+ readonly configurations?: ConfigurationOptions;
1385
+ /** For a runtime, its command line. */
1386
+ readonly runtime?: Runtime;
1387
+ /** For a tool that runs a command written after it, how. */
1388
+ readonly launches?: Launch;
1389
+ /** Its options as its documentation gives them, which its command lines are placed by where its launch places none. */
1390
+ readonly grammar?: Grammar;
1391
+ }
1392
+
1393
+ /**
1394
+ * The row of a launcher whose reader is `read`, which runs a command after `after` where
1395
+ * given, read after its own options `options`.
1396
+ */
1397
+ function launcher(
1398
+ read: Reader | undefined,
1399
+ after: readonly string[] = [],
1400
+ lines?: Launch["lines"],
1401
+ options?: Options,
1402
+ ): Row {
1403
+ return {
1404
+ ...(read === undefined ? {} : { read }),
1405
+ launches: {
1406
+ ...(after.length === 0 ? {} : { after: new Set(after) }),
1407
+ ...(lines === undefined ? {} : { lines }),
1408
+ ...(options === undefined ? {} : { options }),
1409
+ },
1410
+ };
1411
+ }
1412
+
1413
+ /** The row of a runtime. */
1414
+ function runs(line: Runtime): Row {
1415
+ return { read: runtime(line), runtime: line };
1416
+ }
1417
+
1418
+ /** The row of a tool that names its configuration file by `options` anywhere before a `--`. */
1419
+ function configuredBy(...options: readonly string[]): Row {
1420
+ return { configurations: { valued: new Set(options) } };
1421
+ }
1422
+
1423
+ /** The row of a shell, whose `-c` runs its operand as a command line. */
1424
+ const SHELL: Row = { launches: { line: true, lines: shellLines } };
1425
+
1426
+ /**
1427
+ * The reader of a tool that runs the command after its options `options`, the first operand,
1428
+ * or where `ended` the first word after a `--`, before which only its options stand.
1429
+ */
1430
+ function afterOptions(options: Options, ended = false): Reader {
1431
+ return (words) => {
1432
+ const end = words.findIndex((word) => word.value === "--");
1433
+ const read = optionsOf(ended && end >= 0 ? words.slice(0, end) : words, options);
1434
+ if (read === undefined || isUnknown(read)) {
1435
+ return read;
1436
+ }
1437
+ if (ended && end >= 0 && read.at !== end) {
1438
+ return undefined;
1439
+ }
1440
+ const at = ended && end >= 0 ? end + 1 : read.at;
1441
+ const program = words[at];
1442
+ return program?.value === undefined || program.value.startsWith("-")
1443
+ ? undefined
1444
+ : { kind: "command", words: words.slice(at), at };
1445
+ };
1446
+ }
1447
+
1448
+ /**
1449
+ * c8's options, `c8 [opts] [script] [opts]`, https://github.com/bcoe/c8#cli-options, read by
1450
+ * yargs, which clusters no valued option's value: https://github.com/bcoe/c8/blob/main/lib/parse-args.js
1451
+ */
1452
+ const C8: Options = {
1453
+ valued: new Set([
1454
+ ...["-c", "-r", "-o", "-n", "-x", "-e"],
1455
+ ...long("config", "reporter", "reports-dir", "report-dir", "src", "include", "exclude"),
1456
+ ...long("extension", "branches", "functions", "lines", "statements", "temp-directory"),
1457
+ ...long("resolve", "wrapper-length"),
1458
+ ]),
1459
+ flags: new Set([
1460
+ "-a",
1461
+ ...long("all", "exclude-node-modules", "exclude-after-remap", "skip-full", "check-coverage"),
1462
+ ...long("per-file", "100", "clean", "omit-relative", "allowExternal", "merge-async"),
1463
+ ...long("experimental-monocart", "no-clean", "no-exclude-node-modules", "no-omit-relative"),
1464
+ ]),
1465
+ };
1466
+
1467
+ /**
1468
+ * nyc's options, `nyc [options] [bin-to-instrument]`, https://github.com/istanbuljs/nyc#usage,
1469
+ * each of `@istanbuljs/schema`'s with its alias: https://github.com/istanbuljs/schema/blob/master/index.js
1470
+ */
1471
+ const NYC: Options = {
1472
+ valued: new Set([
1473
+ ...["-t", "-x", "-n", "-e", "-i", "-r"],
1474
+ ...long("cwd", "temp-dir", "exclude", "include", "extension", "ignore-class-methods"),
1475
+ ...long("parser-plugins", "require", "reporter", "report-dir", "cache-dir", "nycrc-path"),
1476
+ ...long("branches", "functions", "lines", "statements"),
1477
+ ]),
1478
+ flags: new Set([
1479
+ ...["-s", "-a", "-c"],
1480
+ ...long("exclude-node-modules", "auto-wrap", "es-modules", "compact", "preserve-comments"),
1481
+ ...long("produce-source-map", "source-map", "instrument", "exclude-after-remap", "per-file"),
1482
+ ...long("check-coverage", "show-process-tree", "skip-empty", "skip-full", "silent", "all"),
1483
+ ...long("eager", "cache", "babel-cache", "use-spawn-wrap", "hook-require", "clean"),
1484
+ ...long("hook-run-in-context", "hook-run-in-this-context", "in-place", "exit-on-error"),
1485
+ ...long("delete", "complete-copy", "no-clean", "no-cache", "no-instrument"),
1486
+ ]),
1487
+ };
1488
+
1489
+ /**
1490
+ * dotenv-cli's options, `dotenv [-e <path>] [-v <name>=<value>] [-c [env]] [-- command]`,
1491
+ * https://github.com/entropitor/dotenv-cli#usage. minimist reads a flag's next word as its
1492
+ * value, so only `-e`, `-v` and `-c` are read where no `--` stands; `-p` and `--debug` run nothing.
1493
+ */
1494
+ const DOTENV: Options = {
1495
+ valued: new Set(["-e", "-v", "-c"]),
1496
+ flags: new Set(["-o", "--override", "--no-expand", "-q"]),
1497
+ optional: new Set(["--quiet"]),
1498
+ };
1499
+
1500
+ /** dotenv-cli, reading its flags only before a `--`. */
1501
+ const dotenv: Reader = (words) =>
1502
+ words.some((word) => word.value === "--")
1503
+ ? afterOptions(DOTENV, true)(words)
1504
+ : afterOptions({ valued: DOTENV.valued, flags: new Set() })(words);
1505
+
1506
+ /**
1507
+ * env-cmd's options, `env-cmd [options] -- <command> [...args]`, read by commander, which
1508
+ * clusters short options: https://github.com/toddbluhm/env-cmd#-help. `-e` takes every word
1509
+ * after it that starts with no `-`, so it is read only before a `--`.
1510
+ */
1511
+ const ENV_CMD: Options = {
1512
+ valued: new Set(["-f", "--file"]),
1513
+ flags: new Set([
1514
+ "-x",
1515
+ ...long("expand-envs", "recursive", "fallback", "no-override", "silent", "use-shell"),
1516
+ "--verbose",
1517
+ ]),
1518
+ attached: true,
1519
+ };
1520
+
1521
+ /** env-cmd, whose `-e` and `--environments` stand only before a `--`. */
1522
+ const envCmd: Reader = (words) =>
1523
+ words.some((word) => word.value === "--")
1524
+ ? afterOptions(
1525
+ { ...ENV_CMD, valued: new Set([...ENV_CMD.valued, "-e", "--environments"]) },
1526
+ true,
1527
+ )(words)
1528
+ : afterOptions(ENV_CMD)(words);
1529
+
1530
+ /** env-cmd's `--use-shell` hands its command's words, joined by blanks, to a shell. */
1531
+ function envCmdLines(words: readonly Word[]): Lines {
1532
+ const reading = envCmd(words);
1533
+ return reading?.kind === "command" && words.some((word) => word.value === "--use-shell")
1534
+ ? { at: new Map(), input: false, joined: [[reading.at, words.length]] }
1535
+ : NO_LINES;
1536
+ }
1537
+
1538
+ /**
1539
+ * `dotenvx run [options] -- <command>`, https://dotenvx.com/docs/advanced/run, whose command
1540
+ * stands after `--`: https://github.com/dotenvx/dotenvx/blob/main/src/cli/dotenvx.js
1541
+ */
1542
+ const DOTENVX_RUN: Options = {
1543
+ valued: new Set([
1544
+ ...["-e", "-f", "-fk"],
1545
+ ...long("env", "env-file", "env-keys-file", "convention", "ignore", "token"),
1546
+ ]),
1547
+ flags: new Set(["-o", ...long("overload", "strict", "no-armor", "no-native")]),
1548
+ attached: true,
1549
+ };
1550
+
1551
+ /** dotenvx's own options before its subcommand: https://dotenvx.com/docs/cli */
1552
+ const DOTENVX: Options = {
1553
+ valued: new Set(["-l", "--log-level"]),
1554
+ flags: new Set([
1555
+ "-q",
1556
+ "-v",
1557
+ "-d",
1558
+ "-V",
1559
+ "-h",
1560
+ ...long("quiet", "verbose", "debug", "version", "help"),
1561
+ ]),
1562
+ };
1563
+
1564
+ /**
1565
+ * cross-env-shell, which runs its words after the assignments, joined by blanks, as one
1566
+ * command line: https://github.com/kentcdodds/cross-env#cross-env-vs-cross-env-shell
1567
+ */
1568
+ function crossEnvShellLines(words: readonly Word[]): Lines {
1569
+ const at = words.findIndex((word) => !/^\w+=/u.test(startOf(word)));
1570
+ return at < 0 ? NO_LINES : { at: new Map(), input: false, joined: [[at, words.length]] };
1571
+ }
1572
+
1573
+ /** The row of each tool, by the name its program word's last segment writes, but its grammar. */
1574
+ const ROWS: ReadonlyMap<string, Row> = new Map<string, Row>([
1575
+ ["node", runs(NODE)],
1576
+ ["tsx", runs(TSX)],
1577
+ ["ts-node", runs(TS_NODE)],
1578
+ ["bun", { read: bun, runtime: BUN, launches: { after: new Set(["x"]) } }],
1579
+ ["deno", runs(DENO)],
1580
+ ["env", launcher(env, [], envLines)],
1581
+ ["cross-env", launcher(crossEnv)],
1582
+ ["cross-env-shell", launcher(undefined, [], crossEnvShellLines)],
1583
+ ["c8", launcher(afterOptions(C8))],
1584
+ ["nyc", launcher(afterOptions(NYC))],
1585
+ ["dotenv", launcher(dotenv)],
1586
+ ["env-cmd", launcher(envCmd, [], envCmdLines)],
1587
+ [
1588
+ "dotenvx",
1589
+ launcher(
1590
+ subcommands(new Map([["run", afterOptions(DOTENVX_RUN, true)]]), DOTENVX),
1591
+ ["run"],
1592
+ undefined,
1593
+ DOTENVX,
1594
+ ),
1595
+ ],
1596
+ [
1597
+ "npx",
1598
+ {
1599
+ ...launcher(npx),
1600
+ // `-c` takes a command line, which is read as a configuration file's name as well, so
1601
+ // every configuration `fixtures/commands/observed.json` records stays named.
1602
+ configurations: {
1603
+ valued: new Set(["-c"]),
1604
+ beforeOperand: new Set(["--package", "-p", "--call"]),
1605
+ },
1606
+ },
1607
+ ],
1608
+ // The readers of npm and pnpm leave their own options before the subcommand unread, as each
1609
+ // of them, `--workspace` and `--filter` among them, may run the command in another directory.
1610
+ [
1611
+ "npm",
1612
+ launcher(
1613
+ npm,
1614
+ ["exec", "x"],
1615
+ subcommandLines(new Set(["exec", "x"]), optionLines(NPM_EXEC, CALL, true), NPM),
1616
+ NPM,
1617
+ ),
1618
+ ],
1619
+ // pnpm and yarn run a package's command of a name that is none of theirs:
1620
+ // https://pnpm.io/pnpm-cli, https://yarnpkg.com/cli/run; `yarn exec` runs its command in the
1621
+ // workspace's root, https://yarnpkg.com/cli/exec
1622
+ ["pnpm", launcher(pnpm, [], pnpmLines)],
1623
+ ["pnpx", launcher(pnpmDlx, [], (words) => dlxLines(words))],
1624
+ ["yarn", launcher(yarn)],
1625
+ ["bunx", launcher(bunx)],
1626
+ // The shell's `exec`, `command` and `builtin`, and the wrappers that run their operands.
1627
+ ...["exec", "command", "builtin", "nohup", "nice", "time", "timeout", "sudo"].map(
1628
+ (name) => [name, launcher(undefined)] as const,
1629
+ ),
1630
+ ["eval", { launches: { lines: evalLines } }],
1631
+ ["concurrently", { launches: { lines: concurrentlyLines } }],
1632
+ ...[".", "source"].map(
1633
+ (name) => [name, { launches: { line: true, lines: sourceLines } }] as const,
1634
+ ),
1635
+ ...["sh", "bash", "dash", "zsh", "ksh", "ash"].map((name) => [name, SHELL] as const),
1636
+ ["xargs", { launches: { appends: xargs } }],
1637
+ ["find", { launches: { appends: find } }],
1638
+ ["parallel", { launches: { appends: parallel, lines: parallelLines } }],
1639
+ // https://eslint.org/docs/latest/use/command-line-interface
1640
+ ["eslint", configuredBy("-c", "--config")],
1641
+ // https://prettier.io/docs/cli, whose `-c` is `--check`.
1642
+ ["prettier", configuredBy("--config")],
1643
+ // https://stylelint.io/user-guide/cli
1644
+ ["stylelint", configuredBy("-c", "--config")],
1645
+ // https://jestjs.io/docs/cli
1646
+ ["jest", configuredBy("-c", "--config")],
1647
+ // https://vitest.dev/guide/cli
1648
+ ["vitest", configuredBy("-c", "--config")],
1649
+ // https://vite.dev/guide/cli
1650
+ ["vite", configuredBy("-c", "--config")],
1651
+ // https://webpack.js.org/api/cli/
1652
+ ["webpack", configuredBy("-c", "--config")],
1653
+ // https://mochajs.org/#command-line-usage, whose `-c` is `--color`.
1654
+ ["mocha", configuredBy("--config")],
1655
+ ]);
1656
+
1657
+ /** A runtime's {@link Grammar}, from the same table its reader reads by. */
1658
+ function runtimeGrammar(line: Runtime, doc: string): Grammar {
1659
+ const { valued, flags, optional } = runtimeOptions(line);
1660
+ const inline = new Set([...(line.inline ?? []), ...(line.runner ?? [])]);
1661
+ const options: Options = {
1662
+ valued: new Set([...valued].filter((name) => !inline.has(name))),
1663
+ flags: new Set([...flags].filter((name) => name !== "-" && !inline.has(name))),
1664
+ ...(optional === undefined || optional.size === 0 ? {} : { optional }),
1665
+ };
1666
+ const one: Grammar = { options, operand: "file", inline, doc };
1667
+ const modes = {
1668
+ ...(line.modes === undefined ? {} : { modes: line.modes }),
1669
+ ...(line.modeAfter === undefined ? {} : { modeAfter: line.modeAfter }),
1670
+ };
1671
+ return {
1672
+ ...one,
1673
+ ...modes,
1674
+ subcommands: new Map([...(line.subcommands ?? [])].map((name) => [name, one])),
1675
+ };
1676
+ }
1677
+
1678
+ /**
1679
+ * Each tool's {@link Grammar}: its options, the object its reader parses with, and how its
1680
+ * other words read, which production reads only through {@link linesOf} and the enumerated
1681
+ * property spells whole. Tools with no table, `sudo` and `nohup` among them, have none: after
1682
+ * them every later word may be a program word.
1683
+ */
1684
+ const GRAMMARS: ReadonlyMap<string, Grammar> = new Map<string, Grammar>([
1685
+ ["node", runtimeGrammar(NODE, "https://nodejs.org/api/cli.html")],
1686
+ ["tsx", runtimeGrammar(TSX, "https://tsx.is/usage")],
1687
+ ["ts-node", runtimeGrammar(TS_NODE, "https://typestrong.org/ts-node/docs/options")],
1688
+ ["bun", runtimeGrammar(BUN, "https://bun.com/docs/cli/run")],
1689
+ ["deno", runtimeGrammar(DENO, "https://docs.deno.com/runtime/reference/cli/run/")],
1690
+ [
1691
+ "env",
1692
+ {
1693
+ options: ENV,
1694
+ operand: "command",
1695
+ lines: new Set(["-S", "--split-string"]),
1696
+ doc: "https://www.gnu.org/software/coreutils/manual/html_node/env-invocation.html",
1697
+ },
1698
+ ],
1699
+ [
1700
+ "npx",
1701
+ {
1702
+ options: NPX,
1703
+ operand: "command",
1704
+ lines: CALL,
1705
+ doc: "https://docs.npmjs.com/cli/v11/commands/npx",
1706
+ },
1707
+ ],
1708
+ [
1709
+ "npm",
1710
+ {
1711
+ options: NPM,
1712
+ operand: "command",
1713
+ subcommands: new Map(
1714
+ ["exec", "x"].map((name) => [
1715
+ name,
1716
+ {
1717
+ options: NPM_EXEC,
1718
+ operand: "command",
1719
+ lines: CALL,
1720
+ later: true,
1721
+ doc: "https://docs.npmjs.com/cli/v11/commands/npm-exec",
1722
+ },
1723
+ ]),
1724
+ ),
1725
+ doc: "https://docs.npmjs.com/cli/v11/using-npm/config",
1726
+ },
1727
+ ],
1728
+ [
1729
+ "pnpm",
1730
+ {
1731
+ options: PNPM,
1732
+ operand: "command",
1733
+ subcommands: new Map([
1734
+ [
1735
+ "exec",
1736
+ { options: PNPM, operand: "command", lines: PNPM_LINES, doc: "https://pnpm.io/cli/exec" },
1737
+ ],
1738
+ [
1739
+ "dlx",
1740
+ {
1741
+ options: PNPM_DLX_OPTIONS,
1742
+ operand: "command",
1743
+ lines: PNPM_LINES,
1744
+ doc: "https://pnpm.io/cli/dlx",
1745
+ },
1746
+ ],
1747
+ ]),
1748
+ lines: PNPM_LINES,
1749
+ doc: "https://pnpm.io/pnpm-cli#options",
1750
+ },
1751
+ ],
1752
+ [
1753
+ "yarn",
1754
+ {
1755
+ options: YARN,
1756
+ operand: "command",
1757
+ subcommands: new Map([
1758
+ ["dlx", { options: YARN_DLX, operand: "command", doc: "https://yarnpkg.com/cli/dlx" }],
1759
+ ]),
1760
+ doc: "https://yarnpkg.com/cli",
1761
+ },
1762
+ ],
1763
+ ["bunx", { options: BUNX, operand: "command", doc: "https://bun.com/docs/pm/bunx" }],
1764
+ [
1765
+ "pnpx",
1766
+ {
1767
+ options: PNPM_DLX_OPTIONS,
1768
+ operand: "command",
1769
+ lines: PNPM_LINES,
1770
+ doc: "https://pnpm.io/cli/dlx#pnpx",
1771
+ },
1772
+ ],
1773
+ ["c8", { options: C8, operand: "command", doc: "https://github.com/bcoe/c8#cli-options" }],
1774
+ ["nyc", { options: NYC, operand: "command", doc: "https://github.com/istanbuljs/nyc#usage" }],
1775
+ // Its flags stand only before a `--`, so a form with one before no `--` caps.
1776
+ [
1777
+ "dotenv",
1778
+ { options: DOTENV, operand: "command", doc: "https://github.com/entropitor/dotenv-cli#usage" },
1779
+ ],
1780
+ [
1781
+ "env-cmd",
1782
+ {
1783
+ options: ENV_CMD,
1784
+ operand: "command",
1785
+ lines: new Set(["--use-shell"]),
1786
+ doc: "https://github.com/toddbluhm/env-cmd#-help",
1787
+ },
1788
+ ],
1789
+ [
1790
+ "dotenvx",
1791
+ {
1792
+ options: DOTENVX,
1793
+ operand: "command",
1794
+ subcommands: new Map([
1795
+ [
1796
+ "run",
1797
+ {
1798
+ options: DOTENVX_RUN,
1799
+ operand: "command",
1800
+ ended: true,
1801
+ doc: "https://dotenvx.com/docs/advanced/run",
1802
+ },
1803
+ ],
1804
+ ]),
1805
+ doc: "https://dotenvx.com/docs",
1806
+ },
1807
+ ],
1808
+ ...["sh", "bash", "dash", "zsh", "ksh", "ash"].map(
1809
+ (name) =>
1810
+ [
1811
+ name,
1812
+ {
1813
+ options: SHELL_OPTIONS,
1814
+ operand: "line",
1815
+ lines: SHELL_LINES,
1816
+ doc: "https://pubs.opengroup.org/onlinepubs/9799919799/utilities/sh.html",
1817
+ },
1818
+ ] as const,
1819
+ ),
1820
+ [
1821
+ "concurrently",
1822
+ {
1823
+ options: CONCURRENTLY,
1824
+ operand: "lines",
1825
+ lines: new Set([TEARDOWN]),
1826
+ doc: "https://github.com/open-cli-tools/concurrently/blob/main/bin/index.ts",
1827
+ },
1828
+ ],
1829
+ [
1830
+ "xargs",
1831
+ {
1832
+ options: XARGS,
1833
+ operand: "appended",
1834
+ doc: "https://www.gnu.org/software/findutils/manual/html_node/find_html/xargs-options.html",
1835
+ },
1836
+ ],
1837
+ [
1838
+ "parallel",
1839
+ {
1840
+ options: PARALLEL,
1841
+ operand: "appended",
1842
+ doc: "https://www.gnu.org/software/parallel/parallel.html",
1843
+ },
1844
+ ],
1845
+ ]);
1846
+
1847
+ /**
1848
+ * The row of each tool, by the name its program word's last segment writes, with its grammar,
1849
+ * which the enumerated property of `text/named-files.property.test.ts` spells every option of.
1850
+ */
1851
+ export const TOOLS: ReadonlyMap<string, Row> = new Map(
1852
+ [...ROWS].map(([name, row]) => {
1853
+ const grammar = GRAMMARS.get(name);
1854
+ return [name, grammar === undefined ? row : { ...row, grammar }];
1855
+ }),
1856
+ );
1857
+
1858
+ /** Where the words after the program word of `row` write the command lines it hands to a shell. */
1859
+ function linesOf(row: Row | undefined): ((words: readonly Word[]) => Lines) | undefined {
1860
+ const grammar = row?.grammar;
1861
+ return (
1862
+ row?.launches?.lines ??
1863
+ (grammar?.lines === undefined || row?.launches === undefined
1864
+ ? undefined
1865
+ : optionLines(grammar.options, grammar.lines, grammar.later))
1866
+ );
1867
+ }
1868
+
1869
+ /**
1870
+ * The row of a simple command's program word, by the name its last path segment writes, as a
1871
+ * package runner's operand where `packaged`, its version removed.
1872
+ */
1873
+ function rowOf(program: Word | undefined, packaged = false): Row | undefined {
1874
+ const name = programName(program)?.replace(/\.(?:exe|cmd)$/iu, "");
1875
+ return name === undefined ? undefined : TOOLS.get(commandName(name, packaged));
1876
+ }
1877
+
1878
+ /**
1879
+ * The last path segment of a program word, a dynamic one's too where it writes that segment
1880
+ * whole after a `/`, as `"$(npm root)/.bin/tsx"` does, or `undefined` where the run computes it.
1881
+ */
1882
+ function programName(program: Word | undefined): string | undefined {
1883
+ const text = program?.value ?? program?.written;
1884
+ const last = text?.slice(text.lastIndexOf("/") + 1);
1885
+ return last === undefined ||
1886
+ last.includes(PART) ||
1887
+ (program?.value === undefined && last === text)
1888
+ ? undefined
1889
+ : last;
1890
+ }
1891
+
1892
+ /** What the reader of one simple command's program reads it as, or `undefined` where none understands it. */
1893
+ export function readTool(words: readonly Word[]): Reading | undefined {
1894
+ const reading = readRow(words);
1895
+ return reading?.kind === "unknown" ? undefined : reading;
1896
+ }
1897
+
1898
+ /** The command each launcher of `words` hands on to in turn, as {@link readTool} reads it, and what its last one is read as. */
1899
+ export function handedOn(
1900
+ words: readonly Word[],
1901
+ ): readonly [readonly Word[], Exclude<Reading, { readonly kind: "command" }> | undefined] {
1902
+ let handed = words;
1903
+ let reading = readTool(handed);
1904
+ while (reading?.kind === "command") {
1905
+ handed = reading.words;
1906
+ reading = readTool(handed);
1907
+ }
1908
+ return [handed, reading];
1909
+ }
1910
+
1911
+ /** What one simple command writes to its standard output, by the places of its words, the program word's 0. */
1912
+ interface Output {
1913
+ /** The places of the words each written as one line, as `cat`'s operands name files. */
1914
+ readonly at: readonly number[];
1915
+ /** The places, from one word up to before another, whose words joined by blanks are one line. */
1916
+ readonly joined: readonly (readonly [number, number])[];
1917
+ /** The lines it writes that no word writes whole. */
1918
+ readonly texts: readonly string[];
1919
+ /** Whether it writes its own standard input too. */
1920
+ readonly input: boolean;
1921
+ }
1922
+
1923
+ /** bash's `echo` options, which a word holds only, and a word holding any other letter is written: https://www.gnu.org/software/bash/manual/html_node/Bash-Builtins.html#index-echo */
1924
+ const ECHO_OPTION = /^-[neE]+$/u;
1925
+
1926
+ /** The escapes of a `printf` format this reading writes, each the text it stands for; any other is unread. */
1927
+ const PRINTF_ESCAPES: ReadonlyMap<string, string> = new Map([
1928
+ ["\\n", "\n"],
1929
+ ["\\t", "\t"],
1930
+ ["\\\\", "\\"],
1931
+ ["%%", "%"],
1932
+ ]);
1933
+
1934
+ /**
1935
+ * What one simple command writes to its standard output, `undefined` where no row reads it,
1936
+ * another program's output among it. `echo` writes its operands joined by blanks as one line,
1937
+ * `undefined` where one holds a `\` or `-e` stands, as XSI echo reads escapes:
1938
+ * https://pubs.opengroup.org/onlinepubs/9799919799/utilities/echo.html. `printf` writes its
1939
+ * format, `undefined` where it holds a conversion or an escape other than `%%`, `\n`, `\t` and
1940
+ * `\\`, or other operands follow it, whose output POSIX leaves unspecified:
1941
+ * https://pubs.opengroup.org/onlinepubs/9799919799/utilities/printf.html. `cat` writes the file
1942
+ * each operand names and, with none or a `-`, its own standard input:
1943
+ * https://pubs.opengroup.org/onlinepubs/9799919799/utilities/cat.html.
1944
+ */
1945
+ export function outputOf(words: readonly Word[]): Output | undefined {
1946
+ const name = programName(words[0]);
1947
+ const none: Output = { at: [], joined: [], texts: [], input: false };
1948
+ if (name === "echo") {
1949
+ let from = 1;
1950
+ while (ECHO_OPTION.test(words[from]?.value ?? "")) {
1951
+ from += 1;
1952
+ }
1953
+ const escaped =
1954
+ words.slice(1, from).some((word) => word.value?.includes("e") === true) ||
1955
+ words.slice(from).some((word) => (word.value ?? word.written ?? word.text).includes("\\"));
1956
+ return escaped ? undefined : { ...none, joined: [[from, words.length]] };
1957
+ }
1958
+ if (name === "printf") {
1959
+ const operands = words.slice(words[1]?.value === "--" ? 2 : 1);
1960
+ const [format, ...more] = operands;
1961
+ const known = format?.value;
1962
+ if (known === undefined || known.startsWith("-") || more.length > 0) {
1963
+ return known === "-v" ? none : undefined;
1964
+ }
1965
+ const rest = known.replaceAll(/\\.|%./gsu, (escape) =>
1966
+ PRINTF_ESCAPES.has(escape) ? "" : "\0",
1967
+ );
1968
+ return rest.includes("\\") || rest.includes("%") || rest.includes("\0")
1969
+ ? undefined
1970
+ : {
1971
+ ...none,
1972
+ texts: [known.replaceAll(/\\.|%./gsu, (escape) => PRINTF_ESCAPES.get(escape) ?? escape)],
1973
+ };
1974
+ }
1975
+ if (name !== "cat") {
1976
+ return undefined;
1977
+ }
1978
+ const operands = words.flatMap((word, at) =>
1979
+ at > 0 && !/^-./u.test(word.value ?? "") ? [at] : [],
1980
+ );
1981
+ return {
1982
+ ...none,
1983
+ at: operands.filter((at) => words[at]?.value !== "-"),
1984
+ input: operands.length === 0 || operands.some((at) => words[at]?.value === "-"),
1985
+ };
1986
+ }
1987
+
1988
+ /** What the reader of one simple command's program reads it as, {@link UNKNOWN} where its table does not model it. */
1989
+ function readRow(words: readonly Word[]): Reading | Unknown | undefined {
1990
+ const [program, ...rest] = words;
1991
+ return after(1, rowOf(program)?.read?.(rest));
1992
+ }
1993
+
1994
+ /** The files a runtime's test runner runs: the patterns its operands write, and whether it strips types. */
1995
+ export interface TestRun {
1996
+ readonly patterns: readonly string[];
1997
+ readonly stripped: boolean;
1998
+ }
1999
+
2000
+ /**
2001
+ * The test run of one simple command whose program word names a runtime, as `node --test`, the
2002
+ * one reading of its options that tests.1 roots its files by, or `undefined` where it runs no
2003
+ * test runner, its table does not model it or a word it needs is dynamic.
2004
+ */
2005
+ export function testRun(words: readonly Word[]): TestRun | undefined {
2006
+ const [program, ...rest] = words;
2007
+ const line = rowOf(program)?.runtime;
2008
+ const read = line === undefined ? undefined : runtimeLine(line, rest);
2009
+ if (read === undefined || isUnknown(read) || read.tests === undefined) {
2010
+ return undefined;
2011
+ }
2012
+ const patterns = rest.slice(read.tests).map((word) => word.value);
2013
+ return patterns.every((one) => one !== undefined)
2014
+ ? {
2015
+ patterns,
2016
+ stripped:
2017
+ !read.named.has("--no-strip-types") && !read.named.has("--no-experimental-strip-types"),
2018
+ }
2019
+ : undefined;
2020
+ }
2021
+
2022
+ /** Whether `input`, the file a `<` names, writes no path, so a reader of it runs a file no word writes. */
2023
+ function unnamed(input: Word | undefined): boolean {
2024
+ return input !== undefined && input.value === undefined && !writesPath(input.written);
2025
+ }
2026
+
2027
+ /**
2028
+ * The launch of the program word `program`, at `at` of `words`, where it may run a command
2029
+ * written after it: after one of its subcommands, past its own options, or a dynamic word or an
2030
+ * unread option that may be one, and for a shell where its words write a command line.
2031
+ */
2032
+ function launchAt(words: readonly Word[], at: number, program: Word): Launch | undefined {
2033
+ const launch = rowOf(program)?.launches;
2034
+ if (launch?.after !== undefined) {
2035
+ const sub = launch.options === undefined ? 0 : operandAt(words.slice(at + 1), launch.options);
2036
+ const next = sub === undefined ? undefined : words[at + 1 + sub];
2037
+ return sub === undefined ||
2038
+ (next !== undefined && (next.value === undefined || launch.after.has(next.value)))
2039
+ ? launch
2040
+ : undefined;
2041
+ }
2042
+ return launch?.line !== true || (launch.lines?.(words.slice(at + 1)).at.size ?? 0) > 0
2043
+ ? launch
2044
+ : undefined;
2045
+ }
2046
+
2047
+ /** The program words of one simple command, as {@link chainOf} reads them. */
2048
+ interface Chain {
2049
+ /** Each static program word, by its place: the first word, and each one a launcher hands on. */
2050
+ readonly programs: ReadonlyMap<number, Word>;
2051
+ /** The place of the first dynamic word that may be a program word. */
2052
+ readonly dynamic: number | undefined;
2053
+ /** The words, each holding a text a launcher replaces with a path it finds dynamic, that text a part. */
2054
+ readonly words: readonly Word[];
2055
+ /** Each such text, by the place of the words it may stand in. */
2056
+ readonly replaced: ReadonlyMap<number, RegExp>;
2057
+ /** Where the words of an appending launcher's command end, as at `parallel`'s `:::`. */
2058
+ readonly end: number | undefined;
2059
+ /** The place of the first appending launcher with an option or a construct no row reads. */
2060
+ readonly unread: number | undefined;
2061
+ /** Whether a launcher's reader reads a word its table does not model, {@link UNKNOWN}. */
2062
+ readonly unknown: boolean;
2063
+ }
2064
+
2065
+ /**
2066
+ * The program words of `words`, a simple command: the first word, the program word each
2067
+ * launcher hands a command on to, as its reader reads it or, for one that hands its commands
2068
+ * the paths it finds, where each starts after its options, and, after a launcher whose words
2069
+ * its reader does not read or that has no reader, each later word up to the first dynamic one.
2070
+ */
2071
+ function chainOf(words: readonly Word[]): Chain {
2072
+ const read = [...words];
2073
+ const replaced = new Map<number, RegExp>();
2074
+ const programs = new Map<number, Word>();
2075
+ let dynamic: number | undefined;
2076
+ let end: number | undefined;
2077
+ let unread: number | undefined;
2078
+ let unknown = false;
2079
+ const scanned = new Set<number>();
2080
+ const queue: (readonly [number, Word | undefined])[] = [[0, read[0]]];
2081
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
2082
+ const [at, program] = next;
2083
+ if (program === undefined || programs.has(at)) {
2084
+ continue;
2085
+ }
2086
+ if (programName(program) === undefined) {
2087
+ dynamic = Math.min(dynamic ?? at, at);
2088
+ continue;
2089
+ }
2090
+ programs.set(at, program);
2091
+ const launch = launchAt(read, at, program);
2092
+ const appending = launch?.appends?.(read.slice(at + 1));
2093
+ const pattern = appending?.replaced;
2094
+ for (
2095
+ let later = at + 1 + (appending?.from ?? read.length);
2096
+ pattern !== undefined && later < read.length;
2097
+ later += 1
2098
+ ) {
2099
+ const word = read[later];
2100
+ const written = word?.written?.replace(pattern, PART);
2101
+ replaced.set(later, pattern);
2102
+ if (word !== undefined && written !== undefined && written !== word.written) {
2103
+ read[later] = { value: undefined, text: word.text, written };
2104
+ }
2105
+ }
2106
+ if (appending?.end !== undefined) {
2107
+ end = Math.min(end ?? read.length, at + 1 + appending.end);
2108
+ }
2109
+ if (appending !== undefined && appending.starts === undefined) {
2110
+ unread = Math.min(unread ?? at, at);
2111
+ }
2112
+ const reading =
2113
+ launch === undefined || appending !== undefined
2114
+ ? undefined
2115
+ : readRow([program, ...read.slice(at + 1)]);
2116
+ unknown ||= reading?.kind === "unknown";
2117
+ if (appending?.starts !== undefined) {
2118
+ queue.push(
2119
+ ...appending.starts.map((start) => [at + 1 + start, read[at + 1 + start]] as const),
2120
+ );
2121
+ } else if (reading?.kind === "command") {
2122
+ queue.push([at + reading.at, reading.words[0]]);
2123
+ } else if (launch !== undefined && (reading === undefined || reading.kind === "unknown")) {
2124
+ // An earlier scan that reached `later` queued every word from there to the same stop.
2125
+ for (let later = at + 1; later < read.length && !scanned.has(later); later += 1) {
2126
+ scanned.add(later);
2127
+ queue.push([later, read[later]]);
2128
+ if (programName(read[later]) === undefined) {
2129
+ break;
2130
+ }
2131
+ }
2132
+ }
2133
+ }
2134
+ return { programs, dynamic, words: read, replaced, end, unread, unknown };
2135
+ }
2136
+
2137
+ /** A character a glob reads specially, so a pattern holding one names more than the path it writes. */
2138
+ const GLOB = /[*?[\]{}]/u;
2139
+
2140
+ /** What of one simple command may write the file a command runs, read by {@link fileOperands}. */
2141
+ interface FileOperands {
2142
+ /** The places of the words that may write it. */
2143
+ readonly places: ReadonlySet<number>;
2144
+ /** Whether it may run a file no word of the command writes. */
2145
+ readonly unwritten: boolean;
2146
+ /** The places of the words it hands a shell as a command line, each with the option text it starts with. */
2147
+ readonly lines: ReadonlyMap<number, string>;
2148
+ /** Whether it reads command lines from its standard input. */
2149
+ readonly input: boolean;
2150
+ /** Whether a runtime runs its standard input as its entry, as `node` given no file does. */
2151
+ readonly stdin: boolean;
2152
+ /** The texts, by the place of each word they may stand in, a launcher replaces with each path it finds. */
2153
+ readonly replaced: ReadonlyMap<number, RegExp>;
2154
+ /** The places, each from one word up to before another, whose words joined by blanks it hands a shell as one command line. */
2155
+ readonly joined: readonly (readonly [number, number])[];
2156
+ /** The command lines it hands a shell that no word writes whole. */
2157
+ readonly texts: readonly string[];
2158
+ }
2159
+
2160
+ /**
2161
+ * The words of one simple command that may write the file a runtime runs (roots.13): where
2162
+ * `program` holds, a dynamic program word and every word after it; after each program word
2163
+ * {@link chainOf} places that names a runtime, its entry, or every later word its reader does
2164
+ * not read. A runtime with no entry after a tool that hands it the paths it finds, a command a
2165
+ * row's table does not model, or one that reads command lines or its standard input from the
2166
+ * file `input`, which a `<` names with no path, runs a file no word writes. `tests` lets tests.1 root this command's own test run; otherwise a test runner's
2167
+ * pattern may write a file, and a dynamic or glob one, or none, runs a file no word writes.
2168
+ */
2169
+ export function fileOperands(
2170
+ words: readonly Word[],
2171
+ program: boolean,
2172
+ input?: Word,
2173
+ tests = false,
2174
+ ): FileOperands {
2175
+ const chain = chainOf(words);
2176
+ const read = chain.words;
2177
+ const places = new Set<number>();
2178
+ const fromHere = (at: number): void => {
2179
+ for (let later = at; later < read.length; later += 1) {
2180
+ places.add(later);
2181
+ }
2182
+ };
2183
+ if (program && chain.dynamic !== undefined) {
2184
+ fromHere(chain.dynamic);
2185
+ }
2186
+ let unwritten = chain.unknown || chain.unread !== undefined;
2187
+ let reads = false;
2188
+ let stdin = false;
2189
+ const programs = [...chain.programs].sort(([one], [other]) => one - other);
2190
+ const [handed] = tests ? handedOn(words) : [[]];
2191
+ // Where the command reader's own test run is this runtime's, tests.1 roots its files.
2192
+ const rooted =
2193
+ testRun(handed) !== undefined &&
2194
+ handed.every((word, at) => read[read.length - handed.length + at]?.value === word.value)
2195
+ ? read.length - handed.length
2196
+ : -1;
2197
+ const appender =
2198
+ programs.find(([, word]) => rowOf(word)?.launches?.appends !== undefined)?.[0] ?? -1;
2199
+ for (const [named, word] of programs) {
2200
+ const line = rowOf(word, true)?.runtime;
2201
+ if (line === undefined) {
2202
+ continue;
2203
+ }
2204
+ const end = chain.end !== undefined && named < chain.end ? chain.end : read.length;
2205
+ const runtime = runtimeLine(line, read.slice(named + 1, end));
2206
+ if (runtime === undefined || isUnknown(runtime)) {
2207
+ fromHere(named + 1);
2208
+ unwritten ||= isUnknown(runtime);
2209
+ continue;
2210
+ }
2211
+ stdin ||= runtime.stdin;
2212
+ if (runtime.tests !== undefined && named !== rooted) {
2213
+ const from = named + 1 + runtime.tests;
2214
+ const patterns = read.slice(from, end);
2215
+ patterns.forEach((_, at) => places.add(from + at));
2216
+ unwritten ||=
2217
+ patterns.length === 0 ||
2218
+ patterns.some((one) => one.value === undefined || GLOB.test(one.value));
2219
+ }
2220
+ if (runtime.entry === undefined) {
2221
+ unwritten ||= appender >= 0 && appender < named;
2222
+ } else {
2223
+ places.add(named + 1 + runtime.entry);
2224
+ }
2225
+ }
2226
+ const lines = new Map<number, string>();
2227
+ const replaced = new Map(chain.replaced);
2228
+ const joined: (readonly [number, number])[] = [];
2229
+ const texts: string[] = [];
2230
+ for (const [at, word] of programs) {
2231
+ const found = linesOf(rowOf(word))?.(read.slice(at + 1)) ?? NO_LINES;
2232
+ for (const [one, prefix] of found.at) {
2233
+ lines.set(at + 1 + one, prefix);
2234
+ if (found.replaced !== undefined) {
2235
+ replaced.set(at + 1 + one, found.replaced);
2236
+ }
2237
+ }
2238
+ joined.push(...(found.joined ?? []).map(([from, to]) => [at + 1 + from, at + 1 + to] as const));
2239
+ texts.push(...(found.texts ?? []));
2240
+ reads ||= found.input;
2241
+ unwritten ||=
2242
+ found.unwritten === true || (found.appended === true && appender >= 0 && appender < at);
2243
+ }
2244
+ unwritten ||= (reads || stdin) && unnamed(input);
2245
+ return { places, unwritten, lines, input: reads, stdin, replaced, joined, texts };
476
2246
  }
477
2247
 
478
2248
  /**