OverviewFilesCommitsIssuesDocsReleasesPeers

mirall-cli / node_modules / unbash / dist / parser.js

1091 lines · javascript ref:
import { LexContext, Token, Lexer, TokenValue } from "./lexer.js";
import { parseArithmeticExpression, drainArithCmdExps } from "./arithmetic.js";
import { computeWordParts, computeHereDocBodyParts } from "./parts.js";
import { WordImpl } from "./word.js";
WordImpl._resolveWord = computeWordParts;
WordImpl._resolveHeredocBody = computeHereDocBodyParts;
class ArithmeticCommandImpl {
    type = "ArithmeticCommand";
    pos;
    end;
    body;
    #expression = null;
    constructor(pos, end, body) {
        this.pos = pos;
        this.end = end;
        this.body = body;
    }
    get expression() {
        if (this.#expression === null) {
            this.#expression = parseArithmeticExpression(this.body, this.pos + 2) ?? undefined;
            resolveDrainedArithCmdExps();
        }
        return this.#expression;
    }
    set expression(v) {
        this.#expression = v ?? undefined;
    }
}
class ArithmeticForImpl {
    type = "ArithmeticFor";
    pos;
    end;
    body;
    #initStr;
    #testStr;
    #updateStr;
    #initPos;
    #testPos;
    #updatePos;
    #initialize = null;
    #test = null;
    #update = null;
    constructor(pos, end, body, initStr, testStr, updateStr, initPos, testPos, updatePos) {
        this.pos = pos;
        this.end = end;
        this.body = body;
        this.#initStr = initStr;
        this.#testStr = testStr;
        this.#updateStr = updateStr;
        this.#initPos = initPos;
        this.#testPos = testPos;
        this.#updatePos = updatePos;
    }
    get initialize() {
        if (this.#initialize === null) {
            if (this.#initStr) {
                const expr = parseArithmeticExpression(this.#initStr);
                if (expr)
                    offsetArith(expr, this.#initPos);
                resolveDrainedArithCmdExps();
                this.#initialize = expr ?? undefined;
            }
            else {
                this.#initialize = undefined;
            }
        }
        return this.#initialize;
    }
    set initialize(v) {
        this.#initialize = v ?? undefined;
    }
    get test() {
        if (this.#test === null) {
            if (this.#testStr) {
                const expr = parseArithmeticExpression(this.#testStr);
                if (expr)
                    offsetArith(expr, this.#testPos);
                resolveDrainedArithCmdExps();
                this.#test = expr ?? undefined;
            }
            else {
                this.#test = undefined;
            }
        }
        return this.#test;
    }
    set test(v) {
        this.#test = v ?? undefined;
    }
    get update() {
        if (this.#update === null) {
            if (this.#updateStr) {
                const expr = parseArithmeticExpression(this.#updateStr);
                if (expr)
                    offsetArith(expr, this.#updatePos);
                resolveDrainedArithCmdExps();
                this.#update = expr ?? undefined;
            }
            else {
                this.#update = undefined;
            }
        }
        return this.#update;
    }
    set update(v) {
        this.#update = v ?? undefined;
    }
}
const CASE_TERMINATORS = {
    [Token.DoubleSemi]: ";;",
    [Token.SemiAmp]: ";&",
    [Token.DoubleSemiAmp]: ";;&",
};
const REDIRECT_OPS = {
    ">": ">",
    ">>": ">>",
    "<": "<",
    "<<": "<<",
    "<<-": "<<-",
    "<<<": "<<<",
    "<>": "<>",
    "<&": "<&",
    ">&": ">&",
    ">|": ">|",
    "&>": "&>",
    "&>>": "&>>",
};
function offsetArith(node, base) {
    node.pos += base;
    node.end += base;
    switch (node.type) {
        case "ArithmeticBinary":
            offsetArith(node.left, base);
            offsetArith(node.right, base);
            break;
        case "ArithmeticUnary":
            offsetArith(node.operand, base);
            break;
        case "ArithmeticTernary":
            offsetArith(node.test, base);
            offsetArith(node.consequent, base);
            offsetArith(node.alternate, base);
            break;
        case "ArithmeticGroup":
            offsetArith(node.expression, base);
            break;
    }
}
function resolveDrainedArithCmdExps() {
    const list = drainArithCmdExps();
    if (!list)
        return;
    for (const node of list) {
        if (node.inner !== undefined) {
            node.script = parse(node.inner);
            node.inner = undefined;
        }
    }
}
// Lookup tables for O(1) token classification (replaces sequential comparisons)
const listTerminators = new Uint8Array(37);
listTerminators[Token.EOF] = 1;
listTerminators[Token.RParen] = 1;
listTerminators[Token.RBrace] = 1;
listTerminators[Token.Then] = 1;
listTerminators[Token.Else] = 1;
listTerminators[Token.Elif] = 1;
listTerminators[Token.Fi] = 1;
listTerminators[Token.Do] = 1;
listTerminators[Token.Done] = 1;
listTerminators[Token.Esac] = 1;
listTerminators[Token.DoubleSemi] = 1;
listTerminators[Token.SemiAmp] = 1;
listTerminators[Token.DoubleSemiAmp] = 1;
const commandStarts = new Uint8Array(37);
commandStarts[Token.Word] = 1;
commandStarts[Token.Assignment] = 1;
commandStarts[Token.Bang] = 1;
commandStarts[Token.LParen] = 1;
commandStarts[Token.LBrace] = 1;
commandStarts[Token.DblLBracket] = 1;
commandStarts[Token.If] = 1;
commandStarts[Token.For] = 1;
commandStarts[Token.While] = 1;
commandStarts[Token.Until] = 1;
commandStarts[Token.Case] = 1;
commandStarts[Token.Function] = 1;
commandStarts[Token.Select] = 1;
commandStarts[Token.ArithCmd] = 1;
commandStarts[Token.Coproc] = 1;
commandStarts[Token.Redirect] = 1;
const UNARY_TEST_OPS = {
    "-a": 1,
    "-b": 1,
    "-c": 1,
    "-d": 1,
    "-e": 1,
    "-f": 1,
    "-g": 1,
    "-h": 1,
    "-k": 1,
    "-p": 1,
    "-r": 1,
    "-s": 1,
    "-t": 1,
    "-u": 1,
    "-v": 1,
    "-w": 1,
    "-x": 1,
    "-z": 1,
    "-n": 1,
    "-N": 1,
    "-S": 1,
    "-L": 1,
    "-G": 1,
    "-O": 1,
    "-R": 1,
};
const BINARY_TEST_OPS = {
    "==": 1,
    "!=": 1,
    "=~": 1,
    "=": 1,
    "-eq": 1,
    "-ne": 1,
    "-lt": 1,
    "-le": 1,
    "-gt": 1,
    "-ge": 1,
    "-nt": 1,
    "-ot": 1,
    "-ef": 1,
    "<": 1,
    ">": 1,
};
const EMPTY_PREFIX = [];
const EMPTY_SUFFIX = [];
const EMPTY_REDIRECTS = [];
export function parse(source) {
    return new Parser(source, 0, source.length).run();
}
// Parse a [start, end) window of `source` in place, so the resulting nodes index the original
// source directly. Used to resolve substitution scripts with absolute offsets; not public API.
export function parseRegion(source, start, end) {
    return new Parser(source, start, end).run();
}
class Parser {
    tok;
    source;
    start;
    end;
    errors = [];
    _redirects = [];
    constructor(source, start, end) {
        this.tok = new Lexer(source, start, end);
        this.source = source;
        this.start = start;
        this.end = end;
    }
    run() {
        const start = this.start;
        let shebang;
        if (start === 0 && this.source.charCodeAt(0) === 35 && this.source.charCodeAt(1) === 33) {
            const nl = this.source.indexOf("\n");
            shebang = nl === -1 ? this.source : this.source.slice(0, nl);
        }
        const commands = this.list();
        const lexerErrors = this.tok._errors;
        if (lexerErrors !== null) {
            for (let i = 0; i < lexerErrors.length; i++)
                this.errors.push(lexerErrors[i]);
        }
        const result = {
            type: "Script",
            pos: start,
            end: this.end,
            shebang,
            commands,
            errors: this.errors.length > 0 ? this.errors : undefined,
        };
        return result;
    }
    error(message, pos) {
        this.errors.push({ message, pos });
    }
    skipSemi() {
        if (this.tok.peek(LexContext.Normal).token === Token.Semi)
            this.tok.next(LexContext.Normal);
    }
    accept(token, ctx = LexContext.Normal) {
        if (this.tok.peek(ctx).token === token)
            return this.tok.next(ctx);
        return null;
    }
    acceptEnd(token, ctx = LexContext.Normal) {
        if (this.tok.peek(ctx).token === token)
            return this.tok.next(ctx).end;
        return -1;
    }
    skipNewlines(ctx = LexContext.Normal) {
        while (this.tok.peek(ctx).token === Token.Newline)
            this.tok.next(ctx);
    }
    makeStatement(command, redirects) {
        const end = redirects.length > 0 ? redirects[redirects.length - 1].end : command.end;
        return {
            type: "Statement",
            pos: command.pos,
            end,
            command,
            background: undefined,
            redirects,
        };
    }
    // list := and_or ((';' | '&' | NEWLINE) and_or)* [';' | '&' | NEWLINE]
    list() {
        const commands = [];
        this.skipNewlines(LexContext.CommandStart);
        let t = this.tok.peek(LexContext.CommandStart).token;
        if (listTerminators[t] || !commandStarts[t])
            return commands;
        const first = this.andOr();
        if (first) {
            const redirects = this._redirects;
            this._redirects = [];
            commands.push(this.makeStatement(first, redirects));
        }
        for (;;) {
            t = this.tok.peek(LexContext.Normal).token;
            if (t !== Token.Semi && t !== Token.Newline && t !== Token.Amp)
                break;
            const isBackground = t === Token.Amp;
            const sepEnd = this.tok.next(LexContext.Normal).end;
            if (isBackground) {
                const stmt = commands[commands.length - 1];
                stmt.background = true;
                stmt.end = sepEnd;
            }
            this.skipNewlines(LexContext.CommandStart);
            t = this.tok.peek(LexContext.CommandStart).token;
            if (listTerminators[t] || !commandStarts[t])
                break;
            const node = this.andOr();
            if (node) {
                const redirects = this._redirects;
                this._redirects = [];
                commands.push(this.makeStatement(node, redirects));
            }
        }
        return commands;
    }
    // and_or := pipeline (('&&' | '||') newlines pipeline)*
    andOr() {
        const first = this.pipeline();
        if (!first)
            return null;
        let t = this.tok.peek(LexContext.Normal).token;
        if (t !== Token.And && t !== Token.Or)
            return first;
        // Wrap first pipeline with any pending redirects before creating AndOr
        let wrappedFirst = first;
        if (this._redirects.length > 0) {
            wrappedFirst = this.makeStatement(first, this._redirects);
            this._redirects = [];
        }
        const commands = [wrappedFirst];
        const operators = [];
        do {
            operators.push(this.tok.next(LexContext.Normal).token === Token.And ? "&&" : "||");
            this.skipNewlines(LexContext.CommandStart);
            const next = this.pipeline();
            if (!next)
                break;
            commands.push(next);
            t = this.tok.peek(LexContext.Normal).token;
        } while (t === Token.And || t === Token.Or);
        return {
            type: "AndOr",
            pos: first.pos,
            end: commands[commands.length - 1].end,
            commands,
            operators,
        };
    }
    wrapCompoundRedirects(node) {
        const redirects = this._redirects;
        this._redirects = [];
        if (redirects.length === 0)
            return node;
        return this.makeStatement(node, redirects);
    }
    // pipeline := ['time' ['-p']] ['!'] command ('|' newlines command)*
    pipeline() {
        let time = false;
        let pipelinePos = 0;
        if (this.tok.peek(LexContext.CommandStart).token === Token.Word &&
            this.tok.peek(LexContext.CommandStart).value === "time") {
            time = true;
            pipelinePos = this.tok.next(LexContext.CommandStart).pos;
            if (this.tok.peek(LexContext.CommandStart).token === Token.Word &&
                this.tok.peek(LexContext.CommandStart).value === "-p")
                this.tok.next(LexContext.CommandStart);
        }
        const negated = this.tok.peek(LexContext.CommandStart).token === Token.Bang;
        if (negated) {
            if (!time)
                pipelinePos = this.tok.peek(LexContext.CommandStart).pos;
            this.tok.next(LexContext.CommandStart);
        }
        const first = this.command();
        if (!first) {
            if (time || negated) {
                const pipeline = {
                    type: "Pipeline",
                    pos: pipelinePos,
                    end: pipelinePos,
                    commands: [],
                    negated: negated ? true : undefined,
                    operators: [],
                    time: time ? true : undefined,
                };
                return pipeline;
            }
            return null;
        }
        if (!time && !negated)
            pipelinePos = first.pos;
        const commands = [first];
        const operators = [];
        // Save _redirects from first command — only wrap in Statement if piped
        let firstRedirects = this._redirects;
        this._redirects = [];
        while (this.tok.peek(LexContext.Normal).token === Token.Pipe) {
            if (commands.length === 1 && firstRedirects.length > 0) {
                commands[0] = this.makeStatement(first, firstRedirects);
                firstRedirects = [];
            }
            const pipeVal = this.tok.next(LexContext.Normal).value;
            operators.push(pipeVal === "|&" ? "|&" : "|");
            this.skipNewlines(LexContext.CommandStart);
            const cmd = this.command();
            if (cmd)
                commands.push(this.wrapCompoundRedirects(cmd));
        }
        if (commands.length === 1 && !negated && !time) {
            // Pass redirects up for list() to consume
            this._redirects = firstRedirects;
            return commands[0];
        }
        // Wrap first command's compound redirects in Statement if needed
        if (firstRedirects.length > 0) {
            commands[0] = this.makeStatement(first, firstRedirects);
        }
        const pipeline = {
            type: "Pipeline",
            pos: pipelinePos,
            end: commands[commands.length - 1].end,
            commands,
            negated: negated ? true : undefined,
            operators,
            time: time ? true : undefined,
        };
        return pipeline;
    }
    // command := compound_command | function_def | simple_command
    command() {
        switch (this.tok.peek(LexContext.CommandStart).token) {
            case Token.LParen:
                return this.subshell();
            case Token.LBrace:
                return this.braceGroup();
            case Token.If:
                return this.ifClause();
            case Token.For:
                return this.forClause();
            case Token.While:
                return this.whileClause();
            case Token.Until:
                return this.untilClause();
            case Token.Case:
                return this.caseClause();
            case Token.Function:
                return this.functionDef();
            case Token.Select:
                return this.selectClause();
            case Token.DblLBracket:
                return this.testCommand();
            case Token.ArithCmd:
                return this.arithCommand();
            case Token.Coproc:
                return this.coprocCommand();
            case Token.Word:
            case Token.Assignment:
            case Token.Redirect:
                return this.simpleCommandOrFunction();
            default:
                return null;
        }
    }
    collectTrailingRedirects() {
        let redirects = [];
        while (this.tok.peek(LexContext.Normal).token === Token.Redirect) {
            redirects = this.collectRedirect(redirects, LexContext.Normal);
        }
        return redirects;
    }
    // arith_command := (( expr ))
    arithCommand() {
        const tok = this.tok.next(LexContext.CommandStart);
        this._redirects = this.collectTrailingRedirects();
        return new ArithmeticCommandImpl(tok.pos, tok.end, tok.value);
    }
    // coproc := COPROC [name] command [redirections]
    coprocCommand() {
        const startTok = this.tok.next(LexContext.CommandStart);
        const pos = startTok.pos;
        const startEnd = startTok.end;
        const t = this.tok.peek(LexContext.CommandStart);
        // If next token starts a compound command, no name — parse full pipeline
        if (t.token !== Token.Word && t.token !== Token.Assignment && t.token !== Token.Redirect) {
            const body = this.pipeline() ?? {
                type: "Command",
                pos,
                end: startEnd,
                name: undefined,
                prefix: EMPTY_PREFIX,
                suffix: EMPTY_SUFFIX,
                redirects: EMPTY_REDIRECTS,
            };
            const bodyRedirects = this._redirects;
            this._redirects = [];
            const redirects = this.collectTrailingRedirects();
            const allRedirects = [...bodyRedirects, ...redirects];
            const end = allRedirects.length > 0 ? allRedirects[allRedirects.length - 1].end : body.end;
            return { type: "Coproc", pos, end, name: undefined, body, redirects: allRedirects };
        }
        // Consume first word as tentative name
        const tentativeWord = this.toWord(this.tok.next(LexContext.CommandStart));
        // Try to parse what follows as a pipeline
        const body = this.pipeline();
        if (body === null) {
            const cmd = {
                type: "Command",
                pos: tentativeWord.pos,
                end: tentativeWord.end,
                name: tentativeWord,
                prefix: EMPTY_PREFIX,
                suffix: EMPTY_SUFFIX,
                redirects: EMPTY_REDIRECTS,
            };
            const redirects = this.collectTrailingRedirects();
            const end = redirects.length > 0 ? redirects[redirects.length - 1].end : cmd.end;
            return { type: "Coproc", pos, end, name: undefined, body: cmd, redirects };
        }
        if (body.type === "Command") {
            const cmd = body;
            if (cmd.name) {
                cmd.suffix = [cmd.name, ...cmd.suffix];
            }
            cmd.name = tentativeWord;
            cmd.pos = tentativeWord.pos;
            const redirects = this.collectTrailingRedirects();
            const end = redirects.length > 0 ? redirects[redirects.length - 1].end : cmd.end;
            return { type: "Coproc", pos, end, name: undefined, body: cmd, redirects };
        }
        // Pipeline or compound command — tentative "name" IS the coproc name
        const bodyRedirects = this._redirects;
        this._redirects = [];
        const redirects = this.collectTrailingRedirects();
        const allRedirects = [...bodyRedirects, ...redirects];
        const end = allRedirects.length > 0 ? allRedirects[allRedirects.length - 1].end : body.end;
        return { type: "Coproc", pos, end, name: tentativeWord, body, redirects: allRedirects };
    }
    // subshell := '(' list ')'
    subshell() {
        const pos = this.tok.next(LexContext.CommandStart).pos;
        const commands = this.list();
        const closeEnd = this.acceptEnd(Token.RParen, LexContext.Normal);
        if (closeEnd < 0)
            this.error("expected ')' to close subshell", this.tok.getPos());
        const end = closeEnd >= 0 ? closeEnd : pos;
        this._redirects = this.collectTrailingRedirects();
        return { type: "Subshell", pos, end, body: this.makeCompoundList(commands) };
    }
    // brace_group := '{' list '}'
    braceGroup() {
        const pos = this.tok.next(LexContext.CommandStart).pos;
        const commands = this.list();
        const closeEnd = this.acceptEnd(Token.RBrace, LexContext.Normal);
        if (closeEnd < 0)
            this.error("expected '}' to close brace group", this.tok.getPos());
        const end = closeEnd >= 0 ? closeEnd : pos;
        this._redirects = this.collectTrailingRedirects();
        return { type: "BraceGroup", pos, end, body: this.makeCompoundList(commands) };
    }
    // if_clause := IF list THEN list (ELIF list THEN list)* [ELSE list] FI
    ifClause() {
        const pos = this.tok.next(LexContext.CommandStart).pos;
        const clause = this.makeCompoundList(this.list());
        this.skipSemi();
        if (!this.accept(Token.Then, LexContext.CommandStart))
            this.error("expected 'then'", this.tok.getPos());
        const then_ = this.makeCompoundList(this.list());
        this.skipSemi();
        let else_;
        let end;
        if (this.tok.peek(LexContext.CommandStart).token === Token.Elif) {
            else_ = this.ifClause();
            end = else_.end; // elif's ifClause already consumed fi
        }
        else if (this.accept(Token.Else, LexContext.CommandStart)) {
            else_ = this.makeCompoundList(this.list());
            this.skipSemi();
            const closeEnd = this.acceptEnd(Token.Fi, LexContext.CommandStart);
            if (closeEnd < 0)
                this.error("expected 'fi' to close 'if'", this.tok.getPos());
            end = closeEnd >= 0 ? closeEnd : pos;
        }
        else {
            const closeEnd = this.acceptEnd(Token.Fi, LexContext.CommandStart);
            if (closeEnd < 0)
                this.error("expected 'fi' to close 'if'", this.tok.getPos());
            end = closeEnd >= 0 ? closeEnd : pos;
        }
        this._redirects = this.collectTrailingRedirects();
        return { type: "If", pos, end, clause, then: then_, else: else_ };
    }
    // for_clause := FOR word [IN word* (';'|NL)] DO list DONE
    //            | FOR '((' expr '))' [';'|NL] DO list DONE
    forClause() {
        const pos = this.tok.next(LexContext.CommandStart).pos;
        if (this.tok.peek(LexContext.Normal).token === Token.LParen) {
            return this.cStyleFor(pos);
        }
        const name = this.readWord(LexContext.Normal);
        const wordlist = [];
        this.skipNewlines(LexContext.CommandStart);
        if (this.tok.peek(LexContext.CommandStart).token === Token.In) {
            this.tok.next(LexContext.CommandStart);
            while (this.tok.peek(LexContext.Normal).token === Token.Word) {
                wordlist.push(this.readWord(LexContext.Normal));
            }
        }
        this.skipSemi();
        this.skipNewlines(LexContext.CommandStart);
        if (!this.accept(Token.Do, LexContext.CommandStart))
            this.error("expected 'do'", this.tok.getPos());
        const body = this.list();
        this.skipSemi();
        const closeEnd = this.acceptEnd(Token.Done, LexContext.CommandStart);
        if (closeEnd < 0)
            this.error("expected 'done' to close 'for'", this.tok.getPos());
        const end = closeEnd >= 0 ? closeEnd : pos;
        this._redirects = this.collectTrailingRedirects();
        return { type: "For", pos, end, name, wordlist, body: this.makeCompoundList(body) };
    }
    // C-style for: (( expr; expr; expr )) [;|NL] do list done | { list }
    cStyleFor(pos) {
        const [initStr, testStr, updateStr, initPos, testPos, updatePos] = this.tok.readCStyleForExprs();
        if (this.tok.peek(LexContext.CommandStart).token === Token.Semi)
            this.tok.next(LexContext.CommandStart);
        this.skipNewlines(LexContext.CommandStart);
        if (this.tok.peek(LexContext.CommandStart).token === Token.LBrace) {
            const bg = this.braceGroup();
            return new ArithmeticForImpl(pos, bg.end, bg.body, initStr, testStr, updateStr, initPos, testPos, updatePos);
        }
        if (!this.accept(Token.Do, LexContext.CommandStart))
            this.error("expected 'do'", this.tok.getPos());
        const body = this.list();
        const closeEnd = this.acceptEnd(Token.Done, LexContext.CommandStart);
        if (closeEnd < 0)
            this.error("expected 'done' to close 'for'", this.tok.getPos());
        const end = closeEnd >= 0 ? closeEnd : pos;
        this._redirects = this.collectTrailingRedirects();
        return new ArithmeticForImpl(pos, end, this.makeCompoundList(body), initStr, testStr, updateStr, initPos, testPos, updatePos);
    }
    whileClause() {
        return this.whileOrUntil("while");
    }
    untilClause() {
        return this.whileOrUntil("until");
    }
    whileOrUntil(kind) {
        const pos = this.tok.next(LexContext.CommandStart).pos;
        const clause = this.makeCompoundList(this.list());
        this.skipSemi();
        if (!this.accept(Token.Do, LexContext.CommandStart))
            this.error("expected 'do'", this.tok.getPos());
        const body = this.list();
        this.skipSemi();
        const closeEnd = this.acceptEnd(Token.Done, LexContext.CommandStart);
        if (closeEnd < 0)
            this.error(`expected 'done' to close '${kind}'`, this.tok.getPos());
        const end = closeEnd >= 0 ? closeEnd : pos;
        this._redirects = this.collectTrailingRedirects();
        return { type: "While", pos, end, kind, clause, body: this.makeCompoundList(body) };
    }
    // case_clause := CASE word IN (pattern) list (;; | ;& | ;;&) ... ESAC
    caseClause() {
        const pos = this.tok.next(LexContext.CommandStart).pos;
        const word = this.readWord(LexContext.Normal);
        this.skipNewlines(LexContext.CommandStart);
        if (!this.accept(Token.In, LexContext.CommandStart))
            this.error("expected 'in' after 'case' word", this.tok.getPos());
        this.skipNewlines(LexContext.CommandStart);
        const items = [];
        let t = this.tok.peek(LexContext.CommandStart).token;
        while (t !== Token.Esac && t !== Token.EOF) {
            const itemPos = this.tok.peek(LexContext.Normal).pos;
            this.accept(Token.LParen, LexContext.Normal);
            const pattern = [];
            t = this.tok.peek(LexContext.Normal).token;
            while (t !== Token.RParen && t !== Token.EOF) {
                if (t !== Token.Pipe)
                    pattern.push(this.toWord(this.tok.next(LexContext.Normal)));
                else
                    this.tok.next(LexContext.Normal);
                t = this.tok.peek(LexContext.Normal).token;
            }
            const rparenEnd = this.acceptEnd(Token.RParen, LexContext.Normal);
            const cmds = this.list();
            let itemEnd = rparenEnd >= 0 ? rparenEnd : itemPos;
            if (cmds.length > 0)
                itemEnd = cmds[cmds.length - 1].end;
            const item = {
                type: "CaseItem",
                pos: itemPos,
                end: itemEnd,
                pattern,
                body: this.makeCompoundList(cmds),
                terminator: undefined,
            };
            t = this.tok.peek(LexContext.CommandStart).token;
            if (t === Token.DoubleSemi || t === Token.SemiAmp || t === Token.DoubleSemiAmp) {
                const termTok = this.tok.next(LexContext.CommandStart);
                item.terminator = CASE_TERMINATORS[termTok.token];
                item.end = termTok.end;
            }
            items.push(item);
            this.skipNewlines(LexContext.CommandStart);
            t = this.tok.peek(LexContext.CommandStart).token;
        }
        const closeEnd = this.acceptEnd(Token.Esac, LexContext.CommandStart);
        if (closeEnd < 0)
            this.error("expected 'esac' to close 'case'", this.tok.getPos());
        const end = closeEnd >= 0 ? closeEnd : pos;
        this._redirects = this.collectTrailingRedirects();
        return { type: "Case", pos, end, word, items };
    }
    // select_clause := SELECT word [IN word* (';'|NL)] DO list DONE
    selectClause() {
        const pos = this.tok.next(LexContext.CommandStart).pos;
        const name = this.readWord(LexContext.Normal);
        const wordlist = [];
        this.skipNewlines(LexContext.CommandStart);
        if (this.tok.peek(LexContext.CommandStart).token === Token.In) {
            this.tok.next(LexContext.CommandStart);
            while (this.tok.peek(LexContext.Normal).token === Token.Word) {
                wordlist.push(this.readWord(LexContext.Normal));
            }
        }
        this.skipSemi();
        this.skipNewlines(LexContext.CommandStart);
        if (!this.accept(Token.Do, LexContext.CommandStart))
            this.error("expected 'do'", this.tok.getPos());
        const body = this.list();
        this.skipSemi();
        const closeEnd = this.acceptEnd(Token.Done, LexContext.CommandStart);
        if (closeEnd < 0)
            this.error("expected 'done' to close 'select'", this.tok.getPos());
        const end = closeEnd >= 0 ? closeEnd : pos;
        this._redirects = this.collectTrailingRedirects();
        return { type: "Select", pos, end, name, wordlist, body: this.makeCompoundList(body) };
    }
    // test_command := [[ test_expr ]]
    testCommand() {
        const pos = this.tok.next(LexContext.CommandStart).pos; // consume [[
        const expr = this.parseTestOr();
        const closeEnd = this.acceptEnd(Token.DblRBracket, LexContext.TestMode);
        if (closeEnd < 0 && this.tok.peek(LexContext.Normal).token === Token.EOF)
            this.error("expected ']]' to close '[['", this.tok.getPos());
        const end = closeEnd >= 0 ? closeEnd : pos;
        this._redirects = this.collectTrailingRedirects();
        return { type: "TestCommand", pos, end, expression: expr };
    }
    // test_or := test_and ('||' test_and)*
    parseTestOr() {
        let left = this.parseTestAnd();
        while (this.tok.peek(LexContext.TestMode).token === Token.Or) {
            this.tok.next(LexContext.TestMode);
            const right = this.parseTestAnd();
            left = {
                type: "TestLogical",
                pos: left.pos,
                end: right.end,
                operator: "||",
                left,
                right,
            };
        }
        return left;
    }
    // test_and := test_not ('&&' test_not)*
    parseTestAnd() {
        let left = this.parseTestNot();
        while (this.tok.peek(LexContext.TestMode).token === Token.And) {
            this.tok.next(LexContext.TestMode);
            const right = this.parseTestNot();
            left = {
                type: "TestLogical",
                pos: left.pos,
                end: right.end,
                operator: "&&",
                left,
                right,
            };
        }
        return left;
    }
    // test_not := '!' test_not | test_primary
    parseTestNot() {
        if (this.tok.peek(LexContext.TestMode).token === Token.Word && this.tok.peek(LexContext.TestMode).value === "!") {
            const notPos = this.tok.next(LexContext.TestMode).pos;
            const operand = this.parseTestNot();
            return { type: "TestNot", pos: notPos, end: operand.end, operand };
        }
        return this.parseTestPrimary();
    }
    // test_primary := '(' test_or ')' | unary_op word | word binary_op word | word
    parseTestPrimary() {
        // Grouped: ( expr )
        if (this.tok.peek(LexContext.TestMode).token === Token.LParen) {
            const openPos = this.tok.next(LexContext.TestMode).pos;
            const expr = this.parseTestOr();
            const closeEnd = this.acceptEnd(Token.RParen, LexContext.TestMode);
            if (closeEnd < 0)
                this.error("expected ')' to close test group", this.tok.getPos());
            const end = closeEnd >= 0 ? closeEnd : openPos;
            return { type: "TestGroup", pos: openPos, end, expression: expr };
        }
        const first = this.tok.next(LexContext.TestMode);
        const val = first.value;
        const firstPos = first.pos;
        const firstEnd = first.end;
        // Unary test: -op word
        if (UNARY_TEST_OPS[val] === 1) {
            const nt = this.tok.peek(LexContext.TestMode).token;
            if (nt === Token.Word) {
                const operand = this.readWord(LexContext.TestMode);
                return {
                    type: "TestUnary",
                    pos: firstPos,
                    end: operand.end,
                    operator: val,
                    operand,
                };
            }
        }
        // Check for binary op
        const nt = this.tok.peek(LexContext.TestMode);
        if (nt.token === Token.Word && BINARY_TEST_OPS[nt.value] === 1) {
            const op = this.tok.next(LexContext.TestMode).value;
            let right;
            if (op === "=~") {
                right = this.toWord(this.tok.readTestRegexWord());
            }
            else {
                right = this.readWord(LexContext.TestMode);
            }
            const left = this.toWordFromPosEnd(first, firstPos, firstEnd);
            return {
                type: "TestBinary",
                pos: firstPos,
                end: right.end,
                operator: op,
                left,
                right,
            };
        }
        // Standalone word (implicit -n test)
        const w = this.toWordFromPosEnd(first, firstPos, firstEnd);
        return { type: "TestUnary", pos: firstPos, end: w.end, operator: "-n", operand: w };
    }
    // function_def with 'function' keyword
    functionDef() {
        const pos = this.tok.next(LexContext.CommandStart).pos;
        const name = this.readWord(LexContext.Normal);
        if (this.tok.peek(LexContext.CommandStart).token === Token.LParen) {
            this.tok.next(LexContext.CommandStart);
            if (!this.accept(Token.RParen, LexContext.CommandStart))
                this.error("expected ')' after '('", this.tok.getPos());
        }
        this.skipNewlines(LexContext.CommandStart);
        const body = this.commandAsBody();
        const redirects = this._redirects;
        this._redirects = [];
        const end = redirects.length > 0 ? redirects[redirects.length - 1].end : body.end;
        return { type: "Function", pos, end, name, body, redirects };
    }
    // simple_command or function_def (word '(' ')' body)
    simpleCommandOrFunction() {
        const prefix = [];
        let redirects = [];
        let cmdPos = this.tok.peek(LexContext.CommandStart).pos;
        let lastEnd = cmdPos;
        while (this.tok.peek(LexContext.CommandStart).token === Token.Assignment) {
            const t = this.tok.next(LexContext.CommandStart);
            lastEnd = t.end;
            prefix.push(this.parseAssignment(t));
        }
        // Consume prefix redirects (e.g. "2>/dev/null cmd")
        while (this.tok.peek(LexContext.CommandStart).token === Token.Redirect) {
            redirects = this.collectRedirect(redirects, LexContext.CommandStart);
            lastEnd = redirects[redirects.length - 1].end;
        }
        if (this.tok.peek(LexContext.Normal).token !== Token.Word) {
            if (prefix.length > 0) {
                return {
                    type: "Command",
                    pos: cmdPos,
                    end: lastEnd,
                    name: undefined,
                    prefix,
                    suffix: EMPTY_SUFFIX,
                    redirects,
                };
            }
            return {
                type: "Command",
                pos: cmdPos,
                end: lastEnd,
                name: undefined,
                prefix: EMPTY_PREFIX,
                suffix: EMPTY_SUFFIX,
                redirects: EMPTY_REDIRECTS,
            };
        }
        const name = this.readWord(LexContext.Normal);
        lastEnd = name.end;
        // Check for function definition: word '(' ')' body
        if (this.tok.peek(LexContext.Normal).token === Token.LParen) {
            this.tok.next(LexContext.Normal);
            if (this.tok.peek(LexContext.Normal).token === Token.RParen) {
                this.tok.next(LexContext.Normal);
                this.skipNewlines(LexContext.CommandStart);
                const body = this.commandAsBody();
                const bodyRedirects = this._redirects;
                this._redirects = [];
                const end = bodyRedirects.length > 0 ? bodyRedirects[bodyRedirects.length - 1].end : body.end;
                return { type: "Function", pos: name.pos, end, name, body, redirects: bodyRedirects };
            }
        }
        const suffix = [];
        // Collect suffix words and redirects
        for (;;) {
            const st = this.tok.peek(LexContext.Normal).token;
            if (st === Token.Word || st === Token.Assignment) {
                const w = this.readWord(LexContext.Normal);
                suffix.push(w);
                lastEnd = w.end;
            }
            else if (st === Token.Redirect) {
                redirects = this.collectRedirect(redirects, LexContext.Normal);
                lastEnd = redirects[redirects.length - 1].end;
            }
            else {
                break;
            }
        }
        return { type: "Command", pos: cmdPos, end: lastEnd, name, prefix, suffix, redirects };
    }
    collectRedirect(redirects, ctx) {
        const t = this.tok.next(ctx);
        const tPos = t.pos;
        const tEnd = t.end;
        const r = {
            pos: tPos,
            end: tEnd,
            operator: REDIRECT_OPS[t.value] ?? ">",
            target: undefined,
            fileDescriptor: t.fileDescriptor,
            variableName: t.variableName,
            content: t.content,
            heredocQuoted: undefined,
            body: undefined,
        };
        if (t.content != null) {
            r.target = new WordImpl(t.content, t.targetPos, t.targetEnd, this.source);
        }
        if (t.value === "<<" || t.value === "<<-")
            this.tok.registerHereDocTarget(r);
        redirects.push(r);
        return redirects;
    }
    commandAsBody() {
        const t = this.tok.peek(LexContext.CommandStart).token;
        if (t === Token.LBrace)
            return this.braceGroup();
        if (t === Token.LParen)
            return this.subshell();
        const cmd = this.command();
        const p = this.tok.getPos();
        return cmd ?? { type: "CompoundList", pos: p, end: p, commands: [] };
    }
    readWord(ctx) {
        return this.toWord(this.tok.next(ctx));
    }
    toWord(tok) {
        return new WordImpl(this.source.slice(tok.pos, tok.end), tok.pos, tok.end, this.source);
    }
    toWordFromPosEnd(tok, pos, end) {
        return new WordImpl(this.source.slice(pos, end), pos, end, this.source);
    }
    parseAssignment(tok) {
        const text = this.source.slice(tok.pos, tok.end);
        const tokPos = tok.pos;
        const tokEnd = tok.end;
        const result = {
            type: "Assignment",
            pos: tokPos,
            end: tokEnd,
            text,
            name: undefined,
            value: undefined,
            append: undefined,
            index: undefined,
            array: undefined,
        };
        // Find the = sign, accounting for name, name[index], and += variants
        const eqIdx = text.indexOf("=");
        if (eqIdx <= 0)
            return result;
        let nameEnd = eqIdx;
        let append = false;
        let index;
        // Check for += (append)
        if (text.charCodeAt(eqIdx - 1) === 0x2b /* + */) {
            append = true;
            nameEnd = eqIdx - 1;
        }
        // Check for [index] before = or +=
        const bracketIdx = text.indexOf("[");
        if (bracketIdx > 0 && bracketIdx < nameEnd) {
            const rbracketIdx = text.indexOf("]", bracketIdx);
            if (rbracketIdx > bracketIdx && rbracketIdx + 1 === nameEnd) {
                index = text.slice(bracketIdx + 1, rbracketIdx);
                nameEnd = bracketIdx;
            }
        }
        const name = text.slice(0, nameEnd);
        result.name = name;
        if (append)
            result.append = true;
        if (index !== undefined)
            result.index = index;
        // Value portion starts after =
        const valStart = eqIdx + 1;
        const valueStart = tokPos + valStart;
        // Check for array assignment: value starts with (
        if (text.charCodeAt(valStart) === 0x28 /* ( */ && text.charCodeAt(text.length - 1) === 0x29 /* ) */) {
            const elements = this.parseArrayElements(valueStart + 1, tokEnd - 1);
            result.array = elements;
        }
        else {
            result.value = new WordImpl(text.slice(valStart), valueStart, tokEnd, this.source);
        }
        return result;
    }
    parseArrayElements(start, end) {
        const subTok = new Lexer(this.source, start, end);
        const elements = [];
        while (subTok.peek(LexContext.Normal).token !== Token.EOF) {
            if (subTok.peek(LexContext.Normal).token === Token.Newline) {
                subTok.next(LexContext.Normal);
                continue;
            }
            const t = subTok.next(LexContext.Normal);
            if (t.token === Token.Word || t.token === Token.Assignment) {
                elements.push(new WordImpl(this.source.slice(t.pos, t.end), t.pos, t.end, this.source));
            }
        }
        return elements;
    }
    makeCompoundList(commands) {
        const p = this.tok.getPos();
        const pos = commands.length > 0 ? commands[0].pos : p;
        const end = commands.length > 0 ? commands[commands.length - 1].end : p;
        return { type: "CompoundList", pos, end, commands };
    }
}