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            // The module-scope unit is aggregate-only and never becomes a
342            // finding, so it gets no code lens either: a permanent lens at the
343            // top of every branching file is noise, not a refactoring cue.
344            if fallow_types::extract::is_synthetic_module_unit(&function.name) {
345                continue;
346            }
347            if fallow_types::suppress::is_suppressed(
348                &module.suppressions,
349                function.line,
350                fallow_types::suppress::IssueKind::Complexity,
351            ) {
352                continue;
353            }
354
355            let exceeds_cyclomatic = function.cyclomatic > config.health.max_cyclomatic;
356            let exceeds_cognitive = function.cognitive > config.health.max_cognitive;
357            let exceeded = match (exceeds_cyclomatic, exceeds_cognitive) {
358                (true, true) => EditorInlineComplexityExceeded::CyclomaticAndCognitive,
359                (true, false) => EditorInlineComplexityExceeded::Cyclomatic,
360                (false, true) => EditorInlineComplexityExceeded::Cognitive,
361                (false, false) => continue,
362            };
363
364            findings.push(EditorInlineComplexityFinding {
365                path: (*path).clone(),
366                name: function.name.clone(),
367                line: function.line,
368                col: function.col,
369                cyclomatic: function.cyclomatic,
370                cognitive: function.cognitive,
371                exceeded,
372            });
373        }
374    }
375
376    findings
377}
378
379/// Filter inline complexity findings to the changed-file set.
380#[allow(
381    clippy::implicit_hasher,
382    reason = "editor analysis changed-file sets use the workspace FxHashSet convention"
383)]
384pub fn filter_inline_complexity_by_changed_files(
385    findings: &mut Vec<EditorInlineComplexityFinding>,
386    changed_files: &FxHashSet<PathBuf>,
387) {
388    findings.retain(|finding| changed_files.contains(&finding.path));
389}
390
391fn build_health_ignore_set(patterns: &[String]) -> Option<globset::GlobSet> {
392    if patterns.is_empty() {
393        return None;
394    }
395
396    let mut builder = globset::GlobSetBuilder::new();
397    for pattern in patterns {
398        let Ok(glob) = globset::Glob::new(pattern) else {
399            continue;
400        };
401        builder.add(glob);
402    }
403    builder.build().ok()
404}
405
406/// Reusable editor analysis session owned by the API boundary.
407#[derive(Debug)]
408pub struct EditorAnalysisSession {
409    inner: fallow_engine::session::AnalysisSession,
410}
411
412impl EditorAnalysisSession {
413    /// Load config and discover files for an editor project root.
414    ///
415    /// # Errors
416    ///
417    /// Returns an engine error when project config loading fails.
418    pub fn load(root: &Path, config_path: Option<&Path>) -> fallow_engine::EngineResult<Self> {
419        fallow_engine::session::AnalysisSession::load(root, config_path).map(Self::from_engine)
420    }
421
422    /// Load config, apply one editor-specific adjustment, then discover files.
423    ///
424    /// # Errors
425    ///
426    /// Returns an engine error when project config loading fails.
427    pub fn load_with_config(
428        root: &Path,
429        config_path: Option<&Path>,
430        configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
431    ) -> fallow_engine::EngineResult<Self> {
432        fallow_engine::session::AnalysisSession::load_with_config(root, config_path, configure)
433            .map(Self::from_engine)
434    }
435
436    /// Load config with an explicit inheritance trust policy, apply one
437    /// editor-specific adjustment, then discover files.
438    ///
439    /// # Errors
440    ///
441    /// Returns an engine error when project config loading fails.
442    pub fn load_with_config_options(
443        root: &Path,
444        config_path: Option<&Path>,
445        load_options: fallow_config::ConfigLoadOptions,
446        configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
447    ) -> fallow_engine::EngineResult<Self> {
448        fallow_engine::session::AnalysisSession::load_with_config_options(
449            root,
450            config_path,
451            load_options,
452            configure,
453        )
454        .map(Self::from_engine)
455    }
456
457    /// Build a session from built-in defaults, ignoring project config files.
458    #[must_use]
459    pub fn load_default(root: &Path) -> Self {
460        Self::from_engine(fallow_engine::session::AnalysisSession::load_default(root))
461    }
462
463    /// Resolved project config.
464    #[must_use]
465    pub fn config(&self) -> &fallow_config::ResolvedConfig {
466        self.inner.config()
467    }
468
469    /// Config file path when one was loaded.
470    #[must_use]
471    pub fn config_path(&self) -> Option<&Path> {
472        self.inner.config_path()
473    }
474
475    /// Refine this editor session's dead-code findings with exact TypeScript
476    /// symbol evidence.
477    ///
478    /// # Errors
479    ///
480    /// Returns a programmatic error when the semantic companion cannot provide
481    /// the requested analysis contract.
482    pub fn refine_type_aware_dead_code(
483        &self,
484        options: &crate::TypeAwareOptions,
485        filters: &crate::DeadCodeFilters,
486        output: &mut EditorDeadCodeAnalysisOutput,
487    ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
488        crate::type_aware::refine_programmatic_dead_code(
489            options,
490            filters,
491            &self.inner,
492            &mut output.results,
493        )
494    }
495
496    /// Refine editor findings through a root-bound persistent semantic session.
497    pub fn refine_type_aware_dead_code_in_session(
498        &self,
499        semantic_session: &mut crate::TypeAwareSession,
500        changes: Option<&crate::TypeAwareFileChanges>,
501        options: &crate::TypeAwareOptions,
502        filters: &crate::DeadCodeFilters,
503        output: &mut EditorDeadCodeAnalysisOutput,
504    ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
505        crate::type_aware::refine_programmatic_dead_code_in_session(
506            semantic_session,
507            changes,
508            options,
509            filters,
510            &self.inner,
511            &mut output.results,
512        )
513    }
514
515    /// Run dead-code and duplication analysis for this editor session.
516    ///
517    /// # Errors
518    ///
519    /// Returns an engine error when dead-code parsing or analysis fails.
520    pub fn analyze_project_with(
521        &self,
522        duplicates_config: &fallow_config::DuplicatesConfig,
523        retain_complexity_artifacts: bool,
524    ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
525        self.inner
526            .analyze_project_with(duplicates_config, retain_complexity_artifacts)
527            .map(EditorProjectAnalysisOutput::from_engine)
528    }
529
530    /// Run dead-code and duplication analysis, optionally focusing duplication
531    /// to files the editor already resolved as changed.
532    ///
533    /// Dead-code still runs with full graph context so downstream editor
534    /// filters can preserve existing diagnostic semantics.
535    ///
536    /// # Errors
537    ///
538    /// Returns an engine error when dead-code parsing or analysis fails.
539    pub fn analyze_project_with_changed_files(
540        &self,
541        duplicates_config: &fallow_config::DuplicatesConfig,
542        retain_complexity_artifacts: bool,
543        changed_files: Option<&FxHashSet<PathBuf>>,
544    ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
545        self.inner
546            .analyze_project_with_artifacts(
547                duplicates_config,
548                fallow_engine::project_analysis::ProjectAnalysisArtifactOptions {
549                    retain_complexity_artifacts,
550                    changed_files: changed_files.cloned(),
551                    ..fallow_engine::project_analysis::ProjectAnalysisArtifactOptions::default()
552                },
553            )
554            .map(fallow_engine::project_analysis::ProjectAnalysisArtifacts::into_output)
555            .map(EditorProjectAnalysisOutput::from_engine)
556    }
557
558    const fn from_engine(inner: fallow_engine::session::AnalysisSession) -> Self {
559        Self { inner }
560    }
561}
562
563/// Dead-code and duplication project output owned by the editor API boundary.
564#[derive(Debug)]
565pub struct EditorProjectAnalysisOutput {
566    /// Dead-code findings plus optionally retained parse artifacts.
567    pub dead_code: EditorDeadCodeAnalysisOutput,
568    /// Duplication report for the analyzed project slice.
569    pub duplication: EditorDuplicationReport,
570}
571
572impl EditorProjectAnalysisOutput {
573    fn from_engine(output: fallow_engine::project_analysis::ProjectAnalysisOutput) -> Self {
574        Self {
575            dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
576            duplication: output.duplication,
577        }
578    }
579}
580
581/// Dead-code and duplication output shaped for editor integrations.
582#[derive(Debug, Default)]
583pub struct EditorAnalysisOutput {
584    /// Typed dead-code findings.
585    pub results: EditorAnalysisResults,
586    /// Duplication report.
587    pub duplication: EditorDuplicationReport,
588}
589
590impl EditorAnalysisOutput {
591    /// Pair dead-code results with a duplication report.
592    #[must_use]
593    pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
594        Self {
595            results,
596            duplication,
597        }
598    }
599
600    /// Convert a project analysis output, dropping retained parse artifacts.
601    #[must_use]
602    pub fn from_project_output(output: EditorProjectAnalysisOutput) -> Self {
603        Self::new(output.dead_code.results, output.duplication)
604    }
605
606    /// Merge another project analysis output into this accumulated output.
607    pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
608        self.merge_results(output.dead_code.results);
609        self.merge_duplication(output.duplication);
610    }
611
612    /// Merge another dead-code results set into this one.
613    pub fn merge_results(&mut self, source: EditorAnalysisResults) {
614        self.results.merge_into(source);
615    }
616
617    /// Merge another duplication report into this one, summing the aggregate
618    /// stats and recomputing the duplication percentage over the union.
619    pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
620        self.duplication.clone_groups.extend(source.clone_groups);
621        self.duplication
622            .clone_families
623            .extend(source.clone_families);
624        self.duplication
625            .mirrored_directories
626            .extend(source.mirrored_directories);
627        self.duplication.stats.clone_groups += source.stats.clone_groups;
628        self.duplication.stats.clone_families += source.stats.clone_families;
629        self.duplication.stats.clone_instances += source.stats.clone_instances;
630        self.duplication.stats.total_files += source.stats.total_files;
631        self.duplication.stats.files_with_clones += source.stats.files_with_clones;
632        self.duplication.stats.total_lines += source.stats.total_lines;
633        self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
634        self.duplication.stats.total_tokens += source.stats.total_tokens;
635        self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
636        self.duplication.stats.clone_groups_below_min_occurrences +=
637            source.stats.clone_groups_below_min_occurrences;
638        self.duplication.stats.clone_groups_ignored += source.stats.clone_groups_ignored;
639        self.duplication.stats.near_candidates_skipped += source.stats.near_candidates_skipped;
640        self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
641            (self.duplication.stats.duplicated_lines as f64
642                / self.duplication.stats.total_lines as f64)
643                * 100.0
644        } else {
645            0.0
646        };
647    }
648
649    /// Drop findings and clone groups that do not touch any changed file.
650    pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
651        fallow_engine::changed_files::filter_results_by_changed_files(
652            &mut self.results,
653            changed_files,
654        );
655        fallow_engine::changed_files::filter_duplication_by_changed_files(
656            &mut self.duplication,
657            changed_files,
658            root,
659        );
660    }
661
662    /// Resolve files changed since `git_ref` and filter to them, returning
663    /// how many files changed.
664    ///
665    /// # Errors
666    ///
667    /// Returns a changed-file error when git cannot resolve the ref or
668    /// repository state.
669    pub fn filter_by_changed_since(
670        &mut self,
671        root: &Path,
672        toplevel: &Path,
673        git_ref: &str,
674    ) -> Result<usize, ChangedFilesError> {
675        let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
676        let changed_count = changed.len();
677        self.filter_by_changed_files(&changed, root);
678        Ok(changed_count)
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    use fallow_types::duplicates::{CloneFamily, CloneGroup, CloneInstance, DuplicationStats};
687
688    #[test]
689    fn merges_duplication_stats_and_recomputes_percentage() {
690        let mut output = EditorAnalysisOutput {
691            duplication: EditorDuplicationReport {
692                clone_groups: vec![CloneGroup {
693                    instances: vec![CloneInstance {
694                        file: PathBuf::from("src/a.ts"),
695                        start_line: 1,
696                        end_line: 4,
697                        start_col: 0,
698                        end_col: 10,
699                        fragment: "const a = 1;".to_string(),
700                    }],
701                    token_count: 8,
702                    line_count: 4,
703                    similarity: None,
704                }],
705                clone_families: Vec::new(),
706                mirrored_directories: Vec::new(),
707                stats: DuplicationStats {
708                    clone_groups: 1,
709                    clone_families: 0,
710                    clone_instances: 1,
711                    total_files: 1,
712                    files_with_clones: 1,
713                    total_lines: 20,
714                    duplicated_lines: 4,
715                    total_tokens: 80,
716                    duplicated_tokens: 8,
717                    duplication_percentage: 20.0,
718                    clone_groups_below_min_occurrences: 1,
719                    clone_groups_ignored: 1,
720                    near_candidates_skipped: 2,
721                },
722            },
723            ..Default::default()
724        };
725
726        output.merge_duplication(EditorDuplicationReport {
727            clone_groups: Vec::new(),
728            clone_families: Vec::new(),
729            mirrored_directories: Vec::new(),
730            stats: DuplicationStats {
731                clone_groups: 0,
732                clone_families: 0,
733                clone_instances: 0,
734                total_files: 1,
735                files_with_clones: 0,
736                total_lines: 30,
737                duplicated_lines: 6,
738                total_tokens: 120,
739                duplicated_tokens: 12,
740                duplication_percentage: 20.0,
741                clone_groups_below_min_occurrences: 2,
742                clone_groups_ignored: 3,
743                near_candidates_skipped: 4,
744            },
745        });
746
747        assert_eq!(output.duplication.stats.total_lines, 50);
748        assert_eq!(output.duplication.stats.duplicated_lines, 10);
749        assert_eq!(
750            output.duplication.stats.clone_groups_below_min_occurrences,
751            3
752        );
753        assert_eq!(output.duplication.stats.clone_groups_ignored, 4);
754        assert_eq!(output.duplication.stats.near_candidates_skipped, 6);
755        assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
756    }
757
758    #[test]
759    fn merging_duplication_keeps_the_family_corpus_count_aligned() {
760        let family = |path: &str| CloneFamily {
761            files: vec![PathBuf::from(path)],
762            groups: Vec::new(),
763            total_duplicated_lines: 4,
764            total_duplicated_tokens: 8,
765            suggestions: Vec::new(),
766        };
767        let report = |path: &str, families: usize| EditorDuplicationReport {
768            clone_groups: Vec::new(),
769            clone_families: vec![family(path)],
770            mirrored_directories: Vec::new(),
771            stats: DuplicationStats {
772                clone_families: families,
773                ..DuplicationStats::default()
774            },
775        };
776
777        let mut output = EditorAnalysisOutput {
778            duplication: report("src/a.ts", 3),
779            ..Default::default()
780        };
781        output.merge_duplication(report("src/b.ts", 2));
782
783        assert_eq!(output.duplication.stats.clone_families, 5);
784        assert_eq!(output.duplication.clone_families_shown(), 2);
785        assert_eq!(output.duplication.clone_families_omitted(), 3);
786        assert_eq!(
787            output.duplication.clone_families_total(),
788            output.duplication.stats.clone_families
789        );
790    }
791
792    #[test]
793    fn editor_session_returns_api_owned_project_output() {
794        let temp = tempfile::tempdir().expect("temp project");
795        let root = temp.path();
796        std::fs::create_dir_all(root.join("src")).expect("src dir");
797        std::fs::write(
798            root.join("package.json"),
799            r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
800        )
801        .expect("package.json");
802        std::fs::write(
803            root.join("src/index.ts"),
804            "export const used = 1;\nconsole.log(used);\n",
805        )
806        .expect("source");
807
808        let session = EditorAnalysisSession::load(root, None).expect("session loads");
809        let output = session
810            .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
811            .expect("analysis runs");
812
813        assert!(output.dead_code.modules.is_some());
814        assert!(
815            output
816                .dead_code
817                .files
818                .as_ref()
819                .is_some_and(|files| !files.is_empty())
820        );
821    }
822
823    #[test]
824    fn editor_session_scopes_duplication_to_changed_files() {
825        let temp = tempfile::tempdir().expect("temp project");
826        let root = temp.path();
827        let src = root.join("src");
828        std::fs::create_dir_all(&src).expect("src dir");
829        std::fs::write(
830            root.join("package.json"),
831            r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
832        )
833        .expect("package.json");
834        let repeated =
835            "export function repeated() {\n  return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
836        std::fs::write(src.join("a.ts"), repeated).expect("source a");
837        std::fs::write(src.join("b.ts"), repeated).expect("source b");
838
839        let session = EditorAnalysisSession::load(root, None).expect("session loads");
840        let mut config = session.config().duplicates.clone();
841        config.min_tokens = 1;
842        config.min_lines = 1;
843        let full = session
844            .analyze_project_with(&config, false)
845            .expect("analysis runs");
846        assert!(!full.duplication.clone_groups.is_empty());
847
848        let mut changed_files = FxHashSet::default();
849        changed_files.insert(src.join("unrelated.ts"));
850        let scoped = session
851            .analyze_project_with_changed_files(&config, false, Some(&changed_files))
852            .expect("analysis runs");
853        assert!(scoped.duplication.clone_groups.is_empty());
854    }
855
856    #[test]
857    fn build_health_ignore_set_returns_none_for_empty_patterns() {
858        assert!(
859            build_health_ignore_set(&[]).is_none(),
860            "empty ignore pattern list should avoid building a matcher"
861        );
862    }
863
864    #[test]
865    fn build_health_ignore_set_matches_glob_patterns() {
866        let set =
867            build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
868                .expect("valid patterns build a glob set");
869
870        assert!(set.is_match(Path::new("src/foo.test.ts")));
871        assert!(set.is_match(Path::new("src/generated/client.ts")));
872        assert!(!set.is_match(Path::new("src/app.ts")));
873    }
874
875    #[test]
876    fn build_health_ignore_set_skips_invalid_patterns() {
877        let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
878
879        match result {
880            None => {}
881            Some(set) => assert!(
882                !set.is_match(Path::new("any/path.ts")),
883                "set built from only invalid patterns must not match anything"
884            ),
885        }
886    }
887
888    fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
889        EditorInlineComplexityFinding {
890            path,
891            name: "myFn".to_string(),
892            line: 1,
893            col: 0,
894            cyclomatic: 5,
895            cognitive: 4,
896            exceeded: EditorInlineComplexityExceeded::Cyclomatic,
897        }
898    }
899
900    #[test]
901    fn filter_inline_complexity_keeps_findings_in_changed_set() {
902        let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
903            .into_iter()
904            .collect();
905        let mut findings = vec![
906            make_inline_finding(PathBuf::from("/src/a.ts")),
907            make_inline_finding(PathBuf::from("/src/c.ts")),
908        ];
909
910        filter_inline_complexity_by_changed_files(&mut findings, &changed);
911
912        assert_eq!(findings.len(), 1);
913        assert_eq!(
914            findings[0].path.to_string_lossy().replace('\\', "/"),
915            "/src/a.ts"
916        );
917    }
918
919    #[test]
920    fn filter_inline_complexity_removes_all_when_changed_set_empty() {
921        let changed: FxHashSet<PathBuf> = FxHashSet::default();
922        let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
923
924        filter_inline_complexity_by_changed_files(&mut findings, &changed);
925
926        assert!(
927            findings.is_empty(),
928            "empty changed-files set must drop all inline complexity findings"
929        );
930    }
931
932    #[test]
933    fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
934        let path_a = PathBuf::from("/src/a.ts");
935        let path_b = PathBuf::from("/src/b.ts");
936        let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
937        let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
938
939        filter_inline_complexity_by_changed_files(&mut findings, &changed);
940
941        assert_eq!(
942            findings.len(),
943            2,
944            "all findings in the changed set must be retained"
945        );
946    }
947}