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