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::{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
50/// Run bare combined analysis through one programmatic analysis session.
51///
52/// # Errors
53///
54/// Returns a structured programmatic error for invalid options, config load
55/// failures, or analysis failures.
56pub fn run_combined(options: &CombinedOptions) -> ProgrammaticResult<CombinedProgrammaticOutput> {
57    if !(options.dead_code || options.duplication || options.health) {
58        return Err(ProgrammaticError::new(
59            "combined analysis requires at least one enabled section",
60            2,
61        )
62        .with_code("FALLOW_COMBINED_EMPTY")
63        .with_context("combined"));
64    }
65
66    let start = Instant::now();
67    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
68    resolved.install(|| {
69        resolved.ensure_not_cancelled("combined analysis")?;
70        let production_modes = resolve_effective_production_modes(&resolved, None, None, None)?;
71        let prepared = prepare_combined_options(options, production_modes);
72        let changed_files = changed_files_for_run(&resolved)?;
73        let sections = run_combined_sections(
74            options,
75            &resolved,
76            &prepared,
77            changed_files.as_ref(),
78            production_modes,
79        )?;
80
81        let next_steps = combined_next_steps(
82            sections.dead_code.as_ref(),
83            sections.duplication.as_ref(),
84            sections.health.as_ref(),
85            &sections.root,
86            sections.workspaces.as_deref(),
87        );
88
89        Ok(CombinedProgrammaticOutput {
90            dead_code: sections.dead_code,
91            duplication: sections.duplication,
92            health: sections.health,
93            root: sections.root,
94            elapsed: start.elapsed(),
95            explain: options.analysis.explain,
96            next_steps,
97            telemetry_analysis_run_id: None,
98            request_outcomes: resolved.request_outcomes(),
99        })
100    })
101}
102
103fn prepare_combined_options(
104    options: &CombinedOptions,
105    production_modes: ProductionModes,
106) -> PreparedCombinedOptions {
107    PreparedCombinedOptions {
108        dead_code: combined_dead_code_options(options, production_modes.dead_code),
109        duplication: combined_duplication_options(options, production_modes.dupes),
110        health: combined_health_options(options, production_modes.health),
111    }
112}
113
114fn run_combined_sections(
115    options: &CombinedOptions,
116    resolved: &crate::analysis_context::ProgrammaticAnalysisContext,
117    prepared: &PreparedCombinedOptions,
118    changed_files: Option<&FxHashSet<PathBuf>>,
119    production_modes: ProductionModes,
120) -> ProgrammaticResult<CombinedSectionRun> {
121    let share_health =
122        options.dead_code && options.health && production_modes.dead_code_matches_health();
123    let share_dupes =
124        options.dead_code && options.duplication && production_modes.dead_code_matches_dupes();
125    if share_health || share_dupes {
126        return run_combined_with_dead_code_session(
127            options,
128            resolved,
129            prepared,
130            changed_files,
131            share_health,
132            share_dupes,
133        );
134    }
135    run_combined_sections_isolated(options, resolved, prepared)
136}
137
138fn run_combined_with_dead_code_session(
139    options: &CombinedOptions,
140    resolved: &crate::analysis_context::ProgrammaticAnalysisContext,
141    prepared: &PreparedCombinedOptions,
142    changed_files: Option<&FxHashSet<PathBuf>>,
143    share_health: bool,
144    share_dupes: bool,
145) -> ProgrammaticResult<CombinedSectionRun> {
146    resolved.ensure_not_cancelled("config load and file discovery")?;
147    let session = super::dead_code::load_dead_code_session(&prepared.dead_code, resolved)?;
148    super::dead_code::resolve_package_map_before_analysis(resolved, &session)?;
149    if share_dupes {
150        return run_combined_with_project_artifacts(CombinedProjectArtifactRun {
151            options,
152            resolved,
153            prepared,
154            changed_files,
155            share_health,
156            session: &session,
157        });
158    }
159    let ctx = DeadCodeSessionRun {
160        options,
161        resolved,
162        prepared,
163        changed_files,
164        session: &session,
165    };
166    let (dead_code, dead_code_artifacts) =
167        run_dead_code_with_optional_artifacts(&ctx, options.health && share_health)?;
168    let duplication = run_combined_duplication(&ctx, share_dupes)?;
169    resolved.ensure_not_cancelled("the health section")?;
170    let health = run_combined_health(&ctx, share_health, dead_code_artifacts, None)?;
171    Ok(CombinedSectionRun {
172        dead_code,
173        duplication,
174        health,
175        root: session.root().to_path_buf(),
176        workspaces: Some(session.workspaces().to_vec()),
177    })
178}
179
180#[derive(Clone, Copy)]
181struct CombinedProjectArtifactRun<'a> {
182    options: &'a CombinedOptions,
183    resolved: &'a crate::analysis_context::ProgrammaticAnalysisContext,
184    prepared: &'a PreparedCombinedOptions,
185    changed_files: Option<&'a FxHashSet<PathBuf>>,
186    share_health: bool,
187    session: &'a AnalysisSession,
188}
189
190fn run_combined_with_project_artifacts(
191    run: CombinedProjectArtifactRun<'_>,
192) -> ProgrammaticResult<CombinedSectionRun> {
193    let CombinedProjectArtifactRun {
194        options,
195        resolved,
196        prepared,
197        changed_files,
198        share_health,
199        session,
200    } = run;
201    let retain_dead_code_artifacts =
202        share_health && health_may_consume_dead_code_artifacts(&prepared.health, session.config());
203    let section_start = Instant::now();
204    let project = analyze_project_artifacts_for_combined(&run, retain_dead_code_artifacts)?;
205    let dead_code = super::dead_code::run_dead_code_from_artifacts(
206        &prepared.dead_code,
207        resolved,
208        session,
209        changed_files,
210        project.dead_code,
211        section_start,
212    )?;
213    let pre_computed_duplication_for_health =
214        should_precompute_duplication_for_combined_health(options, prepared, share_health)
215            .then(|| project.duplication.clone());
216    let duplication = run_project_artifact_duplication(&run, project.duplication, section_start)?;
217    let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
218        output: dead_code,
219        artifacts,
220    } = dead_code;
221    let dead_code_artifacts = retain_dead_code_artifacts.then_some(artifacts);
222    resolved.ensure_not_cancelled("the health section")?;
223    let health = run_combined_health(
224        &DeadCodeSessionRun {
225            options,
226            resolved,
227            prepared,
228            changed_files,
229            session,
230        },
231        share_health,
232        dead_code_artifacts,
233        pre_computed_duplication_for_health,
234    )?;
235
236    Ok(CombinedSectionRun {
237        dead_code: Some(dead_code),
238        duplication,
239        health,
240        root: session.root().to_path_buf(),
241        workspaces: Some(session.workspaces().to_vec()),
242    })
243}
244
245fn run_project_artifact_duplication(
246    run: &CombinedProjectArtifactRun<'_>,
247    duplication: fallow_engine::duplicates::DuplicationReport,
248    section_start: Instant,
249) -> ProgrammaticResult<Option<crate::DuplicationProgrammaticOutput>> {
250    let CombinedProjectArtifactRun {
251        options,
252        resolved,
253        prepared,
254        changed_files,
255        session,
256        ..
257    } = *run;
258    options
259        .duplication
260        .then(|| {
261            let change_scope =
262                resolved.change_scope(changed_files, session.config(), session.workspaces())?;
263            super::duplication::run_duplication_report_with_session(
264                &prepared.duplication,
265                resolved,
266                session,
267                duplication,
268                &change_scope,
269                section_start,
270            )
271        })
272        .transpose()
273}
274
275fn should_precompute_duplication_for_combined_health(
276    options: &CombinedOptions,
277    prepared: &PreparedCombinedOptions,
278    share_health: bool,
279) -> bool {
280    options.health
281        && share_health
282        && health_may_consume_duplication_report(&prepared.health)
283        && duplication_options_preserve_health_config(&prepared.duplication)
284}
285
286fn analyze_project_artifacts_for_combined(
287    run: &CombinedProjectArtifactRun<'_>,
288    retain_dead_code_artifacts: bool,
289) -> ProgrammaticResult<ProjectAnalysisArtifacts> {
290    let dupes_config = super::duplication::build_dupes_config(
291        &run.prepared.duplication,
292        &run.session.config().duplicates,
293    );
294    run.session
295        .analyze_project_with_artifacts(
296            &dupes_config,
297            ProjectAnalysisArtifactOptions {
298                retain_complexity_artifacts: retain_dead_code_artifacts,
299                retain_graph: retain_dead_code_artifacts,
300                changed_files: run.changed_files.cloned(),
301                collect_source_fingerprints: false,
302            },
303        )
304        .map_err(|err| {
305            super::dead_code::map_engine_error(
306                &err,
307                "combined analysis failed",
308                "FALLOW_COMBINED_FAILED",
309                "combined",
310            )
311        })
312}
313
314fn run_dead_code_with_optional_artifacts(
315    ctx: &DeadCodeSessionRun<'_>,
316    share_health: bool,
317) -> ProgrammaticResult<(
318    Option<crate::DeadCodeProgrammaticOutput>,
319    Option<DeadCodeAnalysisArtifacts>,
320)> {
321    let retain_artifacts = share_health
322        && health_may_consume_dead_code_artifacts(&ctx.prepared.health, ctx.session.config());
323    if retain_artifacts {
324        let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
325            &ctx.prepared.dead_code,
326            ctx.resolved,
327            ctx.session,
328            ctx.changed_files,
329            |_| {},
330            Instant::now(),
331        )?;
332        return Ok((Some(dead_code.output), Some(dead_code.artifacts)));
333    }
334    let dead_code = super::dead_code::run_dead_code_with_session(
335        &ctx.prepared.dead_code,
336        ctx.resolved,
337        ctx.session,
338        ctx.changed_files,
339        |_| {},
340        Instant::now(),
341    )?;
342    Ok((Some(dead_code), None))
343}
344
345fn run_combined_duplication(
346    ctx: &DeadCodeSessionRun<'_>,
347    share_dupes: bool,
348) -> ProgrammaticResult<Option<crate::DuplicationProgrammaticOutput>> {
349    if !ctx.options.duplication {
350        return Ok(None);
351    }
352    if !share_dupes {
353        return run_duplication(&ctx.prepared.duplication).map(Some);
354    }
355    super::duplication::run_duplication_with_session(
356        &ctx.prepared.duplication,
357        ctx.resolved,
358        ctx.session,
359        ctx.changed_files,
360        Instant::now(),
361    )
362    .map(Some)
363}
364
365fn run_combined_health(
366    ctx: &DeadCodeSessionRun<'_>,
367    share_health: bool,
368    dead_code_artifacts: Option<DeadCodeAnalysisArtifacts>,
369    pre_computed_duplication: Option<fallow_engine::duplicates::DuplicationReport>,
370) -> ProgrammaticResult<Option<crate::HealthProgrammaticOutput>> {
371    if !ctx.options.health {
372        return Ok(None);
373    }
374    if !share_health {
375        return run_health(&ctx.prepared.health).map(Some);
376    }
377    run_health_with_session_artifacts(
378        &ctx.prepared.health,
379        ctx.resolved,
380        ctx.session,
381        ctx.changed_files,
382        dead_code_artifacts,
383        pre_computed_duplication,
384    )
385    .map(Some)
386}
387
388fn run_combined_sections_isolated(
389    options: &CombinedOptions,
390    resolved: &crate::analysis_context::ProgrammaticAnalysisContext,
391    prepared: &PreparedCombinedOptions,
392) -> ProgrammaticResult<CombinedSectionRun> {
393    Ok(CombinedSectionRun {
394        dead_code: options
395            .dead_code
396            .then(|| super::dead_code::run_dead_code(&prepared.dead_code))
397            .transpose()?,
398        duplication: options
399            .duplication
400            .then(|| run_duplication(&prepared.duplication))
401            .transpose()?,
402        health: options
403            .health
404            .then(|| run_health(&prepared.health))
405            .transpose()?,
406        root: resolved.root().to_path_buf(),
407        workspaces: None,
408    })
409}
410
411fn combined_dead_code_options(options: &CombinedOptions, production: bool) -> DeadCodeOptions {
412    DeadCodeOptions {
413        finding_ids: Vec::new(),
414        analysis: analysis_with_effective_production(&options.analysis, production),
415        filters: DeadCodeFilters::default(),
416        files: Vec::new(),
417        include_entry_exports: options.include_entry_exports,
418        show_cascade: options.show_cascade,
419    }
420}
421
422fn combined_duplication_options(options: &CombinedOptions, production: bool) -> DuplicationOptions {
423    let mut duplication = options.duplication_options.clone();
424    duplication.analysis = analysis_with_effective_production(&options.analysis, production);
425    duplication
426}
427
428fn duplication_options_preserve_health_config(options: &DuplicationOptions) -> bool {
429    options.mode.is_none()
430        && options.min_tokens.is_none()
431        && options.min_lines.is_none()
432        && options.min_occurrences.is_none()
433        && options.threshold.is_none()
434        && options.skip_local.is_none()
435        && options.ignore_symlinks.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}