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 #[arg(long, short, num_args(0..), value_name = "PATH")]
75 pub watch: Option<Vec<PathBuf>>,
76
77 #[arg(long)]
79 pub no_restart: bool,
80
81 #[arg(long)]
85 pub run_all: bool,
86
87 #[arg(long, alias = "rerun-failures")]
92 pub rerun_failed: bool,
93
94 #[arg(long, value_name = "DELAY")]
108 pub watch_delay: Option<String>,
109}
110
111impl WatchArgs {
112 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 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 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 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 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 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
332pub 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
350pub async fn watch_build(args: BuildArgs) -> Result<()> {
353 let config = args.watchexec_config()?;
354 run(config).await
355}
356
357pub async fn watch_gas_snapshot(args: GasSnapshotArgs) -> Result<()> {
360 let config = args.watchexec_config()?;
361 run(config).await
362}
363
364pub async fn watch_test(args: TestArgs) -> Result<()> {
367 let config: Config = args.build.load_config()?;
368 let filter = args.filter(&config)?;
369 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 let has_failures = rerun_failed && test_failures_file.exists();
391
392 if has_failures {
393 trace!("Smart watch mode: will rerun failed tests first");
395 command.arg("--rerun");
396 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 return;
412 }
413
414 if changed_sol_test_files.is_empty() {
415 let last = last_test_files.lock();
417 if last.is_empty() {
418 return;
419 }
420 changed_sol_test_files = last.clone();
421 }
422
423 let mut file = changed_sol_test_files.iter().next().expect("test file present").clone();
425
426 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 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
465pub 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 paths.push(config.src.clone());
480
481 if include_libraries {
483 for lib in &config.libs {
484 paths.push(lib.clone());
485 }
486 }
487
488 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 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 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 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
529fn 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
542fn 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 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}