Skip to main content

forge/cmd/
watch.rs

1use super::{
2    build::BuildArgs, coverage::CoverageArgs, doc::DocArgs, fmt::FmtArgs,
3    snapshot::GasSnapshotArgs, test::TestArgs,
4};
5use crate::opts::Forge;
6use alloy_primitives::map::{HashMap, HashSet};
7use clap::{CommandFactory, Parser};
8use eyre::Result;
9use foundry_cli::utils::{self, FoundryPathExt, LoadConfig};
10use foundry_config::Config;
11use parking_lot::Mutex;
12use std::{
13    ffi::{OsStr, OsString},
14    io::IsTerminal,
15    path::PathBuf,
16    sync::{
17        Arc, OnceLock, Weak,
18        atomic::{AtomicU8, Ordering},
19    },
20    time::Duration,
21};
22use tokio::process::Command as TokioCommand;
23use watchexec::{
24    Watchexec,
25    action::ActionHandler,
26    command::{Command, Program},
27    job::{CommandState, Job},
28    paths::summarise_events_to_env,
29};
30use watchexec_events::{
31    Event, KeyCode, Keyboard, Priority, ProcessEnd, Tag,
32    filekind::{AccessKind, FileEventKind},
33};
34use watchexec_signals::Signal;
35use yansi::{Color, Paint};
36
37type SpawnHook = Arc<dyn Fn(&[Event], &mut TokioCommand) + Send + Sync + 'static>;
38type KeyboardConfig = Arc<OnceLock<Weak<watchexec::Config>>>;
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41enum KeyboardAction {
42    Rerun,
43    Quit,
44}
45
46fn keyboard_action(events: &[Event]) -> Option<KeyboardAction> {
47    let mut rerun = false;
48
49    for tag in events.iter().flat_map(|event| &event.tags) {
50        match tag {
51            Tag::Keyboard(Keyboard::Eof) => return Some(KeyboardAction::Quit),
52            Tag::Keyboard(Keyboard::Key { key: KeyCode::Char('a'), modifiers })
53                if modifiers.is_empty() =>
54            {
55                rerun = true;
56            }
57            _ => {}
58        }
59    }
60
61    rerun.then_some(KeyboardAction::Rerun)
62}
63
64fn set_keyboard_events(config: &Option<KeyboardConfig>, enable: bool) {
65    if let Some(config) = config.as_ref().and_then(|config| config.get()).and_then(Weak::upgrade) {
66        config.keyboard_events(enable);
67    }
68}
69
70#[derive(Clone, Debug, Default, Parser)]
71#[command(next_help_heading = "Watch options")]
72pub struct WatchArgs {
73    /// Watch the given files or directories for changes.
74    ///
75    /// If no paths are provided, the source and test directories of the project are watched.
76    #[arg(long, short, num_args(0..), value_name = "PATH")]
77    pub watch: Option<Vec<PathBuf>>,
78
79    /// Do not restart the command while it's still running.
80    #[arg(long)]
81    pub no_restart: bool,
82
83    /// Explicitly re-run all tests when a change is made.
84    ///
85    /// By default, only the tests of the last modified test file are executed.
86    #[arg(long)]
87    pub run_all: bool,
88
89    /// Re-run only previously failed tests first when a change is made.
90    ///
91    /// If all previously failed tests pass, the full test suite will be run automatically.
92    /// This is particularly useful for TDD workflows where you want fast feedback on failures.
93    #[arg(long, alias = "rerun-failures")]
94    pub rerun_failed: bool,
95
96    /// File update debounce delay.
97    ///
98    /// During the delay, incoming change events are accumulated and
99    /// only once the delay has passed, is an action taken. Note that
100    /// this does not mean a command will be started: if --no-restart is
101    /// given and a command is already running, the outcome of the
102    /// action will be to do nothing.
103    ///
104    /// Defaults to 50ms. Parses as decimal seconds by default, but
105    /// using an integer with the `ms` suffix may be more convenient.
106    ///
107    /// When using --poll mode, you'll want a larger duration, or risk
108    /// overloading disk I/O.
109    #[arg(long, value_name = "DELAY")]
110    pub watch_delay: Option<String>,
111}
112
113impl WatchArgs {
114    /// Creates a new [`watchexec::Config`].
115    ///
116    /// If paths were provided as arguments the these will be used as the watcher's pathset,
117    /// otherwise the path the closure returns will be used.
118    pub fn watchexec_config<PS: IntoIterator<Item = P>, P: Into<PathBuf>>(
119        &self,
120        default_paths: impl FnOnce() -> Result<PS>,
121    ) -> Result<watchexec::Config> {
122        self.watchexec_config_generic(default_paths, None, None)
123    }
124
125    /// Creates a new [`watchexec::Config`] with a custom command spawn hook and optional keyboard
126    /// events while the command is idle.
127    ///
128    /// If paths were provided as arguments the these will be used as the watcher's pathset,
129    /// otherwise the path the closure returns will be used.
130    fn watchexec_config_with_override<PS: IntoIterator<Item = P>, P: Into<PathBuf>>(
131        &self,
132        default_paths: impl FnOnce() -> Result<PS>,
133        watch_keyboard: bool,
134        spawn_hook: impl Fn(&[Event], &mut TokioCommand) + Send + Sync + 'static,
135    ) -> Result<(watchexec::Config, Option<KeyboardConfig>)> {
136        let keyboard_config = watch_keyboard.then(|| Arc::new(OnceLock::new()));
137        let config = self.watchexec_config_generic(
138            default_paths,
139            Some(Arc::new(spawn_hook)),
140            keyboard_config.clone(),
141        )?;
142        Ok((config, keyboard_config))
143    }
144
145    fn watchexec_config_generic<PS: IntoIterator<Item = P>, P: Into<PathBuf>>(
146        &self,
147        default_paths: impl FnOnce() -> Result<PS>,
148        spawn_hook: Option<SpawnHook>,
149        keyboard_config: Option<KeyboardConfig>,
150    ) -> Result<watchexec::Config> {
151        let mut paths = self.watch.as_deref().unwrap_or_default();
152        let storage: Vec<_>;
153        if paths.is_empty() {
154            storage = default_paths()?.into_iter().map(Into::into).filter(|p| p.exists()).collect();
155            paths = &storage;
156        }
157        self.watchexec_config_inner(paths, spawn_hook, keyboard_config)
158    }
159
160    fn watchexec_config_inner(
161        &self,
162        paths: &[PathBuf],
163        spawn_hook: Option<SpawnHook>,
164        keyboard_config: Option<KeyboardConfig>,
165    ) -> Result<watchexec::Config> {
166        let config = watchexec::Config::default();
167
168        config.on_error(|err| {
169            let _ = sh_eprintln!("[[{err:?}]]");
170        });
171
172        if let Some(delay) = &self.watch_delay {
173            config.throttle(utils::parse_delay(delay)?);
174        }
175
176        config.pathset(paths.iter().map(|p| p.as_path()));
177
178        let base_command = Arc::new(watch_command(cmd_args())?);
179
180        let id = watchexec::Id::default();
181        let quit_again = Arc::new(AtomicU8::new(0));
182        let stop_timeout = Duration::from_secs(5);
183        let no_restart = self.no_restart;
184        let stop_signal = Signal::Terminate;
185        config.on_action(move |mut action| {
186            let base_command = base_command.clone();
187            let job = action.get_or_create_job(id, move || base_command.clone());
188
189            let events = action.events.clone();
190            let spawn_hook = spawn_hook.clone();
191            job.set_spawn_hook(move |command, _| {
192                // https://github.com/watchexec/watchexec/blob/72f069a8477c679e45f845219276b0bfe22fed79/crates/cli/src/emits.rs#L9
193                let env = summarise_events_to_env(events.iter());
194                for (k, v) in env {
195                    command.command_mut().env(format!("WATCHEXEC_{k}_PATH"), v);
196                }
197
198                if let Some(spawn_hook) = &spawn_hook {
199                    spawn_hook(&events, command.command_mut());
200                }
201            });
202
203            let clear_screen = || {
204                let _ = clearscreen::clear();
205            };
206
207            let quit = |mut action: ActionHandler| {
208                match quit_again.fetch_add(1, Ordering::Relaxed) {
209                    0 => {
210                        let _ = sh_eprintln!(
211                            "[Waiting {stop_timeout:?} for processes to exit before stopping... \
212                             Ctrl-C again to exit faster]"
213                        );
214                        action.quit_gracefully(stop_signal, stop_timeout);
215                    }
216                    1 => action.quit_gracefully(Signal::ForceStop, Duration::ZERO),
217                    _ => action.quit(),
218                }
219
220                action
221            };
222
223            let signals = action.signals().collect::<Vec<_>>();
224            let keyboard_action = keyboard_action(&action.events);
225
226            if signals.contains(&Signal::Terminate)
227                || signals.contains(&Signal::Interrupt)
228                || keyboard_action == Some(KeyboardAction::Quit)
229            {
230                return quit(action);
231            }
232
233            // Only filesystem, keyboard rerun, or empty synthetic events below here.
234            if action.paths().next().is_none()
235                && keyboard_action != Some(KeyboardAction::Rerun)
236                && !action.events.iter().any(|e| e.is_empty())
237            {
238                debug!("no filesystem, rerun, or synthetic events, skip without doing more");
239                return action;
240            }
241
242            if cfg!(target_os = "linux") && keyboard_action != Some(KeyboardAction::Rerun) {
243                // Reading a file now triggers `Access(Open)` events on Linux due to:
244                // https://github.com/notify-rs/notify/pull/612
245                // This causes an infinite rebuild loop: the build reads a file,
246                // which triggers a notification, which restarts the build, and so on.
247                // To prevent this, we ignore `Access(Open)` events during event processing.
248                let mut has_file_events = false;
249                let mut has_synthetic_events = false;
250                'outer: for e in action.events.iter() {
251                    if e.is_empty() {
252                        has_synthetic_events = true;
253                        break;
254                    }
255                    for tag in &e.tags {
256                        if let Tag::FileEventKind(kind) = tag
257                            && !matches!(kind, FileEventKind::Access(AccessKind::Open(_))) {
258                                has_file_events = true;
259                                break 'outer;
260                            }
261                    }
262                }
263                if !has_file_events && !has_synthetic_events {
264                    debug!("no filesystem events (other than Access(Open)) or synthetic events, skip without doing more");
265                    return action;
266                }
267            }
268
269            // Let the child own stdin while it runs. This keeps prompts and the debugger from
270            // racing Watchexec's keyboard event source for terminal input.
271            set_keyboard_events(&keyboard_config, false);
272
273            job.run({
274                let job = job.clone();
275                let keyboard_config = keyboard_config.clone();
276                move |context| {
277                    if context.current.is_running() && no_restart {
278                        return;
279                    }
280                    job.restart_with_signal(stop_signal, stop_timeout);
281                    job.run({
282                        let job = job.clone();
283                        move |context| {
284                            clear_screen();
285                            setup_process(job, &context.command, keyboard_config)
286                        }
287                    });
288                }
289            });
290
291            action
292        });
293
294        Ok(config)
295    }
296}
297
298fn setup_process(job: Job, _command: &Command, keyboard_config: Option<KeyboardConfig>) {
299    tokio::spawn(async move {
300        job.to_wait().await;
301        job.run(move |context| end_of_process(context.current, keyboard_config));
302    });
303}
304
305fn end_of_process(state: &CommandState, keyboard_config: Option<KeyboardConfig>) {
306    let CommandState::Finished { status, started, finished } = state else {
307        return;
308    };
309
310    let duration = *finished - *started;
311    let timings = true;
312    let timing = if timings { format!(", lasted {duration:?}") } else { String::new() };
313    let (msg, fg) = match status {
314        ProcessEnd::ExitError(code) => (format!("Command exited with {code}{timing}"), Color::Red),
315        ProcessEnd::ExitSignal(sig) => {
316            (format!("Command killed by {sig:?}{timing}"), Color::Magenta)
317        }
318        ProcessEnd::ExitStop(sig) => (format!("Command stopped by {sig:?}{timing}"), Color::Blue),
319        ProcessEnd::Continued => (format!("Command continued{timing}"), Color::Cyan),
320        ProcessEnd::Exception(ex) => {
321            (format!("Command ended by exception {ex:#x}{timing}"), Color::Yellow)
322        }
323        ProcessEnd::Success => (format!("Command was successful{timing}"), Color::Green),
324    };
325
326    let quiet = false;
327    set_keyboard_events(&keyboard_config, true);
328    if !quiet {
329        let _ = sh_eprintln!("{}", format!("[{msg}]").paint(fg.foreground()));
330    }
331}
332
333/// Runs the given [`watchexec::Config`].
334pub async fn run(config: watchexec::Config) -> Result<()> {
335    run_inner(config, None).await
336}
337
338async fn run_inner(
339    config: watchexec::Config,
340    keyboard_config: Option<KeyboardConfig>,
341) -> Result<()> {
342    let wx = Watchexec::with_config(config)?;
343    if let Some(config) = keyboard_config {
344        debug_assert!(config.set(Arc::downgrade(&wx.config)).is_ok());
345    }
346    wx.send_event(Event::default(), Priority::Urgent).await?;
347    wx.main().await??;
348    Ok(())
349}
350
351/// Executes a [`Watchexec`] that listens for changes in the project's src dir and reruns `forge
352/// build`
353pub async fn watch_build(args: BuildArgs) -> Result<()> {
354    let config = args.watchexec_config()?;
355    run(config).await
356}
357
358/// Executes a [`Watchexec`] that listens for changes in the project's src dir and reruns `forge
359/// snapshot`
360pub async fn watch_gas_snapshot(args: GasSnapshotArgs) -> Result<()> {
361    let config = args.watchexec_config()?;
362    run(config).await
363}
364
365/// Executes a [`Watchexec`] that listens for changes in the project's src dir and reruns `forge
366/// test`
367pub async fn watch_test(args: TestArgs) -> Result<()> {
368    let config: Config = args.build.load_config()?;
369    let filter = args.filter(&config)?;
370    // Marker to check whether to override the command.
371    let no_reconfigure = filter.args().test_pattern.is_some()
372        || filter.args().path_pattern.is_some()
373        || filter.args().contract_pattern.is_some()
374        || args.watch.run_all;
375
376    let last_test_files = Mutex::new(HashSet::<String>::default());
377    let project_root = config.root.to_string_lossy().into_owned();
378    let test_failures_file = config.test_failures_file.clone();
379    let rerun_failed = args.watch.rerun_failed;
380    let watch_keyboard = std::io::stdin().is_terminal();
381
382    let (config, keyboard_config) = args.watch.watchexec_config_with_override(
383        || Ok([&config.test, &config.src]),
384        watch_keyboard,
385        move |events, command| {
386            if keyboard_action(events) == Some(KeyboardAction::Rerun) {
387                return;
388            }
389
390            // Check if we should prioritize rerunning failed tests
391            let has_failures = rerun_failed && test_failures_file.exists();
392
393            if has_failures {
394                // Smart mode: rerun failed tests first
395                trace!("Smart watch mode: will rerun failed tests first");
396                command.arg("--rerun");
397                // Don't add file-specific filters when rerunning failures
398                return;
399            }
400
401            let mut changed_sol_test_files: HashSet<_> = events
402                .iter()
403                .flat_map(|e| e.paths())
404                .filter(|(path, _)| path.is_sol_test())
405                .filter_map(|(path, _)| path.to_str())
406                .map(str::to_string)
407                .collect();
408
409            if changed_sol_test_files.len() > 1 {
410                // Run all tests if multiple files were changed at once, for example when running
411                // `forge fmt`.
412                return;
413            }
414
415            if changed_sol_test_files.is_empty() {
416                // Reuse the old test files if a non-test file was changed.
417                let last = last_test_files.lock();
418                if last.is_empty() {
419                    return;
420                }
421                changed_sol_test_files = last.clone();
422            }
423
424            // append `--match-path` glob
425            let mut file = changed_sol_test_files.iter().next().expect("test file present").clone();
426
427            // remove the project root dir from the detected file
428            if let Some(f) = file.strip_prefix(&project_root) {
429                file = f.trim_start_matches('/').to_string();
430            }
431
432            trace!(?file, "reconfigure test command");
433
434            // Before appending `--match-path`, check if it already exists
435            if !no_reconfigure {
436                command.arg("--match-path").arg(file);
437            }
438        },
439    )?;
440
441    if watch_keyboard {
442        let _ = sh_eprintln!("[Press 'a' to rerun all tests]");
443    }
444
445    run_inner(config, keyboard_config).await
446}
447
448pub async fn watch_coverage(args: CoverageArgs) -> Result<()> {
449    args.ensure_mode_compatible()?;
450
451    let config = args.watch().watchexec_config(|| {
452        let config = args.load_config()?;
453        Ok([config.test, config.src])
454    })?;
455    run(config).await
456}
457
458pub async fn watch_fmt(args: FmtArgs) -> Result<()> {
459    let config = args.watch.watchexec_config(|| {
460        let config = args.load_config()?;
461        Ok([config.src, config.test, config.script])
462    })?;
463    run(config).await
464}
465
466/// Executes a [`Watchexec`] that listens for changes affecting the generated
467/// documentation: project sources, optionally library sources, the README that
468/// becomes the homepage, the deployments directory, and the foundry config file
469/// itself. Without these, the live preview goes silently stale on common edits.
470pub async fn watch_doc(args: DocArgs) -> Result<()> {
471    let include_libraries = args.include_libraries;
472    let deployments_arg = args.deployments.clone();
473    let config = args.watch.watchexec_config(|| {
474        let config = args.config()?;
475        let root = config.root.clone();
476
477        let mut paths = Vec::new();
478
479        // Solidity sources.
480        paths.push(config.src.clone());
481
482        // External libraries when explicitly opted in.
483        if include_libraries {
484            for lib in &config.libs {
485                paths.push(lib.clone());
486            }
487        }
488
489        // Homepage source: explicit override, else <root>/README.md when present.
490        if let Some(hp) = &config.doc.homepage {
491            let hp_path = if hp.is_absolute() { hp.clone() } else { root.join(hp) };
492            if hp_path.exists() {
493                paths.push(hp_path);
494            }
495        }
496        // Mirror vocs homepage resolution: <sources>/README.md takes priority over
497        // <root>/README.md.
498        let src_readme = config.src.join("README.md");
499        if src_readme.exists() {
500            paths.push(src_readme);
501        }
502        let readme = root.join("README.md");
503        if readme.exists() {
504            paths.push(readme);
505        }
506
507        // Deployments directory: only when the user enabled `--deployments`.
508        if let Some(dir_opt) = deployments_arg.as_ref() {
509            let dep_dir = match dir_opt {
510                Some(p) if p.is_absolute() => p.clone(),
511                Some(p) => root.join(p),
512                None => root.join("deployments"),
513            };
514            if dep_dir.exists() {
515                paths.push(dep_dir);
516            }
517        }
518
519        // Foundry config file (`foundry.toml`), when present.
520        let toml = root.join("foundry.toml");
521        if toml.exists() {
522            paths.push(toml);
523        }
524
525        Ok(paths)
526    })?;
527    run(config).await
528}
529
530/// Converts a list of arguments to a `watchexec::Command`.
531///
532/// The first index in `args` is the path to the executable.
533///
534/// # Panics
535///
536/// Panics if `args` is empty.
537fn watch_command(mut args: Vec<OsString>) -> Result<Command> {
538    debug_assert!(!args.is_empty());
539    let prog = PathBuf::from(args.remove(0));
540    let args = args
541        .into_iter()
542        .map(|arg| {
543            arg.into_string().map_err(|arg| {
544                eyre::eyre!(
545                    "watchexec requires UTF-8 command arguments; {arg:?} is not valid UTF-8"
546                )
547            })
548        })
549        .collect::<Result<Vec<_>>>()?;
550    Ok(Command { program: Program::Exec { prog, args }, options: Default::default() })
551}
552
553/// Returns the env args without the `--watch` flag from the args for the Watchexec command.
554///
555/// `args_os` is used instead of `args` because the latter panics on an argument that is not valid
556/// Unicode, and a watched path is a `PathBuf`, which is allowed to be non-Unicode on Unix.
557fn cmd_args() -> Vec<OsString> {
558    clean_cmd_args(std::env::args_os().collect())
559}
560
561/// How a command-line argument relates to the `watch` flag.
562enum WatchFlag {
563    /// Not the `watch` flag.
564    Other,
565    /// `--watch`, `-w`, or a short flag cluster ending in `w`, with the paths given as the
566    /// arguments that follow it. Holds the valueless short flags leading up to `w`, which are kept.
567    Bare(Option<OsString>),
568    /// `--watch=<PATH>`, `-w<PATH>`, or a short flag cluster with the path attached to its `w`.
569    /// Holds the valueless short flags leading up to `w`, which are kept.
570    Inline(Option<OsString>),
571}
572
573/// Removes the `--watch` flag from the args for the Watchexec command, so that the child command
574/// does not enter watch mode itself and spawn another watcher.
575///
576/// `watch` is an `Option<Vec<PathBuf>>`, so clap accepts the flag as `--watch <PATH>`,
577/// `--watch=<PATH>`, `-w <PATH>`, and `-w<PATH>`, combines it with other short flags (`-vw`,
578/// `-vw<PATH>`), and allows it to repeat, appending the paths of every occurrence.
579#[instrument(level = "debug", ret)]
580fn clean_cmd_args(cmd_args: Vec<OsString>) -> Vec<OsString> {
581    let mut cleaned = Vec::with_capacity(cmd_args.len());
582    let (value_less_short_flags, variable_options) = command_options(&cmd_args);
583    let mut args = cmd_args.into_iter().peekable();
584    cleaned.extend(args.next());
585    let mut deferred = Vec::new();
586
587    while let Some(arg) = args.next() {
588        if arg == "--" {
589            cleaned.append(&mut deferred);
590            cleaned.push(arg);
591            cleaned.extend(args);
592            break;
593        }
594
595        match watch_flag(&arg, &value_less_short_flags) {
596            WatchFlag::Other => {
597                let option = arg.as_encoded_bytes().strip_prefix(b"--").map(|bytes| {
598                    let end = bytes.iter().position(|&byte| byte == b'=').unwrap_or(bytes.len());
599                    (&bytes[..end], end < bytes.len())
600                });
601                if let Some((name, attached)) = option
602                    && let Ok(name) = std::str::from_utf8(name)
603                    && let Some(&max_values) = variable_options.get(name)
604                {
605                    // Keep greedy options and their original values together at the end, so
606                    // removing watch cannot make them consume a previously positional argument.
607                    deferred.push(arg);
608                    if !attached {
609                        for _ in 0..max_values {
610                            if !args.peek().is_some_and(|next| !looks_like_option(next)) {
611                                break;
612                            }
613                            deferred.extend(args.next());
614                        }
615                    }
616                } else {
617                    cleaned.push(arg);
618                }
619            }
620            WatchFlag::Inline(kept) => cleaned.extend(kept),
621            WatchFlag::Bare(kept) => {
622                cleaned.extend(kept);
623                // clap consumes the values of a `num_args(0..)` argument greedily, up to the next
624                // option-like argument, so the paths follow the flag until one of those.
625                while args.peek().is_some_and(|next| !looks_like_option(next)) {
626                    args.next();
627                }
628            }
629        }
630    }
631
632    cleaned.extend(deferred);
633    cleaned
634}
635
636/// Returns how `arg` relates to the `watch` flag.
637fn watch_flag(arg: &OsStr, value_less_short_flags: &HashSet<char>) -> WatchFlag {
638    let bytes = arg.as_encoded_bytes();
639    if bytes == b"--watch" || bytes == b"-w" {
640        return WatchFlag::Bare(None);
641    }
642    if bytes.starts_with(b"--watch=") {
643        return WatchFlag::Inline(None);
644    }
645    let Some(pos) = short_watch_pos(bytes, value_less_short_flags) else {
646        return WatchFlag::Other;
647    };
648    // Everything after `w` is the path attached to it; only the preceding valueless short flags
649    // are kept. The prefix is ASCII because it consists only of recognized option bytes.
650    let kept = (pos > 1).then(|| OsString::from(std::str::from_utf8(&bytes[..pos]).unwrap()));
651    if bytes.len() == pos + 1 { WatchFlag::Bare(kept) } else { WatchFlag::Inline(kept) }
652}
653
654/// Returns the byte index after `w` in a short flag cluster, if `arg` contains a watch flag.
655///
656/// clap parses a short flag cluster from left to right. Only short flags with no value in the
657/// active command may precede `w`; a value-taking flag such as `-D` or `-C` makes the remaining
658/// bytes its value instead.
659fn short_watch_pos(arg: &[u8], value_less_short_flags: &HashSet<char>) -> Option<usize> {
660    let cluster = arg.strip_prefix(b"-")?;
661    if cluster.is_empty() || cluster.starts_with(b"-") {
662        return None;
663    }
664    let pos = cluster.iter().position(|&byte| byte == b'w')?;
665    cluster[..pos]
666        .iter()
667        .all(|&byte| byte.is_ascii() && value_less_short_flags.contains(&(byte as char)))
668        .then_some(pos + 1)
669}
670
671/// Returns whether `arg` looks like an option, which is where clap stops consuming the values of a
672/// `num_args(0..)` argument. A lone `-` is a value.
673fn looks_like_option(arg: &OsStr) -> bool {
674    let bytes = arg.as_encoded_bytes();
675    bytes.len() > 1 && bytes.starts_with(b"-")
676}
677
678/// Returns valueless short flags and variable-arity long options for the active command.
679fn command_options(cmd_args: &[OsString]) -> (HashSet<char>, HashMap<String, usize>) {
680    let mut command = Forge::command();
681    let mut flags = HashSet::default();
682    let mut variable_options = HashMap::default();
683    if let Ok(matches) = command.try_get_matches_from_mut(cmd_args)
684        && let Some((name, _)) = matches.subcommand()
685        && let Some(command) = command.find_subcommand(name)
686    {
687        // Parsing builds the selected subcommand, including inherited global arguments.
688        for arg in command.get_arguments() {
689            let range = arg.get_num_args().unwrap_or_default();
690            if range.max_values() == 0 {
691                flags.extend(arg.get_short());
692                flags.extend(arg.get_all_short_aliases().unwrap_or_default());
693            } else if arg.get_id() != "watch"
694                && range.min_values() != range.max_values()
695                && let Some(long) = arg.get_long()
696            {
697                variable_options.insert(long.to_owned(), range.max_values());
698                for alias in arg.get_all_aliases().unwrap_or_default() {
699                    variable_options.insert(alias.to_owned(), range.max_values());
700                }
701            }
702        }
703    }
704    (flags, variable_options)
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use watchexec_events::Modifiers;
711
712    #[cfg(unix)]
713    use std::os::unix::ffi::OsStrExt;
714
715    fn key_event(key: char, modifiers: Modifiers) -> Event {
716        Event {
717            tags: vec![Tag::Keyboard(Keyboard::Key { key: KeyCode::Char(key), modifiers })],
718            ..Default::default()
719        }
720    }
721
722    fn clean(args: &[&str]) -> Vec<OsString> {
723        clean_cmd_args(args.iter().copied().map(OsString::from).collect())
724    }
725
726    #[test]
727    fn classifies_keyboard_actions() {
728        assert_eq!(
729            keyboard_action(&[key_event('a', Modifiers::default())]),
730            Some(KeyboardAction::Rerun)
731        );
732        assert_eq!(keyboard_action(&[key_event('x', Modifiers::default())]), None);
733        assert_eq!(
734            keyboard_action(&[key_event('a', Modifiers { ctrl: true, ..Default::default() })]),
735            None
736        );
737
738        let eof = Event { tags: vec![Tag::Keyboard(Keyboard::Eof)], ..Default::default() };
739        assert_eq!(
740            keyboard_action(&[key_event('a', Modifiers::default()), eof]),
741            Some(KeyboardAction::Quit)
742        );
743    }
744
745    #[test]
746    fn locked_survives_cleaning_watch_args() {
747        assert_eq!(
748            clean(&["forge", "build", "--locked", "--watch", "src", "test"]),
749            ["forge", "build", "--locked"]
750        );
751
752        assert_eq!(
753            clean(&["forge", "build", "--watch", "src", "--locked"]),
754            ["forge", "build", "--locked"]
755        );
756    }
757
758    #[test]
759    fn watch_spellings_survive_cleaning_watch_args() {
760        assert_eq!(clean(&["forge", "build", "-vw"]), ["forge", "build", "-v"]);
761        assert_eq!(clean(&["forge", "test", "--watch", "src"]), ["forge", "test"]);
762        assert_eq!(clean(&["forge", "test", "--watch=src"]), ["forge", "test"]);
763        assert_eq!(clean(&["forge", "test", "-w", "src"]), ["forge", "test"]);
764        assert_eq!(clean(&["forge", "test", "-wsrc"]), ["forge", "test"]);
765        assert_eq!(clean(&["forge", "test", "-vw", "src"]), ["forge", "test", "-v"]);
766        assert_eq!(clean(&["forge", "test", "-vwsrc"]), ["forge", "test", "-v"]);
767    }
768
769    #[test]
770    fn repeated_watch_survives_cleaning_watch_args() {
771        assert_eq!(
772            clean(&["forge", "test", "--watch", "src", "--watch", "test"]),
773            ["forge", "test"]
774        );
775
776        assert_eq!(clean(&["forge", "test", "--watch=src", "-w"]), ["forge", "test"]);
777        assert_eq!(clean(&["forge", "test", "-w", "--watch=src"]), ["forge", "test"]);
778    }
779
780    #[test]
781    fn other_flags_survive_cleaning_watch_args() {
782        // `--watch-delay` is a different flag.
783        assert_eq!(
784            clean(&["forge", "test", "--watch-delay", "1s"]),
785            ["forge", "test", "--watch-delay", "1s"]
786        );
787
788        // A `w` in the value attached to another short flag is not the watch flag.
789        assert_eq!(clean(&["forge", "test", "-C./workspace"]), ["forge", "test", "-C./workspace"]);
790
791        assert_eq!(clean(&["forge", "build", "-Dwarnings"]), ["forge", "build", "-Dwarnings"]);
792    }
793
794    #[test]
795    fn command_specific_short_flags_survive_cleaning_watch_args() {
796        assert_eq!(clean(&["forge", "build", "-qw", "src"]), ["forge", "build", "-q"]);
797        assert_eq!(clean(&["forge", "fmt", "-rw", "src"]), ["forge", "fmt", "-r"]);
798        assert_eq!(clean(&["forge", "b", "-vw"]), ["forge", "b", "-v"]);
799    }
800
801    #[test]
802    fn watch_cleanup_stops_after_double_dash() {
803        assert_eq!(
804            clean(&["forge", "fmt", "--watch", "src", "--", "--watch=keep.sol"]),
805            ["forge", "fmt", "--", "--watch=keep.sol"]
806        );
807    }
808
809    #[cfg(unix)]
810    #[test]
811    fn non_unicode_watch_survives_cleaning_watch_args() {
812        let mut args = ["forge", "test", "--watch"].map(OsString::from).to_vec();
813        args.push(OsStr::from_bytes(b"src/\xff").to_os_string());
814
815        assert_eq!(clean_cmd_args(args), ["forge", "test"]);
816    }
817
818    #[cfg(unix)]
819    #[test]
820    fn non_unicode_watch_suffixes_and_options_are_preserved() {
821        for path in [b"--watch=src/\xff".as_slice(), b"-wsrc/\xff".as_slice()] {
822            let args = ["forge", "fmt"]
823                .map(OsString::from)
824                .into_iter()
825                .chain([OsStr::from_bytes(path).to_os_string()])
826                .collect();
827            assert_eq!(clean_cmd_args(args), ["forge", "fmt"]);
828        }
829
830        let args = ["forge", "build", "-w", "src"]
831            .map(OsString::from)
832            .into_iter()
833            .chain([OsStr::from_bytes(b"--out=out/\xff").to_os_string()])
834            .collect();
835        assert_eq!(
836            clean_cmd_args(args),
837            vec![
838                OsString::from("forge"),
839                OsString::from("build"),
840                OsStr::from_bytes(b"--out=out/\xff").to_os_string(),
841            ]
842        );
843    }
844
845    #[cfg(unix)]
846    #[test]
847    fn non_unicode_program_path_is_preserved() {
848        let args = vec![OsStr::from_bytes(b"forge\xff").to_os_string(), OsString::from("build")];
849        let command = watch_command(args).unwrap();
850        let Program::Exec { prog, args } = command.program else { panic!("expected exec program") };
851        assert_eq!(prog, PathBuf::from(OsStr::from_bytes(b"forge\xff")));
852        assert_eq!(args, ["build"]);
853    }
854
855    #[test]
856    fn watch_cleanup_preserves_argument_values() {
857        for args in [
858            vec!["forge", "build", "--optimize", "--watch=src", "src/Counter.sol", "--sizes"],
859            vec!["forge", "snapshot", "--check", "--watch=src", "test/Counter.t.sol"],
860            vec!["forge", "snapshot", "--optimize", "--check", "-wsrc", "test/Counter.t.sol"],
861            vec![
862                "forge",
863                "snapshot",
864                "--check",
865                "--watch=src",
866                "test/Counter.t.sol",
867                "--snap",
868                "custom",
869            ],
870            vec!["forge", "build", "--skip", "test", "script", "--watch=src", "src/Counter.sol"],
871            vec![
872                "forge",
873                "build",
874                "--skip",
875                "test",
876                "--watch=src",
877                "src/Counter.sol",
878                "--skip=script",
879            ],
880            vec!["forge", "build", "--optimize", "--watch=src", "--", "src/Counter.sol"],
881        ] {
882            let mut command = Forge::command();
883            let parent = command.try_get_matches_from_mut(&args).unwrap();
884            let child = Forge::command().try_get_matches_from(clean(&args)).unwrap();
885            let (name, parent) = parent.subcommand().unwrap();
886            let (_, child) = child.subcommand().unwrap();
887            assert!(child.get_raw("watch").is_none(), "{args:?}");
888            for arg in command.find_subcommand(name).unwrap().get_arguments() {
889                let id = arg.get_id();
890                if id == "watch" {
891                    continue;
892                }
893                assert_eq!(
894                    parent.get_raw(id.as_str()).map(|values| values.collect::<Vec<_>>()),
895                    child.get_raw(id.as_str()).map(|values| values.collect::<Vec<_>>()),
896                    "{args:?}: {id}"
897                );
898            }
899        }
900    }
901
902    #[test]
903    fn watch_cleanup_preserves_program_name() {
904        assert_eq!(clean(&["--watch=forge", "fmt", "--watch", "src"]), ["--watch=forge", "fmt"]);
905    }
906}