Skip to content

Commit 8d4a149

Browse files
wan9chiclaude
andcommitted
feat(glob): split vite_glob into env/path modules; env globs support negation
`vite_glob` wrapped `wax`, a filesystem path-glob engine, but most callers used it to match environment-variable *names* — flat strings, 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. Reorganize the crate into two modules, each with its own matchers and error type: - `vite_glob::env` — `EnvGlob` (one literal pattern) and `EnvGlobSet` (a set, backed by `globset`) with `literal_separator(false)` and `case_insensitive(cfg!(windows))`, so `*`/`?`/`[...]`/`{a,b}` are pure flat-string wildcards with the platform's env case rules. `EnvGlobSet` supports **negation**: a `!`-prefixed pattern excludes, and a name matches when it matches an include and no exclude (e.g. `["VITE_*", "!VITE_SECRET"]`). Errors: `EnvGlobError`. - `vite_glob::path` — `PathGlobSet` (the former `GlobPatternSet`, renamed), still `wax`-backed with unchanged gitignore (first/last-match-wins) semantics. Errors: `PathGlobError`. This mirrors how Turborepo splits env (regex) from path (wax) matching, and how Turbo treats `!` in env wildcards. Non-negated 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` uses `EnvGlobSet` for its multi-pattern env matching — so `env: ["VITE_*", "!VITE_SECRET"]` in a task config now excludes `VITE_SECRET` (previously `!` patterns were dropped with a warning) — and `vite_workspace` switches to `PathGlobSet` / `PathGlobError`. The runner-aware env-matching sites consume the single-pattern `EnvGlob` in the stacked PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d365a01 commit 8d4a149

11 files changed

Lines changed: 655 additions & 400 deletions

File tree

Cargo.lock

Lines changed: 14 additions & 1 deletion
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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ publish = false
88
rust-version.workspace = true
99

1010
[dependencies]
11+
globset = { workspace = true }
1112
thiserror = { workspace = true }
12-
vite_path = { workspace = true }
1313
wax = { workspace = true }
1414

1515
[dev-dependencies]

crates/vite_glob/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# vite_glob
2+
3+
Centralizes glob-matching semantics so every crate in the workspace matches
4+
patterns the same way, instead of each call site reaching for an ad-hoc glob
5+
engine with subtly different rules (separators, case sensitivity, negation).
6+
7+
Two use cases, each with its own module, matcher, and error type:
8+
9+
- **`env`** — environment-variable **name** matching. Names are flat strings,
10+
not paths, so this is backed by `globset` with path-separator handling
11+
disabled: `*`/`?`/`[...]`/`{a,b}` are plain-string wildcards, and matching is
12+
case-sensitive on Unix and case-insensitive on Windows (mirroring env lookup).
13+
Use `EnvGlob` for one literal pattern, or `EnvGlobSet` for a set with
14+
negation: a `!`-prefixed pattern excludes (e.g. `["VITE_*", "!VITE_SECRET"]`).
15+
- **`path`** — filesystem **path** matching with gitignore semantics, backed by
16+
`wax`. `!`-prefixed patterns negate; first-match-wins, or last-match-wins once
17+
any negation is present. Use `PathGlobSet`.
18+
19+
Keeping both behind one crate means a change to how, say, env names are matched
20+
happens in exactly one place and applies everywhere — the runner's cache
21+
fingerprinting, the IPC server's `getEnvs`, workspace package discovery, and so
22+
on.

