Skip to content

Commit a555c6b

Browse files
authored
Merge pull request #2325 from ehuss/poll-watcher
Add a poll-based file watcher.
2 parents f14fc61 + 0752fa4 commit a555c6b

File tree

9 files changed

+638
-199
lines changed

9 files changed

+638
-199
lines changed

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ notify = { version = "6.1.1", optional = true }
4141
notify-debouncer-mini = { version = "0.4.1", optional = true }
4242
ignore = { version = "0.4.20", optional = true }
4343
pathdiff = { version = "0.2.1", optional = true }
44+
walkdir = { version = "2.3.3", optional = true }
4445

4546
# Serve feature
4647
futures-util = { version = "0.3.28", optional = true }
@@ -61,7 +62,7 @@ walkdir = "2.3.3"
6162

6263
[features]
6364
default = ["watch", "serve", "search"]
64-
watch = ["dep:notify", "dep:notify-debouncer-mini", "dep:ignore", "dep:pathdiff"]
65+
watch = ["dep:notify", "dep:notify-debouncer-mini", "dep:ignore", "dep:pathdiff", "dep:walkdir"]
6566
serve = ["dep:futures-util", "dep:tokio", "dep:warp"]
6667
search = ["dep:elasticlunr-rs", "dep:ammonia"]
6768

