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::{
28 filekind::{AccessKind, FileEventKind},
29 Event, Priority, ProcessEnd, Tag,
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, value_name = "DELAY")]
69 pub watch_delay: Option<String>,
70}
71
72impl WatchArgs {
73 pub fn watchexec_config<PS: IntoIterator<Item = P>, P: Into<PathBuf>>(
78 &self,
79 default_paths: impl FnOnce() -> Result<PS>,
80 ) -> Result<watchexec::Config> {
81 self.watchexec_config_generic(default_paths, None)
82 }
83
84 pub fn watchexec_config_with_override<PS: IntoIterator<Item = P>, P: Into<PathBuf>>(
89 &self,
90 default_paths: impl FnOnce() -> Result<PS>,
91 spawn_hook: impl Fn(&[Event], &mut TokioCommand) + Send + Sync + 'static,
92 ) -> Result<watchexec::Config> {
93 self.watchexec_config_generic(default_paths, Some(Arc::new(spawn_hook)))
94 }
95
96 fn watchexec_config_generic<PS: IntoIterator<Item = P>, P: Into<PathBuf>>(
97 &self,
98 default_paths: impl FnOnce() -> Result<PS>,
99 spawn_hook: Option<SpawnHook>,
100 ) -> Result<watchexec::Config> {
101 let mut paths = self.watch.as_deref().unwrap_or_default();
102 let storage: Vec<_>;
103 if paths.is_empty() {
104 storage = default_paths()?.into_iter().map(Into::into).filter(|p| p.exists()).collect();
105 paths = &storage;
106 }
107 self.watchexec_config_inner(paths, spawn_hook)
108 }
109
110 fn watchexec_config_inner(
111 &self,
112 paths: &[PathBuf],
113 spawn_hook: Option<SpawnHook>,
114 ) -> Result<watchexec::Config> {
115 let config = watchexec::Config::default();
116
117 config.on_error(|err| {
118 let _ = sh_eprintln!("[[{err:?}]]");
119 });
120
121 if let Some(delay) = &self.watch_delay {
122 config.throttle(utils::parse_delay(delay)?);
123 }
124
125 config.pathset(paths.iter().map(|p| p.as_path()));
126
127 let n_path_args = self.watch.as_deref().unwrap_or_default().len();
128 let base_command = Arc::new(watch_command(cmd_args(n_path_args)));
129
130 let id = watchexec::Id::default();
131 let quit_again = Arc::new(AtomicU8::new(0));
132 let stop_timeout = Duration::from_secs(5);
133 let no_restart = self.no_restart;
134 let stop_signal = Signal::Terminate;
135 config.on_action(move |mut action| {
136 let base_command = base_command.clone();
137 let job = action.get_or_create_job(id, move || base_command.clone());
138
139 let events = action.events.clone();
140 let spawn_hook = spawn_hook.clone();
141 job.set_spawn_hook(move |command, _| {
142 let env = summarise_events_to_env(events.iter());
144 for (k, v) in env {
145 command.command_mut().env(format!("WATCHEXEC_{k}_PATH"), v);
146 }
147
148 if let Some(spawn_hook) = &spawn_hook {
149 spawn_hook(&events, command.command_mut());
150 }
151 });
152
153 let clear_screen = || {
154 let _ = clearscreen::clear();
155 };
156
157 let quit = |mut action: ActionHandler| {
158 match quit_again.fetch_add(1, Ordering::Relaxed) {
159 0 => {
160 let _ = sh_eprintln!(
161 "[Waiting {stop_timeout:?} for processes to exit before stopping... \
162 Ctrl-C again to exit faster]"
163 );
164 action.quit_gracefully(stop_signal, stop_timeout);
165 }
166 1 => action.quit_gracefully(Signal::ForceStop, Duration::ZERO),
167 _ => action.quit(),
168 }
169
170 action
171 };
172
173 let signals = action.signals().collect::<Vec<_>>();
174
175 if signals.contains(&Signal::Terminate) || signals.contains(&Signal::Interrupt) {
176 return quit(action);
177 }
178
179 if action.paths().next().is_none() && !action.events.iter().any(|e| e.is_empty()) {
181 debug!("no filesystem or synthetic events, skip without doing more");
182 return action;
183 }
184
185 if cfg!(target_os = "linux") {
186 let mut has_file_events = false;
192 let mut has_synthetic_events = false;
193 'outer: for e in action.events.iter() {
194 if e.is_empty() {
195 has_synthetic_events = true;
196 break;
197 } else {
198 for tag in &e.tags {
199 if let Tag::FileEventKind(kind) = tag {
200 if !matches!(kind, FileEventKind::Access(AccessKind::Open(_))) {
201 has_file_events = true;
202 break 'outer;
203 }
204 }
205 }
206 }
207 }
208 if !has_file_events && !has_synthetic_events {
209 debug!("no filesystem events (other than Access(Open)) or synthetic events, skip without doing more");
210 return action;
211 }
212 }
213
214 job.run({
215 let job = job.clone();
216 move |context| {
217 if context.current.is_running() && no_restart {
218 return;
219 }
220 job.restart_with_signal(stop_signal, stop_timeout);
221 job.run({
222 let job = job.clone();
223 move |context| {
224 clear_screen();
225 setup_process(job, &context.command)
226 }
227 });
228 }
229 });
230
231 action
232 });
233
234 Ok(config)
235 }
236}
237
238fn setup_process(job: Job, _command: &Command) {
239 tokio::spawn(async move {
240 job.to_wait().await;
241 job.run(move |context| end_of_process(context.current));
242 });
243}
244
245fn end_of_process(state: &CommandState) {
246 let CommandState::Finished { status, started, finished } = state else {
247 return;
248 };
249
250 let duration = *finished - *started;
251 let timings = true;
252 let timing = if timings { format!(", lasted {duration:?}") } else { String::new() };
253 let (msg, fg) = match status {
254 ProcessEnd::ExitError(code) => (format!("Command exited with {code}{timing}"), Color::Red),
255 ProcessEnd::ExitSignal(sig) => {
256 (format!("Command killed by {sig:?}{timing}"), Color::Magenta)
257 }
258 ProcessEnd::ExitStop(sig) => (format!("Command stopped by {sig:?}{timing}"), Color::Blue),
259 ProcessEnd::Continued => (format!("Command continued{timing}"), Color::Cyan),
260 ProcessEnd::Exception(ex) => {
261 (format!("Command ended by exception {ex:#x}{timing}"), Color::Yellow)
262 }
263 ProcessEnd::Success => (format!("Command was successful{timing}"), Color::Green),
264 };
265
266 let quiet = false;
267 if !quiet {
268 let _ = sh_eprintln!("{}", format!("[{msg}]").paint(fg.foreground()));
269 }
270}
271
272pub async fn run(config: watchexec::Config) -> Result<()> {
274 let wx = Watchexec::with_config(config)?;
275 wx.send_event(Event::default(), Priority::Urgent).await?;
276 wx.main().await??;
277 Ok(())
278}
279
280pub async fn watch_build(args: BuildArgs) -> Result<()> {
283 let config = args.watchexec_config()?;
284 run(config).await
285}
286
287pub async fn watch_gas_snapshot(args: GasSnapshotArgs) -> Result<()> {
290 let config = args.watchexec_config()?;
291 run(config).await
292}
293
294pub async fn watch_test(args: TestArgs) -> Result<()> {
297 let config: Config = args.build.load_config()?;
298 let filter = args.filter(&config)?;
299 let no_reconfigure = filter.args().test_pattern.is_some() ||
301 filter.args().path_pattern.is_some() ||
302 filter.args().contract_pattern.is_some() ||
303 args.watch.run_all;
304
305 let last_test_files = Mutex::new(HashSet::<String>::default());
306 let project_root = config.root.to_string_lossy().into_owned();
307 let config = args.watch.watchexec_config_with_override(
308 || Ok([&config.test, &config.src]),
309 move |events, command| {
310 let mut changed_sol_test_files: HashSet<_> = events
311 .iter()
312 .flat_map(|e| e.paths())
313 .filter(|(path, _)| path.is_sol_test())
314 .filter_map(|(path, _)| path.to_str())
315 .map(str::to_string)
316 .collect();
317
318 if changed_sol_test_files.len() > 1 {
319 return;
322 }
323
324 if changed_sol_test_files.is_empty() {
325 let last = last_test_files.lock();
327 if last.is_empty() {
328 return;
329 }
330 changed_sol_test_files = last.clone();
331 }
332
333 let mut file = changed_sol_test_files.iter().next().expect("test file present").clone();
335
336 if let Some(f) = file.strip_prefix(&project_root) {
338 file = f.trim_start_matches('/').to_string();
339 }
340
341 trace!(?file, "reconfigure test command");
342
343 if !no_reconfigure {
345 command.arg("--match-path").arg(file);
346 }
347 },
348 )?;
349 run(config).await
350}
351
352pub async fn watch_coverage(args: CoverageArgs) -> Result<()> {
353 let config = args.watch().watchexec_config(|| {
354 let config = args.load_config()?;
355 Ok([config.test, config.src])
356 })?;
357 run(config).await
358}
359
360pub async fn watch_fmt(args: FmtArgs) -> Result<()> {
361 let config = args.watch.watchexec_config(|| {
362 let config = args.load_config()?;
363 Ok([config.src, config.test, config.script])
364 })?;
365 run(config).await
366}
367
368pub async fn watch_doc(args: DocArgs) -> Result<()> {
370 let config = args.watch.watchexec_config(|| {
371 let config = args.config()?;
372 Ok([config.src])
373 })?;
374 run(config).await
375}
376
377fn watch_command(mut args: Vec<String>) -> Command {
385 debug_assert!(!args.is_empty());
386 let prog = args.remove(0);
387 Command { program: Program::Exec { prog: prog.into(), args }, options: Default::default() }
388}
389
390fn cmd_args(num: usize) -> Vec<String> {
392 clean_cmd_args(num, std::env::args().collect())
393}
394
395#[instrument(level = "debug", ret)]
396fn clean_cmd_args(num: usize, mut cmd_args: Vec<String>) -> Vec<String> {
397 if let Some(pos) = cmd_args.iter().position(|arg| arg == "--watch" || arg == "-w") {
398 cmd_args.drain(pos..=(pos + num));
399 }
400
401 if let Some(pos) = cmd_args.iter().position(|arg| {
405 fn contains_w_in_short(arg: &str) -> Option<bool> {
406 let mut iter = arg.chars().peekable();
407 if *iter.peek()? != '-' {
408 return None
409 }
410 iter.next();
411 if *iter.peek()? == '-' {
412 return None
413 }
414 Some(iter.any(|c| c == 'w'))
415 }
416 contains_w_in_short(arg).unwrap_or(false)
417 }) {
418 let clean_arg = cmd_args[pos].replace('w', "");
419 if clean_arg == "-" {
420 cmd_args.remove(pos);
421 } else {
422 cmd_args[pos] = clean_arg;
423 }
424 }
425
426 cmd_args
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 #[test]
434 fn parse_cmd_args() {
435 let args = vec!["-vw".to_string()];
436 let cleaned = clean_cmd_args(0, args);
437 assert_eq!(cleaned, vec!["-v".to_string()]);
438 }
439}