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 #[arg(long, short, num_args(0..), value_name = "PATH")]
43 pub watch: Option<Vec<PathBuf>>,
44
45 #[arg(long)]
47 pub no_restart: bool,
48
49 #[arg(long)]
53 pub run_all: bool,
54
55 #[arg(long, alias = "rerun-failures")]
60 pub rerun_failed: bool,
61
62 #[arg(long, value_name = "DELAY")]
76 pub watch_delay: Option<String>,
77}
78
79impl WatchArgs {
80 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 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 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 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 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
278pub 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
286pub async fn watch_build(args: BuildArgs) -> Result<()> {
289 let config = args.watchexec_config()?;
290 run(config).await
291}
292
293pub async fn watch_gas_snapshot(args: GasSnapshotArgs) -> Result<()> {
296 let config = args.watchexec_config()?;
297 run(config).await
298}
299
300pub async fn watch_test(args: TestArgs) -> Result<()> {
303 let config: Config = args.build.load_config()?;
304 let filter = args.filter(&config)?;
305 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 let has_failures = rerun_failed && test_failures_file.exists();
321
322 if has_failures {
323 trace!("Smart watch mode: will rerun failed tests first");
325 command.arg("--rerun");
326 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 return;
342 }
343
344 if changed_sol_test_files.is_empty() {
345 let last = last_test_files.lock();
347 if last.is_empty() {
348 return;
349 }
350 changed_sol_test_files = last.clone();
351 }
352
353 let mut file = changed_sol_test_files.iter().next().expect("test file present").clone();
355
356 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 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
388pub 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
397fn 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
410fn 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 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}