@eslint/shell 0.0.0-stage → 0.1.2

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 (37) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +167 -2
  3. package/dist/index.d.ts +79 -0
  4. package/dist/index.js +74 -0
  5. package/dist/languages/shell-language.d.ts +36 -0
  6. package/dist/languages/shell-language.js +72 -0
  7. package/dist/languages/shell-source-code.d.ts +55 -0
  8. package/dist/languages/shell-source-code.js +184 -0
  9. package/dist/parser/parse.d.ts +32 -0
  10. package/dist/parser/parse.js +758 -0
  11. package/dist/rules/no-backticks.d.ts +8 -0
  12. package/dist/rules/no-backticks.js +46 -0
  13. package/dist/rules/no-expansions-in-single-quotes.d.ts +8 -0
  14. package/dist/rules/no-expansions-in-single-quotes.js +42 -0
  15. package/dist/rules/no-ls-iteration.d.ts +8 -0
  16. package/dist/rules/no-ls-iteration.js +42 -0
  17. package/dist/rules/no-unquoted-expansions.d.ts +9 -0
  18. package/dist/rules/no-unquoted-expansions.js +91 -0
  19. package/dist/rules/no-unused-vars.d.ts +11 -0
  20. package/dist/rules/no-unused-vars.js +382 -0
  21. package/dist/rules/no-useless-cat.d.ts +8 -0
  22. package/dist/rules/no-useless-cat.js +76 -0
  23. package/dist/rules/no-useless-echo.d.ts +8 -0
  24. package/dist/rules/no-useless-echo.js +42 -0
  25. package/dist/rules/no-variables-in-printf-format.d.ts +8 -0
  26. package/dist/rules/no-variables-in-printf-format.js +60 -0
  27. package/dist/rules/require-cd-guard.d.ts +8 -0
  28. package/dist/rules/require-cd-guard.js +74 -0
  29. package/dist/rules/require-read-r.d.ts +8 -0
  30. package/dist/rules/require-read-r.js +83 -0
  31. package/dist/rules/utils.d.ts +35 -0
  32. package/dist/rules/utils.js +100 -0
  33. package/dist/types.d.ts +343 -0
  34. package/dist/types.js +7 -0
  35. package/dist/visitor-keys.d.ts +5 -0
  36. package/dist/visitor-keys.js +59 -0
  37. package/package.json +75 -4
