@namzu/sdk 44.3.0 → 45.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/authorization/command-line.d.ts +66 -19
  3. package/dist/authorization/command-line.d.ts.map +1 -1
  4. package/dist/authorization/command-line.js +130 -270
  5. package/dist/authorization/command-line.js.map +1 -1
  6. package/dist/authorization/gate.d.ts +7 -0
  7. package/dist/authorization/gate.d.ts.map +1 -1
  8. package/dist/authorization/gate.js +13 -3
  9. package/dist/authorization/gate.js.map +1 -1
  10. package/dist/authorization/rules.d.ts +10 -1
  11. package/dist/authorization/rules.d.ts.map +1 -1
  12. package/dist/authorization/rules.js +55 -8
  13. package/dist/authorization/rules.js.map +1 -1
  14. package/dist/authorization/shell-lexer.d.ts +152 -0
  15. package/dist/authorization/shell-lexer.d.ts.map +1 -0
  16. package/dist/authorization/shell-lexer.js +2156 -0
  17. package/dist/authorization/shell-lexer.js.map +1 -0
  18. package/dist/authorization/skill-grant.d.ts +182 -0
  19. package/dist/authorization/skill-grant.d.ts.map +1 -0
  20. package/dist/authorization/skill-grant.js +314 -0
  21. package/dist/authorization/skill-grant.js.map +1 -0
  22. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  23. package/dist/bridge/a2a/mapper.js +2 -0
  24. package/dist/bridge/a2a/mapper.js.map +1 -1
  25. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  26. package/dist/bridge/sse/mapper.js +1 -0
  27. package/dist/bridge/sse/mapper.js.map +1 -1
  28. package/dist/directory/types.d.ts +2 -0
  29. package/dist/directory/types.d.ts.map +1 -1
  30. package/dist/directory/types.js.map +1 -1
  31. package/dist/manager/resident/outbox.d.ts +4 -4
  32. package/dist/persona/assembler.d.ts.map +1 -1
  33. package/dist/persona/assembler.js +5 -2
  34. package/dist/persona/assembler.js.map +1 -1
  35. package/dist/prompt/coding-agent-doctrine.d.ts +1 -1
  36. package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
  37. package/dist/prompt/coding-agent-doctrine.js +1 -0
  38. package/dist/prompt/coding-agent-doctrine.js.map +1 -1
  39. package/dist/public-runtime.d.ts +5 -1
  40. package/dist/public-runtime.d.ts.map +1 -1
  41. package/dist/public-runtime.js +15 -1
  42. package/dist/public-runtime.js.map +1 -1
  43. package/dist/public-tools.d.ts +4 -0
  44. package/dist/public-tools.d.ts.map +1 -1
  45. package/dist/public-tools.js +12 -1
  46. package/dist/public-tools.js.map +1 -1
  47. package/dist/public-types.d.ts +9 -1
  48. package/dist/public-types.d.ts.map +1 -1
  49. package/dist/runtime/jobs/registry.d.ts +2 -2
  50. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  51. package/dist/runtime/jobs/registry.js +6 -2
  52. package/dist/runtime/jobs/registry.js.map +1 -1
  53. package/dist/runtime/query/declined.d.ts +12 -0
  54. package/dist/runtime/query/declined.d.ts.map +1 -0
  55. package/dist/runtime/query/declined.js +12 -0
  56. package/dist/runtime/query/declined.js.map +1 -0
  57. package/dist/runtime/query/executor.d.ts +36 -40
  58. package/dist/runtime/query/executor.d.ts.map +1 -1
  59. package/dist/runtime/query/executor.js +95 -53
  60. package/dist/runtime/query/executor.js.map +1 -1
  61. package/dist/runtime/query/index.d.ts.map +1 -1
  62. package/dist/runtime/query/index.js +8 -0
  63. package/dist/runtime/query/index.js.map +1 -1
  64. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  65. package/dist/runtime/query/iteration/index.js +13 -0
  66. package/dist/runtime/query/iteration/index.js.map +1 -1
  67. package/dist/runtime/query/iteration/phases/context.d.ts +13 -0
  68. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  69. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  70. package/dist/runtime/query/iteration/phases/handoff.d.ts +22 -0
  71. package/dist/runtime/query/iteration/phases/handoff.d.ts.map +1 -0
  72. package/dist/runtime/query/iteration/phases/handoff.js +65 -0
  73. package/dist/runtime/query/iteration/phases/handoff.js.map +1 -0
  74. package/dist/runtime/query/iteration/phases/index.d.ts +1 -0
  75. package/dist/runtime/query/iteration/phases/index.d.ts.map +1 -1
  76. package/dist/runtime/query/iteration/phases/index.js +1 -0
  77. package/dist/runtime/query/iteration/phases/index.js.map +1 -1
  78. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  79. package/dist/runtime/query/iteration/phases/tool-review.js +56 -3
  80. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  81. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  82. package/dist/runtime/query/resume-pending.js +3 -2
  83. package/dist/runtime/query/resume-pending.js.map +1 -1
  84. package/dist/runtime/query/review-policy.d.ts +11 -0
  85. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  86. package/dist/runtime/query/review-policy.js +36 -3
  87. package/dist/runtime/query/review-policy.js.map +1 -1
  88. package/dist/runtime/query/tooling.d.ts +3 -0
  89. package/dist/runtime/query/tooling.d.ts.map +1 -1
  90. package/dist/runtime/query/tooling.js +1 -0
  91. package/dist/runtime/query/tooling.js.map +1 -1
  92. package/dist/schedules/cron.d.ts +21 -0
  93. package/dist/schedules/cron.d.ts.map +1 -0
  94. package/dist/schedules/cron.js +167 -0
  95. package/dist/schedules/cron.js.map +1 -0
  96. package/dist/schedules/describe.d.ts +14 -0
  97. package/dist/schedules/describe.d.ts.map +1 -0
  98. package/dist/schedules/describe.js +133 -0
  99. package/dist/schedules/describe.js.map +1 -0
  100. package/dist/schedules/errors.d.ts +11 -0
  101. package/dist/schedules/errors.d.ts.map +1 -0
  102. package/dist/schedules/errors.js +15 -0
  103. package/dist/schedules/errors.js.map +1 -0
  104. package/dist/schedules/evaluate.d.ts +35 -0
  105. package/dist/schedules/evaluate.d.ts.map +1 -0
  106. package/dist/schedules/evaluate.js +158 -0
  107. package/dist/schedules/evaluate.js.map +1 -0
  108. package/dist/schedules/index.d.ts +11 -0
  109. package/dist/schedules/index.d.ts.map +1 -0
  110. package/dist/schedules/index.js +8 -0
  111. package/dist/schedules/index.js.map +1 -0
  112. package/dist/schedules/next-fire.d.ts +50 -0
  113. package/dist/schedules/next-fire.d.ts.map +1 -0
  114. package/dist/schedules/next-fire.js +250 -0
  115. package/dist/schedules/next-fire.js.map +1 -0
  116. package/dist/schedules/spec.d.ts +30 -0
  117. package/dist/schedules/spec.d.ts.map +1 -0
  118. package/dist/schedules/spec.js +169 -0
  119. package/dist/schedules/spec.js.map +1 -0
  120. package/dist/schedules/types.d.ts +144 -0
  121. package/dist/schedules/types.d.ts.map +1 -0
  122. package/dist/schedules/types.js +11 -0
  123. package/dist/schedules/types.js.map +1 -0
  124. package/dist/schedules/tz.d.ts +44 -0
  125. package/dist/schedules/tz.d.ts.map +1 -0
  126. package/dist/schedules/tz.js +141 -0
  127. package/dist/schedules/tz.js.map +1 -0
  128. package/dist/skills/index.d.ts +1 -1
  129. package/dist/skills/index.d.ts.map +1 -1
  130. package/dist/skills/index.js +1 -1
  131. package/dist/skills/index.js.map +1 -1
  132. package/dist/skills/loader.d.ts +21 -0
  133. package/dist/skills/loader.d.ts.map +1 -1
  134. package/dist/skills/loader.js +51 -1
  135. package/dist/skills/loader.js.map +1 -1
  136. package/dist/tools/builtins/bash.d.ts.map +1 -1
  137. package/dist/tools/builtins/bash.js +18 -6
  138. package/dist/tools/builtins/bash.js.map +1 -1
  139. package/dist/tools/builtins/browser-url.d.ts +83 -0
  140. package/dist/tools/builtins/browser-url.d.ts.map +1 -0
  141. package/dist/tools/builtins/browser-url.js +240 -0
  142. package/dist/tools/builtins/browser-url.js.map +1 -0
  143. package/dist/tools/builtins/browser.d.ts +367 -0
  144. package/dist/tools/builtins/browser.d.ts.map +1 -0
  145. package/dist/tools/builtins/browser.js +704 -0
  146. package/dist/tools/builtins/browser.js.map +1 -0
  147. package/dist/tools/builtins/skill.d.ts +2 -9
  148. package/dist/tools/builtins/skill.d.ts.map +1 -1
  149. package/dist/tools/builtins/skill.js +74 -51
  150. package/dist/tools/builtins/skill.js.map +1 -1
  151. package/dist/tools/command-shell.d.ts +90 -0
  152. package/dist/tools/command-shell.d.ts.map +1 -0
  153. package/dist/tools/command-shell.js +129 -0
  154. package/dist/tools/command-shell.js.map +1 -0
  155. package/dist/tools/defineTool.d.ts +13 -0
  156. package/dist/tools/defineTool.d.ts.map +1 -1
  157. package/dist/tools/defineTool.js +30 -1
  158. package/dist/tools/defineTool.js.map +1 -1
  159. package/dist/tools/schedules/index.d.ts +5 -0
  160. package/dist/tools/schedules/index.d.ts.map +1 -0
  161. package/dist/tools/schedules/index.js +4 -0
  162. package/dist/tools/schedules/index.js.map +1 -0
  163. package/dist/tools/schedules/loop-tool.d.ts +14 -0
  164. package/dist/tools/schedules/loop-tool.d.ts.map +1 -0
  165. package/dist/tools/schedules/loop-tool.js +81 -0
  166. package/dist/tools/schedules/loop-tool.js.map +1 -0
  167. package/dist/tools/schedules/present.d.ts +16 -0
  168. package/dist/tools/schedules/present.d.ts.map +1 -0
  169. package/dist/tools/schedules/present.js +69 -0
  170. package/dist/tools/schedules/present.js.map +1 -0
  171. package/dist/tools/schedules/prompt-scan.d.ts +17 -0
  172. package/dist/tools/schedules/prompt-scan.d.ts.map +1 -0
  173. package/dist/tools/schedules/prompt-scan.js +92 -0
  174. package/dist/tools/schedules/prompt-scan.js.map +1 -0
  175. package/dist/tools/schedules/schedule-tool.d.ts +16 -0
  176. package/dist/tools/schedules/schedule-tool.d.ts.map +1 -0
  177. package/dist/tools/schedules/schedule-tool.js +327 -0
  178. package/dist/tools/schedules/schedule-tool.js.map +1 -0
  179. package/dist/tools/schedules/types.d.ts +184 -0
  180. package/dist/tools/schedules/types.d.ts.map +1 -0
  181. package/dist/tools/schedules/types.js +11 -0
  182. package/dist/tools/schedules/types.js.map +1 -0
  183. package/dist/types/authorization/index.d.ts +86 -9
  184. package/dist/types/authorization/index.d.ts.map +1 -1
  185. package/dist/types/authorization/index.js +11 -1
  186. package/dist/types/authorization/index.js.map +1 -1
  187. package/dist/types/browser/index.d.ts +280 -0
  188. package/dist/types/browser/index.d.ts.map +1 -0
  189. package/dist/types/browser/index.js +12 -0
  190. package/dist/types/browser/index.js.map +1 -0
  191. package/dist/types/hitl/index.d.ts +23 -0
  192. package/dist/types/hitl/index.d.ts.map +1 -1
  193. package/dist/types/hitl/index.js.map +1 -1
  194. package/dist/types/session/events.d.ts +6 -0
  195. package/dist/types/session/events.d.ts.map +1 -1
  196. package/dist/types/session/events.js.map +1 -1
  197. package/dist/types/session/records.d.ts +23 -0
  198. package/dist/types/session/records.d.ts.map +1 -1
  199. package/dist/types/session/records.js +9 -0
  200. package/dist/types/session/records.js.map +1 -1
  201. package/dist/types/tool/index.d.ts +108 -5
  202. package/dist/types/tool/index.d.ts.map +1 -1
  203. package/dist/types/tool/index.js.map +1 -1
  204. package/dist/types/tool/presentation.d.ts +7 -0
  205. package/dist/types/tool/presentation.d.ts.map +1 -1
  206. package/dist/utils/frontmatter.d.ts +35 -3
  207. package/dist/utils/frontmatter.d.ts.map +1 -1
  208. package/dist/utils/frontmatter.js +45 -5
  209. package/dist/utils/frontmatter.js.map +1 -1
  210. package/dist/utils/id.d.ts +8 -0
  211. package/dist/utils/id.d.ts.map +1 -1
  212. package/dist/utils/id.js +12 -0
  213. package/dist/utils/id.js.map +1 -1
  214. package/package.json +1 -1
  215. package/src/authorization/command-line.ts +148 -293
  216. package/src/authorization/gate.ts +22 -2
  217. package/src/authorization/rules.ts +67 -8
  218. package/src/authorization/shell-lexer.ts +2349 -0
  219. package/src/authorization/skill-grant.ts +400 -0
  220. package/src/bridge/a2a/mapper.ts +2 -0
  221. package/src/bridge/sse/mapper.ts +1 -0
  222. package/src/directory/types.ts +2 -0
  223. package/src/persona/assembler.ts +5 -2
  224. package/src/prompt/coding-agent-doctrine.ts +1 -0
  225. package/src/public-runtime.ts +37 -0
  226. package/src/public-tools.ts +37 -1
  227. package/src/public-types.ts +57 -0
  228. package/src/runtime/jobs/registry.ts +19 -11
  229. package/src/runtime/query/declined.ts +12 -0
  230. package/src/runtime/query/executor.ts +116 -56
  231. package/src/runtime/query/index.ts +8 -0
  232. package/src/runtime/query/iteration/index.ts +13 -0
  233. package/src/runtime/query/iteration/phases/context.ts +13 -0
  234. package/src/runtime/query/iteration/phases/handoff.ts +74 -0
  235. package/src/runtime/query/iteration/phases/index.ts +1 -0
  236. package/src/runtime/query/iteration/phases/tool-review.ts +55 -3
  237. package/src/runtime/query/resume-pending.ts +3 -2
  238. package/src/runtime/query/review-policy.ts +57 -3
  239. package/src/runtime/query/tooling.ts +4 -0
  240. package/src/schedules/cron.ts +202 -0
  241. package/src/schedules/describe.ts +138 -0
  242. package/src/schedules/errors.ts +15 -0
  243. package/src/schedules/evaluate.ts +178 -0
  244. package/src/schedules/index.ts +18 -0
  245. package/src/schedules/next-fire.ts +257 -0
  246. package/src/schedules/spec.ts +210 -0
  247. package/src/schedules/types.ts +163 -0
  248. package/src/schedules/tz.ts +155 -0
  249. package/src/skills/index.ts +1 -1
  250. package/src/skills/loader.ts +59 -1
  251. package/src/tools/builtins/bash.ts +24 -6
  252. package/src/tools/builtins/browser-url.ts +251 -0
  253. package/src/tools/builtins/browser.ts +817 -0
  254. package/src/tools/builtins/skill.ts +92 -53
  255. package/src/tools/command-shell.ts +166 -0
  256. package/src/tools/defineTool.ts +36 -1
  257. package/src/tools/schedules/index.ts +4 -0
  258. package/src/tools/schedules/loop-tool.ts +85 -0
  259. package/src/tools/schedules/present.ts +86 -0
  260. package/src/tools/schedules/prompt-scan.ts +96 -0
  261. package/src/tools/schedules/schedule-tool.ts +376 -0
  262. package/src/tools/schedules/types.ts +197 -0
  263. package/src/types/authorization/index.ts +60 -2
  264. package/src/types/browser/index.ts +341 -0
  265. package/src/types/hitl/index.ts +21 -0
  266. package/src/types/session/events.ts +6 -0
  267. package/src/types/session/records.ts +10 -0
  268. package/src/types/tool/index.ts +109 -5
  269. package/src/types/tool/presentation.ts +7 -0
  270. package/src/utils/frontmatter.ts +81 -5
  271. package/src/utils/id.ts +14 -0
