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        // Explicitly listed files (from CLI positional args or tsconfig "files" array)
54        // are always compiled, including .js/.jsx/.mjs/.cjs files, regardless of
55        // the allowJs setting. This matches tsc behavior where allowJs only controls
56        // pattern-matched file discovery (include/exclude), not explicit file lists.
57        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            // Avoid canonicalizing unless following links; canonicalizing can change
101            // the base prefix (e.g., /var -> /private/var on macOS) which breaks
102            // relative path expectations in the CLI.
103            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
131/// Expand include patterns to match files in directories.
132///
133/// TypeScript's include patterns work as follows:
134/// - `src` matches `src/` directory and expands to `src/**/*`
135/// - `src/*` matches files directly in src, but for directories, adds `/**/*`
136/// - Patterns with extensions (e.g., `*.ts`) are used as-is
137fn expand_include_patterns(patterns: &[String]) -> Vec<String> {
138    let mut expanded = Vec::new();
139    for pattern in patterns {
140        // If pattern already has glob metacharacters with extensions, use as-is
141        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 /**/* or /**/*.*, it's already expanded
153        if pattern.ends_with("/**/*") || pattern.ends_with("/**/*.*") {
154            expanded.push(pattern.clone());
155            continue;
156        }
157
158        // Directory pattern (no extension or glob at end) - expand to match all files
159        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            // Normalize path separators and strip leading "./" prefix
195            // TypeScript treats "./**/*.ts" the same as "**/*.ts"
196            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    // Use safe path handling instead of unwrap_or for panic hardening
240    let rel_path = match path.strip_prefix(base_dir) {
241        Ok(stripped) => stripped,
242        Err(_) => {
243            // If path is not under base_dir, use the path itself for matching
244            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
293/// Check if a path is a valid module file for module resolution purposes.
294/// This includes TypeScript files AND .json files (which can be imported with resolveJsonModule).
295pub(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        // Explicitly listed .js files should be included even when allow_js is false.
329        // This matches tsc behavior where CLI positional args and tsconfig "files"
330        // entries are always compiled regardless of the allowJs setting.
331        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, // NOT set, but .js should still be included
345        };
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        // Pattern-matched .js files (from include/exclude) should NOT be included
363        // when allow_js is false. This is the correct tsc behavior.
364        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        // Without allowJs, pattern-matched .js files are excluded
371        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        // With allowJs, pattern-matched .js files are included
392        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}