refactor: keep heredoc scanning linear

This commit is contained in:
dajiaohuang
2026-08-28 09:05:41 +08:00
committed by haelyra
parent 9a3ee6864a
commit 9768c075c3
3 changed files with 143 additions and 179 deletions

View File

@@ -124,30 +124,6 @@ function findHeredocs(line) {
return heredocs.includes(null) ? null : heredocs;
}
/**
* Iterate over executable substitutions in an unquoted heredoc.
*
* @param {string} text
* @returns {Generator<string>}
*/
function* iterateHeredocCommandSubstitutions(text) {
let escaped = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === '`' || (ch === '$' && text[i + 1] === '(')) {
yield* extractCommandSubstitutions(text.slice(i));
}
}
}
/**
* Extract executable substitutions from an unquoted heredoc. Quote characters
* in its payload are literal and do not suppress expansion.
@@ -157,7 +133,58 @@ function* iterateHeredocCommandSubstitutions(text) {
*/
function extractHeredocCommandSubstitutions(body) {
const text = body.join('\n');
return [...new Set(iterateHeredocCommandSubstitutions(text))];
return [...new Set(extractCommandSubstitutions(text, { literalOuterQuotes: true }))];
}
/**
* Consume one heredoc body and return its immutable parser result.
*
* @param {string[]} lines
* @param {number} startIndex
* @param {{ delimiter: string, quoted: boolean, stripTabs: boolean }} heredoc
* @returns {{ nextIndex: number, substitutions: string[] } | null}
*/
function consumeHeredocBody(lines, startIndex, heredoc) {
for (let lineIndex = startIndex; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex];
if (!heredoc.quoted && /\\$/.test(line)) return null;
const delimiterLine = heredoc.stripTabs ? line.replace(/^\t+/, '') : line;
if (delimiterLine !== heredoc.delimiter) continue;
const body = lines.slice(startIndex, lineIndex);
const substitutions = heredoc.quoted ? [] : extractHeredocCommandSubstitutions(body);
return { nextIndex: lineIndex + 1, substitutions };
}
return null;
}
/**
* @param {string[]} lines
* @param {number} startIndex
* @param {{ delimiter: string, quoted: boolean, stripTabs: boolean }[]} heredocs
* @returns {{ nextIndex: number, chunks: object | null } | null}
*/
function consumeHeredocBodies(lines, startIndex, heredocs) {
let state = { nextIndex: startIndex, chunks: null };
for (const heredoc of heredocs) {
const consumed = consumeHeredocBody(lines, state.nextIndex, heredoc);
if (!consumed) return null;
state = {
nextIndex: consumed.nextIndex,
chunks: consumed.substitutions.length === 0 ? state.chunks : { substitutions: consumed.substitutions, previous: state.chunks }
};
}
return state;
}
/** @returns {Generator<string>} */
function* iterateSubstitutionChunks(chunks) {
let ordered = null;
for (let chunk = chunks; chunk; chunk = chunk.previous) {
ordered = { substitutions: chunk.substitutions, next: ordered };
}
for (let chunk = ordered; chunk; chunk = chunk.next) {
yield* chunk.substitutions;
}
}
/**
@@ -174,62 +201,26 @@ function extractHeredocCommandSubstitutions(body) {
function stripHeredocBodies(input) {
const raw = String(input || '');
const lines = raw.split(/\r?\n/);
let pending = [];
let pendingIndex = 0;
let bodyStartIndex = -1;
let headerIndex = -1;
let trailingStartIndex = lines.length;
let substitutionText = '';
let substitutionCount = 0;
let completedHeredoc = false;
let pending = [];
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex];
if (pendingIndex < pending.length) {
const current = pending[pendingIndex];
if (!current.quoted && /\\$/.test(line)) return raw;
const delimiterLine = current.stripTabs ? line.replace(/^\t+/, '') : line;
if (delimiterLine === current.delimiter) {
if (!current.quoted) {
for (const substitution of extractHeredocCommandSubstitutions(lines.slice(bodyStartIndex, lineIndex))) {
substitutionText = substitutionCount === 0 ? substitution : `${substitutionText}\n${substitution}`;
substitutionCount += 1;
}
}
pendingIndex += 1;
bodyStartIndex = lineIndex + 1;
if (pendingIndex === pending.length) {
completedHeredoc = true;
trailingStartIndex = lineIndex + 1;
}
}
continue;
}
if (completedHeredoc && line.trim()) return raw;
if (completedHeredoc) continue;
const heredocs = findHeredocs(line);
if (heredocs === null) return raw;
if (heredocs.length > 0 && !isProvenPassiveHeredocLine(line)) return raw;
if (heredocs.length > 0) {
pending = heredocs;
pendingIndex = 0;
bodyStartIndex = lineIndex + 1;
headerIndex = lineIndex;
break;
}
}
if (pendingIndex < pending.length) return raw;
if (headerIndex < 0) return lines.join('\n');
const prefix = lines.slice(0, headerIndex + 1).join('\n');
const trailingCount = lines.length - trailingStartIndex;
const trailing = lines.slice(trailingStartIndex).join('\n');
let result = prefix;
let resultCount = headerIndex + 1;
if (substitutionCount > 0) {
result = resultCount === 0 ? substitutionText : `${result}\n${substitutionText}`;
resultCount += substitutionCount;
}
if (trailingCount > 0) result = resultCount === 0 ? trailing : `${result}\n${trailing}`;
return result;
const consumed = consumeHeredocBodies(lines, headerIndex + 1, pending);
if (!consumed) return raw;
const trailing = lines.slice(consumed.nextIndex);
if (trailing.some(line => line.trim())) return raw;
const substitutions = iterateSubstitutionChunks(consumed.chunks);
return [...lines.slice(0, headerIndex + 1), ...substitutions, ...trailing].join('\n');
}
module.exports = { stripHeredocBodies };

View File

@@ -1,126 +1,97 @@
'use strict';
/**
* Extract executable command-substitution bodies from a shell line.
*
* Single quotes are literal, so substitutions inside them are ignored;
* double quotes still permit substitutions, so those bodies are scanned
* before quoted text is stripped. Returns each substitution body plus
* any nested substitutions discovered recursively.
*
* Originally introduced in scripts/hooks/gateguard-fact-force.js
* (PR #1853 round 2). Extracted to a shared lib so other PreToolUse
* hooks that need the same "scan inside `$(...)` and backticks"
* behavior can reuse it without duplicating the parser.
*
* @param {string} input
* @returns {string[]}
*/
function extractCommandSubstitutions(input) {
const source = String(input || '');
const substitutions = [];
/** @returns {{ body: string, endIndex: number }} */
function readBacktickSubstitution(source, startIndex) {
let body = '';
let endIndex = startIndex + 1;
while (endIndex < source.length) {
const inner = source[endIndex];
if (inner === '\\') {
const escaped = source[endIndex + 1];
body = escaped === undefined ? `${body}\\` : `${body}\\${escaped}`;
endIndex += escaped === undefined ? 1 : 2;
continue;
}
if (inner === '`') break;
body = `${body}${inner}`;
endIndex += 1;
}
return { body, endIndex };
}
/** @returns {{ body: string, endIndex: number }} */
function readDollarSubstitution(source, startIndex) {
let body = '';
let depth = 1;
let inSingle = false;
let inDouble = false;
let endIndex = startIndex + 2;
while (endIndex < source.length && depth > 0) {
const inner = source[endIndex];
if (inner === '\\' && !inSingle) {
const escaped = source[endIndex + 1];
body = escaped === undefined ? `${body}\\` : `${body}\\${escaped}`;
endIndex += escaped === undefined ? 1 : 2;
continue;
}
if (inner === "'" && !inDouble) inSingle = !inSingle;
else if (inner === '"' && !inSingle) inDouble = !inDouble;
else if (!inSingle && !inDouble && inner === '(') depth += 1;
else if (!inSingle && !inDouble && inner === ')') depth -= 1;
if (depth > 0) body = `${body}${inner}`;
endIndex += depth > 0 ? 1 : 0;
}
return { body, endIndex };
}
for (let i = 0; i < source.length; i++) {
/**
* Iterate over command-substitution bodies, followed by nested bodies.
* Quote characters in an unquoted heredoc are literal only at the outer level;
* substitutions still use normal shell quote semantics internally.
*
* @param {string} input
* @param {{ literalOuterQuotes?: boolean }} [options]
* @returns {Generator<string>}
*/
function* iterateCommandSubstitutions(input, options = {}) {
const source = String(input || '');
const literalOuterQuotes = options.literalOuterQuotes === true;
let inSingle = false;
let inDouble = false;
for (let i = 0; i < source.length; i += 1) {
const ch = source[i];
const prev = source[i - 1];
if (ch === '\\' && !inSingle) {
i += 1;
continue;
}
if (ch === "'" && !inDouble && prev !== '\\') {
if (!literalOuterQuotes && ch === "'" && !inDouble) {
inSingle = !inSingle;
continue;
}
if (ch === '"' && !inSingle && prev !== '\\') {
if (!literalOuterQuotes && ch === '"' && !inSingle) {
inDouble = !inDouble;
continue;
}
if (inSingle) {
continue;
}
if (ch === '`') {
let body = '';
i += 1;
while (i < source.length) {
const inner = source[i];
if (inner === '\\') {
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 === '`') {
break;
}
body += inner;
i += 1;
}
if (body.trim()) {
substitutions.push(body);
substitutions.push(...extractCommandSubstitutions(body));
}
continue;
}
if (ch === '$' && source[i + 1] === '(') {
let depth = 1;
let body = '';
let bodyInSingle = false;
let bodyInDouble = false;
i += 2;
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()) {
substitutions.push(body);
substitutions.push(...extractCommandSubstitutions(body));
}
}
if (inSingle) continue;
const span = ch === '`' ? readBacktickSubstitution(source, i) : null;
const substitution = ch === '$' && source[i + 1] === '(' ? readDollarSubstitution(source, i) : span;
if (!substitution) continue;
i = substitution.endIndex;
if (!substitution.body.trim()) continue;
yield substitution.body;
yield* iterateCommandSubstitutions(substitution.body);
}
}
return substitutions;
/**
* Extract executable command-substitution bodies from a shell line.
*
* @param {string} input
* @param {{ literalOuterQuotes?: boolean }} [options]
* @returns {string[]}
*/
function extractCommandSubstitutions(input, options = {}) {
return [...iterateCommandSubstitutions(input, options)];
}
/**