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