Skip to main content

scute_core/code_complexity/
check.rs

1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4
5use super::rules::LanguageRules;
6use super::{rust, score, typescript};
7use crate::files;
8use crate::{Evaluation, Evidence, ExecutionError, Expected, Thresholds};
9
10pub const CHECK_NAME: &str = "code-complexity";
11
12/// Configuration for the code complexity check.
13///
14/// All fields are optional and fall back to sensible defaults when absent.
15///
16/// ```
17/// use scute_core::code_complexity::Definition;
18///
19/// // Zero-config: warn at 5, fail at 10
20/// let default = Definition::default();
21/// ```
22#[derive(Debug, Default, Deserialize)]
23#[serde(deny_unknown_fields, rename_all = "kebab-case")]
24pub struct Definition {
25    /// Warn/fail boundaries for per-function complexity scores.
26    /// Defaults to warn: 5, fail: 10.
27    pub thresholds: Option<Thresholds>,
28    /// Glob patterns for files to exclude from scanning.
29    pub exclude: Option<Vec<String>>,
30}
31
32impl Definition {
33    fn thresholds(&self) -> Thresholds {
34        self.thresholds.clone().unwrap_or(Thresholds {
35            warn: Some(5),
36            fail: Some(10),
37        })
38    }
39}
40
41/// Score cognitive complexity for every function in the given paths.
42///
43/// Accepts a mix of files and directories. Directories are walked to
44/// discover supported files (respecting `exclude` patterns).
45///
46/// Returns one [`Evaluation`] per function found. When no supported files
47/// exist, returns a single passing evaluation.
48///
49/// ```no_run
50/// use std::path::PathBuf;
51/// use scute_core::code_complexity::{self, Definition};
52///
53/// let evals = code_complexity::check(
54///     &[PathBuf::from("src/")],
55///     &Definition::default(),
56/// ).unwrap();
57/// for eval in &evals {
58///     if eval.is_fail() {
59///         eprintln!("complex function: {}", eval.target);
60///     }
61/// }
62/// ```
63///
64/// # Errors
65///
66/// Returns `ExecutionError` if any path is invalid.
67pub fn check(
68    paths: &[PathBuf],
69    definition: &Definition,
70) -> Result<Vec<Evaluation>, ExecutionError> {
71    let thresholds = definition.thresholds();
72    let languages = Languages::new();
73    let files = resolve_files(paths, definition, &languages)?;
74
75    let evaluations: Vec<Evaluation> = files
76        .iter()
77        .filter_map(|path| {
78            let source = std::fs::read_to_string(path).ok()?;
79            let rules = languages.for_path(path)?;
80            Some(score_file(path, &source, rules, &thresholds))
81        })
82        .flatten()
83        .collect();
84
85    Ok(with_fallback(evaluations, paths, thresholds))
86}
87
88fn with_fallback(
89    evaluations: Vec<Evaluation>,
90    paths: &[PathBuf],
91    thresholds: Thresholds,
92) -> Vec<Evaluation> {
93    if !evaluations.is_empty() {
94        return evaluations;
95    }
96    let label = paths
97        .first()
98        .map_or_else(|| ".".into(), |p| p.display().to_string());
99    vec![Evaluation::completed(label, 0, thresholds, vec![])]
100}
101
102fn resolve_files(
103    paths: &[PathBuf],
104    definition: &Definition,
105    languages: &Languages,
106) -> Result<Vec<PathBuf>, ExecutionError> {
107    let exclude = definition.exclude.as_deref().unwrap_or_default();
108    let extensions = languages.supported_extensions();
109    files::resolve_paths(paths, &extensions, exclude).map_err(|e| ExecutionError {
110        code: "invalid_target".into(),
111        message: e.to_string(),
112        recovery: "check that the path exists and is readable".into(),
113    })
114}
115
116struct LanguageEntry {
117    extensions: &'static [&'static str],
118    rules: Box<dyn LanguageRules>,
119}
120
121struct Languages {
122    entries: Vec<LanguageEntry>,
123}
124
125impl Languages {
126    fn new() -> Self {
127        Self {
128            entries: vec![
129                LanguageEntry {
130                    extensions: &["rs"],
131                    rules: Box::new(rust::Rust),
132                },
133                LanguageEntry {
134                    extensions: &["ts"],
135                    rules: Box::new(typescript::TypeScript::new(
136                        tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
137                    )),
138                },
139                LanguageEntry {
140                    extensions: &["tsx"],
141                    rules: Box::new(typescript::TypeScript::new(
142                        tree_sitter_typescript::LANGUAGE_TSX.into(),
143                    )),
144                },
145            ],
146        }
147    }
148
149    fn for_path(&self, path: &Path) -> Option<&dyn LanguageRules> {
150        let ext = path.extension()?.to_str()?;
151        self.entries
152            .iter()
153            .find(|e| e.extensions.contains(&ext))
154            .map(|e| e.rules.as_ref())
155    }
156
157    fn supported_extensions(&self) -> Vec<&str> {
158        self.entries
159            .iter()
160            .flat_map(|e| e.extensions.iter().copied())
161            .collect()
162    }
163}
164
165fn score_file(
166    path: &Path,
167    source: &str,
168    rules: &dyn LanguageRules,
169    thresholds: &Thresholds,
170) -> Vec<Evaluation> {
171    score::score_functions(source, rules)
172        .into_iter()
173        .map(|func| {
174            let target = format!("{}:{}:{}", path.display(), func.line, func.name);
175            let evidence = func
176                .contributors
177                .iter()
178                .map(|c| format_evidence(c, path))
179                .collect();
180            Evaluation::completed(target, func.score, thresholds.clone(), evidence)
181        })
182        .collect()
183}
184
185fn format_nesting_chain(chain: &[score::FlowConstruct]) -> String {
186    chain
187        .iter()
188        .map(|c| c.label)
189        .collect::<Vec<_>>()
190        .join(" > ")
191}
192
193fn pluralize_levels(n: u64) -> &'static str {
194    if n == 1 { "level" } else { "levels" }
195}
196
197fn format_operators(operators: &[score::LogicalOp]) -> String {
198    let mut unique: Vec<&str> = operators.iter().map(|o| o.label()).collect();
199    unique.sort_unstable();
200    unique.dedup();
201    let quoted: Vec<String> = unique.iter().map(|o| format!("'{o}'")).collect();
202    let prefix = if unique.len() > 1 { "mixed " } else { "" };
203    format!("{prefix}{}", quoted.join(" and "))
204}
205
206fn format_evidence(c: &score::Contributor, path: &Path) -> Evidence {
207    let location = Some(format!("{}:{}", path.display(), c.line));
208    let text = |s: &str| Some(Expected::Text(s.into()));
209
210    let (rule, found, expected) = match &c.kind {
211        score::ContributorKind::FlowBreak { construct } => (
212            "flow break",
213            format!(
214                "'{}' {} (+{})",
215                construct.label,
216                construct.role.flow_break_category(),
217                c.increment
218            ),
219            None,
220        ),
221        score::ContributorKind::Nesting {
222            construct,
223            depth,
224            chain,
225        } => {
226            let name = construct.label;
227            let chain = format_nesting_chain(chain);
228            let levels = pluralize_levels(*depth);
229            (
230                "nesting",
231                format!(
232                    "'{name}' nested {depth} {levels}: '{chain}' (+{})",
233                    c.increment
234                ),
235                text("extract inner block into a function"),
236            )
237        }
238        score::ContributorKind::Else => (
239            "else",
240            format!("'else' branch (+{})", c.increment),
241            text("use a guard clause or early return"),
242        ),
243        score::ContributorKind::Logical { operators } => (
244            "boolean logic",
245            format!(
246                "{} operators (+{})",
247                format_operators(operators),
248                c.increment
249            ),
250            text("extract into a named boolean"),
251        ),
252        score::ContributorKind::Recursion { fn_name } => (
253            "recursion",
254            format!("recursive call to '{fn_name}' (+{})", c.increment),
255            text("consider iterative approach"),
256        ),
257        score::ContributorKind::Jump { keyword, label } => (
258            "jump",
259            format!("'{}' to label {label} (+{})", keyword.label(), c.increment),
260            text("restructure to avoid labeled jump"),
261        ),
262    };
263
264    Evidence {
265        rule: Some(rule.to_string()),
266        location,
267        found,
268        expected,
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use scute_test_utils::TestDir;
276    use test_case::test_case;
277
278    fn check_dir(dir: &Path) -> Vec<Evaluation> {
279        check(&[dir.to_path_buf()], &Definition::default()).unwrap()
280    }
281
282    #[test]
283    fn returns_one_evaluation_per_function() {
284        let dir = TestDir::new().source_file(
285            "two.rs",
286            "fn a() {} fn b(x: i32) -> i32 { if x > 0 { 1 } else { -1 } }",
287        );
288
289        let evals = check_dir(&dir.root());
290
291        assert_eq!(evals.len(), 2);
292        assert!(evals[0].target.contains('a'));
293        assert!(evals[1].target.contains('b'));
294    }
295
296    #[test]
297    fn returns_single_pass_for_empty_directory() {
298        let dir = TestDir::new();
299
300        let evals = check_dir(&dir.root());
301
302        assert_eq!(evals.len(), 1);
303        assert!(evals[0].is_pass());
304    }
305
306    #[test]
307    fn scores_only_functions_in_specified_file() {
308        let dir = TestDir::new()
309            .source_file("target.rs", "fn focused() { if true {} }")
310            .source_file("other.rs", "fn ignored() { if true {} }");
311
312        let evals = check(&[dir.path("target.rs")], &Definition::default()).unwrap();
313
314        assert!(evals.iter().all(|e| e.target.contains("focused")));
315    }
316
317    #[test]
318    fn applies_default_thresholds() {
319        let dir = TestDir::new().source_file("simple.rs", "fn f() { if true {} }");
320
321        let evals = check_dir(&dir.root());
322
323        assert_eq!(evals.len(), 1);
324        assert!(evals[0].is_pass()); // score 1, default warn 5
325    }
326
327    fn evidence_of(source: &str) -> Vec<Evidence> {
328        evidence_of_file("a.rs", source)
329    }
330
331    fn evidence_of_file(filename: &str, source: &str) -> Vec<Evidence> {
332        let dir = TestDir::new().source_file(filename, source);
333
334        let mut evals = check_dir(&dir.root());
335        let crate::Outcome::Completed { evidence, .. } = evals.remove(0).outcome else {
336            panic!("expected completed");
337        };
338        evidence
339    }
340
341    #[test_case(
342        "fn f() { if true {} }",
343        "flow break", "'if' conditional (+1)", None
344        ; "flow_break_has_no_suggestion"
345    )]
346    #[test_case(
347        "fn f() { for x in [1] { if true {} } }",
348        "nesting", "'if' nested 1 level: 'for > if' (+2)", Some("extract inner block into a function")
349        ; "nesting_shows_chain_and_suggests_extraction"
350    )]
351    #[test_case(
352        "fn f(x: bool) { if x {} else {} }",
353        "else", "'else' branch (+1)", Some("use a guard clause or early return")
354        ; "else_suggests_guard_clause"
355    )]
356    #[test_case(
357        "fn f(a: bool, b: bool) -> bool { a && b }",
358        "boolean logic", "'&&' operators (+1)", Some("extract into a named boolean")
359        ; "logical_single_operator"
360    )]
361    #[test_case(
362        "fn f(a: bool, b: bool, c: bool) -> bool { a && b || c }",
363        "boolean logic", "mixed '&&' and '||' operators (+2)", Some("extract into a named boolean")
364        ; "logical_mixed_operators"
365    )]
366    #[test_case(
367        "fn go(n: u64) -> u64 { go(n - 1) }",
368        "recursion", "recursive call to 'go' (+1)", Some("consider iterative approach")
369        ; "recursion_shows_function_name"
370    )]
371    #[test_case(
372        "fn f() { 'outer: loop { break 'outer; } }",
373        "jump", "'break' to label 'outer (+1)", Some("restructure to avoid labeled jump")
374        ; "jump_shows_label"
375    )]
376    fn evidence_formatting(source: &str, rule: &str, expected_found: &str, expected: Option<&str>) {
377        let evidence = evidence_of(source);
378        let entry = evidence
379            .iter()
380            .find(|e| e.rule.as_deref() == Some(rule))
381            .unwrap_or_else(|| panic!("no evidence with rule '{rule}'"));
382
383        assert_eq!(
384            entry.found, expected_found,
385            "evidence found mismatch for rule '{rule}'"
386        );
387        assert_eq!(entry.expected, expected.map(|s| Expected::Text(s.into())));
388    }
389
390    #[test]
391    fn catch_evidence_formatting() {
392        let evidence = evidence_of_file("a.ts", "function f() { try {} catch (e) {} }");
393        let entry = evidence
394            .iter()
395            .find(|e| e.rule.as_deref() == Some("flow break"))
396            .unwrap();
397
398        assert!(entry.found.contains("'catch' exception handler"));
399    }
400
401    #[test]
402    fn evidence_includes_file_location() {
403        let evidence = evidence_of("fn f() { if true {} }");
404
405        assert!(evidence[0].location.as_ref().unwrap().contains("a.rs:1"));
406    }
407
408    #[test]
409    fn rejects_nonexistent_path() {
410        let result = check(&[PathBuf::from("/does/not/exist")], &Definition::default());
411
412        assert!(result.is_err());
413        assert_eq!(result.unwrap_err().code, "invalid_target");
414    }
415
416    #[test]
417    fn skips_unsupported_files() {
418        let dir = TestDir::new().source_file("code.py", "def foo(): pass");
419
420        let evals = check_dir(&dir.root());
421
422        assert_eq!(evals.len(), 1);
423        assert!(evals[0].is_pass());
424    }
425
426    #[test]
427    fn rejects_nonexistent_file() {
428        let result = check(
429            &[PathBuf::from("/nonexistent/file.rs")],
430            &Definition::default(),
431        );
432
433        assert!(result.is_err());
434        assert_eq!(result.unwrap_err().code, "invalid_target");
435    }
436
437    #[test]
438    fn rejects_unsupported_file_extension() {
439        let dir = TestDir::new().source_file("code.py", "def foo(): pass");
440
441        let result = check(&[dir.path("code.py")], &Definition::default());
442
443        assert!(result.is_err());
444        assert_eq!(result.unwrap_err().code, "invalid_target");
445    }
446
447    #[test]
448    fn scores_tsx_file() {
449        let dir =
450            TestDir::new().source_file("component.tsx", "function Greeting() { return 'hello' }");
451
452        let evals = check_dir(&dir.root());
453
454        assert_eq!(evals.len(), 1);
455        assert!(evals[0].target.contains("Greeting"));
456    }
457
458    #[test]
459    fn scores_mixed_language_project() {
460        let dir = TestDir::new()
461            .source_file("lib.rs", "fn rust_fn() { if true {} }")
462            .source_file("app.ts", "function ts_fn() { return 1 }");
463
464        let evals = check_dir(&dir.root());
465
466        assert_eq!(evals.len(), 2);
467        let names: Vec<&str> = evals.iter().map(|e| e.target.as_str()).collect();
468        assert!(names.iter().any(|t| t.contains("rust_fn")));
469        assert!(names.iter().any(|t| t.contains("ts_fn")));
470    }
471}