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