fix(hooks): return new quote state from toggleQuote instead of mutating it

AGENTS.md asks for new objects rather than mutation. toggleQuote now returns the updated quote state (or null when the character is not a quote it toggles), skipOuterSpan returns it alongside the skipped index, and each scanner reassigns its own local state.
This commit is contained in:
leoca committed 2026-10-29 18:58:18 +00:00
1 parent eb9a5ca0c6
commit b0070af9d1
1 file changed
+40 -30
+40 -30
View File
@@ -120,23 +120,21 @@ function escapesActive(quotes) {
}
/**
* Toggle quote state for the quote character at `source[i]`.
* Returns true when it opened or closed a quote. Callers consume escapes
* Quote state after the quote character at `source[i]`, as a new object, or
* null when it neither opens nor closes a quote. Callers consume escapes
* first, so a quote after a backslash here still closes (`'C:\'`, `"C:\\"`),
* while `\'` inside `$'...'` is consumed before it can close the string.
*/
function toggleQuote(source, i, quotes) {
const ch = source[i];
if (ch === "'" && !quotes.inDouble) {
quotes.inSingle = !quotes.inSingle;
quotes.ansiC = quotes.inSingle && opensAnsiC(source, i);
return true;
const inSingle = !quotes.inSingle;
return { ...quotes, inSingle, ansiC: inSingle && opensAnsiC(source, i) };
}
if (ch === '"' && !quotes.inSingle) {
quotes.inDouble = !quotes.inDouble;
return true;
return { ...quotes, inDouble: !quotes.inDouble };
}
return false;
return null;
}
/**
@@ -144,7 +142,7 @@ function toggleQuote(source, i, quotes) {
* is at `start`, honoring quotes and escapes inside the span.
*/
function findParenSpanEnd(source, start) {
const quotes = { inSingle: false, inDouble: false, ansiC: false };
let quotes = { inSingle: false, inDouble: false, ansiC: false };
let depth = 1;
let i = start;
while (i < source.length && depth > 0) {
@@ -153,7 +151,10 @@ function findParenSpanEnd(source, start) {
i += 2;
continue;
}
if (!toggleQuote(source, i, quotes) && !quotes.inSingle && !quotes.inDouble) {
const toggled = toggleQuote(source, i, quotes);
if (toggled) {
quotes = toggled;
} else if (!quotes.inSingle && !quotes.inDouble) {
depth += parenDelta(inner);
}
i += 1;
@@ -179,17 +180,19 @@ function findBacktickEnd(source, start) {
/**
* 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]`.
* `$(...)` and backtick spans. Returns the quote state after `source[i]` and
* `skipped`, 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 === '\\' && escapesActive(quotes)) return i + 1;
if (toggleQuote(source, i, 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;
if (ch === '\\' && escapesActive(quotes)) return { skipped: i + 1, quotes };
const toggled = toggleQuote(source, i, quotes);
if (toggled) return { skipped: i, quotes: toggled };
if (quotes.inSingle || quotes.inDouble) return { skipped: i, quotes };
if (ch === '$' && source[i + 1] === '(') return { skipped: findParenSpanEnd(source, i + 2) - 1, quotes };
if (ch === '`') return { skipped: findBacktickEnd(source, i + 1), quotes };
return { skipped: -1, quotes };
}
/**
@@ -197,7 +200,7 @@ function skipOuterSpan(source, i, quotes) {
* 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, ansiC: false };
let quotes = { inSingle: false, inDouble: false, ansiC: false };
let depth = 1;
let body = '';
let i = start;
@@ -210,7 +213,10 @@ function readSubshellBody(source, start) {
i = Math.min(i + 2, source.length);
continue;
}
if (!toggleQuote(source, i, quotes) && !quotes.inSingle && !quotes.inDouble) {
const toggled = toggleQuote(source, i, quotes);
if (toggled) {
quotes = toggled;
} else if (!quotes.inSingle && !quotes.inDouble) {
depth += parenDelta(inner);
if (depth === 0) break;
}
@@ -240,12 +246,13 @@ function readSubshellBody(source, start) {
function extractSubshellGroups(input) {
const source = String(input || '');
const groups = [];
const quotes = { inSingle: false, inDouble: false, ansiC: false };
let quotes = { inSingle: false, inDouble: false, ansiC: false };
for (let i = 0; i < source.length; i++) {
const skipped = skipOuterSpan(source, i, quotes);
if (skipped !== -1) {
i = skipped;
const step = skipOuterSpan(source, i, quotes);
quotes = step.quotes;
if (step.skipped !== -1) {
i = step.skipped;
continue;
}
if (source[i] !== '(') continue;
@@ -285,7 +292,7 @@ function opensBraceGroup(source, i) {
* 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, ansiC: false };
let quotes = { inSingle: false, inDouble: false, ansiC: false };
let depth = 1;
let body = '';
let i = start;
@@ -297,7 +304,9 @@ function readBraceBody(source, start) {
i = Math.min(i + 2, source.length);
continue;
}
if (toggleQuote(source, i, quotes) || quotes.inSingle || quotes.inDouble) {
const toggled = toggleQuote(source, i, quotes);
if (toggled) quotes = toggled;
if (toggled || quotes.inSingle || quotes.inDouble) {
body += inner;
i += 1;
continue;
@@ -347,12 +356,13 @@ function readBraceBody(source, start) {
function extractBraceGroups(input) {
const source = String(input || '');
const groups = [];
const quotes = { inSingle: false, inDouble: false, ansiC: false };
let quotes = { inSingle: false, inDouble: false, ansiC: false };
for (let i = 0; i < source.length; i++) {
const skipped = skipOuterSpan(source, i, quotes);
if (skipped !== -1) {
i = skipped;
const step = skipOuterSpan(source, i, quotes);
quotes = step.quotes;
if (step.skipped !== -1) {
i = step.skipped;
continue;
}
if (source[i] === '(') {