Skip to main content

slopguard_core/
metric.rs

1//! File-level structural metrics.
2//!
3//! An AST rule matches nodes; a metric rule measures a property of the whole
4//! file and fires once when the measured value exceeds a threshold. Metrics are
5//! computed directly on the parsed tree rather than through ast-grep, which has
6//! no notion of a whole-file match.
7
8use std::collections::HashSet;
9
10use ast_grep_core::{AstGrep, Doc};
11use serde::{Deserialize, Serialize};
12use strum::Display;
13
14use crate::rule::Language;
15use crate::test_filter::CfgTestRanges;
16
17/// A structural property measured over a whole file.
18#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20#[strum(serialize_all = "snake_case")]
21pub enum Metric {
22    FileLines,
23    CodeLines,
24    ImportCount,
25    FunctionCount,
26    CommentRatio,
27}
28
29/// Tree-sitter node kinds that represent an import in each language.
30fn import_kinds(lang: &Language) -> &'static [&'static str] {
31    match lang {
32        Language::Rust => &["use_declaration", "extern_crate_declaration"],
33        Language::TypeScript => &["import_statement"],
34    }
35}
36
37/// Tree-sitter node kinds that represent a function definition in each
38/// language. Methods and nested functions count in both languages; TypeScript
39/// arrow functions and function expressions count, Rust closures do not.
40fn function_kinds(lang: &Language) -> &'static [&'static str] {
41    match lang {
42        Language::Rust => &["function_item"],
43        Language::TypeScript => &[
44            "function_declaration",
45            "generator_function_declaration",
46            "function_expression",
47            "arrow_function",
48            "method_definition",
49        ],
50    }
51}
52
53/// Tree-sitter node kinds that represent a comment in each language.
54fn comment_kinds(lang: &Language) -> &'static [&'static str] {
55    match lang {
56        Language::Rust => &["line_comment", "block_comment", "doc_comment"],
57        Language::TypeScript => &["comment"],
58    }
59}
60
61/// Measure `metric` over an already parsed file.
62///
63/// When `exclude_tests` is `Some`, lines and nodes inside `#[cfg(test)]` ranges
64/// do not count, so a production file's inline test module does not inflate the
65/// measurement. When it is `None`, everything counts.
66pub fn compute<D: Doc>(
67    metric: Metric,
68    lang: &Language,
69    root: &AstGrep<D>,
70    source: &str,
71    exclude_tests: Option<&CfgTestRanges>,
72) -> f64 {
73    match metric {
74        Metric::FileLines => count_lines(source, exclude_tests) as f64,
75        Metric::CodeLines => {
76            count_code_lines(root, comment_kinds(lang), source, exclude_tests) as f64
77        }
78        Metric::ImportCount => count_kinds(root, import_kinds(lang), exclude_tests) as f64,
79        Metric::FunctionCount => count_kinds(root, function_kinds(lang), exclude_tests) as f64,
80        Metric::CommentRatio => {
81            let total_lines = count_lines(source, exclude_tests);
82            if total_lines == 0 {
83                return 0.0;
84            }
85            comment_lines(root, comment_kinds(lang), exclude_tests) as f64 / total_lines as f64
86        }
87    }
88}
89
90/// Whether a 0-indexed tree-sitter line falls inside a `#[cfg(test)]` range.
91/// `CfgTestRanges` is 1-indexed, so the line is shifted before the lookup.
92fn in_test(exclude_tests: Option<&CfgTestRanges>, zero_indexed_line: usize) -> bool {
93    exclude_tests.is_some_and(|r| r.contains_line(zero_indexed_line + 1))
94}
95
96/// Count source lines, optionally dropping those inside `#[cfg(test)]` ranges.
97///
98/// Lines are counted with [`str::lines`], which treats a trailing newline as a
99/// terminator: `"a\n"` is one line and `""` is zero lines.
100fn count_lines(source: &str, exclude_tests: Option<&CfgTestRanges>) -> usize {
101    source
102        .lines()
103        .enumerate()
104        .filter(|(i, _)| !in_test(exclude_tests, *i))
105        .count()
106}
107
108/// Count lines that hold code, optionally dropping those inside `#[cfg(test)]`
109/// ranges.
110///
111/// Blank lines and lines holding nothing but comments (doc comments included)
112/// do not count; a line of code with a trailing comment does. Comments are
113/// found on the tree, not by text, so `//` inside a string literal stays code.
114/// Line numbering matches [`count_lines`].
115fn count_code_lines<D: Doc>(
116    root: &AstGrep<D>,
117    kinds: &[&str],
118    source: &str,
119    exclude_tests: Option<&CfgTestRanges>,
120) -> usize {
121    let mut code = source.as_bytes().to_vec();
122    for node in root.root().dfs() {
123        if !kinds.contains(&node.kind().as_ref()) {
124            continue;
125        }
126        if let Some(comment) = code.get_mut(node.range()) {
127            for byte in comment.iter_mut().filter(|b| **b != b'\n') {
128                *byte = b' ';
129            }
130        }
131    }
132    code.split(|b| *b == b'\n')
133        .enumerate()
134        .filter(|(i, line)| {
135            !in_test(exclude_tests, *i) && line.iter().any(|b| !b.is_ascii_whitespace())
136        })
137        .count()
138}
139
140fn count_kinds<D: Doc>(
141    root: &AstGrep<D>,
142    kinds: &[&str],
143    exclude_tests: Option<&CfgTestRanges>,
144) -> usize {
145    let mut count = 0;
146    for node in root.root().dfs() {
147        if kinds.contains(&node.kind().as_ref()) && !in_test(exclude_tests, node.start_pos().line())
148        {
149            count += 1;
150        }
151    }
152    count
153}
154
155/// Distinct 0-indexed source lines touched by a *narration* comment. A block
156/// comment spanning five lines counts as five; a line reached twice counts once.
157///
158/// Doc comments (`///`, `//!`, `/** */`) do not count. They are API
159/// documentation, not line-by-line narration, and this project (like most)
160/// requires them on public items, so counting them would flag well-documented
161/// files. Rust marks them with a nested `doc_comment` node; TypeScript has no
162/// such node, so a `/** */` JSDoc block is recognised by its opening marker.
163fn comment_lines<D: Doc>(
164    root: &AstGrep<D>,
165    kinds: &[&str],
166    exclude_tests: Option<&CfgTestRanges>,
167) -> usize {
168    let mut lines = HashSet::new();
169    let mut doc_lines = HashSet::new();
170    for node in root.root().dfs() {
171        let kind = node.kind();
172        if kind.as_ref() == "doc_comment" {
173            doc_lines.extend(node.start_pos().line()..=node.end_pos().line());
174            continue;
175        }
176        if !kinds.contains(&kind.as_ref()) {
177            continue;
178        }
179        let range = node.start_pos().line()..=node.end_pos().line();
180        if node.text().trim_start().starts_with("/**") {
181            doc_lines.extend(range);
182        } else {
183            lines.extend(range);
184        }
185    }
186    lines
187        .into_iter()
188        .filter(|line| !doc_lines.contains(line) && !in_test(exclude_tests, *line))
189        .count()
190}
191
192impl Metric {
193    /// Rendering of a measured value for `$value` substitution: an integer for
194    /// counts, two decimals for ratios.
195    pub fn format_value(&self, value: f64) -> String {
196        match self {
197            Metric::CommentRatio => format!("{value:.2}"),
198            _ => (value as u64).to_string(),
199        }
200    }
201
202    /// The `matched_text` of a metric finding: the value plus its unit, e.g.
203    /// "547 lines", "42 imports", "0.62 comment ratio".
204    pub fn describe(&self, value: f64) -> String {
205        let rendered = self.format_value(value);
206        match self {
207            Metric::FileLines => format!("{rendered} lines"),
208            Metric::CodeLines => format!("{rendered} lines of code"),
209            Metric::ImportCount => format!("{rendered} imports"),
210            Metric::FunctionCount => format!("{rendered} functions"),
211            Metric::CommentRatio => format!("{rendered} comment ratio"),
212        }
213    }
214}
215
216/// A metric rule fires when the measured value is strictly above the threshold,
217/// so `threshold: 500` does not flag a file of exactly 500 lines.
218pub fn exceeds(value: f64, threshold: f64) -> bool {
219    value > threshold
220}
221
222#[cfg(test)]
223mod tests {
224    use ast_grep_core::tree_sitter::LanguageExt;
225    use ast_grep_language::SupportLang;
226
227    use super::*;
228
229    fn rust(metric: Metric, source: &str) -> f64 {
230        let root = SupportLang::Rust.ast_grep(source);
231        compute(metric, &Language::Rust, &root, source, None)
232    }
233
234    fn typescript(metric: Metric, source: &str) -> f64 {
235        let root = SupportLang::TypeScript.ast_grep(source);
236        compute(metric, &Language::TypeScript, &root, source, None)
237    }
238
239    fn cfg_ranges(source: &str) -> CfgTestRanges {
240        CfgTestRanges::from_root(&SupportLang::Rust.ast_grep(source))
241    }
242
243    fn rust_no_tests(metric: Metric, source: &str) -> f64 {
244        let root = SupportLang::Rust.ast_grep(source);
245        compute(
246            metric,
247            &Language::Rust,
248            &root,
249            source,
250            Some(&cfg_ranges(source)),
251        )
252    }
253
254    #[test]
255    fn file_lines_counts_lines() {
256        let source = "fn a() {}\nfn b() {}\nfn c() {}\n";
257        assert_eq!(rust(Metric::FileLines, source), 3.0);
258        assert_eq!(
259            rust(Metric::FileLines, "fn a() {}\n"),
260            1.0,
261            "a trailing newline terminates the last line"
262        );
263    }
264
265    #[test]
266    fn file_lines_empty_file_is_zero() {
267        assert_eq!(rust(Metric::FileLines, ""), 0.0);
268    }
269
270    #[test]
271    fn code_lines_skips_blank_and_comment_only_lines() {
272        let source = "\
273//! module doc
274use std::fmt;
275
276/// doc for a
277/// still doc
278fn a() {} // trailing comment keeps the line
279/* block
280   comment */
281// narration
282fn b() {
283    let url = \"https://example.com\"; /* inline */ let x = 1;
284}
285";
286        assert_eq!(rust(Metric::FileLines, source), 12.0);
287        // Code lines: `use`, `fn a`, `fn b() {`, the `let` line, `}`.
288        assert_eq!(rust(Metric::CodeLines, source), 5.0);
289    }
290
291    #[test]
292    fn code_lines_typescript() {
293        let source = "\
294/** jsdoc
295 * more jsdoc
296 */
297// narration
298export function f(): string {
299  return \"// not a comment\";
300}
301";
302        // `//` inside a string literal is code: 3 code lines.
303        assert_eq!(typescript(Metric::CodeLines, source), 3.0);
304    }
305
306    #[test]
307    fn code_lines_excludes_cfg_test() {
308        let source = "\
309fn prod() {}
310
311#[cfg(test)]
312mod tests {
313    // test comment
314    fn t() {}
315}
316";
317        assert_eq!(rust(Metric::CodeLines, source), 5.0);
318        assert_eq!(rust_no_tests(Metric::CodeLines, source), 1.0);
319    }
320
321    #[test]
322    fn code_lines_empty_or_comment_only_file_is_zero() {
323        assert_eq!(rust(Metric::CodeLines, ""), 0.0);
324        assert_eq!(rust(Metric::CodeLines, "// only\n\n/// docs\n"), 0.0);
325    }
326
327    #[test]
328    fn import_count_rust() {
329        let source = "\
330use std::fmt;
331use std::io::Write;
332extern crate serde;
333
334fn f() {
335    use std::collections::HashMap;
336    let _: HashMap<u8, u8> = HashMap::new();
337}
338";
339        assert_eq!(
340            rust(Metric::ImportCount, source),
341            4.0,
342            "a nested `use` inside a function still counts"
343        );
344    }
345
346    #[test]
347    fn import_count_typescript() {
348        let source = "\
349import { a } from \"./a\";
350import b from \"./b\";
351const c = require(\"./c\");
352";
353        assert_eq!(
354            typescript(Metric::ImportCount, source),
355            2.0,
356            "a require() call is not an import statement"
357        );
358    }
359
360    #[test]
361    fn function_count_rust() {
362        let source = "\
363trait T {
364    fn signature_only(&self);
365}
366
367struct S;
368
369impl S {
370    fn method(&self) {}
371    fn other(&self) {}
372}
373
374fn free() {}
375";
376        assert_eq!(
377            rust(Metric::FunctionCount, source),
378            3.0,
379            "a trait signature has no body and does not count"
380        );
381    }
382
383    #[test]
384    fn function_count_typescript() {
385        let source = "\
386function declared() {}
387const arrow = () => 1;
388class C {
389    method() {}
390}
391";
392        assert_eq!(typescript(Metric::FunctionCount, source), 3.0);
393    }
394
395    #[test]
396    fn excludes_cfg_test_lines_and_functions() {
397        let source = "\
398use std::fmt;
399
400fn prod_one() {}
401fn prod_two() {}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    fn helper() {}
408    fn t_one() {}
409    fn t_two() {}
410}
411";
412        // Counting everything: 5 functions, 2 imports, 13 lines.
413        assert_eq!(rust(Metric::FunctionCount, source), 5.0);
414        assert_eq!(rust(Metric::ImportCount, source), 2.0);
415        assert_eq!(rust(Metric::FileLines, source), 13.0);
416        // Excluding the `#[cfg(test)]` module (lines 6-13): only the 2 production
417        // functions, the 1 production import, and the 5 non-test lines remain.
418        assert_eq!(rust_no_tests(Metric::FunctionCount, source), 2.0);
419        assert_eq!(rust_no_tests(Metric::ImportCount, source), 1.0);
420        assert_eq!(rust_no_tests(Metric::FileLines, source), 5.0);
421    }
422
423    #[test]
424    fn excludes_cfg_test_comment_lines() {
425        let source = "\
426// prod comment
427fn prod() {}
428#[cfg(test)]
429mod tests {
430    // test comment
431    // another test comment
432    fn t() {}
433}
434";
435        // Everything: 3 comment lines / 8 lines.
436        assert_eq!(rust(Metric::CommentRatio, source), 3.0 / 8.0);
437        // Test comments and test lines both drop out: 1 comment / 2 prod lines.
438        assert_eq!(rust_no_tests(Metric::CommentRatio, source), 1.0 / 2.0);
439    }
440
441    #[test]
442    fn no_cfg_test_leaves_metrics_unchanged() {
443        let source = "use std::fmt;\nfn a() {}\nfn b() {}\n";
444        assert_eq!(
445            rust_no_tests(Metric::FunctionCount, source),
446            rust(Metric::FunctionCount, source)
447        );
448        assert_eq!(
449            rust_no_tests(Metric::FileLines, source),
450            rust(Metric::FileLines, source)
451        );
452    }
453
454    #[test]
455    fn comment_ratio_line_comments() {
456        let source = "\
457// one
458// two
459fn a() {}
460// three
461fn b() {}
462// four
463fn c() {}
464fn d() {}
465";
466        assert_eq!(rust(Metric::CommentRatio, source), 0.5);
467    }
468
469    #[test]
470    fn comment_ratio_block_comment_spans_lines() {
471        let source = "\
472/* one
473   two
474   three
475   four
476   five */
477fn a() {}
478fn b() {}
479fn c() {}
480fn d() {}
481fn e() {}
482";
483        assert_eq!(
484            rust(Metric::CommentRatio, source),
485            0.5,
486            "a 5-line block comment contributes 5 comment lines out of 10"
487        );
488    }
489
490    #[test]
491    fn comment_ratio_ignores_doc_comments() {
492        // `///`, `//!` and `/** */` are API docs, not narration: none count.
493        let source = "\
494//! module doc
495/// outer doc for a
496/// still doc
497fn a() {}
498/** block doc */
499fn b() {}
500";
501        assert_eq!(
502            rust(Metric::CommentRatio, source),
503            0.0,
504            "a file with only doc comments has a zero narration ratio"
505        );
506    }
507
508    #[test]
509    fn comment_ratio_counts_narration_but_not_docs() {
510        // Two `//` narration lines and two `///` doc lines over six lines: only
511        // the narration counts, so 2/6, not 4/6.
512        let source = "\
513// narrate one
514// narrate two
515/// doc one
516/// doc two
517fn a() {}
518fn b() {}
519";
520        assert_eq!(rust(Metric::CommentRatio, source), 2.0 / 6.0);
521    }
522
523    #[test]
524    fn comment_ratio_typescript_ignores_jsdoc() {
525        let source = "\
526/** jsdoc line one
527 * jsdoc line two
528 */
529// real narration
530function f() {}
531";
532        // Only the `//` line counts: 1 of 5 lines.
533        assert_eq!(typescript(Metric::CommentRatio, source), 1.0 / 5.0);
534    }
535
536    #[test]
537    fn comment_ratio_empty_file_is_zero() {
538        assert_eq!(rust(Metric::CommentRatio, ""), 0.0);
539    }
540
541    #[test]
542    fn comment_ratio_no_comments_is_zero() {
543        let source = "fn a() {}\nfn b() {}\n";
544        assert_eq!(rust(Metric::CommentRatio, source), 0.0);
545    }
546
547    #[test]
548    fn format_value_and_describe() {
549        assert_eq!(Metric::FileLines.format_value(547.0), "547");
550        assert_eq!(Metric::FileLines.describe(547.0), "547 lines");
551        assert_eq!(Metric::CodeLines.describe(412.0), "412 lines of code");
552        assert_eq!(Metric::ImportCount.describe(42.0), "42 imports");
553        assert_eq!(Metric::FunctionCount.describe(31.0), "31 functions");
554        assert_eq!(Metric::CommentRatio.format_value(0.617), "0.62");
555        let ratio = Metric::CommentRatio.describe(0.617);
556        assert_eq!(ratio, "0.62 comment ratio");
557    }
558
559    #[test]
560    fn exceeds_is_strict() {
561        assert!(!exceeds(500.0, 500.0));
562        assert!(exceeds(501.0, 500.0));
563        assert!(!exceeds(499.0, 500.0));
564    }
565
566    #[test]
567    fn metric_display_is_snake_case() {
568        assert_eq!(Metric::FileLines.to_string(), "file_lines");
569        assert_eq!(Metric::CodeLines.to_string(), "code_lines");
570        assert_eq!(Metric::CommentRatio.to_string(), "comment_ratio");
571    }
572}