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 pub ignore_unresolved: Vec<String>,
19 pub warnings: Vec<String>,
20}
21
22#[derive(Debug, Default, Deserialize)]
26struct ProjectConfig {
27 #[serde(default)]
28 unresolved: UnresolvedConfig,
29}
30
31#[derive(Debug, Default, Deserialize)]
32struct UnresolvedConfig {
33 #[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#[derive(Debug, Clone, Default)]
48pub struct TsCompilerOptions {
49 pub base_dir: Option<PathBuf>,
51 pub paths: BTreeMap<String, Vec<String>>,
52 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 #[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") | Some("jsconfig.json") => match load_tsconfig(&path) {
141 Ok(config) => {
142 if !config.compiler_options.has_aliases() {
146 tsconfigs.extend(load_sibling_tsconfigs(&path, &mut warnings));
147 }
148 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 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 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
241fn 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 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 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
293fn 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
312fn 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 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
368fn 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}