Skip to main content

code_moniker_workspace/
glob.rs

1use std::borrow::Cow;
2use std::path::Path;
3
4use regex::Regex;
5
6pub fn compile_glob(pattern: &str) -> Regex {
7	Regex::new(&glob_to_regex(pattern))
8		.unwrap_or_else(|err| panic!("glob compiler emitted invalid regex: {err}"))
9}
10
11fn glob_to_regex(pattern: &str) -> String {
12	let mut out = String::from("^");
13	let mut chars = pattern.chars().peekable();
14	while let Some(ch) = chars.next() {
15		match ch {
16			'*' if chars.peek() == Some(&'*') => {
17				chars.next();
18				if chars.peek() == Some(&'/') {
19					chars.next();
20					out.push_str("(?:.*/)?");
21				} else {
22					out.push_str(".*");
23				}
24			}
25			'*' => out.push_str("[^/]*"),
26			'?' => out.push_str("[^/]"),
27			other => out.push_str(&regex::escape(&other.to_string())),
28		}
29	}
30	out.push('$');
31	out
32}
33
34#[derive(Clone, Debug, Default)]
35pub struct FilePathFilter {
36	patterns: Vec<Regex>,
37}
38
39impl FilePathFilter {
40	pub fn compile(patterns: &[String]) -> anyhow::Result<Self> {
41		patterns
42			.iter()
43			.map(|pattern| compile_path_pattern(&normalize_path_pattern(pattern)?))
44			.collect::<anyhow::Result<Vec<_>>>()
45			.map(|patterns| patterns.into_iter().flatten().collect())
46			.map(|patterns| Self { patterns })
47	}
48
49	pub fn matches(&self, rel_path: impl AsRef<Path>) -> bool {
50		if self.patterns.is_empty() {
51			return true;
52		}
53		let lossy = rel_path.as_ref().to_string_lossy();
54		let rel = if lossy.contains('\\') {
55			Cow::Owned(lossy.replace('\\', "/"))
56		} else {
57			lossy
58		};
59		self.patterns.iter().any(|pattern| pattern.is_match(&rel))
60	}
61}
62
63fn compile_path_pattern(pattern: &str) -> anyhow::Result<Vec<Regex>> {
64	if pattern.contains(['*', '?']) {
65		return Ok(vec![compile_glob(pattern)]);
66	}
67	let escaped = regex::escape(pattern);
68	Ok(vec![
69		Regex::new(&format!("^{escaped}$"))?,
70		Regex::new(&format!("^{escaped}/.*$"))?,
71	])
72}
73
74fn normalize_path_pattern(pattern: &str) -> anyhow::Result<String> {
75	let trimmed = pattern.trim();
76	if trimmed.is_empty() {
77		anyhow::bail!("path glob must not be empty");
78	}
79	let normalized = trimmed.replace('\\', "/");
80	Ok(normalized
81		.trim_start_matches("./")
82		.trim_start_matches('/')
83		.to_string())
84}
85
86#[cfg(test)]
87mod tests {
88	use super::*;
89
90	#[test]
91	fn empty_filter_matches_everything() {
92		let filter = FilePathFilter::compile(&[]).unwrap();
93		assert!(filter.matches("any/path.rs"));
94	}
95
96	#[test]
97	fn relative_glob_scopes_to_subtree() {
98		let filter = FilePathFilter::compile(&["pkg/src/**".to_string()]).unwrap();
99		assert!(filter.matches("pkg/src/a.ts"));
100		assert!(filter.matches("pkg/src/nested/a.ts"));
101		assert!(!filter.matches("pkg/test/a.ts"));
102	}
103
104	#[test]
105	fn plain_directory_scopes_to_subtree() {
106		let filter = FilePathFilter::compile(&["pkg/src".to_string()]).unwrap();
107		assert!(filter.matches("pkg/src"));
108		assert!(filter.matches("pkg/src/a.ts"));
109		assert!(filter.matches("pkg/src/nested/a.ts"));
110		assert!(!filter.matches("pkg/src-other/a.ts"));
111		assert!(!filter.matches("pkg/test/a.ts"));
112	}
113
114	#[test]
115	fn single_star_stays_within_one_segment() {
116		let filter = FilePathFilter::compile(&["**/fixtures/*.rs".to_string()]).unwrap();
117		assert!(filter.matches("crates/core/tests/fixtures/accounts.rs"));
118		assert!(!filter.matches("crates/core/tests/fixtures/rs/accounts.rs"));
119	}
120
121	#[test]
122	fn double_star_slash_allows_empty_prefix() {
123		assert_eq!(glob_to_regex("**/x"), "^(?:.*/)?x$");
124	}
125
126	#[test]
127	fn metacharacters_are_escaped() {
128		let filter = FilePathFilter::compile(&["a.txt".to_string()]).unwrap();
129		assert!(filter.matches("a.txt"));
130		assert!(!filter.matches("axtxt"));
131	}
132
133	#[test]
134	fn matches_accepts_both_path_and_str() {
135		let filter = FilePathFilter::compile(&["pkg/**".to_string()]).unwrap();
136		assert!(filter.matches(Path::new("pkg/a.rs")));
137		assert!(filter.matches("pkg/a.rs"));
138	}
139
140	#[test]
141	fn leading_dot_slash_is_normalized() {
142		let filter = FilePathFilter::compile(&["./pkg/**".to_string()]).unwrap();
143		assert!(filter.matches("pkg/a.rs"));
144	}
145
146	#[test]
147	fn backslash_separators_normalize_before_prefix_strip() {
148		let filter = FilePathFilter::compile(&["\\pkg\\src\\**".to_string()]).unwrap();
149		assert!(filter.matches("pkg/src/a.rs"));
150		assert!(filter.matches("pkg/src/nested/a.rs"));
151		assert!(!filter.matches("pkg/test/a.rs"));
152	}
153
154	#[test]
155	fn leading_dot_backslash_is_normalized() {
156		let filter = FilePathFilter::compile(&[".\\pkg\\**".to_string()]).unwrap();
157		assert!(filter.matches("pkg/a.rs"));
158	}
159}