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#[derive(Debug, Default, Deserialize)]
23#[serde(deny_unknown_fields, rename_all = "kebab-case")]
24pub struct Definition {
25 pub thresholds: Option<Thresholds>,
28 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
41pub 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()); }
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}