bash.ts
15481 bytes
1import { createRequire } from "node:module";
2import { Language, Parser, type Node } from "web-tree-sitter";
3
4export type BashWord = {
5 text: string;
6 glob: string | undefined;
7 unresolved: boolean;
8};
9
10export type BashRedirect = {
11 operator: string;
12 destination?: BashWord;
13};
14
15export type BashCommand = {
16 source: string;
17 words: BashWord[];
18 assignments: BashWord[];
19 redirects: BashRedirect[];
20};
21
22export type BashNode =
23 | { kind: "command"; command: BashCommand }
24 | { kind: "sequence"; operator: ";" | "&&" | "||" | "|"; left: BashNode; right: BashNode }
25 | { kind: "scope"; body: BashNode }
26 | { kind: "unsupported"; source: string };
27
28export type BashParseResult = {
29 parsed: boolean;
30 parserUnavailable?: boolean;
31 root?: BashNode;
32};
33
34type BashTree = {
35 rootNode: Node;
36 delete(): void;
37};
38
39export type BashParser = {
40 parse(command: string): BashTree | null;
41};
42
43export type BashParserLoader = () => Promise<BashParser>;
44
45const require = createRequire(import.meta.url);
46let parserPromise: Promise<BashParser> | undefined;
47
48const STATEMENT_TYPES = new Set([
49 "case_statement",
50 "c_style_for_statement",
51 "command",
52 "compound_statement",
53 "declaration_command",
54 "for_statement",
55 "function_definition",
56 "if_statement",
57 "list",
58 "negated_command",
59 "pipeline",
60 "redirected_statement",
61 "subshell",
62 "test_command",
63 "unset_command",
64 "variable_assignment",
65 "while_statement",
66]);
67
68const GLOB_CHARACTERS = new Set(["*", "?", "["]);
69const GLOB_ESCAPE_CHARACTERS = new Set(["*", "?", "[", "]", "\\"]);
70
71type DecodedWord = {
72 text: string;
73 pattern: string;
74 activeGlob: boolean;
75 unresolved: boolean;
76};
77
78type DecodeContext = {
79 tildeEligible: boolean;
80};
81
82function emptyWord(): DecodedWord {
83 return { text: "", pattern: "", activeGlob: false, unresolved: false };
84}
85
86function appendWord(target: DecodedWord, part: DecodedWord): void {
87 target.text += part.text;
88 target.pattern += part.pattern;
89 target.activeGlob ||= part.activeGlob;
90 target.unresolved ||= part.unresolved;
91}
92
93function literalPattern(text: string): string {
94 let pattern = "";
95 for (const character of text) {
96 pattern += GLOB_ESCAPE_CHARACTERS.has(character) ? `\\${character}` : character;
97 }
98 return pattern;
99}
100
101function decodeDoubleQuotedContent(source: string): DecodedWord {
102 const result = emptyWord();
103 for (let index = 0; index < source.length; index += 1) {
104 const character = source[index];
105 if (character === "\\" && index + 1 < source.length) {
106 const next = source[index + 1];
107 if (["\\", '"', "$", "`", "\n"].includes(next)) {
108 if (next !== "\n") {
109 result.text += next;
110 result.pattern += literalPattern(next);
111 }
112 index += 1;
113 continue;
114 }
115 }
116 result.text += character;
117 result.pattern += literalPattern(character);
118 }
119 return result;
120}
121
122function decodeAnsiCString(source: string): DecodedWord {
123 const result = emptyWord();
124 const content = source.startsWith("$'") && source.endsWith("'") ? source.slice(2, -1) : source;
125 const escapes: Record<string, string> = {
126 a: "\u0007",
127 b: "\b",
128 e: "\u001b",
129 f: "\f",
130 n: "\n",
131 r: "\r",
132 t: "\t",
133 v: "\v",
134 "\\": "\\",
135 "'": "'",
136 '"': '"',
137 };
138 for (let index = 0; index < content.length; index += 1) {
139 if (content[index] !== "\\" || index + 1 >= content.length) {
140 result.text += content[index];
141 result.pattern += literalPattern(content[index]);
142 continue;
143 }
144 const next = content[index + 1];
145 const escaped = escapes[next];
146 if (escaped !== undefined) {
147 result.text += escaped;
148 result.pattern += literalPattern(escaped);
149 index += 1;
150 continue;
151 }
152 result.text += next;
153 result.pattern += literalPattern(next);
154 result.unresolved = true;
155 index += 1;
156 }
157 return result;
158}
159
160function decodeBare(source: string, context: DecodeContext): DecodedWord {
161 const result = emptyWord();
162 let tildeEligible = context.tildeEligible;
163 for (let index = 0; index < source.length; index += 1) {
164 const character = source[index];
165 if (character === "\\" && index + 1 < source.length) {
166 const next = source[index + 1];
167 if (next !== "\n") {
168 result.text += next;
169 result.pattern += literalPattern(next);
170 }
171 index += 1;
172 tildeEligible = false;
173 continue;
174 }
175 if (character === "~" && tildeEligible) {
176 const next = source[index + 1];
177 if (next === undefined || next === "/") {
178 const home = process.env.HOME;
179 if (home === undefined) {
180 result.text += "~";
181 result.pattern += "~";
182 result.unresolved = true;
183 } else {
184 result.text += home;
185 result.pattern += literalPattern(home);
186 }
187 tildeEligible = false;
188 continue;
189 }
190 result.text += "~";
191 result.pattern += "~";
192 result.unresolved = true;
193 tildeEligible = false;
194 continue;
195 }
196 if (GLOB_CHARACTERS.has(character)) {
197 result.text += character;
198 result.pattern += character;
199 result.activeGlob = true;
200 } else {
201 result.text += character;
202 result.pattern += character;
203 if (character === "{" || character === "}") result.unresolved = true;
204 }
205 tildeEligible = false;
206 }
207 return result;
208}
209
210function unresolvedExpansion(source: string): DecodedWord {
211 const result = emptyWord();
212 result.text = source;
213 result.pattern = literalPattern(source);
214 result.unresolved = true;
215 return result;
216}
217
218function expansionValue(node: Node): DecodedWord {
219 const source = node.text;
220 if (source === "$HOME" || source === "${HOME}") {
221 const home = process.env.HOME;
222 if (home !== undefined && !/[\s*?[]/.test(home))
223 return { text: home, pattern: literalPattern(home), activeGlob: false, unresolved: false };
224 }
225 return unresolvedExpansion(source);
226}
227
228function decodeNode(node: Node, context: DecodeContext = { tildeEligible: true }): DecodedWord {
229 switch (node.type) {
230 case "word":
231 case "number":
232 case "file_descriptor":
233 return decodeBare(node.text, context);
234 case "raw_string": {
235 const content = node.text.startsWith("'") && node.text.endsWith("'") ? node.text.slice(1, -1) : node.text;
236 return { text: content, pattern: literalPattern(content), activeGlob: false, unresolved: false };
237 }
238 case "string": {
239 const result = emptyWord();
240 for (const child of node.children) {
241 if (child.type === '"') continue;
242 if (child.type === "string_content" || child.type === "$")
243 appendWord(result, decodeDoubleQuotedContent(child.text));
244 else if (["simple_expansion", "expansion"].includes(child.type)) appendWord(result, expansionValue(child));
245 else if (child.type === "command_substitution" || child.type === "arithmetic_expansion")
246 appendWord(result, unresolvedExpansion(child.text));
247 else appendWord(result, unresolvedExpansion(child.text));
248 }
249 return result;
250 }
251 case "ansi_c_string":
252 return decodeAnsiCString(node.text);
253 case "translated_string":
254 return unresolvedExpansion(node.text);
255 case "simple_expansion":
256 case "expansion":
257 case "command_substitution":
258 case "process_substitution":
259 case "arithmetic_expansion":
260 return expansionValue(node);
261 case "brace_expression":
262 return unresolvedExpansion(node.text);
263 case "concatenation": {
264 if (node.text.startsWith("~") && !node.text.startsWith("~/")) return unresolvedExpansion(node.text);
265 const result = emptyWord();
266 let tildeEligible = context.tildeEligible;
267 for (const child of node.namedChildren) {
268 const part = decodeNode(child, { tildeEligible });
269 appendWord(result, part);
270 tildeEligible = false;
271 }
272 return result;
273 }
274 case "variable_assignment": {
275 const value = node.childForFieldName("value");
276 if (!value) return unresolvedExpansion(node.text);
277 const result = emptyWord();
278 const valueOffset = value.startIndex - node.startIndex;
279 const prefix = node.text.slice(0, valueOffset);
280 appendWord(result, decodeBare(prefix, { tildeEligible: true }));
281 appendWord(result, decodeNode(value, { tildeEligible: true }));
282 return result;
283 }
284 case "command_name": {
285 const child = node.firstNamedChild;
286 return child ? decodeNode(child, context) : unresolvedExpansion(node.text);
287 }
288 default:
289 return unresolvedExpansion(node.text);
290 }
291}
292
293function bashWord(node: Node, context?: DecodeContext): BashWord {
294 const decoded = decodeNode(node, context);
295 return {
296 text: decoded.text,
297 glob: decoded.activeGlob ? decoded.pattern : undefined,
298 unresolved: decoded.unresolved,
299 };
300}
301
302function redirectOperator(node: Node): string {
303 const descriptor = node.childForFieldName("descriptor")?.text ?? "";
304 const source = node.text.slice(descriptor.length);
305 const match = source.match(/^(<<<|<<-|<<|&>>|&>|>>|>\||<&-|>&-|<&|>&|<>|>|<)/);
306 return `${descriptor}${match?.[1] ?? source}`;
307}
308
309function parseRedirect(node: Node): BashRedirect | undefined {
310 if (node.type !== "file_redirect") return undefined;
311 const destinationNodes = node.childrenForFieldName("destination");
312 if (destinationNodes.length !== 1) return undefined;
313 const destination = bashWord(destinationNodes[0]);
314 return { operator: redirectOperator(node), ...(destination ? { destination } : {}) };
315}
316
317function unsupported(source: string): BashNode {
318 return { kind: "unsupported", source: source.trim() };
319}
320
321function commandFromNode(node: Node): BashNode {
322 const name = node.childForFieldName("name");
323 if (!name) return unsupported(node.text);
324 const words = [bashWord(name)];
325 for (const argument of node.childrenForFieldName("argument")) words.push(bashWord(argument));
326 const assignments: BashWord[] = [];
327 for (const child of node.children) {
328 if (child.type === "variable_assignment") assignments.push(bashWord(child));
329 if (child.type === "subshell" || child.type === "process_substitution") return unsupported(node.text);
330 }
331 const redirects: BashRedirect[] = [];
332 for (const redirect of node.childrenForFieldName("redirect")) {
333 const parsed = parseRedirect(redirect);
334 if (!parsed) return unsupported(node.text);
335 redirects.push(parsed);
336 }
337 return {
338 kind: "command",
339 command: {
340 source: node.text.trim(),
341 words,
342 assignments,
343 redirects,
344 },
345 };
346}
347
348function withRedirects(node: BashNode, redirects: readonly Node[], source: string): BashNode {
349 const parsed = redirects.map(parseRedirect);
350 if (parsed.some((redirect): redirect is undefined => redirect === undefined)) return unsupported(source);
351 const additions = parsed.filter((redirect): redirect is BashRedirect => redirect !== undefined);
352 if (node.kind === "command") {
353 return {
354 kind: "command",
355 command: {
356 ...node.command,
357 source: source.trim(),
358 redirects: [...node.command.redirects, ...additions],
359 },
360 };
361 }
362 if (node.kind === "sequence") {
363 return {
364 ...node,
365 left: withRedirects(node.left, redirects, source),
366 right: withRedirects(node.right, redirects, source),
367 };
368 }
369 if (node.kind === "scope") return { ...node, body: withRedirects(node.body, redirects, source) };
370 return unsupported(source);
371}
372
373function connectorBetween(parent: Node, left: Node, right: Node): string | undefined {
374 const token = parent.children.find(
375 (child) => !child.isNamed && child.startIndex >= left.endIndex && child.endIndex <= right.startIndex,
376 );
377 if (token) return token.text;
378 const gap = parent.tree.rootNode.text.slice(left.endIndex, right.startIndex);
379 if (/\n/.test(gap)) return ";";
380 if (!gap.trim()) return ";";
381 return undefined;
382}
383
384function sequence(
385 parent: Node,
386 statements: readonly Node[],
387 allowedOperators: ReadonlySet<string> = new Set([";", "&&", "||", "|"]),
388): BashNode | undefined {
389 if (!statements.length) return undefined;
390 let result = parseNode(statements[0]);
391 for (let index = 1; index < statements.length; index += 1) {
392 const right = parseNode(statements[index]);
393 const operator = connectorBetween(parent, statements[index - 1], statements[index]);
394 if (!operator || !allowedOperators.has(operator)) return unsupported(parent.text);
395 result = {
396 kind: "sequence",
397 operator: operator as ";" | "&&" | "||" | "|",
398 left: result,
399 right,
400 };
401 }
402 return result;
403}
404
405function directStatements(node: Node): Node[] {
406 return node.children.filter((child) => child.isNamed && (STATEMENT_TYPES.has(child.type) || child.type === "ERROR"));
407}
408
409function parseNode(node: Node): BashNode {
410 if (node.children.some((child) => child.type === "&")) return unsupported(node.text);
411 if (
412 node.namedChildren.some((child) => child.type !== "comment" && !STATEMENT_TYPES.has(child.type)) &&
413 ["program", "subshell", "list", "pipeline"].includes(node.type)
414 )
415 return unsupported(node.text);
416 switch (node.type) {
417 case "program": {
418 const statements = directStatements(node);
419 return sequence(node, statements) ?? unsupported(node.text);
420 }
421 case "command":
422 return commandFromNode(node);
423 case "redirected_statement": {
424 const body = node.childForFieldName("body");
425 const redirects = node.childrenForFieldName("redirect");
426 if (!body || redirects.some((redirect) => redirect.type !== "file_redirect")) return unsupported(node.text);
427 return withRedirects(parseNode(body), redirects, node.text);
428 }
429 case "list": {
430 const statements = directStatements(node);
431 return sequence(node, statements, new Set(["&&", "||"])) ?? unsupported(node.text);
432 }
433 case "pipeline": {
434 const statements = directStatements(node);
435 return sequence(node, statements, new Set(["|"])) ?? unsupported(node.text);
436 }
437 case "subshell": {
438 const body = sequence(node, directStatements(node));
439 return body ? { kind: "scope", body } : unsupported(node.text);
440 }
441 default:
442 return unsupported(node.text);
443 }
444}
445
446async function loadBashParser(): Promise<BashParser> {
447 if (!parserPromise) {
448 parserPromise = (async () => {
449 const parserWasm = require.resolve("web-tree-sitter/web-tree-sitter.wasm");
450 const bashWasm = require.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
451 await Parser.init({ locateFile: () => parserWasm });
452 const language = await Language.load(bashWasm);
453 const parser = new Parser();
454 parser.setLanguage(language);
455 return parser;
456 })();
457 }
458 return parserPromise;
459}
460
461export async function parseBash(
462 command: string,
463 loadParser: BashParserLoader = loadBashParser,
464): Promise<BashParseResult> {
465 let parser: BashParser;
466 try {
467 parser = await loadParser();
468 } catch {
469 return { parsed: false, parserUnavailable: true };
470 }
471
472 let tree: BashTree | null;
473 try {
474 tree = parser.parse(command);
475 } catch {
476 return { parsed: false };
477 }
478 if (!tree) return { parsed: false };
479 try {
480 if (tree.rootNode.hasError) return { parsed: false };
481 if (command.includes("\\\n")) return { parsed: true, root: unsupported(command) };
482 const root = parseNode(tree.rootNode);
483 if (!command.trim()) return { parsed: true };
484 return { parsed: true, root };
485 } catch {
486 return { parsed: false };
487 } finally {
488 tree.delete();
489 }
490}