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