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;
10#[cfg(test)]
11use fallow_types::results::AnalysisResults;
12use fallow_types::source_fingerprint::SourceFingerprint;
13use fallow_types::workspace::WorkspaceDiagnostic;
14use rustc_hash::{FxHashMap, FxHashSet};
15
16use crate::{
17 EngineResult, core_backend, duplicates,
18 project_analysis::{
19 ProjectAnalysisArtifactOptions, ProjectAnalysisArtifacts, ProjectAnalysisOutput,
20 },
21 project_config::{ProjectConfig, config_for_project, default_project_config},
22 results::{
23 DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput, DuplicationAnalysis,
24 SharedDeadCodeAnalysisArtifacts,
25 },
26};
27
28#[derive(Debug)]
34pub struct AnalysisSession {
35 config: ResolvedConfig,
36 config_path: Option<PathBuf>,
37 discovery: crate::discover::AnalysisDiscovery,
38 workspaces: Vec<WorkspaceInfo>,
39 workspace_diagnostics: Vec<WorkspaceDiagnostic>,
40 parsed_cache: Mutex<Option<ParsedModuleCache>>,
41 styling_cache: Mutex<Option<Arc<crate::health::StylingAnalysisArtifacts>>>,
42}
43
44#[derive(Debug)]
45struct ParsedModuleCache {
46 need_complexity: bool,
47 fingerprints: Vec<SourceFingerprint>,
48 modules: Arc<[ModuleInfo]>,
49}
50
51#[derive(Debug)]
53pub struct AnalysisSessionParts {
54 pub config: ResolvedConfig,
56 pub config_path: Option<PathBuf>,
58 pub files: Vec<DiscoveredFile>,
60 pub workspaces: Vec<WorkspaceInfo>,
62 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
64}
65
66#[derive(Debug)]
68pub struct ParsedAnalysisSessionParts {
69 pub config: ResolvedConfig,
71 pub config_path: Option<PathBuf>,
73 pub files: Vec<DiscoveredFile>,
75 pub modules: Vec<ModuleInfo>,
77 pub workspaces: Vec<WorkspaceInfo>,
79 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
81 pub parse_ms: f64,
83 pub cache_update_ms: f64,
85 pub cache_hits: usize,
87 pub cache_misses: usize,
89 pub parse_cpu_ms: f64,
91}
92
93#[derive(Debug)]
94pub(crate) struct SharedParsedAnalysisSessionParts {
95 pub(crate) config: ResolvedConfig,
96 pub(crate) files: Vec<DiscoveredFile>,
97 pub(crate) modules: Arc<[ModuleInfo]>,
98 pub workspaces: Vec<WorkspaceInfo>,
99 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
100 pub parse_ms: f64,
101 pub parse_cpu_ms: f64,
102}
103
104#[derive(Debug)]
106pub struct AnalysisSessionArtifacts {
107 pub analysis: DeadCodeAnalysisArtifacts,
109 pub changed_files: Option<FxHashSet<PathBuf>>,
111 pub source_fingerprints: FxHashMap<PathBuf, SourceFingerprint>,
113}
114
115impl AnalysisSession {
116 pub fn load(root: &Path, config_path: Option<&Path>) -> EngineResult<Self> {
122 let project_config = config_for_project(root, config_path)?;
123 Ok(Self::from_config(project_config))
124 }
125
126 pub fn load_with_config(
133 root: &Path,
134 config_path: Option<&Path>,
135 configure: impl FnOnce(&mut ResolvedConfig),
136 ) -> EngineResult<Self> {
137 Self::load_with_config_options(
138 root,
139 config_path,
140 fallow_config::ConfigLoadOptions::default(),
141 configure,
142 )
143 }
144
145 pub fn load_with_config_options(
152 root: &Path,
153 config_path: Option<&Path>,
154 load_options: fallow_config::ConfigLoadOptions,
155 configure: impl FnOnce(&mut ResolvedConfig),
156 ) -> EngineResult<Self> {
157 let mut project_config = crate::project_config::config_for_project_with_load_options(
158 root,
159 config_path,
160 load_options,
161 )?;
162 configure(&mut project_config.config);
163 project_config.workspaces.clear();
164 project_config.workspace_diagnostics.clear();
165 project_config.workspace_discovery_ms = None;
166 Ok(Self::from_config(project_config))
167 }
168
169 #[must_use]
174 pub fn load_default(root: &Path) -> Self {
175 Self::from_config(default_project_config(root))
176 }
177
178 #[must_use]
180 pub fn from_config(project_config: ProjectConfig) -> Self {
181 let uses_preloaded_workspaces = project_config.workspace_discovery_ms.is_some();
182 let discovery = if let Some(workspace_discovery_ms) = project_config.workspace_discovery_ms
183 {
184 crate::discover::prepare_analysis_discovery_with_workspaces(
185 &project_config.config,
186 &project_config.workspaces,
187 workspace_discovery_ms,
188 )
189 } else {
190 crate::discover::prepare_analysis_discovery(&project_config.config)
191 };
192 let workspaces = if uses_preloaded_workspaces {
193 project_config.workspaces
194 } else {
195 discovery.workspaces().to_vec()
196 };
197 let workspace_diagnostics = merge_workspace_diagnostics(
198 project_config.workspace_diagnostics,
199 fallow_config::workspace_diagnostics_for(&project_config.config.root),
200 );
201 Self {
202 config: project_config.config,
203 config_path: project_config.path,
204 discovery,
205 workspaces,
206 workspace_diagnostics,
207 parsed_cache: Mutex::new(None),
208 styling_cache: Mutex::new(None),
209 }
210 }
211
212 pub fn from_resolved_config(config: ResolvedConfig) -> EngineResult<Self> {
220 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
221 crate::project_config::collect_workspace_metadata(&config)?;
222 Ok(Self::from_config(ProjectConfig {
223 config,
224 path: None,
225 workspaces,
226 workspace_diagnostics,
227 workspace_discovery_ms: Some(workspace_discovery_ms),
228 }))
229 }
230
231 #[must_use]
233 pub fn root(&self) -> &Path {
234 &self.config.root
235 }
236
237 #[must_use]
239 pub fn config(&self) -> &ResolvedConfig {
240 &self.config
241 }
242
243 #[must_use]
245 pub fn config_path(&self) -> Option<&Path> {
246 self.config_path.as_deref()
247 }
248
249 #[must_use]
251 pub fn files(&self) -> &[DiscoveredFile] {
252 self.discovery.files()
253 }
254
255 #[must_use]
257 pub fn workspaces(&self) -> &[WorkspaceInfo] {
258 &self.workspaces
259 }
260
261 #[must_use]
263 fn source_fingerprints(&self) -> FxHashMap<PathBuf, SourceFingerprint> {
264 self.discovery
265 .files()
266 .iter()
267 .map(|file| {
268 let fingerprint = std::fs::metadata(&file.path).map_or_else(
269 |_| SourceFingerprint::new(0, file.size_bytes),
270 |metadata| SourceFingerprint::from_metadata(&metadata),
271 );
272 (file.path.clone(), fingerprint)
273 })
274 .collect()
275 }
276
277 pub(crate) fn changed_files_since(
284 &self,
285 git_ref: &str,
286 ) -> Result<FxHashSet<PathBuf>, crate::changed_files::ChangedFilesError> {
287 crate::changed_files::changed_files(&self.config.root, git_ref)
288 }
289
290 #[must_use]
292 pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
293 &self.workspace_diagnostics
294 }
295
296 #[must_use]
299 pub fn current_workspace_diagnostics(&self) -> Vec<WorkspaceDiagnostic> {
300 merge_workspace_diagnostics(
301 self.workspace_diagnostics.clone(),
302 fallow_config::workspace_diagnostics_for(&self.config.root),
303 )
304 }
305
306 pub(crate) fn styling_analysis_artifacts(
307 &self,
308 ) -> Arc<crate::health::StylingAnalysisArtifacts> {
309 if let Ok(cache) = self.styling_cache.lock()
310 && let Some(artifacts) = cache.as_ref()
311 {
312 return Arc::clone(artifacts);
313 }
314
315 let artifacts = Arc::new(crate::health::build_styling_analysis_artifacts(
316 self.files(),
317 self.config(),
318 ));
319 if let Ok(mut cache) = self.styling_cache.lock() {
320 *cache = Some(Arc::clone(&artifacts));
321 }
322 artifacts
323 }
324
325 #[must_use]
327 pub fn into_parts(self) -> AnalysisSessionParts {
328 let workspace_diagnostics = self.current_workspace_diagnostics();
329 AnalysisSessionParts {
330 config: self.config,
331 config_path: self.config_path,
332 files: self.discovery.into_files(),
333 workspaces: self.workspaces,
334 workspace_diagnostics,
335 }
336 }
337
338 #[must_use]
340 pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
341 let AnalysisSessionParts {
342 config,
343 config_path,
344 files,
345 workspaces,
346 workspace_diagnostics,
347 } = self.into_parts();
348 let ParsedModules {
349 modules,
350 metrics,
351 source_diagnostics,
352 } = parse_files_with_config(&config, &files, need_complexity);
353 ParsedAnalysisSessionParts {
354 config,
355 config_path,
356 files,
357 modules,
358 workspaces,
359 workspace_diagnostics: merge_workspace_diagnostics(
360 workspace_diagnostics,
361 source_diagnostics,
362 ),
363 parse_ms: metrics.parse_ms,
364 cache_update_ms: metrics.cache_ms,
365 cache_hits: metrics.cache_hits,
366 cache_misses: metrics.cache_misses,
367 parse_cpu_ms: metrics.parse_cpu_ms,
368 }
369 }
370
371 #[must_use]
373 pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
374 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
375 self.parsed_parts_from_modules(modules.to_vec(), metrics)
376 }
377
378 #[must_use]
380 pub(crate) fn shared_parsed_parts(
381 &self,
382 need_complexity: bool,
383 ) -> SharedParsedAnalysisSessionParts {
384 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
385 SharedParsedAnalysisSessionParts {
386 config: self.config.clone(),
387 files: self.discovery.files().to_vec(),
388 modules,
389 workspaces: self.workspaces.clone(),
390 workspace_diagnostics: self.current_workspace_diagnostics(),
391 parse_ms: metrics.parse_ms,
392 parse_cpu_ms: metrics.parse_cpu_ms,
393 }
394 }
395
396 #[doc(hidden)]
402 #[must_use]
403 pub(crate) fn shared_parsed_modules(&self, need_complexity: bool) -> Arc<[ModuleInfo]> {
404 self.parse_modules(need_complexity).modules
405 }
406
407 #[must_use]
410 pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
411 let ParsedModules {
412 modules,
413 metrics,
414 source_diagnostics: _,
415 } = parse_files_with_config(&self.config, self.files(), need_complexity);
416 self.parsed_parts_from_modules(modules, metrics)
417 }
418
419 fn parsed_parts_from_modules(
420 &self,
421 modules: Vec<ModuleInfo>,
422 metrics: core_backend::ParseMetrics,
423 ) -> ParsedAnalysisSessionParts {
424 ParsedAnalysisSessionParts {
425 config: self.config.clone(),
426 config_path: self.config_path.clone(),
427 files: self.discovery.files().to_vec(),
428 modules,
429 workspaces: self.workspaces.clone(),
430 workspace_diagnostics: self.current_workspace_diagnostics(),
431 parse_ms: metrics.parse_ms,
432 cache_update_ms: metrics.cache_ms,
433 cache_hits: metrics.cache_hits,
434 cache_misses: metrics.cache_misses,
435 parse_cpu_ms: metrics.parse_cpu_ms,
436 }
437 }
438
439 pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
445 self.analyze_dead_code_with_artifacts(false, false)
446 .map(|output| DeadCodeAnalysis {
447 results: output.results,
448 })
449 }
450
451 pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
457 self.analyze_dead_code_with_artifacts(true, false)
458 .map(|output| DeadCodeAnalysisOutput {
459 results: output.results,
460 modules: output.modules,
461 files: output.files,
462 })
463 }
464
465 pub fn analyze_dead_code_with_artifacts(
471 &self,
472 need_complexity: bool,
473 retain_graph: bool,
474 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
475 self.analyze_dead_code_with_shared_artifacts(need_complexity, retain_graph)
476 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
477 }
478
479 #[doc(hidden)]
489 pub fn analyze_dead_code_with_shared_artifacts(
490 &self,
491 need_complexity: bool,
492 retain_graph: bool,
493 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
494 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
495 }
496
497 pub fn analyze_dead_code_retaining_files(
504 &self,
505 need_complexity: bool,
506 retain_graph: bool,
507 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
508 self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
509 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
510 }
511
512 pub fn analyze_dead_code_with_parsed_modules(
521 &self,
522 modules: &[ModuleInfo],
523 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
524 self.analyze_dead_code_with_shared_modules(Arc::from(modules))
525 }
526
527 #[doc(hidden)]
533 pub(crate) fn analyze_dead_code_with_shared_modules(
534 &self,
535 modules: Arc<[ModuleInfo]>,
536 ) -> EngineResult<DeadCodeAnalysisArtifacts> {
537 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
538 config: &self.config,
539 discovery: &self.discovery,
540 modules,
541 metrics: reused_parse_metrics(),
542 collect_usages: true,
543 retain_graph: true,
544 retain_modules: false,
545 retain_files: false,
546 })
547 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
548 }
549
550 fn analyze_dead_code_with_reuse_artifacts(
551 &self,
552 need_complexity: bool,
553 retain_graph: bool,
554 retain_files: bool,
555 ) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
556 let SharedParsedModules { modules, metrics } = self.parse_modules(need_complexity);
557 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
558 config: &self.config,
559 discovery: &self.discovery,
560 modules,
561 metrics,
562 collect_usages: true,
563 retain_graph,
564 retain_modules: need_complexity,
565 retain_files,
566 })
567 }
568
569 pub fn analyze_dead_code_with_session_artifacts(
580 &self,
581 need_complexity: bool,
582 retain_graph: bool,
583 changed_files: Option<FxHashSet<PathBuf>>,
584 ) -> EngineResult<AnalysisSessionArtifacts> {
585 Ok(AnalysisSessionArtifacts {
586 analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
587 changed_files,
588 source_fingerprints: self.source_fingerprints(),
589 })
590 }
591
592 #[must_use]
594 pub fn find_duplicates(&self) -> duplicates::DuplicationReport {
595 duplicates::find_duplicates(&self.config.root, self.files(), &self.config.duplicates)
596 }
597
598 #[must_use]
600 pub fn find_duplicates_with(&self, config: &DuplicatesConfig) -> duplicates::DuplicationReport {
601 duplicates::find_duplicates(&self.config.root, self.files(), config)
602 }
603
604 pub fn analyze_project_with(
613 &self,
614 duplicates_config: &DuplicatesConfig,
615 retain_complexity_artifacts: bool,
616 ) -> EngineResult<ProjectAnalysisOutput> {
617 self.analyze_project_with_artifacts(
618 duplicates_config,
619 ProjectAnalysisArtifactOptions {
620 retain_complexity_artifacts,
621 ..ProjectAnalysisArtifactOptions::default()
622 },
623 )
624 .map(ProjectAnalysisArtifacts::into_output)
625 }
626
627 pub fn analyze_project_with_artifacts(
637 &self,
638 duplicates_config: &DuplicatesConfig,
639 options: ProjectAnalysisArtifactOptions,
640 ) -> EngineResult<ProjectAnalysisArtifacts> {
641 let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
642 let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
643 let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
644 self.find_duplicates_touching_files_with_defaults(
645 duplicates_config,
646 &changed_files,
647 cache_dir,
648 )
649 .report
650 } else {
651 self.find_duplicates_with_defaults(duplicates_config, cache_dir)
652 .report
653 };
654 let source_fingerprints = options
655 .collect_source_fingerprints
656 .then(|| self.source_fingerprints());
657 Ok(ProjectAnalysisArtifacts {
658 dead_code: self.analyze_dead_code_with_artifacts(
659 options.retain_complexity_artifacts,
660 options.retain_graph,
661 )?,
662 duplication,
663 changed_files: options.changed_files,
664 source_fingerprints,
665 })
666 }
667
668 #[must_use]
670 pub fn find_duplicates_with_defaults(
671 &self,
672 config: &DuplicatesConfig,
673 cache_dir: Option<&Path>,
674 ) -> DuplicationAnalysis {
675 duplicates::find_duplicates_with_defaults(
676 &self.config.root,
677 self.files(),
678 config,
679 cache_dir,
680 )
681 }
682
683 #[must_use]
685 pub fn find_duplicates_touching_files_with_defaults(
686 &self,
687 config: &DuplicatesConfig,
688 changed_files: &[PathBuf],
689 cache_dir: Option<&Path>,
690 ) -> DuplicationAnalysis {
691 duplicates::find_duplicates_touching_files_with_defaults(
692 &self.config.root,
693 self.files(),
694 config,
695 changed_files,
696 cache_dir,
697 )
698 }
699
700 fn parse_modules(&self, need_complexity: bool) -> SharedParsedModules {
701 let fingerprints = source_fingerprints_for_files(self.files());
702 if let Some(fingerprints) = fingerprints.as_ref()
703 && let Some(modules) = self.cached_modules(need_complexity, fingerprints)
704 {
705 return SharedParsedModules {
706 modules,
707 metrics: core_backend::ParseMetrics {
708 parse_ms: 0.0,
709 cache_ms: 0.0,
710 cache_hits: 0,
711 cache_misses: 0,
712 parse_cpu_ms: 0.0,
713 },
714 };
715 }
716
717 let ParsedModules {
718 modules,
719 metrics,
720 source_diagnostics: _,
721 } = parse_files_with_config(&self.config, self.files(), need_complexity);
722 let modules: Arc<[ModuleInfo]> = modules.into();
723 if let Some(fingerprints) = fingerprints
724 && let Ok(mut cache) = self.parsed_cache.lock()
725 {
726 *cache = Some(ParsedModuleCache {
727 need_complexity,
728 fingerprints,
729 modules: Arc::clone(&modules),
730 });
731 }
732 SharedParsedModules { modules, metrics }
733 }
734
735 fn cached_modules(
736 &self,
737 need_complexity: bool,
738 fingerprints: &[SourceFingerprint],
739 ) -> Option<Arc<[ModuleInfo]>> {
740 let Ok(cache) = self.parsed_cache.lock() else {
741 return None;
742 };
743 let cache = cache.as_ref()?;
744 let complexity_mode_satisfies_request = cache.need_complexity || !need_complexity;
745 if complexity_mode_satisfies_request && cache.fingerprints == fingerprints {
746 return Some(Arc::clone(&cache.modules));
747 }
748 None
749 }
750}
751
752fn merge_workspace_diagnostics(
753 primary: Vec<WorkspaceDiagnostic>,
754 secondary: Vec<WorkspaceDiagnostic>,
755) -> Vec<WorkspaceDiagnostic> {
756 let mut merged = Vec::with_capacity(primary.len() + secondary.len());
757 let mut seen: FxHashSet<(String, PathBuf)> = FxHashSet::default();
758 for diagnostic in primary.into_iter().chain(secondary) {
759 let key = (diagnostic.kind.id().to_owned(), diagnostic.path.clone());
760 if seen.insert(key) {
761 merged.push(diagnostic);
762 }
763 }
764 merged
765}
766
767struct ParsedModules {
768 modules: Vec<ModuleInfo>,
769 metrics: core_backend::ParseMetrics,
770 source_diagnostics: Vec<WorkspaceDiagnostic>,
771}
772
773struct SharedParsedModules {
774 modules: Arc<[ModuleInfo]>,
775 metrics: core_backend::ParseMetrics,
776}
777
778fn parse_files_with_config(
779 config: &ResolvedConfig,
780 files: &[DiscoveredFile],
781 need_complexity: bool,
782) -> ParsedModules {
783 let parse_start = Instant::now();
784 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
785 let mut cache = if config.no_cache {
786 None
787 } else {
788 fallow_extract::cache::CacheStore::load(
789 &config.cache_dir,
790 config.cache_config_hash,
791 cache_max_size_bytes,
792 )
793 };
794 let parse_result = crate::source::parse_all_files(files, cache.as_ref(), need_complexity);
795 let source_diagnostics =
796 fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
797 let mut modules = parse_result.modules;
798 for module in &mut modules {
799 module.prepare_analysis_facts();
800 }
801 let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
802 let cache_ms = update_parse_cache_if_enabled(config, &mut cache, &modules, files);
803 let metrics = core_backend::ParseMetrics {
804 parse_ms,
805 cache_ms,
806 cache_hits: parse_result.cache_hits,
807 cache_misses: parse_result.cache_misses,
808 parse_cpu_ms: parse_result.parse_cpu_ms,
809 };
810 ParsedModules {
811 modules,
812 metrics,
813 source_diagnostics,
814 }
815}
816
817fn reused_parse_metrics() -> core_backend::ParseMetrics {
818 core_backend::ParseMetrics {
819 parse_ms: 0.0,
820 cache_ms: 0.0,
821 cache_hits: 0,
822 cache_misses: 0,
823 parse_cpu_ms: 0.0,
824 }
825}
826
827fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
828 files
829 .iter()
830 .map(|file| {
831 std::fs::metadata(&file.path)
832 .ok()
833 .map(|metadata| SourceFingerprint::from_metadata(&metadata))
834 .filter(|fingerprint| fingerprint.has_known_mtime())
835 })
836 .collect()
837}
838
839fn update_parse_cache_if_enabled(
840 config: &ResolvedConfig,
841 cache: &mut Option<fallow_extract::cache::CacheStore>,
842 modules: &[ModuleInfo],
843 files: &[DiscoveredFile],
844) -> f64 {
845 let start = Instant::now();
846 if config.no_cache {
847 return start.elapsed().as_secs_f64() * 1000.0;
848 }
849
850 let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
851 let store = cache.get_or_insert_with(fallow_extract::cache::CacheStore::new);
852 if update_parse_cache(store, modules, files)
853 && let Err(error) = store.save(
854 &config.cache_dir,
855 config.cache_config_hash,
856 cache_max_size_bytes,
857 )
858 {
859 tracing::warn!("Failed to save cache: {error}");
860 }
861 start.elapsed().as_secs_f64() * 1000.0
862}
863
864fn update_parse_cache(
865 store: &mut fallow_extract::cache::CacheStore,
866 modules: &[ModuleInfo],
867 files: &[DiscoveredFile],
868) -> bool {
869 let mut dirty = false;
870 for module in modules {
871 if let Some(file) = files.get(module.file_id.0 as usize) {
872 let fingerprint = source_fingerprint(&file.path);
873 if let Some(cached) = store.get_by_path_only(&file.path)
874 && cached.content_hash == module.content_hash
875 {
876 if cached.source_fingerprint() != fingerprint {
877 let preserved_last_access = cached.last_access_secs;
878 let mut refreshed =
879 fallow_extract::cache::module_to_cached(module, fingerprint);
880 refreshed.last_access_secs = preserved_last_access;
881 store.insert(&file.path, refreshed);
882 dirty = true;
883 }
884 continue;
885 }
886 store.insert(
887 &file.path,
888 fallow_extract::cache::module_to_cached(module, fingerprint),
889 );
890 dirty = true;
891 }
892 }
893 store.retain_paths(files) || dirty
894}
895
896fn source_fingerprint(path: &Path) -> SourceFingerprint {
897 std::fs::metadata(path).map_or_else(
898 |_| SourceFingerprint::new(0, 0),
899 |metadata| SourceFingerprint::from_metadata(&metadata),
900 )
901}
902
903struct EngineDeadCodePipelineInput<'a> {
904 config: &'a ResolvedConfig,
905 discovery: &'a crate::discover::AnalysisDiscovery,
906 modules: Arc<[ModuleInfo]>,
907 metrics: core_backend::ParseMetrics,
908 collect_usages: bool,
909 retain_graph: bool,
910 retain_modules: bool,
911 retain_files: bool,
912}
913
914fn run_engine_owned_dead_code_pipeline(
915 input: EngineDeadCodePipelineInput<'_>,
916) -> EngineResult<SharedDeadCodeAnalysisArtifacts> {
917 let EngineDeadCodePipelineInput {
918 config,
919 discovery,
920 modules,
921 metrics,
922 collect_usages,
923 retain_graph,
924 retain_modules,
925 retain_files,
926 } = input;
927 let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
928 let prelude_timings = prelude.timings();
929 let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
930 let (resolved, graph) = resolve_or_build_dead_code_graph(&prelude, &entry_points, &modules);
931
932 let mut detector = core_backend::run_dead_code_detectors(
933 &prelude,
934 &graph.graph,
935 &resolved.project.modules,
936 &modules,
937 collect_usages,
938 &entry_points,
939 );
940 crate::dead_code::filter_configured_ignored_findings(&mut detector.results, config);
941 let profile =
942 core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
943 retain_timings: retain_graph,
944 prelude: &prelude,
945 prelude_timings,
946 parse_metrics: metrics,
947 module_count: modules.len(),
948 entry_points: &entry_points,
949 resolved: &resolved,
950 graph: &graph,
951 detector: &detector,
952 file_count: discovery.files().len(),
953 workspace_count: discovery.workspaces().len(),
954 });
955 let script_used_packages = prelude.script_used_packages();
956 prelude.finish();
957 let file_hashes = collect_file_hashes(&modules, discovery.files());
958
959 Ok(SharedDeadCodeAnalysisArtifacts {
960 results: detector.results,
961 timings: profile.timings,
962 graph: retain_graph.then_some(graph.graph),
963 modules: retain_modules.then_some(modules),
964 files: retain_files.then(|| discovery.files().to_vec()),
965 script_used_packages,
966 file_hashes,
967 })
968}
969
970fn resolve_or_build_dead_code_graph(
971 prelude: &core_backend::DeadCodeBackendPrelude,
972 entry_points: &core_backend::DeadCodeEntryPoints,
973 modules: &[ModuleInfo],
974) -> (
975 core_backend::DeadCodeResolvedModules,
976 core_backend::DeadCodeGraphRun,
977) {
978 if let Some((resolved, graph)) =
979 core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules)
980 {
981 return (resolved, graph);
982 }
983
984 let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
985 let graph =
986 core_backend::build_dead_code_graph(prelude, &resolved.project, entry_points, modules);
987 (resolved, graph)
988}
989
990fn collect_file_hashes(
991 modules: &[ModuleInfo],
992 files: &[DiscoveredFile],
993) -> FxHashMap<PathBuf, u64> {
994 modules
995 .iter()
996 .filter_map(|module| {
997 files
998 .get(module.file_id.0 as usize)
999 .map(|file| (file.path.clone(), module.content_hash))
1000 })
1001 .collect()
1002}
1003
1004pub(crate) fn analyze_dead_code_with_parse_result_from_config(
1005 config: &ResolvedConfig,
1006 modules: &[ModuleInfo],
1007) -> EngineResult<DeadCodeAnalysisArtifacts> {
1008 let (workspaces, _diagnostics, workspaces_ms) =
1009 crate::project_config::collect_workspace_metadata(config)?;
1010 let discovery = crate::discover::prepare_analysis_discovery_with_workspaces(
1011 config,
1012 &workspaces,
1013 workspaces_ms,
1014 );
1015 run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
1016 config,
1017 discovery: &discovery,
1018 modules: Arc::from(modules),
1019 metrics: reused_parse_metrics(),
1020 collect_usages: true,
1021 retain_graph: true,
1022 retain_modules: false,
1023 retain_files: false,
1024 })
1025 .map(SharedDeadCodeAnalysisArtifacts::into_owned)
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031
1032 fn session_with_source(source: &str) -> (tempfile::TempDir, AnalysisSession) {
1033 let project = tempfile::tempdir().expect("project");
1034 let root = project.path();
1035 std::fs::create_dir(root.join("src")).expect("create source directory");
1036 std::fs::write(root.join("src/index.ts"), source).expect("write source");
1037 let session = AnalysisSession::load_default(root);
1038 (project, session)
1039 }
1040
1041 #[test]
1042 fn session_retains_workspace_metadata_from_config_load() {
1043 let project = tempfile::tempdir().expect("project");
1044 let root = project.path();
1045 std::fs::write(
1046 root.join("package.json"),
1047 r#"{"name":"root","workspaces":["packages/*"]}"#,
1048 )
1049 .expect("write root package");
1050 std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
1051 std::fs::write(
1052 root.join("packages/a/package.json"),
1053 r#"{"name":"pkg-a","type":"module"}"#,
1054 )
1055 .expect("write workspace package");
1056
1057 let session = AnalysisSession::load(root, None).expect("session loads");
1058
1059 assert!(
1060 session
1061 .workspaces()
1062 .iter()
1063 .any(|workspace| workspace.name == "pkg-a"),
1064 "session must retain workspace metadata discovered during config load"
1065 );
1066 }
1067
1068 #[test]
1069 fn finding_ignore_filters_results_without_removing_graph_inputs() {
1070 let project = tempfile::tempdir().expect("project");
1071 let root = project.path();
1072 std::fs::create_dir(root.join("src")).expect("create source directory");
1073 std::fs::write(
1074 root.join("package.json"),
1075 r#"{"name":"finding-ignore","devDependencies":{"vitest":"latest"}}"#,
1076 )
1077 .expect("write package manifest");
1078 std::fs::write(
1079 root.join("vitest.config.ts"),
1080 "import './src/feature';\nexport default {};\n",
1081 )
1082 .expect("write vitest config");
1083 std::fs::write(
1084 root.join("src/feature.ts"),
1085 "export const feature = true;\n",
1086 )
1087 .expect("write reachable source");
1088 std::fs::write(root.join("src/hidden.ts"), "export const hidden = true;\n")
1089 .expect("write hidden source");
1090
1091 let unfiltered = AnalysisSession::load(root, None)
1092 .expect("unfiltered session loads")
1093 .analyze_dead_code()
1094 .expect("unfiltered analysis succeeds");
1095 assert!(
1096 unfiltered
1097 .results
1098 .unused_files
1099 .iter()
1100 .any(|finding| finding.file.path.ends_with("src/hidden.ts"))
1101 );
1102
1103 std::fs::write(
1104 root.join(".fallowrc.json"),
1105 r#"{"ignoreFindings":["src/hidden.ts"]}"#,
1106 )
1107 .expect("write fallow config");
1108 let session = AnalysisSession::load(root, None).expect("filtered session loads");
1109 let hidden_path = root.join("src/hidden.ts");
1110 assert!(session.files().iter().any(|file| file.path == hidden_path));
1111
1112 let filtered = session
1113 .analyze_dead_code_with_artifacts(false, true)
1114 .expect("filtered analysis succeeds");
1115 assert!(
1116 filtered
1117 .results
1118 .unused_files
1119 .iter()
1120 .all(|finding| finding.file.path != hidden_path)
1121 );
1122 assert!(
1123 filtered
1124 .graph
1125 .as_ref()
1126 .is_some_and(|graph| graph.module_count() == session.files().len())
1127 );
1128 }
1129
1130 #[test]
1131 fn finding_ignore_normalizes_separators_and_rejects_outside_paths() {
1132 use fallow_types::output_dead_code::UnusedFileFinding;
1133 use fallow_types::results::UnusedFile;
1134
1135 let project = tempfile::tempdir().expect("project");
1136 let config = serde_json::from_str::<fallow_config::FallowConfig>(
1137 r#"{"ignoreFindings":["**/*.ts"]}"#,
1138 )
1139 .expect("config parses")
1140 .resolve(
1141 project.path().to_path_buf(),
1142 fallow_config::OutputFormat::Human,
1143 1,
1144 true,
1145 true,
1146 None,
1147 );
1148 let outside = project
1149 .path()
1150 .parent()
1151 .expect("project has parent")
1152 .join("outside.ts");
1153 let mut results = AnalysisResults {
1154 unused_files: vec![
1155 UnusedFileFinding::with_actions(UnusedFile {
1156 path: PathBuf::from(r"src\hidden.ts"),
1157 }),
1158 UnusedFileFinding::with_actions(UnusedFile {
1159 path: outside.clone(),
1160 }),
1161 ],
1162 ..AnalysisResults::default()
1163 };
1164
1165 crate::dead_code::filter_configured_ignored_findings(&mut results, &config);
1166
1167 assert_eq!(results.unused_files.len(), 1);
1168 assert_eq!(results.unused_files[0].file.path, outside);
1169 }
1170
1171 #[test]
1172 fn warm_parse_cache_reuses_module_storage() {
1173 let (_project, session) = session_with_source("export function value() { return 1; }\n");
1174 let first = session.parse_modules(true);
1175 let second = session.parse_modules(false);
1176
1177 assert!(
1178 Arc::ptr_eq(&first.modules, &second.modules),
1179 "warm session queries must share parsed module storage"
1180 );
1181 }
1182
1183 #[test]
1184 fn warm_styling_cache_reuses_artifact_allocation() {
1185 let project = tempfile::tempdir().expect("project");
1186 let root = project.path();
1187 std::fs::write(root.join("styles.css"), ".button { color: red; }\n")
1188 .expect("write stylesheet");
1189 let session = AnalysisSession::load_default(root);
1190
1191 let first = session.styling_analysis_artifacts();
1192 let second = session.styling_analysis_artifacts();
1193
1194 assert!(
1195 Arc::ptr_eq(&first, &second),
1196 "warm styling queries must share the cached artifact allocation"
1197 );
1198 }
1199
1200 #[test]
1201 fn shared_parsed_modules_reuse_public_session_storage() {
1202 let (_project, session) = session_with_source("export const value = 1;\n");
1203 let first = session.shared_parsed_modules(true);
1204 let second = session.shared_parsed_modules(false);
1205
1206 assert!(Arc::ptr_eq(&first, &second));
1207 }
1208
1209 #[test]
1210 fn parsed_parts_keep_owned_module_compatibility() {
1211 let (_project, session) = session_with_source("export const value = 1;\n");
1212 let parts: ParsedAnalysisSessionParts = session.parsed_parts(false);
1213
1214 let _: Vec<ModuleInfo> = parts.modules;
1215 }
1216
1217 #[test]
1218 fn shared_parsed_parts_reuse_public_session_storage() {
1219 let (_project, session) = session_with_source("export const value = 1;\n");
1220 let cached = session.shared_parsed_modules(true);
1221 let parts = session.shared_parsed_parts(false);
1222
1223 assert!(Arc::ptr_eq(&cached, &parts.modules));
1224 }
1225
1226 #[test]
1227 fn warm_complexity_artifacts_reuse_cached_module_storage() {
1228 let (_project, session) = session_with_source("export function value() { return 1; }\n");
1229 let cached = session.parse_modules(true);
1230 let artifacts = session
1231 .analyze_dead_code_with_reuse_artifacts(true, true, false)
1232 .expect("analysis succeeds");
1233 let retained = artifacts.modules.expect("complexity modules retained");
1234
1235 assert!(
1236 Arc::ptr_eq(&cached.modules, &retained),
1237 "warm complexity artifacts must share parsed module storage"
1238 );
1239 }
1240
1241 #[test]
1242 fn shared_and_owned_artifacts_preserve_output_bytes() {
1243 let (_project, session) = session_with_source(
1244 "export const used = 1;\nexport const unused = 2;\nconsole.log(used);\n",
1245 );
1246 let owned = session
1247 .analyze_dead_code_with_artifacts(true, true)
1248 .expect("owned analysis succeeds");
1249 let shared = session
1250 .analyze_dead_code_with_shared_artifacts(true, true)
1251 .expect("shared analysis succeeds");
1252
1253 assert_eq!(
1254 serde_json::to_vec(&owned.results).expect("serialize owned results"),
1255 serde_json::to_vec(&shared.results).expect("serialize shared results")
1256 );
1257 assert_eq!(owned.file_hashes, shared.file_hashes);
1258 assert_eq!(
1259 owned
1260 .modules
1261 .as_deref()
1262 .unwrap_or_default()
1263 .iter()
1264 .map(|module| module.content_hash)
1265 .collect::<Vec<_>>(),
1266 shared
1267 .modules
1268 .as_deref()
1269 .unwrap_or_default()
1270 .iter()
1271 .map(|module| module.content_hash)
1272 .collect::<Vec<_>>()
1273 );
1274 }
1275
1276 #[test]
1277 fn route_loader_whole_use_matches_across_cold_and_warm_sessions() {
1278 let project = tempfile::tempdir().expect("project");
1279 let root = project.path();
1280 std::fs::create_dir_all(root.join("app/routes")).expect("create route directory");
1281 std::fs::write(
1282 root.join("package.json"),
1283 r#"{"name":"route-cache-parity","dependencies":{"react-router":"latest"}}"#,
1284 )
1285 .expect("write package manifest");
1286 std::fs::write(
1287 root.join("app/routes/home.tsx"),
1288 r#"
1289import { useLoaderData } from "react-router";
1290export function loader() { return { opaque: "value" }; }
1291export default function Home() {
1292 const data = useLoaderData<typeof loader>();
1293 const copy = { ...data };
1294 return JSON.stringify(copy);
1295}
1296"#,
1297 )
1298 .expect("write route module");
1299
1300 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
1301 let cold_parse = cold_session.parsed_parts(false);
1302 assert_eq!(cold_parse.cache_hits, 0, "first parse must be cold");
1303 let cold = cold_session
1304 .analyze_dead_code()
1305 .expect("cold analysis succeeds");
1306
1307 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
1308 let warm_parse = warm_session.parsed_parts(false);
1309 assert!(
1310 warm_parse.cache_hits > 0,
1311 "second session must use disk cache"
1312 );
1313 let warm = warm_session
1314 .analyze_dead_code()
1315 .expect("warm analysis succeeds");
1316
1317 assert!(
1318 cold.results.unused_load_data_keys.is_empty(),
1319 "cold analysis must abstain for an opaque route-loader use"
1320 );
1321 assert_eq!(
1322 serde_json::to_vec(&cold.results).expect("serialize cold results"),
1323 serde_json::to_vec(&warm.results).expect("serialize warm results"),
1324 "warm route-loader analysis must match cold analysis"
1325 );
1326 }
1327
1328 #[test]
1329 fn replaced_module_coverage_matches_across_cold_and_warm_graph_cache() {
1330 let project = tempfile::tempdir().expect("project");
1331 let root = project.path();
1332 std::fs::create_dir(root.join("src")).expect("create source directory");
1333 std::fs::write(
1334 root.join("package.json"),
1335 r#"{"name":"mock-cache-parity","main":"src/index.ts","devDependencies":{"vitest":"latest"}}"#,
1336 )
1337 .expect("write package manifest");
1338 std::fs::write(
1339 root.join("src/dependency.ts"),
1340 "export function dependency() { return 'real'; }\n",
1341 )
1342 .expect("write dependency");
1343 std::fs::write(
1344 root.join("src/wrapper.ts"),
1345 "import { dependency } from './dependency';\nexport function wrapper() { return dependency(); }\n",
1346 )
1347 .expect("write wrapper");
1348 std::fs::write(
1349 root.join("src/index.ts"),
1350 "export { wrapper } from './wrapper';\n",
1351 )
1352 .expect("write entry point");
1353 std::fs::write(
1354 root.join("src/wrapper.test.ts"),
1355 r#"
1356import { vi } from "vitest";
1357vi.mock("./dependency", () => ({ dependency: () => "mock" }));
1358import { wrapper } from "./wrapper";
1359wrapper();
1360"#,
1361 )
1362 .expect("write test");
1363
1364 let cold_session = AnalysisSession::load(root, None).expect("cold session loads");
1365 let dependency_id = cold_session
1366 .files()
1367 .iter()
1368 .find(|file| file.path == root.join("src/dependency.ts"))
1369 .expect("dependency discovered")
1370 .id;
1371 let cold = cold_session
1372 .analyze_dead_code_with_artifacts(false, true)
1373 .expect("cold analysis succeeds");
1374 let cold_exports = crate::module_graph::module_value_exports(
1375 cold.graph.as_ref().expect("cold graph retained"),
1376 );
1377 assert!(
1378 fallow_graph::cache::GraphCacheStore::load(&cold_session.config().cache_dir).is_some(),
1379 "cold analysis must persist the graph cache"
1380 );
1381
1382 let warm_session = AnalysisSession::load(root, None).expect("warm session loads");
1383 let warm = warm_session
1384 .analyze_dead_code_with_artifacts(false, true)
1385 .expect("warm analysis succeeds");
1386 let warm_exports = crate::module_graph::module_value_exports(
1387 warm.graph.as_ref().expect("warm graph retained"),
1388 );
1389
1390 let dependency = cold_exports
1391 .iter()
1392 .find(|export| export.file_id == dependency_id && export.name == "dependency")
1393 .expect("dependency export retained");
1394 assert!(!dependency.test_referenced);
1395 assert_eq!(warm_exports, cold_exports);
1396 }
1397
1398 #[test]
1399 fn session_parse_surfaces_removed_source_with_sparse_file_ids() {
1400 let project = tempfile::tempdir().expect("project");
1401 let root = project.path();
1402 std::fs::create_dir(root.join("src")).expect("create source directory");
1403 std::fs::write(root.join("package.json"), r#"{"name":"read-failure"}"#)
1404 .expect("write package manifest");
1405 for name in ["a.ts", "b.ts", "c.ts"] {
1406 std::fs::write(
1407 root.join("src").join(name),
1408 format!("export const {} = 1;\n", name.replace('.', "_")),
1409 )
1410 .expect("write source");
1411 }
1412 let session = AnalysisSession::load(root, None).expect("session loads");
1413 let removed_path = root.join("src/b.ts");
1414 let removed_id = session
1415 .files()
1416 .iter()
1417 .find(|file| file.path == removed_path)
1418 .expect("removed source discovered")
1419 .id;
1420 std::fs::remove_file(&removed_path).expect("remove source after discovery");
1421
1422 let parts = session.parsed_parts(false);
1423
1424 assert!(
1425 parts
1426 .modules
1427 .iter()
1428 .all(|module| module.file_id != removed_id),
1429 "unreadable file must not receive a placeholder module"
1430 );
1431 let diagnostic = parts
1432 .workspace_diagnostics
1433 .iter()
1434 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
1435 .expect("parsed session parts carry source read failure");
1436 assert_eq!(diagnostic.path, removed_path);
1437 assert!(
1438 session
1439 .current_workspace_diagnostics()
1440 .iter()
1441 .any(|diagnostic| {
1442 diagnostic.kind.id() == "source-read-failure" && diagnostic.path == removed_path
1443 }),
1444 "session output carries parse-time source diagnostics"
1445 );
1446 }
1447}