1use std::{ffi::OsString, fs, path::Path};
2
3use crate::{
4 error::Result,
5 parser::{ParsedShellExpression, ShellRedirect, ShellRedirectOperator},
6 snapshot::{absolutize, PathSnapshotMode, WatchInput, WatchInputKind},
7};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10enum ExplicitCommandHandler {
11 EnvWrapper,
12 NiceWrapper,
13 NohupWrapper,
14 StdbufWrapper,
15 TimeoutWrapper,
16 CopyLike,
17 MoveLike,
18 Install,
19 LinkLike,
20 RemoveLike,
21 Sort,
22 Uniq,
23 Split,
24 Csplit,
25 Tee,
26 Grep,
27 Sed,
28 Awk,
29 Find,
30 LsLike,
31 Xargs,
32 Tar,
33 Touch,
34 Truncate,
35 ChangeAttributes,
36 Dd,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum HelpInventoryGroup {
41 Wrapper,
42 DedicatedBuiltIn,
43}
44
45#[derive(Debug, Clone, Copy)]
46struct ExplicitCommandSpec {
47 aliases: &'static [&'static str],
48 handler: ExplicitCommandHandler,
49 help_group: HelpInventoryGroup,
50 safe_current_dir_default: bool,
51}
52
53impl ExplicitCommandSpec {
54 const fn wrapper(aliases: &'static [&'static str], handler: ExplicitCommandHandler) -> Self {
55 Self {
56 aliases,
57 handler,
58 help_group: HelpInventoryGroup::Wrapper,
59 safe_current_dir_default: false,
60 }
61 }
62
63 const fn dedicated(aliases: &'static [&'static str], handler: ExplicitCommandHandler) -> Self {
64 Self {
65 aliases,
66 handler,
67 help_group: HelpInventoryGroup::DedicatedBuiltIn,
68 safe_current_dir_default: false,
69 }
70 }
71
72 const fn dedicated_with_safe_current_dir_default(
73 aliases: &'static [&'static str],
74 handler: ExplicitCommandHandler,
75 ) -> Self {
76 Self {
77 aliases,
78 handler,
79 help_group: HelpInventoryGroup::DedicatedBuiltIn,
80 safe_current_dir_default: true,
81 }
82 }
83}
84
85const EXPLICIT_COMMAND_SPECS: &[ExplicitCommandSpec] = &[
86 ExplicitCommandSpec::wrapper(&["env"], ExplicitCommandHandler::EnvWrapper),
87 ExplicitCommandSpec::wrapper(&["nice"], ExplicitCommandHandler::NiceWrapper),
88 ExplicitCommandSpec::wrapper(&["nohup"], ExplicitCommandHandler::NohupWrapper),
89 ExplicitCommandSpec::wrapper(&["stdbuf"], ExplicitCommandHandler::StdbufWrapper),
90 ExplicitCommandSpec::wrapper(&["timeout"], ExplicitCommandHandler::TimeoutWrapper),
91 ExplicitCommandSpec::dedicated(&["cp"], ExplicitCommandHandler::CopyLike),
92 ExplicitCommandSpec::dedicated(&["mv"], ExplicitCommandHandler::MoveLike),
93 ExplicitCommandSpec::dedicated(&["install"], ExplicitCommandHandler::Install),
94 ExplicitCommandSpec::dedicated(&["ln", "link"], ExplicitCommandHandler::LinkLike),
95 ExplicitCommandSpec::dedicated(
96 &["rm", "unlink", "rmdir", "shred"],
97 ExplicitCommandHandler::RemoveLike,
98 ),
99 ExplicitCommandSpec::dedicated(&["sort"], ExplicitCommandHandler::Sort),
100 ExplicitCommandSpec::dedicated(&["uniq"], ExplicitCommandHandler::Uniq),
101 ExplicitCommandSpec::dedicated(&["split"], ExplicitCommandHandler::Split),
102 ExplicitCommandSpec::dedicated(&["csplit"], ExplicitCommandHandler::Csplit),
103 ExplicitCommandSpec::dedicated(&["tee"], ExplicitCommandHandler::Tee),
104 ExplicitCommandSpec::dedicated(&["grep", "egrep", "fgrep"], ExplicitCommandHandler::Grep),
105 ExplicitCommandSpec::dedicated(&["sed"], ExplicitCommandHandler::Sed),
106 ExplicitCommandSpec::dedicated(
107 &["awk", "gawk", "mawk", "nawk"],
108 ExplicitCommandHandler::Awk,
109 ),
110 ExplicitCommandSpec::dedicated_with_safe_current_dir_default(
111 &["find"],
112 ExplicitCommandHandler::Find,
113 ),
114 ExplicitCommandSpec::dedicated_with_safe_current_dir_default(
115 &["ls", "dir", "vdir"],
116 ExplicitCommandHandler::LsLike,
117 ),
118 ExplicitCommandSpec::dedicated(&["xargs"], ExplicitCommandHandler::Xargs),
119 ExplicitCommandSpec::dedicated(&["tar"], ExplicitCommandHandler::Tar),
120 ExplicitCommandSpec::dedicated(&["touch"], ExplicitCommandHandler::Touch),
121 ExplicitCommandSpec::dedicated(&["truncate"], ExplicitCommandHandler::Truncate),
122 ExplicitCommandSpec::dedicated(
123 &["chmod", "chown", "chgrp"],
124 ExplicitCommandHandler::ChangeAttributes,
125 ),
126 ExplicitCommandSpec::dedicated(&["dd"], ExplicitCommandHandler::Dd),
127];
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum CommandAdapterId {
131 WrapperEnv,
132 WrapperNice,
133 WrapperNohup,
134 WrapperStdbuf,
135 WrapperTimeout,
136 CopyLike,
137 MoveLike,
138 Install,
139 LinkLike,
140 RemoveLike,
141 ReadPaths,
142 DefaultCurrentDir,
143 Grep,
144 Sed,
145 Awk,
146 Find,
147 Xargs,
148 Tar,
149 Sort,
150 Uniq,
151 Split,
152 Csplit,
153 Tee,
154 Touch,
155 Truncate,
156 ChangeAttributes,
157 Dd,
158 NonWatchable,
159 Fallback,
160}
161
162impl CommandAdapterId {
163 pub fn as_str(self) -> &'static str {
164 match self {
165 Self::WrapperEnv => "wrapper-env",
166 Self::WrapperNice => "wrapper-nice",
167 Self::WrapperNohup => "wrapper-nohup",
168 Self::WrapperStdbuf => "wrapper-stdbuf",
169 Self::WrapperTimeout => "wrapper-timeout",
170 Self::CopyLike => "copy-like",
171 Self::MoveLike => "move-like",
172 Self::Install => "install",
173 Self::LinkLike => "link-like",
174 Self::RemoveLike => "remove-like",
175 Self::ReadPaths => "read-paths",
176 Self::DefaultCurrentDir => "default-current-dir",
177 Self::Grep => "grep",
178 Self::Sed => "sed",
179 Self::Awk => "awk",
180 Self::Find => "find",
181 Self::Xargs => "xargs",
182 Self::Tar => "tar",
183 Self::Sort => "sort",
184 Self::Uniq => "uniq",
185 Self::Split => "split",
186 Self::Csplit => "csplit",
187 Self::Tee => "tee",
188 Self::Touch => "touch",
189 Self::Truncate => "truncate",
190 Self::ChangeAttributes => "change-attributes",
191 Self::Dd => "dd",
192 Self::NonWatchable => "non-watchable",
193 Self::Fallback => "fallback",
194 }
195 }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
199pub enum SideEffectProfile {
200 ReadOnly,
201 WritesExcludedOutputs,
202 WritesWatchedInputs,
203}
204
205impl SideEffectProfile {
206 pub fn as_str(self) -> &'static str {
207 match self {
208 Self::ReadOnly => "read-only",
209 Self::WritesExcludedOutputs => "writes-excluded-outputs",
210 Self::WritesWatchedInputs => "writes-watched-inputs",
211 }
212 }
213
214 fn merge(self, other: Self) -> Self {
215 self.max(other)
216 }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum CommandAnalysisStatus {
221 Resolved,
222 NoInputs,
223 AmbiguousFallback,
224}
225
226impl CommandAnalysisStatus {
227 pub fn as_str(self) -> &'static str {
228 match self {
229 Self::Resolved => "resolved",
230 Self::NoInputs => "no-inputs",
231 Self::AmbiguousFallback => "ambiguous-fallback",
232 }
233 }
234}
235
236#[derive(Debug, Clone)]
237pub struct CommandAnalysis {
238 pub inputs: Vec<WatchInput>,
239 pub adapter_ids: Vec<CommandAdapterId>,
240 pub fallback_used: bool,
241 pub default_watch_root_used: bool,
242 pub filtered_output_count: usize,
243 pub side_effect_profile: SideEffectProfile,
244 pub status: CommandAnalysisStatus,
245}
246
247impl CommandAnalysis {
248 pub fn adapter_field(&self) -> String {
249 self.adapter_ids
250 .iter()
251 .map(|adapter| adapter.as_str())
252 .collect::<Vec<_>>()
253 .join(",")
254 }
255}
256
257#[derive(Debug, Clone)]
258struct SingleCommandAnalysis {
259 inputs: Vec<WatchInput>,
260 adapter_ids: Vec<CommandAdapterId>,
261 fallback_used: bool,
262 default_watch_root_used: bool,
263 filtered_output_count: usize,
264 side_effect_profile: SideEffectProfile,
265 status: CommandAnalysisStatus,
266}
267
268impl SingleCommandAnalysis {
269 fn empty(adapter_id: CommandAdapterId) -> Self {
270 Self {
271 inputs: Vec::new(),
272 adapter_ids: vec![adapter_id],
273 fallback_used: adapter_id == CommandAdapterId::Fallback,
274 default_watch_root_used: false,
275 filtered_output_count: 0,
276 side_effect_profile: SideEffectProfile::ReadOnly,
277 status: CommandAnalysisStatus::NoInputs,
278 }
279 }
280
281 fn finalize(mut self) -> Self {
282 if self.status != CommandAnalysisStatus::AmbiguousFallback {
283 self.status = if self.inputs.is_empty() {
284 CommandAnalysisStatus::NoInputs
285 } else {
286 CommandAnalysisStatus::Resolved
287 };
288 }
289 self
290 }
291}
292
293pub fn analyze_argv(argv: &[OsString], cwd: &Path) -> Result<CommandAnalysis> {
294 let argv = argv
295 .iter()
296 .map(|value| value.to_string_lossy().into_owned())
297 .collect::<Vec<_>>();
298
299 let analysis = if argv.is_empty() {
300 SingleCommandAnalysis::empty(CommandAdapterId::NonWatchable)
301 } else {
302 analyze_command_tokens(&argv, &[], cwd)?
303 };
304
305 Ok(aggregate_analyses([analysis]))
306}
307
308pub fn analyze_shell_expression(
309 expression: &ParsedShellExpression,
310 cwd: &Path,
311) -> Result<CommandAnalysis> {
312 let mut analyses = Vec::new();
313
314 for command in &expression.commands {
315 analyses.push(analyze_command_tokens(
316 &command.argv,
317 &command.redirects,
318 cwd,
319 )?);
320 }
321
322 Ok(aggregate_analyses(analyses))
323}
324
325fn aggregate_analyses<I>(analyses: I) -> CommandAnalysis
326where
327 I: IntoIterator<Item = SingleCommandAnalysis>,
328{
329 let mut inputs = Vec::new();
330 let mut adapter_ids = Vec::new();
331 let mut fallback_used = false;
332 let mut default_watch_root_used = false;
333 let mut filtered_output_count = 0usize;
334 let mut side_effect_profile = SideEffectProfile::ReadOnly;
335 let mut status = CommandAnalysisStatus::NoInputs;
336
337 for analysis in analyses {
338 for input in analysis.inputs {
339 if !inputs.contains(&input) {
340 inputs.push(input);
341 }
342 }
343 for adapter_id in analysis.adapter_ids {
344 if !adapter_ids.contains(&adapter_id) {
345 adapter_ids.push(adapter_id);
346 }
347 }
348 fallback_used |= analysis.fallback_used;
349 default_watch_root_used |= analysis.default_watch_root_used;
350 filtered_output_count += analysis.filtered_output_count;
351 side_effect_profile = side_effect_profile.merge(analysis.side_effect_profile);
352 if analysis.status == CommandAnalysisStatus::AmbiguousFallback {
353 status = CommandAnalysisStatus::AmbiguousFallback;
354 } else if analysis.status == CommandAnalysisStatus::Resolved
355 && status != CommandAnalysisStatus::AmbiguousFallback
356 {
357 status = CommandAnalysisStatus::Resolved;
358 }
359 }
360
361 if status != CommandAnalysisStatus::AmbiguousFallback && inputs.is_empty() {
362 status = CommandAnalysisStatus::NoInputs;
363 }
364
365 CommandAnalysis {
366 inputs,
367 adapter_ids,
368 fallback_used,
369 default_watch_root_used,
370 filtered_output_count,
371 side_effect_profile,
372 status,
373 }
374}
375
376fn analyze_command_tokens(
377 argv: &[String],
378 redirects: &[ShellRedirect],
379 cwd: &Path,
380) -> Result<SingleCommandAnalysis> {
381 if argv.is_empty() {
382 return Ok(SingleCommandAnalysis::empty(CommandAdapterId::NonWatchable));
383 }
384
385 let command_name = command_name(argv[0].as_str());
386 let mut analysis = if let Some(handler) = explicit_command_handler(command_name.as_str()) {
387 analyze_explicit_command(handler, argv, redirects, cwd)?
388 } else {
389 match command_name.as_str() {
390 name if DEFAULT_CURRENT_DIR_COMMANDS.contains(&name) => {
391 analyze_default_current_dir_reader(argv, redirects, cwd)?
392 }
393 name if NONWATCHABLE_COMMANDS.contains(&name) => {
394 analyze_non_watchable(argv, redirects, cwd)?
395 }
396 name if GENERIC_READ_PATH_COMMANDS.contains(&name) => {
397 analyze_generic_read_paths(argv, redirects, cwd)?
398 }
399 _ => analyze_fallback(argv, redirects, cwd)?,
400 }
401 };
402
403 if analysis.adapter_ids.is_empty() {
404 analysis.adapter_ids.push(CommandAdapterId::Fallback);
405 }
406
407 Ok(analysis.finalize())
408}
409
410const DEFAULT_CURRENT_DIR_COMMANDS: &[&str] = &["du"];
411const NONWATCHABLE_COMMANDS: &[&str] = &[
412 "echo", "printf", "seq", "yes", "sleep", "date", "uname", "pwd", "true", "false", "basename",
413 "dirname", "nproc", "printenv", "whoami", "logname", "users", "hostid", "numfmt", "mktemp",
414 "mkdir", "mkfifo", "mknod",
415];
416const GENERIC_READ_PATH_COMMANDS: &[&str] = &[
417 "cat",
418 "tac",
419 "head",
420 "tail",
421 "wc",
422 "nl",
423 "od",
424 "cut",
425 "fmt",
426 "fold",
427 "paste",
428 "pr",
429 "tr",
430 "expand",
431 "unexpand",
432 "stat",
433 "readlink",
434 "realpath",
435 "md5sum",
436 "b2sum",
437 "cksum",
438 "sum",
439 "sha1sum",
440 "sha224sum",
441 "sha256sum",
442 "sha384sum",
443 "sha512sum",
444 "sha512_224sum",
445 "sha512_256sum",
446 "base32",
447 "base64",
448 "basenc",
449 "comm",
450 "join",
451 "cmp",
452 "tsort",
453 "shuf",
454];
455
456struct HelpInventory {
457 wrapper_commands: Vec<&'static str>,
458 dedicated_built_ins: Vec<&'static str>,
459 generic_read_path_commands: &'static [&'static str],
460 safe_current_dir_defaults: Vec<&'static str>,
461 non_watchable_commands: &'static [&'static str],
462}
463
464fn help_inventory() -> HelpInventory {
465 let mut wrapper_commands = Vec::new();
466 let mut dedicated_built_ins = Vec::new();
467 let mut safe_current_dir_defaults = Vec::new();
468
469 for spec in EXPLICIT_COMMAND_SPECS {
470 match spec.help_group {
471 HelpInventoryGroup::Wrapper => wrapper_commands.extend_from_slice(spec.aliases),
472 HelpInventoryGroup::DedicatedBuiltIn => {
473 dedicated_built_ins.extend_from_slice(spec.aliases)
474 }
475 }
476
477 if spec.safe_current_dir_default {
478 safe_current_dir_defaults.extend_from_slice(spec.aliases);
479 }
480 }
481
482 safe_current_dir_defaults.extend_from_slice(DEFAULT_CURRENT_DIR_COMMANDS);
483
484 HelpInventory {
485 wrapper_commands,
486 dedicated_built_ins,
487 generic_read_path_commands: GENERIC_READ_PATH_COMMANDS,
488 safe_current_dir_defaults,
489 non_watchable_commands: NONWATCHABLE_COMMANDS,
490 }
491}
492
493pub fn render_after_long_help() -> String {
494 let inventory = help_inventory();
495
496 format!(
497 "Command modes:\n Passthrough: with-watch [--no-hash] [--clear] <utility> [args...]\n \
498 Shell: with-watch [--no-hash] [--clear] --shell '<expr>'\n Explicit inputs: with-watch \
499 exec [--no-hash] [--clear] --input <glob>... -- <command> [args...]\n\nWrapper \
500 commands:\n {}\n\nDedicated built-in adapters and aliases:\n {}\n\nGeneric read-path \
501 commands:\n {}\n\nSafe current-directory defaults:\n {}\n\nRecognized but not \
502 auto-watchable commands:\n {}\n These commands are recognized, but they do not expose \
503 stable filesystem inputs on their own.\n\nexec --input escape hatch:\n Use `with-watch \
504 exec --input <glob>... -- <command> [args...]` when inference is ambiguous, when a \
505 command has no stable filesystem inputs, or when you want an explicit watch set.",
506 join_command_names(&inventory.wrapper_commands),
507 join_command_names(&inventory.dedicated_built_ins),
508 join_command_names(inventory.generic_read_path_commands),
509 join_command_names(&inventory.safe_current_dir_defaults),
510 join_command_names(inventory.non_watchable_commands),
511 )
512}
513
514fn join_command_names(commands: &[&str]) -> String {
515 commands.join(", ")
516}
517
518fn explicit_command_handler(command_name: &str) -> Option<ExplicitCommandHandler> {
519 EXPLICIT_COMMAND_SPECS
520 .iter()
521 .find(|spec| spec.aliases.contains(&command_name))
522 .map(|spec| spec.handler)
523}
524
525fn analyze_explicit_command(
526 handler: ExplicitCommandHandler,
527 argv: &[String],
528 redirects: &[ShellRedirect],
529 cwd: &Path,
530) -> Result<SingleCommandAnalysis> {
531 match handler {
532 ExplicitCommandHandler::EnvWrapper => analyze_env_wrapper(argv, redirects, cwd),
533 ExplicitCommandHandler::NiceWrapper => analyze_nice_wrapper(argv, redirects, cwd),
534 ExplicitCommandHandler::NohupWrapper => analyze_nohup_wrapper(argv, redirects, cwd),
535 ExplicitCommandHandler::StdbufWrapper => analyze_stdbuf_wrapper(argv, redirects, cwd),
536 ExplicitCommandHandler::TimeoutWrapper => analyze_timeout_wrapper(argv, redirects, cwd),
537 ExplicitCommandHandler::CopyLike => analyze_copy_like(
538 argv,
539 CommandAdapterId::CopyLike,
540 SideEffectProfile::WritesExcludedOutputs,
541 redirects,
542 cwd,
543 ),
544 ExplicitCommandHandler::MoveLike => analyze_copy_like(
545 argv,
546 CommandAdapterId::MoveLike,
547 SideEffectProfile::WritesWatchedInputs,
548 redirects,
549 cwd,
550 ),
551 ExplicitCommandHandler::Install => analyze_install(argv, redirects, cwd),
552 ExplicitCommandHandler::LinkLike => analyze_link_like(argv, redirects, cwd),
553 ExplicitCommandHandler::RemoveLike => analyze_remove_like(argv, redirects, cwd),
554 ExplicitCommandHandler::Sort => analyze_sort(argv, redirects, cwd),
555 ExplicitCommandHandler::Uniq => analyze_uniq(argv, redirects, cwd),
556 ExplicitCommandHandler::Split => analyze_split(argv, redirects, cwd),
557 ExplicitCommandHandler::Csplit => analyze_csplit(argv, redirects, cwd),
558 ExplicitCommandHandler::Tee => analyze_tee(argv, redirects, cwd),
559 ExplicitCommandHandler::Grep => analyze_grep(argv, redirects, cwd),
560 ExplicitCommandHandler::Sed => analyze_sed(argv, redirects, cwd),
561 ExplicitCommandHandler::Awk => analyze_awk(argv, redirects, cwd),
562 ExplicitCommandHandler::Find => analyze_find(argv, redirects, cwd),
563 ExplicitCommandHandler::LsLike => analyze_ls_like(argv, redirects, cwd),
564 ExplicitCommandHandler::Xargs => analyze_xargs(argv, redirects, cwd),
565 ExplicitCommandHandler::Tar => analyze_tar(argv, redirects, cwd),
566 ExplicitCommandHandler::Touch => {
567 analyze_touch_like(argv, CommandAdapterId::Touch, redirects, cwd)
568 }
569 ExplicitCommandHandler::Truncate => {
570 analyze_touch_like(argv, CommandAdapterId::Truncate, redirects, cwd)
571 }
572 ExplicitCommandHandler::ChangeAttributes => analyze_change_attributes(argv, redirects, cwd),
573 ExplicitCommandHandler::Dd => analyze_dd(argv, redirects, cwd),
574 }
575}
576
577fn command_name(program: &str) -> String {
578 Path::new(program)
579 .file_name()
580 .unwrap_or_else(|| program.as_ref())
581 .to_string_lossy()
582 .to_ascii_lowercase()
583}
584
585fn analyze_env_wrapper(
586 argv: &[String],
587 redirects: &[ShellRedirect],
588 cwd: &Path,
589) -> Result<SingleCommandAnalysis> {
590 let mut index = 1usize;
591
592 while index < argv.len() {
593 let token = argv[index].as_str();
594 if token == "--" || token == "-" {
595 index += 1;
596 break;
597 }
598 if token == "-u" || token == "--unset" || token == "-C" || token == "--chdir" {
599 index += 2;
600 continue;
601 }
602 if token == "-S"
603 || token == "--split-string"
604 || token.starts_with("--unset=")
605 || token.starts_with("--chdir=")
606 || token.starts_with("--split-string=")
607 || token == "-i"
608 || token == "--ignore-environment"
609 {
610 index += 1;
611 continue;
612 }
613 if token.contains('=') && !token.starts_with('=') {
614 index += 1;
615 continue;
616 }
617 break;
618 }
619
620 wrap_analysis(CommandAdapterId::WrapperEnv, &argv[index..], redirects, cwd)
621}
622
623fn analyze_nice_wrapper(
624 argv: &[String],
625 redirects: &[ShellRedirect],
626 cwd: &Path,
627) -> Result<SingleCommandAnalysis> {
628 let mut index = 1usize;
629
630 while index < argv.len() {
631 let token = argv[index].as_str();
632 if token == "--" {
633 index += 1;
634 break;
635 }
636 if token == "-n" || token == "--adjustment" {
637 index += 2;
638 continue;
639 }
640 if token.starts_with("--adjustment=")
641 || token == "--help"
642 || token == "--version"
643 || is_signed_integer(token)
644 {
645 index += 1;
646 continue;
647 }
648 break;
649 }
650
651 wrap_analysis(
652 CommandAdapterId::WrapperNice,
653 &argv[index..],
654 redirects,
655 cwd,
656 )
657}
658
659fn analyze_nohup_wrapper(
660 argv: &[String],
661 redirects: &[ShellRedirect],
662 cwd: &Path,
663) -> Result<SingleCommandAnalysis> {
664 let mut index = 1usize;
665 if index < argv.len() && argv[index] == "--" {
666 index += 1;
667 }
668 wrap_analysis(
669 CommandAdapterId::WrapperNohup,
670 &argv[index..],
671 redirects,
672 cwd,
673 )
674}
675
676fn analyze_stdbuf_wrapper(
677 argv: &[String],
678 redirects: &[ShellRedirect],
679 cwd: &Path,
680) -> Result<SingleCommandAnalysis> {
681 let mut index = 1usize;
682
683 while index < argv.len() {
684 let token = argv[index].as_str();
685 if token == "--" {
686 index += 1;
687 break;
688 }
689 if token == "-i" || token == "-o" || token == "-e" {
690 index += 2;
691 continue;
692 }
693 if token.starts_with("--input=")
694 || token.starts_with("--output=")
695 || token.starts_with("--error=")
696 {
697 index += 1;
698 continue;
699 }
700 if token == "--input" || token == "--output" || token == "--error" {
701 index += 2;
702 continue;
703 }
704 break;
705 }
706
707 wrap_analysis(
708 CommandAdapterId::WrapperStdbuf,
709 &argv[index..],
710 redirects,
711 cwd,
712 )
713}
714
715fn analyze_timeout_wrapper(
716 argv: &[String],
717 redirects: &[ShellRedirect],
718 cwd: &Path,
719) -> Result<SingleCommandAnalysis> {
720 let mut index = 1usize;
721
722 while index < argv.len() {
723 let token = argv[index].as_str();
724 if token == "--" {
725 index += 1;
726 break;
727 }
728 if token == "-s" || token == "--signal" || token == "-k" || token == "--kill-after" {
729 index += 2;
730 continue;
731 }
732 if token.starts_with("--signal=")
733 || token.starts_with("--kill-after=")
734 || token == "--foreground"
735 || token == "--preserve-status"
736 || token == "--verbose"
737 {
738 index += 1;
739 continue;
740 }
741 break;
742 }
743
744 if index < argv.len() {
745 index += 1;
746 }
747
748 wrap_analysis(
749 CommandAdapterId::WrapperTimeout,
750 &argv[index..],
751 redirects,
752 cwd,
753 )
754}
755
756fn wrap_analysis(
757 wrapper_id: CommandAdapterId,
758 inner_argv: &[String],
759 redirects: &[ShellRedirect],
760 cwd: &Path,
761) -> Result<SingleCommandAnalysis> {
762 let mut analysis = if inner_argv.is_empty() {
763 SingleCommandAnalysis::empty(wrapper_id)
764 } else {
765 analyze_command_tokens(inner_argv, redirects, cwd)?
766 };
767
768 if !analysis.adapter_ids.contains(&wrapper_id) {
769 analysis.adapter_ids.insert(0, wrapper_id);
770 }
771 Ok(analysis)
772}
773
774fn analyze_copy_like(
775 argv: &[String],
776 adapter_id: CommandAdapterId,
777 side_effect_profile: SideEffectProfile,
778 redirects: &[ShellRedirect],
779 cwd: &Path,
780) -> Result<SingleCommandAnalysis> {
781 let mut inputs = Vec::new();
782 let mut filtered_output_count = 0usize;
783 let mut target_directory = None::<String>;
784 let mut operands = Vec::new();
785 let mut positional_only = false;
786 let mut index = 1usize;
787
788 while index < argv.len() {
789 let token = argv[index].as_str();
790 if !positional_only && token == "--" {
791 positional_only = true;
792 index += 1;
793 continue;
794 }
795
796 if !positional_only {
797 if token == "-t" || token == "--target-directory" {
798 if let Some(value) = argv.get(index + 1) {
799 target_directory = Some(value.clone());
800 }
801 index += 2;
802 continue;
803 }
804 if let Some(value) = token.strip_prefix("--target-directory=") {
805 target_directory = Some(value.to_string());
806 index += 1;
807 continue;
808 }
809 if token.starts_with('-') {
810 index += 1;
811 continue;
812 }
813 }
814
815 operands.push(argv[index].clone());
816 index += 1;
817 }
818
819 if target_directory.is_some() {
820 filtered_output_count += 1;
821 for operand in operands {
822 push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
823 }
824 let mut analysis = SingleCommandAnalysis {
825 inputs,
826 adapter_ids: vec![adapter_id],
827 fallback_used: false,
828 default_watch_root_used: false,
829 filtered_output_count,
830 side_effect_profile,
831 status: CommandAnalysisStatus::NoInputs,
832 };
833 apply_redirects(&mut analysis, redirects, cwd)?;
834 return Ok(analysis);
835 }
836
837 let split_index = operands.len().saturating_sub(1);
838 for operand in &operands[..split_index] {
839 push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
840 }
841 if operands.len() >= 2 {
842 filtered_output_count += 1;
843 } else if let Some(operand) = operands.first() {
844 push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
845 }
846
847 let mut analysis = SingleCommandAnalysis {
848 inputs,
849 adapter_ids: vec![adapter_id],
850 fallback_used: false,
851 default_watch_root_used: false,
852 filtered_output_count,
853 side_effect_profile,
854 status: CommandAnalysisStatus::NoInputs,
855 };
856 apply_redirects(&mut analysis, redirects, cwd)?;
857 Ok(analysis)
858}
859
860fn analyze_install(
861 argv: &[String],
862 redirects: &[ShellRedirect],
863 cwd: &Path,
864) -> Result<SingleCommandAnalysis> {
865 let mut inputs = Vec::new();
866 let mut filtered_output_count = 0usize;
867 let mut target_directory = None::<String>;
868 let mut compare_reference = None::<String>;
869 let mut operands = Vec::new();
870 let mut positional_only = false;
871 let mut index = 1usize;
872
873 while index < argv.len() {
874 let token = argv[index].as_str();
875 if !positional_only && token == "--" {
876 positional_only = true;
877 index += 1;
878 continue;
879 }
880
881 if !positional_only {
882 if token == "-t" || token == "--target-directory" {
883 if let Some(value) = argv.get(index + 1) {
884 target_directory = Some(value.clone());
885 }
886 index += 2;
887 continue;
888 }
889 if token == "-C" || token == "--compare" {
890 index += 1;
891 continue;
892 }
893 if token == "--compare-with" {
894 if let Some(value) = argv.get(index + 1) {
895 compare_reference = Some(value.clone());
896 }
897 index += 2;
898 continue;
899 }
900 if let Some(value) = token.strip_prefix("--target-directory=") {
901 target_directory = Some(value.to_string());
902 index += 1;
903 continue;
904 }
905 if let Some(value) = token.strip_prefix("--compare-with=") {
906 compare_reference = Some(value.to_string());
907 index += 1;
908 continue;
909 }
910 if token.starts_with('-') {
911 index += 1;
912 continue;
913 }
914 }
915
916 operands.push(argv[index].clone());
917 index += 1;
918 }
919
920 if let Some(compare_reference) = compare_reference {
921 push_inferred_input(&mut inputs, compare_reference.as_str(), cwd)?;
922 }
923
924 if let Some(_target_directory) = target_directory {
925 filtered_output_count += 1;
926 for operand in operands {
927 push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
928 }
929 } else {
930 let split_index = operands.len().saturating_sub(1);
931 for operand in &operands[..split_index] {
932 push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
933 }
934 if operands.len() >= 2 {
935 filtered_output_count += 1;
936 } else if let Some(operand) = operands.first() {
937 push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
938 }
939 }
940
941 let mut analysis = SingleCommandAnalysis {
942 inputs,
943 adapter_ids: vec![CommandAdapterId::Install],
944 fallback_used: false,
945 default_watch_root_used: false,
946 filtered_output_count,
947 side_effect_profile: SideEffectProfile::WritesExcludedOutputs,
948 status: CommandAnalysisStatus::NoInputs,
949 };
950 apply_redirects(&mut analysis, redirects, cwd)?;
951 Ok(analysis)
952}
953
954fn analyze_link_like(
955 argv: &[String],
956 redirects: &[ShellRedirect],
957 cwd: &Path,
958) -> Result<SingleCommandAnalysis> {
959 analyze_copy_like(
960 argv,
961 CommandAdapterId::LinkLike,
962 SideEffectProfile::WritesExcludedOutputs,
963 redirects,
964 cwd,
965 )
966}
967
968fn analyze_remove_like(
969 argv: &[String],
970 redirects: &[ShellRedirect],
971 cwd: &Path,
972) -> Result<SingleCommandAnalysis> {
973 let mut inputs = Vec::new();
974 let mut positional_only = false;
975 let mut index = 1usize;
976
977 while index < argv.len() {
978 let token = argv[index].as_str();
979 if !positional_only && token == "--" {
980 positional_only = true;
981 index += 1;
982 continue;
983 }
984 if !positional_only && token.starts_with('-') {
985 index += 1;
986 continue;
987 }
988 push_inferred_input(&mut inputs, token, cwd)?;
989 index += 1;
990 }
991
992 let mut analysis = SingleCommandAnalysis {
993 inputs,
994 adapter_ids: vec![CommandAdapterId::RemoveLike],
995 fallback_used: false,
996 default_watch_root_used: false,
997 filtered_output_count: 0,
998 side_effect_profile: SideEffectProfile::WritesWatchedInputs,
999 status: CommandAnalysisStatus::NoInputs,
1000 };
1001 apply_redirects(&mut analysis, redirects, cwd)?;
1002 Ok(analysis)
1003}
1004
1005fn analyze_sort(
1006 argv: &[String],
1007 redirects: &[ShellRedirect],
1008 cwd: &Path,
1009) -> Result<SingleCommandAnalysis> {
1010 let mut inputs = Vec::new();
1011 let mut filtered_output_count = 0usize;
1012 let mut operands = Vec::new();
1013 let mut positional_only = false;
1014 let mut index = 1usize;
1015
1016 while index < argv.len() {
1017 let token = argv[index].as_str();
1018 if !positional_only && token == "--" {
1019 positional_only = true;
1020 index += 1;
1021 continue;
1022 }
1023 if !positional_only {
1024 if token == "-o" || token == "--output" {
1025 if argv.get(index + 1).is_some() {
1026 filtered_output_count += 1;
1027 }
1028 index += 2;
1029 continue;
1030 }
1031 if token == "--files0-from" {
1032 if let Some(value) = argv.get(index + 1) {
1033 push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1034 }
1035 index += 2;
1036 continue;
1037 }
1038 if token.starts_with("-o") && token.len() > 2 {
1039 filtered_output_count += 1;
1040 index += 1;
1041 continue;
1042 }
1043 if let Some(value) = token.strip_prefix("--output=") {
1044 if !value.is_empty() {
1045 filtered_output_count += 1;
1046 }
1047 index += 1;
1048 continue;
1049 }
1050 if let Some(value) = token.strip_prefix("--files0-from=") {
1051 push_inferred_input(&mut inputs, value, cwd)?;
1052 index += 1;
1053 continue;
1054 }
1055 if token.starts_with('-') {
1056 index += 1;
1057 continue;
1058 }
1059 }
1060
1061 operands.push(argv[index].clone());
1062 index += 1;
1063 }
1064
1065 for operand in operands {
1066 if operand != "-" {
1067 push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
1068 }
1069 }
1070
1071 let mut analysis = SingleCommandAnalysis {
1072 inputs,
1073 adapter_ids: vec![CommandAdapterId::Sort],
1074 fallback_used: false,
1075 default_watch_root_used: false,
1076 filtered_output_count,
1077 side_effect_profile: if filtered_output_count > 0 {
1078 SideEffectProfile::WritesExcludedOutputs
1079 } else {
1080 SideEffectProfile::ReadOnly
1081 },
1082 status: CommandAnalysisStatus::NoInputs,
1083 };
1084 apply_redirects(&mut analysis, redirects, cwd)?;
1085 Ok(analysis)
1086}
1087
1088fn analyze_uniq(
1089 argv: &[String],
1090 redirects: &[ShellRedirect],
1091 cwd: &Path,
1092) -> Result<SingleCommandAnalysis> {
1093 let mut inputs = Vec::new();
1094 let mut filtered_output_count = 0usize;
1095 let mut operands = Vec::new();
1096 let mut positional_only = false;
1097 let mut index = 1usize;
1098
1099 while index < argv.len() {
1100 let token = argv[index].as_str();
1101 if !positional_only && token == "--" {
1102 positional_only = true;
1103 index += 1;
1104 continue;
1105 }
1106 if !positional_only && token.starts_with('-') {
1107 index += 1;
1108 continue;
1109 }
1110 operands.push(argv[index].clone());
1111 index += 1;
1112 }
1113
1114 if let Some(input) = operands.first() {
1115 if input != "-" {
1116 push_inferred_input(&mut inputs, input.as_str(), cwd)?;
1117 }
1118 }
1119 if operands.len() >= 2 {
1120 filtered_output_count += 1;
1121 }
1122
1123 let mut analysis = SingleCommandAnalysis {
1124 inputs,
1125 adapter_ids: vec![CommandAdapterId::Uniq],
1126 fallback_used: false,
1127 default_watch_root_used: false,
1128 filtered_output_count,
1129 side_effect_profile: if filtered_output_count > 0 {
1130 SideEffectProfile::WritesExcludedOutputs
1131 } else {
1132 SideEffectProfile::ReadOnly
1133 },
1134 status: CommandAnalysisStatus::NoInputs,
1135 };
1136 apply_redirects(&mut analysis, redirects, cwd)?;
1137 Ok(analysis)
1138}
1139
1140fn analyze_split(
1141 argv: &[String],
1142 redirects: &[ShellRedirect],
1143 cwd: &Path,
1144) -> Result<SingleCommandAnalysis> {
1145 let mut inputs = Vec::new();
1146 let mut filtered_output_count = 0usize;
1147 let mut operands = Vec::new();
1148 let mut positional_only = false;
1149 let mut index = 1usize;
1150
1151 while index < argv.len() {
1152 let token = argv[index].as_str();
1153 if !positional_only && token == "--" {
1154 positional_only = true;
1155 index += 1;
1156 continue;
1157 }
1158 if !positional_only {
1159 if token == "--filter"
1160 || token == "--separator"
1161 || token == "--additional-suffix"
1162 || token == "--number"
1163 {
1164 index += 2;
1165 continue;
1166 }
1167 if token == "-n"
1168 || token == "-a"
1169 || token == "-b"
1170 || token == "-C"
1171 || token == "-l"
1172 || token == "-t"
1173 {
1174 index += 2;
1175 continue;
1176 }
1177 if token.starts_with("--filter=")
1178 || token.starts_with("--separator=")
1179 || token.starts_with("--additional-suffix=")
1180 || token.starts_with("--number=")
1181 || token.starts_with("-n")
1182 || token.starts_with("-a")
1183 || token.starts_with("-b")
1184 || token.starts_with("-C")
1185 || token.starts_with("-l")
1186 || token.starts_with("-t")
1187 {
1188 index += 1;
1189 continue;
1190 }
1191 if token.starts_with('-') {
1192 index += 1;
1193 continue;
1194 }
1195 }
1196
1197 operands.push(argv[index].clone());
1198 index += 1;
1199 }
1200
1201 if let Some(input) = operands.first() {
1202 if input != "-" {
1203 push_inferred_input(&mut inputs, input.as_str(), cwd)?;
1204 }
1205 }
1206 if operands.len() >= 2 {
1207 filtered_output_count += 1;
1208 }
1209
1210 let mut analysis = SingleCommandAnalysis {
1211 inputs,
1212 adapter_ids: vec![CommandAdapterId::Split],
1213 fallback_used: false,
1214 default_watch_root_used: false,
1215 filtered_output_count,
1216 side_effect_profile: SideEffectProfile::WritesExcludedOutputs,
1217 status: CommandAnalysisStatus::NoInputs,
1218 };
1219 apply_redirects(&mut analysis, redirects, cwd)?;
1220 Ok(analysis)
1221}
1222
1223fn analyze_csplit(
1224 argv: &[String],
1225 redirects: &[ShellRedirect],
1226 cwd: &Path,
1227) -> Result<SingleCommandAnalysis> {
1228 let mut inputs = Vec::new();
1229 let mut filtered_output_count = 0usize;
1230 let mut first_operand = None::<String>;
1231 let mut positional_only = false;
1232 let mut index = 1usize;
1233
1234 while index < argv.len() {
1235 let token = argv[index].as_str();
1236 if !positional_only && token == "--" {
1237 positional_only = true;
1238 index += 1;
1239 continue;
1240 }
1241 if !positional_only {
1242 if token == "-f" || token == "--prefix" || token == "-b" || token == "--suffix-format" {
1243 if token == "-f" || token == "--prefix" {
1244 filtered_output_count += 1;
1245 }
1246 index += 2;
1247 continue;
1248 }
1249 if token.starts_with("--prefix=") {
1250 filtered_output_count += 1;
1251 index += 1;
1252 continue;
1253 }
1254 if token.starts_with("--suffix-format=") || token.starts_with('-') {
1255 index += 1;
1256 continue;
1257 }
1258 }
1259
1260 if first_operand.is_none() {
1261 first_operand = Some(argv[index].clone());
1262 }
1263 index += 1;
1264 }
1265
1266 if let Some(input) = first_operand {
1267 if input != "-" {
1268 push_inferred_input(&mut inputs, input.as_str(), cwd)?;
1269 }
1270 }
1271
1272 let mut analysis = SingleCommandAnalysis {
1273 inputs,
1274 adapter_ids: vec![CommandAdapterId::Csplit],
1275 fallback_used: false,
1276 default_watch_root_used: false,
1277 filtered_output_count,
1278 side_effect_profile: SideEffectProfile::WritesExcludedOutputs,
1279 status: CommandAnalysisStatus::NoInputs,
1280 };
1281 apply_redirects(&mut analysis, redirects, cwd)?;
1282 Ok(analysis)
1283}
1284
1285fn analyze_tee(
1286 _argv: &[String],
1287 redirects: &[ShellRedirect],
1288 cwd: &Path,
1289) -> Result<SingleCommandAnalysis> {
1290 let mut analysis = SingleCommandAnalysis::empty(CommandAdapterId::Tee);
1291 analysis.side_effect_profile = SideEffectProfile::WritesExcludedOutputs;
1292 analysis.filtered_output_count = 1;
1293 apply_redirects(&mut analysis, redirects, cwd)?;
1294 Ok(analysis)
1295}
1296
1297fn analyze_grep(
1298 argv: &[String],
1299 redirects: &[ShellRedirect],
1300 cwd: &Path,
1301) -> Result<SingleCommandAnalysis> {
1302 let mut inputs = Vec::new();
1303 let mut explicit_pattern = false;
1304 let mut consumed_pattern = false;
1305 let mut positional_only = false;
1306 let mut index = 1usize;
1307
1308 while index < argv.len() {
1309 let token = argv[index].as_str();
1310 if !positional_only && token == "--" {
1311 positional_only = true;
1312 index += 1;
1313 continue;
1314 }
1315
1316 if !positional_only {
1317 if token == "-e" || token == "--regexp" {
1318 explicit_pattern = true;
1319 index += 2;
1320 continue;
1321 }
1322 if token == "-f" || token == "--file" {
1323 explicit_pattern = true;
1324 if let Some(value) = argv.get(index + 1) {
1325 push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1326 }
1327 index += 2;
1328 continue;
1329 }
1330 if let Some(value) = token.strip_prefix("--regexp=") {
1331 explicit_pattern = true;
1332 let _ = value;
1333 index += 1;
1334 continue;
1335 }
1336 if let Some(value) = token.strip_prefix("--file=") {
1337 explicit_pattern = true;
1338 push_inferred_input(&mut inputs, value, cwd)?;
1339 index += 1;
1340 continue;
1341 }
1342 if let Some(option) = parse_grep_short_pattern_option(token) {
1343 explicit_pattern = true;
1344 match option {
1345 GrepShortPatternOption::Inline => {}
1346 GrepShortPatternOption::Next => {
1347 index += 2;
1348 continue;
1349 }
1350 GrepShortPatternOption::FileInline(value) => {
1351 push_inferred_input(&mut inputs, value, cwd)?;
1352 }
1353 GrepShortPatternOption::FileNext => {
1354 if let Some(value) = argv.get(index + 1) {
1355 push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1356 }
1357 index += 2;
1358 continue;
1359 }
1360 }
1361 index += 1;
1362 continue;
1363 }
1364 if token.starts_with('-') {
1365 index += 1;
1366 continue;
1367 }
1368 }
1369
1370 if !explicit_pattern && !consumed_pattern {
1371 consumed_pattern = true;
1372 } else if token != "-" {
1373 push_inferred_input(&mut inputs, token, cwd)?;
1374 }
1375 index += 1;
1376 }
1377
1378 let mut analysis = SingleCommandAnalysis {
1379 inputs,
1380 adapter_ids: vec![CommandAdapterId::Grep],
1381 fallback_used: false,
1382 default_watch_root_used: false,
1383 filtered_output_count: 0,
1384 side_effect_profile: SideEffectProfile::ReadOnly,
1385 status: CommandAnalysisStatus::NoInputs,
1386 };
1387 apply_redirects(&mut analysis, redirects, cwd)?;
1388 Ok(analysis)
1389}
1390
1391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1392enum GrepShortPatternOption<'a> {
1393 Inline,
1394 Next,
1395 FileInline(&'a str),
1396 FileNext,
1397}
1398
1399fn parse_grep_short_pattern_option(token: &str) -> Option<GrepShortPatternOption<'_>> {
1400 if !token.starts_with('-') || token == "-" || token.starts_with("--") {
1401 return None;
1402 }
1403
1404 let flags = token.trim_start_matches('-');
1405 for (index, flag) in flags.char_indices() {
1406 let value = &flags[index + flag.len_utf8()..];
1407 match flag {
1408 'e' if value.is_empty() => return Some(GrepShortPatternOption::Next),
1409 'e' => return Some(GrepShortPatternOption::Inline),
1410 'f' if value.is_empty() => return Some(GrepShortPatternOption::FileNext),
1411 'f' => return Some(GrepShortPatternOption::FileInline(value)),
1412 _ => {}
1413 }
1414 }
1415
1416 None
1417}
1418
1419fn analyze_sed(
1420 argv: &[String],
1421 redirects: &[ShellRedirect],
1422 cwd: &Path,
1423) -> Result<SingleCommandAnalysis> {
1424 let mut inputs = Vec::new();
1425 let mut explicit_script = false;
1426 let mut consumed_script = false;
1427 let mut in_place = false;
1428 let mut positional_only = false;
1429 let mut index = 1usize;
1430
1431 while index < argv.len() {
1432 let token = argv[index].as_str();
1433 if !positional_only && token == "--" {
1434 positional_only = true;
1435 index += 1;
1436 continue;
1437 }
1438
1439 if !positional_only {
1440 if token == "-e" || token == "--expression" {
1441 explicit_script = true;
1442 index += 2;
1443 continue;
1444 }
1445 if token == "-f" || token == "--file" {
1446 explicit_script = true;
1447 if let Some(value) = argv.get(index + 1) {
1448 push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1449 }
1450 index += 2;
1451 continue;
1452 }
1453 if token == "-i" || token == "--in-place" {
1454 in_place = true;
1455 if argv
1456 .get(index + 1)
1457 .is_some_and(|value| !value.starts_with('-'))
1458 {
1459 index += 2;
1460 } else {
1461 index += 1;
1462 }
1463 continue;
1464 }
1465 if token.starts_with("--expression=") {
1466 explicit_script = true;
1467 index += 1;
1468 continue;
1469 }
1470 if let Some(value) = token.strip_prefix("--file=") {
1471 explicit_script = true;
1472 push_inferred_input(&mut inputs, value, cwd)?;
1473 index += 1;
1474 continue;
1475 }
1476 if token.starts_with("--in-place=") || token.starts_with("-i") {
1477 in_place = true;
1478 index += 1;
1479 continue;
1480 }
1481 if token.starts_with("-e") && token.len() > 2 {
1482 explicit_script = true;
1483 index += 1;
1484 continue;
1485 }
1486 if let Some(value) = token.strip_prefix("-f") {
1487 if !value.is_empty() {
1488 explicit_script = true;
1489 push_inferred_input(&mut inputs, value, cwd)?;
1490 index += 1;
1491 continue;
1492 }
1493 }
1494 if token.starts_with('-') {
1495 index += 1;
1496 continue;
1497 }
1498 }
1499
1500 if !explicit_script && !consumed_script {
1501 consumed_script = true;
1502 } else if token != "-" {
1503 push_inferred_input(&mut inputs, token, cwd)?;
1504 }
1505 index += 1;
1506 }
1507
1508 let mut analysis = SingleCommandAnalysis {
1509 inputs,
1510 adapter_ids: vec![CommandAdapterId::Sed],
1511 fallback_used: false,
1512 default_watch_root_used: false,
1513 filtered_output_count: 0,
1514 side_effect_profile: if in_place {
1515 SideEffectProfile::WritesWatchedInputs
1516 } else {
1517 SideEffectProfile::ReadOnly
1518 },
1519 status: CommandAnalysisStatus::NoInputs,
1520 };
1521 apply_redirects(&mut analysis, redirects, cwd)?;
1522 Ok(analysis)
1523}
1524
1525fn analyze_awk(
1526 argv: &[String],
1527 redirects: &[ShellRedirect],
1528 cwd: &Path,
1529) -> Result<SingleCommandAnalysis> {
1530 let mut inputs = Vec::new();
1531 let mut explicit_program = false;
1532 let mut consumed_program = false;
1533 let mut positional_only = false;
1534 let mut index = 1usize;
1535
1536 while index < argv.len() {
1537 let token = argv[index].as_str();
1538 if !positional_only && token == "--" {
1539 positional_only = true;
1540 index += 1;
1541 continue;
1542 }
1543
1544 if !positional_only {
1545 if token == "-f" || token == "--file" {
1546 explicit_program = true;
1547 if let Some(value) = argv.get(index + 1) {
1548 push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1549 }
1550 index += 2;
1551 continue;
1552 }
1553 if token == "-v" || token == "-F" {
1554 index += 2;
1555 continue;
1556 }
1557 if let Some(value) = token.strip_prefix("--file=") {
1558 explicit_program = true;
1559 push_inferred_input(&mut inputs, value, cwd)?;
1560 index += 1;
1561 continue;
1562 }
1563 if let Some(value) = token.strip_prefix("-f") {
1564 if !value.is_empty() {
1565 explicit_program = true;
1566 push_inferred_input(&mut inputs, value, cwd)?;
1567 index += 1;
1568 continue;
1569 }
1570 }
1571 if token.starts_with("-v") || token.starts_with("-F") || token.starts_with('-') {
1572 index += 1;
1573 continue;
1574 }
1575 }
1576
1577 if !explicit_program && !consumed_program {
1578 consumed_program = true;
1579 } else if token != "-" && !looks_like_variable_assignment(token) {
1580 push_inferred_input(&mut inputs, token, cwd)?;
1581 }
1582 index += 1;
1583 }
1584
1585 let mut analysis = SingleCommandAnalysis {
1586 inputs,
1587 adapter_ids: vec![CommandAdapterId::Awk],
1588 fallback_used: false,
1589 default_watch_root_used: false,
1590 filtered_output_count: 0,
1591 side_effect_profile: SideEffectProfile::ReadOnly,
1592 status: CommandAnalysisStatus::NoInputs,
1593 };
1594 apply_redirects(&mut analysis, redirects, cwd)?;
1595 Ok(analysis)
1596}
1597
1598fn analyze_find(
1599 argv: &[String],
1600 redirects: &[ShellRedirect],
1601 cwd: &Path,
1602) -> Result<SingleCommandAnalysis> {
1603 let mut inputs = Vec::new();
1604 let mut saw_expression = false;
1605 let mut index = 1usize;
1606
1607 while index < argv.len() {
1608 let token = argv[index].as_str();
1609 if token == "--" {
1610 index += 1;
1611 continue;
1612 }
1613 if !saw_expression {
1614 if let Some(next_index) = consume_find_global_option(argv, index) {
1615 index = next_index;
1616 continue;
1617 }
1618 }
1619 if !saw_expression && !is_find_expression_token(token) {
1620 push_inferred_input(&mut inputs, token, cwd)?;
1621 } else {
1622 saw_expression = true;
1623 }
1624 index += 1;
1625 }
1626
1627 let mut analysis = SingleCommandAnalysis {
1628 inputs,
1629 adapter_ids: vec![CommandAdapterId::Find],
1630 fallback_used: false,
1631 default_watch_root_used: false,
1632 filtered_output_count: 0,
1633 side_effect_profile: SideEffectProfile::ReadOnly,
1634 status: CommandAnalysisStatus::NoInputs,
1635 };
1636
1637 if analysis.inputs.is_empty() {
1638 push_inferred_input(&mut analysis.inputs, ".", cwd)?;
1639 analysis.default_watch_root_used = true;
1640 }
1641
1642 apply_redirects(&mut analysis, redirects, cwd)?;
1643 Ok(analysis)
1644}
1645
1646fn consume_find_global_option(argv: &[String], index: usize) -> Option<usize> {
1647 let token = argv[index].as_str();
1648 match token {
1649 "-H" | "-L" | "-P" => Some(index + 1),
1650 "-D" => Some((index + 2).min(argv.len())),
1651 "-O" => {
1652 if argv
1653 .get(index + 1)
1654 .is_some_and(|value| is_unsigned_integer(value))
1655 {
1656 Some(index + 2)
1657 } else {
1658 Some(index + 1)
1659 }
1660 }
1661 _ if token.starts_with("-D") && token.len() > 2 => Some(index + 1),
1662 _ if token.starts_with("-O") && token.len() > 2 => Some(index + 1),
1663 _ => None,
1664 }
1665}
1666
1667fn is_unsigned_integer(token: &str) -> bool {
1668 let trimmed = token.trim();
1669 !trimmed.is_empty() && trimmed.chars().all(|character| character.is_ascii_digit())
1670}
1671
1672fn analyze_xargs(
1673 argv: &[String],
1674 redirects: &[ShellRedirect],
1675 cwd: &Path,
1676) -> Result<SingleCommandAnalysis> {
1677 let mut inputs = Vec::new();
1678 let mut index = 1usize;
1679
1680 while index < argv.len() {
1681 let token = argv[index].as_str();
1682 if token == "--" {
1683 break;
1684 }
1685 if token == "-a" || token == "--arg-file" {
1686 if let Some(value) = argv.get(index + 1) {
1687 push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1688 }
1689 index += 2;
1690 continue;
1691 }
1692 if token == "-I" || token == "-i" || token == "--replace" {
1693 index += 2;
1694 continue;
1695 }
1696 if token == "-n"
1697 || token == "-L"
1698 || token == "-s"
1699 || token == "-P"
1700 || token == "-E"
1701 || token == "--delimiter"
1702 || token == "--eof"
1703 || token == "--max-args"
1704 || token == "--max-lines"
1705 || token == "--max-procs"
1706 || token == "--max-chars"
1707 {
1708 index += 2;
1709 continue;
1710 }
1711 if let Some(value) = token.strip_prefix("--arg-file=") {
1712 push_inferred_input(&mut inputs, value, cwd)?;
1713 }
1714 index += 1;
1715 }
1716
1717 let mut analysis = SingleCommandAnalysis {
1718 inputs,
1719 adapter_ids: vec![CommandAdapterId::Xargs],
1720 fallback_used: false,
1721 default_watch_root_used: false,
1722 filtered_output_count: 0,
1723 side_effect_profile: SideEffectProfile::ReadOnly,
1724 status: CommandAnalysisStatus::NoInputs,
1725 };
1726 apply_redirects(&mut analysis, redirects, cwd)?;
1727 Ok(analysis)
1728}
1729
1730fn analyze_tar(
1731 argv: &[String],
1732 redirects: &[ShellRedirect],
1733 cwd: &Path,
1734) -> Result<SingleCommandAnalysis> {
1735 let mut inputs = Vec::new();
1736 let mut filtered_output_count = 0usize;
1737 let mut mode = TarMode::Unknown;
1738 let mut archive_path = None::<String>;
1739 let mut positional_operands = Vec::new();
1740 let mut positional_only = false;
1741 let mut index = 1usize;
1742
1743 while index < argv.len() {
1744 let token = argv[index].as_str();
1745 if !positional_only && token == "--" {
1746 positional_only = true;
1747 index += 1;
1748 continue;
1749 }
1750
1751 if !positional_only {
1752 if token == "-f" || token == "--file" {
1753 if let Some(value) = argv.get(index + 1) {
1754 archive_path = Some(value.clone());
1755 }
1756 index += 2;
1757 continue;
1758 }
1759 if token == "-C" || token == "--directory" {
1760 filtered_output_count += usize::from(matches!(mode, TarMode::ReadArchive));
1761 index += 2;
1762 continue;
1763 }
1764 if let Some(value) = token.strip_prefix("--file=") {
1765 archive_path = Some(value.to_string());
1766 index += 1;
1767 continue;
1768 }
1769 if let Some(value) = token.strip_prefix("--directory=") {
1770 if matches!(mode, TarMode::ReadArchive) {
1771 let _ = value;
1772 filtered_output_count += 1;
1773 }
1774 index += 1;
1775 continue;
1776 }
1777 if token.starts_with("--create")
1778 || token.starts_with("--append")
1779 || token.starts_with("--update")
1780 {
1781 mode = TarMode::CreateLike;
1782 index += 1;
1783 continue;
1784 }
1785 if token.starts_with("--extract")
1786 || token.starts_with("--get")
1787 || token.starts_with("--list")
1788 || token.starts_with("--diff")
1789 || token.starts_with("--compare")
1790 {
1791 mode = TarMode::ReadArchive;
1792 index += 1;
1793 continue;
1794 }
1795 if token.starts_with('-') {
1796 mode = mode.merge(parse_tar_short_mode(token));
1797 if let Some(archive_value) = parse_tar_short_archive(token) {
1798 archive_path = Some(archive_value);
1799 index += 1;
1800 continue;
1801 }
1802 if tar_short_option_consumes_next_archive(token) {
1803 if let Some(value) = argv.get(index + 1) {
1804 archive_path = Some(value.clone());
1805 }
1806 index += 2;
1807 continue;
1808 }
1809 index += 1;
1810 continue;
1811 }
1812 }
1813
1814 positional_operands.push(argv[index].clone());
1815 index += 1;
1816 }
1817
1818 match mode {
1819 TarMode::CreateLike => {
1820 if let Some(archive_path) = archive_path {
1821 let _ = archive_path;
1822 filtered_output_count += 1;
1823 }
1824 for operand in positional_operands {
1825 push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
1826 }
1827 }
1828 TarMode::ReadArchive | TarMode::Unknown => {
1829 if let Some(archive_path) = archive_path {
1830 push_inferred_input(&mut inputs, archive_path.as_str(), cwd)?;
1831 }
1832 }
1833 }
1834
1835 let mut analysis = SingleCommandAnalysis {
1836 inputs,
1837 adapter_ids: vec![CommandAdapterId::Tar],
1838 fallback_used: false,
1839 default_watch_root_used: false,
1840 filtered_output_count,
1841 side_effect_profile: if matches!(mode, TarMode::CreateLike) {
1842 SideEffectProfile::WritesExcludedOutputs
1843 } else {
1844 SideEffectProfile::ReadOnly
1845 },
1846 status: CommandAnalysisStatus::NoInputs,
1847 };
1848 apply_redirects(&mut analysis, redirects, cwd)?;
1849 Ok(analysis)
1850}
1851
1852#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1853enum TarMode {
1854 Unknown,
1855 CreateLike,
1856 ReadArchive,
1857}
1858
1859impl TarMode {
1860 fn merge(self, other: Self) -> Self {
1861 match (self, other) {
1862 (Self::CreateLike, _) | (_, Self::CreateLike) => Self::CreateLike,
1863 (Self::ReadArchive, _) | (_, Self::ReadArchive) => Self::ReadArchive,
1864 _ => Self::Unknown,
1865 }
1866 }
1867}
1868
1869fn parse_tar_short_mode(token: &str) -> TarMode {
1870 let raw = token.trim_start_matches('-');
1871 if raw.contains('c') || raw.contains('r') || raw.contains('u') {
1872 TarMode::CreateLike
1873 } else if raw.contains('x') || raw.contains('t') || raw.contains('d') {
1874 TarMode::ReadArchive
1875 } else {
1876 TarMode::Unknown
1877 }
1878}
1879
1880fn parse_tar_short_archive(token: &str) -> Option<String> {
1881 let raw = token.trim_start_matches('-');
1882 let index = raw.find('f')?;
1883 let attached = &raw[index + 1..];
1884 if attached.is_empty() {
1885 None
1886 } else {
1887 Some(attached.to_string())
1888 }
1889}
1890
1891fn tar_short_option_consumes_next_archive(token: &str) -> bool {
1892 let raw = token.trim_start_matches('-');
1893 raw.contains('f') && parse_tar_short_archive(token).is_none()
1894}
1895
1896fn analyze_touch_like(
1897 argv: &[String],
1898 adapter_id: CommandAdapterId,
1899 redirects: &[ShellRedirect],
1900 cwd: &Path,
1901) -> Result<SingleCommandAnalysis> {
1902 let mut inputs = Vec::new();
1903 let mut positional_only = false;
1904 let mut index = 1usize;
1905
1906 while index < argv.len() {
1907 let token = argv[index].as_str();
1908 if !positional_only && token == "--" {
1909 positional_only = true;
1910 index += 1;
1911 continue;
1912 }
1913 if !positional_only {
1914 if token == "-r" || token == "--reference" {
1915 if let Some(value) = argv.get(index + 1) {
1916 push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1917 }
1918 index += 2;
1919 continue;
1920 }
1921 if let Some(value) = token.strip_prefix("--reference=") {
1922 push_inferred_input(&mut inputs, value, cwd)?;
1923 index += 1;
1924 continue;
1925 }
1926 if token.starts_with('-') {
1927 index += 1;
1928 continue;
1929 }
1930 }
1931 push_inferred_input(&mut inputs, token, cwd)?;
1932 index += 1;
1933 }
1934
1935 let mut analysis = SingleCommandAnalysis {
1936 inputs,
1937 adapter_ids: vec![adapter_id],
1938 fallback_used: false,
1939 default_watch_root_used: false,
1940 filtered_output_count: 0,
1941 side_effect_profile: SideEffectProfile::WritesWatchedInputs,
1942 status: CommandAnalysisStatus::NoInputs,
1943 };
1944 apply_redirects(&mut analysis, redirects, cwd)?;
1945 Ok(analysis)
1946}
1947
1948fn analyze_change_attributes(
1949 argv: &[String],
1950 redirects: &[ShellRedirect],
1951 cwd: &Path,
1952) -> Result<SingleCommandAnalysis> {
1953 let mut inputs = Vec::new();
1954 let mut positional_only = false;
1955 let mut index = 1usize;
1956 let mut metadata_arg_consumed = false;
1957
1958 while index < argv.len() {
1959 let token = argv[index].as_str();
1960 if !positional_only && token == "--" {
1961 positional_only = true;
1962 index += 1;
1963 continue;
1964 }
1965 if !positional_only {
1966 if token == "--reference" {
1967 if let Some(value) = argv.get(index + 1) {
1968 push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1969 }
1970 index += 2;
1971 continue;
1972 }
1973 if let Some(value) = token.strip_prefix("--reference=") {
1974 push_inferred_input(&mut inputs, value, cwd)?;
1975 index += 1;
1976 continue;
1977 }
1978 if token.starts_with('-') {
1979 index += 1;
1980 continue;
1981 }
1982 }
1983 if !metadata_arg_consumed {
1984 metadata_arg_consumed = true;
1985 } else {
1986 push_inferred_input(&mut inputs, token, cwd)?;
1987 }
1988 index += 1;
1989 }
1990
1991 let mut analysis = SingleCommandAnalysis {
1992 inputs,
1993 adapter_ids: vec![CommandAdapterId::ChangeAttributes],
1994 fallback_used: false,
1995 default_watch_root_used: false,
1996 filtered_output_count: 0,
1997 side_effect_profile: SideEffectProfile::WritesWatchedInputs,
1998 status: CommandAnalysisStatus::NoInputs,
1999 };
2000 apply_redirects(&mut analysis, redirects, cwd)?;
2001 Ok(analysis)
2002}
2003
2004fn analyze_dd(
2005 argv: &[String],
2006 redirects: &[ShellRedirect],
2007 cwd: &Path,
2008) -> Result<SingleCommandAnalysis> {
2009 let mut inputs = Vec::new();
2010 let mut filtered_output_count = 0usize;
2011
2012 for token in argv.iter().skip(1) {
2013 if let Some(value) = token.strip_prefix("if=") {
2014 push_inferred_input(&mut inputs, value, cwd)?;
2015 } else if token.starts_with("iflag=") {
2016 } else if token.starts_with("of=") || token.starts_with("seek=") {
2017 filtered_output_count += 1;
2018 }
2019 }
2020
2021 let mut analysis = SingleCommandAnalysis {
2022 inputs,
2023 adapter_ids: vec![CommandAdapterId::Dd],
2024 fallback_used: false,
2025 default_watch_root_used: false,
2026 filtered_output_count,
2027 side_effect_profile: if filtered_output_count > 0 {
2028 SideEffectProfile::WritesExcludedOutputs
2029 } else {
2030 SideEffectProfile::ReadOnly
2031 },
2032 status: CommandAnalysisStatus::NoInputs,
2033 };
2034 apply_redirects(&mut analysis, redirects, cwd)?;
2035 Ok(analysis)
2036}
2037
2038fn analyze_default_current_dir_reader(
2039 argv: &[String],
2040 redirects: &[ShellRedirect],
2041 cwd: &Path,
2042) -> Result<SingleCommandAnalysis> {
2043 let mut inputs = Vec::new();
2044 let mut positional_only = false;
2045 let mut index = 1usize;
2046
2047 while index < argv.len() {
2048 let token = argv[index].as_str();
2049 if !positional_only && token == "--" {
2050 positional_only = true;
2051 index += 1;
2052 continue;
2053 }
2054 if !positional_only && token.starts_with('-') {
2055 index += 1;
2056 continue;
2057 }
2058 push_inferred_input(&mut inputs, token, cwd)?;
2059 index += 1;
2060 }
2061
2062 let mut analysis = SingleCommandAnalysis {
2063 inputs,
2064 adapter_ids: vec![CommandAdapterId::DefaultCurrentDir],
2065 fallback_used: false,
2066 default_watch_root_used: false,
2067 filtered_output_count: 0,
2068 side_effect_profile: SideEffectProfile::ReadOnly,
2069 status: CommandAnalysisStatus::NoInputs,
2070 };
2071
2072 if analysis.inputs.is_empty() {
2073 push_inferred_input(&mut analysis.inputs, ".", cwd)?;
2074 analysis.default_watch_root_used = true;
2075 }
2076
2077 apply_redirects(&mut analysis, redirects, cwd)?;
2078 Ok(analysis)
2079}
2080
2081fn analyze_ls_like(
2082 argv: &[String],
2083 redirects: &[ShellRedirect],
2084 cwd: &Path,
2085) -> Result<SingleCommandAnalysis> {
2086 let mut inputs = Vec::new();
2087 let mut positional_only = false;
2088 let mut recursive = false;
2089 let mut directory_mode = false;
2090 let mut index = 1usize;
2091
2092 while index < argv.len() {
2093 let token = argv[index].as_str();
2094 if !positional_only && token == "--" {
2095 positional_only = true;
2096 index += 1;
2097 continue;
2098 }
2099
2100 if !positional_only {
2101 if token == "-R" || token == "--recursive" {
2102 recursive = true;
2103 index += 1;
2104 continue;
2105 }
2106 if token == "-d" || token == "--directory" {
2107 directory_mode = true;
2108 index += 1;
2109 continue;
2110 }
2111 if token.starts_with("--") {
2112 index += 1;
2113 continue;
2114 }
2115 if token.starts_with('-') && token != "-" {
2116 recursive |= token.contains('R');
2117 directory_mode |= token.contains('d');
2118 index += 1;
2119 continue;
2120 }
2121 }
2122
2123 push_inferred_path_with_mode(
2124 &mut inputs,
2125 token,
2126 cwd,
2127 ls_like_snapshot_mode(token, cwd, recursive, directory_mode),
2128 )?;
2129 index += 1;
2130 }
2131
2132 if inputs.is_empty() {
2133 push_inferred_path_with_mode(
2134 &mut inputs,
2135 ".",
2136 cwd,
2137 default_ls_snapshot_mode(recursive, directory_mode),
2138 )?;
2139 }
2140
2141 let mut analysis = SingleCommandAnalysis {
2142 inputs,
2143 adapter_ids: vec![CommandAdapterId::DefaultCurrentDir],
2144 fallback_used: false,
2145 default_watch_root_used: false,
2146 filtered_output_count: 0,
2147 side_effect_profile: SideEffectProfile::ReadOnly,
2148 status: CommandAnalysisStatus::NoInputs,
2149 };
2150
2151 if argv.len() == 1
2152 || argv
2153 .iter()
2154 .skip(1)
2155 .all(|token| token == "--" || (token.starts_with('-') && token != "-"))
2156 {
2157 analysis.default_watch_root_used = true;
2158 }
2159
2160 apply_redirects(&mut analysis, redirects, cwd)?;
2161 Ok(analysis)
2162}
2163
2164fn default_ls_snapshot_mode(recursive: bool, directory_mode: bool) -> PathSnapshotMode {
2165 if directory_mode {
2166 PathSnapshotMode::MetadataPath
2167 } else if recursive {
2168 PathSnapshotMode::MetadataTree
2169 } else {
2170 PathSnapshotMode::MetadataChildren
2171 }
2172}
2173
2174fn ls_like_snapshot_mode(
2175 raw: &str,
2176 cwd: &Path,
2177 recursive: bool,
2178 directory_mode: bool,
2179) -> PathSnapshotMode {
2180 if directory_mode {
2181 return PathSnapshotMode::MetadataPath;
2182 }
2183
2184 let absolute_path = absolutize(raw, cwd);
2185 match fs::metadata(&absolute_path) {
2186 Ok(metadata) if metadata.is_dir() => {
2187 if recursive {
2188 PathSnapshotMode::MetadataTree
2189 } else {
2190 PathSnapshotMode::MetadataChildren
2191 }
2192 }
2193 Ok(_) | Err(_) => PathSnapshotMode::MetadataPath,
2194 }
2195}
2196
2197fn analyze_non_watchable(
2198 _argv: &[String],
2199 redirects: &[ShellRedirect],
2200 cwd: &Path,
2201) -> Result<SingleCommandAnalysis> {
2202 let mut analysis = SingleCommandAnalysis::empty(CommandAdapterId::NonWatchable);
2203 apply_redirects(&mut analysis, redirects, cwd)?;
2204 Ok(analysis)
2205}
2206
2207fn analyze_generic_read_paths(
2208 argv: &[String],
2209 redirects: &[ShellRedirect],
2210 cwd: &Path,
2211) -> Result<SingleCommandAnalysis> {
2212 let mut inputs = Vec::new();
2213 let mut positional_only = false;
2214 let mut index = 1usize;
2215
2216 while index < argv.len() {
2217 let token = argv[index].as_str();
2218 if !positional_only && token == "--" {
2219 positional_only = true;
2220 index += 1;
2221 continue;
2222 }
2223 if !positional_only {
2224 if try_push_path_option_value(&mut inputs, argv, &mut index, cwd)? {
2225 continue;
2226 }
2227 if token.starts_with('-') {
2228 index += 1;
2229 continue;
2230 }
2231 }
2232 if token != "-" {
2233 push_inferred_input(&mut inputs, token, cwd)?;
2234 }
2235 index += 1;
2236 }
2237
2238 let mut analysis = SingleCommandAnalysis {
2239 inputs,
2240 adapter_ids: vec![CommandAdapterId::ReadPaths],
2241 fallback_used: false,
2242 default_watch_root_used: false,
2243 filtered_output_count: 0,
2244 side_effect_profile: SideEffectProfile::ReadOnly,
2245 status: CommandAnalysisStatus::NoInputs,
2246 };
2247 apply_redirects(&mut analysis, redirects, cwd)?;
2248 Ok(analysis)
2249}
2250
2251fn analyze_fallback(
2252 argv: &[String],
2253 redirects: &[ShellRedirect],
2254 cwd: &Path,
2255) -> Result<SingleCommandAnalysis> {
2256 let mut inputs = Vec::new();
2257 let mut ambiguous_missing = Vec::new();
2258 let mut positional_only = false;
2259 let mut index = 1usize;
2260
2261 while index < argv.len() {
2262 let token = argv[index].as_str();
2263 if !positional_only && token == "--" {
2264 positional_only = true;
2265 index += 1;
2266 continue;
2267 }
2268 if !positional_only {
2269 if try_push_path_option_value(&mut inputs, argv, &mut index, cwd)? {
2270 continue;
2271 }
2272 if token.starts_with('-') {
2273 index += 1;
2274 continue;
2275 }
2276 }
2277
2278 if should_ignore_fallback_token(token) {
2279 index += 1;
2280 continue;
2281 }
2282
2283 if has_glob_magic(token) || path_exists(token, cwd) {
2284 push_inferred_input(&mut inputs, token, cwd)?;
2285 } else if is_path_shaped(token) {
2286 ambiguous_missing.push(token.to_string());
2287 }
2288 index += 1;
2289 }
2290
2291 let mut analysis = SingleCommandAnalysis {
2292 inputs,
2293 adapter_ids: vec![CommandAdapterId::Fallback],
2294 fallback_used: true,
2295 default_watch_root_used: false,
2296 filtered_output_count: 0,
2297 side_effect_profile: SideEffectProfile::ReadOnly,
2298 status: CommandAnalysisStatus::NoInputs,
2299 };
2300
2301 if ambiguous_missing.len() > 1 {
2302 analysis.status = CommandAnalysisStatus::AmbiguousFallback;
2303 } else if let Some(token) = ambiguous_missing.first() {
2304 push_inferred_input(&mut analysis.inputs, token, cwd)?;
2305 }
2306
2307 apply_redirects(&mut analysis, redirects, cwd)?;
2308 Ok(analysis)
2309}
2310
2311fn apply_redirects(
2312 analysis: &mut SingleCommandAnalysis,
2313 redirects: &[ShellRedirect],
2314 cwd: &Path,
2315) -> Result<()> {
2316 for redirect in redirects {
2317 if is_dynamic_shell_token(redirect.target.as_str()) {
2318 continue;
2319 }
2320 if redirect.operator.reads_input() {
2321 push_inferred_input(&mut analysis.inputs, redirect.target.as_str(), cwd)?;
2322 } else if redirect.operator.writes_output()
2323 || matches!(redirect.operator, ShellRedirectOperator::Other(_))
2324 {
2325 analysis.filtered_output_count += 1;
2326 }
2327 }
2328 Ok(())
2329}
2330
2331fn try_push_path_option_value(
2332 inputs: &mut Vec<WatchInput>,
2333 argv: &[String],
2334 index: &mut usize,
2335 cwd: &Path,
2336) -> Result<bool> {
2337 let token = argv[*index].as_str();
2338 if let Some((option_name, value)) = split_long_option(token) {
2339 if is_path_option_name(option_name) {
2340 push_inferred_input(inputs, value, cwd)?;
2341 *index += 1;
2342 return Ok(true);
2343 }
2344 return Ok(false);
2345 }
2346
2347 if token.starts_with("--") && is_path_option_name(token) {
2348 if let Some(value) = argv.get(*index + 1) {
2349 push_inferred_input(inputs, value.as_str(), cwd)?;
2350 }
2351 *index += 2;
2352 return Ok(true);
2353 }
2354
2355 Ok(false)
2356}
2357
2358fn split_long_option(token: &str) -> Option<(&str, &str)> {
2359 if !token.starts_with("--") {
2360 return None;
2361 }
2362 let (name, value) = token.split_once('=')?;
2363 Some((name, value))
2364}
2365
2366fn is_path_option_name(option_name: &str) -> bool {
2367 matches!(
2368 option_name
2369 .trim_start_matches('-')
2370 .to_ascii_lowercase()
2371 .as_str(),
2372 "file"
2373 | "files"
2374 | "files0-from"
2375 | "path"
2376 | "paths"
2377 | "dir"
2378 | "directory"
2379 | "input"
2380 | "inputs"
2381 | "from"
2382 | "glob"
2383 | "arg-file"
2384 | "reference"
2385 )
2386}
2387
2388fn push_inferred_input(inputs: &mut Vec<WatchInput>, raw: &str, cwd: &Path) -> Result<()> {
2389 let trimmed = raw.trim();
2390 if trimmed.is_empty() || trimmed == "-" {
2391 return Ok(());
2392 }
2393
2394 let input = if has_glob_magic(trimmed) {
2395 WatchInput::glob(trimmed, cwd)?
2396 } else {
2397 WatchInput::path(trimmed, cwd, WatchInputKind::Inferred)?
2398 };
2399
2400 if !inputs.contains(&input) {
2401 inputs.push(input);
2402 }
2403
2404 Ok(())
2405}
2406
2407fn push_inferred_path_with_mode(
2408 inputs: &mut Vec<WatchInput>,
2409 raw: &str,
2410 cwd: &Path,
2411 snapshot_mode: PathSnapshotMode,
2412) -> Result<()> {
2413 let trimmed = raw.trim();
2414 if trimmed.is_empty() || trimmed == "-" {
2415 return Ok(());
2416 }
2417
2418 let input =
2419 WatchInput::path_with_snapshot_mode(trimmed, cwd, WatchInputKind::Inferred, snapshot_mode)?;
2420
2421 if !inputs.contains(&input) {
2422 inputs.push(input);
2423 }
2424
2425 Ok(())
2426}
2427
2428fn has_glob_magic(raw: &str) -> bool {
2429 raw.contains('*') || raw.contains('?') || raw.contains('[')
2430}
2431
2432fn path_exists(raw: &str, cwd: &Path) -> bool {
2433 absolutize(raw, cwd).exists()
2434}
2435
2436fn is_path_shaped(token: &str) -> bool {
2437 token.starts_with('/')
2438 || token.starts_with("./")
2439 || token.starts_with("../")
2440 || token.starts_with("~/")
2441 || token.contains('/')
2442 || token.contains('\\')
2443 || token.contains('.')
2444}
2445
2446fn looks_like_variable_assignment(token: &str) -> bool {
2447 let Some((name, _value)) = token.split_once('=') else {
2448 return false;
2449 };
2450 !name.is_empty()
2451 && name
2452 .chars()
2453 .all(|character| character == '_' || character.is_ascii_alphanumeric())
2454}
2455
2456fn is_signed_integer(token: &str) -> bool {
2457 let trimmed = token.trim();
2458 if trimmed.is_empty() {
2459 return false;
2460 }
2461 let rest = trimmed
2462 .strip_prefix('+')
2463 .or_else(|| trimmed.strip_prefix('-'))
2464 .unwrap_or(trimmed);
2465 !rest.is_empty() && rest.chars().all(|character| character.is_ascii_digit())
2466}
2467
2468fn is_find_expression_token(token: &str) -> bool {
2469 token == "!" || token == "(" || token == ")" || token.starts_with('-') || token.starts_with(',')
2470}
2471
2472fn should_ignore_fallback_token(token: &str) -> bool {
2473 token.is_empty()
2474 || is_signed_integer(token)
2475 || looks_like_variable_assignment(token)
2476 || is_dynamic_shell_token(token)
2477}
2478
2479fn is_dynamic_shell_token(token: &str) -> bool {
2480 token.starts_with("$(")
2481 || token.starts_with("`")
2482 || token.starts_with("<(")
2483 || token.starts_with(">(")
2484 || token.starts_with("${")
2485 || token == "$@"
2486 || token == "$*"
2487}
2488
2489#[cfg(test)]
2490mod tests {
2491 use std::{collections::BTreeSet, ffi::OsString, fs};
2492
2493 use super::{
2494 analyze_argv, analyze_shell_expression, help_inventory, render_after_long_help,
2495 CommandAdapterId, CommandAnalysisStatus, SideEffectProfile,
2496 };
2497 use crate::{
2498 parser::parse_shell_expression,
2499 snapshot::{PathSnapshotMode, WatchInput},
2500 };
2501
2502 #[test]
2503 fn cp_watches_only_sources() {
2504 let cwd = tempfile::tempdir().expect("create tempdir");
2505 let analysis = analyze_argv(
2506 &[
2507 OsString::from("cp"),
2508 OsString::from("src.txt"),
2509 OsString::from("dest.txt"),
2510 ],
2511 cwd.path(),
2512 )
2513 .expect("analyze");
2514
2515 assert_eq!(analysis.adapter_ids, vec![CommandAdapterId::CopyLike]);
2516 assert_eq!(analysis.inputs.len(), 1);
2517 assert_eq!(analysis.filtered_output_count, 1);
2518 assert_eq!(
2519 analysis.side_effect_profile,
2520 SideEffectProfile::WritesExcludedOutputs
2521 );
2522 }
2523
2524 #[test]
2525 fn mv_marks_watched_inputs_as_self_mutating() {
2526 let cwd = tempfile::tempdir().expect("create tempdir");
2527 let analysis = analyze_argv(
2528 &[
2529 OsString::from("mv"),
2530 OsString::from("src.txt"),
2531 OsString::from("dest.txt"),
2532 ],
2533 cwd.path(),
2534 )
2535 .expect("analyze");
2536
2537 assert_eq!(analysis.inputs.len(), 1);
2538 assert_eq!(
2539 analysis.side_effect_profile,
2540 SideEffectProfile::WritesWatchedInputs
2541 );
2542 }
2543
2544 #[test]
2545 fn grep_ignores_pattern_but_keeps_pattern_files() {
2546 let cwd = tempfile::tempdir().expect("create tempdir");
2547 let analysis = analyze_argv(
2548 &[
2549 OsString::from("grep"),
2550 OsString::from("hello"),
2551 OsString::from("file.txt"),
2552 ],
2553 cwd.path(),
2554 )
2555 .expect("analyze");
2556 assert_eq!(analysis.inputs.len(), 1);
2557
2558 let analysis = analyze_argv(
2559 &[
2560 OsString::from("grep"),
2561 OsString::from("-f"),
2562 OsString::from("patterns.txt"),
2563 OsString::from("file.txt"),
2564 ],
2565 cwd.path(),
2566 )
2567 .expect("analyze");
2568 assert_eq!(analysis.inputs.len(), 2);
2569
2570 let grouped = analyze_argv(
2571 &[
2572 OsString::from("grep"),
2573 OsString::from("-rf"),
2574 OsString::from("patterns.txt"),
2575 OsString::from("src"),
2576 ],
2577 cwd.path(),
2578 )
2579 .expect("analyze");
2580 assert_eq!(grouped.inputs.len(), 2);
2581 }
2582
2583 #[test]
2584 fn sed_and_awk_ignore_inline_scripts() {
2585 let cwd = tempfile::tempdir().expect("create tempdir");
2586 let sed = analyze_argv(
2587 &[
2588 OsString::from("sed"),
2589 OsString::from("-n"),
2590 OsString::from("1,2p"),
2591 OsString::from("file.txt"),
2592 ],
2593 cwd.path(),
2594 )
2595 .expect("analyze");
2596 assert_eq!(sed.inputs.len(), 1);
2597
2598 let awk = analyze_argv(
2599 &[
2600 OsString::from("awk"),
2601 OsString::from("{print $1}"),
2602 OsString::from("file.txt"),
2603 ],
2604 cwd.path(),
2605 )
2606 .expect("analyze");
2607 assert_eq!(awk.inputs.len(), 1);
2608 }
2609
2610 #[test]
2611 fn pathless_allowlist_defaults_to_current_directory() {
2612 let cwd = tempfile::tempdir().expect("create tempdir");
2613 let analysis = analyze_argv(&[OsString::from("ls"), OsString::from("-l")], cwd.path())
2614 .expect("analyze");
2615
2616 assert_eq!(analysis.inputs.len(), 1);
2617 assert!(analysis.default_watch_root_used);
2618 assert_path_snapshot_mode(
2619 &analysis.inputs[0],
2620 cwd.path(),
2621 PathSnapshotMode::MetadataChildren,
2622 );
2623
2624 let analysis = analyze_argv(&[OsString::from("find")], cwd.path()).expect("analyze");
2625 assert_eq!(analysis.inputs.len(), 1);
2626 assert!(analysis.default_watch_root_used);
2627
2628 let analysis = analyze_argv(
2629 &[
2630 OsString::from("find"),
2631 OsString::from("-D"),
2632 OsString::from("stat"),
2633 OsString::from("-name"),
2634 OsString::from("*.rs"),
2635 ],
2636 cwd.path(),
2637 )
2638 .expect("analyze");
2639 assert_eq!(analysis.inputs.len(), 1);
2640 assert!(analysis.default_watch_root_used);
2641
2642 let analysis = analyze_argv(
2643 &[
2644 OsString::from("find"),
2645 OsString::from("-O"),
2646 OsString::from("3"),
2647 OsString::from("-name"),
2648 OsString::from("*.rs"),
2649 ],
2650 cwd.path(),
2651 )
2652 .expect("analyze");
2653 assert_eq!(analysis.inputs.len(), 1);
2654 assert!(analysis.default_watch_root_used);
2655 }
2656
2657 #[test]
2658 fn ls_like_inputs_use_listing_snapshot_modes() {
2659 let cwd = tempfile::tempdir().expect("create tempdir");
2660 fs::create_dir_all(cwd.path().join("subdir").join("nested")).expect("create nested dir");
2661 fs::write(cwd.path().join("file.txt"), "alpha\n").expect("write file");
2662
2663 let default_ls = analyze_argv(&[OsString::from("ls")], cwd.path()).expect("analyze");
2664 assert_path_snapshot_mode(
2665 &default_ls.inputs[0],
2666 cwd.path(),
2667 PathSnapshotMode::MetadataChildren,
2668 );
2669
2670 let directory_ls = analyze_argv(
2671 &[OsString::from("ls"), OsString::from("subdir")],
2672 cwd.path(),
2673 )
2674 .expect("analyze");
2675 assert_path_snapshot_mode(
2676 &directory_ls.inputs[0],
2677 &cwd.path().join("subdir"),
2678 PathSnapshotMode::MetadataChildren,
2679 );
2680
2681 let recursive_ls = analyze_argv(
2682 &[
2683 OsString::from("ls"),
2684 OsString::from("-R"),
2685 OsString::from("subdir"),
2686 ],
2687 cwd.path(),
2688 )
2689 .expect("analyze");
2690 assert_path_snapshot_mode(
2691 &recursive_ls.inputs[0],
2692 &cwd.path().join("subdir"),
2693 PathSnapshotMode::MetadataTree,
2694 );
2695
2696 let directory_flag_ls = analyze_argv(
2697 &[
2698 OsString::from("ls"),
2699 OsString::from("-d"),
2700 OsString::from("subdir"),
2701 ],
2702 cwd.path(),
2703 )
2704 .expect("analyze");
2705 assert_path_snapshot_mode(
2706 &directory_flag_ls.inputs[0],
2707 &cwd.path().join("subdir"),
2708 PathSnapshotMode::MetadataPath,
2709 );
2710 }
2711
2712 #[test]
2713 fn tar_excludes_archive_outputs_for_create_and_reads_archives_for_extract() {
2714 let cwd = tempfile::tempdir().expect("create tempdir");
2715 let create = analyze_argv(
2716 &[
2717 OsString::from("tar"),
2718 OsString::from("-cf"),
2719 OsString::from("out.tar"),
2720 OsString::from("src"),
2721 OsString::from("dir"),
2722 ],
2723 cwd.path(),
2724 )
2725 .expect("analyze");
2726 assert_eq!(create.inputs.len(), 2);
2727 assert_eq!(create.filtered_output_count, 1);
2728
2729 let extract = analyze_argv(
2730 &[
2731 OsString::from("tar"),
2732 OsString::from("-xf"),
2733 OsString::from("archive.tar"),
2734 ],
2735 cwd.path(),
2736 )
2737 .expect("analyze");
2738 assert_eq!(extract.inputs.len(), 1);
2739 }
2740
2741 #[test]
2742 fn wrappers_unwrap_before_adapter_selection() {
2743 let cwd = tempfile::tempdir().expect("create tempdir");
2744 let analysis = analyze_argv(
2745 &[
2746 OsString::from("env"),
2747 OsString::from("FOO=bar"),
2748 OsString::from("grep"),
2749 OsString::from("hello"),
2750 OsString::from("file.txt"),
2751 ],
2752 cwd.path(),
2753 )
2754 .expect("analyze");
2755 assert_eq!(
2756 analysis.adapter_ids,
2757 vec![CommandAdapterId::WrapperEnv, CommandAdapterId::Grep]
2758 );
2759
2760 let analysis = analyze_argv(
2761 &[
2762 OsString::from("timeout"),
2763 OsString::from("5"),
2764 OsString::from("grep"),
2765 OsString::from("hello"),
2766 OsString::from("file.txt"),
2767 ],
2768 cwd.path(),
2769 )
2770 .expect("analyze");
2771 assert_eq!(
2772 analysis.adapter_ids,
2773 vec![CommandAdapterId::WrapperTimeout, CommandAdapterId::Grep]
2774 );
2775 }
2776
2777 #[test]
2778 fn unknown_command_fallback_ignores_opaque_words() {
2779 let cwd = tempfile::tempdir().expect("create tempdir");
2780 let analysis = analyze_argv(
2781 &[
2782 OsString::from("mystery"),
2783 OsString::from("hello"),
2784 OsString::from("1,2p"),
2785 ],
2786 cwd.path(),
2787 )
2788 .expect("analyze");
2789
2790 assert_eq!(analysis.adapter_ids, vec![CommandAdapterId::Fallback]);
2791 assert_eq!(analysis.status, CommandAnalysisStatus::NoInputs);
2792 }
2793
2794 #[test]
2795 fn help_inventory_preserves_source_order_and_grouping() {
2796 let inventory = help_inventory();
2797
2798 assert_eq!(
2799 inventory.wrapper_commands,
2800 vec!["env", "nice", "nohup", "stdbuf", "timeout"]
2801 );
2802 assert_eq!(
2803 inventory.safe_current_dir_defaults,
2804 vec!["find", "ls", "dir", "vdir", "du"]
2805 );
2806 assert!(inventory
2807 .dedicated_built_ins
2808 .starts_with(&["cp", "mv", "install"]));
2809 assert_eq!(
2810 inventory.non_watchable_commands,
2811 &[
2812 "echo", "printf", "seq", "yes", "sleep", "date", "uname", "pwd", "true", "false",
2813 "basename", "dirname", "nproc", "printenv", "whoami", "logname", "users", "hostid",
2814 "numfmt", "mktemp", "mkdir", "mkfifo", "mknod",
2815 ]
2816 );
2817
2818 let dedicated_set = inventory
2819 .dedicated_built_ins
2820 .iter()
2821 .copied()
2822 .collect::<BTreeSet<_>>();
2823 assert!(dedicated_set.contains("find"));
2824 assert!(dedicated_set.contains("grep"));
2825 assert!(dedicated_set.contains("fgrep"));
2826 assert!(dedicated_set.contains("chgrp"));
2827 }
2828
2829 #[test]
2830 fn long_help_appendix_renders_full_inventory_sections() {
2831 let help = render_after_long_help();
2832
2833 assert!(help.contains("Command modes:"));
2834 assert!(help.contains("Wrapper commands:"));
2835 assert!(help.contains("Dedicated built-in adapters and aliases:"));
2836 assert!(help.contains("Generic read-path commands:"));
2837 assert!(help.contains("Safe current-directory defaults:"));
2838 assert!(help.contains("Recognized but not auto-watchable commands:"));
2839 assert!(help.contains("exec --input escape hatch:"));
2840 assert!(help.contains("find, ls, dir, vdir, du"));
2841 assert!(help.contains("echo, printf, seq, yes, sleep"));
2842 }
2843
2844 #[test]
2845 fn shell_analysis_keeps_input_redirects_and_filters_outputs() {
2846 let cwd = tempfile::tempdir().expect("create tempdir");
2847 let parsed =
2848 parse_shell_expression("grep hello < input.txt > output.txt").expect("parse shell");
2849 let analysis = analyze_shell_expression(&parsed, cwd.path()).expect("analyze shell");
2850
2851 assert_eq!(analysis.inputs.len(), 1);
2852 assert_eq!(analysis.filtered_output_count, 1);
2853 }
2854
2855 fn assert_path_snapshot_mode(
2856 input: &WatchInput,
2857 expected_path: &std::path::Path,
2858 expected_snapshot_mode: PathSnapshotMode,
2859 ) {
2860 match input {
2861 WatchInput::Path {
2862 path,
2863 snapshot_mode,
2864 ..
2865 } => {
2866 assert_eq!(path, expected_path);
2867 assert_eq!(*snapshot_mode, expected_snapshot_mode);
2868 }
2869 other => panic!("unexpected watch input: {other:?}"),
2870 }
2871 }
2872}