1use std::path::PathBuf;
2
3use tree_sitter::Node;
4use tree_sitter::Parser;
5use tree_sitter::Tree;
6use tree_sitter_bash::LANGUAGE as BASH;
7
8use crate::shell_detect::ShellType;
9use crate::shell_detect::detect_shell_type;
10
11pub fn try_parse_shell(shell_lc_arg: &str) -> Option<Tree> {
14 let lang = BASH.into();
15 let mut parser = Parser::new();
16 #[expect(clippy::expect_used)]
17 parser.set_language(&lang).expect("load bash grammar");
18 let old_tree: Option<&Tree> = None;
19 parser.parse(shell_lc_arg, old_tree)
20}
21
22pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option<Vec<Vec<String>>> {
30 if tree.root_node().has_error() {
31 return None;
32 }
33
34 const ALLOWED_KINDS: &[&str] = &[
37 "program",
39 "list",
40 "pipeline",
41 "command",
43 "command_name",
44 "word",
45 "string",
46 "string_content",
47 "raw_string",
48 "number",
49 "concatenation",
50 ];
51 const ALLOWED_PUNCT_TOKENS: &[&str] = &["&&", "||", ";", "|", "\"", "'"];
53
54 let root = tree.root_node();
55 let mut cursor = root.walk();
56 let mut stack = vec![root];
57 let mut command_nodes = Vec::new();
58 while let Some(node) = stack.pop() {
59 let kind = node.kind();
60 if node.is_named() {
61 if !ALLOWED_KINDS.contains(&kind) {
62 return None;
63 }
64 if kind == "command" {
65 command_nodes.push(node);
66 }
67 } else {
68 if kind.chars().any(|c| "&;|".contains(c)) && !ALLOWED_PUNCT_TOKENS.contains(&kind) {
70 return None;
71 }
72 if !(ALLOWED_PUNCT_TOKENS.contains(&kind) || kind.trim().is_empty()) {
73 return None;
76 }
77 }
78 for child in node.children(&mut cursor) {
79 stack.push(child);
80 }
81 }
82
83 command_nodes.sort_by_key(Node::start_byte);
85
86 let mut commands = Vec::new();
87 for node in command_nodes {
88 if let Some(words) = parse_plain_command_from_node(node, src) {
89 commands.push(words);
90 } else {
91 return None;
92 }
93 }
94 Some(commands)
95}
96
97pub fn parse_shell_script_into_commands(script: &str) -> Option<Vec<Vec<String>>> {
99 let tree = try_parse_shell(script)?;
100 try_parse_word_only_commands_sequence(&tree, script)
101}
102
103pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> {
104 let [shell, flag, script] = command else {
105 return None;
106 };
107 if !matches!(flag.as_str(), "-lc" | "-c")
108 || !matches!(
109 detect_shell_type(PathBuf::from(shell)),
110 Some(ShellType::Zsh) | Some(ShellType::Bash) | Some(ShellType::Sh)
111 )
112 {
113 return None;
114 }
115 Some((shell, script))
116}
117
118pub fn parse_shell_lc_plain_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
122 let (_, script) = extract_bash_command(command)?;
123 parse_shell_script_into_commands(script)
124}
125
126pub(crate) fn parse_shell_lc_literal_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
134 let (_, script) = extract_bash_command(command)?;
135 let tree = try_parse_shell(script)?;
136 let root = tree.root_node();
137 if root.has_error() {
138 return None;
139 }
140
141 let mut commands = Vec::new();
142 let mut stack = vec![root];
143 while let Some(node) = stack.pop() {
144 if node.kind() == "command"
145 && let Some(command) = parse_literal_command_from_node(node, script)
146 {
147 commands.push(command);
148 }
149
150 let mut cursor = node.walk();
151 for child in node.named_children(&mut cursor) {
152 stack.push(child);
153 }
154 }
155
156 Some(commands)
157}
158
159pub fn parse_shell_lc_single_command_prefix(command: &[String]) -> Option<Vec<String>> {
162 let (_, script) = extract_bash_command(command)?;
163 let tree = try_parse_shell(script)?;
164 let root = tree.root_node();
165 if root.has_error() {
166 return None;
167 }
168 if !has_named_descendant_kind(root, "heredoc_redirect") {
169 return None;
170 }
171 if has_named_descendant_kind(root, "file_redirect") {
172 return None;
173 }
174
175 let command_node = find_single_command_node(root)?;
176 parse_heredoc_command_words(command_node, script)
177}
178
179fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option<Vec<String>> {
180 if cmd.kind() != "command" {
181 return None;
182 }
183 let mut words = Vec::new();
184 let mut cursor = cmd.walk();
185 for child in cmd.named_children(&mut cursor) {
186 match child.kind() {
187 "command_name" => {
188 let word_node = child.named_child(0)?;
189 if word_node.kind() != "word" {
190 return None;
191 }
192 words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned());
193 }
194 "word" | "number" => {
195 words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned());
196 }
197 "string" => {
198 let parsed = parse_double_quoted_string(child, src)?;
199 words.push(parsed);
200 }
201 "raw_string" => {
202 let parsed = parse_raw_string(child, src)?;
203 words.push(parsed);
204 }
205 "concatenation" => {
206 let mut concatenated = String::new();
208 let mut concat_cursor = child.walk();
209 for part in child.named_children(&mut concat_cursor) {
210 match part.kind() {
211 "word" | "number" => {
212 concatenated
213 .push_str(part.utf8_text(src.as_bytes()).ok()?.to_owned().as_str());
214 }
215 "string" => {
216 let parsed = parse_double_quoted_string(part, src)?;
217 concatenated.push_str(&parsed);
218 }
219 "raw_string" => {
220 let parsed = parse_raw_string(part, src)?;
221 concatenated.push_str(&parsed);
222 }
223 _ => return None,
224 }
225 }
226 if concatenated.is_empty() {
227 return None;
228 }
229 words.push(concatenated);
230 }
231 _ => return None,
232 }
233 }
234 Some(words)
235}
236
237fn parse_literal_command_from_node(cmd: Node<'_>, src: &str) -> Option<Vec<String>> {
238 if cmd.kind() != "command" {
239 return None;
240 }
241
242 let mut words = Vec::new();
243 let mut found_command_name = false;
244 let mut cursor = cmd.walk();
245 for child in cmd.named_children(&mut cursor) {
246 if child.kind() == "command_name" {
247 let command_name = parse_literal_shell_word(child.named_child(0)?, src)?;
248 words.push(command_name);
249 found_command_name = true;
250 } else if found_command_name && let Some(word) = parse_literal_shell_word(child, src) {
251 words.push(word);
252 }
253 }
254
255 found_command_name.then_some(words)
256}
257
258fn parse_literal_shell_word(node: Node<'_>, src: &str) -> Option<String> {
259 match node.kind() {
260 "word" | "number" if is_literal_word_or_number(node) => {
261 Some(node.utf8_text(src.as_bytes()).ok()?.to_owned())
262 }
263 "string" => parse_double_quoted_string(node, src),
264 "raw_string" => parse_raw_string(node, src),
265 "concatenation" => {
266 let mut concatenated = String::new();
267 let mut cursor = node.walk();
268 for part in node.named_children(&mut cursor) {
269 concatenated.push_str(&parse_literal_shell_word(part, src)?);
270 }
271 (!concatenated.is_empty()).then_some(concatenated)
272 }
273 _ => None,
274 }
275}
276
277fn parse_heredoc_command_words(cmd: Node<'_>, src: &str) -> Option<Vec<String>> {
278 if cmd.kind() != "command" {
279 return None;
280 }
281
282 let mut words = Vec::new();
283 let mut cursor = cmd.walk();
284 for child in cmd.named_children(&mut cursor) {
285 match child.kind() {
286 "command_name" => {
287 let word_node = child.named_child(0)?;
288 if !matches!(word_node.kind(), "word" | "number")
289 || !is_literal_word_or_number(word_node)
290 {
291 return None;
292 }
293 words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned());
294 }
295 "word" | "number" => {
296 if !is_literal_word_or_number(child) {
297 return None;
298 }
299 words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned());
300 }
301 "comment" => {}
306 kind if is_allowed_heredoc_attachment_kind(kind) => {}
307 _ => return None,
308 }
309 }
310
311 if words.is_empty() { None } else { Some(words) }
312}
313
314fn is_literal_word_or_number(node: Node<'_>) -> bool {
315 if !matches!(node.kind(), "word" | "number") {
316 return false;
317 }
318 let mut cursor = node.walk();
319 node.named_children(&mut cursor).next().is_none()
320}
321
322fn is_allowed_heredoc_attachment_kind(kind: &str) -> bool {
323 matches!(
324 kind,
325 "heredoc_body"
326 | "simple_heredoc_body"
327 | "heredoc_redirect"
328 | "herestring_redirect"
329 | "redirected_statement"
330 )
331}
332
333fn find_single_command_node(root: Node<'_>) -> Option<Node<'_>> {
334 let mut stack = vec![root];
335 let mut single_command = None;
336 while let Some(node) = stack.pop() {
337 if node.kind() == "command" {
338 if single_command.is_some() {
339 return None;
340 }
341 single_command = Some(node);
342 }
343
344 let mut cursor = node.walk();
345 for child in node.named_children(&mut cursor) {
346 stack.push(child);
347 }
348 }
349 single_command
350}
351
352fn has_named_descendant_kind(node: Node<'_>, kind: &str) -> bool {
353 let mut stack = vec![node];
354 while let Some(current) = stack.pop() {
355 if current.kind() == kind {
356 return true;
357 }
358 let mut cursor = current.walk();
359 for child in current.named_children(&mut cursor) {
360 stack.push(child);
361 }
362 }
363 false
364}
365
366fn parse_double_quoted_string(node: Node, src: &str) -> Option<String> {
367 if node.kind() != "string" {
368 return None;
369 }
370
371 let mut cursor = node.walk();
372 for part in node.named_children(&mut cursor) {
373 if part.kind() != "string_content" {
374 return None;
375 }
376 }
377 let raw = node.utf8_text(src.as_bytes()).ok()?;
378 let stripped = raw
379 .strip_prefix('"')
380 .and_then(|text| text.strip_suffix('"'))?;
381 Some(stripped.to_string())
382}
383
384fn parse_raw_string(node: Node, src: &str) -> Option<String> {
385 if node.kind() != "raw_string" {
386 return None;
387 }
388
389 let raw_string = node.utf8_text(src.as_bytes()).ok()?;
390 let stripped = raw_string
391 .strip_prefix('\'')
392 .and_then(|s| s.strip_suffix('\''));
393 stripped.map(str::to_owned)
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use pretty_assertions::assert_eq;
400
401 fn parse_seq(src: &str) -> Option<Vec<Vec<String>>> {
402 parse_shell_script_into_commands(src)
403 }
404
405 #[test]
406 fn accepts_single_simple_command() {
407 let cmds = parse_seq("ls -1").unwrap();
408 assert_eq!(cmds, vec![vec!["ls".to_string(), "-1".to_string()]]);
409 }
410
411 #[test]
412 fn accepts_multiple_commands_with_allowed_operators() {
413 let src = "ls && pwd; echo 'hi there' | wc -l";
414 let cmds = parse_seq(src).unwrap();
415 let expected: Vec<Vec<String>> = vec![
416 vec!["ls".to_string()],
417 vec!["pwd".to_string()],
418 vec!["echo".to_string(), "hi there".to_string()],
419 vec!["wc".to_string(), "-l".to_string()],
420 ];
421 assert_eq!(cmds, expected);
422 }
423
424 #[test]
425 fn extracts_double_and_single_quoted_strings() {
426 let cmds = parse_seq("echo \"hello world\"").unwrap();
427 assert_eq!(
428 cmds,
429 vec![vec!["echo".to_string(), "hello world".to_string()]]
430 );
431
432 let cmds2 = parse_seq("echo 'hi there'").unwrap();
433 assert_eq!(
434 cmds2,
435 vec![vec!["echo".to_string(), "hi there".to_string()]]
436 );
437 }
438
439 #[test]
440 fn accepts_double_quoted_strings_with_newlines() {
441 let cmds = parse_seq("git commit -m \"line1\nline2\"").unwrap();
442 assert_eq!(
443 cmds,
444 vec![vec![
445 "git".to_string(),
446 "commit".to_string(),
447 "-m".to_string(),
448 "line1\nline2".to_string(),
449 ]]
450 );
451 }
452
453 #[test]
454 fn accepts_mixed_quote_concatenation() {
455 assert_eq!(
456 parse_seq(r#"echo "/usr"'/'"local"/bin"#).unwrap(),
457 vec![vec!["echo".to_string(), "/usr/local/bin".to_string()]]
458 );
459 assert_eq!(
460 parse_seq(r#"echo '/usr'"/"'local'/bin"#).unwrap(),
461 vec![vec!["echo".to_string(), "/usr/local/bin".to_string()]]
462 );
463 }
464
465 #[test]
466 fn rejects_double_quoted_strings_with_expansions() {
467 assert!(parse_seq(r#"echo "hi ${USER}""#).is_none());
468 assert!(parse_seq(r#"echo "$HOME""#).is_none());
469 }
470
471 #[test]
472 fn accepts_numbers_as_words() {
473 let cmds = parse_seq("echo 123 456").unwrap();
474 assert_eq!(
475 cmds,
476 vec![vec![
477 "echo".to_string(),
478 "123".to_string(),
479 "456".to_string()
480 ]]
481 );
482 }
483
484 #[test]
485 fn rejects_parentheses_and_subshells() {
486 assert!(parse_seq("(ls)").is_none());
487 assert!(parse_seq("ls || (pwd && echo hi)").is_none());
488 }
489
490 #[test]
491 fn rejects_redirections_and_unsupported_operators() {
492 assert!(parse_seq("ls > out.txt").is_none());
493 assert!(parse_seq("echo hi & echo bye").is_none());
494 }
495
496 #[test]
497 fn rejects_command_and_process_substitutions_and_expansions() {
498 assert!(parse_seq("echo $(pwd)").is_none());
499 assert!(parse_seq("echo `pwd`").is_none());
500 assert!(parse_seq("echo $HOME").is_none());
501 assert!(parse_seq("echo \"hi $USER\"").is_none());
502 }
503
504 #[test]
505 fn rejects_variable_assignment_prefix() {
506 assert!(parse_seq("FOO=bar ls").is_none());
507 }
508
509 #[test]
510 fn rejects_trailing_operator_parse_error() {
511 assert!(parse_seq("ls &&").is_none());
512 }
513
514 #[test]
515 fn rejects_empty_command_position_with_leading_operator() {
516 assert!(parse_seq("&& ls").is_none());
517 }
518
519 #[test]
520 fn rejects_empty_command_position_with_double_separator() {
521 assert!(parse_seq("ls ;; pwd").is_none());
522 }
523
524 #[test]
525 fn rejects_empty_command_position_with_empty_pipeline_segment() {
526 assert!(parse_seq("ls | | wc").is_none());
527 }
528
529 #[test]
530 fn parse_zsh_lc_plain_commands() {
531 let command = vec!["zsh".to_string(), "-lc".to_string(), "ls".to_string()];
532 let parsed = parse_shell_lc_plain_commands(&command).unwrap();
533 assert_eq!(parsed, vec![vec!["ls".to_string()]]);
534 }
535
536 #[test]
537 fn accepts_concatenated_flag_and_value() {
538 let cmds = parse_seq("rg -n \"foo\" -g\"*.py\"").unwrap();
540 assert_eq!(
541 cmds,
542 vec![vec![
543 "rg".to_string(),
544 "-n".to_string(),
545 "foo".to_string(),
546 "-g*.py".to_string(),
547 ]]
548 );
549 }
550
551 #[test]
552 fn accepts_concatenated_flag_with_single_quotes() {
553 let cmds = parse_seq("grep -n 'pattern' -g'*.txt'").unwrap();
554 assert_eq!(
555 cmds,
556 vec![vec![
557 "grep".to_string(),
558 "-n".to_string(),
559 "pattern".to_string(),
560 "-g*.txt".to_string(),
561 ]]
562 );
563 }
564
565 #[test]
566 fn rejects_concatenation_with_variable_substitution() {
567 assert!(parse_seq("rg -g\"$VAR\" pattern").is_none());
569 assert!(parse_seq("rg -g\"${VAR}\" pattern").is_none());
570 }
571
572 #[test]
573 fn rejects_concatenation_with_command_substitution() {
574 assert!(parse_seq("rg -g\"$(pwd)\" pattern").is_none());
576 assert!(parse_seq("rg -g\"$(echo '*.py')\" pattern").is_none());
577 }
578
579 #[test]
580 fn parse_shell_lc_single_command_prefix_supports_heredoc() {
581 let command = vec![
582 "zsh".to_string(),
583 "-lc".to_string(),
584 "python3 <<'PY'\nprint('hello')\nPY".to_string(),
585 ];
586 let parsed = parse_shell_lc_single_command_prefix(&command);
587 assert_eq!(parsed, Some(vec!["python3".to_string()]));
588
589 let command_unquoted = vec![
590 "zsh".to_string(),
591 "-lc".to_string(),
592 "python3 << PY\nprint('hello')\nPY".to_string(),
593 ];
594 let parsed_unquoted = parse_shell_lc_single_command_prefix(&command_unquoted);
595 assert_eq!(parsed_unquoted, Some(vec!["python3".to_string()]));
596 }
597
598 #[test]
599 fn parse_shell_lc_single_command_prefix_rejects_multi_command_scripts() {
600 let command = vec![
601 "bash".to_string(),
602 "-lc".to_string(),
603 "python3 <<'PY'\nprint('hello')\nPY\necho done".to_string(),
604 ];
605 assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
606 }
607
608 #[test]
609 fn parse_shell_lc_single_command_prefix_rejects_non_heredoc_redirects() {
610 let command = vec![
611 "bash".to_string(),
612 "-lc".to_string(),
613 "echo hello > /tmp/out.txt".to_string(),
614 ];
615 assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
616 }
617
618 #[test]
619 fn parse_shell_lc_single_command_prefix_rejects_heredoc_with_extra_file_redirect() {
620 let command = vec![
621 "bash".to_string(),
622 "-lc".to_string(),
623 "python3 <<'PY' > /tmp/out.txt\nprint('hello')\nPY".to_string(),
624 ];
625 assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
626 }
627
628 #[test]
629 fn parse_shell_lc_single_command_prefix_rejects_heredoc_with_variable_assignment() {
630 let command = vec![
631 "bash".to_string(),
632 "-lc".to_string(),
633 "PATH=/tmp/evil:$PATH cat <<'EOF'\nhello\nEOF".to_string(),
634 ];
635 assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
636 }
637
638 #[test]
639 fn parse_shell_lc_single_command_prefix_rejects_herestring_with_chaining() {
640 let command = vec![
641 "bash".to_string(),
642 "-lc".to_string(),
643 r#"echo hello > /tmp/out.txt && cat /tmp/out.txt"#.to_string(),
644 ];
645 assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
646 }
647
648 #[test]
649 fn parse_shell_lc_single_command_prefix_rejects_herestring_with_substitution() {
650 let command = vec![
651 "bash".to_string(),
652 "-lc".to_string(),
653 r#"python3 <<< "$(rm -rf /)""#.to_string(),
654 ];
655 assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
656 }
657
658 #[test]
659 fn parse_shell_lc_single_command_prefix_rejects_arithmetic_shift_non_heredoc_script() {
660 let command = vec![
661 "bash".to_string(),
662 "-lc".to_string(),
663 "echo $((1<<2))".to_string(),
664 ];
665 assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
666 }
667
668 #[test]
669 fn parse_shell_lc_single_command_prefix_rejects_heredoc_command_with_word_expansion() {
670 let command = vec![
671 "bash".to_string(),
672 "-lc".to_string(),
673 "python3 $((1<<2)) <<'PY'\nprint('hello')\nPY".to_string(),
674 ];
675 assert_eq!(parse_shell_lc_single_command_prefix(&command), None);
676 }
677}