1use anyhow::Result;
2use code_ranker_plugin_api::{
3 attrs::ValueType,
4 default_cycle_kinds, default_node_kinds,
5 graph::Graph,
6 level::{AttributeSpec, Direction, EdgeKindSpec, Grouping, Level, Thresholds},
7 log,
8 node::Node,
9 plugin::{LanguagePlugin, PluginInput, Preset},
10};
11use std::collections::BTreeMap;
12use std::path::Path;
13
14use cargo_metadata::MetadataCommand;
15
16mod collapse;
17mod crate_graph;
18mod ids;
19mod internal;
20mod module_graph;
21mod rust_ts;
22
23use collapse::collapse_to_files;
24use internal::GraphBuilder;
25
26pub struct RustPlugin;
27
28type MetricPreset = (
34 &'static str,
35 &'static str,
36 &'static str,
37 &'static [&'static str],
38 &'static str,
39 &'static str,
40);
41
42const RUST_METRIC_PRESETS: &[MetricPreset] = &[
43 (
44 "HK",
45 "HK — Henry-Kafura Coupling",
46 "hk",
47 &["in", "out"],
48 "henry-kafura-coupling",
49 "These modules carry heavy Henry-Kafura coupling — HK = sloc × (fan_in × fan_out)²,\n\
50 where sloc is the module's source lines of code (real code lines, excluding blanks\n\
51 and comment-only lines), fan_in is how many modules depend on it, and fan_out is how\n\
52 many it depends on.\n\
53 A high score is a large module sitting on a busy crossroads of incoming and outgoing\n\
54 dependencies, so any change here ripples widely.\n\n\
55 For each module below, lower the factor that dominates its HK: shrink the module by\n\
56 extracting cohesive pieces, or cut fan-in/fan-out by narrowing its public surface and\n\
57 depending on fewer collaborators (introduce an abstraction, move a responsibility).\n\
58 Keep existing API contracts intact.",
59 ),
60 (
61 "SLOC",
62 "SLOC — Module Size",
63 "sloc",
64 &[],
65 "module-size",
66 "These are the largest modules by source lines of code. Size alone is not a defect, but\n\
67 oversized files usually bundle several responsibilities and are hard to read, test and\n\
68 review.\n\n\
69 For each module below, identify the distinct responsibilities it holds and propose how\n\
70 to split it into smaller, cohesive modules — each with a single clear purpose — without\n\
71 changing external behaviour.",
72 ),
73 (
74 "FANIN",
75 "Fan-in — Afferent Coupling",
76 "fan_in",
77 &["in"],
78 "fan-in-afferent-coupling",
79 "These modules have high fan-in: many other modules depend on them. They are\n\
80 load-bearing — a change here forces changes (or re-review) across every dependant, and\n\
81 a bug here is widely felt.\n\n\
82 For each module below, confirm its public surface is a stable, minimal contract. Narrow\n\
83 the API to what callers actually need, split it if different callers use disjoint parts\n\
84 (see Interface Segregation), and stabilise the abstractions the rest of the codebase\n\
85 leans on.",
86 ),
87 (
88 "FANOUT",
89 "Fan-out — Efferent Coupling",
90 "fan_out",
91 &["out"],
92 "fan-out-efferent-coupling",
93 "These modules have high fan-out: they depend on many other modules. High efferent\n\
94 coupling makes a module fragile (it breaks when any dependency changes) and hard to\n\
95 test or reuse in isolation.\n\n\
96 For each module below, reduce its direct dependencies: depend on abstractions rather\n\
97 than concretes (see Dependency Inversion), collapse several fine-grained collaborators\n\
98 behind one focused interface, and move logic that pulls in unrelated dependencies into\n\
99 a more appropriate module.",
100 ),
101];
102
103impl LanguagePlugin for RustPlugin {
104 fn name(&self) -> &str {
105 "rust"
106 }
107
108 fn detect(&self, workspace: &Path, _input: &PluginInput) -> bool {
109 workspace.join("Cargo.toml").exists()
110 }
111
112 fn levels(&self) -> Vec<Level> {
113 let mut edge_kinds: BTreeMap<String, EdgeKindSpec> = BTreeMap::new();
114 edge_kinds.insert(
115 "uses".into(),
116 EdgeKindSpec {
117 flow: true,
118 label: Some("uses".into()),
119 description: Some(
120 "Code dependency — this file references an item the target file defines.<br>\
121 Captured from `use path::Item;`, a qualified path (`crate::a::Item`, \
122 `other_crate::Item`), or a derive (`#[derive(serde::Serialize)]`).<br>\
123 The path resolves to the file that defines the item (following `pub use` \
124 re-exports), so the edge points at the definition, not a re-export hub.<br>\
125 This is the real dependency: it counts toward fan-in / fan-out, \
126 Henry-Kafura coupling and cycles."
127 .into(),
128 ),
129 },
130 );
131 edge_kinds.insert(
132 "contains".into(),
133 EdgeKindSpec {
134 flow: false,
135 label: Some("contains".into()),
136 description: Some(
137 "Module ownership — the parent declares the child module \
138 (`mod foo;` / `pub mod foo;`), so `foo.rs` (or `foo/mod.rs`) belongs to it.<br>\
139 This is the Rust module tree: structure, not a code dependency.<br>\
140 Kept in the data but not drawn on the main map, and excluded from \
141 fan-in / fan-out / HK / cycles."
142 .into(),
143 ),
144 },
145 );
146 edge_kinds.insert(
147 "reexports".into(),
148 EdgeKindSpec {
149 flow: false,
150 label: Some("reexport".into()),
151 description: Some(
152 "Re-export (`pub use foo::Item;`) — re-publishes another file's item as part of \
153 this file's public API (the crate-root / prelude facade, e.g. `lib.rs` doing \
154 `pub use access_scope::AccessScope;`).<br>\
155 A facade, not a dependency: excluded from fan-in / fan-out / HK / cycles and \
156 not drawn on the main map, like `contains`.<br>\
157 A consumer's `use this_crate::Item` is attributed to the file that defines \
158 `Item`, so re-export hubs (`lib.rs` / `mod.rs`) collect no false coupling — the \
159 `pub use` is still recorded here so you can see what a file exposes."
160 .into(),
161 ),
162 },
163 );
164 edge_kinds.insert(
165 "super".into(),
166 EdgeKindSpec {
167 flow: false,
168 label: Some("super".into()),
169 description: Some(
170 "Namespace pull from an enclosing module — a glob `use` that reaches \
171 *up* the module tree (`use super::*`, `use crate::<ancestor>::*`), \
172 bringing the parent's items into the child's scope.<br>\
173 Usually structural scope-sugar (a module split across files referring \
174 back to itself). But if the child actually uses a parent item brought \
175 in by the glob, it IS a real back-dependency — technically a cycle. \
176 code-ranker can't tell the two apart without name resolution, so it \
177 treats `super` as a **low-priority** cycle and leaves it non-flow: \
178 deprioritized next to obvious cross-module cycles.<br>\
179 Kept in the data but not drawn on the main map, and excluded from \
180 fan-in / fan-out / HK / cycles — like `contains`."
181 .into(),
182 ),
183 },
184 );
185
186 let aspec = AttributeSpec::new;
187
188 let mut node_attributes: BTreeMap<String, AttributeSpec> = BTreeMap::new();
189 node_attributes.insert("path".into(), aspec(ValueType::Str, "Path"));
190 node_attributes.insert("crate".into(), aspec(ValueType::Str, "Crate"));
191 node_attributes.insert("loc".into(), aspec(ValueType::Int, "Lines"));
192 node_attributes.insert("visibility".into(), aspec(ValueType::Str, "Visibility"));
193 node_attributes.insert("external".into(), aspec(ValueType::Bool, "External"));
194 node_attributes.insert("version".into(), aspec(ValueType::Str, "Version"));
195 node_attributes.insert("items".into(), aspec(ValueType::Int, "Items"));
196 let mut unsafe_spec = aspec(ValueType::Int, "Unsafe");
197 unsafe_spec.short = Some("Unsafe".into());
198 unsafe_spec.description = Some(
199 "Count of `unsafe` blocks and `unsafe fn`/`impl`/`trait` declarations \
200 in production code (test items are excluded). Syntactic count: \
201 `unsafe` inside a macro body is not seen, and the figure is not \
202 type-checked."
203 .into(),
204 );
205 unsafe_spec.direction = Direction::LowerBetter;
206 node_attributes.insert("unsafe".into(), unsafe_spec);
207
208 let mut edge_attributes: BTreeMap<String, AttributeSpec> = BTreeMap::new();
209 edge_attributes.insert("visibility".into(), aspec(ValueType::Str, "Visibility"));
210
211 vec![Level {
212 name: "files".into(),
213 edge_kinds,
214 node_attributes,
215 edge_attributes,
216 attribute_groups: BTreeMap::new(),
217 node_kinds: default_node_kinds(),
218 cycle_kinds: default_cycle_kinds(),
219 grouping: Some(Grouping {
222 key: Some("crate".into()),
223 function: None,
224 }),
225 }]
226 }
227
228 fn thresholds(&self) -> BTreeMap<String, Thresholds> {
229 BTreeMap::from([
232 (
233 "hk".into(),
234 Thresholds {
235 info: 150_000.0,
236 warning: 10_000_000.0,
237 },
238 ),
239 (
240 "sloc".into(),
241 Thresholds {
242 info: 800.0,
243 warning: 3_000.0,
244 },
245 ),
246 (
247 "fan_out".into(),
248 Thresholds {
249 info: 8.0,
250 warning: 18.0,
251 },
252 ),
253 (
254 "items".into(),
255 Thresholds {
256 info: 20.0,
257 warning: 50.0,
258 },
259 ),
260 ])
261 }
262
263 fn presets(&self, mut defaults: Vec<Preset>, _input: &PluginInput) -> Vec<Preset> {
264 let base_dir = defaults
269 .iter()
270 .find_map(|p| p.doc_url.as_deref())
271 .and_then(|u| u.rsplit_once('/').map(|(dir, _)| dir.to_string()));
272 for &(id, title, sort_metric, connections, slug, prompt) in RUST_METRIC_PRESETS {
273 defaults.push(Preset {
274 id: id.to_string(),
275 label: id.to_string(),
276 title: title.to_string(),
277 prompt: prompt.to_string(),
278 doc_url: base_dir.as_ref().map(|d| format!("{d}/{slug}.md")),
279 sort_metric: sort_metric.to_string(),
280 connections: connections.iter().map(|s| (*s).to_string()).collect(),
281 });
282 }
283 defaults
284 }
285
286 fn analyze(&self, workspace: &Path, _level: &str, input: &PluginInput) -> Result<Graph> {
287 let mut builder = GraphBuilder::new();
288 syn_analyze(workspace, input.ignore_tests, &mut builder)?;
289 let internal = builder.build();
290 Ok(collapse_to_files(internal))
291 }
292
293 fn metrics(&self, graph: &mut Graph) -> usize {
294 let mut annotated = 0;
299 for node in &mut graph.nodes {
300 if node.kind != "file" {
301 continue;
302 }
303 let Ok(src) = std::fs::read(&node.id) else {
304 continue;
305 };
306 if rust_file_metrics(node, &src) {
307 annotated += 1;
308 }
309 }
310 annotated
311 }
312
313 fn is_test_path(&self, rel_path: &str) -> bool {
314 matches!(rel_path.split('/').next(), Some("tests") | Some("benches"))
318 }
319
320 fn versions(&self, _workspace: &Path, _input: &PluginInput) -> Vec<(String, String)> {
321 version_string()
322 .map(|rv| vec![("rustc".to_string(), rv)])
323 .unwrap_or_default()
324 }
325
326 fn roots(&self, _workspace: &Path) -> Vec<(String, String)> {
327 rust_toolchain_roots()
328 }
329
330 fn metric_specs(
331 &self,
332 mut defaults: BTreeMap<String, AttributeSpec>,
333 ) -> BTreeMap<String, AttributeSpec> {
334 let rust_loc_note: &[(&str, &str)] = &[
338 (
339 "sloc",
340 "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted. In Rust, lines inside `#[cfg(test)]` / `#[test]` items are excluded too, so this counts production code only (unlike `loc`, the raw file line count).",
341 ),
342 (
343 "lloc",
344 "Logical lines — counts statements, not physical lines. In Rust, measured on production code only (inline `#[cfg(test)]` / `#[test]` tests are excluded, like `sloc`; their lines are `tloc`).",
345 ),
346 (
347 "cloc",
348 "Comment-only lines (inline comments on code lines are not counted). In Rust, measured on production code only (inline `#[cfg(test)]` / `#[test]` tests are excluded, like `sloc`; their lines are `tloc`).",
349 ),
350 (
351 "blank",
352 "Empty or whitespace-only lines. In Rust, measured on production code only (inline `#[cfg(test)]` / `#[test]` tests are excluded, like `sloc`; their lines are `tloc`).",
353 ),
354 ];
355 for (key, desc) in rust_loc_note {
356 if let Some(spec) = defaults.get_mut(*key) {
357 spec.description = Some((*desc).to_string());
358 }
359 }
360 defaults
361 }
362}
363
364fn rust_toolchain_roots() -> Vec<(String, String)> {
370 let mut roots = Vec::new();
371 let home = std::env::var("HOME").unwrap_or_default();
372
373 let cargo = std::env::var("CARGO_HOME").unwrap_or_else(|_| format!("{home}/.cargo"));
374 let rustup = std::env::var("RUSTUP_HOME").unwrap_or_else(|_| format!("{home}/.rustup"));
375
376 if !cargo.is_empty() {
377 let registry_src = format!("{cargo}/registry/src");
379 if let Ok(entries) = std::fs::read_dir(®istry_src) {
380 for entry in entries.flatten() {
381 let name = entry.file_name().to_string_lossy().to_string();
382 if name.starts_with("index.crates.io") {
383 roots.push(("registry".to_string(), format!("{registry_src}/{name}")));
384 break;
385 }
386 }
387 }
388 roots.push(("cargo".to_string(), cargo));
389 }
390 if !rustup.is_empty() {
391 if which::which("rustc").is_ok()
394 && let Ok(out) = log::timed("rustc --print sysroot", || {
395 std::process::Command::new("rustc")
396 .args(["--print", "sysroot"])
397 .output()
398 })
399 && out.status.success()
400 {
401 let sysroot = String::from_utf8_lossy(&out.stdout).trim().to_string();
402 let rust_lib = format!("{sysroot}/lib/rustlib/src/rust/library");
403 if std::path::Path::new(&rust_lib).exists() {
404 roots.push(("rust-src".to_string(), rust_lib));
405 }
406 }
407 roots.push(("rustup".to_string(), rustup));
408 }
409 roots
410}
411
412fn syn_analyze(workspace: &Path, ignore_tests: bool, builder: &mut GraphBuilder) -> Result<()> {
415 let manifest = workspace.join("Cargo.toml");
416 let metadata = log::timed("cargo metadata --offline", || {
421 MetadataCommand::new()
422 .manifest_path(&manifest)
423 .other_options(vec!["--offline".to_string()])
424 .exec()
425 })
426 .map_err(|err| offline_metadata_error(&manifest, err))?;
427
428 crate_graph::contribute(&metadata, builder);
429 module_graph::contribute(&metadata, ignore_tests, builder)?;
430 Ok(())
431}
432
433fn offline_metadata_error(manifest: &Path, err: cargo_metadata::Error) -> anyhow::Error {
434 anyhow::anyhow!(
435 "cargo metadata (offline) failed for {manifest}\n\n\
436 code-ranker is an offline tool — it never downloads dependencies. It reads \
437 the dependency graph from cargo's local cache, which must already be \
438 populated for this project.\n\n\
439 Warm the cache once (with network), then re-run code-ranker:\n \
440 cargo metadata --manifest-path {manifest} >/dev/null\n\
441 (a prior `cargo build` / `cargo fetch` works too).\n\n\
442 In CI: run code-ranker on the same image/cache as your build or test jobs, \
443 where the cache is already warm.\n\n\
444 Underlying cargo error: {err}",
445 manifest = manifest.display(),
446 )
447}
448
449fn version_string() -> Option<String> {
450 which::which("rustc").ok()?;
451 let out = log::timed("rustc --version", || {
452 std::process::Command::new("rustc")
453 .arg("--version")
454 .output()
455 })
456 .ok()?;
457 if out.status.success() {
458 Some(
459 String::from_utf8_lossy(&out.stdout)
460 .split_whitespace()
461 .nth(1)
462 .unwrap_or("unknown")
463 .to_string(),
464 )
465 } else {
466 None
467 }
468}
469
470fn rust_file_metrics(node: &mut Node, src: &[u8]) -> bool {
479 let (prod, tloc) = strip_cfg_test(src);
480 let Some(mut m) = rust_ts::compute(&prod) else {
481 return false;
482 };
483 m.tloc = tloc as f64;
484 code_ranker_graph::write_metrics(node, &m);
485 true
486}
487
488fn is_test_attr(attr: &syn::Attribute) -> bool {
493 if attr.path().is_ident("test") || attr.path().is_ident("bench") {
494 return true;
495 }
496 if attr.path().is_ident("cfg")
497 && let syn::Meta::List(list) = &attr.meta
498 {
499 return tokens_have_test_ident(list.tokens.clone());
500 }
501 false
502}
503
504fn tokens_have_test_ident(ts: proc_macro2::TokenStream) -> bool {
507 ts.into_iter().any(|t| match t {
508 proc_macro2::TokenTree::Ident(i) => i == "test",
509 proc_macro2::TokenTree::Group(g) => tokens_have_test_ident(g.stream()),
510 _ => false,
511 })
512}
513
514#[derive(Default)]
519struct TestSpans {
520 ranges: Vec<(usize, usize)>,
521}
522
523impl TestSpans {
524 fn record(&mut self, attrs: &[syn::Attribute], span: proc_macro2::Span) {
525 use syn::spanned::Spanned;
526 let start = attrs
527 .iter()
528 .map(|a| a.span().start().line)
529 .chain(std::iter::once(span.start().line))
530 .min()
531 .unwrap_or(0);
532 self.ranges.push((start, span.end().line));
533 }
534}
535
536impl<'ast> syn::visit::Visit<'ast> for TestSpans {
537 fn visit_item_mod(&mut self, m: &'ast syn::ItemMod) {
538 use syn::spanned::Spanned;
539 if m.attrs.iter().any(is_test_attr) {
540 self.record(&m.attrs, m.span());
541 } else {
542 syn::visit::visit_item_mod(self, m);
543 }
544 }
545 fn visit_item_fn(&mut self, f: &'ast syn::ItemFn) {
546 use syn::spanned::Spanned;
547 if f.attrs.iter().any(is_test_attr) {
548 self.record(&f.attrs, f.span());
549 }
550 }
551}
552
553fn strip_cfg_test(src: &[u8]) -> (Vec<u8>, usize) {
560 use syn::visit::Visit;
561 let Ok(text) = std::str::from_utf8(src) else {
562 return (src.to_vec(), 0);
563 };
564 let Ok(file) = syn::parse_file(text) else {
565 return (src.to_vec(), 0);
566 };
567 let mut spans = TestSpans::default();
568 spans.visit_file(&file);
569 if spans.ranges.is_empty() {
570 return (src.to_vec(), 0);
571 }
572 let drop: std::collections::HashSet<usize> =
573 spans.ranges.iter().flat_map(|&(s, e)| s..=e).collect();
574 let tloc = drop.len();
575 let mut out: String = text
576 .lines()
577 .enumerate()
578 .filter(|(i, _)| !drop.contains(&(i + 1)))
579 .map(|(_, l)| l)
580 .collect::<Vec<_>>()
581 .join("\n");
582 out.push('\n');
583 (out.into_bytes(), tloc)
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use code_ranker_plugin_api::attrs::AttrValue;
590 use internal::NodeKind;
591
592 fn strip(src: &str) -> String {
593 String::from_utf8(strip_cfg_test(src.as_bytes()).0).unwrap()
594 }
595
596 #[allow(clippy::too_many_arguments)]
600 fn module_node(
601 id: &str,
602 path: &str,
603 line: Option<u32>,
604 visibility: internal::Visibility,
605 loc: u32,
606 items: u32,
607 unsafe_count: u32,
608 krate: &str,
609 ) -> internal::Node {
610 internal::Node {
611 id: id.into(),
612 kind: NodeKind::Module,
613 name: id.into(),
614 path: path.into(),
615 parent: None,
616 external: None,
617 version: None,
618 visibility: Some(visibility),
619 loc: Some(loc),
620 line,
621 item_count: Some(items),
622 unsafe_count: Some(unsafe_count),
623 crate_label: Some(krate.into()),
624 }
625 }
626
627 #[test]
628 fn collapse_lets_the_file_backed_module_overwrite_structural_attrs() {
629 let mut builder = GraphBuilder::new();
636 builder.add_node(module_node(
637 "inline",
638 "/x/foo.rs",
639 Some(5),
640 internal::Visibility::Private,
641 1,
642 1,
643 0,
644 "wrong-crate",
645 ));
646 builder.add_node(module_node(
647 "file",
648 "/x/foo.rs",
649 None,
650 internal::Visibility::Public,
651 42,
652 7,
653 3,
654 "mycrate",
655 ));
656
657 let graph = collapse_to_files(builder.build());
658
659 let file = graph
660 .nodes
661 .iter()
662 .find(|n| n.id == "/x/foo.rs")
663 .expect("the two modules collapsed into one file node");
664 assert_eq!(file.kind, "file");
665 assert_eq!(
666 file.attrs.get("visibility"),
667 Some(&AttrValue::Str("public".into())),
668 "file-backed visibility wins"
669 );
670 assert_eq!(
671 file.attrs.get("loc"),
672 Some(&AttrValue::Int(42)),
673 "file-backed loc wins"
674 );
675 assert_eq!(
676 file.attrs.get("items"),
677 Some(&AttrValue::Int(7)),
678 "file-backed item count wins"
679 );
680 assert_eq!(
681 file.attrs.get("unsafe"),
682 Some(&AttrValue::Int(3)),
683 "file-backed unsafe count wins (and is non-zero so it is kept)"
684 );
685 assert_eq!(
686 file.attrs.get("crate"),
687 Some(&AttrValue::Str("mycrate".into())),
688 "file-backed crate label wins"
689 );
690 }
691
692 #[test]
693 fn strips_cfg_test_module_with_its_attribute() {
694 let out = strip(
695 "pub fn prod() -> i32 {\n 1\n}\n\n\
696 #[cfg(test)]\nmod tests {\n use super::*;\n #[test]\n fn t() { assert_eq!(prod(), 1); }\n}\n",
697 );
698 assert!(out.contains("pub fn prod"), "production kept: {out}");
699 assert!(!out.contains("mod tests"), "test mod removed: {out}");
700 assert!(
701 !out.contains("#[cfg(test)]"),
702 "the cfg attr line removed too: {out}"
703 );
704 assert!(!out.contains("fn t()"), "test fn removed: {out}");
705 }
706
707 #[test]
708 fn strips_standalone_test_and_bench_fns() {
709 let out = strip("fn prod() {}\n#[test]\nfn it_works() {}\n#[bench]\nfn b(_: &mut ()) {}\n");
710 assert!(out.contains("fn prod"));
711 assert!(
712 !out.contains("it_works") && !out.contains("fn b("),
713 "test/bench fns removed: {out}"
714 );
715 }
716
717 #[test]
718 fn keeps_non_test_cfg_and_similarly_named_items() {
719 let out = strip("#[cfg(feature = \"test\")]\npub mod gated {}\npub mod tests_data {}\n");
722 assert!(out.contains("pub mod gated"), "feature-cfg kept: {out}");
723 assert!(
724 out.contains("tests_data"),
725 "non-gated lookalike kept: {out}"
726 );
727 }
728
729 #[test]
730 fn strips_cfg_all_test_combinations() {
731 let out = strip("fn p() {}\n#[cfg(all(test, feature = \"x\"))]\nmod t {}\n");
732 assert!(out.contains("fn p"));
733 assert!(!out.contains("mod t"), "cfg(all(test,…)) removed: {out}");
734 }
735
736 #[test]
737 fn unchanged_without_tests_or_on_parse_error() {
738 let prod = "pub fn a() {}\n";
739 assert_eq!(
740 strip_cfg_test(prod.as_bytes()),
741 (prod.as_bytes().to_vec(), 0)
742 );
743 let broken = "@@@ not rust @@@";
744 assert_eq!(
745 strip_cfg_test(broken.as_bytes()),
746 (broken.as_bytes().to_vec(), 0)
747 );
748 }
749
750 #[test]
751 fn tloc_counts_the_whole_removed_test_region() {
752 let src = "pub fn p() {}\n#[cfg(test)]\nmod tests {\n fn t() {}\n}\n";
755 let (_prod, tloc) = strip_cfg_test(src.as_bytes());
756 assert_eq!(tloc, 4);
757 }
758
759 fn metric(node: &code_ranker_plugin_api::node::Node, key: &str) -> Option<f64> {
760 match node.attrs.get(key) {
761 Some(code_ranker_plugin_api::attrs::AttrValue::Int(v)) => Some(*v as f64),
762 Some(code_ranker_plugin_api::attrs::AttrValue::Float(v)) => Some(*v),
763 _ => None,
764 }
765 }
766
767 fn metric_of(_path: &str, src: &str, key: &str) -> Option<f64> {
771 let (prod, tloc) = strip_cfg_test(src.as_bytes());
772 let mut m = rust_ts::compute(&prod)?;
773 m.tloc = tloc as f64;
774 let mut node = code_ranker_plugin_api::node::Node {
775 id: "t.rs".into(),
776 kind: "file".into(),
777 name: "t.rs".into(),
778 parent: None,
779 attrs: Default::default(),
780 };
781 code_ranker_graph::write_metrics(&mut node, &m);
782 metric(&node, key)
783 }
784
785 fn rs_src(doc: &str, body_inject: &str) -> String {
799 format!(
800 "{doc}fn f(a: i32, b: i32) -> i32 {{\n\
801 {body_inject} let g = |x: i32| x + 1;\n\
802 if a > 0 {{ return g(b); }}\n\
803 a + b\n\
804 }}\n"
805 )
806 }
807
808 const RUST_TRIGGERS: &[&str] = &[
816 "if", "else", "match", "while", "for", "loop", "return", "unsafe", "&&", "||", "?",
817 ];
818
819 #[test]
820 fn rust_complexity_fp_matrix() {
821 let base = rs_src("", "");
824 let kw = RUST_TRIGGERS.join(" ");
825 let positions: &[(&str, String)] = &[
826 (
827 "line comment",
828 rs_src("", &format!(" // {kw} && || ?\n")),
829 ),
830 (
831 "block comment",
832 rs_src("", &format!(" /* {kw} && || ? */\n")),
833 ),
834 ("doc comment", rs_src(&format!("/// {kw}\n"), "")),
835 (
836 "string",
837 rs_src("", &format!(" let _s = \"{kw} && || ?\";\n")),
838 ),
839 (
840 "raw string",
841 rs_src("", &format!(" let _r = r#\"{kw} && ||\"#;\n")),
842 ),
843 (
844 "identifier",
845 rs_src(
846 "",
847 " let if_match_return_loop = 0; let _ = if_match_return_loop;\n",
848 ),
849 ),
850 (
851 "format string",
852 rs_src("", " let _f = format!(\"if {} while\", a);\n"),
853 ),
854 (
855 "macro body",
856 rs_src("", " let _m = vec![\"if\", \"match\", \"while\"];\n"),
857 ),
858 (
859 "raw identifier",
860 rs_src("", " let r#match = 1; let _ = r#match;\n"),
861 ),
862 ];
863 for key in ["cyclomatic", "cognitive", "exits", "args", "closures"] {
864 let want = metric_of("t.rs", &base, key);
865 for (pos, src) in positions {
866 assert_eq!(
867 metric_of("t.rs", src, key),
868 want,
869 "metric `{key}` moved when a keyword appeared only in: {pos}"
870 );
871 }
872 }
873 }
874
875 #[test]
876 fn cyclomatic_counts_every_branch_form() {
877 let baseline =
881 metric_of("t.rs", "fn f() -> i32 { 0 }\n", "cyclomatic").expect("baseline cyclomatic");
882 let forms: &[(&str, &str)] = &[
883 ("if", "fn f(a: i32) -> i32 { if a > 0 { 1 } else { 2 } }\n"),
884 (
885 "else-if",
886 "fn f(a: i32) -> i32 { if a > 0 { 1 } else if a < 0 { 2 } else { 3 } }\n",
887 ),
888 (
889 "match",
890 "fn f(a: i32) -> i32 { match a { 0 => 1, _ => 2 } }\n",
891 ),
892 (
893 "while",
894 "fn f(mut a: i32) -> i32 { while a > 0 { a -= 1; } a }\n",
895 ),
896 (
897 "for",
898 "fn f(a: i32) -> i32 { let mut s = 0; for i in 0..a { s += i; } s }\n",
899 ),
900 ("loop", "fn f() -> i32 { loop { break; } 0 }\n"),
901 (
902 "&&",
903 "fn f(a: i32, b: i32) -> i32 { let _ = a > 0 && b > 0; 0 }\n",
904 ),
905 (
906 "||",
907 "fn f(a: i32, b: i32) -> i32 { let _ = a > 0 || b > 0; 0 }\n",
908 ),
909 ("?", "fn f() -> Option<i32> { let x = Some(1)?; Some(x) }\n"),
910 (
911 "if let",
912 "fn f() -> i32 { if let Some(x) = Some(1) { x } else { 0 } }\n",
913 ),
914 (
915 "while let",
916 "fn f() -> i32 { let mut it = [1].into_iter(); let mut n = 0; while let Some(_) = it.next() { n += 1; } n }\n",
917 ),
918 ];
919 for (name, src) in forms {
920 let c = metric_of("t.rs", src, "cyclomatic")
921 .unwrap_or_else(|| panic!("cyclomatic missing for `{name}`"));
922 assert!(
923 c > baseline,
924 "branch form `{name}` not counted (cyclomatic {c} <= baseline {baseline})"
925 );
926 }
927 let one = metric_of(
929 "t.rs",
930 "fn f(a: i32) -> i32 { if a > 0 { 1 } else { 2 } }\n",
931 "cyclomatic",
932 )
933 .unwrap();
934 let two = metric_of(
935 "t.rs",
936 "fn f(a: i32) -> i32 { if a > 0 { 1 } else if a < 0 { 2 } else { 3 } }\n",
937 "cyclomatic",
938 )
939 .unwrap();
940 assert_eq!(two - one, 1.0, "one extra real `if` must add exactly 1");
941 }
942
943 #[test]
944 fn rust_complexity_fn_per_metric() {
945 let cognitive = metric_of(
948 "t.rs",
949 "fn f(a: i32, b: i32) -> i32 { if a > 0 { if b > 0 { 1 } else { 2 } } else { 3 } }\n",
950 "cognitive",
951 )
952 .expect("cognitive present");
953 assert!(cognitive > 0.0, "nested branches must raise cognitive");
954
955 let exits = metric_of("t.rs", "fn f(a: i32) -> i32 { return a; }\n", "exits")
956 .expect("exits present");
957 assert!(exits >= 1.0, "a real `return` must be counted as an exit");
958
959 let args = metric_of(
960 "t.rs",
961 "fn f(a: i32, b: i32, c: i32) -> i32 { a + b + c }\n",
962 "args",
963 )
964 .expect("args present");
965 assert!(
966 args >= 3.0,
967 "three parameters must count as >=3 args, got {args}"
968 );
969
970 let closures = metric_of(
971 "t.rs",
972 "fn f() -> i32 { let g = |x: i32| x + 1; g(1) }\n",
973 "closures",
974 )
975 .expect("closures present");
976 assert!(closures >= 1.0, "a real closure must be counted");
977 }
978
979 #[test]
980 fn rust_only_complexity_fp_matrix() {
981 let check = |path: &str, base: &str, traps: &[String]| {
984 for key in ["cyclomatic", "cognitive"] {
985 let want = metric_of(path, base, key);
986 for trap in traps {
987 assert_eq!(
988 metric_of(path, trap, key),
989 want,
990 "{path} metric `{key}` moved on a keyword look-alike"
991 );
992 }
993 }
994 };
995
996 let kw = RUST_TRIGGERS.join(" ");
997 let base = "fn f(a: i32) -> i32 { if a > 0 { 1 } else { 2 } }\n";
998 check(
999 "t.rs",
1000 base,
1001 &[
1002 format!("// {kw}\n{base}"),
1003 format!(
1004 "fn f(a: i32) -> i32 {{ let _ = \"{kw}\"; if a > 0 {{ 1 }} else {{ 2 }} }}\n"
1005 ),
1006 ],
1007 );
1008 }
1009
1010 #[test]
1011 fn rust_trigger_set_documented_in_spec() {
1012 let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../..");
1016 let path = format!("{root}/principles/rust/metrics.md");
1017 let spec = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
1018 for kw in RUST_TRIGGERS {
1019 assert!(
1020 spec.contains(&format!("`{kw}`")),
1021 "trigger `{kw}` is not documented in principles/rust/metrics.md — spec and FP test drifted"
1022 );
1023 }
1024 }
1025
1026 fn gen_rs(branches: usize, noise: usize) -> String {
1037 let mut body = String::new();
1038 for i in 0..noise {
1039 body.push_str(&format!(
1040 " // if match while for loop return && || ? noise {i}\n"
1041 ));
1042 body.push_str(&format!(
1043 " let _n{i} = \"if match while return && ||\";\n"
1044 ));
1045 }
1046 for i in 0..branches {
1047 body.push_str(&format!(" if x > {i} {{ let _ = {i}; }}\n"));
1048 }
1049 format!("fn f(x: i32) -> i32 {{\n{body} 0\n}}\n")
1050 }
1051
1052 #[test]
1053 fn generative_cyclomatic_counts_branches_not_noise() {
1054 for noise in 0..8 {
1058 let base =
1059 metric_of("t.rs", &gen_rs(0, noise), "cyclomatic").expect("cyclomatic present");
1060 for branches in 0..8 {
1061 let cyc = metric_of("t.rs", &gen_rs(branches, noise), "cyclomatic")
1062 .expect("cyclomatic present");
1063 assert_eq!(
1064 cyc,
1065 base + branches as f64,
1066 "cyclomatic must add exactly 1 per real `if` and 0 per noise line \
1067 (branches={branches}, noise={noise})"
1068 );
1069 }
1070 }
1071 }
1072
1073 #[test]
1074 fn generative_complexity_invariant_to_noise() {
1075 let mk = |noise: usize| -> String {
1080 let mut body = String::new();
1081 for i in 0..noise {
1082 body.push_str(&format!(" // if match return unsafe && || {i}\n"));
1083 body.push_str(&format!(" let _n{i} = \"if match return && ||\";\n"));
1084 }
1085 format!(
1086 "fn f(a: i32, b: i32) -> i32 {{\n\
1087 {body} let g = |x: i32| x + 1;\n\
1088 if a > 0 {{ return g(b); }}\n\
1089 a + b\n\
1090 }}\n"
1091 )
1092 };
1093 for key in ["cyclomatic", "cognitive", "exits", "args", "closures"] {
1094 let want = metric_of("t.rs", &mk(0), key);
1095 for noise in 1..10 {
1096 assert_eq!(
1097 metric_of("t.rs", &mk(noise), key),
1098 want,
1099 "metric `{key}` moved at noise={noise} — keyword look-alikes leaked in"
1100 );
1101 }
1102 }
1103 }
1104
1105 #[test]
1106 fn per_function_metrics_aggregate_over_child_functions() {
1107 let src = "fn a(x: i32, y: i32) -> i32 { if x > 0 { if x > 1 { return x; } y } else { 3 } }\n\
1118 fn b() -> i32 { let f = |z: i32| z + 1; f(2) }\n";
1119 let cyc = metric_of("t.rs", src, "cyclomatic").expect("cyclomatic present");
1122 assert!(cyc > 1.0, "cyclomatic should be summed, got {cyc}");
1123 let cog = metric_of("t.rs", src, "cognitive").expect("cognitive present");
1124 assert!(cog > 0.0, "cognitive should be summed, got {cog}");
1125 let exits = metric_of("t.rs", src, "exits").expect("exits present");
1126 assert!(exits >= 1.0, "exits should count the `return`, got {exits}");
1127 let args = metric_of("t.rs", src, "args").expect("args present");
1128 assert!(
1129 args >= 3.0,
1130 "args should sum fn (2) + closure (1), got {args}"
1131 );
1132 let closures = metric_of("t.rs", src, "closures").expect("closures present");
1133 assert!(
1134 closures >= 1.0,
1135 "closures should count the closure, got {closures}"
1136 );
1137 }
1138
1139 #[test]
1147 fn complexity_absolute_anchors_hand_derived() {
1148 let classify = "fn classify(n: i32) -> &'static str {\n\
1164 \x20 if n < 0 { \"neg\" } else if n == 0 || n == 1 { \"small\" } else { \"big\" }\n\
1165 }\n";
1166 let two_closures =
1167 "fn f() { let g = |x: i32| x + 1; let h = |y: i32| y; let _ = (g, h); }\n";
1168 let cases: &[(&str, &str, &str, &str, f64)] = &[
1170 ("classify", "t.rs", classify, "cyclomatic", 5.0),
1172 (
1174 "single if",
1175 "t.rs",
1176 "fn f(a: i32) -> i32 { if a > 0 { 1 } else { 2 } }\n",
1177 "cyclomatic",
1178 3.0,
1179 ),
1180 (
1182 "one return",
1183 "t.rs",
1184 "fn f() -> i32 { return 1; }\n",
1185 "exits",
1186 2.0,
1187 ),
1188 (
1190 "one try op",
1191 "t.rs",
1192 "fn f() -> Option<i32> { let x = Some(1)?; Some(x) }\n",
1193 "exits",
1194 2.0,
1195 ),
1196 (
1197 "three params",
1198 "t.rs",
1199 "fn f(a: i32, b: i32, c: i32) -> i32 { a + b + c }\n",
1200 "args",
1201 3.0,
1202 ),
1203 ("two closures", "t.rs", two_closures, "closures", 2.0),
1204 ("two closure args", "t.rs", two_closures, "args", 2.0),
1205 ];
1206 let mut fails = Vec::new();
1207 for (label, path, src, key, want) in cases {
1208 match metric_of(path, src, key) {
1209 Some(got) if got == *want => {}
1210 other => fails.push(format!("{label}: {key} want {want}, got {other:?}")),
1211 }
1212 }
1213 assert!(
1214 fails.is_empty(),
1215 "failing integer anchors:\n{}",
1216 fails.join("\n")
1217 );
1218 }
1219
1220 #[test]
1221 fn complexity_frozen_scale_anchors() {
1222 let classify = "fn classify(n: i32) -> &'static str {\n\
1230 \x20 if n < 0 { \"neg\" } else if n == 0 || n == 1 { \"small\" } else { \"big\" }\n\
1231 }\n";
1232 let cases: &[(&str, f64, f64)] = &[
1234 ("cognitive", 4.0, 0.0), ("vocabulary", 18.0, 0.0), ("length", 28.0, 0.0), ("volume", 116.757, 0.01), ("effort", 875.684, 0.01), ("mi", 127.299, 0.01), ("mi_sei", 108.463, 0.01), ];
1242 let mut fails = Vec::new();
1243 for (key, want, tol) in cases {
1244 match metric_of("t.rs", classify, key) {
1245 Some(got) if (got - *want).abs() <= *tol => {}
1246 other => fails.push(format!("{key}: want {want} (±{tol}), got {other:?}")),
1247 }
1248 }
1249 assert!(
1250 fails.is_empty(),
1251 "failing scale anchors:\n{}",
1252 fails.join("\n")
1253 );
1254 }
1255
1256 #[test]
1257 fn declaration_only_file_emits_no_complexity() {
1258 let src = "pub struct Cli { pub verbose: bool }\n\
1263 pub enum Mode { A, B }\n";
1264 assert_eq!(
1265 metric_of("t.rs", src, "cyclomatic"),
1266 None,
1267 "a declaration-only file must not emit a vacuous cyclomatic"
1268 );
1269 assert_eq!(
1270 metric_of("t.rs", src, "cognitive"),
1271 None,
1272 "a declaration-only file must not emit cognitive"
1273 );
1274 }
1275
1276 #[test]
1277 fn metric_specs_override_adds_rust_cfg_test_note() {
1278 let defaults = code_ranker_graph::metric_specs().0;
1282 assert!(
1284 !defaults["blank"]
1285 .description
1286 .as_deref()
1287 .unwrap_or("")
1288 .contains("#[cfg(test)]"),
1289 "the shared default must stay language-neutral"
1290 );
1291
1292 let refined = RustPlugin.metric_specs(defaults);
1293 for key in ["sloc", "lloc", "cloc", "blank"] {
1294 let desc = refined[key].description.as_deref().unwrap_or("");
1295 assert!(
1296 desc.contains("#[cfg(test)]"),
1297 "Rust `{key}` description should note the cfg(test) exclusion"
1298 );
1299 }
1300 }
1301}