@kaynooo/cli-kit 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/shell.ts CHANGED
@@ -1,20 +1,78 @@
1
- // `text` is the piece as written, `command` the same less its heredoc bodies, which are data and never commands; `separator` is what ends it (`|`, `&&`, `;`, a newline...), empty for the last; `expanded` holds the bodies of its unquoted heredocs, where the shell still runs `$(...)`.
2
- export interface Piece { text: string, start: number, end: number, command: string, separator: string, expanded: string }
1
+ // `text` is the piece as written, `command` the same less its heredoc bodies and comments, which are data and never commands; `separator` is what ends it (`|`, `&&`, `;`, a newline...), empty for the last; `expanded` holds the bodies of its unquoted heredocs, where the shell still runs `$(...)`, and `heredocs` those of all its heredocs.
2
+ export interface Piece { text: string, start: number, end: number, command: string, separator: string, expanded: string, heredocs: string[] }
3
3
  export interface Invocation { program: string, args: string[], assignments: string[], isWrapped: boolean }
4
4
 
5
5
  const SEPARATORS = new Set(['\n', ';', '|'])
6
6
  const PLAIN_BREAKERS = new Set(['<', '>', '(', ')', '&', '|'])
7
+ const GLOB_CHARS = new Set(['*', '?', '['])
8
+ const COMMENT_PREFIXES = new Set([';', '&', '|', '(', ')'])
7
9
  const WHITESPACE = /\s/
8
10
  const ASSIGNMENT = /^\w+=/
9
11
  const TRAILING_SLASHES = /\/+$/
12
+ const TRAILING_CR = /\r$/
10
13
  const LEADING_TILDE = /^~(?=\/|$)/
11
14
  const LEADING_TABS = /^\t+/
12
15
  const OPENERS = /^[({]+/
13
16
  const CLOSERS = /[)}]+$/
17
+ const CASE_LABEL = /^\(?[^\s()]*\)$/
18
+ const REDIRECT_ONLY = /^\d*(?:<|>|>>|<&|>&|&>|>\||<>)$/
19
+ const REDIRECT_START = /^\d*[<>]|^&>/
20
+ const SAFE_WORD = /^[\w@%+=:,./-]+$/
21
+ const LEADING_HEX = /^[\da-f]*/i
14
22
  const DOUBLE_QUOTED_ESCAPES = new Set(['$', '`', '"', '\\'])
15
23
  const HEREDOC = /^<<(-?)[ \t]*(?:'([^']*)'|"([^"]*)"|([^\s;&|<>()-][^\s;&|<>()]*))/
