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::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::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::ChangedFilesError> for ChangedFilesError {
111    fn from(error: fallow_engine::ChangedFilesError) -> Self {
112        match error {
113            fallow_engine::ChangedFilesError::InvalidRef(err) => Self::InvalidRef(err),
114            fallow_engine::ChangedFilesError::GitMissing(err) => Self::GitMissing(err),
115            fallow_engine::ChangedFilesError::NotARepository => Self::NotARepository,
116            fallow_engine::ChangedFilesError::GitFailed(stderr) => Self::GitFailed(stderr),
117        }
118    }
119}
120
121/// Resolve the canonical git toplevel for `cwd`.
122///
123/// # Errors
124///
125/// Returns an API-owned changed-file error when git cannot inspect the
126/// repository.
127pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
128    fallow_engine::resolve_git_toplevel(cwd).map_err(ChangedFilesError::from)
129}
130
131/// Get changed files and the git toplevel used to resolve them.
132///
133/// # Errors
134///
135/// Returns an API-owned changed-file error when git cannot resolve the ref or
136/// repository state.
137pub fn try_get_changed_files_with_toplevel(
138    cwd: &Path,
139    toplevel: &Path,
140    git_ref: &str,
141) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
142    fallow_engine::try_get_changed_files_with_toplevel(cwd, toplevel, git_ref)
143        .map_err(ChangedFilesError::from)
144}
145
146pub mod editor_extract {
147    pub use fallow_types::extract::{
148        AngularComponentSelector, AngularInputMember, AngularOutputMember,
149        AngularTemplateMemberAccessFact, AngularThisSpreadFact, CalleeUse, ClassHeritageInfo,
150        ComplexityContribution, ComplexityContributionKind, ComplexityMetric, ComponentEmit,
151        ComponentFunction, ComponentFunctionKind, ComponentProp, CssAnalytics, CssDeclarationBlock,
152        CssRuleMetric, DiFramework, DiKeySite, DiRole, DispatchedEvent,
153        DynamicCustomElementRenderFact, DynamicImportInfo, DynamicImportPattern, ExportInfo,
154        ExportName, FactoryCallMemberAccessFact, FactoryFnMemberAccessFact, FactoryReturnExport,
155        FlagUse, FlagUseKind, FluentChainMemberAccessFact, FluentChainNewMemberAccessFact,
156        ForwardAttr, FunctionComplexity, HookUse, HookUseKind, ImportInfo, ImportedName,
157        InstanceExportBindingFact, LoadReturnKey, LocalTypeDeclaration, MemberAccess, MemberInfo,
158        MemberKind, MisplacedDirectiveSite, ModuleInfo, NamespaceObjectAlias, PUBLIC_ENV_EXACT,
159        PUBLIC_ENV_METADATA_TOKENS, PUBLIC_ENV_PREFIXES, ParseResult, PlaywrightFixtureAliasFact,
160        PlaywrightFixtureDefinitionFact, PlaywrightFixtureTypeFact, PlaywrightFixtureUseFact,
161        PublicSignatureTypeReference, ReExportInfo, RegisteredCustomElement, RenderEdge,
162        RequireCallInfo, SECRET_ENV_TOKENS, SanitizedSinkArg, SanitizerScope, SecurityControlKind,
163        SecurityControlSite, SecurityUrlShape, SemanticFact, SemanticFactView, SinkArgKind,
164        SinkLiteralValue, SinkObjectProperty, SinkShape, SinkSite,
165        SkippedSecurityCalleeExpressionKind, SkippedSecurityCalleeReason,
166        SkippedSecurityCalleeSite, TaintedBinding, VisibilityTag,
167    };
168}
169
170pub mod editor_results {
171    pub use fallow_types::output_dead_code::{
172        BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
173        CircularDependencyFinding, DuplicateExportFinding, DuplicatePropShapeFinding,
174        DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding, InvalidClientExportFinding,
175        MisconfiguredDependencyOverrideFinding, MisplacedDirectiveFinding,
176        MixedClientServerBarrelFinding, PolicyViolationFinding, PrivateTypeLeakFinding,
177        PropDrillingChainFinding, ReExportCycleFinding, RouteCollisionFinding,
178        TestOnlyDependencyFinding, ThinWrapperFinding, TypeOnlyDependencyFinding,
179        UnlistedDependencyFinding, UnprovidedInjectFinding, UnrenderedComponentFinding,
180        UnresolvedCatalogReferenceFinding, UnresolvedImportFinding, UnusedCatalogEntryFinding,
181        UnusedClassMemberFinding, UnusedComponentEmitFinding, UnusedComponentInputFinding,
182        UnusedComponentOutputFinding, UnusedComponentPropFinding, UnusedDependencyFinding,
183        UnusedDependencyOverrideFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding,
184        UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
185        UnusedOptionalDependencyFinding, UnusedServerActionFinding, UnusedStoreMemberFinding,
186        UnusedSvelteEventFinding, UnusedTypeFinding,
187    };
188    pub use fallow_types::results::{
189        ActiveSuppression, AnalysisResults, BoundaryCallViolation, BoundaryCoverageViolation,
190        BoundaryViolation, CircularDependency, CircularDependencyEdge, DependencyLocation,
191        DependencyOverrideMisconfigReason, DependencyOverrideSource, DuplicateExport,
192        DuplicateLocation, DuplicatePropShape, DuplicatePropShapeMember,
193        DynamicSegmentNameConflict, EmptyCatalogGroup, EntryPointSummary, ExportUsage, FeatureFlag,
194        FlagConfidence, FlagKind, ImportSite, InvalidClientExport, MisconfiguredDependencyOverride,
195        MisplacedDirective, MixedClientServerBarrel, PolicyRuleKind, PolicyViolation,
196        PolicyViolationSeverity, PrivateTypeLeak, PropDrillHop, PropDrillingChain, ReExportCycle,
197        ReExportCycleKind, ReactComponentIntel, ReactHookSummary, ReactPropDrill, ReactPropIntel,
198        ReferenceLocation, RenderFanInComponent, RenderFanInMetric, RouteCollision,
199        SecurityAttackSurfaceEntry, SecurityCandidate, SecurityCandidateBoundary,
200        SecurityCandidateSink, SecurityDeadCodeContext, SecurityDeadCodeKind,
201        SecurityDefensiveBoundary, SecurityDefensiveControl, SecurityFinding, SecurityFindingKind,
202        SecurityNetworkContext, SecurityReachability, SecurityRuntimeContext, SecurityRuntimeState,
203        SecuritySeverity, SecurityTaintFlow, SecurityUnresolvedCalleeDiagnostic,
204        SecurityZoneCrossing, StaleSuppression, SuppressionOrigin, TaintConfidence, TaintEndpoint,
205        TaintPath, TestOnlyDependency, ThinWrapper, TraceHop, TraceHopRole, TypeOnlyDependency,
206        UnlistedDependency, UnprovidedInject, UnrenderedComponent, UnresolvedCatalogReference,
207        UnresolvedImport, UnusedCatalogEntry, UnusedComponentEmit, UnusedComponentInput,
208        UnusedComponentOutput, UnusedComponentProp, UnusedDependency, UnusedDependencyOverride,
209        UnusedExport, UnusedFile, UnusedLoadDataKey, UnusedMember, UnusedServerAction,
210        UnusedSvelteEvent,
211    };
212}
213
214pub mod editor_security {
215    /// Return the human-readable security catalogue title for a finding kind.
216    #[must_use]
217    pub fn security_catalogue_title(kind: &str) -> Option<&'static str> {
218        fallow_engine::security_catalogue_title(kind)
219    }
220}
221
222pub mod editor_suppress {
223    pub use fallow_types::suppress::{IssueKind, is_suppressed};
224}
225
226pub type EditorAnalysisResults = fallow_types::results::AnalysisResults;
227
228/// Dead-code output retained for editor integrations.
229///
230/// The engine produces the data, but the editor API owns this public contract
231/// so LSP and future editor adapters do not depend on engine result structs.
232#[derive(Debug)]
233pub struct EditorDeadCodeAnalysisOutput {
234    pub results: EditorAnalysisResults,
235    pub modules: Option<Vec<ModuleInfo>>,
236    pub files: Option<Vec<DiscoveredFile>>,
237}
238
239impl EditorDeadCodeAnalysisOutput {
240    fn from_engine(output: fallow_engine::DeadCodeAnalysisOutput) -> Self {
241        Self {
242            results: output.results,
243            modules: output.modules,
244            files: output.files,
245        }
246    }
247}
248
249/// Editor-facing inline complexity signal for code lens and similar surfaces.
250///
251/// The finding is derived from retained typed engine parse artifacts, but the
252/// editor API owns the stable shape so LSP and future editor adapters do not
253/// need to inspect raw modules directly.
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct EditorInlineComplexityFinding {
256    pub path: PathBuf,
257    pub name: String,
258    pub line: u32,
259    pub col: u32,
260    pub cyclomatic: u16,
261    pub cognitive: u16,
262    pub exceeded: EditorInlineComplexityExceeded,
263}
264
265/// Which health complexity threshold(s) a function exceeded.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum EditorInlineComplexityExceeded {
268    Cyclomatic,
269    Cognitive,
270    CyclomaticAndCognitive,
271}
272
273/// Collect inline complexity findings from retained editor analysis artifacts.
274#[must_use]
275pub fn collect_inline_complexity(
276    config: &fallow_config::ResolvedConfig,
277    output: &EditorDeadCodeAnalysisOutput,
278) -> Vec<EditorInlineComplexityFinding> {
279    let Some(modules) = output.modules.as_ref() else {
280        return Vec::new();
281    };
282    let Some(files) = output.files.as_ref() else {
283        return Vec::new();
284    };
285
286    let file_paths: rustc_hash::FxHashMap<_, _> =
287        files.iter().map(|file| (file.id, &file.path)).collect();
288    let ignore_set = build_health_ignore_set(&config.health.ignore);
289    let mut findings = Vec::new();
290
291    for module in modules {
292        let Some(path) = file_paths.get(&module.file_id) else {
293            continue;
294        };
295        let relative = path.strip_prefix(&config.root).unwrap_or(path);
296        if ignore_set
297            .as_ref()
298            .is_some_and(|set| set.is_match(relative))
299        {
300            continue;
301        }
302
303        for function in &module.complexity {
304            if fallow_types::suppress::is_suppressed(
305                &module.suppressions,
306                function.line,
307                fallow_types::suppress::IssueKind::Complexity,
308            ) {
309                continue;
310            }
311
312            let exceeds_cyclomatic = function.cyclomatic > config.health.max_cyclomatic;
313            let exceeds_cognitive = function.cognitive > config.health.max_cognitive;
314            let exceeded = match (exceeds_cyclomatic, exceeds_cognitive) {
315                (true, true) => EditorInlineComplexityExceeded::CyclomaticAndCognitive,
316                (true, false) => EditorInlineComplexityExceeded::Cyclomatic,
317                (false, true) => EditorInlineComplexityExceeded::Cognitive,
318                (false, false) => continue,
319            };
320
321            findings.push(EditorInlineComplexityFinding {
322                path: (*path).clone(),
323                name: function.name.clone(),
324                line: function.line,
325                col: function.col,
326                cyclomatic: function.cyclomatic,
327                cognitive: function.cognitive,
328                exceeded,
329            });
330        }
331    }
332
333    findings
334}
335
336/// Filter inline complexity findings to the changed-file set.
337#[allow(
338    clippy::implicit_hasher,
339    reason = "editor analysis changed-file sets use the workspace FxHashSet convention"
340)]
341pub fn filter_inline_complexity_by_changed_files(
342    findings: &mut Vec<EditorInlineComplexityFinding>,
343    changed_files: &FxHashSet<PathBuf>,
344) {
345    findings.retain(|finding| changed_files.contains(&finding.path));
346}
347
348fn build_health_ignore_set(patterns: &[String]) -> Option<globset::GlobSet> {
349    if patterns.is_empty() {
350        return None;
351    }
352
353    let mut builder = globset::GlobSetBuilder::new();
354    for pattern in patterns {
355        let Ok(glob) = globset::Glob::new(pattern) else {
356            continue;
357        };
358        builder.add(glob);
359    }
360    builder.build().ok()
361}
362
363/// Reusable editor analysis session owned by the API boundary.
364#[derive(Debug)]
365pub struct EditorAnalysisSession {
366    inner: fallow_engine::AnalysisSession,
367}
368
369impl EditorAnalysisSession {
370    /// Load config and discover files for an editor project root.
371    ///
372    /// # Errors
373    ///
374    /// Returns an engine error when project config loading fails.
375    pub fn load(root: &Path, config_path: Option<&Path>) -> fallow_engine::EngineResult<Self> {
376        fallow_engine::AnalysisSession::load(root, config_path).map(Self::from_engine)
377    }
378
379    /// Load config, apply one editor-specific adjustment, then discover files.
380    ///
381    /// # Errors
382    ///
383    /// Returns an engine error when project config loading fails.
384    pub fn load_with_config(
385        root: &Path,
386        config_path: Option<&Path>,
387        configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
388    ) -> fallow_engine::EngineResult<Self> {
389        fallow_engine::AnalysisSession::load_with_config(root, config_path, configure)
390            .map(Self::from_engine)
391    }
392
393    /// Build a session from built-in defaults, ignoring project config files.
394    #[must_use]
395    pub fn load_default(root: &Path) -> Self {
396        Self::from_engine(fallow_engine::AnalysisSession::load_default(root))
397    }
398
399    /// Resolved project config.
400    #[must_use]
401    pub fn config(&self) -> &fallow_config::ResolvedConfig {
402        self.inner.config()
403    }
404
405    /// Config file path when one was loaded.
406    #[must_use]
407    pub fn config_path(&self) -> Option<&Path> {
408        self.inner.config_path()
409    }
410
411    /// Run dead-code and duplication analysis for this editor session.
412    ///
413    /// # Errors
414    ///
415    /// Returns an engine error when dead-code parsing or analysis fails.
416    pub fn analyze_project_with(
417        &self,
418        duplicates_config: &fallow_config::DuplicatesConfig,
419        retain_complexity_artifacts: bool,
420    ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
421        self.inner
422            .analyze_project_with(duplicates_config, retain_complexity_artifacts)
423            .map(EditorProjectAnalysisOutput::from_engine)
424    }
425
426    const fn from_engine(inner: fallow_engine::AnalysisSession) -> Self {
427        Self { inner }
428    }
429}
430
431/// Dead-code and duplication project output owned by the editor API boundary.
432#[derive(Debug)]
433pub struct EditorProjectAnalysisOutput {
434    pub dead_code: EditorDeadCodeAnalysisOutput,
435    pub duplication: EditorDuplicationReport,
436}
437
438impl EditorProjectAnalysisOutput {
439    fn from_engine(output: fallow_engine::ProjectAnalysisOutput) -> Self {
440        Self {
441            dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
442            duplication: output.duplication,
443        }
444    }
445}
446
447/// Dead-code and duplication output shaped for editor integrations.
448#[derive(Debug, Default)]
449pub struct EditorAnalysisOutput {
450    pub results: EditorAnalysisResults,
451    pub duplication: EditorDuplicationReport,
452}
453
454impl EditorAnalysisOutput {
455    #[must_use]
456    pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
457        Self {
458            results,
459            duplication,
460        }
461    }
462
463    #[must_use]
464    pub fn from_project_output(output: EditorProjectAnalysisOutput) -> Self {
465        Self::new(output.dead_code.results, output.duplication)
466    }
467
468    pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
469        self.merge_results(output.dead_code.results);
470        self.merge_duplication(output.duplication);
471    }
472
473    pub fn merge_results(&mut self, source: EditorAnalysisResults) {
474        self.results.merge_into(source);
475    }
476
477    pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
478        self.duplication.clone_groups.extend(source.clone_groups);
479        self.duplication
480            .clone_families
481            .extend(source.clone_families);
482        self.duplication
483            .mirrored_directories
484            .extend(source.mirrored_directories);
485        self.duplication.stats.clone_groups += source.stats.clone_groups;
486        self.duplication.stats.clone_instances += source.stats.clone_instances;
487        self.duplication.stats.total_files += source.stats.total_files;
488        self.duplication.stats.files_with_clones += source.stats.files_with_clones;
489        self.duplication.stats.total_lines += source.stats.total_lines;
490        self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
491        self.duplication.stats.total_tokens += source.stats.total_tokens;
492        self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
493        self.duplication.stats.clone_groups_below_min_occurrences +=
494            source.stats.clone_groups_below_min_occurrences;
495        self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
496            (self.duplication.stats.duplicated_lines as f64
497                / self.duplication.stats.total_lines as f64)
498                * 100.0
499        } else {
500            0.0
501        };
502    }
503
504    pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
505        fallow_engine::filter_results_by_changed_files(&mut self.results, changed_files);
506        fallow_engine::filter_duplication_by_changed_files(
507            &mut self.duplication,
508            changed_files,
509            root,
510        );
511    }
512
513    pub fn filter_by_changed_since(
514        &mut self,
515        root: &Path,
516        toplevel: &Path,
517        git_ref: &str,
518    ) -> Result<usize, ChangedFilesError> {
519        let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
520        let changed_count = changed.len();
521        self.filter_by_changed_files(&changed, root);
522        Ok(changed_count)
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
531
532    #[test]
533    fn merges_duplication_stats_and_recomputes_percentage() {
534        let mut output = EditorAnalysisOutput {
535            duplication: EditorDuplicationReport {
536                clone_groups: vec![CloneGroup {
537                    instances: vec![CloneInstance {
538                        file: PathBuf::from("src/a.ts"),
539                        start_line: 1,
540                        end_line: 4,
541                        start_col: 0,
542                        end_col: 10,
543                        fragment: "const a = 1;".to_string(),
544                    }],
545                    token_count: 8,
546                    line_count: 4,
547                }],
548                clone_families: Vec::new(),
549                mirrored_directories: Vec::new(),
550                stats: DuplicationStats {
551                    clone_groups: 1,
552                    clone_instances: 1,
553                    total_files: 1,
554                    files_with_clones: 1,
555                    total_lines: 20,
556                    duplicated_lines: 4,
557                    total_tokens: 80,
558                    duplicated_tokens: 8,
559                    duplication_percentage: 20.0,
560                    clone_groups_below_min_occurrences: 1,
561                },
562            },
563            ..Default::default()
564        };
565
566        output.merge_duplication(EditorDuplicationReport {
567            clone_groups: Vec::new(),
568            clone_families: Vec::new(),
569            mirrored_directories: Vec::new(),
570            stats: DuplicationStats {
571                clone_groups: 0,
572                clone_instances: 0,
573                total_files: 1,
574                files_with_clones: 0,
575                total_lines: 30,
576                duplicated_lines: 6,
577                total_tokens: 120,
578                duplicated_tokens: 12,
579                duplication_percentage: 20.0,
580                clone_groups_below_min_occurrences: 2,
581            },
582        });
583
584        assert_eq!(output.duplication.stats.total_lines, 50);
585        assert_eq!(output.duplication.stats.duplicated_lines, 10);
586        assert_eq!(
587            output.duplication.stats.clone_groups_below_min_occurrences,
588            3
589        );
590        assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
591    }
592
593    #[test]
594    fn editor_session_returns_api_owned_project_output() {
595        let temp = tempfile::tempdir().expect("temp project");
596        let root = temp.path();
597        std::fs::create_dir_all(root.join("src")).expect("src dir");
598        std::fs::write(
599            root.join("package.json"),
600            r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
601        )
602        .expect("package.json");
603        std::fs::write(
604            root.join("src/index.ts"),
605            "export const used = 1;\nconsole.log(used);\n",
606        )
607        .expect("source");
608
609        let session = EditorAnalysisSession::load(root, None).expect("session loads");
610        let output = session
611            .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
612            .expect("analysis runs");
613
614        assert!(output.dead_code.modules.is_some());
615        assert!(
616            output
617                .dead_code
618                .files
619                .as_ref()
620                .is_some_and(|files| !files.is_empty())
621        );
622    }
623
624    #[test]
625    fn build_health_ignore_set_returns_none_for_empty_patterns() {
626        assert!(
627            build_health_ignore_set(&[]).is_none(),
628            "empty ignore pattern list should avoid building a matcher"
629        );
630    }
631
632    #[test]
633    fn build_health_ignore_set_matches_glob_patterns() {
634        let set =
635            build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
636                .expect("valid patterns build a glob set");
637
638        assert!(set.is_match(Path::new("src/foo.test.ts")));
639        assert!(set.is_match(Path::new("src/generated/client.ts")));
640        assert!(!set.is_match(Path::new("src/app.ts")));
641    }
642
643    #[test]
644    fn build_health_ignore_set_skips_invalid_patterns() {
645        let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
646
647        match result {
648            None => {}
649            Some(set) => assert!(
650                !set.is_match(Path::new("any/path.ts")),
651                "set built from only invalid patterns must not match anything"
652            ),
653        }
654    }
655
656    fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
657        EditorInlineComplexityFinding {
658            path,
659            name: "myFn".to_string(),
660            line: 1,
661            col: 0,
662            cyclomatic: 5,
663            cognitive: 4,
664            exceeded: EditorInlineComplexityExceeded::Cyclomatic,
665        }
666    }
667
668    #[test]
669    fn filter_inline_complexity_keeps_findings_in_changed_set() {
670        let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
671            .into_iter()
672            .collect();
673        let mut findings = vec![
674            make_inline_finding(PathBuf::from("/src/a.ts")),
675            make_inline_finding(PathBuf::from("/src/c.ts")),
676        ];
677
678        filter_inline_complexity_by_changed_files(&mut findings, &changed);
679
680        assert_eq!(findings.len(), 1);
681        assert_eq!(
682            findings[0].path.to_string_lossy().replace('\\', "/"),
683            "/src/a.ts"
684        );
685    }
686
687    #[test]
688    fn filter_inline_complexity_removes_all_when_changed_set_empty() {
689        let changed: FxHashSet<PathBuf> = FxHashSet::default();
690        let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
691
692        filter_inline_complexity_by_changed_files(&mut findings, &changed);
693
694        assert!(
695            findings.is_empty(),
696            "empty changed-files set must drop all inline complexity findings"
697        );
698    }
699
700    #[test]
701    fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
702        let path_a = PathBuf::from("/src/a.ts");
703        let path_b = PathBuf::from("/src/b.ts");
704        let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
705        let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
706
707        filter_inline_complexity_by_changed_files(&mut findings, &changed);
708
709        assert_eq!(
710            findings.len(),
711            2,
712            "all findings in the changed set must be retained"
713        );
714    }
715}