1use crate::bash::extract_bash_command;
2use crate::bash::try_parse_shell;
3use crate::bash::try_parse_word_only_commands_sequence;
4use crate::powershell::extract_powershell_command;
5use codex_protocol::parse_command::ParsedCommand;
6use shlex::split as shlex_split;
7use shlex::try_join as shlex_try_join;
8use std::path::PathBuf;
9
10pub fn shlex_join(tokens: &[String]) -> String {
11 shlex_try_join(tokens.iter().map(String::as_str))
12 .unwrap_or_else(|_| "<command included NUL byte>".to_string())
13}
14
15pub fn extract_shell_command(command: &[String]) -> Option<(&str, &str)> {
17 extract_bash_command(command).or_else(|| extract_powershell_command(command))
18}
19
20pub fn parse_command(command: &[String]) -> Vec<ParsedCommand> {
31 let parsed = parse_command_impl(command);
33 let mut deduped: Vec<ParsedCommand> = Vec::with_capacity(parsed.len());
34 for cmd in parsed.into_iter() {
35 if deduped.last().is_some_and(|prev| prev == &cmd) {
36 continue;
37 }
38 deduped.push(cmd);
39 }
40 if deduped
41 .iter()
42 .any(|cmd| matches!(cmd, ParsedCommand::Unknown { .. }))
43 {
44 vec![single_unknown_for_command(command)]
45 } else {
46 deduped
47 }
48}
49
50fn single_unknown_for_command(command: &[String]) -> ParsedCommand {
51 if let Some((_, shell_command)) = extract_shell_command(command) {
52 ParsedCommand::Unknown {
53 cmd: shell_command.to_string(),
54 }
55 } else {
56 ParsedCommand::Unknown {
57 cmd: shlex_join(command),
58 }
59 }
60}
61
62#[cfg(test)]
63#[allow(clippy::items_after_test_module)]
64mod tests {
66 use super::*;
67 use pretty_assertions::assert_eq;
68 use std::path::PathBuf;
69 use std::string::ToString;
70
71 fn shlex_split_safe(s: &str) -> Vec<String> {
72 shlex_split(s).unwrap_or_else(|| s.split_whitespace().map(ToString::to_string).collect())
73 }
74
75 fn vec_str(args: &[&str]) -> Vec<String> {
76 args.iter().map(ToString::to_string).collect()
77 }
78
79 fn assert_parsed(args: &[String], expected: Vec<ParsedCommand>) {
80 let out = parse_command(args);
81 assert_eq!(out, expected);
82 }
83
84 #[test]
85 fn git_status_is_unknown() {
86 assert_parsed(
87 &vec_str(&["git", "status"]),
88 vec![ParsedCommand::Unknown {
89 cmd: "git status".to_string(),
90 }],
91 );
92 }
93
94 #[test]
95 fn supports_git_grep_and_ls_files() {
96 assert_parsed(
97 &shlex_split_safe("git grep TODO src"),
98 vec![ParsedCommand::Search {
99 cmd: "git grep TODO src".to_string(),
100 query: Some("TODO".to_string()),
101 path: Some("src".to_string()),
102 }],
103 );
104 assert_parsed(
105 &shlex_split_safe("git grep -l TODO src"),
106 vec![ParsedCommand::Search {
107 cmd: "git grep -l TODO src".to_string(),
108 query: Some("TODO".to_string()),
109 path: Some("src".to_string()),
110 }],
111 );
112 assert_parsed(
113 &shlex_split_safe("git ls-files"),
114 vec![ParsedCommand::ListFiles {
115 cmd: "git ls-files".to_string(),
116 path: None,
117 }],
118 );
119 assert_parsed(
120 &shlex_split_safe("git ls-files src"),
121 vec![ParsedCommand::ListFiles {
122 cmd: "git ls-files src".to_string(),
123 path: Some("src".to_string()),
124 }],
125 );
126 assert_parsed(
127 &shlex_split_safe("git ls-files --exclude target src"),
128 vec![ParsedCommand::ListFiles {
129 cmd: "git ls-files --exclude target src".to_string(),
130 path: Some("src".to_string()),
131 }],
132 );
133 }
134
135 #[test]
136 fn handles_git_pipe_wc() {
137 let inner = "git status | wc -l";
138 assert_parsed(
139 &vec_str(&["bash", "-lc", inner]),
140 vec![ParsedCommand::Unknown {
141 cmd: inner.to_string(),
142 }],
143 );
144 }
145
146 #[test]
147 fn bash_lc_redirect_not_quoted() {
148 let inner = "echo foo > bar";
149 assert_parsed(
150 &vec_str(&["bash", "-lc", inner]),
151 vec![ParsedCommand::Unknown {
152 cmd: "echo foo > bar".to_string(),
153 }],
154 );
155 }
156
157 #[test]
158 fn handles_complex_bash_command_head() {
159 let inner =
160 "rg --version && node -v && pnpm -v && rg --files | wc -l && rg --files | head -n 40";
161 assert_parsed(
162 &vec_str(&["bash", "-lc", inner]),
163 vec![ParsedCommand::Unknown {
164 cmd: inner.to_string(),
165 }],
166 );
167 }
168
169 #[test]
170 fn supports_searching_for_navigate_to_route() -> anyhow::Result<()> {
171 let inner = "rg -n \"navigate-to-route\" -S";
172 assert_parsed(
173 &vec_str(&["bash", "-lc", inner]),
174 vec![ParsedCommand::Search {
175 cmd: "rg -n navigate-to-route -S".to_string(),
176 query: Some("navigate-to-route".to_string()),
177 path: None,
178 }],
179 );
180 Ok(())
181 }
182
183 #[test]
184 fn handles_complex_bash_command() {
185 let inner = "rg -n \"BUG|FIXME|TODO|XXX|HACK\" -S | head -n 200";
186 assert_parsed(
187 &vec_str(&["bash", "-lc", inner]),
188 vec![ParsedCommand::Search {
189 cmd: "rg -n 'BUG|FIXME|TODO|XXX|HACK' -S".to_string(),
190 query: Some("BUG|FIXME|TODO|XXX|HACK".to_string()),
191 path: None,
192 }],
193 );
194 }
195
196 #[test]
197 fn supports_rg_files_with_path_and_pipe() {
198 let inner = "rg --files webview/src | sed -n";
199 assert_parsed(
200 &vec_str(&["bash", "-lc", inner]),
201 vec![ParsedCommand::ListFiles {
202 cmd: "rg --files webview/src".to_string(),
203 path: Some("webview".to_string()),
204 }],
205 );
206 }
207
208 #[test]
209 fn supports_rg_files_then_head() {
210 let inner = "rg --files | head -n 50";
211 assert_parsed(
212 &vec_str(&["bash", "-lc", inner]),
213 vec![ParsedCommand::ListFiles {
214 cmd: "rg --files".to_string(),
215 path: None,
216 }],
217 );
218 }
219
220 #[test]
221 fn keeps_mutating_xargs_pipeline() {
222 let inner = r#"rg -l QkBindingController presentation/src/main/java | xargs perl -pi -e 's/QkBindingController/QkController/g'"#;
223 assert_parsed(
224 &vec_str(&["bash", "-lc", inner]),
225 vec![ParsedCommand::Unknown {
226 cmd: inner.to_string(),
227 }],
228 );
229 }
230
231 #[test]
232 fn collapses_plain_pipeline_when_any_stage_is_unknown() {
233 let command = shlex_split_safe(
234 "rg -l QkBindingController presentation/src/main/java | xargs perl -pi -e 's/QkBindingController/QkController/g'",
235 );
236 assert_parsed(
237 &command,
238 vec![ParsedCommand::Unknown {
239 cmd: shlex_join(&command),
240 }],
241 );
242 }
243
244 #[test]
245 fn collapses_pipeline_with_helper_when_later_stage_is_unknown() {
246 let command = shlex_split_safe("rg --files | nl -ba | foo");
247 assert_parsed(
248 &command,
249 vec![ParsedCommand::Unknown {
250 cmd: shlex_join(&command),
251 }],
252 );
253 }
254
255 #[test]
256 fn rg_files_with_matches_flags_are_search() {
257 assert_parsed(
258 &shlex_split_safe("rg -l TODO src"),
259 vec![ParsedCommand::Search {
260 cmd: "rg -l TODO src".to_string(),
261 query: Some("TODO".to_string()),
262 path: Some("src".to_string()),
263 }],
264 );
265 assert_parsed(
266 &shlex_split_safe("rg --files-with-matches TODO src"),
267 vec![ParsedCommand::Search {
268 cmd: "rg --files-with-matches TODO src".to_string(),
269 query: Some("TODO".to_string()),
270 path: Some("src".to_string()),
271 }],
272 );
273 assert_parsed(
274 &shlex_split_safe("rg -L TODO src"),
275 vec![ParsedCommand::Search {
276 cmd: "rg -L TODO src".to_string(),
277 query: Some("TODO".to_string()),
278 path: Some("src".to_string()),
279 }],
280 );
281 assert_parsed(
282 &shlex_split_safe("rg --files-without-match TODO src"),
283 vec![ParsedCommand::Search {
284 cmd: "rg --files-without-match TODO src".to_string(),
285 query: Some("TODO".to_string()),
286 path: Some("src".to_string()),
287 }],
288 );
289 assert_parsed(
290 &shlex_split_safe("rga -l TODO src"),
291 vec![ParsedCommand::Search {
292 cmd: "rga -l TODO src".to_string(),
293 query: Some("TODO".to_string()),
294 path: Some("src".to_string()),
295 }],
296 );
297 }
298
299 #[test]
300 fn supports_cat() {
301 let inner = "cat webview/README.md";
302 assert_parsed(
303 &vec_str(&["bash", "-lc", inner]),
304 vec![ParsedCommand::Read {
305 cmd: inner.to_string(),
306 name: "README.md".to_string(),
307 path: PathBuf::from("webview/README.md"),
308 }],
309 );
310 }
311
312 #[test]
313 fn zsh_lc_supports_cat() {
314 let inner = "cat README.md";
315 assert_parsed(
316 &vec_str(&["zsh", "-lc", inner]),
317 vec![ParsedCommand::Read {
318 cmd: inner.to_string(),
319 name: "README.md".to_string(),
320 path: PathBuf::from("README.md"),
321 }],
322 );
323 }
324
325 #[test]
326 fn supports_bat() {
327 let inner = "bat --theme TwoDark README.md";
328 assert_parsed(
329 &vec_str(&["bash", "-lc", inner]),
330 vec![ParsedCommand::Read {
331 cmd: inner.to_string(),
332 name: "README.md".to_string(),
333 path: PathBuf::from("README.md"),
334 }],
335 );
336 }
337
338 #[test]
339 fn supports_batcat() {
340 let inner = "batcat README.md";
341 assert_parsed(
342 &vec_str(&["bash", "-lc", inner]),
343 vec![ParsedCommand::Read {
344 cmd: inner.to_string(),
345 name: "README.md".to_string(),
346 path: PathBuf::from("README.md"),
347 }],
348 );
349 }
350
351 #[test]
352 fn supports_less() {
353 let inner = "less -p TODO README.md";
354 assert_parsed(
355 &vec_str(&["bash", "-lc", inner]),
356 vec![ParsedCommand::Read {
357 cmd: inner.to_string(),
358 name: "README.md".to_string(),
359 path: PathBuf::from("README.md"),
360 }],
361 );
362 }
363
364 #[test]
365 fn supports_more() {
366 let inner = "more README.md";
367 assert_parsed(
368 &vec_str(&["bash", "-lc", inner]),
369 vec![ParsedCommand::Read {
370 cmd: inner.to_string(),
371 name: "README.md".to_string(),
372 path: PathBuf::from("README.md"),
373 }],
374 );
375 }
376
377 #[test]
378 fn cd_then_cat_is_single_read() {
379 assert_parsed(
380 &shlex_split_safe("cd foo && cat foo.txt"),
381 vec![ParsedCommand::Read {
382 cmd: "cat foo.txt".to_string(),
383 name: "foo.txt".to_string(),
384 path: PathBuf::from("foo/foo.txt"),
385 }],
386 );
387 }
388
389 #[test]
390 fn cd_with_double_dash_then_cat_is_read() {
391 assert_parsed(
392 &shlex_split_safe("cd -- -weird && cat foo.txt"),
393 vec![ParsedCommand::Read {
394 cmd: "cat foo.txt".to_string(),
395 name: "foo.txt".to_string(),
396 path: PathBuf::from("-weird/foo.txt"),
397 }],
398 );
399 }
400
401 #[test]
402 fn cd_with_multiple_operands_uses_last() {
403 assert_parsed(
404 &shlex_split_safe("cd dir1 dir2 && cat foo.txt"),
405 vec![ParsedCommand::Read {
406 cmd: "cat foo.txt".to_string(),
407 name: "foo.txt".to_string(),
408 path: PathBuf::from("dir2/foo.txt"),
409 }],
410 );
411 }
412
413 #[test]
414 fn bash_cd_then_bar_is_same_as_bar() {
415 assert_parsed(
417 &shlex_split_safe("bash -lc 'cd foo && bar'"),
418 vec![ParsedCommand::Unknown {
419 cmd: "cd foo && bar".to_string(),
420 }],
421 );
422 }
423
424 #[test]
425 fn bash_cd_then_cat_is_read() {
426 assert_parsed(
427 &shlex_split_safe("bash -lc 'cd foo && cat foo.txt'"),
428 vec![ParsedCommand::Read {
429 cmd: "cat foo.txt".to_string(),
430 name: "foo.txt".to_string(),
431 path: PathBuf::from("foo/foo.txt"),
432 }],
433 );
434 }
435
436 #[test]
437 fn supports_ls_with_pipe() {
438 let inner = "ls -la | sed -n '1,120p'";
439 assert_parsed(
440 &vec_str(&["bash", "-lc", inner]),
441 vec![ParsedCommand::ListFiles {
442 cmd: "ls -la".to_string(),
443 path: None,
444 }],
445 );
446 }
447
448 #[test]
449 fn supports_eza_exa_tree_du() {
450 assert_parsed(
451 &shlex_split_safe("eza --color=always src"),
452 vec![ParsedCommand::ListFiles {
453 cmd: "eza '--color=always' src".to_string(),
454 path: Some("src".to_string()),
455 }],
456 );
457 assert_parsed(
458 &shlex_split_safe("exa -I target ."),
459 vec![ParsedCommand::ListFiles {
460 cmd: "exa -I target .".to_string(),
461 path: Some(".".to_string()),
462 }],
463 );
464 assert_parsed(
465 &shlex_split_safe("tree -L 2 src"),
466 vec![ParsedCommand::ListFiles {
467 cmd: "tree -L 2 src".to_string(),
468 path: Some("src".to_string()),
469 }],
470 );
471 assert_parsed(
472 &shlex_split_safe("du -d 2 ."),
473 vec![ParsedCommand::ListFiles {
474 cmd: "du -d 2 .".to_string(),
475 path: Some(".".to_string()),
476 }],
477 );
478 }
479
480 #[test]
481 fn supports_head_n() {
482 let inner = "head -n 50 Cargo.toml";
483 assert_parsed(
484 &vec_str(&["bash", "-lc", inner]),
485 vec![ParsedCommand::Read {
486 cmd: inner.to_string(),
487 name: "Cargo.toml".to_string(),
488 path: PathBuf::from("Cargo.toml"),
489 }],
490 );
491 }
492
493 #[test]
494 fn supports_head_file_only() {
495 let inner = "head Cargo.toml";
496 assert_parsed(
497 &vec_str(&["bash", "-lc", inner]),
498 vec![ParsedCommand::Read {
499 cmd: inner.to_string(),
500 name: "Cargo.toml".to_string(),
501 path: PathBuf::from("Cargo.toml"),
502 }],
503 );
504 }
505
506 #[test]
507 fn supports_cat_sed_n() {
508 let inner = "cat tui/Cargo.toml | sed -n '1,200p'";
509 assert_parsed(
510 &vec_str(&["bash", "-lc", inner]),
511 vec![ParsedCommand::Read {
512 cmd: inner.to_string(),
513 name: "Cargo.toml".to_string(),
514 path: PathBuf::from("tui/Cargo.toml"),
515 }],
516 );
517 }
518
519 #[test]
520 fn supports_tail_n_plus() {
521 let inner = "tail -n +522 README.md";
522 assert_parsed(
523 &vec_str(&["bash", "-lc", inner]),
524 vec![ParsedCommand::Read {
525 cmd: inner.to_string(),
526 name: "README.md".to_string(),
527 path: PathBuf::from("README.md"),
528 }],
529 );
530 }
531
532 #[test]
533 fn supports_tail_n_last_lines() {
534 let inner = "tail -n 30 README.md";
535 let out = parse_command(&vec_str(&["bash", "-lc", inner]));
536 assert_eq!(
537 out,
538 vec![ParsedCommand::Read {
539 cmd: inner.to_string(),
540 name: "README.md".to_string(),
541 path: PathBuf::from("README.md"),
542 }]
543 );
544 }
545
546 #[test]
547 fn supports_tail_file_only() {
548 let inner = "tail README.md";
549 assert_parsed(
550 &vec_str(&["bash", "-lc", inner]),
551 vec![ParsedCommand::Read {
552 cmd: inner.to_string(),
553 name: "README.md".to_string(),
554 path: PathBuf::from("README.md"),
555 }],
556 );
557 }
558
559 #[test]
560 fn supports_npm_run_build_is_unknown() {
561 assert_parsed(
562 &vec_str(&["npm", "run", "build"]),
563 vec![ParsedCommand::Unknown {
564 cmd: "npm run build".to_string(),
565 }],
566 );
567 }
568
569 #[test]
570 fn supports_grep_recursive_current_dir() {
571 assert_parsed(
572 &vec_str(&["grep", "-R", "CODEX_SANDBOX_ENV_VAR", "-n", "."]),
573 vec![ParsedCommand::Search {
574 cmd: "grep -R CODEX_SANDBOX_ENV_VAR -n .".to_string(),
575 query: Some("CODEX_SANDBOX_ENV_VAR".to_string()),
576 path: Some(".".to_string()),
577 }],
578 );
579 }
580
581 #[test]
582 fn supports_grep_recursive_specific_file() {
583 assert_parsed(
584 &vec_str(&[
585 "grep",
586 "-R",
587 "CODEX_SANDBOX_ENV_VAR",
588 "-n",
589 "core/src/spawn.rs",
590 ]),
591 vec![ParsedCommand::Search {
592 cmd: "grep -R CODEX_SANDBOX_ENV_VAR -n core/src/spawn.rs".to_string(),
593 query: Some("CODEX_SANDBOX_ENV_VAR".to_string()),
594 path: Some("spawn.rs".to_string()),
595 }],
596 );
597 }
598
599 #[test]
600 fn supports_egrep_and_fgrep() {
601 assert_parsed(
602 &shlex_split_safe("egrep -R TODO src"),
603 vec![ParsedCommand::Search {
604 cmd: "egrep -R TODO src".to_string(),
605 query: Some("TODO".to_string()),
606 path: Some("src".to_string()),
607 }],
608 );
609 assert_parsed(
610 &shlex_split_safe("fgrep -l TODO src"),
611 vec![ParsedCommand::Search {
612 cmd: "fgrep -l TODO src".to_string(),
613 query: Some("TODO".to_string()),
614 path: Some("src".to_string()),
615 }],
616 );
617 }
618
619 #[test]
620 fn grep_files_with_matches_flags_are_search() {
621 assert_parsed(
622 &shlex_split_safe("grep -l TODO src"),
623 vec![ParsedCommand::Search {
624 cmd: "grep -l TODO src".to_string(),
625 query: Some("TODO".to_string()),
626 path: Some("src".to_string()),
627 }],
628 );
629 assert_parsed(
630 &shlex_split_safe("grep --files-with-matches TODO src"),
631 vec![ParsedCommand::Search {
632 cmd: "grep --files-with-matches TODO src".to_string(),
633 query: Some("TODO".to_string()),
634 path: Some("src".to_string()),
635 }],
636 );
637 assert_parsed(
638 &shlex_split_safe("grep -L TODO src"),
639 vec![ParsedCommand::Search {
640 cmd: "grep -L TODO src".to_string(),
641 query: Some("TODO".to_string()),
642 path: Some("src".to_string()),
643 }],
644 );
645 assert_parsed(
646 &shlex_split_safe("grep --files-without-match TODO src"),
647 vec![ParsedCommand::Search {
648 cmd: "grep --files-without-match TODO src".to_string(),
649 query: Some("TODO".to_string()),
650 path: Some("src".to_string()),
651 }],
652 );
653 }
654
655 #[test]
656 fn supports_grep_query_with_slashes_not_shortened() {
657 assert_parsed(
660 &shlex_split_safe("grep -R src/main.rs -n ."),
661 vec![ParsedCommand::Search {
662 cmd: "grep -R src/main.rs -n .".to_string(),
663 query: Some("src/main.rs".to_string()),
664 path: Some(".".to_string()),
665 }],
666 );
667 }
668
669 #[test]
670 fn supports_grep_weird_backtick_in_query() {
671 assert_parsed(
672 &shlex_split_safe("grep -R COD`EX_SANDBOX -n"),
673 vec![ParsedCommand::Search {
674 cmd: "grep -R 'COD`EX_SANDBOX' -n".to_string(),
675 query: Some("COD`EX_SANDBOX".to_string()),
676 path: None,
677 }],
678 );
679 }
680
681 #[test]
682 fn supports_cd_and_rg_files() {
683 assert_parsed(
684 &shlex_split_safe("cd codex-rs && rg --files"),
685 vec![ParsedCommand::ListFiles {
686 cmd: "rg --files".to_string(),
687 path: None,
688 }],
689 );
690 }
691
692 #[test]
693 fn supports_single_string_script_with_cd_and_pipe() {
694 let inner = r#"cd /Users/pakrym/code/codex && rg -n "codex_api" codex-rs -S | head -n 50"#;
695 assert_parsed(
696 &vec_str(&["bash", "-lc", inner]),
697 vec![ParsedCommand::Search {
698 cmd: "rg -n codex_api codex-rs -S".to_string(),
699 query: Some("codex_api".to_string()),
700 path: Some("codex-rs".to_string()),
701 }],
702 );
703 }
704
705 #[test]
706 fn supports_python_walks_files() {
707 let inner = r#"python -c "import os; print(os.listdir('.'))""#;
708 assert_parsed(
709 &vec_str(&["bash", "-lc", inner]),
710 vec![ParsedCommand::ListFiles {
711 cmd: shlex_join(&shlex_split_safe(inner)),
712 path: None,
713 }],
714 );
715 }
716
717 #[test]
718 fn supports_python3_walks_files() {
719 let inner = r#"python3 -c "import glob; print(glob.glob('*.rs'))""#;
720 assert_parsed(
721 &vec_str(&["bash", "-lc", inner]),
722 vec![ParsedCommand::ListFiles {
723 cmd: shlex_join(&shlex_split_safe(inner)),
724 path: None,
725 }],
726 );
727 }
728
729 #[test]
730 fn python_without_file_walk_is_unknown() {
731 let inner = r#"python -c "print('hello')""#;
732 assert_parsed(
733 &vec_str(&["bash", "-lc", inner]),
734 vec![ParsedCommand::Unknown {
735 cmd: shlex_join(&shlex_split_safe(inner)),
736 }],
737 );
738 }
739
740 #[test]
742 fn small_formatting_always_true_commands() {
743 for cmd in ["wc", "tr", "cut", "sort", "uniq", "xargs", "tee", "column"] {
744 assert!(is_small_formatting_command(&shlex_split_safe(cmd)));
745 assert!(is_small_formatting_command(&shlex_split_safe(&format!(
746 "{cmd} -x"
747 ))));
748 }
749 }
750
751 #[test]
752 fn awk_behavior() {
753 assert!(is_small_formatting_command(&shlex_split_safe(
754 "awk '{print $1}'"
755 )));
756 assert!(!is_small_formatting_command(&shlex_split_safe(
757 "awk '{print $1}' Cargo.toml"
758 )));
759 assert!(!is_small_formatting_command(&shlex_split_safe(
760 "awk -f script.awk Cargo.toml"
761 )));
762 }
763
764 #[test]
765 fn head_behavior() {
766 assert!(is_small_formatting_command(&vec_str(&["head"])));
768 assert!(is_small_formatting_command(&shlex_split_safe("head -n 40")));
770 assert!(!is_small_formatting_command(&shlex_split_safe(
772 "head -n 40 file.txt"
773 )));
774 assert!(!is_small_formatting_command(&vec_str(&[
776 "head", "file.txt"
777 ])));
778 }
779
780 #[test]
781 fn tail_behavior() {
782 assert!(is_small_formatting_command(&vec_str(&["tail"])));
784 assert!(is_small_formatting_command(&shlex_split_safe(
786 "tail -n +10"
787 )));
788 assert!(!is_small_formatting_command(&shlex_split_safe(
789 "tail -n +10 file.txt"
790 )));
791 assert!(is_small_formatting_command(&shlex_split_safe("tail -n 30")));
793 assert!(!is_small_formatting_command(&shlex_split_safe(
794 "tail -n 30 file.txt"
795 )));
796 assert!(is_small_formatting_command(&shlex_split_safe("tail -c 30")));
798 assert!(is_small_formatting_command(&shlex_split_safe(
799 "tail -c +10"
800 )));
801 assert!(!is_small_formatting_command(&vec_str(&[
803 "tail", "file.txt"
804 ])));
805 }
806
807 #[test]
808 fn sed_behavior() {
809 assert!(is_small_formatting_command(&vec_str(&["sed"])));
811 assert!(is_small_formatting_command(&vec_str(&["sed", "-n", "10p"])));
813 assert!(!is_small_formatting_command(&shlex_split_safe(
815 "sed -n 10p file.txt"
816 )));
817 assert!(!is_small_formatting_command(&shlex_split_safe(
818 "sed -n -e 10p file.txt"
819 )));
820 assert!(!is_small_formatting_command(&shlex_split_safe(
821 "sed -n 10p -- file.txt"
822 )));
823 assert!(!is_small_formatting_command(&shlex_split_safe(
824 "sed -n 1,200p file.txt"
825 )));
826 assert!(is_small_formatting_command(&shlex_split_safe(
828 "sed -n p file.txt"
829 )));
830 assert!(is_small_formatting_command(&shlex_split_safe(
831 "sed -n +10p file.txt"
832 )));
833 }
834
835 #[test]
836 fn empty_tokens_is_not_small() {
837 let empty: Vec<String> = Vec::new();
838 assert!(!is_small_formatting_command(&empty));
839 }
840
841 #[test]
842 fn supports_nl_then_sed_reading() {
843 let inner = "nl -ba core/src/parse_command.rs | sed -n '1200,1720p'";
844 assert_parsed(
845 &vec_str(&["bash", "-lc", inner]),
846 vec![ParsedCommand::Read {
847 cmd: inner.to_string(),
848 name: "parse_command.rs".to_string(),
849 path: PathBuf::from("core/src/parse_command.rs"),
850 }],
851 );
852 }
853
854 #[test]
855 fn supports_sed_n() {
856 let inner = "sed -n '2000,2200p' tui/src/history_cell.rs";
857 assert_parsed(
858 &vec_str(&["bash", "-lc", inner]),
859 vec![ParsedCommand::Read {
860 cmd: inner.to_string(),
861 name: "history_cell.rs".to_string(),
862 path: PathBuf::from("tui/src/history_cell.rs"),
863 }],
864 );
865 }
866
867 #[test]
868 fn supports_awk_with_file() {
869 let inner = "awk '{print $1}' Cargo.toml";
870 assert_parsed(
871 &vec_str(&["bash", "-lc", inner]),
872 vec![ParsedCommand::Read {
873 cmd: inner.to_string(),
874 name: "Cargo.toml".to_string(),
875 path: PathBuf::from("Cargo.toml"),
876 }],
877 );
878 }
879
880 #[test]
881 fn filters_out_printf() {
882 let inner =
883 r#"printf "\n===== ansi-escape/Cargo.toml =====\n"; cat -- ansi-escape/Cargo.toml"#;
884 assert_parsed(
885 &vec_str(&["bash", "-lc", inner]),
886 vec![ParsedCommand::Read {
887 cmd: "cat -- ansi-escape/Cargo.toml".to_string(),
888 name: "Cargo.toml".to_string(),
889 path: PathBuf::from("ansi-escape/Cargo.toml"),
890 }],
891 );
892 }
893
894 #[test]
895 fn drops_yes_in_pipelines() {
896 let inner = "yes | rg --files";
898 assert_parsed(
899 &vec_str(&["bash", "-lc", inner]),
900 vec![ParsedCommand::ListFiles {
901 cmd: "rg --files".to_string(),
902 path: None,
903 }],
904 );
905 }
906
907 #[test]
908 fn supports_sed_n_then_nl_as_search() {
909 let args = shlex_split_safe(
911 "sed -n '260,640p' exec/src/event_processor_with_human_output.rs | nl -ba",
912 );
913 assert_parsed(
914 &args,
915 vec![ParsedCommand::Read {
916 cmd: "sed -n '260,640p' exec/src/event_processor_with_human_output.rs".to_string(),
917 name: "event_processor_with_human_output.rs".to_string(),
918 path: PathBuf::from("exec/src/event_processor_with_human_output.rs"),
919 }],
920 );
921 }
922
923 #[test]
924 fn preserves_rg_with_spaces() {
925 assert_parsed(
926 &shlex_split_safe("yes | rg -n 'foo bar' -S"),
927 vec![ParsedCommand::Search {
928 cmd: "rg -n 'foo bar' -S".to_string(),
929 query: Some("foo bar".to_string()),
930 path: None,
931 }],
932 );
933 }
934
935 #[test]
936 fn ls_with_glob() {
937 assert_parsed(
938 &shlex_split_safe("ls -I '*.test.js'"),
939 vec![ParsedCommand::ListFiles {
940 cmd: "ls -I '*.test.js'".to_string(),
941 path: None,
942 }],
943 );
944 }
945
946 #[test]
947 fn strips_true_in_sequence() {
948 assert_parsed(
950 &shlex_split_safe("true && rg --files"),
951 vec![ParsedCommand::ListFiles {
952 cmd: "rg --files".to_string(),
953 path: None,
954 }],
955 );
956
957 assert_parsed(
958 &shlex_split_safe("rg --files && true"),
959 vec![ParsedCommand::ListFiles {
960 cmd: "rg --files".to_string(),
961 path: None,
962 }],
963 );
964 }
965
966 #[test]
967 fn strips_true_inside_bash_lc() {
968 let inner = "true && rg --files";
969 assert_parsed(
970 &vec_str(&["bash", "-lc", inner]),
971 vec![ParsedCommand::ListFiles {
972 cmd: "rg --files".to_string(),
973 path: None,
974 }],
975 );
976
977 let inner2 = "rg --files || true";
978 assert_parsed(
979 &vec_str(&["bash", "-lc", inner2]),
980 vec![ParsedCommand::ListFiles {
981 cmd: "rg --files".to_string(),
982 path: None,
983 }],
984 );
985 }
986
987 #[test]
988 fn shorten_path_on_windows() {
989 assert_parsed(
990 &shlex_split_safe(r#"cat "pkg\src\main.rs""#),
991 vec![ParsedCommand::Read {
992 cmd: r#"cat "pkg\\src\\main.rs""#.to_string(),
993 name: "main.rs".to_string(),
994 path: PathBuf::from(r#"pkg\src\main.rs"#),
995 }],
996 );
997 }
998
999 #[test]
1000 fn head_with_no_space() {
1001 assert_parsed(
1002 &shlex_split_safe("bash -lc 'head -n50 Cargo.toml'"),
1003 vec![ParsedCommand::Read {
1004 cmd: "head -n50 Cargo.toml".to_string(),
1005 name: "Cargo.toml".to_string(),
1006 path: PathBuf::from("Cargo.toml"),
1007 }],
1008 );
1009 }
1010
1011 #[test]
1012 fn bash_dash_c_pipeline_parsing() {
1013 let inner = "rg --files | head -n 1";
1015 assert_parsed(
1016 &vec_str(&["bash", "-c", inner]),
1017 vec![ParsedCommand::ListFiles {
1018 cmd: "rg --files".to_string(),
1019 path: None,
1020 }],
1021 );
1022 }
1023
1024 #[test]
1025 fn tail_with_no_space() {
1026 assert_parsed(
1027 &shlex_split_safe("bash -lc 'tail -n+10 README.md'"),
1028 vec![ParsedCommand::Read {
1029 cmd: "tail -n+10 README.md".to_string(),
1030 name: "README.md".to_string(),
1031 path: PathBuf::from("README.md"),
1032 }],
1033 );
1034 }
1035
1036 #[test]
1037 fn grep_with_query_and_path() {
1038 assert_parsed(
1039 &shlex_split_safe("grep -R TODO src"),
1040 vec![ParsedCommand::Search {
1041 cmd: "grep -R TODO src".to_string(),
1042 query: Some("TODO".to_string()),
1043 path: Some("src".to_string()),
1044 }],
1045 );
1046 }
1047
1048 #[test]
1049 fn supports_ag_ack_pt_rga() {
1050 assert_parsed(
1051 &shlex_split_safe("ag TODO src"),
1052 vec![ParsedCommand::Search {
1053 cmd: "ag TODO src".to_string(),
1054 query: Some("TODO".to_string()),
1055 path: Some("src".to_string()),
1056 }],
1057 );
1058 assert_parsed(
1059 &shlex_split_safe("ack TODO src"),
1060 vec![ParsedCommand::Search {
1061 cmd: "ack TODO src".to_string(),
1062 query: Some("TODO".to_string()),
1063 path: Some("src".to_string()),
1064 }],
1065 );
1066 assert_parsed(
1067 &shlex_split_safe("pt TODO src"),
1068 vec![ParsedCommand::Search {
1069 cmd: "pt TODO src".to_string(),
1070 query: Some("TODO".to_string()),
1071 path: Some("src".to_string()),
1072 }],
1073 );
1074 assert_parsed(
1075 &shlex_split_safe("rga TODO src"),
1076 vec![ParsedCommand::Search {
1077 cmd: "rga TODO src".to_string(),
1078 query: Some("TODO".to_string()),
1079 path: Some("src".to_string()),
1080 }],
1081 );
1082 }
1083
1084 #[test]
1085 fn ag_ack_pt_files_with_matches_flags_are_search() {
1086 assert_parsed(
1087 &shlex_split_safe("ag -l TODO src"),
1088 vec![ParsedCommand::Search {
1089 cmd: "ag -l TODO src".to_string(),
1090 query: Some("TODO".to_string()),
1091 path: Some("src".to_string()),
1092 }],
1093 );
1094 assert_parsed(
1095 &shlex_split_safe("ack -l TODO src"),
1096 vec![ParsedCommand::Search {
1097 cmd: "ack -l TODO src".to_string(),
1098 query: Some("TODO".to_string()),
1099 path: Some("src".to_string()),
1100 }],
1101 );
1102 assert_parsed(
1103 &shlex_split_safe("pt -l TODO src"),
1104 vec![ParsedCommand::Search {
1105 cmd: "pt -l TODO src".to_string(),
1106 query: Some("TODO".to_string()),
1107 path: Some("src".to_string()),
1108 }],
1109 );
1110 }
1111
1112 #[test]
1113 fn rg_with_equals_style_flags() {
1114 assert_parsed(
1115 &shlex_split_safe("rg --colors=never -n foo src"),
1116 vec![ParsedCommand::Search {
1117 cmd: "rg '--colors=never' -n foo src".to_string(),
1118 query: Some("foo".to_string()),
1119 path: Some("src".to_string()),
1120 }],
1121 );
1122 }
1123
1124 #[test]
1125 fn cat_with_double_dash_and_sed_ranges() {
1126 assert_parsed(
1128 &shlex_split_safe("cat -- ./-strange-file-name"),
1129 vec![ParsedCommand::Read {
1130 cmd: "cat -- ./-strange-file-name".to_string(),
1131 name: "-strange-file-name".to_string(),
1132 path: PathBuf::from("./-strange-file-name"),
1133 }],
1134 );
1135
1136 assert_parsed(
1138 &shlex_split_safe("sed -n '12,20p' Cargo.toml"),
1139 vec![ParsedCommand::Read {
1140 cmd: "sed -n '12,20p' Cargo.toml".to_string(),
1141 name: "Cargo.toml".to_string(),
1142 path: PathBuf::from("Cargo.toml"),
1143 }],
1144 );
1145 }
1146
1147 #[test]
1148 fn drop_trailing_nl_in_pipeline() {
1149 assert_parsed(
1151 &shlex_split_safe("rg --files | nl -ba"),
1152 vec![ParsedCommand::ListFiles {
1153 cmd: "rg --files".to_string(),
1154 path: None,
1155 }],
1156 );
1157 }
1158
1159 #[test]
1160 fn ls_with_time_style_and_path() {
1161 assert_parsed(
1162 &shlex_split_safe("ls --time-style=long-iso ./dist"),
1163 vec![ParsedCommand::ListFiles {
1164 cmd: "ls '--time-style=long-iso' ./dist".to_string(),
1165 path: Some(".".to_string()),
1167 }],
1168 );
1169 }
1170
1171 #[test]
1172 fn fd_file_finder_variants() {
1173 assert_parsed(
1174 &shlex_split_safe("fd -t f src/"),
1175 vec![ParsedCommand::ListFiles {
1176 cmd: "fd -t f src/".to_string(),
1177 path: Some("src".to_string()),
1178 }],
1179 );
1180
1181 assert_parsed(
1183 &shlex_split_safe("fd main src"),
1184 vec![ParsedCommand::Search {
1185 cmd: "fd main src".to_string(),
1186 query: Some("main".to_string()),
1187 path: Some("src".to_string()),
1188 }],
1189 );
1190 }
1191
1192 #[test]
1193 fn find_basic_name_filter() {
1194 assert_parsed(
1195 &shlex_split_safe("find . -name '*.rs'"),
1196 vec![ParsedCommand::Search {
1197 cmd: "find . -name '*.rs'".to_string(),
1198 query: Some("*.rs".to_string()),
1199 path: Some(".".to_string()),
1200 }],
1201 );
1202 }
1203
1204 #[test]
1205 fn find_type_only_path() {
1206 assert_parsed(
1207 &shlex_split_safe("find src -type f"),
1208 vec![ParsedCommand::ListFiles {
1209 cmd: "find src -type f".to_string(),
1210 path: Some("src".to_string()),
1211 }],
1212 );
1213 }
1214
1215 #[test]
1216 fn bin_bash_lc_sed() {
1217 assert_parsed(
1218 &shlex_split_safe("/bin/bash -lc 'sed -n '1,10p' Cargo.toml'"),
1219 vec![ParsedCommand::Read {
1220 cmd: "sed -n '1,10p' Cargo.toml".to_string(),
1221 name: "Cargo.toml".to_string(),
1222 path: PathBuf::from("Cargo.toml"),
1223 }],
1224 );
1225 }
1226 #[test]
1227 fn bin_zsh_lc_sed() {
1228 assert_parsed(
1229 &shlex_split_safe("/bin/zsh -lc 'sed -n '1,10p' Cargo.toml'"),
1230 vec![ParsedCommand::Read {
1231 cmd: "sed -n '1,10p' Cargo.toml".to_string(),
1232 name: "Cargo.toml".to_string(),
1233 path: PathBuf::from("Cargo.toml"),
1234 }],
1235 );
1236 }
1237
1238 #[test]
1239 fn powershell_command_is_stripped() {
1240 assert_parsed(
1241 &vec_str(&["powershell", "-Command", "Get-ChildItem"]),
1242 vec![ParsedCommand::Unknown {
1243 cmd: "Get-ChildItem".to_string(),
1244 }],
1245 );
1246 }
1247
1248 #[test]
1249 fn pwsh_with_noprofile_and_c_alias_is_stripped() {
1250 assert_parsed(
1251 &vec_str(&["pwsh", "-NoProfile", "-c", "Write-Host hi"]),
1252 vec![ParsedCommand::Unknown {
1253 cmd: "Write-Host hi".to_string(),
1254 }],
1255 );
1256 }
1257
1258 #[test]
1259 fn powershell_with_path_is_stripped() {
1260 let command = if cfg!(windows) {
1261 "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
1262 } else {
1263 "/usr/local/bin/powershell.exe"
1264 };
1265
1266 assert_parsed(
1267 &vec_str(&[command, "-NoProfile", "-c", "Write-Host hi"]),
1268 vec![ParsedCommand::Unknown {
1269 cmd: "Write-Host hi".to_string(),
1270 }],
1271 );
1272 }
1273}
1274
1275pub fn parse_command_impl(command: &[String]) -> Vec<ParsedCommand> {
1276 if let Some(commands) = parse_shell_lc_commands(command) {
1277 return commands;
1278 }
1279
1280 if let Some((_, script)) = extract_powershell_command(command) {
1281 return vec![ParsedCommand::Unknown {
1282 cmd: script.to_string(),
1283 }];
1284 }
1285
1286 let normalized = normalize_tokens(command);
1287
1288 let parts = if contains_connectors(&normalized) {
1289 split_on_connectors(&normalized)
1290 } else {
1291 vec![normalized]
1292 };
1293
1294 let mut commands: Vec<ParsedCommand> = Vec::new();
1299 let mut cwd: Option<String> = None;
1300 for tokens in &parts {
1301 if let Some((head, tail)) = tokens.split_first()
1302 && head == "cd"
1303 {
1304 if let Some(dir) = cd_target(tail) {
1305 cwd = Some(match &cwd {
1306 Some(base) => join_paths(base, &dir),
1307 None => dir.clone(),
1308 });
1309 }
1310 continue;
1311 }
1312 let parsed = summarize_main_tokens(tokens);
1313 let parsed = match parsed {
1314 ParsedCommand::Read { cmd, name, path } => {
1315 if let Some(base) = &cwd {
1316 let full = join_paths(base, &path.to_string_lossy());
1317 ParsedCommand::Read {
1318 cmd,
1319 name,
1320 path: PathBuf::from(full),
1321 }
1322 } else {
1323 ParsedCommand::Read { cmd, name, path }
1324 }
1325 }
1326 other => other,
1327 };
1328 commands.push(parsed);
1329 }
1330
1331 while let Some(next) = simplify_once(&commands) {
1332 commands = next;
1333 }
1334
1335 commands
1336}
1337
1338fn simplify_once(commands: &[ParsedCommand]) -> Option<Vec<ParsedCommand>> {
1339 if commands.len() <= 1 {
1340 return None;
1341 }
1342
1343 if let ParsedCommand::Unknown { cmd } = &commands[0]
1345 && shlex_split(cmd).is_some_and(|t| t.first().map(String::as_str) == Some("echo"))
1346 {
1347 return Some(commands[1..].to_vec());
1348 }
1349
1350 if let Some(idx) = commands.iter().position(|pc| match pc {
1352 ParsedCommand::Unknown { cmd } => {
1353 shlex_split(cmd).is_some_and(|t| t.first().map(String::as_str) == Some("cd"))
1354 }
1355 _ => false,
1356 }) && commands.len() > idx + 1
1357 {
1358 let mut out = Vec::with_capacity(commands.len() - 1);
1359 out.extend_from_slice(&commands[..idx]);
1360 out.extend_from_slice(&commands[idx + 1..]);
1361 return Some(out);
1362 }
1363
1364 if let Some(idx) = commands
1366 .iter()
1367 .position(|pc| matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"))
1368 {
1369 let mut out = Vec::with_capacity(commands.len() - 1);
1370 out.extend_from_slice(&commands[..idx]);
1371 out.extend_from_slice(&commands[idx + 1..]);
1372 return Some(out);
1373 }
1374
1375 if let Some(idx) = commands.iter().position(|pc| match pc {
1377 ParsedCommand::Unknown { cmd } => {
1378 if let Some(tokens) = shlex_split(cmd) {
1379 tokens.first().is_some_and(|s| s.as_str() == "nl")
1380 && tokens.iter().skip(1).all(|t| t.starts_with('-'))
1381 } else {
1382 false
1383 }
1384 }
1385 _ => false,
1386 }) {
1387 let mut out = Vec::with_capacity(commands.len() - 1);
1388 out.extend_from_slice(&commands[..idx]);
1389 out.extend_from_slice(&commands[idx + 1..]);
1390 return Some(out);
1391 }
1392
1393 None
1394}
1395
1396fn is_valid_sed_n_arg(arg: Option<&str>) -> bool {
1398 let s = match arg {
1399 Some(s) => s,
1400 None => return false,
1401 };
1402 let core = match s.strip_suffix('p') {
1403 Some(rest) => rest,
1404 None => return false,
1405 };
1406 let parts: Vec<&str> = core.split(',').collect();
1407 match parts.as_slice() {
1408 [num] => !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()),
1409 [a, b] => {
1410 !a.is_empty()
1411 && !b.is_empty()
1412 && a.chars().all(|c| c.is_ascii_digit())
1413 && b.chars().all(|c| c.is_ascii_digit())
1414 }
1415 _ => false,
1416 }
1417}
1418
1419fn sed_read_path(args: &[String]) -> Option<String> {
1420 let args_no_connector = trim_at_connector(args);
1421 if !args_no_connector.iter().any(|arg| arg == "-n") {
1422 return None;
1423 }
1424 let mut has_range_script = false;
1425 let mut i = 0;
1426 while i < args_no_connector.len() {
1427 let arg = &args_no_connector[i];
1428 if matches!(arg.as_str(), "-e" | "--expression") {
1429 if is_valid_sed_n_arg(args_no_connector.get(i + 1).map(String::as_str)) {
1430 has_range_script = true;
1431 }
1432 i += 2;
1433 continue;
1434 }
1435 if matches!(arg.as_str(), "-f" | "--file") {
1436 i += 2;
1437 continue;
1438 }
1439 i += 1;
1440 }
1441 if !has_range_script {
1442 has_range_script = args_no_connector
1443 .iter()
1444 .any(|arg| !arg.starts_with('-') && is_valid_sed_n_arg(Some(arg)));
1445 }
1446 if !has_range_script {
1447 return None;
1448 }
1449 let candidates = skip_flag_values(&args_no_connector, &["-e", "-f", "--expression", "--file"]);
1450 let non_flags: Vec<String> = candidates
1451 .into_iter()
1452 .filter(|arg| !arg.starts_with('-'))
1453 .cloned()
1454 .collect();
1455 match non_flags.as_slice() {
1456 [] => None,
1457 [first, rest @ ..] if is_valid_sed_n_arg(Some(first)) => rest.first().cloned(),
1458 [first, ..] => Some(first.clone()),
1459 }
1460}
1461
1462fn normalize_tokens(cmd: &[String]) -> Vec<String> {
1466 match cmd {
1467 [first, pipe, rest @ ..] if (first == "yes" || first == "y") && pipe == "|" => {
1468 rest.to_vec()
1470 }
1471 [first, pipe, rest @ ..] if (first == "no" || first == "n") && pipe == "|" => {
1472 rest.to_vec()
1474 }
1475 [shell, flag, script]
1476 if (shell == "bash" || shell == "zsh") && (flag == "-c" || flag == "-lc") =>
1477 {
1478 shlex_split(script).unwrap_or_else(|| vec![shell.clone(), flag.clone(), script.clone()])
1479 }
1480 _ => cmd.to_vec(),
1481 }
1482}
1483
1484fn contains_connectors(tokens: &[String]) -> bool {
1485 tokens
1486 .iter()
1487 .any(|t| t == "&&" || t == "||" || t == "|" || t == ";")
1488}
1489
1490fn split_on_connectors(tokens: &[String]) -> Vec<Vec<String>> {
1491 let mut out: Vec<Vec<String>> = Vec::new();
1492 let mut cur: Vec<String> = Vec::new();
1493 for t in tokens {
1494 if t == "&&" || t == "||" || t == "|" || t == ";" {
1495 if !cur.is_empty() {
1496 out.push(std::mem::take(&mut cur));
1497 }
1498 } else {
1499 cur.push(t.clone());
1500 }
1501 }
1502 if !cur.is_empty() {
1503 out.push(cur);
1504 }
1505 out
1506}
1507
1508fn trim_at_connector(tokens: &[String]) -> Vec<String> {
1509 let idx = tokens
1510 .iter()
1511 .position(|t| t == "|" || t == "&&" || t == "||" || t == ";")
1512 .unwrap_or(tokens.len());
1513 tokens[..idx].to_vec()
1514}
1515
1516fn short_display_path(path: &str) -> String {
1522 let normalized = path.replace('\\', "/");
1524 let trimmed = normalized.trim_end_matches('/');
1525 let mut parts = trimmed.split('/').rev().filter(|p| {
1526 !p.is_empty() && *p != "build" && *p != "dist" && *p != "node_modules" && *p != "src"
1527 });
1528 parts
1529 .next()
1530 .map(str::to_string)
1531 .unwrap_or_else(|| trimmed.to_string())
1532}
1533
1534fn skip_flag_values<'a>(args: &'a [String], flags_with_vals: &[&str]) -> Vec<&'a String> {
1536 let mut out: Vec<&'a String> = Vec::new();
1537 let mut skip_next = false;
1538 for (i, a) in args.iter().enumerate() {
1539 if skip_next {
1540 skip_next = false;
1541 continue;
1542 }
1543 if a == "--" {
1544 for rest in &args[i + 1..] {
1546 out.push(rest);
1547 }
1548 break;
1549 }
1550 if a.starts_with("--") && a.contains('=') {
1551 continue;
1553 }
1554 if flags_with_vals.contains(&a.as_str()) {
1555 if i + 1 < args.len() {
1557 skip_next = true;
1558 }
1559 continue;
1560 }
1561 out.push(a);
1562 }
1563 out
1564}
1565
1566fn first_non_flag_operand(args: &[String], flags_with_vals: &[&str]) -> Option<String> {
1567 positional_operands(args, flags_with_vals)
1568 .into_iter()
1569 .next()
1570 .cloned()
1571}
1572
1573fn single_non_flag_operand(args: &[String], flags_with_vals: &[&str]) -> Option<String> {
1574 let mut operands = positional_operands(args, flags_with_vals).into_iter();
1575 let first = operands.next()?;
1576 if operands.next().is_some() {
1577 return None;
1578 }
1579 Some(first.clone())
1580}
1581
1582fn positional_operands<'a>(args: &'a [String], flags_with_vals: &[&str]) -> Vec<&'a String> {
1583 let mut out = Vec::new();
1584 let mut after_double_dash = false;
1585 let mut skip_next = false;
1586 for (i, arg) in args.iter().enumerate() {
1587 if skip_next {
1588 skip_next = false;
1589 continue;
1590 }
1591 if after_double_dash {
1592 out.push(arg);
1593 continue;
1594 }
1595 if arg == "--" {
1596 after_double_dash = true;
1597 continue;
1598 }
1599 if arg.starts_with("--") && arg.contains('=') {
1600 continue;
1601 }
1602 if flags_with_vals.contains(&arg.as_str()) {
1603 if i + 1 < args.len() {
1604 skip_next = true;
1605 }
1606 continue;
1607 }
1608 if arg.starts_with('-') {
1609 continue;
1610 }
1611 out.push(arg);
1612 }
1613 out
1614}
1615
1616fn parse_grep_like(main_cmd: &[String], args: &[String]) -> ParsedCommand {
1617 let args_no_connector = trim_at_connector(args);
1618 let mut operands = Vec::new();
1619 let mut pattern: Option<String> = None;
1620 let mut after_double_dash = false;
1621 let mut iter = args_no_connector.iter().peekable();
1622 while let Some(arg) = iter.next() {
1623 if after_double_dash {
1624 operands.push(arg);
1625 continue;
1626 }
1627 if arg == "--" {
1628 after_double_dash = true;
1629 continue;
1630 }
1631 match arg.as_str() {
1632 "-e" | "--regexp" => {
1633 if let Some(pat) = iter.next()
1634 && pattern.is_none()
1635 {
1636 pattern = Some(pat.clone());
1637 }
1638 continue;
1639 }
1640 "-f" | "--file" => {
1641 if let Some(pat_file) = iter.next()
1642 && pattern.is_none()
1643 {
1644 pattern = Some(pat_file.clone());
1645 }
1646 continue;
1647 }
1648 "-m" | "--max-count" | "-C" | "--context" | "-A" | "--after-context" | "-B"
1649 | "--before-context" => {
1650 iter.next();
1651 continue;
1652 }
1653 _ => {}
1654 }
1655 if arg.starts_with('-') {
1656 continue;
1657 }
1658 operands.push(arg);
1659 }
1660 let has_pattern = pattern.is_some();
1663 let query = pattern.or_else(|| operands.first().cloned().map(String::from));
1664 let path_index = if has_pattern { 0 } else { 1 };
1665 let path = operands.get(path_index).map(|s| short_display_path(s));
1666 ParsedCommand::Search {
1667 cmd: shlex_join(main_cmd),
1668 query,
1669 path,
1670 }
1671}
1672
1673fn awk_data_file_operand(args: &[String]) -> Option<String> {
1674 if args.is_empty() {
1675 return None;
1676 }
1677 let args_no_connector = trim_at_connector(args);
1678 let has_script_file = args_no_connector
1679 .iter()
1680 .any(|arg| arg == "-f" || arg == "--file");
1681 let candidates = skip_flag_values(
1682 &args_no_connector,
1683 &["-F", "-v", "-f", "--field-separator", "--assign", "--file"],
1684 );
1685 let non_flags: Vec<&String> = candidates
1686 .into_iter()
1687 .filter(|arg| !arg.starts_with('-'))
1688 .collect();
1689 if has_script_file {
1690 return non_flags.first().cloned().cloned();
1691 }
1692 if non_flags.len() >= 2 {
1693 return Some(non_flags[1].clone());
1694 }
1695 None
1696}
1697
1698fn python_walks_files(args: &[String]) -> bool {
1699 let args_no_connector = trim_at_connector(args);
1700 let mut iter = args_no_connector.iter();
1701 while let Some(arg) = iter.next() {
1702 if arg == "-c"
1703 && let Some(script) = iter.next()
1704 {
1705 return script.contains("os.walk")
1706 || script.contains("os.listdir")
1707 || script.contains("os.scandir")
1708 || script.contains("glob.glob")
1709 || script.contains("glob.iglob")
1710 || script.contains("pathlib.Path")
1711 || script.contains(".rglob(");
1712 }
1713 }
1714 false
1715}
1716
1717fn is_python_command(cmd: &str) -> bool {
1718 cmd == "python"
1719 || cmd == "python2"
1720 || cmd == "python3"
1721 || cmd.starts_with("python2.")
1722 || cmd.starts_with("python3.")
1723}
1724
1725fn cd_target(args: &[String]) -> Option<String> {
1726 if args.is_empty() {
1727 return None;
1728 }
1729 let mut i = 0;
1730 let mut target: Option<String> = None;
1731 while i < args.len() {
1732 let arg = &args[i];
1733 if arg == "--" {
1734 return args.get(i + 1).cloned();
1735 }
1736 if matches!(arg.as_str(), "-L" | "-P") {
1737 i += 1;
1738 continue;
1739 }
1740 if arg.starts_with('-') {
1741 i += 1;
1742 continue;
1743 }
1744 target = Some(arg.clone());
1745 i += 1;
1746 }
1747 target
1748}
1749
1750fn is_pathish(s: &str) -> bool {
1751 s == "."
1752 || s == ".."
1753 || s.starts_with("./")
1754 || s.starts_with("../")
1755 || s.contains('/')
1756 || s.contains('\\')
1757}
1758
1759fn parse_fd_query_and_path(tail: &[String]) -> (Option<String>, Option<String>) {
1760 let args_no_connector = trim_at_connector(tail);
1761 let candidates = skip_flag_values(
1764 &args_no_connector,
1765 &[
1766 "-t",
1767 "--type",
1768 "-e",
1769 "--extension",
1770 "-E",
1771 "--exclude",
1772 "--search-path",
1773 ],
1774 );
1775 let non_flags: Vec<&String> = candidates
1776 .into_iter()
1777 .filter(|p| !p.starts_with('-'))
1778 .collect();
1779 match non_flags.as_slice() {
1780 [one] => {
1781 if is_pathish(one) {
1782 (None, Some(short_display_path(one)))
1783 } else {
1784 (Some((*one).clone()), None)
1785 }
1786 }
1787 [q, p, ..] => (Some((*q).clone()), Some(short_display_path(p))),
1788 _ => (None, None),
1789 }
1790}
1791
1792fn parse_find_query_and_path(tail: &[String]) -> (Option<String>, Option<String>) {
1793 let args_no_connector = trim_at_connector(tail);
1794 let mut path: Option<String> = None;
1796 for a in &args_no_connector {
1797 if !a.starts_with('-') && *a != "!" && *a != "(" && *a != ")" {
1798 path = Some(short_display_path(a));
1799 break;
1800 }
1801 }
1802 let mut query: Option<String> = None;
1804 let mut i = 0;
1805 while i < args_no_connector.len() {
1806 let a = &args_no_connector[i];
1807 if a == "-name" || a == "-iname" || a == "-path" || a == "-regex" {
1808 if i + 1 < args_no_connector.len() {
1809 query = Some(args_no_connector[i + 1].clone());
1810 }
1811 break;
1812 }
1813 i += 1;
1814 }
1815 (query, path)
1816}
1817
1818fn parse_shell_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
1819 let (_, script) = extract_bash_command(original)?;
1821 Some(parse_shell_script(script))
1822}
1823
1824pub fn parse_shell_script(script: &str) -> Vec<ParsedCommand> {
1826 if let Some(tree) = try_parse_shell(script)
1827 && let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script)
1828 && !all_commands.is_empty()
1829 {
1830 let script_tokens = shlex_split(script).unwrap_or_else(|| vec![script.to_string()]);
1831 let had_multiple_commands = all_commands.len() > 1;
1835 let filtered_commands = drop_small_formatting_commands(all_commands);
1837 if filtered_commands.is_empty() {
1838 return vec![ParsedCommand::Unknown {
1839 cmd: script.to_string(),
1840 }];
1841 }
1842 let mut commands: Vec<ParsedCommand> = Vec::new();
1844 let mut cwd: Option<String> = None;
1845 for tokens in filtered_commands.into_iter() {
1846 if let Some((head, tail)) = tokens.split_first()
1847 && head == "cd"
1848 {
1849 if let Some(dir) = cd_target(tail) {
1850 cwd = Some(match &cwd {
1851 Some(base) => join_paths(base, &dir),
1852 None => dir.clone(),
1853 });
1854 }
1855 continue;
1856 }
1857 let parsed = summarize_main_tokens(&tokens);
1858 let parsed = match parsed {
1859 ParsedCommand::Read { cmd, name, path } => {
1860 if let Some(base) = &cwd {
1861 let full = join_paths(base, &path.to_string_lossy());
1862 ParsedCommand::Read {
1863 cmd,
1864 name,
1865 path: PathBuf::from(full),
1866 }
1867 } else {
1868 ParsedCommand::Read { cmd, name, path }
1869 }
1870 }
1871 other => other,
1872 };
1873 commands.push(parsed);
1874 }
1875
1876 if commands.len() > 1 {
1877 commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"));
1878 while let Some(next) = simplify_once(&commands) {
1880 commands = next;
1881 }
1882 }
1883 if commands.len() == 1 {
1884 let had_connectors = had_multiple_commands
1889 || script_tokens
1890 .iter()
1891 .any(|t| t == "|" || t == "&&" || t == "||" || t == ";");
1892 commands = commands
1893 .into_iter()
1894 .map(|pc| match pc {
1895 ParsedCommand::Read { name, cmd, path } => {
1896 if had_connectors {
1897 let has_pipe = script_tokens.iter().any(|t| t == "|");
1898 let has_sed_n = script_tokens.windows(2).any(|w| {
1899 w.first().map(String::as_str) == Some("sed")
1900 && w.get(1).map(String::as_str) == Some("-n")
1901 });
1902 if has_pipe && has_sed_n {
1903 ParsedCommand::Read {
1904 cmd: script.to_string(),
1905 name,
1906 path,
1907 }
1908 } else {
1909 ParsedCommand::Read { cmd, name, path }
1910 }
1911 } else {
1912 ParsedCommand::Read {
1913 cmd: shlex_join(&script_tokens),
1914 name,
1915 path,
1916 }
1917 }
1918 }
1919 ParsedCommand::ListFiles { path, cmd, .. } => {
1920 if had_connectors {
1921 ParsedCommand::ListFiles { cmd, path }
1922 } else {
1923 ParsedCommand::ListFiles {
1924 cmd: shlex_join(&script_tokens),
1925 path,
1926 }
1927 }
1928 }
1929 ParsedCommand::Search {
1930 query, path, cmd, ..
1931 } => {
1932 if had_connectors {
1933 ParsedCommand::Search { cmd, query, path }
1934 } else {
1935 ParsedCommand::Search {
1936 cmd: shlex_join(&script_tokens),
1937 query,
1938 path,
1939 }
1940 }
1941 }
1942 other => other,
1943 })
1944 .collect();
1945 }
1946 return commands;
1947 }
1948 vec![ParsedCommand::Unknown {
1949 cmd: script.to_string(),
1950 }]
1951}
1952
1953fn is_small_formatting_command(tokens: &[String]) -> bool {
1957 if tokens.is_empty() {
1958 return false;
1959 }
1960 let cmd = tokens[0].as_str();
1961 match cmd {
1962 "wc" | "tr" | "cut" | "sort" | "uniq" | "tee" | "column" | "yes" | "printf" => true,
1965 "xargs" => !is_mutating_xargs_command(tokens),
1966 "awk" => awk_data_file_operand(&tokens[1..]).is_none(),
1967 "head" => {
1968 match tokens {
1972 [_] => true,
1974 [_, arg] => arg.starts_with('-'),
1976 [_, flag, count]
1978 if (flag == "-n" || flag == "-c")
1979 && count.chars().all(|c| c.is_ascii_digit()) =>
1980 {
1981 true
1982 }
1983 _ => false,
1984 }
1985 }
1986 "tail" => {
1987 match tokens {
1991 [_] => true,
1993 [_, arg] => arg.starts_with('-'),
1995 [_, flag, count]
1997 if flag == "-n"
1998 && (count.chars().all(|c| c.is_ascii_digit())
1999 || (count.starts_with('+')
2000 && count[1..].chars().all(|c| c.is_ascii_digit()))) =>
2001 {
2002 true
2003 }
2004 [_, flag, count]
2006 if flag == "-c"
2007 && (count.chars().all(|c| c.is_ascii_digit())
2008 || (count.starts_with('+')
2009 && count[1..].chars().all(|c| c.is_ascii_digit()))) =>
2010 {
2011 true
2012 }
2013 _ => false,
2014 }
2015 }
2016 "sed" => {
2017 sed_read_path(&tokens[1..]).is_none()
2020 }
2021 _ => false,
2022 }
2023}
2024
2025fn is_mutating_xargs_command(tokens: &[String]) -> bool {
2026 xargs_subcommand(tokens).is_some_and(xargs_is_mutating_subcommand)
2027}
2028
2029fn xargs_subcommand(tokens: &[String]) -> Option<&[String]> {
2030 if tokens.first().map(String::as_str) != Some("xargs") {
2031 return None;
2032 }
2033 let mut i = 1;
2034 while i < tokens.len() {
2035 let token = &tokens[i];
2036 if token == "--" {
2037 return tokens.get(i + 1..).filter(|rest| !rest.is_empty());
2038 }
2039 if !token.starts_with('-') {
2040 return tokens.get(i..).filter(|rest| !rest.is_empty());
2041 }
2042 let takes_value = matches!(
2043 token.as_str(),
2044 "-E" | "-e" | "-I" | "-L" | "-n" | "-P" | "-s"
2045 );
2046 if takes_value && token.len() == 2 {
2047 i += 2;
2048 } else {
2049 i += 1;
2050 }
2051 }
2052 None
2053}
2054
2055fn xargs_is_mutating_subcommand(tokens: &[String]) -> bool {
2056 let Some((head, tail)) = tokens.split_first() else {
2057 return false;
2058 };
2059 match head.as_str() {
2060 "perl" | "ruby" => xargs_has_in_place_flag(tail),
2061 "sed" => xargs_has_in_place_flag(tail) || tail.iter().any(|token| token == "--in-place"),
2062 "rg" => tail.iter().any(|token| token == "--replace"),
2063 _ => false,
2064 }
2065}
2066
2067fn xargs_has_in_place_flag(tokens: &[String]) -> bool {
2068 tokens.iter().any(|token| {
2069 token == "-i" || token.starts_with("-i") || token == "-pi" || token.starts_with("-pi")
2070 })
2071}
2072
2073fn drop_small_formatting_commands(mut commands: Vec<Vec<String>>) -> Vec<Vec<String>> {
2074 commands.retain(|tokens| !is_small_formatting_command(tokens));
2075 commands
2076}
2077
2078fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand {
2079 match main_cmd.split_first() {
2080 Some((head, tail)) if matches!(head.as_str(), "ls" | "eza" | "exa") => {
2081 let flags_with_vals: &[&str] = match head.as_str() {
2082 "ls" => &[
2083 "-I",
2084 "-w",
2085 "--block-size",
2086 "--format",
2087 "--time-style",
2088 "--color",
2089 "--quoting-style",
2090 ],
2091 "eza" | "exa" => &[
2092 "-I",
2093 "--ignore-glob",
2094 "--color",
2095 "--sort",
2096 "--time-style",
2097 "--time",
2098 ],
2099 _ => &[],
2100 };
2101 let path =
2102 first_non_flag_operand(tail, flags_with_vals).map(|p| short_display_path(&p));
2103 ParsedCommand::ListFiles {
2104 cmd: shlex_join(main_cmd),
2105 path,
2106 }
2107 }
2108 Some((head, tail)) if head == "tree" => {
2109 let path = first_non_flag_operand(
2110 tail,
2111 &["-L", "-P", "-I", "--charset", "--filelimit", "--sort"],
2112 )
2113 .map(|p| short_display_path(&p));
2114 ParsedCommand::ListFiles {
2115 cmd: shlex_join(main_cmd),
2116 path,
2117 }
2118 }
2119 Some((head, tail)) if head == "du" => {
2120 let path = first_non_flag_operand(
2121 tail,
2122 &[
2123 "-d",
2124 "--max-depth",
2125 "-B",
2126 "--block-size",
2127 "--exclude",
2128 "--time-style",
2129 ],
2130 )
2131 .map(|p| short_display_path(&p));
2132 ParsedCommand::ListFiles {
2133 cmd: shlex_join(main_cmd),
2134 path,
2135 }
2136 }
2137 Some((head, tail)) if head == "rg" || head == "rga" || head == "ripgrep-all" => {
2138 let args_no_connector = trim_at_connector(tail);
2139 let has_files_flag = args_no_connector.iter().any(|a| a == "--files");
2140 let candidates = skip_flag_values(
2141 &args_no_connector,
2142 &[
2143 "-g",
2144 "--glob",
2145 "--iglob",
2146 "-t",
2147 "--type",
2148 "--type-add",
2149 "--type-not",
2150 "-m",
2151 "--max-count",
2152 "-A",
2153 "-B",
2154 "-C",
2155 "--context",
2156 "--max-depth",
2157 ],
2158 );
2159 let non_flags: Vec<&String> = candidates
2160 .into_iter()
2161 .filter(|p| !p.starts_with('-'))
2162 .collect();
2163 if has_files_flag {
2164 let path = non_flags.first().map(|s| short_display_path(s));
2165 ParsedCommand::ListFiles {
2166 cmd: shlex_join(main_cmd),
2167 path,
2168 }
2169 } else {
2170 let query = non_flags.first().cloned().map(String::from);
2171 let path = non_flags.get(1).map(|s| short_display_path(s));
2172 ParsedCommand::Search {
2173 cmd: shlex_join(main_cmd),
2174 query,
2175 path,
2176 }
2177 }
2178 }
2179 Some((head, tail)) if head == "git" => match tail.split_first() {
2180 Some((subcmd, sub_tail)) if subcmd == "grep" => parse_grep_like(main_cmd, sub_tail),
2181 Some((subcmd, sub_tail)) if subcmd == "ls-files" => {
2182 let path = first_non_flag_operand(
2183 sub_tail,
2184 &["--exclude", "--exclude-from", "--pathspec-from-file"],
2185 )
2186 .map(|p| short_display_path(&p));
2187 ParsedCommand::ListFiles {
2188 cmd: shlex_join(main_cmd),
2189 path,
2190 }
2191 }
2192 _ => ParsedCommand::Unknown {
2193 cmd: shlex_join(main_cmd),
2194 },
2195 },
2196 Some((head, tail)) if head == "fd" => {
2197 let (query, path) = parse_fd_query_and_path(tail);
2198 if query.is_some() {
2199 ParsedCommand::Search {
2200 cmd: shlex_join(main_cmd),
2201 query,
2202 path,
2203 }
2204 } else {
2205 ParsedCommand::ListFiles {
2206 cmd: shlex_join(main_cmd),
2207 path,
2208 }
2209 }
2210 }
2211 Some((head, tail)) if head == "find" => {
2212 let (query, path) = parse_find_query_and_path(tail);
2214 if query.is_some() {
2215 ParsedCommand::Search {
2216 cmd: shlex_join(main_cmd),
2217 query,
2218 path,
2219 }
2220 } else {
2221 ParsedCommand::ListFiles {
2222 cmd: shlex_join(main_cmd),
2223 path,
2224 }
2225 }
2226 }
2227 Some((head, tail)) if matches!(head.as_str(), "grep" | "egrep" | "fgrep") => {
2228 parse_grep_like(main_cmd, tail)
2229 }
2230 Some((head, tail)) if matches!(head.as_str(), "ag" | "ack" | "pt") => {
2231 let args_no_connector = trim_at_connector(tail);
2232 let candidates = skip_flag_values(
2233 &args_no_connector,
2234 &[
2235 "-G",
2236 "-g",
2237 "--file-search-regex",
2238 "--ignore-dir",
2239 "--ignore-file",
2240 "--path-to-ignore",
2241 ],
2242 );
2243 let non_flags: Vec<&String> = candidates
2244 .into_iter()
2245 .filter(|p| !p.starts_with('-'))
2246 .collect();
2247 let query = non_flags.first().cloned().map(String::from);
2248 let path = non_flags.get(1).map(|s| short_display_path(s));
2249 ParsedCommand::Search {
2250 cmd: shlex_join(main_cmd),
2251 query,
2252 path,
2253 }
2254 }
2255 Some((head, tail)) if head == "cat" => {
2256 if let Some(path) = single_non_flag_operand(tail, &[]) {
2257 let name = short_display_path(&path);
2258 ParsedCommand::Read {
2259 cmd: shlex_join(main_cmd),
2260 name,
2261 path: PathBuf::from(path),
2262 }
2263 } else {
2264 ParsedCommand::Unknown {
2265 cmd: shlex_join(main_cmd),
2266 }
2267 }
2268 }
2269 Some((head, tail)) if matches!(head.as_str(), "bat" | "batcat") => {
2270 if let Some(path) = single_non_flag_operand(
2271 tail,
2272 &[
2273 "--theme",
2274 "--language",
2275 "--style",
2276 "--terminal-width",
2277 "--tabs",
2278 "--line-range",
2279 "--map-syntax",
2280 ],
2281 ) {
2282 let name = short_display_path(&path);
2283 ParsedCommand::Read {
2284 cmd: shlex_join(main_cmd),
2285 name,
2286 path: PathBuf::from(path),
2287 }
2288 } else {
2289 ParsedCommand::Unknown {
2290 cmd: shlex_join(main_cmd),
2291 }
2292 }
2293 }
2294 Some((head, tail)) if head == "less" => {
2295 if let Some(path) = single_non_flag_operand(
2296 tail,
2297 &[
2298 "-p",
2299 "-P",
2300 "-x",
2301 "-y",
2302 "-z",
2303 "-j",
2304 "--pattern",
2305 "--prompt",
2306 "--tabs",
2307 "--shift",
2308 "--jump-target",
2309 ],
2310 ) {
2311 let name = short_display_path(&path);
2312 ParsedCommand::Read {
2313 cmd: shlex_join(main_cmd),
2314 name,
2315 path: PathBuf::from(path),
2316 }
2317 } else {
2318 ParsedCommand::Unknown {
2319 cmd: shlex_join(main_cmd),
2320 }
2321 }
2322 }
2323 Some((head, tail)) if head == "more" => {
2324 if let Some(path) = single_non_flag_operand(tail, &[]) {
2325 let name = short_display_path(&path);
2326 ParsedCommand::Read {
2327 cmd: shlex_join(main_cmd),
2328 name,
2329 path: PathBuf::from(path),
2330 }
2331 } else {
2332 ParsedCommand::Unknown {
2333 cmd: shlex_join(main_cmd),
2334 }
2335 }
2336 }
2337 Some((head, tail)) if head == "head" => {
2338 let has_valid_n = match tail.split_first() {
2340 Some((first, rest)) if first == "-n" => rest
2341 .first()
2342 .is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())),
2343 Some((first, _)) if first.starts_with("-n") => {
2344 first[2..].chars().all(|c| c.is_ascii_digit())
2345 }
2346 _ => false,
2347 };
2348 if has_valid_n {
2349 let mut candidates: Vec<&String> = Vec::new();
2351 let mut i = 0;
2352 while i < tail.len() {
2353 if i == 0 && tail[i] == "-n" && i + 1 < tail.len() {
2354 let n = &tail[i + 1];
2355 if n.chars().all(|c| c.is_ascii_digit()) {
2356 i += 2;
2357 continue;
2358 }
2359 }
2360 candidates.push(&tail[i]);
2361 i += 1;
2362 }
2363 if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) {
2364 let path = p.clone();
2365 let name = short_display_path(&path);
2366 return ParsedCommand::Read {
2367 cmd: shlex_join(main_cmd),
2368 name,
2369 path: PathBuf::from(path),
2370 };
2371 }
2372 }
2373 if let [path] = tail
2374 && !path.starts_with('-')
2375 {
2376 let name = short_display_path(path);
2377 return ParsedCommand::Read {
2378 cmd: shlex_join(main_cmd),
2379 name,
2380 path: PathBuf::from(path),
2381 };
2382 }
2383 ParsedCommand::Unknown {
2384 cmd: shlex_join(main_cmd),
2385 }
2386 }
2387 Some((head, tail)) if head == "tail" => {
2388 let has_valid_n = match tail.split_first() {
2390 Some((first, rest)) if first == "-n" => rest.first().is_some_and(|n| {
2391 let s = n.strip_prefix('+').unwrap_or(n);
2392 !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
2393 }),
2394 Some((first, _)) if first.starts_with("-n") => {
2395 let v = &first[2..];
2396 let s = v.strip_prefix('+').unwrap_or(v);
2397 !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
2398 }
2399 _ => false,
2400 };
2401 if has_valid_n {
2402 let mut candidates: Vec<&String> = Vec::new();
2404 let mut i = 0;
2405 while i < tail.len() {
2406 if i == 0 && tail[i] == "-n" && i + 1 < tail.len() {
2407 let n = &tail[i + 1];
2408 let s = n.strip_prefix('+').unwrap_or(n);
2409 if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
2410 i += 2;
2411 continue;
2412 }
2413 }
2414 candidates.push(&tail[i]);
2415 i += 1;
2416 }
2417 if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) {
2418 let path = p.clone();
2419 let name = short_display_path(&path);
2420 return ParsedCommand::Read {
2421 cmd: shlex_join(main_cmd),
2422 name,
2423 path: PathBuf::from(path),
2424 };
2425 }
2426 }
2427 if let [path] = tail
2428 && !path.starts_with('-')
2429 {
2430 let name = short_display_path(path);
2431 return ParsedCommand::Read {
2432 cmd: shlex_join(main_cmd),
2433 name,
2434 path: PathBuf::from(path),
2435 };
2436 }
2437 ParsedCommand::Unknown {
2438 cmd: shlex_join(main_cmd),
2439 }
2440 }
2441 Some((head, tail)) if head == "awk" => {
2442 if let Some(path) = awk_data_file_operand(tail) {
2443 let name = short_display_path(&path);
2444 ParsedCommand::Read {
2445 cmd: shlex_join(main_cmd),
2446 name,
2447 path: PathBuf::from(path),
2448 }
2449 } else {
2450 ParsedCommand::Unknown {
2451 cmd: shlex_join(main_cmd),
2452 }
2453 }
2454 }
2455 Some((head, tail)) if head == "nl" => {
2456 let candidates = skip_flag_values(tail, &["-s", "-w", "-v", "-i", "-b"]);
2458 if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) {
2459 let path = p.clone();
2460 let name = short_display_path(&path);
2461 ParsedCommand::Read {
2462 cmd: shlex_join(main_cmd),
2463 name,
2464 path: PathBuf::from(path),
2465 }
2466 } else {
2467 ParsedCommand::Unknown {
2468 cmd: shlex_join(main_cmd),
2469 }
2470 }
2471 }
2472 Some((head, tail)) if head == "sed" => {
2473 if let Some(path) = sed_read_path(tail) {
2474 let name = short_display_path(&path);
2475 ParsedCommand::Read {
2476 cmd: shlex_join(main_cmd),
2477 name,
2478 path: PathBuf::from(path),
2479 }
2480 } else {
2481 ParsedCommand::Unknown {
2482 cmd: shlex_join(main_cmd),
2483 }
2484 }
2485 }
2486 Some((head, tail)) if is_python_command(head) => {
2487 if python_walks_files(tail) {
2488 ParsedCommand::ListFiles {
2489 cmd: shlex_join(main_cmd),
2490 path: None,
2491 }
2492 } else {
2493 ParsedCommand::Unknown {
2494 cmd: shlex_join(main_cmd),
2495 }
2496 }
2497 }
2498 _ => ParsedCommand::Unknown {
2500 cmd: shlex_join(main_cmd),
2501 },
2502 }
2503}
2504
2505fn is_abs_like(path: &str) -> bool {
2506 if std::path::Path::new(path).is_absolute() {
2507 return true;
2508 }
2509 let mut chars = path.chars();
2510 match (chars.next(), chars.next(), chars.next()) {
2511 (Some(d), Some(':'), Some('\\')) if d.is_ascii_alphabetic() => return true,
2513 (Some('\\'), Some('\\'), _) => return true,
2515 _ => {}
2516 }
2517 false
2518}
2519
2520fn join_paths(base: &str, rel: &str) -> String {
2521 if is_abs_like(rel) {
2522 return rel.to_string();
2523 }
2524 if base.is_empty() {
2525 return rel.to_string();
2526 }
2527 let mut buf = PathBuf::from(base);
2528 buf.push(rel);
2529 buf.to_string_lossy().to_string()
2530}