use crate::check_runner::{CheckRunContext, perform_check_run};
use chrono::Local;
use colored::*;
use notify::{Config as NotifyConfig, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use rumdl_lib::config as rumdl_config;
use rumdl_lib::config::MARKDOWNLINT_CONFIG_FILES;
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc::channel;
use std::time::{Duration, Instant};
pub enum ChangeKind {
Configuration,
SourceFile,
}
#[derive(Default)]
pub struct WatchScope {
pub reads_editorconfig: bool,
config_dirs: BTreeSet<PathBuf>,
watched_files: BTreeSet<PathBuf>,
}
impl WatchScope {
fn config_only(&self, path: &Path) -> bool {
path.parent().is_some_and(|parent| self.config_dirs.contains(parent)) && !self.watched_files.contains(path)
}
}
pub fn change_detected(event: &Event, scope: &WatchScope) -> Option<ChangeKind> {
if !matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
) {
return None;
}
let mut source_file = false;
for path in &event.paths {
if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
&& (matches!(file_name, ".rumdl.toml" | "rumdl.toml" | "pyproject.toml")
|| MARKDOWNLINT_CONFIG_FILES.contains(&file_name)
|| (scope.reads_editorconfig && file_name == ".editorconfig"))
{
return Some(ChangeKind::Configuration);
}
if let Some(extension) = path.extension()
&& matches!(extension.to_str(), Some("md" | "markdown" | "mdown" | "mkd" | "mdx"))
&& !scope.config_only(path)
{
source_file = true;
}
}
if source_file {
Some(ChangeKind::SourceFile)
} else {
None
}
}
fn config_directories(watch_paths: &[String], project_root: Option<&Path>) -> (BTreeSet<PathBuf>, BTreeSet<PathBuf>) {
let root = project_root.and_then(|root| root.canonicalize().ok());
let mut watched_dirs: Vec<PathBuf> = Vec::new();
let mut watched_files: BTreeSet<PathBuf> = BTreeSet::new();
for path in watch_paths {
let Ok(canonical) = Path::new(path).canonicalize() else {
continue;
};
if canonical.is_dir() {
watched_dirs.push(canonical);
} else {
watched_files.insert(canonical);
}
}
let mut subscribe: BTreeSet<PathBuf> = BTreeSet::new();
let starts = watched_dirs
.iter()
.map(PathBuf::as_path)
.chain(watched_files.iter().filter_map(|file| file.parent()));
for start in starts {
subscribe.extend(ancestors_up_to(start, root.as_deref()));
}
let nested: Vec<PathBuf> = subscribe
.iter()
.map(|dir| dir.join(".config"))
.filter(|dir| dir.is_dir())
.collect();
subscribe.extend(nested);
subscribe.retain(|dir| !watched_dirs.iter().any(|watched| dir.starts_with(watched)));
(subscribe, watched_files)
}
fn appeared_config_dirs<'a>(event: &'a Event, watched: &BTreeSet<PathBuf>) -> Vec<&'a Path> {
event
.paths
.iter()
.filter(|path| {
path.file_name() == Some(OsStr::new(".config"))
&& !watched.contains(path.as_path())
&& path.parent().is_some_and(|parent| watched.contains(parent))
&& path.is_dir()
})
.map(PathBuf::as_path)
.collect()
}
fn subscribe_config_dirs(watcher: &mut RecommendedWatcher, scope: &mut WatchScope, event: &Event) -> bool {
let mut appeared = false;
for dir in appeared_config_dirs(event, &scope.config_dirs) {
match watcher.watch(dir, RecursiveMode::NonRecursive) {
Ok(()) => {
scope.config_dirs.insert(dir.to_path_buf());
appeared = true;
}
Err(e) => eprintln!(
"{}: Failed to watch {}: {}",
"Warning".yellow().bold(),
dir.display(),
e
),
}
}
appeared
}
fn ancestors_up_to(start: &Path, root: Option<&Path>) -> Vec<PathBuf> {
let mut dirs = vec![start.to_path_buf()];
let Some(root) = root.filter(|root| start.starts_with(root)) else {
return dirs;
};
let mut dir = start;
while dir != root
&& let Some(parent) = dir.parent()
{
dirs.push(parent.to_path_buf());
dir = parent;
}
dirs
}
pub fn clear_screen() {
print!("\x1B[2J\x1B[1;1H");
let _ = io::stdout().flush();
}
pub fn run_watch_mode(
args: &crate::CheckArgs,
global_config_path: Option<&str>,
isolated: bool,
quiet: bool,
inline_overrides: &[toml::Table],
) {
let discovery_dir = None;
let mut sourced = crate::load_config_with_cli_error_handling_with_dir(global_config_path, isolated, discovery_dir);
crate::cli_config_override::apply_inline_overrides(&mut sourced, inline_overrides);
crate::apply_cli_overrides(&mut sourced, args);
let registry = rumdl_config::default_registry();
let validation_warnings = rumdl_config::validate_config_sourced(&sourced, registry);
if !validation_warnings.is_empty() && !args.silent {
for warn in &validation_warnings {
eprintln!("\x1b[33m[config warning]\x1b[0m {}", warn.message);
}
}
let mut project_root = sourced.project_root.clone();
let mut validated = sourced.clone().into_validated_unchecked();
let mut config: rumdl_config::Config = validated.clone().into();
let (tx, rx) = channel();
let mut watcher = match RecommendedWatcher::new(
tx,
NotifyConfig::default().with_poll_interval(Duration::from_millis(500)),
) {
Ok(w) => w,
Err(e) => {
eprintln!("{}: Failed to create file watcher: {}", "Error".red().bold(), e);
crate::exit::tool_error();
}
};
let watch_paths = if args.paths.is_empty() {
vec![".".to_string()]
} else {
args.paths.clone()
};
for path_str in &watch_paths {
let path = Path::new(path_str);
if let Err(e) = watcher.watch(path, RecursiveMode::Recursive) {
eprintln!("{}: Failed to watch {}: {}", "Warning".yellow().bold(), path_str, e);
}
}
let (config_dirs, watched_files) = config_directories(&watch_paths, project_root.as_deref());
for dir in &config_dirs {
if let Err(e) = watcher.watch(dir, RecursiveMode::NonRecursive) {
eprintln!(
"{}: Failed to watch {}: {}",
"Warning".yellow().bold(),
dir.display(),
e
);
}
}
if let Some(config_path) = global_config_path
&& let Err(e) = watcher.watch(Path::new(config_path), RecursiveMode::NonRecursive)
{
eprintln!("{}: Failed to watch config file: {}", "Warning".yellow().bold(), e);
}
clear_screen();
let timestamp = Local::now().format("%H:%M:%S");
println!("[{}] {}...", timestamp, "Starting linter in watch mode".green().bold());
println!("{}", "Press Ctrl-C to exit".cyan());
println!();
let explicit_config = global_config_path.is_some();
let outcome = perform_check_run(&CheckRunContext {
args,
config: &config,
sourced: &validated,
quiet,
cache: None,
workspace_cache_dir: None,
project_root: project_root.as_deref(),
grouping_root: project_root.as_deref(),
inline_overrides,
explicit_config,
isolated,
external_config_warning: false,
});
if !quiet {
println!("\n{}", "Watching for file changes...".cyan());
}
let mut scope = WatchScope {
reads_editorconfig: config.global.editorconfig || outcome.reads_editorconfig,
config_dirs,
watched_files,
};
let debounce_duration = Duration::from_millis(100);
loop {
match rx.recv() {
Ok(event_result) => {
match event_result {
Ok(first_event) => {
let appeared = subscribe_config_dirs(&mut watcher, &mut scope, &first_event);
let detected = change_detected(&first_event, &scope);
let Some(mut change_kind) = (if appeared {
Some(ChangeKind::Configuration)
} else {
detected
}) else {
continue;
};
let start = Instant::now();
while start.elapsed() < debounce_duration {
if let Ok(Ok(event)) = rx.recv_timeout(Duration::from_millis(10)) {
if subscribe_config_dirs(&mut watcher, &mut scope, &event) {
change_kind = ChangeKind::Configuration;
}
if let Some(kind) = change_detected(&event, &scope)
&& matches!(kind, ChangeKind::Configuration)
{
change_kind = ChangeKind::Configuration;
}
}
}
if matches!(change_kind, ChangeKind::Configuration) {
sourced = crate::load_config_with_cli_error_handling_with_dir(
global_config_path,
isolated,
discovery_dir,
);
crate::cli_config_override::apply_inline_overrides(&mut sourced, inline_overrides);
crate::apply_cli_overrides(&mut sourced, args);
let validation_warnings = rumdl_config::validate_config_sourced(&sourced, registry);
if !validation_warnings.is_empty() && !args.silent {
for warn in &validation_warnings {
eprintln!("\x1b[33m[config warning]\x1b[0m {}", warn.message);
}
}
project_root = sourced.project_root.clone();
validated = sourced.clone().into_validated_unchecked();
config = validated.clone().into();
}
let timestamp = chrono::Local::now().format("%H:%M:%S");
let header = match change_kind {
ChangeKind::Configuration => {
format!(
"[{}] {}...\n\n",
timestamp,
"Configuration change detected".yellow().bold()
)
}
ChangeKind::SourceFile => {
format!("[{}] {}...\n\n", timestamp, "File change detected".cyan().bold())
}
};
clear_screen();
print!("{header}");
let _ = io::stdout().flush();
let outcome = perform_check_run(&CheckRunContext {
args,
config: &config,
sourced: &validated,
quiet,
cache: None,
workspace_cache_dir: None,
project_root: project_root.as_deref(),
grouping_root: project_root.as_deref(),
inline_overrides,
explicit_config,
isolated,
external_config_warning: false,
});
scope.reads_editorconfig = config.global.editorconfig || outcome.reads_editorconfig;
if !quiet {
println!("\n{}", "Watching for file changes...".cyan());
}
}
Err(e) => {
eprintln!("{}: Watch error: {}", "Error".red().bold(), e);
}
}
}
Err(e) => {
eprintln!("{}: Failed to receive watch event: {}", "Error".red().bold(), e);
crate::exit::tool_error();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{CreateKind, ModifyKind, RenameMode};
use std::path::PathBuf;
fn modified(path: &str) -> Event {
Event {
kind: EventKind::Modify(ModifyKind::Any),
paths: vec![PathBuf::from(path)],
attrs: Default::default(),
}
}
fn scope(reads_editorconfig: bool) -> WatchScope {
WatchScope {
reads_editorconfig,
..Default::default()
}
}
fn config_dir_scope(dir: &str, watched_files: &[&str]) -> WatchScope {
WatchScope {
reads_editorconfig: true,
config_dirs: BTreeSet::from([PathBuf::from(dir)]),
watched_files: watched_files.iter().map(PathBuf::from).collect(),
}
}
#[test]
fn a_markdown_edit_is_a_source_change() {
assert!(matches!(
change_detected(&modified("docs/guide.md"), &scope(false)),
Some(ChangeKind::SourceFile)
));
}
#[test]
fn a_rumdl_config_edit_is_a_configuration_change() {
assert!(matches!(
change_detected(&modified(".rumdl.toml"), &scope(false)),
Some(ChangeKind::Configuration)
));
}
#[test]
fn an_editorconfig_edit_is_a_configuration_change_when_rumdl_reads_it() {
assert!(
matches!(
change_detected(&modified("docs/.editorconfig"), &scope(true)),
Some(ChangeKind::Configuration)
),
"an opted-in project must re-lint when its .editorconfig changes"
);
}
#[test]
fn an_editorconfig_edit_is_ignored_when_rumdl_does_not_read_it() {
assert!(
change_detected(&modified(".editorconfig"), &scope(false)).is_none(),
"without the opt-in the file cannot change the result, so it must not trigger a run"
);
}
#[test]
fn a_file_rumdl_never_lints_is_ignored() {
assert!(change_detected(&modified("src/main.rs"), &scope(true)).is_none());
}
#[test]
fn a_new_editorconfig_counts_like_an_edited_one() {
let event = Event {
kind: EventKind::Create(CreateKind::File),
paths: vec![PathBuf::from(".editorconfig")],
attrs: Default::default(),
};
assert!(matches!(
change_detected(&event, &scope(true)),
Some(ChangeKind::Configuration)
));
}
#[test]
fn a_config_edit_above_the_watched_path_is_a_configuration_change() {
assert!(
matches!(
change_detected(&modified("/project/.rumdl.toml"), &config_dir_scope("/project", &[])),
Some(ChangeKind::Configuration)
),
"the run reads that config, so an edit to it has to re-run the check"
);
}
#[test]
fn a_markdown_edit_above_the_watched_path_is_not_a_source_change() {
assert!(
change_detected(&modified("/project/README.md"), &config_dir_scope("/project", &[])).is_none(),
"that directory is watched for its configs; the file itself is not being linted"
);
}
#[test]
fn a_watched_file_is_a_source_change_but_the_neighbours_it_shares_a_directory_with_are_not() {
let watch = config_dir_scope("/project", &["/project/doc.md"]);
assert!(
matches!(
change_detected(&modified("/project/doc.md"), &watch),
Some(ChangeKind::SourceFile)
),
"the run was pointed at this file, so its edits are the point of watching"
);
assert!(
change_detected(&modified("/project/other.md"), &watch).is_none(),
"its directory is subscribed for configs; the run never lints the file next to it"
);
}
#[test]
fn the_directories_watched_for_configs_reach_the_project_root() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
std::fs::create_dir_all(root.join("docs/guide")).unwrap();
let (subscribe, watched_files) =
config_directories(&[root.join("docs/guide").to_string_lossy().into_owned()], Some(&root));
assert_eq!(
subscribe,
BTreeSet::from([root.clone(), root.join("docs")]),
"the watched directory arrives recursively; everything above it up to the root does not"
);
assert!(
watched_files.is_empty(),
"and a directory target names no file that has to stay in scope"
);
}
#[test]
fn a_dot_config_directory_above_the_watched_path_is_watched_too() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
std::fs::create_dir_all(root.join("docs")).unwrap();
std::fs::create_dir_all(root.join(".config")).unwrap();
std::fs::write(root.join(".config/rumdl.toml"), "").unwrap();
let (subscribe, _) = config_directories(&[root.join("docs").to_string_lossy().into_owned()], Some(&root));
assert!(
subscribe.contains(&root.join(".config")),
"a config kept there is discovered, so it has to be watched, got {subscribe:?}"
);
}
#[test]
fn nothing_inside_a_watched_tree_is_subscribed_again() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
std::fs::create_dir_all(root.join("docs/guide")).unwrap();
std::fs::create_dir_all(root.join("docs/.config")).unwrap();
std::fs::write(root.join("docs/guide/doc.md"), "# Title\n").unwrap();
for below in ["docs/guide", "docs/guide/doc.md"] {
let (subscribe, _) = config_directories(
&[
root.join("docs").to_string_lossy().into_owned(),
root.join(below).to_string_lossy().into_owned(),
],
Some(&root),
);
assert_eq!(
subscribe,
BTreeSet::from([root.clone()]),
"the recursive watch on docs covers everything under it, including its .config, \
so watching {below} too may add nothing below docs; only the root above it stays"
);
}
}
#[test]
fn a_dot_config_directory_that_appears_while_watching_is_picked_up() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
std::fs::create_dir_all(root.join(".config")).unwrap();
let watched = BTreeSet::from([root.clone()]);
for kind in [
EventKind::Create(CreateKind::Folder),
EventKind::Modify(ModifyKind::Name(RenameMode::To)),
EventKind::Modify(ModifyKind::Any),
] {
let event = Event {
kind,
paths: vec![root.join(".config")],
attrs: Default::default(),
};
assert_eq!(
appeared_config_dirs(&event, &watched),
vec![root.join(".config")],
"a config written into it afterwards has to be noticed, whatever {kind:?} says"
);
}
let created = Event {
kind: EventKind::Create(CreateKind::Folder),
paths: vec![root.join(".config")],
attrs: Default::default(),
};
assert!(
appeared_config_dirs(&created, &BTreeSet::from([root.join("elsewhere")])).is_empty(),
"a directory this run does not read configuration from stays out of scope"
);
assert!(
appeared_config_dirs(&created, &BTreeSet::from([root.clone(), root.join(".config")])).is_empty(),
"and one the watcher already follows is not resubscribed on every event"
);
}
#[test]
fn the_directory_holding_a_watched_file_is_watched_and_the_file_stays_in_scope() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
std::fs::write(root.join("doc.md"), "# Title\n").unwrap();
let (subscribe, watched_files) =
config_directories(&[root.join("doc.md").to_string_lossy().into_owned()], None);
assert_eq!(
subscribe,
BTreeSet::from([root.clone()]),
"a config beside the watched file still has to be noticed"
);
assert_eq!(
watched_files,
BTreeSet::from([root.join("doc.md")]),
"and the watched file itself lives there, so its edits still count"
);
}
#[test]
fn a_nested_watched_directory_adds_no_subscription_of_its_own() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
std::fs::create_dir_all(root.join("docs")).unwrap();
let (subscribe, watched_files) = config_directories(
&[
root.to_string_lossy().into_owned(),
root.join("docs").to_string_lossy().into_owned(),
],
Some(&root),
);
assert!(
subscribe.is_empty(),
"both are watched recursively already, got {subscribe:?}"
);
assert!(watched_files.is_empty(), "and neither target is a single file");
}
}