diff --git a/README.md b/README.md index 3b9d967..5ab68d0 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,20 @@ npm install --save-dev eslint @eslint/shell Requires Node.js `^20.19.0 || ^22.13.0 || >=24`. Tested with ESLint v10. +## Syntax tree + +The tree format is documented in [docs/syntax-tree.md](docs/syntax-tree.md). +Nodes carry `start`/`end` character offsets instead of `range`/`loc` +properties; use `sourceCode.getRange(node)` and `sourceCode.getLoc(node)`. + +The parser is also exported directly: + +```js +import { parseShell } from "@eslint/shell"; + +const { ast, comments } = parseShell('echo "hello"\n'); +``` + ## Development ```bash diff --git a/docs/syntax-tree.md b/docs/syntax-tree.md new file mode 100644 index 0000000..6912bb6 --- /dev/null +++ b/docs/syntax-tree.md @@ -0,0 +1,523 @@ +# Shell Syntax Tree Format + +`@eslint/shell` parses shell source code into an ESTree-style syntax tree by +wrapping the [mvdan-sh](https://www.npmjs.com/package/mvdan-sh) parser (a +JavaScript build of [mvdan.cc/sh](https://github.com/mvdan/sh), the parser +behind `shfmt`) and translating its output. + +## Positions + +Every node has three common properties: + +```ts +interface ShellNodeBase { + type: string; + start: number; // 0-based character offset, inclusive + end: number; // 0-based character offset, exclusive +} +``` + +Nodes intentionally do **not** carry `range` or `loc` properties. To get +position information, use the `SourceCode` methods: + +```js +const range = sourceCode.getRange(node); // [start, end] +const loc = sourceCode.getLoc(node); // { start: { line, column }, end: ... } +``` + +Lines and columns are 1-based. Offsets are JavaScript character offsets +(mvdan-sh's UTF-8 byte offsets are converted during translation). + +### Heredoc bodies + +Nodes are otherwise nested the way ESTree nodes are: a child's range sits +inside its parent's. Heredocs break that rule, because the shell splits the +construct in two. The `< out.txt < out.txt` | `[0, 19]` | `[20, 29]` | **no** | +| `cat < out.txt`, + `redirects[0]` carries a body at `[20, 29]` while `redirects[1]` + (`> out.txt`) sits at `[10, 19]`, so walking redirects in order yields + ranges that move backwards. +4. **Fixes need care.** A fix that rewrites a statement's range may + silently leave the body untouched, and a fix that has to change both + produces two disjoint ranges, which ESLint will not accept as a single + fix. + +## Statements + +Every node usable in statement position also carries: + +```ts +interface StatementBase extends ShellNodeBase { + redirects: Redirect[]; // e.g. `> file`, `2>&1`, heredocs + negated: boolean; // `! cmd` + background: boolean; // `cmd &` +} +``` + +### Program + +The root node. `comments` contains every comment in the file in source +order; comments are not part of the traversed tree. + +```ts +interface Program extends ShellNodeBase { + type: "Program"; + body: Statement[]; + comments: Comment[]; +} + +interface Comment extends ShellNodeBase { + type: "Comment"; + text: string; // text after `#` +} +``` + +### Command + +A simple command: optional environment assignments, a name, and arguments. +`name` is `null` for assignment-only statements (`x=1`) and redirect-only +statements (`> file`). + +```ts +interface Command extends StatementBase { + type: "Command"; + assignments: VariableAssignment[]; // `FOO=1 cmd` + name: Word | null; + arguments: Word[]; +} +``` + +```bash +FOO=1 make -j4 all 2> err.log +``` + +### Pipeline + +Pipelines are flattened: `a | b |& c` produces one `Pipeline` with three +commands. `operators[i]` sits between `commands[i]` and `commands[i + 1]`. + +```ts +interface Pipeline extends StatementBase { + type: "Pipeline"; + commands: Statement[]; + operators: ("|" | "|&")[]; +} +``` + +### LogicalExpression + +`&&` and `||` chains. Left-associative, so `a && b || c` is +`(a && b) || c`. + +```ts +interface LogicalExpression extends StatementBase { + type: "LogicalExpression"; + operator: "&&" | "||"; + left: Statement; + right: Statement; +} +``` + +### Subshell and BlockStatement + +```ts +interface Subshell extends StatementBase { + type: "Subshell"; // ( ... ) + body: Statement[]; +} + +interface BlockStatement extends StatementBase { + type: "BlockStatement"; // { ...; } + body: Statement[]; +} +``` + +### IfStatement and ElseClause + +`elif` chains become nested `IfStatement`s in `alternate`; a plain `else` +becomes an `ElseClause`. + +```ts +interface IfStatement extends StatementBase { + type: "IfStatement"; + test: Statement[]; + consequent: Statement[]; + alternate: IfStatement | ElseClause | null; +} + +interface ElseClause extends ShellNodeBase { + type: "ElseClause"; + body: Statement[]; +} +``` + +### Loops + +```ts +interface WhileStatement extends StatementBase { + type: "WhileStatement"; + test: Statement[]; + body: Statement[]; +} + +interface UntilStatement extends StatementBase { + type: "UntilStatement"; + test: Statement[]; + body: Statement[]; +} + +// `for name in words` and `select name in words` +interface ForStatement extends StatementBase { + type: "ForStatement"; + variable: Identifier | null; + words: Word[]; // empty for `for name; do` + body: Statement[]; + select: boolean; // true for `select` +} + +// `for ((init; test; update))` +interface ArithmeticForStatement extends StatementBase { + type: "ArithmeticForStatement"; + init: ArithmeticExpression | null; + test: ArithmeticExpression | null; + update: ArithmeticExpression | null; + body: Statement[]; +} +``` + +### CaseStatement and CaseClause + +```ts +interface CaseStatement extends StatementBase { + type: "CaseStatement"; + discriminant: Word; + cases: CaseClause[]; +} + +interface CaseClause extends ShellNodeBase { + type: "CaseClause"; + patterns: Word[]; + body: Statement[]; + terminator: string | null; // ";;", ";&", ";;&", ";|" (mksh), or null before `esac` +} +``` + +### FunctionDeclaration + +Both `name() { ...; }` and `function name { ...; }` forms. + +```ts +interface FunctionDeclaration extends StatementBase { + type: "FunctionDeclaration"; + id: Identifier; + body: Statement; // usually a BlockStatement +} +``` + +### TestCommand + +The `[[ ... ]]` conditional. (Plain `[ ... ]` is an ordinary `Command` +named `[`.) + +```ts +interface TestCommand extends StatementBase { + type: "TestCommand"; + expression: TestExpression; +} + +interface BinaryTest extends ShellNodeBase { + type: "BinaryTest"; + operator: string; // "==", "!=", "=~", "-eq", "&&", ... + left: TestExpression; + right: TestExpression; +} + +interface UnaryTest extends ShellNodeBase { + type: "UnaryTest"; + operator: string; // "-f", "-n", "!", ... + argument: TestExpression; +} + +interface ParenthesizedTest extends ShellNodeBase { + type: "ParenthesizedTest"; + expression: TestExpression; +} + +type TestExpression = BinaryTest | UnaryTest | ParenthesizedTest | Word; +``` + +### Arithmetic commands and expressions + +```ts +interface ArithmeticCommand extends StatementBase { + type: "ArithmeticCommand"; // (( ... )) + expression: ArithmeticExpression | null; +} + +interface LetCommand extends StatementBase { + type: "LetCommand"; // let x=1+2 + expressions: ArithmeticExpression[]; +} + +interface BinaryArithmetic extends ShellNodeBase { + type: "BinaryArithmetic"; + operator: string; // "+", "<=", "=", "?", ":", ... + left: ArithmeticExpression; + right: ArithmeticExpression; +} + +interface UnaryArithmetic extends ShellNodeBase { + type: "UnaryArithmetic"; + operator: string; // "++", "--", "-", "!", "~", "+" + prefix: boolean; // false for `x++` + argument: ArithmeticExpression; +} + +interface ParenthesizedArithmetic extends ShellNodeBase { + type: "ParenthesizedArithmetic"; + expression: ArithmeticExpression; +} + +type ArithmeticExpression = + BinaryArithmetic | UnaryArithmetic | ParenthesizedArithmetic | Word; // leaf values: `x`, `1`, `$y` +``` + +### Other statements + +```ts +interface DeclarationCommand extends StatementBase { + type: "DeclarationCommand"; // declare/local/export/readonly/typeset/nameref + kind: string; + arguments: (VariableAssignment | Word)[]; // flags become Words +} + +interface TimeCommand extends StatementBase { + type: "TimeCommand"; + posix: boolean; // time -p + body: Statement | null; +} + +interface CoprocCommand extends StatementBase { + type: "CoprocCommand"; + name: Word | null; + body: Statement | null; +} +``` + +## Words + +A `Word` is a sequence of parts that are concatenated after expansion. +`echo pre"mid"$x` has one word with three parts. + +```ts +interface Word extends ShellNodeBase { + type: "Word"; + parts: WordPart[]; +} + +type WordPart = + | Literal + | SingleQuotedString + | DoubleQuotedString + | ParameterExpansion + | CommandSubstitution + | ProcessSubstitution + | ArithmeticExpansion + | ExtendedGlob; +``` + +### Literal + +Unquoted text, including glob characters (`*.txt`). + +```ts +interface Literal extends ShellNodeBase { + type: "Literal"; + value: string; +} +``` + +### Quoted strings + +```ts +interface SingleQuotedString extends ShellNodeBase { + type: "SingleQuotedString"; + value: string; // text between the quotes + dollar: boolean; // true for $'...' +} + +interface DoubleQuotedString extends ShellNodeBase { + type: "DoubleQuotedString"; + parts: WordPart[]; // literals and expansions + dollar: boolean; // true for $"..." +} +``` + +### ParameterExpansion + +Covers `$name`, `${name}`, and all `${...}` operator forms. The `operator` +is the operator text exactly as written in the source. + +```ts +interface ParameterExpansion extends ShellNodeBase { + type: "ParameterExpansion"; + name: string; // "foo", "1", "@", "?", ... + braced: boolean; // ${x} vs $x + indirect: boolean; // ${!ref} + lengthOf: boolean; // ${#x} + index: ArithmeticExpression | null; // ${arr[i]} + operator: string | null; // ":-", ":=", "##", "%", "/", "//", ":", "*", ... + word: Word | null; // operand, e.g. default in ${x:-default} + replacement: Word | null; // ${x/pattern/replacement} + sliceOffset: ArithmeticExpression | null; // ${x:offset:length} + sliceLength: ArithmeticExpression | null; +} +``` + +Examples: + +| Source | Key properties | +| -------------- | --------------------------------------------------------- | +| `$x` | `name: "x"`, `braced: false` | +| `${x}` | `name: "x"`, `braced: true` | +| `${x:-def}` | `operator: ":-"`, `word` = `def` | +| `${path##*/}` | `operator: "##"`, `word` = `*/` | +| `${#arr}` | `lengthOf: true` | +| `${!ref}` | `indirect: true` | +| `${arr[1]}` | `index` = `1` | +| `${x/pat/rep}` | `operator: "/"`, `word` = `pat`, `replacement` = `rep` | +| `${x:1:2}` | `operator: ":"`, `sliceOffset` = `1`, `sliceLength` = `2` | +| `${!prefix*}` | `operator: "*"` | + +### Substitutions + +```ts +interface CommandSubstitution extends ShellNodeBase { + type: "CommandSubstitution"; + body: Statement[]; + backquotes: boolean; // `...` vs $(...) +} + +interface ProcessSubstitution extends ShellNodeBase { + type: "ProcessSubstitution"; + operator: "<(" | ">("; + body: Statement[]; +} + +interface ArithmeticExpansion extends ShellNodeBase { + type: "ArithmeticExpansion"; // $(( ... )) + expression: ArithmeticExpression | null; +} + +interface ExtendedGlob extends ShellNodeBase { + type: "ExtendedGlob"; // @(a|b), *(x), +(x), ?(x), !(x) + operator: "@(" | "*(" | "+(" | "?(" | "!("; + pattern: string; +} +``` + +## Assignments and redirects + +```ts +interface VariableAssignment extends ShellNodeBase { + type: "VariableAssignment"; + name: Identifier | null; + index: ArithmeticExpression | null; // arr[i]=x + value: Word | null; // null for `declare x` + array: ArrayExpression | null; // a=(1 2 3) + append: boolean; // x+=y +} + +interface ArrayExpression extends ShellNodeBase { + type: "ArrayExpression"; + elements: ArrayElement[]; +} + +interface ArrayElement extends ShellNodeBase { + type: "ArrayElement"; + index: ArithmeticExpression | null; // ([5]=x) + value: Word | null; +} + +interface Identifier extends ShellNodeBase { + type: "Identifier"; + name: string; +} + +interface Redirect extends ShellNodeBase { + type: "Redirect"; + operator: string; // "<", ">", ">>", "<<", "<<-", "<<<", "<&", ">&", "&>", ... + fd: number | null; // 2 in `2>err` + target: Word | null; // file, or heredoc delimiter for `<<` + heredoc: Word | null; // heredoc body for `<<` and `<<-` +} +``` + +## Traversal + +The traversal order for each node type is defined by the exported +`visitorKeys` object. Rules receive nodes by type name and may use `:exit` +suffixes: + +```js +export default { + create(context) { + return { + Command(node) {}, + "Program:exit"(node) {}, + }; + }, +}; +``` diff --git a/src/index.spec.ts b/src/index.spec.ts index 279553f..786f2fa 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it } from "vitest"; -import plugin from "./index.js"; +import plugin, { ShellSyntaxError, parseShell } from "./index.js"; describe("plugin", () => { it("should expose plugin metadata", () => { @@ -11,4 +11,9 @@ describe("plugin", () => { expect(plugin.meta.namespace).toBe("shell"); expect(plugin.meta.version).toMatch(/^\d+\.\d+\.\d+$/u); }); + + it("should export the parser", () => { + expect(parseShell("echo hi\n").ast.type).toBe("Program"); + expect(() => parseShell("if then fi\n")).toThrow(ShellSyntaxError); + }); }); diff --git a/src/index.ts b/src/index.ts index a20d4aa..6e55f77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,3 +12,5 @@ const plugin = { }; export default plugin; +export { parseShell, ShellSyntaxError } from "./parser/parse.js"; +export type * from "./types.js"; diff --git a/src/parser/mvdan-sh.d.ts b/src/parser/mvdan-sh.d.ts new file mode 100644 index 0000000..457c4c9 --- /dev/null +++ b/src/parser/mvdan-sh.d.ts @@ -0,0 +1,50 @@ +/** + * @fileoverview Minimal ambient type declarations for the `mvdan-sh` package, + * a GopherJS build of mvdan.cc/sh. Only the surface used by this package is + * declared. Struct fields are exposed as properties and positions as + * method-bearing objects. + */ + +declare module "mvdan-sh" { + interface MvdanPos { + Line(): number; + Col(): number; + Offset(): number; + } + + /** + * A node from the mvdan-sh syntax tree. Fields vary by node type + * (discoverable via `syntax.NodeType()`), so this is intentionally + * permissive. + */ + interface MvdanNode { + Pos(): MvdanPos; + End(): MvdanPos; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- GopherJS objects are untyped. + [field: string]: any; + } + + interface MvdanParser { + Parse(source: string, name: string): MvdanNode; + } + + interface MvdanSyntax { + NewParser(...options: unknown[]): MvdanParser; + NodeType(node: unknown): string; + Walk( + node: MvdanNode, + visitor: (node: MvdanNode | null) => boolean, + ): void; + KeepComments(enabled: boolean): unknown; + Variant(lang: number): unknown; + LangBash: number; + LangPOSIX: number; + LangMirBSDKorn: number; + } + + const mvdan: { + syntax: MvdanSyntax; + }; + + export default mvdan; +} diff --git a/src/parser/parse.spec.ts b/src/parser/parse.spec.ts new file mode 100644 index 0000000..0406687 --- /dev/null +++ b/src/parser/parse.spec.ts @@ -0,0 +1,856 @@ +/** + * @fileoverview Unit tests for the shell parser wrapper. + */ + +import { describe, expect, it } from "vitest"; +import { ShellSyntaxError, parseShell } from "./parse.js"; +import type { + ArithmeticForStatementNode, + BinaryTestNode, + CaseStatementNode, + CommandNode, + CommandSubstitutionNode, + CoprocCommandNode, + DeclarationCommandNode, + DoubleQuotedStringNode, + ElseClauseNode, + ForStatementNode, + FunctionDeclarationNode, + IfStatementNode, + LetCommandNode, + LogicalExpressionNode, + ParameterExpansionNode, + PipelineNode, + ProcessSubstitutionNode, + RedirectNode, + SingleQuotedStringNode, + StatementNode, + SubshellNode, + TestCommandNode, + TimeCommandNode, + UntilStatementNode, + WhileStatementNode, + WordNode, +} from "../types.js"; + +/** Convenience helper returning the first statement of a parse. */ +function first(text: string): T { + return parseShell(text).ast.body[0] as T; +} + +/** Returns the nth argument word of a simple command. */ +function arg(command: CommandNode, index: number): WordNode { + const word = command.arguments[index]; + + if (!word) { + throw new Error(`Missing argument ${index}`); + } + + return word; +} + +describe("parseShell", () => { + describe("programs", () => { + it("should return a Program covering the whole text", () => { + const text = "echo hello\necho world\n"; + const { ast } = parseShell(text); + + expect(ast.type).toBe("Program"); + expect(ast.start).toBe(0); + expect(ast.end).toBe(text.length); + expect(ast.body).toHaveLength(2); + }); + + it("should parse an empty program", () => { + const { ast } = parseShell(""); + + expect(ast.body).toHaveLength(0); + expect(ast.comments).toHaveLength(0); + }); + + it("should not attach range or loc properties to nodes", () => { + const command = first("echo hi\n"); + + expect(command).not.toHaveProperty("range"); + expect(command).not.toHaveProperty("loc"); + expect(typeof command.start).toBe("number"); + expect(typeof command.end).toBe("number"); + }); + }); + + describe("simple commands", () => { + it("should parse a command with name and arguments", () => { + const text = "echo one two\n"; + const command = first(text); + + expect(command.type).toBe("Command"); + expect(command.name?.parts[0]).toMatchObject({ + type: "Literal", + value: "echo", + }); + expect(command.arguments).toHaveLength(2); + expect(text.slice(command.start, command.end)).toBe("echo one two"); + }); + + it("should parse assignments before the command name", () => { + const command = first("FOO=bar BAZ=qux env\n"); + + expect(command.assignments).toHaveLength(2); + expect(command.assignments[0]?.name?.name).toBe("FOO"); + expect(command.assignments[1]?.name?.name).toBe("BAZ"); + expect(command.name?.parts[0]).toMatchObject({ value: "env" }); + }); + + it("should parse a standalone assignment with no name", () => { + const command = first("x=1\n"); + + expect(command.name).toBeNull(); + expect(command.assignments).toHaveLength(1); + expect(command.assignments[0]?.value?.parts[0]).toMatchObject({ + type: "Literal", + value: "1", + }); + }); + + it("should parse append assignments", () => { + const command = first("x+=more\n"); + + expect(command.assignments[0]?.append).toBe(true); + }); + + it("should parse array assignments", () => { + const command = first("a=(1 2 [5]=x)\n"); + const array = command.assignments[0]?.array; + + expect(array?.type).toBe("ArrayExpression"); + expect(array?.elements).toHaveLength(3); + expect(array?.elements[0]?.index).toBeNull(); + expect(array?.elements[2]?.index).not.toBeNull(); + }); + + it("should parse a redirect-only statement", () => { + const command = first("> file\n"); + + expect(command.type).toBe("Command"); + expect(command.name).toBeNull(); + expect(command.redirects).toHaveLength(1); + }); + + it("should mark background statements", () => { + const command = first("sleep 5 &\n"); + + expect(command.background).toBe(true); + }); + + it("should mark negated statements", () => { + const command = first("! grep -q x file\n"); + + expect(command.negated).toBe(true); + }); + }); + + describe("pipelines and logical expressions", () => { + it("should flatten pipelines into a command list", () => { + const pipeline = first("a | b |& c\n"); + + expect(pipeline.type).toBe("Pipeline"); + expect(pipeline.commands).toHaveLength(3); + expect(pipeline.operators).toEqual(["|", "|&"]); + }); + + it("should parse logical AND/OR expressions", () => { + const logical = first("a && b || c\n"); + + expect(logical.type).toBe("LogicalExpression"); + expect(logical.operator).toBe("||"); + expect(logical.left.type).toBe("LogicalExpression"); + expect((logical.left as LogicalExpressionNode).operator).toBe("&&"); + }); + + it("should nest pipelines inside logical expressions", () => { + const logical = first("a | b && c\n"); + + expect(logical.operator).toBe("&&"); + expect(logical.left.type).toBe("Pipeline"); + }); + }); + + describe("control flow", () => { + it("should parse if/elif/else chains", () => { + const node = first( + "if a; then b; elif c; then d; else e; fi\n", + ); + + expect(node.type).toBe("IfStatement"); + expect(node.test).toHaveLength(1); + expect(node.consequent).toHaveLength(1); + + const elif = node.alternate as IfStatementNode; + + expect(elif.type).toBe("IfStatement"); + expect(elif.test).toHaveLength(1); + + const elseClause = elif.alternate as ElseClauseNode; + + expect(elseClause.type).toBe("ElseClause"); + expect(elseClause.body).toHaveLength(1); + }); + + it("should parse while loops", () => { + const node = first( + "while true; do work; done\n", + ); + + expect(node.type).toBe("WhileStatement"); + expect(node.test).toHaveLength(1); + expect(node.body).toHaveLength(1); + }); + + it("should parse until loops", () => { + const node = first( + "until false; do break; done\n", + ); + + expect(node.type).toBe("UntilStatement"); + }); + + it("should parse for-in loops", () => { + const node = first( + 'for f in a b; do echo "$f"; done\n', + ); + + expect(node.type).toBe("ForStatement"); + expect(node.variable?.name).toBe("f"); + expect(node.words).toHaveLength(2); + expect(node.select).toBe(false); + }); + + it("should parse select loops", () => { + const node = first( + "select s in a b; do break; done\n", + ); + + expect(node.type).toBe("ForStatement"); + expect(node.select).toBe(true); + }); + + it("should parse C-style for loops", () => { + const node = first( + "for ((i=0; i<10; i++)); do echo; done\n", + ); + + expect(node.type).toBe("ArithmeticForStatement"); + expect(node.init?.type).toBe("BinaryArithmetic"); + expect(node.test?.type).toBe("BinaryArithmetic"); + expect(node.update?.type).toBe("UnaryArithmetic"); + expect(node.body).toHaveLength(1); + }); + + it("should parse case statements with terminators", () => { + const node = first( + "case $x in a|b) one;; c) two ;&\nd) three\nesac\n", + ); + + expect(node.type).toBe("CaseStatement"); + expect(node.cases).toHaveLength(3); + expect(node.cases[0]?.patterns).toHaveLength(2); + expect(node.cases[0]?.terminator).toBe(";;"); + expect(node.cases[1]?.terminator).toBe(";&"); + expect(node.cases[2]?.terminator).toBeNull(); + }); + + it("should parse subshells and blocks", () => { + const subshell = first("( echo hi )\n"); + + expect(subshell.type).toBe("Subshell"); + expect(subshell.body).toHaveLength(1); + + const block = first("{ echo hi; }\n"); + + expect(block.type).toBe("BlockStatement"); + }); + }); + + describe("functions", () => { + it("should parse POSIX-style function declarations", () => { + const node = first( + "greet() { echo hi; }\n", + ); + + expect(node.type).toBe("FunctionDeclaration"); + expect(node.id.name).toBe("greet"); + expect(node.body.type).toBe("BlockStatement"); + }); + + it("should parse function-keyword declarations", () => { + const node = first( + "function greet { echo hi; }\n", + ); + + expect(node.type).toBe("FunctionDeclaration"); + expect(node.id.name).toBe("greet"); + }); + }); + + describe("words and quoting", () => { + it("should parse single-quoted strings", () => { + const command = first("echo 'lit $x'\n"); + const part = arg(command, 0).parts[0] as SingleQuotedStringNode; + + expect(part.type).toBe("SingleQuotedString"); + expect(part.value).toBe("lit $x"); + expect(part.dollar).toBe(false); + }); + + it("should parse dollar single quotes", () => { + const command = first("echo $'a\\n'\n"); + const part = arg(command, 0).parts[0] as SingleQuotedStringNode; + + expect(part.dollar).toBe(true); + }); + + it("should parse double-quoted strings with embedded expansions", () => { + const command = first('echo "hi $name"\n'); + const part = arg(command, 0).parts[0] as DoubleQuotedStringNode; + + expect(part.type).toBe("DoubleQuotedString"); + expect(part.parts).toHaveLength(2); + expect(part.parts[0]?.type).toBe("Literal"); + expect(part.parts[1]?.type).toBe("ParameterExpansion"); + }); + + it("should parse extended globs", () => { + const command = first("ls @(a|b)\n"); + const part = arg(command, 0).parts[0]; + + expect(part?.type).toBe("ExtendedGlob"); + expect(part).toMatchObject({ operator: "@(", pattern: "a|b" }); + }); + }); + + describe("parameter expansions", () => { + function expansion(text: string): ParameterExpansionNode { + const command = first(text); + + return arg(command, 0).parts[0] as ParameterExpansionNode; + } + + it("should parse unbraced expansions", () => { + const node = expansion("echo $var\n"); + + expect(node.type).toBe("ParameterExpansion"); + expect(node.name).toBe("var"); + expect(node.braced).toBe(false); + expect(node.operator).toBeNull(); + }); + + it("should parse braced expansions", () => { + const node = expansion("echo ${var}\n"); + + expect(node.braced).toBe(true); + }); + + it("should parse default-value operators", () => { + const node = expansion("echo ${x:-fallback}\n"); + + expect(node.operator).toBe(":-"); + expect(node.word?.parts[0]).toMatchObject({ + value: "fallback", + }); + }); + + it("should parse pattern-removal operators", () => { + const node = expansion("echo ${path##*/}\n"); + + expect(node.operator).toBe("##"); + expect(node.word).not.toBeNull(); + }); + + it("should parse length-of expansions", () => { + const node = expansion("echo ${#arr}\n"); + + expect(node.lengthOf).toBe(true); + expect(node.name).toBe("arr"); + }); + + it("should parse indirection", () => { + const node = expansion("echo ${!ref}\n"); + + expect(node.indirect).toBe(true); + }); + + it("should parse subscripts", () => { + const node = expansion("echo ${arr[1]}\n"); + + expect(node.index).not.toBeNull(); + }); + + it("should parse replacement operators", () => { + const node = expansion("echo ${x/pat/rep}\n"); + + expect(node.operator).toBe("/"); + expect(node.word?.parts[0]).toMatchObject({ value: "pat" }); + expect(node.replacement?.parts[0]).toMatchObject({ + value: "rep", + }); + + const all = expansion("echo ${x//pat/rep}\n"); + + expect(all.operator).toBe("//"); + }); + + it("should parse slices", () => { + const node = expansion("echo ${x:1:2}\n"); + + expect(node.operator).toBe(":"); + expect(node.sliceOffset).not.toBeNull(); + expect(node.sliceLength).not.toBeNull(); + }); + + it("should parse prefix listings", () => { + const node = expansion("echo ${!pre*}\n"); + + expect(node.operator).toBe("*"); + }); + + it("should parse special parameters", () => { + const node = expansion("echo $?\n"); + + expect(node.name).toBe("?"); + expect(node.braced).toBe(false); + }); + }); + + describe("substitutions", () => { + it("should parse $(...) command substitutions", () => { + const command = first("echo $(pwd)\n"); + const part = arg(command, 0).parts[0] as CommandSubstitutionNode; + + expect(part.type).toBe("CommandSubstitution"); + expect(part.backquotes).toBe(false); + expect(part.body).toHaveLength(1); + }); + + it("should mark backquoted substitutions", () => { + const command = first("echo `pwd`\n"); + const part = arg(command, 0).parts[0] as CommandSubstitutionNode; + + expect(part.backquotes).toBe(true); + }); + + it("should parse process substitutions", () => { + const command = first("diff <(sort a) >(sort b)\n"); + const input = arg(command, 0).parts[0] as ProcessSubstitutionNode; + const output = arg(command, 1).parts[0] as ProcessSubstitutionNode; + + expect(input.type).toBe("ProcessSubstitution"); + expect(input.operator).toBe("<("); + expect(output.operator).toBe(">("); + expect(input.body).toHaveLength(1); + }); + + it("should parse arithmetic expansions", () => { + const command = first("echo $((1 + 2))\n"); + const part = arg(command, 0).parts[0]; + + expect(part?.type).toBe("ArithmeticExpansion"); + expect(part).toMatchObject({ + expression: { type: "BinaryArithmetic", operator: "+" }, + }); + }); + }); + + describe("redirects", () => { + it("should parse output redirects with file descriptors", () => { + const command = first("cmd 2>err.log\n"); + const redirect = command.redirects[0]; + + expect(redirect?.operator).toBe(">"); + expect(redirect?.fd).toBe(2); + expect(redirect?.target?.parts[0]).toMatchObject({ + value: "err.log", + }); + }); + + it("should parse append and duplication redirects", () => { + const command = first("cmd >>log 2>&1\n"); + + expect(command.redirects[0]?.operator).toBe(">>"); + expect(command.redirects[1]?.operator).toBe(">&"); + expect(command.redirects[1]?.fd).toBe(2); + }); + + it("should parse heredocs", () => { + const text = "cat <(text); + const redirect = command.redirects[0]; + + expect(redirect?.operator).toBe("<<"); + expect(redirect?.target?.parts[0]).toMatchObject({ + value: "EOF", + }); + expect(redirect?.heredoc).not.toBeNull(); + expect( + redirect?.heredoc?.parts.some( + part => part.type === "ParameterExpansion", + ), + ).toBe(true); + }); + + /* + * Heredoc bodies are the one place where the usual ESTree + * containment invariant does not hold, because the shell splits the + * construct across the line break. These cases lock in the ranges + * documented in `docs/syntax-tree.md`; see that file for why. + */ + describe("heredoc ranges", () => { + /** Collects every heredoc-bearing redirect under a statement. */ + function heredocRedirects(node: StatementNode): RedirectNode[] { + const found: RedirectNode[] = []; + + for (const redirect of node.redirects) { + if (redirect.heredoc) { + found.push(redirect); + } + } + + if (node.type === "Pipeline") { + for (const command of node.commands) { + found.push(...heredocRedirects(command)); + } + } + + return found; + } + + const cases = [ + { + name: "bare heredoc", + text: "cat < out.txt < out.txt\nhello\nEOF\n", + statement: [0, 19], + heredocs: [[20, 29]], + insideStatement: false, + }, + { + name: "piped heredoc", + text: "cat < { + const command = first("cat < { + const statement = first(testCase.text); + const redirects = heredocRedirects(statement); + + expect(redirects).toHaveLength(testCase.heredocs.length); + + for (const redirect of redirects) { + const heredoc = redirect.heredoc!; + + expect(heredoc.start).toBeGreaterThanOrEqual( + redirect.end, + ); + } + }); + + it(`should report documented ranges (${testCase.name})`, () => { + const statement = first(testCase.text); + const redirects = heredocRedirects(statement); + + expect([statement.start, statement.end]).toEqual( + testCase.statement, + ); + expect( + redirects.map(redirect => [ + redirect.heredoc!.start, + redirect.heredoc!.end, + ]), + ).toEqual(testCase.heredocs); + + const inside = redirects.every( + redirect => + redirect.heredoc!.start >= statement.start && + redirect.heredoc!.end <= statement.end, + ); + + expect(inside).toBe(testCase.insideStatement); + }); + } + + it("should order redirect siblings out of source order when a heredoc comes first", () => { + const command = first( + "cat < out.txt\nhello\nEOF\n", + ); + + expect([ + command.redirects[0]?.heredoc?.start, + command.redirects[0]?.heredoc?.end, + ]).toEqual([20, 29]); + expect([ + command.redirects[1]?.start, + command.redirects[1]?.end, + ]).toEqual([10, 19]); + }); + }); + + it("should parse herestrings", () => { + const command = first("cat <<< hi\n"); + + expect(command.redirects[0]?.operator).toBe("<<<"); + }); + }); + + describe("test commands", () => { + it("should parse binary tests", () => { + const node = first("[[ $x == foo ]]\n"); + const expression = node.expression as BinaryTestNode; + + expect(node.type).toBe("TestCommand"); + expect(expression.type).toBe("BinaryTest"); + expect(expression.operator).toBe("=="); + }); + + it("should parse unary tests and logical operators", () => { + const node = first("[[ -n $x && -f $y ]]\n"); + const expression = node.expression as BinaryTestNode; + + expect(expression.operator).toBe("&&"); + expect(expression.left).toMatchObject({ + type: "UnaryTest", + operator: "-n", + }); + }); + + it("should skip comments between test operands", () => { + for (const text of [ + "[[ -n $x # before\n && -f $y ]]\n", + "[[ -n $x && # after\n -f $y ]]\n", + ]) { + const node = first(text); + + expect((node.expression as BinaryTestNode).operator).toBe("&&"); + } + }); + }); + + describe("other commands", () => { + it("should parse arithmetic commands", () => { + const node = first("(( x = 1 + 2 ))\n"); + + expect(node.type).toBe("ArithmeticCommand"); + }); + + it("should parse declaration commands", () => { + const node = first( + "declare -x VAR=1 other\n", + ); + + expect(node.type).toBe("DeclarationCommand"); + expect(node.kind).toBe("declare"); + expect(node.arguments.length).toBeGreaterThanOrEqual(2); + expect(node.arguments[0]?.type).toBe("Word"); + expect( + node.arguments.some( + argument => + argument.type === "VariableAssignment" && + argument.name?.name === "VAR", + ), + ).toBe(true); + }); + + it("should parse local declarations", () => { + const text = "f() { local x=1; }\n"; + const fn = first(text); + const body = fn.body as SubshellNode; + const local = body.body[0] as DeclarationCommandNode; + + expect(local.type).toBe("DeclarationCommand"); + expect(local.kind).toBe("local"); + }); + + it("should parse let commands", () => { + const node = first("let x=1+2 y=2\n"); + + expect(node.type).toBe("LetCommand"); + expect(node.expressions.length).toBe(2); + expect(node.expressions[0]?.type).toBe("BinaryArithmetic"); + }); + + it("should parse time commands", () => { + const node = first("time sleep 1\n"); + + expect(node.type).toBe("TimeCommand"); + expect(node.body?.type).toBe("Command"); + }); + + it("should parse coproc commands", () => { + const node = first("coproc mycmd arg\n"); + + expect(node.type).toBe("CoprocCommand"); + expect(node.body).not.toBeNull(); + }); + }); + + describe("comments", () => { + it("should collect comments with positions", () => { + const text = "#!/bin/bash\n# a note\necho hi # trailing\n"; + const { comments } = parseShell(text); + + expect(comments).toHaveLength(3); + expect(comments[0]?.text).toBe("!/bin/bash"); + expect(comments[1]?.text).toBe(" a note"); + expect(comments[2]?.text).toBe(" trailing"); + expect( + text.slice(comments[1]?.start ?? 0, comments[1]?.end ?? 0), + ).toBe("# a note"); + }); + }); + + describe("unicode", () => { + it("should convert byte offsets to character offsets", () => { + const text = 'echo "héllo wörld" $var\n'; + const command = first(text); + const variable = arg(command, 1); + + expect(text.slice(variable.start, variable.end)).toBe("$var"); + }); + + it("should handle astral characters", () => { + const text = "echo 🎉 $var\n"; + const command = first(text); + const variable = arg(command, 1); + + expect(text.slice(variable.start, variable.end)).toBe("$var"); + }); + }); + + describe("errors", () => { + it("should throw ShellSyntaxError with location on bad syntax", () => { + expect(() => parseShell("if then fi\n")).toThrow(ShellSyntaxError); + + try { + parseShell("echo )\n"); + expect.unreachable(); + } catch (error) { + const syntaxError = error as ShellSyntaxError; + + expect(syntaxError.name).toBe("ShellSyntaxError"); + expect(syntaxError.line).toBeGreaterThanOrEqual(1); + expect(syntaxError.column).toBeGreaterThanOrEqual(1); + expect(syntaxError.message.length).toBeGreaterThan(0); + } + }); + + it("should report the line of errors on later lines", () => { + try { + parseShell("echo ok\nif then fi\n"); + expect.unreachable(); + } catch (error) { + expect((error as ShellSyntaxError).line).toBe(2); + } + }); + + it("should report the path option on the error", () => { + try { + parseShell("if then fi\n", { path: "script.sh" }); + expect.unreachable(); + } catch (error) { + expect((error as ShellSyntaxError).path).toBe("script.sh"); + } + + try { + parseShell("if then fi\n"); + expect.unreachable(); + } catch (error) { + expect((error as ShellSyntaxError).path).toBeUndefined(); + } + }); + + it("should throw on unknown variants", () => { + expect(() => + parseShell("echo hi\n", { + // @ts-expect-error -- testing invalid input + variant: "zsh", + }), + ).toThrow(TypeError); + }); + }); + + describe("variants", () => { + it("should parse mksh sources", () => { + const { ast } = parseShell("echo hi\n", { variant: "mksh" }); + + expect(ast.body).toHaveLength(1); + }); + + it("should report the mksh `;|` case terminator", () => { + const { ast } = parseShell( + "case $x in a) echo a ;| b) echo b ;; esac\n", + { variant: "mksh" }, + ); + const statement = ast.body[0]; + + expect( + statement?.type === "CaseStatement" && + statement.cases.map(clause => clause.terminator), + ).toEqual([";|", ";;"]); + }); + + it("should reject bash-only syntax in the posix variant", () => { + expect(() => + parseShell("diff <(sort a) <(sort b)\n", { + variant: "posix", + }), + ).toThrow(ShellSyntaxError); + }); + }); +}); diff --git a/src/parser/parse.ts b/src/parser/parse.ts new file mode 100644 index 0000000..88d34cc --- /dev/null +++ b/src/parser/parse.ts @@ -0,0 +1,1028 @@ +/** + * @fileoverview Parses shell source code into an ESTree-style syntax tree by + * wrapping the mvdan-sh parser (a GopherJS build of mvdan.cc/sh) and + * translating its tree. See docs/syntax-tree.md for the tree format. + */ + +import mvdan from "mvdan-sh"; +import type { + ArithmeticExpressionNode, + ArrayElementNode, + ArrayExpressionNode, + ShellVariant, + CaseClauseNode, + CommentNode, + IdentifierNode, + IfStatementNode, + ElseClauseNode, + ProgramNode, + RedirectNode, + StatementNode, + TestExpressionNode, + VariableAssignmentNode, + WordNode, + WordPartNode, +} from "../types.js"; + +const { syntax } = mvdan; + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +type MvdanNode = ReturnType["Parse"]>; + +const REDIRECT_OPERATOR = /^(?:&>>|&>|>\||>>|>&|<<<|<<-|<<|<&|<>|<|>)/u; +const UNARY_ARITHMETIC_OPERATOR = /^(?:\+\+|--|[-+!~])/u; +const PROCESS_SUBSTITUTION_OPERATOR = /^[<>]\(/u; +const EXTENDED_GLOB_OPERATOR = /^[?*+@!]\(/u; +const BINARY_COMMAND_OPERATOR = /^(?:\|&|\|\||&&|\|)/u; +const CASE_TERMINATOR = /^(?:;;&|;;|;&|;\|)/u; +const LEADING_TRIVIA = /^(?:\s|#.*)*/u; +// eslint-disable-next-line no-control-regex -- intentionally matches the full ASCII range. +const NON_ASCII = /[^\x00-\x7f]/u; + +const VARIANTS = new Map([ + ["bash", syntax.LangBash], + ["posix", syntax.LangPOSIX], + ["mksh", syntax.LangMirBSDKorn], +]); + +/** + * The error thrown when shell source code cannot be parsed. + */ +export class ShellSyntaxError extends SyntaxError { + line: number; + column: number; + + /** The file path passed to the parser, when there was one. */ + path: string | undefined; + + constructor(message: string, line: number, column: number, path?: string) { + super(message); + this.name = "ShellSyntaxError"; + this.line = line; + this.column = column; + this.path = path; + } +} + +export interface ShellParseOptions { + /** The shell dialect to parse. Defaults to `"bash"`. */ + variant?: ShellVariant; + + /** The file path reported as `ShellSyntaxError#path` on syntax errors. */ + path?: string; +} + +export interface ShellParseResult { + ast: ProgramNode; + comments: CommentNode[]; +} + +/** + * Creates a converter from mvdan-sh byte offsets to JavaScript character + * offsets. mvdan-sh reports positions as UTF-8 byte offsets, which only + * match string indexes for ASCII-only sources. + */ +function createByteToCharConverter(text: string): (byte: number) => number { + if (!NON_ASCII.test(text)) { + return byte => byte; + } + + const map: number[] = []; + let charIndex = 0; + + for (const char of text) { + const codePoint = char.codePointAt(0) as number; + const byteLength = + codePoint <= 0x7f + ? 1 + : codePoint <= 0x7ff + ? 2 + : codePoint <= 0xffff + ? 3 + : 4; + + for (let i = 0; i < byteLength; i++) { + map.push(charIndex); + } + + charIndex += char.length; + } + + map.push(charIndex); + + return byte => map[Math.min(byte, map.length - 1)] as number; +} + +/** + * Computes a 1-based line and column pair for a character offset. + */ +function locate( + text: string, + offset: number, +): { line: number; column: number } { + let line = 1; + let lineStart = 0; + + for (let i = 0; i < offset && i < text.length; i++) { + if (text[i] === "\n") { + line++; + lineStart = i + 1; + } + } + + return { line, column: offset - lineStart + 1 }; +} + +//------------------------------------------------------------------------------ +// Translator +//------------------------------------------------------------------------------ + +/** + * Translates an mvdan-sh syntax tree into the ESTree-style tree documented + * in docs/syntax-tree.md. + */ +class Translator { + #text: string; + #toChar: (byte: number) => number; + + constructor(text: string) { + this.#text = text; + this.#toChar = createByteToCharConverter(text); + } + + #start(node: MvdanNode): number { + return this.#toChar(node.Pos().Offset()); + } + + #end(node: MvdanNode): number { + return this.#toChar(node.End().Offset()); + } + + /** + * Extracts the operator token that appears between two child nodes, + * e.g. the `&&` in `a && b`. Skips surrounding whitespace and any + * comments on either side of the operator. + */ + #operatorBetween(left: MvdanNode, right: MvdanNode): string { + const slice = this.#text + .slice(this.#end(left), this.#start(right)) + .replace(LEADING_TRIVIA, ""); + + return slice.split(/\s+/u)[0] as string; + } + + /** + * Reads the operator of a `BinaryCmd` at its recorded position. + * + * This cannot slice between the two operands the way + * `#operatorBetween()` does: a heredoc body extends the left operand's + * end past the operator, so the slice comes out empty. See the heredoc + * section of `docs/syntax-tree.md`. + */ + #binaryCommandOperator(binary: MvdanNode): string { + const opPos = binary.OpPos as ReturnType; + + return ( + this.#operatorAt( + this.#toChar(opPos.Offset()), + BINARY_COMMAND_OPERATOR, + ) ?? "||" + ); + } + + /** + * Matches an operator pattern at a character offset. + */ + #operatorAt(offset: number, pattern: RegExp): string | null { + const match = pattern.exec(this.#text.slice(offset, offset + 3)); + + return match ? (match[0] as string) : null; + } + + translateProgram(file: MvdanNode): ProgramNode { + return { + type: "Program", + start: 0, + end: this.#text.length, + body: (file.Stmts as MvdanNode[]).map(stmt => + this.translateStatement(stmt), + ), + comments: this.collectComments(file), + }; + } + + collectComments(file: MvdanNode): CommentNode[] { + const comments: CommentNode[] = []; + + syntax.Walk(file, node => { + if (node && syntax.NodeType(node) === "Comment") { + comments.push({ + type: "Comment", + start: this.#start(node), + end: this.#end(node), + text: node.Text as string, + }); + } + + return true; + }); + + comments.sort((a, b) => a.start - b.start); + + return comments; + } + + /** + * Translates an mvdan `Stmt`, folding its statement-level properties + * (redirects, negation, background) into the command node. + */ + translateStatement(stmt: MvdanNode): StatementNode { + const node = this.#translateCommand(stmt.Cmd as MvdanNode | null); + + node.start = this.#start(stmt); + node.end = this.#end(stmt); + node.redirects = ((stmt.Redirs ?? []) as MvdanNode[]).map(redirect => + this.translateRedirect(redirect), + ); + node.negated = Boolean(stmt.Negated); + node.background = Boolean(stmt.Background); + + return node; + } + + #statementBase(node: MvdanNode | null): { + start: number; + end: number; + redirects: RedirectNode[]; + negated: boolean; + background: boolean; + } { + return { + start: node ? this.#start(node) : 0, + end: node ? this.#end(node) : 0, + redirects: [], + negated: false, + background: false, + }; + } + + #translateCommand(cmd: MvdanNode | null): StatementNode { + const nodeType = cmd === null ? "nil" : syntax.NodeType(cmd); + + switch (nodeType) { + case "nil": + // A statement with only redirects, such as `> file`. + return { + type: "Command", + ...this.#statementBase(null), + assignments: [], + name: null, + arguments: [], + }; + + case "CallExpr": { + const call = cmd as MvdanNode; + const args = (call.Args as MvdanNode[]).map(word => + this.translateWord(word), + ); + + return { + type: "Command", + ...this.#statementBase(call), + assignments: (call.Assigns as MvdanNode[]).map(assign => + this.translateAssignment(assign), + ), + name: args[0] ?? null, + arguments: args.slice(1), + }; + } + + case "BinaryCmd": { + const binary = cmd as MvdanNode; + const operator = this.#binaryCommandOperator(binary); + + if (operator === "|" || operator === "|&") { + return this.#translatePipeline(binary); + } + + return { + type: "LogicalExpression", + ...this.#statementBase(binary), + operator: operator === "&&" ? "&&" : "||", + left: this.translateStatement(binary.X as MvdanNode), + right: this.translateStatement(binary.Y as MvdanNode), + }; + } + + case "IfClause": + return this.#translateIfClause(cmd as MvdanNode); + + case "WhileClause": { + const clause = cmd as MvdanNode; + const base = this.#statementBase(clause); + const test = (clause.Cond as MvdanNode[]).map(stmt => + this.translateStatement(stmt), + ); + const body = (clause.Do as MvdanNode[]).map(stmt => + this.translateStatement(stmt), + ); + + return clause.Until + ? { type: "UntilStatement", ...base, test, body } + : { type: "WhileStatement", ...base, test, body }; + } + + case "ForClause": { + const clause = cmd as MvdanNode; + const base = this.#statementBase(clause); + const body = (clause.Do as MvdanNode[]).map(stmt => + this.translateStatement(stmt), + ); + const loop = clause.Loop as MvdanNode; + + if (syntax.NodeType(loop) === "CStyleLoop") { + return { + type: "ArithmeticForStatement", + ...base, + init: loop.Init + ? this.translateArithmetic(loop.Init as MvdanNode) + : null, + test: loop.Cond + ? this.translateArithmetic(loop.Cond as MvdanNode) + : null, + update: loop.Post + ? this.translateArithmetic(loop.Post as MvdanNode) + : null, + body, + }; + } + + return { + type: "ForStatement", + ...base, + variable: loop.Name + ? this.#translateIdentifier(loop.Name as MvdanNode) + : null, + words: ((loop.Items ?? []) as MvdanNode[]).map(word => + this.translateWord(word), + ), + body, + select: Boolean(clause.Select), + }; + } + + case "CaseClause": { + const clause = cmd as MvdanNode; + + return { + type: "CaseStatement", + ...this.#statementBase(clause), + discriminant: this.translateWord(clause.Word as MvdanNode), + cases: (clause.Items as MvdanNode[]).map(item => + this.#translateCaseItem(item), + ), + }; + } + + case "Block": + return { + type: "BlockStatement", + ...this.#statementBase(cmd), + body: ((cmd as MvdanNode).Stmts as MvdanNode[]).map(stmt => + this.translateStatement(stmt), + ), + }; + + case "Subshell": + return { + type: "Subshell", + ...this.#statementBase(cmd), + body: ((cmd as MvdanNode).Stmts as MvdanNode[]).map(stmt => + this.translateStatement(stmt), + ), + }; + + case "FuncDecl": { + const decl = cmd as MvdanNode; + + return { + type: "FunctionDeclaration", + ...this.#statementBase(decl), + id: this.#translateIdentifier(decl.Name as MvdanNode), + body: this.translateStatement(decl.Body as MvdanNode), + }; + } + + case "TestClause": + return { + type: "TestCommand", + ...this.#statementBase(cmd), + expression: this.translateTest( + (cmd as MvdanNode).X as MvdanNode, + ), + }; + + case "ArithmCmd": { + const arith = cmd as MvdanNode; + + return { + type: "ArithmeticCommand", + ...this.#statementBase(arith), + expression: arith.X + ? this.translateArithmetic(arith.X as MvdanNode) + : null, + }; + } + + case "DeclClause": { + const decl = cmd as MvdanNode; + + return { + type: "DeclarationCommand", + ...this.#statementBase(decl), + kind: (decl.Variant as MvdanNode).Value as string, + arguments: (decl.Args as MvdanNode[]).map(assign => + this.#translateDeclarationArgument(assign), + ), + }; + } + + case "TimeClause": { + const clause = cmd as MvdanNode; + + return { + type: "TimeCommand", + ...this.#statementBase(clause), + posix: Boolean(clause.PosixFormat), + body: clause.Stmt + ? this.translateStatement(clause.Stmt as MvdanNode) + : null, + }; + } + + case "CoprocClause": { + const clause = cmd as MvdanNode; + + return { + type: "CoprocCommand", + ...this.#statementBase(clause), + name: clause.Name + ? this.translateWord(clause.Name as MvdanNode) + : null, + body: clause.Stmt + ? this.translateStatement(clause.Stmt as MvdanNode) + : null, + }; + } + + case "LetClause": + return { + type: "LetCommand", + ...this.#statementBase(cmd), + expressions: ((cmd as MvdanNode).Exprs as MvdanNode[]).map( + expr => this.translateArithmetic(expr), + ), + }; + + default: + throw new Error(`Unsupported mvdan-sh node type: ${nodeType}.`); + } + } + + #translatePipeline(binary: MvdanNode): StatementNode { + const commands: StatementNode[] = []; + const operators: ("|" | "|&")[] = []; + + const collect = (stmt: MvdanNode): void => { + const inner = stmt.Cmd as MvdanNode | null; + const isBare = + !stmt.Negated && + !stmt.Background && + ((stmt.Redirs ?? []) as MvdanNode[]).length === 0; + + if ( + inner !== null && + isBare && + syntax.NodeType(inner) === "BinaryCmd" + ) { + const operator = this.#binaryCommandOperator(inner); + + if (operator === "|" || operator === "|&") { + collect(inner.X as MvdanNode); + operators.push(operator); + commands.push( + this.translateStatement(inner.Y as MvdanNode), + ); + return; + } + } + + commands.push(this.translateStatement(stmt)); + }; + + const operator = this.#binaryCommandOperator(binary) as "|" | "|&"; + + collect(binary.X as MvdanNode); + operators.push(operator); + commands.push(this.translateStatement(binary.Y as MvdanNode)); + + return { + type: "Pipeline", + ...this.#statementBase(binary), + commands, + operators, + }; + } + + #translateIfClause(clause: MvdanNode): IfStatementNode { + let alternate: IfStatementNode | ElseClauseNode | null = null; + const elseClause = clause.Else as MvdanNode | null; + + if (elseClause) { + if ((elseClause.Cond as MvdanNode[]).length > 0) { + alternate = this.#translateIfClause(elseClause); + } else { + alternate = { + type: "ElseClause", + start: this.#start(elseClause), + end: this.#end(elseClause), + body: (elseClause.Then as MvdanNode[]).map(stmt => + this.translateStatement(stmt), + ), + }; + } + } + + return { + type: "IfStatement", + ...this.#statementBase(clause), + test: (clause.Cond as MvdanNode[]).map(stmt => + this.translateStatement(stmt), + ), + consequent: (clause.Then as MvdanNode[]).map(stmt => + this.translateStatement(stmt), + ), + alternate, + }; + } + + #translateCaseItem(item: MvdanNode): CaseClauseNode { + const opPos = item.OpPos as ReturnType; + const hasTerminator = opPos.Line() !== 0; + + return { + type: "CaseClause", + start: this.#start(item), + end: this.#end(item), + patterns: (item.Patterns as MvdanNode[]).map(word => + this.translateWord(word), + ), + body: (item.Stmts as MvdanNode[]).map(stmt => + this.translateStatement(stmt), + ), + terminator: hasTerminator + ? this.#operatorAt( + this.#toChar(opPos.Offset()), + CASE_TERMINATOR, + ) + : null, + }; + } + + #translateIdentifier(lit: MvdanNode): IdentifierNode { + return { + type: "Identifier", + start: this.#start(lit), + end: this.#end(lit), + name: lit.Value as string, + }; + } + + translateAssignment(assign: MvdanNode): VariableAssignmentNode { + return { + type: "VariableAssignment", + start: this.#start(assign), + end: this.#end(assign), + name: assign.Name + ? this.#translateIdentifier(assign.Name as MvdanNode) + : null, + index: assign.Index + ? this.translateArithmetic(assign.Index as MvdanNode) + : null, + value: assign.Value + ? this.translateWord(assign.Value as MvdanNode) + : null, + array: assign.Array + ? this.#translateArray(assign.Array as MvdanNode) + : null, + append: Boolean(assign.Append), + }; + } + + /** + * Declaration command arguments are all `Assign` nodes in mvdan-sh; + * plain words such as flags (`-x`) become nameless naked assigns. + */ + #translateDeclarationArgument( + assign: MvdanNode, + ): VariableAssignmentNode | WordNode { + if (!assign.Name && assign.Naked && assign.Value) { + return this.translateWord(assign.Value as MvdanNode); + } + + return this.translateAssignment(assign); + } + + #translateArray(array: MvdanNode): ArrayExpressionNode { + return { + type: "ArrayExpression", + start: this.#start(array), + end: this.#end(array), + elements: (array.Elems as MvdanNode[]).map( + (element): ArrayElementNode => ({ + type: "ArrayElement", + start: this.#start(element), + end: this.#end(element), + index: element.Index + ? this.translateArithmetic(element.Index as MvdanNode) + : null, + value: element.Value + ? this.translateWord(element.Value as MvdanNode) + : null, + }), + ), + }; + } + + translateRedirect(redirect: MvdanNode): RedirectNode { + const opPos = redirect.OpPos as ReturnType; + const opStart = this.#toChar(opPos.Offset()); + const operator = this.#operatorAt(opStart, REDIRECT_OPERATOR) ?? ">"; + const fdLit = redirect.N as MvdanNode | null; + const target = redirect.Word as MvdanNode | null; + + return { + type: "Redirect", + start: fdLit ? this.#start(fdLit) : opStart, + end: target ? this.#end(target) : opStart + operator.length, + operator, + fd: fdLit ? Number.parseInt(fdLit.Value as string, 10) : null, + target: target ? this.translateWord(target) : null, + heredoc: redirect.Hdoc + ? this.translateWord(redirect.Hdoc as MvdanNode) + : null, + }; + } + + translateWord(word: MvdanNode): WordNode { + return { + type: "Word", + start: this.#start(word), + end: this.#end(word), + parts: (word.Parts as MvdanNode[]).map(part => + this.translateWordPart(part), + ), + }; + } + + translateWordPart(part: MvdanNode): WordPartNode { + const nodeType = syntax.NodeType(part); + const start = this.#start(part); + const end = this.#end(part); + + switch (nodeType) { + case "Lit": + return { + type: "Literal", + start, + end, + value: part.Value as string, + }; + + case "SglQuoted": + return { + type: "SingleQuotedString", + start, + end, + value: part.Value as string, + dollar: Boolean(part.Dollar), + }; + + case "DblQuoted": + return { + type: "DoubleQuotedString", + start, + end, + parts: (part.Parts as MvdanNode[]).map(inner => + this.translateWordPart(inner), + ), + dollar: Boolean(part.Dollar), + }; + + case "ParamExp": + return this.#translateParameterExpansion(part); + + case "CmdSubst": + return { + type: "CommandSubstitution", + start, + end, + body: (part.Stmts as MvdanNode[]).map(stmt => + this.translateStatement(stmt), + ), + backquotes: this.#text[start] === "`", + }; + + case "ProcSubst": + return { + type: "ProcessSubstitution", + start, + end, + operator: + (this.#operatorAt( + start, + PROCESS_SUBSTITUTION_OPERATOR, + ) as "<(" | ">(") ?? "<(", + body: (part.Stmts as MvdanNode[]).map(stmt => + this.translateStatement(stmt), + ), + }; + + case "ArithmExp": + return { + type: "ArithmeticExpansion", + start, + end, + expression: part.X + ? this.translateArithmetic(part.X as MvdanNode) + : null, + }; + + case "ExtGlob": + return { + type: "ExtendedGlob", + start, + end, + operator: + (this.#operatorAt(start, EXTENDED_GLOB_OPERATOR) as + "@(" | "*(" | "+(" | "?(" | "!(") ?? "@(", + pattern: ((part.Pattern as MvdanNode)?.Value ?? + "") as string, + }; + + default: + // Unknown word parts (e.g. BraceExp when enabled) degrade to + // literals covering their source text. + return { + type: "Literal", + start, + end, + value: this.#text.slice(start, end), + }; + } + } + + #translateParameterExpansion(part: MvdanNode): WordPartNode { + const start = this.#start(part); + const end = this.#end(part); + const param = part.Param as MvdanNode; + const index = part.Index + ? this.translateArithmetic(part.Index as MvdanNode) + : null; + + let operator: string | null = null; + let word: WordNode | null = null; + let replacement: WordNode | null = null; + let sliceOffset: ArithmeticExpressionNode | null = null; + let sliceLength: ArithmeticExpressionNode | null = null; + + // Character offset where an operator would begin: after the + // parameter name, or after the `]` that closes a subscript. + let operatorStart = this.#end(param); + + if (index) { + const bracket = this.#text.indexOf("]", index.end); + + operatorStart = bracket === -1 ? index.end : bracket + 1; + } + + const rbrace = part.Rbrace + ? this.#toChar( + (part.Rbrace as ReturnType).Offset(), + ) + : end; + + if (part.Slice) { + const slice = part.Slice as MvdanNode; + + operator = ":"; + sliceOffset = slice.Offset + ? this.translateArithmetic(slice.Offset as MvdanNode) + : null; + sliceLength = slice.Length + ? this.translateArithmetic(slice.Length as MvdanNode) + : null; + } else if (part.Repl) { + const repl = part.Repl as MvdanNode; + const orig = repl.Orig as MvdanNode | null; + const origWord = + orig && (orig.Parts as MvdanNode[]).length > 0 + ? this.translateWord(orig) + : null; + + operator = origWord + ? this.#text.slice(operatorStart, origWord.start).trim() + : (repl.All as boolean) + ? "//" + : "/"; + word = origWord; + replacement = + repl.With && + ((repl.With as MvdanNode).Parts as MvdanNode[]).length > 0 + ? this.translateWord(repl.With as MvdanNode) + : null; + } else if (part.Exp) { + const expansion = part.Exp as MvdanNode; + const expansionWord = expansion.Word as MvdanNode | null; + + word = expansionWord ? this.translateWord(expansionWord) : null; + operator = this.#text + .slice(operatorStart, word ? word.start : rbrace) + .trim(); + } else if (part.Names !== 0) { + operator = this.#text.slice(operatorStart, rbrace).trim(); + } + + return { + type: "ParameterExpansion", + start, + end, + name: param.Value as string, + braced: !part.Short, + indirect: Boolean(part.Excl), + lengthOf: Boolean(part.Length), + index, + operator: operator || null, + word, + replacement, + sliceOffset, + sliceLength, + }; + } + + translateArithmetic(expr: MvdanNode): ArithmeticExpressionNode { + const nodeType = syntax.NodeType(expr); + const start = this.#start(expr); + const end = this.#end(expr); + + switch (nodeType) { + case "BinaryArithm": + return { + type: "BinaryArithmetic", + start, + end, + operator: this.#operatorBetween( + expr.X as MvdanNode, + expr.Y as MvdanNode, + ), + left: this.translateArithmetic(expr.X as MvdanNode), + right: this.translateArithmetic(expr.Y as MvdanNode), + }; + + case "UnaryArithm": { + const opPos = expr.OpPos as ReturnType; + + return { + type: "UnaryArithmetic", + start, + end, + operator: + this.#operatorAt( + this.#toChar(opPos.Offset()), + UNARY_ARITHMETIC_OPERATOR, + ) ?? "+", + prefix: !expr.Post, + argument: this.translateArithmetic(expr.X as MvdanNode), + }; + } + + case "ParenArithm": + return { + type: "ParenthesizedArithmetic", + start, + end, + expression: this.translateArithmetic(expr.X as MvdanNode), + }; + + default: + return this.translateWord(expr); + } + } + + translateTest(expr: MvdanNode): TestExpressionNode { + const nodeType = syntax.NodeType(expr); + const start = this.#start(expr); + const end = this.#end(expr); + + switch (nodeType) { + case "BinaryTest": + return { + type: "BinaryTest", + start, + end, + operator: this.#operatorBetween( + expr.X as MvdanNode, + expr.Y as MvdanNode, + ), + left: this.translateTest(expr.X as MvdanNode), + right: this.translateTest(expr.Y as MvdanNode), + }; + + case "UnaryTest": { + const opPos = expr.OpPos as ReturnType; + const opStart = this.#toChar(opPos.Offset()); + const match = /^\S+/u.exec(this.#text.slice(opStart)); + + return { + type: "UnaryTest", + start, + end, + operator: match ? (match[0] as string) : "!", + argument: this.translateTest(expr.X as MvdanNode), + }; + } + + case "ParenTest": + return { + type: "ParenthesizedTest", + start, + end, + expression: this.translateTest(expr.X as MvdanNode), + }; + + default: + return this.translateWord(expr); + } + } +} + +//------------------------------------------------------------------------------ +// Public API +//------------------------------------------------------------------------------ + +/** + * Parses shell source code into an ESTree-style syntax tree. + * @throws {ShellSyntaxError} When the source code contains a syntax error. + */ +export function parseShell( + text: string, + options: ShellParseOptions = {}, +): ShellParseResult { + const variant = VARIANTS.get(options.variant ?? "bash"); + + if (variant === undefined) { + throw new TypeError( + `Unknown shell variant "${options.variant}". Expected "bash", "posix", or "mksh".`, + ); + } + + const parser = syntax.NewParser( + syntax.KeepComments(true), + syntax.Variant(variant), + ); + + let file: MvdanNode; + + try { + file = parser.Parse(text, options.path ?? ""); + } catch (error) { + const parseError = error as { + Error?: () => string; + Text?: string; + Pos?: { Offset(): number }; + }; + + if (typeof parseError?.Error === "function") { + const byteOffset = parseError.Pos?.Offset() ?? 0; + const offset = createByteToCharConverter(text)(byteOffset); + const { line, column } = locate(text, offset); + + throw new ShellSyntaxError( + parseError.Text || parseError.Error(), + line, + column, + options.path, + ); + } + + throw error; + } + + const translator = new Translator(text); + const ast = translator.translateProgram(file); + + return { ast, comments: ast.comments }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..887c927 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,438 @@ +/** + * @fileoverview Type definitions for the shell ESTree-style syntax tree. See + * docs/syntax-tree.md for the full documentation of the tree format. + */ + +//------------------------------------------------------------------------------ +// Base +//------------------------------------------------------------------------------ + +/** + * Every node carries `start` and `end` character offsets into the source + * text (0-based, `end` exclusive). Nodes deliberately do not carry `range` + * or `loc` properties; use `sourceCode.getRange(node)` and + * `sourceCode.getLoc(node)` instead. + */ +export interface ShellNodeBase { + type: string; + start: number; + end: number; +} + +/** + * Properties shared by every node that can appear in a statement position. + */ +export interface StatementBase extends ShellNodeBase { + redirects: RedirectNode[]; + negated: boolean; + background: boolean; +} + +//------------------------------------------------------------------------------ +// Root +//------------------------------------------------------------------------------ + +export interface ProgramNode extends ShellNodeBase { + type: "Program"; + body: StatementNode[]; + comments: CommentNode[]; +} + +export interface CommentNode extends ShellNodeBase { + type: "Comment"; + + /** The comment text without the leading `#`. */ + text: string; +} + +//------------------------------------------------------------------------------ +// Statements +//------------------------------------------------------------------------------ + +export interface CommandNode extends StatementBase { + type: "Command"; + assignments: VariableAssignmentNode[]; + name: WordNode | null; + arguments: WordNode[]; +} + +export interface PipelineNode extends StatementBase { + type: "Pipeline"; + commands: StatementNode[]; + + /** Operator between `commands[i]` and `commands[i + 1]` (`|` or `|&`). */ + operators: ("|" | "|&")[]; +} + +export interface LogicalExpressionNode extends StatementBase { + type: "LogicalExpression"; + operator: "&&" | "||"; + left: StatementNode; + right: StatementNode; +} + +export interface SubshellNode extends StatementBase { + type: "Subshell"; + body: StatementNode[]; +} + +export interface BlockStatementNode extends StatementBase { + type: "BlockStatement"; + body: StatementNode[]; +} + +export interface IfStatementNode extends StatementBase { + type: "IfStatement"; + test: StatementNode[]; + consequent: StatementNode[]; + alternate: IfStatementNode | ElseClauseNode | null; +} + +export interface ElseClauseNode extends ShellNodeBase { + type: "ElseClause"; + body: StatementNode[]; +} + +export interface WhileStatementNode extends StatementBase { + type: "WhileStatement"; + test: StatementNode[]; + body: StatementNode[]; +} + +export interface UntilStatementNode extends StatementBase { + type: "UntilStatement"; + test: StatementNode[]; + body: StatementNode[]; +} + +export interface ForStatementNode extends StatementBase { + type: "ForStatement"; + variable: IdentifierNode | null; + words: WordNode[]; + body: StatementNode[]; + + /** `true` for `select name in ...` loops. */ + select: boolean; +} + +export interface ArithmeticForStatementNode extends StatementBase { + type: "ArithmeticForStatement"; + init: ArithmeticExpressionNode | null; + test: ArithmeticExpressionNode | null; + update: ArithmeticExpressionNode | null; + body: StatementNode[]; +} + +export interface CaseStatementNode extends StatementBase { + type: "CaseStatement"; + discriminant: WordNode; + cases: CaseClauseNode[]; +} + +export interface CaseClauseNode extends ShellNodeBase { + type: "CaseClause"; + patterns: WordNode[]; + body: StatementNode[]; + + /** `;;`, `;&`, `;;&`, `;|` (mksh), or `null` when omitted before `esac`. */ + terminator: string | null; +} + +export interface FunctionDeclarationNode extends StatementBase { + type: "FunctionDeclaration"; + id: IdentifierNode; + body: StatementNode; +} + +export interface TestCommandNode extends StatementBase { + type: "TestCommand"; + expression: TestExpressionNode; +} + +export interface ArithmeticCommandNode extends StatementBase { + type: "ArithmeticCommand"; + expression: ArithmeticExpressionNode | null; +} + +export interface DeclarationCommandNode extends StatementBase { + type: "DeclarationCommand"; + + /** `declare`, `local`, `export`, `readonly`, `typeset`, or `nameref`. */ + kind: string; + arguments: (VariableAssignmentNode | WordNode)[]; +} + +export interface TimeCommandNode extends StatementBase { + type: "TimeCommand"; + posix: boolean; + body: StatementNode | null; +} + +export interface CoprocCommandNode extends StatementBase { + type: "CoprocCommand"; + name: WordNode | null; + body: StatementNode | null; +} + +export interface LetCommandNode extends StatementBase { + type: "LetCommand"; + expressions: ArithmeticExpressionNode[]; +} + +export type StatementNode = + | CommandNode + | PipelineNode + | LogicalExpressionNode + | SubshellNode + | BlockStatementNode + | IfStatementNode + | WhileStatementNode + | UntilStatementNode + | ForStatementNode + | ArithmeticForStatementNode + | CaseStatementNode + | FunctionDeclarationNode + | TestCommandNode + | ArithmeticCommandNode + | DeclarationCommandNode + | TimeCommandNode + | CoprocCommandNode + | LetCommandNode; + +//------------------------------------------------------------------------------ +// Words and word parts +//------------------------------------------------------------------------------ + +export interface WordNode extends ShellNodeBase { + type: "Word"; + parts: WordPartNode[]; +} + +export interface LiteralNode extends ShellNodeBase { + type: "Literal"; + value: string; +} + +export interface SingleQuotedStringNode extends ShellNodeBase { + type: "SingleQuotedString"; + + /** The text between the quotes. */ + value: string; + + /** `true` for `$'...'` strings. */ + dollar: boolean; +} + +export interface DoubleQuotedStringNode extends ShellNodeBase { + type: "DoubleQuotedString"; + parts: WordPartNode[]; + + /** `true` for `$"..."` strings. */ + dollar: boolean; +} + +export interface ParameterExpansionNode extends ShellNodeBase { + type: "ParameterExpansion"; + + /** The parameter name (`foo`, `1`, `@`, `?`, ...). */ + name: string; + + /** `true` for `${foo}`, `false` for `$foo`. */ + braced: boolean; + + /** `true` for `${!ref}` indirection. */ + indirect: boolean; + + /** `true` for `${#foo}`. */ + lengthOf: boolean; + + /** Subscript for `${arr[i]}`. */ + index: ArithmeticExpressionNode | null; + + /** + * The expansion operator as written in the source (`:-`, `:=`, `##`, + * `%`, `/`, `//`, `:` for slices, `*`/`@` for `${!prefix*}`, ...), or + * `null` when the expansion has no operator. + */ + operator: string | null; + + /** Operand of `operator` (e.g. the default in `${x:-default}`). */ + word: WordNode | null; + + /** Replacement word for `${x/pattern/replacement}`. */ + replacement: WordNode | null; + + /** Offset expression for `${x:offset:length}`. */ + sliceOffset: ArithmeticExpressionNode | null; + + /** Length expression for `${x:offset:length}`. */ + sliceLength: ArithmeticExpressionNode | null; +} + +export interface CommandSubstitutionNode extends ShellNodeBase { + type: "CommandSubstitution"; + body: StatementNode[]; + + /** `true` when written with backquotes instead of `$(...)`. */ + backquotes: boolean; +} + +export interface ProcessSubstitutionNode extends ShellNodeBase { + type: "ProcessSubstitution"; + operator: "<(" | ">("; + body: StatementNode[]; +} + +export interface ArithmeticExpansionNode extends ShellNodeBase { + type: "ArithmeticExpansion"; + expression: ArithmeticExpressionNode | null; +} + +export interface ExtendedGlobNode extends ShellNodeBase { + type: "ExtendedGlob"; + operator: "@(" | "*(" | "+(" | "?(" | "!("; + pattern: string; +} + +export type WordPartNode = + | LiteralNode + | SingleQuotedStringNode + | DoubleQuotedStringNode + | ParameterExpansionNode + | CommandSubstitutionNode + | ProcessSubstitutionNode + | ArithmeticExpansionNode + | ExtendedGlobNode; + +//------------------------------------------------------------------------------ +// Assignments, identifiers, redirects +//------------------------------------------------------------------------------ + +export interface VariableAssignmentNode extends ShellNodeBase { + type: "VariableAssignment"; + name: IdentifierNode | null; + + /** Subscript for `arr[i]=value`. */ + index: ArithmeticExpressionNode | null; + value: WordNode | null; + array: ArrayExpressionNode | null; + + /** `true` for `+=`. */ + append: boolean; +} + +export interface ArrayExpressionNode extends ShellNodeBase { + type: "ArrayExpression"; + elements: ArrayElementNode[]; +} + +export interface ArrayElementNode extends ShellNodeBase { + type: "ArrayElement"; + index: ArithmeticExpressionNode | null; + value: WordNode | null; +} + +export interface IdentifierNode extends ShellNodeBase { + type: "Identifier"; + name: string; +} + +export interface RedirectNode extends ShellNodeBase { + type: "Redirect"; + + /** `<`, `>`, `>>`, `<<`, `<<-`, `<<<`, `<&`, `>&`, `&>`, `&>>`, `<>`, `>|`. */ + operator: string; + + /** File descriptor for `2>err`, or `null`. */ + fd: number | null; + + /** Redirect target (the heredoc delimiter for `<<`). */ + target: WordNode | null; + + /** Heredoc body for `<<` and `<<-`. */ + heredoc: WordNode | null; +} + +//------------------------------------------------------------------------------ +// Arithmetic expressions +//------------------------------------------------------------------------------ + +export interface BinaryArithmeticNode extends ShellNodeBase { + type: "BinaryArithmetic"; + operator: string; + left: ArithmeticExpressionNode; + right: ArithmeticExpressionNode; +} + +export interface UnaryArithmeticNode extends ShellNodeBase { + type: "UnaryArithmetic"; + operator: string; + prefix: boolean; + argument: ArithmeticExpressionNode; +} + +export interface ParenthesizedArithmeticNode extends ShellNodeBase { + type: "ParenthesizedArithmetic"; + expression: ArithmeticExpressionNode; +} + +export type ArithmeticExpressionNode = + | BinaryArithmeticNode + | UnaryArithmeticNode + | ParenthesizedArithmeticNode + | WordNode; + +//------------------------------------------------------------------------------ +// Test expressions ([[ ... ]]) +//------------------------------------------------------------------------------ + +export interface BinaryTestNode extends ShellNodeBase { + type: "BinaryTest"; + operator: string; + left: TestExpressionNode; + right: TestExpressionNode; +} + +export interface UnaryTestNode extends ShellNodeBase { + type: "UnaryTest"; + operator: string; + argument: TestExpressionNode; +} + +export interface ParenthesizedTestNode extends ShellNodeBase { + type: "ParenthesizedTest"; + expression: TestExpressionNode; +} + +export type TestExpressionNode = + BinaryTestNode | UnaryTestNode | ParenthesizedTestNode | WordNode; + +//------------------------------------------------------------------------------ +// Union of all nodes +//------------------------------------------------------------------------------ + +export type ShellNode = + | ProgramNode + | CommentNode + | StatementNode + | ElseClauseNode + | CaseClauseNode + | WordNode + | WordPartNode + | VariableAssignmentNode + | ArrayExpressionNode + | ArrayElementNode + | IdentifierNode + | RedirectNode + | BinaryArithmeticNode + | UnaryArithmeticNode + | ParenthesizedArithmeticNode + | BinaryTestNode + | UnaryTestNode + | ParenthesizedTestNode; + +//------------------------------------------------------------------------------ +// Language options +//------------------------------------------------------------------------------ + +export type ShellVariant = "bash" | "posix" | "mksh";