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    path::PathBuf,
13    sync::{
14        Arc,
15        atomic::{AtomicU8, Ordering},
16    },
17    time::Duration,
18};
19use tokio::process::Command as TokioCommand;
20use watchexec::{
21    Watchexec,
22    action::ActionHandler,
23    command::{Command, Program},
24    job::{CommandState, Job},
25    paths::summarise_events_to_env,
26};
27use watchexec_events::{
28    Event, Priority, ProcessEnd, Tag,
29    filekind::{AccessKind, FileEventKind},
30};
31use watchexec_signals::Signal;
32use yansi::{Color, Paint};
33
34type SpawnHook = Arc<dyn Fn(&[Event], &mut TokioCommand) + Send + Sync + 'static>;
35
36#[derive(Clone, Debug, Default, Parser)]
37#[command(next_help_heading = "Watch options")]
38pub struct WatchArgs {
39    /// Watch the given files or directories for changes.
40    ///
41    /// If no paths are provided, the source and test directories of the project are watched.
42    #[arg(long, short, num_args(0..), value_name = "PATH")]
43    pub watch: Option<Vec<PathBuf>>,
44
45    /// Do not restart the command while it's still running.
46    #[arg(long)]
47    pub no_restart: bool,
48
49    /// Explicitly re-run all tests when a change is made.
50    ///
51    /// By default, only the tests of the last modified test file are executed.
52    #[arg(long)]
53    pub run_all: bool,
54
55    /// Re-run only previously failed tests first when a change is made.
56    ///
57    /// If all previously failed tests pass, the full test suite will be run automatically.
58    /// This is particularly useful for TDD workflows where you want fast feedback on failures.
59    #[arg(long, alias = "rerun-failures")]
60    pub rerun_failed: bool,
61
62    /// File update debounce delay.
63    ///
64    /// During the delay, incoming change events are accumulated and
65    /// only once the delay has passed, is an action taken. Note that
66    /// this does not mean a command will be started: if --no-restart is
67    /// given and a command is already running, the outcome of the
68    /// action will be to do nothing.
69    ///
70    /// Defaults to 50ms. Parses as decimal seconds by default, but
71    /// using an integer with the `ms` suffix may be more convenient.
72    ///
73    /// When using --poll mode, you'll want a larger duration, or risk
74    /// overloading disk I/O.
75    #[arg(long, value_name = "DELAY")]
76    pub watch_delay: Option<String>,
77}
78
79impl WatchArgs {
80    /// Creates a new [`watchexec::Config`].
81    ///
82    /// If paths were provided as arguments the these will be used as the watcher's pathset,
83    /// otherwise the path the closure returns will be used.
84    pub fn watchexec_config<PS: IntoIterator<Item = P>, P: Into<PathBuf>>(
85        &self,
86        default_paths: impl FnOnce() -> Result<PS>,
87    ) -> Result<watchexec::Config> {
88        self.watchexec_config_generic(default_paths, None)
89    }
90
91    /// Creates a new [`watchexec::Config`] with a custom command spawn hook.
92    ///
93    /// If paths were provided as arguments the these will be used as the watcher's pathset,
94    /// otherwise the path the closure returns will be used.
95    pub fn watchexec_config_with_override<PS: IntoIterator<Item = P>, P: Into<PathBuf>>(
96        &self,
97        default_paths: impl FnOnce() -> Result<PS>,
98        spawn_hook: impl Fn(&[Event], &mut TokioCommand) + Send + Sync + 'static,
99    ) -> Result<watchexec::Config> {
100        self.watchexec_config_generic(default_paths, Some(Arc::new(spawn_hook)))
101    }
102
103    fn watchexec_config_generic<PS: IntoIterator<Item = P>, P: Into<PathBuf>>(
104        &self,
105        default_paths: impl FnOnce() -> Result<PS>,
106        spawn_hook: Option<SpawnHook>,
107    ) -> Result<watchexec::Config> {
108        let mut paths = self.watch.as_deref().unwrap_or_default();
109        let storage: Vec<_>;
110        if paths.is_empty() {
111            storage = default_paths()?.into_iter().map(Into::into).filter(|p| p.exists()).collect();
112            paths = &storage;
113        }
114        self.watchexec_config_inner(paths, spawn_hook)
115    }
116
117    fn watchexec_config_inner(
118        &self,
119        paths: &[PathBuf],
120        spawn_hook: Option<SpawnHook>,
121    ) -> Result<watchexec::Config> {
122        let config = watchexec::Config::default();
123
124        config.on_error(|err| {
125            let _ = sh_eprintln!("[[{err:?}]]");
126        });
127
128        if let Some(delay) = &self.watch_delay {
129            config.throttle(utils::parse_delay(delay)?);
130        }
131
132        config.pathset(paths.iter().map(|p| p.as_path()));
133
134        let n_path_args = self.watch.as_deref().unwrap_or_default().len();
135        let base_command = Arc::new(watch_command(cmd_args(n_path_args)));
136
137        let id = watchexec::Id::default();
138        let quit_again = Arc::new(AtomicU8::new(0));
139        let stop_timeout = Duration::from_secs(5);
140        let no_restart = self.no_restart;
141        let stop_signal = Signal::Terminate;
142        config.on_action(move |mut action| {
143            let base_command = base_command.clone();
144            let job = action.get_or_create_job(id, move || base_command.clone());
145
146            let events = action.events.clone();
147            let spawn_hook = spawn_hook.clone();
148            job.set_spawn_hook(move |command, _| {
149                // https://github.com/watchexec/watchexec/blob/72f069a8477c679e45f845219276b0bfe22fed79/crates/cli/src/emits.rs#L9
150                let env = summarise_events_to_env(events.iter());
151                for (k, v) in env {
152                    command.command_mut().env(format!("WATCHEXEC_{k}_PATH"), v);
153                }
154
155                if let Some(spawn_hook) = &spawn_hook {
156                    spawn_hook(&events, command.command_mut());
157                }
158            });
159
160            let clear_screen = || {
161                let _ = clearscreen::clear();
162            };
163
164            let quit = |mut action: ActionHandler| {
165                match quit_again.fetch_add(1, Ordering::Relaxed) {
166                    0 => {
167                        let _ = sh_eprintln!(
168                            "[Waiting {stop_timeout:?} for processes to exit before stopping... \
169                             Ctrl-C again to exit faster]"
170                        );
171                        action.quit_gracefully(stop_signal, stop_timeout);
172                    }
173                    1 => action.quit_gracefully(Signal::ForceStop, Duration::ZERO),
174                    _ => action.quit(),
175                }
176
177                action
178            };
179
180            let signals = action.signals().collect::<Vec<_>>();
181
182            if signals.contains(&Signal::Terminate) || signals.contains(&Signal::Interrupt) {
183                return quit(action);
184            }
185
186            // Only filesystem events below here (or empty synthetic events).
187            if action.paths().next().is_none() && !action.events.iter().any(|e| e.is_empty()) {
188                debug!("no filesystem or synthetic events, skip without doing more");
189                return action;
190            }
191
192            if cfg!(target_os = "linux") {
193                // Reading a file now triggers `Access(Open)` events on Linux due to:
194                // https://github.com/notify-rs/notify/pull/612
195                // This causes an infinite rebuild loop: the build reads a file,
196                // which triggers a notification, which restarts the build, and so on.
197                // To prevent this, we ignore `Access(Open)` events during event processing.
198                let mut has_file_events = false;
199                let mut has_synthetic_events = false;
200                'outer: for e in action.events.iter() {
201                    if e.is_empty() {
202                        has_synthetic_events = true;
203                        break;
204                    } else {
205                        for tag in &e.tags {
206                            if let Tag::FileEventKind(kind) = tag
207                                && !matches!(kind, FileEventKind::Access(AccessKind::Open(_))) {
208                                    has_file_events = true;
209                                    break 'outer;
210                                }
211                        }
212                    }
213                }
214                if !has_file_events && !has_synthetic_events {
215                    debug!("no filesystem events (other than Access(Open)) or synthetic events, skip without doing more");
216                    return action;
217                }
218            }
219
220            job.run({
221                let job = job.clone();
222                move |context| {
223                    if context.current.is_running() && no_restart {
224                        return;
225                    }
226                    job.restart_with_signal(stop_signal, stop_timeout);
227                    job.run({
228                        let job = job.clone();
229                        move |context| {
230                            clear_screen();
231                            setup_process(job, &context.command)
232                        }
233                    });
234                }
235            });
236
237            action
238        });
239
240        Ok(config)
241    }
242}
243
244fn setup_process(job: Job, _command: &Command) {
245    tokio::spawn(async move {
246        job.to_wait().await;
247        job.run(move |context| end_of_process(context.current));
248    });
249}
250
251fn end_of_process(state: &CommandState) {
252    let CommandState::Finished { status, started, finished } = state else {
253        return;
254    };
255
256    let duration = *finished - *started;
257    let timings = true;
258    let timing = if timings { format!(", lasted {duration:?}") } else { String::new() };
259    let (msg, fg) = match status {
260        ProcessEnd::ExitError(code) => (format!("Command exited with {code}{timing}"), Color::Red),
261        ProcessEnd::ExitSignal(sig) => {
262            (format!("Command killed by {sig:?}{timing}"), Color::Magenta)
263        }
264        ProcessEnd::ExitStop(sig) => (format!("Command stopped by {sig:?}{timing}"), Color::Blue),
265        ProcessEnd::Continued => (format!("Command continued{timing}"), Color::Cyan),
266        ProcessEnd::Exception(ex) => {
267            (format!("Command ended by exception {ex:#x}{timing}"), Color::Yellow)
268        }
269        ProcessEnd::Success => (format!("Command was successful{timing}"), Color::Green),
270    };
271
272    let quiet = false;
273    if !quiet {
274        let _ = sh_eprintln!("{}", format!("[{msg}]").paint(fg.foreground()));
275    }
276}
277
278/// Runs the given [`watchexec::Config`].
279pub async fn run(config: watchexec::Config) -> Result<()> {
280    let wx = Watchexec::with_config(config)?;
281    wx.send_event(Event::default(), Priority::Urgent).await?;
282    wx.main().await??;
283    Ok(())
284}
285
286/// Executes a [`Watchexec`] that listens for changes in the project's src dir and reruns `forge
287/// build`
288pub async fn watch_build(args: BuildArgs) -> Result<()> {
289    let config = args.watchexec_config()?;
290    run(config).await
291}
292
293/// Executes a [`Watchexec`] that listens for changes in the project's src dir and reruns `forge
294/// snapshot`
295pub async fn watch_gas_snapshot(args: GasSnapshotArgs) -> Result<()> {
296    let config = args.watchexec_config()?;
297    run(config).await
298}
299
300/// Executes a [`Watchexec`] that listens for changes in the project's src dir and reruns `forge
301/// test`
302pub async fn watch_test(args: TestArgs) -> Result<()> {
303    let config: Config = args.build.load_config()?;
304    let filter = args.filter(&config)?;
305    // Marker to check whether to override the command.
306    let no_reconfigure = filter.args().test_pattern.is_some()
307        || filter.args().path_pattern.is_some()
308        || filter.args().contract_pattern.is_some()
309        || args.watch.run_all;
310
311    let last_test_files = Mutex::new(HashSet::<String>::default());
312    let project_root = config.root.to_string_lossy().into_owned();
313    let test_failures_file = config.test_failures_file.clone();
314    let rerun_failed = args.watch.rerun_failed;
315
316    let config = args.watch.watchexec_config_with_override(
317        || Ok([&config.test, &config.src]),
318        move |events, command| {
319            // Check if we should prioritize rerunning failed tests
320            let has_failures = rerun_failed && test_failures_file.exists();
321
322            if has_failures {
323                // Smart mode: rerun failed tests first
324                trace!("Smart watch mode: will rerun failed tests first");
325                command.arg("--rerun");
326                // Don't add file-specific filters when rerunning failures
327                return;
328            }
329
330            let mut changed_sol_test_files: HashSet<_> = events
331                .iter()
332                .flat_map(|e| e.paths())
333                .filter(|(path, _)| path.is_sol_test())
334                .filter_map(|(path, _)| path.to_str())
335                .map(str::to_string)
336                .collect();
337
338            if changed_sol_test_files.len() > 1 {
339                // Run all tests if multiple files were changed at once, for example when running
340                // `forge fmt`.
341                return;
342            }
343
344            if changed_sol_test_files.is_empty() {
345                // Reuse the old test files if a non-test file was changed.
346                let last = last_test_files.lock();
347                if last.is_empty() {
348                    return;
349                }
350                changed_sol_test_files = last.clone();
351            }
352
353            // append `--match-path` glob
354            let mut file = changed_sol_test_files.iter().next().expect("test file present").clone();
355
356            // remove the project root dir from the detected file
357            if let Some(f) = file.strip_prefix(&project_root) {
358                file = f.trim_start_matches('/').to_string();
359            }
360
361            trace!(?file, "reconfigure test command");
362
363            // Before appending `--match-path`, check if it already exists
364            if !no_reconfigure {
365                command.arg("--match-path").arg(file);
366            }
367        },
368    )?;
369    run(config).await
370}
371
372pub async fn watch_coverage(args: CoverageArgs) -> Result<()> {
373    let config = args.watch().watchexec_config(|| {
374        let config = args.load_config()?;
375        Ok([config.test, config.src])
376    })?;
377    run(config).await
378}
379
380pub async fn watch_fmt(args: FmtArgs) -> Result<()> {
381    let config = args.watch.watchexec_config(|| {
382        let config = args.load_config()?;
383        Ok([config.src, config.test, config.script])
384    })?;
385    run(config).await
386}
387
388/// Executes a [`Watchexec`] that listens for changes in the project's sources directory
389pub async fn watch_doc(args: DocArgs) -> Result<()> {
390    let config = args.watch.watchexec_config(|| {
391        let config = args.config()?;
392        Ok([config.src])
393    })?;
394    run(config).await
395}
396
397/// Converts a list of arguments to a `watchexec::Command`.
398///
399/// The first index in `args` is the path to the executable.
400///
401/// # Panics
402///
403/// Panics if `args` is empty.
404fn watch_command(mut args: Vec<String>) -> Command {
405    debug_assert!(!args.is_empty());
406    let prog = args.remove(0);
407    Command { program: Program::Exec { prog: prog.into(), args }, options: Default::default() }
408}
409
410/// Returns the env args without the `--watch` flag from the args for the Watchexec command
411fn cmd_args(num: usize) -> Vec<String> {
412    clean_cmd_args(num, std::env::args().collect())
413}
414
415#[instrument(level = "debug", ret)]
416fn clean_cmd_args(num: usize, mut cmd_args: Vec<String>) -> Vec<String> {
417    if let Some(pos) = cmd_args.iter().position(|arg| arg == "--watch" || arg == "-w") {
418        cmd_args.drain(pos..=(pos + num));
419    }
420
421    // There's another edge case where short flags are combined into one which is supported by clap,
422    // like `-vw` for verbosity and watch
423    // this removes any `w` from concatenated short flags
424    if let Some(pos) = cmd_args.iter().position(|arg| {
425        fn contains_w_in_short(arg: &str) -> Option<bool> {
426            let mut iter = arg.chars().peekable();
427            if *iter.peek()? != '-' {
428                return None;
429            }
430            iter.next();
431            if *iter.peek()? == '-' {
432                return None;
433            }
434            Some(iter.any(|c| c == 'w'))
435        }
436        contains_w_in_short(arg).unwrap_or(false)
437    }) {
438        let clean_arg = cmd_args[pos].replace('w', "");
439        if clean_arg == "-" {
440            cmd_args.remove(pos);
441        } else {
442            cmd_args[pos] = clean_arg;
443        }
444    }
445
446    cmd_args
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn parse_cmd_args() {
455        let args = vec!["-vw".to_string()];
456        let cleaned = clean_cmd_args(0, args);
457        assert_eq!(cleaned, vec!["-v".to_string()]);
458    }
459}