@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.
- package/LICENSE +201 -0
- package/README.md +167 -2
- package/dist/index.d.ts +79 -0
- package/dist/index.js +74 -0
- package/dist/languages/shell-language.d.ts +36 -0
- package/dist/languages/shell-language.js +72 -0
- package/dist/languages/shell-source-code.d.ts +55 -0
- package/dist/languages/shell-source-code.js +184 -0
- package/dist/parser/parse.d.ts +32 -0
- package/dist/parser/parse.js +758 -0
- package/dist/rules/no-backticks.d.ts +8 -0
- package/dist/rules/no-backticks.js +46 -0
- package/dist/rules/no-expansions-in-single-quotes.d.ts +8 -0
- package/dist/rules/no-expansions-in-single-quotes.js +42 -0
- package/dist/rules/no-ls-iteration.d.ts +8 -0
- package/dist/rules/no-ls-iteration.js +42 -0
- package/dist/rules/no-unquoted-expansions.d.ts +9 -0
- package/dist/rules/no-unquoted-expansions.js +91 -0
- package/dist/rules/no-unused-vars.d.ts +11 -0
- package/dist/rules/no-unused-vars.js +382 -0
- package/dist/rules/no-useless-cat.d.ts +8 -0
- package/dist/rules/no-useless-cat.js +76 -0
- package/dist/rules/no-useless-echo.d.ts +8 -0
- package/dist/rules/no-useless-echo.js +42 -0
- package/dist/rules/no-variables-in-printf-format.d.ts +8 -0
- package/dist/rules/no-variables-in-printf-format.js +60 -0
- package/dist/rules/require-cd-guard.d.ts +8 -0
- package/dist/rules/require-cd-guard.js +74 -0
- package/dist/rules/require-read-r.d.ts +8 -0
- package/dist/rules/require-read-r.js +83 -0
- package/dist/rules/utils.d.ts +35 -0
- package/dist/rules/utils.js +100 -0
- package/dist/types.d.ts +343 -0
- package/dist/types.js +7 -0
- package/dist/visitor-keys.d.ts +5 -0
- package/dist/visitor-keys.js +59 -0
- 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
|
+
}
|