1use 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;
11pub type EditorCloneGroup = fallow_types::duplicates::CloneGroup;
13pub type EditorCloneInstance = fallow_types::duplicates::CloneInstance;
15pub type EditorDuplicationReport = fallow_types::duplicates::DuplicationReport;
17pub type EditorDuplicationStats = fallow_types::duplicates::DuplicationStats;
19pub type EditorMirroredDirectory = fallow_types::duplicates::MirroredDirectory;
21pub type EditorRefactoringKind = fallow_types::duplicates::RefactoringKind;
23pub type EditorRefactoringSuggestion = fallow_types::duplicates::RefactoringSuggestion;
25
26#[derive(Debug, Clone)]
28pub struct EditorCloneFingerprintSet {
29 inner: fallow_engine::duplicates::CloneFingerprintSet,
30}
31
32impl EditorCloneFingerprintSet {
33 #[must_use]
35 pub fn from_groups(groups: &[EditorCloneGroup]) -> Self {
36 Self {
37 inner: fallow_engine::duplicates::CloneFingerprintSet::from_groups(groups),
38 }
39 }
40
41 #[must_use]
43 pub fn fingerprint_for_group(&self, group: &EditorCloneGroup) -> String {
44 self.inner.fingerprint_for_group(group)
45 }
46
47 #[must_use]
49 pub fn fingerprint_for_parts(
50 &self,
51 instances: &[EditorCloneInstance],
52 token_count: usize,
53 line_count: usize,
54 ) -> String {
55 self.inner
56 .fingerprint_for_parts(instances, token_count, line_count)
57 }
58
59 #[must_use]
61 pub fn find_group<'a>(
62 &self,
63 groups: &'a [EditorCloneGroup],
64 fingerprint: &str,
65 ) -> Option<&'a EditorCloneGroup> {
66 self.inner.find_group(groups, fingerprint)
67 }
68}
69
70pub mod editor_duplicates {
73 pub use crate::editor::{
74 EditorCloneFamily as CloneFamily, EditorCloneFingerprintSet as CloneFingerprintSet,
75 EditorCloneGroup as CloneGroup, EditorCloneInstance as CloneInstance,
76 EditorDuplicationReport as DuplicationReport, EditorDuplicationStats as DuplicationStats,
77 EditorMirroredDirectory as MirroredDirectory, EditorRefactoringKind as RefactoringKind,
78 EditorRefactoringSuggestion as RefactoringSuggestion,
79 };
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ChangedFilesError {
85 InvalidRef(String),
87 GitMissing(String),
89 NotARepository,
91 GitFailed(String),
93}
94
95impl ChangedFilesError {
96 #[must_use]
98 pub fn describe(&self) -> String {
99 match self {
100 Self::InvalidRef(err) => format!("invalid git ref: {err}"),
101 Self::GitMissing(err) => format!("failed to run git: {err}"),
102 Self::NotARepository => "not a git repository".to_owned(),
103 Self::GitFailed(stderr) => {
104 let lower = stderr.to_ascii_lowercase();
105 if lower.contains("not a valid object name")
106 || lower.contains("unknown revision")
107 || lower.contains("ambiguous argument")
108 {
109 format!(
110 "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
111 )
112 } else {
113 stderr.clone()
114 }
115 }
116 }
117 }
118}
119
120impl From<fallow_engine::changed_files::ChangedFilesError> for ChangedFilesError {
121 fn from(error: fallow_engine::changed_files::ChangedFilesError) -> Self {
122 match error {
123 fallow_engine::changed_files::ChangedFilesError::InvalidRef(err) => {
124 Self::InvalidRef(err)
125 }
126 fallow_engine::changed_files::ChangedFilesError::GitMissing(err) => {
127 Self::GitMissing(err)
128 }
129 fallow_engine::changed_files::ChangedFilesError::NotARepository => Self::NotARepository,
130 fallow_engine::changed_files::ChangedFilesError::GitFailed(stderr) => {
131 Self::GitFailed(stderr)
132 }
133 }
134 }
135}
136
137pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
144 fallow_engine::changed_files::resolve_git_toplevel(cwd).map_err(ChangedFilesError::from)
145}
146
147pub fn try_get_changed_files_with_toplevel(
154 cwd: &Path,
155 toplevel: &Path,
156 git_ref: &str,
157) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
158 fallow_engine::changed_files::try_get_changed_files_with_toplevel(cwd, toplevel, git_ref)
159 .map_err(ChangedFilesError::from)
160}
161
162pub mod editor_extract {
165 pub use fallow_types::extract::{
166 AngularComponentSelector, AngularInputMember, AngularOutputMember,
167 AngularTemplateMemberAccessFact, AngularThisSpreadFact, CalleeUse, ClassHeritageInfo,
168 ComplexityContribution, ComplexityContributionKind, ComplexityMetric, ComponentEmit,
169 ComponentFunction, ComponentFunctionKind, ComponentProp, CssAnalytics, CssDeclarationBlock,
170 CssRuleMetric, DiFramework, DiKeySite, DiRole, DispatchedEvent,
171 DynamicCustomElementRenderFact, DynamicImportInfo, DynamicImportPattern, ExportInfo,
172 ExportName, FactoryCallMemberAccessFact, FactoryFnMemberAccessFact, FactoryReturnExport,
173 FlagUse, FlagUseKind, FluentChainMemberAccessFact, FluentChainNewMemberAccessFact,
174 ForwardAttr, FunctionComplexity, HookUse, HookUseKind, ImportInfo, ImportedName,
175 InstanceExportBindingFact, LoadReturnKey, LocalTypeDeclaration, MemberAccess, MemberInfo,
176 MemberKind, MisplacedDirectiveSite, ModuleInfo, NamespaceObjectAlias, PUBLIC_ENV_EXACT,
177 PUBLIC_ENV_METADATA_TOKENS, PUBLIC_ENV_PREFIXES, ParseResult, PlaywrightFixtureAliasFact,
178 PlaywrightFixtureDefinitionFact, PlaywrightFixtureTypeFact, PlaywrightFixtureUseFact,
179 PublicSignatureTypeReference, ReExportInfo, RegisteredCustomElement, RenderEdge,
180 RequireCallInfo, SECRET_ENV_TOKENS, SanitizedSinkArg, SanitizerScope, SecurityControlKind,
181 SecurityControlSite, SecurityUrlShape, SemanticFact, SemanticFactView, SinkArgKind,
182 SinkLiteralValue, SinkObjectProperty, SinkShape, SinkSite,
183 SkippedSecurityCalleeExpressionKind, SkippedSecurityCalleeReason,
184 SkippedSecurityCalleeSite, TaintedBinding, VisibilityTag,
185 };
186}
187
188pub mod editor_results {
191 pub use fallow_types::output_dead_code::{
192 BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
193 CircularDependencyFinding, DevDependencyInProductionFinding, DuplicateExportFinding,
194 DuplicatePropShapeFinding, DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding,
195 InvalidClientExportFinding, MisconfiguredDependencyOverrideFinding,
196 MisplacedDirectiveFinding, MixedClientServerBarrelFinding, PolicyViolationFinding,
197 PrivateTypeLeakFinding, PropDrillingChainFinding, ReExportCycleFinding,
198 RouteCollisionFinding, TestOnlyDependencyFinding, ThinWrapperFinding,
199 TypeOnlyDependencyFinding, UnlistedDependencyFinding, UnprovidedInjectFinding,
200 UnrenderedComponentFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
201 UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedComponentEmitFinding,
202 UnusedComponentInputFinding, UnusedComponentOutputFinding, UnusedComponentPropFinding,
203 UnusedDependencyFinding, UnusedDependencyOverrideFinding, UnusedDevDependencyFinding,
204 UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
205 UnusedOptionalDependencyFinding, UnusedServerActionFinding, UnusedStoreMemberFinding,
206 UnusedSvelteEventFinding, UnusedTypeFinding,
207 };
208 pub use fallow_types::results::{
209 ActiveSuppression, AnalysisResults, BoundaryCallViolation, BoundaryCoverageViolation,
210 BoundaryViolation, CircularDependency, CircularDependencyEdge, DependencyLocation,
211 DependencyOverrideMisconfigReason, DependencyOverrideSource, DevDependencyInProduction,
212 DuplicateExport, DuplicateLocation, DuplicatePropShape, DuplicatePropShapeMember,
213 DynamicSegmentNameConflict, EmptyCatalogGroup, EntryPointSummary, ExportUsage, FeatureFlag,
214 FlagConfidence, FlagKind, ImportSite, InvalidClientExport, MisconfiguredDependencyOverride,
215 MisplacedDirective, MixedClientServerBarrel, PolicyRuleKind, PolicyViolation,
216 PolicyViolationSeverity, PrivateTypeLeak, PropDrillHop, PropDrillingChain, ReExportCycle,
217 ReExportCycleKind, ReactComponentIntel, ReactHookSummary, ReactPropDrill, ReactPropIntel,
218 ReferenceLocation, RenderFanInComponent, RenderFanInMetric, RouteCollision,
219 SecurityAttackSurfaceEntry, SecurityCandidate, SecurityCandidateBoundary,
220 SecurityCandidateSink, SecurityDeadCodeContext, SecurityDeadCodeKind,
221 SecurityDefensiveBoundary, SecurityDefensiveControl, SecurityFinding, SecurityFindingKind,
222 SecurityNetworkContext, SecurityReachability, SecurityRuntimeContext, SecurityRuntimeState,
223 SecuritySeverity, SecurityTaintFlow, SecurityUnresolvedCalleeDiagnostic,
224 SecurityZoneCrossing, StaleSuppression, SuppressionOrigin, TaintConfidence, TaintEndpoint,
225 TaintPath, TestOnlyDependency, ThinWrapper, TraceHop, TraceHopRole, TypeOnlyDependency,
226 UnlistedDependency, UnprovidedInject, UnrenderedComponent, UnresolvedCatalogReference,
227 UnresolvedImport, UnusedCatalogEntry, UnusedComponentEmit, UnusedComponentInput,
228 UnusedComponentOutput, UnusedComponentProp, UnusedDependency, UnusedDependencyOverride,
229 UnusedExport, UnusedFile, UnusedLoadDataKey, UnusedMember, UnusedServerAction,
230 UnusedSvelteEvent,
231 };
232}
233
234pub mod editor_security {
236 #[must_use]
238 pub fn security_catalogue_title(kind: &str) -> Option<&'static str> {
239 fallow_engine::dead_code::security_catalogue_title(kind)
240 }
241}
242
243pub mod editor_suppress {
245 pub use fallow_types::suppress::{IssueKind, is_suppressed};
246}
247
248pub type EditorAnalysisResults = fallow_types::results::AnalysisResults;
250
251#[derive(Debug)]
256pub struct EditorDeadCodeAnalysisOutput {
257 pub results: EditorAnalysisResults,
259 pub modules: Option<Vec<ModuleInfo>>,
262 pub files: Option<Vec<DiscoveredFile>>,
264}
265
266impl EditorDeadCodeAnalysisOutput {
267 fn from_engine(output: fallow_engine::dead_code::DeadCodeAnalysisOutput) -> Self {
268 Self {
269 results: output.results,
270 modules: output.modules,
271 files: output.files,
272 }
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct EditorInlineComplexityFinding {
283 pub path: PathBuf,
285 pub name: String,
287 pub line: u32,
289 pub col: u32,
291 pub cyclomatic: u16,
293 pub cognitive: u16,
295 pub exceeded: EditorInlineComplexityExceeded,
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub enum EditorInlineComplexityExceeded {
302 Cyclomatic,
304 Cognitive,
306 CyclomaticAndCognitive,
308}
309
310#[must_use]
312pub fn collect_inline_complexity(
313 config: &fallow_config::ResolvedConfig,
314 output: &EditorDeadCodeAnalysisOutput,
315) -> Vec<EditorInlineComplexityFinding> {
316 let Some(modules) = output.modules.as_ref() else {
317 return Vec::new();
318 };
319 let Some(files) = output.files.as_ref() else {
320 return Vec::new();
321 };
322
323 let file_paths: rustc_hash::FxHashMap<_, _> =
324 files.iter().map(|file| (file.id, &file.path)).collect();
325 let ignore_set = build_health_ignore_set(&config.health.ignore);
326 let mut findings = Vec::new();
327
328 for module in modules {
329 let Some(path) = file_paths.get(&module.file_id) else {
330 continue;
331 };
332 let relative = path.strip_prefix(&config.root).unwrap_or(path);
333 if ignore_set
334 .as_ref()
335 .is_some_and(|set| set.is_match(relative))
336 {
337 continue;
338 }
339
340 for function in &module.complexity {
341 if fallow_types::extract::is_synthetic_module_unit(&function.name) {
345 continue;
346 }
347 if fallow_types::suppress::is_suppressed(
348 &module.suppressions,
349 function.line,
350 fallow_types::suppress::IssueKind::Complexity,
351 ) {
352 continue;
353 }
354
355 let exceeds_cyclomatic = function.cyclomatic > config.health.max_cyclomatic;
356 let exceeds_cognitive = function.cognitive > config.health.max_cognitive;
357 let exceeded = match (exceeds_cyclomatic, exceeds_cognitive) {
358 (true, true) => EditorInlineComplexityExceeded::CyclomaticAndCognitive,
359 (true, false) => EditorInlineComplexityExceeded::Cyclomatic,
360 (false, true) => EditorInlineComplexityExceeded::Cognitive,
361 (false, false) => continue,
362 };
363
364 findings.push(EditorInlineComplexityFinding {
365 path: (*path).clone(),
366 name: function.name.clone(),
367 line: function.line,
368 col: function.col,
369 cyclomatic: function.cyclomatic,
370 cognitive: function.cognitive,
371 exceeded,
372 });
373 }
374 }
375
376 findings
377}
378
379#[allow(
381 clippy::implicit_hasher,
382 reason = "editor analysis changed-file sets use the workspace FxHashSet convention"
383)]
384pub fn filter_inline_complexity_by_changed_files(
385 findings: &mut Vec<EditorInlineComplexityFinding>,
386 changed_files: &FxHashSet<PathBuf>,
387) {
388 findings.retain(|finding| changed_files.contains(&finding.path));
389}
390
391fn build_health_ignore_set(patterns: &[String]) -> Option<globset::GlobSet> {
392 if patterns.is_empty() {
393 return None;
394 }
395
396 let mut builder = globset::GlobSetBuilder::new();
397 for pattern in patterns {
398 let Ok(glob) = globset::Glob::new(pattern) else {
399 continue;
400 };
401 builder.add(glob);
402 }
403 builder.build().ok()
404}
405
406#[derive(Debug)]
408pub struct EditorAnalysisSession {
409 inner: fallow_engine::session::AnalysisSession,
410}
411
412impl EditorAnalysisSession {
413 pub fn load(root: &Path, config_path: Option<&Path>) -> fallow_engine::EngineResult<Self> {
419 fallow_engine::session::AnalysisSession::load(root, config_path).map(Self::from_engine)
420 }
421
422 pub fn load_with_config(
428 root: &Path,
429 config_path: Option<&Path>,
430 configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
431 ) -> fallow_engine::EngineResult<Self> {
432 fallow_engine::session::AnalysisSession::load_with_config(root, config_path, configure)
433 .map(Self::from_engine)
434 }
435
436 pub fn load_with_config_options(
443 root: &Path,
444 config_path: Option<&Path>,
445 load_options: fallow_config::ConfigLoadOptions,
446 configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
447 ) -> fallow_engine::EngineResult<Self> {
448 fallow_engine::session::AnalysisSession::load_with_config_options(
449 root,
450 config_path,
451 load_options,
452 configure,
453 )
454 .map(Self::from_engine)
455 }
456
457 #[must_use]
459 pub fn load_default(root: &Path) -> Self {
460 Self::from_engine(fallow_engine::session::AnalysisSession::load_default(root))
461 }
462
463 #[must_use]
465 pub fn config(&self) -> &fallow_config::ResolvedConfig {
466 self.inner.config()
467 }
468
469 #[must_use]
471 pub fn config_path(&self) -> Option<&Path> {
472 self.inner.config_path()
473 }
474
475 pub fn refine_type_aware_dead_code(
483 &self,
484 options: &crate::TypeAwareOptions,
485 filters: &crate::DeadCodeFilters,
486 output: &mut EditorDeadCodeAnalysisOutput,
487 ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
488 crate::type_aware::refine_programmatic_dead_code(
489 options,
490 filters,
491 &self.inner,
492 &mut output.results,
493 )
494 }
495
496 pub fn refine_type_aware_dead_code_in_session(
498 &self,
499 semantic_session: &mut crate::TypeAwareSession,
500 changes: Option<&crate::TypeAwareFileChanges>,
501 options: &crate::TypeAwareOptions,
502 filters: &crate::DeadCodeFilters,
503 output: &mut EditorDeadCodeAnalysisOutput,
504 ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
505 crate::type_aware::refine_programmatic_dead_code_in_session(
506 semantic_session,
507 changes,
508 options,
509 filters,
510 &self.inner,
511 &mut output.results,
512 )
513 }
514
515 pub fn analyze_project_with(
521 &self,
522 duplicates_config: &fallow_config::DuplicatesConfig,
523 retain_complexity_artifacts: bool,
524 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
525 self.inner
526 .analyze_project_with(duplicates_config, retain_complexity_artifacts)
527 .map(EditorProjectAnalysisOutput::from_engine)
528 }
529
530 pub fn analyze_project_with_changed_files(
540 &self,
541 duplicates_config: &fallow_config::DuplicatesConfig,
542 retain_complexity_artifacts: bool,
543 changed_files: Option<&FxHashSet<PathBuf>>,
544 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
545 self.inner
546 .analyze_project_with_artifacts(
547 duplicates_config,
548 fallow_engine::project_analysis::ProjectAnalysisArtifactOptions {
549 retain_complexity_artifacts,
550 changed_files: changed_files.cloned(),
551 ..fallow_engine::project_analysis::ProjectAnalysisArtifactOptions::default()
552 },
553 )
554 .map(fallow_engine::project_analysis::ProjectAnalysisArtifacts::into_output)
555 .map(EditorProjectAnalysisOutput::from_engine)
556 }
557
558 const fn from_engine(inner: fallow_engine::session::AnalysisSession) -> Self {
559 Self { inner }
560 }
561}
562
563#[derive(Debug)]
565pub struct EditorProjectAnalysisOutput {
566 pub dead_code: EditorDeadCodeAnalysisOutput,
568 pub duplication: EditorDuplicationReport,
570}
571
572impl EditorProjectAnalysisOutput {
573 fn from_engine(output: fallow_engine::project_analysis::ProjectAnalysisOutput) -> Self {
574 Self {
575 dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
576 duplication: output.duplication,
577 }
578 }
579}
580
581#[derive(Debug, Default)]
583pub struct EditorAnalysisOutput {
584 pub results: EditorAnalysisResults,
586 pub duplication: EditorDuplicationReport,
588}
589
590impl EditorAnalysisOutput {
591 #[must_use]
593 pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
594 Self {
595 results,
596 duplication,
597 }
598 }
599
600 #[must_use]
602 pub fn from_project_output(output: EditorProjectAnalysisOutput) -> Self {
603 Self::new(output.dead_code.results, output.duplication)
604 }
605
606 pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
608 self.merge_results(output.dead_code.results);
609 self.merge_duplication(output.duplication);
610 }
611
612 pub fn merge_results(&mut self, source: EditorAnalysisResults) {
614 self.results.merge_into(source);
615 }
616
617 pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
620 self.duplication.clone_groups.extend(source.clone_groups);
621 self.duplication
622 .clone_families
623 .extend(source.clone_families);
624 self.duplication
625 .mirrored_directories
626 .extend(source.mirrored_directories);
627 self.duplication.stats.clone_groups += source.stats.clone_groups;
628 self.duplication.stats.clone_families += source.stats.clone_families;
629 self.duplication.stats.clone_instances += source.stats.clone_instances;
630 self.duplication.stats.total_files += source.stats.total_files;
631 self.duplication.stats.files_with_clones += source.stats.files_with_clones;
632 self.duplication.stats.total_lines += source.stats.total_lines;
633 self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
634 self.duplication.stats.total_tokens += source.stats.total_tokens;
635 self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
636 self.duplication.stats.clone_groups_below_min_occurrences +=
637 source.stats.clone_groups_below_min_occurrences;
638 self.duplication.stats.clone_groups_ignored += source.stats.clone_groups_ignored;
639 self.duplication.stats.near_candidates_skipped += source.stats.near_candidates_skipped;
640 self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
641 (self.duplication.stats.duplicated_lines as f64
642 / self.duplication.stats.total_lines as f64)
643 * 100.0
644 } else {
645 0.0
646 };
647 }
648
649 pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
651 fallow_engine::changed_files::filter_results_by_changed_files(
652 &mut self.results,
653 changed_files,
654 );
655 fallow_engine::changed_files::filter_duplication_by_changed_files(
656 &mut self.duplication,
657 changed_files,
658 root,
659 );
660 }
661
662 pub fn filter_by_changed_since(
670 &mut self,
671 root: &Path,
672 toplevel: &Path,
673 git_ref: &str,
674 ) -> Result<usize, ChangedFilesError> {
675 let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
676 let changed_count = changed.len();
677 self.filter_by_changed_files(&changed, root);
678 Ok(changed_count)
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 use fallow_types::duplicates::{CloneFamily, CloneGroup, CloneInstance, DuplicationStats};
687
688 #[test]
689 fn merges_duplication_stats_and_recomputes_percentage() {
690 let mut output = EditorAnalysisOutput {
691 duplication: EditorDuplicationReport {
692 clone_groups: vec![CloneGroup {
693 instances: vec![CloneInstance {
694 file: PathBuf::from("src/a.ts"),
695 start_line: 1,
696 end_line: 4,
697 start_col: 0,
698 end_col: 10,
699 fragment: "const a = 1;".to_string(),
700 }],
701 token_count: 8,
702 line_count: 4,
703 similarity: None,
704 }],
705 clone_families: Vec::new(),
706 mirrored_directories: Vec::new(),
707 stats: DuplicationStats {
708 clone_groups: 1,
709 clone_families: 0,
710 clone_instances: 1,
711 total_files: 1,
712 files_with_clones: 1,
713 total_lines: 20,
714 duplicated_lines: 4,
715 total_tokens: 80,
716 duplicated_tokens: 8,
717 duplication_percentage: 20.0,
718 clone_groups_below_min_occurrences: 1,
719 clone_groups_ignored: 1,
720 near_candidates_skipped: 2,
721 },
722 },
723 ..Default::default()
724 };
725
726 output.merge_duplication(EditorDuplicationReport {
727 clone_groups: Vec::new(),
728 clone_families: Vec::new(),
729 mirrored_directories: Vec::new(),
730 stats: DuplicationStats {
731 clone_groups: 0,
732 clone_families: 0,
733 clone_instances: 0,
734 total_files: 1,
735 files_with_clones: 0,
736 total_lines: 30,
737 duplicated_lines: 6,
738 total_tokens: 120,
739 duplicated_tokens: 12,
740 duplication_percentage: 20.0,
741 clone_groups_below_min_occurrences: 2,
742 clone_groups_ignored: 3,
743 near_candidates_skipped: 4,
744 },
745 });
746
747 assert_eq!(output.duplication.stats.total_lines, 50);
748 assert_eq!(output.duplication.stats.duplicated_lines, 10);
749 assert_eq!(
750 output.duplication.stats.clone_groups_below_min_occurrences,
751 3
752 );
753 assert_eq!(output.duplication.stats.clone_groups_ignored, 4);
754 assert_eq!(output.duplication.stats.near_candidates_skipped, 6);
755 assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
756 }
757
758 #[test]
759 fn merging_duplication_keeps_the_family_corpus_count_aligned() {
760 let family = |path: &str| CloneFamily {
761 files: vec![PathBuf::from(path)],
762 groups: Vec::new(),
763 total_duplicated_lines: 4,
764 total_duplicated_tokens: 8,
765 suggestions: Vec::new(),
766 };
767 let report = |path: &str, families: usize| EditorDuplicationReport {
768 clone_groups: Vec::new(),
769 clone_families: vec![family(path)],
770 mirrored_directories: Vec::new(),
771 stats: DuplicationStats {
772 clone_families: families,
773 ..DuplicationStats::default()
774 },
775 };
776
777 let mut output = EditorAnalysisOutput {
778 duplication: report("src/a.ts", 3),
779 ..Default::default()
780 };
781 output.merge_duplication(report("src/b.ts", 2));
782
783 assert_eq!(output.duplication.stats.clone_families, 5);
784 assert_eq!(output.duplication.clone_families_shown(), 2);
785 assert_eq!(output.duplication.clone_families_omitted(), 3);
786 assert_eq!(
787 output.duplication.clone_families_total(),
788 output.duplication.stats.clone_families
789 );
790 }
791
792 #[test]
793 fn editor_session_returns_api_owned_project_output() {
794 let temp = tempfile::tempdir().expect("temp project");
795 let root = temp.path();
796 std::fs::create_dir_all(root.join("src")).expect("src dir");
797 std::fs::write(
798 root.join("package.json"),
799 r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
800 )
801 .expect("package.json");
802 std::fs::write(
803 root.join("src/index.ts"),
804 "export const used = 1;\nconsole.log(used);\n",
805 )
806 .expect("source");
807
808 let session = EditorAnalysisSession::load(root, None).expect("session loads");
809 let output = session
810 .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
811 .expect("analysis runs");
812
813 assert!(output.dead_code.modules.is_some());
814 assert!(
815 output
816 .dead_code
817 .files
818 .as_ref()
819 .is_some_and(|files| !files.is_empty())
820 );
821 }
822
823 #[test]
824 fn editor_session_scopes_duplication_to_changed_files() {
825 let temp = tempfile::tempdir().expect("temp project");
826 let root = temp.path();
827 let src = root.join("src");
828 std::fs::create_dir_all(&src).expect("src dir");
829 std::fs::write(
830 root.join("package.json"),
831 r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
832 )
833 .expect("package.json");
834 let repeated =
835 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
836 std::fs::write(src.join("a.ts"), repeated).expect("source a");
837 std::fs::write(src.join("b.ts"), repeated).expect("source b");
838
839 let session = EditorAnalysisSession::load(root, None).expect("session loads");
840 let mut config = session.config().duplicates.clone();
841 config.min_tokens = 1;
842 config.min_lines = 1;
843 let full = session
844 .analyze_project_with(&config, false)
845 .expect("analysis runs");
846 assert!(!full.duplication.clone_groups.is_empty());
847
848 let mut changed_files = FxHashSet::default();
849 changed_files.insert(src.join("unrelated.ts"));
850 let scoped = session
851 .analyze_project_with_changed_files(&config, false, Some(&changed_files))
852 .expect("analysis runs");
853 assert!(scoped.duplication.clone_groups.is_empty());
854 }
855
856 #[test]
857 fn build_health_ignore_set_returns_none_for_empty_patterns() {
858 assert!(
859 build_health_ignore_set(&[]).is_none(),
860 "empty ignore pattern list should avoid building a matcher"
861 );
862 }
863
864 #[test]
865 fn build_health_ignore_set_matches_glob_patterns() {
866 let set =
867 build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
868 .expect("valid patterns build a glob set");
869
870 assert!(set.is_match(Path::new("src/foo.test.ts")));
871 assert!(set.is_match(Path::new("src/generated/client.ts")));
872 assert!(!set.is_match(Path::new("src/app.ts")));
873 }
874
875 #[test]
876 fn build_health_ignore_set_skips_invalid_patterns() {
877 let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
878
879 match result {
880 None => {}
881 Some(set) => assert!(
882 !set.is_match(Path::new("any/path.ts")),
883 "set built from only invalid patterns must not match anything"
884 ),
885 }
886 }
887
888 fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
889 EditorInlineComplexityFinding {
890 path,
891 name: "myFn".to_string(),
892 line: 1,
893 col: 0,
894 cyclomatic: 5,
895 cognitive: 4,
896 exceeded: EditorInlineComplexityExceeded::Cyclomatic,
897 }
898 }
899
900 #[test]
901 fn filter_inline_complexity_keeps_findings_in_changed_set() {
902 let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
903 .into_iter()
904 .collect();
905 let mut findings = vec![
906 make_inline_finding(PathBuf::from("/src/a.ts")),
907 make_inline_finding(PathBuf::from("/src/c.ts")),
908 ];
909
910 filter_inline_complexity_by_changed_files(&mut findings, &changed);
911
912 assert_eq!(findings.len(), 1);
913 assert_eq!(
914 findings[0].path.to_string_lossy().replace('\\', "/"),
915 "/src/a.ts"
916 );
917 }
918
919 #[test]
920 fn filter_inline_complexity_removes_all_when_changed_set_empty() {
921 let changed: FxHashSet<PathBuf> = FxHashSet::default();
922 let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
923
924 filter_inline_complexity_by_changed_files(&mut findings, &changed);
925
926 assert!(
927 findings.is_empty(),
928 "empty changed-files set must drop all inline complexity findings"
929 );
930 }
931
932 #[test]
933 fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
934 let path_a = PathBuf::from("/src/a.ts");
935 let path_b = PathBuf::from("/src/b.ts");
936 let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
937 let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
938
939 filter_inline_complexity_by_changed_files(&mut findings, &changed);
940
941 assert_eq!(
942 findings.len(),
943 2,
944 "all findings in the changed set must be retained"
945 );
946 }
947}