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. When the dead-code or health section runs
56/// and the environment or `typeAware.enabled` turns on type-aware analysis
57/// that the options leave off, it returns
58/// [`super::TYPE_AWARE_CONFIG_UNSUPPORTED`].
59pub 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    // Bare `fallow` makes dead code type-aware and adds type coupling to
72    // health from config. The shared-session paths below do not pass through
73    // the checks of `run_dead_code` and `run_health`, so the check runs here.
74    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            &sections.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        // The programmatic runtime has no process-wide record of a loaded
514        // baseline to read, and its caller passes the paths itself, so there is
515        // nothing to point a re-check at here.
516        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}