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 alloy_primitives::map::HashSet;
6use clap::Parser;
7use eyre::Result;
8use foundry_cli::utils::{self, FoundryPathExt, LoadConfig};
9use foundry_config::Config;
10use parking_lot::Mutex;
11use std::{
12    io::IsTerminal,
13    path::PathBuf,
14    sync::{
15        Arc, OnceLock, Weak,
16        atomic::{AtomicU8, Ordering},
17    },
18    time::Duration,
19};
20use tokio::process::Command as TokioCommand;
21use watchexec::{
22    Watchexec,
23    action::ActionHandler,
24    command::{Command, Program},
25    job::{CommandState, Job},
26    paths::summarise_events_to_env,
27};
28use watchexec_events::{
29    Event, KeyCode, Keyboard, Priority, ProcessEnd, Tag,
30    filekind::{AccessKind, FileEventKind},
31};
32use watchexec_signals::Signal;
33use yansi::{Color, Paint};
34
35type SpawnHook = Arc<dyn Fn(&[Event], &mut TokioCommand) + Send + Sync + 'static>;
36type KeyboardConfig = Arc<OnceLock<Weak<watchexec::Config>>>;
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39enum KeyboardAction {
40    Rerun,
41    Quit,
42}
43
44fn keyboard_action(events: &[Event]) -> Option<KeyboardAction> {
45    let mut rerun = false;
46
47    for tag in events.iter().flat_map(|event| &event.tags) {
48        match tag {
49            Tag::Keyboard(Keyboard::Eof) => return Some(KeyboardAction::Quit),
50            Tag::Keyboard(Keyboard::Key { key: KeyCode::Char('a'), modifiers })
51                if modifiers.is_empty() =>
52            {
53                rerun = true;
54            }
55            _ => {}
56        }
57    }
58
59    rerun.then_some(KeyboardAction::Rerun)
60}
61
62fn set_keyboard_events(config: &Option<KeyboardConfig>, enable: bool) {
63    if let Some(config) = config.as_ref().and_then(|config| config.get()).and_then(Weak::upgrade) {
64        config.keyboard_events(enable);
65    }
66}
67
68#[derive(Clone, Debug, Default, Parser)]
69#[command(next_help_heading = "Watch options")]
70pub struct WatchArgs {
71    /// Watch the given files or directories for changes.
72    ///
73    /// If no paths are provided, the source and test directories of the project are watched.
74    #[arg(long, short, num_args(0..), value_name = "PATH")]
75    pub watch: Option<Vec<PathBuf>>,
76
77    /// Do not restart the command while it's still running.
78    #[arg(long)]
79    pub no_restart: bool,
80
81    /// Explicitly re-run all tests when a change is made.
82    ///
83    /// By default, only the tests of the last modified test file are executed.
84    #[arg(long)]
85    pub run_all: bool,
86
87    /// Re-run only previously failed tests first when a change is made.
88    ///
89    /// If all previously failed tests pass, the full test suite will be run automatically.
90    /// This is particularly useful for TDD workflows where you want fast feedback on failures.
91    #[arg(long, alias = "rerun-failures")]
92    pub rerun_failed: bool,
93
94    /// File update debounce delay.
95    ///
96    /// During the delay, incoming change events are accumulated and
97    /// only once the delay has passed, is an action taken. Note that
98    /// this does not mean a command will be started: if --no-restart is
99    /// given and a command is already running, the outcome of the
100    /// action will be to do nothing.
101    ///
102    /// Defaults to 50ms. Parses as decimal seconds by default, but
103    /// using an integer with the `ms` suffix may be more convenient.
104    ///
105    /// When using --poll mode, you'll want a larger duration, or risk
106    /// overloading disk I/O.
107    #[arg(long, value_name = "DELAY")]
108    pub watch_delay: Option<String>,
109}
110
111impl WatchArgs {
112    /// Creates a new [`watchexec::Config`].
113    ///
114    /// If paths were provided as arguments the these will be used as the watcher's pathset,
115    /// otherwise the path the closure returns will be used.
116    pub fn watchexec_config<PS: IntoIterator<Item = P>, P: Into<PathBuf>>(
117        &self,
118        default_paths: impl FnOnce() -> Result<PS>,
119    ) -> Result<watchexec::Config> {
120        self.watchexec_config_generic(default_paths, None, None)
121    }
122
123    /// Creates a new [`watchexec::Config`] with a custom command spawn hook and optional keyboard
124    /// events while the command is idle.
125    ///
126    /// If paths were provided as arguments the these will be used as the watcher's pathset,
127    /// otherwise the path the closure returns will be used.
128    fn watchexec_config_with_override<PS: IntoIterator<Item = P>, P: Into<PathBuf>>(
129        &self,
130        default_paths: impl FnOnce() -> Result<PS>,
131        watch_keyboard: bool,
132        spawn_hook: impl Fn(&[Event], &mut TokioCommand) + Send + Sync + 'static,
133    ) -> Result<(watchexec::Config, Option<KeyboardConfig>)> {
134        let keyboard_config = watch_keyboard.then(|| Arc::new(OnceLock::new()));
135        let config = self.watchexec_config_generic(
136            default_paths,
137            Some(Arc::new(spawn_hook)),
138            keyboard_config.clone(),
139        )?;
140        Ok((config, keyboard_config))
141    }
142
143    fn watchexec_config_generic<PS: IntoIterator<Item = P>, P: Into<PathBuf>>(
144        &self,
145        default_paths: impl FnOnce() -> Result<PS>,
146        spawn_hook: Option<SpawnHook>,
147        keyboard_config: Option<KeyboardConfig>,
148    ) -> Result<watchexec::Config> {
149        let mut paths = self.watch.as_deref().unwrap_or_default();
150        let storage: Vec<_>;
151        if paths.is_empty() {
152            storage = default_paths()?.into_iter().map(Into::into).filter(|p| p.exists()).collect();
153            paths = &storage;
154        }
155        self.watchexec_config_inner(paths, spawn_hook, keyboard_config)
156    }
157
158    fn watchexec_config_inner(
159        &self,
160        paths: &[PathBuf],
161        spawn_hook: Option<SpawnHook>,
162        keyboard_config: Option<KeyboardConfig>,
163    ) -> Result<watchexec::Config> {
164        let config = watchexec::Config::default();
165
166        config.on_error(|err| {
167            let _ = sh_eprintln!("[[{err:?}]]");
168        });
169
170        if let Some(delay) = &self.watch_delay {
171            config.throttle(utils::parse_delay(delay)?);
172        }
173
174        config.pathset(paths.iter().map(|p| p.as_path()));
175
176        let n_path_args = self.watch.as_deref().unwrap_or_default().len();
177        let base_command = Arc::new(watch_command(cmd_args(n_path_args)));
178
179        let id = watchexec::Id::default();
180        let quit_again = Arc::new(AtomicU8::new(0));
181        let stop_timeout = Duration::from_secs(5);
182        let no_restart = self.no_restart;
183        let stop_signal = Signal::Terminate;
184        config.on_action(move |mut action| {
185            let base_command = base_command.clone();
186            let job = action.get_or_create_job(id, move || base_command.clone());
187
188            let events = action.events.clone();
189            let spawn_hook = spawn_hook.clone();
190            job.set_spawn_hook(move |command, _| {
191                // https://github.com/watchexec/watchexec/blob/72f069a8477c679e45f845219276b0bfe22fed79/crates/cli/src/emits.rs#L9
192                let env = summarise_events_to_env(events.iter());
193                for (k, v) in env {
194                    command.command_mut().env(format!("WATCHEXEC_{k}_PATH"), v);
195                }
196
197                if let Some(spawn_hook) = &spawn_hook {
198                    spawn_hook(&events, command.command_mut());
199                }
200            });
201
202            let clear_screen = || {
203                let _ = clearscreen::clear();
204            };
205
206            let quit = |mut action: ActionHandler| {
207                match quit_again.fetch_add(1, Ordering::Relaxed) {
208                    0 => {
209                        let _ = sh_eprintln!(
210                            "[Waiting {stop_timeout:?} for processes to exit before stopping... \
211                             Ctrl-C again to exit faster]"
212                        );
213                        action.quit_gracefully(stop_signal, stop_timeout);
214                    }
215                    1 => action.quit_gracefully(Signal::ForceStop, Duration::ZERO),
216                    _ => action.quit(),
217                }
218
219                action
220            };
221
222            let signals = action.signals().collect::<Vec<_>>();
223            let keyboard_action = keyboard_action(&action.events);
224
225            if signals.contains(&Signal::Terminate)
226                || signals.contains(&Signal::Interrupt)
227                || keyboard_action == Some(KeyboardAction::Quit)
228            {
229                return quit(action);
230            }
231
232            // Only filesystem, keyboard rerun, or empty synthetic events below here.
233            if action.paths().next().is_none()
234                && keyboard_action != Some(KeyboardAction::Rerun)
235                && !action.events.iter().any(|e| e.is_empty())
236            {
237                debug!("no filesystem, rerun, or synthetic events, skip without doing more");
238                return action;
239            }
240
241            if cfg!(target_os = "linux") && keyboard_action != Some(KeyboardAction::Rerun) {
242                // Reading a file now triggers `Access(Open)` events on Linux due to:
243                // https://github.com/notify-rs/notify/pull/612
244                // This causes an infinite rebuild loop: the build reads a file,
245                // which triggers a notification, which restarts the build, and so on.
246                // To prevent this, we ignore `Access(Open)` events during event processing.
247                let mut has_file_events = false;
248                let mut has_synthetic_events = false;
249                'outer: for e in action.events.iter() {
250                    if e.is_empty() {
251                        has_synthetic_events = true;
252                        break;
253                    }
254                    for tag in &e.tags {
255                        if let Tag::FileEventKind(kind) = tag
256                            && !matches!(kind, FileEventKind::Access(AccessKind::Open(_))) {
257                                has_file_events = true;
258                                break 'outer;
259                            }
260                    }
261                }
262                if !has_file_events && !has_synthetic_events {
263                    debug!("no filesystem events (other than Access(Open)) or synthetic events, skip without doing more");
264                    return action;
265                }
266            }
267
268            // Let the child own stdin while it runs. This keeps prompts and the debugger from
269            // racing Watchexec's keyboard event source for terminal input.
270            set_keyboard_events(&keyboard_config, false);
271
272            job.run({
273                let job = job.clone();
274                let keyboard_config = keyboard_config.clone();
275                move |context| {
276                    if context.current.is_running() && no_restart {
277                        return;
278                    }
279                    job.restart_with_signal(stop_signal, stop_timeout);
280                    job.run({
281                        let job = job.clone();
282                        move |context| {
283                            clear_screen();
284                            setup_process(job, &context.command, keyboard_config)
285                        }
286                    });
287                }
288            });
289
290            action
291        });
292
293        Ok(config)
294    }
295}
296
297fn setup_process(job: Job, _command: &Command, keyboard_config: Option<KeyboardConfig>) {
298    tokio::spawn(async move {
299        job.to_wait().await;
300        job.run(move |context| end_of_process(context.current, keyboard_config));
301    });
302}
303
304fn end_of_process(state: &CommandState, keyboard_config: Option<KeyboardConfig>) {
305    let CommandState::Finished { status, started, finished } = state else {
306        return;
307    };
308
309    let duration = *finished - *started;
310    let timings = true;
311    let timing = if timings { format!(", lasted {duration:?}") } else { String::new() };
312    let (msg, fg) = match status {
313        ProcessEnd::ExitError(code) => (format!("Command exited with {code}{timing}"), Color::Red),
314        ProcessEnd::ExitSignal(sig) => {
315            (format!("Command killed by {sig:?}{timing}"), Color::Magenta)
316        }
317        ProcessEnd::ExitStop(sig) => (format!("Command stopped by {sig:?}{timing}"), Color::Blue),
318        ProcessEnd::Continued => (format!("Command continued{timing}"), Color::Cyan),
319        ProcessEnd::Exception(ex) => {
320            (format!("Command ended by exception {ex:#x}{timing}"), Color::Yellow)
321        }
322        ProcessEnd::Success => (format!("Command was successful{timing}"), Color::Green),
323    };
324
325    let quiet = false;
326    set_keyboard_events(&keyboard_config, true);
327    if !quiet {
328        let _ = sh_eprintln!("{}", format!("[{msg}]").paint(fg.foreground()));
329    }
330}
331
332/// Runs the given [`watchexec::Config`].
333pub async fn run(config: watchexec::Config) -> Result<()> {
334    run_inner(config, None).await
335}
336
337async fn run_inner(
338    config: watchexec::Config,
339    keyboard_config: Option<KeyboardConfig>,
340) -> Result<()> {
341    let wx = Watchexec::with_config(config)?;
342    if let Some(config) = keyboard_config {
343        debug_assert!(config.set(Arc::downgrade(&wx.config)).is_ok());
344    }
345    wx.send_event(Event::default(), Priority::Urgent).await?;
346    wx.main().await??;
347    Ok(())
348}
349
350/// Executes a [`Watchexec`] that listens for changes in the project's src dir and reruns `forge
351/// build`
352pub async fn watch_build(args: BuildArgs) -> Result<()> {
353    let config = args.watchexec_config()?;
354    run(config).await
355}
356
357/// Executes a [`Watchexec`] that listens for changes in the project's src dir and reruns `forge
358/// snapshot`
359pub async fn watch_gas_snapshot(args: GasSnapshotArgs) -> Result<()> {
360    let config = args.watchexec_config()?;
361    run(config).await
362}
363
364/// Executes a [`Watchexec`] that listens for changes in the project's src dir and reruns `forge
365/// test`
366pub async fn watch_test(args: TestArgs) -> Result<()> {
367    let config: Config = args.build.load_config()?;
368    let filter = args.filter(&config)?;
369    // Marker to check whether to override the command.
370    let no_reconfigure = filter.args().test_pattern.is_some()
371        || filter.args().path_pattern.is_some()
372        || filter.args().contract_pattern.is_some()
373        || args.watch.run_all;
374
375    let last_test_files = Mutex::new(HashSet::<String>::default());
376    let project_root = config.root.to_string_lossy().into_owned();
377    let test_failures_file = config.test_failures_file.clone();
378    let rerun_failed = args.watch.rerun_failed;
379    let watch_keyboard = std::io::stdin().is_terminal();
380
381    let (config, keyboard_config) = args.watch.watchexec_config_with_override(
382        || Ok([&config.test, &config.src]),
383        watch_keyboard,
384        move |events, command| {
385            if keyboard_action(events) == Some(KeyboardAction::Rerun) {
386                return;
387            }
388
389            // Check if we should prioritize rerunning failed tests
390            let has_failures = rerun_failed && test_failures_file.exists();
391
392            if has_failures {
393                // Smart mode: rerun failed tests first
394                trace!("Smart watch mode: will rerun failed tests first");
395                command.arg("--rerun");
396                // Don't add file-specific filters when rerunning failures
397                return;
398            }
399
400            let mut changed_sol_test_files: HashSet<_> = events
401                .iter()
402                .flat_map(|e| e.paths())
403                .filter(|(path, _)| path.is_sol_test())
404                .filter_map(|(path, _)| path.to_str())
405                .map(str::to_string)
406                .collect();
407
408            if changed_sol_test_files.len() > 1 {
409                // Run all tests if multiple files were changed at once, for example when running
410                // `forge fmt`.
411                return;
412            }
413
414            if changed_sol_test_files.is_empty() {
415                // Reuse the old test files if a non-test file was changed.
416                let last = last_test_files.lock();
417                if last.is_empty() {
418                    return;
419                }
420                changed_sol_test_files = last.clone();
421            }
422
423            // append `--match-path` glob
424            let mut file = changed_sol_test_files.iter().next().expect("test file present").clone();
425
426            // remove the project root dir from the detected file
427            if let Some(f) = file.strip_prefix(&project_root) {
428                file = f.trim_start_matches('/').to_string();
429            }
430
431            trace!(?file, "reconfigure test command");
432
433            // Before appending `--match-path`, check if it already exists
434            if !no_reconfigure {
435                command.arg("--match-path").arg(file);
436            }
437        },
438    )?;
439
440    if watch_keyboard {
441        let _ = sh_eprintln!("[Press 'a' to rerun all tests]");
442    }
443
444    run_inner(config, keyboard_config).await
445}
446
447pub async fn watch_coverage(args: CoverageArgs) -> Result<()> {
448    args.ensure_mode_compatible()?;
449
450    let config = args.watch().watchexec_config(|| {
451        let config = args.load_config()?;
452        Ok([config.test, config.src])
453    })?;
454    run(config).await
455}
456
457pub async fn watch_fmt(args: FmtArgs) -> Result<()> {
458    let config = args.watch.watchexec_config(|| {
459        let config = args.load_config()?;
460        Ok([config.src, config.test, config.script])
461    })?;
462    run(config).await
463}
464
465/// Executes a [`Watchexec`] that listens for changes affecting the generated
466/// documentation: project sources, optionally library sources, the README that
467/// becomes the homepage, the deployments directory, and the foundry config file
468/// itself. Without these, the live preview goes silently stale on common edits.
469pub async fn watch_doc(args: DocArgs) -> Result<()> {
470    let include_libraries = args.include_libraries;
471    let deployments_arg = args.deployments.clone();
472    let config = args.watch.watchexec_config(|| {
473        let config = args.config()?;
474        let root = config.root.clone();
475
476        let mut paths = Vec::new();
477
478        // Solidity sources.
479        paths.push(config.src.clone());
480
481        // External libraries when explicitly opted in.
482        if include_libraries {
483            for lib in &config.libs {
484                paths.push(lib.clone());
485            }
486        }
487
488        // Homepage source: explicit override, else <root>/README.md when present.
489        if let Some(hp) = &config.doc.homepage {
490            let hp_path = if hp.is_absolute() { hp.clone() } else { root.join(hp) };
491            if hp_path.exists() {
492                paths.push(hp_path);
493            }
494        }
495        // Mirror vocs homepage resolution: <sources>/README.md takes priority over
496        // <root>/README.md.
497        let src_readme = config.src.join("README.md");
498        if src_readme.exists() {
499            paths.push(src_readme);
500        }
501        let readme = root.join("README.md");
502        if readme.exists() {
503            paths.push(readme);
504        }
505
506        // Deployments directory: only when the user enabled `--deployments`.
507        if let Some(dir_opt) = deployments_arg.as_ref() {
508            let dep_dir = match dir_opt {
509                Some(p) if p.is_absolute() => p.clone(),
510                Some(p) => root.join(p),
511                None => root.join("deployments"),
512            };
513            if dep_dir.exists() {
514                paths.push(dep_dir);
515            }
516        }
517
518        // Foundry config file (`foundry.toml`), when present.
519        let toml = root.join("foundry.toml");
520        if toml.exists() {
521            paths.push(toml);
522        }
523
524        Ok(paths)
525    })?;
526    run(config).await
527}
528
529/// Converts a list of arguments to a `watchexec::Command`.
530///
531/// The first index in `args` is the path to the executable.
532///
533/// # Panics
534///
535/// Panics if `args` is empty.
536fn watch_command(mut args: Vec<String>) -> Command {
537    debug_assert!(!args.is_empty());
538    let prog = args.remove(0);
539    Command { program: Program::Exec { prog: prog.into(), args }, options: Default::default() }
540}
541
542/// Returns the env args without the `--watch` flag from the args for the Watchexec command
543fn cmd_args(num: usize) -> Vec<String> {
544    clean_cmd_args(num, std::env::args().collect())
545}
546
547#[instrument(level = "debug", ret)]
548fn clean_cmd_args(num: usize, mut cmd_args: Vec<String>) -> Vec<String> {
549    if let Some(pos) = cmd_args.iter().position(|arg| arg == "--watch" || arg == "-w") {
550        cmd_args.drain(pos..=(pos + num));
551    }
552
553    // There's another edge case where short flags are combined into one which is supported by clap,
554    // like `-vw` for verbosity and watch
555    // this removes any `w` from concatenated short flags
556    if let Some(pos) = cmd_args.iter().position(|arg| {
557        fn contains_w_in_short(arg: &str) -> Option<bool> {
558            let mut iter = arg.chars().peekable();
559            if *iter.peek()? != '-' {
560                return None;
561            }
562            iter.next();
563            if *iter.peek()? == '-' {
564                return None;
565            }
566            Some(iter.any(|c| c == 'w'))
567        }
568        contains_w_in_short(arg).unwrap_or(false)
569    }) {
570        let clean_arg = cmd_args[pos].replace('w', "");
571        if clean_arg == "-" {
572            cmd_args.remove(pos);
573        } else {
574            cmd_args[pos] = clean_arg;
575        }
576    }
577
578    cmd_args
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use watchexec_events::Modifiers;
585
586    fn key_event(key: char, modifiers: Modifiers) -> Event {
587        Event {
588            tags: vec![Tag::Keyboard(Keyboard::Key { key: KeyCode::Char(key), modifiers })],
589            ..Default::default()
590        }
591    }
592
593    #[test]
594    fn classifies_keyboard_actions() {
595        assert_eq!(
596            keyboard_action(&[key_event('a', Modifiers::default())]),
597            Some(KeyboardAction::Rerun)
598        );
599        assert_eq!(keyboard_action(&[key_event('x', Modifiers::default())]), None);
600        assert_eq!(
601            keyboard_action(&[key_event('a', Modifiers { ctrl: true, ..Default::default() })]),
602            None
603        );
604
605        let eof = Event { tags: vec![Tag::Keyboard(Keyboard::Eof)], ..Default::default() };
606        assert_eq!(
607            keyboard_action(&[key_event('a', Modifiers::default()), eof]),
608            Some(KeyboardAction::Quit)
609        );
610    }
611
612    #[test]
613    fn parse_cmd_args() {
614        let args = vec!["-vw".to_string()];
615        let cleaned = clean_cmd_args(0, args);
616        assert_eq!(cleaned, vec!["-v".to_string()]);
617    }
618
619    #[test]
620    fn locked_survives_cleaning_watch_args() {
621        let args =
622            ["forge", "build", "--locked", "--watch", "src", "test"].map(str::to_string).to_vec();
623        assert_eq!(clean_cmd_args(2, args), ["forge", "build", "--locked"].map(str::to_string));
624
625        let args = ["forge", "build", "--watch", "src", "--locked"].map(str::to_string).to_vec();
626        assert_eq!(clean_cmd_args(1, args), ["forge", "build", "--locked"].map(str::to_string));
627    }
628}