watchexec_filterer_globset/
lib.rs

1//! A path-only Watchexec filterer based on globsets.
2//!
3//! This filterer mimics the behavior of the `watchexec` v1 filter, but does not match it exactly,
4//! due to differing internals. It is used as the default filterer in Watchexec CLI currently.
5
6#![doc(html_favicon_url = "https://watchexec.github.io/logo:watchexec.svg")]
7#![doc(html_logo_url = "https://watchexec.github.io/logo:watchexec.svg")]
8#![warn(clippy::unwrap_used, missing_docs)]
9#![cfg_attr(not(test), warn(unused_crate_dependencies))]
10#![deny(rust_2018_idioms)]
11
12use std::{
13	ffi::OsString,
14	path::{Path, PathBuf},
15};
16
17use ignore::gitignore::{Gitignore, GitignoreBuilder};
18use ignore_files::{Error, IgnoreFile, IgnoreFilter};
19use tracing::{debug, trace, trace_span};
20use watchexec::{error::RuntimeError, filter::Filterer};
21use watchexec_events::{Event, FileType, Priority};
22use watchexec_filterer_ignore::IgnoreFilterer;
23
24/// A simple filterer in the style of the watchexec v1.17 filter.
25#[cfg_attr(feature = "full_debug", derive(Debug))]
26pub struct GlobsetFilterer {
27	#[cfg_attr(not(unix), allow(dead_code))]
28	origin: PathBuf,
29	filters: Gitignore,
30	ignores: Gitignore,
31	whitelist: Vec<PathBuf>,
32	ignore_files: IgnoreFilterer,
33	extensions: Vec<OsString>,
34}
35
36#[cfg(not(feature = "full_debug"))]
37impl std::fmt::Debug for GlobsetFilterer {
38	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39		f.debug_struct("GlobsetFilterer")
40			.field("origin", &self.origin)
41			.field("filters", &"ignore::gitignore::Gitignore{...}")
42			.field("ignores", &"ignore::gitignore::Gitignore{...}")
43			.field("ignore_files", &self.ignore_files)
44			.field("extensions", &self.extensions)
45			.finish()
46	}
47}
48
49impl GlobsetFilterer {
50	/// Create a new `GlobsetFilterer` from a project origin, allowed extensions, and lists of globs.
51	///
52	/// The first list is used to filter paths (only matching paths will pass the filter), the
53	/// second is used to ignore paths (matching paths will fail the pattern). If the filter list is
54	/// empty, only the ignore list will be used. If both lists are empty, the filter always passes.
55	/// Whitelist is used to automatically accept files even if they would be filtered out
56	/// otherwise. It is passed as an absolute path to the file that should not be filtered.
57	///
58	/// Ignores and filters are passed as a tuple of the glob pattern as a string and an optional
59	/// path of the folder the pattern should apply in (e.g. the folder a gitignore file is in).
60	/// A `None` to the latter will mark the pattern as being global.
61	///
62	/// The extensions list is used to filter files by extension.
63	///
64	/// Non-path events are always passed.
65	#[allow(clippy::future_not_send)]
66	pub async fn new(
67		origin: impl AsRef<Path>,
68		filters: impl IntoIterator<Item = (String, Option<PathBuf>)>,
69		ignores: impl IntoIterator<Item = (String, Option<PathBuf>)>,
70		whitelist: impl IntoIterator<Item = PathBuf>,
71		ignore_files: impl IntoIterator<Item = IgnoreFile>,
72		extensions: impl IntoIterator<Item = OsString>,
73	) -> Result<Self, Error> {
74		let origin = origin.as_ref();
75		let mut filters_builder = GitignoreBuilder::new(origin);
76		let mut ignores_builder = GitignoreBuilder::new(origin);
77
78		for (filter, in_path) in filters {
79			trace!(filter=?&filter, "add filter to globset filterer");
80			filters_builder
81				.add_line(in_path.clone(), &filter)
82				.map_err(|err| Error::Glob { file: in_path, err })?;
83		}
84
85		for (ignore, in_path) in ignores {
86			trace!(ignore=?&ignore, "add ignore to globset filterer");
87			ignores_builder
88				.add_line(in_path.clone(), &ignore)
89				.map_err(|err| Error::Glob { file: in_path, err })?;
90		}
91
92		let filters = filters_builder
93			.build()
94			.map_err(|err| Error::Glob { file: None, err })?;
95		let ignores = ignores_builder
96			.build()
97			.map_err(|err| Error::Glob { file: None, err })?;
98
99		let extensions: Vec<OsString> = extensions.into_iter().collect();
100
101		let mut ignore_files =
102			IgnoreFilter::new(origin, &ignore_files.into_iter().collect::<Vec<_>>()).await?;
103		ignore_files.finish();
104		let ignore_files = IgnoreFilterer(ignore_files);
105
106		let whitelist = whitelist.into_iter().collect::<Vec<_>>();
107
108		debug!(
109			?origin,
110			num_filters=%filters.num_ignores(),
111			num_neg_filters=%filters.num_whitelists(),
112			num_ignores=%ignores.num_ignores(),
113			num_in_ignore_files=?ignore_files.0.num_ignores(),
114			num_neg_ignores=%ignores.num_whitelists(),
115			num_extensions=%extensions.len(),
116		"globset filterer built");
117
118		Ok(Self {
119			origin: origin.into(),
120			filters,
121			ignores,
122			whitelist,
123			ignore_files,
124			extensions,
125		})
126	}
127}
128
129impl Filterer for GlobsetFilterer {
130	/// Filter an event.
131	///
132	/// This implementation never errors.
133	fn check_event(&self, event: &Event, priority: Priority) -> Result<bool, RuntimeError> {
134		let _span = trace_span!("filterer_check").entered();
135
136		{
137			trace!("checking internal whitelist");
138			// Ideally check path equality backwards for better perf
139			// There could be long matching prefixes so we will exit late
140			if event
141				.paths()
142				.any(|(p, _)| self.whitelist.iter().any(|w| w == p))
143			{
144				trace!("internal whitelist filterer matched (success)");
145				return Ok(true);
146			}
147		}
148
149		{
150			trace!("checking internal ignore filterer");
151			if !self
152				.ignore_files
153				.check_event(event, priority)
154				.expect("IgnoreFilterer never errors")
155			{
156				trace!("internal ignore filterer matched (fail)");
157				return Ok(false);
158			}
159		}
160
161		let mut paths = event.paths().peekable();
162		if paths.peek().is_none() {
163			trace!("non-path event (pass)");
164			Ok(true)
165		} else {
166			Ok(paths.any(|(path, file_type)| {
167				let _span = trace_span!("path", ?path).entered();
168				let is_dir = file_type.map_or(false, |t| matches!(t, FileType::Dir));
169
170				if self.ignores.matched(path, is_dir).is_ignore() {
171					trace!("ignored by globset ignore");
172					return false;
173				}
174
175				let mut filtered = false;
176				if self.filters.num_ignores() > 0 {
177					trace!("running through glob filters");
178					filtered = true;
179
180					if self.filters.matched(path, is_dir).is_ignore() {
181						trace!("allowed by globset filters");
182						return true;
183					}
184
185					// Watchexec 1.x bug, TODO remove at 2.0
186					#[cfg(unix)]
187					if let Ok(based) = path.strip_prefix(&self.origin) {
188						let rebased = {
189							use std::path::MAIN_SEPARATOR;
190							let mut b = self.origin.clone().into_os_string();
191							b.push(PathBuf::from(String::from(MAIN_SEPARATOR)));
192							b.push(PathBuf::from(String::from(MAIN_SEPARATOR)));
193							b.push(based.as_os_str());
194							b
195						};
196
197						trace!(?rebased, "testing on rebased path, 1.x bug compat (#258)");
198						if self.filters.matched(rebased, is_dir).is_ignore() {
199							trace!("allowed by globset filters, 1.x bug compat (#258)");
200							return true;
201						}
202					}
203				}
204
205				if !self.extensions.is_empty() {
206					trace!("running through extension filters");
207					filtered = true;
208
209					if is_dir {
210						trace!("failed on extension check due to being a dir");
211						return false;
212					}
213
214					if let Some(ext) = path.extension() {
215						if self.extensions.iter().any(|e| e == ext) {
216							trace!("allowed by extension filter");
217							return true;
218						}
219					} else {
220						trace!(
221							?path,
222							"failed on extension check due to having no extension"
223						);
224						return false;
225					}
226				}
227
228				!filtered
229			}))
230		}
231	}
232}