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