From f1202cfcdae90d2cd64e6ea5d7c121646e3eb407 Mon Sep 17 00:00:00 2001 From: Aliou Diallo Date: Wed, 4 Feb 2026 06:50:01 +0100 Subject: [PATCH] feat: add DeclClause, LetClause, CStyleLoop, array assignments, append assignments, comments - DeclClause: declare/local/export/readonly/typeset/nameref as special forms - LetClause: let expr1 expr2 - CStyleLoop: for ((init; cond; post)); do ... done - ArrayExpr: arr=(a b c), arr=([0]=x), arr=([key]=val) - Assignment.append: NAME+=value - CommentNode: keepComments option preserves comments in Program.comments - refactor: tryParseAssignment replaces assignmentFromParts --- README.md | 56 ++----- src/index.ts | 396 ++++++++++++++++++++++++++++++++++++++------ src/parser.test.ts | 400 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 753 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index de2e6a6..7c309b5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ The parser returns a `Program` node containing `Statement` nodes. Each statement - `IfClause`, `WhileClause`, `ForClause`, `SelectClause`, `CaseClause` - `FunctionDecl`, `Subshell`, `Block` - `TestClause` (`[[ ]]`), `ArithCmd` (`(( ))`), `CoprocClause`, `TimeClause` +- `DeclClause` (`declare`, `local`, `export`, `readonly`, `typeset`, `nameref`) +- `LetClause` (`let`), `CStyleLoop` (`for (( ; ; ))`) Words contain typed parts: `Literal`, `SglQuoted`, `DblQuoted`, `ParamExp`, `CmdSubst`, `ArithExp`, `ProcSubst`. @@ -66,58 +68,23 @@ extractCommandNames(ast); // ["grep", "head"] - Process substitution (`<(cmd)`, `>(cmd)`) - Heredocs (`<<`, `<<-`), herestrings (`<<<`) - All redirect operators (`>`, `>>`, `<`, `>&`, `<&`, `<>`, `>|`, `&>`, `&>>`) -- Assignments (`FOO=bar cmd`) -- Control flow: `if/elif/else/fi`, `while/until`, `for/in`, `select/in`, `case/esac` +- Assignments (`FOO=bar cmd`), append assignments (`FOO+=bar`) +- Array expressions (`arr=(a b c)`, `arr=([0]=x [1]=y)`) +- Declaration builtins as special forms (`declare`, `local`, `export`, `readonly`, `typeset`, `nameref`) +- `let` expressions (`let i++ j=2`) +- Control flow: `if/elif/else/fi`, `while/until`, `for/in`, `for ((...))`, `select/in`, `case/esac` - Functions (`foo() {}`, `function foo {}`) - Subshells `()`, blocks `{}` - `[[ ]]` test expressions, `(( ))` arithmetic commands - `coproc`, `time`, negation (`!`) -- Comments, backslash line continuations, background (`&`), semicolons +- Comments (optionally preserved via `keepComments` option), backslash line continuations, background (`&`), semicolons ## Install -### From a public GitHub repo (no registry needed) - -The simplest approach. Requires the repo to be pushed to GitHub: - ```bash pnpm add github:aliou/sh ``` -This clones the repo and runs the `prepack` script, which builds `dist/`. Pin a specific commit or tag: - -```bash -pnpm add github:aliou/sh#v0.1.0 -pnpm add github:aliou/sh#bbf1d86 -``` - -### From a GitHub release tarball - -Attach the built `.tgz` to a GitHub release, then: - -```bash -pnpm add https://github.com/aliou/sh/releases/download/v0.1.0/aliou-sh-0.1.0.tgz -``` - -To create the tarball locally: - -```bash -pnpm build && pnpm pack -# produces aliou-sh-0.0.1.tgz -``` - -### From npm (npmjs.com) - -If published to npm: - -```bash -pnpm add @aliou/sh -``` - -### From GitHub Packages - -Requires a `GITHUB_TOKEN` for all consumers, even if the package is public. Probably not worth the friction unless the repo is private. - ## Development Requires [Nix](https://nixos.org/) (provides Node 22 and pnpm): @@ -132,14 +99,13 @@ pnpm check # biome format + lint pnpm build # rolldown + tsc declarations ``` -### Git hooks - -- **pre-commit**: staged file formatting/linting (biome) + typecheck +Git hooks (via husky): +- **pre-commit**: staged file formatting/linting + typecheck - **pre-push**: tests ## Status -Work in progress. The parser covers the common Bash subset needed for AST-based command analysis (e.g., guardrail enforcement). Not yet a complete POSIX/Bash parser. +Work in progress. Covers the Bash subset needed for AST-based command analysis (command classification, variable mutation tracking, guardrail enforcement). Not yet a complete POSIX/Bash parser -- notably missing: position tracking in AST nodes, extended globbing, and full arithmetic expression parsing. ## License diff --git a/src/index.ts b/src/index.ts index 8888807..88418a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,15 @@ export type WordPart = | ArithExp | ProcSubst; export type Word = { type: "Word"; parts: WordPart[] }; -export type Assignment = { type: "Assignment"; name: string; value?: Word }; +export type Assignment = { + type: "Assignment"; + name: string; + append?: boolean; + value?: Word; + array?: ArrayExpr; +}; +export type ArrayElem = { type: "ArrayElem"; index?: Word; value?: Word }; +export type ArrayExpr = { type: "ArrayExpr"; elems: ArrayElem[] }; export type RedirOp = | ">" | "<" @@ -108,6 +116,26 @@ export type CoprocClause = { name?: string; body: Statement; }; +export type DeclClause = { + type: "DeclClause"; + variant: "declare" | "local" | "export" | "readonly" | "typeset" | "nameref"; + args?: Word[]; + assigns?: Assignment[]; + redirects?: Redirect[]; +}; +export type LetClause = { + type: "LetClause"; + exprs: Word[]; + redirects?: Redirect[]; +}; +export type CStyleLoop = { + type: "CStyleLoop"; + init?: string; + cond?: string; + post?: string; + body: Statement[]; +}; +export type CommentNode = { type: "Comment"; text: string }; export type Pipeline = { type: "Pipeline"; commands: Statement[] }; export type Logical = { type: "Logical"; @@ -130,14 +158,21 @@ export type Command = | ArithCmd | CoprocClause | Pipeline - | Logical; + | Logical + | DeclClause + | LetClause + | CStyleLoop; export type Statement = { type: "Statement"; command: Command; background?: boolean; negated?: boolean; }; -export type Program = { type: "Program"; body: Statement[] }; +export type Program = { + type: "Program"; + body: Statement[]; + comments?: CommentNode[]; +}; export type ParseResult = { ast: Program; @@ -169,14 +204,12 @@ type Token = | { type: "redir"; op: RedirOp; fd?: string } | { type: "symbol"; value: SymbolTokenValue } | { type: "arith-cmd"; expr: string } - | { type: "heredoc-body"; content: string }; + | { type: "heredoc-body"; content: string } + | { type: "comment"; text: string }; -export function parse( - source: string, - _options: ParseOptions = {}, -): ParseResult { - const tokens = tokenize(source); - const parser = new Parser(tokens); +export function parse(source: string, options: ParseOptions = {}): ParseResult { + const tokens = tokenize(source, options); + const parser = new Parser(tokens, options); const ast = parser.parseProgram(); parser.assertEof(); return { ast }; @@ -344,7 +377,7 @@ function tryRedirOp( return null; } -function tokenize(source: string): Token[] { +function tokenize(source: string, options: ParseOptions = {}): Token[] { const tokens: Token[] = []; let i = 0; let atBoundary = true; @@ -418,10 +451,14 @@ function tokenize(source: string): Token[] { } if (ch === "#" && atBoundary) { + const start = i + 1; i += 1; while (i < source.length && source.charAt(i) !== "\n") { i += 1; } + if (options.keepComments) { + tokens.push({ type: "comment", text: source.slice(start, i) }); + } continue; } @@ -691,10 +728,23 @@ function tokenPartsText(parts: TokenWordPart[]): string { .join(""); } +const DECL_KEYWORDS = new Set([ + "declare", + "local", + "export", + "readonly", + "typeset", + "nameref", +]); + class Parser { private index = 0; + private comments: CommentNode[] = []; - constructor(private readonly tokens: Token[]) {} + constructor( + private readonly tokens: Token[], + private readonly options: ParseOptions = {}, + ) {} parseProgram(): Program { const body: Statement[] = []; @@ -703,7 +753,11 @@ class Parser { body.push(this.parseStatement()); this.skipSeparators(); } - return { type: "Program", body }; + const program: Program = { type: "Program", body }; + if (this.options.keepComments && this.comments.length > 0) { + program.comments = this.comments; + } + return program; } assertEof() { @@ -720,7 +774,9 @@ class Parser { ? "(( ... ))" : token.type === "heredoc-body" ? "<>" - : tokenPartsText(token.parts) + : token.type === "comment" + ? `#${token.text}` + : tokenPartsText(token.parts) : ""; throw new Error(`Unexpected token: ${display}`); } @@ -792,7 +848,7 @@ class Parser { return this.parseWhileClause(true); } if (this.matchKeyword("for")) { - return this.parseForClause(); + return this.parseForOrCStyleLoop(); } if (this.matchKeyword("select")) { return this.parseSelectClause(); @@ -821,6 +877,12 @@ class Parser { if (this.matchSymbol("{")) { return this.parseBlock(); } + if (this.matchDeclKeyword()) { + return this.parseDeclClause(); + } + if (this.matchKeyword("let")) { + return this.parseLetClause(); + } return this.parseSimpleCommand(); } @@ -931,8 +993,14 @@ class Parser { : { type: "WhileClause", cond, body }; } - private parseForClause(): ForClause { + private parseForOrCStyleLoop(): ForClause | CStyleLoop { this.consumeKeyword("for"); + + // C-style: for (( init; cond; post )) + if (this.matchArithCmd()) { + return this.parseCStyleLoop(); + } + const nameToken = this.consume(); if (nameToken.type !== "word") { throw new Error("Expected loop variable name"); @@ -965,6 +1033,31 @@ class Parser { : { type: "ForClause", name, body }; } + private parseCStyleLoop(): CStyleLoop { + const token = this.consume(); + if (token.type !== "arith-cmd") { + throw new Error("Expected (( )) in c-style for"); + } + // Split expr on ";" into init, cond, post + const parts = token.expr.split(";").map((s) => s.trim()); + const init = parts[0] || undefined; + const cond = parts[1] || undefined; + const post = parts[2] || undefined; + + if (this.matchOp(";")) { + this.consume(); + } + this.skipSeparators(); + this.consumeKeyword("do"); + const body = this.parseStatementsUntilKeyword(["done"]); + this.consumeKeyword("done"); + const loop: CStyleLoop = { type: "CStyleLoop", body }; + if (init !== undefined) loop.init = init; + if (cond !== undefined) loop.cond = cond; + if (post !== undefined) loop.post = post; + return loop; + } + private parseSelectClause(): SelectClause { this.consumeKeyword("select"); const nameToken = this.consume(); @@ -1140,6 +1233,111 @@ class Parser { return body; } + private matchDeclKeyword(): boolean { + const token = this.peek(); + if (token?.type !== "word" || token.parts.length !== 1) return false; + const part = token.parts[0]; + return part?.type === "lit" && DECL_KEYWORDS.has(part.value); + } + + private parseDeclClause(): DeclClause { + const variantToken = this.consume(); + if (variantToken.type !== "word") { + throw new Error("Expected decl keyword"); + } + const variant = tokenPartsText(variantToken.parts) as DeclClause["variant"]; + + const args: Word[] = []; + const assigns: Assignment[] = []; + const redirects: Redirect[] = []; + + while (true) { + if (this.matchRedir()) { + const token = this.consume(); + if (token.type !== "redir") { + throw new Error("Expected redirect token"); + } + const targetToken = this.consume(); + if (targetToken.type !== "word") { + throw new Error("Redirect must be followed by a word"); + } + const target = this.wordFromParts(targetToken.parts); + const redir: Redirect = token.fd + ? { type: "Redirect", op: token.op, fd: token.fd, target } + : { type: "Redirect", op: token.op, target }; + redirects.push(redir); + continue; + } + + if (this.matchWord()) { + const token = this.peek(); + if (!token || token.type !== "word") break; + + // tryParseAssignment consumes tokens itself if it matches + const assignment = this.tryParseAssignment(token.parts); + if (assignment) { + assigns.push(assignment); + continue; + } + + // Otherwise it's a plain arg (flag or name) + this.consume(); + args.push(this.wordFromParts(token.parts)); + continue; + } + + break; + } + + const decl: DeclClause = { type: "DeclClause", variant }; + if (args.length > 0) decl.args = args; + if (assigns.length > 0) decl.assigns = assigns; + if (redirects.length > 0) decl.redirects = redirects; + return decl; + } + + private parseLetClause(): LetClause { + this.consumeKeyword("let"); + const exprs: Word[] = []; + const redirects: Redirect[] = []; + + while (true) { + if (this.matchRedir()) { + const token = this.consume(); + if (token.type !== "redir") { + throw new Error("Expected redirect token"); + } + const targetToken = this.consume(); + if (targetToken.type !== "word") { + throw new Error("Redirect must be followed by a word"); + } + const target = this.wordFromParts(targetToken.parts); + const redir: Redirect = token.fd + ? { type: "Redirect", op: token.op, fd: token.fd, target } + : { type: "Redirect", op: token.op, target }; + redirects.push(redir); + continue; + } + + if (this.matchWord()) { + const token = this.consume(); + if (token.type !== "word") break; + exprs.push(this.wordFromParts(token.parts)); + continue; + } + + break; + } + + if (exprs.length === 0) { + throw new Error("let requires at least one expression"); + } + + const clause: LetClause = { type: "LetClause", exprs }; + if (redirects.length > 0) clause.redirects = redirects; + return clause; + } + private parseSimpleCommand(): SimpleCommand { const words: Word[] = []; const assignments: Assignment[] = []; @@ -1148,18 +1346,23 @@ class Parser { while (true) { if (this.matchWord()) { - const token = this.consume(); - if (token.type !== "word") { + const token = this.peek(); + if (!token || token.type !== "word") { throw new Error("Expected word token"); } - const word = this.wordFromParts(token.parts); - const assignment = this.assignmentFromParts(token.parts); - if (!sawWord && assignment) { - assignments.push(assignment); - } else { - sawWord = true; - words.push(word); + + // tryParseAssignment consumes tokens itself if it matches + if (!sawWord) { + const assignment = this.tryParseAssignment(token.parts); + if (assignment) { + assignments.push(assignment); + continue; + } } + + this.consume(); + sawWord = true; + words.push(this.wordFromParts(token.parts)); continue; } @@ -1276,39 +1479,127 @@ class Parser { }; } - private assignmentFromParts(parts: TokenWordPart[]): Assignment | undefined { - if (parts.length !== 1) { - return undefined; - } + /** + * Try to parse an assignment from the current token's parts. + * If it returns an assignment, it has already consumed all relevant tokens + * (the word, and optionally the array `(...)` symbols). + * If it returns undefined, nothing was consumed. + */ + private tryParseAssignment(parts: TokenWordPart[]): Assignment | undefined { + if (parts.length !== 1) return undefined; const part = parts[0]; - if (!part || part.type !== "lit") { - return undefined; - } + if (!part || part.type !== "lit") return undefined; const raw = part.value; - const eqIndex = raw.indexOf("="); - if (eqIndex <= 0) { - return undefined; + + // Detect NAME= or NAME+= + let append = false; + let eqIndex = raw.indexOf("+="); + if (eqIndex > 0) { + append = true; + } else { + eqIndex = raw.indexOf("="); } + if (eqIndex <= 0) return undefined; + const name = raw.slice(0, eqIndex); - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { - return undefined; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return undefined; + + const afterEq = raw.slice(eqIndex + (append ? 2 : 1)); + + // Check for array assignment: NAME=( ... ) or NAME+=( ... ) + const nextToken = this.peekToken(1); + if ( + afterEq === "" && + nextToken?.type === "symbol" && + (nextToken as { value: string }).value === "(" + ) { + this.consume(); // consume the NAME= word + return this.parseArrayAssignment(name, append); } - const value = raw.slice(eqIndex + 1); - return value.length === 0 - ? { type: "Assignment", name } - : { - type: "Assignment", - name, - value: { + + // Consume the word token + this.consume(); + + const assignment: Assignment = { type: "Assignment", name }; + if (append) assignment.append = true; + if (afterEq.length > 0) { + assignment.value = { + type: "Word", + parts: [{ type: "Literal", value: afterEq }], + }; + } + return assignment; + } + + private parseArrayAssignment(name: string, append: boolean): Assignment { + this.consumeSymbol("("); + const elems: ArrayElem[] = []; + + while (!this.matchSymbol(")")) { + if (this.isEof()) { + throw new Error("Unclosed array expression"); + } + if (this.matchOp(";")) { + this.consume(); + continue; + } + if (this.matchComment()) { + this.consumeComment(); + continue; + } + + const token = this.consume(); + if (token.type !== "word") { + throw new Error("Expected word in array expression"); + } + + const text = tokenPartsText(token.parts); + + // Check for [index]=value pattern + const indexMatch = text.match(/^\[([^\]]+)\]=(.*)$/); + if (indexMatch) { + const indexStr = indexMatch[1] as string; + const valStr = indexMatch[2] as string; + const elem: ArrayElem = { + type: "ArrayElem", + index: { type: "Word", - parts: [{ type: "Literal", value }], + parts: [{ type: "Literal", value: indexStr }], }, }; + if (valStr.length > 0) { + elem.value = { + type: "Word", + parts: [{ type: "Literal", value: valStr }], + }; + } + elems.push(elem); + } else { + elems.push({ + type: "ArrayElem", + value: this.wordFromParts(token.parts), + }); + } + } + + this.consumeSymbol(")"); + + const assignment: Assignment = { + type: "Assignment", + name, + array: { type: "ArrayExpr", elems }, + }; + if (append) assignment.append = true; + return assignment; } private skipSeparators() { - while (this.matchOp(";")) { - this.consume(); + while (this.matchOp(";") || this.matchComment()) { + if (this.matchComment()) { + this.consumeComment(); + } else { + this.consume(); + } } } @@ -1407,6 +1698,17 @@ class Parser { return token?.type === "op" && token.value === value; } + private matchComment(): boolean { + return this.peek()?.type === "comment"; + } + + private consumeComment() { + const token = this.consume(); + if (token.type === "comment") { + this.comments.push({ type: "Comment", text: token.text }); + } + } + private isEof() { return this.index >= this.tokens.length; } diff --git a/src/parser.test.ts b/src/parser.test.ts index 10e8454..28cfb89 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -23,7 +23,13 @@ type WordPart = | ArithExp | ProcSubst; type Word = { type: "Word"; parts: WordPart[] }; -type Assignment = { type: "Assignment"; name: string; value?: Word }; +type Assignment = { + type: "Assignment"; + name: string; + append?: boolean; + value?: Word; + array?: ArrayExpr; +}; type RedirOp = | ">" | "<" @@ -100,7 +106,11 @@ type Statement = { background?: boolean; negated?: boolean; }; -type Program = { type: "Program"; body: Statement[] }; +type Program = { + type: "Program"; + body: Statement[]; + comments?: CommentNode[]; +}; type Command = | SimpleCommand | Subshell @@ -116,7 +126,29 @@ type Command = | ArithCmd | CoprocClause | Pipeline - | Logical; + | Logical + | DeclClause + | LetClause + | CStyleLoop; + +type DeclClause = { + type: "DeclClause"; + variant: "declare" | "local" | "export" | "readonly" | "typeset" | "nameref"; + args?: Word[]; + assigns?: Assignment[]; + redirects?: Redirect[]; +}; +type LetClause = { type: "LetClause"; exprs: Word[]; redirects?: Redirect[] }; +type CStyleLoop = { + type: "CStyleLoop"; + init?: string; + cond?: string; + post?: string; + body: Statement[]; +}; +type ArrayExpr = { type: "ArrayExpr"; elems: ArrayElem[] }; +type ArrayElem = { type: "ArrayElem"; index?: Word; value?: Word }; +type CommentNode = { type: "Comment"; text: string }; const lit = (value: string): Literal => ({ type: "Literal", value }); const sgl = (value: string): SglQuoted => ({ type: "SglQuoted", value }); @@ -153,10 +185,58 @@ const simple = (...words: string[]): SimpleCommand => ({ type: "SimpleCommand", words: words.map(word), }); -const assign = (name: string, value?: string): Assignment => - value === undefined - ? { type: "Assignment", name } - : { type: "Assignment", name, value: word(value) }; +const assign = ( + name: string, + value?: string, + opts?: { append?: boolean; array?: ArrayExpr }, +): Assignment => { + const a: Assignment = { type: "Assignment", name }; + if (opts?.append) a.append = true; + if (value !== undefined) a.value = word(value); + if (opts?.array) a.array = opts.array; + return a; +}; +const arrayExpr = (...elems: ArrayElem[]): ArrayExpr => ({ + type: "ArrayExpr", + elems, +}); +const arrayElem = (value?: string, index?: string): ArrayElem => { + const e: ArrayElem = { type: "ArrayElem" }; + if (value !== undefined) e.value = word(value); + if (index !== undefined) e.index = word(index); + return e; +}; +const declClause = ( + variant: DeclClause["variant"], + opts?: { + args?: Word[]; + assigns?: Assignment[]; + redirects?: Redirect[]; + }, +): DeclClause => { + const d: DeclClause = { type: "DeclClause", variant }; + if (opts?.args) d.args = opts.args; + if (opts?.assigns) d.assigns = opts.assigns; + if (opts?.redirects) d.redirects = opts.redirects; + return d; +}; +const letClause = (exprs: Word[], redirects?: Redirect[]): LetClause => { + const l: LetClause = { type: "LetClause", exprs }; + if (redirects) l.redirects = redirects; + return l; +}; +const cStyleLoop = ( + body: Statement[], + init?: string, + cond?: string, + post?: string, +): CStyleLoop => { + const c: CStyleLoop = { type: "CStyleLoop", body }; + if (init !== undefined) c.init = init; + if (cond !== undefined) c.cond = cond; + if (post !== undefined) c.post = post; + return c; +}; const redirect = (op: RedirOp, target: string, fd?: string): Redirect => fd === undefined ? { type: "Redirect", op, target: word(target) } @@ -991,6 +1071,312 @@ describe("parse (phase 21: coproc)", () => { }); }); +describe("parse (phase 22: decl clause)", () => { + it("parses export with assignment", () => { + expect(parse("export FOO=bar")).toEqual({ + ast: program( + stmt( + declClause("export", { + assigns: [assign("FOO", "bar")], + }), + ), + ), + }); + }); + + it("parses export with multiple names", () => { + expect(parse("export FOO BAR")).toEqual({ + ast: program( + stmt( + declClause("export", { + args: [word("FOO"), word("BAR")], + }), + ), + ), + }); + }); + + it("parses local with assignment", () => { + expect(parse("local x=1")).toEqual({ + ast: program( + stmt( + declClause("local", { + assigns: [assign("x", "1")], + }), + ), + ), + }); + }); + + it("parses declare with flags", () => { + expect(parse("declare -r FOO=bar")).toEqual({ + ast: program( + stmt( + declClause("declare", { + args: [word("-r")], + assigns: [assign("FOO", "bar")], + }), + ), + ), + }); + }); + + it("parses readonly with names", () => { + expect(parse("readonly X Y")).toEqual({ + ast: program( + stmt( + declClause("readonly", { + args: [word("X"), word("Y")], + }), + ), + ), + }); + }); + + it("parses typeset with flag and assignment", () => { + expect(parse("typeset -i count=0")).toEqual({ + ast: program( + stmt( + declClause("typeset", { + args: [word("-i")], + assigns: [assign("count", "0")], + }), + ), + ), + }); + }); + + it("parses nameref", () => { + expect(parse("nameref ref=target")).toEqual({ + ast: program( + stmt( + declClause("nameref", { + assigns: [assign("ref", "target")], + }), + ), + ), + }); + }); + + it("parses export with redirect", () => { + expect(parse("export FOO=bar 2>/dev/null")).toEqual({ + ast: program( + stmt( + declClause("export", { + assigns: [assign("FOO", "bar")], + redirects: [redirect(">", "/dev/null", "2")], + }), + ), + ), + }); + }); + + it("parses declare -a with array", () => { + expect(parse("declare -a arr=(a b c)")).toEqual({ + ast: program( + stmt( + declClause("declare", { + args: [word("-a")], + assigns: [ + assign("arr", undefined, { + array: arrayExpr( + arrayElem("a"), + arrayElem("b"), + arrayElem("c"), + ), + }), + ], + }), + ), + ), + }); + }); +}); + +describe("parse (phase 23: append assignment)", () => { + it("parses append assignment", () => { + expect(parse("PATH+=/usr/local/bin echo hi")).toEqual({ + ast: program( + stmt({ + type: "SimpleCommand", + words: [word("echo"), word("hi")], + assignments: [assign("PATH", "/usr/local/bin", { append: true })], + }), + ), + }); + }); + + it("parses standalone append assignment", () => { + expect(parse("arr+=value")).toEqual({ + ast: program( + stmt({ + type: "SimpleCommand", + assignments: [assign("arr", "value", { append: true })], + }), + ), + }); + }); +}); + +describe("parse (phase 24: array expressions)", () => { + it("parses simple array assignment", () => { + expect(parse("arr=(a b c)")).toEqual({ + ast: program( + stmt({ + type: "SimpleCommand", + assignments: [ + assign("arr", undefined, { + array: arrayExpr(arrayElem("a"), arrayElem("b"), arrayElem("c")), + }), + ], + }), + ), + }); + }); + + it("parses indexed array assignment", () => { + expect(parse("arr=([0]=x [1]=y)")).toEqual({ + ast: program( + stmt({ + type: "SimpleCommand", + assignments: [ + assign("arr", undefined, { + array: arrayExpr(arrayElem("x", "0"), arrayElem("y", "1")), + }), + ], + }), + ), + }); + }); + + it("parses associative array assignment", () => { + expect(parse("arr=([key]=val [other]=thing)")).toEqual({ + ast: program( + stmt({ + type: "SimpleCommand", + assignments: [ + assign("arr", undefined, { + array: arrayExpr( + arrayElem("val", "key"), + arrayElem("thing", "other"), + ), + }), + ], + }), + ), + }); + }); + + it("parses empty array", () => { + expect(parse("arr=()")).toEqual({ + ast: program( + stmt({ + type: "SimpleCommand", + assignments: [assign("arr", undefined, { array: arrayExpr() })], + }), + ), + }); + }); + + it("parses append array", () => { + expect(parse("arr+=(x y)")).toEqual({ + ast: program( + stmt({ + type: "SimpleCommand", + assignments: [ + assign("arr", undefined, { + append: true, + array: arrayExpr(arrayElem("x"), arrayElem("y")), + }), + ], + }), + ), + }); + }); +}); + +describe("parse (phase 25: c-style for loop)", () => { + it("parses c-style for loop", () => { + const result = parse("for ((i=0; i<10; i++)); do echo $i; done"); + expect(result.ast.body).toHaveLength(1); + const command = result.ast.body[0]?.command as CStyleLoop; + expect(command.type).toBe("CStyleLoop"); + expect(command.init).toBe("i=0"); + expect(command.cond).toBe("i<10"); + expect(command.post).toBe("i++"); + expect(command.body).toHaveLength(1); + }); + + it("parses c-style for loop with empty parts", () => { + expect(parse("for ((;;)); do echo loop; done")).toEqual({ + ast: program(stmt(cStyleLoop([stmt(simple("echo", "loop"))]))), + }); + }); + + it("parses c-style for loop with only condition", () => { + const result = parse("for (( ; i<5; )); do echo $i; done"); + const command = result.ast.body[0]?.command as CStyleLoop; + expect(command.type).toBe("CStyleLoop"); + expect(command.init).toBeUndefined(); + expect(command.cond).toBe("i<5"); + expect(command.post).toBeUndefined(); + expect(command.body).toHaveLength(1); + }); +}); + +describe("parse (phase 26: let clause)", () => { + it("parses let with single expression", () => { + expect(parse("let i++")).toEqual({ + ast: program(stmt(letClause([word("i++")]))), + }); + }); + + it("parses let with multiple expressions", () => { + expect(parse("let i++ j=2")).toEqual({ + ast: program(stmt(letClause([word("i++"), word("j=2")]))), + }); + }); + + it("parses let with redirect", () => { + expect(parse("let x=1 2>/dev/null")).toEqual({ + ast: program( + stmt(letClause([word("x=1")], [redirect(">", "/dev/null", "2")])), + ), + }); + }); +}); + +describe("parse (phase 27: comments)", () => { + it("does not include comments by default", () => { + const result = parse("echo hi # a comment"); + expect(result.ast.comments).toBeUndefined(); + }); + + it("collects comments when keepComments is true", () => { + const result = parse("echo hi # a comment", { keepComments: true }); + expect(result.ast.comments).toEqual([ + { type: "Comment", text: " a comment" }, + ]); + }); + + it("collects multiple comments", () => { + const result = parse("# first\necho hi\n# second", { + keepComments: true, + }); + expect(result.ast.comments).toEqual([ + { type: "Comment", text: " first" }, + { type: "Comment", text: " second" }, + ]); + }); + + it("collects inline comment after semicolon", () => { + const result = parse("echo hi; # trailing", { keepComments: true }); + expect(result.ast.comments).toEqual([ + { type: "Comment", text: " trailing" }, + ]); + }); +}); + // Real-world guardrail validation tests. // The guardrails extension blocks commands matching /\bnpm\b/ on the full // command string. This causes false positives when "npm" appears in arguments, -- 2.51.2