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 #[arg(long, short, num_args(0..), value_name = "PATH")]
40 pub watch: Option<Vec<PathBuf>>,
41
42 #[arg(long)]
44 pub no_restart: bool,
45
46 #[arg(long)]
50 pub run_all: bool,
51
52 #[arg(long, value_name = "DELAY")]
66 pub watch_delay: Option<String>,
67}
68
69impl WatchArgs {
70 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 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 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 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
240pub 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
248pub async fn watch_build(args: BuildArgs) -> Result<()> {
251 let config = args.watchexec_config()?;
252 run(config).await
253}
254
255pub async fn watch_gas_snapshot(args: GasSnapshotArgs) -> Result<()> {
258 let config = args.watchexec_config()?;
259 run(config).await
260}
261
262pub async fn watch_test(args: TestArgs) -> Result<()> {
265 let config: Config = args.build.load_config()?;
266 let filter = args.filter(&config)?;
267 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 return;
290 }
291
292 if changed_sol_test_files.is_empty() {
293 let last = last_test_files.lock();
295 if last.is_empty() {
296 return;
297 }
298 changed_sol_test_files = last.clone();
299 }
300
301 let mut file = changed_sol_test_files.iter().next().expect("test file present").clone();
303
304 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 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
336pub 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
345fn 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
358fn 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 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}