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