1use anyhow::{Context, Result, bail};
2use globset::{Glob, GlobSet, GlobSetBuilder};
3use std::collections::BTreeSet;
4use std::path::{Path, PathBuf};
5use walkdir::{DirEntry, WalkDir};
6
7use crate::config::TsConfig;
8
9pub(crate) const DEFAULT_EXCLUDES: [&str; 3] =
10 ["node_modules", "bower_components", "jspm_packages"];
11
12#[derive(Debug, Clone)]
13pub struct FileDiscoveryOptions {
14 pub base_dir: PathBuf,
15 pub files: Vec<PathBuf>,
16 pub include: Option<Vec<String>>,
17 pub exclude: Option<Vec<String>>,
18 pub out_dir: Option<PathBuf>,
19 pub follow_links: bool,
20 pub allow_js: bool,
21}
22
23impl FileDiscoveryOptions {
24 pub fn from_tsconfig(config_path: &Path, config: &TsConfig, out_dir: Option<&Path>) -> Self {
25 let base_dir = config_path
26 .parent()
27 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
28
29 let files = config
30 .files
31 .as_ref()
32 .map(|list| list.iter().map(PathBuf::from).collect())
33 .unwrap_or_default();
34
35 Self {
36 base_dir,
37 files,
38 include: config.include.clone(),
39 exclude: config.exclude.clone(),
40 out_dir: out_dir.map(Path::to_path_buf),
41 follow_links: false,
42 allow_js: false,
43 }
44 }
45}
46
47pub fn discover_ts_files(options: &FileDiscoveryOptions) -> Result<Vec<PathBuf>> {
48 let mut files = BTreeSet::new();
49
50 for file in &options.files {
51 let path = resolve_file_path(&options.base_dir, file);
52 ensure_file_exists(&path)?;
53 if is_ts_file(&path) || is_js_file(&path) {
58 files.insert(path);
59 }
60 }
61
62 let include_patterns = build_include_patterns(options);
63 if !include_patterns.is_empty() {
64 let include_set =
65 build_globset(&include_patterns).context("failed to build include globset")?;
66 let exclude_patterns = build_exclude_patterns(options);
67 let exclude_set = if exclude_patterns.is_empty() {
68 None
69 } else {
70 Some(build_globset(&exclude_patterns).context("failed to build exclude globset")?)
71 };
72
73 let walker = WalkDir::new(&options.base_dir)
74 .follow_links(options.follow_links)
75 .into_iter()
76 .filter_entry(|entry| allow_entry(entry, &options.base_dir, exclude_set.as_ref()));
77
78 for entry in walker {
79 let entry = entry.context("failed to read directory entry")?;
80 if !entry.file_type().is_file() {
81 continue;
82 }
83
84 let path = entry.path();
85 if !(is_ts_file(path) || (options.allow_js && is_js_file(path))) {
86 continue;
87 }
88
89 let rel_path = path.strip_prefix(&options.base_dir).unwrap_or(path);
90 if !include_set.is_match(rel_path) {
91 continue;
92 }
93
94 if let Some(exclude) = exclude_set.as_ref()
95 && exclude.is_match(rel_path)
96 {
97 continue;
98 }
99
100 let resolved = if options.follow_links {
104 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
105 } else {
106 path.to_path_buf()
107 };
108 files.insert(resolved);
109 }
110 }
111
112 let mut list: Vec<PathBuf> = files.into_iter().collect();
113 list.sort_by(|a, b| a.to_string_lossy().cmp(&b.to_string_lossy()));
114 Ok(list)
115}
116
117fn build_include_patterns(options: &FileDiscoveryOptions) -> Vec<String> {
118 match options.include.as_ref() {
119 Some(patterns) if patterns.is_empty() => Vec::new(),
120 Some(patterns) => expand_include_patterns(&normalize_patterns(patterns)),
121 None => {
122 if options.files.is_empty() {
123 vec!["**/*".to_string()]
124 } else {
125 Vec::new()
126 }
127 }
128 }
129}
130
131fn expand_include_patterns(patterns: &[String]) -> Vec<String> {
138 let mut expanded = Vec::new();
139 for pattern in patterns {
140 if pattern.ends_with(".ts")
142 || pattern.ends_with(".tsx")
143 || pattern.ends_with(".js")
144 || pattern.ends_with(".jsx")
145 || pattern.ends_with(".mts")
146 || pattern.ends_with(".cts")
147 {
148 expanded.push(pattern.clone());
149 continue;
150 }
151
152 if pattern.ends_with("/**/*") || pattern.ends_with("/**/*.*") {
154 expanded.push(pattern.clone());
155 continue;
156 }
157
158 let base = pattern.trim_end_matches('/');
160 expanded.push(format!("{base}/**/*"));
161 }
162 expanded
163}
164
165fn build_exclude_patterns(options: &FileDiscoveryOptions) -> Vec<String> {
166 let mut patterns = match options.exclude.as_ref() {
167 Some(patterns) => normalize_patterns(patterns),
168 None => normalize_patterns(
169 &DEFAULT_EXCLUDES
170 .iter()
171 .map(std::string::ToString::to_string)
172 .collect::<Vec<_>>(),
173 ),
174 };
175
176 if options.exclude.is_none()
177 && let Some(out_dir) = options.out_dir.as_ref()
178 && let Some(out_pattern) = path_to_pattern(&options.base_dir, out_dir)
179 {
180 patterns.push(out_pattern);
181 }
182
183 expand_exclude_patterns(&patterns)
184}
185
186fn normalize_patterns(patterns: &[String]) -> Vec<String> {
187 patterns
188 .iter()
189 .filter_map(|pattern| {
190 let trimmed = pattern.trim();
191 if trimmed.is_empty() {
192 return None;
193 }
194 let normalized = trimmed.replace('\\', "/");
197 let stripped = normalized.strip_prefix("./").unwrap_or(&normalized);
198 Some(stripped.to_string())
199 })
200 .collect()
201}
202
203fn expand_exclude_patterns(patterns: &[String]) -> Vec<String> {
204 let mut expanded = Vec::new();
205 for pattern in patterns {
206 expanded.push(pattern.clone());
207 if !contains_glob_meta(pattern) && !pattern.ends_with("/**") {
208 expanded.push(format!("{}/**", pattern.trim_end_matches('/')));
209 }
210 }
211 expanded
212}
213
214fn contains_glob_meta(pattern: &str) -> bool {
215 pattern.contains('*') || pattern.contains('?') || pattern.contains('[') || pattern.contains(']')
216}
217
218fn build_globset(patterns: &[String]) -> Result<GlobSet> {
219 let mut builder = GlobSetBuilder::new();
220 for pattern in patterns {
221 let glob =
222 Glob::new(pattern).with_context(|| format!("invalid glob pattern '{pattern}'"))?;
223 builder.add(glob);
224 }
225
226 Ok(builder.build()?)
227}
228
229fn allow_entry(entry: &DirEntry, base_dir: &Path, exclude: Option<&GlobSet>) -> bool {
230 let Some(exclude) = exclude else {
231 return true;
232 };
233
234 let path = entry.path();
235 if path == base_dir {
236 return true;
237 }
238
239 let rel_path = match path.strip_prefix(base_dir) {
241 Ok(stripped) => stripped,
242 Err(_) => {
243 return !exclude.is_match(path);
245 }
246 };
247 !exclude.is_match(rel_path)
248}
249
250fn resolve_file_path(base_dir: &Path, file: &Path) -> PathBuf {
251 if file.is_absolute() {
252 file.to_path_buf()
253 } else {
254 base_dir.join(file)
255 }
256}
257
258fn ensure_file_exists(path: &Path) -> Result<()> {
259 if !path.exists() {
260 bail!("file not found: {}", path.display());
261 }
262
263 if !path.is_file() {
264 bail!("path is not a file: {}", path.display());
265 }
266
267 Ok(())
268}
269
270pub(crate) fn is_js_file(path: &Path) -> bool {
271 matches!(
272 path.extension().and_then(|ext| ext.to_str()),
273 Some("js") | Some("jsx") | Some("mjs") | Some("cjs")
274 )
275}
276
277pub(crate) fn is_ts_file(path: &Path) -> bool {
278 let name = match path.file_name().and_then(|name| name.to_str()) {
279 Some(name) => name,
280 None => return false,
281 };
282
283 if name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts") {
284 return true;
285 }
286
287 matches!(
288 path.extension().and_then(|ext| ext.to_str()),
289 Some("ts") | Some("tsx") | Some("mts") | Some("cts")
290 )
291}
292
293pub(crate) fn is_valid_module_file(path: &Path) -> bool {
296 let name = match path.file_name().and_then(|name| name.to_str()) {
297 Some(name) => name,
298 None => return false,
299 };
300
301 if name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts") {
302 return true;
303 }
304
305 matches!(
306 path.extension().and_then(|ext| ext.to_str()),
307 Some("ts") | Some("tsx") | Some("mts") | Some("cts") | Some("json")
308 )
309}
310
311fn path_to_pattern(base_dir: &Path, path: &Path) -> Option<String> {
312 let rel = if path.is_absolute() {
313 path.strip_prefix(base_dir).ok()?.to_path_buf()
314 } else {
315 path.to_path_buf()
316 };
317 let value = rel.to_string_lossy().replace('\\', "/");
318 if value.is_empty() { None } else { Some(value) }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use std::fs;
325
326 #[test]
327 fn test_discover_explicitly_listed_js_file_without_allow_js() {
328 let dir = std::env::temp_dir().join("tsz_fs_test_explicit_js");
332 let _ = fs::remove_dir_all(&dir);
333 fs::create_dir_all(&dir).unwrap();
334 fs::write(dir.join("app.ts"), "const x = 1;").unwrap();
335 fs::write(dir.join("lib.js"), "var y = 2;").unwrap();
336
337 let options = FileDiscoveryOptions {
338 base_dir: dir.clone(),
339 files: vec![PathBuf::from("app.ts"), PathBuf::from("lib.js")],
340 include: None,
341 exclude: None,
342 out_dir: None,
343 follow_links: false,
344 allow_js: false, };
346
347 let result = discover_ts_files(&options).unwrap();
348 assert!(
349 result.iter().any(|p| p.ends_with("app.ts")),
350 "explicitly listed .ts file should be included"
351 );
352 assert!(
353 result.iter().any(|p| p.ends_with("lib.js")),
354 "explicitly listed .js file should be included even without allowJs"
355 );
356
357 let _ = fs::remove_dir_all(&dir);
358 }
359
360 #[test]
361 fn test_discover_pattern_matched_js_file_requires_allow_js() {
362 let dir = std::env::temp_dir().join("tsz_fs_test_pattern_js");
365 let _ = fs::remove_dir_all(&dir);
366 fs::create_dir_all(dir.join("src")).unwrap();
367 fs::write(dir.join("src/app.ts"), "const x = 1;").unwrap();
368 fs::write(dir.join("src/lib.js"), "var y = 2;").unwrap();
369
370 let options = FileDiscoveryOptions {
372 base_dir: dir.clone(),
373 files: vec![],
374 include: Some(vec!["src".to_string()]),
375 exclude: None,
376 out_dir: None,
377 follow_links: false,
378 allow_js: false,
379 };
380
381 let result = discover_ts_files(&options).unwrap();
382 assert!(
383 result.iter().any(|p| p.ends_with("app.ts")),
384 ".ts file should be included from pattern"
385 );
386 assert!(
387 !result.iter().any(|p| p.ends_with("lib.js")),
388 ".js file should NOT be included from pattern without allowJs"
389 );
390
391 let options_with_js = FileDiscoveryOptions {
393 base_dir: dir.clone(),
394 files: vec![],
395 include: Some(vec!["src".to_string()]),
396 exclude: None,
397 out_dir: None,
398 follow_links: false,
399 allow_js: true,
400 };
401
402 let result_with_js = discover_ts_files(&options_with_js).unwrap();
403 assert!(
404 result_with_js.iter().any(|p| p.ends_with("lib.js")),
405 ".js file should be included from pattern with allowJs"
406 );
407
408 let _ = fs::remove_dir_all(&dir);
409 }
410}