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.ignore_symlinks.is_none()
437        && options.cross_language.is_none()
438        && options.ignore_imports.is_none()
439}
440
441fn combined_health_options(options: &CombinedOptions, production: bool) -> ComplexityOptions {
442    let mut health = options.health_options.clone();
443    health.analysis = analysis_with_effective_production(&options.analysis, production);
444    health
445}
446
447fn analysis_with_effective_production(
448    analysis: &AnalysisOptions,
449    production: bool,
450) -> AnalysisOptions {
451    AnalysisOptions {
452        production,
453        production_override: Some(production),
454        ..analysis.clone()
455    }
456}
457
458fn combined_next_steps(
459    dead_code: Option<&crate::DeadCodeProgrammaticOutput>,
460    duplication: Option<&crate::DuplicationProgrammaticOutput>,
461    health: Option<&crate::HealthProgrammaticOutput>,
462    root: &std::path::Path,
463    workspaces: Option<&[WorkspaceInfo]>,
464) -> Vec<fallow_types::output::NextStep> {
465    let clone_fingerprints = duplication
466        .map(|duplication| {
467            duplication
468                .output
469                .report
470                .clone_groups
471                .iter()
472                .map(|group| group.fingerprint.as_str())
473                .collect::<Vec<_>>()
474        })
475        .unwrap_or_default();
476    let audit_changed = audit_changed_applicable(root);
477    let workspace_ref = audit_changed
478        .then(|| {
479            workspaces.map_or_else(
480                || default_workspace_ref(root),
481                |workspaces| default_workspace_ref_for_workspaces(root, workspaces),
482            )
483        })
484        .flatten();
485    build_combined_next_steps(&CombinedNextStepsInput {
486        suggestions_enabled: suggestions_enabled(),
487        has_dead_code_findings: dead_code
488            .is_some_and(|dead_code| dead_code.output.results.total_issues() > 0),
489        trace_unused_export: dead_code.and_then(|dead_code| {
490            fallow_output::trace_unused_export_input(&dead_code.output.results, root)
491        }),
492        workspace_ref: workspace_ref.as_deref(),
493        clone_fingerprints: &clone_fingerprints,
494        has_complexity_findings: health.is_some_and(|health| !health.report.findings.is_empty()),
495        offer_setup: setup_pointer_applicable(root),
496        impact_digest: None,
497        audit_changed,
498        has_external_plugins: !fallow_config::discover_external_plugins(root, &[]).is_empty(),
499        has_unused_files: dead_code
500            .is_some_and(|dead_code| !dead_code.output.results.unused_files.is_empty()),
501        // The programmatic runtime has no process-wide record of a loaded
502        // baseline to read, and its caller passes the paths itself, so there is
503        // nothing to point a re-check at here.
504        baseline_recheck: None,
505    })
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::DuplicationMode;
512
513    #[test]
514    fn health_reuses_combined_duplication_only_without_detector_overrides() {
515        assert!(duplication_options_preserve_health_config(
516            &DuplicationOptions::default()
517        ));
518        assert!(!duplication_options_preserve_health_config(
519            &DuplicationOptions {
520                min_tokens: Some(1),
521                ..DuplicationOptions::default()
522            }
523        ));
524        assert!(!duplication_options_preserve_health_config(
525            &DuplicationOptions {
526                mode: Some(DuplicationMode::Semantic),
527                ..DuplicationOptions::default()
528            }
529        ));
530    }
531}