mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-20 15:01:46 +02:00
refactor(lib): share span readers in shell-substitution
extractSubshellGroups and extractBraceGroups each carried their own copies of the quote-aware skipping for $(...), backtick and (...) spans, which is where the complexity and nesting warnings came from. The skipping now lives in findParenSpanEnd, findBacktickEnd and skipOuterSpan, with readSubshellBody and readBraceBody doing the body collection. Behavior is unchanged: the same bodies come back in the same order, trailing backslashes and unterminated spans resolve the same way, and the three exports keep their signatures.
This commit is contained in:
committed by
Affaan Mustafa
parent
603e1dee1e
commit
cfcd81c076
+193
-318
@@ -94,6 +94,114 @@ function extractCommandSubstitutions(input, options = {}) {
|
||||
return [...iterateCommandSubstitutions(input, options)];
|
||||
}
|
||||
|
||||
// --- Shared span readers ------------------------------------------------------
|
||||
// The group extractors below all need the same quote-aware skipping of
|
||||
// `$(...)`, backtick and `(...)` spans. Each reader takes the index of the
|
||||
// first character inside the span and returns where scanning should resume.
|
||||
|
||||
function parenDelta(ch) {
|
||||
if (ch === '(') return 1;
|
||||
if (ch === ')') return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle quote state for a quote character at the current position.
|
||||
* Returns true when `ch` opened or closed a quote.
|
||||
*/
|
||||
function toggleQuote(ch, prev, quotes) {
|
||||
if (ch === "'" && !quotes.inDouble && prev !== '\\') {
|
||||
quotes.inSingle = !quotes.inSingle;
|
||||
return true;
|
||||
}
|
||||
if (ch === '"' && !quotes.inSingle && prev !== '\\') {
|
||||
quotes.inDouble = !quotes.inDouble;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index just past the `)` that closes a paren span whose first inner character
|
||||
* is at `start`, honoring quotes and escapes inside the span.
|
||||
*/
|
||||
function findParenSpanEnd(source, start) {
|
||||
const quotes = { inSingle: false, inDouble: false };
|
||||
let depth = 1;
|
||||
let i = start;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
if (inner === '\\' && !quotes.inSingle) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (!toggleQuote(inner, source[i - 1], quotes) && !quotes.inSingle && !quotes.inDouble) {
|
||||
depth += parenDelta(inner);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the backtick that closes a span whose first inner character is at
|
||||
* `start`, or `source.length` when the span is unterminated.
|
||||
*/
|
||||
function findBacktickEnd(source, start) {
|
||||
let i = start;
|
||||
while (i < source.length && source[i] !== '`') {
|
||||
if (source[i] === '\\' && i + 1 < source.length) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level scan step shared by the group extractors: escapes, quote state,
|
||||
* `$(...)` and backtick spans. Returns the last index consumed so a `for`
|
||||
* loop's `i++` resumes after it, or -1 when the caller must inspect `source[i]`.
|
||||
*/
|
||||
function skipOuterSpan(source, i, quotes) {
|
||||
const ch = source[i];
|
||||
if (ch === '\\' && !quotes.inSingle) return i + 1;
|
||||
if (toggleQuote(ch, source[i - 1], quotes)) return i;
|
||||
if (quotes.inSingle || quotes.inDouble) return i;
|
||||
if (ch === '$' && source[i + 1] === '(') return findParenSpanEnd(source, i + 2) - 1;
|
||||
if (ch === '`') return findBacktickEnd(source, i + 1);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a `(...)` subshell body starting just inside the opening paren. Returns
|
||||
* the body text and the index of the closing paren (or where input ran out).
|
||||
*/
|
||||
function readSubshellBody(source, start) {
|
||||
const quotes = { inSingle: false, inDouble: false };
|
||||
let depth = 1;
|
||||
let body = '';
|
||||
let i = start;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
if (inner === '\\' && !quotes.inSingle) {
|
||||
// Keep the escape and its operand together; a trailing backslash at the
|
||||
// end of an unterminated span is appended once and the scan stops.
|
||||
body += source.slice(i, i + 2);
|
||||
i = Math.min(i + 2, source.length);
|
||||
continue;
|
||||
}
|
||||
if (!toggleQuote(inner, source[i - 1], quotes) && !quotes.inSingle && !quotes.inDouble) {
|
||||
depth += parenDelta(inner);
|
||||
if (depth === 0) break;
|
||||
}
|
||||
body += inner;
|
||||
i += 1;
|
||||
}
|
||||
return { body, end: i };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bodies of plain `(...)` subshell groups.
|
||||
*
|
||||
@@ -114,118 +222,89 @@ function extractCommandSubstitutions(input, options = {}) {
|
||||
function extractSubshellGroups(input) {
|
||||
const source = String(input || '');
|
||||
const groups = [];
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
const quotes = { inSingle: false, inDouble: false };
|
||||
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
const prev = source[i - 1];
|
||||
|
||||
if (ch === '\\' && !inSingle) {
|
||||
i += 1;
|
||||
const skipped = skipOuterSpan(source, i, quotes);
|
||||
if (skipped !== -1) {
|
||||
i = skipped;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" && !inDouble && prev !== '\\') {
|
||||
inSingle = !inSingle;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' && !inSingle && prev !== '\\') {
|
||||
inDouble = !inDouble;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSingle || inDouble) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '$' && source[i + 1] === '(') {
|
||||
let depth = 1;
|
||||
let skipInSingle = false;
|
||||
let skipInDouble = false;
|
||||
i += 2;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
const innerPrev = source[i - 1];
|
||||
if (inner === '\\' && !skipInSingle) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (inner === "'" && !skipInDouble && innerPrev !== '\\') {
|
||||
skipInSingle = !skipInSingle;
|
||||
} else if (inner === '"' && !skipInSingle && innerPrev !== '\\') {
|
||||
skipInDouble = !skipInDouble;
|
||||
} else if (!skipInSingle && !skipInDouble) {
|
||||
if (inner === '(') depth += 1;
|
||||
else if (inner === ')') depth -= 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '`') {
|
||||
i += 1;
|
||||
while (i < source.length && source[i] !== '`') {
|
||||
if (source[i] === '\\' && i + 1 < source.length) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '(') {
|
||||
let depth = 1;
|
||||
let body = '';
|
||||
let bodyInSingle = false;
|
||||
let bodyInDouble = false;
|
||||
i += 1;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
const innerPrev = source[i - 1];
|
||||
if (inner === '\\' && !bodyInSingle) {
|
||||
body += inner;
|
||||
if (i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
// Trailing backslash at end of an unterminated span: advance past
|
||||
// it so it is not appended a second time by the fallthrough below.
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
|
||||
bodyInSingle = !bodyInSingle;
|
||||
} else if (inner === '"' && !bodyInSingle && innerPrev !== '\\') {
|
||||
bodyInDouble = !bodyInDouble;
|
||||
} else if (!bodyInSingle && !bodyInDouble) {
|
||||
if (inner === '(') {
|
||||
depth += 1;
|
||||
} else if (inner === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
body += inner;
|
||||
i += 1;
|
||||
}
|
||||
if (body.trim()) {
|
||||
groups.push(body);
|
||||
groups.push(...extractSubshellGroups(body));
|
||||
}
|
||||
if (source[i] !== '(') continue;
|
||||
const { body, end } = readSubshellBody(source, i + 1);
|
||||
i = end;
|
||||
if (body.trim()) {
|
||||
groups.push(body);
|
||||
groups.push(...extractSubshellGroups(body));
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inside a brace body, `$(...)`, backtick and `(...)` spans are copied through
|
||||
* verbatim so a `}` inside them cannot close the group. Returns the index just
|
||||
* past the span, or -1 when `source[i]` does not open one.
|
||||
*/
|
||||
function findNestedSpanEnd(source, i) {
|
||||
const ch = source[i];
|
||||
if (ch === '$' && source[i + 1] === '(') return findParenSpanEnd(source, i + 2);
|
||||
if (ch === '`') return Math.min(findBacktickEnd(source, i + 1) + 1, source.length);
|
||||
if (ch === '(') return findParenSpanEnd(source, i + 1);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// `{` is a reserved word only when followed by whitespace and preceded by the
|
||||
// line start, whitespace, or a shell operator (`;`, `|`, `&`, `(`).
|
||||
function opensBraceGroup(source, i) {
|
||||
if (source[i] !== '{' || !/\s/.test(source[i + 1] || '')) return false;
|
||||
return i === 0 || /[\s;|&(]/.test(source[i - 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a `{ ...; }` brace body starting just after the opening brace. Returns
|
||||
* the body text and the index of the closing brace (or where input ran out).
|
||||
*/
|
||||
function readBraceBody(source, start) {
|
||||
const quotes = { inSingle: false, inDouble: false };
|
||||
let depth = 1;
|
||||
let body = '';
|
||||
let i = start;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
const prev = source[i - 1];
|
||||
if (inner === '\\' && !quotes.inSingle) {
|
||||
body += source.slice(i, i + 2);
|
||||
i = Math.min(i + 2, source.length);
|
||||
continue;
|
||||
}
|
||||
if (toggleQuote(inner, prev, quotes) || quotes.inSingle || quotes.inDouble) {
|
||||
body += inner;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const spanEnd = findNestedSpanEnd(source, i);
|
||||
if (spanEnd !== -1) {
|
||||
body += source.slice(i, spanEnd);
|
||||
i = spanEnd;
|
||||
continue;
|
||||
}
|
||||
if (inner === '{' && /\s/.test(source[i + 1] || '')) {
|
||||
// Match the outer-scan boundary rule for nested `{` so tokens like
|
||||
// `foo{` (no boundary, but followed by space via `foo{ bar`) cannot
|
||||
// bump nested depth.
|
||||
if (/[\s;|&(]/.test(prev)) depth += 1;
|
||||
} else if (inner === '}' && (prev === ';' || /\s/.test(prev))) {
|
||||
depth -= 1;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
body += inner;
|
||||
i += 1;
|
||||
}
|
||||
return { body, end: i };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bodies of `{ ...; }` brace groups.
|
||||
*
|
||||
@@ -250,228 +329,24 @@ function extractSubshellGroups(input) {
|
||||
function extractBraceGroups(input) {
|
||||
const source = String(input || '');
|
||||
const groups = [];
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
const quotes = { inSingle: false, inDouble: false };
|
||||
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
const prev = source[i - 1];
|
||||
|
||||
if (ch === '\\' && !inSingle) {
|
||||
i += 1;
|
||||
const skipped = skipOuterSpan(source, i, quotes);
|
||||
if (skipped !== -1) {
|
||||
i = skipped;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" && !inDouble && prev !== '\\') {
|
||||
inSingle = !inSingle;
|
||||
if (source[i] === '(') {
|
||||
i = findParenSpanEnd(source, i + 1) - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' && !inSingle && prev !== '\\') {
|
||||
inDouble = !inDouble;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSingle || inDouble) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '$' && source[i + 1] === '(') {
|
||||
let depth = 1;
|
||||
let skipInSingle = false;
|
||||
let skipInDouble = false;
|
||||
i += 2;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
const innerPrev = source[i - 1];
|
||||
if (inner === '\\' && !skipInSingle) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (inner === "'" && !skipInDouble && innerPrev !== '\\') {
|
||||
skipInSingle = !skipInSingle;
|
||||
} else if (inner === '"' && !skipInSingle && innerPrev !== '\\') {
|
||||
skipInDouble = !skipInDouble;
|
||||
} else if (!skipInSingle && !skipInDouble) {
|
||||
if (inner === '(') depth += 1;
|
||||
else if (inner === ')') depth -= 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '`') {
|
||||
i += 1;
|
||||
while (i < source.length && source[i] !== '`') {
|
||||
if (source[i] === '\\' && i + 1 < source.length) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '(') {
|
||||
let depth = 1;
|
||||
let skipInSingle = false;
|
||||
let skipInDouble = false;
|
||||
i += 1;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
const innerPrev = source[i - 1];
|
||||
if (inner === '\\' && !skipInSingle) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (inner === "'" && !skipInDouble && innerPrev !== '\\') {
|
||||
skipInSingle = !skipInSingle;
|
||||
} else if (inner === '"' && !skipInSingle && innerPrev !== '\\') {
|
||||
skipInDouble = !skipInDouble;
|
||||
} else if (!skipInSingle && !skipInDouble) {
|
||||
if (inner === '(') depth += 1;
|
||||
else if (inner === ')') depth -= 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '{' && /\s/.test(source[i + 1] || '')) {
|
||||
const prevIsBoundary = i === 0 || /[\s;|&(]/.test(prev);
|
||||
if (!prevIsBoundary) continue;
|
||||
|
||||
let depth = 1;
|
||||
let body = '';
|
||||
let bodyInSingle = false;
|
||||
let bodyInDouble = false;
|
||||
i += 1;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
const innerPrev = source[i - 1];
|
||||
if (inner === '\\' && !bodyInSingle) {
|
||||
body += inner;
|
||||
if (i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
// Trailing backslash at end of an unterminated span: advance past
|
||||
// it so it is not appended a second time by the fallthrough below.
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
|
||||
bodyInSingle = !bodyInSingle;
|
||||
body += inner;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (inner === '"' && !bodyInSingle && innerPrev !== '\\') {
|
||||
bodyInDouble = !bodyInDouble;
|
||||
body += inner;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (bodyInSingle || bodyInDouble) {
|
||||
body += inner;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Skip $(...) spans — a quoted `}` or `}`-as-text inside a
|
||||
// substitution body must not close the enclosing brace group.
|
||||
if (inner === '$' && source[i + 1] === '(') {
|
||||
body += inner + source[i + 1];
|
||||
let subDepth = 1;
|
||||
let subInSingle = false;
|
||||
let subInDouble = false;
|
||||
i += 2;
|
||||
while (i < source.length && subDepth > 0) {
|
||||
const c = source[i];
|
||||
const p = source[i - 1];
|
||||
body += c;
|
||||
if (c === '\\' && !subInSingle && i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" && !subInDouble && p !== '\\') subInSingle = !subInSingle;
|
||||
else if (c === '"' && !subInSingle && p !== '\\') subInDouble = !subInDouble;
|
||||
else if (!subInSingle && !subInDouble) {
|
||||
if (c === '(') subDepth += 1;
|
||||
else if (c === ')') subDepth -= 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Skip backtick spans for the same reason.
|
||||
if (inner === '`') {
|
||||
body += inner;
|
||||
i += 1;
|
||||
while (i < source.length && source[i] !== '`') {
|
||||
if (source[i] === '\\' && i + 1 < source.length) {
|
||||
body += source[i] + source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
body += source[i];
|
||||
i += 1;
|
||||
}
|
||||
if (i < source.length) {
|
||||
body += source[i];
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Skip plain (...) subshell spans for the same reason.
|
||||
if (inner === '(') {
|
||||
body += inner;
|
||||
let subDepth = 1;
|
||||
let subInSingle = false;
|
||||
let subInDouble = false;
|
||||
i += 1;
|
||||
while (i < source.length && subDepth > 0) {
|
||||
const c = source[i];
|
||||
const p = source[i - 1];
|
||||
body += c;
|
||||
if (c === '\\' && !subInSingle && i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" && !subInDouble && p !== '\\') subInSingle = !subInSingle;
|
||||
else if (c === '"' && !subInSingle && p !== '\\') subInDouble = !subInDouble;
|
||||
else if (!subInSingle && !subInDouble) {
|
||||
if (c === '(') subDepth += 1;
|
||||
else if (c === ')') subDepth -= 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inner === '{' && /\s/.test(source[i + 1] || '')) {
|
||||
// Match the outer-scan boundary rule for nested `{` so
|
||||
// tokens like `foo{` (no boundary, but followed by space
|
||||
// via `foo{ bar`) cannot bump nested depth.
|
||||
const nestedPrevIsBoundary = /[\s;|&(]/.test(innerPrev);
|
||||
if (nestedPrevIsBoundary) depth += 1;
|
||||
} else if (inner === '}' && (innerPrev === ';' || /\s/.test(innerPrev))) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
body += inner;
|
||||
i += 1;
|
||||
}
|
||||
if (body.trim()) {
|
||||
groups.push(body);
|
||||
groups.push(...extractBraceGroups(body));
|
||||
}
|
||||
if (!opensBraceGroup(source, i)) continue;
|
||||
const { body, end } = readBraceBody(source, i + 1);
|
||||
i = end;
|
||||
if (body.trim()) {
|
||||
groups.push(body);
|
||||
groups.push(...extractBraceGroups(body));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user