Parent directory

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}