OverviewFilesCommitsIssuesDocsReleasesPeers

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

2322 lines · javascript ref:
import { parseArithmeticExpression, drainArithCmdExps } from "./arithmetic.js";
import { WordImpl } from "./word.js";
import { CH_TAB, CH_NL, CH_SPACE, CH_BANG, CH_DQUOTE, CH_HASH, CH_DOLLAR, CH_PERCENT, CH_AMP, CH_SQUOTE, CH_LPAREN, CH_RPAREN, CH_STAR, CH_PLUS, CH_COMMA, CH_DASH, CH_SLASH, CH_0, CH_9, CH_COLON, CH_SEMI, CH_LT, CH_EQ, CH_GT, CH_QUESTION, CH_AT, CH_A, CH_Z, CH_LBRACKET, CH_BACKSLASH, CH_RBRACKET, CH_CARET, CH_UNDERSCORE, CH_BACKTICK, CH_a, CH_z, CH_LBRACE, CH_PIPE, CH_RBRACE, } from "./chars.js";
export const Token = {
    Word: 0,
    Assignment: 1,
    Semi: 2,
    Newline: 3,
    Pipe: 4,
    And: 5,
    Or: 6,
    Amp: 7,
    LParen: 8,
    RParen: 9,
    LBrace: 10,
    RBrace: 11,
    Bang: 12,
    If: 13,
    Then: 14,
    Else: 15,
    Elif: 16,
    Fi: 17,
    Do: 18,
    Done: 19,
    For: 20,
    While: 21,
    Until: 22,
    In: 23,
    Case: 24,
    Esac: 25,
    Function: 26,
    DoubleSemi: 27,
    SemiAmp: 28,
    DoubleSemiAmp: 29,
    Select: 30,
    DblLBracket: 31,
    DblRBracket: 32,
    EOF: 33,
    ArithCmd: 34,
    Coproc: 35,
    Redirect: 36,
};
export class TokenValue {
    token = Token.EOF;
    value = "";
    pos = 0;
    end = 0;
    fileDescriptor = undefined;
    variableName = undefined;
    content = undefined;
    targetPos = 0;
    targetEnd = 0;
    reset() {
        this.token = Token.EOF;
        this.value = "";
        this.pos = 0;
        this.end = 0;
        this.fileDescriptor = undefined;
        this.variableName = undefined;
        this.content = undefined;
        this.targetPos = 0;
        this.targetEnd = 0;
    }
    copyFrom(other) {
        this.token = other.token;
        this.value = other.value;
        this.pos = other.pos;
        this.end = other.end;
        this.fileDescriptor = other.fileDescriptor;
        this.variableName = other.variableName;
        this.content = other.content;
        this.targetPos = other.targetPos;
        this.targetEnd = other.targetEnd;
    }
}
const RESERVED_WORDS = {
    if: Token.If,
    then: Token.Then,
    else: Token.Else,
    elif: Token.Elif,
    fi: Token.Fi,
    do: Token.Do,
    done: Token.Done,
    for: Token.For,
    while: Token.While,
    until: Token.Until,
    in: Token.In,
    case: Token.Case,
    esac: Token.Esac,
    function: Token.Function,
    select: Token.Select,
    coproc: Token.Coproc,
    "!": Token.Bang,
    "{": Token.LBrace,
    "}": Token.RBrace,
};
// Combined character type table — bit 0: metachar, bit 1: word-special
const charType = new Uint8Array(128);
charType[CH_PIPE] = 1;
charType[CH_AMP] = 1;
charType[CH_SEMI] = 1;
charType[CH_LPAREN] = 1;
charType[CH_RPAREN] = 1;
charType[CH_LT] = 1;
charType[CH_GT] = 1;
charType[CH_SPACE] = 1;
charType[CH_TAB] = 1;
charType[CH_NL] = 1;
charType[CH_BACKSLASH] = 2;
charType[CH_SQUOTE] = 2;
charType[CH_DQUOTE] = 2;
charType[CH_DOLLAR] = 2;
charType[CH_BACKTICK] = 2;
charType[CH_LBRACE] = 2;
function findUnnested(s, target) {
    let depth = 0;
    for (let i = 0; i < s.length; i++) {
        const c = s.charCodeAt(i);
        if (c === CH_BACKSLASH) {
            i++;
            continue;
        }
        if (c === CH_LBRACE) {
            depth++;
            continue;
        }
        if (c === CH_RBRACE) {
            if (depth > 0)
                depth--;
            continue;
        }
        if (c === CH_SQUOTE) {
            i++;
            while (i < s.length && s.charCodeAt(i) !== CH_SQUOTE)
                i++;
            continue;
        }
        if (c === CH_DQUOTE) {
            i++;
            while (i < s.length && s.charCodeAt(i) !== CH_DQUOTE) {
                if (s.charCodeAt(i) === CH_BACKSLASH)
                    i++;
                i++;
            }
            continue;
        }
        if (c === target && depth === 0)
            return i;
    }
    return -1;
}
// Lookup: identifier chars (a-z, A-Z, 0-9, _) — bit 0: start, bit 1: continue
const isIdChar = new Uint8Array(128);
for (let i = CH_a; i <= CH_z; i++)
    isIdChar[i] = 3;
for (let i = CH_A; i <= CH_Z; i++)
    isIdChar[i] = 3;
for (let i = CH_0; i <= CH_9; i++)
    isIdChar[i] = 2;
