use crate::Result;
use crate::pitchfork_toml::WatchMode;
use globset::{GlobBuilder, GlobMatcher};
use itertools::Itertools;
use miette::IntoDiagnostic;
use notify::event::ModifyKind;
use notify::{Config, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode};
use notify_debouncer_full::{DebounceEventResult, Debouncer, NoCache, new_debouncer_opt};
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::time::Duration;
#[derive(Debug)]
pub struct WatchEvents {
pub paths: Vec<PathBuf>,
pub created: Vec<PathBuf>,
}
pub struct WatchFiles {
pub rx: tokio::sync::mpsc::Receiver<WatchEvents>,
backend: WatchFilesBackend,
}
enum WatchFilesBackend {
Native(Debouncer<RecommendedWatcher, NoCache>),
Poll(Debouncer<PollWatcher, NoCache>),
}
impl WatchFiles {
pub fn new(duration: Duration, mode: WatchMode, poll_interval: Duration) -> Result<Self> {
let h = tokio::runtime::Handle::current();
let (tx, rx) = tokio::sync::mpsc::channel(256);
let make_callback = |tx: tokio::sync::mpsc::Sender<WatchEvents>,
h: tokio::runtime::Handle| {
move |res: DebounceEventResult| {
let Ok(ev) = res else { return };
let mut paths = vec![];
let mut created = vec![];
for e in ev.iter().filter(|e| {
matches!(
e.kind,
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
)
}) {
paths.extend(e.paths.iter().cloned());
if matches!(
e.kind,
EventKind::Create(_) | EventKind::Modify(ModifyKind::Name(_))
) {
created.extend(e.paths.iter().cloned());
}
}
if paths.is_empty() {
return;
}
let events = WatchEvents {
paths: paths.into_iter().unique().collect(),
created: created.into_iter().unique().collect(),
};
let tx = tx.clone();
h.spawn(async move {
let _ = tx.send(events).await;
});
}
};
let backend = match mode {
WatchMode::Native => WatchFilesBackend::Native(
new_debouncer_opt(
duration,
None,
make_callback(tx.clone(), h.clone()),
NoCache::new(),
Config::default(),
)
.into_diagnostic()?,
),
WatchMode::Poll => WatchFilesBackend::Poll(
new_debouncer_opt(
duration,
None,
make_callback(tx.clone(), h.clone()),
NoCache::new(),
Config::default().with_poll_interval(poll_interval),
)
.into_diagnostic()?,
),
WatchMode::Auto => {
return Err(miette::miette!(
"WatchMode::Auto must not be passed directly to WatchFiles::new; \
the caller must resolve auto to native or poll"
));
}
};
Ok(Self { backend, rx })
}
pub fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> {
match &mut self.backend {
WatchFilesBackend::Native(debouncer) => {
debouncer.watch(path, recursive_mode).into_diagnostic()
}
WatchFilesBackend::Poll(debouncer) => {
debouncer.watch(path, recursive_mode).into_diagnostic()
}
}
}
pub fn unwatch(&mut self, path: &Path) -> Result<()> {
match &mut self.backend {
WatchFilesBackend::Native(debouncer) => debouncer.unwatch(path).into_diagnostic(),
WatchFilesBackend::Poll(debouncer) => debouncer.unwatch(path).into_diagnostic(),
}
}
}
pub fn watched_entries(dir: &Path, mode: RecursiveMode) -> Vec<PathBuf> {
let mut paths = vec![];
collect_entries(dir, mode, &mut paths);
paths
}
fn collect_entries(dir: &Path, mode: RecursiveMode, paths: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if mode == RecursiveMode::Recursive && entry.file_type().is_ok_and(|t| t.is_dir()) {
collect_entries(&path, mode, paths);
}
paths.push(path);
}
}
fn normalize_watch_path(path: &Path) -> PathBuf {
match path.canonicalize() {
Ok(p) => {
#[cfg(windows)]
{
strip_verbatim_prefix(&p)
}
#[cfg(not(windows))]
{
p
}
}
Err(_) => {
if path.is_absolute() {
path.to_path_buf()
} else {
crate::env::CWD.join(path)
}
}
}
}
#[cfg(windows)]
fn strip_verbatim_prefix(path: &Path) -> PathBuf {
let s = path.to_string_lossy();
if let Some(rest) = s.strip_prefix(r"\\?\") {
if let Some(unc) = rest.strip_prefix(r"UNC\") {
PathBuf::from(format!(r"\\{}", unc))
} else {
PathBuf::from(rest)
}
} else {
path.to_path_buf()
}
}
pub fn expand_watch_patterns(
patterns: &[String],
base_dir: &Path,
) -> HashMap<PathBuf, RecursiveMode> {
let mut targets = HashMap::new();
for pattern in patterns {
for alt in relative_alternatives(pattern) {
for (dir, mode) in watch_targets_for_pattern(&alt, base_dir) {
insert_watch_target(&mut targets, normalize_watch_path(&dir), mode);
}
}
}
targets
}
fn relative_alternatives(pattern: &str) -> Vec<String> {
let path = Path::new(pattern);
if path.is_absolute() || path.has_root() {
return expand_braces(pattern);
}
expand_braces(pattern)
.into_iter()
.map(|alt| alt.trim_start_matches(['/', '\\']).to_string())
.filter(|alt| !Path::new(alt).is_absolute())
.collect()
}
fn expand_braces(pattern: &str) -> Vec<String> {
const MAX_ALTERNATIVES: usize = 64;
let mut expanded = vec![];
let mut pending = vec![pattern.to_string()];
while let Some(p) = pending.pop() {
match first_brace_group(&p) {
Some((start, end, alternatives)) => {
for alt in alternatives {
pending.push(format!("{}{alt}{}", &p[..start], &p[end + 1..]));
}
}
None => expanded.push(p),
}
if expanded.len() + pending.len() > MAX_ALTERNATIVES {
return vec![pattern.to_string()];
}
}
expanded
}
fn first_brace_group(pattern: &str) -> Option<(usize, usize, Vec<&str>)> {
let bytes = pattern.as_bytes();
let (mut start, mut alt_start, mut depth) = (0, 0, 0);
let mut in_class = false;
let mut alternatives = vec![];
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' if cfg!(not(windows)) => i += 1,
b']' if in_class => in_class = false,
_ if in_class => {}
b'[' => in_class = true,
b'{' => {
if depth == 0 {
start = i;
alt_start = i + 1;
}
depth += 1;
}
b',' if depth == 1 => {
alternatives.push(&pattern[alt_start..i]);
alt_start = i + 1;
}
b'}' if depth > 0 => {
depth -= 1;
if depth == 0 {
alternatives.push(&pattern[alt_start..i]);
return Some((start, i, alternatives));
}
}
_ => {}
}
i += 1;
}
None
}
pub fn insert_watch_target(
targets: &mut HashMap<PathBuf, RecursiveMode>,
dir: PathBuf,
mode: RecursiveMode,
) {
let entry = targets.entry(dir).or_insert(mode);
if mode == RecursiveMode::Recursive {
*entry = RecursiveMode::Recursive;
}
}
fn watch_targets_for_pattern(pattern: &str, base_dir: &Path) -> Vec<(PathBuf, RecursiveMode)> {
let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
let full_path = base_dir.join(pattern);
let mut components = full_path.components().collect_vec();
let Some(file_part) = components.pop() else {
return vec![];
};
let mut dir_parts = components.into_iter().peekable();
let mut literal_dir = PathBuf::new();
while let Some(part) = dir_parts.next_if(|c| !is_glob_component(c)) {
literal_dir.push(part);
}
if !literal_dir.is_dir() {
return literal_dir
.ancestors()
.find(|p| p.is_dir())
.map(|p| vec![(p.to_path_buf(), RecursiveMode::NonRecursive)])
.unwrap_or_default();
}
let mut targets = vec![];
let mut current = vec![literal_dir];
for part in dir_parts {
let part = part.as_os_str().to_string_lossy();
if part == "**" {
targets.extend(current.into_iter().map(|d| (d, RecursiveMode::Recursive)));
return targets;
}
let Some(matcher) = component_matcher(&part) else {
let full_pattern = normalize_path_for_glob(&full_path.to_string_lossy());
match GlobBuilder::new(&full_pattern).build() {
Ok(_) => targets.extend(current.into_iter().map(|d| (d, RecursiveMode::Recursive))),
Err(e) => log::warn!("Invalid glob pattern '{pattern}': {e}"),
}
return targets;
};
targets.extend(
current
.iter()
.map(|d| (d.clone(), RecursiveMode::NonRecursive)),
);
current = current
.iter()
.flat_map(|d| matching_subdirs(d, &matcher))
.collect();
}
let mode = if file_part.as_os_str() == "**" {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
targets.extend(current.into_iter().map(|d| (d, mode)));
targets
}
fn is_glob_component(component: &Component) -> bool {
component
.as_os_str()
.to_string_lossy()
.contains(['*', '?', '[', '{'])
}
fn component_matcher(component: &str) -> Option<GlobMatcher> {
GlobBuilder::new(component)
.case_insensitive(cfg!(target_os = "windows"))
.literal_separator(true)
.build()
.ok()
.map(|glob| glob.compile_matcher())
}
fn matching_subdirs(dir: &Path, matcher: &GlobMatcher) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return vec![];
};
entries
.flatten()
.filter(|e| matcher.is_match(e.file_name()))
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect()
}
fn normalize_path_for_glob(path: &str) -> String {
if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
format!("//{}", rest.replace('\\', "/"))
} else {
path.strip_prefix(r"\\?\")
.unwrap_or(path)
.replace('\\', "/")
}
}
pub fn path_matches_patterns(changed_path: &Path, patterns: &[String], base_dir: &Path) -> bool {
let changed_path_str = normalize_path_for_glob(&changed_path.to_string_lossy());
for pattern in patterns.iter().flat_map(|p| relative_alternatives(p)) {
let normalized_pattern = pattern.strip_prefix("./").unwrap_or(&pattern);
let full_pattern = if Path::new(normalized_pattern).is_absolute() {
normalize_path_for_glob(normalized_pattern)
} else {
normalize_path_for_glob(&base_dir.join(normalized_pattern).to_string_lossy())
};
let glob = globset::GlobBuilder::new(&full_pattern)
.case_insensitive(cfg!(target_os = "windows"))
.literal_separator(true) .build();
if let Ok(glob) = glob {
let matcher = glob.compile_matcher();
if matcher.is_match(&changed_path_str) {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_normalize_watch_path_existing_directory() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path().join("test_dir");
fs::create_dir(&dir_path).unwrap();
let normalized = normalize_watch_path(&dir_path);
assert!(normalized.is_absolute());
assert!(normalized.exists());
}
#[test]
fn test_normalize_watch_path_nonexistent_path() {
#[cfg(unix)]
let path = PathBuf::from("/nonexistent/path/to/dir");
#[cfg(windows)]
let path = PathBuf::from(r"C:\nonexistent\path\to\dir");
let normalized = normalize_watch_path(&path);
assert_eq!(normalized, path);
}
#[test]
fn test_normalize_watch_path_deduplication() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path().join("test_dir");
fs::create_dir(&dir_path).unwrap();
let subdir = dir_path.join("subdir");
fs::create_dir(&subdir).unwrap();
let path1 = subdir.clone();
let path2 = subdir.join("..").join("subdir");
let normalized1 = normalize_watch_path(&path1);
let normalized2 = normalize_watch_path(&path2);
assert_eq!(normalized1, normalized2);
}
fn canon(path: &Path) -> PathBuf {
normalize_watch_path(path)
}
fn expand(patterns: &[&str], base_dir: &Path) -> HashMap<PathBuf, RecursiveMode> {
let patterns = patterns.iter().map(|p| p.to_string()).collect_vec();
expand_watch_patterns(&patterns, base_dir)
}
#[test]
fn test_expand_watch_patterns_specific_file() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
fs::write(base_dir.join("package.json"), "{}").unwrap();
fs::create_dir_all(base_dir.join("node_modules/a/b")).unwrap();
let dirs = expand(&["package.json"], base_dir);
assert_eq!(
dirs,
HashMap::from([(canon(base_dir), RecursiveMode::NonRecursive)])
);
}
#[test]
fn test_expand_watch_patterns_recursive_glob() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let subdir = base_dir.join("src");
fs::create_dir_all(subdir.join("nested")).unwrap();
fs::write(subdir.join("file1.rs"), "").unwrap();
fs::write(subdir.join("nested/file2.rs"), "").unwrap();
let dirs = expand(&["src/**/*.rs"], base_dir);
assert_eq!(
dirs,
HashMap::from([(canon(&subdir), RecursiveMode::Recursive)])
);
}
#[test]
fn test_expand_watch_patterns_single_level_glob() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
fs::create_dir_all(base_dir.join("config/nested")).unwrap();
let dirs = expand(&["config/*.toml"], base_dir);
assert_eq!(
dirs,
HashMap::from([(canon(&base_dir.join("config")), RecursiveMode::NonRecursive)])
);
}
#[test]
fn test_expand_watch_patterns_glob_directory_component() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
fs::create_dir_all(base_dir.join("crates/a/deep")).unwrap();
fs::create_dir_all(base_dir.join("crates/b")).unwrap();
fs::write(base_dir.join("crates/file.toml"), "").unwrap();
let dirs = expand(&["crates/*/Cargo.toml"], base_dir);
assert_eq!(
dirs,
HashMap::from([
(canon(&base_dir.join("crates")), RecursiveMode::NonRecursive),
(
canon(&base_dir.join("crates/a")),
RecursiveMode::NonRecursive
),
(
canon(&base_dir.join("crates/b")),
RecursiveMode::NonRecursive
),
])
);
}
#[test]
fn test_expand_watch_patterns_embedded_double_star() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
fs::create_dir_all(base_dir.join("src/a")).unwrap();
fs::create_dir_all(base_dir.join("src/b")).unwrap();
let dirs = expand(&["src/foo**bar.rs", "sr**/x.rs"], base_dir);
assert_eq!(
dirs,
HashMap::from([
(canon(base_dir), RecursiveMode::NonRecursive),
(canon(&base_dir.join("src")), RecursiveMode::NonRecursive),
])
);
}
#[test]
fn test_expand_watch_patterns_trailing_double_star() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
fs::create_dir(base_dir.join("src")).unwrap();
let dirs = expand(&["src/**"], base_dir);
assert_eq!(
dirs,
HashMap::from([(canon(&base_dir.join("src")), RecursiveMode::Recursive)])
);
}
#[test]
fn test_expand_watch_patterns_alternatives_with_separator() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
fs::create_dir_all(base_dir.join("src/api")).unwrap();
fs::create_dir(base_dir.join("lib")).unwrap();
let dirs = expand(&["{src/api,lib}/*.rs"], base_dir);
assert_eq!(
dirs,
HashMap::from([
(
canon(&base_dir.join("src/api")),
RecursiveMode::NonRecursive
),
(canon(&base_dir.join("lib")), RecursiveMode::NonRecursive),
])
);
assert!(path_matches_patterns(
&base_dir.join("src/api/main.rs"),
&["{src/api,lib}/*.rs".to_string()],
base_dir
));
let dirs = expand(&["[a/b]/*.rs"], base_dir);
assert_eq!(
dirs,
HashMap::from([(canon(base_dir), RecursiveMode::Recursive)])
);
assert!(expand(&["[z-a]/*.rs"], base_dir).is_empty());
}
#[test]
fn test_expand_braces() {
let mut expanded = expand_braces("{src/{a,b},lib}/*.{rs,toml}");
expanded.sort();
assert_eq!(
expanded,
[
"lib/*.rs",
"lib/*.toml",
"src/a/*.rs",
"src/a/*.toml",
"src/b/*.rs",
"src/b/*.toml",
]
);
assert_eq!(expand_braces("[{]x}/*.rs"), ["[{]x}/*.rs"]);
#[cfg(unix)]
assert_eq!(expand_braces(r"\{a,b}.rs"), [r"\{a,b}.rs"]);
let many = "{a,b,c,d,e}/{a,b,c,d,e}/{a,b,c}/*.rs";
assert_eq!(expand_braces(many), [many]);
}
#[test]
fn test_expand_watch_patterns_alternatives_stay_relative() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
fs::create_dir_all(base_dir.join("src/x")).unwrap();
let dirs = expand(&["{src,}/**/*.rs", "{/src/x,lib}/*.rs"], base_dir);
assert_eq!(
dirs,
HashMap::from([
(canon(base_dir), RecursiveMode::Recursive),
(canon(&base_dir.join("src")), RecursiveMode::Recursive),
(canon(&base_dir.join("src/x")), RecursiveMode::NonRecursive),
])
);
}
#[test]
fn test_relative_alternatives() {
assert_eq!(relative_alternatives("{src,}/*.rs"), ["*.rs", "src/*.rs"]);
assert_eq!(relative_alternatives("/{a,b}.rs"), ["/b.rs", "/a.rs"]);
#[cfg(windows)]
assert_eq!(relative_alternatives(r"\src\main.rs"), [r"\src\main.rs"]);
}
#[test]
fn test_path_matches_patterns_alternatives_stay_relative() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let patterns = ["{/src/x,lib}/*.rs".to_string()];
assert!(path_matches_patterns(
&base_dir.join("src/x/main.rs"),
&patterns,
base_dir
));
assert!(path_matches_patterns(
&base_dir.join("lib/main.rs"),
&patterns,
base_dir
));
assert!(!path_matches_patterns(
&base_dir.join("src/main.rs"),
&patterns,
base_dir
));
}
#[test]
fn test_expand_watch_patterns_recursive_wins() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
fs::create_dir(base_dir.join("src")).unwrap();
let dirs = expand(&["src/main.rs", "src/**/*.rs"], base_dir);
assert_eq!(
dirs,
HashMap::from([(canon(&base_dir.join("src")), RecursiveMode::Recursive)])
);
}
#[test]
fn test_expand_watch_patterns_nonexistent_file() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let dirs = expand(&["config.toml"], base_dir);
assert_eq!(
dirs,
HashMap::from([(canon(base_dir), RecursiveMode::NonRecursive)])
);
}
#[test]
fn test_expand_watch_patterns_nonexistent_directory() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
fs::create_dir(base_dir.join("config")).unwrap();
let dirs = expand(&["config/app/*.toml", "lib/**/*.ts"], base_dir);
assert_eq!(
dirs,
HashMap::from([
(canon(base_dir), RecursiveMode::NonRecursive),
(canon(&base_dir.join("config")), RecursiveMode::NonRecursive),
])
);
}
#[test]
fn test_watched_entries() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path().join("new");
fs::create_dir_all(dir.join("nested")).unwrap();
fs::write(dir.join("a.toml"), "").unwrap();
fs::write(dir.join("nested/b.toml"), "").unwrap();
let mut paths = watched_entries(&dir, RecursiveMode::NonRecursive);
paths.sort();
assert_eq!(paths, vec![dir.join("a.toml"), dir.join("nested")]);
let mut paths = watched_entries(&dir, RecursiveMode::Recursive);
paths.sort();
assert_eq!(
paths,
vec![
dir.join("a.toml"),
dir.join("nested"),
dir.join("nested/b.toml"),
]
);
}
#[test]
fn test_path_matches_patterns_simple() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let test_txt = base_dir.join("test.txt");
let test_rs = base_dir.join("test.rs");
fs::write(&test_txt, "").unwrap();
fs::write(&test_rs, "").unwrap();
assert!(path_matches_patterns(
&test_txt,
&["*.txt".to_string()],
base_dir
));
assert!(!path_matches_patterns(
&test_rs,
&["*.txt".to_string()],
base_dir
));
}
#[test]
fn test_path_matches_patterns_recursive_glob() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let src_dir = base_dir.join("src");
let deep_dir = src_dir.join("deep");
fs::create_dir_all(&deep_dir).unwrap();
let deep_file = deep_dir.join("file.rs");
let src_file = src_dir.join("file.rs");
fs::write(&deep_file, "").unwrap();
fs::write(&src_file, "").unwrap();
assert!(path_matches_patterns(
&deep_file,
&["src/**/*.rs".to_string()],
base_dir
));
assert!(path_matches_patterns(
&src_file,
&["src/**/*.rs".to_string()],
base_dir
));
}
#[test]
fn test_path_matches_patterns_multiple_patterns() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let cargo_toml = base_dir.join("Cargo.toml");
let main_rs = base_dir.join("main.rs");
let readme_md = base_dir.join("README.md");
fs::write(&cargo_toml, "").unwrap();
fs::write(&main_rs, "").unwrap();
fs::write(&readme_md, "").unwrap();
let patterns = vec!["*.rs".to_string(), "*.toml".to_string()];
assert!(path_matches_patterns(&cargo_toml, &patterns, base_dir));
assert!(path_matches_patterns(&main_rs, &patterns, base_dir));
assert!(!path_matches_patterns(&readme_md, &patterns, base_dir));
}
#[test]
fn test_path_matches_patterns_relative_prefix() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let test_file = base_dir.join("config.json");
fs::write(&test_file, "{}").unwrap();
assert!(path_matches_patterns(
&test_file,
&["./config.json".to_string()],
base_dir
));
assert!(path_matches_patterns(
&test_file,
&["config.json".to_string()],
base_dir
));
}
#[test]
fn test_expand_watch_patterns_relative_prefix() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let test_file = base_dir.join("config.json");
fs::write(&test_file, "{}").unwrap();
let dirs = expand(&["./config.json"], base_dir);
assert_eq!(
dirs,
HashMap::from([(canon(base_dir), RecursiveMode::NonRecursive)])
);
}
}