Skip to content

Commit 3748ecf

Browse files
branchseerclaude
andcommitted
refactor(vite_glob): move rerooting logic into AnchoredGlob
Move path_bridge, rerooted_pattern, common_ancestor, and escape_glob from walk.rs into anchored.rs, exposed as AnchoredGlob::reroot() and has_related_prefix() methods. This simplifies the walk module by encapsulating glob rerooting within the type that owns the data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9263ec2 commit 3748ecf

3 files changed

Lines changed: 255 additions & 92 deletions

File tree

crates/vite_glob/src/anchored.rs

Lines changed: 244 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::sync::Arc;
22

3-
use vite_path::AbsolutePath;
3+
use vite_path::{AbsolutePath, AbsolutePathBuf};
44
use wax::Glob;
55

66
use crate::Error;
@@ -59,6 +59,38 @@ impl AnchoredGlob {
5959
self.variant.as_ref()
6060
}
6161

62+
/// Whether this glob's prefix is an ancestor or descendant of `other`,
63+
/// meaning a rerooting between them is possible.
64+
#[must_use]
65+
pub(crate) fn has_related_prefix(&self, other: &AbsolutePath) -> bool {
66+
self.prefix.as_path().starts_with(other.as_path())
67+
|| other.as_path().starts_with(self.prefix.as_path())
68+
}
69+
70+
/// Reroot this glob relative to `new_root`, returning a wax `Glob` whose
71+
/// invariant prefix bridges from `new_root` to this glob's prefix.
72+
///
73+
/// Returns `None` if this glob's prefix is not a descendant of `new_root`
74+
/// (unrelated prefixes), or if the glob is variant-less and sits exactly
75+
/// at `new_root` (cannot exclude/include files from the root itself).
76+
///
77+
/// # Errors
78+
///
79+
/// Returns an error if the rerooted glob pattern is invalid.
80+
pub(crate) fn reroot(&self, new_root: &AbsolutePath) -> Result<Option<Glob<'static>>, Error> {
81+
let Some(bridge) = path_bridge(new_root, &self.prefix) else {
82+
return Ok(None);
83+
};
84+
match &self.variant {
85+
Some(variant) => {
86+
let pattern = rerooted_pattern(&bridge, variant);
87+
Ok(Some(Glob::new(&pattern)?.into_owned()))
88+
}
89+
None if !bridge.is_empty() => Ok(Some(Glob::new(&wax::escape(&bridge))?.into_owned())),
90+
None => Ok(None),
91+
}
92+
}
93+
6294
/// Check if an absolute path matches this anchored glob.
6395
#[must_use]
6496
pub fn is_match(&self, path: &AbsolutePath) -> bool {
@@ -72,3 +104,214 @@ impl AnchoredGlob {
72104
v.is_match(remainder)
73105
}
74106
}
107+
108+
/// Compute the longest common ancestor of two absolute paths.
109+
#[expect(
110+
clippy::disallowed_types,
111+
reason = "collecting std::path::Components requires std::path::PathBuf"
112+
)]
113+
pub fn common_ancestor(a: &AbsolutePath, b: &AbsolutePath) -> AbsolutePathBuf {
114+
let common: std::path::PathBuf = a
115+
.as_path()
116+
.components()
117+
.zip(b.as_path().components())
118+
.take_while(|(a, b)| a == b)
119+
.map(|(a, _)| a)
120+
.collect();
121+
AbsolutePathBuf::new(common).expect("common ancestor of absolute paths is absolute")
122+
}
123+
124+
/// Compute the "bridge" — the relative path from `ancestor` to `path` — as a
125+
/// `/`-separated string. Returns `None` if `path` is not under `ancestor`
126+
/// (i.e. the prefixes are unrelated and no rerooting is possible).
127+
#[expect(
128+
clippy::disallowed_types,
129+
clippy::disallowed_methods,
130+
reason = "bridge computation requires std String and str::replace for wax glob patterns"
131+
)]
132+
fn path_bridge(ancestor: &AbsolutePath, path: &AbsolutePath) -> Option<String> {
133+
let remainder = path.as_path().strip_prefix(ancestor.as_path()).ok()?;
134+
Some(remainder.to_string_lossy().replace('\\', "/"))
135+
}
136+
137+
/// Build a rerooted glob pattern by joining an escaped bridge path with a
138+
/// variant glob. When the bridge is empty (prefix == walk root), the variant
139+
/// is returned unchanged.
140+
#[expect(clippy::disallowed_types, reason = "building glob pattern string for wax requires String")]
141+
fn rerooted_pattern(bridge: &str, variant: &Glob<'_>) -> String {
142+
if bridge.is_empty() {
143+
variant.to_string()
144+
} else {
145+
[&*wax::escape(bridge), "/", &variant.to_string()].concat()
146+
}
147+
}
148+
149+
/// Escape wax glob metacharacters in a literal path string. The bridge is
150+
/// always a literal path (derived from invariant prefixes), but it may
151+
/// contain characters that wax interprets as glob syntax.
152+
fn escape_glob(s: &str) -> Cow<'_, str> {
153+
const GLOB_CHARS: &[char] = &['?', '*', '$', ':', '<', '>', '(', ')', '[', ']', '{', '}', ','];
154+
if !s.contains(GLOB_CHARS) {
155+
return Cow::Borrowed(s);
156+
}
157+
let mut escaped = s.to_owned();
158+
escaped.clear();
159+
escaped.reserve(s.len() + 4);
160+
for c in s.chars() {
161+
if GLOB_CHARS.contains(&c) {
162+
escaped.push('\\');
163+
}
164+
escaped.push(c);
165+
}
166+
Cow::Owned(escaped)
167+
}
168+
169+
#[cfg(test)]
170+
mod tests {
171+
use super::*;
172+
173+
fn abs(p: &str) -> AbsolutePathBuf {
174+
AbsolutePathBuf::new(std::path::PathBuf::from(p)).expect("test path should be absolute")
175+
}
176+
177+
#[test]
178+
fn common_ancestor_same_path() {
179+
let a = abs("/app/src");
180+
let result = common_ancestor(&a, &a);
181+
assert_eq!(result, a);
182+
}
183+
184+
#[test]
185+
fn common_ancestor_parent_child() {
186+
let parent = abs("/app");
187+
let child = abs("/app/src/lib");
188+
assert_eq!(common_ancestor(&parent, &child), parent);
189+
assert_eq!(common_ancestor(&child, &parent), parent);
190+
}
191+
192+
#[test]
193+
fn common_ancestor_siblings() {
194+
let a = abs("/app/src");
195+
let b = abs("/app/dist");
196+
assert_eq!(common_ancestor(&a, &b), abs("/app"));
197+
}
198+
199+
#[test]
200+
fn common_ancestor_only_root() {
201+
let a = abs("/foo/bar");
202+
let b = abs("/baz/qux");
203+
assert_eq!(common_ancestor(&a, &b), abs("/"));
204+
}
205+
206+
#[test]
207+
fn reroot_same_prefix() {
208+
let root = abs("/app/src");
209+
let glob = AnchoredGlob::new("**/*.rs", &root).unwrap();
210+
let rerooted = glob.reroot(&root).unwrap().unwrap();
211+
assert_eq!(rerooted.to_string(), "**/*.rs");
212+
}
213+
214+
#[test]
215+
fn reroot_descendant_prefix() {
216+
let base = abs("/app/src");
217+
let glob = AnchoredGlob::new("lib/**/*.rs", &base).unwrap();
218+
// prefix = /app/src/lib, variant = **/*.rs
219+
// reroot to /app → bridge = "src/lib"
220+
let root = abs("/app");
221+
let rerooted = glob.reroot(&root).unwrap().unwrap();
222+
assert_eq!(rerooted.to_string(), "src/lib/**/*.rs");
223+
}
224+
225+
#[test]
226+
fn reroot_unrelated_returns_none() {
227+
let base = abs("/app/src");
228+
let glob = AnchoredGlob::new("**/*.rs", &base).unwrap();
229+
let root = abs("/other");
230+
assert!(glob.reroot(&root).unwrap().is_none());
231+
}
232+
233+
#[test]
234+
fn reroot_variantless_with_bridge() {
235+
let base = abs("/app");
236+
let glob = AnchoredGlob::new("src/main.rs", &base).unwrap();
237+
// prefix = /app/src/main.rs, variant = None
238+
let root = abs("/app");
239+
let rerooted = glob.reroot(&root).unwrap().unwrap();
240+
assert_eq!(rerooted.to_string(), "src/main.rs");
241+
}
242+
243+
#[test]
244+
fn reroot_variantless_at_root_returns_none() {
245+
let base = abs("/app");
246+
// A pattern that is just the base dir itself (no variant, no bridge)
247+
let glob = AnchoredGlob::new(".", &base).unwrap();
248+
let result = glob.reroot(&base).unwrap();
249+
assert!(result.is_none());
250+
}
251+
252+
#[test]
253+
fn has_related_prefix_ancestor() {
254+
let glob = AnchoredGlob::new("**", &abs("/app/src")).unwrap();
255+
assert!(glob.has_related_prefix(&abs("/app")));
256+
}
257+
258+
#[test]
259+
fn has_related_prefix_descendant() {
260+
let glob = AnchoredGlob::new("**", &abs("/app")).unwrap();
261+
assert!(glob.has_related_prefix(&abs("/app/src")));
262+
}
263+
264+
#[test]
265+
fn has_related_prefix_same() {
266+
let glob = AnchoredGlob::new("**", &abs("/app")).unwrap();
267+
assert!(glob.has_related_prefix(&abs("/app")));
268+
}
269+
270+
#[test]
271+
fn has_related_prefix_unrelated() {
272+
let glob = AnchoredGlob::new("**", &abs("/app")).unwrap();
273+
assert!(!glob.has_related_prefix(&abs("/other")));
274+
}
275+
276+
#[test]
277+
fn escape_glob_no_metacharacters() {
278+
assert_eq!(escape_glob("src/lib"), "src/lib");
279+
}
280+
281+
#[test]
282+
fn escape_glob_with_metacharacters() {
283+
assert_eq!(escape_glob("a[b]*c?d"), "a\\[b\\]\\*c\\?d");
284+
}
285+
286+
#[test]
287+
fn path_bridge_direct_child() {
288+
let ancestor = abs("/app");
289+
let path = abs("/app/src");
290+
assert_eq!(path_bridge(&ancestor, &path).unwrap(), "src");
291+
}
292+
293+
#[test]
294+
fn path_bridge_same_path() {
295+
let path = abs("/app");
296+
assert_eq!(path_bridge(&path, &path).unwrap(), "");
297+
}
298+
299+
#[test]
300+
fn path_bridge_unrelated() {
301+
let a = abs("/app");
302+
let b = abs("/other");
303+
assert!(path_bridge(&a, &b).is_none());
304+
}
305+
306+
#[test]
307+
fn rerooted_pattern_empty_bridge() {
308+
let glob = Glob::new("**/*.rs").unwrap();
309+
assert_eq!(rerooted_pattern("", &glob), "**/*.rs");
310+
}
311+
312+
#[test]
313+
fn rerooted_pattern_with_bridge() {
314+
let glob = Glob::new("**/*.rs").unwrap();
315+
assert_eq!(rerooted_pattern("src/lib", &glob), "src/lib/**/*.rs");
316+
}
317+
}

crates/vite_glob/src/error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ pub enum Error {
44
WaxBuild(#[from] wax::BuildError),
55
#[error(transparent)]
66
Walk(#[from] wax::walk::WalkError),
7+
#[error(transparent)]
8+
InvalidPathData(#[from] vite_path::relative::InvalidPathDataError),
79
}

0 commit comments

Comments
 (0)