1use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::Instant;
6
7use fallow_config::{DuplicatesConfig, ResolvedConfig, WorkspaceInfo};
8use fallow_types::discover::DiscoveredFile;
9use fallow_types::extract::ModuleInfo;
10use fallow_types::source_fingerprint::SourceFingerprint;
11use fallow_types::workspace::WorkspaceDiagnostic;
12use rustc_hash::{FxHashMap, FxHashSet};
13
14use crate::{
15 EngineResult, core_backend, duplicates,
16 project_analysis::{
17 ProjectAnalysisArtifactOptions, ProjectAnalysisArtifacts, ProjectAnalysisOutput,
18 },
19 project_config::{ProjectConfig, config_for_project, default_project_config},
20 results::{
21 DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput, DuplicationAnalysis,
22 SharedDeadCodeAnalysisArtifacts,
23 },
24};
25
26#[derive(Debug)]
32pub struct AnalysisSession {
33 config: ResolvedConfig,
34 config_path: Option<PathBuf>,
35 discovery: crate::discover::AnalysisDiscovery,
36 workspaces: Vec<WorkspaceInfo>,
37 workspace_diagnostics: Vec<WorkspaceDiagnostic>,
38 parsed_cache: Mutex<Option<ParsedModuleCache>>,
39 styling_cache: Mutex<Option<crate::health::StylingAnalysisArtifacts>>,
40}
41
42#[derive(Debug)]
43struct ParsedModuleCache {
44 need_complexity: bool,
45 fingerprints: Vec<SourceFingerprint>,
46 modules: Arc<[ModuleInfo]>,
47}
48
49#[derive(Debug)]
51pub struct AnalysisSessionParts {
52 pub config: ResolvedConfig,
53 pub config_path: Option<PathBuf>,
54 pub files: Vec<DiscoveredFile>,
55 pub workspaces: Vec<WorkspaceInfo>,
56 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
57}
58
59#[derive(Debug)]
61pub struct ParsedAnalysisSessionParts {
62 pub config: ResolvedConfig,
63 pub config_path: Option<PathBuf>,
64 pub files: Vec<DiscoveredFile>,
65 pub modules: Vec<ModuleInfo>,
66 pub workspaces: Vec<WorkspaceInfo>,
67 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
68 pub parse_ms: f64,
69 pub cache_update_ms: f64,
70 pub cache_hits: usize,
71 pub cache_misses: usize,
72 pub parse_cpu_ms: f64,
73}
74
75#[derive(Debug)]
77pub struct AnalysisSessionArtifacts {
78 pub analysis: DeadCodeAnalysisArtifacts,
79 pub changed_files: Option<FxHashSet<PathBuf>>,
80 pub source_fingerprints: FxHashMap<PathBuf, SourceFingerprint>,
81}
82
83impl AnalysisSession {
84 pub fn load(root: &Path, config_path: Option<&Path>) -> EngineResult<Self> {
90 let project_config = config_for_project(root, config_path)?;
91 Ok(Self::from_config(project_config))
92 }
93
94 pub fn load_with_config(
101 root: &Path,
102 config_path: Option<&Path>,
103 configure: impl FnOnce(&mut ResolvedConfig),
104 ) -> EngineResult<Self> {
105 Self::load_with_config_options(
106 root,
107 config_path,
108 fallow_config::ConfigLoadOptions::default(),
109 configure,
110 )
111 }
112
113 pub fn load_with_config_options(
120 root: &Path,
121 config_path: Option<&Path>,
122 load_options: fallow_config::ConfigLoadOptions,
123 configure: impl FnOnce(&mut ResolvedConfig),
124 ) -> EngineResult<Self> {
125 let mut project_config = crate::project_config::config_for_project_with_load_options(
126 root,
127 config_path,
128 load_options,
129 )?;
130 configure(&mut project_config.config);
131 project_config.workspaces.clear();
132 project_config.workspace_diagnostics.clear();
133 project_config.workspace_discovery_ms = None;
134 Ok(Self::from_config(project_config))
135 }
136
137 #[must_use]
142 pub fn load_default(root: &Path) -> Self {
143 Self::from_config(default_project_config(root))
144 }
145
146 #[must_use]
148 pub fn from_config(project_config: ProjectConfig) -> Self {
149 let uses_preloaded_workspaces = project_config.workspace_discovery_ms.is_some();
150 let discovery = if let Some(workspace_discovery_ms) = project_config.workspace_discovery_ms
151 {
152 crate::discover::prepare_analysis_discovery_with_workspaces(
153 &project_config.config,
154 &project_config.workspaces,
155 workspace_discovery_ms,
156 )
157 } else {
158 crate::discover::prepare_analysis_discovery(&project_config.config)
159 };
160 let workspaces = if uses_preloaded_workspaces {
161 project_config.workspaces
162 } else {
163 discovery.workspaces().to_vec()
164 };
165 let workspace_diagnostics = merge_workspace_diagnostics(
166 project_config.workspace_diagnostics,
167 fallow_config::workspace_diagnostics_for(&project_config.config.root),
168 );
169 Self {
170 config: project_config.config,
171 config_path: project_config.path,
172 discovery,
173 workspaces,
174 workspace_diagnostics,
175 parsed_cache: Mutex::new(None),
176 styling_cache: Mutex::new(None),
177 }
178 }
179
180 #[must_use]
183 pub fn from_resolved_config(config: ResolvedConfig) -> Self {
184 Self::from_config(ProjectConfig {
185 config,
186 path: None,
187 workspaces: Vec::new(),
188 workspace_diagnostics: Vec::new(),
189 workspace_discovery_ms: None,
190 })
191 }
192
193 #[must_use]
195 pub fn root(&self) -> &Path {
196 &self.config.root
197 }
198
199 #[must_use]
201 pub fn config(&self) -> &ResolvedConfig {
202 &self.config
203 }
204
205 #[must_use]
207 pub fn config_path(&self) -> Option<&Path> {
208 self.config_path.as_deref()
209 }
210
211 #[must_use]
213 pub fn files(&self) -> &[DiscoveredFile] {
214 self.discovery.files()
215 }
216
217 #[must_use]
219 pub fn workspaces(&self) -> &[WorkspaceInfo] {
220 &self.workspaces
221 }
222
223 #[must_use]
225 pub fn source_fingerprints(&self) -> FxHashMap<PathBuf, SourceFingerprint> {
226 self.discovery
227 .files()
228 .iter()
229 .map(|file| {
230 let fingerprint = std::fs::metadata(&file.path).map_or_else(
231 |_| SourceFingerprint::new(0, file.size_bytes),
232 |metadata| SourceFingerprint::from_metadata(&metadata),
233 );
234 (file.path.clone(), fingerprint)
235 })
236 .collect()
237 }
238
239 pub fn changed_files_since(
246 &self,
247 git_ref: &str,
248 ) -> Result<FxHashSet<PathBuf>, crate::changed_files::ChangedFilesError> {
249 crate::changed_files::changed_files(&self.config.root, git_ref)
250 }
251
252 #[must_use]
254 pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
255 &self.workspace_diagnostics
256 }
257
258 #[must_use]
261 pub fn current_workspace_diagnostics(&self) -> Vec<WorkspaceDiagnostic> {
262 merge_workspace_diagnostics(
263 self.workspace_diagnostics.clone(),
264 fallow_config::workspace_diagnostics_for(&self.config.root),
265 )
266 }
267
268 pub(crate) fn styling_analysis_artifacts(&self) -> crate::health::StylingAnalysisArtifacts {
269 if let Ok(cache) = self.styling_cache.lock()
270 && let Some(artifacts) = cache.as_ref()
271 {
272 return artifacts.clone();
273 }
274
275 let artifacts =
276 crate::health::build_styling_analysis_artifacts(self.files(), self.config());
277 if let Ok(mut cache) = self.styling_cache.lock() {
278 *cache = Some(artifacts.clone());
279 }
280 artifacts
281 }
282
283 #[must_use]
285 pub fn into_parts(self) -> AnalysisSessionParts {
286 let workspace_diagnostics = self.current_workspace_diagnostics();
287 AnalysisSessionParts {
288 config: self.config,
289 config_path: self.config_path,
290 files: self.discovery.into_files(),
291 workspaces: self.workspaces,
292 workspace_diagnostics,
293 }
294 }
295
296 #[must_use]
298 pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
299 let AnalysisSessionParts {
300 config,
301 config_path,
302 files,
303 workspaces,
304 workspace_diagnostics,
305 } = self.into_parts();
306 let ParsedModules {
307 modules,
308 metrics,
309 source_diagnostics,
310 } = parse_files_with_config(&config, &files, need_complexity);
311 ParsedAnalysisSessionParts {
312 config,
313 config_path,
314 files,
315 modules,
316 workspaces,
317 workspace_diagnostics: merge_workspace_diagnostics(
318 workspace_diagnostics,
319 source_diagnostics,
320 ),
321 parse_ms: metrics.parse_ms,
322 cache_update_ms: metrics.cache_ms,
323 cache_hits: metrics.cache_hits,
324 cache_misses: metrics.cache_misses,
325 parse_cpu_ms: metrics.parse_cpu_ms,
326 }
327 }
328
329 #[must_use]
331 pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
332 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
333 self.parsed_parts_from_modules(modules.to_vec(), metrics)
334 }
335
336 #[doc(hidden)]
342 #[must_use]
343 pub fn shared_parsed_modules(&self, need_complexity: bool) -> Arc<[ModuleInfo]> {
344 self.parse_modules(need_complexity).modules
345 }
346
347 #[must_use]
350 pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
351 let ParsedModules {
352 modules,
353 metrics,
354 source_diagnostics: _,
355 } = parse_files_with_config(&self.config, self.files(), need_complexity);
356 self.parsed_parts_from_modules(modules, metrics)
357 }
358
359 fn parsed_parts_from_modules(
360 &self,
361 modules: Vec<ModuleInfo>,
362 metrics: core_backend::ParseMetrics,
363 ) -> ParsedAnalysisSessionParts {
364 ParsedAnalysisSessionParts {
365 config: self.config.clone(),
366 config_path: self.config_path.clone(),
367 files: self.discovery.files().to_vec(),
368 modules,
369 workspaces: self.workspaces.clone(),
370 workspace_diagnostics: self.current_workspace_diagnostics(),
371 parse_ms: metrics.parse_ms,
372 cache_update_ms: metrics.cache_ms,
373 cache_hits: metrics.cache_hits,
374 cache_misses: metrics.cache_misses,
375 parse_cpu_ms: metrics.parse_cpu_ms,
376 }
377 }
378
379 pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
385 self.analyze_dead_code_with_artifacts(false, false)
386 .map(|output| DeadCodeAnalysis {
387 results: output.results,
388 })
389 }
390
391 pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
397 self.analyze_dead_code_with_artifacts(true, false)
398 .map(|output| DeadCodeAnalysisOutput {
399 results: output.results,
400 modules: output.modules,
401 files: output.files,
402 })
403 }
404
405 pub fn analyze_dead_code_with_artifacts(
411 &self,
412 need_complexity: bool,
413 retain_graph: bool,
414 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
415 self.analyze_dead_code_with_shared_artifacts(need_complexity, retain_graph)
416 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
417 }
418
419 #[doc(hidden)]
429 pub fn analyze_dead_code_with_shared_artifacts(
430 &self,
431 need_complexity: bool,
432 retain_graph: bool,
433 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
434 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
435 }
436
437 pub fn analyze_dead_code_retaining_files(
444 &self,
445 need_complexity: bool,
446 retain_graph: bool,
447 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
448 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
449 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
450 }
451
452 pub fn analyze_dead_code_with_parsed_modules(
461 &self,
462 modules: &[ModuleInfo],
463 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
464 self.analyze_dead_code_with_shared_modules(Arc::from(modules))
465 }
466
467 #[doc(hidden)]
473 pub fn analyze_dead_code_with_shared_modules(
474 &self,
475 modules: Arc<[ModuleInfo]>,
476 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
477 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
478 config: &self.config,
479 discovery: &self.discovery,
480 modules,
481 metrics: reused_parse_metrics(),
482 collect_usages: true,
483 retain_graph: true,
484 retain_modules: false,
485 retain_files: false,
486 })
487 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
488 }
489
490 fn analyze_dead_code_with_reuse_artifacts(
491 &self,
492 need_complexity: bool,
493 retain_graph: bool,
494 retain_files: bool,
495 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
496 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
497 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
498 config: &self.config,
499 discovery: &self.discovery,
500 modules,
501 metrics,
502 collect_usages: true,
503 retain_graph,
504 retain_modules: need_complexity,
505 retain_files,
506 })
507 }
508
509 pub fn analyze_dead_code_with_session_artifacts(
520 &self,
521 need_complexity: bool,
522 retain_graph: bool,
523 changed_files: Option<FxHashSet<PathBuf>>,
524 ) -> EngineResult<AnalysisSessionArtifacts> {
525 Ok(AnalysisSessionArtifacts {
526 analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
527 changed_files,
528 source_fingerprints: self.source_fingerprints(),
529 })
530 }
531
532 #[must_use]
534 pub fn find_duplicates(&self) -> duplicates::DuplicationReport {
535 duplicates::find_duplicates(&self.config.root, self.files(), &self.config.duplicates)
536 }
537
538 #[must_use]
540 pub fn find_duplicates_with(&self, config: &DuplicatesConfig) -> duplicates::DuplicationReport {
541 duplicates::find_duplicates(&self.config.root, self.files(), config)
542 }
543
544 pub fn analyze_project_with(
553 &self,
554 duplicates_config: &DuplicatesConfig,
555 retain_complexity_artifacts: bool,
556 ) -> EngineResult<ProjectAnalysisOutput> {
557 self.analyze_project_with_artifacts(
558 duplicates_config,
559 ProjectAnalysisArtifactOptions {
560 retain_complexity_artifacts,
561 ..ProjectAnalysisArtifactOptions::default()
562 },
563 )
564 .map(ProjectAnalysisArtifacts::into_output)
565 }
566
567 pub fn analyze_project_with_artifacts(
577 &self,
578 duplicates_config: &DuplicatesConfig,
579 options: ProjectAnalysisArtifactOptions,
580 ) -> EngineResult<ProjectAnalysisArtifacts> {
581 let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
582 let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
583 let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
584 self.find_duplicates_touching_files_with_defaults(
585 duplicates_config,
586 &changed_files,
587 cache_dir,
588 )
589 .report
590 } else {
591 self.find_duplicates_with_defaults(duplicates_config, cache_dir)
592 .report
593 };
594 let source_fingerprints = options
595 .collect_source_fingerprints
596 .then(|| self.source_fingerprints());
597 Ok(ProjectAnalysisArtifacts {
598 dead_code: self.analyze_dead_code_with_artifacts(
599 options.retain_complexity_artifacts,
600 options.retain_graph,
601 )?,
602 duplication,
603 changed_files: options.changed_files,
604 source_fingerprints,
605 })
606 }
607
608 #[must_use]
610 pub fn find_duplicates_with_defaults(
611 &self,
612 config: &DuplicatesConfig,
613 cache_dir: Option<&Path>,
614 ) -> DuplicationAnalysis {
615 duplicates::find_duplicates_with_defaults(
616 &self.config.root,
617 self.files(),
618 config,
619 cache_dir,
620 )
621 }
622
623 #[must_use]
625 pub fn find_duplicates_touching_files_with_defaults(
626 &self,
627 config: &DuplicatesConfig,
628 changed_files: &[PathBuf],
629 cache_dir: Option<&Path>,
630 ) -> DuplicationAnalysis {
631 duplicates::find_duplicates_touching_files_with_defaults(
632 &self.config.root,
633 self.files(),
634 config,
635 changed_files,
636 cache_dir,
637 )
638 }
639
640 fn parse_modules(&self, need_complexity: bool) -> SharedParsedModules {
641 let fingerprints = source_fingerprints_for_files(self.files());
642 if let Some(fingerprints) = fingerprints.as_ref()
643 && let Some(modules) = self.cached_modules(need_complexity, fingerprints)
644 {
645 return SharedParsedModules {
646 modules,
647 metrics: core_backend::ParseMetrics {
648 parse_ms: 0.0,
649 cache_ms: 0.0,
650 cache_hits: 0,
651 cache_misses: 0,
652 parse_cpu_ms: 0.0,
653 },
654 };
655 }
656
657 let ParsedModules {
658 modules,
659 metrics,
660 source_diagnostics: _,
661 } = parse_files_with_config(&self.config, self.files(), need_complexity);
662 let modules: Arc<[ModuleInfo]> = modules.into();
663 if let Some(fingerprints) = fingerprints
664 && let Ok(mut cache) = self.parsed_cache.lock()
665 {
666 *cache = Some(ParsedModuleCache {
667 need_complexity,
668 fingerprints,
669 modules: Arc::clone(&modules),
670 });
671 }
672 SharedParsedModules { modules, metrics }
673 }
674
675 fn cached_modules(
676 &self,
677 need_complexity: bool,
678 fingerprints: &[SourceFingerprint],
679 ) -> Option<Arc<[ModuleInfo]>> {
680 let Ok(cache) = self.parsed_cache.lock() else {
681 return None;
682 };
683 let cache = cache.as_ref()?;
684 let complexity_mode_satisfies_request = cache.need_complexity || !need_complexity;
685 if complexity_mode_satisfies_request && cache.fingerprints == fingerprints {
686 return Some(Arc::clone(&cache.modules));
687 }
688 None
689 }
690}
691
692fn merge_workspace_diagnostics(
693 primary: Vec<WorkspaceDiagnostic>,
694 secondary: Vec<WorkspaceDiagnostic>,
695) -> Vec<WorkspaceDiagnostic> {
696 let mut merged = Vec::with_capacity(primary.len() + secondary.len());
697 let mut seen: FxHashSet<(String, PathBuf)> = FxHashSet::default();
698 for diagnostic in primary.into_iter().chain(secondary) {
699 let key = (diagnostic.kind.id().to_owned(), diagnostic.path.clone());
700 if seen.insert(key) {
701 merged.push(diagnostic);
702 }
703 }
704 merged
705}
706
707struct ParsedModules {
708 modules: Vec<ModuleInfo>,
709 metrics: core_backend::ParseMetrics,
710 source_diagnostics: Vec<WorkspaceDiagnostic>,
711}
712
713struct SharedParsedModules {
714 modules: Arc<[ModuleInfo]>,
715 metrics: core_backend::ParseMetrics,
716}
717
718fn parse_files_with_config(
719 config: &ResolvedConfig,
720 files: &[DiscoveredFile],
721 need_complexity: bool,
722) -> ParsedModules {
723 let parse_start = Instant::now();
724 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
725 let mut cache = if config.no_cache {
726 None
727 } else {
728 fallow_extract::cache::CacheStore::load(
729 &config.cache_dir,
730 config.cache_config_hash,
731 cache_max_size_bytes,
732 )
733 };
734 let parse_result = crate::source::parse_all_files(files, cache.as_ref(), need_complexity);
735 let source_diagnostics =
736 fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
737 let mut modules = parse_result.modules;
738 for module in &mut modules {
739 module.prepare_analysis_facts();
740 }
741 let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
742 let cache_ms = update_parse_cache_if_enabled(config, &mut cache, &modules, files);
743 let metrics = core_backend::ParseMetrics {
744 parse_ms,
745 cache_ms,
746 cache_hits: parse_result.cache_hits,
747 cache_misses: parse_result.cache_misses,
748 parse_cpu_ms: parse_result.parse_cpu_ms,
749 };
750 ParsedModules {
751 modules,
752 metrics,
753 source_diagnostics,
754 }
755}
756
757fn reused_parse_metrics() -> core_backend::ParseMetrics {
758 core_backend::ParseMetrics {
759 parse_ms: 0.0,
760 cache_ms: 0.0,
761 cache_hits: 0,
762 cache_misses: 0,
763 parse_cpu_ms: 0.0,
764 }
765}
766
767fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
768 files
769 .iter()
770 .map(|file| {
771 std::fs::metadata(&file.path)
772 .ok()
773 .map(|metadata| SourceFingerprint::from_metadata(&metadata))
774 .filter(|fingerprint| fingerprint.has_known_mtime())
775 })
776 .collect()
777}
778
779fn update_parse_cache_if_enabled(
780 config: &ResolvedConfig,
781 cache: &mut Option<fallow_extract::cache::CacheStore>,
782 modules: &[ModuleInfo],
783 files: &[DiscoveredFile],
784) -> f64 {
785 let start = Instant::now();
786 if config.no_cache {
787 return start.elapsed().as_secs_f64() * 1000.0;
788 }
789
790 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
791 let store = cache.get_or_insert_with(fallow_extract::cache::CacheStore::new);
792 if update_parse_cache(store, modules, files)
793 && let Err(error) = store.save(
794 &config.cache_dir,
795 config.cache_config_hash,
796 cache_max_size_bytes,
797 )
798 {
799 tracing::warn!("Failed to save cache: {error}");
800 }
801 start.elapsed().as_secs_f64() * 1000.0
802}
803
804fn update_parse_cache(
805 store: &mut fallow_extract::cache::CacheStore,
806 modules: &[ModuleInfo],
807 files: &[DiscoveredFile],
808) -> bool {
809 let mut dirty = false;
810 for module in modules {
811 if let Some(file) = files.get(module.file_id.0 as usize) {
812 let fingerprint = source_fingerprint(&file.path);
813 if let Some(cached) = store.get_by_path_only(&file.path)
814 && cached.content_hash == module.content_hash
815 {
816 if cached.source_fingerprint() != fingerprint {
817 let preserved_last_access = cached.last_access_secs;
818 let mut refreshed =
819 fallow_extract::cache::module_to_cached(module, fingerprint);
820 refreshed.last_access_secs = preserved_last_access;
821 store.insert(&file.path, refreshed);
822 dirty = true;
823 }
824 continue;
825 }
826 store.insert(
827 &file.path,
828 fallow_extract::cache::module_to_cached(module, fingerprint),
829 );
830 dirty = true;
831 }
832 }
833 store.retain_paths(files) || dirty
834}
835
836fn source_fingerprint(path: &Path) -> SourceFingerprint {
837 std::fs::metadata(path).map_or_else(
838 |_| SourceFingerprint::new(0, 0),
839 |metadata| SourceFingerprint::from_metadata(&metadata),
840 )
841}
842
843struct EngineDeadCodePipelineInput<'a> {
844 config: &'a ResolvedConfig,
845 discovery: &'a crate::discover::AnalysisDiscovery,
846 modules: Arc<[ModuleInfo]>,
847 metrics: core_backend::ParseMetrics,
848 collect_usages: bool,
849 retain_graph: bool,
850 retain_modules: bool,
851 retain_files: bool,
852}
853
854fn run_engine_owned_dead_code_pipeline(
855 input: EngineDeadCodePipelineInput<'_>,
856) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
857 let EngineDeadCodePipelineInput {
858 config,
859 discovery,
860 modules,
861 metrics,
862 collect_usages,
863 retain_graph,
864 retain_modules,
865 retain_files,
866 } = input;
867 let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
868 let prelude_timings = prelude.timings();
869 let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
870 let (resolved, graph) = resolve_or_build_dead_code_graph(&prelude, &entry_points, &modules);
871
872 let detector = core_backend::run_dead_code_detectors(
873 &prelude,
874 &graph.graph,
875 &resolved.resolved,
876 &modules,
877 collect_usages,
878 &entry_points,
879 );
880 let profile =
881 core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
882 retain_timings: retain_graph,
883 prelude: &prelude,
884 prelude_timings,
885 parse_metrics: metrics,
886 module_count: modules.len(),
887 entry_points: &entry_points,
888 resolved: &resolved,
889 graph: &graph,
890 detector: &detector,
891 file_count: discovery.files().len(),
892 workspace_count: discovery.workspaces().len(),
893 });
894 let script_used_packages = prelude.script_used_packages();
895 prelude.finish();
896 let file_hashes = collect_file_hashes(&modules, discovery.files());
897
898 Ok(SharedDeadCodeAnalysisArtifacts {
899 results: detector.results,
900 timings: profile.timings,
901 graph: retain_graph.then_some(graph.graph),
902 modules: retain_modules.then_some(modules),
903 files: retain_files.then(|| discovery.files().to_vec()),
904 script_used_packages,
905 file_hashes,
906 })
907}
908
909fn resolve_or_build_dead_code_graph(
910 prelude: &core_backend::DeadCodeBackendPrelude,
911 entry_points: &core_backend::DeadCodeEntryPoints,
912 modules: &[ModuleInfo],
913) -> (
914 core_backend::DeadCodeResolvedModules,
915 core_backend::DeadCodeGraphRun,
916) {
917 if let Some((resolved, graph)) =
918 core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules)
919 {
920 return (resolved, graph);
921 }
922
923 let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
924 let graph =
925 core_backend::build_dead_code_graph(prelude, &resolved.resolved, entry_points, modules);
926 (resolved, graph)
927}
928
929fn collect_file_hashes(
930 modules: &[ModuleInfo],
931 files: &[DiscoveredFile],
932) -> FxHashMap<PathBuf, u64> {
933 modules
934 .iter()
935 .filter_map(|module| {
936 files
937 .get(module.file_id.0 as usize)
938 .map(|file| (file.path.clone(), module.content_hash))
939 })
940 .collect()
941}
942
943pub(crate) fn analyze_dead_code_with_parse_result_from_config(
944 config: &ResolvedConfig,
945 modules: &[ModuleInfo],
946) -> EngineResult<DeadCodeAnalysisArtifacts> {
947 let discovery = crate::discover::prepare_analysis_discovery(config);
948 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
949 config,
950 discovery: &discovery,
951 modules: Arc::from(modules),
952 metrics: reused_parse_metrics(),
953 collect_usages: true,
954 retain_graph: true,
955 retain_modules: false,
956 retain_files: false,
957 })
958 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964
965 fn session_with_source(source: &str) -> (tempfile::TempDir, AnalysisSession) {
966 let project = tempfile::tempdir().expect("project");
967 let root = project.path();
968 std::fs::create_dir(root.join("src")).expect("create source directory");
969 std::fs::write(root.join("src/index.ts"), source).expect("write source");
970 let session = AnalysisSession::load_default(root);
971 (project, session)
972 }
973
974 #[test]
975 fn session_retains_workspace_metadata_from_config_load() {
976 let project = tempfile::tempdir().expect("project");
977 let root = project.path();
978 std::fs::write(
979 root.join("package.json"),
980 r#"{"name":"root","workspaces":["packages/*"]}"#,
981 )
982 .expect("write root package");
983 std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
984 std::fs::write(
985 root.join("packages/a/package.json"),
986 r#"{"name":"pkg-a","type":"module"}"#,
987 )
988 .expect("write workspace package");
989
990 let session = AnalysisSession::load(root, None).expect("session loads");
991
992 assert!(
993 session
994 .workspaces()
995 .iter()
996 .any(|workspace| workspace.name == "pkg-a"),
997 "session must retain workspace metadata discovered during config load"
998 );
999 }
1000
1001 #[test]
1002 fn warm_parse_cache_reuses_module_storage() {
1003 let (_project, session) = session_with_source("export function value() { return 1; }\n");
1004 let first = session.parse_modules(true);
1005 let second = session.parse_modules(false);
1006
1007 assert!(
1008 Arc::ptr_eq(&first.modules, &second.modules),
1009 "warm session queries must share parsed module storage"
1010 );
1011 }
1012
1013 #[test]
1014 fn shared_parsed_modules_reuse_public_session_storage() {
1015 let (_project, session) = session_with_source("export const value = 1;\n");
1016 let first = session.shared_parsed_modules(true);
1017 let second = session.shared_parsed_modules(false);
1018
1019 assert!(Arc::ptr_eq(&first, &second));
1020 }
1021
1022 #[test]
1023 fn warm_complexity_artifacts_reuse_cached_module_storage() {
1024 let (_project, session) = session_with_source("export function value() { return 1; }\n");
1025 let cached = session.parse_modules(true);
1026 let artifacts = session
1027 .analyze_dead_code_with_reuse_artifacts(true, true, false)
1028 .expect("analysis succeeds");
1029 let retained = artifacts.modules.expect("complexity modules retained");
1030
1031 assert!(
1032 Arc::ptr_eq(&cached.modules, &retained),
1033 "warm complexity artifacts must share parsed module storage"
1034 );
1035 }
1036
1037 #[test]
1038 fn shared_and_owned_artifacts_preserve_output_bytes() {
1039 let (_project, session) = session_with_source(
1040 "export const used = 1;\nexport const unused = 2;\nconsole.log(used);\n",
1041 );
1042 let owned = session
1043 .analyze_dead_code_with_artifacts(true, true)
1044 .expect("owned analysis succeeds");
1045 let shared = session
1046 .analyze_dead_code_with_shared_artifacts(true, true)
1047 .expect("shared analysis succeeds");
1048
1049 assert_eq!(
1050 serde_json::to_vec(&owned.results).expect("serialize owned results"),
1051 serde_json::to_vec(&shared.results).expect("serialize shared results")
1052 );
1053 assert_eq!(owned.file_hashes, shared.file_hashes);
1054 assert_eq!(
1055 owned
1056 .modules
1057 .as_deref()
1058 .unwrap_or_default()
1059 .iter()
1060 .map(|module| module.content_hash)
1061 .collect::<Vec<_>>(),
1062 shared
1063 .modules
1064 .as_deref()
1065 .unwrap_or_default()
1066 .iter()
1067 .map(|module| module.content_hash)
1068 .collect::<Vec<_>>()
1069 );
1070 }
1071
1072 #[test]
1073 fn route_loader_whole_use_matches_across_cold_and_warm_sessions() {
1074 let project = tempfile::tempdir().expect("project");
1075 let root = project.path();
1076 std::fs::create_dir_all(root.join("app/routes")).expect("create route directory");
1077 std::fs::write(
1078 root.join("package.json"),
1079 r#"{"name":"route-cache-parity","dependencies":{"react-router":"latest"}}"#,
1080 )
1081 .expect("write package manifest");
1082 std::fs::write(
1083 root.join("app/routes/home.tsx"),
1084 r#"
1085import { useLoaderData } from "react-router";
1086export function loader() { return { opaque: "value" }; }
1087export default function Home() {
1088 const data = useLoaderData<typeof loader>();
1089 const copy = { ...data };
1090 return JSON.stringify(copy);
1091}
1092"#,
1093 )
1094 .expect("write route module");
1095
1096 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
1097 let cold_parse = cold_session.parsed_parts(false);
1098 assert_eq!(cold_parse.cache_hits, 0, "first parse must be cold");
1099 let cold = cold_session
1100 .analyze_dead_code()
1101 .expect("cold analysis succeeds");
1102
1103 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
1104 let warm_parse = warm_session.parsed_parts(false);
1105 assert!(
1106 warm_parse.cache_hits > 0,
1107 "second session must use disk cache"
1108 );
1109 let warm = warm_session
1110 .analyze_dead_code()
1111 .expect("warm analysis succeeds");
1112
1113 assert!(
1114 cold.results.unused_load_data_keys.is_empty(),
1115 "cold analysis must abstain for an opaque route-loader use"
1116 );
1117 assert_eq!(
1118 serde_json::to_vec(&cold.results).expect("serialize cold results"),
1119 serde_json::to_vec(&warm.results).expect("serialize warm results"),
1120 "warm route-loader analysis must match cold analysis"
1121 );
1122 }
1123
1124 #[test]
1125 fn session_parse_surfaces_removed_source_with_sparse_file_ids() {
1126 let project = tempfile::tempdir().expect("project");
1127 let root = project.path();
1128 std::fs::create_dir(root.join("src")).expect("create source directory");
1129 std::fs::write(root.join("package.json"), r#"{"name":"read-failure"}"#)
1130 .expect("write package manifest");
1131 for name in ["a.ts", "b.ts", "c.ts"] {
1132 std::fs::write(
1133 root.join("src").join(name),
1134 format!("export const {} = 1;\n", name.replace('.', "_")),
1135 )
1136 .expect("write source");
1137 }
1138 let session = AnalysisSession::load(root, None).expect("session loads");
1139 let removed_path = root.join("src/b.ts");
1140 let removed_id = session
1141 .files()
1142 .iter()
1143 .find(|file| file.path == removed_path)
1144 .expect("removed source discovered")
1145 .id;
1146 std::fs::remove_file(&removed_path).expect("remove source after discovery");
1147
1148 let parts = session.parsed_parts(false);
1149
1150 assert!(
1151 parts
1152 .modules
1153 .iter()
1154 .all(|module| module.file_id != removed_id),
1155 "unreadable file must not receive a placeholder module"
1156 );
1157 let diagnostic = parts
1158 .workspace_diagnostics
1159 .iter()
1160 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
1161 .expect("parsed session parts carry source read failure");
1162 assert_eq!(diagnostic.path, removed_path);
1163 assert!(
1164 session
1165 .current_workspace_diagnostics()
1166 .iter()
1167 .any(|diagnostic| {
1168 diagnostic.kind.id() == "source-read-failure" && diagnostic.path == removed_path
1169 }),
1170 "session output carries parse-time source diagnostics"
1171 );
1172 }
1173}