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, DuplicateExportFinding, DuplicatePropShapeFinding,
180        DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding, InvalidClientExportFinding,
181        MisconfiguredDependencyOverrideFinding, MisplacedDirectiveFinding,
182        MixedClientServerBarrelFinding, PolicyViolationFinding, PrivateTypeLeakFinding,
183        PropDrillingChainFinding, ReExportCycleFinding, RouteCollisionFinding,
184        TestOnlyDependencyFinding, ThinWrapperFinding, TypeOnlyDependencyFinding,
185        UnlistedDependencyFinding, UnprovidedInjectFinding, UnrenderedComponentFinding,
186        UnresolvedCatalogReferenceFinding, UnresolvedImportFinding, UnusedCatalogEntryFinding,
187        UnusedClassMemberFinding, UnusedComponentEmitFinding, UnusedComponentInputFinding,
188        UnusedComponentOutputFinding, UnusedComponentPropFinding, UnusedDependencyFinding,
189        UnusedDependencyOverrideFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding,
190        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, DuplicateExport,
198        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    /// Build a session from built-in defaults, ignoring project config files.
400    #[must_use]
401    pub fn load_default(root: &Path) -> Self {
402        Self::from_engine(fallow_engine::session::AnalysisSession::load_default(root))
403    }
404
405    /// Resolved project config.
406    #[must_use]
407    pub fn config(&self) -> &fallow_config::ResolvedConfig {
408        self.inner.config()
409    }
410
411    /// Config file path when one was loaded.
412    #[must_use]
413    pub fn config_path(&self) -> Option<&Path> {
414        self.inner.config_path()
415    }
416
417    /// Run dead-code and duplication analysis for this editor session.
418    ///
419    /// # Errors
420    ///
421    /// Returns an engine error when dead-code parsing or analysis fails.
422    pub fn analyze_project_with(
423        &self,
424        duplicates_config: &fallow_config::DuplicatesConfig,
425        retain_complexity_artifacts: bool,
426    ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
427        self.inner
428            .analyze_project_with(duplicates_config, retain_complexity_artifacts)
429            .map(EditorProjectAnalysisOutput::from_engine)
430    }
431
432    /// Run dead-code and duplication analysis, optionally focusing duplication
433    /// to files the editor already resolved as changed.
434    ///
435    /// Dead-code still runs with full graph context so downstream editor
436    /// filters can preserve existing diagnostic semantics.
437    ///
438    /// # Errors
439    ///
440    /// Returns an engine error when dead-code parsing or analysis fails.
441    pub fn analyze_project_with_changed_files(
442        &self,
443        duplicates_config: &fallow_config::DuplicatesConfig,
444        retain_complexity_artifacts: bool,
445        changed_files: Option<&FxHashSet<PathBuf>>,
446    ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
447        self.inner
448            .analyze_project_with_artifacts(
449                duplicates_config,
450                fallow_engine::project_analysis::ProjectAnalysisArtifactOptions {
451                    retain_complexity_artifacts,
452                    changed_files: changed_files.cloned(),
453                    ..fallow_engine::project_analysis::ProjectAnalysisArtifactOptions::default()
454                },
455            )
456            .map(fallow_engine::project_analysis::ProjectAnalysisArtifacts::into_output)
457            .map(EditorProjectAnalysisOutput::from_engine)
458    }
459
460    const fn from_engine(inner: fallow_engine::session::AnalysisSession) -> Self {
461        Self { inner }
462    }
463}
464
465/// Dead-code and duplication project output owned by the editor API boundary.
466#[derive(Debug)]
467pub struct EditorProjectAnalysisOutput {
468    pub dead_code: EditorDeadCodeAnalysisOutput,
469    pub duplication: EditorDuplicationReport,
470}
471
472impl EditorProjectAnalysisOutput {
473    fn from_engine(output: fallow_engine::project_analysis::ProjectAnalysisOutput) -> Self {
474        Self {
475            dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
476            duplication: output.duplication,
477        }
478    }
479}
480
481/// Dead-code and duplication output shaped for editor integrations.
482#[derive(Debug, Default)]
483pub struct EditorAnalysisOutput {
484    pub results: EditorAnalysisResults,
485    pub duplication: EditorDuplicationReport,
486}
487
488impl EditorAnalysisOutput {
489    #[must_use]
490    pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
491        Self {
492            results,
493            duplication,
494        }
495    }
496
497    #[must_use]
498    pub fn from_project_output(output: EditorProjectAnalysisOutput) -> Self {
499        Self::new(output.dead_code.results, output.duplication)
500    }
501
502    pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
503        self.merge_results(output.dead_code.results);
504        self.merge_duplication(output.duplication);
505    }
506
507    pub fn merge_results(&mut self, source: EditorAnalysisResults) {
508        self.results.merge_into(source);
509    }
510
511    pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
512        self.duplication.clone_groups.extend(source.clone_groups);
513        self.duplication
514            .clone_families
515            .extend(source.clone_families);
516        self.duplication
517            .mirrored_directories
518            .extend(source.mirrored_directories);
519        self.duplication.stats.clone_groups += source.stats.clone_groups;
520        self.duplication.stats.clone_instances += source.stats.clone_instances;
521        self.duplication.stats.total_files += source.stats.total_files;
522        self.duplication.stats.files_with_clones += source.stats.files_with_clones;
523        self.duplication.stats.total_lines += source.stats.total_lines;
524        self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
525        self.duplication.stats.total_tokens += source.stats.total_tokens;
526        self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
527        self.duplication.stats.clone_groups_below_min_occurrences +=
528            source.stats.clone_groups_below_min_occurrences;
529        self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
530            (self.duplication.stats.duplicated_lines as f64
531                / self.duplication.stats.total_lines as f64)
532                * 100.0
533        } else {
534            0.0
535        };
536    }
537
538    pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
539        fallow_engine::changed_files::filter_results_by_changed_files(
540            &mut self.results,
541            changed_files,
542        );
543        fallow_engine::changed_files::filter_duplication_by_changed_files(
544            &mut self.duplication,
545            changed_files,
546            root,
547        );
548    }
549
550    pub fn filter_by_changed_since(
551        &mut self,
552        root: &Path,
553        toplevel: &Path,
554        git_ref: &str,
555    ) -> Result<usize, ChangedFilesError> {
556        let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
557        let changed_count = changed.len();
558        self.filter_by_changed_files(&changed, root);
559        Ok(changed_count)
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
568
569    #[test]
570    fn merges_duplication_stats_and_recomputes_percentage() {
571        let mut output = EditorAnalysisOutput {
572            duplication: EditorDuplicationReport {
573                clone_groups: vec![CloneGroup {
574                    instances: vec![CloneInstance {
575                        file: PathBuf::from("src/a.ts"),
576                        start_line: 1,
577                        end_line: 4,
578                        start_col: 0,
579                        end_col: 10,
580                        fragment: "const a = 1;".to_string(),
581                    }],
582                    token_count: 8,
583                    line_count: 4,
584                }],
585                clone_families: Vec::new(),
586                mirrored_directories: Vec::new(),
587                stats: DuplicationStats {
588                    clone_groups: 1,
589                    clone_instances: 1,
590                    total_files: 1,
591                    files_with_clones: 1,
592                    total_lines: 20,
593                    duplicated_lines: 4,
594                    total_tokens: 80,
595                    duplicated_tokens: 8,
596                    duplication_percentage: 20.0,
597                    clone_groups_below_min_occurrences: 1,
598                },
599            },
600            ..Default::default()
601        };
602
603        output.merge_duplication(EditorDuplicationReport {
604            clone_groups: Vec::new(),
605            clone_families: Vec::new(),
606            mirrored_directories: Vec::new(),
607            stats: DuplicationStats {
608                clone_groups: 0,
609                clone_instances: 0,
610                total_files: 1,
611                files_with_clones: 0,
612                total_lines: 30,
613                duplicated_lines: 6,
614                total_tokens: 120,
615                duplicated_tokens: 12,
616                duplication_percentage: 20.0,
617                clone_groups_below_min_occurrences: 2,
618            },
619        });
620
621        assert_eq!(output.duplication.stats.total_lines, 50);
622        assert_eq!(output.duplication.stats.duplicated_lines, 10);
623        assert_eq!(
624            output.duplication.stats.clone_groups_below_min_occurrences,
625            3
626        );
627        assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
628    }
629
630    #[test]
631    fn editor_session_returns_api_owned_project_output() {
632        let temp = tempfile::tempdir().expect("temp project");
633        let root = temp.path();
634        std::fs::create_dir_all(root.join("src")).expect("src dir");
635        std::fs::write(
636            root.join("package.json"),
637            r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
638        )
639        .expect("package.json");
640        std::fs::write(
641            root.join("src/index.ts"),
642            "export const used = 1;\nconsole.log(used);\n",
643        )
644        .expect("source");
645
646        let session = EditorAnalysisSession::load(root, None).expect("session loads");
647        let output = session
648            .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
649            .expect("analysis runs");
650
651        assert!(output.dead_code.modules.is_some());
652        assert!(
653            output
654                .dead_code
655                .files
656                .as_ref()
657                .is_some_and(|files| !files.is_empty())
658        );
659    }
660
661    #[test]
662    fn editor_session_scopes_duplication_to_changed_files() {
663        let temp = tempfile::tempdir().expect("temp project");
664        let root = temp.path();
665        let src = root.join("src");
666        std::fs::create_dir_all(&src).expect("src dir");
667        std::fs::write(
668            root.join("package.json"),
669            r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
670        )
671        .expect("package.json");
672        let repeated =
673            "export function repeated() {\n  return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
674        std::fs::write(src.join("a.ts"), repeated).expect("source a");
675        std::fs::write(src.join("b.ts"), repeated).expect("source b");
676
677        let session = EditorAnalysisSession::load(root, None).expect("session loads");
678        let mut config = session.config().duplicates.clone();
679        config.min_tokens = 1;
680        config.min_lines = 1;
681        let full = session
682            .analyze_project_with(&config, false)
683            .expect("analysis runs");
684        assert!(!full.duplication.clone_groups.is_empty());
685
686        let mut changed_files = FxHashSet::default();
687        changed_files.insert(src.join("unrelated.ts"));
688        let scoped = session
689            .analyze_project_with_changed_files(&config, false, Some(&changed_files))
690            .expect("analysis runs");
691        assert!(scoped.duplication.clone_groups.is_empty());
692    }
693
694    #[test]
695    fn build_health_ignore_set_returns_none_for_empty_patterns() {
696        assert!(
697            build_health_ignore_set(&[]).is_none(),
698            "empty ignore pattern list should avoid building a matcher"
699        );
700    }
701
702    #[test]
703    fn build_health_ignore_set_matches_glob_patterns() {
704        let set =
705            build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
706                .expect("valid patterns build a glob set");
707
708        assert!(set.is_match(Path::new("src/foo.test.ts")));
709        assert!(set.is_match(Path::new("src/generated/client.ts")));
710        assert!(!set.is_match(Path::new("src/app.ts")));
711    }
712
713    #[test]
714    fn build_health_ignore_set_skips_invalid_patterns() {
715        let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
716
717        match result {
718            None => {}
719            Some(set) => assert!(
720                !set.is_match(Path::new("any/path.ts")),
721                "set built from only invalid patterns must not match anything"
722            ),
723        }
724    }
725
726    fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
727        EditorInlineComplexityFinding {
728            path,
729            name: "myFn".to_string(),
730            line: 1,
731            col: 0,
732            cyclomatic: 5,
733            cognitive: 4,
734            exceeded: EditorInlineComplexityExceeded::Cyclomatic,
735        }
736    }
737
738    #[test]
739    fn filter_inline_complexity_keeps_findings_in_changed_set() {
740        let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
741            .into_iter()
742            .collect();
743        let mut findings = vec![
744            make_inline_finding(PathBuf::from("/src/a.ts")),
745            make_inline_finding(PathBuf::from("/src/c.ts")),
746        ];
747
748        filter_inline_complexity_by_changed_files(&mut findings, &changed);
749
750        assert_eq!(findings.len(), 1);
751        assert_eq!(
752            findings[0].path.to_string_lossy().replace('\\', "/"),
753            "/src/a.ts"
754        );
755    }
756
757    #[test]
758    fn filter_inline_complexity_removes_all_when_changed_set_empty() {
759        let changed: FxHashSet<PathBuf> = FxHashSet::default();
760        let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
761
762        filter_inline_complexity_by_changed_files(&mut findings, &changed);
763
764        assert!(
765            findings.is_empty(),
766            "empty changed-files set must drop all inline complexity findings"
767        );
768    }
769
770    #[test]
771    fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
772        let path_a = PathBuf::from("/src/a.ts");
773        let path_b = PathBuf::from("/src/b.ts");
774        let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
775        let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
776
777        filter_inline_complexity_by_changed_files(&mut findings, &changed);
778
779        assert_eq!(
780            findings.len(),
781            2,
782            "all findings in the changed set must be retained"
783        );
784    }
785}