guide/src/cli/arg-watcher.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#### `--watcher`
2+
3+
There are different backends used to determine when a file has changed.
4+
5+
* `poll` (default) --- Checks for file modifications by scanning the filesystem every second.
6+
* `native` --- Uses the native operating system facilities to receive notifications when files change.
7+
This can have less constant overhead, but may not be as reliable as the `poll` based watcher. See these issues for more information: [#383](https://github.com/rust-lang/mdBook/issues/383) [#1441](https://github.com/rust-lang/mdBook/issues/1441) [#1707](https://github.com/rust-lang/mdBook/issues/1707) [#2035](https://github.com/rust-lang/mdBook/issues/2035) [#2102](https://github.com/rust-lang/mdBook/issues/2102)

guide/src/cli/serve.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ book. Relative paths are interpreted relative to the book's root directory. If
4444
not specified it will default to the value of the `build.build-dir` key in
4545
`book.toml`, or to `./book`.
4646

47+
{{#include arg-watcher.md}}
48+
4749
#### Specify exclude patterns
4850

4951
The `serve` command will not automatically trigger a build for files listed in

guide/src/cli/watch.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ book. Relative paths are interpreted relative to the book's root directory. If
2727
not specified it will default to the value of the `build.build-dir` key in
2828
`book.toml`, or to `./book`.
2929

30+
{{#include arg-watcher.md}}
3031

3132
#### Specify exclude patterns
3233

src/cmd/command_prelude.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ pub trait CommandExt: Sized {
3636
fn arg_open(self) -> Self {
3737
self._arg(arg!(-o --open "Opens the compiled book in a web browser"))
3838
}
39+
40+
fn arg_watcher(self) -> Self {
41+
#[cfg(feature = "watch")]
42+
return self._arg(
43+
Arg::new("watcher")
44+
.long("watcher")
45+
.value_parser(["poll", "native"])
46+
.default_value("poll")
47+
.help("The filesystem watching technique"),
48+
);
49+
#[cfg(not(feature = "watch"))]
50+
return self;
51+
}
3952
}
4053

4154
impl CommandExt for Command {

src/cmd/serve.rs

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ use clap::builder::NonEmptyStringValueParser;
66
use futures_util::sink::SinkExt;
77
use futures_util::StreamExt;
88
use mdbook::errors::*;
9-
use mdbook::utils;
109
use mdbook::utils::fs::get_404_output_file;
1110
use mdbook::MDBook;
1211
use std::net::{SocketAddr, ToSocketAddrs};
@@ -43,12 +42,13 @@ pub fn make_subcommand() -> Command {
4342
.help("Port to use for HTTP connections"),
4443
)
4544
.arg_open()
45+
.arg_watcher()
4646
}
4747

4848
// Serve command implementation
4949
pub fn execute(args: &ArgMatches) -> Result<()> {
5050
let book_dir = get_book_dir(args);
51-
let mut book = MDBook::load(book_dir)?;
51+
let mut book = MDBook::load(&book_dir)?;
5252

5353
let port = args.get_one::<String>("port").unwrap();
5454
let hostname = args.get_one::<String>("hostname").unwrap();
@@ -97,23 +97,12 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
9797
}
9898

9999
#[cfg(feature = "watch")]
100-
watch::trigger_on_change(&book, move |paths, book_dir| {
101-
info!("Files changed: {:?}", paths);
102-
info!("Building book...");
103-
104-
// FIXME: This area is really ugly because we need to re-set livereload :(
105-
let result = MDBook::load(book_dir).and_then(|mut b| {
106-
update_config(&mut b);
107-
b.build()
108-
});
109-
110-
if let Err(e) = result {
111-
error!("Unable to load the book");
112-
utils::log_backtrace(&e);
113-
} else {
100+
{
101+
let watcher = watch::WatcherKind::from_str(args.get_one::<String>("watcher").unwrap());
102+
watch::rebuild_on_change(watcher, &book_dir, &update_config, &move || {
114103
let _ = tx.send(Message::text("reload"));
115-
}
116-
});
104+
});
105+
}
117106

118107
let _ = thread_handle.join();
119108

src/cmd/watch.rs

Lines changed: 31 additions & 180 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
use super::command_prelude::*;
22
use crate::{get_book_dir, open};
3-
use ignore::gitignore::Gitignore;
43
use mdbook::errors::Result;
5-
use mdbook::utils;
64
use mdbook::MDBook;
7-
use pathdiff::diff_paths;
85
use std::path::{Path, PathBuf};
9-
use std::sync::mpsc::channel;
10-
use std::thread::sleep;
11-
use std::time::Duration;
6+
7+
mod native;
8+
mod poller;
129

1310
// Create clap subcommand arguments
1411
pub fn make_subcommand() -> Command {
@@ -17,12 +14,28 @@ pub fn make_subcommand() -> Command {
1714
.arg_dest_dir()
1815
.arg_root_dir()
1916
.arg_open()
17+
.arg_watcher()
18+
}
19+
20+
pub enum WatcherKind {
21+
Poll,
22+
Native,
23+
}
24+
25+
impl WatcherKind {
26+
pub fn from_str(s: &str) -> WatcherKind {
27+
match s {
28+
"poll" => WatcherKind::Poll,
29+
"native" => WatcherKind::Native,
30+
_ => panic!("unsupported watcher {s}"),
31+
}
32+
}
2033
}
2134

2235
// Watch command implementation
2336
pub fn execute(args: &ArgMatches) -> Result<()> {
2437
let book_dir = get_book_dir(args);
25-
let mut book = MDBook::load(book_dir)?;
38+
let mut book = MDBook::load(&book_dir)?;
2639

2740
let update_config = |book: &mut MDBook| {
2841
if let Some(dest_dir) = args.get_one::<PathBuf>("dest-dir") {
@@ -41,42 +54,21 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
4154
open(path);
4255
}
4356

44-
trigger_on_change(&book, |paths, book_dir| {
45-
info!("Files changed: {:?}\nBuilding book...\n", paths);
46-
let result = MDBook::load(book_dir).and_then(|mut b| {
47-
update_config(&mut b);
48-
b.build()
49-
});
50-
51-
if let Err(e) = result {
52-
error!("Unable to build the book");
53-
utils::log_backtrace(&e);
54-
}
55-
});
57+
let watcher = WatcherKind::from_str(args.get_one::<String>("watcher").unwrap());
58+
rebuild_on_change(watcher, &book_dir, &update_config, &|| {});
5659

5760
Ok(())
5861
}
5962

60-
fn remove_ignored_files(book_root: &Path, paths: &[PathBuf]) -> Vec<PathBuf> {
61-
if paths.is_empty() {
62-
return vec![];
63-
}
64-
65-
match find_gitignore(book_root) {
66-
Some(gitignore_path) => {
67-
let (ignore, err) = Gitignore::new(&gitignore_path);
68-
if let Some(err) = err {
69-
warn!(
70-
"error reading gitignore `{}`: {err}",
71-
gitignore_path.display()
72-
);
73-
}
74-
filter_ignored_files(ignore, paths)
75-
}
76-
None => {
77-
// There is no .gitignore file.
78-
paths.iter().map(|path| path.to_path_buf()).collect()
79-
}
63+
pub fn rebuild_on_change(
64+
kind: WatcherKind,
65+
book_dir: &Path,
66+
update_config: &dyn Fn(&mut MDBook),
67+
post_build: &dyn Fn(),
68+
) {
69+
match kind {
70+
WatcherKind::Poll => self::poller::rebuild_on_change(book_dir, update_config, post_build),
71+
WatcherKind::Native => self::native::rebuild_on_change(book_dir, update_config, post_build),
8072
}
8173
}
8274

@@ -86,144 +78,3 @@ fn find_gitignore(book_root: &Path) -> Option<PathBuf> {
8678
.map(|p| p.join(".gitignore"))
8779
.find(|p| p.exists())
8880
}
89-
90-
// Note: The usage of `canonicalize` may encounter occasional failures on the Windows platform, presenting a potential risk.
91-
// For more details, refer to [Pull Request #2229](https://github.com/rust-lang/mdBook/pull/2229#discussion_r1408665981).
92-
fn filter_ignored_files(ignore: Gitignore, paths: &[PathBuf]) -> Vec<PathBuf> {
93-
let ignore_root = ignore
94-
.path()
95-
.canonicalize()
96-
.expect("ignore root canonicalize error");
97-
98-
paths
99-
.iter()
100-
.filter(|path| {
101-
let relative_path =
102-
diff_paths(path, &ignore_root).expect("One of the paths should be an absolute");
103-
!ignore
104-
.matched_path_or_any_parents(&relative_path, relative_path.is_dir())
105-
.is_ignore()
106-
})
107-
.map(|path| path.to_path_buf())
108-
.collect()
109-
}
110-
111-
/// Calls the closure when a book source file is changed, blocking indefinitely.
112-
pub fn trigger_on_change<F>(book: &MDBook, closure: F)
113-
where
114-
F: Fn(Vec<PathBuf>, &Path),
115-
{
116-
use notify::RecursiveMode::*;
117-
118-
// Create a channel to receive the events.
119-
let (tx, rx) = channel();
120-
121-
let mut debouncer = match notify_debouncer_mini::new_debouncer(Duration::from_secs(1), tx) {
122-
Ok(d) => d,
123-
Err(e) => {
124-
error!("Error while trying to watch the files:\n\n\t{:?}", e);
125-
std::process::exit(1)
126-
}
127-
};
128-
let watcher = debouncer.watcher();
129-
130-
// Add the source directory to the watcher
131-
if let Err(e) = watcher.watch(&book.source_dir(), Recursive) {
132-
error!("Error while watching {:?}:\n {:?}", book.source_dir(), e);
133-
std::process::exit(1);
134-
};
135-
136-
let _ = watcher.watch(&book.theme_dir(), Recursive);
137-
138-
// Add the book.toml file to the watcher if it exists
139-
let _ = watcher.watch(&book.root.join("book.toml"), NonRecursive);
140-
141-
for dir in &book.config.build.extra_watch_dirs {
142-
let path = book.root.join(dir);
143-
let canonical_path = path.canonicalize().unwrap_or_else(|e| {
144-
error!("Error while watching extra directory {path:?}:\n {e}");
145-
std::process::exit(1);
146-
});
147-
148-
if let Err(e) = watcher.watch(&canonical_path, Recursive) {
149-
error!(
150-
"Error while watching extra directory {:?}:\n {:?}",
151-
canonical_path, e
152-
);
153-
std::process::exit(1);
154-
}
155-
}
156-
157-
info!("Listening for changes...");
158-
159-
loop {
160-
let first_event = rx.recv().unwrap();
161-
sleep(Duration::from_millis(50));
162-
let other_events = rx.try_iter();
163-
164-
let all_events = std::iter::once(first_event).chain(other_events);
165-
166-
let paths: Vec<_> = all_events
167-
.filter_map(|event| match event {
168-
Ok(events) => Some(events),
169-
Err(error) => {
170-
log::warn!("error while watching for changes: {error}");
171-
None
172-
}
173-
})
174-
.flatten()
175-
.map(|event| event.path)
176-
.collect();
177-
178-
// If we are watching files outside the current repository (via extra-watch-dirs), then they are definitionally
179-
// ignored by gitignore. So we handle this case by including such files into the watched paths list.
180-
let any_external_paths = paths.iter().filter(|p| !p.starts_with(&book.root)).cloned();
181-
let mut paths = remove_ignored_files(&book.root, &paths[..]);
182-
paths.extend(any_external_paths);
183-
184-
if !paths.is_empty() {
185-
closure(paths, &book.root);
186-
}
187-
}
188-
}
189-
190-
#[cfg(test)]
191-
mod tests {
192-
use super::*;
193-
use ignore::gitignore::GitignoreBuilder;
194-
use std::env;
195-
196-
#[test]
197-
fn test_filter_ignored_files() {
198-
let current_dir = env::current_dir().unwrap();
199-
200-
let ignore = GitignoreBuilder::new(&current_dir)
201-
.add_line(None, "*.html")
202-
.unwrap()
203-
.build()
204-
.unwrap();
205-
let should_remain = current_dir.join("record.text");
206-
let should_filter = current_dir.join("index.html");
207-
208-
let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
209-
assert_eq!(remain, vec![should_remain])
210-
}
211-
212-
#[test]
213-
fn filter_ignored_files_should_handle_parent_dir() {
214-
let current_dir = env::current_dir().unwrap();
215-
216-
let ignore = GitignoreBuilder::new(&current_dir)
217-
.add_line(None, "*.html")
218-
.unwrap()
219-
.build()
220-
.unwrap();
221-
222-
let parent_dir = current_dir.join("..");
223-
let should_remain = parent_dir.join("record.text");
224-
let should_filter = parent_dir.join("index.html");
225-
226-
let remain = filter_ignored_files(ignore, &[should_remain.clone(), should_filter]);
227-
assert_eq!(remain, vec![should_remain])
228-
}
229-
}

0 commit comments

Comments
 (0)