Skip to main content

code_ranker_graph/
metrics.rs

1//! Language-neutral complexity-metric scaffolding: the computed-value carrier
2//! [`FileMetrics`], the writer [`write_metrics`] (omit-at + LOC/Halstead gating),
3//! the no-signal values [`metric_omit_at`], and the metric attribute catalog
4//! [`metric_specs`]. The sibling [`coupling_specs`](crate::coupling_specs) lives
5//! alongside it; both are merged into the snapshot's node-attribute dictionary.
6//!
7//! The per-language **engines** that produce a `FileMetrics` live in the language
8//! crates — `rust_ts` in `code-ranker-plugin-rust`, `python_ts` in
9//! `code-ranker-plugin-python`, `ecmascript_ts` in `code-ranker-ecmascript-core`.
10//! Each plugin computes a `FileMetrics` with its own engine and calls
11//! [`write_metrics`] here. This module names no language and pulls in no grammar.
12
13use crate::attrs::num_attr;
14use code_ranker_plugin_api::{
15    attrs::ValueType,
16    level::{AttributeGroup, AttributeSpec, Direction, SpecRow, attr_dict, group},
17    node::Node,
18};
19use std::collections::BTreeMap;
20
21/// The per-file complexity metric values a language engine computes, in the
22/// canonical key set this crate writes. Engines fill the fields they support and
23/// leave the rest at `0.0` (e.g. `tloc` is non-zero only for Rust, where inline
24/// `#[cfg(test)]` items are stripped). [`write_metrics`] turns this into node
25/// attributes, applying each key's `omit_at` and the LOC / Halstead gating.
26#[derive(Debug, Clone, Default, PartialEq)]
27pub struct FileMetrics {
28    pub cyclomatic: f64,
29    pub cognitive: f64,
30    pub exits: f64,
31    pub args: f64,
32    pub closures: f64,
33    pub mi: f64,
34    pub mi_sei: f64,
35    pub sloc: f64,
36    pub lloc: f64,
37    pub cloc: f64,
38    pub blank: f64,
39    /// Test lines (Rust only: lines removed with `#[cfg(test)]`/`#[test]`/`#[bench]`).
40    pub tloc: f64,
41    pub length: f64,
42    pub vocabulary: f64,
43    pub volume: f64,
44    pub effort: f64,
45    pub time: f64,
46    pub bugs: f64,
47}
48
49/// Write the metric attributes for one file node from a computed [`FileMetrics`].
50/// Each value is dropped at its `omit_at` ([`metric_omit_at`]); the LOC block is
51/// gated on `sloc > 0` and the Halstead block on `volume > 0`. The same omit
52/// values are published on the specs ([`metric_specs`]), so emission and the
53/// declared spec never drift. Called by each language plugin after its engine
54/// produces the values.
55pub fn write_metrics(node: &mut Node, m: &FileMetrics) {
56    let mut put = |key: &str, v: f64| {
57        let a = num_attr(v);
58        if a == num_attr(metric_omit_at(key)) {
59            node.attrs.remove(key);
60        } else {
61            node.attrs.insert(key.to_string(), a);
62        }
63    };
64    put("cyclomatic", m.cyclomatic);
65    put("cognitive", m.cognitive);
66    put("exits", m.exits);
67    put("args", m.args);
68    put("closures", m.closures);
69    put("mi", m.mi);
70    put("mi_sei", m.mi_sei);
71    if m.sloc > 0.0 {
72        put("sloc", m.sloc);
73        put("lloc", m.lloc);
74        put("cloc", m.cloc);
75        put("blank", m.blank);
76    }
77    put("tloc", m.tloc);
78    if m.volume > 0.0 {
79        put("length", m.length);
80        put("vocabulary", m.vocabulary);
81        put("volume", m.volume);
82        put("effort", m.effort);
83        put("time", m.time);
84        put("bugs", m.bugs);
85    }
86}
87
88/// The value at which a per-file metric carries no signal and is **omitted** from
89/// output (see [`code_ranker_plugin_api::level::AttributeSpec::omit_at`]). `0` for
90/// almost everything; `1` for `cyclomatic` — the analyzer gives the file unit a
91/// McCabe base path of `1`, so a function-less file reports a vacuous `1` that
92/// carries no signal and must be dropped. The per-language writers gate emission
93/// on this value and [`metric_specs`] publishes the same value on each spec, so
94/// the two never drift.
95fn metric_omit_at(key: &str) -> f64 {
96    match key {
97        "cyclomatic" => 1.0,
98        _ => 0.0,
99    }
100}
101
102/// The complexity metric attribute dictionary and its groups, fully enriched
103/// (label/name/short/description/formula/calc/direction) so the UI hardcodes no
104/// metric. The orchestrator merges these into each level's `node_attributes` /
105/// `attribute_groups` (then prunes to keys actually present) and overlays
106/// language thresholds. Coupling/cycle specs live in `code-ranker-graph`.
107pub fn metric_specs() -> (
108    BTreeMap<String, AttributeSpec>,
109    BTreeMap<String, AttributeGroup>,
110) {
111    use Direction::{HigherBetter, LowerBetter};
112    use ValueType::Float;
113    let mut specs = attr_dict(vec![
114        (
115            "cyclomatic",
116            SpecRow {
117                group: "complexity",
118                label: "Cyclomatic",
119                name: "Cyclomatic complexity",
120                short: "Cyclomatic",
121                description: "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.<br>A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.<br>Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.<br>Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.",
122                formula: "Σ (branches + 1) over functions",
123                direction: LowerBetter,
124                ..Default::default()
125            },
126        ),
127        (
128            "cognitive",
129            SpecRow {
130                group: "complexity",
131                label: "Cognitive",
132                name: "Cognitive complexity",
133                short: "Cognitive",
134                description: "How hard the code is for a human to follow — not just how many paths it has.<br>Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.<br>That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.<br>Summed across every function in the file.",
135                direction: LowerBetter,
136                ..Default::default()
137            },
138        ),
139        (
140            "exits",
141            SpecRow {
142                group: "complexity",
143                label: "Exits",
144                name: "Exit points",
145                short: "Exits",
146                description: "Number of exit points (return/throw) in the unit.",
147                direction: LowerBetter,
148                ..Default::default()
149            },
150        ),
151        (
152            "args",
153            SpecRow {
154                group: "complexity",
155                label: "Args",
156                name: "Arguments",
157                short: "Args",
158                description: "Number of function / closure arguments.",
159                direction: LowerBetter,
160                ..Default::default()
161            },
162        ),
163        (
164            "closures",
165            SpecRow {
166                group: "complexity",
167                label: "Closures",
168                name: "Closures",
169                short: "Closures",
170                description: "Number of closures defined in the unit.",
171                direction: LowerBetter,
172                ..Default::default()
173            },
174        ),
175        (
176            "mi",
177            SpecRow {
178                group: "maintainability",
179                value_type: Float,
180                label: "MI",
181                name: "Maintainability index",
182                short: "MI",
183                description: "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.",
184                formula: "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(sloc)",
185                direction: HigherBetter,
186                ..Default::default()
187            },
188        ),
189        (
190            "mi_sei",
191            SpecRow {
192                group: "maintainability",
193                value_type: Float,
194                label: "MI (SEI)",
195                name: "Maintainability (SEI)",
196                short: "MI SEI",
197                description: "SEI variant of the Maintainability Index — adds a bonus for comment density.",
198                formula: "MI + 50·sin(√(2.4 × comment-ratio))",
199                direction: HigherBetter,
200                ..Default::default()
201            },
202        ),
203        (
204            "sloc",
205            SpecRow {
206                group: "loc",
207                label: "Source",
208                name: "Source lines",
209                short: "SLOC",
210                description: "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).",
211                ..Default::default()
212            },
213        ),
214        (
215            "lloc",
216            SpecRow {
217                group: "loc",
218                label: "Logical",
219                name: "Logical lines",
220                short: "Logical",
221                description: "Logical lines — counts statements, not physical lines.",
222                ..Default::default()
223            },
224        ),
225        (
226            "cloc",
227            SpecRow {
228                group: "loc",
229                label: "Comments",
230                name: "Comment lines",
231                short: "Comments",
232                description: "Comment-only lines (inline comments on code lines are not counted).",
233                ..Default::default()
234            },
235        ),
236        (
237            "blank",
238            SpecRow {
239                group: "loc",
240                label: "Blank",
241                name: "Blank lines",
242                short: "Blank",
243                description: "Empty or whitespace-only lines.",
244                ..Default::default()
245            },
246        ),
247        (
248            "tloc",
249            SpecRow {
250                group: "loc",
251                label: "Test",
252                name: "Test lines",
253                short: "TLOC",
254                description: "Test lines of code — the lines inside `#[cfg(test)]` / `#[test]` / `#[bench]` items (Rust), removed before the production metrics are measured. The complement of `sloc`: test code never inflates a file's size, HK, or complexity.",
255                ..Default::default()
256            },
257        ),
258        (
259            "length",
260            SpecRow {
261                group: "halstead",
262                value_type: Float,
263                label: "Length",
264                name: "Halstead length",
265                short: "H.len",
266                description: "Program length — total operator + operand occurrences.",
267                formula: "N₁ + N₂",
268                direction: LowerBetter,
269                ..Default::default()
270            },
271        ),
272        (
273            "vocabulary",
274            SpecRow {
275                group: "halstead",
276                value_type: Float,
277                label: "Vocabulary",
278                name: "Halstead vocabulary",
279                short: "H.vocab",
280                description: "Vocabulary — distinct operators + operands.",
281                formula: "η₁ + η₂",
282                direction: LowerBetter,
283                ..Default::default()
284            },
285        ),
286        (
287            "volume",
288            SpecRow {
289                group: "halstead",
290                value_type: Float,
291                label: "Volume",
292                name: "Halstead volume",
293                short: "H.vol",
294                description: "Algorithm size in bits, from distinct operators and operands.",
295                formula: "length × log₂(vocabulary)",
296                calc: "length * Math.log2(vocabulary)",
297                direction: LowerBetter,
298                ..Default::default()
299            },
300        ),
301        (
302            "effort",
303            SpecRow {
304                group: "halstead",
305                value_type: Float,
306                label: "Effort",
307                name: "Halstead effort",
308                short: "H.effort",
309                description: "Mental effort to implement the algorithm.",
310                formula: "volume × difficulty",
311                direction: LowerBetter,
312                ..Default::default()
313            },
314        ),
315        (
316            "time",
317            SpecRow {
318                group: "halstead",
319                value_type: Float,
320                label: "Time",
321                name: "Halstead time, s",
322                short: "H.time(s)",
323                description: "Estimated implementation time, in seconds.",
324                formula: "effort ÷ 18",
325                calc: "effort / 18",
326                direction: LowerBetter,
327                ..Default::default()
328            },
329        ),
330        (
331            "bugs",
332            SpecRow {
333                group: "halstead",
334                value_type: Float,
335                label: "Bugs",
336                name: "Halstead bugs",
337                short: "H.bugs",
338                description: "Estimated delivered bugs — a rough predictor of defect density.",
339                formula: "effort^⅔ ÷ 3000",
340                calc: "effort ** (2/3) / 3000",
341                direction: LowerBetter,
342                ..Default::default()
343            },
344        ),
345    ]);
346    // Publish each metric's no-signal value on its spec, from the same
347    // `metric_omit_at` the writers gate on — so the emitted JSON and the declared
348    // spec agree.
349    for (key, spec) in specs.iter_mut() {
350        spec.omit_at = metric_omit_at(key);
351    }
352    let mut groups = BTreeMap::new();
353    groups.insert(
354        "complexity".to_string(),
355        group("Complexity", "Code complexity metrics"),
356    );
357    groups.insert(
358        "halstead".to_string(),
359        group("Halstead", "Halstead software metrics"),
360    );
361    groups.insert(
362        "loc".to_string(),
363        group("Lines of Code", "Lines of code breakdown"),
364    );
365    groups.insert(
366        "maintainability".to_string(),
367        group("Maintainability", "Maintainability index"),
368    );
369    (specs, groups)
370}