@@ -279,6 +279,23 @@ fn collect_commands(node: tree_sitter::Node, source: &[u8], commands: &mut Vec<S
279279 }
280280 }
281281 }
282+ // redirected_statement: recurse into the body (the actual command),
283+ // stripping redirect operators (>, >>, <, 2>&1, etc.).
284+ // Redirect target paths are left to the OS-level sandbox to enforce.
285+ // Also recurse into redirect children to extract nested commands
286+ // (e.g. process substitutions: `cmd > >(nested_cmd)`).
287+ "redirected_statement" => {
288+ if let Some ( body) = node. child_by_field_name ( "body" ) {
289+ collect_commands ( body, source, commands) ;
290+ }
291+ for i in 0 ..node. child_count ( ) {
292+ if node. field_name_for_child ( i as u32 ) == Some ( "redirect" )
293+ && let Some ( child) = node. child ( i)
294+ {
295+ collect_substitutions_recursive ( child, source, commands) ;
296+ }
297+ }
298+ }
282299 // comment: skip shell comments (e.g. `# description`)
283300 "comment" => { }
284301 // variable_assignment: transparent container — skip the assignment itself
@@ -295,27 +312,49 @@ fn collect_commands(node: tree_sitter::Node, source: &[u8], commands: &mut Vec<S
295312 }
296313 }
297314 // command node: strip leading variable_assignment children
298- // (environment variable prefixes like `FOO=bar echo hello`), extract
299- // nested command_substitution nodes, and emit the remaining text.
315+ // (environment variable prefixes like `FOO=bar echo hello`), strip
316+ // redirect children (herestring_redirect, etc. that tree-sitter
317+ // attaches directly to a command node), extract nested
318+ // command_substitution nodes, and emit the remaining text.
300319 "command" => {
301- let mut cursor = node. walk ( ) ;
302- for child in node. named_children ( & mut cursor) {
303- match child. kind ( ) {
304- "command_substitution" => {
305- collect_commands ( child, source, commands) ;
306- }
307- "variable_assignment" => {
308- collect_substitutions_recursive ( child, source, commands) ;
320+ for i in 0 ..node. child_count ( ) {
321+ let Some ( child) = node. child ( i) else {
322+ continue ;
323+ } ;
324+ if !child. is_named ( ) {
325+ continue ;
326+ }
327+ if node. field_name_for_child ( i as u32 ) == Some ( "redirect" ) {
328+ // Recurse into redirect children for nested substitutions
329+ // (e.g. `cat <<< $(secret_cmd)`)
330+ collect_substitutions_recursive ( child, source, commands) ;
331+ } else {
332+ match child. kind ( ) {
333+ "command_substitution" => {
334+ collect_commands ( child, source, commands) ;
335+ }
336+ "variable_assignment" => {
337+ collect_substitutions_recursive ( child, source, commands) ;
338+ }
339+ _ => { }
309340 }
310- _ => { }
311341 }
312342 }
313- // Build command text excluding variable_assignment children
314- let mut cursor = node. walk ( ) ;
315- let parts: Vec < & str > = node
316- . named_children ( & mut cursor)
317- . filter ( |child| child. kind ( ) != "variable_assignment" )
318- . filter_map ( |child| {
343+ // Build command text excluding variable_assignment and redirect children.
344+ // Redirects (e.g. herestring_redirect) attached directly to a command
345+ // node use the field name "redirect".
346+ let parts: Vec < & str > = ( 0 ..node. child_count ( ) )
347+ . filter_map ( |i| {
348+ let child = node. child ( i) ?;
349+ if !child. is_named ( ) {
350+ return None ;
351+ }
352+ if child. kind ( ) == "variable_assignment" {
353+ return None ;
354+ }
355+ if node. field_name_for_child ( i as u32 ) == Some ( "redirect" ) {
356+ return None ;
357+ }
319358 let text = & source[ child. start_byte ( ) ..child. end_byte ( ) ] ;
320359 std:: str:: from_utf8 ( text) . ok ( )
321360 } )
@@ -601,7 +640,50 @@ mod tests {
601640 " }
602641 . trim_end ( ) ;
603642 let result = extract_commands ( input) . unwrap ( ) ;
604- assert_eq ! ( result, vec![ input] ) ;
643+ // heredoc is a redirected_statement; only the body command is extracted
644+ assert_eq ! ( result, vec![ "cat" ] ) ;
645+ }
646+
647+ // ========================================
648+ // extract_commands: redirected statements
649+ // ========================================
650+
651+ #[ rstest]
652+ #[ case:: stdout_to_file( "echo hello > file.txt" , vec![ "echo hello" ] ) ]
653+ #[ case:: append_to_file( "echo hello >> file.txt" , vec![ "echo hello" ] ) ]
654+ #[ case:: stdin_from_file( "cat < input.txt" , vec![ "cat" ] ) ]
655+ #[ case:: stderr_to_devnull( "cmd 2> /dev/null" , vec![ "cmd" ] ) ]
656+ #[ case:: stdout_and_stderr( "cmd > out.txt 2>&1" , vec![ "cmd" ] ) ]
657+ #[ case:: fd_redirect_only( "echo hello 2>&1" , vec![ "echo hello" ] ) ]
658+ #[ case:: devnull_redirect( "curl url > /dev/null" , vec![ "curl url" ] ) ]
659+ #[ case:: herestring( "cat <<< hello" , vec![ "cat" ] ) ]
660+ #[ case:: redirect_with_pipeline(
661+ "echo hello 2>&1 | grep world" ,
662+ vec![ "echo hello" , "grep world" ] ,
663+ ) ]
664+ #[ case:: redirect_with_list(
665+ "echo hello > file.txt && cat file.txt" ,
666+ vec![ "echo hello" , "cat file.txt" ] ,
667+ ) ]
668+ #[ case:: redirect_in_compound(
669+ r#"X="test" && echo "$X" 2>&1"# ,
670+ vec![ r#"echo "$X""# ] ,
671+ ) ]
672+ #[ case:: process_substitution_in_redirect(
673+ "cmd > >(nested_cmd)" ,
674+ vec![ "cmd" , "nested_cmd" ] ,
675+ ) ]
676+ #[ case:: command_substitution_in_redirect(
677+ "cmd > $(echo /tmp/file)" ,
678+ vec![ "cmd" , "echo /tmp/file" ] ,
679+ ) ]
680+ #[ case:: command_substitution_in_herestring(
681+ "cat <<< $(secret_cmd)" ,
682+ vec![ "secret_cmd" , "cat" ] ,
683+ ) ]
684+ fn extract_redirected_statements ( #[ case] input : & str , #[ case] expected : Vec < & str > ) {
685+ let result = extract_commands ( input) . unwrap ( ) ;
686+ assert_eq ! ( result, expected) ;
605687 }
606688
607689 // ========================================
0 commit comments