16
- // Programs that run the command following them, which is the one that counts.
17
- const WRAPPERS = new Set(['command', 'env', 'sudo', 'exec', 'nohup', 'time'])
24
+ // The quote opened by `$'`, inside which a backslash escapes and decodes.
25
+ const ANSI_QUOTE = '$\''
26
+ const ANSI_ESCAPES: Record<string, string> = { 'n': '\n', 't': '\t', 'r': '\r', 'a': '\x07', 'b': '\b', 'f': '\f', 'v': '\v', 'e': '\x1B', 'E': '\x1B', '\\': '\\', '\'': '\'', '"': '"', '?': '?' }
27
+ const ANSI_DIGITS: Record<string, number> = { x: 2, u: 4, U: 8 }
28
+ const OCTAL = /^[0-7]{1,3}/
29
+ // Keywords opening a command of a compound statement, which the command following them is the one that counts.
30
+ const KEYWORDS = new Set(['then', 'do', 'else', 'if', 'elif', 'while', 'until', '!', '{', '}'])
31
+ const MAX_DEPTH = 8
32
+ const MAX_BRACES = 256
33
+ // Programs reading a command string with `-c`.
34
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ash', 'ksh'])
35
+ const COMMAND_STRING_PROGRAMS = new Set([...SHELLS, 'su', 'runuser'])
36
+ const SHELL_VALUE_OPTIONS = new Set(['-o', '-O', '--rcfile', '--init-file'])
37
+ const PRINTERS = new Set(['echo', 'printf'])
38
+ const FIND_EXEC = new Set(['-exec', '-execdir', '-ok', '-okdir'])
39
+
40
+ // Programs that run the command following them, which is the one that counts, with the options taking a value and the operands before that command.
41
+ interface WrapperSpec { short: string, long: readonly string[], operands: number }
42
+
43
+ function wrapper(short = '', long: readonly string[] = [], operands = 0): WrapperSpec {
44
+ return { short, long, operands }
45
+ }
46
+
47
+ const WRAPPERS = new Map<string, WrapperSpec>([
48
+ ['command', wrapper()],
49
+ ['builtin', wrapper()],
50
+ ['nohup', wrapper()],
51
+ ['setsid', wrapper()],
52
+ ['busybox', wrapper()],
53
+ ['exec', wrapper('a')],
54
+ ['time', wrapper('fo', ['--format', '--output'])],
55
+ ['env', wrapper('uCS', ['--unset', '--chdir', '--split-string', '--argv0'])],
56
+ ['sudo', wrapper('ughpCTrtUDR', ['--user', '--group', '--host', '--prompt', '--close-from', '--command-timeout', '--role', '--type', '--other-user', '--chdir', '--chroot'])],
57
+ ['doas', wrapper('uC')],
58
+ ['timeout', wrapper('sk', ['--signal', '--kill-after'], 1)],
59
+ ['nice', wrapper('n', ['--adjustment'])],
60
+ ['ionice', wrapper('cnpPu', ['--class', '--classdata', '--pid', '--pgid', '--uid'])],
61
+ ['stdbuf', wrapper('ioe', ['--input', '--output', '--error'])],
62
+ ['chroot', wrapper('', ['--userspec', '--groups'], 1)],
63
+ ['xargs', wrapper('IndsEaPL', ['--max-args', '--max-procs', '--max-chars', '--delimiter', '--arg-file', '--eof', '--max-lines', '--process-slot-var'])],
64
+ ])
65
+
66
+ function closerOf(quote: string): string {
67
+ return quote === ANSI_QUOTE ? '\'' : quote
68
+ }
69
+
70
+ // A `#` starts a comment at the start of a word, never inside one such as `$#` or `a#b`.
71
+ function startsComment(command: string, i: number): boolean {
72
+ const before = command[i - 1] ?? ''
73
+
74
+ return i === 0 || WHITESPACE.test(before) || COMMENT_PREFIXES.has(before)
75
+ }
18
76
 
19
77
  // A lone `&` sends the command before it to the background, unless it belongs to a redirection such as `2>&1` or `&>`.
