Skip to main content

blast_radius/
fs.rs

1use std::collections::{BTreeMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use ignore::WalkBuilder;
7use jsonc_parser::{ParseOptions, parse_to_serde_value};
8use serde::Deserialize;
9
10#[derive(Debug, Clone)]
11pub struct RepoContext {
12    pub repo_root: PathBuf,
13    pub source_files: Vec<PathBuf>,
14    pub tsconfigs: Vec<TsConfigPath>,
15    pub package_jsons: Vec<PathBuf>,
16    /// Import-specifier substrings the repo asks not to count as unresolved
17    /// (generated/virtual modules its tooling produces). From `.blast-radius.json`.
18    pub ignore_unresolved: Vec<String>,
19    pub warnings: Vec<String>,
20}
21
22/// Optional per-repo configuration loaded from `.blast-radius.json` at the repo
23/// root. Lets a repo declare tooling-specific quirks the language-neutral core
24/// shouldn't hardcode.
25#[derive(Debug, Default, Deserialize)]
26struct ProjectConfig {
27    #[serde(default)]
28    unresolved: UnresolvedConfig,
29    #[serde(default)]
30    discovery: DiscoveryConfig,
31}
32
33#[derive(Debug, Default, Deserialize)]
34struct UnresolvedConfig {
35    /// Import specifiers whose substring matches are not counted as unresolved.
36    #[serde(default)]
37    ignore: Vec<String>,
38}
39
40#[derive(Debug, Default, Deserialize)]
41struct DiscoveryConfig {
42    /// Repo-relative files or directory prefixes to skip while walking.
43    #[serde(default)]
44    exclude: Vec<String>,
45}
46
47#[derive(Debug, Clone)]
48pub struct TsConfigPath {
49    pub path: PathBuf,
50    pub compiler_options: TsCompilerOptions,
51}
52
53/// Compiler options after following `extends` chains. Directory-relative
54/// fields are resolved against the config file that declared them, so the
55/// merged result is anchored to absolute directories.
56#[derive(Debug, Clone, Default)]
57pub struct TsCompilerOptions {
58    /// Absolute directory `baseUrl` points at, when declared.
59    pub base_dir: Option<PathBuf>,
60    pub paths: BTreeMap<String, Vec<String>>,
61    /// Absolute directory relative `paths` targets resolve against when no
62    /// `baseUrl` is in effect: the directory of the config declaring `paths`.
63    pub paths_dir: Option<PathBuf>,
64}
65
66impl TsCompilerOptions {
67    pub fn has_aliases(&self) -> bool {
68        self.base_dir.is_some() || !self.paths.is_empty()
69    }
70}
71
72#[derive(Debug, Default, Deserialize)]
73struct TsConfigFile {
74    #[serde(default)]
75    extends: Option<serde_json::Value>,
76    #[serde(default, rename = "compilerOptions")]
77    compiler_options: RawCompilerOptions,
78    /// Project references (`"references": [{ "path": "../lib" }]`). Followed so
79    /// referenced configs with non-standard names (tsconfig.lib.json) still
80    /// contribute their aliases; directory references resolve to the
81    /// `tsconfig.json` inside, which repo discovery usually finds anyway.
82    #[serde(default)]
83    references: Vec<TsConfigReference>,
84}
85
86#[derive(Debug, Deserialize)]
87struct TsConfigReference {
88    path: String,
89}
90
91#[derive(Debug, Default, Deserialize)]
92struct RawCompilerOptions {
93    #[serde(default, rename = "baseUrl")]
94    base_url: Option<String>,
95    #[serde(default)]
96    paths: Option<BTreeMap<String, Vec<String>>>,
97}
98
99impl RepoContext {
100    pub fn discover(repo_root: &Path) -> Result<Self> {
101        let repo_root = repo_root
102            .canonicalize()
103            .with_context(|| format!("failed to resolve repo root {}", repo_root.display()))?;
104
105        let mut source_files = Vec::new();
106        let mut tsconfigs = Vec::new();
107        let mut package_jsons = Vec::new();
108        let mut warnings = Vec::new();
109
110        let config = match load_project_config(&repo_root) {
111            Ok(config) => config,
112            Err(error) => {
113                warnings.push(format!("{error:#}"));
114                ProjectConfig::default()
115            }
116        };
117        let ignore_unresolved = config.unresolved.ignore.clone();
118        let discovery_excludes = config.discovery.exclude.clone();
119
120        let filter_repo_root = repo_root.clone();
121        let walker = WalkBuilder::new(&repo_root)
122            .hidden(false)
123            .git_ignore(true)
124            .git_exclude(true)
125            .git_global(true)
126            .filter_entry(move |entry| {
127                let name = entry.file_name().to_string_lossy();
128                let default_excluded = matches!(
129                    name.as_ref(),
130                    ".git" | "node_modules" | "dist" | "build" | "coverage" | ".next" | ".turbo"
131                );
132                !default_excluded
133                    && !is_custom_excluded(&filter_repo_root, entry.path(), &discovery_excludes)
134            })
135            .build();
136
137        for entry in walker {
138            let entry = match entry {
139                Ok(entry) => entry,
140                Err(error) => {
141                    warnings.push(format!("skipping unreadable path: {error}"));
142                    continue;
143                }
144            };
145            if !entry.file_type().is_some_and(|kind| kind.is_file()) {
146                continue;
147            }
148
149            let path = entry.into_path();
150            match path.file_name().and_then(|name| name.to_str()) {
151                // jsconfig.json is tsconfig.json for JS projects (same format,
152                // same `paths`/`baseUrl` aliases) — load it the same way so JS
153                // repos' path aliases resolve.
154                Some("tsconfig.json") | Some("jsconfig.json") => match load_tsconfig(&path) {
155                    Ok(config) => {
156                        // Nx/Vite scaffolds keep aliases in a sibling config the
157                        // tsconfig.json only references; pull those in when the
158                        // merged options carry no aliases of their own.
159                        if !config.compiler_options.has_aliases() {
160                            tsconfigs.extend(load_sibling_tsconfigs(&path, &mut warnings));
161                        }
162                        // Project references can point at configs with
163                        // non-standard names (tsconfig.lib.json) the walk skips.
164                        tsconfigs.extend(load_referenced_tsconfigs(&path, &mut warnings));
165                        tsconfigs.push(config);
166                    }
167                    Err(error) => warnings.push(format!("{error:#}")),
168                },
169                Some("package.json") => package_jsons.push(path.clone()),
170                _ => {}
171            }
172
173            if is_source_file(&path) {
174                source_files.push(path);
175            }
176        }
177
178        source_files.sort();
179        tsconfigs.sort_by(|a, b| a.path.cmp(&b.path));
180        // Referenced configs can duplicate walk-discovered ones.
181        tsconfigs.dedup_by(|a, b| a.path == b.path);
182        package_jsons.sort();
183
184        Ok(Self {
185            repo_root,
186            source_files,
187            tsconfigs,
188            package_jsons,
189            ignore_unresolved,
190            warnings,
191        })
192    }
193}
194
195fn load_project_config(repo_root: &Path) -> Result<ProjectConfig> {
196    let path = repo_root.join(".blast-radius.json");
197    let contents = match fs::read_to_string(&path) {
198        Ok(contents) => contents,
199        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
200            return Ok(ProjectConfig::default());
201        }
202        Err(error) => {
203            return Err(anyhow::Error::new(error)
204                .context(format!("failed to read config {}", path.display())));
205        }
206    };
207
208    // Parsed as JSONC (comments + trailing commas), matching tsconfig handling.
209    let value: serde_json::Value = parse_to_serde_value(
210        &contents,
211        &ParseOptions {
212            allow_comments: true,
213            allow_loose_object_property_names: false,
214            allow_trailing_commas: true,
215            allow_missing_commas: false,
216            allow_single_quoted_strings: false,
217            allow_hexadecimal_numbers: false,
218            allow_unary_plus_numbers: false,
219        },
220    )
221    .with_context(|| format!("failed to parse config {}", path.display()))?;
222
223    serde_json::from_value(value)
224        .with_context(|| format!("failed to decode config {}", path.display()))
225}
226
227fn is_custom_excluded(repo_root: &Path, path: &Path, excludes: &[String]) -> bool {
228    if excludes.is_empty() {
229        return false;
230    }
231
232    let Ok(relative) = path.strip_prefix(repo_root) else {
233        return false;
234    };
235    let relative = crate::graph::normalize_separators(relative.display().to_string());
236    let relative = relative.trim_start_matches("./").trim_end_matches('/');
237    if relative.is_empty() {
238        return false;
239    }
240
241    excludes.iter().any(|exclude| {
242        let pattern = crate::graph::normalize_separators(exclude.as_str())
243            .trim_start_matches("./")
244            .trim_end_matches('/')
245            .to_string();
246        !pattern.is_empty() && (relative == pattern || relative.starts_with(&format!("{pattern}/")))
247    })
248}
249
250fn load_tsconfig(path: &Path) -> Result<TsConfigPath> {
251    let mut visited = HashSet::new();
252    Ok(TsConfigPath {
253        path: path.to_path_buf(),
254        compiler_options: load_tsconfig_options(path, &mut visited)?,
255    })
256}
257
258fn parse_tsconfig_file(path: &Path) -> Result<TsConfigFile> {
259    let contents = fs::read_to_string(path)
260        .with_context(|| format!("failed to read tsconfig {}", path.display()))?;
261    let value: serde_json::Value = parse_to_serde_value(
262        &contents,
263        &ParseOptions {
264            allow_comments: true,
265            allow_loose_object_property_names: false,
266            allow_trailing_commas: true,
267            allow_missing_commas: false,
268            allow_single_quoted_strings: false,
269            allow_hexadecimal_numbers: false,
270            allow_unary_plus_numbers: false,
271        },
272    )
273    .with_context(|| format!("failed to parse tsconfig {}", path.display()))?;
274    serde_json::from_value(value)
275        .with_context(|| format!("failed to decode tsconfig {}", path.display()))
276}
277
278/// Load a config's options after following its `extends` chain. Child fields
279/// shallow-override parents; `paths` replaces wholesale when redeclared.
280fn load_tsconfig_options(path: &Path, visited: &mut HashSet<PathBuf>) -> Result<TsCompilerOptions> {
281    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
282    if !visited.insert(canonical) {
283        // `extends` cycle: treat the revisited config as empty.
284        return Ok(TsCompilerOptions::default());
285    }
286
287    let parsed = parse_tsconfig_file(path)?;
288
289    let dir = path.parent().unwrap_or(Path::new("."));
290    let mut merged = TsCompilerOptions::default();
291    for specifier in extends_specifiers(&parsed.extends) {
292        // Bare package specifiers (e.g. @tsconfig/node18) live in node_modules,
293        // which isn't indexed; skip them silently.
294        let Some(parent_path) = resolve_extends_target(dir, &specifier) else {
295            continue;
296        };
297        let parent = load_tsconfig_options(&parent_path, visited)
298            .with_context(|| format!("failed to load extended tsconfig from {}", path.display()))?;
299        if parent.base_dir.is_some() {
300            merged.base_dir = parent.base_dir;
301        }
302        if parent.paths_dir.is_some() {
303            merged.paths = parent.paths;
304            merged.paths_dir = parent.paths_dir;
305        }
306    }
307
308    if let Some(base_url) = parsed.compiler_options.base_url {
309        merged.base_dir = Some(crate::resolve::clean_path(&dir.join(base_url)));
310    }
311    if let Some(paths) = parsed.compiler_options.paths {
312        merged.paths = paths;
313        merged.paths_dir = Some(dir.to_path_buf());
314    }
315
316    Ok(merged)
317}
318
319fn extends_specifiers(value: &Option<serde_json::Value>) -> Vec<String> {
320    match value {
321        Some(serde_json::Value::String(specifier)) => vec![specifier.clone()],
322        Some(serde_json::Value::Array(items)) => items
323            .iter()
324            .filter_map(|item| item.as_str().map(str::to_string))
325            .collect(),
326        _ => Vec::new(),
327    }
328}
329
330/// Map an `extends` specifier to a config file on disk: `./`/`../` specifiers
331/// and plain sibling filenames resolve relative to `dir` (with `.json` appended
332/// when missing, as TypeScript does); anything else is a package specifier.
333fn resolve_extends_target(dir: &Path, specifier: &str) -> Option<PathBuf> {
334    let candidate = dir.join(specifier);
335    if candidate.is_file() {
336        return Some(candidate);
337    }
338    if !specifier.ends_with(".json") {
339        let mut with_json = candidate.into_os_string();
340        with_json.push(".json");
341        let with_json = PathBuf::from(with_json);
342        if with_json.is_file() {
343            return Some(with_json);
344        }
345    }
346    None
347}
348
349/// Follow `references` entries to their config files and load any that carry
350/// aliases. Directory references resolve to the `tsconfig.json` inside (which
351/// repo discovery usually finds on its own); file references pick up
352/// non-standard names like `tsconfig.lib.json` that discovery skips. Chained
353/// references are followed with cycle protection.
354fn load_referenced_tsconfigs(tsconfig: &Path, warnings: &mut Vec<String>) -> Vec<TsConfigPath> {
355    let mut configs = Vec::new();
356    let mut visited = HashSet::new();
357    let canonical = tsconfig
358        .canonicalize()
359        .unwrap_or_else(|_| tsconfig.to_path_buf());
360    visited.insert(canonical);
361    collect_referenced_tsconfigs(tsconfig, &mut visited, &mut configs, warnings);
362    configs
363}
364
365fn collect_referenced_tsconfigs(
366    tsconfig: &Path,
367    visited: &mut HashSet<PathBuf>,
368    configs: &mut Vec<TsConfigPath>,
369    warnings: &mut Vec<String>,
370) {
371    let Ok(parsed) = parse_tsconfig_file(tsconfig) else {
372        // Unreadable configs are already reported by the primary load.
373        return;
374    };
375    let Some(dir) = tsconfig.parent() else {
376        return;
377    };
378
379    for reference in &parsed.references {
380        let target = crate::resolve::clean_path(&dir.join(&reference.path));
381        let target = if target.is_dir() {
382            target.join("tsconfig.json")
383        } else {
384            target
385        };
386        if !target.is_file() {
387            continue;
388        }
389        let canonical = target.canonicalize().unwrap_or_else(|_| target.clone());
390        if !visited.insert(canonical) {
391            continue;
392        }
393        match load_tsconfig(&target) {
394            Ok(config) => {
395                if config.compiler_options.has_aliases() {
396                    configs.push(config);
397                }
398                collect_referenced_tsconfigs(&target, visited, configs, warnings);
399            }
400            Err(error) => warnings.push(format!("{error:#}")),
401        }
402    }
403}
404
405/// Probe well-known sibling configs that hold path aliases in Nx and Vite
406/// scaffold layouts, where tsconfig.json itself declares none.
407fn load_sibling_tsconfigs(tsconfig: &Path, warnings: &mut Vec<String>) -> Vec<TsConfigPath> {
408    let Some(dir) = tsconfig.parent() else {
409        return Vec::new();
410    };
411
412    let mut configs = Vec::new();
413    for name in ["tsconfig.base.json", "tsconfig.app.json"] {
414        let path = dir.join(name);
415        if !path.is_file() {
416            continue;
417        }
418        match load_tsconfig(&path) {
419            Ok(config) if config.compiler_options.has_aliases() => configs.push(config),
420            Ok(_) => {}
421            Err(error) => warnings.push(format!("{error:#}")),
422        }
423    }
424    configs
425}
426
427fn is_source_file(path: &Path) -> bool {
428    path.extension()
429        .and_then(|ext| ext.to_str())
430        .is_some_and(crate::language::is_source_extension)
431}
432
433#[cfg(test)]
434mod tests {
435    use std::fs;
436
437    use tempfile::tempdir;
438
439    use super::RepoContext;
440
441    #[test]
442    fn discovers_source_files_and_tsconfig() {
443        let dir = tempdir().unwrap();
444        fs::create_dir_all(dir.path().join("src")).unwrap();
445        fs::write(
446            dir.path().join("tsconfig.json"),
447            r#"{"compilerOptions":{"baseUrl":".","paths":{"@ui/*":["packages/ui/*"]}}}"#,
448        )
449        .unwrap();
450        fs::write(
451            dir.path().join("src").join("Button.tsx"),
452            "export const Button = () => null;",
453        )
454        .unwrap();
455        fs::write(
456            dir.path().join("src").join("legacy.mjs"),
457            "export const legacy = true;",
458        )
459        .unwrap();
460        fs::write(dir.path().join("src").join("server.cts"), "export = {};").unwrap();
461        fs::write(
462            dir.path().join("src").join("helper.py"),
463            "def helper(): pass",
464        )
465        .unwrap();
466        fs::write(dir.path().join("src").join("lib.rs"), "pub fn helper() {}").unwrap();
467        fs::write(
468            dir.path().join("src").join("Button.vue"),
469            "<script setup>import x from './x'</script>",
470        )
471        .unwrap();
472        fs::write(
473            dir.path().join("src").join("Card.svelte"),
474            "<script>import x from './x'</script>",
475        )
476        .unwrap();
477        fs::write(dir.path().join("package.json"), r#"{"name":"fixture"}"#).unwrap();
478
479        let repo = RepoContext::discover(dir.path()).unwrap();
480
481        let mut expected = 3;
482        if cfg!(feature = "python") {
483            expected += 1;
484        }
485        if cfg!(feature = "rust") {
486            expected += 1;
487        }
488        if cfg!(feature = "vue") {
489            expected += 1;
490        }
491        if cfg!(feature = "svelte") {
492            expected += 1;
493        }
494        assert_eq!(repo.source_files.len(), expected);
495        assert_eq!(repo.tsconfigs.len(), 1);
496        assert_eq!(repo.package_jsons.len(), 1);
497    }
498
499    #[test]
500    fn loads_ignore_unresolved_from_project_config() {
501        let dir = tempdir().unwrap();
502        fs::create_dir_all(dir.path().join("src")).unwrap();
503        fs::write(
504            dir.path().join("src").join("App.tsx"),
505            "export const App = () => null;",
506        )
507        .unwrap();
508        fs::write(
509            dir.path().join(".blast-radius.json"),
510            r#"{ "unresolved": { "ignore": ["styled-system/css", ".velite"] } }"#,
511        )
512        .unwrap();
513
514        let repo = RepoContext::discover(dir.path()).unwrap();
515
516        assert_eq!(
517            repo.ignore_unresolved,
518            vec!["styled-system/css".to_string(), ".velite".to_string()]
519        );
520    }
521
522    #[test]
523    fn skips_project_configured_discovery_excludes() {
524        let dir = tempdir().unwrap();
525        fs::create_dir_all(dir.path().join("src")).unwrap();
526        fs::create_dir_all(dir.path().join("generated")).unwrap();
527        fs::write(
528            dir.path().join("src").join("App.tsx"),
529            "export const App = () => null;",
530        )
531        .unwrap();
532        fs::write(
533            dir.path().join("generated").join("client.ts"),
534            "export const generated = true;",
535        )
536        .unwrap();
537        fs::write(
538            dir.path().join("generated").join("package.json"),
539            r#"{"name":"generated"}"#,
540        )
541        .unwrap();
542        fs::write(
543            dir.path().join(".blast-radius.json"),
544            r#"{ "discovery": { "exclude": ["generated/"] } }"#,
545        )
546        .unwrap();
547
548        let repo = RepoContext::discover(dir.path()).unwrap();
549
550        assert_eq!(repo.source_files.len(), 1);
551        assert!(repo.source_files[0].ends_with("src/App.tsx"));
552        assert!(repo.package_jsons.is_empty());
553    }
554
555    #[test]
556    fn follows_project_references_to_nonstandard_config_names() {
557        let dir = tempdir().unwrap();
558        fs::create_dir_all(dir.path().join("lib/src")).unwrap();
559        fs::write(
560            dir.path().join("tsconfig.json"),
561            r#"{ "files": [], "references": [{ "path": "./lib/tsconfig.lib.json" }] }"#,
562        )
563        .unwrap();
564        fs::write(
565            dir.path().join("lib/tsconfig.lib.json"),
566            r#"{ "compilerOptions": { "baseUrl": ".", "paths": { "@lib/*": ["src/*"] } } }"#,
567        )
568        .unwrap();
569        fs::write(dir.path().join("lib/src/util.ts"), "export const x = 1;").unwrap();
570
571        let repo = RepoContext::discover(dir.path()).unwrap();
572
573        assert_eq!(repo.tsconfigs.len(), 2, "referenced config must be loaded");
574        assert!(
575            repo.tsconfigs
576                .iter()
577                .any(|config| config.path.ends_with("tsconfig.lib.json")
578                    && config.compiler_options.has_aliases()),
579            "tsconfig.lib.json aliases must be available"
580        );
581    }
582
583    #[test]
584    fn defaults_ignore_unresolved_when_config_absent() {
585        let dir = tempdir().unwrap();
586        fs::create_dir_all(dir.path().join("src")).unwrap();
587        fs::write(
588            dir.path().join("src").join("App.tsx"),
589            "export const App = () => null;",
590        )
591        .unwrap();
592
593        let repo = RepoContext::discover(dir.path()).unwrap();
594
595        assert!(repo.ignore_unresolved.is_empty());
596    }
597
598    #[test]
599    fn reports_invalid_project_config_as_warning() {
600        let dir = tempdir().unwrap();
601        fs::write(dir.path().join(".blast-radius.json"), "{ not valid json").unwrap();
602
603        let repo = RepoContext::discover(dir.path()).unwrap();
604
605        assert!(repo.ignore_unresolved.is_empty());
606        assert!(
607            repo.warnings
608                .iter()
609                .any(|warning| warning.contains("failed to parse config")),
610            "invalid config should be reported as a discovery warning"
611        );
612    }
613
614    #[test]
615    fn reports_invalid_tsconfig_as_warning() {
616        let dir = tempdir().unwrap();
617        fs::create_dir_all(dir.path().join("src")).unwrap();
618        fs::write(dir.path().join("tsconfig.json"), "{ invalid json").unwrap();
619        fs::write(
620            dir.path().join("src").join("Button.tsx"),
621            "export const Button = () => null;",
622        )
623        .unwrap();
624
625        let repo = RepoContext::discover(dir.path()).unwrap();
626
627        assert_eq!(repo.source_files.len(), 1);
628        assert!(repo.tsconfigs.is_empty());
629        assert!(
630            repo.warnings
631                .iter()
632                .any(|warning| warning.contains("failed to parse tsconfig")),
633            "invalid tsconfig should be reported as a discovery warning"
634        );
635    }
636
637    #[cfg(feature = "python")]
638    #[test]
639    fn discovers_python_sources_when_enabled() {
640        let dir = tempdir().unwrap();
641        fs::create_dir_all(dir.path().join("src")).unwrap();
642        fs::write(
643            dir.path().join("src").join("helper.py"),
644            "def helper(): pass",
645        )
646        .unwrap();
647
648        let repo = RepoContext::discover(dir.path()).unwrap();
649
650        assert_eq!(repo.source_files.len(), 1);
651    }
652
653    #[cfg(feature = "rust")]
654    #[test]
655    fn discovers_rust_sources_when_enabled() {
656        let dir = tempdir().unwrap();
657        fs::create_dir_all(dir.path().join("src")).unwrap();
658        fs::write(dir.path().join("src").join("lib.rs"), "pub fn helper() {}").unwrap();
659
660        let repo = RepoContext::discover(dir.path()).unwrap();
661
662        assert_eq!(repo.source_files.len(), 1);
663    }
664
665    #[cfg(feature = "vue")]
666    #[test]
667    fn discovers_vue_sources_when_enabled() {
668        let dir = tempdir().unwrap();
669        fs::create_dir_all(dir.path().join("src")).unwrap();
670        fs::write(dir.path().join("src").join("Button.vue"), "<template />").unwrap();
671
672        let repo = RepoContext::discover(dir.path()).unwrap();
673
674        assert_eq!(repo.source_files.len(), 1);
675    }
676
677    #[cfg(feature = "svelte")]
678    #[test]
679    fn discovers_svelte_sources_when_enabled() {
680        let dir = tempdir().unwrap();
681        fs::create_dir_all(dir.path().join("src")).unwrap();
682        fs::write(
683            dir.path().join("src").join("Card.svelte"),
684            "<script></script>",
685        )
686        .unwrap();
687
688        let repo = RepoContext::discover(dir.path()).unwrap();
689
690        assert_eq!(repo.source_files.len(), 1);
691    }
692}