Skip to main content

fallow_api/runtime/
combined.rs

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
52/// Run bare combined analysis through one programmatic analysis session.
53///
54/// # Errors
55///
56/// Returns a structured programmatic error for invalid options, config load
57/// failures, or analysis failures.
58pub 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            &sections.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        // The programmatic runtime has no process-wide record of a loaded
501        // baseline to read, and its caller passes the paths itself, so there is
502        // nothing to point a re-check at here.
503        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}