1use std::{path::PathBuf, time::Instant};
2
3use fallow_config::WorkspaceInfo;
4use fallow_engine::{
5 dead_code::DeadCodeAnalysisArtifacts,
6 project_analysis::{ProjectAnalysisArtifactOptions, ProjectAnalysisArtifacts},
7 session::AnalysisSession,
8};
9use fallow_output::{CombinedNextStepsInput, build_combined_next_steps};
10use rustc_hash::FxHashSet;
11
12use crate::{
13 AnalysisOptions, CombinedOptions, CombinedProgrammaticOutput, ComplexityOptions,
14 DeadCodeFilters, DeadCodeOptions, DuplicationOptions, ProgrammaticError,
15 analysis_context::{
16 changed_files_for_run, resolve_programmatic_analysis_context_deferred_workspace,
17 },
18 next_steps::{
19 audit_changed_applicable, default_workspace_ref, default_workspace_ref_for_workspaces,
20 setup_pointer_applicable, suggestions_enabled,
21 },
22};
23
24use super::{
25 ProductionModes, ProgrammaticResult, health_may_consume_dead_code_artifacts,
26 health_may_consume_duplication_report, resolve_effective_production_modes, run_duplication,
27 run_health, run_health_with_session_artifacts,
28};
29
30struct PreparedCombinedOptions {
31 dead_code: DeadCodeOptions,
32 duplication: DuplicationOptions,
33 health: ComplexityOptions,
34}
35
36struct CombinedSectionRun {
37 dead_code: Option<crate::DeadCodeProgrammaticOutput>,
38 duplication: Option<crate::DuplicationProgrammaticOutput>,
39 health: Option<crate::HealthProgrammaticOutput>,
40 root: PathBuf,
41 workspaces: Option<Vec<WorkspaceInfo>>,
42}
43
44struct DeadCodeSessionRun<'a> {
45 options: &'a CombinedOptions,
46 resolved: &'a crate::analysis_context::ProgrammaticAnalysisContext,
47 prepared: &'a PreparedCombinedOptions,
48 changed_files: Option<&'a FxHashSet<PathBuf>>,
49 session: &'a AnalysisSession,
50}
51
52pub fn run_combined(options: &CombinedOptions) -> ProgrammaticResult<CombinedProgrammaticOutput> {
59 if !(options.dead_code || options.duplication || options.health) {
60 return Err(ProgrammaticError::new(
61 "combined analysis requires at least one enabled section",
62 2,
63 )
64 .with_code("FALLOW_COMBINED_EMPTY")
65 .with_context("combined"));
66 }
67
68 let start = Instant::now();
69 let resolved = resolve_programmatic_analysis_context_deferred_workspace(&options.analysis)?;
70 resolved.install(|| {
71 resolved.ensure_not_cancelled("combined analysis")?;
72 let production_modes = resolve_effective_production_modes(&resolved, None, None, None)?;
73 let prepared = prepare_combined_options(options, production_modes);
74 let changed_files = changed_files_for_run(&resolved)?;
75 let sections = run_combined_sections(
76 options,
77 &resolved,
78 &prepared,
79 changed_files.as_ref(),
80 production_modes,
81 )?;
82
83 let next_steps = combined_next_steps(
84 sections.dead_code.as_ref(),
85 sections.duplication.as_ref(),
86 sections.health.as_ref(),
87 §ions.root,
88 sections.workspaces.as_deref(),
89 );
90
91 Ok(CombinedProgrammaticOutput {
92 dead_code: sections.dead_code,
93 duplication: sections.duplication,
94 health: sections.health,
95 root: sections.root,
96 elapsed: start.elapsed(),
97 explain: options.analysis.explain,
98 next_steps,
99 telemetry_analysis_run_id: None,
100 request_outcomes: resolved.request_outcomes(),
101 })
102 })
103}
104
105fn prepare_combined_options(
106 options: &CombinedOptions,
107 production_modes: ProductionModes,
108) -> PreparedCombinedOptions {
109 PreparedCombinedOptions {
110 dead_code: combined_dead_code_options(options, production_modes.dead_code),
111 duplication: combined_duplication_options(options, production_modes.dupes),
112 health: combined_health_options(options, production_modes.health),
113 }
114}
115
116fn run_combined_sections(
117 options: &CombinedOptions,
118 resolved: &crate::analysis_context::ProgrammaticAnalysisContext,
119 prepared: &PreparedCombinedOptions,
120 changed_files: Option<&FxHashSet<PathBuf>>,
121 production_modes: ProductionModes,
122) -> ProgrammaticResult<CombinedSectionRun> {
123 let share_health =
124 options.dead_code && options.health && production_modes.dead_code_matches_health();
125 let share_dupes =
126 options.dead_code && options.duplication && production_modes.dead_code_matches_dupes();
127 if share_health || share_dupes {
128 return run_combined_with_dead_code_session(
129 options,
130 resolved,
131 prepared,
132 changed_files,
133 share_health,
134 share_dupes,
135 );
136 }
137 run_combined_sections_isolated(options, resolved, prepared)
138}
139
140fn run_combined_with_dead_code_session(
141 options: &CombinedOptions,
142 resolved: &crate::analysis_context::ProgrammaticAnalysisContext,
143 prepared: &PreparedCombinedOptions,
144 changed_files: Option<&FxHashSet<PathBuf>>,
145 share_health: bool,
146 share_dupes: bool,
147) -> ProgrammaticResult<CombinedSectionRun> {
148 resolved.ensure_not_cancelled("config load and file discovery")?;
149 let session = super::dead_code::load_dead_code_session(&prepared.dead_code, resolved)?;
150 super::dead_code::resolve_package_map_before_analysis(resolved, &session)?;
151 if share_dupes {
152 return run_combined_with_project_artifacts(CombinedProjectArtifactRun {
153 options,
154 resolved,
155 prepared,
156 changed_files,
157 share_health,
158 session: &session,
159 });
160 }
161 let ctx = DeadCodeSessionRun {
162 options,
163 resolved,
164 prepared,
165 changed_files,
166 session: &session,
167 };
168 let (dead_code, dead_code_artifacts) =
169 run_dead_code_with_optional_artifacts(&ctx, options.health && share_health)?;
170 let duplication = run_combined_duplication(&ctx, share_dupes)?;
171 resolved.ensure_not_cancelled("the health section")?;
172 let health = run_combined_health(&ctx, share_health, dead_code_artifacts, None)?;
173 Ok(CombinedSectionRun {
174 dead_code,
175 duplication,
176 health,
177 root: session.root().to_path_buf(),
178 workspaces: Some(session.workspaces().to_vec()),
179 })
180}
181
182#[derive(Clone, Copy)]
183struct CombinedProjectArtifactRun<'a> {
184 options: &'a CombinedOptions,
185 resolved: &'a crate::analysis_context::ProgrammaticAnalysisContext,
186 prepared: &'a PreparedCombinedOptions,
187 changed_files: Option<&'a FxHashSet<PathBuf>>,
188 share_health: bool,
189 session: &'a AnalysisSession,
190}
191
192fn run_combined_with_project_artifacts(
193 run: CombinedProjectArtifactRun<'_>,
194) -> ProgrammaticResult<CombinedSectionRun> {
195 let CombinedProjectArtifactRun {
196 options,
197 resolved,
198 prepared,
199 changed_files,
200 share_health,
201 session,
202 } = run;
203 let retain_dead_code_artifacts =
204 share_health && health_may_consume_dead_code_artifacts(&prepared.health, session.config());
205 let section_start = Instant::now();
206 let project = analyze_project_artifacts_for_combined(&run, retain_dead_code_artifacts)?;
207 let dead_code = super::dead_code::run_dead_code_from_artifacts(
208 &prepared.dead_code,
209 resolved,
210 session,
211 changed_files,
212 project.dead_code,
213 section_start,
214 )?;
215 let pre_computed_duplication_for_health =
216 should_precompute_duplication_for_combined_health(options, prepared, share_health)
217 .then(|| project.duplication.clone());
218 let duplication = run_project_artifact_duplication(&run, project.duplication, section_start)?;
219 let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
220 output: dead_code,
221 artifacts,
222 } = dead_code;
223 let dead_code_artifacts = retain_dead_code_artifacts.then_some(artifacts);
224 resolved.ensure_not_cancelled("the health section")?;
225 let health = run_combined_health(
226 &DeadCodeSessionRun {
227 options,
228 resolved,
229 prepared,
230 changed_files,
231 session,
232 },
233 share_health,
234 dead_code_artifacts,
235 pre_computed_duplication_for_health,
236 )?;
237
238 Ok(CombinedSectionRun {
239 dead_code: Some(dead_code),
240 duplication,
241 health,
242 root: session.root().to_path_buf(),
243 workspaces: Some(session.workspaces().to_vec()),
244 })
245}
246
247fn run_project_artifact_duplication(
248 run: &CombinedProjectArtifactRun<'_>,
249 duplication: fallow_engine::duplicates::DuplicationReport,
250 section_start: Instant,
251) -> ProgrammaticResult<Option<crate::DuplicationProgrammaticOutput>> {
252 let CombinedProjectArtifactRun {
253 options,
254 resolved,
255 prepared,
256 changed_files,
257 session,
258 ..
259 } = *run;
260 options
261 .duplication
262 .then(|| {
263 let change_scope =
264 resolved.change_scope(changed_files, session.config(), session.workspaces())?;
265 super::duplication::run_duplication_report_with_session(
266 &prepared.duplication,
267 resolved,
268 session,
269 duplication,
270 &change_scope,
271 section_start,
272 )
273 })
274 .transpose()
275}
276
277fn should_precompute_duplication_for_combined_health(
278 options: &CombinedOptions,
279 prepared: &PreparedCombinedOptions,
280 share_health: bool,
281) -> bool {
282 options.health
283 && share_health
284 && health_may_consume_duplication_report(&prepared.health)
285 && duplication_options_preserve_health_config(&prepared.duplication)
286}
287
288fn analyze_project_artifacts_for_combined(
289 run: &CombinedProjectArtifactRun<'_>,
290 retain_dead_code_artifacts: bool,
291) -> ProgrammaticResult<ProjectAnalysisArtifacts> {
292 let dupes_config = super::duplication::build_dupes_config(
293 &run.prepared.duplication,
294 &run.session.config().duplicates,
295 );
296 run.session
297 .analyze_project_with_artifacts(
298 &dupes_config,
299 ProjectAnalysisArtifactOptions {
300 retain_complexity_artifacts: retain_dead_code_artifacts,
301 retain_graph: retain_dead_code_artifacts,
302 changed_files: run.changed_files.cloned(),
303 collect_source_fingerprints: false,
304 },
305 )
306 .map_err(|err| {
307 super::dead_code::map_engine_error(
308 &err,
309 "combined analysis failed",
310 "FALLOW_COMBINED_FAILED",
311 "combined",
312 )
313 })
314}
315
316fn run_dead_code_with_optional_artifacts(
317 ctx: &DeadCodeSessionRun<'_>,
318 share_health: bool,
319) -> ProgrammaticResult<(
320 Option<crate::DeadCodeProgrammaticOutput>,
321 Option<DeadCodeAnalysisArtifacts>,
322)> {
323 let retain_artifacts = share_health
324 && health_may_consume_dead_code_artifacts(&ctx.prepared.health, ctx.session.config());
325 if retain_artifacts {
326 let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
327 &ctx.prepared.dead_code,
328 ctx.resolved,
329 ctx.session,
330 ctx.changed_files,
331 |_| {},
332 Instant::now(),
333 )?;
334 return Ok((Some(dead_code.output), Some(dead_code.artifacts)));
335 }
336 let dead_code = super::dead_code::run_dead_code_with_session(
337 &ctx.prepared.dead_code,
338 ctx.resolved,
339 ctx.session,
340 ctx.changed_files,
341 |_| {},
342 Instant::now(),
343 )?;
344 Ok((Some(dead_code), None))
345}
346
347fn run_combined_duplication(
348 ctx: &DeadCodeSessionRun<'_>,
349 share_dupes: bool,
350) -> ProgrammaticResult<Option<crate::DuplicationProgrammaticOutput>> {
351 if !ctx.options.duplication {
352 return Ok(None);
353 }
354 if !share_dupes {
355 return run_duplication(&ctx.prepared.duplication).map(Some);
356 }
357 super::duplication::run_duplication_with_session(
358 &ctx.prepared.duplication,
359 ctx.resolved,
360 ctx.session,
361 ctx.changed_files,
362 Instant::now(),
363 )
364 .map(Some)
365}
366
367fn run_combined_health(
368 ctx: &DeadCodeSessionRun<'_>,
369 share_health: bool,
370 dead_code_artifacts: Option<DeadCodeAnalysisArtifacts>,
371 pre_computed_duplication: Option<fallow_engine::duplicates::DuplicationReport>,
372) -> ProgrammaticResult<Option<crate::HealthProgrammaticOutput>> {
373 if !ctx.options.health {
374 return Ok(None);
375 }
376 if !share_health {
377 return run_health(&ctx.prepared.health).map(Some);
378 }
379 run_health_with_session_artifacts(
380 &ctx.prepared.health,
381 ctx.resolved,
382 ctx.session,
383 ctx.changed_files,
384 dead_code_artifacts,
385 pre_computed_duplication,
386 )
387 .map(Some)
388}
389
390fn run_combined_sections_isolated(
391 options: &CombinedOptions,
392 resolved: &crate::analysis_context::ProgrammaticAnalysisContext,
393 prepared: &PreparedCombinedOptions,
394) -> ProgrammaticResult<CombinedSectionRun> {
395 Ok(CombinedSectionRun {
396 dead_code: options
397 .dead_code
398 .then(|| super::dead_code::run_dead_code(&prepared.dead_code))
399 .transpose()?,
400 duplication: options
401 .duplication
402 .then(|| run_duplication(&prepared.duplication))
403 .transpose()?,
404 health: options
405 .health
406 .then(|| run_health(&prepared.health))
407 .transpose()?,
408 root: resolved.root().to_path_buf(),
409 workspaces: None,
410 })
411}
412
413fn combined_dead_code_options(options: &CombinedOptions, production: bool) -> DeadCodeOptions {
414 DeadCodeOptions {
415 finding_ids: Vec::new(),
416 analysis: analysis_with_effective_production(&options.analysis, production),
417 filters: DeadCodeFilters::default(),
418 files: Vec::new(),
419 include_entry_exports: options.include_entry_exports,
420 }
421}
422
423fn combined_duplication_options(options: &CombinedOptions, production: bool) -> DuplicationOptions {
424 let mut duplication = options.duplication_options.clone();
425 duplication.analysis = analysis_with_effective_production(&options.analysis, production);
426 duplication
427}
428
429fn duplication_options_preserve_health_config(options: &DuplicationOptions) -> bool {
430 options.mode.is_none()
431 && options.min_tokens.is_none()
432 && options.min_lines.is_none()
433 && options.min_occurrences.is_none()
434 && options.threshold.is_none()
435 && options.skip_local.is_none()
436 && options.cross_language.is_none()
437 && options.ignore_imports.is_none()
438}
439
440fn combined_health_options(options: &CombinedOptions, production: bool) -> ComplexityOptions {
441 let mut health = options.health_options.clone();
442 health.analysis = analysis_with_effective_production(&options.analysis, production);
443 health
444}
445
446fn analysis_with_effective_production(
447 analysis: &AnalysisOptions,
448 production: bool,
449) -> AnalysisOptions {
450 AnalysisOptions {
451 production,
452 production_override: Some(production),
453 ..analysis.clone()
454 }
455}
456
457fn combined_next_steps(
458 dead_code: Option<&crate::DeadCodeProgrammaticOutput>,
459 duplication: Option<&crate::DuplicationProgrammaticOutput>,
460 health: Option<&crate::HealthProgrammaticOutput>,
461 root: &std::path::Path,
462 workspaces: Option<&[WorkspaceInfo]>,
463) -> Vec<fallow_types::output::NextStep> {
464 let clone_fingerprints = duplication
465 .map(|duplication| {
466 duplication
467 .output
468 .report
469 .clone_groups
470 .iter()
471 .map(|group| group.fingerprint.as_str())
472 .collect::<Vec<_>>()
473 })
474 .unwrap_or_default();
475 let audit_changed = audit_changed_applicable(root);
476 let workspace_ref = audit_changed
477 .then(|| {
478 workspaces.map_or_else(
479 || default_workspace_ref(root),
480 |workspaces| default_workspace_ref_for_workspaces(root, workspaces),
481 )
482 })
483 .flatten();
484 build_combined_next_steps(&CombinedNextStepsInput {
485 suggestions_enabled: suggestions_enabled(),
486 has_dead_code_findings: dead_code
487 .is_some_and(|dead_code| dead_code.output.results.total_issues() > 0),
488 trace_unused_export: dead_code.and_then(|dead_code| {
489 fallow_output::trace_unused_export_input(&dead_code.output.results, root)
490 }),
491 workspace_ref: workspace_ref.as_deref(),
492 clone_fingerprints: &clone_fingerprints,
493 has_complexity_findings: health.is_some_and(|health| !health.report.findings.is_empty()),
494 offer_setup: setup_pointer_applicable(root),
495 impact_digest: None,
496 audit_changed,
497 has_external_plugins: !fallow_config::discover_external_plugins(root, &[]).is_empty(),
498 has_unused_files: dead_code
499 .is_some_and(|dead_code| !dead_code.output.results.unused_files.is_empty()),
500 baseline_recheck: None,
504 })
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::DuplicationMode;
511
512 #[test]
513 fn health_reuses_combined_duplication_only_without_detector_overrides() {
514 assert!(duplication_options_preserve_health_config(
515 &DuplicationOptions::default()
516 ));
517 assert!(!duplication_options_preserve_health_config(
518 &DuplicationOptions {
519 min_tokens: Some(1),
520 ..DuplicationOptions::default()
521 }
522 ));
523 assert!(!duplication_options_preserve_health_config(
524 &DuplicationOptions {
525 mode: Some(DuplicationMode::Semantic),
526 ..DuplicationOptions::default()
527 }
528 ));
529 }
530}