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_instances += source.stats.clone_instances;
629        self.duplication.stats.total_files += source.stats.total_files;
630        self.duplication.stats.files_with_clones += source.stats.files_with_clones;
631        self.duplication.stats.total_lines += source.stats.total_lines;
632        self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
633        self.duplication.stats.total_tokens += source.stats.total_tokens;
634        self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
635        self.duplication.stats.clone_groups_below_min_occurrences +=
636            source.stats.clone_groups_below_min_occurrences;
637        self.duplication.stats.clone_groups_ignored += source.stats.clone_groups_ignored;
638        self.duplication.stats.near_candidates_skipped += source.stats.near_candidates_skipped;
639        self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
640            (self.duplication.stats.duplicated_lines as f64
641                / self.duplication.stats.total_lines as f64)
642                * 100.0
643        } else {
644            0.0
645        };
646    }
647
648    /// Drop findings and clone groups that do not touch any changed file.
649    pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
650        fallow_engine::changed_files::filter_results_by_changed_files(
651            &mut self.results,
652            changed_files,
653        );
654        fallow_engine::changed_files::filter_duplication_by_changed_files(
655            &mut self.duplication,
656            changed_files,
657            root,
658        );
659    }
660
661    /// Resolve files changed since `git_ref` and filter to them, returning
662    /// how many files changed.
663    ///
664    /// # Errors
665    ///
666    /// Returns a changed-file error when git cannot resolve the ref or
667    /// repository state.
668    pub fn filter_by_changed_since(
669        &mut self,
670        root: &Path,
671        toplevel: &Path,
672        git_ref: &str,
673    ) -> Result<usize, ChangedFilesError> {
674        let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
675        let changed_count = changed.len();
676        self.filter_by_changed_files(&changed, root);
677        Ok(changed_count)
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
686
687    #[test]
688    fn merges_duplication_stats_and_recomputes_percentage() {
689        let mut output = EditorAnalysisOutput {
690            duplication: EditorDuplicationReport {
691                clone_groups: vec![CloneGroup {
692                    instances: vec![CloneInstance {
693                        file: PathBuf::from("src/a.ts"),
694                        start_line: 1,
695                        end_line: 4,
696                        start_col: 0,
697                        end_col: 10,
698                        fragment: "const a = 1;".to_string(),
699                    }],
700                    token_count: 8,
701                    line_count: 4,
702                    similarity: None,
703                }],
704                clone_families: Vec::new(),
705                mirrored_directories: Vec::new(),
706                stats: DuplicationStats {
707                    clone_groups: 1,
708                    clone_instances: 1,
709                    total_files: 1,
710                    files_with_clones: 1,
711                    total_lines: 20,
712                    duplicated_lines: 4,
713                    total_tokens: 80,
714                    duplicated_tokens: 8,
715                    duplication_percentage: 20.0,
716                    clone_groups_below_min_occurrences: 1,
717                    clone_groups_ignored: 1,
718                    near_candidates_skipped: 2,
719                },
720            },
721            ..Default::default()
722        };
723
724        output.merge_duplication(EditorDuplicationReport {
725            clone_groups: Vec::new(),
726            clone_families: Vec::new(),
727            mirrored_directories: Vec::new(),
728            stats: DuplicationStats {
729                clone_groups: 0,
730                clone_instances: 0,
731                total_files: 1,
732                files_with_clones: 0,
733                total_lines: 30,
734                duplicated_lines: 6,
735                total_tokens: 120,
736                duplicated_tokens: 12,
737                duplication_percentage: 20.0,
738                clone_groups_below_min_occurrences: 2,
739                clone_groups_ignored: 3,
740                near_candidates_skipped: 4,
741            },
742        });
743
744        assert_eq!(output.duplication.stats.total_lines, 50);
745        assert_eq!(output.duplication.stats.duplicated_lines, 10);
746        assert_eq!(
747            output.duplication.stats.clone_groups_below_min_occurrences,
748            3
749        );
750        assert_eq!(output.duplication.stats.clone_groups_ignored, 4);
751        assert_eq!(output.duplication.stats.near_candidates_skipped, 6);
752        assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
753    }
754
755    #[test]
756    fn editor_session_returns_api_owned_project_output() {
757        let temp = tempfile::tempdir().expect("temp project");
758        let root = temp.path();
759        std::fs::create_dir_all(root.join("src")).expect("src dir");
760        std::fs::write(
761            root.join("package.json"),
762            r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
763        )
764        .expect("package.json");
765        std::fs::write(
766            root.join("src/index.ts"),
767            "export const used = 1;\nconsole.log(used);\n",
768        )
769        .expect("source");
770
771        let session = EditorAnalysisSession::load(root, None).expect("session loads");
772        let output = session
773            .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
774            .expect("analysis runs");
775
776        assert!(output.dead_code.modules.is_some());
777        assert!(
778            output
779                .dead_code
780                .files
781                .as_ref()
782                .is_some_and(|files| !files.is_empty())
783        );
784    }
785
786    #[test]
787    fn editor_session_scopes_duplication_to_changed_files() {
788        let temp = tempfile::tempdir().expect("temp project");
789        let root = temp.path();
790        let src = root.join("src");
791        std::fs::create_dir_all(&src).expect("src dir");
792        std::fs::write(
793            root.join("package.json"),
794            r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
795        )
796        .expect("package.json");
797        let repeated =
798            "export function repeated() {\n  return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
799        std::fs::write(src.join("a.ts"), repeated).expect("source a");
800        std::fs::write(src.join("b.ts"), repeated).expect("source b");
801
802        let session = EditorAnalysisSession::load(root, None).expect("session loads");
803        let mut config = session.config().duplicates.clone();
804        config.min_tokens = 1;
805        config.min_lines = 1;
806        let full = session
807            .analyze_project_with(&config, false)
808            .expect("analysis runs");
809        assert!(!full.duplication.clone_groups.is_empty());
810
811        let mut changed_files = FxHashSet::default();
812        changed_files.insert(src.join("unrelated.ts"));
813        let scoped = session
814            .analyze_project_with_changed_files(&config, false, Some(&changed_files))
815            .expect("analysis runs");
816        assert!(scoped.duplication.clone_groups.is_empty());
817    }
818
819    #[test]
820    fn build_health_ignore_set_returns_none_for_empty_patterns() {
821        assert!(
822            build_health_ignore_set(&[]).is_none(),
823            "empty ignore pattern list should avoid building a matcher"
824        );
825    }
826
827    #[test]
828    fn build_health_ignore_set_matches_glob_patterns() {
829        let set =
830            build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
831                .expect("valid patterns build a glob set");
832
833        assert!(set.is_match(Path::new("src/foo.test.ts")));
834        assert!(set.is_match(Path::new("src/generated/client.ts")));
835        assert!(!set.is_match(Path::new("src/app.ts")));
836    }
837
838    #[test]
839    fn build_health_ignore_set_skips_invalid_patterns() {
840        let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
841
842        match result {
843            None => {}
844            Some(set) => assert!(
845                !set.is_match(Path::new("any/path.ts")),
846                "set built from only invalid patterns must not match anything"
847            ),
848        }
849    }
850
851    fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
852        EditorInlineComplexityFinding {
853            path,
854            name: "myFn".to_string(),
855            line: 1,
856            col: 0,
857            cyclomatic: 5,
858            cognitive: 4,
859            exceeded: EditorInlineComplexityExceeded::Cyclomatic,
860        }
861    }
862
863    #[test]
864    fn filter_inline_complexity_keeps_findings_in_changed_set() {
865        let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
866            .into_iter()
867            .collect();
868        let mut findings = vec![
869            make_inline_finding(PathBuf::from("/src/a.ts")),
870            make_inline_finding(PathBuf::from("/src/c.ts")),
871        ];
872
873        filter_inline_complexity_by_changed_files(&mut findings, &changed);
874
875        assert_eq!(findings.len(), 1);
876        assert_eq!(
877            findings[0].path.to_string_lossy().replace('\\', "/"),
878            "/src/a.ts"
879        );
880    }
881
882    #[test]
883    fn filter_inline_complexity_removes_all_when_changed_set_empty() {
884        let changed: FxHashSet<PathBuf> = FxHashSet::default();
885        let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
886
887        filter_inline_complexity_by_changed_files(&mut findings, &changed);
888
889        assert!(
890            findings.is_empty(),
891            "empty changed-files set must drop all inline complexity findings"
892        );
893    }
894
895    #[test]
896    fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
897        let path_a = PathBuf::from("/src/a.ts");
898        let path_b = PathBuf::from("/src/b.ts");
899        let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
900        let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
901
902        filter_inline_complexity_by_changed_files(&mut findings, &changed);
903
904        assert_eq!(
905            findings.len(),
906            2,
907            "all findings in the changed set must be retained"
908        );
909    }
910}