Skip to main content

nu_command/filesystem/
watch.rs

1use itertools::Either;
2use notify_debouncer_full::{
3    DebouncedEvent, Debouncer, FileIdMap, new_debouncer,
4    notify::{
5        self, EventKind, RecommendedWatcher, RecursiveMode, Watcher,
6        event::{DataChange, ModifyKind, RenameMode},
7    },
8};
9use nu_engine::{ClosureEval, command_prelude::*};
10use nu_protocol::{
11    Signals,
12    engine::Closure,
13    report_shell_error, report_shell_warning,
14    shell_error::{generic::GenericError, io::IoError},
15};
16use std::{
17    path::{Path, PathBuf},
18    sync::mpsc::{Receiver, RecvTimeoutError, channel},
19    time::Duration,
20};
21
22// durations chosen mostly arbitrarily
23const CHECK_CTRL_C_FREQUENCY: Duration = Duration::from_millis(100);
24const DEFAULT_WATCH_DEBOUNCE_DURATION: Duration = Duration::from_millis(100);
25
26/// Unified glob pattern for watch --glob, routing between legacy and dc_glob backends.
27enum WatchGlob {
28    Legacy(nu_glob::Pattern),
29    DcGlob(nu_glob::dc_glob::DcPattern),
30}
31
32impl WatchGlob {
33    fn matches_path(&self, path: &Path) -> bool {
34        match self {
35            WatchGlob::Legacy(p) => p.matches_path(path),
36            WatchGlob::DcGlob(p) => p.matches_path(path),
37        }
38    }
39}
40
41#[derive(Clone)]
42pub struct Watch;
43
44impl Command for Watch {
45    fn name(&self) -> &str {
46        "watch"
47    }
48
49    fn description(&self) -> &str {
50        "Watch for file changes and execute Nu code when they happen."
51    }
52
53    fn extra_description(&self) -> &str {
54        "When run without a closure, `watch` returns a stream of events instead."
55    }
56
57    fn search_terms(&self) -> Vec<&str> {
58        vec!["watcher", "reload", "filesystem"]
59    }
60
61    fn signature(&self) -> nu_protocol::Signature {
62        Signature::build("watch")
63            .input_output_types(vec![
64                (Type::Nothing, Type::Nothing),
65                (
66                    Type::Nothing,
67                    Type::Table(
68                        vec![
69                            ("operation".into(), Type::String),
70                            ("path".into(), Type::one_of([Type::String, Type::Nothing])),
71                            (
72                                "new_path".into(),
73                                Type::one_of([Type::String, Type::Nothing]),
74                            ),
75                        ]
76                        .into(),
77                    ),
78                ),
79            ])
80            .required(
81                "path",
82                SyntaxShape::Filepath,
83                "The path to watch. Can be a file or directory.",
84            )
85            .optional(
86                "closure",
87                SyntaxShape::Closure(Some(vec![
88                    SyntaxShape::String,
89                    SyntaxShape::String,
90                    SyntaxShape::String,
91                ])),
92                "Some Nu code to run whenever a file changes. \
93                    The closure will be passed `operation`, `path`, \
94                    and `new_path` (for renames only) arguments in that order (deprecated).",
95            )
96            .named(
97                "debounce",
98                SyntaxShape::Duration,
99                "Debounce changes for this duration (default: 100ms). \
100                    Adjust if you find that single writes are reported as multiple events.",
101                Some('d'),
102            )
103            .named(
104                "glob",
105                // SyntaxShape::GlobPattern gets interpreted relative to cwd, so use String instead
106                SyntaxShape::String,
107                "Only report changes for files that match this glob pattern (default: all files)",
108                Some('g'),
109            )
110            .named(
111                "recursive",
112                SyntaxShape::Boolean,
113                "Watch all directories under `<path>` recursively. \
114                    Will be ignored if `<path>` is a file (default: true).",
115                Some('r'),
116            )
117            .switch(
118                "quiet",
119                "Hide the initial status message (default: false).",
120                Some('q'),
121            )
122            .switch(
123                "verbose",
124                "Operate in verbose mode (default: false).",
125                Some('v'),
126            )
127            .category(Category::FileSystem)
128    }
129
130    fn run(
131        &self,
132        engine_state: &EngineState,
133        stack: &mut Stack,
134        call: &Call,
135        _input: PipelineData,
136    ) -> Result<PipelineData, ShellError> {
137        let head = call.head;
138        let path = {
139            let cwd = engine_state.cwd_as_string(Some(stack))?;
140            let path_arg: Spanned<String> = call.req(engine_state, stack, 0)?;
141            let path_no_whitespace = path_arg
142                .item
143                .trim_end_matches(|x| matches!(x, '\x09'..='\x0d'));
144
145            nu_path::absolute_with(path_no_whitespace, cwd).map_err(|err| {
146                ShellError::Io(IoError::new(
147                    err,
148                    path_arg.span,
149                    PathBuf::from(path_no_whitespace),
150                ))
151            })?
152        };
153        let closure: Option<Spanned<Closure>> = call.opt(engine_state, stack, 1)?;
154        let verbose = call.has_flag(engine_state, stack, "verbose")?;
155        let quiet = call.has_flag(engine_state, stack, "quiet")?;
156        let debounce_duration: Duration = call
157            .get_flag(engine_state, stack, "debounce")?
158            .unwrap_or(DEFAULT_WATCH_DEBOUNCE_DURATION);
159
160        let glob_pattern = call
161            .get_flag::<Spanned<String>>(engine_state, stack, "glob")?
162            .map(|glob| {
163                let absolute_path = path.join(glob.item);
164                if verbose {
165                    eprintln!("Absolute glob path: {absolute_path:?}");
166                }
167                let path_str = absolute_path.to_string_lossy();
168                if nu_experimental::DC_GLOB.get() {
169                    nu_glob::dc_glob::DcPattern::new(path_str.as_ref())
170                        .map(WatchGlob::DcGlob)
171                        .map_err(|_| ShellError::TypeMismatch {
172                            err_message: "Glob pattern is invalid".to_string(),
173                            span: glob.span,
174                        })
175                } else {
176                    nu_glob::Pattern::new(path_str.as_ref())
177                        .map(WatchGlob::Legacy)
178                        .map_err(|_| ShellError::TypeMismatch {
179                            err_message: "Glob pattern is invalid".to_string(),
180                            span: glob.span,
181                        })
182                }
183            })
184            .transpose()?;
185
186        let recursive_mode = {
187            let recursive_flag = call
188                .get_flag::<bool>(engine_state, stack, "recursive")?
189                .unwrap_or(true);
190            match recursive_flag {
191                true => RecursiveMode::Recursive,
192                false => RecursiveMode::NonRecursive,
193            }
194        };
195
196        let iter = {
197            let (tx, rx) = channel();
198
199            let mut debouncer = new_debouncer(debounce_duration, None, tx)
200                .and_then(|mut debouncer| {
201                    debouncer.watcher().watch(&path, recursive_mode)?;
202                    Ok(debouncer)
203                })
204                .map_err(|err| {
205                    ShellError::Generic(GenericError::new(
206                        "Failed to create watcher",
207                        err.to_string(),
208                        call.head,
209                    ))
210                })?;
211
212            // need to cache to make sure that rename event works.
213            debouncer.cache().add_root(&path, recursive_mode);
214
215            WatchIterator::new(debouncer, rx, engine_state.signals().clone())
216        };
217
218        if let Some(closure) = closure {
219            report_shell_warning(
220                Some(stack),
221                engine_state,
222                &ShellWarning::Deprecated {
223                    dep_type: "Argument".into(),
224                    label: "remove this".into(),
225                    span: closure.span,
226                    help: "Since 0.113.0, running a closure with `watch` is deprecated. \
227                            Instead, use `watch` by piping its output to `each` \
228                            or as the source of a `for` loop."
229                        .to_string()
230                        .into(),
231                    report_mode: nu_protocol::ReportMode::FirstUse,
232                },
233            );
234
235            #[allow(deprecated)]
236            run_closure(
237                engine_state,
238                stack,
239                head,
240                quiet,
241                verbose,
242                &path,
243                glob_pattern,
244                iter,
245                closure.item,
246            )
247        } else {
248            let out = iter
249                .flat_map(|e| match e {
250                    Ok(events) => Either::Right(events.into_iter().map(Ok)),
251                    Err(err) => Either::Left(std::iter::once(Err(err))),
252                })
253                .filter_map(move |e| match e {
254                    Ok(ev) if glob_filter(glob_pattern.as_ref(), &ev) => Some(ev.into_value(head)),
255                    Ok(_) => None,
256                    Err(err) => Some(Value::error(err, head)),
257                })
258                .into_pipeline_data(head, engine_state.signals().clone());
259            Ok(out)
260        }
261    }
262
263    fn examples(&self) -> Vec<Example<'_>> {
264        vec![
265            Example {
266                description: "Run `cargo test` whenever a Rust file changes.",
267                example: "for _ in (watch . --glob=**/*.rs) { cargo test }",
268                result: None,
269            },
270            Example {
271                description: "Watch all changes in the current directory.",
272                example: "watch . | each { print }",
273                result: None,
274            },
275            Example {
276                description: "Filter, limit and modify `watch`'s output by using it as part of a pipeline.",
277                example: r#"watch /foo/bar
278    | where operation == Create
279    | first 5
280    | each {|e| $"New file!: ($e.path)" }
281    | to text
282    | save --append changes_in_bar.log"#,
283                result: None,
284            },
285            Example {
286                description: "Print file changes with a debounce time of 5 minutes.",
287                example: r#"watch /foo/bar --debounce 5min | each {|e| $"Registered ($e.operation) on ($e.path)" | print }"#,
288                result: None,
289            },
290            Example {
291                description: "Note: if you are looking to run a command every N units of time, this can be accomplished with a loop and sleep.",
292                example: "loop { command; sleep duration }",
293                result: None,
294            },
295            Example {
296                description: "Run `cargo test` whenever a Rust file changes (with the deprecated closure argument).",
297                example: "watch . --glob=**/*.rs {|| cargo test }",
298                result: None,
299            },
300        ]
301    }
302}
303
304fn glob_filter(glob: Option<&WatchGlob>, ev: &WatchEvent) -> bool {
305    let Some(glob) = glob else { return true };
306    let Some(path) = ev.path.as_deref().or(ev.new_path.as_deref()) else {
307        return false;
308    };
309
310    glob.matches_path(path)
311}
312
313#[allow(clippy::too_many_arguments)]
314#[inline]
315#[deprecated(since = "0.113.0")]
316fn run_closure(
317    engine_state: &EngineState,
318    stack: &mut Stack,
319    head: Span,
320    quiet: bool,
321    verbose: bool,
322    path: &Path,
323    glob_pattern: Option<WatchGlob>,
324    iter: WatchIterator,
325    closure: Closure,
326) -> Result<PipelineData, ShellError> {
327    if !quiet {
328        eprintln!("Now watching files at {path:?}. Press ctrl+c to abort.");
329    }
330
331    let mut closure = ClosureEval::new(engine_state, stack, closure);
332    for events in iter {
333        for event in events? {
334            let matches_glob = glob_filter(glob_pattern.as_ref(), &event);
335
336            if verbose && glob_pattern.is_some() {
337                eprintln!("Matches glob: {matches_glob}");
338            }
339
340            if matches_glob {
341                let result = closure
342                    .add_arg(event.operation.into_value(head))?
343                    .add_arg(event.path.into_value(head))?
344                    .add_arg(event.new_path.into_value(head))?
345                    .run_with_input(PipelineData::empty());
346
347                match result {
348                    Ok(val) => val.print_table(engine_state, stack, false, false)?,
349                    Err(err) => report_shell_error(Some(stack), engine_state, &err),
350                };
351            }
352        }
353    }
354
355    Ok(PipelineData::empty())
356}
357
358#[derive(IntoValue)]
359struct WatchEvent {
360    operation: WatchEventKind,
361    path: Option<PathBuf>,
362    new_path: Option<PathBuf>,
363}
364
365#[derive(IntoValue)]
366#[nu_value(rename_all = "UpperCamelCase")]
367enum WatchEventKind {
368    Create,
369    Write,
370    Rename,
371    Remove,
372}
373
374impl TryFrom<EventKind> for WatchEventKind {
375    type Error = ();
376
377    fn try_from(value: EventKind) -> Result<Self, Self::Error> {
378        Ok(match value {
379            EventKind::Create(_) => Self::Create,
380            EventKind::Remove(_) => Self::Remove,
381            EventKind::Modify(
382                ModifyKind::Data(DataChange::Content | DataChange::Any) | ModifyKind::Any,
383            ) => Self::Write,
384            EventKind::Modify(ModifyKind::Name(
385                RenameMode::Both | RenameMode::From | RenameMode::To,
386            )) => Self::Rename,
387            _ => return Err(()),
388        })
389    }
390}
391
392impl TryFrom<DebouncedEvent> for WatchEvent {
393    type Error = ();
394
395    fn try_from(ev: DebouncedEvent) -> Result<Self, Self::Error> {
396        // TODO: Maybe we should handle all event kinds?
397        let DebouncedEvent {
398            event: notify::Event {
399                kind, mut paths, ..
400            },
401            ..
402        } = ev;
403
404        let (path, new_path) = match paths.as_mut_slice() {
405            [path] => (std::mem::take(path), None),
406            [path, new_path] => (std::mem::take(path), Some(std::mem::take(new_path))),
407            _ => return Err(()),
408        };
409
410        if let EventKind::Modify(ModifyKind::Name(RenameMode::To)) = kind {
411            Ok(WatchEvent {
412                operation: WatchEventKind::Rename,
413                path: None,
414                new_path: Some(path),
415            })
416        } else {
417            Ok(WatchEvent {
418                operation: kind.try_into()?,
419                path: Some(path),
420                new_path,
421            })
422        }
423    }
424}
425
426struct WatchIterator {
427    /// Debouncer needs to be kept alive for `rx` to keep receiving events.
428    _debouncer: Debouncer<RecommendedWatcher, FileIdMap>,
429    rx: Option<Receiver<Result<Vec<DebouncedEvent>, Vec<notify::Error>>>>,
430    signals: Signals,
431}
432
433impl WatchIterator {
434    fn new(
435        debouncer: Debouncer<RecommendedWatcher, FileIdMap>,
436        rx: Receiver<Result<Vec<DebouncedEvent>, Vec<notify::Error>>>,
437        signals: Signals,
438    ) -> Self {
439        Self {
440            _debouncer: debouncer,
441            rx: Some(rx),
442            signals,
443        }
444    }
445}
446
447impl Iterator for WatchIterator {
448    type Item = Result<Vec<WatchEvent>, ShellError>;
449
450    fn next(&mut self) -> Option<Self::Item> {
451        let rx = self.rx.as_ref()?;
452        while !self.signals.interrupted() {
453            let x = match rx.recv_timeout(CHECK_CTRL_C_FREQUENCY) {
454                Ok(x) => x,
455                Err(RecvTimeoutError::Timeout) => continue,
456                Err(RecvTimeoutError::Disconnected) => {
457                    self.rx = None;
458                    return Some(Err(ShellError::Generic(GenericError::new_internal(
459                        "Disconnected",
460                        "Unexpected disconnect from file watcher",
461                    ))));
462                }
463            };
464
465            let Ok(events) = x else {
466                self.rx = None;
467                return Some(Err(ShellError::Generic(GenericError::new_internal(
468                    "Receiving events failed",
469                    "Unexpected errors when receiving events",
470                ))));
471            };
472
473            let watch_events = events
474                .into_iter()
475                .filter_map(|ev| WatchEvent::try_from(ev).ok())
476                .collect::<Vec<_>>();
477
478            return Some(Ok(watch_events));
479        }
480        self.rx = None;
481        None
482    }
483}