@@ -0,0 +1,2349 @@
1
+ /**
2
+ * One reading of a bash command line, shared by everything that authorizes
3
+ * one.
4
+ *
5
+ * ## Why there is exactly one
6
+ *
7
+ * Permission rules decide on the commands a line runs, so something has to say
8
+ * what those commands are. That used to be three separate walkers — one cut
9
+ * the line into commands, one split a command into words, one looked for
10
+ * output redirections — and each carried its own copy of bash's quoting rules.
11
+ * Every rule added to one had to be added to the others, and every review
12
+ * found a place where it had not been: `$'…'` first, then `$$'…'`. Each miss
13
+ * was the same defect: two readers disagreeing about where a quote ends, so
14
+ * one of them took a command the shell runs for quoted text. The fix for that
15
+ * class is not a fourth copy. It is a single lexer whose output every caller
16
+ * reads.
17
+ *
18
+ * ## What it reads
19
+ *
20
+ * The bash (5.x, non-POSIX mode) grammar, as far as authorization needs it:
21
+ * quoting in all its forms (`'…'`, `"…"`, `\`, `$'…'` with its escapes
22
+ * decoded, `$"…"`), line continuation, every expansion's extent (`$name`,
23
+ * the special parameters, `${…}` with nested quotes, `$(…)`, `$((…))`,
24
+ * `$[…]`, backticks, `<(…)` and `>(…)`), control operators, redirections
25
+ * including here-documents (whose bodies are consumed, never read as
26
+ * commands), comments, reserved words and compound commands (`( )`, `{ }`,
27
+ * `if`, `while`, `until`, `for`, `select`, `case`, `[[ ]]`), and a nested
28
+ * `bash -c '<payload>'`, whose decoded payload is read the same way.
29
+ *
30
+ * The result is the list of simple commands with each word as bash produces
31
+ * it after quote removal and before expansion, plus a flag on every word whose
32
+ * text is not its runtime value (it contains a parameter, command or
33
+ * arithmetic expansion, a glob, a brace expansion, a tilde, or a
34
+ * locale-dependent escape). A word without that flag is exactly the argument
35
+ * bash passes. `packages/sdk/src/authorization/__tests__/shell-lexer-bash.test.ts`
36
+ * checks that against the bash on the machine running the tests.
37
+ *
38
+ * ## Failing closed
39
+ *
40
+ * {@link ShellLexResult.opaque} is set when the command list is not a
41
+ * complete account of what the line runs: a command or process substitution,
42
+ * an arithmetic context that could evaluate code held in a variable, a
43
+ * syntax error, an unterminated quote, a construct this module does not model
44
+ * (`coproc`, `for ((…))`, a compound array assignment, a function
45
+ * definition), a command that changes how later text is parsed (`shopt`,
46
+ * `enable`, `set -o posix`, …), or a nesting depth past the limit. Nothing is
47
+ * guessed. When the lexer is unsure it says so, and the caller refuses to
48
+ * grant on that line.
49
+ *
50
+ * It is a lexer, not an interpreter. It does not know what `env`, `xargs` or
51
+ * `sudo` do with their arguments, and it does not know what `eval` or `source`
52
+ * will run; callers treat those as they see fit.
53
+ */
54
+
55
+ /** One word of a command, before expansion and after quote removal. */
56
+ export interface ShellWord {
57
+ /** The word as written in the source, quotes and escapes included. */
58
+ readonly text: string
59
+ /**
60
+ * The word after quote removal, with `$'…'` escapes decoded. Expansions
61
+ * that happen at runtime are left as written (`$HOME` stays `$HOME`), and
62
+ * {@link expands} says so.
63
+ */
64
+ readonly value: string
65
+ /**
66
+ * True when {@link value} is not the runtime word: the word contains an
67
+ * expansion, a glob, a brace expansion, a tilde prefix, or a
68
+ * locale-dependent quote or escape.
69
+ */
70
+ readonly expands: boolean
71
+ /** True when any part of the word was quoted or escaped. */
72
+ readonly quoted: boolean
73
+ }
74
+
75
+ /** A redirection: `2>&1`, `> file`, `<<EOF` and the rest. */
76
+ export interface ShellRedirection {
77
+ /**
78
+ * The operator: `<`, `>`, `>>`, `>|`, `<>`, `&>`, `&>>`, `<&`, `>&`, `<<`,
79
+ * `<<-` or `<<<`.
80
+ */
81
+ readonly operator: string
82
+ /** The descriptor it names before the operator (`2`, `{fd}`), if any. */
83
+ readonly fd?: string
84
+ /** The target word. For a here-document, its delimiter. */
85
+ readonly target: ShellWord
86
+ /**
87
+ * A here-document's body as written, up to its delimiter line, once the
88
+ * lexer has read it: the text the command reads on its input, which is
89
+ * a command line of its own when the command is a shell (`bash <<EOF`).
90
+ * Absent for every other operator and for a body the line never reached.
91
+ */
92
+ readonly body?: string
93
+ }
94
+
95
+ /** One simple command. */
96
+ export interface ShellCommand {
97
+ /**
98
+ * Every word, leading assignments included. `words[assignments]` is the
99
+ * command name when there is one.
100
+ */
101
+ readonly words: readonly ShellWord[]
102
+ /** How many leading words are variable assignments (`A=1 cmd`). */
103
+ readonly assignments: number
104
+ readonly redirections: readonly ShellRedirection[]
105
+ /**
106
+ * The command's source text, from its first token to its last, taken from
107
+ * the string it was read from (the payload, for a nested shell).
108
+ */
109
+ readonly text: string
110
+ /**
111
+ * Where it was found: the line itself, a command or process substitution
112
+ * (which also makes the line opaque), or the payload of a nested
113
+ * `bash -c`.
114
+ */
115
+ readonly origin: 'line' | 'substitution' | 'shell'
116
+ /** How many `bash -c` payloads enclose it. */
117
+ readonly depth: number
118
+ }
119
+
120
+ export interface ShellLexResult {
121
+ /** Every simple command found, in the order its parse completed. */
122
+ readonly commands: readonly ShellCommand[]
123
+ /**
124
+ * Every redirection in the line, including those on compound commands
125
+ * (`{ a; } > f`) that belong to no single simple command.
126
+ */
127
+ readonly redirections: readonly ShellRedirection[]
128
+ /**
129
+ * Words that belong to no simple command: a `for` or `select` loop's
130
+ * variable and the words of its list, and a `case` statement's subject
131
+ * and patterns. `for d in ~/x; do rm -r "$d"; done` passes `~/x` to `rm`
132
+ * although no command lists it.
133
+ */
134
+ readonly compoundWords: readonly ShellWord[]
135
+ /** True when {@link commands} may not be everything the line runs. */
136
+ readonly opaque: boolean
137
+ /** False when parsing stopped early: a syntax error or an unsupported construct. */
138
+ readonly complete: boolean
139
+ /** Why the line is opaque, for diagnostics. Empty when it is not. */
140
+ readonly reasons: readonly string[]
141
+ }
142
+
143
+ /**
144
+ * Shells whose `-c` argument is another command line, by basename. The
145
+ * payload is decoded as bash reads it; for `dash` and friends the bash reading
146
+ * is a close approximation, and the line is only as exact as that.
147
+ */
148
+ export const NESTED_SHELLS: ReadonlySet<string> = new Set([
149
+ 'sh',
150
+ 'bash',
151
+ 'zsh',
152
+ 'dash',
153
+ 'ksh',
154
+ 'ash',
155
+ 'mksh',
156
+ ])
157
+
158
+ /** How many `bash -c` payloads deep the lexer follows before it gives up. */
159
+ const MAX_SHELL_DEPTH = 4
160
+ /** How deep compound commands and expansions may nest before it gives up. */
161
+ const MAX_NESTING = 100
162
+
163
+ import type { ShellDialect } from '../types/tool/index.js'
164
+
165
+ export type { ShellDialect }
166
+
167
+ export interface ShellLexOptions {
168
+ /** Default `bash`. */
169
+ readonly dialect?: ShellDialect
170
+ }
171
+
172
+ export function lexShellCommandLine(line: string, options: ShellLexOptions = {}): ShellLexResult {
173
+ const context = new Context(line.length)
174
+ context.setDialect(line, options.dialect ?? 'bash')
175
+ try {
176
+ lexInto(line, context, 'line', 0)
177
+ } catch {
178
+ // A defect here, or a stack exhausted by nesting, must not read as a
179
+ // complete account of the line.
180
+ context.complete = false
181
+ context.opaque('internal error')
182
+ }
183
+ return {
184
+ commands: context.commands,
185
+ redirections: context.redirections,
186
+ compoundWords: context.compoundWords,
187
+ opaque: context.reasons.size > 0,
188
+ complete: context.complete,
189
+ reasons: [...context.reasons],
190
+ }
191
+ }
192
+
193
+ class Context {
194
+ readonly commands: ShellCommand[] = []
195
+ readonly redirections: ShellRedirection[] = []
196
+ readonly compoundWords: ShellWord[] = []
197
+ readonly reasons = new Set<string>()
198
+ complete = true
199
+ /**
200
+ * Characters the lexer may re-read. A `((` is read once as arithmetic and,
201
+ * when that fails, again as two subshells; unbounded, nested attempts make
202
+ * the lexer quadratic. Past the budget the line is opaque.
203
+ */
204
+ private rescans: number
205
+
206
+ constructor(length: number) {
207
+ this.rescans = 4 * length + 4096
208
+ }
209
+
210
+ opaque(reason: string): void {
211
+ this.reasons.add(reason)
212
+ }
213
+
214
+ private readonly dialects = new Map<string, ShellDialect>()
215
+
216
+ /** The dialect a string is read in. A string read two ways gets the stricter. */
217
+ setDialect(src: string, dialect: ShellDialect): void {
218
+ if (this.dialects.get(src) !== 'sh') this.dialects.set(src, dialect)
219
+ }
220
+
221
+ dialectFor(src: string): ShellDialect {
222
+ return this.dialects.get(src) ?? 'sh'
223
+ }
224
+
225
+ private readonly sources = new Map<string, SourceState>()
226
+
227
+ /** Per-string state shared by every parser reading that string. */
228
+ source(src: string): SourceState {
229
+ let state = this.sources.get(src)
230
+ if (state === undefined) {
231
+ state = { lastNewline: src.lastIndexOf('\n'), finalLineRaw: false }
232
+ this.sources.set(src, state)
233
+ }
234
+ return state
235
+ }
236
+
237
+ /** Charge `count` re-read characters; stops the parse when spent. */
238
+ rescan(count: number): void {
239
+ this.rescans -= count
240
+ if (this.rescans < 0) throw new Stop('too complex to read')
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Bash appends a newline to each line it reads from a string. For a final line
246
+ * that ends in a backslash it appends a second backslash instead, so the
247
+ * backslash stays literal, but only when that line was read outside a single
248
+ * quote. When the last newline of the string is inside `'…'` or `$'…'`, the
249
+ * final line was read inside the quote, and a trailing backslash becomes a
250
+ * line continuation that disappears (measured, bash 5.3).
251
+ */
252
+ interface SourceState {
253
+ readonly lastNewline: number
254
+ finalLineRaw: boolean
255
+ }
256
+
257
+ /** Stops the parse. Whatever was read so far stays; the line becomes opaque. */
258
+ class Stop extends Error {
259
+ constructor(readonly reason: string) {
260
+ super(reason)
261
+ }
262
+ }
263
+
264
+ function lexInto(
265
+ source: string,
266
+ context: Context,
267
+ origin: ShellCommand['origin'],
268
+ depth: number,
269
+ ): void {
270
+ const parser = new Parser(source, 0, context, origin, depth, 0)
271
+ try {
272
+ parser.program()
273
+ } catch (error) {
274
+ if (!(error instanceof Stop)) throw error
275
+ context.complete = false
276
+ context.opaque(error.reason)
277
+ }
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Tokens
282
+
283
+ type Token =
284
+ | {
285
+ readonly kind: 'word'
286
+ readonly start: number
287
+ readonly end: number
288
+ readonly word: ShellWord
289
+ readonly reservedOk: boolean
290
+ }
291
+ | {
292
+ readonly kind: 'op'
293
+ readonly start: number
294
+ readonly end: number
295
+ readonly op: string
296
+ readonly fd?: string
297
+ }
298
+ | { readonly kind: 'newline'; readonly start: number; readonly end: number }
299
+ | { readonly kind: 'eof'; readonly start: number; readonly end: number }
300
+ /** `(( … ))` at command position. */
301
+ | { readonly kind: 'arith'; readonly start: number; readonly end: number }
302
+
303
+ const REDIRECTIONS = new Set([
304
+ '<',
305
+ '>',
306
+ '>>',
307
+ '>|',
308
+ '<>',
309
+ '&>',
310
+ '&>>',
311
+ '<&',
312
+ '>&',
313
+ '<<',
314
+ '<<-',
315
+ '<<<',
316
+ ])
317
+
318
+ /** Operators after which bash recognises a reserved word. */
319
+ const RESERVED_AFTER_OPS = new Set([';', ';;', ';&', ';;&', '&', '&&', '||', '|', '|&', '(', ')'])
320
+ /** Reserved words after which bash recognises another one. */
321
+ const RESERVED_AFTER_WORDS = new Set([
322
+ '!',
323
+ '{',
324
+ '}',
325
+ 'do',
326
+ 'done',
327
+ 'elif',
328
+ 'else',
329
+ 'esac',
330
+ 'fi',
331
+ 'if',
332
+ 'then',
333
+ 'time',
334
+ 'until',
335
+ 'while',
336
+ ])
337
+ const RESERVED = new Set([
338
+ '!',
339
+ '{',
340
+ '}',
341
+ 'case',
342
+ 'coproc',
343
+ 'do',
344
+ 'done',
345
+ 'elif',
346
+ 'else',
347
+ 'esac',
348
+ 'fi',
349
+ 'for',
350
+ 'function',
351
+ 'if',
352
+ 'select',
353
+ 'then',
354
+ 'time',
355
+ 'until',
356
+ 'while',
357
+ '[[',
358
+ ']]',
359
+ 'in',
360
+ ])
361
+
362
+ /** A compound command starts here (for `coproc NAME compound`). */
363
+ const COMPOUND_AFTER = /^(?:\(|(?:\{|if|while|until|for|select|case|\[\[)(?=[\s;&|()<>]|$))/
364
+ /** Builtins whose arguments may be array assignments. */
365
+ const ASSIGNMENT_BUILTINS = new Set(['declare', 'typeset', 'local', 'export', 'readonly'])
366
+
367
+ const WORD_BREAK = new Set([' ', '\t', '\n', ';', '&', '|', '(', ')', '<', '>'])
368
+
369
+ const NAME_START = /[A-Za-z_]/
370
+ const NAME_CHAR = /[A-Za-z0-9_]/
371
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=/
372
+
373
+ interface PendingHeredoc {
374
+ readonly delimiter: string
375
+ readonly stripTabs: boolean
376
+ readonly quoted: boolean
377
+ /** The redirection the body belongs to, filled in when it is read. */
378
+ readonly redirection: { body?: string }
379
+ }
380
+
381
+ type Last =
382
+ | { readonly kind: 'start' | 'newline' | 'other' }
383
+ | { readonly kind: 'op'; readonly op: string }
384
+ | { readonly kind: 'word'; readonly reserved: string | null }
385
+
386
+ /** A mutable word under construction. */
387
+ class WordBuilder {
388
+ value = ''
389
+ expands = false
390
+ quoted = false
391
+ /** A brace expansion, which POSIX shells do not perform. */
392
+ brace = false
393
+ /** Unquoted `{` seen, and whether a `,` or `..` followed it: brace expansion. */
394
+ private braceOpen = false
395
+ private braceSeparator = false
396
+ /** The previous unquoted character, for tilde and brace detection. */
397
+ private previous = ''
398
+ private empty = true
399
+
400
+ literal(char: string): void {
401
+ this.value += char
402
+ this.empty = false
403
+ }
404
+
405
+ /** An unquoted character, which may be special to expansion. */
406
+ unquoted(char: string): void {
407
+ if (char === '*' || char === '?' || char === '[') this.expands = true
408
+ if (char === '~' && (this.empty || this.previous === '=' || this.previous === ':')) {
409
+ this.expands = true
410
+ }
411
+ if (char === '{') {
412
+ this.braceOpen = true
413
+ } else if (this.braceOpen && (char === ',' || (char === '.' && this.previous === '.'))) {
414
+ this.braceSeparator = true
415
+ } else if (char === '}' && this.braceOpen && this.braceSeparator) {
416
+ this.expands = true
417
+ this.brace = true
418
+ }
419
+ this.previous = char
420
+ this.literal(char)
421
+ }
422
+
423
+ /** Quoted text: never special. */
424
+ quotedText(text: string): void {
425
+ this.quoted = true
426
+ this.previous = ''
427
+ this.value += text
428
+ this.empty = false
429
+ }
430
+
431
+ /** An expansion kept as written. */
432
+ expansion(text: string): void {
433
+ this.expands = true
434
+ this.previous = ''
435
+ this.value += text
436
+ this.empty = false
437
+ }
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // The parser. One instance per string and per nesting of `$(…)`; the token
442
+ // stream is lazy so that a substitution can hand its position back.
443
+
444
+ class Parser {
445
+ private peeked: Token | null = null
446
+ private last: Last = { kind: 'start' }
447
+ private readonly heredocs: PendingHeredoc[] = []
448
+ private readonly source: SourceState
449
+
450
+ constructor(
451
+ private readonly src: string,
452
+ public pos: number,
453
+ private readonly context: Context,
454
+ private readonly origin: ShellCommand['origin'],
455
+ private readonly depth: number,
456
+ private readonly nesting: number,
457
+ ) {
458
+ if (nesting > MAX_NESTING) throw new Stop('nesting too deep')
459
+ this.source = context.source(src)
460
+ }
461
+
462
+ private get dialect(): ShellDialect {
463
+ return this.context.dialectFor(this.src)
464
+ }
465
+
466
+ /**
467
+ * A construct bash reads differently from a POSIX shell. In the `sh`
468
+ * dialect the line is opaque: which of the two runs it is not known.
469
+ */
470
+ private bashOnly(what: string): void {
471
+ if (this.dialect === 'sh') this.context.opaque(`not POSIX sh: ${what}`)
472
+ }
473
+
474
+ /** A string read inside this one (a backtick or here-document body) keeps its dialect. */
475
+ private inherit(body: string): void {
476
+ this.context.setDialect(body, this.dialect)
477
+ }
478
+
479
+ /** Record a single-quoted region; see {@link SourceState}. */
480
+ private singleQuoted(open: number, close: number): void {
481
+ const last = this.source.lastNewline
482
+ if (last > open && last < close) this.source.finalLineRaw = true
483
+ }
484
+
485
+ // --- grammar ----------------------------------------------------------
486
+
487
+ /** The whole string: a list, possibly empty, up to the end. */
488
+ program(): void {
489
+ this.newlines()
490
+ while (this.peek().kind !== 'eof') {
491
+ this.andOr()
492
+ const token = this.peek()
493
+ if (token.kind === 'eof') break
494
+ if (
495
+ token.kind === 'newline' ||
496
+ (token.kind === 'op' && (token.op === ';' || token.op === '&'))
497
+ ) {
498
+ this.take()
499
+ this.newlines()
500
+ continue
501
+ }
502
+ throw this.unexpected(token)
503
+ }
504
+ }
505
+
506
+ /**
507
+ * A `$(…)` body: a list up to the matching `)`. Returns the offset just
508
+ * past it.
509
+ */
510
+ substitution(): number {
511
+ this.newlines()
512
+ const first = this.peek()
513
+ if (!(first.kind === 'op' && first.op === ')')) {
514
+ this.list((token) => token.kind === 'op' && token.op === ')')
515
+ }
516
+ const close = this.take()
517
+ if (close.kind !== 'op' || close.op !== ')') throw this.unexpected(close)
518
+ return close.end
519
+ }
520
+
521
+ /**
522
+ * A `${ …; }` body: a list, possibly empty, up to a `}` where a command
523
+ * could start. That `}` closes the substitution even with more of the
524
+ * word after it: `a${ }b` is the word `ab`.
525
+ */
526
+ braceSubstitution(): number {
527
+ this.closesOnBrace = true
528
+ const closing = (token: Token): boolean => token.kind === 'op' && token.op === '}'
529
+ this.newlines()
530
+ if (!closing(this.peek())) this.list(closing)
531
+ return this.take().end
532
+ }
533
+
534
+ /** Set while reading a `${ …; }` body. */
535
+ private closesOnBrace = false
536
+ /** Open `{ …; }` groups, whose `}` is theirs and not the substitution's. */
537
+ private braceDepth = 0
538
+
539
+ /** After `{`: the list and its closing `}`. */
540
+ private braceGroup(): void {
541
+ this.braceDepth += 1
542
+ try {
543
+ this.list((t) => this.isReserved(t, '}'))
544
+ } finally {
545
+ this.braceDepth -= 1
546
+ }
547
+ this.take()
548
+ }
549
+
550
+ /**
551
+ * A compound list: one or more and-or lists separated by `;`, `&` or
552
+ * newlines, stopping before a token `ends` accepts.
553
+ */
554
+ private list(ends: (token: Token) => boolean): void {
555
+ this.newlines()
556
+ if (ends(this.peek())) throw this.unexpected(this.peek())
557
+ for (;;) {
558
+ this.andOr()
559
+ const token = this.peek()
560
+ if (ends(token)) return
561
+ if (
562
+ token.kind === 'newline' ||
563
+ (token.kind === 'op' && (token.op === ';' || token.op === '&'))
564
+ ) {
565
+ this.take()
566
+ this.newlines()
567
+ if (ends(this.peek())) return
568
+ continue
569
+ }
570
+ throw this.unexpected(token)
571
+ }
572
+ }
573
+
574
+ private andOr(): void {
575
+ this.pipeline()
576
+ for (;;) {
577
+ const token = this.peek()
578
+ if (token.kind === 'op' && (token.op === '&&' || token.op === '||')) {
579
+ this.take()
580
+ this.newlines()
581
+ this.pipeline()
582
+ continue
583
+ }
584
+ return
585
+ }
586
+ }
587
+
588
+ private pipeline(): void {
589
+ // `time`, `time -p` and any number of `!` may lead a pipeline, and a
590
+ // pipeline of just `time` or `!` is valid.
591
+ let prefixed = false
592
+ for (;;) {
593
+ const token = this.peek()
594
+ if (this.isReserved(token, '!')) {
595
+ this.take()
596
+ prefixed = true
597
+ continue
598
+ }
599
+ if (this.isReserved(token, 'time')) {
600
+ this.bashOnly('time')
601
+ this.take()
602
+ prefixed = true
603
+ const option = this.peek()
604
+ if (option.kind === 'word' && option.word.text.startsWith('-')) {
605
+ // In POSIX mode, which is how bash runs as `/bin/sh`, `time`
606
+ // before a word starting with `-` is not a reserved word but
607
+ // the command `time`. The two modes disagree about what runs.
608
+ this.context.opaque('time with an option')
609
+ }
610
+ if (
611
+ option.kind === 'word' &&
612
+ !option.word.quoted &&
613
+ (option.word.value === '-p' || option.word.value === '--')
614
+ ) {
615
+ this.take()
616
+ this.last = { kind: 'word', reserved: 'time' }
617
+ }
618
+ continue
619
+ }
620
+ break
621
+ }
622
+ // `!` or `time` with nothing after it is valid only before a newline,
623
+ // a `;` or the end of the input. (Bash 5.3 also accepts a `&`; 5.2
624
+ // does not, and reading it as an error is the direction that fails
625
+ // closed.)
626
+ if (prefixed) {
627
+ const next = this.peek()
628
+ if (next.kind === 'eof' || next.kind === 'newline' || (next.kind === 'op' && next.op === ';'))
629
+ return
630
+ }
631
+ this.command()
632
+ for (;;) {
633
+ const token = this.peek()
634
+ if (token.kind === 'op' && (token.op === '|' || token.op === '|&')) {
635
+ this.take()
636
+ this.newlines()
637
+ this.afterPipe = true
638
+ this.command()
639
+ continue
640
+ }
641
+ return
642
+ }
643
+ }
644
+
645
+ private command(): void {
646
+ this.level += 1
647
+ try {
648
+ if (this.nestingNow > MAX_NESTING) throw new Stop('nesting too deep')
649
+ this.commandAt()
650
+ } finally {
651
+ this.level -= 1
652
+ }
653
+ }
654
+
655
+ /** Set between a `|` and the command after it, where `time` is a plain word. */
656
+ private afterPipe = false
657
+
658
+ private commandAt(): void {
659
+ const afterPipe = this.afterPipe
660
+ this.afterPipe = false
661
+ const token = this.peek()
662
+ if (token.kind === 'arith') {
663
+ this.take()
664
+ this.redirectionsAfterCompound()
665
+ return
666
+ }
667
+ if (token.kind === 'op' && token.op === '(') {
668
+ this.take()
669
+ this.list((t) => t.kind === 'op' && t.op === ')')
670
+ this.expectOp(')')
671
+ this.redirectionsAfterCompound()
672
+ return
673
+ }
674
+ if (
675
+ token.kind === 'word' &&
676
+ token.reservedOk &&
677
+ token.word.text.replace(/\\\n/g, '') === '[['
678
+ ) {
679
+ this.bashOnly('[[')
680
+ this.conditional()
681
+ this.redirectionsAfterCompound()
682
+ return
683
+ }
684
+ if (
685
+ token.kind === 'word' &&
686
+ token.reservedOk &&
687
+ !token.word.quoted &&
688
+ !token.word.expands &&
689
+ !(afterPipe && token.word.value === 'time')
690
+ ) {
691
+ switch (token.word.value) {
692
+ case '{':
693
+ this.take()
694
+ this.braceGroup()
695
+ this.redirectionsAfterCompound()
696
+ return
697
+ case 'if':
698
+ this.ifCommand()
699
+ this.redirectionsAfterCompound()
700
+ return
701
+ case 'while':
702
+ case 'until':
703
+ this.take()
704
+ this.list((t) => this.isReserved(t, 'do'))
705
+ this.take()
706
+ this.list((t) => this.isReserved(t, 'done'))
707
+ this.take()
708
+ this.redirectionsAfterCompound()
709
+ return
710
+ case 'for':
711
+ case 'select':
712
+ if (token.word.value === 'select') this.bashOnly('select')
713
+ this.forCommand()
714
+ this.redirectionsAfterCompound()
715
+ return
716
+ case 'case':
717
+ this.caseCommand()
718
+ this.redirectionsAfterCompound()
719
+ return
720
+ case 'function':
721
+ this.bashOnly('function')
722
+ this.context.opaque('function definition')
723
+ this.take()
724
+ this.functionBody(true)
725
+ return
726
+ case 'coproc': {
727
+ this.bashOnly('coproc')
728
+ // `coproc [NAME] command`: runs in the background, and its
729
+ // descriptors land in a variable. Read the command; opaque.
730
+ this.context.opaque('coproc')
731
+ this.take()
732
+ const next = this.peek()
733
+ if (
734
+ next.kind === 'word' &&
735
+ COMPOUND_AFTER.test(this.src.slice(this.skipBlanks(next.end)))
736
+ ) {
737
+ this.take()
738
+ const body = this.peek()
739
+ if (body.kind === 'word') this.peeked = { ...body, reservedOk: true }
740
+ }
741
+ this.command()
742
+ return
743
+ }
744
+ case '}':
745
+ case 'then':
746
+ case 'else':
747
+ case 'elif':
748
+ case 'fi':
749
+ case 'do':
750
+ case 'done':
751
+ case 'esac':
752
+ case '!':
753
+ case 'time':
754
+ case 'in':
755
+ case ']]':
756
+ throw this.unexpected(token)
757
+ }
758
+ }
759
+ this.simpleCommand()
760
+ }
761
+
762
+ private ifCommand(): void {
763
+ this.take()
764
+ this.list((t) => this.isReserved(t, 'then'))
765
+ this.take()
766
+ this.list(
767
+ (t) => this.isReserved(t, 'elif') || this.isReserved(t, 'else') || this.isReserved(t, 'fi'),
768
+ )
769
+ for (;;) {
770
+ const token = this.take()
771
+ if (this.isReserved(token, 'fi')) return
772
+ if (this.isReserved(token, 'elif')) {
773
+ this.list((t) => this.isReserved(t, 'then'))
774
+ this.take()
775
+ this.list(
776
+ (t) =>
777
+ this.isReserved(t, 'elif') || this.isReserved(t, 'else') || this.isReserved(t, 'fi'),
778
+ )
779
+ continue
780
+ }
781
+ // else
782
+ this.list((t) => this.isReserved(t, 'fi'))
783
+ this.take()
784
+ return
785
+ }
786
+ }
787
+
788
+ private forCommand(): void {
789
+ this.take()
790
+ const header = this.skipBlanks(this.pos)
791
+ let token: Token
792
+ if (this.peeked === null && this.src.startsWith('((', header)) {
793
+ // `for (( init; test; step ))`: arithmetic throughout.
794
+ const end = this.scanArithmetic(header + 2)
795
+ if (end < 0) throw new Stop('syntax error: arithmetic for loop')
796
+ this.bashOnly('for ((…))')
797
+ this.arithmeticContent(this.src.slice(header + 2, end - 2), header + 2)
798
+ this.pos = end
799
+ this.last = { kind: 'other' }
800
+ this.newlines()
801
+ token = this.peek()
802
+ if (token.kind === 'op' && token.op === ';') {
803
+ this.take()
804
+ this.newlines()
805
+ }
806
+ this.loopBody()
807
+ return
808
+ }
809
+ const name = this.takePlainWord()
810
+ if (name.kind !== 'word') throw this.unexpected(name)
811
+ this.context.compoundWords.push(name.word)
812
+ this.newlines()
813
+ token = this.peek()
814
+ if (token.kind === 'word' && !token.word.quoted && token.word.value === 'in') {
815
+ this.take()
816
+ // Inside a case statement, bash reads `esac` right after `in` as
817
+ // the end of the case.
818
+ const first = this.peekPlainWord()
819
+ if (
820
+ this.caseDepth > 0 &&
821
+ first.kind === 'word' &&
822
+ !first.word.quoted &&
823
+ first.word.value === 'esac'
824
+ ) {
825
+ throw this.unexpected(first)
826
+ }
827
+ for (;;) {
828
+ token = this.takePlainWord()
829
+ if (token.kind === 'word') {
830
+ this.context.compoundWords.push(token.word)
831
+ continue
832
+ }
833
+ if (token.kind === 'newline' || (token.kind === 'op' && token.op === ';')) break
834
+ throw this.unexpected(token)
835
+ }
836
+ this.newlines()
837
+ } else if (token.kind === 'op' && token.op === ';') {
838
+ this.take()
839
+ this.newlines()
840
+ }
841
+ this.loopBody()
842
+ }
843
+
844
+ /** `do list done`, or bash's `{ list }` in its place. */
845
+ private loopBody(): void {
846
+ const token = this.peek()
847
+ if (this.isReservedAnywhere(token, 'do')) {
848
+ this.take()
849
+ this.list((t) => this.isReserved(t, 'done'))
850
+ this.take()
851
+ return
852
+ }
853
+ if (this.isReservedAnywhere(token, '{')) {
854
+ this.bashOnly('a { } loop body')
855
+ this.take()
856
+ this.braceGroup()
857
+ return
858
+ }
859
+ throw this.unexpected(token)
860
+ }
861
+
862
+ private caseDepth = 0
863
+
864
+ private caseCommand(): void {
865
+ this.caseDepth += 1
866
+ try {
867
+ this.caseBody()
868
+ } finally {
869
+ this.caseDepth -= 1
870
+ this.assignmentSyntax = true
871
+ this.allowCompound = true
872
+ }
873
+ }
874
+
875
+ private caseBody(): void {
876
+ this.take()
877
+ const subject = this.takePlainWord()
878
+ if (subject.kind !== 'word') throw this.unexpected(subject)
879
+ this.context.compoundWords.push(subject.word)
880
+ this.newlines()
881
+ const keyword = this.take()
882
+ if (keyword.kind !== 'word' || keyword.word.quoted || keyword.word.value !== 'in') {
883
+ throw this.unexpected(keyword)
884
+ }
885
+ // Patterns are never assignments.
886
+ const patterns = (): void => {
887
+ this.assignmentSyntax = false
888
+ this.allowCompound = false
889
+ }
890
+ patterns()
891
+ this.newlines()
892
+ for (;;) {
893
+ patterns()
894
+ let token = this.peek()
895
+ if (
896
+ token.kind === 'word' &&
897
+ !token.word.quoted &&
898
+ !token.word.expands &&
899
+ token.word.value === 'esac'
900
+ ) {
901
+ this.take()
902
+ return
903
+ }
904
+ if (token.kind === 'op' && token.op === '(') {
905
+ this.take()
906
+ token = this.peek()
907
+ }
908
+ // One or more patterns separated by `|`, then `)`.
909
+ for (;;) {
910
+ patterns()
911
+ const pattern = this.take()
912
+ if (pattern.kind !== 'word') throw this.unexpected(pattern)
913
+ this.context.compoundWords.push(pattern.word)
914
+ const next = this.take()
915
+ if (next.kind === 'op' && next.op === '|') continue
916
+ if (next.kind === 'op' && next.op === ')') break
917
+ throw this.unexpected(next)
918
+ }
919
+ this.assignmentSyntax = true
920
+ this.allowCompound = true
921
+ this.newlines()
922
+ token = this.peek()
923
+ const endsClause = (t: Token): boolean =>
924
+ (t.kind === 'op' && (t.op === ';;' || t.op === ';&' || t.op === ';;&')) ||
925
+ (t.kind === 'word' && !t.word.quoted && !t.word.expands && t.word.value === 'esac')
926
+ if (!endsClause(token)) this.list(endsClause)
927
+ token = this.take()
928
+ if (token.kind === 'word') return // esac
929
+ patterns()
930
+ this.newlines()
931
+ }
932
+ }
933
+
934
+ /**
935
+ * `[[ … ]]`. Nothing in it runs a command except a substitution, which
936
+ * word reading already records. Its operands are arithmetic in places
937
+ * (`-eq`), which can evaluate code held in a variable, so it is opaque.
938
+ */
939
+ private conditional(): void {
940
+ this.take()
941
+ this.context.opaque('conditional expression')
942
+ this.assignmentSyntax = false
943
+ this.allowCompound = false
944
+ for (;;) {
945
+ const token = this.takeConditional()
946
+ if (token.kind === 'eof') throw new Stop('unterminated [[')
947
+ if (token.kind === 'word' && !token.word.quoted && token.word.value === ']]') {
948
+ // Like `((…))`, a finished `[[…]]` accepts a reserved word next.
949
+ this.last = { kind: 'word', reserved: '}' }
950
+ this.assignmentSyntax = true
951
+ this.allowCompound = true
952
+ return
953
+ }
954
+ }
955
+ }
956
+
957
+ /** `name () body` or `function name [()] body`. */
958
+ private functionBody(keyword: boolean): void {
959
+ if (keyword) {
960
+ const name = this.take()
961
+ if (name.kind !== 'word') throw this.unexpected(name)
962
+ }
963
+ const open = this.peek()
964
+ if (open.kind === 'op' && open.op === '(') {
965
+ this.take()
966
+ this.expectOp(')')
967
+ } else if (!keyword) {
968
+ throw this.unexpected(open)
969
+ }
970
+ this.newlines()
971
+ // The body must be a compound command; the reserved word is recognised
972
+ // here whatever came before it.
973
+ const body = this.peek()
974
+ if (
975
+ body.kind === 'word' &&
976
+ !body.word.quoted &&
977
+ ['{', 'if', 'while', 'until', 'for', 'select', 'case', '[['].includes(body.word.value)
978
+ ) {
979
+ this.peeked = { ...body, reservedOk: true }
980
+ this.command()
981
+ return
982
+ }
983
+ if (body.kind === 'op' && body.op === '(') {
984
+ this.command()
985
+ return
986
+ }
987
+ if (body.kind === 'arith') {
988
+ this.command()
989
+ return
990
+ }
991
+ throw this.unexpected(body)
992
+ }
993
+
994
+ private redirectionsAfterCompound(): void {
995
+ for (;;) {
996
+ const token = this.peek()
997
+ if (token.kind === 'op' && REDIRECTIONS.has(token.op)) {
998
+ this.take()
999
+ this.context.redirections.push(this.redirectionTarget(token))
1000
+ continue
1001
+ }
1002
+ if (token.kind === 'word') throw this.unexpected(token)
1003
+ return
1004
+ }
1005
+ }
1006
+
1007
+ private simpleCommand(): void {
1008
+ const words: ShellWord[] = []
1009
+ const redirections: ShellRedirection[] = []
1010
+ let assignments = 0
1011
+ let afterAssignment = false
1012
+ let start = -1
1013
+ let end = -1
1014
+ for (;;) {
1015
+ // `NAME=(…)` and `NAME[…]` are read as assignment syntax only where an
1016
+ // assignment can stand: first in the command (after any leading
1017
+ // redirections), or right after another assignment. An array also
1018
+ // counts as an argument of `declare` and its kin.
1019
+ this.assignmentSyntax =
1020
+ words.length === 0 || (words.length === assignments && afterAssignment)
1021
+ this.allowCompound =
1022
+ this.assignmentSyntax || ASSIGNMENT_BUILTINS.has(words[assignments]?.value ?? '')
1023
+ const token = this.peek()
1024
+ if (token.kind === 'word') {
1025
+ this.take()
1026
+ if (start < 0) start = token.start
1027
+ end = token.end
1028
+ afterAssignment = false
1029
+ if (words.length === assignments && ASSIGNMENT.test(joined(token.word.text))) {
1030
+ assignments += 1
1031
+ if (/^[A-Za-z_][A-Za-z0-9_]*(?:\[|\+=)/.test(joined(token.word.text)))
1032
+ this.bashOnly('array or += assignment')
1033
+ afterAssignment = true
1034
+ const subscript = /^[A-Za-z_][A-Za-z0-9_]*\[([^\]]*)\]/.exec(joined(token.word.text))
1035
+ if (subscript && !/^\d*$/.test(subscript[1] as string))
1036
+ this.context.opaque('arithmetic subscript')
1037
+ }
1038
+ words.push(token.word)
1039
+ continue
1040
+ }
1041
+ if (token.kind === 'op' && REDIRECTIONS.has(token.op)) {
1042
+ this.take()
1043
+ if (start < 0) start = token.start
1044
+ afterAssignment = false
1045
+ // A bash 5 quirk, measured: in a command that so far has only
1046
+ // redirections, the target of a later `&>>` is read as an
1047
+ // assignment word, subscript included, and rejected when it is
1048
+ // shaped like one.
1049
+ const quirk = token.op === '&>>' && words.length === 0 && redirections.length > 0
1050
+ const redirection = this.redirectionTarget(token, quirk)
1051
+ if (quirk && ASSIGNMENT.test(joined(redirection.target.text)))
1052
+ throw new Stop('syntax error')
1053
+ end = this.lastEnd
1054
+ redirections.push(redirection)
1055
+ this.context.redirections.push(redirection)
1056
+ continue
1057
+ }
1058
+ if (
1059
+ token.kind === 'op' &&
1060
+ token.op === '(' &&
1061
+ words.length === 1 &&
1062
+ assignments === 0 &&
1063
+ redirections.length === 0
1064
+ ) {
1065
+ // `name ( ) compound`: a function definition. Its body runs only
1066
+ // when something calls it, under a name no rule sees.
1067
+ this.context.opaque('function definition')
1068
+ this.functionBody(false)
1069
+ return
1070
+ }
1071
+ break
1072
+ }
1073
+ this.allowCompound = true
1074
+ this.assignmentSyntax = true
1075
+ if (start < 0) throw this.unexpected(this.peek())
1076
+ const command: ShellCommand = {
1077
+ words,
1078
+ assignments,
1079
+ redirections,
1080
+ text: this.src.slice(start, end),
1081
+ origin: this.origin,
1082
+ depth: this.depth,
1083
+ }
1084
+ this.context.commands.push(command)
1085
+ this.inspect(command)
1086
+ }
1087
+
1088
+ /**
1089
+ * Take the next token where no assignment syntax applies: a redirection
1090
+ * target, a `for` name or word, a `case` subject.
1091
+ */
1092
+ private takePlainWord(): Token {
1093
+ const assignmentSyntax = this.assignmentSyntax
1094
+ const allowCompound = this.allowCompound
1095
+ this.assignmentSyntax = false
1096
+ this.allowCompound = false
1097
+ try {
1098
+ return this.take()
1099
+ } finally {
1100
+ this.assignmentSyntax = assignmentSyntax
1101
+ this.allowCompound = allowCompound
1102
+ }
1103
+ }
1104
+
1105
+ private peekPlainWord(): Token {
1106
+ const assignmentSyntax = this.assignmentSyntax
1107
+ const allowCompound = this.allowCompound
1108
+ this.assignmentSyntax = false
1109
+ this.allowCompound = false
1110
+ try {
1111
+ return this.peek()
1112
+ } finally {
1113
+ this.assignmentSyntax = assignmentSyntax
1114
+ this.allowCompound = allowCompound
1115
+ }
1116
+ }
1117
+
1118
+ /** The end offset of the last token taken. */
1119
+ private lastEnd = 0
1120
+ /** Whether a word may be a compound array assignment here. */
1121
+ private allowCompound = true
1122
+ /** Whether `NAME[…]` is read as a subscript here, spaces and all. */
1123
+ private assignmentSyntax = true
1124
+
1125
+ private redirectionTarget(
1126
+ operator: Token & { kind: 'op' },
1127
+ assignmentSyntax = false,
1128
+ ): ShellRedirection {
1129
+ const target = assignmentSyntax ? this.take() : this.takePlainWord()
1130
+ if (target.kind !== 'word') throw this.unexpected(target)
1131
+ if (
1132
+ (operator.op === '>&' || operator.op === '<&') &&
1133
+ (target.word.quoted || target.word.expands)
1134
+ ) {
1135
+ // Bash 5.2 expands the target of `>&` twice: `x >&2'$(cmd)'` and
1136
+ // `x >&2${v:-'$(cmd)'}` run `cmd`, which the parse saw quoted
1137
+ // (measured; fixed in 5.3, and 5.2 is what current Debian and
1138
+ // Ubuntu ship).
1139
+ this.context.opaque('quoted or expanding target of >& or <&')
1140
+ }
1141
+ const redirection: { -readonly [K in keyof ShellRedirection]: ShellRedirection[K] } = {
1142
+ operator: operator.op,
1143
+ ...(operator.fd !== undefined ? { fd: operator.fd } : {}),
1144
+ target: target.word,
1145
+ }
1146
+ if (operator.op === '<<' || operator.op === '<<-') {
1147
+ if (target.word.value.includes('\n')) {
1148
+ // Bash starts the body at the newline inside the delimiter, so
1149
+ // the rest of the line is body text. Reading on as commands
1150
+ // reports more than runs, which is the safe way to be wrong.
1151
+ this.context.opaque('here-document delimiter spans lines')
1152
+ } else
1153
+ this.heredocs.push({
1154
+ delimiter: target.word.value,
1155
+ stripTabs: operator.op === '<<-',
1156
+ quoted: target.word.quoted,
1157
+ redirection,
1158
+ })
1159
+ }
1160
+ return redirection
1161
+ }
1162
+
1163
+ /**
1164
+ * What a finished simple command means for the rest of the line: a nested
1165
+ * shell to read, or a command that changes how bash parses what follows.
1166
+ */
1167
+ private inspect(command: ShellCommand): void {
1168
+ for (const word of command.words) {
1169
+ // Either variable, set in the line, changes how bash parses the rest.
1170
+ if (word.value.includes('POSIXLY_CORRECT=') || word.value.includes('BASH_COMPAT=')) {
1171
+ this.context.opaque('parser setting')
1172
+ }
1173
+ }
1174
+ const head = command.words[command.assignments]
1175
+ if (head === undefined || head.expands) return
1176
+ const name = basename(head.value)
1177
+ if (
1178
+ name !== 'shopt' &&
1179
+ name !== 'enable' &&
1180
+ name !== 'set' &&
1181
+ name !== 'busybox' &&
1182
+ !NESTED_SHELLS.has(name)
1183
+ )
1184
+ return
1185
+ const words = command.words.slice(command.assignments)
1186
+ if (name === 'shopt' || name === 'enable') {
1187
+ this.context.opaque('parser setting')
1188
+ return
1189
+ }
1190
+ if (name === 'set') {
1191
+ for (let i = 1; i < words.length; i += 1) {
1192
+ const word = words[i] as ShellWord
1193
+ if (word.expands) this.context.opaque('parser setting')
1194
+ if (/^[-+][A-Za-z]*k/.test(word.value) || word.value === '--posix')
1195
+ this.context.opaque('parser setting')
1196
+ if (
1197
+ /^[-+][A-Za-z]*o$/.test(word.value) &&
1198
+ /^(?:posix|keyword)$/.test(words[i + 1]?.value ?? '')
1199
+ ) {
1200
+ this.context.opaque('parser setting')
1201
+ }
1202
+ }
1203
+ return
1204
+ }
1205
+ if (name === 'busybox' && words[1] !== undefined && !words[1].expands) {
1206
+ if (NESTED_SHELLS.has(basename(words[1].value))) this.nestedShell(words.slice(1))
1207
+ return
1208
+ }
1209
+ if (!NESTED_SHELLS.has(name)) return
1210
+ this.nestedShell(words)
1211
+ }
1212
+
1213
+ /**
1214
+ * `bash [options] -c payload [name args…]`: the payload is the first
1215
+ * non-option argument once `-c` has been seen among the options.
1216
+ */
1217
+ private nestedShell(words: readonly ShellWord[]): void {
1218
+ const shell = basename(words[0]?.value ?? '')
1219
+ let command = false
1220
+ let payload: ShellWord | undefined
1221
+ for (let i = 1; i < words.length; i += 1) {
1222
+ const word = words[i] as ShellWord
1223
+ if (word.expands) {
1224
+ this.context.opaque('nested shell option')
1225
+ return
1226
+ }
1227
+ const value = word.value
1228
+ if (value === '--' || value === '-') {
1229
+ payload = words[i + 1]
1230
+ break
1231
+ }
1232
+ if (value.startsWith('--')) {
1233
+ if (value === '--rcfile' || value === '--init-file') i += 1
1234
+ continue
1235
+ }
1236
+ if (/^[-+][A-Za-z]+$/.test(value)) {
1237
+ if (value.startsWith('-') && value.includes('c')) command = true
1238
+ // `-o name`, `-O name`: the option takes the next word.
1239
+ if (/[oO]$/.test(value)) i += 1
1240
+ continue
1241
+ }
1242
+ payload = word
1243
+ break
1244
+ }
1245
+ if (!command) return
1246
+ if (payload === undefined) {
1247
+ this.context.opaque('nested shell without a command')
1248
+ return
1249
+ }
1250
+ if (payload.expands) {
1251
+ this.context.opaque('nested shell command is expanded at runtime')
1252
+ return
1253
+ }
1254
+ if (this.depth + 1 >= MAX_SHELL_DEPTH) {
1255
+ this.context.opaque('nested shells too deep')
1256
+ return
1257
+ }
1258
+ const origin = this.origin === 'substitution' ? 'substitution' : 'shell'
1259
+ this.context.rescan(payload.value.length)
1260
+ // `bash -c` is read as bash. Another shell is read in the dialect
1261
+ // that holds for every POSIX shell; zsh and ksh go beyond POSIX in
1262
+ // ways this lexer does not model, so their payloads are opaque, and
1263
+ // still read for what a deny rule can see.
1264
+ this.context.setDialect(payload.value, shell === 'bash' ? 'bash' : 'sh')
1265
+ if (shell === 'zsh' || shell === 'ksh' || shell === 'mksh')
1266
+ this.context.opaque(`nested ${shell} is not modeled`)
1267
+ const inner = new Parser(
1268
+ payload.value,
1269
+ 0,
1270
+ this.context,
1271
+ origin,
1272
+ this.depth + 1,
1273
+ this.nestingNow + 1,
1274
+ )
1275
+ try {
1276
+ inner.program()
1277
+ } catch (error) {
1278
+ if (!(error instanceof Stop)) throw error
1279
+ // The payload does not parse. Bash would refuse it, but this
1280
+ // reading may be wrong about that, so it is opaque rather than empty.
1281
+ this.context.opaque(`nested shell: ${error.reason}`)
1282
+ }
1283
+ }
1284
+
1285
+ /** Compound commands nest on the JS stack; bound how deep. */
1286
+ private level = 0
1287
+ private get nestingNow(): number {
1288
+ return this.nesting + this.level
1289
+ }
1290
+
1291
+ private expectOp(op: string): Token {
1292
+ const token = this.take()
1293
+ if (token.kind !== 'op' || token.op !== op) throw this.unexpected(token)
1294
+ return token
1295
+ }
1296
+
1297
+ private newlines(): void {
1298
+ while (this.peek().kind === 'newline') this.take()
1299
+ }
1300
+
1301
+ private isReserved(token: Token, name: string): boolean {
1302
+ return (
1303
+ token.kind === 'word' &&
1304
+ token.reservedOk &&
1305
+ !token.word.quoted &&
1306
+ !token.word.expands &&
1307
+ token.word.value === name
1308
+ )
1309
+ }
1310
+
1311
+ /** A reserved word recognised by position in a `for` header regardless of what preceded it. */
1312
+ private isReservedAnywhere(token: Token, name: string): boolean {
1313
+ return (
1314
+ token.kind === 'word' &&
1315
+ !token.word.quoted &&
1316
+ !token.word.expands &&
1317
+ token.word.value === name
1318
+ )
1319
+ }
1320
+
1321
+ private unexpected(token: Token): Stop {
1322
+ if (token.kind === 'eof') return new Stop('syntax error: unexpected end of input')
1323
+ return new Stop('syntax error')
1324
+ }
1325
+
1326
+ // --- tokens -----------------------------------------------------------
1327
+
1328
+ private peek(): Token {
1329
+ if (this.peeked === null) this.peeked = this.read()
1330
+ return this.peeked
1331
+ }
1332
+
1333
+ private take(): Token {
1334
+ const token = this.peek()
1335
+ this.peeked = null
1336
+ this.lastEnd = token.end
1337
+ if (token.kind === 'word') {
1338
+ const reserved =
1339
+ token.reservedOk &&
1340
+ !token.word.quoted &&
1341
+ !token.word.expands &&
1342
+ RESERVED.has(token.word.value)
1343
+ ? token.word.value
1344
+ : null
1345
+ this.last = { kind: 'word', reserved }
1346
+ } else if (token.kind === 'op') {
1347
+ this.last = { kind: 'op', op: token.op }
1348
+ } else if (token.kind === 'newline') {
1349
+ this.last = { kind: 'newline' }
1350
+ } else {
1351
+ this.last = { kind: 'other' }
1352
+ }
1353
+ return token
1354
+ }
1355
+
1356
+ private reservedOk(): boolean {
1357
+ const last = this.last
1358
+ switch (last.kind) {
1359
+ case 'start':
1360
+ case 'newline':
1361
+ return true
1362
+ case 'op':
1363
+ return RESERVED_AFTER_OPS.has(last.op)
1364
+ case 'word':
1365
+ return last.reserved !== null && RESERVED_AFTER_WORDS.has(last.reserved)
1366
+ default:
1367
+ return false
1368
+ }
1369
+ }
1370
+
1371
+ /** Skip any `\<newline>` pairs at `at`: bash removes them before tokenizing. */
1372
+ private cont(at: number): number {
1373
+ let i = at
1374
+ for (;;) {
1375
+ if (this.src[i] !== '\\') return i
1376
+ const next = i + 1
1377
+ if (this.src[next] === '\n') i += 2
1378
+ else if (next === this.src.length && this.source.finalLineRaw) {
1379
+ this.bashOnly('a trailing backslash')
1380
+ i += 1
1381
+ } else return i
1382
+ }
1383
+ }
1384
+
1385
+ private skipBlanks(at: number): number {
1386
+ let i = this.cont(at)
1387
+ while (this.src[i] === ' ' || this.src[i] === '\t') i = this.cont(i + 1)
1388
+ return i
1389
+ }
1390
+
1391
+ private read(): Token {
1392
+ const src = this.src
1393
+ for (;;) {
1394
+ const start = this.skipBlanks(this.pos)
1395
+ this.pos = start
1396
+ if (start >= src.length) return { kind: 'eof', start, end: start }
1397
+ const char = src[start] as string
1398
+ if (char === '#') {
1399
+ // A comment runs to the newline; continuations do not extend it.
1400
+ let i = start
1401
+ while (i < src.length && src[i] !== '\n') i += 1
1402
+ this.pos = i
1403
+ continue
1404
+ }
1405
+ if (char === '\n') {
1406
+ this.pos = start + 1
1407
+ this.readHeredocs()
1408
+ return { kind: 'newline', start, end: start + 1 }
1409
+ }
1410
+ if (char === '}' && this.closesOnBrace && this.braceDepth === 0 && this.reservedOk()) {
1411
+ this.pos = start + 1
1412
+ return { kind: 'op', start, end: start + 1, op: '}' }
1413
+ }
1414
+ // After `<&` or `>&`, bash takes a `-` as a token of its own: `3<&-a`
1415
+ // closes descriptor 3 and `a` is the next word.
1416
+ if (
1417
+ char === '-' &&
1418
+ this.last.kind === 'op' &&
1419
+ (this.last.op === '<&' || this.last.op === '>&')
1420
+ ) {
1421
+ const after = src[this.cont(start + 1)]
1422
+ if (after !== undefined && !WORD_BREAK.has(after))
1423
+ this.bashOnly('a word glued to <&- or >&-')
1424
+ this.pos = start + 1
1425
+ return {
1426
+ kind: 'word',
1427
+ start,
1428
+ end: start + 1,
1429
+ word: { text: '-', value: '-', expands: false, quoted: false },
1430
+ reservedOk: false,
1431
+ }
1432
+ }
1433
+ return this.operatorOrWord(start)
1434
+ }
1435
+ }
1436
+
1437
+ /** Read the operator starting at `start`, or a word. */
1438
+ private operatorOrWord(start: number): Token {
1439
+ const src = this.src
1440
+ const char = src[start] as string
1441
+ const n1 = this.cont(start + 1)
1442
+ const c1 = src[n1]
1443
+ const op = (text: string, end: number, fd?: string): Token => {
1444
+ this.pos = end
1445
+ return fd === undefined
1446
+ ? { kind: 'op', start, end, op: text }
1447
+ : { kind: 'op', start, end, op: text, fd }
1448
+ }
1449
+ switch (char) {
1450
+ case ';': {
1451
+ if (c1 === ';') {
1452
+ const n2 = this.cont(n1 + 1)
1453
+ if (src[n2] === '&') {
1454
+ this.bashOnly(';;&')
1455
+ return op(';;&', n2 + 1)
1456
+ }
1457
+ return op(';;', n1 + 1)
1458
+ }
1459
+ if (c1 === '&') {
1460
+ this.bashOnly(';&')
1461
+ return op(';&', n1 + 1)
1462
+ }
1463
+ return op(';', start + 1)
1464
+ }
1465
+ case '&': {
1466
+ if (c1 === '&') return op('&&', n1 + 1)
1467
+ if (c1 === '>') {
1468
+ const n2 = this.cont(n1 + 1)
1469
+ this.bashOnly('&> and &>>')
1470
+ if (src[n2] === '>') return op('&>>', n2 + 1)
1471
+ return op('&>', n1 + 1)
1472
+ }
1473
+ return op('&', start + 1)
1474
+ }
1475
+ case '|': {
1476
+ if (c1 === '|') return op('||', n1 + 1)
1477
+ if (c1 === '&') {
1478
+ this.bashOnly('|&')
1479
+ return op('|&', n1 + 1)
1480
+ }
1481
+ return op('|', start + 1)
1482
+ }
1483
+ case '(': {
1484
+ if (c1 === '(' && this.reservedOk()) {
1485
+ const end = this.arithmeticCommand(n1 + 1)
1486
+ if (end >= 0) {
1487
+ this.pos = end
1488
+ this.bashOnly('((…))')
1489
+ return { kind: 'arith', start, end }
1490
+ }
1491
+ }
1492
+ return op('(', start + 1)
1493
+ }
1494
+ case ')':
1495
+ return op(')', start + 1)
1496
+ case '<':
1497
+ case '>': {
1498
+ if (c1 === '(') return this.word(start)
1499
+ return this.redirectionOperator(start, undefined)
1500
+ }
1501
+ }
1502
+ return this.word(start)
1503
+ }
1504
+
1505
+ private redirectionOperator(start: number, fd: string | undefined): Token {
1506
+ const src = this.src
1507
+ const char = src[start] as string
1508
+ const n1 = this.cont(start + 1)
1509
+ const c1 = src[n1]
1510
+ const done = (text: string, end: number): Token => {
1511
+ this.pos = end
1512
+ return fd === undefined
1513
+ ? { kind: 'op', start, end, op: text }
1514
+ : { kind: 'op', start, end, op: text, fd }
1515
+ }
1516
+ if (char === '<') {
1517
+ if (c1 === '<') {
1518
+ const n2 = this.cont(n1 + 1)
1519
+ if (src[n2] === '<') {
1520
+ this.bashOnly('<<<')
1521
+ return done('<<<', n2 + 1)
1522
+ }
1523
+ if (src[n2] === '-') return done('<<-', n2 + 1)
1524
+ return done('<<', n1 + 1)
1525
+ }
1526
+ if (c1 === '>') return done('<>', n1 + 1)
1527
+ if (c1 === '&') return done('<&', n1 + 1)
1528
+ return done('<', start + 1)
1529
+ }
1530
+ if (c1 === '>') return done('>>', n1 + 1)
1531
+ if (c1 === '|') return done('>|', n1 + 1)
1532
+ if (c1 === '&') return done('>&', n1 + 1)
1533
+ return done('>', start + 1)
1534
+ }
1535
+
1536
+ /** Tokens inside `[[ … ]]`, where `<`, `>` and `(` are operands. */
1537
+ private takeConditional(): Token {
1538
+ this.peeked = null
1539
+ const start = this.skipBlanks(this.pos)
1540
+ this.pos = start
1541
+ const src = this.src
1542
+ if (start >= src.length) return { kind: 'eof', start, end: start }
1543
+ const char = src[start] as string
1544
+ if (char === '\n') {
1545
+ this.pos = start + 1
1546
+ this.readHeredocs()
1547
+ return { kind: 'newline', start, end: start + 1 }
1548
+ }
1549
+ if (char === '<' || char === '>' || char === '(' || char === ')' || char === ';') {
1550
+ this.pos = start + 1
1551
+ return { kind: 'op', start, end: start + 1, op: char }
1552
+ }
1553
+ if (char === '&' || char === '|') {
1554
+ const n1 = this.cont(start + 1)
1555
+ const end = src[n1] === char ? n1 + 1 : start + 1
1556
+ this.pos = end
1557
+ return { kind: 'op', start, end, op: src.slice(start, end) }
1558
+ }
1559
+ const token = this.word(start)
1560
+ return token
1561
+ }
1562
+
1563
+ /**
1564
+ * `(( … ))` at command position. Returns the offset past the closing
1565
+ * `))`, or -1 when the parentheses do not close that way, in which case
1566
+ * bash reads the text as nested subshells instead.
1567
+ */
1568
+ private arithmeticCommand(from: number): number {
1569
+ const scanned = this.scanArithmetic(from)
1570
+ if (scanned < 0) return -1
1571
+ const content = this.src.slice(from, scanned - 2)
1572
+ this.arithmeticContent(content, from)
1573
+ return scanned
1574
+ }
1575
+
1576
+ /**
1577
+ * Scan arithmetic text from `from` to the `))` that closes it at depth
1578
+ * zero. Returns the offset past `))`, or -1.
1579
+ */
1580
+ private scanArithmetic(from: number): number {
1581
+ const src = this.src
1582
+ let depth = 0
1583
+ let i = from
1584
+ while (i < src.length) {
1585
+ i = this.cont(i)
1586
+ const char = src[i]
1587
+ if (char === undefined) return -1
1588
+ if (char === '\\') {
1589
+ i += 2
1590
+ continue
1591
+ }
1592
+ if (char === "'" || char === '"' || char === '`') {
1593
+ const close = src.indexOf(char, i + 1)
1594
+ if (close < 0) {
1595
+ this.context.rescan(src.length - from)
1596
+ return -1
1597
+ }
1598
+ i = close + 1
1599
+ continue
1600
+ }
1601
+ if (char === '(') depth += 1
1602
+ else if (char === ')') {
1603
+ if (depth === 0) {
1604
+ const next = this.cont(i + 1)
1605
+ this.context.rescan(i - from)
1606
+ return src[next] === ')' ? next + 1 : -1
1607
+ }
1608
+ depth -= 1
1609
+ }
1610
+ i += 1
1611
+ }
1612
+ this.context.rescan(i - from)
1613
+ return -1
1614
+ }
1615
+
1616
+ /**
1617
+ * Arithmetic evaluates variables recursively, and a variable holding
1618
+ * `a[$(cmd)]` runs `cmd`. Only arithmetic on literals is transparent.
1619
+ */
1620
+ private arithmeticContent(content: string, at: number): void {
1621
+ const plain = content.replace(/\\\n/g, '')
1622
+ if (/^[0-9\s+\-*/%()<>=!&|^~?:,]*$/.test(plain)) return
1623
+ this.context.opaque('arithmetic')
1624
+ if (/\$\(|`/.test(plain)) {
1625
+ // Read the substitution for the commands it runs.
1626
+ this.scanForSubstitutions(at, at + content.length)
1627
+ }
1628
+ }
1629
+
1630
+ /** Record the commands inside any `$(…)` or backticks in [from, to). */
1631
+ private scanForSubstitutions(from: number, to: number): void {
1632
+ let i = from
1633
+ while (i < to) {
1634
+ const char = this.src[i]
1635
+ if (char === '$' && this.src[i + 1] === '(' && this.src[i + 2] !== '(') {
1636
+ const inner = new Parser(
1637
+ this.src,
1638
+ i + 2,
1639
+ this.context,
1640
+ 'substitution',
1641
+ this.depth,
1642
+ this.nestingNow + 1,
1643
+ )
1644
+ try {
1645
+ i = inner.substitution()
1646
+ } catch (error) {
1647
+ if (!(error instanceof Stop)) throw error
1648
+ return
1649
+ }
1650
+ continue
1651
+ }
1652
+ if (char === '`') {
1653
+ const end = this.backtick(i, new WordBuilder(), false)
1654
+ i = end
1655
+ continue
1656
+ }
1657
+ i += 1
1658
+ }
1659
+ }
1660
+
1661
+ // --- here-documents ---------------------------------------------------
1662
+
1663
+ /** Consume the bodies of here-documents opened on the line just ended. */
1664
+ private readHeredocs(): void {
1665
+ const src = this.src
1666
+ while (this.heredocs.length > 0) {
1667
+ const heredoc = this.heredocs.shift() as PendingHeredoc
1668
+ let i = this.pos
1669
+ const bodyStart = i
1670
+ let bodyEnd = i
1671
+ for (;;) {
1672
+ bodyEnd = i
1673
+ if (i >= src.length) break
1674
+ let line = ''
1675
+ let j = i
1676
+ for (;;) {
1677
+ const newline = src.indexOf('\n', j)
1678
+ const lineEnd = newline < 0 ? src.length : newline
1679
+ const piece = src.slice(j, lineEnd)
1680
+ // In an unquoted body, a backslash that escapes the newline
1681
+ // joins the lines before the delimiter test.
1682
+ if (!heredoc.quoted && newline >= 0 && trailingBackslashes(piece) % 2 === 1) {
1683
+ this.bashOnly('a line continuation in a here-document')
1684
+ line += piece.slice(0, -1)
1685
+ j = newline + 1
1686
+ continue
1687
+ }
1688
+ line += piece
1689
+ j = newline < 0 ? src.length : newline + 1
1690
+ break
1691
+ }
1692
+ const test = heredoc.stripTabs ? line.replace(/^\t+/, '') : line
1693
+ i = j
1694
+ if (test === heredoc.delimiter) break
1695
+ bodyEnd = i
1696
+ }
1697
+ heredoc.redirection.body = src.slice(bodyStart, bodyEnd)
1698
+ if (!heredoc.quoted) {
1699
+ const body = src.slice(bodyStart, i)
1700
+ if (/\$[({[]|`/.test(body)) this.heredocBody(body)
1701
+ }
1702
+ this.pos = i
1703
+ }
1704
+ }
1705
+
1706
+ /** An unquoted here-document body expands; read it for substitutions. */
1707
+ private heredocBody(body: string): void {
1708
+ // Parameter expansion in a body runs nothing; substitution and
1709
+ // arithmetic might. Read with double-quote rules, where `"` is plain.
1710
+ this.context.rescan(body.length)
1711
+ this.inherit(body)
1712
+ const reader = new Parser(body, 0, this.context, this.origin, this.depth, this.nestingNow + 1)
1713
+ try {
1714
+ let i = 0
1715
+ const builder = new WordBuilder()
1716
+ while (i < body.length) {
1717
+ const char = body[i]
1718
+ if (char === '\\') {
1719
+ i += 2
1720
+ continue
1721
+ }
1722
+ if (char === '$') {
1723
+ i = reader.dollar(i, builder, true)
1724
+ continue
1725
+ }
1726
+ if (char === '`') {
1727
+ i = reader.backtick(i, builder, true)
1728
+ continue
1729
+ }
1730
+ i += 1
1731
+ }
1732
+ } catch (error) {
1733
+ if (!(error instanceof Stop)) throw error
1734
+ this.context.opaque(`here-document: ${error.reason}`)
1735
+ }
1736
+ }
1737
+
1738
+ // --- words ------------------------------------------------------------
1739
+
1740
+ private word(start: number): Token {
1741
+ const reservedOk = this.reservedOk()
1742
+ const src = this.src
1743
+ const builder = new WordBuilder()
1744
+ let i = start
1745
+ for (;;) {
1746
+ i = this.cont(i)
1747
+ if (i >= src.length) break
1748
+ const char = src[i] as string
1749
+ if (WORD_BREAK.has(char)) {
1750
+ // `<(…)` and `>(…)` are words of their own.
1751
+ // `<(…)` and `>(…)` are read as part of the word, even mid-word.
1752
+ if ((char === '<' || char === '>') && src[this.cont(i + 1)] === '(') {
1753
+ i = this.processSubstitution(i, builder)
1754
+ continue
1755
+ }
1756
+ if (
1757
+ char === '(' &&
1758
+ this.allowCompound &&
1759
+ /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=$/.test(joined(src.slice(start, i)))
1760
+ ) {
1761
+ this.bashOnly('array assignment')
1762
+ i = this.compoundArray(i + 1, builder)
1763
+ continue
1764
+ }
1765
+ break
1766
+ }
1767
+ if (
1768
+ char === '[' &&
1769
+ this.assignmentSyntax &&
1770
+ /^[A-Za-z_][A-Za-z0-9_]*$/.test(joined(src.slice(start, i)))
1771
+ ) {
1772
+ // `NAME[…]` where an assignment may stand: bash reads the
1773
+ // subscript as one unit, blanks and quotes included.
1774
+ this.bashOnly('subscript')
1775
+ const close = this.subscript(i + 1)
1776
+ builder.expansion(src.slice(i, close))
1777
+ i = close
1778
+ continue
1779
+ }
1780
+ if (char === '\\') {
1781
+ if (i + 1 >= src.length) {
1782
+ // A trailing backslash is a literal backslash.
1783
+ this.bashOnly('a trailing backslash')
1784
+ builder.literal('\\')
1785
+ i += 1
1786
+ continue
1787
+ }
1788
+ builder.quotedText(src[i + 1] as string)
1789
+ i += 2
1790
+ continue
1791
+ }
1792
+ if (char === "'") {
1793
+ const close = src.indexOf("'", i + 1)
1794
+ if (close < 0) throw new Stop('unterminated quote')
1795
+ this.singleQuoted(i, close)
1796
+ builder.quotedText(src.slice(i + 1, close))
1797
+ i = close + 1
1798
+ continue
1799
+ }
1800
+ if (char === '"') {
1801
+ i = this.doubleQuoted(i + 1, builder)
1802
+ continue
1803
+ }
1804
+ if (char === '`') {
1805
+ i = this.backtick(i, builder, false)
1806
+ continue
1807
+ }
1808
+ if (char === '$') {
1809
+ i = this.dollar(i, builder, false)
1810
+ continue
1811
+ }
1812
+ builder.unquoted(char)
1813
+ i += 1
1814
+ }
1815
+ this.pos = i
1816
+ if (builder.brace) this.bashOnly('brace expansion')
1817
+ const word: ShellWord = {
1818
+ text: src.slice(start, i),
1819
+ value: builder.value,
1820
+ expands: builder.expands,
1821
+ quoted: builder.quoted,
1822
+ }
1823
+ // A descriptor before a redirection operator: `2>`, `{fd}>`.
1824
+ const next = src[i]
1825
+ if ((next === '<' || next === '>') && src[this.cont(i + 1)] !== '(') {
1826
+ const fd = joined(word.text)
1827
+ if (/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(fd)) this.bashOnly('{name} redirection')
1828
+ if (/^\d+$/.test(fd) || /^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(fd)) {
1829
+ const operator = this.redirectionOperator(i, fd)
1830
+ return { ...operator, start }
1831
+ }
1832
+ }
1833
+ return { kind: 'word', start, end: i, word, reservedOk }
1834
+ }
1835
+
1836
+ /**
1837
+ * After the `(` of `NAME=(…)`: read the element words to the closing `)`.
1838
+ * Returns the offset past it. The whole word is marked as expanding: an
1839
+ * array is not one argument.
1840
+ */
1841
+ private compoundArray(from: number, builder: WordBuilder): number {
1842
+ const src = this.src
1843
+ let i = from
1844
+ for (;;) {
1845
+ i = this.cont(i)
1846
+ if (i >= src.length) throw new Stop('syntax error: unterminated array')
1847
+ const char = src[i] as string
1848
+ if (char === ' ' || char === '\t' || char === '\n') {
1849
+ i += 1
1850
+ continue
1851
+ }
1852
+ if (char === '#') {
1853
+ while (i < src.length && src[i] !== '\n') i += 1
1854
+ continue
1855
+ }
1856
+ if (char === ')') {
1857
+ builder.expansion(src.slice(from - 1, i + 1))
1858
+ return i + 1
1859
+ }
1860
+ if (
1861
+ WORD_BREAK.has(char) &&
1862
+ !((char === '<' || char === '>') && src[this.cont(i + 1)] === '(')
1863
+ ) {
1864
+ throw new Stop('syntax error in array')
1865
+ }
1866
+ const element = this.word(i)
1867
+ if (element.kind !== 'word') throw new Stop('syntax error in array')
1868
+ const subscript = /^\[([^\]]*)\]\+?=/.exec(element.word.text)
1869
+ if (subscript && !/^\d*$/.test(subscript[1] as string))
1870
+ this.context.opaque('arithmetic subscript')
1871
+ i = element.end
1872
+ }
1873
+ }
1874
+
1875
+ /** After the `[` of `NAME[`: returns the offset past the matching `]`. */
1876
+ private subscript(from: number): number {
1877
+ const src = this.src
1878
+ const scratch = new WordBuilder()
1879
+ let depth = 0
1880
+ let i = from
1881
+ for (;;) {
1882
+ i = this.cont(i)
1883
+ if (i >= src.length) throw new Stop('syntax error: unterminated subscript')
1884
+ const char = src[i] as string
1885
+ if (char === '\\') {
1886
+ i += 2
1887
+ continue
1888
+ }
1889
+ if (char === "'") {
1890
+ const close = src.indexOf("'", i + 1)
1891
+ if (close < 0) throw new Stop('unterminated quote')
1892
+ this.singleQuoted(i, close)
1893
+ i = close + 1
1894
+ continue
1895
+ }
1896
+ if (char === '"') {
1897
+ i = this.doubleQuoted(i + 1, scratch)
1898
+ continue
1899
+ }
1900
+ if (char === '`') {
1901
+ i = this.backtick(i, scratch, false)
1902
+ continue
1903
+ }
1904
+ if (char === '$') {
1905
+ i = this.dollar(i, scratch, false)
1906
+ continue
1907
+ }
1908
+ if ((char === '<' || char === '>') && src[this.cont(i + 1)] === '(') {
1909
+ // Measured: `b[<(cmd)]` runs `cmd`.
1910
+ i = this.processSubstitution(i, scratch)
1911
+ continue
1912
+ }
1913
+ if (char === '[') depth += 1
1914
+ else if (char === ']') {
1915
+ if (depth === 0) return i + 1
1916
+ depth -= 1
1917
+ }
1918
+ i += 1
1919
+ }
1920
+ }
1921
+
1922
+ /** After an opening `"`: returns the offset past the closing one. */
1923
+ private doubleQuoted(from: number, builder: WordBuilder): number {
1924
+ const src = this.src
1925
+ let i = from
1926
+ builder.quotedText('')
1927
+ for (;;) {
1928
+ i = this.cont(i)
1929
+ if (i >= src.length) throw new Stop('unterminated quote')
1930
+ const char = src[i] as string
1931
+ if (char === '"') return i + 1
1932
+ if (char === '\\') {
1933
+ const next = src[i + 1]
1934
+ if (next === '$' || next === '`' || next === '"' || next === '\\') {
1935
+ builder.quotedText(next)
1936
+ i += 2
1937
+ continue
1938
+ }
1939
+ builder.quotedText('\\')
1940
+ i += 1
1941
+ continue
1942
+ }
1943
+ if (char === '$') {
1944
+ i = this.dollar(i, builder, true)
1945
+ continue
1946
+ }
1947
+ if (char === '`') {
1948
+ i = this.backtick(i, builder, true)
1949
+ continue
1950
+ }
1951
+ builder.quotedText(char)
1952
+ i += 1
1953
+ }
1954
+ }
1955
+
1956
+ /** A `$` at `at`. Returns the offset past whatever it introduces. */
1957
+ dollar(at: number, builder: WordBuilder, inDouble: boolean): number {
1958
+ const src = this.src
1959
+ const n = this.cont(at + 1)
1960
+ const next = src[n]
1961
+ if (next === "'" && !inDouble) {
1962
+ this.bashOnly("$'…'")
1963
+ return this.ansiC(n + 1, builder)
1964
+ }
1965
+ if (next === '"' && !inDouble) {
1966
+ this.bashOnly('$"…"')
1967
+ // `$"…"` is translated through the message catalogue at runtime.
1968
+ const inner = new WordBuilder()
1969
+ const end = this.doubleQuoted(n + 1, inner)
1970
+ builder.expansion(src.slice(at, end))
1971
+ builder.quoted = true
1972
+ if (inner.expands) builder.expands = true
1973
+ return end
1974
+ }
1975
+ if (next === '{') {
1976
+ const n2 = this.cont(n + 1)
1977
+ const inner = src[n2]
1978
+ if (inner === ' ' || inner === '\t' || inner === '\n' || inner === '|') {
1979
+ // Bash 5.3's `${ list; }` and `${| list; }`: a command
1980
+ // substitution that runs in the current shell.
1981
+ this.context.opaque('command substitution')
1982
+ const parser = new Parser(
1983
+ src,
1984
+ inner === '|' ? n2 + 1 : n2,
1985
+ this.context,
1986
+ 'substitution',
1987
+ this.depth,
1988
+ this.nestingNow + 1,
1989
+ )
1990
+ const end = parser.braceSubstitution()
1991
+ builder.expansion(src.slice(at, end))
1992
+ return end
1993
+ }
1994
+ const end = this.parameterBraces(n + 1, inDouble)
1995
+ builder.expansion(src.slice(at, end))
1996
+ return end
1997
+ }
1998
+ if (next === '(') {
1999
+ const n2 = this.cont(n + 1)
2000
+ if (src[n2] === '(') {
2001
+ const end = this.scanArithmetic(n2 + 1)
2002
+ if (end >= 0) {
2003
+ this.arithmeticContent(src.slice(n2 + 1, end - 2), n2 + 1)
2004
+ builder.expansion(src.slice(at, end))
2005
+ return end
2006
+ }
2007
+ }
2008
+ this.context.opaque('command substitution')
2009
+ const inner = new Parser(
2010
+ src,
2011
+ n + 1,
2012
+ this.context,
2013
+ 'substitution',
2014
+ this.depth,
2015
+ this.nestingNow + 1,
2016
+ )
2017
+ const end = inner.substitution()
2018
+ builder.expansion(src.slice(at, end))
2019
+ return end
2020
+ }
2021
+ if (next === '[') {
2022
+ this.bashOnly('$[…]')
2023
+ const end = this.matchBracket(n + 1)
2024
+ this.arithmeticContent(src.slice(n + 1, end - 1), n + 1)
2025
+ builder.expansion(src.slice(at, end))
2026
+ return end
2027
+ }
2028
+ if (next !== undefined && NAME_START.test(next)) {
2029
+ let i = n + 1
2030
+ for (;;) {
2031
+ i = this.cont(i)
2032
+ if (i < src.length && NAME_CHAR.test(src[i] as string)) i += 1
2033
+ else break
2034
+ }
2035
+ builder.expansion(src.slice(at, i))
2036
+ return i
2037
+ }
2038
+ if (next === '$' && inDouble) {
2039
+ // Bash's parser pairs `$$` inside double quotes, so a `(` or `{`
2040
+ // after it is literal text as far as the extent of the string goes.
2041
+ // Its expander does not: it re-scans the string and reads the
2042
+ // second `$` as the start of `$(…)` or `${…}`, and so
2043
+ // `"$${x:-"'$(cmd)'"}"` runs `cmd`, which the parse had seen as
2044
+ // single-quoted. Nothing read from the source can say what that
2045
+ // runs, so the line is opaque.
2046
+ const after = src[this.cont(n + 1)]
2047
+ if (after === '(' || after === '{') this.context.opaque('$$ before ( or { in double quotes')
2048
+ }
2049
+ if (next !== undefined && /[0-9$?!#@*-]/.test(next)) {
2050
+ builder.expansion(src.slice(at, n + 1))
2051
+ return n + 1
2052
+ }
2053
+ // A lone `$` is literal.
2054
+ if (inDouble) builder.quotedText('$')
2055
+ else builder.unquoted('$')
2056
+ return at + 1
2057
+ }
2058
+
2059
+ /** `$[…]`: returns the offset past the matching `]`. */
2060
+ private matchBracket(from: number): number {
2061
+ const src = this.src
2062
+ let depth = 0
2063
+ let i = from
2064
+ for (;;) {
2065
+ i = this.cont(i)
2066
+ if (i >= src.length) throw new Stop('unterminated $[')
2067
+ const char = src[i] as string
2068
+ if (char === '\\') {
2069
+ i += 2
2070
+ continue
2071
+ }
2072
+ if (char === '[') depth += 1
2073
+ else if (char === ']') {
2074
+ if (depth === 0) return i + 1
2075
+ depth -= 1
2076
+ }
2077
+ i += 1
2078
+ }
2079
+ }
2080
+
2081
+ /**
2082
+ * After `${`: returns the offset past the matching `}`. The content is
2083
+ * checked against the forms whose evaluation runs nothing; anything else
2084
+ * (indirection, a subscript or offset evaluated as arithmetic, a
2085
+ * transformation) is opaque.
2086
+ */
2087
+ private parameterBraces(from: number, inDouble: boolean): number {
2088
+ const src = this.src
2089
+ let i = from
2090
+ const scratch = new WordBuilder()
2091
+ for (;;) {
2092
+ i = this.cont(i)
2093
+ if (i >= src.length) throw new Stop('unterminated ${')
2094
+ const char = src[i] as string
2095
+ if (char === '}') break
2096
+ if (char === '\\') {
2097
+ i += 2
2098
+ continue
2099
+ }
2100
+ if (char === "'") {
2101
+ if (inDouble) {
2102
+ // Whether a single quote inside `${…}` inside double quotes
2103
+ // quotes depends on the operator and the shell's mode.
2104
+ this.context.opaque('single quote in parameter expansion')
2105
+ i += 1
2106
+ continue
2107
+ }
2108
+ const close = src.indexOf("'", i + 1)
2109
+ if (close < 0) throw new Stop('unterminated quote')
2110
+ this.singleQuoted(i, close)
2111
+ i = close + 1
2112
+ continue
2113
+ }
2114
+ if (char === '"') {
2115
+ i = this.doubleQuoted(i + 1, scratch)
2116
+ continue
2117
+ }
2118
+ if (char === '`') {
2119
+ i = this.backtick(i, scratch, inDouble)
2120
+ continue
2121
+ }
2122
+ if (char === '$') {
2123
+ i = this.dollar(i, scratch, inDouble)
2124
+ continue
2125
+ }
2126
+ if ((char === '<' || char === '>') && src[this.cont(i + 1)] === '(') {
2127
+ // Measured: unquoted, `${x:-<(cmd)}` runs `cmd`. Inside double
2128
+ // quotes bash still parses it, and rejects a malformed one.
2129
+ i = this.processSubstitution(i, scratch)
2130
+ continue
2131
+ }
2132
+ i += 1
2133
+ }
2134
+ const content = src.slice(from, i).replace(/\\\n/g, '')
2135
+ if (!SAFE_PARAMETER.test(content)) this.context.opaque('parameter expansion')
2136
+ else if (!POSIX_PARAMETER.test(content)) this.bashOnly('parameter expansion')
2137
+ return i + 1
2138
+ }
2139
+
2140
+ /** After `$'`: decode to the closing quote. */
2141
+ private ansiC(from: number, builder: WordBuilder): number {
2142
+ const src = this.src
2143
+ let i = from
2144
+ let decoded = ''
2145
+ let exact = true
2146
+ for (;;) {
2147
+ if (i >= src.length) throw new Stop('unterminated quote')
2148
+ const char = src[i] as string
2149
+ if (char === "'") break
2150
+ if (char !== '\\') {
2151
+ decoded += char
2152
+ i += 1
2153
+ continue
2154
+ }
2155
+ const letter = src[i + 1]
2156
+ if (letter === undefined) throw new Stop('unterminated quote')
2157
+ i += 2
2158
+ const simple = ANSI_C_SIMPLE[letter]
2159
+ if (simple !== undefined) {
2160
+ decoded += simple
2161
+ continue
2162
+ }
2163
+ if (letter >= '0' && letter <= '7') {
2164
+ let digits = letter
2165
+ while (digits.length < 3 && /[0-7]/.test(src[i] ?? '')) {
2166
+ digits += src[i]
2167
+ i += 1
2168
+ }
2169
+ const code = Number.parseInt(digits, 8) & 0xff
2170
+ if (code === 0 || code > 0x7f) exact = false
2171
+ decoded += String.fromCharCode(code)
2172
+ continue
2173
+ }
2174
+ if (letter === 'x' && src[i] === '{') {
2175
+ // `\x{HHH…}`: any number of digits, the closing brace optional,
2176
+ // the value truncated to a byte.
2177
+ i += 1
2178
+ let digits = ''
2179
+ while (/[0-9A-Fa-f]/.test(src[i] ?? '')) {
2180
+ digits += src[i]
2181
+ i += 1
2182
+ }
2183
+ if (src[i] === '}') i += 1
2184
+ const code = digits === '' ? 0 : Number.parseInt(digits.slice(-2), 16)
2185
+ if (code === 0 || code > 0x7f) exact = false
2186
+ decoded += String.fromCharCode(code)
2187
+ continue
2188
+ }
2189
+ if (letter === 'x') {
2190
+ let digits = ''
2191
+ while (digits.length < 2 && /[0-9A-Fa-f]/.test(src[i] ?? '')) {
2192
+ digits += src[i]
2193
+ i += 1
2194
+ }
2195
+ if (digits === '') {
2196
+ decoded += '\\x'
2197
+ continue
2198
+ }
2199
+ const code = Number.parseInt(digits, 16)
2200
+ if (code === 0 || code > 0x7f) exact = false
2201
+ decoded += String.fromCharCode(code)
2202
+ continue
2203
+ }
2204
+ if (letter === 'u' || letter === 'U') {
2205
+ const max = letter === 'u' ? 4 : 8
2206
+ let digits = ''
2207
+ while (digits.length < max && /[0-9A-Fa-f]/.test(src[i] ?? '')) {
2208
+ digits += src[i]
2209
+ i += 1
2210
+ }
2211
+ if (digits === '') {
2212
+ decoded += `\\${letter}`
2213
+ continue
2214
+ }
2215
+ const code = Number.parseInt(digits, 16)
2216
+ // Beyond ASCII the result depends on the locale's encoding.
2217
+ if (code === 0 || code > 0x7f) exact = false
2218
+ decoded += code <= 0x10ffff ? String.fromCodePoint(code) : ''
2219
+ continue
2220
+ }
2221
+ if (letter === 'c') {
2222
+ const target = src[i]
2223
+ if (target === undefined || target === "'") {
2224
+ // `\c` with nothing to control: bash keeps it.
2225
+ decoded += '\\c'
2226
+ continue
2227
+ }
2228
+ i += 1
2229
+ if (target === '\\' && src[i] === '\\') i += 1
2230
+ const code = target === '?' ? 0x7f : target.toUpperCase().charCodeAt(0) & 0x1f
2231
+ if (code === 0 || target.charCodeAt(0) > 0x7f) exact = false
2232
+ decoded += String.fromCharCode(code)
2233
+ continue
2234
+ }
2235
+ // Unknown escapes stay as written.
2236
+ decoded += `\\${letter}`
2237
+ }
2238
+ this.singleQuoted(from - 1, i)
2239
+ builder.quotedText(decoded)
2240
+ if (!exact) builder.expands = true
2241
+ return i + 1
2242
+ }
2243
+
2244
+ /**
2245
+ * A backtick substitution starting at `at`. The body is unescaped the way
2246
+ * bash does it and read as a command line of its own. Returns the offset
2247
+ * past the closing backtick.
2248
+ */
2249
+ backtick(at: number, builder: WordBuilder, inDouble: boolean): number {
2250
+ const src = this.src
2251
+ let i = at + 1
2252
+ let body = ''
2253
+ for (;;) {
2254
+ if (i >= src.length) throw new Stop('unterminated backtick')
2255
+ const char = src[i] as string
2256
+ if (char === '`') break
2257
+ if (char === '\\') {
2258
+ const next = src[i + 1]
2259
+ if (next === '$' || next === '`' || next === '\\' || (inDouble && next === '"')) {
2260
+ body += next
2261
+ i += 2
2262
+ continue
2263
+ }
2264
+ body += char
2265
+ i += 1
2266
+ continue
2267
+ }
2268
+ body += char
2269
+ i += 1
2270
+ }
2271
+ this.context.opaque('command substitution')
2272
+ builder.expansion(src.slice(at, i + 1))
2273
+ this.context.rescan(body.length)
2274
+ this.inherit(body)
2275
+ const inner = new Parser(body, 0, this.context, 'substitution', this.depth, this.nestingNow + 1)
2276
+ try {
2277
+ inner.program()
2278
+ } catch (error) {
2279
+ if (!(error instanceof Stop)) throw error
2280
+ }
2281
+ return i + 1
2282
+ }
2283
+
2284
+ /** `<(…)` or `>(…)` at `at`. */
2285
+ private processSubstitution(at: number, builder: WordBuilder): number {
2286
+ const open = this.cont(at + 1)
2287
+ this.context.opaque('process substitution')
2288
+ const inner = new Parser(
2289
+ this.src,
2290
+ open + 1,
2291
+ this.context,
2292
+ 'substitution',
2293
+ this.depth,
2294
+ this.nestingNow + 1,
2295
+ )
2296
+ const end = inner.substitution()
2297
+ builder.expansion(this.src.slice(at, end))
2298
+ return end
2299
+ }
2300
+ }
2301
+
2302
+ /**
2303
+ * `${…}` contents that evaluate nothing but a parameter: a name, a positional
2304
+ * or special parameter, optionally its length, a literal subscript, a literal
2305
+ * substring offset, or one of the default/assign/error/alternative, pattern
2306
+ * removal, substitution and case operators followed by any word (the word
2307
+ * was already read for substitutions).
2308
+ */
2309
+ const SAFE_PARAMETER =
2310
+ /^(?:#?(?:[A-Za-z_][A-Za-z0-9_]*(?:\[(?:\d+|@|\*)\])?|\d+|[@*#?$!-])(?:(?::?[-=+?]|##?|%%?|\/[/#%]?|\^\^?|,,?)[\s\S]*|:\s*-?\d+\s*(?::\s*-?\d+\s*)?)?)$/
2311
+
2312
+ /**
2313
+ * `${…}` forms POSIX defines, with a word free of quotes and escapes, whose
2314
+ * handling inside `${…}` differs between shells.
2315
+ */
2316
+ const POSIX_PARAMETER =
2317
+ /^(?:#?(?:[A-Za-z_][A-Za-z0-9_]*|\d+|[@*#?$!-])|(?:[A-Za-z_][A-Za-z0-9_]*|\d+|[@*#?$!-])(?::?[-=+?]|##?|%%?)[^'"\\`]*)$/
2318
+
2319
+ const ANSI_C_SIMPLE: Readonly<Record<string, string>> = {
2320
+ a: '\x07',
2321
+ b: '\b',
2322
+ e: '\x1b',
2323
+ E: '\x1b',
2324
+ f: '\f',
2325
+ n: '\n',
2326
+ r: '\r',
2327
+ t: '\t',
2328
+ v: '\v',
2329
+ '\\': '\\',
2330
+ "'": "'",
2331
+ '"': '"',
2332
+ '?': '?',
2333
+ }
2334
+
2335
+ /** Source text with its line continuations removed, as bash reads it. */
2336
+ function joined(text: string): string {
2337
+ return text.includes('\\\n') ? text.replace(/\\\n/g, '') : text
2338
+ }
2339
+
2340
+ function trailingBackslashes(text: string): number {
2341
+ let count = 0
2342
+ for (let i = text.length - 1; i >= 0 && text[i] === '\\'; i -= 1) count += 1
2343
+ return count
2344
+ }
2345
+
2346
+ export function basename(word: string): string {
2347
+ const cut = word.lastIndexOf('/')
2348
+ return cut < 0 ? word : word.slice(cut + 1)
2349
+ }