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