@@ -6,16 +6,53 @@ use std::path::Path;
66pub use error:: Error ;
77use wax:: { Glob , Program } ;
88
9+ /// Matches a single environment-variable **name** as a flat string.
10+ ///
11+ /// Backed by `globset` with path-separator handling disabled, so `*`, `?`,
12+ /// `[...]`, and `{a,b}` behave as plain-string wildcards rather than path globs
13+ /// (env names are never paths). Matching is case-sensitive on Unix and
14+ /// case-insensitive on Windows, mirroring how environment variables are looked
15+ /// up on each platform. `!` is an ordinary character — there is no negation.
16+ #[ derive( Debug , Clone ) ]
17+ pub struct EnvGlob {
18+ matcher : globset:: GlobMatcher ,
19+ }
20+
21+ impl EnvGlob {
22+ /// Compiles `pattern` into an env-name matcher.
23+ ///
24+ /// # Errors
25+ /// Returns an error if `pattern` is not a valid glob.
26+ pub fn new ( pattern : & str ) -> Result < Self , Error > {
27+ let glob = globset:: GlobBuilder :: new ( pattern)
28+ // Env names contain no path separators, so disabling separator
29+ // handling makes `*`/`?` match any character — a pure string match.
30+ . literal_separator ( false )
31+ // Env lookups are case-insensitive on Windows, case-sensitive elsewhere.
32+ . case_insensitive ( cfg ! ( windows) )
33+ . build ( ) ?;
34+ Ok ( Self { matcher : glob. compile_matcher ( ) } )
35+ }
36+
37+ /// Returns whether `name` matches the pattern.
38+ #[ must_use]
39+ pub fn is_match ( & self , name : & str ) -> bool {
40+ self . matcher . is_match ( name)
41+ }
42+ }
43+
44+ /// Matches filesystem **paths** with gitignore semantics.
45+ ///
946/// If there are no negated patterns, it will follow the first match wins semantics.
1047/// Otherwise, it will follow the last match wins semantics.
1148#[ derive( Debug ) ]
12- pub struct GlobPatternSet < ' a > {
49+ pub struct PathGlobSet < ' a > {
1350 /// (`glob_pattern`, `match_or_not`)
1451 patterns : Vec < ( Glob < ' a > , bool ) > ,
1552 has_negated : bool ,
1653}
1754
18- impl < ' a > GlobPatternSet < ' a > {
55+ impl < ' a > PathGlobSet < ' a > {
1956 /// # Errors
2057 /// Returns an error if any glob pattern is invalid.
2158 pub fn new < I , S > ( match_patterns : I ) -> Result < Self , Error >
@@ -74,7 +111,7 @@ mod tests {
74111 "!**/yarn.lock" ,
75112 "!**/pnpm-lock.yaml" ,
76113 ] ;
77- let ignores = GlobPatternSet :: new ( & patterns) ?;
114+ let ignores = PathGlobSet :: new ( & patterns) ?;
78115
79116 // Should ignore paths inside node_modules
80117 assert ! ( ignores. is_match( "node_modules/react/index.js" ) ) ;
@@ -104,7 +141,7 @@ mod tests {
104141 #[ test]
105142 fn test_match_ignores_with_file_patterns ( ) -> Result < ( ) , Error > {
106143 let patterns = vec ! [ "*.log" , "**/*.tmp" , "!important.log" ] ;
107- let ignores = GlobPatternSet :: new ( & patterns) ?;
144+ let ignores = PathGlobSet :: new ( & patterns) ?;
108145
109146 // Should ignore matching files
110147 assert ! ( ignores. is_match( "debug.log" ) ) ;
@@ -130,7 +167,7 @@ mod tests {
130167 #[ test]
131168 fn test_match_ignores_directory_patterns ( ) -> Result < ( ) , Error > {
132169 let patterns = vec ! [ "dist/**" , "build/**" , "!dist/public/**" ] ;
133- let ignores = GlobPatternSet :: new ( & patterns) ?;
170+ let ignores = PathGlobSet :: new ( & patterns) ?;
134171
135172 // Should ignore paths in dist and build
136173 assert ! ( ignores. is_match( "dist/bundle.js" ) ) ;
@@ -158,7 +195,7 @@ mod tests {
158195 "**/tests/**" ,
159196 "!**/integration/tests/**" ,
160197 ] ;
161- let ignores = GlobPatternSet :: new ( & patterns) ?;
198+ let ignores = PathGlobSet :: new ( & patterns) ?;
162199
163200 // Should ignore test files
164201 assert ! ( ignores. is_match( "src/utils.test.js" ) ) ;
@@ -180,7 +217,7 @@ mod tests {
180217 #[ test]
181218 fn test_match_ignores_empty_patterns ( ) -> Result < ( ) , Error > {
182219 let patterns: Vec < & str > = vec ! [ ] ;
183- let ignores = GlobPatternSet :: new ( & patterns) ?;
220+ let ignores = PathGlobSet :: new ( & patterns) ?;
184221
185222 // Should not ignore anything with empty patterns
186223 assert ! ( !ignores. is_match( "node_modules/package.json" ) ) ;
@@ -193,7 +230,7 @@ mod tests {
193230 #[ test]
194231 fn test_match_ignores_with_wildcards ( ) -> Result < ( ) , Error > {
195232 let patterns = vec ! [ "*.{js,ts,jsx,tsx}" , "!index.js" , "!main.ts" ] ;
196- let ignores = GlobPatternSet :: new ( & patterns) ?;
233+ let ignores = PathGlobSet :: new ( & patterns) ?;
197234
198235 // Should ignore matching extensions
199236 assert ! ( ignores. is_match( "utils.js" ) ) ;
@@ -215,7 +252,7 @@ mod tests {
215252 #[ test]
216253 fn test_match_ignores_dotfiles ( ) -> Result < ( ) , Error > {
217254 let patterns = vec ! [ ".*" , "!.gitignore" , "!.env.example" ] ;
218- let ignores = GlobPatternSet :: new ( & patterns) ?;
255+ let ignores = PathGlobSet :: new ( & patterns) ?;
219256
220257 // Should ignore dotfiles
221258 assert ! ( ignores. is_match( ".env" ) ) ;
@@ -242,7 +279,7 @@ mod tests {
242279 "!dist/public" ,
243280 "**/node_modules" ,
244281 ] ;
245- let ignores = GlobPatternSet :: new ( & patterns) ?;
282+ let ignores = PathGlobSet :: new ( & patterns) ?;
246283 // Patterns match at any level
247284 assert ! ( ignores. is_match( "dist" ) ) ;
248285 assert ! ( ignores. is_match( "src/dist" ) ) ; // Also matches nested
@@ -264,7 +301,7 @@ mod tests {
264301 "build/**" , // Match everything under build
265302 "!build/keep/**" , // But not under build/keep
266303 ] ;
267- let ignores = GlobPatternSet :: new ( & patterns) ?;
304+ let ignores = PathGlobSet :: new ( & patterns) ?;
268305 // Directory patterns
269306 assert ! ( ignores. is_match( "build/output.js" ) ) ;
270307 assert ! ( ignores. is_match( "build/assets/style.css" ) ) ;
@@ -284,7 +321,7 @@ mod tests {
284321 "!**/temp/keep/**" ,
285322 "!debug.log" ,
286323 ] ;
287- let ignores = GlobPatternSet :: new ( & patterns) ?;
324+ let ignores = PathGlobSet :: new ( & patterns) ?;
288325
289326 // Test various patterns together
290327 assert ! ( ignores. is_match( "error.log" ) ) ;
@@ -314,31 +351,31 @@ mod tests {
314351
315352 // Test with Vec<&str>
316353 let patterns_str = vec ! [ "*.log" , "!important.log" ] ;
317- let ignores_str = GlobPatternSet :: new ( & patterns_str) ?;
354+ let ignores_str = PathGlobSet :: new ( & patterns_str) ?;
318355 assert ! ( ignores_str. is_match( "debug.log" ) ) ;
319356 assert ! ( !ignores_str. is_match( "important.log" ) ) ;
320357
321358 // Test with Vec<String>
322359 let patterns_string = vec ! [ String :: from( "*.tmp" ) , String :: from( "!keep.tmp" ) ] ;
323- let ignores_string = GlobPatternSet :: new ( & patterns_string) ?;
360+ let ignores_string = PathGlobSet :: new ( & patterns_string) ?;
324361 assert ! ( ignores_string. is_match( "temp.tmp" ) ) ;
325362 assert ! ( !ignores_string. is_match( "keep.tmp" ) ) ;
326363
327364 // Test with Vec<Str>
328365 let patterns_vite_str = vec ! [ Str :: from( "*.rs" ) , Str :: from( "!main.rs" ) ] ;
329- let ignores_vite_str = GlobPatternSet :: new ( & patterns_vite_str) ?;
366+ let ignores_vite_str = PathGlobSet :: new ( & patterns_vite_str) ?;
330367 assert ! ( ignores_vite_str. is_match( "lib.rs" ) ) ;
331368 assert ! ( !ignores_vite_str. is_match( "main.rs" ) ) ;
332369
333370 // Test with array
334371 let patterns_array = [ "build/**" , "!build/dist/**" ] ;
335- let ignores_array = GlobPatternSet :: new ( & patterns_array) ?;
372+ let ignores_array = PathGlobSet :: new ( & patterns_array) ?;
336373 assert ! ( ignores_array. is_match( "build/src/main.js" ) ) ;
337374 assert ! ( !ignores_array. is_match( "build/dist/bundle.js" ) ) ;
338375
339376 // Test with iterator
340377 let patterns_iter = [ "*.md" , "!README.md" ] . iter ( ) ;
341- let ignores_iter = GlobPatternSet :: new ( patterns_iter) ?;
378+ let ignores_iter = PathGlobSet :: new ( patterns_iter) ?;
342379 assert ! ( ignores_iter. is_match( "CHANGELOG.md" ) ) ;
343380 assert ! ( !ignores_iter. is_match( "README.md" ) ) ;
344381
@@ -353,7 +390,7 @@ mod tests {
353390 "!logs/important.log" , // Second: don't ignore important.log
354391 "logs/important.log" , // Third: ignore important.log again (this wins)
355392 ] ;
356- let ignores = GlobPatternSet :: new ( & patterns) ?;
393+ let ignores = PathGlobSet :: new ( & patterns) ?;
357394
358395 assert ! ( ignores. is_match( "logs/error.log" ) ) ;
359396 assert ! ( ignores. is_match( "logs/src/app.log" ) ) ;
@@ -364,3 +401,80 @@ mod tests {
364401 Ok ( ( ) )
365402 }
366403}
404+
405+ #[ cfg( test) ]
406+ mod env_tests {
407+ use super :: EnvGlob ;
408+
409+ #[ test]
410+ fn matches_star_prefix_and_suffix ( ) {
411+ let g = EnvGlob :: new ( "VITE_*" ) . unwrap ( ) ;
412+ assert ! ( g. is_match( "VITE_FOO" ) ) ;
413+ assert ! ( g. is_match( "VITE_" ) ) ; // `*` matches the empty string
414+ assert ! ( !g. is_match( "MYVITE_FOO" ) ) ;
415+
416+ let g = EnvGlob :: new ( "*_KEY" ) . unwrap ( ) ;
417+ assert ! ( g. is_match( "MY_KEY" ) ) ;
418+ assert ! ( !g. is_match( "MY_KEYS" ) ) ;
419+
420+ let g = EnvGlob :: new ( "*_CREDENTIAL*" ) . unwrap ( ) ;
421+ assert ! ( g. is_match( "AWS_CREDENTIALS" ) ) ;
422+ assert ! ( g. is_match( "X_CREDENTIAL_Y" ) ) ;
423+ }
424+
425+ #[ test]
426+ fn question_mark_matches_exactly_one_char ( ) {
427+ let g = EnvGlob :: new ( "APP?_*" ) . unwrap ( ) ;
428+ assert ! ( g. is_match( "APP1_TOKEN" ) ) ;
429+ assert ! ( g. is_match( "APP2_NAME" ) ) ;
430+ // `?` requires exactly one character, so `APP_X` (nothing before `_`) does not match.
431+ assert ! ( !g. is_match( "APP_X" ) ) ;
432+ }
433+
434+ #[ test]
435+ fn brace_alternation_is_supported ( ) {
436+ let g = EnvGlob :: new ( "{VITE,NEXT}_*" ) . unwrap ( ) ;
437+ assert ! ( g. is_match( "VITE_FOO" ) ) ;
438+ assert ! ( g. is_match( "NEXT_BAR" ) ) ;
439+ assert ! ( !g. is_match( "NUXT_BAR" ) ) ;
440+ }
441+
442+ #[ test]
443+ fn dot_and_separators_are_literal_not_path_special ( ) {
444+ // Env names are flat strings: `*` spans `.` and `/` (no path semantics),
445+ // and a literal `.` in the pattern matches a literal `.`.
446+ assert ! ( EnvGlob :: new( "A*" ) . unwrap( ) . is_match( "A.B" ) ) ;
447+ assert ! ( EnvGlob :: new( "A*" ) . unwrap( ) . is_match( "A/B" ) ) ;
448+ assert ! ( EnvGlob :: new( "*.local" ) . unwrap( ) . is_match( "APP.local" ) ) ;
449+ assert ! ( !EnvGlob :: new( "*.local" ) . unwrap( ) . is_match( "APPXlocal" ) ) ;
450+ }
451+
452+ #[ test]
453+ fn bang_is_a_literal_character ( ) {
454+ // There is no negation: `!FOO` matches the literal name `!FOO`, not `FOO`.
455+ let g = EnvGlob :: new ( "!FOO" ) . unwrap ( ) ;
456+ assert ! ( g. is_match( "!FOO" ) ) ;
457+ assert ! ( !g. is_match( "FOO" ) ) ;
458+ }
459+
460+ #[ test]
461+ fn non_match_default_is_false ( ) {
462+ assert ! ( !EnvGlob :: new( "VITE_*" ) . unwrap( ) . is_match( "PATH" ) ) ;
463+ }
464+
465+ #[ test]
466+ #[ cfg( not( windows) ) ]
467+ fn unix_matching_is_case_sensitive ( ) {
468+ let g = EnvGlob :: new ( "VITE_*" ) . unwrap ( ) ;
469+ assert ! ( g. is_match( "VITE_FOO" ) ) ;
470+ assert ! ( !g. is_match( "vite_foo" ) ) ;
471+ }
472+
473+ #[ test]
474+ #[ cfg( windows) ]
475+ fn windows_matching_is_case_insensitive ( ) {
476+ let g = EnvGlob :: new ( "VITE_*" ) . unwrap ( ) ;
477+ assert ! ( g. is_match( "VITE_FOO" ) ) ;
478+ assert ! ( g. is_match( "vite_foo" ) ) ;
479+ }
480+ }
0 commit comments