Skip to content

Commit 5b8df85

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** (Turbo-style): a `!`-prefixed pattern excludes, a name matches when it matches an include and no exclude (e.g. `["VITE_*", "!VITE_SECRET"]`), and `\!` escapes a literal leading `!`. 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 5b8df85

11 files changed

Lines changed: 670 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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
and `\!` escapes a literal leading `!`.
16+
- **`path`** — filesystem **path** matching with gitignore semantics, backed by
17+
`wax`. `!`-prefixed patterns negate; first-match-wins, or last-match-wins once
18+
any negation is present. Use `PathGlobSet`.
19+
20+
Keeping both behind one crate means a change to how, say, env names are matched
21+
happens in exactly one place and applies everywhere — the runner's cache
22+
fingerprinting, the IPC server's `getEnvs`, workspace package discovery, and so
23+
on.

crates/vite_glob/src/env.rs

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

crates/vite_glob/src/error.rs

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

0 commit comments

Comments
 (0)