Skip to main content

fallow_api/
editor.rs

1//! Editor-facing analysis contracts shared by LSP and future editor adapters.
2
3use 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/// Editor-facing inline complexity signal for code lens and similar surfaces.
52///
53/// The finding is derived from retained typed engine parse artifacts, but the
54/// editor API owns the stable shape so LSP and future editor adapters do not
55/// need to inspect raw modules directly.
56#[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/// Which health complexity threshold(s) a function exceeded.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum EditorInlineComplexityExceeded {
70    Cyclomatic,
71    Cognitive,
72    CyclomaticAndCognitive,
73}
74
75/// Collect inline complexity findings from retained editor analysis artifacts.
76#[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/// Filter inline complexity findings to the changed-file set.
139#[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/// Reusable editor analysis session owned by the API boundary.
166#[derive(Debug)]
167pub struct EditorAnalysisSession {
168    inner: fallow_engine::AnalysisSession,
169}
170
171impl EditorAnalysisSession {
172    /// Load config and discover files for an editor project root.
173    ///
174    /// # Errors
175    ///
176    /// Returns an engine error when project config loading fails.
177    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    /// Load config, apply one editor-specific adjustment, then discover files.
182    ///
183    /// # Errors
184    ///
185    /// Returns an engine error when project config loading fails.
186    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    /// Build a session from built-in defaults, ignoring project config files.
196    #[must_use]
197    pub fn load_default(root: &Path) -> Self {
198        Self::from_engine(fallow_engine::AnalysisSession::load_default(root))
199    }
200
201    /// Resolved project config.
202    #[must_use]
203    pub fn config(&self) -> &fallow_config::ResolvedConfig {
204        self.inner.config()
205    }
206
207    /// Config file path when one was loaded.
208    #[must_use]
209    pub fn config_path(&self) -> Option<&Path> {
210        self.inner.config_path()
211    }
212
213    /// Run dead-code and duplication analysis for this editor session.
214    ///
215    /// # Errors
216    ///
217    /// Returns an engine error when dead-code parsing or analysis fails.
218    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/// Dead-code and duplication project output owned by the editor API boundary.
234#[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/// Dead-code and duplication output shaped for editor integrations.
250#[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}