@taliesinb/mash 0.0.0-stage → 0.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 (203) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -2
  3. package/bin/mash.ts +253 -0
  4. package/dist/bin/mash.d.ts +39 -0
  5. package/dist/bin/mash.d.ts.map +1 -0
  6. package/dist/bin/mash.js +281 -0
  7. package/dist/bin/mash.js.map +1 -0
  8. package/dist/src/analysis/analyze.d.ts +97 -0
  9. package/dist/src/analysis/analyze.d.ts.map +1 -0
  10. package/dist/src/analysis/analyze.js +1406 -0
  11. package/dist/src/analysis/analyze.js.map +1 -0
  12. package/dist/src/analysis/fs.d.ts +39 -0
  13. package/dist/src/analysis/fs.d.ts.map +1 -0
  14. package/dist/src/analysis/fs.js +117 -0
  15. package/dist/src/analysis/fs.js.map +1 -0
  16. package/dist/src/analysis/outputs.d.ts +16 -0
  17. package/dist/src/analysis/outputs.d.ts.map +1 -0
  18. package/dist/src/analysis/outputs.js +277 -0
  19. package/dist/src/analysis/outputs.js.map +1 -0
  20. package/dist/src/analysis/value.d.ts +76 -0
  21. package/dist/src/analysis/value.d.ts.map +1 -0
  22. package/dist/src/analysis/value.js +248 -0
  23. package/dist/src/analysis/value.js.map +1 -0
  24. package/dist/src/behaviors/defs/core.d.ts +15 -0
  25. package/dist/src/behaviors/defs/core.d.ts.map +1 -0
  26. package/dist/src/behaviors/defs/core.js +1084 -0
  27. package/dist/src/behaviors/defs/core.js.map +1 -0
  28. package/dist/src/behaviors/defs/devtools.d.ts +9 -0
  29. package/dist/src/behaviors/defs/devtools.d.ts.map +1 -0
  30. package/dist/src/behaviors/defs/devtools.js +432 -0
  31. package/dist/src/behaviors/defs/devtools.js.map +1 -0
  32. package/dist/src/behaviors/defs/macos.d.ts +12 -0
  33. package/dist/src/behaviors/defs/macos.d.ts.map +1 -0
  34. package/dist/src/behaviors/defs/macos.js +454 -0
  35. package/dist/src/behaviors/defs/macos.js.map +1 -0
  36. package/dist/src/behaviors/defs/tools.d.ts +9 -0
  37. package/dist/src/behaviors/defs/tools.d.ts.map +1 -0
  38. package/dist/src/behaviors/defs/tools.js +639 -0
  39. package/dist/src/behaviors/defs/tools.js.map +1 -0
  40. package/dist/src/behaviors/dsl.d.ts +95 -0
  41. package/dist/src/behaviors/dsl.d.ts.map +1 -0
  42. package/dist/src/behaviors/dsl.js +267 -0
  43. package/dist/src/behaviors/dsl.js.map +1 -0
  44. package/dist/src/behaviors/index.d.ts +12 -0
  45. package/dist/src/behaviors/index.d.ts.map +1 -0
  46. package/dist/src/behaviors/index.js +18 -0
  47. package/dist/src/behaviors/index.js.map +1 -0
  48. package/dist/src/behaviors/registry.d.ts +45 -0
  49. package/dist/src/behaviors/registry.d.ts.map +1 -0
  50. package/dist/src/behaviors/registry.js +266 -0
  51. package/dist/src/behaviors/registry.js.map +1 -0
  52. package/dist/src/behaviors/roles.d.ts +29 -0
  53. package/dist/src/behaviors/roles.d.ts.map +1 -0
  54. package/dist/src/behaviors/roles.js +198 -0
  55. package/dist/src/behaviors/roles.js.map +1 -0
  56. package/dist/src/behaviors/types.d.ts +120 -0
  57. package/dist/src/behaviors/types.d.ts.map +1 -0
  58. package/dist/src/behaviors/types.js +12 -0
  59. package/dist/src/behaviors/types.js.map +1 -0
  60. package/dist/src/index.d.ts +27 -0
  61. package/dist/src/index.d.ts.map +1 -0
  62. package/dist/src/index.js +24 -0
  63. package/dist/src/index.js.map +1 -0
  64. package/dist/src/integrations/deepsea.d.ts +121 -0
  65. package/dist/src/integrations/deepsea.d.ts.map +1 -0
  66. package/dist/src/integrations/deepsea.js +199 -0
  67. package/dist/src/integrations/deepsea.js.map +1 -0
  68. package/dist/src/machine.d.ts +139 -0
  69. package/dist/src/machine.d.ts.map +1 -0
  70. package/dist/src/machine.js +400 -0
  71. package/dist/src/machine.js.map +1 -0
  72. package/dist/src/parser/ast.d.ts +369 -0
  73. package/dist/src/parser/ast.d.ts.map +1 -0
  74. package/dist/src/parser/ast.js +17 -0
  75. package/dist/src/parser/ast.js.map +1 -0
  76. package/dist/src/parser/parser.d.ts +48 -0
  77. package/dist/src/parser/parser.d.ts.map +1 -0
  78. package/dist/src/parser/parser.js +1950 -0
  79. package/dist/src/parser/parser.js.map +1 -0
  80. package/dist/src/policy/effects.d.ts +86 -0
  81. package/dist/src/policy/effects.d.ts.map +1 -0
  82. package/dist/src/policy/effects.js +53 -0
  83. package/dist/src/policy/effects.js.map +1 -0
  84. package/dist/src/policy/policy.d.ts +130 -0
  85. package/dist/src/policy/policy.d.ts.map +1 -0
  86. package/dist/src/policy/policy.js +532 -0
  87. package/dist/src/policy/policy.js.map +1 -0
  88. package/dist/src/printer/doc.d.ts +151 -0
  89. package/dist/src/printer/doc.d.ts.map +1 -0
  90. package/dist/src/printer/doc.js +409 -0
  91. package/dist/src/printer/doc.js.map +1 -0
  92. package/dist/src/printer/highlight.d.ts +53 -0
  93. package/dist/src/printer/highlight.d.ts.map +1 -0
  94. package/dist/src/printer/highlight.js +339 -0
  95. package/dist/src/printer/highlight.js.map +1 -0
  96. package/dist/src/printer/html.d.ts +72 -0
  97. package/dist/src/printer/html.d.ts.map +1 -0
  98. package/dist/src/printer/html.js +423 -0
  99. package/dist/src/printer/html.js.map +1 -0
  100. package/dist/src/printer/print.d.ts +63 -0
  101. package/dist/src/printer/print.d.ts.map +1 -0
  102. package/dist/src/printer/print.js +484 -0
  103. package/dist/src/printer/print.js.map +1 -0
  104. package/dist/src/printer/summary.d.ts +50 -0
  105. package/dist/src/printer/summary.d.ts.map +1 -0
  106. package/dist/src/printer/summary.js +621 -0
  107. package/dist/src/printer/summary.js.map +1 -0
  108. package/dist/src/trace/events.d.ts +183 -0
  109. package/dist/src/trace/events.d.ts.map +1 -0
  110. package/dist/src/trace/events.js +9 -0
  111. package/dist/src/trace/events.js.map +1 -0
  112. package/dist/src/trace/progress.d.ts +58 -0
  113. package/dist/src/trace/progress.d.ts.map +1 -0
  114. package/dist/src/trace/progress.js +83 -0
  115. package/dist/src/trace/progress.js.map +1 -0
  116. package/dist/src/vm/arith.d.ts +29 -0
  117. package/dist/src/vm/arith.d.ts.map +1 -0
  118. package/dist/src/vm/arith.js +258 -0
  119. package/dist/src/vm/arith.js.map +1 -0
  120. package/dist/src/vm/builtins.d.ts +31 -0
  121. package/dist/src/vm/builtins.d.ts.map +1 -0
  122. package/dist/src/vm/builtins.js +1773 -0
  123. package/dist/src/vm/builtins.js.map +1 -0
  124. package/dist/src/vm/emulate.d.ts +75 -0
  125. package/dist/src/vm/emulate.d.ts.map +1 -0
  126. package/dist/src/vm/emulate.js +380 -0
  127. package/dist/src/vm/emulate.js.map +1 -0
  128. package/dist/src/vm/expand.d.ts +68 -0
  129. package/dist/src/vm/expand.d.ts.map +1 -0
  130. package/dist/src/vm/expand.js +744 -0
  131. package/dist/src/vm/expand.js.map +1 -0
  132. package/dist/src/vm/external.d.ts +34 -0
  133. package/dist/src/vm/external.d.ts.map +1 -0
  134. package/dist/src/vm/external.js +228 -0
  135. package/dist/src/vm/external.js.map +1 -0
  136. package/dist/src/vm/fifo.d.ts +66 -0
  137. package/dist/src/vm/fifo.d.ts.map +1 -0
  138. package/dist/src/vm/fifo.js +232 -0
  139. package/dist/src/vm/fifo.js.map +1 -0
  140. package/dist/src/vm/interp.d.ts +209 -0
  141. package/dist/src/vm/interp.d.ts.map +1 -0
  142. package/dist/src/vm/interp.js +1641 -0
  143. package/dist/src/vm/interp.js.map +1 -0
  144. package/dist/src/vm/io.d.ts +180 -0
  145. package/dist/src/vm/io.d.ts.map +1 -0
  146. package/dist/src/vm/io.js +502 -0
  147. package/dist/src/vm/io.js.map +1 -0
  148. package/dist/src/vm/pattern.d.ts +46 -0
  149. package/dist/src/vm/pattern.d.ts.map +1 -0
  150. package/dist/src/vm/pattern.js +498 -0
  151. package/dist/src/vm/pattern.js.map +1 -0
  152. package/dist/src/vm/proc.d.ts +94 -0
  153. package/dist/src/vm/proc.d.ts.map +1 -0
  154. package/dist/src/vm/proc.js +130 -0
  155. package/dist/src/vm/proc.js.map +1 -0
  156. package/dist/src/vm/state.d.ts +104 -0
  157. package/dist/src/vm/state.d.ts.map +1 -0
  158. package/dist/src/vm/state.js +233 -0
  159. package/dist/src/vm/state.js.map +1 -0
  160. package/dist/src/vm/test.d.ts +11 -0
  161. package/dist/src/vm/test.d.ts.map +1 -0
  162. package/dist/src/vm/test.js +277 -0
  163. package/dist/src/vm/test.js.map +1 -0
  164. package/package.json +63 -4
  165. package/src/analysis/analyze.ts +1395 -0
  166. package/src/analysis/fs.ts +121 -0
  167. package/src/analysis/outputs.ts +242 -0
  168. package/src/analysis/value.ts +264 -0
  169. package/src/behaviors/defs/core.ts +970 -0
  170. package/src/behaviors/defs/devtools.ts +400 -0
  171. package/src/behaviors/defs/macos.ts +426 -0
  172. package/src/behaviors/defs/tools.ts +596 -0
  173. package/src/behaviors/dsl.ts +293 -0
  174. package/src/behaviors/index.ts +19 -0
  175. package/src/behaviors/registry.ts +261 -0
  176. package/src/behaviors/roles.ts +187 -0
  177. package/src/behaviors/types.ts +127 -0
  178. package/src/index.ts +27 -0
  179. package/src/integrations/deepsea.ts +259 -0
  180. package/src/machine.ts +483 -0
  181. package/src/parser/ast.ts +472 -0
  182. package/src/parser/parser.ts +1826 -0
  183. package/src/policy/effects.ts +102 -0
  184. package/src/policy/policy.ts +578 -0
  185. package/src/printer/doc.ts +454 -0
  186. package/src/printer/highlight.ts +283 -0
  187. package/src/printer/html.ts +441 -0
  188. package/src/printer/print.ts +528 -0
  189. package/src/printer/summary.ts +601 -0
  190. package/src/trace/events.ts +102 -0
  191. package/src/trace/progress.ts +119 -0
  192. package/src/vm/arith.ts +236 -0
  193. package/src/vm/builtins.ts +1590 -0
  194. package/src/vm/emulate.ts +337 -0
  195. package/src/vm/expand.ts +721 -0
  196. package/src/vm/external.ts +265 -0
  197. package/src/vm/fifo.ts +226 -0
  198. package/src/vm/interp.ts +1623 -0
  199. package/src/vm/io.ts +570 -0
  200. package/src/vm/pattern.ts +477 -0
  201. package/src/vm/proc.ts +147 -0
  202. package/src/vm/state.ts +267 -0
  203. package/src/vm/test.ts +253 -0
