Add a poll-based file watcher.
This commit is contained in:
parent
504900d7bd
commit
4345c2dd0b
|
@ -41,6 +41,7 @@ notify = { version = "6.1.1", optional = true }
|
||||||
notify-debouncer-mini = { version = "0.4.1", optional = true }
|
notify-debouncer-mini = { version = "0.4.1", optional = true }
|
||||||
ignore = { version = "0.4.20", optional = true }
|
ignore = { version = "0.4.20", optional = true }
|
||||||
pathdiff = { version = "0.2.1", optional = true }
|
pathdiff = { version = "0.2.1", optional = true }
|
||||||
|
walkdir = { version = "2.3.3", optional = true }
|
||||||
|
|
||||||
# Serve feature
|
# Serve feature
|
||||||
futures-util = { version = "0.3.28", optional = true }
|
futures-util = { version = "0.3.28", optional = true }
|
||||||
|
@ -61,7 +62,7 @@ walkdir = "2.3.3"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["watch", "serve", "search"]
|
default = ["watch", "serve", "search"]
|
||||||
watch = ["dep:notify", "dep:notify-debouncer-mini", "dep:ignore", "dep:pathdiff"]
|
watch = ["dep:notify", "dep:notify-debouncer-mini", "dep:ignore", "dep:pathdiff", "dep:walkdir"]
|
||||||
serve = ["dep:futures-util", "dep:tokio", "dep:warp"]
|
serve = ["dep:futures-util", "dep:tokio", "dep:warp"]
|
||||||
search = ["dep:elasticlunr-rs", "dep:ammonia"]
|
search = ["dep:elasticlunr-rs", "dep:ammonia"]
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
#### --watcher
|
||||||
|
|
||||||
|
There are different backends used to determine when a file has changed.
|
||||||
|
|
||||||
|
* `poll` (default) --- Checks for file modifications by scanning the filesystem every second.
|
||||||
|
* `native` --- Uses the native operating system facilities to receive notifications when files change.
|
||||||
|
This can have less constant overhead, but may not be as reliable as the `poll` based watcher.
|
|
@ -44,6 +44,8 @@ book. Relative paths are interpreted relative to the book's root directory. If
|
||||||
not specified it will default to the value of the `build.build-dir` key in
|
not specified it will default to the value of the `build.build-dir` key in
|
||||||
`book.toml`, or to `./book`.
|
`book.toml`, or to `./book`.
|
||||||
|
|
||||||
|
{{#include arg-watcher.md}}
|
||||||
|
|
||||||
#### Specify exclude patterns
|
#### Specify exclude patterns
|
||||||
|
|
||||||
The `serve` command will not automatically trigger a build for files listed in
|
The `serve` command will not automatically trigger a build for files listed in
|
||||||
|
|
|
@ -27,6 +27,7 @@ book. Relative paths are interpreted relative to the book's root directory. If
|
||||||
not specified it will default to the value of the `build.build-dir` key in
|
not specified it will default to the value of the `build.build-dir` key in
|
||||||
`book.toml`, or to `./book`.
|
`book.toml`, or to `./book`.
|
||||||
|
|
||||||
|
{{#include arg-watcher.md}}
|
||||||
|
|
||||||
#### Specify exclude patterns
|
#### Specify exclude patterns
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,19 @@ pub trait CommandExt: Sized {
|
||||||
fn arg_open(self) -> Self {
|
fn arg_open(self) -> Self {
|
||||||
self._arg(arg!(-o --open "Opens the compiled book in a web browser"))
|
self._arg(arg!(-o --open "Opens the compiled book in a web browser"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn arg_watcher(self) -> Self {
|
||||||
|
#[cfg(feature = "watch")]
|
||||||
|
return self._arg(
|
||||||
|
Arg::new("watcher")
|
||||||
|
.long("watcher")
|
||||||
|
.value_parser(["poll", "native"])
|
||||||
|
.default_value("poll")
|
||||||
|
.help("The filesystem watching technique"),
|
||||||
|
);
|
||||||
|
#[cfg(not(feature = "watch"))]
|
||||||
|
return self;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommandExt for Command {
|
impl CommandExt for Command {
|
||||||
|
|
|
@ -6,7 +6,6 @@ use clap::builder::NonEmptyStringValueParser;
|
||||||
use futures_util::sink::SinkExt;
|
use futures_util::sink::SinkExt;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use mdbook::errors::*;
|
use mdbook::errors::*;
|
||||||
use mdbook::utils;
|
|
||||||
use mdbook::utils::fs::get_404_output_file;
|
use mdbook::utils::fs::get_404_output_file;
|
||||||
use mdbook::MDBook;
|
use mdbook::MDBook;
|
||||||
use std::net::{SocketAddr, ToSocketAddrs};
|
use std::net::{SocketAddr, ToSocketAddrs};
|
||||||
|
@ -43,12 +42,13 @@ pub fn make_subcommand() -> Command {
|
||||||
.help("Port to use for HTTP connections"),
|
.help("Port to use for HTTP connections"),
|
||||||
)
|
)
|
||||||
.arg_open()
|
.arg_open()
|
||||||
|
.arg_watcher()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve command implementation
|
// Serve command implementation
|
||||||
pub fn execute(args: &ArgMatches) -> Result<()> {
|
pub fn execute(args: &ArgMatches) -> Result<()> {
|
||||||
let book_dir = get_book_dir(args);
|
let book_dir = get_book_dir(args);
|
||||||
let mut book = MDBook::load(book_dir)?;
|
let mut book = MDBook::load(&book_dir)?;
|
||||||
|
|
||||||
let port = args.get_one::<String>("port").unwrap();
|
let port = args.get_one::<String>("port").unwrap();
|
||||||
let hostname = args.get_one::<String>("hostname").unwrap();
|
let hostname = args.get_one::<String>("hostname").unwrap();
|
||||||
|
@ -97,23 +97,12 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "watch")]
|
#[cfg(feature = "watch")]
|
||||||
watch::trigger_on_change(&book, move |paths, book_dir| {
|
{
|
||||||
info!("Files changed: {:?}", paths);
|
let watcher = watch::WatcherKind::from_str(args.get_one::<String>("watcher").unwrap());
|
||||||
info!("Building book...");
|
watch::rebuild_on_change(watcher, &book_dir, &update_config, &move || {
|
||||||
|
|
||||||
// FIXME: This area is really ugly because we need to re-set livereload :(
|
|
||||||
let result = MDBook::load(book_dir).and_then(|mut b| {
|
|
||||||
update_config(&mut b);
|
|
||||||
b.build()
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
error!("Unable to load the book");
|
|
||||||
utils::log_backtrace(&e);
|
|
||||||
} else {
|
|
||||||
let _ = tx.send(Message::text("reload"));
|
let _ = tx.send(Message::text("reload"));
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
let _ = thread_handle.join();
|
let _ = thread_handle.join();
|
||||||
|
|
||||||
|
|
211
src/cmd/watch.rs
211
src/cmd/watch.rs
|
@ -1,14 +1,11 @@
|
||||||
use super::command_prelude::*;
|
use super::command_prelude::*;
|
||||||
use crate::{get_book_dir, open};
|
use crate::{get_book_dir, open};
|
||||||
use ignore::gitignore::Gitignore;
|
|
||||||
use mdbook::errors::Result;
|
use mdbook::errors::Result;
|
||||||
use mdbook::utils;
|
|
||||||
use mdbook::MDBook;
|
use mdbook::MDBook;
|
||||||
use pathdiff::diff_paths;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::mpsc::channel;
|
|
||||||
use std::thread::sleep;
|
mod native;
|
||||||
use std::time::Duration;
|
mod poller;
|
||||||
|
|
||||||
// Create clap subcommand arguments
|
// Create clap subcommand arguments
|
||||||
pub fn make_subcommand() -> Command {
|
pub fn make_subcommand() -> Command {
|
||||||
|
@ -17,12 +14,28 @@ pub fn make_subcommand() -> Command {
|
||||||
.arg_dest_dir()
|
.arg_dest_dir()
|
||||||
.arg_root_dir()
|
.arg_root_dir()
|
||||||
.arg_open()
|
.arg_open()
|
||||||
|
.arg_watcher()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum WatcherKind {
|
||||||
|
Poll,
|
||||||
|
Native,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WatcherKind {
|
||||||
|
pub fn from_str(s: &str) -> WatcherKind {
|
||||||
|
match s {
|
||||||
|
"poll" => WatcherKind::Poll,
|
||||||
|
"native" => WatcherKind::Native,
|
||||||
|
_ => panic!("unsupported watcher {s}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch command implementation
|
// Watch command implementation
|
||||||
pub fn execute(args: &ArgMatches) -> Result<()> {
|
pub fn execute(args: &ArgMatches) -> Result<()> {
|
||||||
let book_dir = get_book_dir(args);
|
let book_dir = get_book_dir(args);
|
||||||
let mut book = MDBook::load(book_dir)?;
|
let mut book = MDBook::load(&book_dir)?;
|
||||||
|
|
||||||
let update_config = |book: &mut MDBook| {
|
let update_config = |book: &mut MDBook| {
|
||||||
if let Some(dest_dir) = args.get_one::<PathBuf>("dest-dir") {
|
if let Some(dest_dir) = args.get_one::<PathBuf>("dest-dir") {
|
||||||
|
@ -41,42 +54,21 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
|
||||||
open(path);
|
open(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
trigger_on_change(&book, |paths, book_dir| {
|
let watcher = WatcherKind::from_str(args.get_one::<String>("watcher").unwrap());
|
||||||
info!("Files changed: {:?}\nBuilding book...\n", paths);
|
rebuild_on_change(watcher, &book_dir, &update_config, &|| {});
|
||||||
let result = MDBook::load(book_dir).and_then(|mut b| {
|
|
||||||
update_config(&mut b);
|
|
||||||
b.build()
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
error!("Unable to build the book");
|
|
||||||
utils::log_backtrace(&e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_ignored_files(book_root: &Path, paths: &[PathBuf]) -> Vec<PathBuf> {
|
pub fn rebuild_on_change(
|
||||||
if paths.is_empty() {
|
kind: WatcherKind,
|
||||||
return vec![];
|
book_dir: &Path,
|
||||||
}
|
update_config: &dyn Fn(&mut MDBook),
|
||||||
|
post_build: &dyn Fn(),
|
||||||
match find_gitignore(book_root) {
|
) {
|
||||||
Some(gitignore_path) => {
|
match kind {
|
||||||
let (ignore, err) = Gitignore::new(&gitignore_path);
|
WatcherKind::Poll => self::poller::rebuild_on_change(book_dir, update_config, post_build),
|
||||||
if let Some(err) = err {
|
WatcherKind::Native => self::native::rebuild_on_change(book_dir, update_config, post_build),
|
||||||
warn!(
|
|
||||||
"error reading gitignore `{}`: {err}",
|
|
||||||
gitignore_path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
filter_ignored_files(ignore, paths)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// There is no .gitignore file.
|
|
||||||
paths.iter().map(|path| path.to_path_buf()).collect()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,144 +78,3 @@ fn find_gitignore(book_root: &Path) -> Option<PathBuf> {
|
||||||
.map(|p| p.join(".gitignore"))
|
.map(|p| p.join(".gitignore"))
|
||||||
.find(|p| p.exists())
|
.find(|p| p.exists())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: The usage of `canonicalize` may encounter occasional failures on the Windows platform, presenting a potential risk.
|
|
||||||
// For more details, refer to [Pull Request #2229](https://github.com/rust-lang/mdBook/pull/2229#discussion_r1408665981).
|
|
||||||
fn filter_ignored_files(ignore: Gitignore, paths: &[PathBuf]) -> Vec<PathBuf> {
|
|
||||||
let ignore_root = ignore
|
|
||||||
.path()
|
|
||||||
.canonicalize()
|
|
||||||
.expect("ignore root canonicalize error");
|
|
||||||
|
|
||||||
paths
|
|
||||||
.iter()
|
|
||||||
.filter(|path| {
|
|
||||||
let relative_path =
|
|
||||||
diff_paths(&path, &ignore_root).expect("One of the paths should be an absolute");
|
|
||||||
!ignore
|
|
||||||
.matched_path_or_any_parents(&relative_path, relative_path.is_dir())
|
|
||||||
.is_ignore()
|
|
||||||
})
|
|
||||||
.map(|path| path.to_path_buf())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calls the closure when a book source file is changed, blocking indefinitely.
|
|
||||||
pub fn trigger_on_change<F>(book: &MDBook, closure: F)
|
|
||||||
where
|
|
||||||
F: Fn(Vec<PathBuf>, &Path),
|
|
||||||
{
|
|
||||||
use notify::RecursiveMode::*;
|
|
||||||
|
|
||||||
// Create a channel to receive the events.
|
|
||||||
let (tx, rx) = channel();
|
|
||||||
|
|
||||||
let mut debouncer = match notify_debouncer_mini::new_debouncer(Duration::from_secs(1), tx) {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error while trying to watch the files:\n\n\t{:?}", e);
|
|
||||||
std::process::exit(1)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let watcher = debouncer.watcher();
|
|
||||||
|
|
||||||
// Add the source directory to the watcher
|
|
||||||
if let Err(e) = watcher.watch(&book.source_dir(), Recursive) {
|
|
||||||
error!("Error while watching {:?}:\n {:?}", book.source_dir(), e);
|
|
||||||
std::process::exit(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = watcher.watch(&book.theme_dir(), Recursive);
|
|
||||||
|
|
||||||
// Add the book.toml file to the watcher if it exists
|
|
||||||
let _ = watcher.watch(&book.root.join("book.toml"), NonRecursive);
|
|
||||||
|
|
||||||
for dir in &book.config.build.extra_watch_dirs {
|
|
||||||
let path = book.root.join(dir);
|
|
||||||
let canonical_path = path.canonicalize().unwrap_or_else(|e| {
|
|
||||||
error!("Error while watching extra directory {path:?}:\n {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Err(e) = watcher.watch(&canonical_path, Recursive) {
|
|
||||||
error!(
|
|
||||||
"Error while watching extra directory {:?}:\n {:?}",
|
|
||||||
canonical_path, e
|
|
||||||
);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Listening for changes...");
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let first_event = rx.recv().unwrap();
|
|
||||||
sleep(Duration::from_millis(50));
|
|
||||||
let other_events = rx.try_iter();
|
|
||||||
|
|
||||||
let all_events = std::iter::once(first_event).chain(other_events);
|
|
||||||
|
|
||||||
let paths: Vec<_> = all_events
|
|
||||||
.filter_map(|event| match event {
|
|
||||||
Ok(events) => Some(events),
|
|
||||||
Err(error) => {
|
|
||||||
log::warn!("error while watching for changes: {error}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.flatten()
|
|
||||||
.map(|event| event.path)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// If we are watching files outside the current repository (via extra-watch-dirs), then they are definitionally
|
|
||||||
// ignored by gitignore. So we handle this case by including such files into the watched paths list.
|
|
||||||
let any_external_paths = paths.iter().filter(|p| !p.starts_with(&book.root)).cloned();
|
|
||||||
let mut paths = remove_ignored_files(&book.root, &paths[..]);
|
|
||||||
paths.extend(any_external_paths);
|
|
||||||
|
|
||||||
if !paths.is_empty() {
|
|
||||||
closure(paths, &book.root);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use ignore::gitignore::GitignoreBuilder;
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_filter_ignored_files() {
|
|
||||||
let current_dir = env::current_dir().unwrap();
|
|
||||||
|
|
||||||
let ignore = GitignoreBuilder::new(¤t_dir)
|
|
||||||
.add_line(None, "*.html")
|
|
||||||
.unwrap()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
let should_remain = current_dir.join("record.text");
|
|
||||||
let should_filter = current_dir.join("index.html");
|
|
||||||
|
|
||||||
let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
|
|
||||||
assert_eq!(remain, vec![should_remain])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn filter_ignored_files_should_handle_parent_dir() {
|
|
||||||
let current_dir = env::current_dir().unwrap();
|
|
||||||
|
|
||||||
let ignore = GitignoreBuilder::new(¤t_dir)
|
|
||||||
.add_line(None, "*.html")
|
|
||||||
.unwrap()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let parent_dir = current_dir.join("..");
|
|
||||||
let should_remain = parent_dir.join("record.text");
|
|
||||||
let should_filter = parent_dir.join("index.html");
|
|
||||||
|
|
||||||
let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
|
|
||||||
assert_eq!(remain, vec![should_remain])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,189 @@
|
||||||
|
//! A filesystem watcher using native operating system facilities.
|
||||||
|
|
||||||
|
use ignore::gitignore::Gitignore;
|
||||||
|
use mdbook::MDBook;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc::channel;
|
||||||
|
use std::thread::sleep;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub fn rebuild_on_change(
|
||||||
|
book_dir: &Path,
|
||||||
|
update_config: &dyn Fn(&mut MDBook),
|
||||||
|
post_build: &dyn Fn(),
|
||||||
|
) {
|
||||||
|
use notify::RecursiveMode::*;
|
||||||
|
|
||||||
|
let mut book = MDBook::load(book_dir).unwrap_or_else(|e| {
|
||||||
|
error!("failed to load book: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a channel to receive the events.
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
|
||||||
|
let mut debouncer = match notify_debouncer_mini::new_debouncer(Duration::from_secs(1), tx) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error while trying to watch the files:\n\n\t{:?}", e);
|
||||||
|
std::process::exit(1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let watcher = debouncer.watcher();
|
||||||
|
|
||||||
|
// Add the source directory to the watcher
|
||||||
|
if let Err(e) = watcher.watch(&book.source_dir(), Recursive) {
|
||||||
|
error!("Error while watching {:?}:\n {:?}", book.source_dir(), e);
|
||||||
|
std::process::exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = watcher.watch(&book.theme_dir(), Recursive);
|
||||||
|
|
||||||
|
// Add the book.toml file to the watcher if it exists
|
||||||
|
let _ = watcher.watch(&book.root.join("book.toml"), NonRecursive);
|
||||||
|
|
||||||
|
for dir in &book.config.build.extra_watch_dirs {
|
||||||
|
let path = book.root.join(dir);
|
||||||
|
let canonical_path = path.canonicalize().unwrap_or_else(|e| {
|
||||||
|
error!("Error while watching extra directory {path:?}:\n {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(e) = watcher.watch(&canonical_path, Recursive) {
|
||||||
|
error!(
|
||||||
|
"Error while watching extra directory {:?}:\n {:?}",
|
||||||
|
canonical_path, e
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Listening for changes...");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let first_event = rx.recv().unwrap();
|
||||||
|
sleep(Duration::from_millis(50));
|
||||||
|
let other_events = rx.try_iter();
|
||||||
|
|
||||||
|
let all_events = std::iter::once(first_event).chain(other_events);
|
||||||
|
|
||||||
|
let paths: Vec<_> = all_events
|
||||||
|
.filter_map(|event| match event {
|
||||||
|
Ok(events) => Some(events),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("error while watching for changes: {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
.map(|event| event.path)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// If we are watching files outside the current repository (via extra-watch-dirs), then they are definitionally
|
||||||
|
// ignored by gitignore. So we handle this case by including such files into the watched paths list.
|
||||||
|
let any_external_paths = paths.iter().filter(|p| !p.starts_with(&book.root)).cloned();
|
||||||
|
let mut paths = remove_ignored_files(&book.root, &paths[..]);
|
||||||
|
paths.extend(any_external_paths);
|
||||||
|
|
||||||
|
if !paths.is_empty() {
|
||||||
|
info!("Files changed: {paths:?}");
|
||||||
|
match MDBook::load(book_dir) {
|
||||||
|
Ok(mut b) => {
|
||||||
|
update_config(&mut b);
|
||||||
|
if let Err(e) = b.build() {
|
||||||
|
error!("failed to build the book: {e:?}");
|
||||||
|
} else {
|
||||||
|
post_build();
|
||||||
|
}
|
||||||
|
book = b;
|
||||||
|
}
|
||||||
|
Err(e) => error!("failed to load book config: {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_ignored_files(book_root: &Path, paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||||
|
if paths.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
match super::find_gitignore(book_root) {
|
||||||
|
Some(gitignore_path) => {
|
||||||
|
let (ignore, err) = Gitignore::new(&gitignore_path);
|
||||||
|
if let Some(err) = err {
|
||||||
|
warn!(
|
||||||
|
"error reading gitignore `{}`: {err}",
|
||||||
|
gitignore_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
filter_ignored_files(ignore, paths)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// There is no .gitignore file.
|
||||||
|
paths.iter().map(|path| path.to_path_buf()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: The usage of `canonicalize` may encounter occasional failures on the Windows platform, presenting a potential risk.
|
||||||
|
// For more details, refer to [Pull Request #2229](https://github.com/rust-lang/mdBook/pull/2229#discussion_r1408665981).
|
||||||
|
fn filter_ignored_files(ignore: Gitignore, paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||||
|
let ignore_root = ignore
|
||||||
|
.path()
|
||||||
|
.canonicalize()
|
||||||
|
.expect("ignore root canonicalize error");
|
||||||
|
|
||||||
|
paths
|
||||||
|
.iter()
|
||||||
|
.filter(|path| {
|
||||||
|
let relative_path = pathdiff::diff_paths(&path, &ignore_root)
|
||||||
|
.expect("One of the paths should be an absolute");
|
||||||
|
!ignore
|
||||||
|
.matched_path_or_any_parents(&relative_path, relative_path.is_dir())
|
||||||
|
.is_ignore()
|
||||||
|
})
|
||||||
|
.map(|path| path.to_path_buf())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ignore::gitignore::GitignoreBuilder;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_ignored_files() {
|
||||||
|
let current_dir = env::current_dir().unwrap();
|
||||||
|
|
||||||
|
let ignore = GitignoreBuilder::new(¤t_dir)
|
||||||
|
.add_line(None, "*.html")
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let should_remain = current_dir.join("record.text");
|
||||||
|
let should_filter = current_dir.join("index.html");
|
||||||
|
|
||||||
|
let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
|
||||||
|
assert_eq!(remain, vec![should_remain])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_ignored_files_should_handle_parent_dir() {
|
||||||
|
let current_dir = env::current_dir().unwrap();
|
||||||
|
|
||||||
|
let ignore = GitignoreBuilder::new(¤t_dir)
|
||||||
|
.add_line(None, "*.html")
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let parent_dir = current_dir.join("..");
|
||||||
|
let should_remain = parent_dir.join("record.text");
|
||||||
|
let should_filter = parent_dir.join("index.html");
|
||||||
|
|
||||||
|
let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
|
||||||
|
assert_eq!(remain, vec![should_remain])
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,386 @@
|
||||||
|
//! A simple poll-based filesystem watcher.
|
||||||
|
//!
|
||||||
|
//! This exists because the native change notifications have historically had
|
||||||
|
//! lots of problems. Various operating systems and different filesystems have
|
||||||
|
//! had problems correctly reporting changes.
|
||||||
|
|
||||||
|
use ignore::gitignore::Gitignore;
|
||||||
|
use mdbook::MDBook;
|
||||||
|
use pathdiff::diff_paths;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::FileType;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant, SystemTime};
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
/// Calls the closure when a book source file is changed, blocking indefinitely.
|
||||||
|
pub fn rebuild_on_change(
|
||||||
|
book_dir: &Path,
|
||||||
|
update_config: &dyn Fn(&mut MDBook),
|
||||||
|
post_build: &dyn Fn(),
|
||||||
|
) {
|
||||||
|
let mut book = MDBook::load(book_dir).unwrap_or_else(|e| {
|
||||||
|
error!("failed to load book: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut watcher = Watcher::new(book_dir);
|
||||||
|
|
||||||
|
info!("Watching for changes...");
|
||||||
|
// Scan once to initialize the starting point.
|
||||||
|
watcher.set_roots(&book);
|
||||||
|
watcher.scan();
|
||||||
|
|
||||||
|
// Track average scan time, to help investigate if the poller is taking
|
||||||
|
// undesirably long. This is not a rigorous benchmark, just a rough
|
||||||
|
// estimate.
|
||||||
|
const AVG_SIZE: usize = 60;
|
||||||
|
let mut avgs = vec![0.0; AVG_SIZE];
|
||||||
|
let mut avg_i = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(Duration::new(1, 0));
|
||||||
|
watcher.set_roots(&book);
|
||||||
|
let start = Instant::now();
|
||||||
|
let paths = watcher.scan();
|
||||||
|
let elapsed = start.elapsed().as_secs_f64();
|
||||||
|
avgs[avg_i] = elapsed;
|
||||||
|
avg_i += 1;
|
||||||
|
if avg_i >= AVG_SIZE {
|
||||||
|
avg_i = 0;
|
||||||
|
let avg = avgs.iter().sum::<f64>() / (avgs.len() as f64);
|
||||||
|
trace!(
|
||||||
|
"scan average time: {avg:.2}s, scan size is {}",
|
||||||
|
watcher.path_data.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !paths.is_empty() {
|
||||||
|
info!("Files changed: {paths:?}");
|
||||||
|
match MDBook::load(book_dir) {
|
||||||
|
Ok(mut b) => {
|
||||||
|
update_config(&mut b);
|
||||||
|
if let Err(e) = b.build() {
|
||||||
|
error!("failed to build the book: {e:?}");
|
||||||
|
} else {
|
||||||
|
post_build();
|
||||||
|
}
|
||||||
|
book = b;
|
||||||
|
}
|
||||||
|
Err(e) => error!("failed to load book config: {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
struct PathData {
|
||||||
|
file_type: FileType,
|
||||||
|
mtime: SystemTime,
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A very simple poll-watcher that scans for modified files.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Watcher {
|
||||||
|
/// The root paths where it will recursively scan for changes.
|
||||||
|
root_paths: Vec<PathBuf>,
|
||||||
|
/// Data about files on disk.
|
||||||
|
path_data: HashMap<PathBuf, PathData>,
|
||||||
|
/// Filters paths that will be watched.
|
||||||
|
ignore: Option<(PathBuf, Gitignore)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Watcher {
|
||||||
|
fn new(book_root: &Path) -> Watcher {
|
||||||
|
// FIXME: ignore should be reloaded when it changes.
|
||||||
|
let ignore = super::find_gitignore(book_root).map(|gitignore_path| {
|
||||||
|
let (ignore, err) = Gitignore::new(&gitignore_path);
|
||||||
|
if let Some(err) = err {
|
||||||
|
warn!(
|
||||||
|
"error reading gitignore `{}`: {err}",
|
||||||
|
gitignore_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Note: The usage of `canonicalize` may encounter occasional
|
||||||
|
// failures on the Windows platform, presenting a potential risk.
|
||||||
|
// For more details, refer to [Pull Request
|
||||||
|
// #2229](https://github.com/rust-lang/mdBook/pull/2229#discussion_r1408665981).
|
||||||
|
let ignore_path = ignore
|
||||||
|
.path()
|
||||||
|
.canonicalize()
|
||||||
|
.expect("ignore root canonicalize error");
|
||||||
|
(ignore_path, ignore)
|
||||||
|
});
|
||||||
|
|
||||||
|
Watcher {
|
||||||
|
ignore,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the root directories where scanning will start.
|
||||||
|
fn set_roots(&mut self, book: &MDBook) {
|
||||||
|
let mut root_paths = vec![
|
||||||
|
book.source_dir(),
|
||||||
|
book.theme_dir(),
|
||||||
|
book.root.join("book.toml"),
|
||||||
|
];
|
||||||
|
root_paths.extend(
|
||||||
|
book.config
|
||||||
|
.build
|
||||||
|
.extra_watch_dirs
|
||||||
|
.iter()
|
||||||
|
.map(|path| book.root.join(path)),
|
||||||
|
);
|
||||||
|
if let Some(html_config) = book.config.html_config() {
|
||||||
|
root_paths.extend(
|
||||||
|
html_config
|
||||||
|
.additional_css
|
||||||
|
.iter()
|
||||||
|
.chain(html_config.additional_js.iter())
|
||||||
|
.map(|path| book.root.join(path)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.root_paths = root_paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scans for changes.
|
||||||
|
///
|
||||||
|
/// Returns the paths that have changed.
|
||||||
|
fn scan(&mut self) -> Vec<PathBuf> {
|
||||||
|
let ignore = &self.ignore;
|
||||||
|
let new_path_data: HashMap<_, _> = self
|
||||||
|
.root_paths
|
||||||
|
.iter()
|
||||||
|
.filter(|root| root.exists())
|
||||||
|
.flat_map(|root| {
|
||||||
|
WalkDir::new(root)
|
||||||
|
.follow_links(true)
|
||||||
|
.into_iter()
|
||||||
|
.filter_entry(|entry| {
|
||||||
|
if let Some((ignore_path, ignore)) = ignore {
|
||||||
|
let path = entry.path();
|
||||||
|
// Canonicalization helps with removing `..` and
|
||||||
|
// `.` entries, which can cause issues with
|
||||||
|
// diff_paths.
|
||||||
|
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||||
|
let relative_path = diff_paths(&path, &ignore_path)
|
||||||
|
.expect("One of the paths should be an absolute");
|
||||||
|
if ignore
|
||||||
|
.matched_path_or_any_parents(&relative_path, relative_path.is_dir())
|
||||||
|
.is_ignore()
|
||||||
|
{
|
||||||
|
trace!("ignoring {path:?}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.filter_map(move |entry| {
|
||||||
|
let entry = match entry {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
debug!("failed to scan {root:?}: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if entry.file_type().is_dir() {
|
||||||
|
// Changes to directories themselves aren't
|
||||||
|
// particularly interesting.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = entry.path().to_path_buf();
|
||||||
|
|
||||||
|
let meta = match entry.metadata() {
|
||||||
|
Ok(meta) => meta,
|
||||||
|
Err(e) => {
|
||||||
|
debug!("failed to scan {path:?}: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
|
||||||
|
let pd = PathData {
|
||||||
|
file_type: meta.file_type(),
|
||||||
|
mtime,
|
||||||
|
size: meta.len(),
|
||||||
|
};
|
||||||
|
Some((path, pd))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
for (new_path, new_data) in &new_path_data {
|
||||||
|
match self.path_data.get(new_path) {
|
||||||
|
Some(old_data) => {
|
||||||
|
if new_data != old_data {
|
||||||
|
paths.push(new_path.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
paths.push(new_path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for old_path in self.path_data.keys() {
|
||||||
|
if !new_path_data.contains_key(old_path) {
|
||||||
|
paths.push(old_path.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.path_data = new_path_data;
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Helper for testing the watcher.
|
||||||
|
fn check_watch_behavior(
|
||||||
|
gitignore_path: &str,
|
||||||
|
gitignore: &str,
|
||||||
|
book_root_path: &str,
|
||||||
|
ignored: &[&str],
|
||||||
|
not_ignored: &[&str],
|
||||||
|
extra_setup: &dyn Fn(&Path),
|
||||||
|
) {
|
||||||
|
// Create the book and initialize things.
|
||||||
|
let temp = tempfile::Builder::new()
|
||||||
|
.prefix("mdbook-")
|
||||||
|
.tempdir()
|
||||||
|
.unwrap();
|
||||||
|
let root = temp.path();
|
||||||
|
let book_root = root.join(book_root_path);
|
||||||
|
// eprintln!("book_root={book_root:?}",);
|
||||||
|
MDBook::init(&book_root).build().unwrap();
|
||||||
|
std::fs::write(root.join(gitignore_path), gitignore).unwrap();
|
||||||
|
let create = |paths: &[&str]| {
|
||||||
|
let mut paths = paths
|
||||||
|
.iter()
|
||||||
|
.map(|path| root.join(path))
|
||||||
|
.inspect(|path| {
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(path, "initial content").unwrap();
|
||||||
|
})
|
||||||
|
.map(|path| path.canonicalize().unwrap())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
paths.sort();
|
||||||
|
paths
|
||||||
|
};
|
||||||
|
let ignored = create(ignored);
|
||||||
|
let not_ignored = create(not_ignored);
|
||||||
|
extra_setup(&book_root);
|
||||||
|
// Create a watcher and check its behavior.
|
||||||
|
let book = MDBook::load(&book_root).unwrap();
|
||||||
|
let mut watcher = Watcher::new(&book_root);
|
||||||
|
watcher.set_roots(&book);
|
||||||
|
// Do an initial scan to initialize its state.
|
||||||
|
watcher.scan();
|
||||||
|
// Verify the steady state is empty.
|
||||||
|
let changed = watcher.scan();
|
||||||
|
assert_eq!(changed, Vec::<PathBuf>::new());
|
||||||
|
// Modify all files, and verify that only not_ignored are detected.
|
||||||
|
for path in ignored.iter().chain(not_ignored.iter()) {
|
||||||
|
std::fs::write(path, "modified").unwrap();
|
||||||
|
}
|
||||||
|
let changed = watcher.scan();
|
||||||
|
let mut changed = changed
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| p.canonicalize().unwrap())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
changed.sort();
|
||||||
|
assert_eq!(changed, not_ignored);
|
||||||
|
// Verify again that steady state is empty.
|
||||||
|
let changed = watcher.scan();
|
||||||
|
assert_eq!(changed, Vec::<PathBuf>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ignore() {
|
||||||
|
// Basic gitignore test.
|
||||||
|
check_watch_behavior(
|
||||||
|
"foo/.gitignore",
|
||||||
|
"*.tmp",
|
||||||
|
"foo",
|
||||||
|
&["foo/src/somefile.tmp"],
|
||||||
|
&["foo/src/chapter.md"],
|
||||||
|
&|_book_root| {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ignore_in_parent() {
|
||||||
|
// gitignore is in the parent of the book
|
||||||
|
check_watch_behavior(
|
||||||
|
".gitignore",
|
||||||
|
"*.tmp\nsomedir/\n/inroot\n/foo/src/inbook\n",
|
||||||
|
"foo",
|
||||||
|
&[
|
||||||
|
"foo/src/somefile.tmp",
|
||||||
|
"foo/src/somedir/somefile",
|
||||||
|
"inroot/somefile",
|
||||||
|
"foo/src/inbook/somefile",
|
||||||
|
],
|
||||||
|
&["foo/src/inroot/somefile"],
|
||||||
|
&|_book_root| {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ignore_canonical() {
|
||||||
|
// test with path with ..
|
||||||
|
check_watch_behavior(
|
||||||
|
".gitignore",
|
||||||
|
"*.tmp\nsomedir/\n/foo/src/inbook\n",
|
||||||
|
"bar/../foo",
|
||||||
|
&[
|
||||||
|
"foo/src/somefile.tmp",
|
||||||
|
"foo/src/somedir/somefile",
|
||||||
|
"foo/src/inbook/somefile",
|
||||||
|
],
|
||||||
|
&["foo/src/chapter.md"],
|
||||||
|
&|_book_root| {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scan_extra_watch() {
|
||||||
|
// Check behavior with extra-watch-dirs
|
||||||
|
check_watch_behavior(
|
||||||
|
".gitignore",
|
||||||
|
"*.tmp\n/outside-root/ignoreme\n/foo/examples/ignoreme\n",
|
||||||
|
"foo",
|
||||||
|
&[
|
||||||
|
"foo/src/somefile.tmp",
|
||||||
|
"foo/examples/example.tmp",
|
||||||
|
"outside-root/somefile.tmp",
|
||||||
|
"outside-root/ignoreme",
|
||||||
|
"foo/examples/ignoreme",
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
"foo/src/chapter.md",
|
||||||
|
"foo/examples/example.rs",
|
||||||
|
"foo/examples/example2.rs",
|
||||||
|
"outside-root/image.png",
|
||||||
|
],
|
||||||
|
&|book_root| {
|
||||||
|
std::fs::write(
|
||||||
|
book_root.join("book.toml"),
|
||||||
|
r#"
|
||||||
|
[book]
|
||||||
|
title = "foo"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
extra-watch-dirs = [
|
||||||
|
"examples",
|
||||||
|
"../outside-root",
|
||||||
|
]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue