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::suppress::is_suppressed(
342 &module.suppressions,
343 function.line,
344 fallow_types::suppress::IssueKind::Complexity,
345 ) {
346 continue;
347 }
348
349 let exceeds_cyclomatic = function.cyclomatic > config.health.max_cyclomatic;
350 let exceeds_cognitive = function.cognitive > config.health.max_cognitive;
351 let exceeded = match (exceeds_cyclomatic, exceeds_cognitive) {
352 (true, true) => EditorInlineComplexityExceeded::CyclomaticAndCognitive,
353 (true, false) => EditorInlineComplexityExceeded::Cyclomatic,
354 (false, true) => EditorInlineComplexityExceeded::Cognitive,
355 (false, false) => continue,
356 };
357
358 findings.push(EditorInlineComplexityFinding {
359 path: (*path).clone(),
360 name: function.name.clone(),
361 line: function.line,
362 col: function.col,
363 cyclomatic: function.cyclomatic,
364 cognitive: function.cognitive,
365 exceeded,
366 });
367 }
368 }
369
370 findings
371}
372
373#[allow(
375 clippy::implicit_hasher,
376 reason = "editor analysis changed-file sets use the workspace FxHashSet convention"
377)]
378pub fn filter_inline_complexity_by_changed_files(
379 findings: &mut Vec<EditorInlineComplexityFinding>,
380 changed_files: &FxHashSet<PathBuf>,
381) {
382 findings.retain(|finding| changed_files.contains(&finding.path));
383}
384
385fn build_health_ignore_set(patterns: &[String]) -> Option<globset::GlobSet> {
386 if patterns.is_empty() {
387 return None;
388 }
389
390 let mut builder = globset::GlobSetBuilder::new();
391 for pattern in patterns {
392 let Ok(glob) = globset::Glob::new(pattern) else {
393 continue;
394 };
395 builder.add(glob);
396 }
397 builder.build().ok()
398}
399
400#[derive(Debug)]
402pub struct EditorAnalysisSession {
403 inner: fallow_engine::session::AnalysisSession,
404}
405
406impl EditorAnalysisSession {
407 pub fn load(root: &Path, config_path: Option<&Path>) -> fallow_engine::EngineResult<Self> {
413 fallow_engine::session::AnalysisSession::load(root, config_path).map(Self::from_engine)
414 }
415
416 pub fn load_with_config(
422 root: &Path,
423 config_path: Option<&Path>,
424 configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
425 ) -> fallow_engine::EngineResult<Self> {
426 fallow_engine::session::AnalysisSession::load_with_config(root, config_path, configure)
427 .map(Self::from_engine)
428 }
429
430 pub fn load_with_config_options(
437 root: &Path,
438 config_path: Option<&Path>,
439 load_options: fallow_config::ConfigLoadOptions,
440 configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
441 ) -> fallow_engine::EngineResult<Self> {
442 fallow_engine::session::AnalysisSession::load_with_config_options(
443 root,
444 config_path,
445 load_options,
446 configure,
447 )
448 .map(Self::from_engine)
449 }
450
451 #[must_use]
453 pub fn load_default(root: &Path) -> Self {
454 Self::from_engine(fallow_engine::session::AnalysisSession::load_default(root))
455 }
456
457 #[must_use]
459 pub fn config(&self) -> &fallow_config::ResolvedConfig {
460 self.inner.config()
461 }
462
463 #[must_use]
465 pub fn config_path(&self) -> Option<&Path> {
466 self.inner.config_path()
467 }
468
469 pub fn refine_type_aware_dead_code(
477 &self,
478 options: &crate::TypeAwareOptions,
479 filters: &crate::DeadCodeFilters,
480 output: &mut EditorDeadCodeAnalysisOutput,
481 ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
482 crate::type_aware::refine_programmatic_dead_code(
483 options,
484 filters,
485 &self.inner,
486 &mut output.results,
487 )
488 }
489
490 pub fn refine_type_aware_dead_code_in_session(
492 &self,
493 semantic_session: &mut crate::TypeAwareSession,
494 changes: Option<&crate::TypeAwareFileChanges>,
495 options: &crate::TypeAwareOptions,
496 filters: &crate::DeadCodeFilters,
497 output: &mut EditorDeadCodeAnalysisOutput,
498 ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
499 crate::type_aware::refine_programmatic_dead_code_in_session(
500 semantic_session,
501 changes,
502 options,
503 filters,
504 &self.inner,
505 &mut output.results,
506 )
507 }
508
509 pub fn analyze_project_with(
515 &self,
516 duplicates_config: &fallow_config::DuplicatesConfig,
517 retain_complexity_artifacts: bool,
518 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
519 self.inner
520 .analyze_project_with(duplicates_config, retain_complexity_artifacts)
521 .map(EditorProjectAnalysisOutput::from_engine)
522 }
523
524 pub fn analyze_project_with_changed_files(
534 &self,
535 duplicates_config: &fallow_config::DuplicatesConfig,
536 retain_complexity_artifacts: bool,
537 changed_files: Option<&FxHashSet<PathBuf>>,
538 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
539 self.inner
540 .analyze_project_with_artifacts(
541 duplicates_config,
542 fallow_engine::project_analysis::ProjectAnalysisArtifactOptions {
543 retain_complexity_artifacts,
544 changed_files: changed_files.cloned(),
545 ..fallow_engine::project_analysis::ProjectAnalysisArtifactOptions::default()
546 },
547 )
548 .map(fallow_engine::project_analysis::ProjectAnalysisArtifacts::into_output)
549 .map(EditorProjectAnalysisOutput::from_engine)
550 }
551
552 const fn from_engine(inner: fallow_engine::session::AnalysisSession) -> Self {
553 Self { inner }
554 }
555}
556
557#[derive(Debug)]
559pub struct EditorProjectAnalysisOutput {
560 pub dead_code: EditorDeadCodeAnalysisOutput,
562 pub duplication: EditorDuplicationReport,
564}
565
566impl EditorProjectAnalysisOutput {
567 fn from_engine(output: fallow_engine::project_analysis::ProjectAnalysisOutput) -> Self {
568 Self {
569 dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
570 duplication: output.duplication,
571 }
572 }
573}
574
575#[derive(Debug, Default)]
577pub struct EditorAnalysisOutput {
578 pub results: EditorAnalysisResults,
580 pub duplication: EditorDuplicationReport,
582}
583
584impl EditorAnalysisOutput {
585 #[must_use]
587 pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
588 Self {
589 results,
590 duplication,
591 }
592 }
593
594 #[must_use]
596 pub fn from_project_output(output: EditorProjectAnalysisOutput) -> Self {
597 Self::new(output.dead_code.results, output.duplication)
598 }
599
600 pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
602 self.merge_results(output.dead_code.results);
603 self.merge_duplication(output.duplication);
604 }
605
606 pub fn merge_results(&mut self, source: EditorAnalysisResults) {
608 self.results.merge_into(source);
609 }
610
611 pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
614 self.duplication.clone_groups.extend(source.clone_groups);
615 self.duplication
616 .clone_families
617 .extend(source.clone_families);
618 self.duplication
619 .mirrored_directories
620 .extend(source.mirrored_directories);
621 self.duplication.stats.clone_groups += source.stats.clone_groups;
622 self.duplication.stats.clone_instances += source.stats.clone_instances;
623 self.duplication.stats.total_files += source.stats.total_files;
624 self.duplication.stats.files_with_clones += source.stats.files_with_clones;
625 self.duplication.stats.total_lines += source.stats.total_lines;
626 self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
627 self.duplication.stats.total_tokens += source.stats.total_tokens;
628 self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
629 self.duplication.stats.clone_groups_below_min_occurrences +=
630 source.stats.clone_groups_below_min_occurrences;
631 self.duplication.stats.clone_groups_ignored += source.stats.clone_groups_ignored;
632 self.duplication.stats.near_candidates_skipped += source.stats.near_candidates_skipped;
633 self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
634 (self.duplication.stats.duplicated_lines as f64
635 / self.duplication.stats.total_lines as f64)
636 * 100.0
637 } else {
638 0.0
639 };
640 }
641
642 pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
644 fallow_engine::changed_files::filter_results_by_changed_files(
645 &mut self.results,
646 changed_files,
647 );
648 fallow_engine::changed_files::filter_duplication_by_changed_files(
649 &mut self.duplication,
650 changed_files,
651 root,
652 );
653 }
654
655 pub fn filter_by_changed_since(
663 &mut self,
664 root: &Path,
665 toplevel: &Path,
666 git_ref: &str,
667 ) -> Result<usize, ChangedFilesError> {
668 let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
669 let changed_count = changed.len();
670 self.filter_by_changed_files(&changed, root);
671 Ok(changed_count)
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
680
681 #[test]
682 fn merges_duplication_stats_and_recomputes_percentage() {
683 let mut output = EditorAnalysisOutput {
684 duplication: EditorDuplicationReport {
685 clone_groups: vec![CloneGroup {
686 instances: vec![CloneInstance {
687 file: PathBuf::from("src/a.ts"),
688 start_line: 1,
689 end_line: 4,
690 start_col: 0,
691 end_col: 10,
692 fragment: "const a = 1;".to_string(),
693 }],
694 token_count: 8,
695 line_count: 4,
696 similarity: None,
697 }],
698 clone_families: Vec::new(),
699 mirrored_directories: Vec::new(),
700 stats: DuplicationStats {
701 clone_groups: 1,
702 clone_instances: 1,
703 total_files: 1,
704 files_with_clones: 1,
705 total_lines: 20,
706 duplicated_lines: 4,
707 total_tokens: 80,
708 duplicated_tokens: 8,
709 duplication_percentage: 20.0,
710 clone_groups_below_min_occurrences: 1,
711 clone_groups_ignored: 1,
712 near_candidates_skipped: 2,
713 },
714 },
715 ..Default::default()
716 };
717
718 output.merge_duplication(EditorDuplicationReport {
719 clone_groups: Vec::new(),
720 clone_families: Vec::new(),
721 mirrored_directories: Vec::new(),
722 stats: DuplicationStats {
723 clone_groups: 0,
724 clone_instances: 0,
725 total_files: 1,
726 files_with_clones: 0,
727 total_lines: 30,
728 duplicated_lines: 6,
729 total_tokens: 120,
730 duplicated_tokens: 12,
731 duplication_percentage: 20.0,
732 clone_groups_below_min_occurrences: 2,
733 clone_groups_ignored: 3,
734 near_candidates_skipped: 4,
735 },
736 });
737
738 assert_eq!(output.duplication.stats.total_lines, 50);
739 assert_eq!(output.duplication.stats.duplicated_lines, 10);
740 assert_eq!(
741 output.duplication.stats.clone_groups_below_min_occurrences,
742 3
743 );
744 assert_eq!(output.duplication.stats.clone_groups_ignored, 4);
745 assert_eq!(output.duplication.stats.near_candidates_skipped, 6);
746 assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
747 }
748
749 #[test]
750 fn editor_session_returns_api_owned_project_output() {
751 let temp = tempfile::tempdir().expect("temp project");
752 let root = temp.path();
753 std::fs::create_dir_all(root.join("src")).expect("src dir");
754 std::fs::write(
755 root.join("package.json"),
756 r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
757 )
758 .expect("package.json");
759 std::fs::write(
760 root.join("src/index.ts"),
761 "export const used = 1;\nconsole.log(used);\n",
762 )
763 .expect("source");
764
765 let session = EditorAnalysisSession::load(root, None).expect("session loads");
766 let output = session
767 .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
768 .expect("analysis runs");
769
770 assert!(output.dead_code.modules.is_some());
771 assert!(
772 output
773 .dead_code
774 .files
775 .as_ref()
776 .is_some_and(|files| !files.is_empty())
777 );
778 }
779
780 #[test]
781 fn editor_session_scopes_duplication_to_changed_files() {
782 let temp = tempfile::tempdir().expect("temp project");
783 let root = temp.path();
784 let src = root.join("src");
785 std::fs::create_dir_all(&src).expect("src dir");
786 std::fs::write(
787 root.join("package.json"),
788 r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
789 )
790 .expect("package.json");
791 let repeated =
792 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
793 std::fs::write(src.join("a.ts"), repeated).expect("source a");
794 std::fs::write(src.join("b.ts"), repeated).expect("source b");
795
796 let session = EditorAnalysisSession::load(root, None).expect("session loads");
797 let mut config = session.config().duplicates.clone();
798 config.min_tokens = 1;
799 config.min_lines = 1;
800 let full = session
801 .analyze_project_with(&config, false)
802 .expect("analysis runs");
803 assert!(!full.duplication.clone_groups.is_empty());
804
805 let mut changed_files = FxHashSet::default();
806 changed_files.insert(src.join("unrelated.ts"));
807 let scoped = session
808 .analyze_project_with_changed_files(&config, false, Some(&changed_files))
809 .expect("analysis runs");
810 assert!(scoped.duplication.clone_groups.is_empty());
811 }
812
813 #[test]
814 fn build_health_ignore_set_returns_none_for_empty_patterns() {
815 assert!(
816 build_health_ignore_set(&[]).is_none(),
817 "empty ignore pattern list should avoid building a matcher"
818 );
819 }
820
821 #[test]
822 fn build_health_ignore_set_matches_glob_patterns() {
823 let set =
824 build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
825 .expect("valid patterns build a glob set");
826
827 assert!(set.is_match(Path::new("src/foo.test.ts")));
828 assert!(set.is_match(Path::new("src/generated/client.ts")));
829 assert!(!set.is_match(Path::new("src/app.ts")));
830 }
831
832 #[test]
833 fn build_health_ignore_set_skips_invalid_patterns() {
834 let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
835
836 match result {
837 None => {}
838 Some(set) => assert!(
839 !set.is_match(Path::new("any/path.ts")),
840 "set built from only invalid patterns must not match anything"
841 ),
842 }
843 }
844
845 fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
846 EditorInlineComplexityFinding {
847 path,
848 name: "myFn".to_string(),
849 line: 1,
850 col: 0,
851 cyclomatic: 5,
852 cognitive: 4,
853 exceeded: EditorInlineComplexityExceeded::Cyclomatic,
854 }
855 }
856
857 #[test]
858 fn filter_inline_complexity_keeps_findings_in_changed_set() {
859 let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
860 .into_iter()
861 .collect();
862 let mut findings = vec![
863 make_inline_finding(PathBuf::from("/src/a.ts")),
864 make_inline_finding(PathBuf::from("/src/c.ts")),
865 ];
866
867 filter_inline_complexity_by_changed_files(&mut findings, &changed);
868
869 assert_eq!(findings.len(), 1);
870 assert_eq!(
871 findings[0].path.to_string_lossy().replace('\\', "/"),
872 "/src/a.ts"
873 );
874 }
875
876 #[test]
877 fn filter_inline_complexity_removes_all_when_changed_set_empty() {
878 let changed: FxHashSet<PathBuf> = FxHashSet::default();
879 let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
880
881 filter_inline_complexity_by_changed_files(&mut findings, &changed);
882
883 assert!(
884 findings.is_empty(),
885 "empty changed-files set must drop all inline complexity findings"
886 );
887 }
888
889 #[test]
890 fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
891 let path_a = PathBuf::from("/src/a.ts");
892 let path_b = PathBuf::from("/src/b.ts");
893 let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
894 let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
895
896 filter_inline_complexity_by_changed_files(&mut findings, &changed);
897
898 assert_eq!(
899 findings.len(),
900 2,
901 "all findings in the changed set must be retained"
902 );
903 }
904}