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