🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

unbash

Package Overview
Dependencies
Maintainers
1
Versions
17
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

unbash - npm Package Compare versions

Comparing version
4.0.4
to
4.0.5
+5
dist/ansi-c.d.ts
export declare function decodeAnsiCQuoted(source: string, start: number, limit: number): {
value: string;
end: number;
closed: boolean;
};
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;

@@ -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;
}
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;
}
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;

@@ -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 @@ }

@@ -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;

@@ -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;

@@ -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;

@@ -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;

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;

@@ -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 };

{
"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 @@ }

## Print
### 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.
### Print
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