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;
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#[derive(Debug, Clone)]
20pub struct EditorCloneFingerprintSet {
21 inner: fallow_engine::duplicates::CloneFingerprintSet,
22}
23
24impl EditorCloneFingerprintSet {
25 #[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 #[must_use]
35 pub fn fingerprint_for_group(&self, group: &EditorCloneGroup) -> String {
36 self.inner.fingerprint_for_group(group)
37 }
38
39 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum ChangedFilesError {
75 InvalidRef(String),
77 GitMissing(String),
79 NotARepository,
81 GitFailed(String),
83}
84
85impl ChangedFilesError {
86 #[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
127pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
134 fallow_engine::changed_files::resolve_git_toplevel(cwd).map_err(ChangedFilesError::from)
135}
136
137pub 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 #[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum EditorInlineComplexityExceeded {
274 Cyclomatic,
275 Cognitive,
276 CyclomaticAndCognitive,
277}
278
279#[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#[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#[derive(Debug)]
371pub struct EditorAnalysisSession {
372 inner: fallow_engine::session::AnalysisSession,
373}
374
375impl EditorAnalysisSession {
376 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 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 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 #[must_use]
422 pub fn load_default(root: &Path) -> Self {
423 Self::from_engine(fallow_engine::session::AnalysisSession::load_default(root))
424 }
425
426 #[must_use]
428 pub fn config(&self) -> &fallow_config::ResolvedConfig {
429 self.inner.config()
430 }
431
432 #[must_use]
434 pub fn config_path(&self) -> Option<&Path> {
435 self.inner.config_path()
436 }
437
438 pub fn refine_type_aware_dead_code(
446 &self,
447 options: &crate::TypeAwareOptions,
448 filters: &crate::DeadCodeFilters,
449 output: &mut EditorDeadCodeAnalysisOutput,
450 ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
451 crate::type_aware::refine_programmatic_dead_code(
452 options,
453 filters,
454 &self.inner,
455 &mut output.results,
456 )
457 }
458
459 pub fn refine_type_aware_dead_code_in_session(
461 &self,
462 semantic_session: &mut crate::TypeAwareSession,
463 changes: Option<&crate::TypeAwareFileChanges>,
464 options: &crate::TypeAwareOptions,
465 filters: &crate::DeadCodeFilters,
466 output: &mut EditorDeadCodeAnalysisOutput,
467 ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
468 crate::type_aware::refine_programmatic_dead_code_in_session(
469 semantic_session,
470 changes,
471 options,
472 filters,
473 &self.inner,
474 &mut output.results,
475 )
476 }
477
478 pub fn analyze_project_with(
484 &self,
485 duplicates_config: &fallow_config::DuplicatesConfig,
486 retain_complexity_artifacts: bool,
487 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
488 self.inner
489 .analyze_project_with(duplicates_config, retain_complexity_artifacts)
490 .map(EditorProjectAnalysisOutput::from_engine)
491 }
492
493 pub fn analyze_project_with_changed_files(
503 &self,
504 duplicates_config: &fallow_config::DuplicatesConfig,
505 retain_complexity_artifacts: bool,
506 changed_files: Option<&FxHashSet<PathBuf>>,
507 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
508 self.inner
509 .analyze_project_with_artifacts(
510 duplicates_config,
511 fallow_engine::project_analysis::ProjectAnalysisArtifactOptions {
512 retain_complexity_artifacts,
513 changed_files: changed_files.cloned(),
514 ..fallow_engine::project_analysis::ProjectAnalysisArtifactOptions::default()
515 },
516 )
517 .map(fallow_engine::project_analysis::ProjectAnalysisArtifacts::into_output)
518 .map(EditorProjectAnalysisOutput::from_engine)
519 }
520
521 const fn from_engine(inner: fallow_engine::session::AnalysisSession) -> Self {
522 Self { inner }
523 }
524}
525
526#[derive(Debug)]
528pub struct EditorProjectAnalysisOutput {
529 pub dead_code: EditorDeadCodeAnalysisOutput,
530 pub duplication: EditorDuplicationReport,
531}
532
533impl EditorProjectAnalysisOutput {
534 fn from_engine(output: fallow_engine::project_analysis::ProjectAnalysisOutput) -> Self {
535 Self {
536 dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
537 duplication: output.duplication,
538 }
539 }
540}
541
542#[derive(Debug, Default)]
544pub struct EditorAnalysisOutput {
545 pub results: EditorAnalysisResults,
546 pub duplication: EditorDuplicationReport,
547}
548
549impl EditorAnalysisOutput {
550 #[must_use]
551 pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
552 Self {
553 results,
554 duplication,
555 }
556 }
557
558 #[must_use]
559 pub fn from_project_output(output: EditorProjectAnalysisOutput) -> Self {
560 Self::new(output.dead_code.results, output.duplication)
561 }
562
563 pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
564 self.merge_results(output.dead_code.results);
565 self.merge_duplication(output.duplication);
566 }
567
568 pub fn merge_results(&mut self, source: EditorAnalysisResults) {
569 self.results.merge_into(source);
570 }
571
572 pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
573 self.duplication.clone_groups.extend(source.clone_groups);
574 self.duplication
575 .clone_families
576 .extend(source.clone_families);
577 self.duplication
578 .mirrored_directories
579 .extend(source.mirrored_directories);
580 self.duplication.stats.clone_groups += source.stats.clone_groups;
581 self.duplication.stats.clone_instances += source.stats.clone_instances;
582 self.duplication.stats.total_files += source.stats.total_files;
583 self.duplication.stats.files_with_clones += source.stats.files_with_clones;
584 self.duplication.stats.total_lines += source.stats.total_lines;
585 self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
586 self.duplication.stats.total_tokens += source.stats.total_tokens;
587 self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
588 self.duplication.stats.clone_groups_below_min_occurrences +=
589 source.stats.clone_groups_below_min_occurrences;
590 self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
591 (self.duplication.stats.duplicated_lines as f64
592 / self.duplication.stats.total_lines as f64)
593 * 100.0
594 } else {
595 0.0
596 };
597 }
598
599 pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
600 fallow_engine::changed_files::filter_results_by_changed_files(
601 &mut self.results,
602 changed_files,
603 );
604 fallow_engine::changed_files::filter_duplication_by_changed_files(
605 &mut self.duplication,
606 changed_files,
607 root,
608 );
609 }
610
611 pub fn filter_by_changed_since(
612 &mut self,
613 root: &Path,
614 toplevel: &Path,
615 git_ref: &str,
616 ) -> Result<usize, ChangedFilesError> {
617 let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
618 let changed_count = changed.len();
619 self.filter_by_changed_files(&changed, root);
620 Ok(changed_count)
621 }
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
629
630 #[test]
631 fn merges_duplication_stats_and_recomputes_percentage() {
632 let mut output = EditorAnalysisOutput {
633 duplication: EditorDuplicationReport {
634 clone_groups: vec![CloneGroup {
635 instances: vec![CloneInstance {
636 file: PathBuf::from("src/a.ts"),
637 start_line: 1,
638 end_line: 4,
639 start_col: 0,
640 end_col: 10,
641 fragment: "const a = 1;".to_string(),
642 }],
643 token_count: 8,
644 line_count: 4,
645 }],
646 clone_families: Vec::new(),
647 mirrored_directories: Vec::new(),
648 stats: DuplicationStats {
649 clone_groups: 1,
650 clone_instances: 1,
651 total_files: 1,
652 files_with_clones: 1,
653 total_lines: 20,
654 duplicated_lines: 4,
655 total_tokens: 80,
656 duplicated_tokens: 8,
657 duplication_percentage: 20.0,
658 clone_groups_below_min_occurrences: 1,
659 },
660 },
661 ..Default::default()
662 };
663
664 output.merge_duplication(EditorDuplicationReport {
665 clone_groups: Vec::new(),
666 clone_families: Vec::new(),
667 mirrored_directories: Vec::new(),
668 stats: DuplicationStats {
669 clone_groups: 0,
670 clone_instances: 0,
671 total_files: 1,
672 files_with_clones: 0,
673 total_lines: 30,
674 duplicated_lines: 6,
675 total_tokens: 120,
676 duplicated_tokens: 12,
677 duplication_percentage: 20.0,
678 clone_groups_below_min_occurrences: 2,
679 },
680 });
681
682 assert_eq!(output.duplication.stats.total_lines, 50);
683 assert_eq!(output.duplication.stats.duplicated_lines, 10);
684 assert_eq!(
685 output.duplication.stats.clone_groups_below_min_occurrences,
686 3
687 );
688 assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
689 }
690
691 #[test]
692 fn editor_session_returns_api_owned_project_output() {
693 let temp = tempfile::tempdir().expect("temp project");
694 let root = temp.path();
695 std::fs::create_dir_all(root.join("src")).expect("src dir");
696 std::fs::write(
697 root.join("package.json"),
698 r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
699 )
700 .expect("package.json");
701 std::fs::write(
702 root.join("src/index.ts"),
703 "export const used = 1;\nconsole.log(used);\n",
704 )
705 .expect("source");
706
707 let session = EditorAnalysisSession::load(root, None).expect("session loads");
708 let output = session
709 .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
710 .expect("analysis runs");
711
712 assert!(output.dead_code.modules.is_some());
713 assert!(
714 output
715 .dead_code
716 .files
717 .as_ref()
718 .is_some_and(|files| !files.is_empty())
719 );
720 }
721
722 #[test]
723 fn editor_session_scopes_duplication_to_changed_files() {
724 let temp = tempfile::tempdir().expect("temp project");
725 let root = temp.path();
726 let src = root.join("src");
727 std::fs::create_dir_all(&src).expect("src dir");
728 std::fs::write(
729 root.join("package.json"),
730 r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
731 )
732 .expect("package.json");
733 let repeated =
734 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
735 std::fs::write(src.join("a.ts"), repeated).expect("source a");
736 std::fs::write(src.join("b.ts"), repeated).expect("source b");
737
738 let session = EditorAnalysisSession::load(root, None).expect("session loads");
739 let mut config = session.config().duplicates.clone();
740 config.min_tokens = 1;
741 config.min_lines = 1;
742 let full = session
743 .analyze_project_with(&config, false)
744 .expect("analysis runs");
745 assert!(!full.duplication.clone_groups.is_empty());
746
747 let mut changed_files = FxHashSet::default();
748 changed_files.insert(src.join("unrelated.ts"));
749 let scoped = session
750 .analyze_project_with_changed_files(&config, false, Some(&changed_files))
751 .expect("analysis runs");
752 assert!(scoped.duplication.clone_groups.is_empty());
753 }
754
755 #[test]
756 fn build_health_ignore_set_returns_none_for_empty_patterns() {
757 assert!(
758 build_health_ignore_set(&[]).is_none(),
759 "empty ignore pattern list should avoid building a matcher"
760 );
761 }
762
763 #[test]
764 fn build_health_ignore_set_matches_glob_patterns() {
765 let set =
766 build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
767 .expect("valid patterns build a glob set");
768
769 assert!(set.is_match(Path::new("src/foo.test.ts")));
770 assert!(set.is_match(Path::new("src/generated/client.ts")));
771 assert!(!set.is_match(Path::new("src/app.ts")));
772 }
773
774 #[test]
775 fn build_health_ignore_set_skips_invalid_patterns() {
776 let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
777
778 match result {
779 None => {}
780 Some(set) => assert!(
781 !set.is_match(Path::new("any/path.ts")),
782 "set built from only invalid patterns must not match anything"
783 ),
784 }
785 }
786
787 fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
788 EditorInlineComplexityFinding {
789 path,
790 name: "myFn".to_string(),
791 line: 1,
792 col: 0,
793 cyclomatic: 5,
794 cognitive: 4,
795 exceeded: EditorInlineComplexityExceeded::Cyclomatic,
796 }
797 }
798
799 #[test]
800 fn filter_inline_complexity_keeps_findings_in_changed_set() {
801 let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
802 .into_iter()
803 .collect();
804 let mut findings = vec![
805 make_inline_finding(PathBuf::from("/src/a.ts")),
806 make_inline_finding(PathBuf::from("/src/c.ts")),
807 ];
808
809 filter_inline_complexity_by_changed_files(&mut findings, &changed);
810
811 assert_eq!(findings.len(), 1);
812 assert_eq!(
813 findings[0].path.to_string_lossy().replace('\\', "/"),
814 "/src/a.ts"
815 );
816 }
817
818 #[test]
819 fn filter_inline_complexity_removes_all_when_changed_set_empty() {
820 let changed: FxHashSet<PathBuf> = FxHashSet::default();
821 let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
822
823 filter_inline_complexity_by_changed_files(&mut findings, &changed);
824
825 assert!(
826 findings.is_empty(),
827 "empty changed-files set must drop all inline complexity findings"
828 );
829 }
830
831 #[test]
832 fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
833 let path_a = PathBuf::from("/src/a.ts");
834 let path_b = PathBuf::from("/src/b.ts");
835 let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
836 let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
837
838 filter_inline_complexity_by_changed_files(&mut findings, &changed);
839
840 assert_eq!(
841 findings.len(),
842 2,
843 "all findings in the changed set must be retained"
844 );
845 }
846}