* Single shared state-machine walk used by both `findUnterminatedQuote` and * `maskTopLevelQuotes`. Walks `command` left-to-right, identifies every * top-level quoted region (outside any other quote and outside `#` comments), * and returns the list of spans together with an unterminated-quote des
(command: string)
| 199 | * - `heredoc` <<WORD...WORD |
| 200 | */ |
| 201 | function scanTopLevelQuotes(command: string): ScanResult { |
| 202 | const spans: QuoteSpan[] = [] |
| 203 | let i = 0 |
| 204 | |
| 205 | while (i < command.length) { |
| 206 | const char = command[i] |
| 207 | |
| 208 | // Outside any quoted region: handle backslash, comments, and quote openers. |
| 209 | if (char === "\\") { |
| 210 | // Backslash escapes the next character; a quote after it is literal. |
| 211 | i += 2 |
| 212 | continue |
| 213 | } |
| 214 | |
| 215 | if (char === "#" && (i === 0 || /\s/.test(command[i - 1]))) { |
| 216 | // Comment: skip to end of line. Quotes inside are not shell quoting. |
| 217 | while (i < command.length && command[i] !== "\n" && command[i] !== "\r") { |
| 218 | i++ |
| 219 | } |
| 220 | continue |
| 221 | } |
| 222 | |
| 223 | // Herestring (<<<): single-line stdin redirect -- no body or terminator. |
| 224 | if (char === "<" && command[i + 1] === "<" && command[i + 2] === "<") { |
| 225 | i += 3 |
| 226 | continue |
| 227 | } |
| 228 | |
| 229 | // Heredoc opener: <<[-]? followed by an optional-quoted delimiter word. |
| 230 | if (char === "<" && command[i + 1] === "<") { |
| 231 | const start = i |
| 232 | i += 2 // skip << |
| 233 | const stripTabs = command[i] === "-" |
| 234 | if (stripTabs) i++ |
| 235 | // Skip horizontal whitespace between << and the delimiter word. |
| 236 | while (i < command.length && (command[i] === " " || command[i] === "\t")) { |
| 237 | i++ |
| 238 | } |
| 239 | const { delimiter, endIndex } = parseHeredocDelimiter(command, i) |
| 240 | i = endIndex |
| 241 | // Advance past the remainder of the opener line. |
| 242 | while (i < command.length && command[i] !== "\n") i++ |
| 243 | if (i < command.length) i++ // consume newline |
| 244 | if (delimiter.length > 0) { |
| 245 | let found = false |
| 246 | while (i < command.length) { |
| 247 | const lineStart = i |
| 248 | while (i < command.length && command[i] !== "\n" && command[i] !== "\r") { |
| 249 | i++ |
| 250 | } |
| 251 | // Strip leading tabs only for <<- heredocs. |
| 252 | const rawLine = command.slice(lineStart, i) |
| 253 | const line = stripTabs ? rawLine.replace(/^\t*/, "") : rawLine |
| 254 | // Do NOT advance past the terminator's newline -- leave it as a |
| 255 | // separator for any command that follows the heredoc. |
| 256 | if (line === delimiter) { |
| 257 | found = true |
| 258 | break |
no test coverage detected