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