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_instances += source.stats.clone_instances;
629 self.duplication.stats.total_files += source.stats.total_files;
630 self.duplication.stats.files_with_clones += source.stats.files_with_clones;
631 self.duplication.stats.total_lines += source.stats.total_lines;
632 self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
633 self.duplication.stats.total_tokens += source.stats.total_tokens;
634 self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
635 self.duplication.stats.clone_groups_below_min_occurrences +=
636 source.stats.clone_groups_below_min_occurrences;
637 self.duplication.stats.clone_groups_ignored += source.stats.clone_groups_ignored;
638 self.duplication.stats.near_candidates_skipped += source.stats.near_candidates_skipped;
639 self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
640 (self.duplication.stats.duplicated_lines as f64
641 / self.duplication.stats.total_lines as f64)
642 * 100.0
643 } else {
644 0.0
645 };
646 }
647
648 pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
650 fallow_engine::changed_files::filter_results_by_changed_files(
651 &mut self.results,
652 changed_files,
653 );
654 fallow_engine::changed_files::filter_duplication_by_changed_files(
655 &mut self.duplication,
656 changed_files,
657 root,
658 );
659 }
660
661 pub fn filter_by_changed_since(
669 &mut self,
670 root: &Path,
671 toplevel: &Path,
672 git_ref: &str,
673 ) -> Result<usize, ChangedFilesError> {
674 let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
675 let changed_count = changed.len();
676 self.filter_by_changed_files(&changed, root);
677 Ok(changed_count)
678 }
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
686
687 #[test]
688 fn merges_duplication_stats_and_recomputes_percentage() {
689 let mut output = EditorAnalysisOutput {
690 duplication: EditorDuplicationReport {
691 clone_groups: vec![CloneGroup {
692 instances: vec![CloneInstance {
693 file: PathBuf::from("src/a.ts"),
694 start_line: 1,
695 end_line: 4,
696 start_col: 0,
697 end_col: 10,
698 fragment: "const a = 1;".to_string(),
699 }],
700 token_count: 8,
701 line_count: 4,
702 similarity: None,
703 }],
704 clone_families: Vec::new(),
705 mirrored_directories: Vec::new(),
706 stats: DuplicationStats {
707 clone_groups: 1,
708 clone_instances: 1,
709 total_files: 1,
710 files_with_clones: 1,
711 total_lines: 20,
712 duplicated_lines: 4,
713 total_tokens: 80,
714 duplicated_tokens: 8,
715 duplication_percentage: 20.0,
716 clone_groups_below_min_occurrences: 1,
717 clone_groups_ignored: 1,
718 near_candidates_skipped: 2,
719 },
720 },
721 ..Default::default()
722 };
723
724 output.merge_duplication(EditorDuplicationReport {
725 clone_groups: Vec::new(),
726 clone_families: Vec::new(),
727 mirrored_directories: Vec::new(),
728 stats: DuplicationStats {
729 clone_groups: 0,
730 clone_instances: 0,
731 total_files: 1,
732 files_with_clones: 0,
733 total_lines: 30,
734 duplicated_lines: 6,
735 total_tokens: 120,
736 duplicated_tokens: 12,
737 duplication_percentage: 20.0,
738 clone_groups_below_min_occurrences: 2,
739 clone_groups_ignored: 3,
740 near_candidates_skipped: 4,
741 },
742 });
743
744 assert_eq!(output.duplication.stats.total_lines, 50);
745 assert_eq!(output.duplication.stats.duplicated_lines, 10);
746 assert_eq!(
747 output.duplication.stats.clone_groups_below_min_occurrences,
748 3
749 );
750 assert_eq!(output.duplication.stats.clone_groups_ignored, 4);
751 assert_eq!(output.duplication.stats.near_candidates_skipped, 6);
752 assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
753 }
754
755 #[test]
756 fn editor_session_returns_api_owned_project_output() {
757 let temp = tempfile::tempdir().expect("temp project");
758 let root = temp.path();
759 std::fs::create_dir_all(root.join("src")).expect("src dir");
760 std::fs::write(
761 root.join("package.json"),
762 r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
763 )
764 .expect("package.json");
765 std::fs::write(
766 root.join("src/index.ts"),
767 "export const used = 1;\nconsole.log(used);\n",
768 )
769 .expect("source");
770
771 let session = EditorAnalysisSession::load(root, None).expect("session loads");
772 let output = session
773 .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
774 .expect("analysis runs");
775
776 assert!(output.dead_code.modules.is_some());
777 assert!(
778 output
779 .dead_code
780 .files
781 .as_ref()
782 .is_some_and(|files| !files.is_empty())
783 );
784 }
785
786 #[test]
787 fn editor_session_scopes_duplication_to_changed_files() {
788 let temp = tempfile::tempdir().expect("temp project");
789 let root = temp.path();
790 let src = root.join("src");
791 std::fs::create_dir_all(&src).expect("src dir");
792 std::fs::write(
793 root.join("package.json"),
794 r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
795 )
796 .expect("package.json");
797 let repeated =
798 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
799 std::fs::write(src.join("a.ts"), repeated).expect("source a");
800 std::fs::write(src.join("b.ts"), repeated).expect("source b");
801
802 let session = EditorAnalysisSession::load(root, None).expect("session loads");
803 let mut config = session.config().duplicates.clone();
804 config.min_tokens = 1;
805 config.min_lines = 1;
806 let full = session
807 .analyze_project_with(&config, false)
808 .expect("analysis runs");
809 assert!(!full.duplication.clone_groups.is_empty());
810
811 let mut changed_files = FxHashSet::default();
812 changed_files.insert(src.join("unrelated.ts"));
813 let scoped = session
814 .analyze_project_with_changed_files(&config, false, Some(&changed_files))
815 .expect("analysis runs");
816 assert!(scoped.duplication.clone_groups.is_empty());
817 }
818
819 #[test]
820 fn build_health_ignore_set_returns_none_for_empty_patterns() {
821 assert!(
822 build_health_ignore_set(&[]).is_none(),
823 "empty ignore pattern list should avoid building a matcher"
824 );
825 }
826
827 #[test]
828 fn build_health_ignore_set_matches_glob_patterns() {
829 let set =
830 build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
831 .expect("valid patterns build a glob set");
832
833 assert!(set.is_match(Path::new("src/foo.test.ts")));
834 assert!(set.is_match(Path::new("src/generated/client.ts")));
835 assert!(!set.is_match(Path::new("src/app.ts")));
836 }
837
838 #[test]
839 fn build_health_ignore_set_skips_invalid_patterns() {
840 let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
841
842 match result {
843 None => {}
844 Some(set) => assert!(
845 !set.is_match(Path::new("any/path.ts")),
846 "set built from only invalid patterns must not match anything"
847 ),
848 }
849 }
850
851 fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
852 EditorInlineComplexityFinding {
853 path,
854 name: "myFn".to_string(),
855 line: 1,
856 col: 0,
857 cyclomatic: 5,
858 cognitive: 4,
859 exceeded: EditorInlineComplexityExceeded::Cyclomatic,
860 }
861 }
862
863 #[test]
864 fn filter_inline_complexity_keeps_findings_in_changed_set() {
865 let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
866 .into_iter()
867 .collect();
868 let mut findings = vec![
869 make_inline_finding(PathBuf::from("/src/a.ts")),
870 make_inline_finding(PathBuf::from("/src/c.ts")),
871 ];
872
873 filter_inline_complexity_by_changed_files(&mut findings, &changed);
874
875 assert_eq!(findings.len(), 1);
876 assert_eq!(
877 findings[0].path.to_string_lossy().replace('\\', "/"),
878 "/src/a.ts"
879 );
880 }
881
882 #[test]
883 fn filter_inline_complexity_removes_all_when_changed_set_empty() {
884 let changed: FxHashSet<PathBuf> = FxHashSet::default();
885 let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
886
887 filter_inline_complexity_by_changed_files(&mut findings, &changed);
888
889 assert!(
890 findings.is_empty(),
891 "empty changed-files set must drop all inline complexity findings"
892 );
893 }
894
895 #[test]
896 fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
897 let path_a = PathBuf::from("/src/a.ts");
898 let path_b = PathBuf::from("/src/b.ts");
899 let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
900 let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
901
902 filter_inline_complexity_by_changed_files(&mut findings, &changed);
903
904 assert_eq!(
905 findings.len(),
906 2,
907 "all findings in the changed set must be retained"
908 );
909 }
910}