Skip to content

Commit 658c18f

Browse files
Boshenclaude
andcommitted
feat: implement tsconfig include/exclude/files matching
Implements support for TypeScript's include, exclude, and files fields in tsconfig.json, enabling proper file filtering for tools like Vite and Rolldown. Key features: - Pattern matching with glob support (**, *, ?, character sets) - Default values (include: ["**/*"], exclude: node_modules/bower_components/jspm_packages) - Files array priority (overrides exclude) - Template variable substitution (${configDir}) - outDir auto-exclusion - Special handling for empty files + no include Implementation aligns with vite-tsconfig-paths and TypeScript compiler behavior. Closes #764 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6a05166 commit 658c18f

File tree

4 files changed

+404
-2
lines changed

4 files changed

+404
-2
lines changed

Cargo.lock

Lines changed: 16 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
@@ -78,6 +78,7 @@ name = "resolver"
7878

7979
[dependencies]
8080
cfg-if = "1"
81+
fast-glob = "1.0.0"
8182
indexmap = { version = "2", features = ["serde"] }
8283
json-strip-comments = "3"
8384
once_cell = "1" # Use `std::sync::OnceLock::get_or_try_init` when it is stable.

src/tsconfig/file_matcher.rs

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
//! File matching logic for tsconfig include/exclude/files patterns.
2+
//!
3+
//! Based on vite-tsconfig-paths implementation:
4+
//! <https://github.com/aleclarson/vite-tsconfig-paths>
5+
6+
use std::path::{Path, PathBuf};
7+
8+
/// Matches files against tsconfig include/exclude/files patterns.
9+
///
10+
/// Implements the matching logic from vite-tsconfig-paths which uses globrex
11+
/// for pattern compilation. This implementation uses fast-glob instead.
12+
///
13+
/// ## Matching Rules
14+
///
15+
/// 1. **Files priority**: If a file is in the `files` array, it's included regardless of exclude patterns
16+
/// 2. **Include matching**: File must match at least one include pattern
17+
/// 3. **Exclude filtering**: File must NOT match any exclude pattern
18+
///
19+
/// ## Default Values
20+
///
21+
/// - Include: `["**/*"]` if not specified
22+
/// - Exclude: `["node_modules", "bower_components", "jspm_packages"]` + outDir if specified
23+
#[derive(Debug)]
24+
pub struct TsconfigFileMatcher {
25+
/// Explicit files (highest priority, overrides exclude)
26+
files: Option<Vec<String>>,
27+
28+
/// Include patterns (defaults to `["**/*"]`)
29+
include_patterns: Vec<String>,
30+
31+
/// Exclude patterns (defaults to node_modules, bower_components, jspm_packages)
32+
exclude_patterns: Vec<String>,
33+
34+
/// Directory containing tsconfig.json
35+
tsconfig_dir: PathBuf,
36+
}
37+
38+
impl TsconfigFileMatcher {
39+
/// Create a matcher that matches nothing (for empty files + no include case).
40+
///
41+
/// Per vite-tsconfig-paths: when `files` is explicitly empty AND `include` is
42+
/// missing or empty, the tsconfig should not match any files.
43+
#[must_use]
44+
pub fn empty() -> Self {
45+
Self {
46+
files: None,
47+
include_patterns: Vec::new(),
48+
exclude_patterns: Vec::new(),
49+
tsconfig_dir: PathBuf::new(),
50+
}
51+
}
52+
53+
/// Create matcher from tsconfig fields.
54+
///
55+
/// # Arguments
56+
///
57+
/// * `files` - Explicit files array from tsconfig
58+
/// * `include` - Include patterns from tsconfig
59+
/// * `exclude` - Exclude patterns from tsconfig
60+
/// * `out_dir` - CompilerOptions.outDir (automatically added to exclude)
61+
/// * `tsconfig_dir` - Directory containing tsconfig.json
62+
#[must_use]
63+
pub fn new(
64+
files: Option<Vec<String>>,
65+
include: Option<Vec<String>>,
66+
exclude: Option<Vec<String>>,
67+
out_dir: Option<&Path>,
68+
tsconfig_dir: PathBuf,
69+
) -> Self {
70+
// Default include: **/* (only if include not specified AND files not specified)
71+
// If files is specified without include, don't default include
72+
let include_patterns = include.unwrap_or_else(|| {
73+
if files.is_some() {
74+
Vec::new() // No default include when files is specified
75+
} else {
76+
vec!["**/*".to_string()]
77+
}
78+
});
79+
80+
// Start with default excludes
81+
let mut exclude_patterns = vec![
82+
"node_modules".to_string(),
83+
"bower_components".to_string(),
84+
"jspm_packages".to_string(),
85+
];
86+
87+
// Merge user-specified excludes with defaults
88+
if let Some(user_excludes) = exclude {
89+
exclude_patterns.extend(user_excludes);
90+
}
91+
92+
// Add outDir to exclude if specified
93+
if let Some(out_dir) = out_dir {
94+
if let Some(out_dir_str) = out_dir.to_str() {
95+
exclude_patterns.push(out_dir_str.to_string());
96+
}
97+
}
98+
99+
Self {
100+
files,
101+
include_patterns: Self::normalize_patterns(include_patterns, &tsconfig_dir),
102+
exclude_patterns: Self::normalize_patterns(exclude_patterns, &tsconfig_dir),
103+
tsconfig_dir,
104+
}
105+
}
106+
107+
/// Normalize patterns per vite-tsconfig-paths logic.
108+
///
109+
/// Rules:
110+
/// 1. Convert absolute paths to relative from tsconfig_dir
111+
/// 2. Ensure patterns start with ./
112+
/// 3. Expand non-glob patterns: "foo" → `["./foo/**"]`
113+
/// 4. File-like patterns: "foo.ts" → `["./foo.ts", "./foo.ts/**"]`
114+
fn normalize_patterns(patterns: Vec<String>, tsconfig_dir: &Path) -> Vec<String> {
115+
patterns
116+
.into_iter()
117+
.flat_map(|pattern| {
118+
// Convert absolute to relative
119+
#[allow(clippy::option_if_let_else)] // map_or would cause borrow checker issues
120+
let pattern = if Path::new(&pattern).is_absolute() {
121+
match Path::new(&pattern).strip_prefix(tsconfig_dir) {
122+
Ok(rel) => rel.to_string_lossy().to_string(),
123+
Err(_) => pattern,
124+
}
125+
} else {
126+
pattern
127+
};
128+
129+
// Ensure starts with ./
130+
let pattern = if pattern.starts_with("./") || pattern.starts_with("../") {
131+
pattern
132+
} else {
133+
format!("./{pattern}")
134+
};
135+
136+
// Handle non-glob patterns
137+
let ends_with_glob = pattern
138+
.split('/')
139+
.next_back()
140+
.is_some_and(|part| part.contains('*') || part.contains('?'));
141+
142+
if ends_with_glob {
143+
// Pattern already has wildcards, use as-is
144+
vec![pattern]
145+
} else {
146+
// Non-glob pattern: expand to match directory
147+
// Strip trailing slash before adding /**
148+
let pattern_base = pattern.trim_end_matches('/');
149+
let mut patterns = Vec::new();
150+
151+
// If looks like a file (has extension after last slash), also match exact
152+
if pattern.rsplit('/').next().is_some_and(|part| part.contains('.') && part != "." && part != "..") {
153+
patterns.push(pattern.clone());
154+
}
155+
156+
patterns.push(format!("{pattern_base}/**"));
157+
patterns
158+
}
159+
})
160+
.collect()
161+
}
162+
163+
/// Test if a file matches this tsconfig's patterns.
164+
///
165+
/// # Returns
166+
///
167+
/// `true` if the file matches, `false` otherwise.
168+
///
169+
/// # Algorithm
170+
///
171+
/// 1. Normalize the file path (relative to tsconfig_dir with ./ prefix)
172+
/// 2. Check files array first (highest priority, overrides exclude)
173+
/// 3. Check if path matches any include pattern
174+
/// 4. Check if path matches any exclude pattern
175+
#[must_use]
176+
pub fn matches(&self, file_path: &Path) -> bool {
177+
// Normalize path for matching
178+
#[allow(clippy::manual_let_else)] // Match is clearer here
179+
let normalized = match self.normalize_path(file_path) {
180+
Some(p) => p,
181+
None => return false, // Path can't be normalized
182+
};
183+
184+
// 1. Check files array first (absolute priority)
185+
if let Some(files) = &self.files {
186+
for file in files {
187+
// Check both exact match and ends_with
188+
if normalized == *file || normalized.ends_with(file) {
189+
return true; // Files overrides exclude
190+
}
191+
}
192+
// If files specified but no match, continue to include/exclude
193+
// (unless include is empty)
194+
if self.include_patterns.is_empty() {
195+
return false;
196+
}
197+
}
198+
199+
// 2. Check if empty patterns (match nothing case)
200+
if self.include_patterns.is_empty() {
201+
return false;
202+
}
203+
204+
// 3. Test against include patterns
205+
let mut included = false;
206+
for pattern in &self.include_patterns {
207+
if fast_glob::glob_match(pattern, &normalized) {
208+
included = true;
209+
break;
210+
}
211+
}
212+
213+
if !included {
214+
return false;
215+
}
216+
217+
// 4. Test against exclude patterns
218+
for pattern in &self.exclude_patterns {
219+
if fast_glob::glob_match(pattern, &normalized) {
220+
return false;
221+
}
222+
}
223+
224+
true
225+
}
226+
227+
/// Normalize file path for matching.
228+
///
229+
/// Rules (from vite-tsconfig-paths):
230+
/// 1. Remove query parameters (e.g., `?inline`)
231+
/// 2. Convert to absolute if relative
232+
/// 3. Make relative to tsconfig_dir
233+
/// 4. Use forward slashes for cross-platform consistency
234+
/// 5. Prepend ./ if needed
235+
///
236+
/// # Returns
237+
///
238+
/// `None` if path is outside tsconfig_dir or can't be normalized.
239+
fn normalize_path(&self, file_path: &Path) -> Option<String> {
240+
// Remove query parameters
241+
let path_str = file_path.to_str()?;
242+
let path_str = path_str.split('?').next()?;
243+
let file_path = Path::new(path_str);
244+
245+
// Make absolute if relative
246+
let absolute = if file_path.is_absolute() {
247+
file_path.to_path_buf()
248+
} else {
249+
std::env::current_dir().ok()?.join(file_path)
250+
};
251+
252+
// Make relative to tsconfig directory
253+
let relative = absolute.strip_prefix(&self.tsconfig_dir).ok()?;
254+
255+
// Convert to string with forward slashes
256+
let mut normalized = relative.to_str()?.replace('\\', "/");
257+
258+
// Ensure starts with ./
259+
if !normalized.starts_with("./") && !normalized.starts_with("../") {
260+
normalized = format!("./{normalized}");
261+
}
262+
263+
Some(normalized)
264+
}
265+
}
266+
267+
#[cfg(test)]
268+
mod tests {
269+
use super::*;
270+
271+
#[test]
272+
fn test_empty_matcher() {
273+
let matcher = TsconfigFileMatcher::empty();
274+
let path = PathBuf::from("index.ts");
275+
assert!(!matcher.matches(&path));
276+
}
277+
278+
#[test]
279+
fn test_normalize_patterns() {
280+
let tsconfig_dir = PathBuf::from("/project");
281+
let patterns = vec![
282+
"src/**/*.ts".to_string(),
283+
"lib".to_string(),
284+
"file.ts".to_string(),
285+
];
286+
287+
let normalized = TsconfigFileMatcher::normalize_patterns(patterns, &tsconfig_dir);
288+
289+
assert_eq!(normalized, vec![
290+
"./src/**/*.ts",
291+
"./lib/**",
292+
"./file.ts",
293+
"./file.ts/**",
294+
]);
295+
}
296+
}

0 commit comments

Comments
 (0)