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