* Single-pass collection of all quote-related spans. * Previously this was 5 separate tree walks (one per type-set plus * allQuoteTypes plus heredoc); fusing cuts tree-traversal ~5x. * * Replicates the per-type walk semantics: each original walk stopped at * its own type. So the raw_string walk
( node: TreeSitterNode, out: QuoteSpans, inDouble: boolean, )
| 86 | * in bash (no expansion), so no nested quote nodes exist — return early. |
| 87 | */ |
| 88 | function collectQuoteSpans( |
| 89 | node: TreeSitterNode, |
| 90 | out: QuoteSpans, |
| 91 | inDouble: boolean, |
| 92 | ): void { |
| 93 | switch (node.type) { |
| 94 | case 'raw_string': |
| 95 | out.raw.push([node.startIndex, node.endIndex]) |
| 96 | return // literal body, no nested quotes possible |
| 97 | case 'ansi_c_string': |
| 98 | out.ansiC.push([node.startIndex, node.endIndex]) |
| 99 | return // literal body |
| 100 | case 'string': |
| 101 | // Only collect the outermost string (matches old per-type walk |
| 102 | // which stops at first match). Recurse regardless — a nested |
| 103 | // $(cmd 'x') inside "..." has a real inner raw_string. |
| 104 | if (!inDouble) out.double.push([node.startIndex, node.endIndex]) |
| 105 | for (const child of node.children) { |
| 106 | if (child) collectQuoteSpans(child, out, true) |
| 107 | } |
| 108 | return |
| 109 | case 'heredoc_redirect': { |
| 110 | // Quoted heredocs (<<'EOF', <<"EOF", <<\EOF): literal body. |
| 111 | // Unquoted (<<EOF) expands $()/${} — the body can contain |
| 112 | // $(cmd 'x') whose inner '...' IS a real raw_string node. |
| 113 | // Detection: heredoc_start text starts with '/"/\\ |
| 114 | // Matches sync path's extractHeredocs({ quotedOnly: true }). |
| 115 | let isQuoted = false |
| 116 | for (const child of node.children) { |
| 117 | if (child && child.type === 'heredoc_start') { |
| 118 | const first = child.text[0] |
| 119 | isQuoted = first === "'" || first === '"' || first === '\\' |
| 120 | break |
| 121 | } |
| 122 | } |
| 123 | if (isQuoted) { |
| 124 | out.heredoc.push([node.startIndex, node.endIndex]) |
| 125 | return // literal body, no nested quote nodes |
| 126 | } |
| 127 | // Unquoted: recurse into heredoc_body → command_substitution → |
| 128 | // inner quote nodes. The original per-type walks did NOT stop at |
| 129 | // heredoc_redirect (not in their type sets), so they recursed here. |
| 130 | break |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | for (const child of node.children) { |
| 135 | if (child) collectQuoteSpans(child, out, inDouble) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * Builds a Set of all character positions covered by the given spans. |
no test coverage detected