isIdChar[CH_UNDERSCORE] = 3;
const extglobPrefix = new Uint8Array(128);
extglobPrefix[CH_QUESTION] = 1;
extglobPrefix[CH_AT] = 1;
extglobPrefix[CH_STAR] = 1;
extglobPrefix[CH_PLUS] = 1;
extglobPrefix[CH_BANG] = 1;
extglobPrefix[CH_EQ] = 1;
const extglobOp = {
    [CH_QUESTION]: "?",
    [CH_AT]: "@",
    [CH_STAR]: "*",
    [CH_PLUS]: "+",
    [CH_BANG]: "!",
};
function isDQChild(p) {
    const t = p.type;
    return (t === "Literal" ||
        t === "SimpleExpansion" ||
        t === "ParameterExpansion" ||
        t === "CommandExpansion" ||
        t === "ArithmeticExpansion");
}
function isAllDigits(text) {
    for (let i = 0; i < text.length; i++) {
        const c = text.charCodeAt(i);
        if (c < CH_0 || c > CH_9)
            return false;
    }
    return text.length > 0;
}
function isAssignmentWord(text) {
    const eqIdx = text.indexOf("=");
    if (eqIdx <= 0)
        return false;
    let c = text.charCodeAt(0);
    if (c >= 128 || !(isIdChar[c] & 1))
        return false;
    let i = 1;
    for (; i < eqIdx; i++) {
        c = text.charCodeAt(i);
        if (c >= 128 || !(isIdChar[c] & 2))
            break;
    }
    if (i === eqIdx)
        return true;
    // name+=value
    if (c === CH_PLUS && i + 1 === eqIdx)
        return true;
    // name[index]=value or name[index]+=value
    if (c === CH_LBRACKET) {
        const rbIdx = text.indexOf("]", i + 1);
        if (rbIdx > i && (rbIdx + 1 === eqIdx || (text.charCodeAt(rbIdx + 1) === CH_PLUS && rbIdx + 2 === eqIdx)))
            return true;
    }
    return false;
}
function setToken(out, token, value, pos = 0, end = 0) {
    out.token = token;
    out.value = value;
    out.pos = pos;
    out.end = end;
    out.fileDescriptor = undefined;
    out.variableName = undefined;
    out.content = undefined;
}
export const LexContext = {
    Normal: 0,
    CommandStart: 1,
    TestMode: 2,
};
function scanBraceExpansion(src, pos, len) {
    const nextCh = pos + 1 < len ? src.charCodeAt(pos + 1) : 0;
    if (nextCh <= CH_SPACE || nextCh === CH_RBRACE)
        return -1;
    let depth = 1;
    let hasSep = false;
    let scanPos = pos + 1;
    while (scanPos < len && depth > 0) {
        const bc = src.charCodeAt(scanPos);
        if (bc === CH_LBRACE)
            depth++;
        else if (bc === CH_RBRACE) {
            if (--depth === 0)
                break;
        }
        else if (bc <= CH_SPACE || bc === CH_SEMI || bc === CH_PIPE || bc === CH_AMP)
            return -1;
        else if (depth === 1 &&
            (bc === 0x2c /* , */ || (bc === 0x2e /* . */ && scanPos + 1 < len && src.charCodeAt(scanPos + 1) === 0x2e)))
            hasSep = true;
        if (bc === CH_BACKSLASH)
            scanPos++;
        scanPos++;
    }
    if (depth === 0 && hasSep)
        return scanPos + 1;
    return -1;
}
export class Lexer {
    src;
    srcEnd;
    pos;
    current;
    nextState;
    hasPeek;
    pendingHereDocs;
    collectedExpansions;
    _errors = null;
    _buildParts = false;
    // `start`/`end` bound the lexer to a window of `src` so substitution scripts can be
    // parsed in place against the original source — every position is then absolute, with
    // no slicing or re-basing. Defaults cover the whole string (the common top-level parse).
    constructor(src, start = 0, end = src.length) {
        this.src = src;
        this.srcEnd = end;
        this.pos = start;
        this.current = new TokenValue();
        this.nextState = new TokenValue();
        this.hasPeek = false;
        this.pendingHereDocs = [];
        this.collectedExpansions = [];
        if (start === 0 && src.charCodeAt(0) === CH_HASH && src.charCodeAt(1) === CH_BANG) {
            const nl = src.indexOf("\n");
            this.pos = nl === -1 ? this.srcEnd : nl + 1;
        }
    }
    getSource() {
        return this.src;
    }
    get errors() {
        return this._errors ?? (this._errors = []);
    }
    getCollectedExpansions() {
        return this.collectedExpansions;
    }
    getPos() {
        return this.pos;
    }
    /** Set position and scan a word, building parts. Used by computeWordParts. */
    buildWordParts(startPos) {
        this._buildParts = true;
        this.pos = startPos;
        // Handle process substitution words <(...) and >(...)
        const ch = this.src.charCodeAt(startPos);
        if ((ch === 0x3c /* < */ || ch === 0x3e) /* > */ &&
            startPos + 1 < this.srcEnd &&
            this.src.charCodeAt(startPos + 1) === 0x28 /* ( */) {
            this.pos = startPos + 2;
            const inner = this.extractBalanced();
            const text = this.src.slice(startPos, this.pos);
            const part = {
                type: "ProcessSubstitution",
                text,
                operator: ch === 0x3c ? "<" : ">",
                script: undefined,
                inner: inner ?? undefined,
                innerStart: startPos + 2,
            };
            this.collectedExpansions.push(part);
            // Continue reading any trailing word text (e.g., suffix after proc sub)
            if (this.pos < this.srcEnd) {
                this.readWordText();
                if (this._wordParts) {
                    this._wordParts.unshift(part);
                }
                else {
                    this._wordParts = [part];
                }
            }
            else {
                this._wordParts = [part];
            }
        }
        else {
            this.readWordText();
        }
        return this._wordParts;
    }
    /** Scan a heredoc body for expansions, building parts. Spaces/newlines are literal. */
    buildHereDocParts(bodyPos, bodyEnd) {
        this._buildParts = true;
        const src = this.src;
        const parts = [];
        let litBuf = "";
        let litStart = bodyPos;
        let i = bodyPos;
        const flushLit = () => {
            if (litBuf) {
                parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, i) });
                litBuf = "";
            }
        };
        while (i < bodyEnd) {
            const ch = src.charCodeAt(i);
            if (ch === 0x5c /* \\ */) {
                // Backslash escape — in unquoted heredoc, \\$, \\`, \\\\ are special
                if (i + 1 < bodyEnd) {
                    const nc = src.charCodeAt(i + 1);
                    if (nc === 0x24 /* $ */ || nc === 0x60 /* ` */ || nc === 0x5c /* \\ */) {
                        litBuf += String.fromCharCode(nc);
                        i += 2;
                        continue;
                    }
                }
                litBuf += "\\";
                i++;
                continue;
            }
            if (ch === 0x24 /* $ */) {
                flushLit();
                litStart = i;
                this.pos = i;
                this.readDollar();
                if (this._resultPart) {
                    parts.push(this._resultPart);
                    litStart = this.pos;
                }
                else {
                    litBuf += src.slice(i, this.pos);
                }
                i = this.pos;
                continue;
            }
            if (ch === 0x60 /* ` */) {
                flushLit();
                litStart = i;
                this.pos = i;
                this.readBacktickExpansion();
                if (this._resultPart) {
                    parts.push(this._resultPart);
                    litStart = this.pos;
                }
                else {
                    litBuf += src.slice(i, this.pos);
                }
                i = this.pos;
                continue;
            }
            litBuf += src[i];
            i++;
        }
        flushLit();
        return parts.length > 1 || (parts.length === 1 && parts[0].type !== "Literal") ? parts : null;
    }
    registerHereDocTarget(target) {
        for (const hd of this.pendingHereDocs) {
            if (!hd.target) {
                hd.target = target;
                return;
            }
        }
    }
    // Read the right-hand operand of =~ in [[ ]]. Parentheses and pipe are not
    // metacharacters in regex patterns, so we temporarily clear their charType
    // entries so that readWord's fast/slow path treats them as plain chars.
    readTestRegexWord() {
        this.hasPeek = false;
        const chars = [CH_LPAREN, CH_RPAREN, CH_PIPE, CH_LT, CH_GT];
        const saved = chars.map((c) => charType[c]);
        for (const c of chars)
            charType[c] = 0;
        try {
            this.skipSpacesAndTabs();
            this.readWord(this.current, LexContext.Normal, this.pos);
            return this.current;
        }
        finally {
            for (let i = 0; i < chars.length; i++)
                charType[chars[i]] = saved[i];
        }
    }
    // Read C-style for expressions: called after first '(' consumed by parser.
    // Expects pos at second '('. Returns [init, test, update] raw text.
    readCStyleForExprs() {
        this.hasPeek = false; // discard any peeked token
        const src = this.src;
        const len = this.srcEnd;
        // Skip spaces to second '('
        while (this.pos < len && (src.charCodeAt(this.pos) === CH_SPACE || src.charCodeAt(this.pos) === CH_TAB))
            this.pos++;
        if (this.pos < len && src.charCodeAt(this.pos) === CH_LPAREN)
            this.pos++;
        const starts = [this.pos, 0, 0];
        const parts = ["", "", "", 0, 0, 0];
        let partIdx = 0;
        let depth = 1;
        let partStart = this.pos;
        while (this.pos < len && depth > 0) {
            const c = src.charCodeAt(this.pos);
            if (c === CH_LPAREN) {
                depth++;
                this.pos++;
            }
            else if (c === CH_RPAREN) {
                depth--;
                if (depth === 0) {
                    const raw = src.slice(partStart, this.pos);
                    parts[partIdx] = raw.trim();
                    parts[3 + partIdx] = starts[partIdx] + raw.length - raw.trimStart().length;
                    this.pos++; // skip closing )
                    // Skip the outer ) as well
                    while (this.pos < len && (src.charCodeAt(this.pos) === CH_SPACE || src.charCodeAt(this.pos) === CH_TAB))
                        this.pos++;
                    if (this.pos < len && src.charCodeAt(this.pos) === CH_RPAREN)
                        this.pos++;
                    break;
                }
                this.pos++;
            }
            else if (c === CH_SEMI && depth === 1) {
                const raw = src.slice(partStart, this.pos);
                parts[partIdx] = raw.trim();
                parts[3 + partIdx] = starts[partIdx] + raw.length - raw.trimStart().length;
                if (partIdx < 2)
                    partIdx++;
                this.pos++;
                partStart = this.pos;
                starts[partIdx] = partStart;
            }
            else if (c === CH_SQUOTE) {
                this.pos++;
                this.skipSQ();
            }
            else if (c === CH_DQUOTE) {
                this.pos++;
                this.skipDQ();
            }
            else {
                this.pos++;
            }
        }
        return parts;
    }
    peek(ctx = LexContext.Normal) {
        if (!this.hasPeek) {
            this.readNext(this.nextState, ctx);
            this.hasPeek = true;
        }
        return this.nextState;
    }
    next(ctx = LexContext.Normal) {
        if (this.hasPeek) {
            this.hasPeek = false;
            const temp = this.current;
            this.current = this.nextState;
            this.nextState = temp;
            return this.current;
        }
        this.readNext(this.current, ctx);
        return this.current;
    }
    unshift(tok) {
        this.nextState.copyFrom(tok);
        this.hasPeek = true;
    }
    readNext(out, ctx) {
        const src = this.src;
        const len = this.srcEnd;
        // Skip spaces and tabs (inlined for hot path)
        let pos = this.pos;
        while (pos < len) {
            const ch = src.charCodeAt(pos);
            if (ch === CH_SPACE || ch === CH_TAB) {
                pos++;
                continue;
            }
            if (ch === CH_BACKSLASH && pos + 1 < len && src.charCodeAt(pos + 1) === CH_NL) {
                pos += 2;
                continue;
            }
            // Inside [[ ]], newlines are whitespace
            if (ch === CH_NL && ctx === LexContext.TestMode) {
                pos++;
                continue;
            }
            break;
        }
        this.pos = pos;
        if (pos >= len) {
            setToken(out, Token.EOF, "", pos, pos);
            return;
        }
        const tokenStart = pos;
        const ch = src.charCodeAt(pos);
        if (ch === CH_HASH) {
            // Skip comment
            while (this.pos < len && src.charCodeAt(this.pos) !== CH_NL)
                this.pos++;
            this.readNext(out, ctx);
            return;
        }
        if (ch === CH_NL) {
            this.pos++;
            this.consumePendingHereDocs();
            setToken(out, Token.Newline, "\n", tokenStart, this.pos);
            return;
        }
        // In test mode (inside [[ ]]), < and > are string comparison operators, not redirects
        if (ctx === LexContext.TestMode && (ch === CH_LT || ch === CH_GT)) {
            this.pos++;
            setToken(out, Token.Word, ch === CH_LT ? "<" : ">", tokenStart, this.pos);
            return;
        }
        if (this.tryReadOperator(out, ch, ctx, tokenStart))
            return;
        this.readWord(out, ctx, tokenStart);
    }
    tryReadOperator(out, ch, ctx, tokenStart) {
        const src = this.src;
        const pos = this.pos;
        const next = pos + 1 < this.srcEnd ? src.charCodeAt(pos + 1) : 0;
        switch (ch) {
            case CH_SEMI:
                if (next === CH_SEMI) {
                    if (pos + 2 < this.srcEnd && src.charCodeAt(pos + 2) === CH_AMP) {
                        this.pos += 3;
                        setToken(out, Token.DoubleSemiAmp, ";;&", tokenStart, this.pos);
                        return true;
                    }
                    this.pos += 2;
                    setToken(out, Token.DoubleSemi, ";;", tokenStart, this.pos);
                    return true;
                }
                if (next === CH_AMP) {
                    this.pos += 2;
                    setToken(out, Token.SemiAmp, ";&", tokenStart, this.pos);
                    return true;
                }
                this.pos++;
                setToken(out, Token.Semi, ";", tokenStart, this.pos);
                return true;
            case CH_PIPE:
                if (next === CH_PIPE) {
                    this.pos += 2;
                    setToken(out, Token.Or, "||", tokenStart, this.pos);
                    return true;
                }
                if (next === CH_AMP) {
                    this.pos += 2;
                    setToken(out, Token.Pipe, "|&", tokenStart, this.pos);
                    return true;
                } // |& → pipe (stderr merge)
                this.pos++;
                setToken(out, Token.Pipe, "|", tokenStart, this.pos);
                return true;
            case CH_AMP:
                if (next === CH_AMP) {
                    this.pos += 2;
                    setToken(out, Token.And, "&&", tokenStart, this.pos);
                    return true;
                }
                if (next === CH_GT) {
                    // &> or &>> — redirect, not background
                    this.pos += 2;
                    const append = this.pos < this.srcEnd && src.charCodeAt(this.pos) === CH_GT;
                    if (append)
                        this.pos++;
                    this.skipSpacesAndTabs();
                    this._redirectTargetPos = this.pos;
                    if (this.pos < this.srcEnd && src.charCodeAt(this.pos) !== CH_NL)
                        this.readWordText();
                    this.redirectToken(out, append ? "&>>" : "&>", tokenStart);
                    return true;
                }
                this.pos++;
                setToken(out, Token.Amp, "&", tokenStart, this.pos);
                return true;
            case CH_LPAREN:
                if (ctx === LexContext.CommandStart && next === CH_LPAREN) {
                    this.readArithmeticCommand(out, tokenStart);
                    return true;
                }
                this.pos++;
                setToken(out, Token.LParen, "(", tokenStart, this.pos);
                return true;
            case CH_RPAREN:
                this.pos++;
                setToken(out, Token.RParen, ")", tokenStart, this.pos);
                return true;
            case CH_LT:
            case CH_GT:
                return this.readRedirection(out, tokenStart);
            default:
                return false;
        }
    }
    readRedirection(out, tokenStart) {
        const src = this.src;
        const ch = src.charCodeAt(this.pos);
        let op = "";
        if (ch === CH_LT) {
            this.pos++;
            const next = this.pos < this.srcEnd ? src.charCodeAt(this.pos) : 0;
            if (next === CH_LT) {
                this.pos++;
                const third = this.pos < this.srcEnd ? src.charCodeAt(this.pos) : 0;
                if (third === CH_LT) {
                    // <<< herestring
                    this.pos++;
                    this.skipSpacesAndTabs();
                    this._redirectTargetPos = this.pos;
                    if (this.pos < this.srcEnd && src.charCodeAt(this.pos) !== CH_NL)
                        this.readWordText();
                    this.redirectToken(out, "<<<", tokenStart);
                    return true;
                }
                const dash = third === CH_DASH;
                if (dash)
                    this.pos++;
                this.skipSpacesAndTabs();
                this.readHereDocDelimiter();
                this.pendingHereDocs.push({ delimiter: this._hereDelim, strip: dash, quoted: this._hereQuoted });
                setToken(out, Token.Redirect, dash ? "<<-" : "<<", tokenStart, this.pos);
                out.content = this._hereDelim;
                return true;
            }
            if (next === CH_LPAREN) {
                this.readProcessSubstitution(out, "<", tokenStart);
                return true;
            }
            if (next === CH_GT) {
                op = "<>";
                this.pos++;
            }
            else if (next === CH_AMP) {
                op = "<&";
                this.pos++;
            }
            else {
                op = "<";
            }
        }
        else if (ch === CH_GT) {
            this.pos++;
            const next = this.pos < this.srcEnd ? src.charCodeAt(this.pos) : 0;
            if (next === CH_LPAREN) {
                this.readProcessSubstitution(out, ">", tokenStart);
                return true;
            }
            if (next === CH_GT) {
                op = ">>";
                this.pos++;
            }
            else if (next === CH_AMP) {
                op = ">&";
                this.pos++;
            }
            else if (next === CH_PIPE) {
                op = ">|";
                this.pos++;
            }
            else {
                op = ">";
            }
        }
        this.skipSpacesAndTabs();
        if (this.pos < this.srcEnd) {
            const nc = src.charCodeAt(this.pos);
            if ((nc === CH_LT || nc === CH_GT) && this.pos + 1 < this.srcEnd && src.charCodeAt(this.pos + 1) === CH_LPAREN) {
                const psStart = this.pos;
                this.pos += 2;
                this.extractBalanced();
                const psText = src.slice(psStart, this.pos);
                setToken(out, Token.Redirect, op, tokenStart, this.pos);
                out.content = psText;
                out.targetPos = psStart;
                out.targetEnd = this.pos;
                return true;
            }
            this._redirectTargetPos = this.pos;
            if (nc !== CH_NL)
                this.readWordText();
        }
        this.redirectToken(out, op, tokenStart);
        return true;
    }
    redirectToken(out, operator, tokenStart) {
        setToken(out, Token.Redirect, operator, tokenStart, this.pos);
        out.content = this._wordText;
        out.targetPos = this._redirectTargetPos;
        out.targetEnd = this.pos;
    }
    readProcessSubstitution(out, operator, tokenStart) {
        this.pos++; // skip (
        this.extractBalanced();
        const text = this.src.slice(tokenStart, this.pos);
        setToken(out, Token.Word, text, tokenStart, this.pos);
    }
    readHereDocDelimiter() {
        const src = this.src;
        const len = this.srcEnd;
        let delimiter = "";
        if (this.pos < len && src.charCodeAt(this.pos) === CH_SQUOTE) {
            this.pos++;
            const start = this.pos;
            while (this.pos < len && src.charCodeAt(this.pos) !== CH_SQUOTE)
                this.pos++;
            delimiter = src.slice(start, this.pos);
            if (this.pos < len)
                this.pos++;
            this._hereDelim = delimiter;
            this._hereQuoted = true;
            return;
        }
        else if (this.pos < len && src.charCodeAt(this.pos) === CH_DQUOTE) {
            this.pos++;
            while (this.pos < len && src.charCodeAt(this.pos) !== CH_DQUOTE) {
                if (src.charCodeAt(this.pos) === CH_BACKSLASH)
                    this.pos++;
                delimiter += src[this.pos];
                this.pos++;
            }
            if (this.pos < len)
                this.pos++;
            this._hereDelim = delimiter;
            this._hereQuoted = true;
            return;
        }
        else if (this.pos < len && src.charCodeAt(this.pos) === CH_BACKSLASH) {
            while (this.pos < len) {
                const c = src.charCodeAt(this.pos);
                if (c < 128 && charType[c] & 1)
                    break;
                if (c === CH_BACKSLASH)
                    this.pos++;
                if (this.pos < len) {
                    delimiter += src[this.pos];
                    this.pos++;
                }
            }
            this._hereDelim = delimiter;
            this._hereQuoted = true;
            return;
        }
        else {
            const start = this.pos;
            while (this.pos < len) {
                const c = src.charCodeAt(this.pos);
                if (c < 128 && charType[c] & 1)
                    break;
                this.pos++;
            }
            this._hereDelim = src.slice(start, this.pos);
            this._hereQuoted = false;
        }
    }
    consumePendingHereDocs() {
        for (const hd of this.pendingHereDocs) {
            const bodyPos = this.pos;
            const body = this.readHereDocBody(hd.delimiter, hd.strip);
            if (hd.target) {
                hd.target.content = body;
                if (hd.quoted) {
                    hd.target.heredocQuoted = true;
                }
                else if (body) {
                    const parsed = this.parseHereDocBody(body, bodyPos);
                    if (parsed)
                        hd.target.body = parsed;
                }
            }
        }
        this.pendingHereDocs.length = 0;
    }
    readHereDocBody(delimiter, strip) {
        const src = this.src;
        const len = this.srcEnd;
        const dLen = delimiter.length;
        const bodyStart = this.pos;
        while (this.pos < len) {
            let lineStart = this.pos;
            let lineEnd = src.indexOf("\n", this.pos);
            if (lineEnd === -1)
                lineEnd = len;
            if (strip) {
                while (lineStart < lineEnd && src.charCodeAt(lineStart) === CH_TAB)
                    lineStart++;
            }
            if (lineEnd - lineStart === dLen && src.startsWith(delimiter, lineStart)) {
                const body = src.slice(bodyStart, this.pos);
                this.pos = lineEnd < len ? lineEnd + 1 : lineEnd;
                return body;
            }
            this.pos = lineEnd < len ? lineEnd + 1 : lineEnd;
        }
        return src.slice(bodyStart, this.pos);
    }
    // Scan an unquoted heredoc body for expansions ($var, ${...}, $(...), `...`).
    // Returns a Word (without parts — use computeWordParts for those) if expansions exist.
    parseHereDocBody(body, bodyPos) {
        // Quick scan: if no $ or backtick, no expansions possible
        let hasExpansion = false;
        for (let i = 0; i < body.length; i++) {
            const c = body.charCodeAt(i);
            if (c === CH_BACKTICK) {
                hasExpansion = true;
                break;
            }
            if (c === CH_DOLLAR) {
                // Check next char — bare $ at end or before space/newline is literal
                const next = i + 1 < body.length ? body.charCodeAt(i + 1) : 0;
                if (next === CH_LBRACE ||
                    next === CH_LPAREN ||
                    next === CH_DOLLAR ||
                    (next >= CH_a && next <= CH_z) ||
                    (next >= CH_A && next <= CH_Z) ||
                    next === CH_UNDERSCORE ||
                    next === CH_BANG ||
                    next === CH_HASH ||
                    next === CH_AT ||
                    next === CH_STAR ||
                    next === CH_QUESTION ||
                    next === CH_DASH ||
                    (next >= CH_0 && next <= CH_9)) {
                    hasExpansion = true;
                    break;
                }
            }
            if (c === CH_BACKSLASH)
                i++; // skip escaped char
        }
        if (!hasExpansion)
            return null;
        return new WordImpl(body, bodyPos, bodyPos + body.length, this.src, WordImpl._resolveHeredocBody);
    }
    _wordText = "";
    _wordQuoted = false;
    _wordHasExpansions = false;
    _wordParts = null;
    _redirectTargetPos = 0;
    _resultText = "";
    _resultHasExpansion = false;
    _resultPart;
    _dqText = "";
    _dqHasExpansions = false;
    _dqParts = null;
    _hereDelim = "";
    _hereQuoted = false;
    readWord(out, ctx, tokenStart = 0) {
        this.readWordText();
        const text = this._wordText;
        const hasExpansions = this._wordHasExpansions;
        const quoted = this._wordQuoted;
        const wordEnd = this.pos;
        if (ctx === LexContext.CommandStart) {
            if (!hasExpansions && !quoted) {
                const fc = text.charCodeAt(0);
                if (((fc >= CH_a && fc <= CH_z && text.length <= 8) || fc === CH_BANG || fc === CH_LBRACE || fc === CH_RBRACE) &&
                    text in RESERVED_WORDS) {
                    setToken(out, RESERVED_WORDS[text], text, tokenStart, wordEnd);
                    return;
                }
                if (fc === CH_LBRACKET && text === "[[") {
                    setToken(out, Token.DblLBracket, text, tokenStart, wordEnd);
                    return;
                }
            }
            if (isAssignmentWord(text)) {
                setToken(out, Token.Assignment, text, tokenStart, wordEnd);
                return;
            }
        }
        if (!hasExpansions && !quoted && text === "]]") {
            setToken(out, Token.DblRBracket, text, tokenStart, wordEnd);
            return;
        }
        // FD number prefix: all-digit word followed by < or > → redirect with fd
        if (!hasExpansions && this.pos < this.srcEnd) {
            const nc = this.src.charCodeAt(this.pos);
            if (nc === CH_LT || nc === CH_GT) {
                if (text.charCodeAt(0) >= CH_0 && text.charCodeAt(0) <= CH_9 && isAllDigits(text)) {
                    const fd = Number.parseInt(text, 10);
                    if (this.readRedirection(out, tokenStart)) {
                        out.fileDescriptor = fd;
                        return;
                    }
                }
                if (text.charCodeAt(0) === CH_LBRACE && text.charCodeAt(text.length - 1) === CH_RBRACE && text.length > 2) {
                    const varname = text.slice(1, -1);
                    if (this.readRedirection(out, tokenStart)) {
                        out.variableName = varname;
                        return;
                    }
                }
            }
        }
        setToken(out, Token.Word, text, tokenStart, wordEnd);
    }
    readWordText() {
        const src = this.src;
        const len = this.srcEnd;
        let pos = this.pos;
        // Fast path: scan a single run of plain chars (covers most words)
        const fastStart = pos;
        while (pos < len) {
            const c = src.charCodeAt(pos);
            if (c < 128 && charType[c])
                break;
            pos++;
        }
        const exitCh = pos < len ? src.charCodeAt(pos) : 0;
        if (pos >= len ||
            (charType[exitCh] & 1 && !(exitCh === CH_LPAREN && pos > fastStart && extglobPrefix[src.charCodeAt(pos - 1)]))) {
            this.pos = pos;
            this._wordText = pos > fastStart ? src.slice(fastStart, pos) : "";
            this._wordQuoted = false;
            this._wordHasExpansions = false;
            if (this._buildParts)
                this._wordParts = null;
            return;
        }
        // Slow path: word contains quotes, expansions, escapes, etc.
        let text = pos > fastStart ? src.slice(fastStart, pos) : "";
        let quoted = false;
        let hasExpansions = false;
        const bp = this._buildParts;
        let parts;
        let litBuf = "";
        let litStart = 0;
        if (bp) {
            parts = [];
            litBuf = text; // fast-path prefix is literal
            litStart = fastStart;
        }
        while (pos < len) {
            const ch = src.charCodeAt(pos);
            if (ch >= 128 || !charType[ch]) {
                const runStart = pos;
                pos++;
                while (pos < len) {
                    const c = src.charCodeAt(pos);
                    if (c < 128 && charType[c])
                        break;
                    pos++;
                }
                const chunk = src.slice(runStart, pos);
                text += chunk;
                if (bp)
                    litBuf += chunk;
                continue;
            }
            if (charType[ch] & 1) {
                if (ch === CH_LPAREN && text.length > 0 && extglobPrefix[text.charCodeAt(text.length - 1)]) {
                    const prefixChar = text.charCodeAt(text.length - 1);
                    pos++;
                    const innerStart = pos;
                    let depth = 1;
                    while (pos < len && depth > 0) {
                        const c = src.charCodeAt(pos);
                        if (c === CH_LPAREN)
                            depth++;
                        else if (c === CH_RPAREN)
                            depth--;
                        pos++;
                    }
                    const pattern = src.slice(innerStart, pos - 1); // without closing )
                    const eg = "(" + src.slice(innerStart, pos);
                    text += eg;
                    // Create ExtendedGlob part for real extglob operators (not = which is array assignment)
                    if (bp && prefixChar !== CH_EQ) {
                        // Remove the prefix char from litBuf (it was appended in the previous iteration)
                        if (litBuf.length > 0) {
                            const trimmed = litBuf.slice(0, -1);
                            if (trimmed)
                                parts.push({ type: "Literal", value: trimmed, text: src.slice(litStart, innerStart - 2) });
                            litBuf = "";
                        }
                        const op = extglobOp[prefixChar];
                        const fullText = op + eg;
                        parts.push({ type: "ExtendedGlob", text: fullText, operator: op, pattern });
                        litStart = pos;
                    }
                    else if (bp) {
                        litBuf += eg;
                    }
                    continue;
                }
                break;
            }
            if (ch === CH_BACKSLASH) {
                pos++;
                if (pos < len) {
                    if (src.charCodeAt(pos) === CH_NL) {
                        pos++;
                    }
                    else {
                        quoted = true;
                        const escaped = src[pos++];
                        text += escaped;
                        if (bp)
                            litBuf += escaped;
                    }
                }
                continue;
            }
            if (ch === CH_SQUOTE) {
                const sqStart = pos;
                quoted = true;
                pos++;
                const start = pos;
                while (pos < len && src.charCodeAt(pos) !== CH_SQUOTE)
                    pos++;
                const value = src.slice(start, pos);
                text += value;
                if (pos < len)
                    pos++;
                else
                    this.errors.push({ message: "unterminated single quote", pos: start - 1 });
                if (bp) {
                    if (litBuf) {
                        parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, sqStart) });
                        litBuf = "";
                    }
                    parts.push({ type: "SingleQuoted", value, text: src.slice(sqStart, pos) });
                    litStart = pos;
                }
                continue;
            }
            if (ch === CH_DQUOTE) {
                const dqStart = pos;
                quoted = true;
                pos++;
                this.pos = pos;
                this.readDoubleQuoted();
                pos = this.pos;
                text += this._dqText;
                if (this._dqHasExpansions)
                    hasExpansions = true;
                if (bp) {
                    if (litBuf) {
                        parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, dqStart) });
                        litBuf = "";
                    }
                    const dqText = src.slice(dqStart, pos);
                    parts.push({
                        type: "DoubleQuoted",
                        text: dqText,
                        parts: this._dqParts ?? [{ type: "Literal", value: this._dqText, text: src.slice(dqStart + 1, pos - 1) }],
                    });
                    litStart = pos;
                }
                continue;
            }
            if (ch === CH_DOLLAR) {
                const dollarStart = pos;
                this.pos = pos;
                this.readDollar();
                pos = this.pos;
                text += this._resultText;
                if (this._resultHasExpansion)
                    hasExpansions = true;
                if (bp) {
                    if (this._resultPart) {
                        if (litBuf) {
                            parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, dollarStart) });
                            litBuf = "";
                        }
                        parts.push(this._resultPart);
                        litStart = pos;
                    }
                    else {
                        litBuf += this._resultText;
                    }
                }
                continue;
            }
            if (ch === CH_BACKTICK) {
                const btStart = pos;
                this.pos = pos;
                this.readBacktickExpansion();
                pos = this.pos;
                text += this._resultText;
                hasExpansions = true;
                if (bp) {
                    if (litBuf) {
                        parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, btStart) });
                        litBuf = "";
                    }
                    parts.push(this._resultPart);
                    litStart = pos;
                }
                continue;
            }
            if (ch === CH_LBRACE) {
                const braceEnd = scanBraceExpansion(src, pos, len);
                if (braceEnd > 0) {
                    const braceText = src.slice(pos, braceEnd);
                    text += braceText;
                    if (bp) {
                        if (litBuf) {
                            parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, pos) });
                            litBuf = "";
                        }
                        parts.push({ type: "BraceExpansion", text: braceText });
                        litStart = braceEnd;
                    }
                    pos = braceEnd;
                    continue;
                }
                text += "{";
                if (bp)
                    litBuf += "{";
                pos++;
                continue;
            }
            pos++;
        }
        if (bp && litBuf)
            parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, pos) });
        this.pos = pos;
        this._wordText = text;
        this._wordQuoted = quoted;
        this._wordHasExpansions = hasExpansions;
        if (bp) {
            // Only store parts if they add structure beyond a single literal
            this._wordParts = parts.length > 1 || (parts.length === 1 && parts[0].type !== "Literal") ? parts : null;
        }
    }
    readInnerWordText() {
        const src = this.src;
        const len = this.srcEnd;
        let pos = this.pos;
        let text = "";
        const bp = this._buildParts;
        let parts;
        let litBuf = "";
        let litStart = 0;
        if (bp) {
            parts = [];
            litStart = pos;
        }
        while (pos < len) {
            const ch = src.charCodeAt(pos);
            if (ch === CH_BACKSLASH) {
                pos++;
                if (pos < len) {
                    if (src.charCodeAt(pos) === CH_NL) {
                        pos++;
                    }
                    else {
                        const escaped = src[pos++];
                        text += escaped;
                        if (bp)
                            litBuf += escaped;
                    }
                }
                continue;
            }
            if (ch === CH_SQUOTE) {
                const sqStart = pos;
                pos++;
                const start = pos;
                while (pos < len && src.charCodeAt(pos) !== CH_SQUOTE)
                    pos++;
                const value = src.slice(start, pos);
                text += value;
                if (pos < len)
                    pos++;
                if (bp) {
                    if (litBuf) {
                        parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, sqStart) });
                        litBuf = "";
                    }
                    parts.push({ type: "SingleQuoted", value, text: src.slice(sqStart, pos) });
                    litStart = pos;
                }
                continue;
            }
            if (ch === CH_DQUOTE) {
                const dqStart = pos;
                pos++;
                this.pos = pos;
                this.readDoubleQuoted();
                pos = this.pos;
                text += this._dqText;
                if (bp) {
                    if (litBuf) {
                        parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, dqStart) });
                        litBuf = "";
                    }
                    const dqText = src.slice(dqStart, pos);
                    parts.push({
                        type: "DoubleQuoted",
                        text: dqText,
                        parts: this._dqParts ?? [{ type: "Literal", value: this._dqText, text: src.slice(dqStart + 1, pos - 1) }],
                    });
                    litStart = pos;
                }
                continue;
            }
            if (ch === CH_DOLLAR) {
                const dollarStart = pos;
                this.pos = pos;
                this.readDollar();
                pos = this.pos;
                text += this._resultText;
                if (bp) {
                    if (this._resultPart) {
                        if (litBuf) {
                            parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, dollarStart) });
                            litBuf = "";
                        }
                        parts.push(this._resultPart);
                        litStart = pos;
                    }
                    else {
                        litBuf += this._resultText;
                    }
                }
                continue;
            }
            if (ch === CH_BACKTICK) {
                const btStart = pos;
                this.pos = pos;
                this.readBacktickExpansion();
                pos = this.pos;
                text += this._resultText;
                if (bp) {
                    if (litBuf) {
                        parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, btStart) });
                        litBuf = "";
                    }
                    parts.push(this._resultPart);
                    litStart = pos;
                }
                continue;
            }
            text += src[pos];
            if (bp)
                litBuf += src[pos];
            pos++;
        }
        if (bp && litBuf)
            parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, pos) });
        this.pos = pos;
        this._wordText = text;
        this._wordQuoted = false;
        this._wordHasExpansions = false;
        if (bp) {
            this._wordParts = parts.length > 1 || (parts.length === 1 && parts[0].type !== "Literal") ? parts : null;
        }
    }
    // Parse a parameter-expansion sub-field (operand, slice bound, replacement pattern) over
    // the window [start, end) of the original source. Parsing in place — rather than against a
    // detached slice — gives the word and any nested substitutions absolute offsets, and
    // composes through nested ${...}. The `${...}` inner is a verbatim substring of the
    // source, so every sub-field offset maps straight back.
    parseSubFieldWord(start, end) {
        if (start >= end)
            return new WordImpl("", start, start);
        const savedEnd = this.srcEnd;
        const savedPos = this.pos;
        const savedText = this._wordText;
        const savedParts = this._wordParts;
        const savedQuoted = this._wordQuoted;
        this.srcEnd = end;
        this.pos = start;
        this.readInnerWordText();
        // text is the raw source span (so source.slice(pos, end) === text holds, as for every
        // other word); value resolves quotes via parts.
        const word = new WordImpl(this.src.slice(start, end), start, end);
        if (this._buildParts && this._wordParts) {
            word.parts = this._wordParts;
        }
        this.srcEnd = savedEnd;
        this.pos = savedPos;
        this._wordText = savedText;
        this._wordParts = savedParts;
        this._wordQuoted = savedQuoted;
        return word;
    }
    skipSQ() {
        while (this.pos < this.srcEnd && this.src.charCodeAt(this.pos) !== CH_SQUOTE)
            this.pos++;
        if (this.pos < this.srcEnd)
            this.pos++;
    }
    skipDQ() {
        const src = this.src;
        const len = this.srcEnd;
        while (this.pos < len) {
            const ch = src.charCodeAt(this.pos);
            if (ch === CH_DQUOTE) {
                this.pos++;
                return;
            }
            if (ch === CH_BACKSLASH) {
                this.pos += 2;
                continue;
            }
            if (ch === CH_DOLLAR && this.pos + 1 < len) {
                const next = src.charCodeAt(this.pos + 1);
                if (next === CH_LPAREN) {
                    this.pos += 2;
                    this.extractBalanced();
                    continue;
                }
                if (next === CH_LBRACE) {
                    this.pos += 2;
                    let d = 1;
                    while (this.pos < len && d > 0) {
                        const c = src.charCodeAt(this.pos);
                        if (c === CH_RBRACE) {
                            if (--d === 0) {
                                this.pos++;
                                break;
                            }
                        }
                        else if (c === CH_LBRACE && this.pos > 0 && src.charCodeAt(this.pos - 1) === CH_DOLLAR)
                            d++;
                        else if (c === CH_BACKSLASH) {
                            this.pos++;
                        }
                        else if (c === CH_SQUOTE) {
                            this.pos++;
                            this.skipSQ();
                            continue;
                        }
                        else if (c === CH_DQUOTE) {
                            this.pos++;
                            this.skipDQ();
                            continue;
                        }
                        this.pos++;
                    }
                    continue;
                }
            }
            if (ch === CH_BACKTICK) {
                this.pos++;
                while (this.pos < len && src.charCodeAt(this.pos) !== CH_BACKTICK) {
                    if (src.charCodeAt(this.pos) === CH_BACKSLASH)
                        this.pos++;
                    this.pos++;
                }
                if (this.pos < len)
                    this.pos++;
                continue;
            }
            this.pos++;
        }
    }
    skipSpacesAndTabs() {
        const src = this.src;
        const len = this.srcEnd;
        while (this.pos < len) {
            const ch = src.charCodeAt(this.pos);
            if (ch === CH_SPACE || ch === CH_TAB)
                this.pos++;
            else if (ch === CH_BACKSLASH && this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_NL)
                this.pos += 2;
            else
                break;
        }
    }
    readDoubleQuoted() {
        const src = this.src;
        const len = this.srcEnd;
        const contentStart = this.pos;
        let hasExpansions = false;
        const bp = this._buildParts;
        // Fast path: pure literal content (no $, `, or \ — just find closing ")
        if (!bp) {
            let p = this.pos;
            while (p < len) {
                const c = src.charCodeAt(p);
                if (c === CH_DQUOTE) {
                    this._dqText = src.slice(contentStart, p);
                    this.pos = p + 1;
                    this._dqHasExpansions = false;
                    this._dqParts = null;
                    return;
                }
                if (c === CH_DOLLAR || c === CH_BACKTICK || c === CH_BACKSLASH)
                    break;
                p++;
            }
            // Fall through to general path
        }
        let text = "";
        let parts = null;
        let litBuf = "";
        let litStart = bp ? this.pos : 0;
        while (this.pos < len && src.charCodeAt(this.pos) !== CH_DQUOTE) {
            // Scan run of plain chars inside double quotes
            const runStart = this.pos;
            while (this.pos < len) {
                const c = src.charCodeAt(this.pos);
                if (c === CH_DQUOTE || c === CH_BACKSLASH || c === CH_DOLLAR || c === CH_BACKTICK)
                    break;
                this.pos++;
            }
            if (this.pos > runStart) {
                const chunk = src.slice(runStart, this.pos);
                text += chunk;
                if (bp)
                    litBuf += chunk;
            }
            if (this.pos >= len || src.charCodeAt(this.pos) === CH_DQUOTE)
                break;
            const ch = src.charCodeAt(this.pos);
            if (ch === CH_BACKSLASH) {
                this.pos++;
                if (this.pos < len) {
                    const next = src.charCodeAt(this.pos);
                    if (next === CH_NL) {
                        this.pos++;
                        continue;
                    }
                    if (next === CH_DOLLAR || next === CH_BACKTICK || next === CH_DQUOTE || next === CH_BACKSLASH) {
                        const c = src[this.pos];
                        text += c;
                        if (bp)
                            litBuf += c;
                    }
                    else {
                        const pair = "\\" + src[this.pos];
                        text += pair;
                        if (bp)
                            litBuf += pair;
                    }
                    this.pos++;
                }
                continue;
            }
            if (ch === CH_DOLLAR) {
                // $" inside double quotes is literal $ followed by closing " (not a locale string)
                if (this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_DQUOTE) {
                    text += "$";
                    if (bp)
                        litBuf += "$";
                    this.pos++;
                    continue;
                }
                const expStart = this.pos;
                this.readDollar();
                text += this._resultText;
                if (this._resultHasExpansion)
                    hasExpansions = true;
                if (bp) {
                    const rp = this._resultPart;
                    if (rp && isDQChild(rp)) {
                        if (!parts)
                            parts = [];
                        if (litBuf) {
                            parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, expStart) });
                            litBuf = "";
                        }
                        parts.push(rp);
                        litStart = this.pos;
                    }
                    else {
                        litBuf += this._resultText;
                    }
                }
                continue;
            }
            if (ch === CH_BACKTICK) {
                const btStart = this.pos;
                this.readBacktickExpansion();
                text += this._resultText;
                hasExpansions = true;
                if (bp && this._resultPart && isDQChild(this._resultPart)) {
                    if (!parts)
                        parts = [];
                    if (litBuf) {
                        parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, btStart) });
                        litBuf = "";
                    }
                    parts.push(this._resultPart);
                    litStart = this.pos;
                }
                continue;
            }
        }
        if (bp && parts && litBuf)
            parts.push({ type: "Literal", value: litBuf, text: src.slice(litStart, this.pos) });
        if (this.pos < len)
            this.pos++; // closing "
        else
            this.errors.push({ message: "unterminated double quote", pos: contentStart - 1 });
        this._dqText = text;
        this._dqHasExpansions = hasExpansions;
        this._dqParts = parts;
    }
    readDollar() {
        const dollarPos = this.pos;
        this.pos++; // skip $
        const src = this.src;
        const len = this.srcEnd;
        if (this.pos >= len) {
            this._resultText = "$";
            this._resultHasExpansion = false;
            this._resultPart = undefined;
            return;
        }
        const ch = src.charCodeAt(this.pos);
        if (ch === CH_LPAREN) {
            if (this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_LPAREN) {
                this.readArithmeticExpansion();
                return;
            }
            this.readCommandSubstitution();
            return;
        }
        if (ch === CH_LBRACE) {
            const after = this.pos + 1 < len ? src.charCodeAt(this.pos + 1) : 0;
            if (after === CH_SPACE || after === CH_TAB || after === CH_NL) {
                this.readBraceCommandSubstitution();
                return;
            }
            if (after === CH_PIPE) {
                this.readValueSubstitution();
                return;
            }
            this.readParameterExpansion();
            return;
        }
        if (ch === CH_SQUOTE) {
            this.pos++;
            const value = this.readAnsiCQuoted();
            this._resultText = value;
            this._resultHasExpansion = false;
            this._resultPart = this._buildParts
                ? { type: "AnsiCQuoted", text: src.slice(dollarPos, this.pos), value }
                : undefined;
            return;
        }
        if (ch === CH_DQUOTE) {
            this.pos++;
            this.readDoubleQuoted();
            this._resultText = this._dqText;
            this._resultHasExpansion = this._dqHasExpansions;
            if (this._buildParts) {
                const text = src.slice(dollarPos, this.pos);
                this._resultPart = {
                    type: "LocaleString",
                    text,
                    parts: this._dqParts ?? [
                        { type: "Literal", value: this._dqText, text: src.slice(dollarPos + 2, this.pos - 1) },
                    ],
                };
            }
            else {
                this._resultPart = undefined;
            }
            return;
        }
        if (ch === CH_AT ||
            ch === CH_STAR ||
            ch === CH_HASH ||
            ch === CH_QUESTION ||
            ch === CH_DASH ||
            ch === CH_DOLLAR ||
            ch === CH_BANG) {
            this.pos++;
            const text = src.slice(this.pos - 2, this.pos);
            this._resultText = text;
            this._resultHasExpansion = false;
            this._resultPart = this._buildParts ? { type: "SimpleExpansion", text } : undefined;
            return;
        }
        if (ch >= CH_0 && ch <= CH_9) {
            this.pos++;
            const text = src.slice(this.pos - 2, this.pos);
            this._resultText = text;
            this._resultHasExpansion = false;
            this._resultPart = this._buildParts ? { type: "SimpleExpansion", text } : undefined;
            return;
        }
        if (ch < 128 && isIdChar[ch] & 1) {
            const dollarPos = this.pos - 1;
            while (this.pos < len) {
                const c = src.charCodeAt(this.pos);
                if (c < 128 && isIdChar[c] & 2)
                    this.pos++;
                else
                    break;
            }
            const text = src.slice(dollarPos, this.pos);
            this._resultText = text;
            this._resultHasExpansion = false;
            this._resultPart = this._buildParts ? { type: "SimpleExpansion", text } : undefined;
            return;
        }
        this._resultText = "$";
        this._resultHasExpansion = false;
        this._resultPart = undefined;
    }
    scanArithmeticBody() {
        this.pos += 2;
        let depth = 1;
        const src = this.src;
        const len = this.srcEnd;
        const start = this.pos;
        while (this.pos < len && depth > 0) {
            const c = src.charCodeAt(this.pos);
            if (c === CH_LPAREN && this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_LPAREN) {
                depth++;
                this.pos += 2;
            }
            else if (c === CH_RPAREN && this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_RPAREN) {
                if (--depth === 0) {
                    this.pos += 2;
                    break;
                }
                this.pos += 2;
            }
            else {
                this.pos++;
            }
        }
        return src.slice(start, this.pos - 2);
    }
    readArithmeticExpansion() {
        const bodyStart = this.pos + 2; // absolute offset of the body, past the "((" at this.pos
        const body = this.scanArithmeticBody();
        const text = "$((" + body + "))";
        this._resultText = text;
        this._resultHasExpansion = false;
        if (this._buildParts) {
            // Pass the absolute body offset so arithmetic nodes index the original source
            // directly (no re-basing). Nested $(...) command subs inside the arithmetic get
            // an absolute innerStart so resolveCollected parses their window in place.
            const expr = parseArithmeticExpression(body, bodyStart) ?? undefined;
            const drained = drainArithCmdExps();
            if (drained)
                for (const node of drained) {
                    node.innerStart = node.pos + 2;
                    this.collectedExpansions.push(node);
                }
            this._resultPart = { type: "ArithmeticExpansion", text, expression: expr };
        }
        else {
            this._resultPart = undefined;
        }
    }
    readArithmeticCommand(out, tokenStart) {
        const body = this.scanArithmeticBody();
        setToken(out, Token.ArithCmd, body, tokenStart, this.pos);
    }
    readCommandSubstitution() {
        const dollarPos = this.pos - 1;
        this.pos++; // skip (
        this.extractBalanced();
        const text = this.src.slice(dollarPos, this.pos);
        this._resultText = text;
        this._resultHasExpansion = true;
        if (this._buildParts) {
            const inner = text.slice(2, -1);
            this._resultPart = { type: "CommandExpansion", text, script: undefined, inner, innerStart: dollarPos + 2 };
            this.collectedExpansions.push(this._resultPart);
        }
        else {
            this._resultPart = undefined;
        }
    }
    readBraceCommandSubstitution() {
        this.readBraceSubstitution("${ ", 1);
    }
    readValueSubstitution() {
        this.readBraceSubstitution("${| ", 2);
    }
    readBraceSubstitution(prefix, skip) {
        this.pos += skip;
        const src = this.src;
        const len = this.srcEnd;
        let depth = 1;
        const start = this.pos;
        while (this.pos < len) {
            const c = src.charCodeAt(this.pos);
            if (c === CH_LBRACE)
                depth++;
            else if (c === CH_RBRACE) {
                if (--depth === 0) {
                    this.pos++;
                    break;
                }
            }
            else if (c === CH_SQUOTE) {
                this.pos++;
                this.skipSQ();
                continue;
            }
            else if (c === CH_DQUOTE) {
                this.pos++;
                this.skipDQ();
                continue;
            }
            else if (c === CH_BACKSLASH)
                this.pos++;
            this.pos++;
        }
        const rawInner = src.slice(start, this.pos - 1);
        const inner = rawInner.trim();
        const text = prefix + inner + " }";
        this._resultText = text;
        this._resultHasExpansion = true;
        if (this._buildParts) {
            const innerStart = start + (rawInner.length - rawInner.trimStart().length);
            this._resultPart = { type: "CommandExpansion", text, script: undefined, inner, innerStart };
            this.collectedExpansions.push(this._resultPart);
        }
        else {
            this._resultPart = undefined;
        }
    }
    readBacktickExpansion() {
        this.pos++; // skip opening `
        const src = this.src;
        const len = this.srcEnd;
        let inner = "";
        const start = this.pos;
        let hasEscapes = false;
        while (this.pos < len && src.charCodeAt(this.pos) !== CH_BACKTICK) {
            if (src.charCodeAt(this.pos) === CH_BACKSLASH) {
                hasEscapes = true;
                break;
            }
            this.pos++;
        }
        if (!hasEscapes) {
            inner = src.slice(start, this.pos);
        }
        else {
            inner = src.slice(start, this.pos);
            while (this.pos < len && src.charCodeAt(this.pos) !== CH_BACKTICK) {
                if (src.charCodeAt(this.pos) === CH_BACKSLASH) {
                    this.pos++;
                    if (this.pos < len) {
                        const c = src.charCodeAt(this.pos);
                        if (c === CH_DOLLAR || c === CH_BACKTICK || c === CH_BACKSLASH) {
                            inner += src[this.pos];
                        }
                        else {
                            inner += "\\" + src[this.pos];
                        }
                        this.pos++;
                    }
                }
                else {
                    const runStart = this.pos;
                    while (this.pos < len) {
                        const c = src.charCodeAt(this.pos);
                        if (c === CH_BACKTICK || c === CH_BACKSLASH)
                            break;
                        this.pos++;
                    }
                    inner += src.slice(runStart, this.pos);
                }
            }
        }
        if (this.pos < len)
            this.pos++; // closing `
        else
            this.errors.push({ message: "unterminated backtick", pos: start - 1 });
        const text = src.slice(start - 1, this.pos); // raw source including backticks
        this._resultText = inner;
        this._resultHasExpansion = true;
        if (this._buildParts) {
            // Escaped backticks rebuild `inner` (escapes removed), so its offsets no
            // longer map linearly onto the source — leave innerStart undefined there.
            this._resultPart = {
                type: "CommandExpansion",
                text,
                script: undefined,
                inner,
                innerStart: hasEscapes ? undefined : start,
            };
            this.collectedExpansions.push(this._resultPart);
        }
        else {
            this._resultPart = undefined;
        }
    }
    readParameterExpansion() {
        const src = this.src;
        const len = this.srcEnd;
        const start = this.pos; // at {
        this.pos++;
        let depth = 1;
        while (this.pos < len && depth > 0) {
            const ch = src.charCodeAt(this.pos);
            if (ch === CH_LBRACE && this.pos > 0 && src.charCodeAt(this.pos - 1) === CH_DOLLAR)
                depth++;
            else if (ch === CH_RBRACE) {
                if (--depth === 0) {
                    this.pos++;
                    break;
                }
            }
            else if (ch === CH_BACKSLASH) {
                this.pos++;
            }
            else if (ch === CH_SQUOTE) {
                this.pos++;
                this.skipSQ();
                continue;
            }
            else if (ch === CH_DQUOTE) {
                this.pos++;
                this.skipDQ();
                continue;
            }
            this.pos++;
        }
        const text = src.slice(start - 1, this.pos);
        this._resultText = text;
        this._resultHasExpansion = false;
        if (this._buildParts) {
            const inner = src.slice(start + 1, this.pos - 1);
            this._resultPart = this.parseParamInner(text, inner, start + 1);
        }
        else {
            this._resultPart = undefined;
        }
    }
    // `innerStart` is the absolute offset of `inner` in the original source, so each sub-field
    // word is parsed in place at its true position. `sub(a, b)` maps inner-relative offsets to
    // that absolute window.
    parseParamInner(text, inner, innerStart) {
        const result = {
            type: "ParameterExpansion",
            text,
            parameter: "",
            index: undefined,
            indirect: undefined,
            length: undefined,
            operator: undefined,
            operand: undefined,
            slice: undefined,
            replace: undefined,
        };
        const ilen = inner.length;
        if (ilen === 0)
            return result;
        const sub = (a, b) => this.parseSubFieldWord(innerStart + a, innerStart + b);
        let i = 0;
        // Check for ! prefix (indirect)
        if (inner.charCodeAt(0) === CH_BANG) {
            result.indirect = true;
            i = 1;
        }
        // Check for # prefix (length) — only when not indirect
        if (!result.indirect && inner.charCodeAt(0) === CH_HASH) {
            if (ilen === 1) {
                // ${#} = special variable
                result.parameter = "#";
                return result;
            }
            // ${##...} is always param="#" with operator (bash resolves ambiguity this way)
            if (inner.charCodeAt(1) === CH_HASH) {
                result.parameter = "#";
                i = 1;
            }
            else {
                // Try as length operator: parse param after #, check if at end
                const tryI = this.scanParamName(inner, 1);
                if (tryI > 1) {
                    let endI = tryI;
                    if (endI < ilen && inner.charCodeAt(endI) === CH_LBRACKET) {
                        const closeB = this.findCloseBracket(inner, endI + 1);
                        if (closeB !== -1)
                            endI = closeB + 1;
                    }
                    if (endI >= ilen) {
                        // ${#param} or ${#param[idx]} — length
                        result.length = true;
                        result.parameter = inner.slice(1, tryI);
                        if (tryI < ilen && inner.charCodeAt(tryI) === CH_LBRACKET) {
                            const closeB = this.findCloseBracket(inner, tryI + 1);
                            if (closeB !== -1)
                                result.index = inner.slice(tryI + 1, closeB);
                        }
                        return result;
                    }
                }
                // Not length — # is the parameter name
                result.parameter = "#";
                i = 1;
            }
        }
        // Parse parameter name if not set yet
        if (!result.parameter) {
            const nameStart = i;
            i = this.scanParamName(inner, i);
            result.parameter = inner.slice(nameStart, i);
        }
        // Check for [index]
        if (i < ilen && inner.charCodeAt(i) === CH_LBRACKET) {
            const closeB = this.findCloseBracket(inner, i + 1);
            if (closeB !== -1) {
                result.index = inner.slice(i + 1, closeB);
                i = closeB + 1;
            }
        }
        // Nothing more → simple expansion
        if (i >= ilen)
            return result;
        // Determine operator
        const opChar = inner.charCodeAt(i);
        // Colon variants: :-, :=, :+, :? or slice
        if (opChar === CH_COLON) {
            if (i + 1 < ilen) {
                const nc = inner.charCodeAt(i + 1);
                if (nc === CH_DASH || nc === CH_EQ || nc === CH_PLUS || nc === CH_QUESTION) {
                    result.operator = inner.slice(i, i + 2);
                    result.operand = sub(i + 2, ilen);
                    return result;
                }
            }
            // Slice: ${var:offset} or ${var:offset:length}
            i++;
            const sliceRest = inner.slice(i);
            const colonIdx = findUnnested(sliceRest, CH_COLON);
            if (colonIdx === -1) {
                result.slice = { offset: sub(i, ilen), length: undefined };
            }
            else {
                result.slice = {
                    offset: sub(i, i + colonIdx),
                    length: sub(i + colonIdx + 1, ilen),
                };
            }
            return result;
        }
        // Default/assign/error/alt without colon
        if (opChar === CH_DASH || opChar === CH_EQ || opChar === CH_PLUS || opChar === CH_QUESTION) {
            result.operator = inner[i];
            result.operand = sub(i + 1, ilen);
            return result;
        }
        // Prefix strip
        if (opChar === CH_HASH) {
            if (i + 1 < ilen && inner.charCodeAt(i + 1) === CH_HASH) {
                result.operator = "##";
                result.operand = sub(i + 2, ilen);
            }
            else {
                result.operator = "#";
                result.operand = sub(i + 1, ilen);
            }
            return result;
        }
        // Suffix strip
        if (opChar === CH_PERCENT) {
            if (i + 1 < ilen && inner.charCodeAt(i + 1) === CH_PERCENT) {
                result.operator = "%%";
                result.operand = sub(i + 2, ilen);
            }
            else {
                result.operator = "%";
                result.operand = sub(i + 1, ilen);
            }
            return result;
        }
        // Replacement
        if (opChar === CH_SLASH) {
            i++;
            let replOp = "/";
            if (i < ilen) {
                const nc = inner.charCodeAt(i);
                if (nc === CH_SLASH) {
                    replOp = "//";
                    i++;
                }
                else if (nc === CH_HASH) {
                    replOp = "/#";
                    i++;
                }
                else if (nc === CH_PERCENT) {
                    replOp = "/%";
                    i++;
                }
            }
            result.operator = replOp;
            const rest = inner.slice(i);
            const sepIdx = findUnnested(rest, CH_SLASH);
            if (sepIdx === -1) {
                result.replace = {
                    pattern: sub(i, ilen),
                    replacement: new WordImpl("", innerStart + ilen, innerStart + ilen),
                };
            }
            else {
                result.replace = {
                    pattern: sub(i, i + sepIdx),
                    replacement: sub(i + sepIdx + 1, ilen),
                };
            }
            return result;
        }
        // Case modification: ^ ^^ , ,,
        if (opChar === CH_CARET) {
            if (i + 1 < ilen && inner.charCodeAt(i + 1) === CH_CARET) {
                result.operator = "^^";
                if (i + 2 < ilen)
                    result.operand = sub(i + 2, ilen);
            }
            else {
                result.operator = "^";
                if (i + 1 < ilen)
                    result.operand = sub(i + 1, ilen);
            }
            return result;
        }
        if (opChar === CH_COMMA) {
            if (i + 1 < ilen && inner.charCodeAt(i + 1) === CH_COMMA) {
                result.operator = ",,";
                if (i + 2 < ilen)
                    result.operand = sub(i + 2, ilen);
            }
            else {
                result.operator = ",";
                if (i + 1 < ilen)
                    result.operand = sub(i + 1, ilen);
            }
            return result;
        }
        // Transform: @
        if (opChar === CH_AT) {
            result.operator = "@";
            result.operand = sub(i + 1, ilen);
            return result;
        }
        // Unknown operator — store remaining as op
        result.operator = inner.slice(i);
        return result;
    }
    scanParamName(s, start) {
        let i = start;
        if (i >= s.length)
            return i;
        const c = s.charCodeAt(i);
        // Special single-char params
        if (c === CH_AT ||
            c === CH_STAR ||
            c === CH_HASH ||
            c === CH_QUESTION ||
            c === CH_DASH ||
            c === CH_DOLLAR ||
            c === CH_BANG) {
            return i + 1;
        }
        // Digits
        if (c >= CH_0 && c <= CH_9) {
            while (i < s.length && s.charCodeAt(i) >= CH_0 && s.charCodeAt(i) <= CH_9)
                i++;
            return i;
        }
        // Regular name: [a-zA-Z_][a-zA-Z0-9_]*
        if ((c >= CH_a && c <= CH_z) || (c >= CH_A && c <= CH_Z) || c === CH_UNDERSCORE) {
            i++;
            while (i < s.length) {
                const ch = s.charCodeAt(i);
                if ((ch >= CH_a && ch <= CH_z) ||
                    (ch >= CH_A && ch <= CH_Z) ||
                    (ch >= CH_0 && ch <= CH_9) ||
                    ch === CH_UNDERSCORE)
                    i++;
                else
                    break;
            }
        }
        return i;
    }
    findCloseBracket(s, start) {
        let depth = 1;
        for (let i = start; i < s.length; i++) {
            const c = s.charCodeAt(i);
            if (c === CH_LBRACKET)
                depth++;
            else if (c === CH_RBRACKET) {
                if (--depth === 0)
                    return i;
            }
        }
        return -1;
    }
    readAnsiCQuoted() {
        const src = this.src;
        const len = this.srcEnd;
        let text = "";
        while (this.pos < len && src.charCodeAt(this.pos) !== CH_SQUOTE) {
            if (src.charCodeAt(this.pos) === CH_BACKSLASH && this.pos + 1 < len) {
                this.pos++;
                const ch = src[this.pos];
                switch (ch) {
                    case "n":
                        text += "\n";
                        break;
                    case "t":
                        text += "\t";
                        break;
                    case "r":
                        text += "\r";
                        break;
                    case "\\":
                        text += "\\";
                        break;
                    case "'":
                        text += "'";
                        break;
                    case '"':
                        text += '"';
                        break;
                    case "a":
                        text += "\x07";
                        break;
                    case "b":
                        text += "\b";
                        break;
                    case "e":
                    case "E":
                        text += "\x1B";
                        break;
                    case "f":
                        text += "\f";
                        break;
                    case "v":
                        text += "\v";
                        break;
                    default:
                        text += "\\" + ch;
                        break;
                }
                this.pos++;
            }
            else {
                const runStart = this.pos;
                while (this.pos < len) {
                    const c = src.charCodeAt(this.pos);
                    if (c === CH_SQUOTE || c === CH_BACKSLASH)
                        break;
                    this.pos++;
                }
                text += src.slice(runStart, this.pos);
            }
        }
        if (this.pos < len)
            this.pos++;
        return text;
    }
    // Extract balanced parens for $(...) — respects nested quotes and case..esac
    extractBalanced() {
        const src = this.src;
        const len = this.srcEnd;
        let depth = 1;
        const start = this.pos;
        // Fast path: scan for simple cases with no nested quotes/parens/case
        while (this.pos < len && depth > 0) {
            const c = src.charCodeAt(this.pos);
            if (c === CH_RPAREN) {
                depth--;
                if (depth === 0) {
                    const result = src.slice(start, this.pos);
                    this.pos++;
                    return result;
                }
                this.pos++;
            }
            else if (c === CH_LPAREN || c === CH_BACKSLASH || c === CH_SQUOTE || c === CH_DQUOTE || c === CH_BACKTICK) {
                break;
            }
            else if (c === 99 /* c */ &&
                // Ensure word start boundary (not inside e.g. "lowercase")
                (this.pos === start || (src.charCodeAt(this.pos - 1) < 128 && charType[src.charCodeAt(this.pos - 1)] !== 0)) &&
                this.pos + 3 < len &&
                src.charCodeAt(this.pos + 1) === 97 /* a */ &&
                src.charCodeAt(this.pos + 2) === 115 /* s */ &&
                src.charCodeAt(this.pos + 3) === 101 /* e */ &&
                (this.pos + 4 >= len || (src.charCodeAt(this.pos + 4) < 128 && charType[src.charCodeAt(this.pos + 4)] & 1))) {
                break;
            }
            else {
                this.pos++;
            }
        }
        if (depth === 0)
            return src.slice(start, this.pos);
        // Slow path: just track position (source is copied verbatim, so slice at end)
        let caseDepth = 0;
        while (this.pos < len && depth > 0) {
            const ch = src.charCodeAt(this.pos);
            if (ch === CH_LPAREN) {
                depth++;
                this.pos++;
            }
            else if (ch === CH_RPAREN) {
                if (caseDepth > 0) {
                    this.pos++;
                }
                else {
                    depth--;
                    if (depth === 0) {
                        const result = src.slice(start, this.pos);
                        this.pos++;
                        return result;
                    }
                    this.pos++;
                }
            }
            else if (ch === CH_BACKSLASH) {
                this.pos++;
                if (this.pos < len)
                    this.pos++;
            }
            else if (ch === CH_SQUOTE) {
                this.pos++;
                this.skipSQ();
            }
            else if (ch === CH_DQUOTE) {
                this.pos++;
                this.skipDQ();
            }
            else if (ch === CH_BACKTICK) {
                this.pos++;
                while (this.pos < len && src.charCodeAt(this.pos) !== CH_BACKTICK) {
                    if (src.charCodeAt(this.pos) === CH_BACKSLASH)
                        this.pos++;
                    if (this.pos < len)
                        this.pos++;
                }
                if (this.pos < len)
                    this.pos++;
            }
            else {
                const wStart = this.pos;
                while (this.pos < len) {
                    const wc = src.charCodeAt(this.pos);
                    if (wc < 128 && charType[wc])
                        break;
                    this.pos++;
                }
                if (this.pos > wStart) {
                    const wLen = this.pos - wStart;
                    if (wLen === 4) {
                        const c0 = src.charCodeAt(wStart);
                        if (c0 === 99 &&
                            src.charCodeAt(wStart + 1) === 97 &&
                            src.charCodeAt(wStart + 2) === 115 &&
                            src.charCodeAt(wStart + 3) === 101) {
                            caseDepth++;
                        }
                        else if (c0 === 101 &&
                            src.charCodeAt(wStart + 1) === 115 &&
                            src.charCodeAt(wStart + 2) === 97 &&
                            src.charCodeAt(wStart + 3) === 99 &&
                            caseDepth > 0) {
                            caseDepth--;
                        }
                    }
                }
                else {
                    this.pos++;
                }
            }
        }
        return src.slice(start, this.pos);
    }
}