Skip to main content

fallow_api/
editor.rs

1//! Editor-facing analysis contracts shared by LSP and future editor adapters.
2
3use std::path::{Path, PathBuf};
4
5use rustc_hash::FxHashSet;
6
7use fallow_types::{discover::DiscoveredFile, extract::ModuleInfo};
8
9/// Editor-boundary alias for the clone-family payload.
10pub type EditorCloneFamily = fallow_types::duplicates::CloneFamily;
11/// Editor-boundary alias for the clone-group payload.
12pub type EditorCloneGroup = fallow_types::duplicates::CloneGroup;
13/// Editor-boundary alias for one clone occurrence within a group.
14pub type EditorCloneInstance = fallow_types::duplicates::CloneInstance;
15/// Editor-boundary alias for the full duplication report.
16pub type EditorDuplicationReport = fallow_types::duplicates::DuplicationReport;
17/// Editor-boundary alias for aggregate duplication statistics.
18pub type EditorDuplicationStats = fallow_types::duplicates::DuplicationStats;
19/// Editor-boundary alias for a mirrored-directory finding.
20pub type EditorMirroredDirectory = fallow_types::duplicates::MirroredDirectory;
21/// Editor-boundary alias for the refactoring-suggestion kind.
22pub type EditorRefactoringKind = fallow_types::duplicates::RefactoringKind;
23/// Editor-boundary alias for a refactoring suggestion.
24pub type EditorRefactoringSuggestion = fallow_types::duplicates::RefactoringSuggestion;
25
26/// Report-scoped clone fingerprint assignment for editor-facing duplication output.
27#[derive(Debug, Clone)]
28pub struct EditorCloneFingerprintSet {
29    inner: fallow_engine::duplicates::CloneFingerprintSet,
30}
31
32impl EditorCloneFingerprintSet {
33    /// Assign collision-free fingerprints for clone groups in one report.
34    #[must_use]
35    pub fn from_groups(groups: &[EditorCloneGroup]) -> Self {
36        Self {
37            inner: fallow_engine::duplicates::CloneFingerprintSet::from_groups(groups),
38        }
39    }
40
41    /// Return the assigned fingerprint for a clone group.
42    #[must_use]
43    pub fn fingerprint_for_group(&self, group: &EditorCloneGroup) -> String {
44        self.inner.fingerprint_for_group(group)
45    }
46
47    /// Return the assigned fingerprint for clone-group parts.
48    #[must_use]
49    pub fn fingerprint_for_parts(
50        &self,
51        instances: &[EditorCloneInstance],
52        token_count: usize,
53        line_count: usize,
54    ) -> String {
55        self.inner
56            .fingerprint_for_parts(instances, token_count, line_count)
57    }
58
59    /// Find the group addressed by an assigned fingerprint.
60    #[must_use]
61    pub fn find_group<'a>(
62        &self,
63        groups: &'a [EditorCloneGroup],
64        fingerprint: &str,
65    ) -> Option<&'a EditorCloneGroup> {
66        self.inner.find_group(groups, fingerprint)
67    }
68}
69
70/// Duplication contracts re-exported under their unprefixed names so editor
71/// adapters can import them as a module namespace.
72pub mod editor_duplicates {
73    pub use crate::editor::{
74        EditorCloneFamily as CloneFamily, EditorCloneFingerprintSet as CloneFingerprintSet,
75        EditorCloneGroup as CloneGroup, EditorCloneInstance as CloneInstance,
76        EditorDuplicationReport as DuplicationReport, EditorDuplicationStats as DuplicationStats,
77        EditorMirroredDirectory as MirroredDirectory, EditorRefactoringKind as RefactoringKind,
78        EditorRefactoringSuggestion as RefactoringSuggestion,
79    };
80}
81
82/// Classification of a changed-file git failure for editor integrations.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ChangedFilesError {
85    /// Git ref failed validation before invoking `git`.
86    InvalidRef(String),
87    /// `git` binary not found or not executable.
88    GitMissing(String),
89    /// Command ran but the directory is not a git repository.
90    NotARepository,
91    /// Command ran but the ref is invalid or another git error occurred.
92    GitFailed(String),
93}
94
95impl ChangedFilesError {
96    /// Human-readable clause suitable for embedding in an error message.
97    #[must_use]
98    pub fn describe(&self) -> String {
99        match self {
100            Self::InvalidRef(err) => format!("invalid git ref: {err}"),
101            Self::GitMissing(err) => format!("failed to run git: {err}"),
102            Self::NotARepository => "not a git repository".to_owned(),
103            Self::GitFailed(stderr) => {
104                let lower = stderr.to_ascii_lowercase();
105                if lower.contains("not a valid object name")
106                    || lower.contains("unknown revision")
107                    || lower.contains("ambiguous argument")
108                {
109                    format!(
110                        "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
111                    )
112                } else {
113                    stderr.clone()
114                }
115            }
116        }
117    }
118}
119
120impl From<fallow_engine::changed_files::ChangedFilesError> for ChangedFilesError {
121    fn from(error: fallow_engine::changed_files::ChangedFilesError) -> Self {
122        match error {
123            fallow_engine::changed_files::ChangedFilesError::InvalidRef(err) => {
124                Self::InvalidRef(err)
125            }
126            fallow_engine::changed_files::ChangedFilesError::GitMissing(err) => {
127                Self::GitMissing(err)
128            }
129            fallow_engine::changed_files::ChangedFilesError::NotARepository => Self::NotARepository,
130            fallow_engine::changed_files::ChangedFilesError::GitFailed(stderr) => {
131                Self::GitFailed(stderr)
132            }
133        }
134    }
135}
136
137/// Resolve the canonical git toplevel for `cwd`.
138///
139/// # Errors
140///
141/// Returns an API-owned changed-file error when git cannot inspect the
142/// repository.
143pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
144    fallow_engine::changed_files::resolve_git_toplevel(cwd).map_err(ChangedFilesError::from)
145}
146
147/// Get changed files and the git toplevel used to resolve them.
148///
149/// # Errors
150///
151/// Returns an API-owned changed-file error when git cannot resolve the ref or
152/// repository state.
153pub fn try_get_changed_files_with_toplevel(
154    cwd: &Path,
155    toplevel: &Path,
156    git_ref: &str,
157) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
158    fallow_engine::changed_files::try_get_changed_files_with_toplevel(cwd, toplevel, git_ref)
159        .map_err(ChangedFilesError::from)
160}
161
162/// Per-module extraction facts re-exported for editor adapters that inspect
163/// retained parse artifacts.
164pub mod editor_extract {
165    pub use fallow_types::extract::{
166        AngularComponentSelector, AngularInputMember, AngularOutputMember,
167        AngularTemplateMemberAccessFact, AngularThisSpreadFact, CalleeUse, ClassHeritageInfo,
168        ComplexityContribution, ComplexityContributionKind, ComplexityMetric, ComponentEmit,
169        ComponentFunction, ComponentFunctionKind, ComponentProp, CssAnalytics, CssDeclarationBlock,
170        CssRuleMetric, DiFramework, DiKeySite, DiRole, DispatchedEvent,
171        DynamicCustomElementRenderFact, DynamicImportInfo, DynamicImportPattern, ExportInfo,
172        ExportName, FactoryCallMemberAccessFact, FactoryFnMemberAccessFact, FactoryReturnExport,
173        FlagUse, FlagUseKind, FluentChainMemberAccessFact, FluentChainNewMemberAccessFact,
174        ForwardAttr, FunctionComplexity, HookUse, HookUseKind, ImportInfo, ImportedName,
175        InstanceExportBindingFact, LoadReturnKey, LocalTypeDeclaration, MemberAccess, MemberInfo,
176        MemberKind, MisplacedDirectiveSite, ModuleInfo, NamespaceObjectAlias, PUBLIC_ENV_EXACT,
177        PUBLIC_ENV_METADATA_TOKENS, PUBLIC_ENV_PREFIXES, ParseResult, PlaywrightFixtureAliasFact,
178        PlaywrightFixtureDefinitionFact, PlaywrightFixtureTypeFact, PlaywrightFixtureUseFact,
179        PublicSignatureTypeReference, ReExportInfo, RegisteredCustomElement, RenderEdge,
180        RequireCallInfo, SECRET_ENV_TOKENS, SanitizedSinkArg, SanitizerScope, SecurityControlKind,
181        SecurityControlSite, SecurityUrlShape, SemanticFact, SemanticFactView, SinkArgKind,
182        SinkLiteralValue, SinkObjectProperty, SinkShape, SinkSite,
183        SkippedSecurityCalleeExpressionKind, SkippedSecurityCalleeReason,
184        SkippedSecurityCalleeSite, TaintedBinding, VisibilityTag,
185    };
186}
187
188/// Typed analysis-result and finding contracts re-exported for editor
189/// adapters.
190pub mod editor_results {
191    pub use fallow_types::output_dead_code::{
192        BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
193        CircularDependencyFinding, DevDependencyInProductionFinding, DuplicateExportFinding,
194        DuplicatePropShapeFinding, DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding,
195        InvalidClientExportFinding, MisconfiguredDependencyOverrideFinding,
196        MisplacedDirectiveFinding, MixedClientServerBarrelFinding, PolicyViolationFinding,
197        PrivateTypeLeakFinding, PropDrillingChainFinding, ReExportCycleFinding,
198        RouteCollisionFinding, TestOnlyDependencyFinding, ThinWrapperFinding,
199        TypeOnlyDependencyFinding, UnlistedDependencyFinding, UnprovidedInjectFinding,
200        UnrenderedComponentFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
201        UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedComponentEmitFinding,
202        UnusedComponentInputFinding, UnusedComponentOutputFinding, UnusedComponentPropFinding,
203        UnusedDependencyFinding, UnusedDependencyOverrideFinding, UnusedDevDependencyFinding,
204        UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
205        UnusedOptionalDependencyFinding, UnusedServerActionFinding, UnusedStoreMemberFinding,
206        UnusedSvelteEventFinding, UnusedTypeFinding,
207    };
208    pub use fallow_types::results::{
209        ActiveSuppression, AnalysisResults, BoundaryCallViolation, BoundaryCoverageViolation,
210        BoundaryViolation, CircularDependency, CircularDependencyEdge, DependencyLocation,
211        DependencyOverrideMisconfigReason, DependencyOverrideSource, DevDependencyInProduction,
212        DuplicateExport, DuplicateLocation, DuplicatePropShape, DuplicatePropShapeMember,
213        DynamicSegmentNameConflict, EmptyCatalogGroup, EntryPointSummary, ExportUsage, FeatureFlag,
214        FlagConfidence, FlagKind, ImportSite, InvalidClientExport, MisconfiguredDependencyOverride,
215        MisplacedDirective, MixedClientServerBarrel, PolicyRuleKind, PolicyViolation,
216        PolicyViolationSeverity, PrivateTypeLeak, PropDrillHop, PropDrillingChain, ReExportCycle,
217        ReExportCycleKind, ReactComponentIntel, ReactHookSummary, ReactPropDrill, ReactPropIntel,
218        ReferenceLocation, RenderFanInComponent, RenderFanInMetric, RouteCollision,
219        SecurityAttackSurfaceEntry, SecurityCandidate, SecurityCandidateBoundary,
220        SecurityCandidateSink, SecurityDeadCodeContext, SecurityDeadCodeKind,
221        SecurityDefensiveBoundary, SecurityDefensiveControl, SecurityFinding, SecurityFindingKind,
222        SecurityNetworkContext, SecurityReachability, SecurityRuntimeContext, SecurityRuntimeState,
223        SecuritySeverity, SecurityTaintFlow, SecurityUnresolvedCalleeDiagnostic,
224        SecurityZoneCrossing, StaleSuppression, SuppressionOrigin, TaintConfidence, TaintEndpoint,
225        TaintPath, TestOnlyDependency, ThinWrapper, TraceHop, TraceHopRole, TypeOnlyDependency,
226        UnlistedDependency, UnprovidedInject, UnrenderedComponent, UnresolvedCatalogReference,
227        UnresolvedImport, UnusedCatalogEntry, UnusedComponentEmit, UnusedComponentInput,
228        UnusedComponentOutput, UnusedComponentProp, UnusedDependency, UnusedDependencyOverride,
229        UnusedExport, UnusedFile, UnusedLoadDataKey, UnusedMember, UnusedServerAction,
230        UnusedSvelteEvent,
231    };
232}
233
234/// Security catalogue lookups exposed at the editor boundary.
235pub mod editor_security {
236    /// Return the human-readable security catalogue title for a finding kind.
237    #[must_use]
238    pub fn security_catalogue_title(kind: &str) -> Option<&'static str> {
239        fallow_engine::dead_code::security_catalogue_title(kind)
240    }
241}
242
243/// Inline-suppression contracts re-exported for editor adapters.
244pub mod editor_suppress {
245    pub use fallow_types::suppress::{IssueKind, is_suppressed};
246}
247
248/// Editor-boundary alias for the typed dead-code analysis results.
249pub type EditorAnalysisResults = fallow_types::results::AnalysisResults;
250
251/// Dead-code output retained for editor integrations.
252///
253/// The engine produces the data, but the editor API owns this public contract
254/// so LSP and future editor adapters do not depend on engine result structs.
255#[derive(Debug)]
256pub struct EditorDeadCodeAnalysisOutput {
257    /// Typed dead-code findings from the analysis.
258    pub results: EditorAnalysisResults,
259    /// Retained per-module parse artifacts; `None` unless the run was asked
260    /// to keep them for follow-up editor features.
261    pub modules: Option<Vec<ModuleInfo>>,
262    /// Retained discovered-file records matching `modules`.
263    pub files: Option<Vec<DiscoveredFile>>,
264}
265
266impl EditorDeadCodeAnalysisOutput {
267    fn from_engine(output: fallow_engine::dead_code::DeadCodeAnalysisOutput) -> Self {
268        Self {
269            results: output.results,
270            modules: output.modules,
271            files: output.files,
272        }
273    }
274}
275
276/// Editor-facing inline complexity signal for code lens and similar surfaces.
277///
278/// The finding is derived from retained typed engine parse artifacts, but the
279/// editor API owns the stable shape so LSP and future editor adapters do not
280/// need to inspect raw modules directly.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct EditorInlineComplexityFinding {
283    /// Absolute path of the file declaring the function.
284    pub path: PathBuf,
285    /// Function name as extracted from the source.
286    pub name: String,
287    /// One-based line of the function declaration.
288    pub line: u32,
289    /// Zero-based column of the function declaration.
290    pub col: u32,
291    /// Measured cyclomatic complexity.
292    pub cyclomatic: u16,
293    /// Measured cognitive complexity.
294    pub cognitive: u16,
295    /// Which configured threshold(s) the function exceeded.
296    pub exceeded: EditorInlineComplexityExceeded,
297}
298
299/// Which health complexity threshold(s) a function exceeded.
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub enum EditorInlineComplexityExceeded {
302    /// Only the cyclomatic threshold was exceeded.
303    Cyclomatic,
304    /// Only the cognitive threshold was exceeded.
305    Cognitive,
306    /// Both thresholds were exceeded.
307    CyclomaticAndCognitive,
308}
309
310/// Collect inline complexity findings from retained editor analysis artifacts.
311#[must_use]
312pub fn collect_inline_complexity(
313    config: &fallow_config::ResolvedConfig,
314    output: &EditorDeadCodeAnalysisOutput,
315) -> Vec<EditorInlineComplexityFinding> {
316    let Some(modules) = output.modules.as_ref() else {
317        return Vec::new();
318    };
319    let Some(files) = output.files.as_ref() else {
320        return Vec::new();
321    };
322
323    let file_paths: rustc_hash::FxHashMap<_, _> =
324        files.iter().map(|file| (file.id, &file.path)).collect();
325    let ignore_set = build_health_ignore_set(&config.health.ignore);
326    let mut findings = Vec::new();
327
328    for module in modules {
329        let Some(path) = file_paths.get(&module.file_id) else {
330            continue;
331        };
332        let relative = path.strip_prefix(&config.root).unwrap_or(path);
333        if ignore_set
334            .as_ref()
335            .is_some_and(|set| set.is_match(relative))
336        {
337            continue;
338        }
339
340        for function in &module.complexity {
341            if fallow_types::suppress::is_suppressed(
342                &module.suppressions,
343                function.line,
344                fallow_types::suppress::IssueKind::Complexity,
345            ) {
346                continue;
347            }
348
349            let exceeds_cyclomatic = function.cyclomatic > config.health.max_cyclomatic;
350            let exceeds_cognitive = function.cognitive > config.health.max_cognitive;
351            let exceeded = match (exceeds_cyclomatic, exceeds_cognitive) {
352                (true, true) => EditorInlineComplexityExceeded::CyclomaticAndCognitive,
353                (true, false) => EditorInlineComplexityExceeded::Cyclomatic,
354                (false, true) => EditorInlineComplexityExceeded::Cognitive,
355                (false, false) => continue,
356            };
357
358            findings.push(EditorInlineComplexityFinding {
359                path: (*path).clone(),
360                name: function.name.clone(),
361                line: function.line,
362                col: function.col,
363                cyclomatic: function.cyclomatic,
364                cognitive: function.cognitive,
365                exceeded,
366            });
367        }
368    }
369
370    findings
371}
372
373/// Filter inline complexity findings to the changed-file set.
374#[allow(
375    clippy::implicit_hasher,
376    reason = "editor analysis changed-file sets use the workspace FxHashSet convention"
377)]
378pub fn filter_inline_complexity_by_changed_files(
379    findings: &mut Vec<EditorInlineComplexityFinding>,
380    changed_files: &FxHashSet<PathBuf>,
381) {
382    findings.retain(|finding| changed_files.contains(&finding.path));
383}
384
385fn build_health_ignore_set(patterns: &[String]) -> Option<globset::GlobSet> {
386    if patterns.is_empty() {
387        return None;
388    }
389
390    let mut builder = globset::GlobSetBuilder::new();
391    for pattern in patterns {
392        let Ok(glob) = globset::Glob::new(pattern) else {
393            continue;
394        };
395        builder.add(glob);
396    }
397    builder.build().ok()
398}
399
400/// Reusable editor analysis session owned by the API boundary.
401#[derive(Debug)]
402pub struct EditorAnalysisSession {
403    inner: fallow_engine::session::AnalysisSession,
404}
405
406impl EditorAnalysisSession {
407    /// Load config and discover files for an editor project root.
408    ///
409    /// # Errors
410    ///
411    /// Returns an engine error when project config loading fails.
412    pub fn load(root: &Path, config_path: Option<&Path>) -> fallow_engine::EngineResult<Self> {
413        fallow_engine::session::AnalysisSession::load(root, config_path).map(Self::from_engine)
414    }
415
416    /// Load config, apply one editor-specific adjustment, then discover files.
417    ///
418    /// # Errors
419    ///
420    /// Returns an engine error when project config loading fails.
421    pub fn load_with_config(
422        root: &Path,
423        config_path: Option<&Path>,
424        configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
425    ) -> fallow_engine::EngineResult<Self> {
426        fallow_engine::session::AnalysisSession::load_with_config(root, config_path, configure)
427            .map(Self::from_engine)
428    }
429
430    /// Load config with an explicit inheritance trust policy, apply one
431    /// editor-specific adjustment, then discover files.
432    ///
433    /// # Errors
434    ///
435    /// Returns an engine error when project config loading fails.
436    pub fn load_with_config_options(
437        root: &Path,
438        config_path: Option<&Path>,
439        load_options: fallow_config::ConfigLoadOptions,
440        configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
441    ) -> fallow_engine::EngineResult<Self> {
442        fallow_engine::session::AnalysisSession::load_with_config_options(
443            root,
444            config_path,
445            load_options,
446            configure,
447        )
448        .map(Self::from_engine)
449    }
450
451    /// Build a session from built-in defaults, ignoring project config files.
452    #[must_use]
453    pub fn load_default(root: &Path) -> Self {
454        Self::from_engine(fallow_engine::session::AnalysisSession::load_default(root))
455    }
456
457    /// Resolved project config.
458    #[must_use]
459    pub fn config(&self) -> &fallow_config::ResolvedConfig {
460        self.inner.config()
461    }
462
463    /// Config file path when one was loaded.
464    #[must_use]
465    pub fn config_path(&self) -> Option<&Path> {
466        self.inner.config_path()
467    }
468
469    /// Refine this editor session's dead-code findings with exact TypeScript
470    /// symbol evidence.
471    ///
472    /// # Errors
473    ///
474    /// Returns a programmatic error when the semantic companion cannot provide
475    /// the requested analysis contract.
476    pub fn refine_type_aware_dead_code(
477        &self,
478        options: &crate::TypeAwareOptions,
479        filters: &crate::DeadCodeFilters,
480        output: &mut EditorDeadCodeAnalysisOutput,
481    ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
482        crate::type_aware::refine_programmatic_dead_code(
483            options,
484            filters,
485            &self.inner,
486            &mut output.results,
487        )
488    }
489
490    /// Refine editor findings through a root-bound persistent semantic session.
491    pub fn refine_type_aware_dead_code_in_session(
492        &self,
493        semantic_session: &mut crate::TypeAwareSession,
494        changes: Option<&crate::TypeAwareFileChanges>,
495        options: &crate::TypeAwareOptions,
496        filters: &crate::DeadCodeFilters,
497        output: &mut EditorDeadCodeAnalysisOutput,
498    ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
499        crate::type_aware::refine_programmatic_dead_code_in_session(
500            semantic_session,
501            changes,
502            options,
503            filters,
504            &self.inner,
505            &mut output.results,
506        )
507    }
508
509    /// Run dead-code and duplication analysis for this editor session.
510    ///
511    /// # Errors
512    ///
513    /// Returns an engine error when dead-code parsing or analysis fails.
514    pub fn analyze_project_with(
515        &self,
516        duplicates_config: &fallow_config::DuplicatesConfig,
517        retain_complexity_artifacts: bool,
518    ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
519        self.inner
520            .analyze_project_with(duplicates_config, retain_complexity_artifacts)
521            .map(EditorProjectAnalysisOutput::from_engine)
522    }
523
524    /// Run dead-code and duplication analysis, optionally focusing duplication
525    /// to files the editor already resolved as changed.
526    ///
527    /// Dead-code still runs with full graph context so downstream editor
528    /// filters can preserve existing diagnostic semantics.
529    ///
530    /// # Errors
531    ///
532    /// Returns an engine error when dead-code parsing or analysis fails.
533    pub fn analyze_project_with_changed_files(
534        &self,
535        duplicates_config: &fallow_config::DuplicatesConfig,
536        retain_complexity_artifacts: bool,
537        changed_files: Option<&FxHashSet<PathBuf>>,
538    ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
539        self.inner
540            .analyze_project_with_artifacts(
541                duplicates_config,
542                fallow_engine::project_analysis::ProjectAnalysisArtifactOptions {
543                    retain_complexity_artifacts,
544                    changed_files: changed_files.cloned(),
545                    ..fallow_engine::project_analysis::ProjectAnalysisArtifactOptions::default()
546                },
547            )
548            .map(fallow_engine::project_analysis::ProjectAnalysisArtifacts::into_output)
549            .map(EditorProjectAnalysisOutput::from_engine)
550    }
551
552    const fn from_engine(inner: fallow_engine::session::AnalysisSession) -> Self {
553        Self { inner }
554    }
555}
556
557/// Dead-code and duplication project output owned by the editor API boundary.
558#[derive(Debug)]
559pub struct EditorProjectAnalysisOutput {
560    /// Dead-code findings plus optionally retained parse artifacts.
561    pub dead_code: EditorDeadCodeAnalysisOutput,
562    /// Duplication report for the analyzed project slice.
563    pub duplication: EditorDuplicationReport,
564}
565
566impl EditorProjectAnalysisOutput {
567    fn from_engine(output: fallow_engine::project_analysis::ProjectAnalysisOutput) -> Self {
568        Self {
569            dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
570            duplication: output.duplication,
571        }
572    }
573}
574
575/// Dead-code and duplication output shaped for editor integrations.
576#[derive(Debug, Default)]
577pub struct EditorAnalysisOutput {
578    /// Typed dead-code findings.
579    pub results: EditorAnalysisResults,
580    /// Duplication report.
581    pub duplication: EditorDuplicationReport,
582}
583
584impl EditorAnalysisOutput {
585    /// Pair dead-code results with a duplication report.
586    #[must_use]
587    pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
588        Self {
589            results,
590            duplication,
591        }
592    }
593
594    /// Convert a project analysis output, dropping retained parse artifacts.
595    #[must_use]
596    pub fn from_project_output(output: EditorProjectAnalysisOutput) -> Self {
597        Self::new(output.dead_code.results, output.duplication)
598    }
599
600    /// Merge another project analysis output into this accumulated output.
601    pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
602        self.merge_results(output.dead_code.results);
603        self.merge_duplication(output.duplication);
604    }
605
606    /// Merge another dead-code results set into this one.
607    pub fn merge_results(&mut self, source: EditorAnalysisResults) {
608        self.results.merge_into(source);
609    }
610
611    /// Merge another duplication report into this one, summing the aggregate
612    /// stats and recomputing the duplication percentage over the union.
613    pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
614        self.duplication.clone_groups.extend(source.clone_groups);
615        self.duplication
616            .clone_families
617            .extend(source.clone_families);
618        self.duplication
619            .mirrored_directories
620            .extend(source.mirrored_directories);
621        self.duplication.stats.clone_groups += source.stats.clone_groups;
622        self.duplication.stats.clone_instances += source.stats.clone_instances;
623        self.duplication.stats.total_files += source.stats.total_files;
624        self.duplication.stats.files_with_clones += source.stats.files_with_clones;
625        self.duplication.stats.total_lines += source.stats.total_lines;
626        self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
627        self.duplication.stats.total_tokens += source.stats.total_tokens;
628        self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
629        self.duplication.stats.clone_groups_below_min_occurrences +=
630            source.stats.clone_groups_below_min_occurrences;
631        self.duplication.stats.clone_groups_ignored += source.stats.clone_groups_ignored;
632        self.duplication.stats.near_candidates_skipped += source.stats.near_candidates_skipped;
633        self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
634            (self.duplication.stats.duplicated_lines as f64
635                / self.duplication.stats.total_lines as f64)
636                * 100.0
637        } else {
638            0.0
639        };
640    }
641
642    /// Drop findings and clone groups that do not touch any changed file.
643    pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
644        fallow_engine::changed_files::filter_results_by_changed_files(
645            &mut self.results,
646            changed_files,
647        );
648        fallow_engine::changed_files::filter_duplication_by_changed_files(
649            &mut self.duplication,
650            changed_files,
651            root,
652        );
653    }
654
655    /// Resolve files changed since `git_ref` and filter to them, returning
656    /// how many files changed.
657    ///
658    /// # Errors
659    ///
660    /// Returns a changed-file error when git cannot resolve the ref or
661    /// repository state.
662    pub fn filter_by_changed_since(
663        &mut self,
664        root: &Path,
665        toplevel: &Path,
666        git_ref: &str,
667    ) -> Result<usize, ChangedFilesError> {
668        let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
669        let changed_count = changed.len();
670        self.filter_by_changed_files(&changed, root);
671        Ok(changed_count)
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
680
681    #[test]
682    fn merges_duplication_stats_and_recomputes_percentage() {
683        let mut output = EditorAnalysisOutput {
684            duplication: EditorDuplicationReport {
685                clone_groups: vec![CloneGroup {
686                    instances: vec![CloneInstance {
687                        file: PathBuf::from("src/a.ts"),
688                        start_line: 1,
689                        end_line: 4,
690                        start_col: 0,
691                        end_col: 10,
692                        fragment: "const a = 1;".to_string(),
693                    }],
694                    token_count: 8,
695                    line_count: 4,
696                    similarity: None,
697                }],
698                clone_families: Vec::new(),
699                mirrored_directories: Vec::new(),
700                stats: DuplicationStats {
701                    clone_groups: 1,
702                    clone_instances: 1,
703                    total_files: 1,
704                    files_with_clones: 1,
705                    total_lines: 20,
706                    duplicated_lines: 4,
707                    total_tokens: 80,
708                    duplicated_tokens: 8,
709                    duplication_percentage: 20.0,
710                    clone_groups_below_min_occurrences: 1,
711                    clone_groups_ignored: 1,
712                    near_candidates_skipped: 2,
713                },
714            },
715            ..Default::default()
716        };
717
718        output.merge_duplication(EditorDuplicationReport {
719            clone_groups: Vec::new(),
720            clone_families: Vec::new(),
721            mirrored_directories: Vec::new(),
722            stats: DuplicationStats {
723                clone_groups: 0,
724                clone_instances: 0,
725                total_files: 1,
726                files_with_clones: 0,
727                total_lines: 30,
728                duplicated_lines: 6,
729                total_tokens: 120,
730                duplicated_tokens: 12,
731                duplication_percentage: 20.0,
732                clone_groups_below_min_occurrences: 2,
733                clone_groups_ignored: 3,
734                near_candidates_skipped: 4,
735            },
736        });
737
738        assert_eq!(output.duplication.stats.total_lines, 50);
739        assert_eq!(output.duplication.stats.duplicated_lines, 10);
740        assert_eq!(
741            output.duplication.stats.clone_groups_below_min_occurrences,
742            3
743        );
744        assert_eq!(output.duplication.stats.clone_groups_ignored, 4);
745        assert_eq!(output.duplication.stats.near_candidates_skipped, 6);
746        assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
747    }
748
749    #[test]
750    fn editor_session_returns_api_owned_project_output() {
751        let temp = tempfile::tempdir().expect("temp project");
752        let root = temp.path();
753        std::fs::create_dir_all(root.join("src")).expect("src dir");
754        std::fs::write(
755            root.join("package.json"),
756            r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
757        )
758        .expect("package.json");
759        std::fs::write(
760            root.join("src/index.ts"),
761            "export const used = 1;\nconsole.log(used);\n",
762        )
763        .expect("source");
764
765        let session = EditorAnalysisSession::load(root, None).expect("session loads");
766        let output = session
767            .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
768            .expect("analysis runs");
769
770        assert!(output.dead_code.modules.is_some());
771        assert!(
772            output
773                .dead_code
774                .files
775                .as_ref()
776                .is_some_and(|files| !files.is_empty())
777        );
778    }
779
780    #[test]
781    fn editor_session_scopes_duplication_to_changed_files() {
782        let temp = tempfile::tempdir().expect("temp project");
783        let root = temp.path();
784        let src = root.join("src");
785        std::fs::create_dir_all(&src).expect("src dir");
786        std::fs::write(
787            root.join("package.json"),
788            r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
789        )
790        .expect("package.json");
791        let repeated =
792            "export function repeated() {\n  return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
793        std::fs::write(src.join("a.ts"), repeated).expect("source a");
794        std::fs::write(src.join("b.ts"), repeated).expect("source b");
795
796        let session = EditorAnalysisSession::load(root, None).expect("session loads");
797        let mut config = session.config().duplicates.clone();
798        config.min_tokens = 1;
799        config.min_lines = 1;
800        let full = session
801            .analyze_project_with(&config, false)
802            .expect("analysis runs");
803        assert!(!full.duplication.clone_groups.is_empty());
804
805        let mut changed_files = FxHashSet::default();
806        changed_files.insert(src.join("unrelated.ts"));
807        let scoped = session
808            .analyze_project_with_changed_files(&config, false, Some(&changed_files))
809            .expect("analysis runs");
810        assert!(scoped.duplication.clone_groups.is_empty());
811    }
812
813    #[test]
814    fn build_health_ignore_set_returns_none_for_empty_patterns() {
815        assert!(
816            build_health_ignore_set(&[]).is_none(),
817            "empty ignore pattern list should avoid building a matcher"
818        );
819    }
820
821    #[test]
822    fn build_health_ignore_set_matches_glob_patterns() {
823        let set =
824            build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
825                .expect("valid patterns build a glob set");
826
827        assert!(set.is_match(Path::new("src/foo.test.ts")));
828        assert!(set.is_match(Path::new("src/generated/client.ts")));
829        assert!(!set.is_match(Path::new("src/app.ts")));
830    }
831
832    #[test]
833    fn build_health_ignore_set_skips_invalid_patterns() {
834        let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
835
836        match result {
837            None => {}
838            Some(set) => assert!(
839                !set.is_match(Path::new("any/path.ts")),
840                "set built from only invalid patterns must not match anything"
841            ),
842        }
843    }
844
845    fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
846        EditorInlineComplexityFinding {
847            path,
848            name: "myFn".to_string(),
849            line: 1,
850            col: 0,
851            cyclomatic: 5,
852            cognitive: 4,
853            exceeded: EditorInlineComplexityExceeded::Cyclomatic,
854        }
855    }
856
857    #[test]
858    fn filter_inline_complexity_keeps_findings_in_changed_set() {
859        let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
860            .into_iter()
861            .collect();
862        let mut findings = vec![
863            make_inline_finding(PathBuf::from("/src/a.ts")),
864            make_inline_finding(PathBuf::from("/src/c.ts")),
865        ];
866
867        filter_inline_complexity_by_changed_files(&mut findings, &changed);
868
869        assert_eq!(findings.len(), 1);
870        assert_eq!(
871            findings[0].path.to_string_lossy().replace('\\', "/"),
872            "/src/a.ts"
873        );
874    }
875
876    #[test]
877    fn filter_inline_complexity_removes_all_when_changed_set_empty() {
878        let changed: FxHashSet<PathBuf> = FxHashSet::default();
879        let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
880
881        filter_inline_complexity_by_changed_files(&mut findings, &changed);
882
883        assert!(
884            findings.is_empty(),
885            "empty changed-files set must drop all inline complexity findings"
886        );
887    }
888
889    #[test]
890    fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
891        let path_a = PathBuf::from("/src/a.ts");
892        let path_b = PathBuf::from("/src/b.ts");
893        let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
894        let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
895
896        filter_inline_complexity_by_changed_files(&mut findings, &changed);
897
898        assert_eq!(
899            findings.len(),
900            2,
901            "all findings in the changed set must be retained"
902        );
903    }
904}