| export declare function decodeAnsiCQuoted(source: string, start: number, limit: number): { | ||
| value: string; | ||
| end: number; | ||
| closed: boolean; | ||
| }; |
+130
| function isOctal(code) { | ||
| return code >= 48 && code <= 55; | ||
| } | ||
| function isHex(code) { | ||
| return (code >= 48 && code <= 57) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102); | ||
| } | ||
| function codePoint(value, fallback) { | ||
| try { | ||
| return String.fromCodePoint(value); | ||
| } | ||
| catch { | ||
| return fallback; | ||
| } | ||
| } | ||
| export function decodeAnsiCQuoted(source, start, limit) { | ||
| let pos = start; | ||
| let value = ""; | ||
| while (pos < limit && source.charCodeAt(pos) !== 39) { | ||
| if (source.charCodeAt(pos) !== 92 || pos + 1 >= limit) { | ||
| const runStart = pos; | ||
| while (pos < limit) { | ||
| const code = source.charCodeAt(pos); | ||
| // A backslash at the end of input has nothing to escape; consume it as a | ||
| // literal so the loop always advances. | ||
| if (code === 39 || (code === 92 && pos + 1 < limit)) | ||
| break; | ||
| pos++; | ||
| } | ||
| value += source.slice(runStart, pos); | ||
| continue; | ||
| } | ||
| const escapeStart = pos++; | ||
| const escaped = source[pos++]; | ||
| switch (escaped) { | ||
| case "a": | ||
| value += "\x07"; | ||
| break; | ||
| case "b": | ||
| value += "\b"; | ||
| break; | ||
| case "e": | ||
| case "E": | ||
| value += "\x1B"; | ||
| break; | ||
| case "f": | ||
| value += "\f"; | ||
| break; | ||
| case "n": | ||
| value += "\n"; | ||
| break; | ||
| case "r": | ||
| value += "\r"; | ||
| break; | ||
| case "t": | ||
| value += "\t"; | ||
| break; | ||
| case "v": | ||
| value += "\v"; | ||
| break; | ||
| case "\\": | ||
| value += "\\"; | ||
| break; | ||
| case "'": | ||
| value += "'"; | ||
| break; | ||
| case '"': | ||
| value += '"'; | ||
| break; | ||
| case "?": | ||
| value += "?"; | ||
| break; | ||
| case "\n": | ||
| break; | ||
| case "c": { | ||
| // The closing quote (or end of input) is not an operand: \c stays literal. | ||
| const code = pos < limit ? source.charCodeAt(pos) : 39; | ||
| if (code === 39) { | ||
| value += source.slice(escapeStart, pos); | ||
| break; | ||
| } | ||
| pos++; | ||
| if (code === 92) { | ||
| // Bash writes a backslash operand as the pair \c\\; a lone backslash | ||
| // still decodes and leaves the character it escaped as a literal. | ||
| const pair = pos < limit && source.charCodeAt(pos) === 92; | ||
| if (pair) | ||
| pos++; | ||
| value += "\x1c"; | ||
| if (!pair && pos < limit) { | ||
| value += source[pos]; | ||
| pos++; | ||
| } | ||
| break; | ||
| } | ||
| value += String.fromCharCode(code === 63 ? 127 : code & 31); | ||
| break; | ||
| } | ||
| case "x": | ||
| case "u": | ||
| case "U": { | ||
| const digitsStart = pos; | ||
| const maxDigits = escaped === "x" ? 2 : escaped === "u" ? 4 : 8; | ||
| while (pos < limit && pos - digitsStart < maxDigits && isHex(source.charCodeAt(pos))) | ||
| pos++; | ||
| if (pos === digitsStart) { | ||
| value += `\\${escaped}`; | ||
| break; | ||
| } | ||
| const raw = source.slice(escapeStart, pos); | ||
| value += codePoint(Number.parseInt(source.slice(digitsStart, pos), 16), raw); | ||
| break; | ||
| } | ||
| default: { | ||
| const escapedCode = escaped.charCodeAt(0); | ||
| if (!isOctal(escapedCode)) { | ||
| value += `\\${escaped}`; | ||
| break; | ||
| } | ||
| while (pos < limit && pos - escapeStart - 1 < 3 && isOctal(source.charCodeAt(pos))) | ||
| pos++; | ||
| value += String.fromCharCode(Number.parseInt(source.slice(escapeStart + 1, pos), 8) & 0xff); | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| const closed = pos < limit; | ||
| if (closed) | ||
| pos++; | ||
| return { value, end: pos, closed }; | ||
| } |
| import type { ParsedScript, ScriptSourceMap } from "./types.ts"; | ||
| export declare function setExpansionSourceMap(expansion: object, sourceMap: ScriptSourceMap): void; | ||
| export declare function takeExpansionSourceMap(expansion: object): ScriptSourceMap | undefined; | ||
| export declare function setScriptSourceMap(script: ParsedScript, sourceMap: ScriptSourceMap): void; | ||
| /** | ||
| * Map a decoded script's node spans into the source that contained the script. | ||
| * Returns a map only for scripts parsed from a rebuilt string (decoded | ||
| * escaped-backtick substitutions); `undefined` means the script's positions | ||
| * already index the string the caller parsed — no mapping is needed. | ||
| */ | ||
| export declare function computeScriptSourceMap(script: ParsedScript): ScriptSourceMap | undefined; |
| const expansionSourceMaps = new WeakMap(); | ||
| const scriptSourceMaps = new WeakMap(); | ||
| export function setExpansionSourceMap(expansion, sourceMap) { | ||
| expansionSourceMaps.set(expansion, sourceMap); | ||
| } | ||
| export function takeExpansionSourceMap(expansion) { | ||
| const sourceMap = expansionSourceMaps.get(expansion); | ||
| expansionSourceMaps.delete(expansion); | ||
| return sourceMap; | ||
| } | ||
| export function setScriptSourceMap(script, sourceMap) { | ||
| scriptSourceMaps.set(script, sourceMap); | ||
| } | ||
| /** | ||
| * Map a decoded script's node spans into the source that contained the script. | ||
| * Returns a map only for scripts parsed from a rebuilt string (decoded | ||
| * escaped-backtick substitutions); `undefined` means the script's positions | ||
| * already index the string the caller parsed — no mapping is needed. | ||
| */ | ||
| export function computeScriptSourceMap(script) { | ||
| return scriptSourceMaps.get(script); | ||
| } |
+11
-3
@@ -1,3 +0,11 @@ | ||
| import type { ArithmeticCommandExpansion, ArithmeticExpression } from "./types.ts"; | ||
| export declare function drainArithCmdExps(): ArithmeticCommandExpansion[] | null; | ||
| export declare function parseArithmeticExpression(src: string, offset?: number): ArithmeticExpression | null; | ||
| import type { ArithmeticCommandExpansion, ArithmeticExpression, ArithmeticWord } from "./types.ts"; | ||
| export interface ArithmeticParseCollector { | ||
| commandExpansions: ArithmeticCommandExpansion[]; | ||
| embeddedWords: ArithmeticWord[]; | ||
| findClosingBracket?: (start: number, end: number) => number; | ||
| findClosingBrace: (start: number, end: number) => number; | ||
| findClosingParenthesis: (start: number, end: number) => number; | ||
| findArithmeticExpansionEnd: (start: number, end: number) => number; | ||
| findArithmeticWordEnd?: (start: number, end: number) => number; | ||
| } | ||
| export declare function parseArithmeticExpression(src: string, offset?: number, collector?: ArithmeticParseCollector): ArithmeticExpression | null; |
+116
-55
@@ -1,2 +0,2 @@ | ||
| import { CH_TAB, CH_NL, CH_SPACE, CH_BANG, CH_DOLLAR, CH_PERCENT, CH_AMP, CH_LPAREN, CH_RPAREN, CH_STAR, CH_PLUS, CH_COMMA, CH_DASH, CH_SLASH, CH_0, CH_9, CH_COLON, CH_LT, CH_EQ, CH_GT, CH_QUESTION, CH_A, CH_Z, CH_LBRACKET, CH_RBRACKET, CH_CARET, CH_UNDERSCORE, CH_a, CH_z, CH_LBRACE, CH_PIPE, CH_RBRACE, CH_TILDE, } from "./chars.js"; | ||
| import { CH_TAB, CH_NL, CH_SPACE, CH_BANG, CH_DOLLAR, CH_PERCENT, CH_AMP, CH_LPAREN, CH_RPAREN, CH_STAR, CH_PLUS, CH_COMMA, CH_DASH, CH_SLASH, CH_0, CH_9, CH_COLON, CH_LT, CH_EQ, CH_GT, CH_QUESTION, CH_A, CH_Z, CH_LBRACKET, CH_RBRACKET, CH_CARET, CH_UNDERSCORE, CH_a, CH_z, CH_LBRACE, CH_RBRACE, CH_PIPE, CH_TILDE, } from "./chars.js"; | ||
| function opPrec(op) { | ||
@@ -71,12 +71,19 @@ switch (op) { | ||
| } | ||
| let pendingArithCmdExps = null; | ||
| export function drainArithCmdExps() { | ||
| const out = pendingArithCmdExps; | ||
| pendingArithCmdExps = null; | ||
| return out; | ||
| } | ||
| export function parseArithmeticExpression(src, offset = 0) { | ||
| pendingArithCmdExps = null; | ||
| export function parseArithmeticExpression(src, offset = 0, collector) { | ||
| let pos = 0; | ||
| const len = src.length; | ||
| const initialCommandCount = collector?.commandExpansions.length ?? 0; | ||
| const initialWordCount = collector?.embeddedWords.length ?? 0; | ||
| function makeWord(start, end, embedded = false) { | ||
| const node = { | ||
| type: "ArithmeticWord", | ||
| pos: start + offset, | ||
| end: end + offset, | ||
| value: src.slice(start, end), | ||
| parts: undefined, | ||
| }; | ||
| if (embedded) | ||
| collector?.embeddedWords.push(node); | ||
| return node; | ||
| } | ||
| function skipWS() { | ||
@@ -261,3 +268,3 @@ while (pos < len) { | ||
| if (pos >= len) | ||
| return { type: "ArithmeticWord", pos: pos + offset, end: pos + offset, value: "" }; | ||
| return makeWord(pos, pos); | ||
| const start = pos; | ||
@@ -320,3 +327,3 @@ const c = src.charCodeAt(pos); | ||
| if (pos >= len) | ||
| return { type: "ArithmeticWord", pos: pos + offset, end: pos + offset, value: "" }; | ||
| return makeWord(pos, pos); | ||
| const c = src.charCodeAt(pos); | ||
@@ -335,6 +342,34 @@ // Parenthesized expression | ||
| if (c === CH_DOLLAR) { | ||
| return readDollarAtom(); | ||
| const start = pos; | ||
| const commandCount = collector?.commandExpansions.length ?? 0; | ||
| const wordCount = collector?.embeddedWords.length ?? 0; | ||
| const atom = readDollarAtom(); | ||
| const wordEnd = collector?.findArithmeticWordEnd?.(start + offset, offset + len) ?? pos + offset; | ||
| if (wordEnd > pos + offset) { | ||
| if (collector) { | ||
| collector.commandExpansions.length = commandCount; | ||
| collector.embeddedWords.length = wordCount; | ||
| } | ||
| pos = wordEnd - offset; | ||
| return makeWord(start, pos, true); | ||
| } | ||
| return atom; | ||
| } | ||
| if (c === 0x60 /* ` */ || c === 0x22 /* " */ || c === 0x27 /* ' */) { | ||
| const start = pos; | ||
| pos = (collector?.findArithmeticWordEnd?.(start + offset, offset + len) ?? start + offset + 1) - offset; | ||
| return makeWord(start, pos, true); | ||
| } | ||
| // Number or variable name | ||
| return readWordAtom(); | ||
| const start = pos; | ||
| const wordCount = collector?.embeddedWords.length ?? 0; | ||
| const atom = readWordAtom(); | ||
| const wordEnd = collector?.findArithmeticWordEnd?.(start + offset, offset + len) ?? pos + offset; | ||
| if (wordEnd > pos + offset) { | ||
| if (collector) | ||
| collector.embeddedWords.length = wordCount; | ||
| pos = wordEnd - offset; | ||
| return makeWord(start, pos, true); | ||
| } | ||
| return atom; | ||
| } | ||
@@ -345,3 +380,3 @@ function readDollarAtom() { | ||
| if (pos >= len) | ||
| return { type: "ArithmeticWord", pos: start + offset, end: pos + offset, value: "$" }; | ||
| return makeWord(start, pos); | ||
| const c = src.charCodeAt(pos); | ||
@@ -351,18 +386,22 @@ if (c === CH_LPAREN) { | ||
| // $(( nested arithmetic )) | ||
| pos += 2; | ||
| let depth = 1; | ||
| while (pos < len && depth > 0) { | ||
| if (src.charCodeAt(pos) === CH_LPAREN && pos + 1 < len && src.charCodeAt(pos + 1) === CH_LPAREN) { | ||
| depth++; | ||
| pos += 2; | ||
| } | ||
| else if (src.charCodeAt(pos) === CH_RPAREN && pos + 1 < len && src.charCodeAt(pos + 1) === CH_RPAREN) { | ||
| depth--; | ||
| if (depth > 0) | ||
| const expansionEnd = collector?.findArithmeticExpansionEnd(start + offset, offset + len) ?? -1; | ||
| if (expansionEnd !== -1) { | ||
| pos = expansionEnd - offset; | ||
| } | ||
| else { | ||
| pos += 2; | ||
| let depth = 1; | ||
| while (pos < len && depth > 0) { | ||
| if (src.charCodeAt(pos) === CH_LPAREN && src.charCodeAt(pos + 1) === CH_LPAREN) { | ||
| depth++; | ||
| pos += 2; | ||
| else | ||
| } | ||
| else if (src.charCodeAt(pos) === CH_RPAREN && src.charCodeAt(pos + 1) === CH_RPAREN) { | ||
| depth--; | ||
| pos += 2; | ||
| } | ||
| else { | ||
| pos++; | ||
| } | ||
| } | ||
| else | ||
| pos++; | ||
| } | ||
@@ -373,11 +412,16 @@ } | ||
| pos++; // skip ( | ||
| let depth = 1; | ||
| while (pos < len && depth > 0) { | ||
| const ch = src.charCodeAt(pos); | ||
| if (ch === CH_LPAREN) | ||
| depth++; | ||
| else if (ch === CH_RPAREN) | ||
| depth--; | ||
| pos++; | ||
| const close = collector?.findClosingParenthesis(pos + offset, offset + len) ?? -1; | ||
| if (close !== -1) { | ||
| pos = close - offset + 1; | ||
| } | ||
| else { | ||
| let depth = 1; | ||
| while (pos < len && depth > 0) { | ||
| const ch = src.charCodeAt(pos++); | ||
| if (ch === CH_LPAREN) | ||
| depth++; | ||
| else if (ch === CH_RPAREN) | ||
| depth--; | ||
| } | ||
| } | ||
| const text = src.slice(start, pos); | ||
@@ -393,3 +437,3 @@ const inner = text.slice(2, -1); // remove "$(" and ")" | ||
| }; | ||
| (pendingArithCmdExps ??= []).push(node); | ||
| collector?.commandExpansions.push(node); | ||
| return node; | ||
@@ -400,11 +444,16 @@ } | ||
| // ${ parameter expansion } | ||
| pos++; | ||
| let depth = 1; | ||
| while (pos < len && depth > 0) { | ||
| const ch = src.charCodeAt(pos); | ||
| if (ch === CH_LBRACE) | ||
| depth++; | ||
| else if (ch === CH_RBRACE) | ||
| depth--; | ||
| const close = collector?.findClosingBrace(pos + offset + 1, offset + len) ?? -1; | ||
| if (close !== -1) { | ||
| pos = close - offset + 1; | ||
| } | ||
| else { | ||
| pos++; | ||
| let depth = 1; | ||
| while (pos < len && depth > 0) { | ||
| const ch = src.charCodeAt(pos++); | ||
| if (ch === CH_LBRACE) | ||
| depth++; | ||
| else if (ch === CH_RBRACE) | ||
| depth--; | ||
| } | ||
| } | ||
@@ -425,3 +474,3 @@ } | ||
| } | ||
| return { type: "ArithmeticWord", pos: start + offset, end: pos + offset, value: src.slice(start, pos) }; | ||
| return makeWord(start, pos, c === CH_LPAREN || c === CH_LBRACE); | ||
| } | ||
@@ -445,12 +494,19 @@ function readWordAtom() { | ||
| if (pos > start && pos < len && src.charCodeAt(pos) === CH_LBRACKET) { | ||
| pos++; | ||
| let depth = 1; | ||
| while (pos < len && depth > 0) { | ||
| const c = src.charCodeAt(pos); | ||
| if (c === CH_LBRACKET) | ||
| depth++; | ||
| else if (c === CH_RBRACKET) | ||
| depth--; | ||
| const close = collector?.findClosingBracket?.(pos + offset + 1, offset + len) ?? -1; | ||
| if (close !== -1) { | ||
| pos = close - offset + 1; | ||
| } | ||
| else { | ||
| pos++; | ||
| let depth = 1; | ||
| while (pos < len && depth > 0) { | ||
| const c = src.charCodeAt(pos); | ||
| if (c === CH_LBRACKET) | ||
| depth++; | ||
| else if (c === CH_RBRACKET) | ||
| depth--; | ||
| pos++; | ||
| } | ||
| } | ||
| return makeWord(start, pos, true); | ||
| } | ||
@@ -460,5 +516,5 @@ if (pos === start) { | ||
| pos++; | ||
| return { type: "ArithmeticWord", pos: start + offset, end: pos + offset, value: src.slice(start, pos) }; | ||
| return makeWord(start, pos); | ||
| } | ||
| return { type: "ArithmeticWord", pos: start + offset, end: pos + offset, value: src.slice(start, pos) }; | ||
| return makeWord(start, pos); | ||
| } | ||
@@ -471,3 +527,8 @@ skipWS(); | ||
| skipWS(); | ||
| if (pos < len && collector) { | ||
| collector.commandExpansions.length = initialCommandCount; | ||
| collector.embeddedWords.length = initialWordCount; | ||
| return makeWord(0, len, true); | ||
| } | ||
| return result; | ||
| } |
+41
-4
| import type { DeferredCommandExpansion, ParseError, Word, WordPart } from "./types.ts"; | ||
| export declare const MAX_SYNTAX_NESTING = 256; | ||
| export declare const Token: { | ||
@@ -44,3 +45,4 @@ readonly Word: 0; | ||
| token: Token; | ||
| value: string; | ||
| _value: string | null; | ||
| _owner: Lexer | null; | ||
| pos: number; | ||
@@ -53,5 +55,11 @@ end: number; | ||
| targetEnd: number; | ||
| assignmentOperatorPos: number; | ||
| raw: boolean; | ||
| constructor(owner?: Lexer | null); | ||
| get value(): string; | ||
| set value(v: string); | ||
| reset(): void; | ||
| copyFrom(other: TokenValue): void; | ||
| } | ||
| export declare function hasEmbeddedWordStructure(source: string, start: number, end: number): boolean; | ||
| export declare const LexContext: { | ||
@@ -74,9 +82,33 @@ readonly Normal: 0; | ||
| _buildParts: boolean; | ||
| _buildValue: boolean; | ||
| _nestingDepth: number; | ||
| constructor(src: string, start?: number, end?: number); | ||
| getSource(): string; | ||
| get errors(): ParseError[]; | ||
| getCollectedExpansions(): DeferredCommandExpansion[]; | ||
| getCollectedExpansions(): [DeferredCommandExpansion, number][]; | ||
| private collect; | ||
| getPos(): number; | ||
| /** Materialize a word token's value: raw spans slice directly, others re-lex the span. */ | ||
| _tokenValue(pos: number, end: number, raw: boolean): string; | ||
| private wordValueOf; | ||
| /** Find the closing bracket for a shell subscript, ignoring brackets inside nested shell syntax. */ | ||
| findClosingBracket(start: number, end?: number): number; | ||
| /** Find the closing brace for a parameter expansion, ignoring braces inside nested shell syntax. */ | ||
| findClosingBrace(start: number, end?: number): number; | ||
| /** Find the closing parenthesis for a shell substitution using the command-aware scanner. */ | ||
| findClosingParenthesis(start: number, end?: number): number; | ||
| /** Find the end of one arithmetic expansion using the canonical lexer scanner. */ | ||
| findArithmeticExpansionEnd(start: number, end?: number): number; | ||
| /** Find the end of one shell-expanded arithmetic word using the canonical lexer scanners. */ | ||
| findArithmeticWordEnd(start: number, end?: number): number; | ||
| private scanArithmeticWordEnd; | ||
| private findClosingShellDelimiter; | ||
| skipSubshellBody(): number; | ||
| skipCompoundBody(closeToken: Token): number; | ||
| skipTestGroup(): number; | ||
| private skipTestCommandBody; | ||
| /** Set position and scan a word, building parts. Used by computeWordParts. */ | ||
| buildWordParts(startPos: number): WordPart[] | null; | ||
| /** Scan a bounded word-like span without treating shell operators or whitespace as terminators. */ | ||
| buildEmbeddedWordParts(startPos: number): WordPart[] | null; | ||
| /** Scan a heredoc body for expansions, building parts. Spaces/newlines are literal. */ | ||
@@ -97,2 +129,3 @@ buildHereDocParts(bodyPos: number, bodyEnd: number): WordPart[] | null; | ||
| private readRedirection; | ||
| private readRedirectTargetText; | ||
| private redirectToken; | ||
@@ -106,7 +139,10 @@ private readProcessSubstitution; | ||
| private _wordText; | ||
| private _wordRaw; | ||
| private _wordQuoted; | ||
| private _wordHasExpansions; | ||
| private _wordIsAssignment; | ||
| private _wordAssignmentOperatorPos; | ||
| _wordParts: WordPart[] | null; | ||
| private _redirectTargetPos; | ||
| private _resultText; | ||
| private _resultIsRaw; | ||
| private _resultHasExpansion; | ||
@@ -118,2 +154,3 @@ private _resultPart; | ||
| private _dqParts; | ||
| private _dqEnd; | ||
| private _hereDelim; | ||
@@ -126,2 +163,3 @@ private _hereQuoted; | ||
| private skipSQ; | ||
| private skipAnsiCQuoted; | ||
| private skipDQ; | ||
@@ -142,5 +180,4 @@ private skipSpacesAndTabs; | ||
| private scanParamName; | ||
| private findCloseBracket; | ||
| private readAnsiCQuoted; | ||
| private extractBalanced; | ||
| } |
+3
-7
| export type * from "./types.ts"; | ||
| import type { ParseError, Script } from "./types.ts"; | ||
| export declare function parse(source: string): Script & { | ||
| errors?: ParseError[]; | ||
| }; | ||
| export declare function parseRegion(source: string, start: number, end: number): Script & { | ||
| errors?: ParseError[]; | ||
| }; | ||
| import type { ParsedScript } from "./types.ts"; | ||
| export declare function parse(source: string): ParsedScript; | ||
| export declare function parseRegion(source: string, start: number, end: number, depth?: number): ParsedScript; |
+331
-130
@@ -1,4 +0,4 @@ | ||
| import { LexContext, Token, Lexer, TokenValue } from "./lexer.js"; | ||
| import { parseArithmeticExpression, drainArithCmdExps } from "./arithmetic.js"; | ||
| import { computeWordParts, computeHereDocBodyParts } from "./parts.js"; | ||
| import { hasEmbeddedWordStructure, LexContext, MAX_SYNTAX_NESTING, Token, Lexer, TokenValue } from "./lexer.js"; | ||
| import { parseArithmeticExpression } from "./arithmetic.js"; | ||
| import { computeWordParts, computeEmbeddedWordParts, computeHereDocBodyParts } from "./parts.js"; | ||
| import { WordImpl } from "./word.js"; | ||
@@ -12,12 +12,15 @@ WordImpl._resolveWord = computeWordParts; | ||
| body; | ||
| #source; | ||
| #depth; | ||
| #expression = null; | ||
| constructor(pos, end, body) { | ||
| constructor(pos, end, body, source, depth) { | ||
| this.pos = pos; | ||
| this.end = end; | ||
| this.body = body; | ||
| this.#source = source; | ||
| this.#depth = depth; | ||
| } | ||
| get expression() { | ||
| if (this.#expression === null) { | ||
| this.#expression = parseArithmeticExpression(this.body, this.pos + 2) ?? undefined; | ||
| resolveDrainedArithCmdExps(); | ||
| this.#expression = parseArithmeticWithParts(this.body, this.pos + 2, this.#source, this.#depth); | ||
| } | ||
@@ -41,6 +44,8 @@ return this.#expression; | ||
| #updatePos; | ||
| #source; | ||
| #depth; | ||
| #initialize = null; | ||
| #test = null; | ||
| #update = null; | ||
| constructor(pos, end, body, initStr, testStr, updateStr, initPos, testPos, updatePos) { | ||
| constructor(pos, end, body, initStr, testStr, updateStr, initPos, testPos, updatePos, source, depth) { | ||
| this.pos = pos; | ||
@@ -55,2 +60,4 @@ this.end = end; | ||
| this.#updatePos = updatePos; | ||
| this.#source = source; | ||
| this.#depth = depth; | ||
| } | ||
@@ -60,7 +67,3 @@ get initialize() { | ||
| if (this.#initStr) { | ||
| const expr = parseArithmeticExpression(this.#initStr); | ||
| if (expr) | ||
| offsetArith(expr, this.#initPos); | ||
| resolveDrainedArithCmdExps(); | ||
| this.#initialize = expr ?? undefined; | ||
| this.#initialize = parseArithmeticWithParts(this.#initStr, this.#initPos, this.#source, this.#depth); | ||
| } | ||
@@ -79,7 +82,3 @@ else { | ||
| if (this.#testStr) { | ||
| const expr = parseArithmeticExpression(this.#testStr); | ||
| if (expr) | ||
| offsetArith(expr, this.#testPos); | ||
| resolveDrainedArithCmdExps(); | ||
| this.#test = expr ?? undefined; | ||
| this.#test = parseArithmeticWithParts(this.#testStr, this.#testPos, this.#source, this.#depth); | ||
| } | ||
@@ -98,7 +97,3 @@ else { | ||
| if (this.#updateStr) { | ||
| const expr = parseArithmeticExpression(this.#updateStr); | ||
| if (expr) | ||
| offsetArith(expr, this.#updatePos); | ||
| resolveDrainedArithCmdExps(); | ||
| this.#update = expr ?? undefined; | ||
| this.#update = parseArithmeticWithParts(this.#updateStr, this.#updatePos, this.#source, this.#depth); | ||
| } | ||
@@ -134,33 +129,30 @@ else { | ||
| }; | ||
| 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 parseArithmeticWithParts(body, offset, source, depth = 0) { | ||
| if (!hasEmbeddedWordStructure(source, offset, offset + body.length)) { | ||
| return parseArithmeticExpression(body, offset) ?? undefined; | ||
| } | ||
| } | ||
| function resolveDrainedArithCmdExps() { | ||
| const list = drainArithCmdExps(); | ||
| if (!list) | ||
| return; | ||
| for (const node of list) { | ||
| const commandExpansions = []; | ||
| const embeddedWords = []; | ||
| const lexer = new Lexer(source); | ||
| const expression = parseArithmeticExpression(body, offset, { | ||
| commandExpansions, | ||
| embeddedWords, | ||
| findClosingBracket: (start, end) => lexer.findClosingBracket(start, end), | ||
| findClosingBrace: (start, end) => lexer.findClosingBrace(start, end), | ||
| findClosingParenthesis: (start, end) => lexer.findClosingParenthesis(start, end), | ||
| findArithmeticExpansionEnd: (start, end) => lexer.findArithmeticExpansionEnd(start, end), | ||
| findArithmeticWordEnd: (start, end) => lexer.findArithmeticWordEnd(start, end), | ||
| }) ?? undefined; | ||
| for (const node of commandExpansions) { | ||
| if (node.inner !== undefined) { | ||
| node.script = parse(node.inner); | ||
| if (depth <= MAX_SYNTAX_NESTING) { | ||
| const innerStart = node.pos + 2; | ||
| node.script = parseRegion(source, innerStart, innerStart + node.inner.length, depth + 1); | ||
| } | ||
| node.inner = undefined; | ||
| } | ||
| } | ||
| for (const node of embeddedWords) | ||
| node.parts = computeEmbeddedWordParts(source, node, depth); | ||
| return expression; | ||
| } | ||
@@ -219,2 +211,3 @@ // Lookup tables for O(1) token classification (replaces sequential comparisons) | ||
| "-n": 1, | ||
| "-o": 1, | ||
| "-N": 1, | ||
@@ -252,4 +245,4 @@ "-S": 1, | ||
| // 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(); | ||
| export function parseRegion(source, start, end, depth = 0) { | ||
| return new Parser(source, start, end, depth).run(); | ||
| } | ||
@@ -261,12 +254,22 @@ class Parser { | ||
| end; | ||
| errors = []; | ||
| _redirects = []; | ||
| constructor(source, start, end) { | ||
| depth; | ||
| errors = null; | ||
| _redirects = EMPTY_REDIRECTS; | ||
| syntaxDepth = 0; | ||
| // `depth` counts the substitution scripts (and sub-fields) enclosing this region; it | ||
| // shares the MAX_SYNTAX_NESTING budget with the lexer's lazy word-part materialization. | ||
| constructor(source, start, end, depth = 0) { | ||
| this.tok = new Lexer(source, start, end); | ||
| this.tok._nestingDepth = depth; | ||
| this.source = source; | ||
| this.start = start; | ||
| this.end = end; | ||
| this.depth = depth; | ||
| } | ||
| run() { | ||
| const start = this.start; | ||
| // The boundary script one level past the budget still parses (one level is cheap and | ||
| // iterative) but is flagged: everything below it stays unresolved. | ||
| if (this.depth > MAX_SYNTAX_NESTING) | ||
| this.error("maximum substitution nesting depth exceeded", start); | ||
| let shebang; | ||
@@ -279,5 +282,6 @@ if (start === 0 && this.source.charCodeAt(0) === 35 && this.source.charCodeAt(1) === 33) { | ||
| const lexerErrors = this.tok._errors; | ||
| if (lexerErrors !== null) { | ||
| if (lexerErrors !== null && lexerErrors.length > 0) { | ||
| const errors = (this.errors ??= []); | ||
| for (let i = 0; i < lexerErrors.length; i++) | ||
| this.errors.push(lexerErrors[i]); | ||
| errors.push(lexerErrors[i]); | ||
| } | ||
@@ -290,3 +294,3 @@ const result = { | ||
| commands, | ||
| errors: this.errors.length > 0 ? this.errors : undefined, | ||
| errors: this.errors ?? undefined, | ||
| }; | ||
@@ -296,3 +300,3 @@ return result; | ||
| error(message, pos) { | ||
| this.errors.push({ message, pos }); | ||
| (this.errors ??= []).push({ message, pos }); | ||
| } | ||
@@ -338,3 +342,3 @@ skipSemi() { | ||
| const redirects = this._redirects; | ||
| this._redirects = []; | ||
| this._redirects = EMPTY_REDIRECTS; | ||
| commands.push(this.makeStatement(first, redirects)); | ||
@@ -360,3 +364,3 @@ } | ||
| const redirects = this._redirects; | ||
| this._redirects = []; | ||
| this._redirects = EMPTY_REDIRECTS; | ||
| commands.push(this.makeStatement(node, redirects)); | ||
@@ -379,3 +383,3 @@ } | ||
| wrappedFirst = this.makeStatement(first, this._redirects); | ||
| this._redirects = []; | ||
| this._redirects = EMPTY_REDIRECTS; | ||
| } | ||
@@ -385,7 +389,11 @@ const commands = [wrappedFirst]; | ||
| do { | ||
| operators.push(this.tok.next(LexContext.Normal).token === Token.And ? "&&" : "||"); | ||
| const operatorToken = this.tok.next(LexContext.Normal); | ||
| const operator = operatorToken.token === Token.And ? "&&" : "||"; | ||
| this.skipNewlines(LexContext.CommandStart); | ||
| const next = this.pipeline(); | ||
| if (!next) | ||
| if (!next) { | ||
| this.error(`expected command after '${operator}'`, operatorToken.end); | ||
| break; | ||
| } | ||
| operators.push(operator); | ||
| commands.push(next); | ||
@@ -404,3 +412,3 @@ t = this.tok.peek(LexContext.Normal).token; | ||
| const redirects = this._redirects; | ||
| this._redirects = []; | ||
| this._redirects = EMPTY_REDIRECTS; | ||
| if (redirects.length === 0) | ||
@@ -450,3 +458,3 @@ return node; | ||
| let firstRedirects = this._redirects; | ||
| this._redirects = []; | ||
| this._redirects = EMPTY_REDIRECTS; | ||
| while (this.tok.peek(LexContext.Normal).token === Token.Pipe) { | ||
@@ -457,8 +465,12 @@ if (commands.length === 1 && firstRedirects.length > 0) { | ||
| } | ||
| const pipeVal = this.tok.next(LexContext.Normal).value; | ||
| operators.push(pipeVal === "|&" ? "|&" : "|"); | ||
| const pipeToken = this.tok.next(LexContext.Normal); | ||
| const operator = pipeToken.value === "|&" ? "|&" : "|"; | ||
| this.skipNewlines(LexContext.CommandStart); | ||
| const cmd = this.command(); | ||
| if (cmd) | ||
| commands.push(this.wrapCompoundRedirects(cmd)); | ||
| if (!cmd) { | ||
| this.error(`expected command after '${operator}'`, pipeToken.end); | ||
| break; | ||
| } | ||
| operators.push(operator); | ||
| commands.push(this.wrapCompoundRedirects(cmd)); | ||
| } | ||
@@ -521,3 +533,3 @@ if (commands.length === 1 && !negated && !time) { | ||
| collectTrailingRedirects() { | ||
| let redirects = []; | ||
| let redirects = EMPTY_REDIRECTS; | ||
| while (this.tok.peek(LexContext.Normal).token === Token.Redirect) { | ||
@@ -532,3 +544,3 @@ redirects = this.collectRedirect(redirects, LexContext.Normal); | ||
| this._redirects = this.collectTrailingRedirects(); | ||
| return new ArithmeticCommandImpl(tok.pos, tok.end, tok.value); | ||
| return new ArithmeticCommandImpl(tok.pos, tok.end, tok.value, this.source, this.depth); | ||
| } | ||
@@ -553,3 +565,3 @@ // coproc := COPROC [name] command [redirections] | ||
| const bodyRedirects = this._redirects; | ||
| this._redirects = []; | ||
| this._redirects = EMPTY_REDIRECTS; | ||
| const redirects = this.collectTrailingRedirects(); | ||
@@ -591,3 +603,3 @@ const allRedirects = [...bodyRedirects, ...redirects]; | ||
| const bodyRedirects = this._redirects; | ||
| this._redirects = []; | ||
| this._redirects = EMPTY_REDIRECTS; | ||
| const redirects = this.collectTrailingRedirects(); | ||
@@ -601,3 +613,14 @@ const allRedirects = [...bodyRedirects, ...redirects]; | ||
| const pos = this.tok.next(LexContext.CommandStart).pos; | ||
| if (this.syntaxDepth === MAX_SYNTAX_NESTING) { | ||
| this.error("maximum subshell nesting depth exceeded", pos); | ||
| const closeEnd = this.tok.skipSubshellBody(); | ||
| 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([]) }; | ||
| } | ||
| this.syntaxDepth++; | ||
| const commands = this.list(); | ||
| this.syntaxDepth--; | ||
| const closeEnd = this.acceptEnd(Token.RParen, LexContext.Normal); | ||
@@ -613,3 +636,14 @@ if (closeEnd < 0) | ||
| const pos = this.tok.next(LexContext.CommandStart).pos; | ||
| if (this.syntaxDepth === MAX_SYNTAX_NESTING) { | ||
| this.error("maximum brace group nesting depth exceeded", pos); | ||
| const closeEnd = this.tok.skipCompoundBody(Token.RBrace); | ||
| 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([]) }; | ||
| } | ||
| this.syntaxDepth++; | ||
| const commands = this.list(); | ||
| this.syntaxDepth--; | ||
| const closeEnd = this.acceptEnd(Token.RBrace, LexContext.Normal); | ||
@@ -625,15 +659,52 @@ if (closeEnd < 0) | ||
| 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(); | ||
| if (this.syntaxDepth === MAX_SYNTAX_NESTING) { | ||
| this.error("maximum if nesting depth exceeded", pos); | ||
| const closeEnd = this.tok.skipCompoundBody(Token.Fi); | ||
| if (closeEnd < 0) | ||
| this.error("expected 'fi' to close 'if'", this.tok.getPos()); | ||
| const end = closeEnd >= 0 ? closeEnd : pos; | ||
| this._redirects = this.collectTrailingRedirects(); | ||
| return { | ||
| type: "If", | ||
| pos, | ||
| end, | ||
| clause: this.makeCompoundList([]), | ||
| then: this.makeCompoundList([]), | ||
| else: undefined, | ||
| }; | ||
| } | ||
| this.syntaxDepth++; | ||
| let firstBranch; | ||
| let lastBranch; | ||
| let branchPos = pos; | ||
| let clause; | ||
| let then_; | ||
| for (;;) { | ||
| clause = this.makeCompoundList(this.list()); | ||
| this.skipSemi(); | ||
| if (!this.accept(Token.Then, LexContext.CommandStart)) | ||
| this.error("expected 'then'", this.tok.getPos()); | ||
| then_ = this.makeCompoundList(this.list()); | ||
| this.skipSemi(); | ||
| const elif = this.accept(Token.Elif, LexContext.CommandStart); | ||
| if (!elif) | ||
| break; | ||
| const branch = { | ||
| type: "If", | ||
| pos: branchPos, | ||
| end: branchPos, | ||
| clause, | ||
| then: then_, | ||
| else: undefined, | ||
| }; | ||
| if (lastBranch) | ||
| lastBranch.else = branch; | ||
| else | ||
| firstBranch = branch; | ||
| lastBranch = branch; | ||
| branchPos = elif.pos; | ||
| } | ||
| 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)) { | ||
| if (this.accept(Token.Else, LexContext.CommandStart)) { | ||
| else_ = this.makeCompoundList(this.list()); | ||
@@ -644,3 +715,3 @@ this.skipSemi(); | ||
| this.error("expected 'fi' to close 'if'", this.tok.getPos()); | ||
| end = closeEnd >= 0 ? closeEnd : pos; | ||
| end = closeEnd >= 0 ? closeEnd : branchPos; | ||
| } | ||
@@ -651,6 +722,16 @@ else { | ||
| this.error("expected 'fi' to close 'if'", this.tok.getPos()); | ||
| end = closeEnd >= 0 ? closeEnd : pos; | ||
| end = closeEnd >= 0 ? closeEnd : branchPos; | ||
| } | ||
| this.syntaxDepth--; | ||
| this._redirects = this.collectTrailingRedirects(); | ||
| return { type: "If", pos, end, clause, then: then_, else: else_ }; | ||
| const finalBranch = { type: "If", pos: branchPos, end, clause, then: then_, else: else_ }; | ||
| if (!firstBranch) | ||
| return finalBranch; | ||
| lastBranch.else = finalBranch; | ||
| let branch = firstBranch; | ||
| while (branch !== finalBranch) { | ||
| branch.end = end; | ||
| branch = branch.else; | ||
| } | ||
| return firstBranch; | ||
| } | ||
@@ -677,3 +758,14 @@ // for_clause := FOR word [IN word* (';'|NL)] DO list DONE | ||
| this.error("expected 'do'", this.tok.getPos()); | ||
| if (this.syntaxDepth === MAX_SYNTAX_NESTING) { | ||
| this.error("maximum for nesting depth exceeded", pos); | ||
| const closeEnd = this.tok.skipCompoundBody(Token.Done); | ||
| 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([]) }; | ||
| } | ||
| this.syntaxDepth++; | ||
| const body = this.list(); | ||
| this.syntaxDepth--; | ||
| this.skipSemi(); | ||
@@ -695,7 +787,18 @@ const closeEnd = this.acceptEnd(Token.Done, LexContext.CommandStart); | ||
| const bg = this.braceGroup(); | ||
| return new ArithmeticForImpl(pos, bg.end, bg.body, initStr, testStr, updateStr, initPos, testPos, updatePos); | ||
| return new ArithmeticForImpl(pos, bg.end, bg.body, initStr, testStr, updateStr, initPos, testPos, updatePos, this.source, this.depth); | ||
| } | ||
| if (!this.accept(Token.Do, LexContext.CommandStart)) | ||
| this.error("expected 'do'", this.tok.getPos()); | ||
| if (this.syntaxDepth === MAX_SYNTAX_NESTING) { | ||
| this.error("maximum for nesting depth exceeded", pos); | ||
| const closeEnd = this.tok.skipCompoundBody(Token.Done); | ||
| 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([]), initStr, testStr, updateStr, initPos, testPos, updatePos, this.source, this.depth); | ||
| } | ||
| this.syntaxDepth++; | ||
| const body = this.list(); | ||
| this.syntaxDepth--; | ||
| const closeEnd = this.acceptEnd(Token.Done, LexContext.CommandStart); | ||
@@ -706,3 +809,3 @@ if (closeEnd < 0) | ||
| this._redirects = this.collectTrailingRedirects(); | ||
| return new ArithmeticForImpl(pos, end, this.makeCompoundList(body), initStr, testStr, updateStr, initPos, testPos, updatePos); | ||
| return new ArithmeticForImpl(pos, end, this.makeCompoundList(body), initStr, testStr, updateStr, initPos, testPos, updatePos, this.source, this.depth); | ||
| } | ||
@@ -717,2 +820,19 @@ whileClause() { | ||
| const pos = this.tok.next(LexContext.CommandStart).pos; | ||
| if (this.syntaxDepth === MAX_SYNTAX_NESTING) { | ||
| this.error(`maximum ${kind} nesting depth exceeded`, pos); | ||
| const closeEnd = this.tok.skipCompoundBody(Token.Done); | ||
| 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: this.makeCompoundList([]), | ||
| body: this.makeCompoundList([]), | ||
| }; | ||
| } | ||
| this.syntaxDepth++; | ||
| const clause = this.makeCompoundList(this.list()); | ||
@@ -728,2 +848,3 @@ this.skipSemi(); | ||
| const end = closeEnd >= 0 ? closeEnd : pos; | ||
| this.syntaxDepth--; | ||
| this._redirects = this.collectTrailingRedirects(); | ||
@@ -740,2 +861,12 @@ return { type: "While", pos, end, kind, clause, body: this.makeCompoundList(body) }; | ||
| this.skipNewlines(LexContext.CommandStart); | ||
| if (this.syntaxDepth === MAX_SYNTAX_NESTING) { | ||
| this.error("maximum case nesting depth exceeded", pos); | ||
| const closeEnd = this.tok.skipCompoundBody(Token.Esac); | ||
| 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: [] }; | ||
| } | ||
| this.syntaxDepth++; | ||
| const items = []; | ||
@@ -782,2 +913,3 @@ let t = this.tok.peek(LexContext.CommandStart).token; | ||
| const end = closeEnd >= 0 ? closeEnd : pos; | ||
| this.syntaxDepth--; | ||
| this._redirects = this.collectTrailingRedirects(); | ||
@@ -802,3 +934,14 @@ return { type: "Case", pos, end, word, items }; | ||
| this.error("expected 'do'", this.tok.getPos()); | ||
| if (this.syntaxDepth === MAX_SYNTAX_NESTING) { | ||
| this.error("maximum select nesting depth exceeded", pos); | ||
| const closeEnd = this.tok.skipCompoundBody(Token.Done); | ||
| 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([]) }; | ||
| } | ||
| this.syntaxDepth++; | ||
| const body = this.list(); | ||
| this.syntaxDepth--; | ||
| this.skipSemi(); | ||
@@ -817,3 +960,3 @@ const closeEnd = this.acceptEnd(Token.Done, LexContext.CommandStart); | ||
| const closeEnd = this.acceptEnd(Token.DblRBracket, LexContext.TestMode); | ||
| if (closeEnd < 0 && this.tok.peek(LexContext.Normal).token === Token.EOF) | ||
| if (closeEnd < 0) | ||
| this.error("expected ']]' to close '[['", this.tok.getPos()); | ||
@@ -860,8 +1003,26 @@ const end = closeEnd >= 0 ? closeEnd : pos; | ||
| 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 }; | ||
| let t = this.tok.peek(LexContext.TestMode); | ||
| if (t.token !== Token.Word || t.value !== "!") | ||
| return this.parseTestPrimary(); | ||
| const firstPos = this.tok.next(LexContext.TestMode).pos; | ||
| t = this.tok.peek(LexContext.TestMode); | ||
| if (t.token !== Token.Word || t.value !== "!") { | ||
| const operand = this.parseTestPrimary(); | ||
| return { type: "TestNot", pos: firstPos, end: operand.end, operand }; | ||
| } | ||
| return this.parseTestPrimary(); | ||
| const positions = [firstPos]; | ||
| while (t.token === Token.Word && t.value === "!") { | ||
| positions.push(this.tok.next(LexContext.TestMode).pos); | ||
| t = this.tok.peek(LexContext.TestMode); | ||
| } | ||
| let expression = this.parseTestPrimary(); | ||
| for (let i = positions.length - 1; i >= 0; i--) { | ||
| expression = { | ||
| type: "TestNot", | ||
| pos: positions[i], | ||
| end: expression.end, | ||
| operand: expression, | ||
| }; | ||
| } | ||
| return expression; | ||
| } | ||
@@ -873,3 +1034,21 @@ // test_primary := '(' test_or ')' | unary_op word | word binary_op word | word | ||
| const openPos = this.tok.next(LexContext.TestMode).pos; | ||
| if (this.syntaxDepth === MAX_SYNTAX_NESTING) { | ||
| this.error("maximum test group nesting depth exceeded", openPos); | ||
| const closeEnd = this.tok.skipTestGroup(); | ||
| if (closeEnd < 0) | ||
| this.error("expected ')' to close test group", this.tok.getPos()); | ||
| const end = closeEnd >= 0 ? closeEnd : openPos; | ||
| const operand = new WordImpl("", openPos, openPos, this.source, undefined, this.depth); | ||
| const expression = { | ||
| type: "TestUnary", | ||
| pos: openPos, | ||
| end: openPos, | ||
| operator: "-n", | ||
| operand, | ||
| }; | ||
| return { type: "TestGroup", pos: openPos, end, expression }; | ||
| } | ||
| this.syntaxDepth++; | ||
| const expr = this.parseTestOr(); | ||
| this.syntaxDepth--; | ||
| const closeEnd = this.acceptEnd(Token.RParen, LexContext.TestMode); | ||
@@ -905,3 +1084,4 @@ if (closeEnd < 0) | ||
| if (op === "=~") { | ||
| right = this.toWord(this.tok.readTestRegexWord()); | ||
| const token = this.tok.readTestRegexWord(); | ||
| right = new WordImpl(this.source.slice(token.pos, token.end), token.pos, token.end, this.source, computeEmbeddedWordParts, this.depth); | ||
| } | ||
@@ -937,3 +1117,3 @@ else { | ||
| const redirects = this._redirects; | ||
| this._redirects = []; | ||
| this._redirects = EMPTY_REDIRECTS; | ||
| const end = redirects.length > 0 ? redirects[redirects.length - 1].end : body.end; | ||
@@ -944,4 +1124,4 @@ return { type: "Function", pos, end, name, body, redirects }; | ||
| simpleCommandOrFunction() { | ||
| const prefix = []; | ||
| let redirects = []; | ||
| let prefix = EMPTY_PREFIX; | ||
| let redirects = EMPTY_REDIRECTS; | ||
| let cmdPos = this.tok.peek(LexContext.CommandStart).pos; | ||
@@ -952,2 +1132,4 @@ let lastEnd = cmdPos; | ||
| lastEnd = t.end; | ||
| if (prefix === EMPTY_PREFIX) | ||
| prefix = []; | ||
| prefix.push(this.parseAssignment(t)); | ||
@@ -961,13 +1143,2 @@ } | ||
| 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 { | ||
@@ -978,5 +1149,5 @@ type: "Command", | ||
| name: undefined, | ||
| prefix: EMPTY_PREFIX, | ||
| prefix, | ||
| suffix: EMPTY_SUFFIX, | ||
| redirects: EMPTY_REDIRECTS, | ||
| redirects, | ||
| }; | ||
@@ -994,3 +1165,3 @@ } | ||
| const bodyRedirects = this._redirects; | ||
| this._redirects = []; | ||
| this._redirects = EMPTY_REDIRECTS; | ||
| const end = bodyRedirects.length > 0 ? bodyRedirects[bodyRedirects.length - 1].end : body.end; | ||
@@ -1000,3 +1171,3 @@ return { type: "Function", pos: name.pos, end, name, body, redirects: bodyRedirects }; | ||
| } | ||
| const suffix = []; | ||
| let suffix = EMPTY_SUFFIX; | ||
| // Collect suffix words and redirects | ||
@@ -1007,2 +1178,4 @@ for (;;) { | ||
| const w = this.readWord(LexContext.Normal); | ||
| if (suffix === EMPTY_SUFFIX) | ||
| suffix = []; | ||
| suffix.push(w); | ||
@@ -1022,2 +1195,4 @@ lastEnd = w.end; | ||
| collectRedirect(redirects, ctx) { | ||
| if (redirects === EMPTY_REDIRECTS) | ||
| redirects = []; | ||
| const t = this.tok.next(ctx); | ||
@@ -1037,6 +1212,9 @@ const tPos = t.pos; | ||
| }; | ||
| if (t.content != null) { | ||
| r.target = new WordImpl(t.content, t.targetPos, t.targetEnd, this.source); | ||
| if (t.targetEnd > t.targetPos) { | ||
| r.target = new WordImpl(t.content ?? "", t.targetPos, t.targetEnd, this.source, undefined, this.depth); | ||
| } | ||
| if (t.value === "<<" || t.value === "<<-") | ||
| else { | ||
| this.error("expected redirect target", t.targetPos); | ||
| } | ||
| if (r.target && (t.value === "<<" || t.value === "<<-")) | ||
| this.tok.registerHereDocTarget(r); | ||
@@ -1060,9 +1238,11 @@ redirects.push(r); | ||
| toWord(tok) { | ||
| return new WordImpl(this.source.slice(tok.pos, tok.end), tok.pos, tok.end, this.source); | ||
| const text = tok.raw ? tok.value : this.source.slice(tok.pos, tok.end); | ||
| return new WordImpl(text, tok.pos, tok.end, this.source, undefined, this.depth); | ||
| } | ||
| toWordFromPosEnd(tok, pos, end) { | ||
| return new WordImpl(this.source.slice(pos, end), pos, end, this.source); | ||
| const text = tok.raw && tok.pos === pos && tok.end === end ? tok.value : this.source.slice(pos, end); | ||
| return new WordImpl(text, pos, end, this.source, undefined, this.depth); | ||
| } | ||
| parseAssignment(tok) { | ||
| const text = this.source.slice(tok.pos, tok.end); | ||
| const text = tok.raw ? tok.value : this.source.slice(tok.pos, tok.end); | ||
| const tokPos = tok.pos; | ||
@@ -1079,6 +1259,6 @@ const tokEnd = tok.end; | ||
| index: undefined, | ||
| indexParts: undefined, | ||
| array: undefined, | ||
| }; | ||
| // Find the = sign, accounting for name, name[index], and += variants | ||
| const eqIdx = text.indexOf("="); | ||
| const eqIdx = tok.assignmentOperatorPos - tokPos; | ||
| if (eqIdx <= 0) | ||
@@ -1090,5 +1270,8 @@ return result; | ||
| // Check for += (append) | ||
| if (text.charCodeAt(eqIdx - 1) === 0x2b /* + */) { | ||
| let appendPos = eqIdx; | ||
| while (appendPos >= 2 && text.charCodeAt(appendPos - 2) === 0x5c && text.charCodeAt(appendPos - 1) === 0x0a) | ||
| appendPos -= 2; | ||
| if (text.charCodeAt(appendPos - 1) === 0x2b /* + */) { | ||
| append = true; | ||
| nameEnd = eqIdx - 1; | ||
| nameEnd = appendPos - 1; | ||
| } | ||
@@ -1098,4 +1281,4 @@ // Check for [index] before = or += | ||
| if (bracketIdx > 0 && bracketIdx < nameEnd) { | ||
| const rbracketIdx = text.indexOf("]", bracketIdx); | ||
| if (rbracketIdx > bracketIdx && rbracketIdx + 1 === nameEnd) { | ||
| const rbracketIdx = text.lastIndexOf("]", eqIdx); | ||
| if (rbracketIdx > bracketIdx) { | ||
| index = text.slice(bracketIdx + 1, rbracketIdx); | ||
@@ -1105,8 +1288,23 @@ nameEnd = bracketIdx; | ||
| } | ||
| const name = text.slice(0, nameEnd); | ||
| const rawName = text.slice(0, nameEnd); | ||
| const name = rawName.includes("\\\n") ? rawName.split("\\\n").join("") : rawName; | ||
| result.name = name; | ||
| if (append) | ||
| result.append = true; | ||
| if (index !== undefined) | ||
| if (index !== undefined) { | ||
| result.index = index; | ||
| const indexPos = tokPos + bracketIdx + 1; | ||
| const indexEnd = indexPos + index.length; | ||
| if (hasEmbeddedWordStructure(this.source, indexPos, indexEnd)) { | ||
| const indexWord = new WordImpl(index, indexPos, indexEnd, this.source, computeEmbeddedWordParts, this.depth); | ||
| Object.defineProperty(result, "indexParts", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get: () => indexWord.parts, | ||
| set: (value) => { | ||
| indexWord.parts = value; | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
| // Value portion starts after = | ||
@@ -1116,3 +1314,5 @@ const valStart = eqIdx + 1; | ||
| // Check for array assignment: value starts with ( | ||
| if (text.charCodeAt(valStart) === 0x28 /* ( */ && text.charCodeAt(text.length - 1) === 0x29 /* ) */) { | ||
| if (valStart < text.length && | ||
| text.charCodeAt(valStart) === 0x28 /* ( */ && | ||
| text.charCodeAt(text.length - 1) === 0x29 /* ) */) { | ||
| const elements = this.parseArrayElements(valueStart + 1, tokEnd - 1); | ||
@@ -1122,3 +1322,3 @@ result.array = elements; | ||
| else { | ||
| result.value = new WordImpl(text.slice(valStart), valueStart, tokEnd, this.source); | ||
| result.value = new WordImpl(text.slice(valStart), valueStart, tokEnd, this.source, undefined, this.depth); | ||
| } | ||
@@ -1137,3 +1337,4 @@ return result; | ||
| 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)); | ||
| const text = t.raw ? t.value : this.source.slice(t.pos, t.end); | ||
| elements.push(new WordImpl(text, t.pos, t.end, this.source, undefined, this.depth)); | ||
| } | ||
@@ -1140,0 +1341,0 @@ } |
+4
-2
@@ -8,3 +8,5 @@ import type { Word, WordPart } from "./types.ts"; | ||
| */ | ||
| export declare function computeWordParts(source: string, word: Word): WordPart[] | undefined; | ||
| export declare function computeWordParts(source: string, word: Word, depth?: number): WordPart[] | undefined; | ||
| /** Compute structural parts for a word-like span that may contain shell operators or whitespace. */ | ||
| export declare function computeEmbeddedWordParts(source: string, word: Pick<Word, "pos" | "end">, depth?: number): WordPart[] | undefined; | ||
| /** | ||
@@ -15,2 +17,2 @@ * Compute parts for an unquoted heredoc body. | ||
| */ | ||
| export declare function computeHereDocBodyParts(source: string, word: Word): WordPart[] | undefined; | ||
| export declare function computeHereDocBodyParts(source: string, word: Word, depth?: number): WordPart[] | undefined; |
+34
-7
@@ -1,2 +0,2 @@ | ||
| import { Lexer } from "./lexer.js"; | ||
| import { hasEmbeddedWordStructure, Lexer, MAX_SYNTAX_NESTING } from "./lexer.js"; | ||
| import { parse, parseRegion } from "./parser.js"; | ||
@@ -9,3 +9,3 @@ /** | ||
| */ | ||
| export function computeWordParts(source, word) { | ||
| export function computeWordParts(source, word, depth = 0) { | ||
| // Bound the re-lex to the word's span. A word inside a substitution script carries the | ||
@@ -16,2 +16,3 @@ // whole original as its source, so an unbounded scan would overrun the word into an | ||
| const lexer = new Lexer(source, word.pos, word.end); | ||
| lexer._nestingDepth = depth; | ||
| const parts = lexer.buildWordParts(word.pos); | ||
@@ -23,2 +24,14 @@ if (!parts) | ||
| } | ||
| /** Compute structural parts for a word-like span that may contain shell operators or whitespace. */ | ||
| export function computeEmbeddedWordParts(source, word, depth = 0) { | ||
| if (!hasEmbeddedWordStructure(source, word.pos, word.end)) | ||
| return undefined; | ||
| const lexer = new Lexer(source, word.pos, word.end); | ||
| lexer._nestingDepth = depth; | ||
| const parts = lexer.buildEmbeddedWordParts(word.pos); | ||
| if (!parts) | ||
| return undefined; | ||
| resolveCollected(lexer); | ||
| return parts; | ||
| } | ||
| /** | ||
@@ -29,4 +42,5 @@ * Compute parts for an unquoted heredoc body. | ||
| */ | ||
| export function computeHereDocBodyParts(source, word) { | ||
| export function computeHereDocBodyParts(source, word, depth = 0) { | ||
| const lexer = new Lexer(source, word.pos, word.end); | ||
| lexer._nestingDepth = depth; | ||
| const parts = lexer.buildHereDocParts(word.pos, word.end); | ||
@@ -46,10 +60,23 @@ if (!parts) | ||
| * substring of the source and carries no innerStart; those parse the rebuilt slice and stay | ||
| * relative to it — the single documented exception to absolute offsets. | ||
| * relative to it — the single exception to absolute offsets. Only these scripts carry a | ||
| * non-enumerable `source` property holding the decoded string their positions index. | ||
| */ | ||
| function resolveCollected(lexer) { | ||
| const source = lexer.getSource(); | ||
| for (const e of lexer.getCollectedExpansions()) { | ||
| for (const [e, innerDepth] of lexer.getCollectedExpansions()) { | ||
| if (e.inner !== undefined) { | ||
| e.script = | ||
| e.innerStart !== undefined ? parseRegion(source, e.innerStart, e.innerStart + e.inner.length) : parse(e.inner); | ||
| const depth = innerDepth + 1; | ||
| if (depth > MAX_SYNTAX_NESTING + 1) { | ||
| // Past the flagged boundary script — stop descending and leave the | ||
| // substitution unresolved rather than materializing unbounded structure. | ||
| } | ||
| else if (e.innerStart !== undefined) { | ||
| e.script = parseRegion(source, e.innerStart, e.innerStart + e.inner.length, depth); | ||
| } | ||
| else { | ||
| // Escaped-backtick scripts parse from the decoded slice. Each nesting level | ||
| // doubles the escaping, so their depth is bounded by input size already. | ||
| e.script = parse(e.inner); | ||
| Object.defineProperty(e.script, "source", { value: e.inner, enumerable: false }); | ||
| } | ||
| e.inner = undefined; | ||
@@ -56,0 +83,0 @@ e.innerStart = undefined; |
+23
-10
@@ -0,1 +1,2 @@ | ||
| import { WordImpl } from "./word.js"; | ||
| export function print(script) { | ||
@@ -48,11 +49,3 @@ let out = ""; | ||
| function delimName(r) { | ||
| if (!r.target) | ||
| return ""; | ||
| const text = wd(r.target); | ||
| if ((text[0] === "'" && text[text.length - 1] === "'") || (text[0] === '"' && text[text.length - 1] === '"')) { | ||
| return text.slice(1, -1); | ||
| } | ||
| if (text.includes("\\")) | ||
| return text.replaceAll("\\", ""); | ||
| return text; | ||
| return r.target?.value ?? ""; | ||
| } | ||
@@ -405,2 +398,22 @@ function printNode(n, indent) { | ||
| } | ||
| // Partless redirect targets carry decoded text (quotes and escapes removed), so | ||
| // tokenization-breaking characters must be requoted from the value. Glob and | ||
| // expansion characters stay verbatim — quoting them would change meaning. | ||
| const UNSAFE_TARGET = /[\s"'\\|&;<>()]/; | ||
| function singleQuote(value) { | ||
| return "'" + value.replaceAll("'", "'\\''") + "'"; | ||
| } | ||
| function redirectTarget(r) { | ||
| const w = r.target; | ||
| if ((r.operator === "<<" || r.operator === "<<-") && r.heredocQuoted) | ||
| return singleQuote(w.value); | ||
| if (w.parts) | ||
| return wd(w); | ||
| const sourceText = w instanceof WordImpl ? w.sourceText() : undefined; | ||
| if (sourceText !== undefined && sourceText !== w.text) | ||
| return singleQuote(w.value); | ||
| if (!UNSAFE_TARGET.test(w.text)) | ||
| return w.text; | ||
| return singleQuote(w.value); | ||
| } | ||
| function redir(r) { | ||
@@ -416,3 +429,3 @@ let out = ""; | ||
| out += " "; | ||
| out += wd(r.target); | ||
| out += redirectTarget(r); | ||
| } | ||
@@ -419,0 +432,0 @@ return out; |
+18
-3
@@ -42,2 +42,3 @@ export interface Word { | ||
| index: string | undefined; | ||
| indexParts?: WordPart[]; | ||
| indirect: boolean | undefined; | ||
@@ -59,3 +60,3 @@ length: boolean | undefined; | ||
| text: string; | ||
| script: Script | undefined; | ||
| script: ParsedScript | undefined; | ||
| inner: string | undefined; | ||
@@ -74,3 +75,3 @@ /** Internal: absolute offset of `inner` in the original source; cleared after resolution. */ | ||
| operator: "<" | ">"; | ||
| script: Script | undefined; | ||
| script: ParsedScript | undefined; | ||
| inner: string | undefined; | ||
@@ -86,2 +87,3 @@ /** Internal: absolute offset of `inner` in the original source; cleared after resolution. */ | ||
| pattern: string; | ||
| parts?: WordPart[]; | ||
| } | ||
@@ -91,2 +93,3 @@ export interface BraceExpansionPart { | ||
| text: string; | ||
| parts?: WordPart[]; | ||
| } | ||
@@ -129,2 +132,3 @@ export type ArithmeticExpression = ArithmeticBinary | ArithmeticUnary | ArithmeticTernary | ArithmeticGroup | ArithmeticWord | ArithmeticCommandExpansion; | ||
| value: string; | ||
| parts?: WordPart[]; | ||
| } | ||
@@ -137,3 +141,3 @@ export interface ArithmeticCommandExpansion { | ||
| inner: string | undefined; | ||
| script: Script | undefined; | ||
| script: ParsedScript | undefined; | ||
| innerStart?: number; | ||
@@ -152,2 +156,3 @@ } | ||
| index: string | undefined; | ||
| indexParts?: WordPart[]; | ||
| array: Word[] | undefined; | ||
@@ -351,2 +356,12 @@ } | ||
| } | ||
| export interface ParsedScript extends Script { | ||
| /** | ||
| * Present only on scripts parsed from a rebuilt string (decoded escaped-backtick | ||
| * substitutions), whose positions index this decoded string instead of the | ||
| * caller's source. Absent everywhere else: positions already index the string | ||
| * the caller parsed. Non-enumerable when present. | ||
| */ | ||
| readonly source?: string; | ||
| errors?: ParseError[]; | ||
| } | ||
| export interface ParseError { | ||
@@ -353,0 +368,0 @@ message: string; |
+3
-2
| import type { Word, WordPart } from "./types.ts"; | ||
| export type PartsResolver = (source: string, word: Word) => WordPart[] | undefined; | ||
| export type PartsResolver = (source: string, word: Word, depth: number) => WordPart[] | undefined; | ||
| export declare class WordImpl implements Word { | ||
@@ -10,6 +10,7 @@ #private; | ||
| end: number; | ||
| constructor(text: string, pos: number, end: number, source?: string, resolver?: PartsResolver); | ||
| constructor(text: string, pos: number, end: number, source?: string, resolver?: PartsResolver, depth?: number); | ||
| get value(): string; | ||
| get parts(): WordPart[] | undefined; | ||
| set parts(v: WordPart[] | undefined); | ||
| sourceText(): string | undefined; | ||
| toJSON(): { | ||
@@ -16,0 +17,0 @@ text: string; |
+8
-3
@@ -37,10 +37,12 @@ function dequoteValue(parts) { | ||
| #resolver; | ||
| #depth; | ||
| #parts; | ||
| #value = null; | ||
| constructor(text, pos, end, source, resolver) { | ||
| constructor(text, pos, end, source, resolver, depth = 0) { | ||
| this.text = text; | ||
| this.pos = pos; | ||
| this.end = end; | ||
| this.#source = source ?? ""; | ||
| this.#source = source; | ||
| this.#resolver = resolver ?? WordImpl._resolveWord; | ||
| this.#depth = depth; | ||
| this.#parts = source !== undefined ? null : undefined; | ||
@@ -79,3 +81,3 @@ } | ||
| if (this.#parts === null) { | ||
| this.#parts = this.#resolver(this.#source, this) ?? undefined; | ||
| this.#parts = this.#resolver(this.#source ?? "", this, this.#depth) ?? undefined; | ||
| } | ||
@@ -87,2 +89,5 @@ return this.#parts; | ||
| } | ||
| sourceText() { | ||
| return this.#source?.slice(this.pos, this.end); | ||
| } | ||
| toJSON() { | ||
@@ -89,0 +94,0 @@ return { text: this.text, pos: this.pos, end: this.end, parts: this.parts, value: this.value }; |
+1
-1
| { | ||
| "name": "unbash", | ||
| "version": "4.0.4", | ||
| "version": "4.0.5", | ||
| "description": "Fast 0-deps bash parser written in TypeScript", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
+123
-28
@@ -11,2 +11,30 @@ # unbash | ||
| ## When to use unbash? | ||
| Use unbash when your input is Bash syntax, such as a pasted command or a | ||
| complete script, and you need to inspect its structure without executing it. It | ||
| returns a typed, source-positioned AST for commands, pipelines, redirects, | ||
| assignments, compound statements, word expansions, and nested substitutions. | ||
| Example use cases: | ||
| - Audit commands or scripts against an application-defined safety policy | ||
| - Find and classify commands, including commands nested in substitutions | ||
| - Surface parse errors in generated or pasted Bash, with source positions | ||
| - Extract a command such as `curl` from pasted shell input while keeping | ||
| neighboring pipelines, logical chains, redirects, and comments separate | ||
| - Build command explanations from syntax, expansions, and source positions | ||
| - Rewrite one syntactic element while preserving the surrounding command text | ||
| unbash does not execute code, perform shell expansion, provide a sandbox, or | ||
| decide whether a command is safe. Security-sensitive consumers must inspect word | ||
| parts, nested scripts, and errors on each parsed script. unbash is a tolerant | ||
| parser: for malformed or incomplete input, it recovers where possible and | ||
| returns a best-effort partial AST with source-positioned errors. It does not | ||
| target PowerShell, `cmd.exe`, or other shell languages. Much POSIX `sh` syntax | ||
| is also valid Bash. | ||
| To parse `process.argv` (`string[]`), use Node.js [`parseArgs`][1] or a CLI | ||
| library such as [yargs][2] or [citty][3]. | ||
| ## Usage | ||
@@ -26,5 +54,8 @@ | ||
| commands: [{ | ||
| type: "If", | ||
| clause: { type: "Command", name: { text: "[" }, ... }, | ||
| then: { type: "Command", name: { text: "cat" }, ... } | ||
| type: "Statement", | ||
| command: { | ||
| type: "If", | ||
| clause: { type: "CompoundList", commands: [ /* [ -f "$1" ] */ ] }, | ||
| then: { type: "CompoundList", commands: [ /* cat "$1" */ ] } | ||
| } | ||
| }] | ||
@@ -34,4 +65,65 @@ } | ||
| ### Word parts | ||
| A `Word` holds its expansions in `parts`. This is a lazy getter, computed on | ||
| first access (not an own enumerable property): | ||
| ```js | ||
| const word = parse("echo a$(id)b").commands[0].command.suffix[0]; | ||
| word.parts; // [Literal, CommandExpansion, Literal] | ||
| Object.keys(word); // ["text", "pos", "end"] — no `parts` | ||
| ({ ...word }); // same | ||
| structuredClone(word); // same | ||
| ``` | ||
| Read `parts` directly, or serialize with `JSON.stringify`, which includes it | ||
| through `toJSON`. A generic walker driven by `Object.keys` finds no expansions | ||
| at all, and reports no error while doing so: | ||
| ```js | ||
| import { parse } from "unbash"; | ||
| const script = parse('echo "$HOME" $(mktemp)'); | ||
| for (const statement of script.commands) { | ||
| const command = statement.command; | ||
| if (command.type !== "Command") continue; | ||
| for (const word of [command.name, ...command.suffix]) { | ||
| for (const part of word?.parts ?? []) { | ||
| if (part.type === "CommandExpansion") console.log(part.text); | ||
| } | ||
| } | ||
| } | ||
| // $(mktemp) | ||
| ``` | ||
| Word-like fields that can execute nested shell syntax expose the same structure. | ||
| `BraceExpansion`, `ExtendedGlob`, and `ArithmeticWord` use `parts`; parameter | ||
| and assignment array indexes use `indexParts`. | ||
| Positions index the source owned by the nearest `ParsedScript`. Root scripts and | ||
| verbatim nested substitutions share the caller's source, so their `pos`/`end` | ||
| slice that source directly: | ||
| ```js | ||
| const nested = word.parts.find((part) => part.type === "CommandExpansion").script; | ||
| const command = nested.commands[0].command; | ||
| source.slice(command.pos, command.end); // exact nested command source | ||
| ``` | ||
| A legacy backtick script whose body contains backslash escapes owns its decoded | ||
| string as a non-enumerable `source` property. Ordinary scripts nested inside it | ||
| index that decoded source. Object spread and `structuredClone` omit `source` | ||
| because it is non-enumerable. | ||
| Parse errors inside a lazily parsed script surface on that script, not on the | ||
| root: check `errors` on every nested `script` while traversing. A consumer that | ||
| only reads the root `errors` array cannot tell that a substitution body failed | ||
| to parse. | ||
| Basic opinionated printer, does not preserve whitespace or comments (except | ||
@@ -42,3 +134,3 @@ shebang): | ||
| import { parse } from "unbash"; | ||
| import { print } from "unbash/print"; | ||
| import { print } from "unbash/printer"; | ||
@@ -59,3 +151,3 @@ const ast = parse('if [ -f "$1" ]; then cat "$1"; fi'); | ||
| [tree-sitter-bash][1] is an excellent choice if you need: | ||
| [tree-sitter-bash][4] is an excellent choice if you need: | ||
@@ -74,7 +166,7 @@ - Incremental parsing | ||
| }`, `[[ ]]`, `(( ))`, and extglob | ||
| - Tolerant parsing that never throws and collects parse errors | ||
| - Best-effort error recovery that preserves a partial AST and collects errors | ||
| ## unbash vs sh-syntax | ||
| [sh-syntax][2] is a WASM wrapper around the robust [mvdan/sh][3] Go parser. It | ||
| [sh-syntax][5] is a WASM wrapper around the robust [mvdan/sh][6] Go parser. It | ||
| is highly recommended if you need: | ||
@@ -93,4 +185,4 @@ | ||
| [bash-parser][4] (last publish: 2017) and its fork | ||
| [@ericcornelissen/bash-parser][5] (community dependency maintenance fork ❤️ now | ||
| [bash-parser][7] (last publish: 2017) and its fork | ||
| [@ericcornelissen/bash-parser][8] (community dependency maintenance fork ❤️ now | ||
| archived) might be interesting if you need: | ||
@@ -104,3 +196,3 @@ | ||
| - A typed TypeScript API (ESM-only) | ||
| - Tolerant parsing that never throws and collects parse errors | ||
| - Best-effort error recovery that preserves a partial AST and collects errors | ||
| - Structured AST nodes for parameter expansions, arithmetic expressions, and `[[ | ||
@@ -113,12 +205,12 @@ ]]` test expressions | ||
| Relative performance comparison (on Apple M1 Pro/32GB), unbash is x times | ||
| faster: | ||
| Median relative performance across three runs on Apple M1 Pro/32GB using Node.js | ||
| 22.23.2. unbash is x times faster: | ||
| | Parser | short | advanced | medium | large | | ||
| | ---------------------------- | ----: | -------: | -----: | ----: | | ||
| | tree-sitter-bash (native) | 13x | 9x | 4x | 5x | | ||
| | tree-sitter-bash (WASM) | 16x | 12x | 8x | 8x | | ||
| | sh-syntax | 2136x | 1537x | 8x | 4x | | ||
| | bash-parser | 256x | N/A | N/A | N/A | | ||
| | @ericcornelissen/bash-parser | 267x | N/A | N/A | N/A | | ||
| | tree-sitter-bash (native) | 17x | 10x | 7x | 10x | | ||
| | tree-sitter-bash (WASM) | 20x | 14x | 15x | 17x | | ||
| | sh-syntax | 3560x | 2370x | 15x | 9x | | ||
| | bash-parser | 317x | N/A | N/A | N/A | | ||
| | @ericcornelissen/bash-parser | 335x | N/A | N/A | N/A | | ||
@@ -134,8 +226,8 @@ Run the benchmarks using Node.js v22: | ||
| unbash is 53K minified, 13KB gzipped. | ||
| The parser bundle is 77KB minified and 18KB gzipped. | ||
| ## Playgrounds | ||
| - [unbash.statichost.page][6] | ||
| - [ast-explorer.dev][7] | ||
| - [unbash.statichost.page][9] | ||
| - [ast-explorer.dev][10] | ||
@@ -146,8 +238,11 @@ ## License | ||
| [1]: https://github.com/tree-sitter/tree-sitter-bash | ||
| [2]: https://github.com/un-ts/sh-syntax | ||
| [3]: https://github.com/mvdan/sh | ||
| [4]: https://github.com/vorpaljs/bash-parser | ||
| [5]: https://github.com/ericcornelissen/bash-parser | ||
| [6]: https://unbash.statichost.page | ||
| [7]: https://ast-explorer.dev/#eNoVjDsKwzAQRK8yDK5CyAGS2nVAId02jixZAbFr/Kls393rbh7zeBsrn5xLqpV3jr5X/XVzcYgOKRaD8LoNzffTBqFotgkZf8XtUW14oTdRIHaLq00WYscwpRFtCO8g2psm75n3toPHCdz+Ivg= | ||
| [1]: https://nodejs.org/api/util.html#utilparseargsconfig | ||
| [2]: https://yargs.js.org/ | ||
| [3]: https://www.npmjs.com/package/citty | ||
| [4]: https://github.com/tree-sitter/tree-sitter-bash | ||
| [5]: https://github.com/un-ts/sh-syntax | ||
| [6]: https://github.com/mvdan/sh | ||
| [7]: https://github.com/vorpaljs/bash-parser | ||
| [8]: https://github.com/ericcornelissen/bash-parser | ||
| [9]: https://unbash.statichost.page | ||
| [10]: https://ast-explorer.dev/#eNoVjDsKwzAQRK8yDK5CyAGS2nVAId02jixZAbFr/Kls393rbh7zeBsrn5xLqpV3jr5X/XVzcYgOKRaD8LoNzffTBqFotgkZf8XtUW14oTdRIHaLq00WYscwpRFtCO8g2psm75n3toPHCdz+Ivg= |
Sorry, the diff of this file is too big to display
257096
38.22%23
21.05%6663
29.4%238
66.43%