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