Skip to main content

tsz_cli/
fs.rs

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            // Avoid canonicalizing unless following links; canonicalizing can change
97            // the base prefix (e.g., /var -> /private/var on macOS) which breaks
98            // relative path expectations in the CLI.
99            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
127/// Expand include patterns to match files in directories.
128///
129/// TypeScript's include patterns work as follows:
130/// - `src` matches `src/` directory and expands to `src/**/*`
131/// - `src/*` matches files directly in src, but for directories, adds `/**/*`
132/// - Patterns with extensions (e.g., `*.ts`) are used as-is
133fn expand_include_patterns(patterns: &[String]) -> Vec<String> {
134    let mut expanded = Vec::new();
135    for pattern in patterns {
136        // If pattern already has glob metacharacters with extensions, use as-is
137        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 /**/* or /**/*.*, it's already expanded
149        if pattern.ends_with("/**/*") || pattern.ends_with("/**/*.*") {
150            expanded.push(pattern.clone());
151            continue;
152        }
153
154        // Directory pattern (no extension or glob at end) - expand to match all files
155        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            // Normalize path separators and strip leading "./" prefix
191            // TypeScript treats "./**/*.ts" the same as "**/*.ts"
192            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    // Use safe path handling instead of unwrap_or for panic hardening
236    let rel_path = match path.strip_prefix(base_dir) {
237        Ok(stripped) => stripped,
238        Err(_) => {
239            // If path is not under base_dir, use the path itself for matching
240            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
289/// Check if a path is a valid module file for module resolution purposes.
290/// This includes TypeScript files AND .json files (which can be imported with resolveJsonModule).
291pub(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}