1use std::path::{Path, PathBuf};
4
5use rustc_hash::FxHashSet;
6
7pub use fallow_engine::changed_files::{
8 ChangedFilesError, resolve_git_toplevel, try_get_changed_files_with_toplevel,
9};
10
11pub mod editor_duplicates {
12 pub use fallow_engine::duplicates::*;
13}
14
15pub mod editor_extract {
16 pub use fallow_types::extract::*;
17}
18
19pub mod editor_results {
20 pub use fallow_types::output_dead_code::{
21 BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
22 CircularDependencyFinding, DuplicateExportFinding, DuplicatePropShapeFinding,
23 DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding, InvalidClientExportFinding,
24 MisconfiguredDependencyOverrideFinding, MisplacedDirectiveFinding,
25 MixedClientServerBarrelFinding, PolicyViolationFinding, PrivateTypeLeakFinding,
26 ReExportCycleFinding, RouteCollisionFinding, TestOnlyDependencyFinding,
27 TypeOnlyDependencyFinding, UnlistedDependencyFinding, UnprovidedInjectFinding,
28 UnrenderedComponentFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
29 UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedComponentEmitFinding,
30 UnusedComponentInputFinding, UnusedComponentOutputFinding, UnusedComponentPropFinding,
31 UnusedDependencyFinding, UnusedDependencyOverrideFinding, UnusedDevDependencyFinding,
32 UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
33 UnusedOptionalDependencyFinding, UnusedServerActionFinding, UnusedStoreMemberFinding,
34 UnusedSvelteEventFinding, UnusedTypeFinding,
35 };
36 pub use fallow_types::results::*;
37}
38
39pub mod editor_security {
40 pub use fallow_engine::security::security_catalogue_title;
41}
42
43pub mod editor_suppress {
44 pub use fallow_engine::suppress::{IssueKind, is_suppressed};
45}
46
47pub type EditorAnalysisResults = fallow_types::results::AnalysisResults;
48pub type EditorDeadCodeAnalysisOutput = fallow_engine::DeadCodeAnalysisOutput;
49pub type EditorDuplicationReport = fallow_engine::DuplicationReport;
50
51#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct EditorInlineComplexityFinding {
58 pub path: PathBuf,
59 pub name: String,
60 pub line: u32,
61 pub col: u32,
62 pub cyclomatic: u16,
63 pub cognitive: u16,
64 pub exceeded: EditorInlineComplexityExceeded,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum EditorInlineComplexityExceeded {
70 Cyclomatic,
71 Cognitive,
72 CyclomaticAndCognitive,
73}
74
75#[must_use]
77pub fn collect_inline_complexity(
78 config: &fallow_config::ResolvedConfig,
79 output: &EditorDeadCodeAnalysisOutput,
80) -> Vec<EditorInlineComplexityFinding> {
81 let Some(modules) = output.modules.as_ref() else {
82 return Vec::new();
83 };
84 let Some(files) = output.files.as_ref() else {
85 return Vec::new();
86 };
87
88 let file_paths: rustc_hash::FxHashMap<_, _> =
89 files.iter().map(|file| (file.id, &file.path)).collect();
90 let ignore_set = build_health_ignore_set(&config.health.ignore);
91 let mut findings = Vec::new();
92
93 for module in modules {
94 let Some(path) = file_paths.get(&module.file_id) else {
95 continue;
96 };
97 let relative = path.strip_prefix(&config.root).unwrap_or(path);
98 if ignore_set
99 .as_ref()
100 .is_some_and(|set| set.is_match(relative))
101 {
102 continue;
103 }
104
105 for function in &module.complexity {
106 if fallow_engine::suppress::is_suppressed(
107 &module.suppressions,
108 function.line,
109 fallow_engine::suppress::IssueKind::Complexity,
110 ) {
111 continue;
112 }
113
114 let exceeds_cyclomatic = function.cyclomatic > config.health.max_cyclomatic;
115 let exceeds_cognitive = function.cognitive > config.health.max_cognitive;
116 let exceeded = match (exceeds_cyclomatic, exceeds_cognitive) {
117 (true, true) => EditorInlineComplexityExceeded::CyclomaticAndCognitive,
118 (true, false) => EditorInlineComplexityExceeded::Cyclomatic,
119 (false, true) => EditorInlineComplexityExceeded::Cognitive,
120 (false, false) => continue,
121 };
122
123 findings.push(EditorInlineComplexityFinding {
124 path: (*path).clone(),
125 name: function.name.clone(),
126 line: function.line,
127 col: function.col,
128 cyclomatic: function.cyclomatic,
129 cognitive: function.cognitive,
130 exceeded,
131 });
132 }
133 }
134
135 findings
136}
137
138#[allow(
140 clippy::implicit_hasher,
141 reason = "editor analysis changed-file sets use the workspace FxHashSet convention"
142)]
143pub fn filter_inline_complexity_by_changed_files(
144 findings: &mut Vec<EditorInlineComplexityFinding>,
145 changed_files: &FxHashSet<PathBuf>,
146) {
147 findings.retain(|finding| changed_files.contains(&finding.path));
148}
149
150fn build_health_ignore_set(patterns: &[String]) -> Option<globset::GlobSet> {
151 if patterns.is_empty() {
152 return None;
153 }
154
155 let mut builder = globset::GlobSetBuilder::new();
156 for pattern in patterns {
157 let Ok(glob) = globset::Glob::new(pattern) else {
158 continue;
159 };
160 builder.add(glob);
161 }
162 builder.build().ok()
163}
164
165#[derive(Debug)]
167pub struct EditorAnalysisSession {
168 inner: fallow_engine::AnalysisSession,
169}
170
171impl EditorAnalysisSession {
172 pub fn load(root: &Path, config_path: Option<&Path>) -> fallow_engine::EngineResult<Self> {
178 fallow_engine::AnalysisSession::load(root, config_path).map(Self::from_engine)
179 }
180
181 pub fn load_with_config(
187 root: &Path,
188 config_path: Option<&Path>,
189 configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
190 ) -> fallow_engine::EngineResult<Self> {
191 fallow_engine::AnalysisSession::load_with_config(root, config_path, configure)
192 .map(Self::from_engine)
193 }
194
195 #[must_use]
197 pub fn load_default(root: &Path) -> Self {
198 Self::from_engine(fallow_engine::AnalysisSession::load_default(root))
199 }
200
201 #[must_use]
203 pub fn config(&self) -> &fallow_config::ResolvedConfig {
204 self.inner.config()
205 }
206
207 #[must_use]
209 pub fn config_path(&self) -> Option<&Path> {
210 self.inner.config_path()
211 }
212
213 pub fn analyze_project_with(
219 &self,
220 duplicates_config: &fallow_config::DuplicatesConfig,
221 retain_complexity_artifacts: bool,
222 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
223 self.inner
224 .analyze_project_with(duplicates_config, retain_complexity_artifacts)
225 .map(EditorProjectAnalysisOutput::from_engine)
226 }
227
228 const fn from_engine(inner: fallow_engine::AnalysisSession) -> Self {
229 Self { inner }
230 }
231}
232
233#[derive(Debug)]
235pub struct EditorProjectAnalysisOutput {
236 pub dead_code: EditorDeadCodeAnalysisOutput,
237 pub duplication: EditorDuplicationReport,
238}
239
240impl EditorProjectAnalysisOutput {
241 fn from_engine(output: fallow_engine::ProjectAnalysisOutput) -> Self {
242 Self {
243 dead_code: output.dead_code,
244 duplication: output.duplication,
245 }
246 }
247}
248
249#[derive(Debug, Default)]
251pub struct EditorAnalysisOutput {
252 pub results: EditorAnalysisResults,
253 pub duplication: EditorDuplicationReport,
254}
255
256impl EditorAnalysisOutput {
257 #[must_use]
258 pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
259 Self {
260 results,
261 duplication,
262 }
263 }
264
265 #[must_use]
266 pub fn from_project_output(output: EditorProjectAnalysisOutput) -> Self {
267 Self::new(output.dead_code.results, output.duplication)
268 }
269
270 pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
271 self.merge_results(output.dead_code.results);
272 self.merge_duplication(output.duplication);
273 }
274
275 pub fn merge_results(&mut self, source: EditorAnalysisResults) {
276 self.results.merge_into(source);
277 }
278
279 pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
280 self.duplication.clone_groups.extend(source.clone_groups);
281 self.duplication
282 .clone_families
283 .extend(source.clone_families);
284 self.duplication
285 .mirrored_directories
286 .extend(source.mirrored_directories);
287 self.duplication.stats.clone_groups += source.stats.clone_groups;
288 self.duplication.stats.clone_instances += source.stats.clone_instances;
289 self.duplication.stats.total_files += source.stats.total_files;
290 self.duplication.stats.files_with_clones += source.stats.files_with_clones;
291 self.duplication.stats.total_lines += source.stats.total_lines;
292 self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
293 self.duplication.stats.total_tokens += source.stats.total_tokens;
294 self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
295 self.duplication.stats.clone_groups_below_min_occurrences +=
296 source.stats.clone_groups_below_min_occurrences;
297 self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
298 (self.duplication.stats.duplicated_lines as f64
299 / self.duplication.stats.total_lines as f64)
300 * 100.0
301 } else {
302 0.0
303 };
304 }
305
306 pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
307 fallow_engine::changed_files::filter_results_by_changed_files(
308 &mut self.results,
309 changed_files,
310 );
311 fallow_engine::changed_files::filter_duplication_by_changed_files(
312 &mut self.duplication,
313 changed_files,
314 root,
315 );
316 }
317
318 pub fn filter_by_changed_since(
319 &mut self,
320 root: &Path,
321 toplevel: &Path,
322 git_ref: &str,
323 ) -> Result<usize, ChangedFilesError> {
324 let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
325 let changed_count = changed.len();
326 self.filter_by_changed_files(&changed, root);
327 Ok(changed_count)
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 use fallow_engine::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
336
337 #[test]
338 fn merges_duplication_stats_and_recomputes_percentage() {
339 let mut output = EditorAnalysisOutput {
340 duplication: EditorDuplicationReport {
341 clone_groups: vec![CloneGroup {
342 instances: vec![CloneInstance {
343 file: PathBuf::from("src/a.ts"),
344 start_line: 1,
345 end_line: 4,
346 start_col: 0,
347 end_col: 10,
348 fragment: "const a = 1;".to_string(),
349 }],
350 token_count: 8,
351 line_count: 4,
352 }],
353 clone_families: Vec::new(),
354 mirrored_directories: Vec::new(),
355 stats: DuplicationStats {
356 clone_groups: 1,
357 clone_instances: 1,
358 total_files: 1,
359 files_with_clones: 1,
360 total_lines: 20,
361 duplicated_lines: 4,
362 total_tokens: 80,
363 duplicated_tokens: 8,
364 duplication_percentage: 20.0,
365 clone_groups_below_min_occurrences: 1,
366 },
367 },
368 ..Default::default()
369 };
370
371 output.merge_duplication(EditorDuplicationReport {
372 clone_groups: Vec::new(),
373 clone_families: Vec::new(),
374 mirrored_directories: Vec::new(),
375 stats: DuplicationStats {
376 clone_groups: 0,
377 clone_instances: 0,
378 total_files: 1,
379 files_with_clones: 0,
380 total_lines: 30,
381 duplicated_lines: 6,
382 total_tokens: 120,
383 duplicated_tokens: 12,
384 duplication_percentage: 20.0,
385 clone_groups_below_min_occurrences: 2,
386 },
387 });
388
389 assert_eq!(output.duplication.stats.total_lines, 50);
390 assert_eq!(output.duplication.stats.duplicated_lines, 10);
391 assert_eq!(
392 output.duplication.stats.clone_groups_below_min_occurrences,
393 3
394 );
395 assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
396 }
397
398 #[test]
399 fn editor_session_returns_api_owned_project_output() {
400 let temp = tempfile::tempdir().expect("temp project");
401 let root = temp.path();
402 std::fs::create_dir_all(root.join("src")).expect("src dir");
403 std::fs::write(
404 root.join("package.json"),
405 r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
406 )
407 .expect("package.json");
408 std::fs::write(
409 root.join("src/index.ts"),
410 "export const used = 1;\nconsole.log(used);\n",
411 )
412 .expect("source");
413
414 let session = EditorAnalysisSession::load(root, None).expect("session loads");
415 let output = session
416 .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
417 .expect("analysis runs");
418
419 assert!(output.dead_code.modules.is_some());
420 assert!(
421 output
422 .dead_code
423 .files
424 .as_ref()
425 .is_some_and(|files| !files.is_empty())
426 );
427 }
428
429 #[test]
430 fn build_health_ignore_set_returns_none_for_empty_patterns() {
431 assert!(
432 build_health_ignore_set(&[]).is_none(),
433 "empty ignore pattern list should avoid building a matcher"
434 );
435 }
436
437 #[test]
438 fn build_health_ignore_set_matches_glob_patterns() {
439 let set =
440 build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
441 .expect("valid patterns build a glob set");
442
443 assert!(set.is_match(Path::new("src/foo.test.ts")));
444 assert!(set.is_match(Path::new("src/generated/client.ts")));
445 assert!(!set.is_match(Path::new("src/app.ts")));
446 }
447
448 #[test]
449 fn build_health_ignore_set_skips_invalid_patterns() {
450 let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
451
452 match result {
453 None => {}
454 Some(set) => assert!(
455 !set.is_match(Path::new("any/path.ts")),
456 "set built from only invalid patterns must not match anything"
457 ),
458 }
459 }
460
461 fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
462 EditorInlineComplexityFinding {
463 path,
464 name: "myFn".to_string(),
465 line: 1,
466 col: 0,
467 cyclomatic: 5,
468 cognitive: 4,
469 exceeded: EditorInlineComplexityExceeded::Cyclomatic,
470 }
471 }
472
473 #[test]
474 fn filter_inline_complexity_keeps_findings_in_changed_set() {
475 let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
476 .into_iter()
477 .collect();
478 let mut findings = vec![
479 make_inline_finding(PathBuf::from("/src/a.ts")),
480 make_inline_finding(PathBuf::from("/src/c.ts")),
481 ];
482
483 filter_inline_complexity_by_changed_files(&mut findings, &changed);
484
485 assert_eq!(findings.len(), 1);
486 assert_eq!(
487 findings[0].path.to_string_lossy().replace('\\', "/"),
488 "/src/a.ts"
489 );
490 }
491
492 #[test]
493 fn filter_inline_complexity_removes_all_when_changed_set_empty() {
494 let changed: FxHashSet<PathBuf> = FxHashSet::default();
495 let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
496
497 filter_inline_complexity_by_changed_files(&mut findings, &changed);
498
499 assert!(
500 findings.is_empty(),
501 "empty changed-files set must drop all inline complexity findings"
502 );
503 }
504
505 #[test]
506 fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
507 let path_a = PathBuf::from("/src/a.ts");
508 let path_b = PathBuf::from("/src/b.ts");
509 let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
510 let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
511
512 filter_inline_complexity_by_changed_files(&mut findings, &changed);
513
514 assert_eq!(
515 findings.len(),
516 2,
517 "all findings in the changed set must be retained"
518 );
519 }
520}