Skip to main content

big_code_analysis/metrics/
halstead.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(
8    clippy::doc_markdown,
9    clippy::enum_glob_use,
10    clippy::match_wildcard_for_single_variants,
11    clippy::similar_names,
12    clippy::unused_self,
13    clippy::wildcard_imports
14)]
15// Metric counts (token, function, branch, argument, etc.) are stored as
16// `usize` and crossed with `f64` averages, ratios, and Halstead scores
17// across the cyclomatic / MI / Halstead computations. The `usize as f64`
18// and `f64 as usize` casts are intentional and snapshot-anchored — every
19// site is bounded by the count it came from. Allowing the lints at the
20// module level keeps the metric arithmetic legible.
21#![allow(
22    clippy::cast_precision_loss,
23    clippy::cast_possible_truncation,
24    clippy::cast_sign_loss
25)]
26
27use std::collections::HashMap;
28
29use std::fmt;
30
31use crate::checker::Checker;
32use crate::getter::Getter;
33use crate::int_hash::IntKeyHashMap;
34use crate::macros::implement_metric_trait;
35
36use crate::*;
37
38/// The `Halstead` metric suite.
39#[derive(Default, Clone, Debug, PartialEq)]
40#[non_exhaustive]
41pub struct Stats {
42    u_operators: u64,
43    operators: u64,
44    u_operands: u64,
45    operands: u64,
46}
47
48/// Specifies the type of nodes accepted by the `Halstead` metric.
49pub enum HalsteadType {
50    /// The node is an `Halstead` operator
51    Operator,
52    /// The node is an `Halstead` operand
53    Operand,
54    /// The node is unknown to the `Halstead` metric
55    Unknown,
56}
57
58/// Per-space operator / operand occurrence maps used to compute the
59/// Halstead `Stats` struct. One map per distinct operator (`kind_id`)
60/// and one per distinct operand (`text`); merged across nested spaces.
61#[derive(Debug, Default, Clone, PartialEq)]
62pub struct HalsteadMaps<'a> {
63    /// Keyed by `kind_id`, so it is hashed with [`crate::int_hash`]'s
64    /// integer hasher rather than SipHash: the key is a grammar symbol
65    /// this crate generated, drawn from an alphabet of at most a few
66    /// hundred values, so there is nothing for a keyed hash to defend.
67    pub(crate) operators: IntKeyHashMap<u16, u64>,
68    /// Primitive-type operators stored by text so each distinct primitive
69    /// (e.g. `int` vs `double`) counts as a separate distinct operator,
70    /// even when the grammar maps them all to a single kind_id.
71    ///
72    /// Text-keyed, so it keeps SipHash — see the module doc on
73    /// [`crate::int_hash`] for why analysed source text does not qualify
74    /// for the fast hasher.
75    pub(crate) primitive_operators: HashMap<&'a [u8], u64>,
76    /// Text-keyed, and on SipHash for the same reason as
77    /// `primitive_operators`.
78    pub(crate) operands: HashMap<&'a [u8], u64>,
79}
80
81impl<'a> HalsteadMaps<'a> {
82    pub(crate) fn new() -> Self {
83        Self::default()
84    }
85
86    pub(crate) fn merge(&mut self, other: &HalsteadMaps<'a>) {
87        for (k, v) in &other.operators {
88            *self.operators.entry(*k).or_insert(0) += v;
89        }
90        for (k, v) in &other.primitive_operators {
91            *self.primitive_operators.entry(*k).or_insert(0) += v;
92        }
93        for (k, v) in &other.operands {
94            *self.operands.entry(*k).or_insert(0) += v;
95        }
96    }
97
98    pub(crate) fn finalize(&self, stats: &mut Stats) {
99        stats.u_operators = (self.operators.len() + self.primitive_operators.len()) as u64;
100        stats.operators =
101            self.operators.values().sum::<u64>() + self.primitive_operators.values().sum::<u64>();
102        stats.u_operands = self.operands.len() as u64;
103        stats.operands = self.operands.values().sum::<u64>();
104    }
105}
106
107impl fmt::Display for Stats {
108    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
109        write!(
110            f,
111            "unique_operators: {}, \
112             total_operators: {}, \
113             unique_operands: {}, \
114             total_operands: {}, \
115             length: {}, \
116             estimated_program_length: {}, \
117             purity_ratio: {}, \
118             size: {}, \
119             volume: {}, \
120             difficulty: {}, \
121             level: {}, \
122             effort: {}, \
123             time: {}, \
124             bugs: {}",
125            self.unique_operators(),
126            self.total_operators(),
127            self.unique_operands(),
128            self.total_operands(),
129            self.length(),
130            self.estimated_program_length(),
131            self.purity_ratio(),
132            self.vocabulary(),
133            self.volume(),
134            self.difficulty(),
135            self.level(),
136            self.effort(),
137            self.time(),
138            self.bugs(),
139        )
140    }
141}
142
143impl Stats {
144    // Intentionally a no-op. Halstead distinct-counts (`u_operators` /
145    // `u_operands`) cannot be summed across sibling spaces without
146    // double-counting operators/operands they share. Cross-space
147    // aggregation is instead done by unioning the occurrence maps
148    // (`HalsteadMaps::merge`) and re-running `finalize` on the parent
149    // (see `spaces/compute.rs`). Summing the finalized fields here —
150    // mirroring the sibling metrics' `merge` — would silently inflate
151    // every parent space's n1/n2/N1/N2.
152    pub(crate) fn merge(&mut self, _other: &Stats) {}
153
154    /// Returns `η1`, the number of distinct operators
155    #[inline]
156    #[must_use]
157    pub fn unique_operators(&self) -> u64 {
158        self.u_operators
159    }
160
161    /// Returns `N1`, the number of total operators
162    #[inline]
163    #[must_use]
164    pub fn total_operators(&self) -> u64 {
165        self.operators
166    }
167
168    /// Returns `η2`, the number of distinct operands
169    #[inline]
170    #[must_use]
171    pub fn unique_operands(&self) -> u64 {
172        self.u_operands
173    }
174
175    /// Returns `N2`, the number of total operands
176    #[inline]
177    #[must_use]
178    pub fn total_operands(&self) -> u64 {
179        self.operands
180    }
181
182    /// Returns the program length
183    ///
184    /// Computed as `N = N1 + N2`, the sum of [`Self::total_operators`] and
185    /// [`Self::total_operands`].
186    #[inline]
187    #[must_use]
188    pub fn length(&self) -> u64 {
189        self.total_operands() + self.total_operators()
190    }
191
192    /// Returns the calculated estimated program length
193    ///
194    /// Computed as `N^ = n1 * log2(n1) + n2 * log2(n2)`, where `n1` is
195    /// [`Self::unique_operators`] and `n2` is [`Self::unique_operands`]. Each term is
196    /// treated as `0` when its unique count is `0`.
197    #[inline]
198    #[must_use]
199    pub fn estimated_program_length(&self) -> f64 {
200        let uo = self.unique_operators() as f64;
201        let ud = self.unique_operands() as f64;
202        let uo_term = if uo == 0.0 { 0.0 } else { uo * uo.log2() };
203        let ud_term = if ud == 0.0 { 0.0 } else { ud * ud.log2() };
204        uo_term + ud_term
205    }
206
207    /// Returns the purity ratio
208    ///
209    /// Computed as `PR = N^ / N`, the ratio of
210    /// [`Self::estimated_program_length`] to [`Self::length`].
211    #[inline]
212    #[must_use]
213    pub fn purity_ratio(&self) -> f64 {
214        let len = self.length() as f64;
215        if len == 0.0 {
216            0.0
217        } else {
218            self.estimated_program_length() / len
219        }
220    }
221
222    /// Returns the program vocabulary
223    ///
224    /// Computed as `n = n1 + n2`, the sum of [`Self::unique_operators`] and
225    /// [`Self::unique_operands`].
226    #[inline]
227    #[must_use]
228    pub fn vocabulary(&self) -> u64 {
229        self.unique_operands() + self.unique_operators()
230    }
231
232    /// Returns the program volume.
233    ///
234    /// Computed as `V = N * log2(n)`, where `N` is [`Self::length`] and `n`
235    /// is [`Self::vocabulary`]. Returns `0` when the vocabulary is `<= 1`,
236    /// since `log2` would be non-positive.
237    ///
238    /// Unit of measurement: bits
239    #[inline]
240    #[must_use]
241    pub fn volume(&self) -> f64 {
242        // Assumes a uniform binary encoding for the vocabulary is used.
243        let vocab = self.vocabulary() as f64;
244        if vocab <= 1.0 {
245            0.0
246        } else {
247            self.length() as f64 * vocab.log2()
248        }
249    }
250
251    /// Returns the estimated difficulty required to program
252    ///
253    /// Computed as `D = (n1 / 2) * (N2 / n2)`, where `n1` is
254    /// [`Self::unique_operators`], `N2` is [`Self::total_operands`], and `n2` is
255    /// [`Self::unique_operands`].
256    #[inline]
257    #[must_use]
258    pub fn difficulty(&self) -> f64 {
259        let ud = self.unique_operands() as f64;
260        if ud == 0.0 {
261            0.0
262        } else {
263            self.unique_operators() as f64 / 2. * self.total_operands() as f64 / ud
264        }
265    }
266
267    /// Returns the estimated level of difficulty required to program
268    ///
269    /// Computed as `L = 1 / D`, the reciprocal of [`Self::difficulty`].
270    #[inline]
271    #[must_use]
272    pub fn level(&self) -> f64 {
273        let d = self.difficulty();
274        if d == 0.0 { 0.0 } else { 1. / d }
275    }
276
277    /// Returns the estimated effort required to program
278    ///
279    /// Computed as `E = D * V`, the product of [`Self::difficulty`] and
280    /// [`Self::volume`].
281    #[inline]
282    #[must_use]
283    pub fn effort(&self) -> f64 {
284        self.difficulty() * self.volume()
285    }
286
287    /// Returns the estimated time required to program.
288    ///
289    /// Computed as `T = E / 18`, where `E` is [`Self::effort`] and `18` is
290    /// the Stroud number (see the divisor rationale below).
291    ///
292    /// Unit of measurement: seconds
293    #[inline]
294    #[must_use]
295    pub fn time(&self) -> f64 {
296        // The floating point `18.` aims to describe the processing rate of the
297        // human brain. It is called Stoud number, S, and its
298        // unit of measurement is moments/seconds.
299        // A moment is the time required by the human brain to carry out the
300        // most elementary decision.
301        // 5 <= S <= 20. Halstead uses 18.
302        // The value of S has been empirically developed from psychological
303        // reasoning, and its recommended value for
304        // programming applications is 18.
305        //
306        // Source: https://www.geeksforgeeks.org/software-engineering-halsteads-software-metrics/
307        self.effort() / 18.
308    }
309
310    /// Returns the estimated number of delivered bugs.
311    ///
312    /// This metric represents the average amount of work a programmer can do
313    /// without introducing an error.
314    ///
315    /// Computed as `B = E^(2/3) / 3000`, where `E` is [`Self::effort`]. This
316    /// is the effort-based variant of Halstead's delivered-bugs estimate
317    /// rather than the more commonly cited volume-based form `B = V / 3000`;
318    /// it matches the formula used by upstream `rust-code-analysis`.
319    #[inline]
320    #[must_use]
321    pub fn bugs(&self) -> f64 {
322        // The floating point `3000.` represents the number of elementary
323        // mental discriminations.
324        // A mental discrimination, in psychology, is the ability to perceive
325        // and respond to differences among stimuli.
326        //
327        // The value above is obtained starting from a constant that
328        // is different for every language and assumes that natural language is
329        // the language of the brain.
330        // For programming languages, the English language constant
331        // has been considered.
332        //
333        // After every 3000 mental discriminations a result is produced.
334        // This result, whether correct or incorrect, is more than likely
335        // either used as an input for the next operation or is output to the
336        // environment.
337        // If incorrect the error should become apparent.
338        // Thus, an opportunity for error occurs every 3000
339        // mental discriminations.
340        //
341        // Source: https://docs.lib.purdue.edu/cgi/viewcontent.cgi?article=1145&context=cstech
342        self.effort().powf(2. / 3.) / 3000.
343    }
344}
345
346#[doc(hidden)]
347/// Per-language extraction of Halstead operator/operand maps.
348pub(crate) trait Halstead
349where
350    Self: Checker + Getter,
351{
352    /// Walk `node` and update `stats` with this metric for the language
353    /// implementing the trait.
354    ///
355    /// `ancestors` is the chain the walker descended through; it is
356    /// handed to [`Getter::get_op_type`], six of whose impls classify a
357    /// token by what encloses it (#1096).
358    fn compute<'a>(
359        node: &Node<'a>,
360        code: &'a [u8],
361        ancestors: Ancestors<'a, '_>,
362        halstead_maps: &mut HalsteadMaps<'a>,
363    );
364}
365
366#[inline]
367fn get_id<'a>(node: &Node<'a>, code: &'a [u8]) -> &'a [u8] {
368    &code[node.start_byte()..node.end_byte()]
369}
370
371#[inline]
372fn compute_halstead<'a, T: Getter + Checker>(
373    node: &Node<'a>,
374    code: &'a [u8],
375    ancestors: Ancestors<'a, '_>,
376    halstead_maps: &mut HalsteadMaps<'a>,
377) {
378    match T::get_op_type_with_code(node, code, ancestors) {
379        HalsteadType::Operator => {
380            if T::is_primitive(node) {
381                // Store primitive-type operators by text so distinct
382                // primitives (e.g. `int` vs `double`) that share a
383                // single kind_id are counted separately in n1/N1.
384                *halstead_maps
385                    .primitive_operators
386                    .entry(get_id(node, code))
387                    .or_insert(0) += 1;
388            } else {
389                *halstead_maps.operators.entry(node.kind_id()).or_insert(0) += 1;
390            }
391        }
392        HalsteadType::Operand => {
393            *halstead_maps
394                .operands
395                .entry(T::get_operand_id(node, code, ancestors))
396                .or_insert(0) += 1;
397        }
398        _ => {}
399    }
400}
401
402// Every language's `Halstead::compute` is the same forward to
403// `compute_halstead`, which classifies each node through the language's
404// own `Getter` / `Checker`. Nothing per-language lives here — it lives
405// in `src/getter/<lang>.rs` — so writing the impls out was 23 copies of
406// one signature. (This is the only metric whose per-language impls are
407// all identical; every other trait has real per-language bodies.)
408macro_rules! impl_halstead_forwarding {
409    ($($code:ty),+ $(,)?) => {
410        $(
411            impl Halstead for $code {
412                fn compute<'a>(
413                    node: &Node<'a>,
414                    code: &'a [u8],
415                    ancestors: Ancestors<'a, '_>,
416                    halstead_maps: &mut HalsteadMaps<'a>,
417                ) {
418                    compute_halstead::<Self>(node, code, ancestors, halstead_maps);
419                }
420            }
421        )+
422    };
423}
424
425impl_halstead_forwarding!(
426    PythonCode,
427    MozjsCode,
428    JavascriptCode,
429    TypescriptCode,
430    TsxCode,
431    RustCode,
432    CppCode,
433    CCode,
434    ObjcCode,
435    MozcppCode,
436    JavaCode,
437    GroovyCode,
438    CsharpCode,
439    GoCode,
440    PerlCode,
441    KotlinCode,
442    LuaCode,
443    PhpCode,
444    RubyCode,
445    ElixirCode,
446    BashCode,
447    TclCode,
448    IrulesCode,
449);
450
451// Real defaults — no operators / operands to count. Audited in #188.
452implement_metric_trait!(Halstead, PreprocCode, CcommentCode);
453
454#[cfg(test)]
455#[allow(
456    clippy::float_cmp,
457    clippy::cast_precision_loss,
458    clippy::cast_possible_truncation,
459    clippy::cast_sign_loss,
460    clippy::similar_names,
461    clippy::doc_markdown,
462    clippy::needless_raw_string_hashes,
463    clippy::too_many_lines
464)]
465mod tests {
466    use std::collections::HashSet;
467    use std::path::PathBuf;
468
469    use crate::test_support::check_metrics_only_shim;
470
471    use super::*;
472
473    check_metrics_only_shim!(check_metrics, Halstead);
474
475    // Pins the lesson-4 invariant `n2 == len(dedupe(ops.operands))` by
476    // running `operands_and_operators` (the text-keyed `--ops` store)
477    // on the same source and comparing its deduplicated operand count
478    // to the expected `n2`. The metrics store and the ops store are
479    // independent (lesson 4); this catches a classification change that
480    // moves one without the other.
481    fn assert_ops_operands<T: crate::ParserTrait>(
482        source: &str,
483        file: &str,
484        expected_n2: usize,
485        mut expected_operands: Vec<&str>,
486    ) {
487        let path = PathBuf::from(file);
488        let parser = T::new(source.as_bytes().to_vec(), &path, None);
489        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
490
491        let unique: HashSet<&str> = ops.operands.iter().map(String::as_str).collect();
492        assert_eq!(
493            unique.len(),
494            expected_n2,
495            "dedupe(ops.operands) must equal n2; operands were {:?}",
496            ops.operands
497        );
498
499        let mut got: Vec<&str> = unique.into_iter().collect();
500        got.sort_unstable();
501        expected_operands.sort_unstable();
502        assert_eq!(got, expected_operands);
503    }
504
505    #[test]
506    fn python_operators_and_operands() {
507        check_metrics::<PythonParser>(
508            "def foo():
509                 def bar():
510                     def toto():
511                        a = 1 + 1
512                     b = 2 + a
513                 c = 3 + 3",
514            "foo.py",
515            |metric| {
516                // unique operators: def, =, +
517                // operators: def, def, def, =, =, =, +, +, +
518                // unique operands: foo, bar, toto, a, b, c, 1, 2, 3
519                // operands: foo, bar, toto, a, b, c, 1, 1, 2, a, 3, 3
520                insta::assert_json_snapshot!(
521                    metric.halstead,
522                    @r#"
523                {
524                  "unique_operators": 3,
525                  "total_operators": 9,
526                  "unique_operands": 9,
527                  "total_operands": 12,
528                  "length": 21,
529                  "estimated_program_length": 33.284212515144276,
530                  "purity_ratio": 1.584962500721156,
531                  "vocabulary": 12,
532                  "volume": 75.28421251514428,
533                  "difficulty": 2.0,
534                  "level": 0.5,
535                  "effort": 150.56842503028855,
536                  "time": 8.364912501682698,
537                  "bugs": 0.0094341190071077
538                }
539                "#
540                );
541            },
542        );
543    }
544
545    /// Pointer-arithmetic operators: `*` (dereference), `&` (address-of),
546    /// `->` (member-of-pointer), `+` (pointer + offset). Each is counted
547    /// once in `n1`; multiple uses bump `N1`. The headline integer values
548    /// (`u_operators`, `u_operands`) anchor the snapshot per the
549    /// snapshot-anchor policy.
550    #[test]
551    fn c_pointer_arithmetic_operators() {
552        check_metrics::<CParser>(
553            "int g(int* p, int* q) {
554                 return *(p + 1) + *q;
555             }",
556            "foo.c",
557            |metric| {
558                // Unique operators: int, *, (), {, }, +, ;, return  (= 8)
559                //   `*` covers both pointer-type and dereference; the grammar
560                //   does NOT split them.  `,` does not appear (only one
561                //   parameter on each side of the body).
562                // Unique operands: g, p, q, 1                       (= 4)
563                assert_eq!(metric.halstead.unique_operators(), 8);
564                assert_eq!(metric.halstead.unique_operands(), 4);
565                insta::assert_json_snapshot!(metric.halstead);
566            },
567        );
568    }
569
570    /// Bitwise (`&`, `|`, `^`, `~`, `<<`, `>>`) and logical (`&&`, `||`,
571    /// `!`) operators are distinct kind_ids and count as separate unique
572    /// operators in Halstead.  `&` (bitwise-and) and `&&` (logical-and)
573    /// must NOT collapse, even though both render as ampersands.
574    #[test]
575    fn c_bitwise_and_logical_operators() {
576        check_metrics::<CParser>(
577            "int f(int a, int b) {
578                 int x = (a & b) | (a ^ b);
579                 int y = ~a;
580                 int z = (a << 1) >> 2;
581                 return (a && b) || !x;
582             }",
583            "foo.c",
584            |metric| {
585                // Expect: 6 bitwise op kinds (& | ^ ~ << >>), 3 logical (&& || !).
586                // Plus int, (), {, }, =, ;, return, , — 8 syntactic / arithmetic
587                // operator kinds.  Six bitwise + three logical + eight = 17 unique
588                // operators is the upper bound; actuals depend on grammar collapse,
589                // so we assert a lower-bound and anchor via snapshot below.
590                let s = &metric.halstead;
591                assert!(
592                    s.unique_operators() >= 14,
593                    "expected >= 14 unique operators (bitwise + logical + syntax), got {}",
594                    s.unique_operators(),
595                );
596                assert_eq!(s.unique_operands(), 8); // f, a, b, x, y, z, 1, 2
597                insta::assert_json_snapshot!(metric.halstead);
598            },
599        );
600    }
601
602    /// Increment / decrement (`++`, `--`) and `sizeof` / cast operators
603    /// each contribute distinct unique operators.  C-style casts in the
604    /// tree-sitter grammar surface as `cast_expression` with the type
605    /// token classified as a primitive_type operator.
606    #[test]
607    fn c_increment_decrement_and_sizeof() {
608        check_metrics::<CParser>(
609            "void f(int* p) {
610                 int n = sizeof(int);
611                 ++p;
612                 --n;
613                 long w = (long) n;
614             }",
615            "foo.c",
616            |metric| {
617                // Unique operators include: void, int, long, *, =, sizeof, ++, --, (), {, }, ;
618                // Unique operands: f, p, n, w
619                let s = &metric.halstead;
620                assert!(
621                    s.unique_operators() >= 10,
622                    "expected >= 10 unique operators including ++ / -- / sizeof / cast, got {}",
623                    s.unique_operators(),
624                );
625                assert_eq!(s.unique_operands(), 4);
626                insta::assert_json_snapshot!(metric.halstead);
627            },
628        );
629    }
630
631    #[test]
632    fn cpp_operators_and_operands() {
633        // Define operators and operands for C/C++ grammar according to this specification:
634        // https://www.verifysoft.com/en_halstead_metrics.html
635        // The only difference with the specification above is that
636        // primitive types are treated as operators, since the definition of a
637        // primitive type can be seen as the creation of a slot of a certain size.
638        // i.e. The `int a;` definition creates a n-bytes slot.
639        check_metrics::<CppParser>(
640            "main()
641            {
642              int a, b, c, avg;
643              scanf(\"%d %d %d\", &a, &b, &c);
644              avg = (a + b + c) / 3;
645              printf(\"avg = %d\", avg);
646            }",
647            "foo.c",
648            |metric| {
649                // unique operators: (), {}, int, &, =, +, /, ,, ;
650                // unique operands: main, a, b, c, avg, scanf, "%d %d %d", 3, printf, "avg = %d"
651                insta::assert_json_snapshot!(
652                    metric.halstead,
653                    @r#"
654                {
655                  "unique_operators": 9,
656                  "total_operators": 24,
657                  "unique_operands": 10,
658                  "total_operands": 18,
659                  "length": 42,
660                  "estimated_program_length": 61.74860596185444,
661                  "purity_ratio": 1.470204903853677,
662                  "vocabulary": 19,
663                  "volume": 178.41295556463058,
664                  "difficulty": 8.1,
665                  "level": 0.1234567901234568,
666                  "effort": 1445.1449400735075,
667                  "time": 80.28583000408375,
668                  "bugs": 0.04260752914034329
669                }
670                "#
671                );
672            },
673        );
674    }
675
676    /// A `sized_type_specifier` carries its `unsigned`/`signed`/`long`/
677    /// `short` modifiers as bare keyword tokens (distinct kind_ids), not
678    /// as `primitive_type` children. Prior to issue #466 those tokens
679    /// fell through to the `Unknown` arm and were dropped from `n1`/`N1`,
680    /// so `unsigned int` collapsed to just `int` and `signed long`
681    /// contributed nothing. They must each count as a distinct operator,
682    /// while `long long`'s two `long` tokens fold to one `n1` entry but
683    /// two `N1` hits. Regression test for issue #466.
684    #[test]
685    fn cpp_sized_type_specifier_operators() {
686        let source = "unsigned int u = 3; signed long b = 4; long long c = 5;";
687        check_metrics::<CppParser>(source, "foo.cpp", |metric| {
688            // Distinct operators (n1): unsigned, signed, long, int, =, ; = 6
689            // Total operators (N1):
690            //   unsigned(1) + int(1) + =(3) + ;(3) + signed(1) + long(3) = 12
691            //   (`long` appears once in `signed long` and twice in `long long`)
692            // Distinct/total operands: u, b, c, 3, 4, 5 = 6 / 6
693            assert_eq!(metric.halstead.unique_operators(), 6);
694            assert_eq!(metric.halstead.total_operators(), 12);
695            assert_eq!(metric.halstead.unique_operands(), 6);
696            assert_eq!(metric.halstead.total_operands(), 6);
697        });
698
699        // Pin the lesson-4 `n1 == dedupe(ops.operators)` invariant: the
700        // kind_id-keyed metrics store and the text-keyed `--ops` store are
701        // independent, so a modifier classified in one but not the other
702        // would diverge here.
703        let path = PathBuf::from("foo.cpp");
704        let parser = CppParser::new(source.as_bytes().to_vec(), &path, None);
705        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
706        let unique_operators: HashSet<&str> = ops.operators.iter().map(String::as_str).collect();
707        assert_eq!(
708            unique_operators.len(),
709            6,
710            "dedupe(ops.operators) must equal n1; operators were {:?}",
711            ops.operators
712        );
713        for modifier in ["unsigned", "signed", "long"] {
714            assert!(
715                unique_operators.contains(modifier),
716                "sized_type_specifier modifier {modifier:?} missing from ops.operators: {:?}",
717                ops.operators
718            );
719        }
720    }
721
722    /// C++20 spaceship operator `<=>` (`Cpp::LTEQGT`) is a comparison
723    /// operator and must be counted in Halstead, like its sibling
724    /// comparison operators `<`, `>`, `<=`, `>=`, `==`, `!=`. Prior to
725    /// this fix it fell through to the `Unknown` arm and was silently
726    /// dropped from `n1` / `N1`, under-reporting volume / effort on any
727    /// C++20+ codebase that defines `operator<=>`. Regression test for
728    /// issue #197.
729    #[test]
730    fn cpp_spaceship_operator_is_halstead_operator() {
731        check_metrics::<CppParser>(
732            "int f(int a, int b) {
733                 return (a <=> b) != 0;
734             }",
735            "foo.cpp",
736            |metric| {
737                // Unique operators (grammar collapses matched delimiters
738                // to a single kind_id): int, (), {}, <=>, !=, return, ;, ,
739                //   `<=>` is the regression target — without the fix it
740                //   would be Unknown and `u_operators` would be 7.
741                // Unique operands: f, a, b, 0
742                let s = &metric.halstead;
743                assert_eq!(s.unique_operators(), 8);
744                assert_eq!(s.unique_operands(), 4);
745                insta::assert_json_snapshot!(
746                    s,
747                    @r#"
748                {
749                  "unique_operators": 8,
750                  "total_operators": 11,
751                  "unique_operands": 4,
752                  "total_operands": 6,
753                  "length": 17,
754                  "estimated_program_length": 32.0,
755                  "purity_ratio": 1.8823529411764706,
756                  "vocabulary": 12,
757                  "volume": 60.94436251225965,
758                  "difficulty": 6.0,
759                  "level": 0.16666666666666666,
760                  "effort": 365.6661750735579,
761                  "time": 20.31478750408655,
762                  "bugs": 0.01704519358507665
763                }
764                "#
765                );
766            },
767        );
768    }
769
770    /// C++ compound subtract-assign `-=` (`Cpp::DASHEQ`) must be counted
771    /// in Halstead like every other compound assignment (`+=`, `*=`,
772    /// `/=`, etc.). Prior to the fix it fell through to the `Unknown`
773    /// arm and was silently dropped from `n1` / `N1` — under-reporting
774    /// volume / effort wherever C++ code subtracts in place. Regression
775    /// test for issue #198.
776    #[test]
777    fn cpp_dash_eq_is_halstead_operator() {
778        check_metrics::<CppParser>("void f(int a, int b) { a -= b; }", "foo.cpp", |metric| {
779            // Unique operators: void, (), {}, int, ,, -=, ;
780            //   `-=` is the regression target — without the fix it
781            //   would be Unknown and `u_operators` would be 6.
782            // Unique operands: f, a, b
783            let s = &metric.halstead;
784            assert_eq!(s.unique_operators(), 7);
785            assert_eq!(s.unique_operands(), 3);
786        });
787    }
788
789    /// C++ pointer-to-member access `.*` (`Cpp::DOTSTAR`) must be
790    /// counted in Halstead. Prior to the fix it fell through to the
791    /// `Unknown` arm and was silently dropped from `n1` / `N1`.
792    /// Regression test for issue #198.
793    ///
794    /// The snippet uses an `operator.*` declaration because that is
795    /// where the C++ tree-sitter grammar reliably emits a single
796    /// `DOTSTAR` leaf; in expression position (`a.*b`) some grammar
797    /// versions split the token into `DOT` + `STAR` and the regression
798    /// would be masked.
799    #[test]
800    fn cpp_dot_star_is_halstead_operator() {
801        check_metrics::<CppParser>("struct S { void operator.*(int); };", "foo.cpp", |metric| {
802            // Unique operators with fix: {}, ;, (), int, void, .*
803            //   `.*` is the regression target — without the fix it
804            //   falls through to `Unknown` and `u_operators` is 5.
805            // Unique operands: S
806            let s = &metric.halstead;
807            assert_eq!(s.unique_operators(), 6);
808            assert_eq!(s.unique_operands(), 1);
809        });
810    }
811
812    /// C++ pointer-to-member access through pointer `->*`
813    /// (`Cpp::DASHGTSTAR`) must be counted in Halstead. Prior to the
814    /// fix it fell through to the `Unknown` arm and was silently
815    /// dropped from `n1` / `N1`. Regression test for issue #198.
816    ///
817    /// The snippet uses an `operator->*` declaration because that is
818    /// where the C++ tree-sitter grammar reliably emits a single
819    /// `DASHGTSTAR` leaf; in expression position (`a->*b`) the grammar
820    /// splits the token into `DASHGT` + `STAR` and the regression would
821    /// be masked.
822    #[test]
823    fn cpp_dash_gt_star_is_halstead_operator() {
824        check_metrics::<CppParser>(
825            "struct S { void operator->*(int); };",
826            "foo.cpp",
827            |metric| {
828                // Unique operators with fix: {}, ;, (), int, void, ->*
829                //   `->*` is the regression target — without the fix it
830                //   falls through to `Unknown` and `u_operators` is 5.
831                // Unique operands: S
832                let s = &metric.halstead;
833                assert_eq!(s.unique_operators(), 6);
834                assert_eq!(s.unique_operands(), 1);
835            },
836        );
837    }
838
839    #[test]
840    fn rust_operators_and_operands() {
841        check_metrics::<RustParser>(
842            "fn main() {
843              let a = 5; let b = 5; let c = 5;
844              let avg = (a + b + c) / 3;
845              println!(\"{}\", avg);
846            }",
847            "foo.rs",
848            |metric| {
849                // unique operators: fn, (), {}, let, =, +, /, ;, !, ,
850                // unique operands: main, a, b, c, avg, 5, 3, println, "{}"
851                insta::assert_json_snapshot!(
852                    metric.halstead,
853                    @r#"
854                {
855                  "unique_operators": 10,
856                  "total_operators": 23,
857                  "unique_operands": 9,
858                  "total_operands": 15,
859                  "length": 38,
860                  "estimated_program_length": 61.74860596185444,
861                  "purity_ratio": 1.624963314785643,
862                  "vocabulary": 19,
863                  "volume": 161.42124551085624,
864                  "difficulty": 8.333333333333334,
865                  "level": 0.12,
866                  "effort": 1345.177045923802,
867                  "time": 74.7320581068779,
868                  "bugs": 0.040619232256751396
869                }
870                "#
871                );
872            },
873        );
874    }
875
876    #[test]
877    fn rust_aliased_primitive_type_classification() {
878        // Regression for issue #95 (lesson #2): the Rust grammar emits 17
879        // distinct `kind_id`s for `primitive_type` (one base plus 16
880        // numeric-suffixed alias variants). `RustCode::is_primitive` in
881        // `src/checker.rs` must list every variant; if a future regression
882        // omits one, primitive type names emitted in that aliased position
883        // silently drop into the kind_id-keyed operators bucket instead of
884        // the text-keyed primitive_operators map, miscounting Halstead n1.
885        //
886        // The snippet exercises every primitive scalar type across many
887        // syntactic positions (function parameter types, return types,
888        // let-binding annotations, `as` casts, const items, type aliases,
889        // struct fields, function pointer types, tuple types, array types,
890        // reference types, generic type arguments). Empirically, ordinary
891        // Rust source emits the base `Rust::PrimitiveType` variant from
892        // all of these positions; the 16 suffixed alias variants are
893        // produced by specific grammar productions not reachable from
894        // user-written code. Mutation-verified: dropping
895        // `Rust::PrimitiveType` from `is_primitive` fails this test
896        // (u_operators 30→15). Dropping any single suffixed variant
897        // currently leaves the test passing; if a future grammar bump
898        // makes any suffixed variant reachable from idiomatic source,
899        // extend the snippet so the test fires for that variant too.
900        check_metrics::<RustParser>(
901            "const C: u8 = 0;
902            type T = i64;
903            struct S { x: u32, y: u64 }
904            fn g(p: fn(u8) -> u16) -> bool { let _ = p(0); true }
905            fn f(a: u8, b: u16, c: u32, d: u64) -> u128 {
906                let _x: i8 = 0;
907                let _y: i16 = 0;
908                let _z: i32 = 0;
909                let _w: i64 = 0;
910                let _v: i128 = 0;
911                let _p: f32 = 1.0;
912                let _q: f64 = 2.0;
913                let _r: bool = true;
914                let _s: char = 'x';
915                let _t: usize = 0;
916                let _u: isize = 0;
917                let _arr: [u32; 4] = [0; 4];
918                let _ref: &u8 = &0;
919                let _tup: (u32, u64) = (0, 0);
920                let _opt: Option<u32> = None;
921                a as u128 + b as u128 + c as u128 + d
922            }",
923            "foo.rs",
924            |metric| {
925                // Headline: u_operators is the load-bearing assertion —
926                // the 16 distinct primitive type names dedupe by text in
927                // the primitive_operators map. Total operators (N1) and
928                // operand counts pin the rest of the Halstead state.
929                // Grew from 30 → 33 with the issue #394 fix: `const`,
930                // `type`, and `struct` keywords are now classified as
931                // operators (one occurrence each).
932                assert_eq!(metric.halstead.unique_operators(), 33);
933                assert_eq!(metric.halstead.total_operators(), 121);
934                // u_operands / operands grew (was 31/50 before #390): the
935                // fix now classifies TypeIdentifier (`T`, `S`, `Option`)
936                // and FieldIdentifier (struct fields `x`, `y`) as operands
937                // alongside the existing primitive type names.
938                assert_eq!(metric.halstead.unique_operands(), 36);
939                assert_eq!(metric.halstead.total_operands(), 55);
940            },
941        );
942    }
943
944    #[test]
945    fn rust_field_identifier_is_operand() {
946        // Regression for issue #390: prior to the fix, `FieldIdentifier`
947        // (e.g. the `x` / `y` in `p.x`, `p.y`) fell through to
948        // `HalsteadType::Unknown`, so the field names were not counted
949        // as operands. Both C++ and Go already classify FieldIdentifier
950        // as an operand. After the fix:
951        //   unique operators: fn, (), {}, let, =, +, ;, .
952        //   unique operands : main, p, Point, x, y, sum, 0, 1
953        // Field names `x` and `y` each appear twice (`p.x + p.y` and
954        // the struct literal `Point { x: 0, y: 1 }`).
955        check_metrics::<RustParser>(
956            "fn main() {
957              let p = Point { x: 0, y: 1 };
958              let sum = p.x + p.y;
959            }",
960            "foo.rs",
961            |metric| {
962                // Headline: pre-fix, FieldIdentifier (`x`, `y`) and
963                // TypeIdentifier (`Point`) fell through to Unknown, so
964                // u_operands was 5 (main, p, sum, 0, 1). After the
965                // fix, +Point, +x, +y → 8 distinct names.
966                assert_eq!(metric.halstead.unique_operands(), 8);
967                assert_eq!(metric.halstead.total_operands(), 12);
968                insta::assert_json_snapshot!(
969                    metric.halstead,
970                    @r#"
971                {
972                  "unique_operators": 9,
973                  "total_operators": 14,
974                  "unique_operands": 8,
975                  "total_operands": 12,
976                  "length": 26,
977                  "estimated_program_length": 52.529325012980806,
978                  "purity_ratio": 2.0203586543454155,
979                  "vocabulary": 17,
980                  "volume": 106.27403387250882,
981                  "difficulty": 6.75,
982                  "level": 0.14814814814814814,
983                  "effort": 717.3497286394346,
984                  "time": 39.85276270219081,
985                  "bugs": 0.026711567292222575
986                }
987                "#
988                );
989            },
990        );
991    }
992
993    #[test]
994    fn rust_type_identifier_is_operand() {
995        // Regression for issue #390: `TypeIdentifier` (e.g. `Vec`,
996        // `HashMap`, `String` when used as a path name) was dropped to
997        // `HalsteadType::Unknown` for Rust. C++ and Go classify them as
998        // operands. After the fix, u_operands = 8:
999        //   main, v, m, Vec, HashMap, new, K, V
1000        // (`i32` is a primitive type, classified as an operator.)
1001        //
1002        // Also covers issue #394: `::` is now an operator. The snippet
1003        // has two `::` tokens (`Vec::new`, `HashMap::new`), so n1 grew
1004        // from 10 → 11 and N1 from 17 → 19.
1005        check_metrics::<RustParser>(
1006            "fn main() {
1007              let v: Vec<i32> = Vec::new();
1008              let m: HashMap<K, V> = HashMap::new();
1009            }",
1010            "foo.rs",
1011            |metric| {
1012                // Headline: u_operands includes `Vec`, `HashMap`, `K`,
1013                // `V` (and `i32` as a primitive operator). Without the
1014                // fix, Vec/HashMap/K/V silently dropped to Unknown.
1015                assert_eq!(metric.halstead.unique_operands(), 8);
1016                assert_eq!(metric.halstead.total_operands(), 11);
1017                // `::` appears twice (Vec::new, HashMap::new); without
1018                // the #394 fix u_operators was 10 and operators 17.
1019                assert_eq!(metric.halstead.unique_operators(), 11);
1020                assert_eq!(metric.halstead.total_operators(), 19);
1021                insta::assert_json_snapshot!(
1022                    metric.halstead,
1023                    @r#"
1024                {
1025                  "unique_operators": 11,
1026                  "total_operators": 19,
1027                  "unique_operands": 8,
1028                  "total_operands": 11,
1029                  "length": 30,
1030                  "estimated_program_length": 62.05374780501027,
1031                  "purity_ratio": 2.068458260167009,
1032                  "vocabulary": 19,
1033                  "volume": 127.43782540330756,
1034                  "difficulty": 7.5625,
1035                  "level": 0.1322314049586777,
1036                  "effort": 963.7485546125134,
1037                  "time": 53.54158636736186,
1038                  "bugs": 0.03252279825177962
1039                }
1040                "#
1041                );
1042            },
1043        );
1044    }
1045
1046    #[test]
1047    fn rust_path_separator_is_operator() {
1048        // Regression for issue #394: `::` (`COLONCOLON`) was missing
1049        // from the Rust `get_op_type` operator arm even though C++,
1050        // Java, C#, and Kotlin all classify it as an operator. Path-
1051        // heavy code (`std::collections::HashMap`, `Vec::new`,
1052        // `T::method`) had every `::` silently dropped into
1053        // HalsteadType::Unknown.
1054        //
1055        // Snippet has three `::` tokens (`std::collections::HashMap`,
1056        // counted as two `::` separators, plus `HashMap::new`).
1057        check_metrics::<RustParser>(
1058            "fn main() {
1059              let m = std::collections::HashMap::new();
1060            }",
1061            "foo.rs",
1062            |metric| {
1063                // `::` appears 3 times across the two path expressions
1064                // (`std::collections::HashMap` contributes two; the
1065                // `HashMap::new` contributes one). Pre-fix all three
1066                // dropped to Unknown: u_operators would be 6 (no `::`
1067                // distinct) and total_operators() would be 7 (minus 3 `::`
1068                // occurrences). With the fix u_operators=7 and
1069                // operators=10.
1070                //
1071                // unique operators (post-fix): fn, LPAREN, LBRACE,
1072                // let, =, ::, ;. unique operands: main, m, std,
1073                // collections, HashMap, new.
1074                assert_eq!(metric.halstead.unique_operators(), 7);
1075                assert_eq!(metric.halstead.total_operators(), 10);
1076                assert_eq!(metric.halstead.unique_operands(), 6);
1077                assert_eq!(metric.halstead.total_operands(), 6);
1078            },
1079        );
1080    }
1081
1082    #[test]
1083    fn rust_declaration_keywords_are_operators() {
1084        // Regression for issue #394: the Rust impl already accepted 17
1085        // keywords as operators (As, Async, Await, …, Fn) but omitted
1086        // 14 declaration / visibility keywords. The fix adds `Const`,
1087        // `Static`, `Enum`, `Struct`, `Trait`, `Impl`, `Use`, `Mod`,
1088        // `Pub`, `Type`, `Union`, `Where`, `Extern`, `Dyn`.
1089        //
1090        // Snippet exercises `use`, `pub`, `struct`, and `impl` (one of
1091        // each); together they account for 4 new operator occurrences
1092        // and 4 new unique operators.
1093        check_metrics::<RustParser>(
1094            "use std::fmt;
1095            pub struct S;
1096            impl S { fn n() -> u8 { 0 } }",
1097            "foo.rs",
1098            |metric| {
1099                // expected: unique operators (11) = use, ::, ;, pub,
1100                // struct, impl, LBRACE, fn, LPAREN, DASHGT, u8. Without
1101                // the #394 fix, `use`, `pub`, `struct`, and `impl`
1102                // would each drop to Unknown and u_operators would be
1103                // 7. unique operands (5): std, fmt, S, n, 0.
1104                assert_eq!(metric.halstead.unique_operators(), 11);
1105                assert_eq!(metric.halstead.total_operators(), 13);
1106                assert_eq!(metric.halstead.unique_operands(), 5);
1107                assert_eq!(metric.halstead.total_operands(), 6);
1108            },
1109        );
1110    }
1111
1112    #[test]
1113    fn javascript_operators_and_operands() {
1114        check_metrics::<JavascriptParser>(
1115            "function main() {
1116              var a, b, c, avg;
1117              a = 5; b = 5; c = 5;
1118              avg = (a + b + c) / 3;
1119              console.log(\"{}\", avg);
1120            }",
1121            "foo.js",
1122            |metric| {
1123                // unique operators: function, (), {}, var, =, +, /, ,, ., ;
1124                // unique operands: main, a, b, c, avg, 3, 5, console.log, console, log, "{}"
1125                insta::assert_json_snapshot!(
1126                    metric.halstead,
1127                    @r#"
1128                {
1129                  "unique_operators": 10,
1130                  "total_operators": 24,
1131                  "unique_operands": 11,
1132                  "total_operands": 21,
1133                  "length": 45,
1134                  "estimated_program_length": 71.27302875388389,
1135                  "purity_ratio": 1.583845083419642,
1136                  "vocabulary": 21,
1137                  "volume": 197.65428402504423,
1138                  "difficulty": 9.545454545454545,
1139                  "level": 0.10476190476190476,
1140                  "effort": 1886.699983875422,
1141                  "time": 104.81666577085679,
1142                  "bugs": 0.05089564733125986
1143                }
1144                "#
1145                );
1146            },
1147        );
1148    }
1149
1150    #[test]
1151    fn mozjs_operators_and_operands() {
1152        check_metrics::<MozjsParser>(
1153            "function main() {
1154              var a, b, c, avg;
1155              a = 5; b = 5; c = 5;
1156              avg = (a + b + c) / 3;
1157              console.log(\"{}\", avg);
1158            }",
1159            "foo.js",
1160            |metric| {
1161                // unique operators: function, (), {}, var, =, +, /, ,, ., ;
1162                // unique operands: main, a, b, c, avg, 3, 5, console.log, console, log, "{}"
1163                insta::assert_json_snapshot!(
1164                    metric.halstead,
1165                    @r#"
1166                {
1167                  "unique_operators": 10,
1168                  "total_operators": 24,
1169                  "unique_operands": 11,
1170                  "total_operands": 21,
1171                  "length": 45,
1172                  "estimated_program_length": 71.27302875388389,
1173                  "purity_ratio": 1.583845083419642,
1174                  "vocabulary": 21,
1175                  "volume": 197.65428402504423,
1176                  "difficulty": 9.545454545454545,
1177                  "level": 0.10476190476190476,
1178                  "effort": 1886.699983875422,
1179                  "time": 104.81666577085679,
1180                  "bugs": 0.05089564733125986
1181                }
1182                "#
1183                );
1184            },
1185        );
1186    }
1187
1188    #[test]
1189    fn typescript_operators_and_operands() {
1190        check_metrics::<TypescriptParser>(
1191            "function main() {
1192              var a, b, c, avg;
1193              a = 5; b = 5; c = 5;
1194              avg = (a + b + c) / 3;
1195              console.log(\"{}\", avg);
1196            }",
1197            "foo.ts",
1198            |metric| {
1199                // unique operators: function, (), {}, var, =, +, /, ,, ., ;
1200                // unique operands: main, a, b, c, avg, 3, 5, console.log, console, log, "{}"
1201                insta::assert_json_snapshot!(
1202                    metric.halstead,
1203                    @r#"
1204                {
1205                  "unique_operators": 10,
1206                  "total_operators": 24,
1207                  "unique_operands": 11,
1208                  "total_operands": 21,
1209                  "length": 45,
1210                  "estimated_program_length": 71.27302875388389,
1211                  "purity_ratio": 1.583845083419642,
1212                  "vocabulary": 21,
1213                  "volume": 197.65428402504423,
1214                  "difficulty": 9.545454545454545,
1215                  "level": 0.10476190476190476,
1216                  "effort": 1886.699983875422,
1217                  "time": 104.81666577085679,
1218                  "bugs": 0.05089564733125986
1219                }
1220                "#
1221                );
1222            },
1223        );
1224    }
1225
1226    #[test]
1227    fn tsx_operators_and_operands() {
1228        check_metrics::<TsxParser>(
1229            "function main() {
1230              var a, b, c, avg;
1231              a = 5; b = 5; c = 5;
1232              avg = (a + b + c) / 3;
1233              console.log(\"{}\", avg);
1234            }",
1235            "foo.ts",
1236            |metric| {
1237                // unique operators: function, (), {}, var, =, +, /, ,, ., ;
1238                // unique operands: main, a, b, c, avg, 3, 5, console.log, console, log, "{}"
1239                insta::assert_json_snapshot!(
1240                    metric.halstead,
1241                    @r#"
1242                {
1243                  "unique_operators": 10,
1244                  "total_operators": 24,
1245                  "unique_operands": 11,
1246                  "total_operands": 21,
1247                  "length": 45,
1248                  "estimated_program_length": 71.27302875388389,
1249                  "purity_ratio": 1.583845083419642,
1250                  "vocabulary": 21,
1251                  "volume": 197.65428402504423,
1252                  "difficulty": 9.545454545454545,
1253                  "level": 0.10476190476190476,
1254                  "effort": 1886.699983875422,
1255                  "time": 104.81666577085679,
1256                  "bugs": 0.05089564733125986
1257                }
1258                "#
1259                );
1260            },
1261        );
1262    }
1263
1264    #[test]
1265    fn javascript_template_string_plain_is_operand() {
1266        // Regression: issue #192. A backtick-delimited `` `hello` ``
1267        // without `${...}` is semantically identical to `"hello"` /
1268        // `'hello'` and must contribute exactly one operand — before
1269        // the fix `TemplateString` fell through to `HalsteadType::Unknown`
1270        // and contributed zero. expected: operands are `f` (function
1271        // name) and the wrapping `` `hello` `` template literal →
1272        // u_operands = 2, N2 = 2 (matches the equivalent
1273        // `function f() { return "hello"; }` baseline).
1274        check_metrics::<JavascriptParser>("function f() { return `hello`; }", "foo.js", |metric| {
1275            assert_eq!(metric.halstead.unique_operands(), 2);
1276            assert_eq!(metric.halstead.total_operands(), 2);
1277        });
1278    }
1279
1280    /// Regression for #695. The `get` / `set` property-accessor keywords
1281    /// are operators, matching the C# getter's `Get | Set | Init | Add |
1282    /// Remove` accessor arm. Before #695 the JS family classified them as
1283    /// operands, so the same accessor keyword landed in opposite Halstead
1284    /// groups across languages. This pins them in the operator store and
1285    /// out of the operand store.
1286    #[test]
1287    fn js_get_set_accessors_are_operators() {
1288        let source = "class C { get x() { return 1; } set x(v) { this._x = v; } }";
1289        let path = PathBuf::from("foo.js");
1290        let parser = JavascriptParser::new(source.as_bytes().to_vec(), &path, None);
1291        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
1292        assert!(
1293            ops.operators.iter().any(|o| o.as_str() == "get")
1294                && ops.operators.iter().any(|o| o.as_str() == "set"),
1295            "`get`/`set` accessors must be operators; operators were {:?}",
1296            ops.operators
1297        );
1298        assert!(
1299            !ops.operands.iter().any(|o| o.as_str() == "get")
1300                && !ops.operands.iter().any(|o| o.as_str() == "set"),
1301            "`get`/`set` accessors must not be operands; operands were {:?}",
1302            ops.operands
1303        );
1304    }
1305
1306    #[test]
1307    fn javascript_template_string_interpolation_no_double_count() {
1308        // Regression: issue #192. An interpolated template literal
1309        // `` `Hi ${name}!` `` used to fall through to `Unknown`,
1310        // dropping the wrapper from the count entirely; the inner
1311        // `name` was still walked and counted via the
1312        // `TemplateSubstitution` child. Mirrors #183 (C#), #191
1313        // (Kotlin), #199 (Perl): the wrapper is skipped when a
1314        // `TemplateSubstitution` child is present so the inner
1315        // expression is not double-counted.
1316        //
1317        // expected: for `function f(name) { return ` + "`Hi ${name}!`"
1318        // + `; }`, operands are `f` and `name` (twice — `name` as the
1319        // parameter, then again inside the interpolation), so
1320        // u_operands = 2 and N2 = 3. Without the wrapper-skip guard
1321        // the wrapping literal would also be counted, lifting
1322        // u_operands to 3 and N2 to 4.
1323        check_metrics::<JavascriptParser>(
1324            "function f(name) { return `Hi ${name}!`; }",
1325            "foo.js",
1326            |metric| {
1327                assert_eq!(metric.halstead.unique_operands(), 2);
1328                assert_eq!(metric.halstead.total_operands(), 3);
1329            },
1330        );
1331    }
1332
1333    #[test]
1334    fn mozjs_template_string_plain_is_operand() {
1335        // Regression: issue #192. Mirrors
1336        // `javascript_template_string_plain_is_operand` for the
1337        // Firefox-mode dialect — the four JS-family `get_op_type`
1338        // impls share the same template-literal handling.
1339        check_metrics::<MozjsParser>("function f() { return `hello`; }", "foo.js", |metric| {
1340            assert_eq!(metric.halstead.unique_operands(), 2);
1341            assert_eq!(metric.halstead.total_operands(), 2);
1342        });
1343    }
1344
1345    #[test]
1346    fn mozjs_template_string_interpolation_no_double_count() {
1347        // Regression: issue #192. Mirrors
1348        // `javascript_template_string_interpolation_no_double_count`
1349        // for the Firefox-mode dialect.
1350        check_metrics::<MozjsParser>(
1351            "function f(name) { return `Hi ${name}!`; }",
1352            "foo.js",
1353            |metric| {
1354                assert_eq!(metric.halstead.unique_operands(), 2);
1355                assert_eq!(metric.halstead.total_operands(), 3);
1356            },
1357        );
1358    }
1359
1360    #[test]
1361    fn typescript_template_string_plain_is_operand() {
1362        // Regression: issue #192. Mirrors
1363        // `javascript_template_string_plain_is_operand` for
1364        // TypeScript — the four JS-family `get_op_type` impls share
1365        // the same template-literal handling.
1366        //
1367        // After #313 the `: string` annotation's `String2` child also
1368        // counts as an operand (text `"string"`), so unique operands
1369        // are `f`, `` `hello` ``, `string` (3 each). The headline of
1370        // this test — that the plain template literal contributes one
1371        // operand — is unaffected.
1372        check_metrics::<TypescriptParser>(
1373            "function f(): string { return `hello`; }",
1374            "foo.ts",
1375            |metric| {
1376                assert_eq!(metric.halstead.unique_operands(), 3);
1377                assert_eq!(metric.halstead.total_operands(), 3);
1378            },
1379        );
1380    }
1381
1382    #[test]
1383    fn typescript_template_string_interpolation_no_double_count() {
1384        // Regression: issue #192. Mirrors
1385        // `javascript_template_string_interpolation_no_double_count`
1386        // for TypeScript.
1387        //
1388        // After #313 each `: string` annotation contributes one
1389        // `"string"` operand. Unique operands: `f`, `name`, `string`
1390        // (3). Total operands: `f`, `name` (param), `name` (in the
1391        // interpolation), `string`, `string` (5). The interpolation
1392        // guard from #192 still holds — the wrapping `` `Hi ${name}!` ``
1393        // is `Unknown`, not double-counted.
1394        check_metrics::<TypescriptParser>(
1395            "function f(name: string): string { return `Hi ${name}!`; }",
1396            "foo.ts",
1397            |metric| {
1398                assert_eq!(metric.halstead.unique_operands(), 3);
1399                assert_eq!(metric.halstead.total_operands(), 5);
1400            },
1401        );
1402    }
1403
1404    #[test]
1405    fn tsx_template_string_plain_is_operand() {
1406        // Regression: issue #192. Mirrors
1407        // `javascript_template_string_plain_is_operand` for the
1408        // TSX (TypeScript + JSX) variant.
1409        //
1410        // After #313 TSX's type-keyword `string` (`String3`) also
1411        // counts as an operand, mirroring TS::String2.
1412        check_metrics::<TsxParser>(
1413            "function f(): string { return `hello`; }",
1414            "foo.tsx",
1415            |metric| {
1416                assert_eq!(metric.halstead.unique_operands(), 3);
1417                assert_eq!(metric.halstead.total_operands(), 3);
1418            },
1419        );
1420    }
1421
1422    #[test]
1423    fn tsx_template_string_interpolation_no_double_count() {
1424        // Regression: issue #192. Mirrors
1425        // `javascript_template_string_interpolation_no_double_count`
1426        // for the TSX (TypeScript + JSX) variant.
1427        //
1428        // After #313 each `: string` annotation contributes one
1429        // `String3` operand; see `typescript_template_string_…` for
1430        // the count derivation.
1431        check_metrics::<TsxParser>(
1432            "function f(name: string): string { return `Hi ${name}!`; }",
1433            "foo.tsx",
1434            |metric| {
1435                assert_eq!(metric.halstead.unique_operands(), 3);
1436                assert_eq!(metric.halstead.total_operands(), 5);
1437            },
1438        );
1439    }
1440
1441    // Issue #281: optional chaining (`?.`) was double-counted as a
1442    // Halstead operator in TypeScript and TSX because the grammar
1443    // exposes both an `optional_chain` named wrapper AND a child
1444    // `?.` token, and both were classified as `Operator`. The fix
1445    // counts only the bare `?.` token (`QMARKDOT`) in TS/TSX so each
1446    // textual `?.` contributes exactly once, matching JS / MozJS
1447    // (whose grammars expose only `OptionalChain` — the `?.` token
1448    // itself).
1449    //
1450    // The four assertions below all compare against the same totals:
1451    // for `function f(a) { return a?.b?.c; }` the operator stream is
1452    // `function`, `(`, `{`, `return`, `?.`, `?.`, `;` (7 total, 6
1453    // unique — `LPAREN`/`LBRACE` count once, closing tokens are not
1454    // in the operator set). Before the fix, TS/TSX reported 9/7
1455    // instead of 7/6.
1456    #[test]
1457    fn javascript_optional_chain_not_double_counted_in_halstead_281() {
1458        check_metrics::<JavascriptParser>("function f(a) { return a?.b?.c; }", "foo.js", |m| {
1459            assert_eq!(m.halstead.unique_operators(), 6);
1460            assert_eq!(m.halstead.total_operators(), 7);
1461        });
1462    }
1463
1464    #[test]
1465    fn mozjs_optional_chain_not_double_counted_in_halstead_281() {
1466        check_metrics::<MozjsParser>("function f(a) { return a?.b?.c; }", "foo.js", |m| {
1467            assert_eq!(m.halstead.unique_operators(), 6);
1468            assert_eq!(m.halstead.total_operators(), 7);
1469        });
1470    }
1471
1472    #[test]
1473    fn typescript_optional_chain_not_double_counted_in_halstead_281() {
1474        // The TS grammar wraps member-expression `?.` in an
1475        // `optional_chain` named node containing the bare `?.`
1476        // token; classifying both as `Operator` double-counted the
1477        // chain. We now count only the bare token, so TS matches JS.
1478        check_metrics::<TypescriptParser>("function f(a) { return a?.b?.c; }", "foo.ts", |m| {
1479            assert_eq!(m.halstead.unique_operators(), 6);
1480            assert_eq!(m.halstead.total_operators(), 7);
1481        });
1482    }
1483
1484    #[test]
1485    fn tsx_optional_chain_not_double_counted_in_halstead_281() {
1486        check_metrics::<TsxParser>("function f(a) { return a?.b?.c; }", "foo.tsx", |m| {
1487            assert_eq!(m.halstead.unique_operators(), 6);
1488            assert_eq!(m.halstead.total_operators(), 7);
1489        });
1490    }
1491
1492    // Issue #299: parity guard for the JS-family `get_op_type` macro
1493    // on the optional-chain operator token (#281's prior regression
1494    // surface). All four languages must classify the bare `?.` token
1495    // identically — `OptionalChain` in JS/MozJS, `QMARKDOT` in
1496    // TS/TSX — and emit the same totals for
1497    // `function f(a) { return a?.b?.c; }`:
1498    //
1499    // * Operators: `function`, `(`, `{`, `return`, `?.`, `?.`, `;`
1500    //   (7 total, 6 unique).
1501    // * Operands: `f`, `a`, `a`, `b`, `c`, plus the two wrapping
1502    //   member expressions (`a?.b`, `a?.b?.c`) classified as
1503    //   `MemberExpression*` (7 total, 6 unique).
1504    //
1505    // Verified by test-via-revert: dropping `OptionalChain` from
1506    // JS/MozJS, or `QMARKDOT` from TS/TSX, trips the test
1507    // (u_operators 6→5). This input does NOT exercise every operand
1508    // alias in the per-language `operand_extras` (`Identifier2`,
1509    // `String2`, `NestedIdentifier`, `MemberExpression4`); drift in
1510    // those is out of scope for this regression guard and would need a
1511    // separate fixture. The `PredefinedType` operator path (`: void`
1512    // double-count) is now covered by `ts_void_return_type_single_operator_453`
1513    // below.
1514    #[test]
1515    fn js_family_get_op_type_parity_optional_chain_member_299() {
1516        // Non-capturing closure (coerced to the `fn` pointer that
1517        // `check_metrics` accepts) avoids the
1518        // `clippy::needless_pass_by_value` warning that a free `fn`
1519        // taking `CodeMetrics` by value would trigger.
1520        const SRC: &str = "function f(a) { return a?.b?.c; }";
1521        let check = |m: crate::CodeMetrics| {
1522            assert_eq!(m.halstead.unique_operators(), 6);
1523            assert_eq!(m.halstead.total_operators(), 7);
1524            assert_eq!(m.halstead.unique_operands(), 6);
1525            assert_eq!(m.halstead.total_operands(), 7);
1526        };
1527
1528        check_metrics::<JavascriptParser>(SRC, "foo.js", check);
1529        check_metrics::<MozjsParser>(SRC, "foo.js", check);
1530        check_metrics::<TypescriptParser>(SRC, "foo.ts", check);
1531        check_metrics::<TsxParser>(SRC, "foo.tsx", check);
1532    }
1533
1534    // Issue #313: parity guard for the `"string"` type-keyword aliases
1535    // that the TS / TSX grammars expose. `Checker::is_string` matches
1536    // these aliases (#283), so `Getter::get_op_type` must also classify
1537    // them — otherwise the same node disagrees between the two
1538    // predicates and Halstead silently undercounts every `: string`
1539    // annotation by one operand.
1540    //
1541    // For the input `let x: string = "y";`:
1542    //
1543    // * TypeScript emits `Typescript::String2` for the `string` type
1544    //   keyword (kind_id 135, in the type-keyword block of the enum).
1545    // * TSX emits `Tsx::String3` for the same role (kind_id 141).
1546    //
1547    // After #313 both kinds are in `operand_extras` and contribute one
1548    // `"string"` operand. Verified by test-via-revert: dropping
1549    // `String2` from TS's `operand_extras` (or `String3` from TSX's)
1550    // trips this test on `u_operands` / `operands` for the affected
1551    // language.
1552    #[test]
1553    fn ts_family_string2_string3_type_keyword_parity_313() {
1554        const SRC: &str = "let x: string = \"y\";";
1555        // Operators (n1 = 5, N1 = 5):
1556        //   `let`, `:`, `=`, `;`, plus `string` (PredefinedType wrapper,
1557        //   routed through `is_primitive` so it's keyed by its lexeme
1558        //   `"string"` in `primitive_operators`).
1559        // Operands (n2 = 3, N2 = 3):
1560        //   `x`, the `"y"` literal, and `string` (the type-keyword
1561        //   child of `predefined_type`, classified via the operand
1562        //   extras added by #313). Pre-fix the TS column reported
1563        //   n2 = 2 / N2 = 2 because String2 fell through to `Unknown`;
1564        //   the TSX column had the same gap for String3.
1565        let check = |m: crate::CodeMetrics| {
1566            assert_eq!(m.halstead.unique_operators(), 5);
1567            assert_eq!(m.halstead.total_operators(), 5);
1568            assert_eq!(m.halstead.unique_operands(), 3);
1569            assert_eq!(m.halstead.total_operands(), 3);
1570        };
1571
1572        check_metrics::<TypescriptParser>(SRC, "foo.ts", check);
1573        check_metrics::<TsxParser>(SRC, "foo.tsx", check);
1574    }
1575
1576    // Issue #453: a `void` return type must contribute exactly one
1577    // Halstead operator. The TS / TSX grammars parse `: void` as a
1578    // `predefined_type` wrapper around an inner `void` token. `is_primitive`
1579    // routes the wrapper into the text-keyed `primitive_operators` map as
1580    // `"void"`, while the inner `Void` token is independently a standalone
1581    // expression operator (`void 0`). Pre-fix both classified as operators
1582    // and one source `void` counted as TWO distinct Halstead operators.
1583    // The fix suppresses the wrapper when its child is a `Void` token, so
1584    // only the inner token carries the operator — matching expression
1585    // `void 0` and keeping the kind_id-keyed count consistent.
1586    //
1587    // For `function f(): void { return; }`:
1588    //
1589    // * Operators (n1 = 7, N1 = 7): `function`, `()`, `{}`, `:`, `return`,
1590    //   `;`, and a single `void`. (The untyped form is n1 = 5; the `: void`
1591    //   annotation adds the `:` operator and one `void`, NOT two — the
1592    //   issue's "n1 = 6" target overlooked the annotation colon.)
1593    //
1594    // Verified by test-via-revert: removing the `predefined_void` guard
1595    // restores the pre-fix `u_operators` 7 -> 8 with a duplicate `"void"`
1596    // (one kind_id-keyed, one in `primitive_operators`). Both `metrics()`
1597    // and the `ops`-list dedup invariant (`ts_void_return_and_expression_*`
1598    // in `ops.rs`) are pinned per lesson 4.
1599    #[test]
1600    fn ts_void_return_type_single_operator_453() {
1601        const SRC: &str = "function f(): void { return; }";
1602        let check = |m: crate::CodeMetrics| {
1603            assert_eq!(m.halstead.unique_operators(), 7);
1604            assert_eq!(m.halstead.total_operators(), 7);
1605        };
1606
1607        check_metrics::<TypescriptParser>(SRC, "foo.ts", check);
1608        check_metrics::<TsxParser>(SRC, "foo.tsx", check);
1609    }
1610
1611    // Issue #453 over-suppression guard: expression `void 0` (a
1612    // `unary_expression`, NOT a `predefined_type` wrapper) must still
1613    // count `void` as exactly one operator. The fix keys only on a
1614    // `predefined_type` whose child is a `Void` token, so the bare
1615    // expression operator is untouched.
1616    //
1617    // For `const x = void 0;`:
1618    //
1619    // * Operators (n1 = 4, N1 = 4): `const`, `=`, `void`, `;`.
1620    // * Operands (n2 = 2, N2 = 2): `x`, `0`.
1621    #[test]
1622    fn ts_void_expression_still_single_operator_453() {
1623        const SRC: &str = "const x = void 0;";
1624        let check = |m: crate::CodeMetrics| {
1625            assert_eq!(m.halstead.unique_operators(), 4);
1626            assert_eq!(m.halstead.total_operators(), 4);
1627            assert_eq!(m.halstead.unique_operands(), 2);
1628            assert_eq!(m.halstead.total_operands(), 2);
1629        };
1630
1631        check_metrics::<TypescriptParser>(SRC, "foo.ts", check);
1632        check_metrics::<TsxParser>(SRC, "foo.tsx", check);
1633    }
1634
1635    #[test]
1636    fn python_wrong_operators() {
1637        check_metrics::<PythonParser>("()[]{}", "foo.py", |metric| {
1638            insta::assert_json_snapshot!(
1639                metric.halstead,
1640                @r#"
1641            {
1642              "unique_operators": 0,
1643              "total_operators": 0,
1644              "unique_operands": 0,
1645              "total_operands": 0,
1646              "length": 0,
1647              "estimated_program_length": 0.0,
1648              "purity_ratio": 0.0,
1649              "vocabulary": 0,
1650              "volume": 0.0,
1651              "difficulty": 0.0,
1652              "level": 0.0,
1653              "effort": 0.0,
1654              "time": 0.0,
1655              "bugs": 0.0
1656            }
1657            "#
1658            );
1659        });
1660    }
1661
1662    #[test]
1663    fn python_check_metrics() {
1664        check_metrics::<PythonParser>(
1665            "def f():
1666                 pass",
1667            "foo.py",
1668            |metric| {
1669                insta::assert_json_snapshot!(
1670                    metric.halstead,
1671                    @r#"
1672                {
1673                  "unique_operators": 2,
1674                  "total_operators": 2,
1675                  "unique_operands": 1,
1676                  "total_operands": 1,
1677                  "length": 3,
1678                  "estimated_program_length": 2.0,
1679                  "purity_ratio": 0.6666666666666666,
1680                  "vocabulary": 3,
1681                  "volume": 4.754887502163468,
1682                  "difficulty": 1.0,
1683                  "level": 1.0,
1684                  "effort": 4.754887502163468,
1685                  "time": 0.26416041678685936,
1686                  "bugs": 0.0009425525573729414
1687                }
1688                "#
1689                );
1690            },
1691        );
1692    }
1693
1694    #[test]
1695    fn java_operators_and_operands() {
1696        check_metrics::<JavaParser>(
1697            "public class Main {
1698            public static void main(string args[]) {
1699                  int a, b, c, avg;
1700                  a = 5; b = 5; c = 5;
1701                  avg = (a + b + c) / 3;
1702                  MessageFormat.format(\"{0}\", avg);
1703                }
1704            }",
1705            "foo.java",
1706            |metric| {
1707                // Operators (n1=11): {} void () [] , . ; int = + /
1708                // Operands (n2=12): Main main args a b c avg 5 3 MessageFormat format "{0}"
1709                insta::assert_json_snapshot!(
1710                    metric.halstead,
1711                    @r#"
1712                {
1713                  "unique_operators": 11,
1714                  "total_operators": 26,
1715                  "unique_operands": 12,
1716                  "total_operands": 22,
1717                  "length": 48,
1718                  "estimated_program_length": 81.07329781366414,
1719                  "purity_ratio": 1.6890270377846697,
1720                  "vocabulary": 23,
1721                  "volume": 217.13097389073664,
1722                  "difficulty": 10.083333333333334,
1723                  "level": 0.09917355371900825,
1724                  "effort": 2189.4039867315946,
1725                  "time": 121.63355481842193,
1726                  "bugs": 0.05620341201461669
1727                }
1728                "#
1729                );
1730            },
1731        );
1732    }
1733
1734    #[test]
1735    fn java_primitive_types_and_booleans() {
1736        check_metrics::<JavaParser>(
1737            "public class Prims {
1738                byte a = 1;
1739                short b = 2;
1740                int c = 3;
1741                long d = 4;
1742                char e = 'x';
1743                float f = 1.0f;
1744                double g = 2.0;
1745                boolean h = true;
1746                boolean i = false;
1747            }",
1748            "foo.java",
1749            |metric| {
1750                // Verifies all 8 Java primitive-type keywords (byte, short, int, long,
1751                // char, float, double, boolean) are counted as distinct operators, and
1752                // that true/false are counted as operands.
1753                insta::assert_json_snapshot!(
1754                    metric.halstead,
1755                    @r#"
1756                {
1757                  "unique_operators": 11,
1758                  "total_operators": 28,
1759                  "unique_operands": 19,
1760                  "total_operands": 19,
1761                  "length": 47,
1762                  "estimated_program_length": 118.76437056043838,
1763                  "purity_ratio": 2.526901501285923,
1764                  "vocabulary": 30,
1765                  "volume": 230.62385799360038,
1766                  "difficulty": 5.5,
1767                  "level": 0.18181818181818182,
1768                  "effort": 1268.4312189648022,
1769                  "time": 70.46840105360012,
1770                  "bugs": 0.03905920146699976
1771                }
1772                "#
1773                );
1774            },
1775        );
1776    }
1777
1778    #[test]
1779    fn groovy_operators_and_operands() {
1780        check_metrics::<GroovyParser>(
1781            "class Main {
1782                static void main(String[] args) {
1783                    int a, b, c, avg;
1784                    a = 5; b = 5; c = 5;
1785                    avg = (a + b + c) / 3;
1786                    println(avg);
1787                }
1788            }",
1789            "foo.groovy",
1790            |metric| {
1791                // Groovy mirror of `java_operators_and_operands`. The juxt
1792                // call `println avg` exercises `juxt_function_call` in
1793                // place of Java's `MessageFormat.format(...)`. amaanq's
1794                // grammar inherits Java's tokenisation, so n1/N1/n2/N2
1795                // shapes match Java up to those substitutions.
1796                // The dekobon grammar parses primitive type names
1797                // (`void`, `int`, `String`) as `type_identifier`
1798                // rather than as distinct keyword tokens, so they
1799                // count as operands here — the prior amaanq grammar
1800                // treated them as operators. Net shift: −2 unique
1801                // operators (`void`, `int`), +2 unique operands
1802                // (`void`, `int` were the only two type_identifiers
1803                // not already counted as operands, since `String`
1804                // was already an identifier in the prior grammar's
1805                // counting).
1806                assert_eq!(metric.halstead.unique_operators(), 8);
1807                assert_eq!(metric.halstead.unique_operands(), 13);
1808                insta::assert_json_snapshot!(
1809                    metric.halstead,
1810                    @r#"
1811                {
1812                  "unique_operators": 8,
1813                  "total_operators": 22,
1814                  "unique_operands": 13,
1815                  "total_operands": 23,
1816                  "length": 45,
1817                  "estimated_program_length": 72.10571633583419,
1818                  "purity_ratio": 1.6023492519074265,
1819                  "vocabulary": 21,
1820                  "volume": 197.65428402504423,
1821                  "difficulty": 7.076923076923077,
1822                  "level": 0.14130434782608697,
1823                  "effort": 1398.7841638695438,
1824                  "time": 77.71023132608576,
1825                  "bugs": 0.04169134280255714
1826                }
1827                "#
1828                );
1829            },
1830        );
1831    }
1832
1833    #[test]
1834    fn groovy_primitive_types_and_booleans() {
1835        check_metrics::<GroovyParser>(
1836            "class Prims {
1837                byte a = 1
1838                short b = 2
1839                int c = 3
1840                long d = 4
1841                char e = 'x'
1842                float f = 1.0f
1843                double g = 2.0
1844                boolean h = true
1845                boolean i = false
1846            }",
1847            "foo.groovy",
1848            |metric| {
1849                // The dekobon grammar consolidates the 8 primitive
1850                // type names (`byte`, `short`, `int`, `long`, `char`,
1851                // `float`, `double`, `boolean`) under `type_identifier`
1852                // — so they count as operands, not as distinct
1853                // operators. Likewise numeric literals collapse to one
1854                // `NumberLiteral` shape (no Hex/Octal/Binary/Decimal
1855                // split), and `'x'` parses as `StringLiteral` (Groovy
1856                // single-quoted strings) rather than as
1857                // `CharacterLiteral`. Operators remaining in this
1858                // fixture: `=` and `class`-body braces (only `{` is in
1859                // the operator set). True/false collapse under one
1860                // `BooleanLiteral`.
1861                assert_eq!(metric.halstead.unique_operators(), 2);
1862                assert_eq!(metric.halstead.unique_operands(), 27);
1863                insta::assert_json_snapshot!(
1864                    metric.halstead,
1865                    @r#"
1866                {
1867                  "unique_operators": 2,
1868                  "total_operators": 10,
1869                  "unique_operands": 27,
1870                  "total_operands": 28,
1871                  "length": 38,
1872                  "estimated_program_length": 130.38196255841365,
1873                  "purity_ratio": 3.4311042778529908,
1874                  "vocabulary": 29,
1875                  "volume": 184.60327781484773,
1876                  "difficulty": 1.037037037037037,
1877                  "level": 0.9642857142857143,
1878                  "effort": 191.44043625243467,
1879                  "time": 10.635579791801925,
1880                  "bugs": 0.01107221547116606
1881                }
1882                "#
1883                );
1884            },
1885        );
1886    }
1887
1888    #[test]
1889    fn groovy_closure_operators_and_operands() {
1890        check_metrics::<GroovyParser>("def double = { x -> x * 2 }", "foo.groovy", |metric| {
1891            // Closure with arrow-style parameter list.
1892            // Distinct operators: def, =, {}, ->, * = 5.
1893            // Distinct operands: double, x, 2 = 3.
1894            assert_eq!(metric.halstead.unique_operators(), 5);
1895            assert_eq!(metric.halstead.unique_operands(), 3);
1896        });
1897    }
1898
1899    /// Regression for issue #247: every Groovy-specific operator the
1900    /// prior amaanq grammar dropped to ERROR or mis-shaped as a Java
1901    /// node now parses as a distinct lexer token in the dekobon
1902    /// grammar, so Halstead counts each one. The fixture below
1903    /// exercises Elvis `?:`, safe-nav `?.`, safe-chain `??.`,
1904    /// spread-dot `*.`, method-pointer `.&`, direct-field `.@`,
1905    /// identity `===` / `!==`, spaceship `<=>`, regex `=~` / `==~`,
1906    /// exclusive ranges `..<` / `<..` / `<..<`, `as` coercion, and
1907    /// `?[` safe index — every distinct operator kind must appear in
1908    /// `u_operators` (the count grows by exactly the number of new
1909    /// distinct operator tokens introduced).
1910    #[test]
1911    fn groovy_dekobon_operator_coverage_247() {
1912        check_metrics::<GroovyParser>(
1913            "def f(a, b, list, s) {
1914                def x = a ?: b
1915                def y = a?.field
1916                def z = a??.field
1917                def items = list*.size()
1918                def ptr = a.&size
1919                def fld = a.@field
1920                def id1 = a === b
1921                def id2 = a !== b
1922                def ship = a <=> b
1923                def find = s =~ /pat/
1924                def match = s ==~ /^pat\\$/
1925                def r1 = 0..<10
1926                def r2 = 0<..10
1927                def r3 = 0<..<10
1928                def cast = a as String
1929                def safe = list?[0]
1930                return x
1931            }",
1932            "foo.groovy",
1933            |metric| {
1934                // Each Groovy-specific operator kind contributes one
1935                // distinct entry to the operator set. The 20-operator
1936                // floor breaks down as: 16 Groovy-specific tokens
1937                // exercised by the fixture (`?:`, `?.`, `??.`, `*.`,
1938                // `.&`, `.@`, `===`, `!==`, `<=>`, `=~`, `==~`, `..<`,
1939                // `<..`, `<..<`, `as`, `?[`) plus a handful of
1940                // ambient Java-shaped operators the fixture also
1941                // uses (`def`, `=`, `{`, `(`, `,`, `return`). A
1942                // grammar regression that drops one of the 16
1943                // Groovy-specific tokens would push the count below
1944                // this floor.
1945                // Exact pin: with the dekobon Groovy grammar this
1946                // fixture exercises 16 Groovy-specific tokens (`?:`,
1947                // `?.`, `??.`, `*.`, `.&`, `.@`, `===`, `!==`, `<=>`,
1948                // `=~`, `==~`, `..<`, `<..`, `<..<`, `as`, `?[`) plus
1949                // 7 ambient Java-shaped operators the fixture also
1950                // uses (`def`, `=`, `,`, `{`, `(`, `[`, `return`),
1951                // for a total of 23 distinct operator kinds. A
1952                // regression that drops any one of the 16 #247
1953                // operators would push the count below 23 and fail
1954                // this assertion. The complementary AST walk below
1955                // pins each #247 operator's identity individually so
1956                // a grammar change that adds an unrelated operator
1957                // (lifting `u_operators` to 24) still flags the loss
1958                // of a #247 operator at the per-token level.
1959                assert_eq!(
1960                    metric.halstead.unique_operators(),
1961                    23,
1962                    "u_operators changed; check whether a #247 operator was dropped or an unrelated operator added (and update the comment / token list above accordingly)",
1963                );
1964            },
1965        );
1966    }
1967
1968    #[test]
1969    fn groovy_gstring_no_double_count() {
1970        // Issue #454: before the fix Groovy had no interpolation guard
1971        // at all — `StringLiteral` was classified as a plain operand, so
1972        // a GString counted the wrapping literal AND descended into its
1973        // interpolated expression, double-counting the inner identifier
1974        // in N2. The fix routes `StringLiteral` through
1975        // `string_operand_type` with both GString interpolation child
1976        // kinds (`gstring_brace_interpolation` / `gstring_dollar_-
1977        // interpolation`), so the wrapper is Unknown and only the inner
1978        // expression contributes.
1979        //
1980        // `def greet(name) {\n  return "Hi ${name}"\n}\n`
1981        //   operands by token text: `greet` × 1, `name` × 2 (param +
1982        //   inside `${name}`). The wrapping `"Hi ${name}"` is suppressed
1983        //   → u_operands = 2 (`greet`, `name`), N2 = 3. Without the fix
1984        //   the wrapping literal would also count → u_operands = 3,
1985        //   N2 = 4.
1986        let src = "def greet(name) {\n  return \"Hi ${name}\"\n}\n";
1987        check_metrics::<GroovyParser>(src, "foo.groovy", |metric| {
1988            assert_eq!(metric.halstead.unique_operands(), 2);
1989            assert_eq!(metric.halstead.total_operands(), 3);
1990        });
1991        assert_ops_operands::<GroovyParser>(src, "foo.groovy", 2, vec!["greet", "name"]);
1992    }
1993
1994    #[test]
1995    fn groovy_gstring_dollar_form_no_double_count() {
1996        // Issue #454: the short `$name` GString form emits a distinct
1997        // `gstring_dollar_interpolation` child whose inner `identifier`
1998        // text is `$name` (the grammar's identifier node spans the
1999        // leading `$`). The wrapper is suppressed; the inner `$name`
2000        // operand is distinct from the bare `name` param.
2001        //
2002        // `def greet(name) {\n  return "Hi $name"\n}\n`
2003        //   operands: `greet`, `name` (param), `$name` (interp) →
2004        //   u_operands = 3, N2 = 3. Without the fix the wrapping
2005        //   `"Hi $name"` would also count → u_operands = 4, N2 = 4.
2006        let src = "def greet(name) {\n  return \"Hi $name\"\n}\n";
2007        check_metrics::<GroovyParser>(src, "foo.groovy", |metric| {
2008            assert_eq!(metric.halstead.unique_operands(), 3);
2009            assert_eq!(metric.halstead.total_operands(), 3);
2010        });
2011        assert_ops_operands::<GroovyParser>(src, "foo.groovy", 3, vec!["greet", "name", "$name"]);
2012    }
2013
2014    #[test]
2015    fn groovy_plain_string_still_operand() {
2016        // Counterpart to `groovy_gstring_no_double_count`: a plain
2017        // non-interpolated literal has neither GString interpolation
2018        // child and must still contribute exactly one operand.
2019        //
2020        // `def f() {\n  return "plain"\n}\n`
2021        //   operands: `f`, `"plain"` → u_operands = 2, N2 = 2.
2022        let src = "def f() {\n  return \"plain\"\n}\n";
2023        check_metrics::<GroovyParser>(src, "foo.groovy", |metric| {
2024            assert_eq!(metric.halstead.unique_operands(), 2);
2025            assert_eq!(metric.halstead.total_operands(), 2);
2026        });
2027        assert_ops_operands::<GroovyParser>(src, "foo.groovy", 2, vec!["f", "\"plain\""]);
2028    }
2029
2030    #[test]
2031    fn csharp_operators_and_operands() {
2032        // After issue #286, `void`, `string`, and `int` count as three
2033        // distinct Halstead operators rather than collapsing into one
2034        // `PredefinedType` kind_id entry, lifting u_operators from 13
2035        // to 15. Total operators (N1) is unchanged because the same
2036        // nodes are still counted, just keyed by lexeme.
2037        check_metrics::<CsharpParser>(
2038            "public class Main {
2039                public static void Run(string[] args) {
2040                    int a, b, c, avg;
2041                    a = 5; b = 5; c = 5;
2042                    avg = (a + b + c) / 3;
2043                    System.Console.WriteLine(\"{0}\", avg);
2044                }
2045            }",
2046            "foo.cs",
2047            |metric| {
2048                assert_eq!(metric.halstead.unique_operators(), 15);
2049                assert_eq!(metric.halstead.total_operators(), 32);
2050                assert_eq!(metric.halstead.unique_operands(), 13);
2051                assert_eq!(metric.halstead.total_operands(), 23);
2052                // Pin every Halstead field; values are whatever the
2053                // classifier produces and become the regression spec.
2054                insta::assert_json_snapshot!(metric.halstead);
2055            },
2056        );
2057    }
2058
2059    #[test]
2060    fn csharp_primitive_types_and_booleans() {
2061        // After issue #286: each of `byte`, `short`, `int`, `long`,
2062        // `char`, `float`, `double`, `bool`, `object` is now a distinct
2063        // Halstead operator (9 primitives) rather than collapsing into
2064        // one `PredefinedType` kind_id entry. u_operators rises from 6
2065        // to 14 (5 non-primitive operators + 9 distinct primitives);
2066        // total operators (N1) is unchanged because the same nodes are
2067        // still counted, just keyed by lexeme.
2068        check_metrics::<CsharpParser>(
2069            "public class Prims {
2070                byte a = 1;
2071                short b = 2;
2072                int c = 3;
2073                long d = 4;
2074                char e = 'x';
2075                float f = 1.0f;
2076                double g = 2.0;
2077                bool h = true;
2078                bool i = false;
2079                object j = null;
2080            }",
2081            "foo.cs",
2082            |metric| {
2083                assert_eq!(metric.halstead.unique_operators(), 14);
2084                assert_eq!(metric.halstead.total_operators(), 33);
2085                assert_eq!(metric.halstead.unique_operands(), 21);
2086                assert_eq!(metric.halstead.total_operands(), 23);
2087                insta::assert_json_snapshot!(metric.halstead);
2088            },
2089        );
2090    }
2091
2092    #[test]
2093    fn csharp_predefined_types_keyed_by_lexeme() {
2094        // Regression: issue #286. The C# grammar emits one `PredefinedType`
2095        // kind_id for every keyword type (`int`, `string`, `bool`, …).
2096        // Without keying by source text the entire family collapses into
2097        // a single Halstead operator (n1 += 1) instead of one per distinct
2098        // keyword. This test pins the post-fix behaviour using four
2099        // distinct primitives — `int`, `string`, `bool`, `object` —
2100        // appearing as parameter types so no other operators interact
2101        // with the count.
2102        //
2103        // expected: operators are `class`, `void`, `M`, `{}`, `()`, `,`
2104        // (×3 between 4 params), plus the four distinct predefined types
2105        // → u_operators = 5 + 4 = 9. Without the fix the four primitives
2106        // collapse to one entry, giving u_operators = 6.
2107        check_metrics::<CsharpParser>(
2108            "class C { void M(int a, string b, bool c, object d) {} }",
2109            "foo.cs",
2110            |metric| {
2111                // The headline assertion: four distinct primitive
2112                // keywords contribute four distinct operators, not one.
2113                assert_eq!(metric.halstead.unique_operators(), 9);
2114            },
2115        );
2116    }
2117
2118    #[test]
2119    fn csharp_interpolated_string_no_double_count() {
2120        // Regression: issue #183. A C# `$"Hi {name}!"` used to be
2121        // classified as a Halstead operand (the wrapping
2122        // `InterpolatedStringExpression`) AND have its inner
2123        // `Interpolation`'s identifier classified as an operand too.
2124        // The fix routes `InterpolatedStringExpression` through a
2125        // conditional: when it has an `Interpolation` child, the inner
2126        // identifier already carries the operand contribution and the
2127        // wrapper is treated as `Unknown`; when it does not (static
2128        // `$"hello"`), the wrapper still counts as one operand.
2129        //
2130        // expected: operand contributions for
2131        //   `class C { void M(string name) { string s = $"Hi {name}!"; } }`
2132        // — `C` (class), `M` (method), `name` (param), `s` (local),
2133        // and the inner `name` (inside `{...}`). With the fix,
2134        // u_operands = 4 (C, M, name, s); N2 = 5 (`name` twice).
2135        // Without the fix, the wrapping `$"Hi {name}!"` would also
2136        // count → u_operands = 5, N2 = 6.
2137        check_metrics::<CsharpParser>(
2138            "class C { void M(string name) { string s = $\"Hi {name}!\"; } }",
2139            "foo.cs",
2140            |metric| {
2141                assert_eq!(metric.halstead.unique_operands(), 4);
2142                assert_eq!(metric.halstead.total_operands(), 5);
2143            },
2144        );
2145    }
2146
2147    #[test]
2148    fn csharp_static_interpolated_string_is_operand() {
2149        // Regression: issue #183. A `$"..."` with no `{...}` is
2150        // semantically identical to `"..."` and must still contribute
2151        // exactly one operand — the conditional `is_child(Interpolation)`
2152        // check distinguishes it from a true interpolation. expected:
2153        // operands are `C`, `M`, `s`, `$"hello"` → u_operands = 4, N2 = 4.
2154        // A naive "always Unknown" fix would yield u_operands = 3, N2 = 3,
2155        // diverging from the plain-string equivalent below.
2156        check_metrics::<CsharpParser>(
2157            "class C { void M() { string s = $\"hello\"; } }",
2158            "foo.cs",
2159            |metric| {
2160                assert_eq!(metric.halstead.unique_operands(), 4);
2161                assert_eq!(metric.halstead.total_operands(), 4);
2162            },
2163        );
2164    }
2165
2166    #[test]
2167    fn csharp_plain_string_still_operand() {
2168        // The fix for #183 only changes how `InterpolatedStringExpression`
2169        // is classified; plain `StringLiteral` (and `VerbatimStringLiteral`
2170        // / `RawStringLiteral`) must still contribute exactly one operand
2171        // each. expected: operands are `C`, `M`, `s`, `"hi"` →
2172        // u_operands = 4, N2 = 4.
2173        check_metrics::<CsharpParser>(
2174            "class C { void M() { string s = \"hi\"; } }",
2175            "foo.cs",
2176            |metric| {
2177                assert_eq!(metric.halstead.unique_operands(), 4);
2178                assert_eq!(metric.halstead.total_operands(), 4);
2179            },
2180        );
2181    }
2182
2183    #[test]
2184    fn go_operators_and_operands() {
2185        check_metrics::<GoParser>(
2186            "package main
2187            func sum(a, b int) int {
2188                return a + b
2189            }",
2190            "foo.go",
2191            |metric| {
2192                insta::assert_json_snapshot!(
2193                    metric.halstead,
2194                    @r#"
2195                {
2196                  "unique_operators": 7,
2197                  "total_operators": 7,
2198                  "unique_operands": 5,
2199                  "total_operands": 8,
2200                  "length": 15,
2201                  "estimated_program_length": 31.26112492884004,
2202                  "purity_ratio": 2.0840749952560027,
2203                  "vocabulary": 12,
2204                  "volume": 53.77443751081734,
2205                  "difficulty": 5.6,
2206                  "level": 0.17857142857142858,
2207                  "effort": 301.1368500605771,
2208                  "time": 16.729825003365395,
2209                  "bugs": 0.014975730436275946
2210                }
2211                "#
2212                );
2213            },
2214        );
2215    }
2216
2217    #[test]
2218    fn perl_operators_and_operands() {
2219        check_metrics::<PerlParser>(
2220            "sub sum {
2221                my ($a, $b) = @_;
2222                return $a + $b;
2223            }",
2224            "foo.pl",
2225            |metric| {
2226                insta::assert_json_snapshot!(
2227                    metric.halstead,
2228                    @r#"
2229                {
2230                  "unique_operators": 10,
2231                  "total_operators": 14,
2232                  "unique_operands": 4,
2233                  "total_operands": 6,
2234                  "length": 20,
2235                  "estimated_program_length": 41.219280948873624,
2236                  "purity_ratio": 2.0609640474436812,
2237                  "vocabulary": 14,
2238                  "volume": 76.14709844115208,
2239                  "difficulty": 7.5,
2240                  "level": 0.13333333333333333,
2241                  "effort": 571.1032383086406,
2242                  "time": 31.727957683813365,
2243                  "bugs": 0.02294502281013948
2244                }
2245                "#
2246                );
2247            },
2248        );
2249    }
2250
2251    #[test]
2252    fn perl_interpolated_string_no_double_count() {
2253        // Regression: issue #199. A `string_double_quoted` (and
2254        // `string_qq_quoted` / `backtick_quoted` / `command_qx_quoted`)
2255        // wrapping an `interpolation` child used to be counted as a
2256        // Halstead operand while the inner scalar/array/hash variable
2257        // was also walked and counted — double-counting the inner
2258        // variable's contribution to `N2`. Mirrors #180 (Bash/Elixir),
2259        // #183 (C#), #184 (PHP), #191 (Kotlin).
2260        //
2261        // expected: for
2262        //   sub greet { my $name = shift; my $msg = "Hi $name"; return $msg; }
2263        // — operands are `greet`, `$name`, `shift`, `$msg`. With the
2264        // fix the wrapping `"Hi $name"` is skipped (has `Interpolation`
2265        // child), so u_operands = 4 and N2 = 6 (`$name` x2 from the
2266        // `my` binding and the interpolation; `$msg` x2 from the `my`
2267        // binding and `return`; `greet`, `shift` once each). Without
2268        // the fix the wrapping literal would also be counted, lifting
2269        // u_operands to 5 and N2 to 7.
2270        check_metrics::<PerlParser>(
2271            "sub greet { my $name = shift; my $msg = \"Hi $name\"; return $msg; }",
2272            "foo.pl",
2273            |metric| {
2274                assert_eq!(metric.halstead.unique_operands(), 4);
2275                assert_eq!(metric.halstead.total_operands(), 6);
2276                insta::assert_json_snapshot!(metric.halstead);
2277            },
2278        );
2279    }
2280
2281    #[test]
2282    fn perl_plain_string_still_operand() {
2283        // The fix for #199 only skips wrapping literals that carry an
2284        // `Interpolation` child; a plain `"hello"` (no `$…` inside)
2285        // must still contribute exactly one operand. expected: operands
2286        // `greet`, `$msg`, `"hello"` → u_operands = 3, N2 = 4 (`$msg`
2287        // appears in the `my` binding and the `return`).
2288        check_metrics::<PerlParser>(
2289            "sub greet { my $msg = \"hello\"; return $msg; }",
2290            "foo.pl",
2291            |metric| {
2292                assert_eq!(metric.halstead.unique_operands(), 3);
2293                assert_eq!(metric.halstead.total_operands(), 4);
2294            },
2295        );
2296    }
2297
2298    #[test]
2299    fn perl_single_quoted_string_never_interpolates() {
2300        // Single-quoted (`'…'`) and `q{…}` literals are not subject to
2301        // interpolation in Perl, so even when their text contains a
2302        // `$name`-shaped sequence the wrapper is still counted as one
2303        // operand and the inner text is not parsed as a variable.
2304        // expected: operands `greet`, `$msg`, `'Hi $name'` →
2305        // u_operands = 3, N2 = 4 (`$msg` x2).
2306        check_metrics::<PerlParser>(
2307            "sub greet { my $msg = 'Hi $name'; return $msg; }",
2308            "foo.pl",
2309            |metric| {
2310                assert_eq!(metric.halstead.unique_operands(), 3);
2311                assert_eq!(metric.halstead.total_operands(), 4);
2312            },
2313        );
2314    }
2315
2316    #[test]
2317    fn perl_plain_heredoc_counts_as_one_operand() {
2318        // Regression: issue #287. A plain (non-interpolating) Perl
2319        // heredoc body used to be classified `HalsteadType::Unknown`,
2320        // so its visible `HeredocBodyStatement` node contributed
2321        // nothing to N2 even though it is a string literal. The fix
2322        // adds `HeredocBodyStatement` to the interpolation-aware
2323        // operand arm, so an inert heredoc counts as one operand.
2324        //
2325        // Source (heredoc body lives at the source_file level, not
2326        // inside any sub):
2327        //   my $msg = <<END;
2328        //   hello world
2329        //   END
2330        //
2331        // Operands traversed:
2332        //   * `$msg` (`scalar_variable`)                    × 1
2333        //   * heredoc body (`heredoc_body_statement`)       × 1
2334        // expected: u_operands = 2, N2 = 2.
2335        check_metrics::<PerlParser>("my $msg = <<END;\nhello world\nEND\n", "foo.pl", |metric| {
2336            assert_eq!(metric.halstead.unique_operands(), 2);
2337            assert_eq!(metric.halstead.total_operands(), 2);
2338        });
2339    }
2340
2341    #[test]
2342    fn perl_interpolated_heredoc_no_double_count() {
2343        // Regression: issue #287. An interpolating Perl heredoc
2344        // (`<<"TAG"` or bare `<<TAG`) carries an `Interpolation` child
2345        // when its body contains a `$var`. The wrapper must drop to
2346        // `Unknown` so the inner scalar variable carries the operand
2347        // count — same dispatch as the existing double-quoted /
2348        // backtick / qx wrappers (issue #199) and the PHP heredoc fix
2349        // (issue #184).
2350        //
2351        // Source:
2352        //   my $name = "x";
2353        //   my $msg = <<"END";
2354        //   hi $name
2355        //   END
2356        //
2357        // Operands by text key:
2358        //   * `$name` × 2 (my-binding + interpolation inside heredoc)
2359        //   * `"x"`  × 1 (inert double-quoted string)
2360        //   * `$msg` × 1
2361        // expected: u_operands = 3, N2 = 4. Without the
2362        // interpolation-aware drop the wrapping heredoc body would
2363        // also count, lifting u_operands to 4 and N2 to 5.
2364        check_metrics::<PerlParser>(
2365            "my $name = \"x\";\nmy $msg = <<\"END\";\nhi $name\nEND\n",
2366            "foo.pl",
2367            |metric| {
2368                assert_eq!(metric.halstead.unique_operands(), 3);
2369                assert_eq!(metric.halstead.total_operands(), 4);
2370            },
2371        );
2372    }
2373
2374    #[test]
2375    fn lua_operators_and_operands() {
2376        check_metrics::<LuaParser>(
2377            "local function add(a, b)
2378  local result = a + b
2379  if result > 0 then
2380    return result
2381  end
2382  return 0
2383end",
2384            "foo.lua",
2385            |metric| {
2386                // n1=11: local,function,(,,,=,+,if,>,then,return,end
2387                // (after #695 the `)` closer no longer counts — only the
2388                // folded `(` opener does; was n1=12).
2389                // n2=5: add,a,b,result,0
2390                insta::assert_json_snapshot!(metric.halstead, @r#"
2391                {
2392                  "unique_operators": 11,
2393                  "total_operators": 14,
2394                  "unique_operands": 5,
2395                  "total_operands": 10,
2396                  "length": 24,
2397                  "estimated_program_length": 49.66338827944708,
2398                  "purity_ratio": 2.0693078449769615,
2399                  "vocabulary": 16,
2400                  "volume": 96.0,
2401                  "difficulty": 11.0,
2402                  "level": 0.09090909090909091,
2403                  "effort": 1056.0,
2404                  "time": 58.666666666666664,
2405                  "bugs": 0.03456644293839657
2406                }
2407                "#);
2408            },
2409        );
2410    }
2411
2412    /// Regression for #695. Lua/Bash/Tcl/iRules/PHP/Ruby/Elixir used to
2413    /// classify the *closing* delimiter (`)`/`]`/`}`) as a separate
2414    /// operator, while the C-family majority folds each balanced pair to a
2415    /// single glyph via `get_operator_id_as_str` and counts only the
2416    /// opener. A balanced `(1)` therefore double-counted as `()` + `)`,
2417    /// inflating n1 and N1. With the fix only the folded `(` opener counts:
2418    /// `local x = (1)` yields operators `local`, `=`, `()` — n1 = N1 = 3,
2419    /// with no standalone `)`.
2420    #[test]
2421    fn lua_balanced_paren_counts_opener_only() {
2422        let source = "local x = (1)\n";
2423        let path = PathBuf::from("foo.lua");
2424        let parser = LuaParser::new(source.as_bytes().to_vec(), &path, None);
2425        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
2426        let paren = ops.operators.iter().filter(|o| o.as_str() == "()").count();
2427        assert_eq!(
2428            paren, 1,
2429            "balanced `(1)` must be one `()` operator; operators were {:?}",
2430            ops.operators
2431        );
2432        assert!(
2433            !ops.operators.iter().any(|o| o.as_str() == ")"),
2434            "the closing `)` must not be a separate operator; operators were {:?}",
2435            ops.operators
2436        );
2437    }
2438
2439    /// Guard for #768. Several `get_op_type` impls (Cpp/C/Objc/Mozcpp/
2440    /// Tcl/iRules/Php/Elixir/Ruby) classify a grammar's *second-alias*
2441    /// opener — `LPAREN2`, and for Elixir/Ruby `LBRACK2`/`LBRACK3` — as a
2442    /// Halstead operator alongside the base `LPAREN`/`LBRACK`. #768 worried
2443    /// that an alias opener would reach `compute_halstead` with a kind_id
2444    /// distinct from the base, inflating n1 (a second `()` entry) and
2445    /// rendering a bare `"("` instead of the folded `"()"`.
2446    ///
2447    /// That cannot happen: tree-sitter's runtime collapses each alias to
2448    /// its base via the grammar's `public_symbol_map` *before*
2449    /// `Node::kind_id()` (`ts_node_symbol`) ever returns. So the alias
2450    /// kind_id is unobservable to the metric layer and the alias match arms
2451    /// are defensive — they only fire if a future grammar bump drops that
2452    /// collapse. This test pins the invariant: parsing the exact
2453    /// constructs each grammar produces the alias for internally
2454    /// (pp-conditional `defined(...)` for Cpp; call arg-list / subscript /
2455    /// constant-array-pattern for Ruby) must yield **no** node carrying the
2456    /// alias kind_id, and the balanced opener must count once and render as
2457    /// the pair glyph. If a grammar bump makes an alias id observable, this
2458    /// goes red and signals that the alias arms must additionally fold to
2459    /// the base in `get_operator_id_as_str` (the fix #768 proposed).
2460    #[test]
2461    fn second_alias_opener_collapses_to_base_kind_id() {
2462        fn assert_no_alias<T: crate::ParserTrait>(
2463            source: &str,
2464            file: &str,
2465            alias_id: u16,
2466            alias_name: &str,
2467        ) {
2468            let path = PathBuf::from(file);
2469            let parser = T::new(source.as_bytes().to_vec(), &path, None);
2470            let mut stack = vec![parser.root()];
2471            while let Some(node) = stack.pop() {
2472                assert_ne!(
2473                    node.kind_id(),
2474                    alias_id,
2475                    "{alias_name} (kind_id {alias_id}) must never reach kind_id() \
2476                     for `{source}`; the runtime public_symbol_map should have \
2477                     collapsed it to the base opener. If this fires after a \
2478                     grammar bump, fold {alias_name} to its pair glyph in \
2479                     get_operator_id_as_str (issue #768)."
2480                );
2481                for child in node.children() {
2482                    stack.push(child);
2483                }
2484            }
2485        }
2486
2487        // Balanced openers must count once and render folded (no bare
2488        // `(`/`[`, no n1 inflation) — the property #768 feared was broken.
2489        fn assert_folded_openers<T: crate::ParserTrait>(source: &str, file: &str) {
2490            let path = PathBuf::from(file);
2491            let parser = T::new(source.as_bytes().to_vec(), &path, None);
2492            let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
2493            assert!(
2494                !ops.operators.iter().any(|o| o.as_str() == "("),
2495                "no bare `(` operator (must fold to `()`); operators were {:?}",
2496                ops.operators
2497            );
2498            assert!(
2499                !ops.operators.iter().any(|o| o.as_str() == "["),
2500                "no bare `[` operator (must fold to `[]`); operators were {:?}",
2501                ops.operators
2502            );
2503            // Each pair glyph appears at most once — the alias does not add
2504            // a second `()`/`[]` entry to n1.
2505            assert!(
2506                ops.operators.iter().filter(|o| o.as_str() == "()").count() <= 1,
2507                "`()` must be a single n1 entry; operators were {:?}",
2508                ops.operators
2509            );
2510            assert!(
2511                ops.operators.iter().filter(|o| o.as_str() == "[]").count() <= 1,
2512                "`[]` must be a single n1 entry; operators were {:?}",
2513                ops.operators
2514            );
2515        }
2516
2517        // Cpp/C/Mozcpp: LPAREN2 = 20. The grammar emits it internally only
2518        // inside preprocessor-conditional expressions (`#if defined(FOO)`).
2519        assert_no_alias::<crate::CppParser>(
2520            "#if defined(FOO)\n#endif\n",
2521            "a.cpp",
2522            20,
2523            "Cpp::LPAREN2",
2524        );
2525        assert_no_alias::<crate::CParser>("#if defined(FOO)\n#endif\n", "a.c", 20, "C::LPAREN2");
2526
2527        // Ruby: LPAREN2 = 47 (call arg-list), LBRACK3 = 155 (element-
2528        // reference subscript), LBRACK2 = 46 (constant array pattern).
2529        assert_no_alias::<crate::RubyParser>("f(1)\n", "a.rb", 47, "Ruby::LPAREN2");
2530        assert_no_alias::<crate::RubyParser>("a[0]\n", "a.rb", 155, "Ruby::LBRACK3");
2531        assert_no_alias::<crate::RubyParser>(
2532            "case p\nin Point[1, 2] then 1\nend\n",
2533            "a.rb",
2534            46,
2535            "Ruby::LBRACK2",
2536        );
2537
2538        // Elixir: LPAREN2 = 95 (immediate call paren), LBRACK2 = 96
2539        // (access / subscript).
2540        assert_no_alias::<crate::ElixirParser>("f(1)\n", "a.ex", 95, "Elixir::LPAREN2");
2541        assert_no_alias::<crate::ElixirParser>("x[0]\n", "a.ex", 96, "Elixir::LBRACK2");
2542
2543        assert_folded_openers::<crate::CppParser>("int main(){ int a[3]; return a[0]; }", "b.cpp");
2544        assert_folded_openers::<crate::RubyParser>("f(1)\nb = [1]\nb[0]\n", "b.rb");
2545    }
2546
2547    #[test]
2548    fn kotlin_halstead_basic() {
2549        check_metrics::<KotlinParser>(
2550            "fun add(a: Int, b: Int): Int {
2551                val result = a + b
2552                return result
2553            }",
2554            "foo.kt",
2555            |metric| {
2556                insta::assert_json_snapshot!(
2557                    metric.halstead,
2558                    @r#"
2559                {
2560                  "unique_operators": 9,
2561                  "total_operators": 11,
2562                  "unique_operands": 5,
2563                  "total_operands": 10,
2564                  "length": 21,
2565                  "estimated_program_length": 40.13896548741762,
2566                  "purity_ratio": 1.9113793089246487,
2567                  "vocabulary": 14,
2568                  "volume": 79.9544533632097,
2569                  "difficulty": 9.0,
2570                  "level": 0.1111111111111111,
2571                  "effort": 719.5900802688873,
2572                  "time": 39.97722668160485,
2573                  "bugs": 0.026767153565498338
2574                }
2575                "#
2576                );
2577            },
2578        );
2579    }
2580
2581    #[test]
2582    fn kotlin_string_template_no_double_count() {
2583        // Re-anchored for issue #454. The pre-#454 comment claimed
2584        // kotlin-ng emits an `identifier` node for the short `$name`
2585        // form whose bytes include the leading `$`. That is factually
2586        // false: AST dump shows the short form produces bare
2587        // `string_content` tokens (`$`, then `name`) with **no**
2588        // structured node. The old assertion (u_operands = 4, N2 = 5)
2589        // passed for the wrong reason (lesson 6): the wrapping literal
2590        // was counted (+1) and the inner `name` was dropped (-1), and
2591        // the two errors cancelled. The `$name!` it used also defeats
2592        // recovery because the grammar glues the trailing `!` onto the
2593        // name token.
2594        //
2595        // Correct mechanism (clean end-of-segment short form):
2596        // `fun greet(name: String): String {\n    return "Hi $name"\n}\n`
2597        //   operators: fun, (, ), :, {}, return → as classified.
2598        //   operands by token text:
2599        //     `greet` × 1, `name` × 2 (param + recovered short-interp),
2600        //     `String` × 2 (param type + return type).
2601        //   The wrapping `"Hi $name"` literal is suppressed and the
2602        //   inner `name` recovered → u_operands = 3 (`greet`, `name`,
2603        //   `String`), N2 = 5. Pre-#454: wrapper counted, inner dropped
2604        //   → u_operands = 4, N2 = 6.
2605        check_metrics::<KotlinParser>(
2606            "fun greet(name: String): String {\n    return \"Hi $name\"\n}\n",
2607            "foo.kt",
2608            |metric| {
2609                assert_eq!(metric.halstead.unique_operands(), 3);
2610                assert_eq!(metric.halstead.total_operands(), 5);
2611            },
2612        );
2613        // Lesson 4: the ops store agrees on n2 and the exact operand set
2614        // (inner `name` present, wrapper absent).
2615        assert_ops_operands::<KotlinParser>(
2616            "fun greet(name: String): String {\n    return \"Hi $name\"\n}\n",
2617            "foo.kt",
2618            3,
2619            vec!["greet", "name", "String"],
2620        );
2621    }
2622
2623    #[test]
2624    fn kotlin_short_interpolation_counts_inner_not_wrapper() {
2625        // Issue #454: the short `$name` template — distinct from the
2626        // long `${expr}` form, which the kotlin-ng grammar gives a
2627        // structured `interpolation` node (see
2628        // `kotlin_string_template_long_form_no_double_count`). The short
2629        // form has no such node; the variable arrives as a bare
2630        // `string_content` token preceded by a `$` `string_content`.
2631        // The fix recovers the clean-identifier variable as an operand
2632        // and suppresses the opaque wrapper.
2633        //
2634        // `fun f() { val x = 1; println("v=$x") }\n`
2635        //   operands by token text: `f`, `x` × 2 (decl + recovered),
2636        //   `println`, `1`. The wrapping `"v=$x"` is suppressed →
2637        //   u_operands = 4 (`f`, `x`, `println`, `1`), N2 = 5.
2638        // Pre-#454 the wrapper `"v=$x"` counted and the inner `x` was
2639        // dropped → u_operands = 4 but the wrapper, not `x`, was the
2640        // fourth operand, and N2 = 5 with the wrong member — the ops
2641        // assertion below pins the exact set so the cancellation cannot
2642        // hide it.
2643        let src = "fun f() { val x = 1; println(\"v=$x\") }\n";
2644        check_metrics::<KotlinParser>(src, "foo.kt", |metric| {
2645            assert_eq!(metric.halstead.unique_operands(), 4);
2646            assert_eq!(metric.halstead.total_operands(), 5);
2647        });
2648        assert_ops_operands::<KotlinParser>(src, "foo.kt", 4, vec!["f", "x", "println", "1"]);
2649    }
2650
2651    #[test]
2652    fn kotlin_short_interpolation_space_separated() {
2653        // Issue #454 follow-up: tree-sitter-kotlin-ng splits the literal
2654        // only at each `$`, so a `$name` segment's name token absorbs any
2655        // trailing inter-segment text into its byte range. For `"$a $b"`
2656        // the token after the first `$` is `"a "` (with the trailing
2657        // space). Pre-fix `kotlin_is_identifier("a ")` returned false and
2658        // the leading variable `a` was silently dropped, yielding
2659        // operands `{b, f, s}` (verified: `a` missing) — breaking parity
2660        // with the long form `"${a} ${b}"`, which recovers `{a, b, f, s}`.
2661        //
2662        // The fix takes the maximal leading-identifier prefix of the name
2663        // token, recovering `a` and keying it as the bare `"a"` (not
2664        // `"a "`). Short and long forms must now agree exactly.
2665        //
2666        // `fun f() { val s = "$a $b" }\n`
2667        //   operands by token text: `f`, `s`, `a` (recovered), `b`
2668        //   (recovered). Wrapper suppressed → u_operands = 4, N2 = 4.
2669        let short = "fun f() { val s = \"$a $b\" }\n";
2670        let long = "fun f() { val s = \"${a} ${b}\" }\n";
2671        check_metrics::<KotlinParser>(short, "foo.kt", |metric| {
2672            assert_eq!(metric.halstead.unique_operands(), 4);
2673            assert_eq!(metric.halstead.total_operands(), 4);
2674        });
2675        // Both `a` and `b` present, wrapper absent, n2 == dedupe(operands).
2676        assert_ops_operands::<KotlinParser>(short, "foo.kt", 4, vec!["f", "s", "a", "b"]);
2677        // Exact parity with the long `${a} ${b}` form.
2678        assert_ops_operands::<KotlinParser>(long, "foo.kt", 4, vec!["f", "s", "a", "b"]);
2679
2680        // Comma after the name (`"$a, $b"`): the first name token is
2681        // `"a, "`; its leading identifier prefix is `a`.
2682        let comma = "fun f() { val s = \"$a, $b\" }\n";
2683        assert_ops_operands::<KotlinParser>(comma, "foo.kt", 4, vec!["f", "s", "a", "b"]);
2684
2685        // Name preceded by literal text and at end-of-segment (`"x=$a"`):
2686        // the `a` token has no trailing text, so recovery is unchanged.
2687        let prefixed = "fun f() { val s = \"x=$a\" }\n";
2688        assert_ops_operands::<KotlinParser>(prefixed, "foo.kt", 3, vec!["f", "s", "a"]);
2689
2690        // Mid-prose `"$x is "`: the name token is `"x is "`. The leading
2691        // identifier prefix is `x`, matching the long form `"${x} is "`,
2692        // which also recovers `x` and treats `" is "` as literal text.
2693        let prose_short = "fun f() { val s = \"$x is \" }\n";
2694        let prose_long = "fun f() { val s = \"${x} is \" }\n";
2695        assert_ops_operands::<KotlinParser>(prose_short, "foo.kt", 3, vec!["f", "s", "x"]);
2696        assert_ops_operands::<KotlinParser>(prose_long, "foo.kt", 3, vec!["f", "s", "x"]);
2697    }
2698
2699    #[test]
2700    fn kotlin_dollar_non_identifier_stays_literal() {
2701        // Issue #454 boundary: a `$` not followed by a clean identifier
2702        // is literal text, not an interpolation. `"price: $5"` (digit
2703        // after `$`) must keep the wrapping literal as a single operand
2704        // and recover nothing.
2705        //
2706        // `fun f() { val a = "price: $5" }\n`
2707        //   operands: `f`, `a`, `"price: $5"` → u_operands = 3, N2 = 3.
2708        let src = "fun f() { val a = \"price: $5\" }\n";
2709        check_metrics::<KotlinParser>(src, "foo.kt", |metric| {
2710            assert_eq!(metric.halstead.unique_operands(), 3);
2711            assert_eq!(metric.halstead.total_operands(), 3);
2712        });
2713        assert_ops_operands::<KotlinParser>(src, "foo.kt", 3, vec!["f", "a", "\"price: $5\""]);
2714    }
2715
2716    #[test]
2717    fn kotlin_string_template_long_form_no_double_count() {
2718        // The `${expr}` long form of a Kotlin string template also
2719        // produces an `Interpolation` child. The fix must apply to it
2720        // identically.
2721        //
2722        // Source: `fun f(x: Int): String { return "v=${x}" }\n`
2723        // Operands by source-byte key:
2724        //   `f` × 1, `x` × 2 (param + inside `${x}`),
2725        //   `Int` × 1, `String` × 1.
2726        // With the fix u_operands = 4 (`f`, `x`, `Int`, `String`),
2727        // N2 = 5. Without the fix the wrapping `"v=${x}"` would also
2728        // count → u_operands = 5, N2 = 6.
2729        check_metrics::<KotlinParser>(
2730            "fun f(x: Int): String { return \"v=${x}\" }\n",
2731            "foo.kt",
2732            |metric| {
2733                assert_eq!(metric.halstead.unique_operands(), 4);
2734                assert_eq!(metric.halstead.total_operands(), 5);
2735            },
2736        );
2737    }
2738
2739    #[test]
2740    fn kotlin_plain_string_still_operand() {
2741        // The fix for #191 only skips wrapping templates that contain
2742        // an `Interpolation` child; a plain `"hello"` (no `$` interp)
2743        // must still contribute exactly one operand.
2744        //
2745        // Source: `fun f(): String { return "hello" }\n`
2746        // Operands: `f` × 1, `String` × 1, `"hello"` × 1 →
2747        // u_operands = 3, N2 = 3.
2748        check_metrics::<KotlinParser>(
2749            "fun f(): String { return \"hello\" }\n",
2750            "foo.kt",
2751            |metric| {
2752                assert_eq!(metric.halstead.unique_operands(), 3);
2753                assert_eq!(metric.halstead.total_operands(), 3);
2754            },
2755        );
2756    }
2757
2758    #[test]
2759    fn python_fstring_no_double_count() {
2760        // Regression: issue #191. A Python f-string (`f"Hi {name}!"`)
2761        // wraps an `Interpolation` child whose inner identifier
2762        // `name` is walked and counted as its own operand. Without
2763        // the `is_child(Interpolation)` guard the wrapping `String`
2764        // would also count, double-counting `name`'s contribution to
2765        // `N2`. Same pattern as #180 (Bash/Elixir) and #184 (PHP).
2766        //
2767        // Source: `def greet(name):\n    return f"Hi {name}!"\n`
2768        // Operands by source-byte key:
2769        //   `greet` × 1, `name` × 2 (param + inside `{name}`).
2770        // With the fix the wrapping `f"Hi {name}!"` is skipped →
2771        // u_operands = 2 (`greet`, `name`), N2 = 3. Without the fix
2772        // the wrapping literal would also count → u_operands = 3,
2773        // N2 = 4.
2774        check_metrics::<PythonParser>(
2775            "def greet(name):\n    return f\"Hi {name}!\"\n",
2776            "foo.py",
2777            |metric| {
2778                assert_eq!(metric.halstead.unique_operands(), 2);
2779                assert_eq!(metric.halstead.total_operands(), 3);
2780            },
2781        );
2782    }
2783
2784    #[test]
2785    fn python_plain_string_still_operand() {
2786        // The fix for #191 only skips wrapping `String` nodes that
2787        // contain an `Interpolation` child; a plain `"hi"` must still
2788        // contribute exactly one operand.
2789        //
2790        // Source: `def f():\n    return "hi"\n`
2791        // Operands: `f` × 1, `"hi"` × 1 → u_operands = 2, N2 = 2.
2792        // (The previous documentation-string filter is preserved:
2793        // a bare `"hi"` as a top-level `expression_statement` would
2794        // be skipped, but here it appears as `return "hi"`.)
2795        check_metrics::<PythonParser>("def f():\n    return \"hi\"\n", "foo.py", |metric| {
2796            assert_eq!(metric.halstead.unique_operands(), 2);
2797            assert_eq!(metric.halstead.total_operands(), 2);
2798        });
2799    }
2800
2801    #[test]
2802    fn python_concatenated_docstring_suppressed() {
2803        // Regression for #695. An implicit-concatenation docstring
2804        // (`"""doc""" "more"`) parses as `expression_statement >
2805        // concatenated_string > [string, string]`. The single-literal
2806        // docstring guard (`parent == expression_statement &&
2807        // child_count == 1`) never fired here, so each fragment counted
2808        // as a separate operand and the docstring's N2 contribution
2809        // depended on how many literals it was split into. With the fix,
2810        // every fragment of such a docstring is suppressed.
2811        //
2812        // Source: `def f():\n    """doc""" "more"\n    return 1\n`
2813        // Operands: `f`, `1` only — both docstring fragments suppressed →
2814        // u_operands = 2, N2 = 2.
2815        check_metrics::<PythonParser>(
2816            "def f():\n    \"\"\"doc\"\"\" \"more\"\n    return 1\n",
2817            "foo.py",
2818            |metric| {
2819                assert_eq!(metric.halstead.unique_operands(), 2);
2820                assert_eq!(metric.halstead.total_operands(), 2);
2821            },
2822        );
2823    }
2824
2825    #[test]
2826    fn python_concatenated_non_docstring_still_counts() {
2827        // The #695 fix must only suppress concatenated literals in the
2828        // *docstring* position (sole child of an `expression_statement`).
2829        // A concatenated string used as a value (`x = "a" "b"`) is not a
2830        // docstring — its `concatenated_string` parent's grandparent is
2831        // an assignment, not a single-child statement — so both fragments
2832        // must still be operands.
2833        //
2834        // Source: `def f():\n    x = "a" "b"\n    return x\n`
2835        // Operands: `f`, `x` (twice: assign + return), `"a"`, `"b"` →
2836        // u_operands = 4, N2 = 5.
2837        check_metrics::<PythonParser>(
2838            "def f():\n    x = \"a\" \"b\"\n    return x\n",
2839            "foo.py",
2840            |metric| {
2841                assert_eq!(metric.halstead.unique_operands(), 4);
2842                assert_eq!(metric.halstead.total_operands(), 5);
2843            },
2844        );
2845    }
2846
2847    #[test]
2848    fn python_empty_file_halstead() {
2849        check_metrics::<PythonParser>("", "empty.py", |metric| {
2850            let h = &metric.halstead;
2851            assert_eq!(h.unique_operators(), 0);
2852            assert_eq!(h.total_operands(), 0);
2853            assert_eq!(h.estimated_program_length(), 0.0);
2854            assert_eq!(h.purity_ratio(), 0.0);
2855            assert_eq!(h.volume(), 0.0);
2856            assert_eq!(h.difficulty(), 0.0);
2857            assert_eq!(h.level(), 0.0);
2858            assert_eq!(h.effort(), 0.0);
2859            assert_eq!(h.time(), 0.0);
2860            assert_eq!(h.bugs(), 0.0);
2861        });
2862    }
2863
2864    /// Regression #413, sub-fix (1): `await` was double-counted because the
2865    /// operator arm listed both the await-expression node (Await=237) and the
2866    /// nested `await` keyword token (Await2=95). Only the node should count,
2867    /// mirroring how `yield` counts only the Yield node.
2868    #[test]
2869    fn python_await_counted_once_per_use() {
2870        check_metrics::<PythonParser>(
2871            "async def f():\n    await a()\n    await b()\n    await c()\n",
2872            "foo.py",
2873            |metric| {
2874                // expected operators: async, def, await  (3 unique)
2875                //   await used three times -> N1 counts: async(1) def(1) await(3) = 5
2876                //   Before #413, Await + Await2 both matched, so `await` was a
2877                //   distinct operator twice: n1=4, N1=8.
2878                assert_eq!(metric.halstead.unique_operators(), 3);
2879                assert_eq!(metric.halstead.total_operators(), 5);
2880            },
2881        );
2882    }
2883
2884    /// Regression #413, sub-fix (3): `lambda` was dropped entirely. Only the
2885    /// `lambda` keyword token (Lambda3=73) is classified, not the wrapping
2886    /// Lambda/Lambda2 expression nodes, to avoid an await-style double count.
2887    #[test]
2888    fn python_lambda_counted_once() {
2889        check_metrics::<PythonParser>("g = lambda x: x + 1\n", "foo.py", |metric| {
2890            // expected operators: =, lambda, +  (3 unique, each used once)
2891            // Before #413, lambda was absent: only =, + were counted.
2892            assert_eq!(metric.halstead.unique_operators(), 3);
2893            assert_eq!(metric.halstead.total_operators(), 3);
2894        });
2895    }
2896
2897    /// Regression #413, sub-fix (2): `match` / `case` keyword tokens
2898    /// (Match=26, Case=27) were dropped. Each should now count as an operator,
2899    /// matching the cyclomatic metric which already counts every `case`.
2900    #[test]
2901    fn python_match_case_counted() {
2902        check_metrics::<PythonParser>(
2903            "match x:\n    case 1:\n        pass\n    case _:\n        pass\n",
2904            "foo.py",
2905            |metric| {
2906                // expected operators: match, case, pass  (3 unique)
2907                //   match(1) + case(2) + pass(2) = 5 total occurrences.
2908                // Before #413, neither match nor case was counted (only pass).
2909                assert_eq!(metric.halstead.unique_operators(), 3);
2910                assert_eq!(metric.halstead.total_operators(), 5);
2911            },
2912        );
2913    }
2914
2915    /// Regression #413, sub-fix (2): `nonlocal` (Nonlocal=41) was dropped while
2916    /// `global` was already classified. Both should count, for parity.
2917    #[test]
2918    fn python_nonlocal_and_global_counted() {
2919        check_metrics::<PythonParser>(
2920            "def f():\n    global a\n    nonlocal b\n",
2921            "foo.py",
2922            |metric| {
2923                // expected operators: def, global, nonlocal  (3 unique)
2924                // Before #413, nonlocal was absent: only def, global counted.
2925                assert_eq!(metric.halstead.unique_operators(), 3);
2926                assert_eq!(metric.halstead.total_operators(), 3);
2927            },
2928        );
2929    }
2930
2931    /// Regression #413, sub-fix (4): `not in` (Notin=193) and `is not`
2932    /// (Isnot=194) are single compound operators. The parent-guard suppresses
2933    /// the inner Not/In/Is leaves only under those compounds, so standalone
2934    /// `not x`, `a in b`, `a is b`, and `for x in y` still count their leaves.
2935    #[test]
2936    fn python_not_in_is_not_counted_as_single_operator() {
2937        check_metrics::<PythonParser>(
2938            "a not in b\na is not b\nnot c\nd in e\nf is g\nfor h in i:\n    pass\n",
2939            "foo.py",
2940            |metric| {
2941                // expected operators (7 unique):
2942                //   "not in" (compound, once), "is not" (compound, once),
2943                //   "not" (standalone `not c`, once),
2944                //   "in" (standalone `d in e` + `for h in i` = twice),
2945                //   "is" (standalone `f is g`, once),
2946                //   "for" (once), "pass" (once)
2947                // Total occurrences: 1+1+1+2+1+1+1 = 8.
2948                // Before #413, `a not in b` counted not+in (two) and
2949                // `a is not b` counted is+not (two); the compounds were
2950                // never classified.
2951                assert_eq!(metric.halstead.unique_operators(), 7);
2952                assert_eq!(metric.halstead.total_operators(), 8);
2953            },
2954        );
2955    }
2956
2957    #[test]
2958    fn bash_operators_and_operands() {
2959        check_metrics::<BashParser>(
2960            "#!/bin/bash
2961f() {
2962    local x=1
2963    if [ $x -eq 1 ]; then
2964        echo 'one'
2965    fi
2966}",
2967            "foo.sh",
2968            |metric| {
2969                // Operators (9 unique, 9 occurrences): the opening
2970                // delimiters `()`/`{}`/`[]` (each folded to one glyph and
2971                // counted once per balanced pair, #695 — the closers no
2972                // longer add a second operator), `local`, `=`, `if`,
2973                // `then`, `fi`, `;`.
2974                // Operands (6 unique, 8 occurrences): `f`, `x` (the
2975                // assignment LHS `variable_name`, kind 160), `1` (twice:
2976                // `=1` and `-eq 1`), `$x` (the `simple_expansion` — its
2977                // inner `variable_name` leaf is now suppressed so `$x`
2978                // counts once, #695), `echo`, `'one'`.
2979                assert_eq!(metric.halstead.unique_operators(), 9);
2980                assert_eq!(metric.halstead.total_operators(), 9);
2981                assert_eq!(metric.halstead.unique_operands(), 6);
2982                assert_eq!(metric.halstead.total_operands(), 8);
2983                insta::assert_json_snapshot!(metric.halstead);
2984            },
2985        );
2986    }
2987
2988    #[test]
2989    fn bash_interpolated_string_no_double_count() {
2990        // Regression: issue #180. A double-quoted Bash string containing
2991        // `$name`, `${name[…]}`, or `$(cmd)` used to be classified as a
2992        // Halstead operand AND have its inner `simple_expansion` /
2993        // `expansion` / `command_substitution` children classified as
2994        // operands too. We now skip the wrapping literal when it has an
2995        // expansion child so only the inner expansion contributes.
2996        //
2997        // expected: operands across `a="plain"\nb="$x"\n` —
2998        //   line 1: variable_name `a`, plain string `"plain"` (no
2999        //     expansion, still operand) → 2.
3000        //   line 2: variable_name `b`, wrapping `"$x"` skipped (has
3001        //     expansion), `simple_expansion` `$x` (its inner
3002        //     variable_name `x` leaf is suppressed under #695) → 2.
3003        // Total unique operands: 4 (`a`, `b`, `"plain"`, `$x`), each
3004        // appearing once → N2 = 4. Before #695 the inner `x` leaf of
3005        // `$x` was also counted (u_operands = 5, N2 = 5); before the
3006        // earlier #180 fix the wrapping `"$x"` literal was counted too.
3007        // The `=` is the only operator; appears twice (N1 = 2, n1 = 1).
3008        check_metrics::<BashParser>("a=\"plain\"\nb=\"$x\"\n", "foo.sh", |metric| {
3009            assert_eq!(metric.halstead.unique_operators(), 1);
3010            assert_eq!(metric.halstead.total_operators(), 2);
3011            assert_eq!(metric.halstead.unique_operands(), 4);
3012            assert_eq!(metric.halstead.total_operands(), 4);
3013            insta::assert_json_snapshot!(metric.halstead);
3014        });
3015    }
3016
3017    #[test]
3018    fn elixir_interpolated_string_no_double_count() {
3019        // Regression: issue #180. Without the fix, an interpolated
3020        // Elixir `String` was classified as a single operand while its
3021        // inner `interpolation` identifier was also walked and
3022        // classified as its own operand — double-counting the
3023        // interpolated identifier's contribution to `N2`.
3024        //
3025        // expected: operand contributions for
3026        //   `def greet(name) do\n  msg = "Hi #{name}"\nend\n` —
3027        // `def`, `greet`, `name` (param), `msg`, and the inner `name`
3028        // (inside `#{...}`). With the fix, the wrapping
3029        // `"Hi #{name}"` literal is skipped (has `Interpolation`
3030        // child), so `name` is the only repeated operand:
3031        // u_operands = 4 (def, greet, name, msg), N2 = 5. Without the
3032        // fix, the wrapping literal would also count → u_operands = 5,
3033        // N2 = 6. Operators: `do`, `end`, `(`, `=`, `#{` → u = N = 5.
3034        // Only the *opening* delimiters count after #695, so the `)`
3035        // and the `}` interpolation closer no longer add operators (the
3036        // `(` and `#{` openers still do); before #695 this was 7.
3037        check_metrics::<ElixirParser>(
3038            "def greet(name) do\n  msg = \"Hi #{name}\"\nend\n",
3039            "foo.ex",
3040            |metric| {
3041                assert_eq!(metric.halstead.unique_operators(), 5);
3042                assert_eq!(metric.halstead.total_operators(), 5);
3043                assert_eq!(metric.halstead.unique_operands(), 4);
3044                assert_eq!(metric.halstead.total_operands(), 5);
3045                insta::assert_json_snapshot!(metric.halstead);
3046            },
3047        );
3048    }
3049
3050    #[test]
3051    fn elixir_plain_string_still_operand() {
3052        // The fix for #180 only skips wrapping literals that contain
3053        // interpolation; a plain `"hello"` must still contribute exactly
3054        // one operand. expected: `def`, `f`, `"hello"` → 3 unique
3055        // operands (n2 = 3), each appearing once (N2 = 3).
3056        check_metrics::<ElixirParser>("def f do\n  \"hello\"\nend\n", "foo.ex", |metric| {
3057            assert_eq!(metric.halstead.unique_operands(), 3);
3058            assert_eq!(metric.halstead.total_operands(), 3);
3059        });
3060    }
3061
3062    #[test]
3063    fn elixir_interpolated_sigil_no_double_count() {
3064        // Sigils mirror strings under #180. For `~r/foo#{name}/`, the
3065        // wrapping `Sigil` is skipped, but `SigilName` (`r`) and the
3066        // inner `name` identifier each contribute one operand.
3067        // expected: `def`, `f`, `name` (param), `re`, `r` (sigil name),
3068        // `name` (inside `#{...}`) → u_operands = 5, N2 = 6 (`name`
3069        // twice).
3070        check_metrics::<ElixirParser>(
3071            "def f(name) do\n  re = ~r/foo#{name}/\nend\n",
3072            "foo.ex",
3073            |metric| {
3074                assert_eq!(metric.halstead.unique_operands(), 5);
3075                assert_eq!(metric.halstead.total_operands(), 6);
3076            },
3077        );
3078    }
3079
3080    #[test]
3081    fn elixir_interpolated_charlist_no_double_count() {
3082        // Charlists mirror strings and sigils under #180. The
3083        // `E::String | E::Charlist | E::Sigil` arm in `get_op_type`
3084        // skips any wrapping literal that has an `Interpolation`
3085        // child; this test exercises the `Charlist` branch
3086        // specifically.
3087        //
3088        // expected: for `def f(name) do\n  cl = 'Hi #{name}'\nend\n` —
3089        // `def`, `f`, `name` (param), `cl`, and the inner `name`
3090        // (inside `#{...}`). With the fix, the wrapping
3091        // `'Hi #{name}'` is skipped → u_operands = 4 (def, f, name,
3092        // cl), N2 = 5 (`name` twice).
3093        check_metrics::<ElixirParser>(
3094            "def f(name) do\n  cl = 'Hi #{name}'\nend\n",
3095            "foo.ex",
3096            |metric| {
3097                assert_eq!(metric.halstead.unique_operands(), 4);
3098                assert_eq!(metric.halstead.total_operands(), 5);
3099            },
3100        );
3101    }
3102
3103    #[test]
3104    fn bash_all_expansion_kinds_skip_wrapper() {
3105        // Exercises every node kind tested by
3106        // `bash_string_has_expansion`: `simple_expansion` (`$v`),
3107        // `expansion` (`${v[0]}`), `command_substitution` (`$(date)`),
3108        // and `arithmetic_expansion` (`$((1+2))`). A typo replacing
3109        // one kind with an aliased neighbour in `language_bash.rs`
3110        // (e.g., `ExpansionBody` instead of `Expansion`) would leave
3111        // the corresponding wrapping string counted as an operand and
3112        // shift the totals.
3113        //
3114        // expected: operands across the four lines —
3115        //   line 1 `a="$v"`: var_name `a`, simple_expansion `$v` (its
3116        //     inner var_name `v` leaf is suppressed under #695; wrapper
3117        //     skipped) → 2
3118        //   line 2 `b="${v[0]}"`: var_name `b`, var_name `v` (inside
3119        //     subscript — parent is `expansion`, not `simple_expansion`,
3120        //     so it still counts), number `0` (wrapper skipped,
3121        //     `expansion` itself is not in the operand list) → 3
3122        //   line 3 `c="$(date)"`: var_name `c`, command_name `date`
3123        //     (wrapper skipped, `command_substitution` not in operand
3124        //     list) → 2
3125        //   line 4 `d="$((1+2))"`: var_name `d`, numbers `1` and `2`
3126        //     (wrapper skipped, `arithmetic_expansion` not in operand
3127        //     list) → 3
3128        // Unique operands: a, b, c, d, $v, v, 0, date, 1, 2 → 10. Total
3129        // occurrences: 11 (`v` now appears once — only line 2's subscript
3130        // leaf; line 1's `$v` inner leaf is suppressed). Operators after
3131        // #695: only the openers `[` (folded `[]`) and `+`, plus `=` four
3132        // times — the `}`/`)`/`))`/`]` closers no longer count.
3133        check_metrics::<BashParser>(
3134            "a=\"$v\"\nb=\"${v[0]}\"\nc=\"$(date)\"\nd=\"$((1+2))\"\n",
3135            "foo.sh",
3136            |metric| {
3137                assert_eq!(metric.halstead.unique_operators(), 3);
3138                assert_eq!(metric.halstead.total_operators(), 6);
3139                assert_eq!(metric.halstead.unique_operands(), 10);
3140                assert_eq!(metric.halstead.total_operands(), 11);
3141            },
3142        );
3143    }
3144
3145    /// Regression for #695. A bare `$x` (outside any string) parses as a
3146    /// `simple_expansion` wrapping a `variable_name` leaf — and `$?` / `$1`
3147    /// as a `simple_expansion` wrapping a `special_variable_name` leaf. Both
3148    /// the wrapper and the inner leaf used to be classified as operands, so
3149    /// each bare variable reference double-counted (the same hazard Tcl
3150    /// guards with its `Id2` exclusion and iRules with a parent check). The
3151    /// `variable_name` / `special_variable_name` arm now yields `Unknown`
3152    /// when its parent is a `simple_expansion`, so `$x` contributes exactly
3153    /// one operand while the assignment LHS `variable_name` (`x` in `x=…`,
3154    /// parent is `variable_assignment`) still counts.
3155    #[test]
3156    fn bash_bare_variable_no_double_count() {
3157        let source = "x=1\necho $x\necho $?\n";
3158        let path = PathBuf::from("foo.sh");
3159        let parser = BashParser::new(source.as_bytes().to_vec(), &path, None);
3160        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
3161        let bare_x = ops.operands.iter().filter(|o| o.as_str() == "$x").count();
3162        let special = ops.operands.iter().filter(|o| o.as_str() == "$?").count();
3163        // Each bare reference is exactly one operand; the inner leaf is not
3164        // double-counted. If the guard regressed, the inner `variable_name`
3165        // `x` would add a second `x` occurrence (text-colliding with the
3166        // assignment LHS) and the inner `special_variable_name` `?` would
3167        // appear as a standalone `?` operand.
3168        assert_eq!(
3169            bare_x, 1,
3170            "bare $x must be one operand; operands were {:?}",
3171            ops.operands
3172        );
3173        assert_eq!(
3174            special, 1,
3175            "bare $? must be one operand; operands were {:?}",
3176            ops.operands
3177        );
3178        assert!(
3179            !ops.operands.iter().any(|o| o.as_str() == "?"),
3180            "the inner special_variable_name `?` leaf must be suppressed; operands were {:?}",
3181            ops.operands
3182        );
3183        // The assignment LHS `variable_name` `x` (parent `variable_assignment`,
3184        // not `simple_expansion`) must still be an operand.
3185        assert!(
3186            ops.operands.iter().any(|o| o.as_str() == "x"),
3187            "assignment LHS `x` must still be an operand; operands were {:?}",
3188            ops.operands
3189        );
3190    }
3191
3192    #[test]
3193    fn tcl_operators_and_operands() {
3194        check_metrics::<TclParser>(
3195            "proc f {a b} {
3196    set x [expr {$a + $b}]
3197    if {$x > 0 && $x != 0} {
3198        return $x
3199    }
3200    return 0
3201}",
3202            "foo.tcl",
3203            |metric| {
3204                insta::assert_json_snapshot!(metric.halstead);
3205            },
3206        );
3207    }
3208
3209    #[test]
3210    fn tcl_bitwise_ternary_string_ops() {
3211        // Exercises operator families not covered by tcl_operators_and_operands:
3212        // bitwise (&, |, ^, ~, <<, >>), ternary (?), and string-comparison (eq, ne, in, ni).
3213        check_metrics::<TclParser>(
3214            "proc f {a b} {
3215    set bits [expr {$a & $b | $a ^ ~$b}]
3216    set sh [expr {$a << 1 | $b >> 1}]
3217    set t [expr {$a > 0 ? $a : $b}]
3218    if {$a eq {x} || $a ne {y}} {
3219        return $a
3220    }
3221    return $b
3222}",
3223            "foo.tcl",
3224            |metric| {
3225                insta::assert_json_snapshot!(metric.halstead);
3226            },
3227        );
3228    }
3229
3230    #[test]
3231    fn tcl_bare_variable_operand() {
3232        // Bare `$varname` produces a VariableSubstitution node (already an operand).
3233        // Its anonymous Id2 child must NOT be counted separately; each reference is 1 operand.
3234        check_metrics::<TclParser>(
3235            "proc f {x} {
3236    return $x
3237}",
3238            "foo.tcl",
3239            |metric| {
3240                insta::assert_json_snapshot!(metric.halstead);
3241            },
3242        );
3243    }
3244
3245    #[test]
3246    fn tcl_inert_quoted_word_counts_as_operand() {
3247        // Regression for #277. A `"..."` literal with no `$var` / `[cmd]`
3248        // interpolation must contribute exactly one operand (the wrapping
3249        // `QuotedWord`). The string content `hello world` is exposed as a
3250        // single `_quoted_word_content` token (not itself classified by
3251        // `get_op_type`), so the only operands here are `f`, `s`, and the
3252        // quoted string. `set` is the anonymous `Set2` keyword and is
3253        // classified as an operator, not an operand.
3254        check_metrics::<TclParser>(
3255            "proc f {} {
3256    set s \"hello world\"
3257}",
3258            "foo.tcl",
3259            |metric| {
3260                // Operands: `f`, `s`, `"hello world"` — 3 unique, 3 total.
3261                // The wrapping `QuotedWord` must still contribute exactly
3262                // one operand when it carries no interpolation children;
3263                // dropping to 2 would mean the inert case was over-guarded.
3264                assert_eq!(metric.halstead.unique_operands(), 3);
3265                assert_eq!(metric.halstead.total_operands(), 3);
3266                insta::assert_json_snapshot!(metric.halstead);
3267            },
3268        );
3269    }
3270
3271    #[test]
3272    fn tcl_interpolated_quoted_word_no_double_count() {
3273        // Regression for #277. Before the fix, `"$x is $y"` produced an
3274        // extra operand for the wrapping `QuotedWord` on top of the two
3275        // inner `VariableSubstitution` operands (`$x`, `$y`), giving 7.
3276        // After the fix, the wrapper is `HalsteadType::Unknown` whenever
3277        // it carries an interpolation child, so operand attribution
3278        // belongs solely to the inner substitutions.
3279        check_metrics::<TclParser>(
3280            "proc f {x y} {
3281    set s \"$x is $y\"
3282}",
3283            "foo.tcl",
3284            |metric| {
3285                // Operands: `f`, `x`, `y` (proc args), `s`, `$x`, `$y` — 6
3286                // unique, 6 total. The wrapping `QuotedWord` contributes
3287                // nothing. Pre-fix this read 7/7 (double-counted wrapper).
3288                assert_eq!(metric.halstead.unique_operands(), 6);
3289                assert_eq!(metric.halstead.total_operands(), 6);
3290                insta::assert_json_snapshot!(metric.halstead);
3291            },
3292        );
3293    }
3294
3295    #[test]
3296    fn tcl_command_substitution_quoted_word_no_double_count() {
3297        // Regression for #277. A `"...[cmd]..."` literal exposes the
3298        // bracketed command as a `command_substitution` child whose inner
3299        // identifiers/literals contribute their own operands. The wrapping
3300        // `QuotedWord` must not also be classified as an operand, or the
3301        // command's identifier would be counted alongside a phantom
3302        // wrapper operand.
3303        check_metrics::<TclParser>(
3304            "proc f {} {
3305    set s \"result: [foo]\"
3306}",
3307            "foo.tcl",
3308            |metric| {
3309                // Operands: `f`, `s`, `foo` — 3 unique, 3 total. The
3310                // wrapping `QuotedWord` and the inert text `result: ` do
3311                // not contribute extra operands. Pre-fix this read 4/4
3312                // (double-counted wrapper).
3313                assert_eq!(metric.halstead.unique_operands(), 3);
3314                assert_eq!(metric.halstead.total_operands(), 3);
3315                insta::assert_json_snapshot!(metric.halstead);
3316            },
3317        );
3318    }
3319
3320    #[test]
3321    fn php_operators_and_operands() {
3322        check_metrics::<PhpParser>(
3323            "<?php
3324            function avg(int $a, int $b, int $c): int {
3325                return ($a + $b + $c) / 3;
3326            }",
3327            "foo.php",
3328            |metric| {
3329                // After #695 only the opening delimiters count: `()` and
3330                // `{}` fold to one operator each per balanced pair, so the
3331                // former `)`/`}` closers no longer inflate n1/N1 (was
3332                // 11 unique / 15 total). Operands are unchanged.
3333                assert_eq!(metric.halstead.unique_operators(), 9);
3334                assert_eq!(metric.halstead.total_operators(), 12);
3335                assert_eq!(metric.halstead.unique_operands(), 9);
3336                assert_eq!(metric.halstead.total_operands(), 22);
3337                insta::assert_json_snapshot!(metric.halstead);
3338            },
3339        );
3340    }
3341
3342    #[test]
3343    fn php_simple_function() {
3344        check_metrics::<PhpParser>(
3345            "<?php
3346            function inc(int $x): int { return $x + 1; }",
3347            "foo.php",
3348            |metric| {
3349                // After #695 only opening delimiters count: the `)`/`}`
3350                // closers no longer add operators (was 9 unique / 9 total).
3351                assert_eq!(metric.halstead.unique_operators(), 7);
3352                assert_eq!(metric.halstead.total_operators(), 7);
3353                assert_eq!(metric.halstead.unique_operands(), 5);
3354                assert_eq!(metric.halstead.total_operands(), 10);
3355                insta::assert_json_snapshot!(metric.halstead);
3356            },
3357        );
3358    }
3359
3360    #[test]
3361    fn php_encapsed_string_interpolation_no_double_count() {
3362        // Regression: issue #184. A PHP `"Hello $name!"` used to be
3363        // classified as a Halstead operand (the wrapping
3364        // `encapsed_string`) AND have its inner `variable_name`
3365        // (`$name`) plus the inner `name` token classified as
3366        // operands too. With the fix, the wrapping literal drops to
3367        // `Unknown` when it carries any `$var` / `${name}` / `{$expr}`
3368        // child, so `$name` is counted exactly once at each text
3369        // occurrence.
3370        //
3371        // Source:
3372        //   <?php $name = "world"; echo "Hello $name!";
3373        //
3374        // Inert operand: `"world"` (no interpolation, still operand).
3375        // Operands by text key (`get_id` keys by source bytes):
3376        //   `$name` × 2 (assignment LHS and `$name` inside the
3377        //   interpolated string), `name` × 2 (the `name` token inside
3378        //   each `variable_name`), `"world"` × 1.
3379        // u_operands = 3, N2 = 5.
3380        // Without the fix the wrapping `"Hello $name!"` would also
3381        // count → u_operands = 4, N2 = 6.
3382        check_metrics::<PhpParser>(
3383            "<?php $name = \"world\"; echo \"Hello $name!\";",
3384            "foo.php",
3385            |metric| {
3386                assert_eq!(metric.halstead.unique_operands(), 3);
3387                assert_eq!(metric.halstead.total_operands(), 5);
3388            },
3389        );
3390    }
3391
3392    #[test]
3393    fn php_encapsed_string_no_interpolation_still_operand() {
3394        // The fix for #184 only drops `EncapsedString`/`Heredoc` from
3395        // the operand arm when interpolation is present. An inert
3396        // double-quoted string must still count as exactly one
3397        // operand, identical to the single-quoted equivalent.
3398        //
3399        // Source: `<?php echo "Hello world!";`
3400        // Operands: `"Hello world!"` × 1 → u_operands = 1, N2 = 1.
3401        check_metrics::<PhpParser>("<?php echo \"Hello world!\";", "foo.php", |metric| {
3402            assert_eq!(metric.halstead.unique_operands(), 1);
3403            assert_eq!(metric.halstead.total_operands(), 1);
3404        });
3405    }
3406
3407    #[test]
3408    fn php_heredoc_interpolation_no_double_count() {
3409        // Regression: issue #184. A PHP heredoc whose body
3410        // interpolates `$name` previously counted both the wrapping
3411        // `heredoc` node and the inner `$name` as operands; the fix
3412        // drops the wrapper when its `heredoc_body` carries any
3413        // interpolation child.
3414        //
3415        // Source:
3416        //   <?php $name = "x"; echo <<<EOT
3417        //   hi $name
3418        //   EOT;
3419        //
3420        // Operands by text key: `$name` × 2, `name` × 2, `"x"` × 1
3421        // (inert single-interp encapsed string also operand). With
3422        // the fix u_operands = 3, N2 = 5. Without the fix the
3423        // wrapping heredoc text would add one more unique operand.
3424        check_metrics::<PhpParser>(
3425            "<?php $name = \"x\"; echo <<<EOT\nhi $name\nEOT;\n",
3426            "foo.php",
3427            |metric| {
3428                assert_eq!(metric.halstead.unique_operands(), 3);
3429                assert_eq!(metric.halstead.total_operands(), 5);
3430            },
3431        );
3432    }
3433
3434    #[test]
3435    fn php_nowdoc_unaffected() {
3436        // `Nowdoc` (single-quoted heredoc) never interpolates and is
3437        // never matched by `php_string_has_interpolation`. It must
3438        // continue counting as exactly one operand regardless of the
3439        // text inside, mirroring single-quoted `String`.
3440        //
3441        // Source:
3442        //   <?php echo <<<'EOT'
3443        //   plain $name not interpolated
3444        //   EOT;
3445        //
3446        // Operands: the nowdoc literal × 1 → u_operands = 1, N2 = 1.
3447        check_metrics::<PhpParser>(
3448            "<?php echo <<<'EOT'\nplain $name not interpolated\nEOT;\n",
3449            "foo.php",
3450            |metric| {
3451                assert_eq!(metric.halstead.unique_operands(), 1);
3452                assert_eq!(metric.halstead.total_operands(), 1);
3453            },
3454        );
3455    }
3456
3457    #[test]
3458    fn php_encapsed_string_bare_member_access_no_double_count() {
3459        // Regression: issue #184 follow-up. The PHP grammar allows
3460        // bare `$obj->prop` interpolation inside `"…"` without
3461        // surrounding `{ … }`; tree-sitter-php emits this as a
3462        // direct `member_access_expression` child of
3463        // `encapsed_string` (kind_id 329 in the current grammar).
3464        // The wrapper must drop to `Unknown` for that form too —
3465        // otherwise the inner `$obj` and `prop` `name` tokens are
3466        // walked as operands while the wrapper also counts,
3467        // double-counting `N2`.
3468        //
3469        // Source:
3470        //   <?php $obj = new stdClass; $obj->prop = "x"; echo "Hi $obj->prop!";
3471        //
3472        // Operands tallied by `get_id` (keyed on source bytes):
3473        //   `$obj`        × 3 (LHS assignment, member-access target,
3474        //                      inside the interpolated string)
3475        //   `obj`  (name) × 3 (one per `variable_name`)
3476        //   `prop` (name) × 2 (member-access RHS twice)
3477        //   `stdClass`    × 1
3478        //   `"x"`         × 1
3479        // ⇒ u_operands = 5, N2 = 10.
3480        // With the bug the wrapping `"Hi $obj->prop!"` text adds one
3481        // more unique operand and one more occurrence ⇒ 6 / 11.
3482        check_metrics::<PhpParser>(
3483            "<?php $obj = new stdClass; $obj->prop = \"x\"; echo \"Hi $obj->prop!\";",
3484            "foo.php",
3485            |metric| {
3486                assert_eq!(metric.halstead.unique_operands(), 5);
3487                assert_eq!(metric.halstead.total_operands(), 10);
3488            },
3489        );
3490    }
3491
3492    #[test]
3493    fn php_encapsed_string_bare_subscript_no_double_count() {
3494        // Regression: issue #184 follow-up. Bare `$arr[0]` inside
3495        // `"…"` produces a `subscript_expression` child of
3496        // `encapsed_string` (kind_id 351). The wrapper must drop to
3497        // `Unknown` for that form.
3498        //
3499        // Source:
3500        //   <?php $arr = [1]; echo "Hi $arr[0]!";
3501        //
3502        // Operands tallied by `get_id`:
3503        //   `$arr` × 2, `arr` × 2 (inner `name`), `1` × 1, `0` × 1.
3504        // ⇒ u_operands = 4, N2 = 6.
3505        // With the bug the wrapping `"Hi $arr[0]!"` text adds 1 / 1.
3506        check_metrics::<PhpParser>(
3507            "<?php $arr = [1]; echo \"Hi $arr[0]!\";",
3508            "foo.php",
3509            |metric| {
3510                assert_eq!(metric.halstead.unique_operands(), 4);
3511                assert_eq!(metric.halstead.total_operands(), 6);
3512            },
3513        );
3514    }
3515
3516    #[test]
3517    fn php_shell_command_expression_inert_is_operand() {
3518        // Regression: issue #288. Backtick command literals (PHP's
3519        // `shell_command_expression`) were filtered as strings by
3520        // `Checker::is_string` and `Alterator::alterate`, but never
3521        // classified as Halstead operands — so they contributed
3522        // nothing to N2 / eta2. An inert backtick literal must now
3523        // count as exactly one operand, matching `EncapsedString`
3524        // and `Heredoc`.
3525        //
3526        // Source: `<?php $out = ` + backtick `ls` + backtick + `;`
3527        // Operands tallied by `get_id`:
3528        //   `$out` × 1, `out` × 1 (inner `name`), backtick literal × 1.
3529        // ⇒ u_operands = 3, N2 = 3.
3530        // Before the fix the backtick literal vanished from the count
3531        // ⇒ u_operands = 2, N2 = 2.
3532        check_metrics::<PhpParser>("<?php $out = `ls`;", "foo.php", |metric| {
3533            assert_eq!(metric.halstead.unique_operands(), 3);
3534            assert_eq!(metric.halstead.total_operands(), 3);
3535        });
3536    }
3537
3538    #[test]
3539    fn php_shell_command_expression_interpolation_no_double_count() {
3540        // Regression: issue #288. PHP backtick literals DO support
3541        // `$var` interpolation (see tree-sitter-php node-types.json:
3542        // `shell_command_expression` children include `variable_name`,
3543        // `dynamic_variable_name`, `member_access_expression`,
3544        // `subscript_expression`). With the fix the wrapper drops to
3545        // `Unknown` when it carries any interpolation child, exactly
3546        // as `EncapsedString` does.
3547        //
3548        // Source: `<?php $dir = "/tmp"; $out = ` + backtick `ls $dir` +
3549        //   backtick + `;`
3550        //
3551        // Operands tallied by `get_id`:
3552        //   `$dir` × 2 (assignment LHS, inside backticks),
3553        //   `dir`  × 2 (inner `name`),
3554        //   `$out` × 1, `out` × 1, `"/tmp"` × 1.
3555        // ⇒ u_operands = 5, N2 = 7.
3556        // Without the interpolation guard the wrapping backtick literal
3557        // would also count ⇒ u_operands = 6, N2 = 8.
3558        check_metrics::<PhpParser>(
3559            "<?php $dir = \"/tmp\"; $out = `ls $dir`;",
3560            "foo.php",
3561            |metric| {
3562                assert_eq!(metric.halstead.unique_operands(), 5);
3563                assert_eq!(metric.halstead.total_operands(), 7);
3564            },
3565        );
3566    }
3567
3568    #[test]
3569    fn elixir_operators_and_operands() {
3570        // Exercises every Halstead family classified in Elixir's
3571        // `get_op_type`: control-flow keywords (`do`, `end`, `fn`),
3572        // structural punctuation — only the *opening* delimiters `(`,
3573        // `[` count after #695 (the `)`/`]` closers were dropped), plus
3574        // `,`, `.`, `@`,
3575        // arithmetic (`+`, `-`, `*`, `/`), comparison (`==`, `>`),
3576        // logical (`&&`, `||`, `and`, `or`, `!`), pipe (`|>`), capture
3577        // (`&`), assignment/match (`=`), and the stab arrow (`->`).
3578        // The body mixes identifiers, integers, atoms, and a string.
3579        check_metrics::<ElixirParser>(
3580            "defmodule Foo do\n  @doc \"add\"\n  def calc(a, b) do\n    result = a + b * 2\n    flag = result > 0 && a == b\n    out = if flag, do: result, else: -result\n    [out, a, b]\n  end\nend\n",
3581            "foo.ex",
3582            |metric| {
3583                // Positive headline assertions on integer counts. After
3584                // #695 only opening delimiters count: the `)`/`]` closers
3585                // no longer add operators (was 15 unique / 23 total).
3586                assert_eq!(metric.halstead.unique_operators(), 13);
3587                assert_eq!(metric.halstead.total_operators(), 21);
3588                assert_eq!(metric.halstead.unique_operands(), 16);
3589                assert_eq!(metric.halstead.total_operands(), 27);
3590                insta::assert_json_snapshot!(
3591                    metric.halstead,
3592                    @r#"
3593                {
3594                  "unique_operators": 13,
3595                  "total_operators": 21,
3596                  "unique_operands": 16,
3597                  "total_operands": 27,
3598                  "length": 48,
3599                  "estimated_program_length": 112.10571633583419,
3600                  "purity_ratio": 2.3355357569965456,
3601                  "vocabulary": 29,
3602                  "volume": 233.18308776612344,
3603                  "difficulty": 10.96875,
3604                  "level": 0.09116809116809117,
3605                  "effort": 2557.7269939346666,
3606                  "time": 142.09594410748147,
3607                  "bugs": 0.062342115670886794
3608                }
3609                "#
3610                );
3611            },
3612        );
3613    }
3614
3615    #[test]
3616    fn ruby_operators_and_operands() {
3617        // A small Ruby method exercising operators (def/if/end keyword
3618        // tokens, `+`, `==`, `<=`, structural punctuation) and operands
3619        // (`n`, `1`, `factorial`). Anchors the unique/total counts on
3620        // both sides and snapshots the full Halstead derivation.
3621        //
3622        // Lesson 4 invariants: u_operators / u_operands here equal the
3623        // dedupe lengths the `--ops` accessor would emit on the same
3624        // source. Any future grammar bump that adds an aliased kind_id
3625        // to either side will trip this without snapshot drift.
3626        check_metrics::<RubyParser>(
3627            "def factorial(n)\n  return 1 if n <= 1\n  n * factorial(n - 1)\nend\n",
3628            "foo.rb",
3629            |metric| {
3630                // After #695 only the `(` opener counts (folded `()`); the
3631                // `)` closer — which appeared twice across the two calls —
3632                // no longer adds an operator (was 9 unique / 11 total).
3633                assert_eq!(metric.halstead.unique_operators(), 8);
3634                assert_eq!(metric.halstead.total_operators(), 9);
3635                assert_eq!(metric.halstead.unique_operands(), 3);
3636                assert_eq!(metric.halstead.total_operands(), 9);
3637                insta::assert_json_snapshot!(metric.halstead);
3638            },
3639        );
3640    }
3641
3642    #[test]
3643    fn ruby_halstead_plain_string_operand() {
3644        // A bare string literal contributes exactly one operand. The
3645        // counterpart to `ruby_halstead_interpolated_string_no_double_count`
3646        // — verifies the "no interpolation" branch of the same arm
3647        // (see `src/getter.rs::get_op_type`'s `R::String | …` case).
3648        // expected: operators = {def, end} = 2; operands = {f, "hello"} = 2.
3649        check_metrics::<RubyParser>("def f\n  \"hello\"\nend\n", "foo.rb", |metric| {
3650            assert_eq!(metric.halstead.unique_operators(), 2);
3651            assert_eq!(metric.halstead.total_operators(), 2);
3652            assert_eq!(metric.halstead.unique_operands(), 2);
3653            assert_eq!(metric.halstead.total_operands(), 2);
3654        });
3655    }
3656
3657    #[test]
3658    fn ruby_halstead_interpolated_string_no_double_count() {
3659        // Regression mirror for #180 (Bash) / #183 (C#): when a Ruby
3660        // string literal carries an `Interpolation` child, the
3661        // wrapping `String` node is intentionally classified as
3662        // `Unknown` so the inner expression's identifiers are not
3663        // double-counted as operands.
3664        //
3665        // expected: for `def f(name)\n  "Hi #{name}"\nend\n` —
3666        //   operators: def, (, ), #{, }, end → u_operators = 6.
3667        //   operands: f, name (param), name (inside `#{name}`). The
3668        //   wrapping `"…#{name}"` literal is skipped by the
3669        //   `is_child(R::Interpolation)` guard; the operand store
3670        //   keys by token text so the two `name` occurrences dedupe
3671        //   into one distinct entry → u_operands = 2, operands = 3
3672        //   (`f` once, `name` twice).
3673        // Without the guard, the wrapping literal would also count,
3674        // inflating u_operands to 3 and operands to 4.
3675        check_metrics::<RubyParser>("def f(name)\n  \"Hi #{name}\"\nend\n", "foo.rb", |metric| {
3676            assert_eq!(metric.halstead.unique_operands(), 2);
3677            assert_eq!(metric.halstead.total_operands(), 3);
3678        });
3679    }
3680
3681    #[test]
3682    fn ruby_halstead_symbol_literal_operand() {
3683        // `:foo` is a `SimpleSymbol` leaf — counts as a single
3684        // operand, no interpolation guard needed (only
3685        // `DelimitedSymbol` (`:"…#{x}…"`) can interpolate).
3686        // expected: operators = {def, end} = 2; operands = {f, :ok} = 2.
3687        check_metrics::<RubyParser>("def f\n  :ok\nend\n", "foo.rb", |metric| {
3688            assert_eq!(metric.halstead.unique_operators(), 2);
3689            assert_eq!(metric.halstead.unique_operands(), 2);
3690        });
3691    }
3692
3693    #[test]
3694    fn ruby_halstead_regex_operand() {
3695        // `/foo/` parses as a `Regex` node — one operand. The slash
3696        // delimiters around it are emitted as `SLASH` tokens and
3697        // classified as arithmetic-or-divide operators by the shared
3698        // arm; they count once toward the distinct-operator set.
3699        // expected: u_operators = {def, (, =~, /, end} = 5 (only the
3700        // `(` opener counts after #695 — the `)` closer was dropped);
3701        // u_operands = {f, s, /foo/} = 3.
3702        check_metrics::<RubyParser>("def f(s)\n  s =~ /foo/\nend\n", "foo.rb", |metric| {
3703            assert_eq!(metric.halstead.unique_operators(), 5);
3704            assert_eq!(metric.halstead.unique_operands(), 3);
3705        });
3706    }
3707
3708    /// Comprehensive iRules Halstead test exercising every operator family
3709    /// classified in `get_op_type`: declaration/control keywords (`proc`,
3710    /// `set`, `if`, `return`), structural punctuation (`{}` `[]` `()`),
3711    /// arithmetic (`+`), comparison (`>`), the word-form string comparator
3712    /// (`eq`), and short-circuit logical (`&&`). Anchored on the integer
3713    /// `n1`/`N1`/`n2`/`N2` headline values; the float fields are derived and
3714    /// bit-brittle, so they are not pinned.
3715    ///
3716    /// The second half pins the lesson-4 invariant: the independent
3717    /// text-keyed `operands_and_operators` store must dedupe to the same
3718    /// `n1`/`n2`. A classification change that moved one store without the
3719    /// other (e.g. a kind landing in both the operator and operand arms)
3720    /// would break this even though the snapshot stayed green.
3721    #[test]
3722    fn irules_operators_and_operands() {
3723        let source = "proc f { a b } {
3724    set x [expr { $a + $b }]
3725    if { $x > 0 && $a eq \"go\" } {
3726        return $x
3727    }
3728    return 0
3729}
3730";
3731        check_metrics::<IrulesParser>(source, "foo.irule", |metric| {
3732            // After #695 only opening delimiters count: the `}`/`]`
3733            // closers no longer add operators (was 12 unique / 20 total).
3734            assert_eq!(metric.halstead.unique_operators(), 10);
3735            assert_eq!(metric.halstead.total_operators(), 14);
3736            assert_eq!(metric.halstead.unique_operands(), 12);
3737            assert_eq!(metric.halstead.total_operands(), 16);
3738        });
3739
3740        let path = PathBuf::from("foo.irule");
3741        let parser = IrulesParser::new(source.as_bytes().to_vec(), &path, None);
3742        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
3743        let unique_operators: HashSet<&str> = ops.operators.iter().map(String::as_str).collect();
3744        let unique_operands: HashSet<&str> = ops.operands.iter().map(String::as_str).collect();
3745        assert_eq!(
3746            unique_operators.len(),
3747            10,
3748            "dedupe(ops.operators) must equal n1; operators were {:?}",
3749            ops.operators
3750        );
3751        assert_eq!(
3752            unique_operands.len(),
3753            12,
3754            "dedupe(ops.operands) must equal n2; operands were {:?}",
3755            ops.operands
3756        );
3757    }
3758
3759    /// An inert `"hello world"` double-quoted string (no `$var` / `[cmd]`
3760    /// interpolation child) contributes exactly **one** operand — the
3761    /// wrapping `QuotedWord`. Operands are `f`, `s`, `"hello world"`, and
3762    /// the proc-body `braced_word` (counted as an operand in the Tcl
3763    /// family). iRules additionally counts the `set` target `s`, which
3764    /// tree-sitter-tcl's grammar structure omits — hence n2=4 here vs Tcl's
3765    /// 3. Mirrors `tcl_inert_quoted_word_counts_as_operand` (#277).
3766    #[test]
3767    fn irules_inert_quoted_word_counts_as_operand() {
3768        let source = "proc f {} {\n    set s \"hello world\"\n}\n";
3769        check_metrics::<IrulesParser>(source, "foo.irule", |metric| {
3770            // After #695 only the `{` opener counts; the `}` closer no
3771            // longer adds an operator (was 4 unique / 6 total).
3772            assert_eq!(metric.halstead.unique_operators(), 3);
3773            assert_eq!(metric.halstead.total_operators(), 4);
3774            assert_eq!(metric.halstead.unique_operands(), 4);
3775            assert_eq!(metric.halstead.total_operands(), 4);
3776        });
3777
3778        let path = PathBuf::from("foo.irule");
3779        let parser = IrulesParser::new(source.as_bytes().to_vec(), &path, None);
3780        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
3781        // The inert quoted word is present as exactly one operand (not
3782        // dropped, not split): dropping it would mean the inert branch was
3783        // over-guarded.
3784        let quoted = ops
3785            .operands
3786            .iter()
3787            .filter(|o| o.as_str() == "\"hello world\"")
3788            .count();
3789        assert_eq!(quoted, 1, "inert quoted word must be one operand");
3790        let unique_operands: HashSet<&str> = ops.operands.iter().map(String::as_str).collect();
3791        assert_eq!(unique_operands.len(), 4, "operands were {:?}", ops.operands);
3792    }
3793
3794    /// Regression for the `QuotedWord` interpolation guard (the #277 /
3795    /// Bash-#180 / C#-#183 / PHP-#184 pattern). An interpolated
3796    /// `"$x is $y"` must contribute **zero** operands for the wrapping
3797    /// `QuotedWord`; the inner `$x` / `$y` `variable_substitution` nodes are
3798    /// walked separately and count on their own. Operands are `f`, `x`, `y`,
3799    /// `s`, `$x`, `$y`, and the proc-body `braced_word` = 7. If the guard
3800    /// regressed (wrapper classified `Operand`), the wrapper string would
3801    /// add an 8th operand. This is the branch that had no test before.
3802    #[test]
3803    fn irules_interpolated_quoted_word_no_double_count() {
3804        let source = "proc f {x y} {\n    set s \"$x is $y\"\n}\n";
3805        check_metrics::<IrulesParser>(source, "foo.irule", |metric| {
3806            // After #695 only the `{` opener counts; the `}` closer no
3807            // longer adds an operator (was 4 unique / 6 total).
3808            assert_eq!(metric.halstead.unique_operators(), 3);
3809            assert_eq!(metric.halstead.total_operators(), 4);
3810            assert_eq!(metric.halstead.unique_operands(), 7);
3811            assert_eq!(metric.halstead.total_operands(), 7);
3812        });
3813
3814        let path = PathBuf::from("foo.irule");
3815        let parser = IrulesParser::new(source.as_bytes().to_vec(), &path, None);
3816        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
3817        // The wrapping interpolated string must NOT appear as an operand;
3818        // its inner substitutions must. The wrapper, if wrongly counted,
3819        // would surface as the quoted literal `"$x is $y"` (with quotes,
3820        // like the inert `"hello world"` operand). Match that exact token —
3821        // a substring check would false-match the proc-body `braced_word`
3822        // operand, which legitimately contains the source text.
3823        assert!(
3824            !ops.operands.iter().any(|o| o.as_str() == "\"$x is $y\""),
3825            "interpolated wrapper must not be an operand; operands were {:?}",
3826            ops.operands
3827        );
3828        assert!(
3829            ops.operands.iter().any(|o| o.as_str() == "$x")
3830                && ops.operands.iter().any(|o| o.as_str() == "$y"),
3831            "inner $x / $y substitutions must each be operands; operands were {:?}",
3832            ops.operands
3833        );
3834        let unique_operands: HashSet<&str> = ops.operands.iter().map(String::as_str).collect();
3835        assert_eq!(unique_operands.len(), 7, "operands were {:?}", ops.operands);
3836    }
3837
3838    /// Exercises the operator families not covered by
3839    /// `irules_operators_and_operands`: bitwise (`& | ^ ~ << >>`), ternary
3840    /// (`? :`), the keyword string comparators (`starts_with`, `ends_with`,
3841    /// `contains`, `matches`, `eq`, `ne`), and the keyword logical operator
3842    /// (`and`). Pins every operator-family arm in `get_op_type` plus the
3843    /// lesson-4 dedupe invariant.
3844    #[test]
3845    fn irules_bitwise_ternary_string_ops() {
3846        let source = "proc f { a b } {
3847    set bits [expr { $a & $b | $a ^ ~$b }]
3848    set sh [expr { $a << 2 | $b >> 1 }]
3849    set t [expr { $a > 0 ? $a : $b }]
3850    if { $a starts_with \"x\" && $b ends_with \"y\" } { return 1 }
3851    if { $a contains \"z\" || $b matches \"q\" } { return 2 }
3852    if { $a eq \"m\" and $b ne \"n\" } { return 3 }
3853    return $b
3854}
3855";
3856        check_metrics::<IrulesParser>(source, "foo.irule", |metric| {
3857            // After #695 only opening delimiters count: the `}`/`]`
3858            // closers no longer add operators (was 26 unique / 57 total).
3859            assert_eq!(metric.halstead.unique_operators(), 24);
3860            assert_eq!(metric.halstead.total_operators(), 43);
3861            assert_eq!(metric.halstead.unique_operands(), 23);
3862            assert_eq!(metric.halstead.total_operands(), 42);
3863        });
3864
3865        let path = PathBuf::from("foo.irule");
3866        let parser = IrulesParser::new(source.as_bytes().to_vec(), &path, None);
3867        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
3868        let unique_operators: HashSet<&str> = ops.operators.iter().map(String::as_str).collect();
3869        let unique_operands: HashSet<&str> = ops.operands.iter().map(String::as_str).collect();
3870        assert_eq!(
3871            unique_operators.len(),
3872            24,
3873            "dedupe(ops.operators) must equal n1; operators were {:?}",
3874            ops.operators
3875        );
3876        assert_eq!(
3877            unique_operands.len(),
3878            23,
3879            "dedupe(ops.operands) must equal n2; operands were {:?}",
3880            ops.operands
3881        );
3882    }
3883
3884    /// A bare `$x` produces one `variable_substitution` operand. Its inner
3885    /// `id` leaf (the *named* `Id` node — not the anonymous `Id2` token Tcl
3886    /// has there) must NOT be counted separately, or every variable
3887    /// reference double-counts. `get_op_type` excludes `Id` whose parent is
3888    /// a `VariableSubstitution`. Operands: `f`, the proc arg `x`, `return`,
3889    /// `$x`, and the proc-body `braced_word` — five, with no duplicate
3890    /// (`total_operands()` == 5). If the guard regressed, the inner `id` "x"
3891    /// would add a sixth operand occurrence (it text-collides with the proc
3892    /// arg `x`, so `u_operands` would stay 5 but `total_operands()` would rise
3893    /// to 6 — hence the total, not just the unique count, is asserted).
3894    #[test]
3895    fn irules_bare_variable_operand() {
3896        let source = "proc f {x} {\n    return $x\n}\n";
3897        check_metrics::<IrulesParser>(source, "foo.irule", |metric| {
3898            // After #695 only the `{` opener counts (folded `{}`); the
3899            // `}` closer no longer adds an operator (was 3 unique / 5 total).
3900            assert_eq!(metric.halstead.unique_operators(), 2);
3901            assert_eq!(metric.halstead.total_operators(), 3);
3902            assert_eq!(metric.halstead.unique_operands(), 5);
3903            assert_eq!(metric.halstead.total_operands(), 5);
3904        });
3905
3906        let path = PathBuf::from("foo.irule");
3907        let parser = IrulesParser::new(source.as_bytes().to_vec(), &path, None);
3908        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
3909        let bare_var = ops.operands.iter().filter(|o| o.as_str() == "$x").count();
3910        assert_eq!(
3911            bare_var, 1,
3912            "bare $x must be exactly one operand (inner id leaf not double-counted); operands were {:?}",
3913            ops.operands
3914        );
3915    }
3916
3917    /// Regression for #563: the two Halstead `Display` labels must use the
3918    /// underscore key that matches the JSON/CSV field name, so a user can grep
3919    /// the same token across `Display` and JSON. The space-separated forms
3920    /// (`estimated program length` / `purity ratio`) were the only outliers,
3921    /// mirroring the `dump` fix in #562.
3922    #[test]
3923    fn display_halstead_labels_use_underscore_keys() {
3924        check_metrics::<CppParser>("int a = 42;", "foo.cpp", |metric| {
3925            let out = metric.halstead.to_string();
3926            assert!(
3927                out.contains("estimated_program_length: "),
3928                "Display must use the underscore key `estimated_program_length`:\n{out}"
3929            );
3930            assert!(
3931                out.contains("purity_ratio: "),
3932                "Display must use the underscore key `purity_ratio`:\n{out}"
3933            );
3934            assert!(
3935                !out.contains("estimated program length"),
3936                "Display must not emit the space-separated `estimated program length`:\n{out}"
3937            );
3938            assert!(
3939                !out.contains("purity ratio"),
3940                "Display must not emit the space-separated `purity ratio`:\n{out}"
3941            );
3942        });
3943    }
3944
3945    /// Comprehensive Objective-C Halstead fixture exercising a message
3946    /// send (`[self log:@"hi"]`), an ObjC string literal (`@"hi"`), an
3947    /// `if`, a short-circuit `&&`, arithmetic (`+`), comparisons, and
3948    /// assignment. Pins every field and enforces the lesson-4 invariants
3949    /// `unique_operators == n1` / `unique_operands == n2` via the
3950    /// independent `--ops` store.
3951    #[test]
3952    fn objc_operators_and_operands() {
3953        let source = "@implementation Foo
3954- (int)bar:(int)x {
3955    int y = x + 1;
3956    if (x > 0 && y < 10) {
3957        [self log:@\"hi\"];
3958    }
3959    return y;
3960}
3961@end
3962";
3963        check_metrics::<ObjcParser>(source, "foo.m", |metric| {
3964            // n1 = 15 unique operators:
3965            //   `&&`, `()`, `+`, `-`, `:`, `;`, `<`, `=`, `>`, `@`,
3966            //   `[]` (message send), `if`, `int`, `return`, `{}`.
3967            // n2 = 10 unique operands:
3968            //   `Foo`, `bar`, `log`, `self`, `x`, `y`, `0`, `1`, `10`,
3969            //   `@"hi"` (the ObjC string literal).
3970            assert_eq!(metric.halstead.unique_operators(), 15);
3971            assert_eq!(metric.halstead.unique_operands(), 10);
3972            insta::assert_json_snapshot!(metric.halstead, @r#"
3973            {
3974              "unique_operators": 15,
3975              "total_operators": 23,
3976              "unique_operands": 10,
3977              "total_operands": 14,
3978              "length": 37,
3979              "estimated_program_length": 91.82263988300141,
3980              "purity_ratio": 2.481692969810849,
3981              "vocabulary": 25,
3982              "volume": 171.8226790216648,
3983              "difficulty": 10.5,
3984              "level": 0.09523809523809523,
3985              "effort": 1804.1381297274804,
3986              "time": 100.22989609597113,
3987              "bugs": 0.049399808887691035
3988            }
3989            "#);
3990        });
3991        // Lesson-4 invariant: dedupe(ops.operands) == n2 (10), via the
3992        // independent text-keyed `--ops` store.
3993        assert_ops_operands::<ObjcParser>(
3994            source,
3995            "foo.m",
3996            10,
3997            vec![
3998                "Foo", "bar", "log", "self", "x", "y", "0", "1", "10", "@\"hi\"",
3999            ],
4000        );
4001    }
4002
4003    /// Builds a `HalsteadMaps` from explicit occurrence counts.
4004    ///
4005    /// The per-language tests above reach these maps only through a
4006    /// parse, which cannot produce a *chosen* overlap between a child
4007    /// and its parent — the cases `merge` exists to get right.
4008    fn halstead_maps_of<'a>(
4009        operators: &[(u16, u64)],
4010        primitive_operators: &[(&'a [u8], u64)],
4011        operands: &[(&'a [u8], u64)],
4012    ) -> HalsteadMaps<'a> {
4013        HalsteadMaps {
4014            operators: operators.iter().copied().collect(),
4015            primitive_operators: primitive_operators.iter().copied().collect(),
4016            operands: operands.iter().copied().collect(),
4017        }
4018    }
4019
4020    /// `HalsteadMaps::operators` must stay on the crate's integer hasher.
4021    ///
4022    /// Swapping a hasher moves no metric value, so every other test in
4023    /// this file passes just as well with #1108 reverted. Both halves
4024    /// here are needed: the typed binding stops compiling if the field
4025    /// goes back to a default-hasher `HashMap`, and the `type_name`
4026    /// comparison still fails at runtime if `IntKeyHashMap` itself is
4027    /// ever redefined to wrap `RandomState`.
4028    ///
4029    /// The two text-keyed maps are pinned to SipHash in the same test,
4030    /// because moving *them* would be a regression rather than an
4031    /// optimisation. `crate::int_hash`'s module doc is the single place
4032    /// that argues why analysed source text does not qualify.
4033    #[test]
4034    fn halstead_operator_map_uses_the_int_key_hasher() {
4035        use std::any::{type_name, type_name_of_val};
4036        use std::hash::BuildHasherDefault;
4037
4038        use crate::int_hash::IntKeyHasher;
4039
4040        let maps = HalsteadMaps::new();
4041
4042        let operators: &IntKeyHashMap<u16, u64> = &maps.operators;
4043        assert_eq!(
4044            type_name_of_val(operators.hasher()),
4045            type_name::<BuildHasherDefault<IntKeyHasher>>(),
4046            "the kind_id-keyed operator map must use the int_hash hasher"
4047        );
4048
4049        let siphash = type_name::<std::collections::hash_map::RandomState>();
4050        assert_eq!(
4051            type_name_of_val(maps.operands.hasher()),
4052            siphash,
4053            "operand keys come from the analysed source, so the keyed hash \
4054             is what stops a crafted file from flooding this map"
4055        );
4056        assert_eq!(
4057            type_name_of_val(maps.primitive_operators.hasher()),
4058            siphash,
4059            "primitive-operator keys come from the analysed source, so the \
4060             keyed hash is what stops a crafted file from flooding this map"
4061        );
4062    }
4063
4064    /// `merge` sums overlapping keys and adopts disjoint ones, in all
4065    /// three maps, and `finalize` reads the union back as n1/N1/n2/N2.
4066    ///
4067    /// Every count differs from every other and none is zero, so a
4068    /// dropped key, an overwrite where an addition belongs, or a map
4069    /// crossed with its neighbour all change the totals.
4070    #[test]
4071    fn halstead_maps_merge_sums_overlaps_and_adopts_disjoint_keys() {
4072        let mut parent = halstead_maps_of(
4073            &[(1, 2), (2, 3)],
4074            &[(b"int", 1)],
4075            &[(b"alpha", 4), (b"beta", 7)],
4076        );
4077        let child = halstead_maps_of(
4078            &[(2, 5), (7, 11)],
4079            &[(b"double", 13)],
4080            &[(b"alpha", 17), (b"gamma", 19)],
4081        );
4082
4083        parent.merge(&child);
4084
4085        // expected: operators {1: 2, 2: 3+5, 7: 11}; primitives
4086        // {int: 1, double: 13}; operands {alpha: 4+17, beta: 7,
4087        // gamma: 19}.
4088        assert_eq!(
4089            parent,
4090            halstead_maps_of(
4091                &[(1, 2), (2, 8), (7, 11)],
4092                &[(b"int", 1), (b"double", 13)],
4093                &[(b"alpha", 21), (b"beta", 7), (b"gamma", 19)],
4094            )
4095        );
4096
4097        let mut stats = Stats::default();
4098        parent.finalize(&mut stats);
4099        // expected: n1 = 3 kind ids + 2 primitives; N1 = (2+8+11) +
4100        // (1+13); n2 = 3 texts; N2 = 21+7+19.
4101        assert_eq!(stats.unique_operators(), 5);
4102        assert_eq!(stats.total_operators(), 35);
4103        assert_eq!(stats.unique_operands(), 3);
4104        assert_eq!(stats.total_operands(), 47);
4105    }
4106
4107    /// Merging an empty child leaves the parent untouched.
4108    ///
4109    /// A space with no operators or operands is the common case for a
4110    /// leaf getter or an empty function body, and `finalize` runs on
4111    /// the parent afterwards either way.
4112    #[test]
4113    fn halstead_maps_merge_of_empty_child_is_a_no_op() {
4114        let mut parent = halstead_maps_of(&[(3, 5)], &[(b"char", 2)], &[(b"delta", 9)]);
4115        let before = parent.clone();
4116
4117        parent.merge(&HalsteadMaps::new());
4118
4119        assert_eq!(parent, before);
4120
4121        let mut stats = Stats::default();
4122        parent.finalize(&mut stats);
4123        // expected: n1 = 1 kind id + 1 primitive; N1 = 5 + 2; n2 = 1;
4124        // N2 = 9.
4125        assert_eq!(stats.unique_operators(), 2);
4126        assert_eq!(stats.total_operators(), 7);
4127        assert_eq!(stats.unique_operands(), 1);
4128        assert_eq!(stats.total_operands(), 9);
4129    }
4130
4131    /// Folding a chain of nested spaces bottom-up must reach the union
4132    /// of every level, re-merging already-merged maps on the way up.
4133    ///
4134    /// This is what `spaces.rs` and `ops.rs` actually do: each space is
4135    /// merged into its parent as the walk pops it, so by the time the
4136    /// root sees a grandchild's counts they have already passed through
4137    /// one `merge`. The literal expectation below is what discriminates
4138    /// — the `nested == flat` cross-check on its own does not, because
4139    /// any entry-wise fold over the same levels agrees with itself
4140    /// however it is associated, including a broken one.
4141    #[test]
4142    fn halstead_maps_merge_folds_a_nested_chain() {
4143        let levels = [
4144            halstead_maps_of(&[(1, 1)], &[(b"int", 1)], &[(b"a", 1)]),
4145            halstead_maps_of(&[(1, 2), (2, 3)], &[], &[(b"a", 2), (b"b", 4)]),
4146            halstead_maps_of(&[(2, 5)], &[(b"long", 6)], &[(b"b", 7)]),
4147            halstead_maps_of(&[(3, 8)], &[(b"int", 9)], &[(b"c", 10)]),
4148        ];
4149
4150        // Bottom-up: the deepest level folds into its parent, that
4151        // result into *its* parent, and so on up to the root.
4152        let mut nested = levels[levels.len() - 1].clone();
4153        for level in levels.iter().rev().skip(1) {
4154            let mut outer = level.clone();
4155            outer.merge(&nested);
4156            nested = outer;
4157        }
4158
4159        // Flat: every level merged directly into the root.
4160        let mut flat = levels[0].clone();
4161        for level in &levels[1..] {
4162            flat.merge(level);
4163        }
4164
4165        // expected: every key summed across the four levels — operators
4166        // {1: 1+2, 2: 3+5, 3: 8}, primitives {int: 1+9, long: 6},
4167        // operands {a: 1+2, b: 4+7, c: 10}.
4168        assert_eq!(
4169            nested,
4170            halstead_maps_of(
4171                &[(1, 3), (2, 8), (3, 8)],
4172                &[(b"int", 10), (b"long", 6)],
4173                &[(b"a", 3), (b"b", 11), (b"c", 10)],
4174            )
4175        );
4176        assert_eq!(nested, flat);
4177
4178        let mut stats = Stats::default();
4179        nested.finalize(&mut stats);
4180        // expected: n1 = 3 kind ids + 2 primitives; N1 = (3+8+8) +
4181        // (10+6); n2 = 3 texts; N2 = 3+11+10.
4182        assert_eq!(stats.unique_operators(), 5);
4183        assert_eq!(stats.total_operators(), 35);
4184        assert_eq!(stats.unique_operands(), 3);
4185        assert_eq!(stats.total_operands(), 24);
4186    }
4187
4188    /// A `kind_id` at the top of the `u16` range must behave like any
4189    /// other key.
4190    ///
4191    /// The largest grammar in the workspace (`mozcpp`) tops out around
4192    /// 640 symbols, so nothing near `u16::MAX` occurs today — but the
4193    /// map is keyed by the raw id, and a dense-array representation
4194    /// (the shape #1108 considered and rejected) is exactly what such a
4195    /// key would break. Pinning it keeps that trade-off honest if the
4196    /// representation is ever revisited.
4197    #[test]
4198    fn halstead_maps_handle_the_full_kind_id_range() {
4199        let mut parent = halstead_maps_of(&[(0, 3), (u16::MAX, 5)], &[], &[]);
4200        parent.merge(&halstead_maps_of(&[(u16::MAX, 7)], &[], &[]));
4201
4202        let mut stats = Stats::default();
4203        parent.finalize(&mut stats);
4204        // expected: two distinct kind ids, occurrences 3 and 5+7.
4205        assert_eq!(stats.unique_operators(), 2);
4206        assert_eq!(stats.total_operators(), 15);
4207    }
4208}