crates/vite_glob/src/env.rs

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
//! Glob matching for environment-variable **names** (flat strings, never paths).
2+
//!
3+
//! Backed by `globset` with path-separator handling disabled, so `*`, `?`,
4+
//! `[...]`, and `{a,b}` behave as plain-string wildcards. Matching is
5+
//! case-sensitive on Unix and case-insensitive on Windows, mirroring how
6+
//! environment variables are looked up on each platform.
7+
//!
8+
//! [`EnvGlobSet`] supports negation: a `!`-prefixed pattern *excludes* names,
9+
//! and a name matches the set when it matches an include pattern and no exclude
10+
//! pattern. [`EnvGlob`] matches a single pattern literally — `!` is an ordinary
11+
//! character there (no negation), since a lone exclude has nothing to subtract
12+
//! from.
13+
14+
use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
15+
16+
/// Error compiling an environment-variable name pattern.
17+
#[derive(Debug, thiserror::Error)]
18+
#[error(transparent)]
19+
pub struct EnvGlobError(#[from] globset::Error);
20+
21+
/// Compiles `pattern` into a `globset::Glob` configured for env-name matching:
22+
/// separators are not special, and case follows the platform's env semantics.
23+
fn build(pattern: &str) -> Result<Glob, globset::Error> {
24+
GlobBuilder::new(pattern)
25+
// Env names contain no path separators, so disabling separator handling
26+
// makes `*`/`?` match any character — a pure string match.
27+
.literal_separator(false)
28+
// Env lookups are case-insensitive on Windows, case-sensitive elsewhere.
29+
.case_insensitive(cfg!(windows))
30+
.build()
31+
}
32+
33+
/// Matches a single environment-variable name against one glob pattern.
34+
#[derive(Debug, Clone)]
35+
pub struct EnvGlob {
36+
matcher: GlobMatcher,
37+
}
38+
39+
impl EnvGlob {
40+
/// Compiles `pattern` into an env-name matcher.
41+
///
42+
/// # Errors
43+
/// Returns an error if `pattern` is not a valid glob.
44+
pub fn new(pattern: &str) -> Result<Self, EnvGlobError> {
45+
Ok(Self { matcher: build(pattern)?.compile_matcher() })
46+
}
47+
48+
/// Returns whether `name` matches the pattern.
49+
#[must_use]
50+
pub fn is_match(&self, name: &str) -> bool {
51+
self.matcher.is_match(name)
52+
}
53+
}
54+
55+
/// Matches an environment-variable name against a **set** of glob patterns,
56+
/// with negation.
57+
///
58+
/// Patterns are split into includes and excludes: a `!`-prefixed pattern is an
59+
/// **exclude**, any other pattern is an **include**.
60+
///
61+
/// A name matches when it matches some include pattern and no exclude pattern.
62+
/// A set with no include patterns matches nothing (an exclude has nothing to
63+
/// subtract from), so an empty set — or a set of only excludes — never matches.
64+
#[derive(Debug, Clone)]
65+
pub struct EnvGlobSet {
66+
include: GlobSet,
67+
exclude: GlobSet,
68+
}
69+
70+
impl EnvGlobSet {
71+
/// Compiles `patterns` into a combined env-name matcher.
72+
///
73+
/// # Errors
74+
/// Returns an error if any pattern is not a valid glob.
75+
pub fn new<I, S>(patterns: I) -> Result<Self, EnvGlobError>
76+
where
77+
I: IntoIterator<Item = S>,
78+
S: AsRef<str>,
79+
{
80+
let mut include = GlobSetBuilder::new();
81+
let mut exclude = GlobSetBuilder::new();
82+
for pattern in patterns {
83+
let pattern = pattern.as_ref();
84+
if let Some(rest) = pattern.strip_prefix('!') {
85+
exclude.add(build(rest)?);
86+
} else {
87+
include.add(build(pattern)?);
88+
}
89+
}
90+
Ok(Self { include: include.build()?, exclude: exclude.build()? })
91+
}
92+
93+
/// Returns whether `name` matches an include pattern and no exclude pattern.
94+
#[must_use]
95+
pub fn is_match(&self, name: &str) -> bool {
96+
self.include.is_match(name) && !self.exclude.is_match(name)
97+
}
98+
}
99+
100+
#[cfg(test)]
101+
mod tests {
102+
use super::{EnvGlob, EnvGlobSet};
103+
104+
#[test]
105+
fn matches_star_prefix_and_suffix() {
106+
let g = EnvGlob::new("VITE_*").unwrap();
107+
assert!(g.is_match("VITE_FOO"));
108+
assert!(g.is_match("VITE_")); // `*` matches the empty string
109+
assert!(!g.is_match("MYVITE_FOO"));
110+
111+
let g = EnvGlob::new("*_KEY").unwrap();
112+
assert!(g.is_match("MY_KEY"));
113+
assert!(!g.is_match("MY_KEYS"));
114+
115+
let g = EnvGlob::new("*_CREDENTIAL*").unwrap();
116+
assert!(g.is_match("AWS_CREDENTIALS"));
117+
assert!(g.is_match("X_CREDENTIAL_Y"));
118+
}
119+
120+
#[test]
121+
fn question_mark_matches_exactly_one_char() {
122+
let g = EnvGlob::new("APP?_*").unwrap();
123+
assert!(g.is_match("APP1_TOKEN"));
124+
assert!(g.is_match("APP2_NAME"));
125+
// `?` requires exactly one character, so `APP_X` (nothing before `_`) does not match.
126+
assert!(!g.is_match("APP_X"));
127+
}
128+
129+
#[test]
130+
fn brace_alternation_is_supported() {
131+
let g = EnvGlob::new("{VITE,NEXT}_*").unwrap();
132+
assert!(g.is_match("VITE_FOO"));
133+
assert!(g.is_match("NEXT_BAR"));
134+
assert!(!g.is_match("NUXT_BAR"));
135+
}
136+
137+
#[test]
138+
fn dot_and_separators_are_literal_not_path_special() {
139+
// Env names are flat strings: `*` spans `.` and `/` (no path semantics),
140+
// and a literal `.` in the pattern matches a literal `.`.
141+
assert!(EnvGlob::new("A*").unwrap().is_match("A.B"));
142+
assert!(EnvGlob::new("A*").unwrap().is_match("A/B"));
143+
assert!(EnvGlob::new("*.local").unwrap().is_match("APP.local"));
144+
assert!(!EnvGlob::new("*.local").unwrap().is_match("APPXlocal"));
145+
}
146+
147+
#[test]
148+
fn single_glob_bang_is_a_literal_character() {
149+
// A single `EnvGlob` has no negation: `!FOO` matches the literal name
150+
// `!FOO`, not `FOO`.
151+
let g = EnvGlob::new("!FOO").unwrap();
152+
assert!(g.is_match("!FOO"));
153+
assert!(!g.is_match("FOO"));
154+
}
155+
156+
#[test]
157+
fn non_match_default_is_false() {
158+
assert!(!EnvGlob::new("VITE_*").unwrap().is_match("PATH"));
159+
}
160+
161+
#[test]
162+
fn set_matches_any_pattern() {
163+
let set = EnvGlobSet::new(["VITE_*", "*_KEY", "APP?_*"]).unwrap();
164+
assert!(set.is_match("VITE_FOO"));
165+
assert!(set.is_match("MY_KEY"));
166+
assert!(set.is_match("APP1_TOKEN"));
167+
assert!(!set.is_match("PATH"));
168+
assert!(!set.is_match("APP_X"));
169+
}
170+
171+
#[test]
172+
fn empty_set_matches_nothing() {
173+
let set = EnvGlobSet::new(std::iter::empty::<&str>()).unwrap();
174+
assert!(!set.is_match("VITE_FOO"));
175+
}
176+
177+
#[test]
178+
fn set_negation_excludes_matching_names() {
179+
// `!VITE_SECRET` excludes that name from the `VITE_*` include set.
180+
let set = EnvGlobSet::new(["VITE_*", "!VITE_SECRET"]).unwrap();
181+
assert!(set.is_match("VITE_FOO"));
182+
assert!(set.is_match("VITE_BAR"));
183+
assert!(!set.is_match("VITE_SECRET"));
184+
assert!(!set.is_match("PATH"));
185+
186+
// An exclude glob can itself be a wildcard.
187+
let set = EnvGlobSet::new(["*", "!*_SECRET"]).unwrap();
188+
assert!(set.is_match("VITE_FOO"));
189+
assert!(!set.is_match("API_SECRET"));
190+
}
191+
192+
#[test]
193+
fn set_only_excludes_matches_nothing() {
194+
// With no include patterns there is nothing to subtract from.
195+
let set = EnvGlobSet::new(["!FOO"]).unwrap();
196+
assert!(!set.is_match("FOO"));
197+
assert!(!set.is_match("BAR"));
198+
}
199+
200+
#[test]
201+
#[cfg(not(windows))]
202+
fn unix_matching_is_case_sensitive() {
203+
let g = EnvGlob::new("VITE_*").unwrap();
204+
assert!(g.is_match("VITE_FOO"));
205+
assert!(!g.is_match("vite_foo"));
206+
let set = EnvGlobSet::new(["VITE_*"]).unwrap();
207+
assert!(!set.is_match("vite_foo"));
208+
}
209+
210+
#[test]
211+
#[cfg(windows)]
212+
fn windows_matching_is_case_insensitive() {
213+
let g = EnvGlob::new("VITE_*").unwrap();
214+
assert!(g.is_match("VITE_FOO"));
215+
assert!(g.is_match("vite_foo"));
216+
let set = EnvGlobSet::new(["VITE_*"]).unwrap();
217+
assert!(set.is_match("vite_foo"));
218+
}
219+
}

crates/vite_glob/src/error.rs

Lines changed: 0 additions & 9 deletions
This file was deleted.

0 commit comments

Comments
 (0)