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