Skip to content

Commit 1ed138d

Browse files
wan9chiclaude
andcommitted
refactor(glob): back env matching with globset, split EnvGlob / PathGlobSet
`vite_glob` wraps `wax`, a filesystem path-glob engine, but most callers use it to match environment-variable *names* — flat strings that are not paths — by pushing `&str` through `is_match(impl AsRef<Path>)`. That is semantically muddy, and `wax` exposes no option to disable its path semantics (separators, rooting, `**`). Split the crate into two purpose-built matchers: - `EnvGlob` — a single env-name matcher backed by `globset` with `literal_separator(false)` and `case_insensitive(cfg!(windows))`, so `*`/`?`/`[...]`/`{a,b}` behave as pure flat-string wildcards with the platform's env case rules (sensitive on Unix, insensitive on Windows). `!` is an ordinary character — there is no negation. - `PathGlobSet` — the former `GlobPatternSet`, renamed; still `wax`-backed with unchanged gitignore (first/last-match-wins) path semantics. This mirrors how Turborepo splits env (regex) from path (wax) matching. Env results are unchanged: globset matches the same `*`/`?`/`[...]`/`{}` syntax wax did for separator-free names, including SENSITIVE_PATTERNS and the Unix/Windows case split (covered by existing envs.rs tests). Migrate the pre-existing callers: `envs.rs` compiles `Vec<EnvGlob>` and matches with `.any(...)` (dropping the now-unnecessary `!`-filtering and its warning), and `vite_workspace` switches to `PathGlobSet`. The new runner-aware env-matching sites consume `EnvGlob` in the follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d365a01 commit 1ed138d

7 files changed

Lines changed: 170 additions & 41 deletions

File tree

Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ fspy_shared = { path = "crates/fspy_shared" }
8080
fspy_shared_unix = { path = "crates/fspy_shared_unix" }
8181
futures = "0.3.31"
8282
futures-util = "0.3.31"
83+
globset = "0.4.18"
8384
jsonc-parser = { version = "0.32.0", features = ["serde"] }
8485
libc = "0.2.185"
8586
libtest-mimic = "0.8.2"

crates/vite_glob/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ publish = false
88
rust-version.workspace = true
99

1010
[dependencies]
11+
globset = { workspace = true }
1112
thiserror = { workspace = true }
1213
vite_path = { workspace = true }
1314
wax = { workspace = true }

crates/vite_glob/src/error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#[derive(Debug, thiserror::Error)]
22
pub enum Error {
3+
#[error(transparent)]
4+
Globset(#[from] globset::Error),
35
#[error(transparent)]
46
WaxBuild(#[from] wax::BuildError),
57
#[error(transparent)]

crates/vite_glob/src/lib.rs

Lines changed: 132 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,53 @@ use std::path::Path;
66
pub use error::Error;
77
use 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

Comments
 (0)