11use std:: sync:: Arc ;
22
3- use vite_path:: AbsolutePath ;
3+ use vite_path:: { AbsolutePath , AbsolutePathBuf } ;
44use wax:: Glob ;
55
66use 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+ }
0 commit comments