diff --git a/scripts/lib/shell-substitution.js b/scripts/lib/shell-substitution.js index a2241770d..a5dec6fbb 100644 --- a/scripts/lib/shell-substitution.js +++ b/scripts/lib/shell-substitution.js @@ -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)); } }