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) || (options.allow_js && is_js_file(&path)) {
54 files.insert(path);
55 }
56 }
57
58 let include_patterns = build_include_patterns(options);
59 if !include_patterns.is_empty() {
60 let include_set =
61 build_globset(&include_patterns).context("failed to build include globset")?;
62 let exclude_patterns = build_exclude_patterns(options);
63 let exclude_set = if exclude_patterns.is_empty() {
64 None
65 } else {
66 Some(build_globset(&exclude_patterns).context("failed to build exclude globset")?)
67 };
68
69 let walker = WalkDir::new(&options.base_dir)
70 .follow_links(options.follow_links)
71 .into_iter()
72 .filter_entry(|entry| allow_entry(entry, &options.base_dir, exclude_set.as_ref()));
73
74 for entry in walker {
75 let entry = entry.context("failed to read directory entry")?;
76 if !entry.file_type().is_file() {
77 continue;
78 }
79
80 let path = entry.path();
81 if !(is_ts_file(path) || (options.allow_js && is_js_file(path))) {
82 continue;
83 }
84
85 let rel_path = path.strip_prefix(&options.base_dir).unwrap_or(path);
86 if !include_set.is_match(rel_path) {
87 continue;
88 }
89
90 if let Some(exclude) = exclude_set.as_ref()
91 && exclude.is_match(rel_path)
92 {
93 continue;
94 }
95
96 let resolved = if options.follow_links {
100 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
101 } else {
102 path.to_path_buf()
103 };
104 files.insert(resolved);
105 }
106 }
107
108 let mut list: Vec<PathBuf> = files.into_iter().collect();
109 list.sort_by(|a, b| a.to_string_lossy().cmp(&b.to_string_lossy()));
110 Ok(list)
111}
112
113fn build_include_patterns(options: &FileDiscoveryOptions) -> Vec<String> {
114 match options.include.as_ref() {
115 Some(patterns) if patterns.is_empty() => Vec::new(),
116 Some(patterns) => expand_include_patterns(&normalize_patterns(patterns)),
117 None => {
118 if options.files.is_empty() {
119 vec!["**/*".to_string()]
120 } else {
121 Vec::new()
122 }
123 }
124 }
125}
126
127fn expand_include_patterns(patterns: &[String]) -> Vec<String> {
134 let mut expanded = Vec::new();
135 for pattern in patterns {
136 if pattern.ends_with(".ts")
138 || pattern.ends_with(".tsx")
139 || pattern.ends_with(".js")
140 || pattern.ends_with(".jsx")
141 || pattern.ends_with(".mts")
142 || pattern.ends_with(".cts")
143 {
144 expanded.push(pattern.clone());
145 continue;
146 }
147
148 if pattern.ends_with("/**/*") || pattern.ends_with("/**/*.*") {
150 expanded.push(pattern.clone());
151 continue;
152 }
153
154 let base = pattern.trim_end_matches('/');
156 expanded.push(format!("{base}/**/*"));
157 }
158 expanded
159}
160
161fn build_exclude_patterns(options: &FileDiscoveryOptions) -> Vec<String> {
162 let mut patterns = match options.exclude.as_ref() {
163 Some(patterns) => normalize_patterns(patterns),
164 None => normalize_patterns(
165 &DEFAULT_EXCLUDES
166 .iter()
167 .map(std::string::ToString::to_string)
168 .collect::<Vec<_>>(),
169 ),
170 };
171
172 if options.exclude.is_none()
173 && let Some(out_dir) = options.out_dir.as_ref()
174 && let Some(out_pattern) = path_to_pattern(&options.base_dir, out_dir)
175 {
176 patterns.push(out_pattern);
177 }
178
179 expand_exclude_patterns(&patterns)
180}
181
182fn normalize_patterns(patterns: &[String]) -> Vec<String> {
183 patterns
184 .iter()
185 .filter_map(|pattern| {
186 let trimmed = pattern.trim();
187 if trimmed.is_empty() {
188 return None;
189 }
190 let normalized = trimmed.replace('\\', "/");
193 let stripped = normalized.strip_prefix("./").unwrap_or(&normalized);
194 Some(stripped.to_string())
195 })
196 .collect()
197}
198
199fn expand_exclude_patterns(patterns: &[String]) -> Vec<String> {
200 let mut expanded = Vec::new();
201 for pattern in patterns {
202 expanded.push(pattern.clone());
203 if !contains_glob_meta(pattern) && !pattern.ends_with("/**") {
204 expanded.push(format!("{}/**", pattern.trim_end_matches('/')));
205 }
206 }
207 expanded
208}
209
210fn contains_glob_meta(pattern: &str) -> bool {
211 pattern.contains('*') || pattern.contains('?') || pattern.contains('[') || pattern.contains(']')
212}
213
214fn build_globset(patterns: &[String]) -> Result<GlobSet> {
215 let mut builder = GlobSetBuilder::new();
216 for pattern in patterns {
217 let glob =
218 Glob::new(pattern).with_context(|| format!("invalid glob pattern '{pattern}'"))?;
219 builder.add(glob);
220 }
221
222 Ok(builder.build()?)
223}
224
225fn allow_entry(entry: &DirEntry, base_dir: &Path, exclude: Option<&GlobSet>) -> bool {
226 let Some(exclude) = exclude else {
227 return true;
228 };
229
230 let path = entry.path();
231 if path == base_dir {
232 return true;
233 }
234
235 let rel_path = match path.strip_prefix(base_dir) {
237 Ok(stripped) => stripped,
238 Err(_) => {
239 return !exclude.is_match(path);
241 }
242 };
243 !exclude.is_match(rel_path)
244}
245
246fn resolve_file_path(base_dir: &Path, file: &Path) -> PathBuf {
247 if file.is_absolute() {
248 file.to_path_buf()
249 } else {
250 base_dir.join(file)
251 }
252}
253
254fn ensure_file_exists(path: &Path) -> Result<()> {
255 if !path.exists() {
256 bail!("file not found: {}", path.display());
257 }
258
259 if !path.is_file() {
260 bail!("path is not a file: {}", path.display());
261 }
262
263 Ok(())
264}
265
266pub(crate) fn is_js_file(path: &Path) -> bool {
267 matches!(
268 path.extension().and_then(|ext| ext.to_str()),
269 Some("js") | Some("jsx") | Some("mjs") | Some("cjs")
270 )
271}
272
273pub(crate) fn is_ts_file(path: &Path) -> bool {
274 let name = match path.file_name().and_then(|name| name.to_str()) {
275 Some(name) => name,
276 None => return false,
277 };
278
279 if name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts") {
280 return true;
281 }
282
283 matches!(
284 path.extension().and_then(|ext| ext.to_str()),
285 Some("ts") | Some("tsx") | Some("mts") | Some("cts")
286 )
287}
288
289pub(crate) fn is_valid_module_file(path: &Path) -> bool {
292 let name = match path.file_name().and_then(|name| name.to_str()) {
293 Some(name) => name,
294 None => return false,
295 };
296
297 if name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts") {
298 return true;
299 }
300
301 matches!(
302 path.extension().and_then(|ext| ext.to_str()),
303 Some("ts") | Some("tsx") | Some("mts") | Some("cts") | Some("json")
304 )
305}
306
307fn path_to_pattern(base_dir: &Path, path: &Path) -> Option<String> {
308 let rel = if path.is_absolute() {
309 path.strip_prefix(base_dir).ok()?.to_path_buf()
310 } else {
311 path.to_path_buf()
312 };
313 let value = rel.to_string_lossy().replace('\\', "/");
314 if value.is_empty() { None } else { Some(value) }
315}