(node: TreeSitterNode)
| 306 | |
| 307 | // Walk top-level children of the program node |
| 308 | function walkTopLevel(node: TreeSitterNode): void { |
| 309 | for (const child of node.children) { |
| 310 | if (!child) continue |
| 311 | |
| 312 | if (child.type === 'list') { |
| 313 | // list nodes contain && and || operators |
| 314 | for (const listChild of child.children) { |
| 315 | if (!listChild) continue |
| 316 | if (listChild.type === '&&' || listChild.type === '||') { |
| 317 | operators.push(listChild.type) |
| 318 | } else if ( |
| 319 | listChild.type === 'list' || |
| 320 | listChild.type === 'redirected_statement' |
| 321 | ) { |
| 322 | // Nested list, or redirected_statement wrapping a list/pipeline — |
| 323 | // recurse so inner operators/pipelines are detected. For |
| 324 | // `cmd1 && cmd2 2>/dev/null && cmd3`, the redirected_statement |
| 325 | // wraps `list(cmd1 && cmd2)` — the inner `&&` would be missed |
| 326 | // without recursion. |
| 327 | walkTopLevel({ ...node, children: [listChild] } as TreeSitterNode) |
| 328 | } else if (listChild.type === 'pipeline') { |
| 329 | hasPipeline = true |
| 330 | segments.push(listChild.text) |
| 331 | } else if (listChild.type === 'subshell') { |
| 332 | hasSubshell = true |
| 333 | segments.push(listChild.text) |
| 334 | } else if (listChild.type === 'compound_statement') { |
| 335 | hasCommandGroup = true |
| 336 | segments.push(listChild.text) |
| 337 | } else { |
| 338 | segments.push(listChild.text) |
| 339 | } |
| 340 | } |
| 341 | } else if (child.type === ';') { |
| 342 | operators.push(';') |
| 343 | } else if (child.type === 'pipeline') { |
| 344 | hasPipeline = true |
| 345 | segments.push(child.text) |
| 346 | } else if (child.type === 'subshell') { |
| 347 | hasSubshell = true |
| 348 | segments.push(child.text) |
| 349 | } else if (child.type === 'compound_statement') { |
| 350 | hasCommandGroup = true |
| 351 | segments.push(child.text) |
| 352 | } else if ( |
| 353 | child.type === 'command' || |
| 354 | child.type === 'declaration_command' || |
| 355 | child.type === 'variable_assignment' |
| 356 | ) { |
| 357 | segments.push(child.text) |
| 358 | } else if (child.type === 'redirected_statement') { |
| 359 | // `cd ~/src && find path 2>/dev/null` — tree-sitter wraps the ENTIRE |
| 360 | // compound in a redirected_statement: program → redirected_statement → |
| 361 | // (list → cmd1, &&, cmd2) + file_redirect. Same for `cmd1 | cmd2 > out` |
| 362 | // (wraps pipeline) and `(cmd) > out` (wraps subshell). Recurse to |
| 363 | // detect the inner structure; skip file_redirect children (redirects |
| 364 | // don't affect compound/pipeline classification). |
| 365 | let foundInner = false |
no test coverage detected