20
78
  function separatorWidth(command: string, i: number): number {
@@ -31,7 +89,7 @@ function separatorWidth(command: string, i: number): number {
31
89
  return SEPARATORS.has(char) ? 1 : 0
32
90
  }
33
91
 
34
- // Where the heredocs opened on a line end: from the newline closing that line to the end of the last delimiter line.
92
+ // Where the heredocs opened on a line end: from the newline closing that line to the end of the last delimiter line, a CRLF one included.
35
93
  function heredocEnd(command: string, newline: number, words: readonly { word: string, stripsTabs: boolean }[]): number {
36
94
  let lineStart = newline + 1
37
95
 
@@ -39,7 +97,7 @@ function heredocEnd(command: string, newline: number, words: readonly { word: st
39
97
  while (lineStart <= command.length) {
40
98
  const next = command.indexOf('\n', lineStart)
41
99
  const lineEnd = next === -1 ? command.length : next
42
- const line = command.slice(lineStart, lineEnd)
100
+ const line = command.slice(lineStart, lineEnd).replace(TRAILING_CR, '')
43
101
 
44
102
  lineStart = lineEnd + 1
45
103
 
@@ -66,10 +124,11 @@ function withoutRanges(command: string, start: number, end: number, ranges: read
66
124
  return text + command.slice(at, end)
67
125
  }
68
126
 
69
- // Splits on the shell separators outside quotes and heredocs, so the `;` and `|` of an inline script stay in its piece, each piece keeping where it sits in the command.
127
+ // Splits on the shell separators outside quotes, comments and heredocs, so the `;` and `|` of an inline script stay in its piece, each piece keeping where it sits in the command.
70
128
  export function splitCommand(command: string): Piece[] {
71
129
  const bounds: [number, number, string][] = []
72
130
  const bodies: [number, number, boolean][] = []
131
+ const comments: [number, number][] = []
73
132
  let heredocs: { word: string, stripsTabs: boolean, isQuoted: boolean }[] = []
74
133
  let start = 0
75
134
  let quote: string | null = null
@@ -84,18 +143,35 @@ export function splitCommand(command: string): Piece[] {
84
143
  }
85
144
 
86
145
  if (quote !== null) {
87
- if (char === quote)
146
+ if (char === closerOf(quote))
88
147
  quote = null
89
148
 
90
149
  continue
91
150
  }
92
151
 
152
+ if (char === '$' && command[i + 1] === '\'') {
153
+ quote = ANSI_QUOTE
154
+ i++
155
+
156
+ continue
157
+ }
158
+
93
159
  if (char === '\'' || char === '"') {
94
160
  quote = char
95
161
 
96
162
  continue
97
163
  }
98
164
 
165
+ if (char === '#' && startsComment(command, i)) {
166
+ const newline = command.indexOf('\n', i)
167
+ const end = newline === -1 ? command.length : newline
168
+
169
+ comments.push([i, end])
170
+ i = end - 1
171
+
172
+ continue
173
+ }
174
+
99
175
  const heredoc = char === '<' && command[i + 1] === '<' && command[i + 2] !== '<' ? HEREDOC.exec(command.slice(i)) : null
100
176
 
101
177
  if (heredoc) {
@@ -126,16 +202,21 @@ export function splitCommand(command: string): Piece[] {
126
202
  }
127
203
  bounds.push([start, command.length, ''])
128
204
 
129
- const ranges = bodies.map(([from, to]): [number, number] => [from, to])
205
+ const ranges = [...bodies, ...comments].map(([from, to]): [number, number] => [from, to]).sort(([a], [b]) => a - b)
206
+
207
+ return bounds.map(([from, to, separator]) => {
208
+ const inside = bodies.filter(([body]) => body >= from && body < to)
130
209
 
131
- return bounds.map(([from, to, separator]) => ({
132
- text: command.slice(from, to),
133
- start: from,
134
- end: to,
135
- command: withoutRanges(command, from, to, ranges),
136
- separator,
137
- expanded: bodies.filter(([body, , expands]) => expands && body >= from && body < to).map(([body, bodyEnd]) => command.slice(body, bodyEnd)).join(''),
138
- }))
210
+ return {
211
+ text: command.slice(from, to),
212
+ start: from,
213
+ end: to,
214
+ command: withoutRanges(command, from, to, ranges),
215
+ separator,
216
+ expanded: inside.filter(([, , expands]) => expands).map(([body, bodyEnd]) => command.slice(body, bodyEnd)).join(''),
217
+ heredocs: inside.map(([body, bodyEnd]) => command.slice(body, bodyEnd)),
218
+ }
219
+ })
139
220
  }
140
221
 
141
222
  // True when the text holds no redirect, substitution, subshell or group outside single quotes, and closes every quote it opens.
@@ -146,19 +227,74 @@ export function isPlain(text: string): boolean {
146
227
  const char = text[i] ?? ''
147
228
  const isSubstitution = char === '`' || (char === '$' && text[i + 1] === '(')
148
229
 
230
+ if (quote === null && char === '$' && text[i + 1] === '\'') {
231
+ quote = ANSI_QUOTE
232
+ i++
233
+
234
+ continue
235
+ }
236
+
149
237
  if (char === '\\' && quote !== '\'')
150
238
  i++
151
- else if (quote !== null && char === quote)
239
+ else if (quote !== null && char === closerOf(quote))
152
240
  quote = null
153
241
  else if (quote === null && (char === '"' || char === '\''))
154
242
  quote = char
155
- else if (quote !== '\'' && (isSubstitution || (quote === null && PLAIN_BREAKERS.has(char))))
243
+ else if (quote !== '\'' && quote !== ANSI_QUOTE && (isSubstitution || (quote === null && PLAIN_BREAKERS.has(char))))
156
244
  return false
157
245
  }
158
246
 
159
247
  return quote === null
160
248
  }
161
249
 
250
+ // How many `)` the text closes without opening, as the end of a subshell does.
251
+ function unmatchedClosers(text: string): number {
252
+ let depth = 0
253
+ let unmatched = 0
254
+ let quote: string | null = null
255
+
256
+ for (let i = 0; i < text.length; i++) {
257
+ const char = text[i] ?? ''
258
+
259
+ if (quote === null && char === '$' && text[i + 1] === '\'') {
260
+ quote = ANSI_QUOTE
261
+ i++
262
+
263
+ continue
264
+ }
265
+
266
+ if (char === '\\' && quote !== '\'')
267
+ i++
268
+ else if (quote !== null && char === closerOf(quote))
269
+ quote = null
270
+ else if (quote === null && (char === '"' || char === '\''))
271
+ quote = char
272
+ else if (quote === null && char === '(')
273
+ depth++
274
+ else if (quote === null && char === ')' && depth > 0)
275
+ depth--
276
+ else if (quote === null && char === ')')
277
+ unmatched++
278
+ }
279
+
280
+ return unmatched
281
+ }
282
+
283
+ // A piece less the `(` opening its subshell and the `)` closing it, so the last word of `(cat .env)` is the file.
284
+ function withoutSubshell(text: string): string {
285
+ let result = text.trim()
286
+ let closers = unmatchedClosers(result)
287
+
288
+ while (result.startsWith('(')) result = result.slice(1).trimStart()
289
+
290
+ while (closers > 0 && result.endsWith(')')) {
291
+ result = result.slice(0, -1).trimEnd()
292
+ closers--
293
+ }
294
+
295
+ return result
296
+ }
297
+
162
298
  // The index of the `)` closing the `(` at `open`, past nested parentheses and quotes; the text's end when it never closes.
163
299
  function closingParen(text: string, open: number): number {
164
300
  let depth = 0
@@ -171,9 +307,13 @@ function closingParen(text: string, open: number): number {
171
307
  i++
172
308
  }
173
309
  else if (quote !== null) {
174
- if (char === quote)
310
+ if (char === closerOf(quote))
175
311
  quote = null
176
312
  }
313
+ else if (char === '$' && text[i + 1] === '\'') {
314
+ quote = ANSI_QUOTE
315
+ i++
316
+ }
177
317
  else if (char === '\'' || char === '"') {
178
318
  quote = char
179
319
  }
@@ -200,22 +340,29 @@ function closingBackquote(text: string, open: number): number {
200
340
  return text.length
201
341
  }
202
342
 
203
- // The bodies of the outermost `$(...)` and backquotes; quotes only count where `isQuoting`, as a heredoc body holds them as plain text.
343
+ // The bodies of the outermost `$(...)`, `<(...)`, `>(...)` and backquotes; quotes only count where `isQuoting`, as a heredoc body holds them as plain text.
204
344
  function bodies(text: string, isQuoting: boolean): string[] {
205
345
  const found: string[] = []
206
346
  let quote: string | null = null
207
347
 
208
348
  for (let i = 0; i < text.length; i++) {
209
349
  const char = text[i] ?? ''
350
+ const next = text[i + 1]
210
351
 
211
352
  if (quote === '\'') {
212
353
  if (char === '\'')
213
354
  quote = null
214
355
  }
356
+ else if (quote === ANSI_QUOTE) {
357
+ if (char === '\\')
358
+ i++
359
+ else if (char === '\'')
360
+ quote = null
361
+ }
215
362
  else if (char === '\\') {
216
363
  i++
217
364
  }
218
- else if (char === '$' && text[i + 1] === '(') {
365
+ else if ((char === '$' && next === '(') || (isQuoting && quote === null && (char === '<' || char === '>') && next === '(')) {
219
366
  const end = closingParen(text, i + 1)
220
367
 
221
368
  found.push(text.slice(i + 2, end))
@@ -230,6 +377,10 @@ function bodies(text: string, isQuoting: boolean): string[] {
230
377
  else if (isQuoting && char === '"') {
231
378
  quote = quote === null ? char : null
232
379
  }
380
+ else if (isQuoting && char === '$' && next === '\'' && quote === null) {
381
+ quote = ANSI_QUOTE
382
+ i++
383
+ }
233
384
  else if (isQuoting && char === '\'' && quote === null) {
234
385
  quote = char
235
386
  }
@@ -238,13 +389,34 @@ function bodies(text: string, isQuoting: boolean): string[] {
238
389
  return found
239
390
  }
240
391
 
241
- // The commands each piece runs through `$(...)` or backquotes, inside double quotes and unquoted heredoc bodies too; a nested one is found by reading each result in turn.
392
+ // The commands each piece runs through `$(...)`, `<(...)` or backquotes, inside double quotes and unquoted heredoc bodies too; a nested one is found by reading each result in turn.
242
393
  export function substitutions(command: string): string[] {
243
394
  return splitCommand(command).flatMap(piece => [...bodies(piece.command, true), ...bodies(piece.expanded, false)])
244
395
  }
245
396
 
246
- // A word as the shell would pass it; `isProse` when a quoted part of it holds whitespace, a message or a pattern rather than a path.
247
- export interface Word { text: string, isProse: boolean }
397
+ // A word as the shell would pass it; `isProse` when a quoted part of it holds whitespace, a message or a pattern rather than a path, `hasGlob` and `hasBrace` when it holds an unquoted `*?[` or `{` the shell would expand.
398
+ export interface Word { text: string, isProse: boolean, hasGlob: boolean, hasBrace: boolean }
399
+
400
+ // What the backslash escape at `at` inside `$'...'` stands for, and the index of its last character.
401
+ function ansiEscape(text: string, at: number): [string, number] {
402
+ const letter = text[at + 1] ?? ''
403
+ const simple = ANSI_ESCAPES[letter]
404
+
405
+ if (simple !== undefined)
406
+ return [simple, at + 1]
407
+
408
+ const hex = LEADING_HEX.exec(text.slice(at + 2, at + 2 + (ANSI_DIGITS[letter] ?? 0)))?.[0] ?? ''
409
+
410
+ if (hex !== '')
411
+ return [String.fromCodePoint(Number.parseInt(hex, 16)), at + 1 + hex.length]
412
+
413
+ const octal = OCTAL.exec(text.slice(at + 1))?.[0]
414
+
415
+ if (octal !== undefined)
416
+ return [String.fromCodePoint(Number.parseInt(octal, 8)), at + octal.length]
417
+
418
+ return [`\\${letter}`, at + 1]
419
+ }
248
420
 
249
421
  // The words of a piece, quotes and line continuations removed.
250
422
  export function words(text: string): Word[] {
@@ -252,6 +424,8 @@ export function words(text: string): Word[] {
252
424
  let current = ''
253
425
  let isStarted = false
254
426
  let isProse = false
427
+ let hasGlob = false
428
+ let hasBrace = false
255
429
  let quote: string | null = null
256
430
 
257
431
  for (let i = 0; i < text.length; i++) {
@@ -263,11 +437,13 @@ export function words(text: string): Word[] {
263
437
 
264
438
  if (quote === null && (isContinuation || WHITESPACE.test(char))) {
265
439
  if (isStarted)
266
- found.push({ text: current, isProse })
440
+ found.push({ text: current, isProse, hasGlob, hasBrace })
267
441
 
268
442
  current = ''
269
443
  isStarted = false
270
444
  isProse = false
445
+ hasGlob = false
446
+ hasBrace = false
271
447
 
272
448
  continue
273
449
  }
@@ -278,22 +454,39 @@ export function words(text: string): Word[] {
278
454
  isStarted = true
279
455
 
280
456
  // within double quotes a backslash escapes only `$`, a backtick, `"` and itself, and stays before anything else (`"v\d"`)
281
- if (char === '\\' && quote === '"' && !DOUBLE_QUOTED_ESCAPES.has(text[i + 1] ?? ''))
457
+ if (char === '\\' && quote === ANSI_QUOTE) {
458
+ const [decoded, last] = ansiEscape(text, i)
459
+
460
+ current += decoded
461
+ i = last
462
+ }
463
+ else if (char === '\\' && quote === '"' && !DOUBLE_QUOTED_ESCAPES.has(text[i + 1] ?? '')) {
282
464
  current += char
283
- else if (char === '\\' && quote !== '\'')
465
+ }
466
+ else if (char === '\\' && quote !== '\'') {
284
467
  current += text[++i] ?? ''
285
- else if (quote !== null && char === quote)
468
+ }
469
+ else if (quote !== null && char === closerOf(quote)) {
286
470
  quote = null
287
- else if (quote === null && (char === '"' || char === '\''))
471
+ }
472
+ else if (quote === null && char === '$' && text[i + 1] === '\'') {
473
+ quote = ANSI_QUOTE
474
+ i++
475
+ }
476
+ else if (quote === null && (char === '"' || char === '\'')) {
288
477
  quote = char
289
- else
478
+ }
479
+ else {
290
480
  current += char
481
+ hasGlob ||= quote === null && GLOB_CHARS.has(char)
482
+ hasBrace ||= quote === null && char === '{'
483
+ }
291
484
 
292
485
  isProse ||= quote !== null && WHITESPACE.test(char)
293
486
  }
294
487
 
295
488
  if (isStarted)
296
- found.push({ text: current, isProse })
489
+ found.push({ text: current, isProse, hasGlob, hasBrace })
297
490
 
298
491
  return found
299
492
  }
@@ -303,7 +496,45 @@ export function tokenize(text: string): string[] {
303
496
  return words(text).map(word => word.text)
304
497
  }
305
498
 
306
- // The program a piece runs, by its base name, past any subshell opener, variable assignment or wrapper such as `env` or `sudo`.
499
+ // Drops the options of a wrapper and the values they take, then its leading operands; `env -S` splits its value into the command it runs.
500
+ function skipOptions(tokens: string[], spec: WrapperSpec): void {
501
+ const take = (name: string, attached: string) => {
502
+ const value = attached === '' ? (tokens.shift() ?? '') : attached
503
+
504
+ if (name === 'S' || name === '--split-string')
505
+ tokens.unshift(...tokenize(value))
506
+ }
507
+
508
+ while (tokens[0] !== undefined && tokens[0].startsWith('-') && tokens[0] !== '-') {
509
+ const option = tokens.shift() ?? ''
510
+
511
+ if (option === '--')
512
+ break
513
+
514
+ if (option.startsWith('--')) {
515
+ const [name = '', ...value] = option.split('=')
516
+
517
+ if (spec.long.includes(name))
518
+ take(name, value.join('='))
519
+
520
+ continue
521
+ }
522
+
523
+ for (const [index, letter] of [...option.slice(1)].entries()) {
524
+ if (!spec.short.includes(letter))
525
+ continue
526
+
527
+ // the value is what follows the letter, else the next word
528
+ take(letter, option.slice(index + 2))
529
+
530
+ break
531
+ }
532
+ }
533
+
534
+ for (let operand = 0; operand < spec.operands; operand++) tokens.shift()
535
+ }
536
+
537
+ // The program a piece runs, by its base name, past any subshell opener, keyword, variable assignment or wrapper such as `env` or `sudo`.
307
538
  export function parseCommand(text: string): Invocation {
308
539
  const tokens = tokenize(text)
309
540
  const assignments: string[] = []
@@ -311,6 +542,7 @@ export function parseCommand(text: string): Invocation {
311
542
 
312
543
  for (;;) {
313
544
  const first = tokens[0] ?? ''
545
+ const spec = WRAPPERS.get(first.split('/').pop() ?? '')
314
546
 
315
547
  if (OPENERS.test(first)) {
316
548
  isWrapped = true
@@ -323,11 +555,24 @@ export function parseCommand(text: string): Invocation {
323
555
  else if (ASSIGNMENT.test(first)) {
324
556
  assignments.push(tokens.shift() ?? '')
325
557
  }
326
- else if (WRAPPERS.has(first)) {
558
+ else if (KEYWORDS.has(first)) {
327
559
  isWrapped = true
328
560
  tokens.shift()
561
+ }
562
+ else if (first === 'case') {
563
+ isWrapped = true
564
+ const at = tokens.indexOf('in')
329
565
 
330
- while (tokens[0]?.startsWith('-')) tokens.shift()
566
+ tokens.splice(0, at === -1 ? tokens.length : at + 1)
567
+ }
568
+ else if (tokens.length > 1 && CASE_LABEL.test(first)) {
569
+ isWrapped = true
570
+ tokens.shift()
571
+ }
572
+ else if (spec !== undefined) {
573
+ isWrapped = true
574
+ tokens.shift()
575
+ skipOptions(tokens, spec)
331
576
  }
332
577
  else {
333
578
  break
@@ -347,6 +592,193 @@ export function parseCommand(text: string): Invocation {
347
592
  return { program: program.split('/').pop() ?? '', args, assignments, isWrapped }
348
593
  }
349
594
 
595
+ function quoted(word: string): string {
596
+ return SAFE_WORD.test(word) || word === '{}' ? word : `'${word.replaceAll('\'', '\'\\\'\'')}'`
597
+ }
598
+
599
+ // The command strings a piece hands to a shell with `-c`, wherever the shell sits in it (`docker exec db sh -c '...'`, `su -c '...'`).
600
+ function commandStrings(tokens: readonly string[]): string[] {
601
+ const found: string[] = []
602
+
603
+ for (const [i, token] of tokens.entries()) {
604
+ const name = token.split('/').pop() ?? ''
605
+
606
+ if (!COMMAND_STRING_PROGRAMS.has(name))
607
+ continue
608
+
609
+ const isUser = name === 'su' || name === 'runuser'
610
+ let isValue = false
611
+
612
+ for (let j = i + 1; j < tokens.length; j++) {
613
+ const option = tokens[j] ?? ''
614
+
615
+ if (isValue) {
616
+ isValue = false
617
+
618
+ continue
619
+ }
620
+
621
+ if (SHELL_VALUE_OPTIONS.has(option)) {
622
+ isValue = true
623
+
624
+ continue
625
+ }
626
+
627
+ if (option.startsWith('--command=')) {
628
+ found.push(option.slice('--command='.length))
629
+
630
+ break
631
+ }
632
+
633
+ if (option === '--command' || (!option.startsWith('--') && option.startsWith('-') && option.slice(1).includes('c'))) {
634
+ found.push(tokens[j + 1] ?? '')
635
+
636
+ break
637
+ }
638
+
639
+ if (!option.startsWith('-') && !isUser)
640
+ break
641
+ }
642
+ }
643
+
644
+ return found
645
+ }
646
+
647
+ // A piece's arguments less its redirections, and the words fed to it as here-strings.
648
+ function redirections(args: readonly string[]): { operands: string[], hereStrings: string[] } {
649
+ const operands: string[] = []
650
+ const hereStrings: string[] = []
651
+
652
+ for (let i = 0; i < args.length; i++) {
653
+ const arg = args[i] ?? ''
654
+
655
+ if (arg.startsWith('<<<'))
656
+ hereStrings.push(arg.length > 3 ? arg.slice(3) : (args[++i] ?? ''))
657
+ else if (REDIRECT_ONLY.test(arg))
658
+ i++
659
+ else if (!REDIRECT_START.test(arg))
660
+ operands.push(arg)
661
+ }
662
+
663
+ return { operands, hereStrings }
664
+ }
665
+
666
+ // The argv `find` hands to each `-exec`, up to its `;` or `+`.
667
+ function findExecs(args: readonly string[]): string[] {
668
+ const found: string[] = []
669
+
670
+ for (const [i, arg] of args.entries()) {
671
+ if (!FIND_EXEC.has(arg))
672
+ continue
673
+
674
+ const rest = args.slice(i + 1)
675
+ const end = rest.findIndex(word => word === ';' || word === '+')
676
+
677
+ found.push((end === -1 ? rest : rest.slice(0, end)).map(quoted).join(' '))
678
+ }
679
+
680
+ return found
681
+ }
682
+
683
+ // The commands a piece makes a shell run besides its own: `sh -c` strings, `eval`, `find -exec`, and what is fed to a shell reading its input, heredocs, here-strings and an `echo` piped in.
684
+ function nestedCommands(piece: Piece, previous: Piece | undefined): string[] {
685
+ const tokens = tokenize(piece.command)
686
+ const { program, args } = parseCommand(piece.command)
687
+ const found = commandStrings(tokens)
688
+
689
+ if (program === 'eval')
690
+ found.push(args.join(' '))
691
+
692
+ if (program === 'find')
693
+ found.push(...findExecs(args))
694
+
695
+ if (!SHELLS.has(program))
696
+ return found
697
+
698
+ const { operands, hereStrings } = redirections(args)
699
+
700
+ if (operands.length > 0)
701
+ return found
702
+
703
+ found.push(...piece.heredocs, ...hereStrings)
704
+
705
+ if (previous?.separator === '|') {
706
+ const before = parseCommand(previous.command)
707
+
708
+ if (PRINTERS.has(before.program))
709
+ found.push(...before.args, before.args.join(' '))
710
+ }
711
+
712
+ return found
713
+ }
714
+
715
+ // The command and every command it makes the shell run: substitutions, subshells, `sh -c` and `eval` strings, `find -exec`, and what is fed to a shell; each is one to check.
716
+ export function commandsRun(command: string, depth = 0): string[] {
717
+ const found = [command]
718
+
719
+ if (depth >= MAX_DEPTH)
720
+ return found
721
+
722
+ const pieces = splitCommand(command)
723
+
724
+ for (const [index, piece] of pieces.entries()) {
725
+ const inner = withoutSubshell(piece.command)
726
+ const nested = [...bodies(piece.command, true), ...bodies(piece.expanded, false), ...nestedCommands(piece, pieces[index - 1])]
727
+
728
+ if (inner !== piece.command.trim())
729
+ nested.push(inner)
730
+
731
+ for (const each of nested)
732
+ found.push(...commandsRun(each, depth + 1))
733
+ }
734
+
735
+ return found
736
+ }
737
+
738
+ function expandBraces(word: string): string[] {
739
+ const open = word.indexOf('{')
740
+
741
+ if (open === -1)
742
+ return [word]
743
+
744
+ const alternatives: string[] = []
745
+ let depth = 0
746
+ let cut = open
747
+
748
+ for (let i = open; i < word.length; i++) {
749
+ const char = word[i]
750
+
751
+ if (char === '{') {
752
+ depth++
753
+ }
754
+ else if (char === ',' && depth === 1) {
755
+ alternatives.push(word.slice(cut + 1, i))
756
+ cut = i
757
+ }
758
+ else if (char === '}' && --depth === 0) {
759
+ if (alternatives.length === 0)
760
+ break
761
+
762
+ alternatives.push(word.slice(cut + 1, i))
763
+
764
+ const prefix = word.slice(0, open)
765
+ const suffix = word.slice(i + 1)
766
+
767
+ return alternatives.flatMap(alternative => expandBraces(prefix + alternative + suffix)).slice(0, MAX_BRACES + 1)
768
+ }
769
+ }
770
+
771
+ // no list at this brace: keep it and expand what follows
772
+ return expandBraces(word.slice(open + 1)).map(rest => `${word.slice(0, open + 1)}${rest}`)
773
+ }
774
+
775
+ // The words a word with `{a,b}` lists stands for, `.{e,}nv` being `.env` and `.nv`; a word with no list, or one growing past a sane size, is returned as it is.
776
+ export function braceExpand(word: string): string[] {
777
+ const found = expandBraces(word)
778
+
779
+ return found.length > MAX_BRACES ? [word] : found
780
+ }
781
+
350
782
  // Swaps each piece's text for its replacement, keeping the separators between them.
351
783
  export function replacePieces(command: string, replacements: ReadonlyMap<Piece, string>): string {
352
784
  let result = command