refactor(hooks): split shell-scan into per-token helpers

heredocDelimiter, executionRegion and scanShell each carried one long character loop with every quote, escape, heredoc, case and scope rule inline, which is where the three complexity warnings came from. The delimiter reader now has skipDelimiterBlanks, delimiterEscape and readDelimiterWord; the $(...) walker dispatches to regionQuotedEnd, regionSubstitution, openRegionQuote, regionHeredocOperator, regionHeredocBodies, casePhaseStep and closeRegionParen; and the top-level scanner keeps its position and pending word on a scan object that scanQuotedChar, scanEscape, scanSubstitution, openQuote, scanOperator and scanRedirect advance.

Every budget.spend call keeps its amount and order, so the work quota tests still land in the same window. The module still has no requires and exports createBudget, scanShell and scanExpansions unchanged; a differential run of the old and new scanner over the hook test corpus plus 58k generated inputs produced identical commands, nested regions and spend totals.
This commit is contained in:
Affaan Mustafa authored and Affaan Mustafa committed 2026-10-19 23:39:18 -04:00
1 parent 35f57c80e6
commit 26d2010a16
1 file changed
+380 -247
+380 -247
View File
@@ -18,13 +18,15 @@ function continuationEnd(input, index) {
return index;
}
// Delimiters undergo quote removal, not command expansion. Keeping this small
// reader shared with substitution matching prevents body punctuation becoming
// shell syntax while locating the enclosing execution region.
function heredocDelimiter(input, start, budget) {
if (!input.startsWith('<<', start) || input[start + 2] === '<') return null;
const operator = input[start + 2] === '-' ? '<<-' : '<<';
let i = start + operator.length;
// Skip to the end of a comment. The returned index is the newline or EOF.
function skipComment(input, start, budget) {
let i = start;
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
return i;
}
function skipDelimiterBlanks(input, start, budget) {
let i = start;
while (i < input.length) {
budget.spend();
if (input[i] === ' ' || input[i] === '\t' || input[i] === '\r') { i++; continue; }
@@ -32,37 +34,55 @@ function heredocDelimiter(input, start, budget) {
if (continued === i) break;
i = continued;
}
let value = '';
let quote = null;
let began = false;
let quoted = false;
return i;
}
// A backslash in the delimiter word. Returns the index of the last consumed
// character; a continuation is removed without starting the word.
function delimiterEscape(input, i, word) {
const continued = continuationEnd(input, i);
if (continued !== i) return continued - 1;
word.began = true; word.quoted = true;
const next = input[i + 1];
if (next === undefined) { word.value += '\\'; return i; }
if (word.quote === '"' && !'"$`\\'.includes(next)) word.value += '\\';
word.value += next;
return i + 1;
}
function readDelimiterWord(input, start, budget) {
const word = { value: '', quote: null, began: false, quoted: false, end: start };
let i = start;
for (; i < input.length; i++) {
budget.spend();
const c = input[i];
if (quote === "'") {
if (c === "'") quote = null;
else value += c;
if (word.quote === "'") {
if (c === "'") word.quote = null;
else word.value += c;
continue;
}
if (c === '\\') {
const continued = continuationEnd(input, i);
if (continued !== i) { i = continued - 1; continue; }
began = true; quoted = true;
const next = input[i + 1];
if (next === undefined) { value += c; continue; }
if (quote === '"' && !'"$`\\'.includes(next)) value += '\\';
value += next; i++; continue;
}
if (quote === '"') {
if (c === '"') quote = null;
else value += c;
if (c === '\\') { i = delimiterEscape(input, i, word); continue; }
if (word.quote === '"') {
if (c === '"') word.quote = null;
else word.value += c;
continue;
}
if (c === "'" || c === '"') { quote = c; began = true; quoted = true; continue; }
if (/[\s;|&()<>]/.test(c) || (!began && c === '#')) break;
began = true; value += c;
if (c === "'" || c === '"') { word.quote = c; word.began = true; word.quoted = true; continue; }
if (/[\s;|&()<>]/.test(c) || (!word.began && c === '#')) break;
word.began = true; word.value += c;
}
return began ? { operator, word: { value, quoted }, end: i } : null;
word.end = i;
return word;
}
// Delimiters undergo quote removal, not command expansion. Keeping this small
// reader shared with substitution matching prevents body punctuation becoming
// shell syntax while locating the enclosing execution region.
function heredocDelimiter(input, start, budget) {
if (!input.startsWith('<<', start) || input[start + 2] === '<') return null;
const operator = input[start + 2] === '-' ? '<<-' : '<<';
const word = readDelimiterWord(input, skipDelimiterBlanks(input, start + operator.length, budget), budget);
return word.began ? { operator, word: { value: word.value, quoted: word.quoted }, end: word.end } : null;
}
function heredocLine(input, start, joinContinuations, budget) {
@@ -129,90 +149,141 @@ function legacyRegion(input, start, budget) {
return { text: decoded.join(''), end: i < input.length ? i + 1 : i };
}
// --- $(...) region state ---
// Each helper below takes the innermost frame and the index of the current
// character. Those that consume input return the index of the last consumed
// character, or undefined when the character was not theirs to handle.
function regionFrame() {
return { quote: null, depth: 1, cases: [], word: '', quotedWord: false, commandStart: true, heredocs: [] };
}
function endRegionWord(state) {
if (!state.word) return;
const phase = state.cases[state.cases.length - 1];
if (!state.quotedWord && state.word === 'esac' && (state.commandStart || phase === 'pattern')) state.cases.pop();
else if (!state.quotedWord && state.word === 'case' && state.commandStart) state.cases.push('subject');
else if (phase === 'subject') state.cases[state.cases.length - 1] = 'in';
else if (!state.quotedWord && state.word === 'in' && phase === 'in') state.cases[state.cases.length - 1] = 'pattern';
state.commandStart = false;
state.word = ''; state.quotedWord = false;
}
// Inside '...' or $'...': only the closing quote matters, and an ANSI-C escape
// protects the next character.
function regionQuotedEnd(state, input, i) {
const c = input[i];
if (state.quote === "$'" && c === '\\' && i + 1 < input.length) return i + 1;
if (c === "'") state.quote = null;
return i;
}
// Escapes and nested substitutions apply inside double quotes as well.
function regionSubstitution(state, stack, input, i, budget) {
const c = input[i];
if (c === '\\') { state.word += input[i + 1] || ''; state.quotedWord = true; return i + 1; }
if (c === '$' && input[i + 1] === '(') {
state.word += '$()'; state.quotedWord = true; stack.push(regionFrame()); return i + 1;
}
if (c === '`') {
const region = legacyRegion(input, i, budget);
state.word += '`'; state.quotedWord = true; return region.end - 1;
}
return undefined;
}
function openRegionQuote(state, input, i) {
const c = input[i];
if (c === '$' && input[i + 1] === "'") {
state.quote = "$'"; state.word += "$'"; state.quotedWord = true; return i + 1;
}
if (c === '"' || c === "'") { state.quote = c; state.word += c; state.quotedWord = true; return i; }
return undefined;
}
// A here-string or heredoc operator ends the current word. A heredoc
// delimiter queues its body for the next newline.
function regionHeredocOperator(state, input, i, budget) {
if (input[i] !== '<') return undefined;
if (input.startsWith('<<<', i)) { endRegionWord(state); return i + 2; }
if (input[i + 1] === '<' && input[i + 2] !== '<') {
endRegionWord(state);
const delimiter = heredocDelimiter(input, i, budget);
if (delimiter) { state.heredocs.push(delimiter); return delimiter.end - 1; }
}
return undefined;
}
function regionHeredocBodies(state, input, i, budget) {
endRegionWord(state);
let next = i + 1;
for (const redirect of state.heredocs) next = heredocBody(input, next, redirect, budget).end;
state.heredocs.length = 0;
state.commandStart = true;
return next - 1;
}
// A case pattern's closing ')' is not the end of $(...). Only literal
// keyword positions affect this state; quoted or echo operands do not.
function casePhaseStep(state, input, i) {
const c = input[i];
const phase = state.cases[state.cases.length - 1];
if (c === ')' && phase === 'pattern') {
state.cases[state.cases.length - 1] = 'body'; state.commandStart = true; return i;
}
if (c === '(' && phase === 'pattern') return i;
if (c === ';' && input[i + 1] === ';' && phase === 'body') {
state.cases[state.cases.length - 1] = 'pattern'; state.commandStart = true; return i + 1;
}
return undefined;
}
// Tracks parenthesis depth; true once the outermost region has closed.
function closeRegionParen(stack, state, c) {
if (/[;|&\n]/.test(c)) state.commandStart = true;
if (c === '(') state.depth++;
if (c !== ')') return false;
state.depth--;
if (state.depth !== 0) return false;
stack.pop();
return stack.length === 0;
}
// Locate a nested execution region without evaluating any supplied text.
// Balanced substitutions have independent quote state; malformed regions extend
// to EOF and remain conservatively inspectable instead of silently disappearing.
function executionRegion(input, start, budget) {
if (input[start] === '`') return legacyRegion(input, start, budget);
const from = start + 2;
const frame = () => ({ quote: null, depth: 1, cases: [], word: '', quotedWord: false, commandStart: true, heredocs: [] });
const stack = [frame()];
function endWord(state) {
if (!state.word) return;
const phase = state.cases[state.cases.length - 1];
if (!state.quotedWord && state.word === 'esac' && (state.commandStart || phase === 'pattern')) state.cases.pop();
else if (!state.quotedWord && state.word === 'case' && state.commandStart) state.cases.push('subject');
else if (phase === 'subject') state.cases[state.cases.length - 1] = 'in';
else if (!state.quotedWord && state.word === 'in' && phase === 'in') state.cases[state.cases.length - 1] = 'pattern';
state.commandStart = false;
state.word = ''; state.quotedWord = false;
}
const stack = [regionFrame()];
for (let i = from; i < input.length; i++) {
budget.spend();
const state = stack[stack.length - 1];
const c = input[i];
if (state.quote === "'" || state.quote === "$'") {
if (state.quote === "$'" && c === '\\' && i + 1 < input.length) { i++; continue; }
if (c === "'") state.quote = null;
continue;
}
if (c === '\\') { state.word += input[i + 1] || ''; state.quotedWord = true; i++; continue; }
if (c === '$' && input[i + 1] === '(') {
state.word += '$()'; state.quotedWord = true; stack.push(frame()); i++; continue;
}
if (c === '`') {
const region = legacyRegion(input, i, budget);
state.word += '`'; state.quotedWord = true; i = region.end - 1; continue;
}
if (state.quote === "'" || state.quote === "$'") { i = regionQuotedEnd(state, input, i); continue; }
const substituted = regionSubstitution(state, stack, input, i, budget);
if (substituted !== undefined) { i = substituted; continue; }
if (state.quote === '"') {
if (c === '"') state.quote = null;
continue;
}
if (c === '$' && input[i + 1] === "'") {
state.quote = "$'"; state.word += "$'"; state.quotedWord = true; i++; continue;
}
if (c === '"' || c === "'") { state.quote = c; state.word += c; state.quotedWord = true; continue; }
const opened = openRegionQuote(state, input, i);
if (opened !== undefined) { i = opened; continue; }
if (c === '#' && /[\s;|&()]/.test(input[i - 1] || ' ')) {
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
i--; // Process the newline, including any pending heredoc bodies.
// Stop before the newline so pending heredoc bodies are still processed.
i = skipComment(input, i, budget) - 1;
continue;
}
if (c === '<' && input.startsWith('<<<', i)) { endWord(state); i += 2; continue; }
if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') {
endWord(state);
const delimiter = heredocDelimiter(input, i, budget);
if (delimiter) { state.heredocs.push(delimiter); i = delimiter.end - 1; continue; }
}
if (c === '\n' && state.heredocs.length) {
endWord(state);
let next = i + 1;
for (const redirect of state.heredocs) next = heredocBody(input, next, redirect, budget).end;
state.heredocs.length = 0;
state.commandStart = true; i = next - 1; continue;
}
if (/[\s;|&()]/.test(c)) endWord(state);
const redirected = regionHeredocOperator(state, input, i, budget);
if (redirected !== undefined) { i = redirected; continue; }
if (c === '\n' && state.heredocs.length) { i = regionHeredocBodies(state, input, i, budget); continue; }
if (/[\s;|&()]/.test(c)) endRegionWord(state);
else state.word += c;
const phase = state.cases[state.cases.length - 1];
// A case pattern's closing ')' is not the end of $(...). Only literal
// keyword positions affect this state; quoted or echo operands do not.
if (c === ')' && phase === 'pattern') {
state.cases[state.cases.length - 1] = 'body'; state.commandStart = true; continue;
}
if (c === '(' && phase === 'pattern') continue;
if (c === ';' && input[i + 1] === ';' && phase === 'body') {
state.cases[state.cases.length - 1] = 'pattern'; state.commandStart = true; i++; continue;
}
if (/[;|&\n]/.test(c)) state.commandStart = true;
if (c === '(') state.depth++;
if (c === ')') {
state.depth--;
if (state.depth === 0) {
stack.pop();
if (stack.length === 0) {
budget.spend(i - from + 1);
return { text: input.slice(from, i), end: i + 1 };
}
}
const stepped = casePhaseStep(state, input, i);
if (stepped !== undefined) { i = stepped; continue; }
if (closeRegionParen(stack, state, c)) {
budget.spend(i - from + 1);
return { text: input.slice(from, i), end: i + 1 };
}
}
budget.spend(input.length - from + 1);
@@ -243,164 +314,226 @@ function scanExpansions(input, budget) {
return nested;
}
function scanShell(input, budget) {
const commands = [];
const nested = [];
const pendingHeredocs = [];
// --- Top-level scan state ---
// scan.i is the read position; every helper advances it past what it consumed.
const COMPOUND_KEYWORDS = ['if', 'then', 'elif', 'else', 'while', 'until', 'do', 'case', 'for', 'select', 'function'];
function newCommand(scan, pipeFrom) {
return { words: [], redirects: [], pipeFrom, nested: [], scope: scan.scope };
}
function createScan(input, budget) {
const rootScope = { parent: null, isolated: false, conditional: false };
let scope = rootScope;
const command = pipeFrom => ({ words: [], redirects: [], pipeFrom, nested: [], scope });
let current = command(null);
let word = null;
let quote = null;
let pendingRedirect = null;
let i = 0;
function begin() {
if (!word) word = { value: '', start: i, end: i, quoted: false, literal: true, dynamic: false };
const scan = {
input, budget, commands: [], nested: [], pendingHeredocs: [], rootScope,
scope: rootScope, current: null, word: null, quote: null, pendingRedirect: null, i: 0
};
scan.current = newCommand(scan, null);
return scan;
}
function beginWord(scan) {
if (!scan.word) scan.word = { value: '', start: scan.i, end: scan.i, quoted: false, literal: true, dynamic: false };
}
function flushWord(scan) {
const { word, current } = scan;
if (!word) return;
word.end = scan.i;
word.raw = scan.input.slice(word.start, scan.i);
if (scan.pendingRedirect) {
const redirect = { operator: scan.pendingRedirect, word, body: '' };
current.redirects.push(redirect);
if (scan.pendingRedirect === '<<' || scan.pendingRedirect === '<<-') scan.pendingHeredocs.push({ redirect, owner: current });
scan.pendingRedirect = null;
} else current.words.push(word);
scan.word = null;
}
function flushCommand(scan, pipe = false, background = false) {
flushWord(scan);
const previous = scan.current;
previous.pipeTo = pipe;
previous.background = background;
if (previous.closedScope && (pipe || background)) {
previous.closedScope.isolated = true;
previous.closedScope.pipelineLast = false;
}
function flushWord() {
if (!word) return;
word.end = i;
word.raw = input.slice(word.start, i);
if (pendingRedirect) {
const redirect = { operator: pendingRedirect, word, body: '' };
current.redirects.push(redirect);
if (pendingRedirect === '<<' || pendingRedirect === '<<-') pendingHeredocs.push({ redirect, owner: current });
pendingRedirect = null;
} else current.words.push(word);
word = null;
}
function flushCommand(pipe = false, background = false) {
flushWord();
const previous = current;
previous.pipeTo = pipe;
previous.background = background;
if (previous.closedScope && (pipe || background)) {
previous.closedScope.isolated = true;
previous.closedScope.pipelineLast = false;
const first = previous.words[0];
if (first && !first.quoted && COMPOUND_KEYWORDS.includes(first.value)) scan.scope.conditional = true;
if (previous.words.length || previous.redirects.length) scan.commands.push(previous);
scan.current = newCommand(scan, pipe ? previous : null);
scan.pendingRedirect = null;
}
function consumeHeredocs(scan) {
for (const { redirect, owner } of scan.pendingHeredocs) {
const region = heredocBody(scan.input, scan.i, redirect, scan.budget);
redirect.body = region.body;
scan.i = region.end;
if (!redirect.word.quoted) {
const regions = scanExpansions(redirect.body, scan.budget);
for (const text of regions) { scan.budget.spend(); scan.nested.push(text); owner.nested.push(text); }
}
const first = previous.words[0];
if (first && !first.quoted && ['if', 'then', 'elif', 'else', 'while', 'until', 'do', 'case', 'for', 'select', 'function'].includes(first.value)) scope.conditional = true;
if (previous.words.length || previous.redirects.length) commands.push(previous);
current = command(pipe ? previous : null);
pendingRedirect = null;
}
function consumeHeredocs() {
for (const { redirect, owner } of pendingHeredocs) {
const region = heredocBody(input, i, redirect, budget);
redirect.body = region.body;
i = region.end;
if (!redirect.word.quoted) {
const regions = scanExpansions(redirect.body, budget);
for (const text of regions) { budget.spend(); nested.push(text); owner.nested.push(text); }
}
}
pendingHeredocs.length = 0;
scan.pendingHeredocs.length = 0;
}
// Inside '...' or $'...'.
function scanQuotedChar(scan) {
const c = scan.input[scan.i];
if (scan.quote === "$'" && c === '\\' && scan.i + 1 < scan.input.length) {
const next = scan.input[scan.i + 1];
// Preserve boundaries without claiming general ANSI-C escape expansion.
scan.word.value += next === "'" || next === '\\' ? next : c + next;
scan.i += 2;
return;
}
while (i < input.length) {
if (c === "'") scan.quote = null;
else scan.word.value += c;
scan.i++;
}
function scanDoubleQuotedChar(scan, c) {
if (c === '"') scan.quote = null;
else scan.word.value += c;
scan.i++;
}
function scanEscape(scan) {
beginWord(scan); scan.word.quoted = true;
const next = scan.input[scan.i + 1];
if (next === undefined) { scan.word.value += '\\'; scan.i++; return; }
if (scan.quote === '"' && !'"$`\\'.includes(next)) scan.word.value += '\\';
scan.word.value += next; scan.i += 2;
}
function scanSubstitution(scan) {
beginWord(scan); scan.word.literal = false; scan.word.dynamic = true;
const region = executionRegion(scan.input, scan.i, scan.budget);
scan.nested.push(region.text); scan.current.nested.push(region.text);
scan.word.value += '\u0000'; scan.i = region.end;
}
// Parameter expansions remain opaque values. Escaped/single/ANSI-C
// quoted dollars have already been consumed as data before this point.
function markParameterExpansion(scan, c) {
if (c === '$' && /[A-Za-z0-9_@*#?$!{-]/.test(scan.input[scan.i + 1] || '')) {
beginWord(scan); scan.word.dynamic = true;
}
}
function openQuote(scan, c) {
if (c === '$' && scan.input[scan.i + 1] === "'") {
// ANSI-C escaped quotes do not close the word; its contents never expand.
// Numeric/control escapes and generated names remain outside this grammar.
beginWord(scan); scan.word.quoted = true; scan.quote = "$'"; scan.i += 2;
return true;
}
if (c === '"' || c === "'") { beginWord(scan); scan.word.quoted = true; scan.quote = c; scan.i++; return true; }
return false;
}
function isBraceKeyword(scan, c) {
return (c === '{' || c === '}') && !scan.word && scan.current.words.length === 0 && /[\s;&|]/.test(scan.input[scan.i + 1] || ' ');
}
function markFunctionDefinition(scan) {
flushWord(scan);
// A function definition does not execute its body. Function grammar
// is unsupported: keep pre-definition alternatives instead of using
// flattened body mutations to certify a later command as safe.
const words = scan.current.words;
if (words.length === 1 && !words[0].quoted && /^[A-Za-z_][A-Za-z0-9_]*$/.test(words[0].value)) scan.scope.conditional = true;
}
function openScope(scan, c, incomingPipe) {
scan.scope = { parent: scan.scope, isolated: c === '(' || incomingPipe, conditional: false, pipelineLast: c === '{' && incomingPipe };
scan.current.scope = scan.scope;
}
function closeScope(scan) {
const closedScope = scan.scope;
scan.scope = closedScope.parent;
scan.current.scope = scan.scope;
scan.current.closedScope = closedScope;
// Ordered metadata only; there is no executable argv in this event.
scan.commands.push({ ...newCommand(scan, null), scopeExit: closedScope });
}
function scanOperator(scan, c, braceKeyword) {
const doubled = (c === '&' || c === '|') && scan.input[scan.i + 1] === c;
const pipe = c === '|' && !doubled;
const background = c === '&' && !doubled;
if (doubled) scan.scope.conditional = true;
const incomingPipe = Boolean(scan.current.pipeFrom);
if (c === '(') markFunctionDefinition(scan);
flushCommand(scan, pipe, background);
if (c === '(' || (braceKeyword && c === '{')) openScope(scan, c, incomingPipe);
else if ((c === ')' || (braceKeyword && c === '}')) && scan.scope.parent) closeScope(scan);
scan.i += doubled ? 2 : 1;
if (c === '\n') consumeHeredocs(scan);
}
// An immediately adjacent numeric word is a descriptor, not an argv word.
function dropDescriptorWord(scan) {
if (scan.word && /^\d+$/.test(scan.word.value)) scan.word = null;
}
function queueHeredoc(scan, delimiter) {
dropDescriptorWord(scan);
flushWord(scan);
const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' };
scan.current.redirects.push(redirect);
scan.pendingHeredocs.push({ redirect, owner: scan.current });
scan.i = delimiter.end;
scan.pendingRedirect = null;
}
function redirectOperator(input, i, c) {
let operator = c;
if (input[i + 1] === c) operator += c;
if (operator === '<<' && input[i + 2] === '<') operator = '<<<';
else if (operator === '<<' && input[i + 2] === '-') operator = '<<-';
else if (input[i + 1] === '&') operator += '&';
return operator;
}
function scanRedirect(scan, c) {
const { input } = scan;
if (c === '<' && input[scan.i + 1] === '<' && input[scan.i + 2] !== '<') {
const delimiter = heredocDelimiter(input, scan.i, scan.budget);
if (delimiter) { queueHeredoc(scan, delimiter); return; }
}
dropDescriptorWord(scan);
flushWord(scan);
scan.pendingRedirect = redirectOperator(input, scan.i, c);
scan.i += scan.pendingRedirect.length;
}
function scanShell(input, budget) {
const scan = createScan(input, budget);
while (scan.i < input.length) {
budget.spend();
const c = input[i];
if (quote === "'" || quote === "$'") {
if (quote === "$'" && c === '\\' && i + 1 < input.length) {
const next = input[i + 1];
// Preserve boundaries without claiming general ANSI-C escape expansion.
word.value += next === "'" || next === '\\' ? next : c + next;
i += 2; continue;
}
if (c === "'") quote = null;
else word.value += c;
i++; continue;
}
const continued = continuationEnd(input, i);
if (continued !== i) { i = continued; continue; }
if (c === '\\') {
begin(); word.quoted = true;
const next = input[i + 1];
if (next === undefined) { word.value += c; i++; continue; }
if (quote === '"' && !'"$`\\'.includes(next)) word.value += '\\';
word.value += next; i += 2; continue;
}
if (hasExpansion(input, i, quote === null)) {
begin(); word.literal = false; word.dynamic = true;
const region = executionRegion(input, i, budget);
nested.push(region.text); current.nested.push(region.text); word.value += '\u0000'; i = region.end; continue;
}
// Parameter expansions remain opaque values. Escaped/single/ANSI-C
// quoted dollars have already been consumed as data above.
if (c === '$' && /[A-Za-z0-9_@*#?$!{-]/.test(input[i + 1] || '')) {
begin(); word.dynamic = true;
}
if (quote === '"') {
if (c === '"') quote = null;
else word.value += c;
i++; continue;
}
if (c === '$' && input[i + 1] === "'") {
// ANSI-C escaped quotes do not close the word; its contents never expand.
// Numeric/control escapes and generated names remain outside this grammar.
begin(); word.quoted = true; quote = "$'"; i += 2; continue;
}
if (c === '"' || c === "'") { begin(); word.quoted = true; quote = c; i++; continue; }
if (c === '#' && !word) {
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
continue;
}
const braceKeyword = (c === '{' || c === '}') && !word && current.words.length === 0 && /[\s;&|]/.test(input[i + 1] || ' ');
if (c === '\n' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')' || braceKeyword) {
const pipe = c === '|' && input[i + 1] !== '|';
const background = c === '&' && input[i + 1] !== '&';
if ((c === '&' || c === '|') && input[i + 1] === c) scope.conditional = true;
const incomingPipe = Boolean(current.pipeFrom);
if (c === '(') {
flushWord();
// A function definition does not execute its body. Function grammar
// is unsupported: keep pre-definition alternatives instead of using
// flattened body mutations to certify a later command as safe.
if (current.words.length === 1 && !current.words[0].quoted &&
/^[A-Za-z_][A-Za-z0-9_]*$/.test(current.words[0].value)) scope.conditional = true;
}
flushCommand(pipe, background);
if (c === '(' || (braceKeyword && c === '{')) {
scope = { parent: scope, isolated: c === '(' || incomingPipe, conditional: false, pipelineLast: c === '{' && incomingPipe };
current.scope = scope;
} else if ((c === ')' || (braceKeyword && c === '}')) && scope.parent) {
const closedScope = scope;
scope = scope.parent;
current.scope = scope;
current.closedScope = closedScope;
// Ordered metadata only; there is no executable argv in this event.
commands.push({ ...command(null), scopeExit: closedScope });
}
i += (c === '&' || c === '|') && input[i + 1] === c ? 2 : 1;
if (c === '\n') consumeHeredocs();
continue;
}
if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') {
const delimiter = heredocDelimiter(input, i, budget);
if (delimiter) {
if (word && /^\d+$/.test(word.value)) word = null;
flushWord();
const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' };
current.redirects.push(redirect); pendingHeredocs.push({ redirect, owner: current });
i = delimiter.end; pendingRedirect = null; continue;
}
}
if (c === '<' || c === '>') {
// An immediately adjacent numeric word is a descriptor, not an argv word.
if (word && /^\d+$/.test(word.value)) word = null;
flushWord();
let operator = c;
if (input[i + 1] === c) operator += c;
if (operator === '<<' && input[i + 2] === '<') operator = '<<<';
else if (operator === '<<' && input[i + 2] === '-') operator = '<<-';
else if (input[i + 1] === '&') operator += '&';
pendingRedirect = operator; i += operator.length; continue;
}
if (/\s/.test(c)) { flushWord(); i++; continue; }
begin(); word.value += c; i++;
if (scan.quote === "'" || scan.quote === "$'") { scanQuotedChar(scan); continue; }
const continued = continuationEnd(input, scan.i);
if (continued !== scan.i) { scan.i = continued; continue; }
const c = input[scan.i];
if (c === '\\') { scanEscape(scan); continue; }
if (hasExpansion(input, scan.i, scan.quote === null)) { scanSubstitution(scan); continue; }
markParameterExpansion(scan, c);
if (scan.quote === '"') { scanDoubleQuotedChar(scan, c); continue; }
if (openQuote(scan, c)) continue;
if (c === '#' && !scan.word) { scan.i = skipComment(input, scan.i, budget); continue; }
const braceKeyword = isBraceKeyword(scan, c);
if (braceKeyword || '\n;&|()'.includes(c)) { scanOperator(scan, c, braceKeyword); continue; }
if (c === '<' || c === '>') { scanRedirect(scan, c); continue; }
if (/\s/.test(c)) { flushWord(scan); scan.i++; continue; }
beginWord(scan); scan.word.value += c; scan.i++;
}
flushCommand();
return { commands, nested, rootScope };
flushCommand(scan);
return { commands: scan.commands, nested: scan.nested, rootScope: scan.rootScope };
}
module.exports = { createBudget, scanShell, scanExpansions };