home ~ projects ~ socials

Watch a Directory for File Changes in Rust with watchexec

Seeing Things, Doing Things

This is another refinementprior to watching files for changes in Rust. This time, using watchexecwe to handle the debouncing. It'll also automatically stop a command and restart it if it hasn't finished up before the next change happens.

#!/usr/bin/env cargo -q -Zscript

---
[dependencies]
anyhow = "1.0.98"
clearscreen = "4.0.1"
itertools = "0.14.0"
tokio = { version = "1.45.1"}
watchexec = "8.0.1"
watchexec-events = "6.0.0"
watchexec-signals = "5.0.0"
---

use anyhow::Result;
use itertools::Itertools;
use std::sync::Arc;
use std::path::PathBuf;
use watchexec::command::{Command, Program, Shell};
use watchexec::{Id, Watchexec};
use watchexec_events::{Event, Tag};
use watchexec_events::filekind::*;
use watchexec_signals::Signal;

#[tokio::main]
async fn main() -> Result<()> {
    watch_for_changes().await?;
    Ok(())
}

async fn watch_for_changes() -> Result<()> {
    let wx = Watchexec::default();
    wx.config.pathset(vec!["."]);
    wx.config.on_action(move |mut action| {
        if action.signals().any(|sig| sig == Signal::Interrupt) {
            action.quit(); // Required for Ctrl+c to work
        } else {
            for path in get_paths(&action.events).iter() {
                clearscreen::clear().unwrap();
                let job = action.get_or_create_job(Id::default(), || make_command(&path));
                job.restart();
            }
        }
        action
    });
    println!("Watching files for changes");
    let _ = wx.main().await?;
    Ok(())
}

fn make_command(path: &PathBuf) -> Arc<Command> {
    Arc::new(
        Command{
            program: Program::Shell {
                shell: Shell::new("bash"),
                command: format!("cat {}", path.display()),
                args: vec![] // args for bash, not your command
            },
            options: Default::default()
        }
    )
}

fn get_paths(events: &Arc<[Event]>) -> Vec<PathBuf> {
    events
        .iter()
        .filter(|event| {
            event
                .tags
                .iter()
                .find(|tag| {
                    if let Tag::FileEventKind(kind) = &tag {
                        if let FileEventKind::Modify(mod_kind) = kind {
                            if let ModifyKind::Data(change) = mod_kind {
                                if let DataChange::Content = change {
                                    return true;
                                }
                            }
                        }
                    };
                    false
                })
                .is_some()
        })
        .filter_map(|event| {
            event.tags.iter().find_map(|tag| {
                if let Tag::Path { path, .. } = tag {
                    for component in path.components() {
                        if let std::path::Component::Normal(part) = component {
                            if part.display().to_string().starts_with(".") {
                                return None
                            }
                        }
                    };
                    if let Some(file_name_path) = path.file_name() {
                        let file_name = file_name_path.display().to_string();
                        if file_name.ends_with("~") {
                            return None;
                        }
                    };
                    Some(path.to_path_buf())
                } else {
                    None
                }
            })
        })
        .unique()
        .collect()
}

Details

  • The command is run by passing a -c flag to bash. That means the full command (including arguments) needs to be assembled into a string and set for the command of the Program::Shell.

    If you try to put stuff in args they won't got to your command. The way I read the docs, they go to bash, but I haven't checked that directly.

  • The majority of the script is the get_paths() function. When a file change happens a ton of events are fired off. get_paths() filters down to just the events where data changed (instead of metadata).

    The filter_map() filters out files that are either hidden themselves or in a hidden directory. It also filters out files that end with a ~ which is Neovim's temporary file extension.

TODO

    Link watch-scripts

    Link watch-file

    Link serve-folder

-- end of line --

Footnotes

I'd been using notify and notify_debouncer_full to watch for changes. That's what watchexec uses under the hood. It adds the process supervisor which is a very nice quality of life improvement.