@@ -0,0 +1,1826 @@
1
+ /**
2
+ * A scannerless recursive-descent parser for bash. Bash's lexical structure
3
+ * depends on context (command position, `[[ ]]`, `$(( ))`, `${ }`, here-docs,
4
+ * case patterns), so instead of a separate tokenizer the parser scans the
5
+ * source directly with a cursor and context-specific word scanners.
6
+ *
7
+ * Every node gets a span of offsets into the source. Command substitutions
8
+ * and process substitutions are parsed in place, so their spans are exact;
9
+ * backquoted substitutions are parsed from their unescaped text and their
10
+ * inner spans are approximate (offset from the opening backquote).
11
+ */
12
+
13
+ import type * as A from './ast.ts'
14
+ import { DECL_BUILTINS } from './ast.ts'
15
+
16
+ export class ParseError extends Error {
17
+ readonly offset: number
18
+ readonly line: number
19
+ readonly column: number
20
+ /** True when the error is "unexpected end of input" (an incomplete snippet). */
21
+ readonly incomplete: boolean
22
+
23
+ constructor(message: string, source: string, offset: number, incomplete = false) {
24
+ const { line, column } = lineColumn(source, offset)
25
+ super(`${message} (line ${line}, column ${column})`)
26
+ this.name = 'ParseError'
27
+ this.offset = offset
28
+ this.line = line
29
+ this.column = column
30
+ this.incomplete = incomplete
31
+ }
32
+ }
33
+
34
+ /** 1-based line and column of an offset. */
35
+ export function lineColumn(source: string, offset: number): { line: number; column: number } {
36
+ let line = 1
37
+ let lineStart = 0
38
+ for (let i = 0; i < offset && i < source.length; i++) {
39
+ if (source.charCodeAt(i) === 10) {
40
+ line++
41
+ lineStart = i + 1
42
+ }
43
+ }
44
+ return { line, column: offset - lineStart + 1 }
45
+ }
46
+
47
+ export interface ParseOptions {
48
+ /** Starting node id (default 1). Lets callers keep ids unique across several parses (e.g. `eval`). */
49
+ firstId?: number
50
+ /** Return the statements before a syntax error (in `script.error`) instead of throwing. */
51
+ partial?: boolean
52
+ }
53
+
54
+ /** Parse a bash script into an AST. Throws {@link ParseError}. */
55
+ /** Mutable state shared by a parser and its sub-parsers (backquotes, here-docs). */
56
+ interface Shared {
57
+ next: number
58
+ /** Comments seen, keyed by start offset (backtracking may visit one twice). */
59
+ comments?: Map<number, A.Comment>
60
+ }
61
+
62
+ function sortedComments(shared: Shared): A.Comment[] {
63
+ return [...(shared.comments?.values() ?? [])].sort((a, b) => a.span.start - b.span.start)
64
+ }
65
+
66
+ export function parse(source: string, options: ParseOptions = {}): A.Script {
67
+ const ids: Shared = { next: options.firstId ?? 1, comments: new Map() }
68
+ const p = new Parser(source, 0, ids, source)
69
+ if (!options.partial) {
70
+ const script = p.parseScript()
71
+ script.comments = sortedComments(ids)
72
+ return script
73
+ }
74
+ const sink: A.Stmt[] = []
75
+ try {
76
+ const script = p.parseScript(sink)
77
+ script.comments = sortedComments(ids)
78
+ return script
79
+ } catch (e) {
80
+ if (!(e instanceof ParseError)) throw e
81
+ // keep complete statements on lines before the error line
82
+ const lineStart = source.lastIndexOf('\n', Math.max(0, e.offset - 1)) + 1
83
+ const errLineStart = e.offset >= source.length ? source.lastIndexOf('\n', source.length - 1) + 1 : lineStart
84
+ const body = sink.filter((s) => s.span.end < errLineStart && source.indexOf('\n', s.span.end) < errLineStart && source.indexOf('\n', s.span.end) !== -1)
85
+ const last = body[body.length - 1]?.span.end ?? 0
86
+ return { id: ids.next++, span: { start: 0, end: source.length }, type: 'Script', body, error: e, comments: sortedComments(ids).filter((c) => c.span.end <= last) }
87
+ }
88
+ }
89
+
90
+ const RESERVED = new Set([
91
+ 'if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'case', 'esac', 'while', 'until',
92
+ 'for', 'in', 'select', 'function', 'time', '{', '}', '!', '[[', ']]', 'coproc',
93
+ ])
94
+
95
+ const COND_UNARY = new Set([
96
+ '-a', '-b', '-c', '-d', '-e', '-f', '-g', '-h', '-k', '-p', '-r', '-s', '-t', '-u', '-w', '-x',
97
+ '-G', '-L', '-N', '-O', '-S', '-z', '-n', '-o', '-v', '-R',
98
+ ])
99
+
100
+ const COND_BINARY = new Set([
101
+ '==', '=', '!=', '=~', '<', '>', '-eq', '-ne', '-lt', '-le', '-gt', '-ge', '-nt', '-ot', '-ef',
102
+ ])
103
+
104
+ const SPECIAL_PARAMS = new Set(['@', '*', '#', '?', '-', '$', '!', '0'])
105
+
106
+ type StopFn = (c: string, p: Parser) => boolean
107
+
108
+ const ARITH_EXPR_TYPES = new Set([
109
+ 'ArithNum', 'ArithVar', 'ArithWord', 'ArithUnary', 'ArithBinary', 'ArithAssign', 'ArithTernary', 'ArithIncDec', 'ArithGroup',
110
+ ])
111
+
112
+ const arithSources = new WeakMap<object, { src: string; offset: number }>()
113
+
114
+ /**
115
+ * The exact source text of an arithmetic expression node. Bash expands `$x`
116
+ * inside arithmetic textually before evaluating, so the VM needs the raw text
117
+ * around `ArithWord`s.
118
+ */
119
+ export function arithText(node: A.ArithExpr, span: A.Span = node.span): string | undefined {
120
+ const s = arithSources.get(node)
121
+ if (!s) return undefined
122
+ return s.src.slice(span.start - s.offset, span.end - s.offset)
123
+ }
124
+
125
+ interface PendingHeredoc {
126
+ redirect: A.Redirect
127
+ delimiter: string
128
+ quoted: boolean
129
+ strip: boolean
130
+ }
131
+
132
+ function isBlank(c: string): boolean {
133
+ return c === ' ' || c === '\t'
134
+ }
135
+
136
+ function isMeta(c: string): boolean {
137
+ return c === ' ' || c === '\t' || c === '\n' || c === '|' || c === '&' || c === ';' || c === '(' || c === ')' || c === '<' || c === '>'
138
+ }
139
+
140
+ function isNameStart(c: string | undefined): boolean {
141
+ return c !== undefined && /[A-Za-z_]/.test(c)
142
+ }
143
+
144
+ function isNameChar(c: string | undefined): boolean {
145
+ return c !== undefined && /[A-Za-z0-9_]/.test(c)
146
+ }
147
+
148
+ const normalStop: StopFn = (c) => isMeta(c)
149
+
150
+ class Parser {
151
+ pos = 0
152
+ private pending: PendingHeredoc[] = []
153
+ /** >0 while scanning inside double quotes (affects `${v:-'x'}`). */
154
+ private dquoteDepth = 0
155
+
156
+ readonly src: string
157
+ /** Added to every span (non-zero for backquote sub-parsers). */
158
+ private readonly offset: number
159
+ private readonly ids: Shared
160
+ /** The whole original source, for error positions. */
161
+ private readonly root: string
162
+
163
+ constructor(src: string, offset: number, ids: Shared, root: string) {
164
+ this.src = src
165
+ this.offset = offset
166
+ this.ids = ids
167
+ this.root = root
168
+ }
169
+
170
+ // -------------------------------------------------------------------------
171
+ // Utilities
172
+ // -------------------------------------------------------------------------
173
+
174
+ private node<T extends A.Node>(start: number, end: number, fields: Omit<T, 'id' | 'span'>): T {
175
+ const n = { id: this.ids.next++, span: { start: start + this.offset, end: end + this.offset }, ...fields } as T
176
+ if (ARITH_EXPR_TYPES.has(n.type)) arithSources.set(n, { src: this.src, offset: this.offset })
177
+ return n
178
+ }
179
+
180
+ private error(message: string, at = this.pos): never {
181
+ throw new ParseError(message, this.root, at + this.offset, at >= this.src.length)
182
+ }
183
+
184
+ private peek(n = 0): string {
185
+ return this.src[this.pos + n] ?? ''
186
+ }
187
+
188
+ private startsWith(s: string): boolean {
189
+ return this.src.startsWith(s, this.pos)
190
+ }
191
+
192
+ private eof(): boolean {
193
+ return this.pos >= this.src.length
194
+ }
195
+
196
+ /** Skip spaces, tabs, line continuations and comments (not newlines). */
197
+ private skipBlanks(): void {
198
+ for (;;) {
199
+ const c = this.peek()
200
+ if (isBlank(c)) this.pos++
201
+ else if (c === '\\' && this.peek(1) === '\n') this.pos += 2
202
+ else if (c === '#') {
203
+ const start = this.pos
204
+ while (!this.eof() && this.peek() !== '\n') this.pos++
205
+ const span = { start: start + this.offset, end: this.pos + this.offset }
206
+ this.ids.comments?.set(span.start, { span, text: this.src.slice(start, this.pos) })
207
+ } else return
208
+ }
209
+ }
210
+
211
+ /** Consume one newline and then any here-document bodies queued on the line it ends. */
212
+ private consumeNewline(): void {
213
+ this.pos++
214
+ if (this.pending.length > 0) this.readHeredocBodies()
215
+ }
216
+
217
+ /** Skip blanks, comments and newlines. */
218
+ private skipNewlines(): void {
219
+ for (;;) {
220
+ this.skipBlanks()
221
+ if (this.peek() === '\n') this.consumeNewline()
222
+ else return
223
+ }
224
+ }
225
+
226
+ /**
227
+ * If an unquoted reserved word (or `{`/`}`/`!`/`[[`) sits at the cursor and is
228
+ * followed by a delimiter, return it (without consuming).
229
+ */
230
+ private peekReserved(): string | null {
231
+ const m = /^(?:[A-Za-z]+|\{|\}|!|\[\[|\]\])/.exec(this.src.slice(this.pos, this.pos + 10))
232
+ if (!m) return null
233
+ const w = m[0]
234
+ if (!RESERVED.has(w)) return null
235
+ const after = this.src[this.pos + w.length]
236
+ if (after === undefined || isMeta(after)) return w
237
+ return null
238
+ }
239
+
240
+ private expectReserved(word: string): void {
241
+ this.skipNewlines()
242
+ if (this.peekReserved() !== word) this.error(`expected '${word}'`)
243
+ this.pos += word.length
244
+ }
245
+
246
+ // -------------------------------------------------------------------------
247
+ // Script, lists, statements
248
+ // -------------------------------------------------------------------------
249
+
250
+ parseScript(sink?: A.Stmt[]): A.Script {
251
+ const body = this.parseList(new Set(), false, sink)
252
+ this.skipNewlines()
253
+ if (!this.eof()) this.error(`unexpected '${this.peek()}'`)
254
+ if (this.pending.length > 0) {
255
+ // Here-docs at the very end without a trailing newline: read what there is.
256
+ this.readHeredocBodies()
257
+ }
258
+ return this.node<A.Script>(0, this.src.length, { type: 'Script', body })
259
+ }
260
+
261
+ /** True when the cursor (after blanks/newlines) is at a terminator of the current list. */
262
+ private atListEnd(stopWords: Set<string>, stopParen: boolean): boolean {
263
+ if (this.eof()) return true
264
+ const c = this.peek()
265
+ if (c === ')' && stopParen) return true
266
+ if (stopWords.has(';;') && (this.startsWith(';;') || this.startsWith(';&'))) return true
267
+ if (c === ')' && stopWords.has(')')) return true
268
+ const r = this.peekReserved()
269
+ return r !== null && stopWords.has(r)
270
+ }
271
+
272
+ /**
273
+ * Parse statements until one of `stopWords` (reserved words, or ';;' for case
274
+ * items) or, with `stopParen`, a `)`.
275
+ */
276
+ private parseList(stopWords: Set<string>, stopParen: boolean, sink?: A.Stmt[]): A.Stmt[] {
277
+ const stmts: A.Stmt[] = sink ?? []
278
+ for (;;) {
279
+ this.skipNewlines()
280
+ if (this.atListEnd(stopWords, stopParen)) break
281
+ const c = this.peek()
282
+ if (c === ')' && !stopParen) this.error(`unexpected ')'`)
283
+ if (c === ';') this.error(`unexpected ';'`)
284
+ const start = this.pos
285
+ const command = this.parseAndOr()
286
+ const end = command.span.end - this.offset
287
+ this.skipBlanks()
288
+ let background = false
289
+ const sep = this.peek()
290
+ if (sep === '&' && !this.startsWith('&&') && !this.startsWith('&>')) {
291
+ background = true
292
+ this.pos++
293
+ } else if (sep === ';' && !this.startsWith(';;') && !this.startsWith(';&')) {
294
+ this.pos++
295
+ } else if (sep === '\n') {
296
+ this.consumeNewline()
297
+ } else if (!this.eof() && !this.atListEnd(stopWords, stopParen) && sep !== ')' && !this.startsWith(';;') && !this.startsWith(';&')) {
298
+ this.error(`unexpected '${sep}'`)
299
+ }
300
+ stmts.push(this.node<A.Stmt>(start, end, { type: 'Stmt', command, background }))
301
+ }
302
+ return stmts
303
+ }
304
+
305
+ private parseAndOr(): A.Command {
306
+ const start = this.pos
307
+ const items: A.Command[] = [this.parsePipeline()]
308
+ const ops: ('&&' | '||')[] = []
309
+ let end = items[0].span.end - this.offset
310
+ for (;;) {
311
+ this.skipBlanks()
312
+ if (this.startsWith('&&') || this.startsWith('||')) {
313
+ ops.push(this.src.slice(this.pos, this.pos + 2) as '&&' | '||')
314
+ this.pos += 2
315
+ this.skipNewlines()
316
+ items.push(this.parsePipeline())
317
+ end = items[items.length - 1].span.end - this.offset
318
+ } else break
319
+ }
320
+ if (items.length === 1) return items[0]
321
+ return this.node<A.AndOr>(start, end, { type: 'AndOr', items, ops })
322
+ }
323
+
324
+ private parsePipeline(): A.Command {
325
+ this.skipBlanks()
326
+ const start = this.pos
327
+ let timed = false
328
+ let negated = false
329
+ for (;;) {
330
+ const r = this.peekReserved()
331
+ if (r === 'time' && !timed) {
332
+ timed = true
333
+ this.pos += 4
334
+ this.skipBlanks()
335
+ if (this.startsWith('-p') && (isMeta(this.peek(2)) || this.peek(2) === '')) {
336
+ this.pos += 2
337
+ this.skipBlanks()
338
+ }
339
+ } else if (r === '!') {
340
+ negated = !negated
341
+ this.pos += 1
342
+ this.skipBlanks()
343
+ } else break
344
+ }
345
+ const stages: A.Command[] = [this.parseCommand()]
346
+ const pipeStderr: boolean[] = []
347
+ let end = stages[0].span.end - this.offset
348
+ for (;;) {
349
+ this.skipBlanks()
350
+ if (this.peek() === '|' && this.peek(1) !== '|') {
351
+ const amp = this.peek(1) === '&'
352
+ this.pos += amp ? 2 : 1
353
+ pipeStderr.push(amp)
354
+ this.skipNewlines()
355
+ stages.push(this.parseCommand())
356
+ end = stages[stages.length - 1].span.end - this.offset
357
+ } else break
358
+ }
359
+ if (stages.length === 1 && !negated && !timed) return stages[0]
360
+ return this.node<A.Pipeline>(start, end, { type: 'Pipeline', stages, pipeStderr, negated, timed })
361
+ }
362
+
363
+ // -------------------------------------------------------------------------
364
+ // Commands
365
+ // -------------------------------------------------------------------------
366
+
367
+ private parseCommand(): A.Command {
368
+ this.skipBlanks()
369
+ const start = this.pos
370
+ if (this.eof()) this.error('unexpected end of input, expected a command')
371
+ const r = this.peekReserved()
372
+ switch (r) {
373
+ case 'if': return this.withRedirs(this.parseIf())
374
+ case 'while':
375
+ case 'until': return this.withRedirs(this.parseLoop(r))
376
+ case 'for': return this.withRedirs(this.parseFor())
377
+ case 'select': return this.withRedirs(this.parseSelect())
378
+ case 'case': return this.withRedirs(this.parseCase())
379
+ case '{': return this.withRedirs(this.parseGroup())
380
+ case '[[': return this.withRedirs(this.parseCondCommand())
381
+ case 'function': return this.parseFunctionKeyword()
382
+ case 'then': case 'else': case 'elif': case 'fi': case 'do': case 'done': case 'esac': case '}':
383
+ this.error(`unexpected reserved word '${r}'`)
384
+ }
385
+ if (this.startsWith('((')) {
386
+ const arith = this.tryParseArithCommand()
387
+ if (arith) return this.withRedirs(arith)
388
+ }
389
+ if (this.peek() === '(') return this.withRedirs(this.parseSubshell())
390
+ // `name () compound`
391
+ const fm = /^([^\s|&;()<>'"`$\\=]+)[ \t]*\([ \t]*\)/.exec(this.src.slice(this.pos))
392
+ if (fm && !RESERVED.has(fm[1])) {
393
+ this.pos += fm[0].length
394
+ return this.parseFunctionBody(start, fm[1])
395
+ }
396
+ return this.parseSimpleCommand()
397
+ }
398
+
399
+ private withRedirs<T extends A.CompoundCommand>(cmd: T): T {
400
+ for (;;) {
401
+ this.skipBlanks()
402
+ const r = this.tryParseRedirect()
403
+ if (!r) break
404
+ cmd.redirs.push(r)
405
+ cmd.span.end = this.pos + this.offset
406
+ }
407
+ return cmd
408
+ }
409
+
410
+ private parseFunctionKeyword(): A.FunctionDef {
411
+ const start = this.pos
412
+ this.pos += 'function'.length
413
+ this.skipBlanks()
414
+ const m = /^[^\s|&;()<>'"`$\\]+/.exec(this.src.slice(this.pos))
415
+ if (!m) this.error('expected function name')
416
+ this.pos += m[0].length
417
+ this.skipBlanks()
418
+ if (this.startsWith('(')) {
419
+ this.pos++
420
+ this.skipBlanks()
421
+ if (this.peek() !== ')') this.error(`expected ')'`)
422
+ this.pos++
423
+ }
424
+ return this.parseFunctionBody(start, m[0])
425
+ }
426
+
427
+ private parseFunctionBody(start: number, name: string): A.FunctionDef {
428
+ this.skipNewlines()
429
+ const body = this.parseCommand()
430
+ if (body.type === 'SimpleCommand' || body.type === 'Pipeline' || body.type === 'AndOr' || body.type === 'FunctionDef') {
431
+ this.error('function body must be a compound command', body.span.start - this.offset)
432
+ }
433
+ return this.node<A.FunctionDef>(start, this.pos, { type: 'FunctionDef', name, body })
434
+ }
435
+
436
+ private parseGroup(): A.Group {
437
+ const start = this.pos
438
+ this.pos++ // {
439
+ const body = this.parseList(new Set(['}']), false)
440
+ this.expectReserved('}')
441
+ return this.node<A.Group>(start, this.pos, { type: 'Group', body, redirs: [] })
442
+ }
443
+
444
+ private parseSubshell(): A.Subshell {
445
+ const start = this.pos
446
+ this.pos++ // (
447
+ const body = this.parseList(new Set(), true)
448
+ this.skipNewlines()
449
+ if (this.peek() !== ')') this.error(`expected ')'`)
450
+ this.pos++
451
+ return this.node<A.Subshell>(start, this.pos, { type: 'Subshell', body, redirs: [] })
452
+ }
453
+
454
+ private parseIf(): A.If {
455
+ const start = this.pos
456
+ this.pos += 2
457
+ const clauses: A.IfClause[] = []
458
+ let elseBody: A.Stmt[] | null = null
459
+ const condStop = new Set(['then'])
460
+ const bodyStop = new Set(['elif', 'else', 'fi'])
461
+ for (;;) {
462
+ const cond = this.parseList(condStop, false)
463
+ this.expectReserved('then')
464
+ const body = this.parseList(bodyStop, false)
465
+ clauses.push({ cond, body })
466
+ this.skipNewlines()
467
+ const r = this.peekReserved()
468
+ if (r === 'elif') {
469
+ this.pos += 4
470
+ continue
471
+ }
472
+ if (r === 'else') {
473
+ this.pos += 4
474
+ elseBody = this.parseList(new Set(['fi']), false)
475
+ }
476
+ this.expectReserved('fi')
477
+ break
478
+ }
479
+ return this.node<A.If>(start, this.pos, { type: 'If', clauses, elseBody, redirs: [] })
480
+ }
481
+
482
+ private parseDoGroup(): A.Stmt[] {
483
+ this.skipNewlines()
484
+ const r = this.peekReserved()
485
+ if (r === '{') {
486
+ // `for x in a; { ...; }` (undocumented bash form)
487
+ this.pos++
488
+ const body = this.parseList(new Set(['}']), false)
489
+ this.expectReserved('}')
490
+ return body
491
+ }
492
+ this.expectReserved('do')
493
+ const body = this.parseList(new Set(['done']), false)
494
+ this.expectReserved('done')
495
+ return body
496
+ }
497
+
498
+ private parseLoop(kind: 'while' | 'until'): A.Loop {
499
+ const start = this.pos
500
+ this.pos += kind.length
501
+ const cond = this.parseList(new Set(['do']), false)
502
+ const body = this.parseDoGroup()
503
+ return this.node<A.Loop>(start, this.pos, { type: 'Loop', kind, cond, body, redirs: [] })
504
+ }
505
+
506
+ private parseForHead(keyword: string): { variable: string; items: A.Word[] | null } {
507
+ this.pos += keyword.length
508
+ this.skipBlanks()
509
+ const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.src.slice(this.pos))
510
+ if (!m) this.error(`expected a variable name after '${keyword}'`)
511
+ this.pos += m[0].length
512
+ this.skipBlanks()
513
+ let items: A.Word[] | null = null
514
+ // Optional newlines before `in`
515
+ const save = this.pos
516
+ this.skipNewlines()
517
+ if (this.peekReserved() === 'in') {
518
+ this.pos += 2
519
+ items = []
520
+ for (;;) {
521
+ this.skipBlanks()
522
+ const c = this.peek()
523
+ if (c === ';' || c === '\n' || this.eof()) break
524
+ const w = this.parseWord(normalStop)
525
+ if (!w) this.error(`unexpected '${c}' in for list`)
526
+ items.push(w)
527
+ }
528
+ if (this.peek() === ';') this.pos++
529
+ } else {
530
+ this.pos = save
531
+ this.skipBlanks()
532
+ if (this.peek() === ';') this.pos++
533
+ }
534
+ return { variable: m[0], items }
535
+ }
536
+
537
+ private parseFor(): A.For | A.ArithFor {
538
+ const start = this.pos
539
+ // for (( ... ))
540
+ const after = /^for[ \t]*\(\(/.exec(this.src.slice(this.pos))
541
+ if (after) {
542
+ this.pos += after[0].length
543
+ const parts: (A.ArithExpr | null)[] = []
544
+ for (let i = 0; i < 3; i++) {
545
+ this.skipArithSpace()
546
+ const term = i < 2 ? ';' : ')'
547
+ if (this.peek() === term) {
548
+ parts.push(null)
549
+ } else {
550
+ parts.push(this.parseArithComma())
551
+ this.skipArithSpace()
552
+ }
553
+ if (this.peek() !== term) this.error(`expected '${term}' in arithmetic for`)
554
+ this.pos++
555
+ }
556
+ if (this.peek() !== ')') this.error(`expected '))'`)
557
+ this.pos++
558
+ this.skipBlanks()
559
+ if (this.peek() === ';') this.pos++
560
+ const body = this.parseDoGroup()
561
+ return this.node<A.ArithFor>(start, this.pos, { type: 'ArithFor', init: parts[0], cond: parts[1], step: parts[2], body, redirs: [] })
562
+ }
563
+ const { variable, items } = this.parseForHead('for')
564
+ const body = this.parseDoGroup()
565
+ return this.node<A.For>(start, this.pos, { type: 'For', variable, items, body, redirs: [] })
566
+ }
567
+
568
+ private parseSelect(): A.Select {
569
+ const start = this.pos
570
+ const { variable, items } = this.parseForHead('select')
571
+ const body = this.parseDoGroup()
572
+ return this.node<A.Select>(start, this.pos, { type: 'Select', variable, items, body, redirs: [] })
573
+ }
574
+
575
+ private parseCase(): A.Case {
576
+ const start = this.pos
577
+ this.pos += 4
578
+ this.skipBlanks()
579
+ const word = this.parseWord(normalStop)
580
+ if (!word) this.error('expected a word after case')
581
+ this.skipNewlines()
582
+ if (this.peekReserved() !== 'in') this.error(`expected 'in'`)
583
+ this.pos += 2
584
+ const items: A.CaseItem[] = []
585
+ const patStop: StopFn = (c) => isBlank(c) || c === '\n' || c === '|' || c === ')' || c === ';' || c === '&' || c === '<' || c === '>'
586
+ for (;;) {
587
+ this.skipNewlines()
588
+ if (this.peekReserved() === 'esac') {
589
+ this.pos += 4
590
+ break
591
+ }
592
+ if (this.eof()) this.error(`expected 'esac'`)
593
+ const itemStart = this.pos
594
+ if (this.peek() === '(') this.pos++
595
+ const patterns: A.Word[] = []
596
+ for (;;) {
597
+ this.skipBlanks()
598
+ const w = this.parseWord(patStop)
599
+ if (!w) this.error('expected a case pattern')
600
+ patterns.push(w)
601
+ this.skipBlanks()
602
+ if (this.peek() === '|') {
603
+ this.pos++
604
+ continue
605
+ }
606
+ if (this.peek() === ')') {
607
+ this.pos++
608
+ break
609
+ }
610
+ this.error(`expected ')' or '|' in case pattern`)
611
+ }
612
+ const body = this.parseList(new Set([';;', 'esac']), false)
613
+ this.skipNewlines()
614
+ let terminator: ';;' | ';&' | ';;&' = ';;'
615
+ if (this.startsWith(';;&')) {
616
+ terminator = ';;&'
617
+ this.pos += 3
618
+ } else if (this.startsWith(';;')) {
619
+ this.pos += 2
620
+ } else if (this.startsWith(';&')) {
621
+ terminator = ';&'
622
+ this.pos += 2
623
+ } else if (this.peekReserved() !== 'esac') {
624
+ this.error(`expected ';;' or 'esac'`)
625
+ }
626
+ items.push(this.node<A.CaseItem>(itemStart, this.pos, { type: 'CaseItem', patterns, body, terminator }))
627
+ }
628
+ return this.node<A.Case>(start, this.pos, { type: 'Case', word, items, redirs: [] })
629
+ }
630
+
631
+ private tryParseArithCommand(): A.ArithCommand | null {
632
+ const start = this.pos
633
+ const savePending = this.pending.length
634
+ try {
635
+ this.pos += 2
636
+ const expr = this.parseArithComma()
637
+ this.skipArithSpace()
638
+ if (!this.startsWith('))')) throw new Error('no ))')
639
+ this.pos += 2
640
+ return this.node<A.ArithCommand>(start, this.pos, { type: 'ArithCommand', expr, redirs: [] })
641
+ } catch {
642
+ this.pos = start
643
+ this.pending.length = savePending
644
+ const close = this.matchingArithClose(start + 2)
645
+ if (close !== -1) {
646
+ const text = this.src.slice(start + 2, close)
647
+ // a subshell inside a subshell, `((cmd) ...)`, is not arithmetic
648
+ if (/^\s*\(/.test(text) && !this.looksArithmetic(text)) return null
649
+ const expr = this.rawArithWord(start + 2, close)
650
+ this.pos = close + 2
651
+ return this.node<A.ArithCommand>(start, this.pos, { type: 'ArithCommand', expr, redirs: [] })
652
+ }
653
+ return null
654
+ }
655
+ }
656
+
657
+ private looksArithmetic(text: string): boolean {
658
+ return /^[\s\d\w+\-*/%<>=!&|^~?:,()$#\[\]{}"'.]*$/.test(text) && !/[a-zA-Z_][\w]*\s+[a-zA-Z_-]/.test(text)
659
+ }
660
+
661
+ // -------------------------------------------------------------------------
662
+ // Simple commands, assignments, redirections
663
+ // -------------------------------------------------------------------------
664
+
665
+ private parseSimpleCommand(): A.SimpleCommand {
666
+ const start = this.pos
667
+ const assigns: A.Assign[] = []
668
+ const words: (A.Word | A.Assign)[] = []
669
+ const redirs: A.Redirect[] = []
670
+ let declMode = false
671
+ let end = this.pos
672
+ for (;;) {
673
+ this.skipBlanks()
674
+ if (this.eof()) break
675
+ const c = this.peek()
676
+ const redir = this.tryParseRedirect()
677
+ if (redir) {
678
+ redirs.push(redir)
679
+ end = this.pos
680
+ continue
681
+ }
682
+ if (c === '\n' || c === ';' || c === '&' || c === '|' || c === ')') break
683
+ if (c === '(') {
684
+ this.error(`unexpected '('`)
685
+ }
686
+ if (c === '<' || c === '>') {
687
+ // process substitution as a word
688
+ if (this.peek(1) !== '(') this.error(`unexpected '${c}'`)
689
+ }
690
+ if (words.length === 0 || declMode) {
691
+ const a = this.tryParseAssign(declMode)
692
+ if (a) {
693
+ if (words.length === 0) assigns.push(a)
694
+ else words.push(a)
695
+ end = this.pos
696
+ continue
697
+ }
698
+ }
699
+ const w = this.parseWord(normalStop)
700
+ if (!w) this.error(`unexpected '${c}'`)
701
+ if (words.length === 0 && w.parts.length === 1 && w.parts[0].type === 'Lit' && DECL_BUILTINS.has(w.parts[0].value)) {
702
+ declMode = true
703
+ }
704
+ words.push(w)
705
+ end = this.pos
706
+ }
707
+ if (assigns.length === 0 && words.length === 0 && redirs.length === 0) this.error('expected a command')
708
+ return this.node<A.SimpleCommand>(start, end, { type: 'SimpleCommand', assigns, words, redirs })
709
+ }
710
+
711
+ /** `name=value`, `name+=value`, `name[sub]=value`, `name=(...)`. In decl mode a bare `name` is not an assignment. */
712
+ private tryParseAssign(declMode: boolean): A.Assign | null {
713
+ const start = this.pos
714
+ const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.src.slice(this.pos, this.pos + 256))
715
+ if (!m) return null
716
+ let p = this.pos + m[0].length
717
+ let index: A.Word | null = null
718
+ if (this.src[p] === '[') {
719
+ // find matching ]
720
+ let depth = 0
721
+ let q = p
722
+ for (; q < this.src.length; q++) {
723
+ const ch = this.src[q]
724
+ if (ch === '[') depth++
725
+ else if (ch === ']') {
726
+ depth--
727
+ if (depth === 0) break
728
+ } else if (ch === '\n') return null
729
+ }
730
+ if (q >= this.src.length) return null
731
+ const after = this.src[q + 1]
732
+ if (!(after === '=' || (after === '+' && this.src[q + 2] === '='))) return null
733
+ this.pos = p + 1
734
+ index = this.parseWord((c) => c === ']', { allowBlanks: true }) ?? this.node<A.Word>(this.pos, this.pos, { type: 'Word', parts: [] })
735
+ if (this.peek() !== ']') {
736
+ this.pos = start
737
+ return null
738
+ }
739
+ this.pos++
740
+ p = this.pos
741
+ }
742
+ let append = false
743
+ if (this.src[p] === '+' && this.src[p + 1] === '=') {
744
+ append = true
745
+ p += 2
746
+ } else if (this.src[p] === '=') {
747
+ p += 1
748
+ } else {
749
+ this.pos = start
750
+ return null
751
+ }
752
+ this.pos = p
753
+ let value: A.Word | A.ArrayLiteral | null = null
754
+ if (this.peek() === '(') {
755
+ value = this.parseArrayLiteral()
756
+ } else {
757
+ value = this.parseWord(normalStop)
758
+ }
759
+ return this.node<A.Assign>(start, this.pos, { type: 'Assign', name: m[0], index, append, value })
760
+ }
761
+
762
+ private parseArrayLiteral(): A.ArrayLiteral {
763
+ const start = this.pos
764
+ this.pos++ // (
765
+ const elems: A.ArrayElem[] = []
766
+ for (;;) {
767
+ this.skipNewlines()
768
+ if (this.peek() === ')') {
769
+ this.pos++
770
+ break
771
+ }
772
+ if (this.eof()) this.error(`expected ')' to close array`)
773
+ let index: A.Word | null = null
774
+ if (this.peek() === '[') {
775
+ const save = this.pos
776
+ this.pos++
777
+ const idx = this.parseWord((c) => c === ']', { allowBlanks: true })
778
+ if (this.peek() === ']' && this.peek(1) === '=') {
779
+ index = idx ?? this.node<A.Word>(this.pos, this.pos, { type: 'Word', parts: [] })
780
+ this.pos += 2
781
+ } else {
782
+ this.pos = save
783
+ }
784
+ }
785
+ const value = this.parseWord(normalStop) ?? this.node<A.Word>(this.pos, this.pos, { type: 'Word', parts: [] })
786
+ if (!index && value.parts.length === 0) this.error(`unexpected '${this.peek()}' in array`)
787
+ elems.push({ index, value })
788
+ }
789
+ return this.node<A.ArrayLiteral>(start, this.pos, { type: 'ArrayLiteral', elems })
790
+ }
791
+
792
+ private tryParseRedirect(): A.Redirect | null {
793
+ const start = this.pos
794
+ let fd: number | null = null
795
+ let fdVar: string | null = null
796
+ const rest = this.src.slice(this.pos, this.pos + 64)
797
+ let m = /^(\d+)(?=[<>])/.exec(rest)
798
+ if (m) {
799
+ fd = Number(m[1])
800
+ this.pos += m[0].length
801
+ } else if ((m = /^\{([A-Za-z_][A-Za-z0-9_]*)\}(?=[<>])/.exec(rest))) {
802
+ fdVar = m[1]
803
+ this.pos += m[0].length
804
+ }
805
+ const ops: A.RedirectOp[] = ['&>>', '&>', '<<<', '<<-', '<<', '<&', '<>', '>>', '>&', '>|', '<', '>']
806
+ let op: A.RedirectOp | null = null
807
+ for (const o of ops) {
808
+ if (this.startsWith(o)) {
809
+ op = o
810
+ break
811
+ }
812
+ }
813
+ if (op === null || ((op === '<' || op === '>') && this.peek(1) === '(') || ((op === '&>' || op === '&>>') && (fd !== null || fdVar !== null))) {
814
+ this.pos = start
815
+ return null
816
+ }
817
+ this.pos += op.length
818
+ this.skipBlanks()
819
+ const target = this.parseWord(normalStop)
820
+ if (!target) this.error(`expected a word after '${op}'`)
821
+ const redirect = this.node<A.Redirect>(start, this.pos, { type: 'Redirect', op, fd, fdVar, target, heredoc: null })
822
+ if (op === '<<' || op === '<<-') {
823
+ let delimiter = ''
824
+ let quoted = false
825
+ for (const part of target.parts) {
826
+ if (part.type === 'Lit') delimiter += part.value
827
+ else if (part.type === 'SglQuoted' || part.type === 'Escaped') {
828
+ delimiter += part.value
829
+ quoted = true
830
+ } else if (part.type === 'DblQuoted') {
831
+ quoted = true
832
+ for (const inner of part.parts) {
833
+ if (inner.type === 'Lit') delimiter += inner.value
834
+ else delimiter += this.src.slice(inner.span.start - this.offset, inner.span.end - this.offset)
835
+ }
836
+ } else {
837
+ delimiter += this.src.slice(part.span.start - this.offset, part.span.end - this.offset)
838
+ }
839
+ }
840
+ this.pending.push({ redirect, delimiter, quoted, strip: op === '<<-' })
841
+ }
842
+ return redirect
843
+ }
844
+
845
+ private readHeredocBodies(): void {
846
+ const queue = this.pending
847
+ this.pending = []
848
+ for (const h of queue) {
849
+ const bodyStart = this.pos
850
+ let bodyEnd = this.pos
851
+ let found = false
852
+ while (!this.eof()) {
853
+ const nl = this.src.indexOf('\n', this.pos)
854
+ const lineEnd = nl === -1 ? this.src.length : nl
855
+ let line = this.src.slice(this.pos, lineEnd)
856
+ if (h.strip) line = line.replace(/^\t+/, '')
857
+ if (line === h.delimiter) {
858
+ bodyEnd = this.pos
859
+ this.pos = nl === -1 ? this.src.length : nl + 1
860
+ found = true
861
+ break
862
+ }
863
+ this.pos = nl === -1 ? this.src.length : nl + 1
864
+ bodyEnd = this.pos
865
+ }
866
+ if (!found) bodyEnd = this.src.length
867
+ const missingNewline = !found && bodyEnd > bodyStart && this.src[bodyEnd - 1] !== '\n'
868
+ let body: A.Word
869
+ if (h.quoted) {
870
+ let text = this.src.slice(bodyStart, bodyEnd)
871
+ if (h.strip) text = text.replace(/^\t+/gm, '')
872
+ if (missingNewline) text += '\n'
873
+ body = this.node<A.Word>(bodyStart, bodyEnd, {
874
+ type: 'Word',
875
+ parts: [this.node<A.SglQuoted>(bodyStart, bodyEnd, { type: 'SglQuoted', value: text, ansi: false })],
876
+ })
877
+ } else {
878
+ const resume = this.pos
879
+ const sub = new Parser(this.src.slice(0, bodyEnd), this.offset, this.ids, this.root)
880
+ sub.pos = bodyStart
881
+ const parts = sub.parseDquotedParts('heredoc')
882
+ if (h.strip) {
883
+ for (const part of parts) {
884
+ if (part.type !== 'Lit') continue
885
+ const atLineStart = part.span.start - this.offset === bodyStart || this.src[part.span.start - this.offset - 1] === '\n'
886
+ part.value = part.value.replace(/\n\t+/g, '\n')
887
+ if (atLineStart) part.value = part.value.replace(/^\t+/, '')
888
+ }
889
+ }
890
+ if (missingNewline) parts.push(this.node<A.Lit>(bodyEnd, bodyEnd, { type: 'Lit', value: '\n' }))
891
+ body = this.node<A.Word>(bodyStart, bodyEnd, { type: 'Word', parts })
892
+ this.pos = resume
893
+ }
894
+ h.redirect.heredoc = { delimiter: h.delimiter, quoted: h.quoted, body }
895
+ }
896
+ }
897
+
898
+ // -------------------------------------------------------------------------
899
+ // Words
900
+ // -------------------------------------------------------------------------
901
+
902
+ /**
903
+ * Parse one word. `stop` decides which unquoted characters end it. Returns
904
+ * null if the word would be empty (cursor at a stop character).
905
+ */
906
+ parseWord(stop: StopFn, opts: { allowBlanks?: boolean; inDquote?: boolean; sglLiteral?: boolean } = {}): A.Word | null {
907
+ const start = this.pos
908
+ const parts = this.parseWordParts(stop, opts)
909
+ if (parts.length === 0 && this.pos === start) return null
910
+ return this.node<A.Word>(start, this.pos, { type: 'Word', parts })
911
+ }
912
+
913
+ private parseWordParts(stop: StopFn, opts: { allowBlanks?: boolean; inDquote?: boolean; sglLiteral?: boolean }): A.WordPart[] {
914
+ const parts: A.WordPart[] = []
915
+ let litStart = -1
916
+ let lit = ''
917
+ const flush = (): void => {
918
+ if (litStart >= 0) {
919
+ parts.push(this.node<A.Lit>(litStart, this.pos, { type: 'Lit', value: lit }))
920
+ litStart = -1
921
+ lit = ''
922
+ }
923
+ }
924
+ while (!this.eof()) {
925
+ const c = this.peek()
926
+ if (c === '\\' && this.peek(1) === '\n') {
927
+ flush()
928
+ this.pos += 2
929
+ continue
930
+ }
931
+ if ((c === '<' || c === '>') && this.peek(1) === '(' && !opts.inDquote && !opts.allowBlanks) {
932
+ flush()
933
+ parts.push(this.parseProcSubst())
934
+ continue
935
+ }
936
+ if ('@*+?!'.includes(c) && this.peek(1) === '(' && !opts.inDquote) {
937
+ const end = this.extglobEnd(this.pos + 1)
938
+ if (end !== -1) {
939
+ if (litStart < 0) litStart = this.pos
940
+ lit += this.src.slice(this.pos, end)
941
+ this.pos = end
942
+ continue
943
+ }
944
+ }
945
+ if (stop(c, this) && !(opts.allowBlanks && isBlank(c))) break
946
+ if (c === '\\') {
947
+ flush()
948
+ const s = this.pos
949
+ if (this.pos + 1 >= this.src.length) {
950
+ // a backslash at the very end of the input: bash 5 keeps it literally (3.2 dropped it)
951
+ this.pos++
952
+ parts.push(this.node<A.Lit>(s, this.pos, { type: 'Lit', value: '\\' }))
953
+ continue
954
+ }
955
+ this.pos += 2
956
+ if (opts.inDquote) {
957
+ const e = this.src[s + 1]
958
+ const value = '$`"\\}'.includes(e) ? e : '\\' + e
959
+ parts.push(this.node<A.Lit>(s, this.pos, { type: 'Lit', value }))
960
+ } else {
961
+ parts.push(this.node<A.Escaped>(s, this.pos, { type: 'Escaped', value: this.src[s + 1] }))
962
+ }
963
+ continue
964
+ }
965
+ if (c === "'" && !opts.inDquote && !opts.sglLiteral) {
966
+ flush()
967
+ const s = this.pos
968
+ const close = this.src.indexOf("'", this.pos + 1)
969
+ if (close === -1) this.error('unterminated single quote', s)
970
+ this.pos = close + 1
971
+ parts.push(this.node<A.SglQuoted>(s, this.pos, { type: 'SglQuoted', value: this.src.slice(s + 1, close), ansi: false }))
972
+ continue
973
+ }
974
+ if (c === '"') {
975
+ flush()
976
+ if (opts.inDquote) break
977
+ parts.push(this.parseDquoted(false))
978
+ continue
979
+ }
980
+ if (c === '$') {
981
+ const d = this.parseDollar(opts.inDquote ?? false)
982
+ if (d) {
983
+ flush()
984
+ parts.push(d)
985
+ continue
986
+ }
987
+ }
988
+ if (c === '`') {
989
+ flush()
990
+ parts.push(this.parseBackquote())
991
+ continue
992
+ }
993
+ if (litStart < 0) litStart = this.pos
994
+ lit += c
995
+ this.pos++
996
+ }
997
+ flush()
998
+ return parts
999
+ }
1000
+
1001
+ /** End offset (exclusive) of an extglob group starting at the `(` at `open`, or -1. */
1002
+ private extglobEnd(open: number): number {
1003
+ let depth = 0
1004
+ for (let i = open; i < this.src.length; i++) {
1005
+ const c = this.src[i]
1006
+ if (c === '\\') {
1007
+ i++
1008
+ continue
1009
+ }
1010
+ if (c === '(') depth++
1011
+ else if (c === ')') {
1012
+ depth--
1013
+ if (depth === 0) return i + 1
1014
+ } else if (c === '\n' || ((c === ' ' || c === '\t') && depth <= 1 && false)) return -1
1015
+ }
1016
+ return -1
1017
+ }
1018
+
1019
+ private parseDquoted(locale: boolean): A.DblQuoted {
1020
+ const s = locale ? this.pos - 1 : this.pos
1021
+ this.pos++ // "
1022
+ const parts = this.parseDquotedParts('dquote')
1023
+ if (this.peek() !== '"') this.error('unterminated double quote', s)
1024
+ this.pos++
1025
+ return this.node<A.DblQuoted>(s, this.pos, { type: 'DblQuoted', parts, locale })
1026
+ }
1027
+
1028
+ /**
1029
+ * Parts inside double quotes (until an unescaped `"`) or in an unquoted
1030
+ * here-document body (until the end of the sub-source).
1031
+ */
1032
+ parseDquotedParts(mode: 'dquote' | 'heredoc'): A.WordPart[] {
1033
+ this.dquoteDepth++
1034
+ try {
1035
+ return this.parseDquotedPartsInner(mode)
1036
+ } finally {
1037
+ this.dquoteDepth--
1038
+ }
1039
+ }
1040
+
1041
+ private parseDquotedPartsInner(mode: 'dquote' | 'heredoc'): A.WordPart[] {
1042
+ const parts: A.WordPart[] = []
1043
+ let litStart = -1
1044
+ let lit = ''
1045
+ const flush = (): void => {
1046
+ if (litStart >= 0) {
1047
+ parts.push(this.node<A.Lit>(litStart, this.pos, { type: 'Lit', value: lit }))
1048
+ litStart = -1
1049
+ lit = ''
1050
+ }
1051
+ }
1052
+ while (!this.eof()) {
1053
+ const c = this.peek()
1054
+ if (c === '"' && mode === 'dquote') break
1055
+ if (c === '\\') {
1056
+ const e = this.peek(1)
1057
+ if (e === '\n') {
1058
+ flush()
1059
+ this.pos += 2
1060
+ continue
1061
+ }
1062
+ const escapable = mode === 'dquote' ? '$`"\\' : '$`\\'
1063
+ if (e !== '' && escapable.includes(e)) {
1064
+ if (litStart < 0) litStart = this.pos
1065
+ lit += e
1066
+ this.pos += 2
1067
+ continue
1068
+ }
1069
+ if (litStart < 0) litStart = this.pos
1070
+ lit += c
1071
+ this.pos++
1072
+ continue
1073
+ }
1074
+ if (c === '$') {
1075
+ const d = this.parseDollar(true)
1076
+ if (d) {
1077
+ flush()
1078
+ parts.push(d)
1079
+ continue
1080
+ }
1081
+ }
1082
+ if (c === '`') {
1083
+ flush()
1084
+ parts.push(this.parseBackquote())
1085
+ continue
1086
+ }
1087
+ if (litStart < 0) litStart = this.pos
1088
+ lit += c
1089
+ this.pos++
1090
+ }
1091
+ flush()
1092
+ return parts
1093
+ }
1094
+
1095
+ private parseBackquote(): A.CmdSubst {
1096
+ const s = this.pos
1097
+ this.pos++
1098
+ let text = ''
1099
+ for (;;) {
1100
+ if (this.eof()) this.error('unterminated backquote', s)
1101
+ const c = this.peek()
1102
+ if (c === '`') {
1103
+ this.pos++
1104
+ break
1105
+ }
1106
+ if (c === '\\' && (('`\\$'.includes(this.peek(1)) && this.peek(1) !== '') || (this.peek(1) === '"' && this.dquoteDepth > 0))) {
1107
+ text += this.peek(1)
1108
+ this.pos += 2
1109
+ continue
1110
+ }
1111
+ text += c
1112
+ this.pos++
1113
+ }
1114
+ const sub = new Parser(text, this.offset + s + 1, this.ids, this.root)
1115
+ const body = sub.parseList(new Set(), false)
1116
+ sub.skipNewlines()
1117
+ if (!sub.eof()) sub.error(`unexpected '${sub.peek()}'`)
1118
+ return this.node<A.CmdSubst>(s, this.pos, { type: 'CmdSubst', body, backquote: true })
1119
+ }
1120
+
1121
+ private parseProcSubst(): A.ProcSubst {
1122
+ const s = this.pos
1123
+ const direction = this.peek() as '<' | '>'
1124
+ this.pos += 2
1125
+ const body = this.parseList(new Set(), true)
1126
+ this.skipNewlines()
1127
+ if (this.peek() !== ')') this.error(`expected ')' to close process substitution`, s)
1128
+ this.pos++
1129
+ return this.node<A.ProcSubst>(s, this.pos, { type: 'ProcSubst', direction, body })
1130
+ }
1131
+
1132
+ /** Parse a `$`-construct at the cursor, or return null if `$` is literal here. */
1133
+ private parseDollar(inDquote: boolean): A.WordPart | null {
1134
+ const s = this.pos
1135
+ const n = this.peek(1)
1136
+ if (n === '(') {
1137
+ if (this.peek(2) === '(') {
1138
+ // $(( arith )) — or a command substitution starting with a subshell
1139
+ const savePending = this.pending.length
1140
+ const close = this.matchingArithClose(this.pos + 3)
1141
+ try {
1142
+ this.pos += 3
1143
+ this.skipArithSpace()
1144
+ if (this.startsWith('))')) {
1145
+ this.pos += 2
1146
+ return this.node<A.ArithExp>(s, this.pos, { type: 'ArithExp', expr: this.node<A.ArithNum>(s, this.pos, { type: 'ArithNum', text: '0' }) })
1147
+ }
1148
+ const expr = this.parseArithComma()
1149
+ this.skipArithSpace()
1150
+ if (!this.startsWith('))')) throw new Error('not arithmetic')
1151
+ this.pos += 2
1152
+ return this.node<A.ArithExp>(s, this.pos, { type: 'ArithExp', expr })
1153
+ } catch {
1154
+ this.pos = s
1155
+ this.pending.length = savePending
1156
+ if (close !== -1) {
1157
+ // balanced `$(( ... ))` that does not parse: an arithmetic syntax error at runtime, like bash
1158
+ return this.rawArith(s, this.pos + 3, close)
1159
+ }
1160
+ }
1161
+ }
1162
+ this.pos += 2
1163
+ const savedDq = this.dquoteDepth
1164
+ this.dquoteDepth = 0
1165
+ const body = this.parseList(new Set(), true)
1166
+ this.dquoteDepth = savedDq
1167
+ this.skipNewlines()
1168
+ if (this.peek() !== ')') this.error(`expected ')' to close command substitution`, s)
1169
+ this.pos++
1170
+ return this.node<A.CmdSubst>(s, this.pos, { type: 'CmdSubst', body, backquote: false })
1171
+ }
1172
+ if (n === '[') {
1173
+ // deprecated $[ expr ]
1174
+ const close = this.src.indexOf(']', this.pos + 2)
1175
+ if (close !== -1) {
1176
+ const save = this.pos
1177
+ try {
1178
+ this.pos += 2
1179
+ const expr = this.parseArithComma()
1180
+ this.skipArithSpace()
1181
+ if (this.peek() !== ']') throw new Error('not arithmetic')
1182
+ this.pos++
1183
+ return this.node<A.ArithExp>(s, this.pos, { type: 'ArithExp', expr })
1184
+ } catch {
1185
+ this.pos = save
1186
+ }
1187
+ }
1188
+ }
1189
+ if (n === '{') return this.parseBracedParam()
1190
+ if (n === "'" && !inDquote) return this.parseAnsiQuoted()
1191
+ if (n === '"' && !inDquote) {
1192
+ this.pos++
1193
+ return this.parseDquoted(true)
1194
+ }
1195
+ if (isNameStart(n)) {
1196
+ let e = this.pos + 2
1197
+ while (isNameChar(this.src[e])) e++
1198
+ const name = this.src.slice(this.pos + 1, e)
1199
+ this.pos = e
1200
+ return this.param(s, name, true)
1201
+ }
1202
+ if (/[0-9]/.test(n) || SPECIAL_PARAMS.has(n)) {
1203
+ this.pos += 2
1204
+ return this.param(s, n, true)
1205
+ }
1206
+ return null
1207
+ }
1208
+
1209
+ /**
1210
+ * If `from` starts arithmetic text whose parentheses balance up to a `))`,
1211
+ * the offset of that `))`; else -1. (bash decides `$((` vs `$( (` this way.)
1212
+ */
1213
+ private matchingArithClose(from: number): number {
1214
+ let depth = 0
1215
+ for (let i = from; i < this.src.length; i++) {
1216
+ const c = this.src[i]
1217
+ if (c === '(') depth++
1218
+ else if (c === ')') {
1219
+ if (depth === 0) return this.src[i + 1] === ')' ? i : -1
1220
+ depth--
1221
+ } else if (c === '\n' && depth === 0) {
1222
+ // keep scanning: arithmetic may span lines
1223
+ } else if (c === "'" || c === '"' || c === '`') return -1
1224
+ }
1225
+ return -1
1226
+ }
1227
+
1228
+ /** An arithmetic expansion kept as raw text (evaluated, and diagnosed, at runtime). */
1229
+ private rawArith(s: number, textStart: number, close: number): A.ArithExp {
1230
+ const expr = this.rawArithWord(textStart, close)
1231
+ this.pos = close + 2
1232
+ return this.node<A.ArithExp>(s, this.pos, { type: 'ArithExp', expr })
1233
+ }
1234
+
1235
+ /** Raw arithmetic text as one word with `$`-expansions active (substituted textually, then parsed). */
1236
+ private rawArithWord(textStart: number, close: number): A.ArithWord {
1237
+ const sub = new Parser(this.src.slice(0, close), this.offset, this.ids, this.root)
1238
+ sub.pos = textStart
1239
+ const parts = sub.parseDquotedParts('heredoc')
1240
+ const word = this.node<A.Word>(textStart, close, { type: 'Word', parts })
1241
+ return this.node<A.ArithWord>(textStart, close, { type: 'ArithWord', word })
1242
+ }
1243
+
1244
+ private param(s: number, name: string, short: boolean, extra: Partial<A.ParamExp> = {}): A.ParamExp {
1245
+ return this.node<A.ParamExp>(s, this.pos, {
1246
+ type: 'ParamExp', short, name, index: null, length: false, indirect: false, prefixNames: null, op: null, ...extra,
1247
+ })
1248
+ }
1249
+
1250
+ private parseAnsiQuoted(): A.SglQuoted {
1251
+ const s = this.pos
1252
+ this.pos += 2
1253
+ let value = ''
1254
+ for (;;) {
1255
+ if (this.eof()) this.error('unterminated $\'', s)
1256
+ const c = this.peek()
1257
+ if (c === "'") {
1258
+ this.pos++
1259
+ break
1260
+ }
1261
+ if (c !== '\\') {
1262
+ value += c
1263
+ this.pos++
1264
+ continue
1265
+ }
1266
+ const e = this.peek(1)
1267
+ this.pos += 2
1268
+ switch (e) {
1269
+ case 'n': value += '\n'; break
1270
+ case 't': value += '\t'; break
1271
+ case 'r': value += '\r'; break
1272
+ case 'a': value += '\x07'; break
1273
+ case 'b': value += '\b'; break
1274
+ case 'e': case 'E': value += '\x1b'; break
1275
+ case 'f': value += '\f'; break
1276
+ case 'v': value += '\v'; break
1277
+ case '\\': value += '\\'; break
1278
+ case "'": value += "'"; break
1279
+ case '"': value += '"'; break
1280
+ case '?': value += '?'; break
1281
+ case 'c': {
1282
+ const ch = this.peek()
1283
+ this.pos++
1284
+ value += String.fromCharCode(ch.toUpperCase().charCodeAt(0) ^ 0x40)
1285
+ break
1286
+ }
1287
+ case 'x': case 'u': case 'U': {
1288
+ const max = e === 'x' ? 2 : e === 'u' ? 4 : 8
1289
+ const m = new RegExp(`^[0-9a-fA-F]{1,${max}}`).exec(this.src.slice(this.pos))
1290
+ if (!m) {
1291
+ value += '\\' + e
1292
+ break
1293
+ }
1294
+ this.pos += m[0].length
1295
+ value += String.fromCodePoint(parseInt(m[0], 16))
1296
+ break
1297
+ }
1298
+ default: {
1299
+ if (/[0-7]/.test(e)) {
1300
+ const m = /^[0-7]{0,2}/.exec(this.src.slice(this.pos))!
1301
+ this.pos += m[0].length
1302
+ value += String.fromCharCode(parseInt(e + m[0], 8) & 0xff)
1303
+ } else {
1304
+ value += '\\' + e
1305
+ }
1306
+ }
1307
+ }
1308
+ }
1309
+ return this.node<A.SglQuoted>(s, this.pos, { type: 'SglQuoted', value, ansi: true })
1310
+ }
1311
+
1312
+ private parseBracedParam(): A.ParamExp {
1313
+ const s = this.pos
1314
+ this.pos += 2 // ${
1315
+ let length = false
1316
+ let indirect = false
1317
+ const c0 = this.peek()
1318
+ if (c0 === '#' && this.peek(1) !== '}' && (isNameStart(this.peek(1)) || /[0-9@*#?$!-]/.test(this.peek(1)))) {
1319
+ // ${#x} — but ${#} is $#, and ${#-} etc are rare
1320
+ length = true
1321
+ this.pos++
1322
+ } else if (c0 === '!' && this.peek(1) !== '}' && (isNameStart(this.peek(1)) || /[0-9@*#?$-]/.test(this.peek(1)))) {
1323
+ indirect = true
1324
+ this.pos++
1325
+ }
1326
+ let name: string
1327
+ const c = this.peek()
1328
+ if (isNameStart(c)) {
1329
+ let e = this.pos + 1
1330
+ while (isNameChar(this.src[e])) e++
1331
+ name = this.src.slice(this.pos, e)
1332
+ this.pos = e
1333
+ } else if (/[0-9]/.test(c)) {
1334
+ let e = this.pos + 1
1335
+ while (/[0-9]/.test(this.src[e] ?? '')) e++
1336
+ name = this.src.slice(this.pos, e)
1337
+ this.pos = e
1338
+ } else if (SPECIAL_PARAMS.has(c)) {
1339
+ name = c
1340
+ this.pos++
1341
+ } else {
1342
+ this.error('bad substitution', s)
1343
+ }
1344
+ if (indirect && (this.peek() === '*' || this.peek() === '@') && this.peek(1) === '}') {
1345
+ const which = this.peek() as '*' | '@'
1346
+ this.pos += 2
1347
+ return this.param(s, name, false, { prefixNames: which })
1348
+ }
1349
+ let index: A.Word | null = null
1350
+ if (this.peek() === '[') {
1351
+ this.pos++
1352
+ index = this.parseWord((ch) => ch === ']', { allowBlanks: true }) ?? this.node<A.Word>(this.pos, this.pos, { type: 'Word', parts: [] })
1353
+ if (this.peek() !== ']') this.error(`expected ']'`)
1354
+ this.pos++
1355
+ }
1356
+ let op: A.ParamOp | null = null
1357
+ const braceStop: StopFn = (ch) => ch === '}'
1358
+ const wordUntilBrace = (): A.Word | null => this.parseWord(braceStop, { allowBlanks: true })
1359
+ // inside "...", single quotes in ${v:-'x'} are literal characters
1360
+ const defaultWord = (): A.Word | null => this.parseWord(braceStop, { allowBlanks: true, sglLiteral: this.dquoteDepth > 0 })
1361
+ const emptyWord = (): A.Word => this.node<A.Word>(this.pos, this.pos, { type: 'Word', parts: [] })
1362
+ const o = this.peek()
1363
+ if (o === '}') {
1364
+ // plain
1365
+ } else if (o === ':' && '-=?+'.includes(this.peek(1)) && this.peek(1) !== '') {
1366
+ const kind = ({ '-': 'default', '=': 'assign', '?': 'error', '+': 'alternative' } as const)[this.peek(1) as '-' | '=' | '?' | '+']
1367
+ this.pos += 2
1368
+ op = { kind, colon: true, word: defaultWord() }
1369
+ } else if ('-=?+'.includes(o)) {
1370
+ const kind = ({ '-': 'default', '=': 'assign', '?': 'error', '+': 'alternative' } as const)[o as '-' | '=' | '?' | '+']
1371
+ this.pos += 1
1372
+ op = { kind, colon: false, word: defaultWord() }
1373
+ } else if (o === ':') {
1374
+ this.pos++
1375
+ const offset = this.parseArithSegment([':', '}'])
1376
+ let len: A.ArithExpr | null = null
1377
+ if (this.peek() === ':') {
1378
+ this.pos++
1379
+ len = this.parseArithSegment(['}'])
1380
+ }
1381
+ op = { kind: 'substring', offset, length: len }
1382
+ } else if (o === '#' || o === '%') {
1383
+ const longest = this.peek(1) === o
1384
+ this.pos += longest ? 2 : 1
1385
+ op = { kind: o === '#' ? 'removePrefix' : 'removeSuffix', longest, pattern: wordUntilBrace() ?? emptyWord() }
1386
+ } else if (o === '/') {
1387
+ let mode: 'first' | 'all' | 'prefix' | 'suffix' = 'first'
1388
+ this.pos++
1389
+ if (this.peek() === '/') { mode = 'all'; this.pos++ } else if (this.peek() === '#') { mode = 'prefix'; this.pos++ } else if (this.peek() === '%') { mode = 'suffix'; this.pos++ }
1390
+ const pattern = this.parseWord((ch) => ch === '/' || ch === '}', { allowBlanks: true }) ?? emptyWord()
1391
+ let replacement: A.Word | null = null
1392
+ if (this.peek() === '/') {
1393
+ this.pos++
1394
+ replacement = wordUntilBrace() ?? emptyWord()
1395
+ }
1396
+ op = { kind: 'replace', mode, pattern, replacement }
1397
+ } else if (o === '^' || o === ',') {
1398
+ const all = this.peek(1) === o
1399
+ this.pos += all ? 2 : 1
1400
+ op = { kind: 'case', upper: o === '^', all, pattern: wordUntilBrace() }
1401
+ } else if (o === '@') {
1402
+ this.pos++
1403
+ const operator = this.peek()
1404
+ this.pos++
1405
+ op = { kind: 'transform', operator }
1406
+ } else {
1407
+ this.error('bad substitution', s)
1408
+ }
1409
+ if (this.peek() !== '}') this.error(`expected '}' to close parameter expansion`, s)
1410
+ this.pos++
1411
+ return this.param(s, name, false, { index, length, indirect, op })
1412
+ }
1413
+
1414
+ // -------------------------------------------------------------------------
1415
+ // [[ conditional ]]
1416
+ // -------------------------------------------------------------------------
1417
+
1418
+ private parseCondCommand(): A.CondCommand {
1419
+ const start = this.pos
1420
+ this.pos += 2
1421
+ const expr = this.parseCondOr()
1422
+ this.skipCondSpace()
1423
+ if (!this.startsWith(']]')) this.error(`expected ']]'`)
1424
+ this.pos += 2
1425
+ return this.node<A.CondCommand>(start, this.pos, { type: 'CondCommand', expr, redirs: [] })
1426
+ }
1427
+
1428
+ private skipCondSpace(): void {
1429
+ for (;;) {
1430
+ this.skipBlanks()
1431
+ if (this.peek() === '\n') this.consumeNewline()
1432
+ else return
1433
+ }
1434
+ }
1435
+
1436
+ private atCondEnd(): boolean {
1437
+ return this.startsWith(']]') && (isMeta(this.peek(2)) || this.peek(2) === '')
1438
+ }
1439
+
1440
+ private parseCondOr(): A.CondExpr {
1441
+ this.skipCondSpace()
1442
+ const start = this.pos
1443
+ let left = this.parseCondAnd()
1444
+ for (;;) {
1445
+ this.skipCondSpace()
1446
+ if (!this.startsWith('||')) return left
1447
+ this.pos += 2
1448
+ const right = this.parseCondAnd()
1449
+ left = this.node<A.CondAndOr>(start, this.pos, { type: 'CondAndOr', op: '||', left, right })
1450
+ }
1451
+ }
1452
+
1453
+ private parseCondAnd(): A.CondExpr {
1454
+ this.skipCondSpace()
1455
+ const start = this.pos
1456
+ let left = this.parseCondNot()
1457
+ for (;;) {
1458
+ this.skipCondSpace()
1459
+ if (!this.startsWith('&&')) return left
1460
+ this.pos += 2
1461
+ const right = this.parseCondNot()
1462
+ left = this.node<A.CondAndOr>(start, this.pos, { type: 'CondAndOr', op: '&&', left, right })
1463
+ }
1464
+ }
1465
+
1466
+ private parseCondNot(): A.CondExpr {
1467
+ this.skipCondSpace()
1468
+ const start = this.pos
1469
+ if (this.peek() === '!' && (isMeta(this.peek(1)) || this.peek(1) === '')) {
1470
+ this.pos++
1471
+ const operand = this.parseCondNot()
1472
+ return this.node<A.CondNot>(start, this.pos, { type: 'CondNot', operand })
1473
+ }
1474
+ return this.parseCondPrimary()
1475
+ }
1476
+
1477
+ private condWord(): A.Word {
1478
+ const stop: StopFn = (c, p) => isBlank(c) || c === '\n' || c === '(' || c === ')' || c === '<' || c === '>' || c === ';'
1479
+ || (c === '&' && p.peek(1) === '&') || (c === '|' && p.peek(1) === '|')
1480
+ const w = this.parseWord(stop)
1481
+ if (!w) this.error(this.eof() ? 'unexpected end of input in [[ ]]' : `unexpected '${this.peek()}' in [[ ]]`)
1482
+ return w
1483
+ }
1484
+
1485
+ private regexWord(): A.Word {
1486
+ // RHS of =~: parens and | are part of the regex; blanks end it at depth 0.
1487
+ let depth = 0
1488
+ const stop: StopFn = (c, p) => {
1489
+ if (c === '(') { depth++; return false }
1490
+ if (c === ')') {
1491
+ if (depth === 0) return true
1492
+ depth--
1493
+ return false
1494
+ }
1495
+ if (depth > 0) return c === '\n'
1496
+ return isBlank(c) || c === '\n' || (c === '&' && p.peek(1) === '&') || (c === '|' && p.peek(1) === '|')
1497
+ }
1498
+ const w = this.parseWord(stop)
1499
+ if (!w) this.error('expected a regular expression after =~')
1500
+ return w
1501
+ }
1502
+
1503
+ private parseCondPrimary(): A.CondExpr {
1504
+ this.skipCondSpace()
1505
+ const start = this.pos
1506
+ if (this.peek() === '(') {
1507
+ this.pos++
1508
+ const expr = this.parseCondOr()
1509
+ this.skipCondSpace()
1510
+ if (this.peek() !== ')') this.error(`expected ')' in [[ ]]`)
1511
+ this.pos++
1512
+ return this.node<A.CondGroup>(start, this.pos, { type: 'CondGroup', expr })
1513
+ }
1514
+ if (this.atCondEnd()) this.error('expected an expression in [[ ]]')
1515
+ const first = this.condWord()
1516
+ const firstLit = literalOf(first)
1517
+ this.skipBlanks()
1518
+ if (firstLit !== null && COND_UNARY.has(firstLit) && !this.atCondEnd() && !this.startsWith('&&') && !this.startsWith('||') && this.peek() !== ')') {
1519
+ // unary unless followed by a binary operator
1520
+ const save = this.pos
1521
+ const opm = this.peekCondBinaryOp()
1522
+ if (opm === null) {
1523
+ const operand = this.condWord()
1524
+ return this.node<A.CondUnary>(start, this.pos, { type: 'CondUnary', op: firstLit, operand })
1525
+ }
1526
+ this.pos = save
1527
+ }
1528
+ const op = this.peekCondBinaryOp()
1529
+ if (op !== null) {
1530
+ this.pos += op.length
1531
+ this.skipBlanks()
1532
+ const right = op === '=~' ? this.regexWord() : this.condWord()
1533
+ return this.node<A.CondBinary>(start, this.pos, { type: 'CondBinary', op, left: first, right })
1534
+ }
1535
+ return this.node<A.CondWord>(start, this.pos, { type: 'CondWord', word: first })
1536
+ }
1537
+
1538
+ private peekCondBinaryOp(): string | null {
1539
+ const m = /^(==|=~|!=|=|<|>|-(?:eq|ne|lt|le|gt|ge|nt|ot|ef))/.exec(this.src.slice(this.pos, this.pos + 4))
1540
+ if (!m) return null
1541
+ const after = this.src[this.pos + m[0].length]
1542
+ if (m[0] === '<' || m[0] === '>') return m[0]
1543
+ if (after !== undefined && !isBlank(after) && after !== '\n') return null
1544
+ return COND_BINARY.has(m[0]) ? m[0] : null
1545
+ }
1546
+
1547
+ // -------------------------------------------------------------------------
1548
+ // Arithmetic
1549
+ // -------------------------------------------------------------------------
1550
+
1551
+ private skipArithSpace(): void {
1552
+ for (;;) {
1553
+ const c = this.peek()
1554
+ if (c === ' ' || c === '\t' || c === '\r') this.pos++
1555
+ else if (c === '\n') this.consumeNewline()
1556
+ else if (c === '\\' && this.peek(1) === '\n') this.pos += 2
1557
+ else return
1558
+ }
1559
+ }
1560
+
1561
+ /** Arithmetic inside `${x:off:len}`: parse up to one of `terminators`. */
1562
+ private parseArithSegment(terminators: string[]): A.ArithExpr {
1563
+ const start = this.pos
1564
+ this.skipArithSpace()
1565
+ if (terminators.includes(this.peek())) {
1566
+ // empty offset/length means 0
1567
+ return this.node<A.ArithNum>(start, this.pos, { type: 'ArithNum', text: '0' })
1568
+ }
1569
+ const e = this.parseArithTernary()
1570
+ this.skipArithSpace()
1571
+ if (!terminators.includes(this.peek())) this.error(`unexpected '${this.peek()}' in substring expansion`)
1572
+ return e
1573
+ }
1574
+
1575
+ parseArithComma(): A.ArithExpr {
1576
+ this.skipArithSpace()
1577
+ const start = this.pos
1578
+ let left = this.parseArithAssign()
1579
+ for (;;) {
1580
+ this.skipArithSpace()
1581
+ if (this.peek() !== ',') return left
1582
+ this.pos++
1583
+ const right = this.parseArithAssign()
1584
+ left = this.node<A.ArithBinary>(start, this.pos, { type: 'ArithBinary', op: ',', left, right })
1585
+ }
1586
+ }
1587
+
1588
+ private parseArithAssign(): A.ArithExpr {
1589
+ this.skipArithSpace()
1590
+ const start = this.pos
1591
+ const left = this.parseArithTernary()
1592
+ this.skipArithSpace()
1593
+ const m = /^(<<=|>>=|\*=|\/=|%=|\+=|-=|&=|\^=|\|=|=(?!=))/.exec(this.src.slice(this.pos, this.pos + 3))
1594
+ if (m) {
1595
+ if (left.type !== 'ArithVar') this.error('attempted assignment to non-variable')
1596
+ this.pos += m[0].length
1597
+ const value = this.parseArithAssign()
1598
+ return this.node<A.ArithAssign>(start, this.pos, { type: 'ArithAssign', op: m[0] as A.ArithAssign['op'], target: left, value })
1599
+ }
1600
+ return left
1601
+ }
1602
+
1603
+ private parseArithTernary(): A.ArithExpr {
1604
+ this.skipArithSpace()
1605
+ const start = this.pos
1606
+ const cond = this.parseArithBinary(0)
1607
+ this.skipArithSpace()
1608
+ if (this.peek() !== '?') return cond
1609
+ this.pos++
1610
+ const then = this.parseArithAssign()
1611
+ this.skipArithSpace()
1612
+ if (this.peek() !== ':') this.error(`expected ':' in ternary`)
1613
+ this.pos++
1614
+ const els = this.parseArithAssign()
1615
+ return this.node<A.ArithTernary>(start, this.pos, { type: 'ArithTernary', cond, then, else: els })
1616
+ }
1617
+
1618
+ private static readonly BIN_LEVELS: string[][] = [
1619
+ ['||'], ['&&'], ['|'], ['^'], ['&'], ['==', '!='], ['<=', '>=', '<', '>'], ['<<', '>>'], ['+', '-'], ['*', '/', '%'],
1620
+ ]
1621
+
1622
+ private matchArithOp(level: number): string | null {
1623
+ for (const op of Parser.BIN_LEVELS[level]) {
1624
+ if (!this.startsWith(op)) continue
1625
+ const next = this.src[this.pos + op.length]
1626
+ // don't confuse with longer operators / assignments
1627
+ if (op === '|' && (next === '|' || next === '=')) continue
1628
+ if (op === '&' && (next === '&' || next === '=')) continue
1629
+ if ((op === '<' || op === '>') && (next === op || next === '=')) continue
1630
+ if ((op === '<<' || op === '>>') && next === '=') continue
1631
+ if ((op === '+' || op === '-') && (next === op || next === '=')) continue
1632
+ if (op === '*' && (next === '*' || next === '=')) continue
1633
+ if ((op === '/' || op === '%' || op === '^') && next === '=') continue
1634
+ return op
1635
+ }
1636
+ return null
1637
+ }
1638
+
1639
+ private parseArithBinary(level: number): A.ArithExpr {
1640
+ if (level >= Parser.BIN_LEVELS.length) return this.parseArithPower()
1641
+ this.skipArithSpace()
1642
+ const start = this.pos
1643
+ let left = this.parseArithBinary(level + 1)
1644
+ for (;;) {
1645
+ this.skipArithSpace()
1646
+ const op = this.matchArithOp(level)
1647
+ if (op === null) return left
1648
+ this.pos += op.length
1649
+ const right = this.parseArithBinary(level + 1)
1650
+ left = this.node<A.ArithBinary>(start, this.pos, { type: 'ArithBinary', op: op as A.ArithBinaryOp, left, right })
1651
+ }
1652
+ }
1653
+
1654
+ private parseArithPower(): A.ArithExpr {
1655
+ this.skipArithSpace()
1656
+ const start = this.pos
1657
+ const base = this.parseArithUnary()
1658
+ this.skipArithSpace()
1659
+ if (this.startsWith('**') && this.peek(2) !== '=') {
1660
+ this.pos += 2
1661
+ const exp = this.parseArithPower()
1662
+ return this.node<A.ArithBinary>(start, this.pos, { type: 'ArithBinary', op: '**', left: base, right: exp })
1663
+ }
1664
+ return base
1665
+ }
1666
+
1667
+ private parseArithUnary(): A.ArithExpr {
1668
+ this.skipArithSpace()
1669
+ const start = this.pos
1670
+ if (this.startsWith('++') || this.startsWith('--')) {
1671
+ const op = this.src.slice(this.pos, this.pos + 2) as '++' | '--'
1672
+ this.pos += 2
1673
+ this.skipArithSpace()
1674
+ const target = this.parseArithPostfix()
1675
+ if (target.type !== 'ArithVar') this.error(`${op} requires a variable`)
1676
+ return this.node<A.ArithIncDec>(start, this.pos, { type: 'ArithIncDec', op, prefix: true, target })
1677
+ }
1678
+ const c = this.peek()
1679
+ if (c === '-' || c === '+' || c === '!' || c === '~') {
1680
+ this.pos++
1681
+ const operand = this.parseArithUnary()
1682
+ return this.node<A.ArithUnary>(start, this.pos, { type: 'ArithUnary', op: c, operand })
1683
+ }
1684
+ return this.parseArithPostfix()
1685
+ }
1686
+
1687
+ private parseArithPostfix(): A.ArithExpr {
1688
+ const start = this.pos
1689
+ const primary = this.parseArithPrimary()
1690
+ if (primary.type === 'ArithVar') {
1691
+ const save = this.pos
1692
+ this.skipArithSpace()
1693
+ if (this.startsWith('++') || this.startsWith('--')) {
1694
+ const op = this.src.slice(this.pos, this.pos + 2) as '++' | '--'
1695
+ this.pos += 2
1696
+ return this.node<A.ArithIncDec>(start, this.pos, { type: 'ArithIncDec', op, prefix: false, target: primary })
1697
+ }
1698
+ this.pos = save
1699
+ }
1700
+ return primary
1701
+ }
1702
+
1703
+ private parseArithPrimary(): A.ArithExpr {
1704
+ this.skipArithSpace()
1705
+ const start = this.pos
1706
+ const c = this.peek()
1707
+ if (c === '(') {
1708
+ this.pos++
1709
+ const expr = this.parseArithComma()
1710
+ this.skipArithSpace()
1711
+ if (this.peek() !== ')') this.error(`expected ')' in arithmetic`)
1712
+ this.pos++
1713
+ return this.node<A.ArithGroup>(start, this.pos, { type: 'ArithGroup', expr })
1714
+ }
1715
+ if (/[0-9]/.test(c)) {
1716
+ const based = /^([0-9]+#)(?=[$`])/.exec(this.src.slice(this.pos, this.pos + 8))
1717
+ if (based) {
1718
+ // `10#$n`: base prefix glued to an expansion — kept as one word, evaluated textually
1719
+ const litEnd = this.pos + based[1].length
1720
+ const lit = this.node<A.Lit>(this.pos, litEnd, { type: 'Lit', value: based[1] })
1721
+ this.pos = litEnd
1722
+ const d = this.peek() === '$' ? this.parseDollar(false) : this.parseBackquote()
1723
+ if (!d) this.error('bad base expression')
1724
+ const word = this.node<A.Word>(start, this.pos, { type: 'Word', parts: [lit, d] })
1725
+ return this.node<A.ArithWord>(start, this.pos, { type: 'ArithWord', word })
1726
+ }
1727
+ const m = /^(0[xX][0-9a-fA-F]+|[0-9]+#[0-9a-zA-Z@_]+|[0-9]+)/.exec(this.src.slice(this.pos))!
1728
+ this.pos += m[0].length
1729
+ return this.node<A.ArithNum>(start, this.pos, { type: 'ArithNum', text: m[0] })
1730
+ }
1731
+ if (isNameStart(c)) {
1732
+ let e = this.pos + 1
1733
+ while (isNameChar(this.src[e])) e++
1734
+ const name = this.src.slice(this.pos, e)
1735
+ this.pos = e
1736
+ let index: A.ArithExpr | null = null
1737
+ if (this.peek() === '[') {
1738
+ this.pos++
1739
+ index = this.parseArithComma()
1740
+ this.skipArithSpace()
1741
+ if (this.peek() !== ']') this.error(`expected ']' in arithmetic`)
1742
+ this.pos++
1743
+ }
1744
+ return this.node<A.ArithVar>(start, this.pos, { type: 'ArithVar', name, index })
1745
+ }
1746
+ if (c === '$' || c === '`' || c === '"' || c === "'") {
1747
+ // A word: $x, ${x}, $(cmd), `cmd`, "..."
1748
+ const parts: A.WordPart[] = []
1749
+ for (;;) {
1750
+ const ch = this.peek()
1751
+ if (ch === '$') {
1752
+ const d = this.parseDollar(false)
1753
+ if (!d) break
1754
+ parts.push(d)
1755
+ } else if (ch === '`') {
1756
+ parts.push(this.parseBackquote())
1757
+ } else if (ch === '"') {
1758
+ parts.push(this.parseDquoted(false))
1759
+ } else if (ch === "'") {
1760
+ const close = this.src.indexOf("'", this.pos + 1)
1761
+ if (close === -1) this.error('unterminated quote')
1762
+ const s = this.pos
1763
+ this.pos = close + 1
1764
+ parts.push(this.node<A.SglQuoted>(s, this.pos, { type: 'SglQuoted', value: this.src.slice(s + 1, close), ansi: false }))
1765
+ } else if (isNameChar(ch) && parts.length > 0) {
1766
+ // $x1 style continuation (e.g. ${a}b)
1767
+ const s = this.pos
1768
+ let e = this.pos
1769
+ while (isNameChar(this.src[e])) e++
1770
+ this.pos = e
1771
+ parts.push(this.node<A.Lit>(s, e, { type: 'Lit', value: this.src.slice(s, e) }))
1772
+ } else break
1773
+ }
1774
+ if (parts.length === 0) this.error(`unexpected '${c}' in arithmetic`)
1775
+ const word = this.node<A.Word>(start, this.pos, { type: 'Word', parts })
1776
+ return this.node<A.ArithWord>(start, this.pos, { type: 'ArithWord', word })
1777
+ }
1778
+ this.error(this.eof() ? 'unexpected end of arithmetic expression' : `unexpected '${c}' in arithmetic expression`)
1779
+ }
1780
+ }
1781
+
1782
+ /** The literal value of a word if it contains only unquoted literal text. */
1783
+ export function literalOf(w: A.Word): string | null {
1784
+ let s = ''
1785
+ for (const p of w.parts) {
1786
+ if (p.type === 'Lit') s += p.value
1787
+ else return null
1788
+ }
1789
+ return s
1790
+ }
1791
+
1792
+ /**
1793
+ * The static value of a word if it involves no expansions (quotes allowed),
1794
+ * e.g. `'a b'"c"` → `a bc`. Glob characters in unquoted parts are kept as-is.
1795
+ */
1796
+ export function staticWordValue(w: A.Word): string | null {
1797
+ let s = ''
1798
+ for (const p of w.parts) {
1799
+ switch (p.type) {
1800
+ case 'Lit':
1801
+ case 'SglQuoted':
1802
+ case 'Escaped':
1803
+ s += p.value
1804
+ break
1805
+ case 'DblQuoted':
1806
+ for (const q of p.parts) {
1807
+ if (q.type === 'Lit') s += q.value
1808
+ else return null
1809
+ }
1810
+ break
1811
+ default:
1812
+ return null
1813
+ }
1814
+ }
1815
+ return s
1816
+ }
1817
+
1818
+ /** The `parseArithmetic` entry point used by the VM to re-parse textually expanded arithmetic. */
1819
+ export function parseArithmetic(text: string, firstId = 1_000_000_000): A.ArithExpr {
1820
+ const p = new Parser(text, 0, { next: firstId }, text)
1821
+ const expr = p.parseArithComma()
1822
+ // ensure everything consumed
1823
+ const rest = text.slice(p.pos).trim()
1824
+ if (rest !== '') throw new ParseError(`syntax error in expression (error token is "${rest}")`, text, p.pos)
1825
+ return expr
1826
+ }