Skip to main content

big_code_analysis/metrics/
tokens.rs

1// Per-language metric and AST modules deliberately consume the macro-
2// generated tree-sitter token enums via `use crate::*` and `use Foo::*`
3// inside match expressions — explicit imports would list dozens of
4// variants per arm and obscure the per-language token sets that are the
5// point of these files. Allowed at the module level rather than per
6// function so the per-language impl blocks stay readable.
7#![allow(clippy::wildcard_imports, clippy::enum_glob_use)]
8// Metric counts (token, function, branch, argument, etc.) are stored as
9// `usize` and crossed with `f64` averages, ratios, and Halstead scores
10// across the cyclomatic / MI / Halstead computations. The `usize as f64`
11// and `f64 as usize` casts are intentional and snapshot-anchored — every
12// site is bounded by the count it came from. Allowing the lints at the
13// module level keeps the metric arithmetic legible.
14#![allow(
15    clippy::cast_precision_loss,
16    clippy::cast_possible_truncation,
17    clippy::cast_sign_loss
18)]
19
20use std::fmt;
21
22use crate::checker::Checker;
23use crate::macros::implement_metric_trait;
24
25use crate::*;
26
27/// The `Tokens` metric: per-function and per-file count of tree-sitter
28/// leaf tokens, excluding any leaf whose ancestor chain includes a
29/// comment node.
30///
31/// This is a token-based size proxy: it counts the lexer's tokens
32/// (identifiers, literals, keywords, punctuation) rather than lines or
33/// Halstead operators/operands. Punctuation that Halstead skips
34/// (parentheses, semicolons, separators) does contribute, so
35/// `tokens` ≠ Halstead `N1 + N2`.
36#[derive(Clone, Debug, PartialEq)]
37#[non_exhaustive]
38pub struct Stats {
39    tokens: usize,
40    tokens_sum: usize,
41    tokens_min: usize,
42    tokens_max: usize,
43    space_count: usize,
44}
45
46impl Default for Stats {
47    fn default() -> Self {
48        Self {
49            tokens: 0,
50            tokens_sum: 0,
51            tokens_min: usize::MAX,
52            tokens_max: 0,
53            space_count: 1,
54        }
55    }
56}
57
58impl fmt::Display for Stats {
59    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
60        write!(
61            f,
62            "tokens: {}, \
63             tokens_average: {}, \
64             tokens_min: {}, \
65             tokens_max: {}",
66            self.tokens_sum(),
67            self.tokens_average(),
68            self.tokens_min(),
69            self.tokens_max(),
70        )
71    }
72}
73
74impl Stats {
75    /// Merges a second `Tokens` metric suite into the first one.
76    pub fn merge(&mut self, other: &Stats) {
77        self.tokens_min = self.tokens_min.min(other.tokens_min);
78        self.tokens_max = self.tokens_max.max(other.tokens_max);
79        self.tokens_sum += other.tokens_sum;
80        self.space_count += other.space_count;
81    }
82
83    /// Returns the total token count across all merged spaces.
84    #[inline]
85    #[must_use]
86    pub fn tokens_sum(&self) -> u64 {
87        self.tokens_sum as u64
88    }
89
90    /// Returns the average tokens per space.
91    #[inline]
92    #[must_use]
93    pub fn tokens_average(&self) -> f64 {
94        crate::metrics::average(self.tokens_sum() as f64, self.space_count)
95    }
96
97    /// Returns the smallest single-space token count.
98    ///
99    /// Diverges intentionally from `nom::Stats::functions_min`, which
100    /// surfaces the raw `usize::MAX` sentinel for a never-observed
101    /// space. We collapse the sentinel to `0` so a `Stats::default()`
102    /// that bypasses the metric pipeline serializes to a meaningful
103    /// number rather than `18446744073709551615`.
104    #[inline]
105    #[must_use]
106    pub fn tokens_min(&self) -> u64 {
107        if self.tokens_min == usize::MAX {
108            0
109        } else {
110            self.tokens_min as u64
111        }
112    }
113
114    /// Returns the largest single-space token count.
115    #[inline]
116    #[must_use]
117    pub fn tokens_max(&self) -> u64 {
118        self.tokens_max as u64
119    }
120
121    #[inline]
122    pub(crate) fn compute_sum(&mut self) {
123        self.tokens_sum += self.tokens;
124    }
125
126    #[inline]
127    pub(crate) fn compute_minmax(&mut self) {
128        self.tokens_min = self.tokens_min.min(self.tokens);
129        self.tokens_max = self.tokens_max.max(self.tokens);
130        self.compute_sum();
131    }
132}
133
134#[doc(hidden)]
135/// Per-language counting of tokens.
136pub(crate) trait Tokens
137where
138    Self: Checker,
139{
140    /// Walk `node` and update `stats` with this metric for the language
141    /// implementing the trait.
142    fn compute(node: &Node, stats: &mut Stats) {
143        if node.child_count() != 0 {
144            return;
145        }
146        // Walk the leaf's ancestors so grammars whose comments have
147        // internal structure (e.g. Rust doc comments split into
148        // markers and content) also exclude inner leaves; the leaf
149        // itself is the first item, so bare comment nodes are caught
150        // immediately.
151        let in_comment =
152            std::iter::successors(Some(*node), Node::parent).any(|n| Self::is_comment(&n));
153        if !in_comment {
154            stats.tokens += 1;
155        }
156    }
157}
158
159implement_metric_trait!(
160    [Tokens],
161    PythonCode,
162    MozjsCode,
163    JavascriptCode,
164    TypescriptCode,
165    TsxCode,
166    CppCode,
167    MozcppCode,
168    CCode,
169    ObjcCode,
170    RustCode,
171    PreprocCode,
172    CcommentCode,
173    JavaCode,
174    KotlinCode,
175    GoCode,
176    PerlCode,
177    BashCode,
178    LuaCode,
179    TclCode,
180    PhpCode,
181    CsharpCode,
182    ElixirCode,
183    RubyCode,
184    GroovyCode,
185    IrulesCode
186);
187
188#[cfg(test)]
189#[allow(
190    clippy::float_cmp,
191    clippy::cast_precision_loss,
192    clippy::cast_possible_truncation,
193    clippy::cast_sign_loss,
194    clippy::similar_names,
195    clippy::doc_markdown,
196    clippy::needless_raw_string_hashes,
197    clippy::too_many_lines
198)]
199mod tests {
200    use crate::tools::check_metrics;
201
202    use super::*;
203
204    /// `def foo(x): return x` → leaves: `def`, `foo`, `(`, `x`, `)`,
205    /// `:`, `return`, `x` = 8 tokens, hand-counted.
206    #[test]
207    fn python_tokens_exact_count() {
208        check_metrics::<PythonParser>("def foo(x): return x", "foo.py", |metric| {
209            assert_eq!(metric.tokens.tokens_sum(), 8);
210            assert!(metric.tokens.tokens_max() >= 7);
211        });
212    }
213
214    /// Adding a Python comment must not change the token count.
215    #[test]
216    fn python_tokens_comments_excluded() {
217        check_metrics::<PythonParser>(
218            "def foo(x): return x  # explanation\n# header\n",
219            "foo.py",
220            |metric| {
221                assert_eq!(metric.tokens.tokens_sum(), 8);
222            },
223        );
224    }
225
226    /// Blank lines and indentation must not change the token count.
227    #[test]
228    fn python_tokens_whitespace_excluded() {
229        check_metrics::<PythonParser>(
230            "\n\n    def foo(x):\n        return x\n\n",
231            "foo.py",
232            |metric| {
233                assert_eq!(metric.tokens.tokens_sum(), 8);
234            },
235        );
236    }
237
238    /// Tokens must exceed Halstead `N1 + N2` for code containing
239    /// punctuation Halstead skips. Guards against accidental Halstead
240    /// reuse.
241    #[test]
242    fn python_tokens_distinct_from_halstead() {
243        check_metrics::<PythonParser>("def foo(x): return (x + 1)", "foo.py", |metric| {
244            let halstead_total =
245                metric.halstead.total_operators() + metric.halstead.total_operands();
246            assert!(
247                metric.tokens.tokens_sum() > halstead_total,
248                "expected tokens ({}) > halstead N1+N2 ({}); punctuation \
249                 like `(`, `)`, `:` should contribute to tokens but not Halstead",
250                metric.tokens.tokens_sum(),
251                halstead_total,
252            );
253        });
254    }
255
256    /// Inner functions get attributed to their innermost scope. For
257    /// `def outer(): def inner(): return 1`, the inner scope owns
258    /// `def, inner, (, ), :, return, 1` = 7 tokens; the outer scope
259    /// owns `def, outer, (, ), :` = 5; the unit owns 0 directly.
260    /// Asserting the exact `tokens_max` is what catches an attribution
261    /// regression — a broken implementation that credited all 12
262    /// tokens to one scope would still pass `max <= sum`.
263    #[test]
264    fn python_tokens_nested_attribution() {
265        check_metrics::<PythonParser>(
266            "def outer():\n    def inner():\n        return 1\n",
267            "foo.py",
268            |metric| {
269                assert_eq!(metric.tokens.tokens_sum(), 12);
270                assert_eq!(metric.tokens.tokens_max(), 7);
271                assert_eq!(metric.tokens.tokens_min(), 0);
272            },
273        );
274    }
275
276    /// C++ `/* … */` block comments must not contribute.
277    /// Same fixture with and without comment yields the same count.
278    #[test]
279    fn cpp_tokens_block_comments_excluded() {
280        check_metrics::<CppParser>(
281            "int foo(int x) { /* multi\n   line */ return x; }",
282            "foo.cpp",
283            |m| {
284                // Leaves outside the comment:
285                // int, foo, (, int, x, ), {, return, x, ;, } = 11.
286                assert_eq!(m.tokens.tokens_sum(), 11);
287            },
288        );
289        check_metrics::<CppParser>("int foo(int x) { return x; }", "foo.cpp", |m| {
290            assert_eq!(m.tokens.tokens_sum(), 11);
291        });
292    }
293
294    /// C++ `// …` line comments must not contribute, matching the Python
295    /// hand-counted style.  Leaves outside the comment:
296    /// `int`, `x`, `=`, `1`, `;` = 5.
297    #[test]
298    fn cpp_tokens_line_comments_excluded() {
299        check_metrics::<CppParser>("int x = 1; // a one-line comment\n", "foo.cpp", |m| {
300            assert_eq!(m.tokens.tokens_sum(), 5);
301        });
302        check_metrics::<CppParser>("int x = 1;\n", "foo.cpp", |m| {
303            assert_eq!(m.tokens.tokens_sum(), 5);
304        });
305    }
306
307    /// Whitespace and blank lines must not contribute to the token count
308    /// (mirrors `python_tokens_whitespace_excluded`).
309    #[test]
310    fn cpp_tokens_whitespace_excluded() {
311        check_metrics::<CppParser>("\n\nint foo(int x) {\n    return x;\n}\n", "foo.cpp", |m| {
312            // int, foo, (, int, x, ), {, return, x, ;, } = 11.
313            assert_eq!(m.tokens.tokens_sum(), 11);
314        });
315    }
316
317    /// Tokens count punctuation that Halstead skips (parentheses, braces,
318    /// semicolons), so `tokens_sum` must exceed `N1 + N2` for a fixture
319    /// with significant punctuation.  Mirrors
320    /// `python_tokens_distinct_from_halstead`.
321    #[test]
322    fn cpp_tokens_distinct_from_halstead() {
323        check_metrics::<CppParser>("int foo(int x) { return (x + 1); }", "foo.cpp", |m| {
324            let halstead_total = m.halstead.total_operators() + m.halstead.total_operands();
325            assert!(
326                m.tokens.tokens_sum() > halstead_total,
327                "expected tokens ({}) > halstead N1+N2 ({}); punctuation like \
328                 `(`, `)`, `{{`, `}}` and `;` should contribute to tokens but not Halstead",
329                m.tokens.tokens_sum(),
330                halstead_total,
331            );
332        });
333    }
334
335    /// A C++ struct method and a free function each open their own
336    /// `FuncSpace`, so the leaves of
337    /// `struct S { int method(int a) { return a + 1; } }; int outer() { return 2; }`
338    /// split across scopes: `method` owns 13 and `outer` owns 9, with the
339    /// `struct`/unit framing owning the rest of the 27-token total.
340    /// Asserting the exact `tokens_max` (13 — the largest single scope,
341    /// strictly below the sum of 27) is what catches an attribution
342    /// regression: a broken implementation that credited every leaf to one
343    /// scope would raise `tokens_max` to 27 while still passing
344    /// `max <= sum`, mirroring the Python sibling's exact-max guard.
345    #[test]
346    fn cpp_tokens_nested_attribution() {
347        check_metrics::<CppParser>(
348            "struct S {\n    int method(int a) { return a + 1; }\n};\nint outer() { return 2; }\n",
349            "foo.cpp",
350            |m| {
351                assert_eq!(m.tokens.tokens_sum(), 27);
352                assert_eq!(m.tokens.tokens_max(), 13);
353                assert_eq!(m.tokens.tokens_min(), 1);
354            },
355        );
356    }
357
358    /// Java `// …` line comments must not contribute.
359    #[test]
360    fn java_tokens_line_comments_excluded() {
361        check_metrics::<JavaParser>(
362            "class A { void foo() { // hi\n return; } }",
363            "A.java",
364            |m| {
365                // class, A, {, void, foo, (, ), {, return, ;, }, } = 12.
366                assert_eq!(m.tokens.tokens_sum(), 12);
367            },
368        );
369        check_metrics::<JavaParser>("class A { void foo() { return; } }", "A.java", |m| {
370            assert_eq!(m.tokens.tokens_sum(), 12);
371        });
372    }
373
374    #[test]
375    fn groovy_tokens_line_comments_excluded() {
376        // Groovy mirror — `// …` line comments must not contribute.
377        check_metrics::<GroovyParser>(
378            "class A { void foo() { // hi\n return\n } }",
379            "A.groovy",
380            |m| {
381                // class, A, {, void, foo, (, ), {, return, newline,
382                // }, } = 11 tokens (Groovy's newline acts as the
383                // statement terminator that Java spells `;`).
384                assert_eq!(m.tokens.tokens_sum(), 11);
385            },
386        );
387    }
388
389    /// JS-family `<!-- -->` Annex-B `html_comment` leaves must not
390    /// contribute tokens — they classify as comments now (#697). The
391    /// count must match the comment-free source exactly.
392    #[test]
393    fn javascript_tokens_html_comment_excluded() {
394        check_metrics::<JavascriptParser>("<!-- hi -->\nlet x = 1;\n", "foo.js", |m| {
395            // let, x, =, 1, ; = 5.
396            assert_eq!(m.tokens.tokens_sum(), 5);
397        });
398        check_metrics::<JavascriptParser>("let x = 1;\n", "foo.js", |m| {
399            assert_eq!(m.tokens.tokens_sum(), 5);
400        });
401    }
402
403    /// Groovy `/** … */` `groovydoc_comment` leaves must not contribute
404    /// tokens (#697 — `is_comment` previously missed this kind even
405    /// though `Loc` counted it).
406    #[test]
407    fn groovy_tokens_groovydoc_excluded() {
408        check_metrics::<GroovyParser>(
409            "/** doc */\nclass A { void f() { return\n } }\n",
410            "A.groovy",
411            |m| {
412                // class, A, {, void, f, (, ), {, return, newline,
413                // }, } = 11.
414                assert_eq!(m.tokens.tokens_sum(), 11);
415            },
416        );
417        check_metrics::<GroovyParser>("class A { void f() { return\n } }\n", "A.groovy", |m| {
418            assert_eq!(m.tokens.tokens_sum(), 11);
419        });
420    }
421
422    /// Rust doc comments may split into structured children under
423    /// some grammars; the ancestor walk must filter every inner leaf.
424    #[test]
425    fn rust_tokens_doc_comments_excluded() {
426        check_metrics::<RustParser>(
427            "/// outer doc\n/// more doc\nfn f() { let x = 1; }",
428            "foo.rs",
429            |m| {
430                // fn, f, (, ), {, let, x, =, 1, ;, } = 11.
431                assert_eq!(m.tokens.tokens_sum(), 11);
432            },
433        );
434        check_metrics::<RustParser>("fn f() { let x = 1; }", "foo.rs", |m| {
435            assert_eq!(m.tokens.tokens_sum(), 11);
436        });
437    }
438
439    // -- Per-language smoke tests --------------------------------------
440    //
441    // Lesson 1 (`docs/development/lessons_learned.md`): every supported
442    // language must have a positive test that asserts non-zero tokens
443    // on real source. Catches the silent-zero regression where a
444    // metric is registered but never fires. `check_metrics` takes a
445    // `fn` pointer so each test inlines its assertion directly.
446
447    #[test]
448    fn smoke_python() {
449        check_metrics::<PythonParser>("x = 1\n", "foo.py", |m| {
450            assert!(m.tokens.tokens_sum() > 0);
451        });
452    }
453
454    #[test]
455    fn smoke_rust() {
456        check_metrics::<RustParser>("fn f() { let x = 1; }", "foo.rs", |m| {
457            assert!(m.tokens.tokens_sum() > 0);
458        });
459    }
460
461    #[test]
462    fn smoke_cpp() {
463        check_metrics::<CppParser>("int x = 1;", "foo.cpp", |m| {
464            assert!(m.tokens.tokens_sum() > 0);
465        });
466    }
467
468    #[test]
469    fn smoke_java() {
470        check_metrics::<JavaParser>("class A { int x = 1; }", "A.java", |m| {
471            assert!(m.tokens.tokens_sum() > 0);
472        });
473    }
474
475    #[test]
476    fn smoke_csharp() {
477        check_metrics::<CsharpParser>("class A { int X = 1; }", "A.cs", |m| {
478            assert!(m.tokens.tokens_sum() > 0);
479        });
480    }
481
482    #[test]
483    fn smoke_javascript() {
484        check_metrics::<JavascriptParser>("let x = 1;", "foo.js", |m| {
485            assert!(m.tokens.tokens_sum() > 0);
486        });
487    }
488
489    #[test]
490    fn smoke_mozjs() {
491        check_metrics::<MozjsParser>("let x = 1;", "foo.js", |m| {
492            assert!(m.tokens.tokens_sum() > 0);
493        });
494    }
495
496    #[test]
497    fn smoke_typescript() {
498        check_metrics::<TypescriptParser>("const x: number = 1;", "foo.ts", |m| {
499            assert!(m.tokens.tokens_sum() > 0);
500        });
501    }
502
503    #[test]
504    fn smoke_tsx() {
505        check_metrics::<TsxParser>("const x: number = 1;", "foo.tsx", |m| {
506            assert!(m.tokens.tokens_sum() > 0);
507        });
508    }
509
510    #[test]
511    fn smoke_go() {
512        check_metrics::<GoParser>("package main\nfunc f() {}", "foo.go", |m| {
513            assert!(m.tokens.tokens_sum() > 0);
514        });
515    }
516
517    #[test]
518    fn smoke_kotlin() {
519        check_metrics::<KotlinParser>("fun f(): Int = 1", "foo.kt", |m| {
520            assert!(m.tokens.tokens_sum() > 0);
521        });
522    }
523
524    #[test]
525    fn smoke_lua() {
526        check_metrics::<LuaParser>("local x = 1", "foo.lua", |m| {
527            assert!(m.tokens.tokens_sum() > 0);
528        });
529    }
530
531    #[test]
532    fn smoke_bash() {
533        check_metrics::<BashParser>("x=1", "foo.sh", |m| {
534            assert!(m.tokens.tokens_sum() > 0);
535        });
536    }
537
538    #[test]
539    fn smoke_tcl() {
540        check_metrics::<TclParser>("set x 1", "foo.tcl", |m| {
541            assert!(m.tokens.tokens_sum() > 0);
542        });
543    }
544
545    #[test]
546    fn smoke_perl() {
547        check_metrics::<PerlParser>("my $x = 1;", "foo.pl", |m| {
548            assert!(m.tokens.tokens_sum() > 0);
549        });
550    }
551
552    #[test]
553    fn smoke_php() {
554        check_metrics::<PhpParser>("<?php $x = 1;", "foo.php", |m| {
555            assert!(m.tokens.tokens_sum() > 0);
556        });
557    }
558
559    #[test]
560    fn smoke_preproc() {
561        check_metrics::<PreprocParser>("#define FOO 1\n", "foo.h", |m| {
562            assert!(m.tokens.tokens_sum() > 0);
563        });
564    }
565
566    #[test]
567    fn smoke_ccomment() {
568        // Ccomment's grammar parses bare C source; non-comment text
569        // produces non-comment leaves.
570        check_metrics::<CcommentParser>("int x = 1;", "foo.c", |m| {
571            assert!(m.tokens.tokens_sum() > 0);
572        });
573    }
574
575    #[test]
576    fn smoke_c() {
577        check_metrics::<CParser>("int x = 1;\n", "foo.c", |m| {
578            assert!(m.tokens.tokens_sum() > 0);
579        });
580    }
581
582    #[test]
583    fn smoke_objc() {
584        check_metrics::<ObjcParser>("int x = 1;\n", "foo.m", |m| {
585            assert!(m.tokens.tokens_sum() > 0);
586        });
587    }
588
589    #[test]
590    fn smoke_elixir() {
591        check_metrics::<ElixirParser>("defmodule Foo do\n  :ok\nend\n", "foo.ex", |m| {
592            assert!(m.tokens.tokens_sum() > 0);
593        });
594    }
595
596    #[test]
597    fn smoke_ruby() {
598        check_metrics::<RubyParser>("def foo\n  a = 1\nend\n", "foo.rb", |m| {
599            assert!(m.tokens.tokens_sum() > 0);
600        });
601    }
602
603    #[test]
604    fn smoke_irules() {
605        check_metrics::<IrulesParser>("when X {\n    set x 1\n}\n", "foo.irule", |m| {
606            assert!(m.tokens.tokens_sum() > 0);
607        });
608    }
609}