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