@@ -0,0 +1,758 @@
1
+ /**
2
+ * @fileoverview Parses shell source code into an ESTree-style syntax tree by
3
+ * wrapping the mvdan-sh parser (a GopherJS build of mvdan.cc/sh) and
4
+ * translating its tree. See docs/syntax-tree.md for the tree format.
5
+ * @author Claude Opus 5.5
6
+ */
7
+ //------------------------------------------------------------------------------
8
+ // Imports
9
+ //------------------------------------------------------------------------------
10
+ import mvdan from "mvdan-sh";
11
+ const { syntax } = mvdan;
12
+ const REDIRECT_OPERATOR = /^(?:&>>|&>|>\||>>|>&|<<<|<<-|<<|<&|<>|<|>)/u;
13
+ const UNARY_ARITHMETIC_OPERATOR = /^(?:\+\+|--|[-+!~])/u;
14
+ const PROCESS_SUBSTITUTION_OPERATOR = /^[<>]\(/u;
15
+ const EXTENDED_GLOB_OPERATOR = /^[?*+@!]\(/u;
16
+ const BINARY_COMMAND_OPERATOR = /^(?:\|&|\|\||&&|\|)/u;
17
+ const CASE_TERMINATOR = /^(?:;;&|;;|;&|;\|)/u;
18
+ const LEADING_TRIVIA = /^(?:\s|#.*)*/u;
19
+ // eslint-disable-next-line no-control-regex -- intentionally matches the full ASCII range.
20
+ const NON_ASCII = /[^\x00-\x7f]/u;
21
+ const VARIANTS = new Map([
22
+ ["bash", syntax.LangBash],
23
+ ["posix", syntax.LangPOSIX],
24
+ ["mksh", syntax.LangMirBSDKorn],
25
+ ]);
26
+ /**
27
+ * The error thrown when shell source code cannot be parsed.
28
+ */
29
+ export class ShellSyntaxError extends SyntaxError {
30
+ line;
31
+ column;
32
+ /** The file path passed to the parser, when there was one. */
33
+ path;
34
+ constructor(message, line, column, path) {
35
+ super(message);
36
+ this.name = "ShellSyntaxError";
37
+ this.line = line;
38
+ this.column = column;
39
+ this.path = path;
40
+ }
41
+ }
42
+ /**
43
+ * Creates a converter from mvdan-sh byte offsets to JavaScript character
44
+ * offsets. mvdan-sh reports positions as UTF-8 byte offsets, which only
45
+ * match string indexes for ASCII-only sources.
46
+ */
47
+ function createByteToCharConverter(text) {
48
+ if (!NON_ASCII.test(text)) {
49
+ return byte => byte;
50
+ }
51
+ const map = [];
52
+ let charIndex = 0;
53
+ for (const char of text) {
54
+ const codePoint = char.codePointAt(0);
55
+ const byteLength = codePoint <= 0x7f
56
+ ? 1
57
+ : codePoint <= 0x7ff
58
+ ? 2
59
+ : codePoint <= 0xffff
60
+ ? 3
61
+ : 4;
62
+ for (let i = 0; i < byteLength; i++) {
63
+ map.push(charIndex);
64
+ }
65
+ charIndex += char.length;
66
+ }
67
+ map.push(charIndex);
68
+ return byte => map[Math.min(byte, map.length - 1)];
69
+ }
70
+ /**
71
+ * Computes a 1-based line and column pair for a character offset.
72
+ */
73
+ function locate(text, offset) {
74
+ let line = 1;
75
+ let lineStart = 0;
76
+ for (let i = 0; i < offset && i < text.length; i++) {
77
+ if (text[i] === "\n") {
78
+ line++;
79
+ lineStart = i + 1;
80
+ }
81
+ }
82
+ return { line, column: offset - lineStart + 1 };
83
+ }
84
+ //------------------------------------------------------------------------------
85
+ // Translator
86
+ //------------------------------------------------------------------------------
87
+ /**
88
+ * Translates an mvdan-sh syntax tree into the ESTree-style tree documented
89
+ * in docs/syntax-tree.md.
90
+ */
91
+ class Translator {
92
+ #text;
93
+ #toChar;
94
+ constructor(text) {
95
+ this.#text = text;
96
+ this.#toChar = createByteToCharConverter(text);
97
+ }
98
+ #start(node) {
99
+ return this.#toChar(node.Pos().Offset());
100
+ }
101
+ #end(node) {
102
+ return this.#toChar(node.End().Offset());
103
+ }
104
+ /**
105
+ * Extracts the operator token that appears between two child nodes,
106
+ * e.g. the `&&` in `a && b`. Skips surrounding whitespace and any
107
+ * comments on either side of the operator.
108
+ */
109
+ #operatorBetween(left, right) {
110
+ const slice = this.#text
111
+ .slice(this.#end(left), this.#start(right))
112
+ .replace(LEADING_TRIVIA, "");
113
+ return slice.split(/\s+/u)[0];
114
+ }
115
+ /**
116
+ * Reads the operator of a `BinaryCmd` at its recorded position.
117
+ *
118
+ * This cannot slice between the two operands the way
119
+ * `#operatorBetween()` does: a heredoc body extends the left operand's
120
+ * end past the operator, so the slice comes out empty. See the heredoc
121
+ * section of `docs/syntax-tree.md`.
122
+ */
123
+ #binaryCommandOperator(binary) {
124
+ const opPos = binary.OpPos;
125
+ return (this.#operatorAt(this.#toChar(opPos.Offset()), BINARY_COMMAND_OPERATOR) ?? "||");
126
+ }
127
+ /**
128
+ * Matches an operator pattern at a character offset.
129
+ */
130
+ #operatorAt(offset, pattern) {
131
+ const match = pattern.exec(this.#text.slice(offset, offset + 3));
132
+ return match ? match[0] : null;
133
+ }
134
+ translateProgram(file) {
135
+ return {
136
+ type: "Program",
137
+ start: 0,
138
+ end: this.#text.length,
139
+ body: file.Stmts.map(stmt => this.translateStatement(stmt)),
140
+ comments: this.collectComments(file),
141
+ };
142
+ }
143
+ collectComments(file) {
144
+ const comments = [];
145
+ syntax.Walk(file, node => {
146
+ if (node && syntax.NodeType(node) === "Comment") {
147
+ comments.push({
148
+ type: "Comment",
149
+ start: this.#start(node),
150
+ end: this.#end(node),
151
+ text: node.Text,
152
+ });
153
+ }
154
+ return true;
155
+ });
156
+ comments.sort((a, b) => a.start - b.start);
157
+ return comments;
158
+ }
159
+ /**
160
+ * Translates an mvdan `Stmt`, folding its statement-level properties
161
+ * (redirects, negation, background) into the command node.
162
+ */
163
+ translateStatement(stmt) {
164
+ const node = this.#translateCommand(stmt.Cmd);
165
+ node.start = this.#start(stmt);
166
+ node.end = this.#end(stmt);
167
+ node.redirects = (stmt.Redirs ?? []).map(redirect => this.translateRedirect(redirect));
168
+ node.negated = Boolean(stmt.Negated);
169
+ node.background = Boolean(stmt.Background);
170
+ return node;
171
+ }
172
+ #statementBase(node) {
173
+ return {
174
+ start: node ? this.#start(node) : 0,
175
+ end: node ? this.#end(node) : 0,
176
+ redirects: [],
177
+ negated: false,
178
+ background: false,
179
+ };
180
+ }
181
+ #translateCommand(cmd) {
182
+ const nodeType = cmd === null ? "nil" : syntax.NodeType(cmd);
183
+ switch (nodeType) {
184
+ case "nil":
185
+ // A statement with only redirects, such as `> file`.
186
+ return {
187
+ type: "Command",
188
+ ...this.#statementBase(null),
189
+ assignments: [],
190
+ name: null,
191
+ arguments: [],
192
+ };
193
+ case "CallExpr": {
194
+ const call = cmd;
195
+ const args = call.Args.map(word => this.translateWord(word));
196
+ return {
197
+ type: "Command",
198
+ ...this.#statementBase(call),
199
+ assignments: call.Assigns.map(assign => this.translateAssignment(assign)),
200
+ name: args[0] ?? null,
201
+ arguments: args.slice(1),
202
+ };
203
+ }
204
+ case "BinaryCmd": {
205
+ const binary = cmd;
206
+ const operator = this.#binaryCommandOperator(binary);
207
+ if (operator === "|" || operator === "|&") {
208
+ return this.#translatePipeline(binary);
209
+ }
210
+ return {
211
+ type: "LogicalExpression",
212
+ ...this.#statementBase(binary),
213
+ operator: operator === "&&" ? "&&" : "||",
214
+ left: this.translateStatement(binary.X),
215
+ right: this.translateStatement(binary.Y),
216
+ };
217
+ }
218
+ case "IfClause":
219
+ return this.#translateIfClause(cmd);
220
+ case "WhileClause": {
221
+ const clause = cmd;
222
+ const base = this.#statementBase(clause);
223
+ const test = clause.Cond.map(stmt => this.translateStatement(stmt));
224
+ const body = clause.Do.map(stmt => this.translateStatement(stmt));
225
+ return clause.Until
226
+ ? { type: "UntilStatement", ...base, test, body }
227
+ : { type: "WhileStatement", ...base, test, body };
228
+ }
229
+ case "ForClause": {
230
+ const clause = cmd;
231
+ const base = this.#statementBase(clause);
232
+ const body = clause.Do.map(stmt => this.translateStatement(stmt));
233
+ const loop = clause.Loop;
234
+ if (syntax.NodeType(loop) === "CStyleLoop") {
235
+ return {
236
+ type: "ArithmeticForStatement",
237
+ ...base,
238
+ init: loop.Init
239
+ ? this.translateArithmetic(loop.Init)
240
+ : null,
241
+ test: loop.Cond
242
+ ? this.translateArithmetic(loop.Cond)
243
+ : null,
244
+ update: loop.Post
245
+ ? this.translateArithmetic(loop.Post)
246
+ : null,
247
+ body,
248
+ };
249
+ }
250
+ return {
251
+ type: "ForStatement",
252
+ ...base,
253
+ variable: loop.Name
254
+ ? this.#translateIdentifier(loop.Name)
255
+ : null,
256
+ words: (loop.Items ?? []).map(word => this.translateWord(word)),
257
+ body,
258
+ select: Boolean(clause.Select),
259
+ };
260
+ }
261
+ case "CaseClause": {
262
+ const clause = cmd;
263
+ return {
264
+ type: "CaseStatement",
265
+ ...this.#statementBase(clause),
266
+ discriminant: this.translateWord(clause.Word),
267
+ cases: clause.Items.map(item => this.#translateCaseItem(item)),
268
+ };
269
+ }
270
+ case "Block":
271
+ return {
272
+ type: "BlockStatement",
273
+ ...this.#statementBase(cmd),
274
+ body: cmd.Stmts.map(stmt => this.translateStatement(stmt)),
275
+ };
276
+ case "Subshell":
277
+ return {
278
+ type: "Subshell",
279
+ ...this.#statementBase(cmd),
280
+ body: cmd.Stmts.map(stmt => this.translateStatement(stmt)),
281
+ };
282
+ case "FuncDecl": {
283
+ const decl = cmd;
284
+ return {
285
+ type: "FunctionDeclaration",
286
+ ...this.#statementBase(decl),
287
+ id: this.#translateIdentifier(decl.Name),
288
+ body: this.translateStatement(decl.Body),
289
+ };
290
+ }
291
+ case "TestClause":
292
+ return {
293
+ type: "TestCommand",
294
+ ...this.#statementBase(cmd),
295
+ expression: this.translateTest(cmd.X),
296
+ };
297
+ case "ArithmCmd": {
298
+ const arith = cmd;
299
+ return {
300
+ type: "ArithmeticCommand",
301
+ ...this.#statementBase(arith),
302
+ expression: arith.X
303
+ ? this.translateArithmetic(arith.X)
304
+ : null,
305
+ };
306
+ }
307
+ case "DeclClause": {
308
+ const decl = cmd;
309
+ return {
310
+ type: "DeclarationCommand",
311
+ ...this.#statementBase(decl),
312
+ kind: decl.Variant.Value,
313
+ arguments: decl.Args.map(assign => this.#translateDeclarationArgument(assign)),
314
+ };
315
+ }
316
+ case "TimeClause": {
317
+ const clause = cmd;
318
+ return {
319
+ type: "TimeCommand",
320
+ ...this.#statementBase(clause),
321
+ posix: Boolean(clause.PosixFormat),
322
+ body: clause.Stmt
323
+ ? this.translateStatement(clause.Stmt)
324
+ : null,
325
+ };
326
+ }
327
+ case "CoprocClause": {
328
+ const clause = cmd;
329
+ return {
330
+ type: "CoprocCommand",
331
+ ...this.#statementBase(clause),
332
+ name: clause.Name
333
+ ? this.translateWord(clause.Name)
334
+ : null,
335
+ body: clause.Stmt
336
+ ? this.translateStatement(clause.Stmt)
337
+ : null,
338
+ };
339
+ }
340
+ case "LetClause":
341
+ return {
342
+ type: "LetCommand",
343
+ ...this.#statementBase(cmd),
344
+ expressions: cmd.Exprs.map(expr => this.translateArithmetic(expr)),
345
+ };
346
+ default:
347
+ throw new Error(`Unsupported mvdan-sh node type: ${nodeType}.`);
348
+ }
349
+ }
350
+ #translatePipeline(binary) {
351
+ const commands = [];
352
+ const operators = [];
353
+ const collect = (stmt) => {
354
+ const inner = stmt.Cmd;
355
+ const isBare = !stmt.Negated &&
356
+ !stmt.Background &&
357
+ (stmt.Redirs ?? []).length === 0;
358
+ if (inner !== null &&
359
+ isBare &&
360
+ syntax.NodeType(inner) === "BinaryCmd") {
361
+ const operator = this.#binaryCommandOperator(inner);
362
+ if (operator === "|" || operator === "|&") {
363
+ collect(inner.X);
364
+ operators.push(operator);
365
+ commands.push(this.translateStatement(inner.Y));
366
+ return;
367
+ }
368
+ }
369
+ commands.push(this.translateStatement(stmt));
370
+ };
371
+ const operator = this.#binaryCommandOperator(binary);
372
+ collect(binary.X);
373
+ operators.push(operator);
374
+ commands.push(this.translateStatement(binary.Y));
375
+ return {
376
+ type: "Pipeline",
377
+ ...this.#statementBase(binary),
378
+ commands,
379
+ operators,
380
+ };
381
+ }
382
+ #translateIfClause(clause) {
383
+ let alternate = null;
384
+ const elseClause = clause.Else;
385
+ if (elseClause) {
386
+ if (elseClause.Cond.length > 0) {
387
+ alternate = this.#translateIfClause(elseClause);
388
+ }
389
+ else {
390
+ alternate = {
391
+ type: "ElseClause",
392
+ start: this.#start(elseClause),
393
+ end: this.#end(elseClause),
394
+ body: elseClause.Then.map(stmt => this.translateStatement(stmt)),
395
+ };
396
+ }
397
+ }
398
+ return {
399
+ type: "IfStatement",
400
+ ...this.#statementBase(clause),
401
+ test: clause.Cond.map(stmt => this.translateStatement(stmt)),
402
+ consequent: clause.Then.map(stmt => this.translateStatement(stmt)),
403
+ alternate,
404
+ };
405
+ }
406
+ #translateCaseItem(item) {
407
+ const opPos = item.OpPos;
408
+ const hasTerminator = opPos.Line() !== 0;
409
+ return {
410
+ type: "CaseClause",
411
+ start: this.#start(item),
412
+ end: this.#end(item),
413
+ patterns: item.Patterns.map(word => this.translateWord(word)),
414
+ body: item.Stmts.map(stmt => this.translateStatement(stmt)),
415
+ terminator: hasTerminator
416
+ ? this.#operatorAt(this.#toChar(opPos.Offset()), CASE_TERMINATOR)
417
+ : null,
418
+ };
419
+ }
420
+ #translateIdentifier(lit) {
421
+ return {
422
+ type: "Identifier",
423
+ start: this.#start(lit),
424
+ end: this.#end(lit),
425
+ name: lit.Value,
426
+ };
427
+ }
428
+ translateAssignment(assign) {
429
+ return {
430
+ type: "VariableAssignment",
431
+ start: this.#start(assign),
432
+ end: this.#end(assign),
433
+ name: assign.Name
434
+ ? this.#translateIdentifier(assign.Name)
435
+ : null,
436
+ index: assign.Index
437
+ ? this.translateArithmetic(assign.Index)
438
+ : null,
439
+ value: assign.Value
440
+ ? this.translateWord(assign.Value)
441
+ : null,
442
+ array: assign.Array
443
+ ? this.#translateArray(assign.Array)
444
+ : null,
445
+ append: Boolean(assign.Append),
446
+ };
447
+ }
448
+ /**
449
+ * Declaration command arguments are all `Assign` nodes in mvdan-sh;
450
+ * plain words such as flags (`-x`) become nameless naked assigns.
451
+ */
452
+ #translateDeclarationArgument(assign) {
453
+ if (!assign.Name && assign.Naked && assign.Value) {
454
+ return this.translateWord(assign.Value);
455
+ }
456
+ return this.translateAssignment(assign);
457
+ }
458
+ #translateArray(array) {
459
+ return {
460
+ type: "ArrayExpression",
461
+ start: this.#start(array),
462
+ end: this.#end(array),
463
+ elements: array.Elems.map((element) => ({
464
+ type: "ArrayElement",
465
+ start: this.#start(element),
466
+ end: this.#end(element),
467
+ index: element.Index
468
+ ? this.translateArithmetic(element.Index)
469
+ : null,
470
+ value: element.Value
471
+ ? this.translateWord(element.Value)
472
+ : null,
473
+ })),
474
+ };
475
+ }
476
+ translateRedirect(redirect) {
477
+ const opPos = redirect.OpPos;
478
+ const opStart = this.#toChar(opPos.Offset());
479
+ const operator = this.#operatorAt(opStart, REDIRECT_OPERATOR) ?? ">";
480
+ const fdLit = redirect.N;
481
+ const target = redirect.Word;
482
+ return {
483
+ type: "Redirect",
484
+ start: fdLit ? this.#start(fdLit) : opStart,
485
+ end: target ? this.#end(target) : opStart + operator.length,
486
+ operator,
487
+ fd: fdLit ? Number.parseInt(fdLit.Value, 10) : null,
488
+ target: target ? this.translateWord(target) : null,
489
+ heredoc: redirect.Hdoc
490
+ ? this.translateWord(redirect.Hdoc)
491
+ : null,
492
+ };
493
+ }
494
+ translateWord(word) {
495
+ return {
496
+ type: "Word",
497
+ start: this.#start(word),
498
+ end: this.#end(word),
499
+ parts: word.Parts.map(part => this.translateWordPart(part)),
500
+ };
501
+ }
502
+ translateWordPart(part) {
503
+ const nodeType = syntax.NodeType(part);
504
+ const start = this.#start(part);
505
+ const end = this.#end(part);
506
+ switch (nodeType) {
507
+ case "Lit":
508
+ return {
509
+ type: "Literal",
510
+ start,
511
+ end,
512
+ value: part.Value,
513
+ };
514
+ case "SglQuoted":
515
+ return {
516
+ type: "SingleQuotedString",
517
+ start,
518
+ end,
519
+ value: part.Value,
520
+ dollar: Boolean(part.Dollar),
521
+ };
522
+ case "DblQuoted":
523
+ return {
524
+ type: "DoubleQuotedString",
525
+ start,
526
+ end,
527
+ parts: part.Parts.map(inner => this.translateWordPart(inner)),
528
+ dollar: Boolean(part.Dollar),
529
+ };
530
+ case "ParamExp":
531
+ return this.#translateParameterExpansion(part);
532
+ case "CmdSubst":
533
+ return {
534
+ type: "CommandSubstitution",
535
+ start,
536
+ end,
537
+ body: part.Stmts.map(stmt => this.translateStatement(stmt)),
538
+ backquotes: this.#text[start] === "`",
539
+ };
540
+ case "ProcSubst":
541
+ return {
542
+ type: "ProcessSubstitution",
543
+ start,
544
+ end,
545
+ operator: this.#operatorAt(start, PROCESS_SUBSTITUTION_OPERATOR) ?? "<(",
546
+ body: part.Stmts.map(stmt => this.translateStatement(stmt)),
547
+ };
548
+ case "ArithmExp":
549
+ return {
550
+ type: "ArithmeticExpansion",
551
+ start,
552
+ end,
553
+ expression: part.X
554
+ ? this.translateArithmetic(part.X)
555
+ : null,
556
+ };
557
+ case "ExtGlob":
558
+ return {
559
+ type: "ExtendedGlob",
560
+ start,
561
+ end,
562
+ operator: this.#operatorAt(start, EXTENDED_GLOB_OPERATOR) ?? "@(",
563
+ pattern: (part.Pattern?.Value ??
564
+ ""),
565
+ };
566
+ default:
567
+ // Unknown word parts (e.g. BraceExp when enabled) degrade to
568
+ // literals covering their source text.
569
+ return {
570
+ type: "Literal",
571
+ start,
572
+ end,
573
+ value: this.#text.slice(start, end),
574
+ };
575
+ }
576
+ }
577
+ #translateParameterExpansion(part) {
578
+ const start = this.#start(part);
579
+ const end = this.#end(part);
580
+ const param = part.Param;
581
+ const index = part.Index
582
+ ? this.translateArithmetic(part.Index)
583
+ : null;
584
+ let operator = null;
585
+ let word = null;
586
+ let replacement = null;
587
+ let sliceOffset = null;
588
+ let sliceLength = null;
589
+ // Character offset where an operator would begin: after the
590
+ // parameter name, or after the `]` that closes a subscript.
591
+ let operatorStart = this.#end(param);
592
+ if (index) {
593
+ const bracket = this.#text.indexOf("]", index.end);
594
+ operatorStart = bracket === -1 ? index.end : bracket + 1;
595
+ }
596
+ const rbrace = part.Rbrace
597
+ ? this.#toChar(part.Rbrace.Offset())
598
+ : end;
599
+ if (part.Slice) {
600
+ const slice = part.Slice;
601
+ operator = ":";
602
+ sliceOffset = slice.Offset
603
+ ? this.translateArithmetic(slice.Offset)
604
+ : null;
605
+ sliceLength = slice.Length
606
+ ? this.translateArithmetic(slice.Length)
607
+ : null;
608
+ }
609
+ else if (part.Repl) {
610
+ const repl = part.Repl;
611
+ const orig = repl.Orig;
612
+ const origWord = orig && orig.Parts.length > 0
613
+ ? this.translateWord(orig)
614
+ : null;
615
+ operator = origWord
616
+ ? this.#text.slice(operatorStart, origWord.start).trim()
617
+ : repl.All
618
+ ? "//"
619
+ : "/";
620
+ word = origWord;
621
+ replacement =
622
+ repl.With &&
623
+ repl.With.Parts.length > 0
624
+ ? this.translateWord(repl.With)
625
+ : null;
626
+ }
627
+ else if (part.Exp) {
628
+ const expansion = part.Exp;
629
+ const expansionWord = expansion.Word;
630
+ word = expansionWord ? this.translateWord(expansionWord) : null;
631
+ operator = this.#text
632
+ .slice(operatorStart, word ? word.start : rbrace)
633
+ .trim();
634
+ }
635
+ else if (part.Names !== 0) {
636
+ operator = this.#text.slice(operatorStart, rbrace).trim();
637
+ }
638
+ return {
639
+ type: "ParameterExpansion",
640
+ start,
641
+ end,
642
+ name: param.Value,
643
+ braced: !part.Short,
644
+ indirect: Boolean(part.Excl),
645
+ lengthOf: Boolean(part.Length),
646
+ index,
647
+ operator: operator || null,
648
+ word,
649
+ replacement,
650
+ sliceOffset,
651
+ sliceLength,
652
+ };
653
+ }
654
+ translateArithmetic(expr) {
655
+ const nodeType = syntax.NodeType(expr);
656
+ const start = this.#start(expr);
657
+ const end = this.#end(expr);
658
+ switch (nodeType) {
659
+ case "BinaryArithm":
660
+ return {
661
+ type: "BinaryArithmetic",
662
+ start,
663
+ end,
664
+ operator: this.#operatorBetween(expr.X, expr.Y),
665
+ left: this.translateArithmetic(expr.X),
666
+ right: this.translateArithmetic(expr.Y),
667
+ };
668
+ case "UnaryArithm": {
669
+ const opPos = expr.OpPos;
670
+ return {
671
+ type: "UnaryArithmetic",
672
+ start,
673
+ end,
674
+ operator: this.#operatorAt(this.#toChar(opPos.Offset()), UNARY_ARITHMETIC_OPERATOR) ?? "+",
675
+ prefix: !expr.Post,
676
+ argument: this.translateArithmetic(expr.X),
677
+ };
678
+ }
679
+ case "ParenArithm":
680
+ return {
681
+ type: "ParenthesizedArithmetic",
682
+ start,
683
+ end,
684
+ expression: this.translateArithmetic(expr.X),
685
+ };
686
+ default:
687
+ return this.translateWord(expr);
688
+ }
689
+ }
690
+ translateTest(expr) {
691
+ const nodeType = syntax.NodeType(expr);
692
+ const start = this.#start(expr);
693
+ const end = this.#end(expr);
694
+ switch (nodeType) {
695
+ case "BinaryTest":
696
+ return {
697
+ type: "BinaryTest",
698
+ start,
699
+ end,
700
+ operator: this.#operatorBetween(expr.X, expr.Y),
701
+ left: this.translateTest(expr.X),
702
+ right: this.translateTest(expr.Y),
703
+ };
704
+ case "UnaryTest": {
705
+ const opPos = expr.OpPos;
706
+ const opStart = this.#toChar(opPos.Offset());
707
+ const match = /^\S+/u.exec(this.#text.slice(opStart));
708
+ return {
709
+ type: "UnaryTest",
710
+ start,
711
+ end,
712
+ operator: match ? match[0] : "!",
713
+ argument: this.translateTest(expr.X),
714
+ };
715
+ }
716
+ case "ParenTest":
717
+ return {
718
+ type: "ParenthesizedTest",
719
+ start,
720
+ end,
721
+ expression: this.translateTest(expr.X),
722
+ };
723
+ default:
724
+ return this.translateWord(expr);
725
+ }
726
+ }
727
+ }
728
+ //------------------------------------------------------------------------------
729
+ // Public API
730
+ //------------------------------------------------------------------------------
731
+ /**
732
+ * Parses shell source code into an ESTree-style syntax tree.
733
+ * @throws {ShellSyntaxError} When the source code contains a syntax error.
734
+ */
735
+ export function parseShell(text, options = {}) {
736
+ const variant = VARIANTS.get(options.variant ?? "bash");
737
+ if (variant === undefined) {
738
+ throw new TypeError(`Unknown shell variant "${options.variant}". Expected "bash", "posix", or "mksh".`);
739
+ }
740
+ const parser = syntax.NewParser(syntax.KeepComments(true), syntax.Variant(variant));
741
+ let file;
742
+ try {
743
+ file = parser.Parse(text, options.path ?? "<input>");
744
+ }
745
+ catch (error) {
746
+ const parseError = error;
747
+ if (typeof parseError?.Error === "function") {
748
+ const byteOffset = parseError.Pos?.Offset() ?? 0;
749
+ const offset = createByteToCharConverter(text)(byteOffset);
750
+ const { line, column } = locate(text, offset);
751
+ throw new ShellSyntaxError(parseError.Text || parseError.Error(), line, column, options.path);
752
+ }
753
+ throw error;
754
+ }
755
+ const translator = new Translator(text);
756
+ const ast = translator.translateProgram(file);
757
+ return { ast, comments: ast.comments };
758
+ }