Skip to main content

big_code_analysis/metrics/
halstead.rs

1// Per-language metric and AST modules deliberately consume the macro-
2// generated tree-sitter token enums via `use crate::*` and `use Foo::*`
3// inside match expressions — explicit imports would list dozens of
4// variants per arm and obscure the per-language token sets that are the
5// point of these files. Allowed at the module level rather than per
6// function so the per-language impl blocks stay readable.
7#![allow(
8    clippy::doc_markdown,
9    clippy::enum_glob_use,
10    clippy::match_wildcard_for_single_variants,
11    clippy::similar_names,
12    clippy::unused_self,
13    clippy::wildcard_imports
14)]
15// Metric counts (token, function, branch, argument, etc.) are stored as
16// `usize` and crossed with `f64` averages, ratios, and Halstead scores
17// across the cyclomatic / MI / Halstead computations. The `usize as f64`
18// and `f64 as usize` casts are intentional and snapshot-anchored — every
19// site is bounded by the count it came from. Allowing the lints at the
20// module level keeps the metric arithmetic legible.
21#![allow(
22    clippy::cast_precision_loss,
23    clippy::cast_possible_truncation,
24    clippy::cast_sign_loss
25)]
26
27use std::collections::HashMap;
28
29use std::fmt;
30
31use crate::checker::Checker;
32use crate::getter::Getter;
33use crate::int_hash::IntKeyHashMap;
34use crate::macros::implement_metric_trait;
35
36use crate::*;
37
38/// The `Halstead` metric suite.
39#[derive(Default, Clone, Debug, PartialEq)]
40#[non_exhaustive]
41pub struct Stats {
42    u_operators: u64,
43    operators: u64,
44    u_operands: u64,
45    operands: u64,
46}
47
48/// Specifies the type of nodes accepted by the `Halstead` metric.
49pub enum HalsteadType {
50    /// The node is an `Halstead` operator
51    Operator,
52    /// The node is an `Halstead` operand
53    Operand,
54    /// The node is unknown to the `Halstead` metric
55    Unknown,
56}
57
58/// Per-space operator / operand occurrence maps used to compute the
59/// Halstead `Stats` struct. One map per distinct operator (`kind_id`)
60/// and one per distinct operand (`text`); merged across nested spaces.
61#[derive(Debug, Default, Clone, PartialEq)]
62pub struct HalsteadMaps<'a> {
63    /// Keyed by `kind_id`, so it is hashed with [`crate::int_hash`]'s
64    /// integer hasher rather than SipHash: the key is a grammar symbol
65    /// this crate generated, drawn from an alphabet of at most a few
66    /// hundred values, so there is nothing for a keyed hash to defend.
67    pub(crate) operators: IntKeyHashMap<u16, u64>,
68    /// Primitive-type operators stored by text so each distinct primitive
69    /// (e.g. `int` vs `double`) counts as a separate distinct operator,
70    /// even when the grammar maps them all to a single kind_id.
71    ///
72    /// Text-keyed, so it keeps SipHash — see the module doc on
73    /// [`crate::int_hash`] for why analysed source text does not qualify
74    /// for the fast hasher.
75    pub(crate) primitive_operators: HashMap<&'a [u8], u64>,
76    /// Text-keyed, and on SipHash for the same reason as
77    /// `primitive_operators`.
78    pub(crate) operands: HashMap<&'a [u8], u64>,
79}
80
81impl<'a> HalsteadMaps<'a> {
82    pub(crate) fn new() -> Self {
83        Self::default()
84    }
85
86    pub(crate) fn merge(&mut self, other: &HalsteadMaps<'a>) {
87        for (k, v) in &other.operators {
88            *self.operators.entry(*k).or_insert(0) += v;
89        }
90        for (k, v) in &other.primitive_operators {
91            *self.primitive_operators.entry(*k).or_insert(0) += v;
92        }
93        for (k, v) in &other.operands {
94            *self.operands.entry(*k).or_insert(0) += v;
95        }
96    }
97
98    pub(crate) fn finalize(&self, stats: &mut Stats) {
99        stats.u_operators = (self.operators.len() + self.primitive_operators.len()) as u64;
100        stats.operators =
101            self.operators.values().sum::<u64>() + self.primitive_operators.values().sum::<u64>();
102        stats.u_operands = self.operands.len() as u64;
103        stats.operands = self.operands.values().sum::<u64>();
104    }
105}
106
107impl fmt::Display for Stats {
108    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
109        write!(
110            f,
111            "unique_operators: {}, \
112             total_operators: {}, \
113             unique_operands: {}, \
114             total_operands: {}, \
115             length: {}, \
116             estimated_program_length: {}, \
117             purity_ratio: {}, \
118             size: {}, \
119             volume: {}, \
120             difficulty: {}, \
121             level: {}, \
122             effort: {}, \
123             time: {}, \
124             bugs: {}",
125            self.unique_operators(),
126            self.total_operators(),
127            self.unique_operands(),
128            self.total_operands(),
129            self.length(),
130            self.estimated_program_length(),
131            self.purity_ratio(),
132            self.vocabulary(),
133            self.volume(),
134            self.difficulty(),
135            self.level(),
136            self.effort(),
137            self.time(),
138            self.bugs(),
139        )
140    }
141}
142
143impl Stats {
144    // Intentionally a no-op. Halstead distinct-counts (`u_operators` /
145    // `u_operands`) cannot be summed across sibling spaces without
146    // double-counting operators/operands they share. Cross-space
147    // aggregation is instead done by unioning the occurrence maps
148    // (`HalsteadMaps::merge`) and re-running `finalize` on the parent
149    // (see `spaces/compute.rs`). Summing the finalized fields here —
150    // mirroring the sibling metrics' `merge` — would silently inflate
151    // every parent space's n1/n2/N1/N2.
152    pub(crate) fn merge(&mut self, _other: &Stats) {}
153
154    /// Returns `η1`, the number of distinct operators
155    #[inline]
156    #[must_use]
157    pub fn unique_operators(&self) -> u64 {
158        self.u_operators
159    }
160
161    /// Returns `N1`, the number of total operators
162    #[inline]
163    #[must_use]
164    pub fn total_operators(&self) -> u64 {
165        self.operators
166    }
167
168    /// Returns `η2`, the number of distinct operands
169    #[inline]
170    #[must_use]
171    pub fn unique_operands(&self) -> u64 {
172        self.u_operands
173    }
174
175    /// Returns `N2`, the number of total operands
176    #[inline]
177    #[must_use]
178    pub fn total_operands(&self) -> u64 {
179        self.operands
180    }
181
182    /// Returns the program length
183    ///
184    /// Computed as `N = N1 + N2`, the sum of [`Self::total_operators`] and
185    /// [`Self::total_operands`].
186    #[inline]
187    #[must_use]
188    pub fn length(&self) -> u64 {
189        self.total_operands() + self.total_operators()
190    }
191
192    /// Returns the calculated estimated program length
193    ///
194    /// Computed as `N^ = n1 * log2(n1) + n2 * log2(n2)`, where `n1` is
195    /// [`Self::unique_operators`] and `n2` is [`Self::unique_operands`]. Each term is
196    /// treated as `0` when its unique count is `0`.
197    #[inline]
198    #[must_use]
199    pub fn estimated_program_length(&self) -> f64 {
200        let uo = self.unique_operators() as f64;
201        let ud = self.unique_operands() as f64;
202        let uo_term = if uo == 0.0 { 0.0 } else { uo * uo.log2() };
203        let ud_term = if ud == 0.0 { 0.0 } else { ud * ud.log2() };
204        uo_term + ud_term
205    }
206
207    /// Returns the purity ratio
208    ///
209    /// Computed as `PR = N^ / N`, the ratio of
210    /// [`Self::estimated_program_length`] to [`Self::length`].
211    #[inline]
212    #[must_use]
213    pub fn purity_ratio(&self) -> f64 {
214        let len = self.length() as f64;
215        if len == 0.0 {
216            0.0
217        } else {
218            self.estimated_program_length() / len
219        }
220    }
221
222    /// Returns the program vocabulary
223    ///
224    /// Computed as `n = n1 + n2`, the sum of [`Self::unique_operators`] and
225    /// [`Self::unique_operands`].
226    #[inline]
227    #[must_use]
228    pub fn vocabulary(&self) -> u64 {
229        self.unique_operands() + self.unique_operators()
230    }
231
232    /// Returns the program volume.
233    ///
234    /// Computed as `V = N * log2(n)`, where `N` is [`Self::length`] and `n`
235    /// is [`Self::vocabulary`]. Returns `0` when the vocabulary is `<= 1`,
236    /// since `log2` would be non-positive.
237    ///
238    /// Unit of measurement: bits
239    #[inline]
240    #[must_use]
241    pub fn volume(&self) -> f64 {
242        // Assumes a uniform binary encoding for the vocabulary is used.
243        let vocab = self.vocabulary() as f64;
244        if vocab <= 1.0 {
245            0.0
246        } else {
247            self.length() as f64 * vocab.log2()
248        }
249    }
250
251    /// Returns the estimated difficulty required to program
252    ///
253    /// Computed as `D = (n1 / 2) * (N2 / n2)`, where `n1` is
254    /// [`Self::unique_operators`], `N2` is [`Self::total_operands`], and `n2` is
255    /// [`Self::unique_operands`].
256    #[inline]
257    #[must_use]
258    pub fn difficulty(&self) -> f64 {
259        let ud = self.unique_operands() as f64;
260        if ud == 0.0 {
261            0.0
262        } else {
263            self.unique_operators() as f64 / 2. * self.total_operands() as f64 / ud
264        }
265    }
266
267    /// Returns the estimated level of difficulty required to program
268    ///
269    /// Computed as `L = 1 / D`, the reciprocal of [`Self::difficulty`].
270    #[inline]
271    #[must_use]
272    pub fn level(&self) -> f64 {
273        let d = self.difficulty();
274        if d == 0.0 { 0.0 } else { 1. / d }
275    }
276
277    /// Returns the estimated effort required to program
278    ///
279    /// Computed as `E = D * V`, the product of [`Self::difficulty`] and
280    /// [`Self::volume`].
281    #[inline]
282    #[must_use]
283    pub fn effort(&self) -> f64 {
284        self.difficulty() * self.volume()
285    }
286
287    /// Returns the estimated time required to program.
288    ///
289    /// Computed as `T = E / 18`, where `E` is [`Self::effort`] and `18` is
290    /// the Stroud number (see the divisor rationale below).
291    ///
292    /// Unit of measurement: seconds
293    #[inline]
294    #[must_use]
295    pub fn time(&self) -> f64 {
296        // The floating point `18.` aims to describe the processing rate of the
297        // human brain. It is called Stoud number, S, and its
298        // unit of measurement is moments/seconds.
299        // A moment is the time required by the human brain to carry out the
300        // most elementary decision.
301        // 5 <= S <= 20. Halstead uses 18.
302        // The value of S has been empirically developed from psychological
303        // reasoning, and its recommended value for
304        // programming applications is 18.
305        //
306        // Source: https://www.geeksforgeeks.org/software-engineering-halsteads-software-metrics/
307        self.effort() / 18.
308    }
309
310    /// Returns the estimated number of delivered bugs.
311    ///
312    /// This metric represents the average amount of work a programmer can do
313    /// without introducing an error.
314    ///
315    /// Computed as `B = E^(2/3) / 3000`, where `E` is [`Self::effort`]. This
316    /// is the effort-based variant of Halstead's delivered-bugs estimate
317    /// rather than the more commonly cited volume-based form `B = V / 3000`;
318    /// it matches the formula used by upstream `rust-code-analysis`.
319    #[inline]
320    #[must_use]
321    pub fn bugs(&self) -> f64 {
322        // The floating point `3000.` represents the number of elementary
323        // mental discriminations.
324        // A mental discrimination, in psychology, is the ability to perceive
325        // and respond to differences among stimuli.
326        //
327        // The value above is obtained starting from a constant that
328        // is different for every language and assumes that natural language is
329        // the language of the brain.
330        // For programming languages, the English language constant
331        // has been considered.
332        //
333        // After every 3000 mental discriminations a result is produced.
334        // This result, whether correct or incorrect, is more than likely
335        // either used as an input for the next operation or is output to the
336        // environment.
337        // If incorrect the error should become apparent.
338        // Thus, an opportunity for error occurs every 3000
339        // mental discriminations.
340        //
341        // Source: https://docs.lib.purdue.edu/cgi/viewcontent.cgi?article=1145&context=cstech
342        self.effort().powf(2. / 3.) / 3000.
343    }
344}
345
346#[doc(hidden)]
347/// Per-language extraction of Halstead operator/operand maps.
348pub(crate) trait Halstead
349where
350    Self: Checker + Getter,
351{
352    /// Walk `node` and update `stats` with this metric for the language
353    /// implementing the trait.
354    ///
355    /// `ancestors` is the chain the walker descended through; it is
356    /// handed to [`Getter::get_op_type`], six of whose impls classify a
357    /// token by what encloses it (#1096).
358    fn compute<'a>(
359        node: &Node<'a>,
360        code: &'a [u8],
361        ancestors: Ancestors<'a, '_>,
362        halstead_maps: &mut HalsteadMaps<'a>,
363    );
364}
365
366#[inline]
367fn get_id<'a>(node: &Node<'a>, code: &'a [u8]) -> &'a [u8] {
368    &code[node.start_byte()..node.end_byte()]
369}
370
371#[inline]
372fn compute_halstead<'a, T: Getter + Checker>(
373    node: &Node<'a>,
374    code: &'a [u8],
375    ancestors: Ancestors<'a, '_>,
376    halstead_maps: &mut HalsteadMaps<'a>,
377) {
378    match T::get_op_type_with_code(node, code, ancestors) {
379        HalsteadType::Operator => {
380            if T::is_primitive(node) {
381                // Store primitive-type operators by text so distinct
382                // primitives (e.g. `int` vs `double`) that share a
383                // single kind_id are counted separately in n1/N1.
384                *halstead_maps
385                    .primitive_operators
386                    .entry(get_id(node, code))
387                    .or_insert(0) += 1;
388            } else {
389                *halstead_maps.operators.entry(node.kind_id()).or_insert(0) += 1;
390            }
391        }
392        HalsteadType::Operand => {
393            *halstead_maps
394                .operands
395                .entry(T::get_operand_id(node, code, ancestors))
396                .or_insert(0) += 1;
397        }
398        _ => {}
399    }
400}
401
402// Every language's `Halstead::compute` is the same forward to
403// `compute_halstead`, which classifies each node through the language's
404// own `Getter` / `Checker`. Nothing per-language lives here — it lives
405// in `src/getter/<lang>.rs` — so writing the impls out was 23 copies of
406// one signature. (This is the only metric whose per-language impls are
407// all identical; every other trait has real per-language bodies.)
408macro_rules! impl_halstead_forwarding {
409    ($($code:ty),+ $(,)?) => {
410        $(
411            impl Halstead for $code {
412                fn compute<'a>(
413                    node: &Node<'a>,
414                    code: &'a [u8],
415                    ancestors: Ancestors<'a, '_>,
416                    halstead_maps: &mut HalsteadMaps<'a>,
417                ) {
418                    compute_halstead::<Self>(node, code, ancestors, halstead_maps);
419                }
420            }
421        )+
422    };
423}
424
425impl_halstead_forwarding!(
426    PythonCode,
427    MozjsCode,
428    JavascriptCode,
429    TypescriptCode,
430    TsxCode,
431    RustCode,
432    CppCode,
433    CCode,
434    ObjcCode,
435    MozcppCode,
436    JavaCode,
437    GroovyCode,
438    CsharpCode,
439    GoCode,
440    PerlCode,
441    KotlinCode,
442    LuaCode,
443    PhpCode,
444    RubyCode,
445    ElixirCode,
446    BashCode,
447    TclCode,
448    IrulesCode,
449);
450
451// Real defaults — no operators / operands to count. Audited in #188.
452implement_metric_trait!(Halstead, PreprocCode, CcommentCode);
453
454#[cfg(test)]
455#[allow(
456    clippy::float_cmp,
457    clippy::cast_precision_loss,
458    clippy::cast_possible_truncation,
459    clippy::cast_sign_loss,
460    clippy::similar_names,
461    clippy::doc_markdown,
462    clippy::needless_raw_string_hashes,
463    clippy::too_many_lines
464)]
465mod tests {
466    use std::collections::HashSet;
467    use std::path::PathBuf;
468
469    use crate::test_support::{ast_has_kind_id, check_metrics_only_shim, for_each_node_with_chain};
470
471    use super::*;
472
473    check_metrics_only_shim!(check_metrics, Halstead);
474
475    // Pins the lesson-4 invariant `n2 == len(dedupe(ops.operands))` by
476    // running `operands_and_operators` (the text-keyed `--ops` store)
477    // on the same source and comparing its deduplicated operand count
478    // to the expected `n2`. The metrics store and the ops store are
479    // independent (lesson 4); this catches a classification change that
480    // moves one without the other.
481    // `#[track_caller]` so a failure reports the *caller's* line rather
482    // than this helper's. Callers that wrap it in a per-language helper
483    // (`assert_char_literal_operands`, #1316) are tracked too, so the
484    // reported location names the language row instead of a shared line
485    // no assertion message distinguishes.
486    #[track_caller]
487    fn assert_ops_operands<T: crate::ParserTrait>(
488        source: &str,
489        file: &str,
490        expected_n2: usize,
491        mut expected_operands: Vec<&str>,
492    ) {
493        let path = PathBuf::from(file);
494        let parser = T::new(source.as_bytes().to_vec(), &path, None);
495        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
496
497        let unique: HashSet<&str> = ops.operands.iter().map(String::as_str).collect();
498        assert_eq!(
499            unique.len(),
500            expected_n2,
501            "dedupe(ops.operands) must equal n2; operands were {:?}",
502            ops.operands
503        );
504
505        let mut got: Vec<&str> = unique.into_iter().collect();
506        got.sort_unstable();
507        expected_operands.sort_unstable();
508        assert_eq!(got, expected_operands, "operand vocabulary for {file}");
509    }
510
511    /// Asserts the root space's `[n1, N1, n2, N2]`, naming `label` when
512    /// it does not hold.
513    ///
514    /// The delimiter-invariance tests (#1256 Elixir, #1312 Ruby and
515    /// Perl) each loop over spellings of one literal and need the
516    /// spelling in the failure message; `check_metrics` expands to a
517    /// plain `fn` that cannot capture a loop variable, so they reach
518    /// for the closure-taking helper it wraps. Three copies of that
519    /// dance is two too many.
520    fn assert_halstead_counts<T: crate::ParserTrait>(
521        source: &str,
522        file: &str,
523        expected: [u64; 4],
524        label: &str,
525    ) {
526        crate::test_support::check_func_space_only::<T, _>(
527            source,
528            file,
529            &[crate::Metric::Halstead],
530            |space| {
531                let halstead = &space.metrics.halstead;
532                assert_eq!(
533                    [
534                        halstead.unique_operators(),
535                        halstead.total_operators(),
536                        halstead.unique_operands(),
537                        halstead.total_operands(),
538                    ],
539                    expected,
540                    "{label}"
541                );
542            },
543        );
544    }
545
546    #[test]
547    fn python_operators_and_operands() {
548        check_metrics::<PythonParser>(
549            "def foo():
550                 def bar():
551                     def toto():
552                        a = 1 + 1
553                     b = 2 + a
554                 c = 3 + 3",
555            "foo.py",
556            |metric| {
557                // unique operators: def, =, +
558                // operators: def, def, def, =, =, =, +, +, +
559                // unique operands: foo, bar, toto, a, b, c, 1, 2, 3
560                // operands: foo, bar, toto, a, b, c, 1, 1, 2, a, 3, 3
561                insta::assert_json_snapshot!(
562                    metric.halstead,
563                    @r#"
564                {
565                  "unique_operators": 3,
566                  "total_operators": 9,
567                  "unique_operands": 9,
568                  "total_operands": 12,
569                  "length": 21,
570                  "estimated_program_length": 33.284212515144276,
571                  "purity_ratio": 1.584962500721156,
572                  "vocabulary": 12,
573                  "volume": 75.28421251514428,
574                  "difficulty": 2.0,
575                  "level": 0.5,
576                  "effort": 150.56842503028855,
577                  "time": 8.364912501682698,
578                  "bugs": 0.0094341190071077
579                }
580                "#
581                );
582            },
583        );
584    }
585
586    /// Pointer-arithmetic operators: `*` (dereference), `&` (address-of),
587    /// `->` (member-of-pointer), `+` (pointer + offset). Each is counted
588    /// once in `n1`; multiple uses bump `N1`. The headline integer values
589    /// (`u_operators`, `u_operands`) anchor the snapshot per the
590    /// snapshot-anchor policy.
591    #[test]
592    fn c_pointer_arithmetic_operators() {
593        check_metrics::<CParser>(
594            "int g(int* p, int* q) {
595                 return *(p + 1) + *q;
596             }",
597            "foo.c",
598            |metric| {
599                // Unique operators: int, *, (), {, }, +, ;, return  (= 8)
600                //   `*` covers both pointer-type and dereference; the grammar
601                //   does NOT split them.  `,` does not appear (only one
602                //   parameter on each side of the body).
603                // Unique operands: g, p, q, 1                       (= 4)
604                assert_eq!(metric.halstead.unique_operators(), 8);
605                assert_eq!(metric.halstead.unique_operands(), 4);
606                insta::assert_json_snapshot!(metric.halstead);
607            },
608        );
609    }
610
611    /// Bitwise (`&`, `|`, `^`, `~`, `<<`, `>>`) and logical (`&&`, `||`,
612    /// `!`) operators are distinct kind_ids and count as separate unique
613    /// operators in Halstead.  `&` (bitwise-and) and `&&` (logical-and)
614    /// must NOT collapse, even though both render as ampersands.
615    #[test]
616    fn c_bitwise_and_logical_operators() {
617        check_metrics::<CParser>(
618            "int f(int a, int b) {
619                 int x = (a & b) | (a ^ b);
620                 int y = ~a;
621                 int z = (a << 1) >> 2;
622                 return (a && b) || !x;
623             }",
624            "foo.c",
625            |metric| {
626                // Expect: 6 bitwise op kinds (& | ^ ~ << >>), 3 logical (&& || !).
627                // Plus int, (), {, }, =, ;, return, , — 8 syntactic / arithmetic
628                // operator kinds.  Six bitwise + three logical + eight = 17 unique
629                // operators is the upper bound; actuals depend on grammar collapse,
630                // so we assert a lower-bound and anchor via snapshot below.
631                let s = &metric.halstead;
632                assert!(
633                    s.unique_operators() >= 14,
634                    "expected >= 14 unique operators (bitwise + logical + syntax), got {}",
635                    s.unique_operators(),
636                );
637                assert_eq!(s.unique_operands(), 8); // f, a, b, x, y, z, 1, 2
638                insta::assert_json_snapshot!(metric.halstead);
639            },
640        );
641    }
642
643    /// Increment / decrement (`++`, `--`) and `sizeof` / cast operators
644    /// each contribute distinct unique operators.  C-style casts in the
645    /// tree-sitter grammar surface as `cast_expression` with the type
646    /// token classified as a primitive_type operator.
647    #[test]
648    fn c_increment_decrement_and_sizeof() {
649        check_metrics::<CParser>(
650            "void f(int* p) {
651                 int n = sizeof(int);
652                 ++p;
653                 --n;
654                 long w = (long) n;
655             }",
656            "foo.c",
657            |metric| {
658                // Unique operators include: void, int, long, *, =, sizeof, ++, --, (), {, }, ;
659                // Unique operands: f, p, n, w
660                let s = &metric.halstead;
661                assert!(
662                    s.unique_operators() >= 10,
663                    "expected >= 10 unique operators including ++ / -- / sizeof / cast, got {}",
664                    s.unique_operators(),
665                );
666                assert_eq!(s.unique_operands(), 4);
667                insta::assert_json_snapshot!(metric.halstead);
668            },
669        );
670    }
671
672    #[test]
673    fn cpp_operators_and_operands() {
674        // Define operators and operands for C/C++ grammar according to this specification:
675        // https://www.verifysoft.com/en_halstead_metrics.html
676        // The only difference with the specification above is that
677        // primitive types are treated as operators, since the definition of a
678        // primitive type can be seen as the creation of a slot of a certain size.
679        // i.e. The `int a;` definition creates a n-bytes slot.
680        check_metrics::<CppParser>(
681            "main()
682            {
683              int a, b, c, avg;
684              scanf(\"%d %d %d\", &a, &b, &c);
685              avg = (a + b + c) / 3;
686              printf(\"avg = %d\", avg);
687            }",
688            "foo.c",
689            |metric| {
690                // unique operators: (), {}, int, &, =, +, /, ,, ;
691                // unique operands: main, a, b, c, avg, scanf, "%d %d %d", 3, printf, "avg = %d"
692                insta::assert_json_snapshot!(
693                    metric.halstead,
694                    @r#"
695                {
696                  "unique_operators": 9,
697                  "total_operators": 24,
698                  "unique_operands": 10,
699                  "total_operands": 18,
700                  "length": 42,
701                  "estimated_program_length": 61.74860596185444,
702                  "purity_ratio": 1.470204903853677,
703                  "vocabulary": 19,
704                  "volume": 178.41295556463058,
705                  "difficulty": 8.1,
706                  "level": 0.1234567901234568,
707                  "effort": 1445.1449400735075,
708                  "time": 80.28583000408375,
709                  "bugs": 0.04260752914034329
710                }
711                "#
712                );
713            },
714        );
715    }
716
717    /// A `sized_type_specifier` carries its `unsigned`/`signed`/`long`/
718    /// `short` modifiers as bare keyword tokens (distinct kind_ids), not
719    /// as `primitive_type` children. Prior to issue #466 those tokens
720    /// fell through to the `Unknown` arm and were dropped from `n1`/`N1`,
721    /// so `unsigned int` collapsed to just `int` and `signed long`
722    /// contributed nothing. They must each count as a distinct operator,
723    /// while `long long`'s two `long` tokens fold to one `n1` entry but
724    /// two `N1` hits. Regression test for issue #466.
725    #[test]
726    fn cpp_sized_type_specifier_operators() {
727        let source = "unsigned int u = 3; signed long b = 4; long long c = 5;";
728        check_metrics::<CppParser>(source, "foo.cpp", |metric| {
729            // Distinct operators (n1): unsigned, signed, long, int, =, ; = 6
730            // Total operators (N1):
731            //   unsigned(1) + int(1) + =(3) + ;(3) + signed(1) + long(3) = 12
732            //   (`long` appears once in `signed long` and twice in `long long`)
733            // Distinct/total operands: u, b, c, 3, 4, 5 = 6 / 6
734            assert_eq!(metric.halstead.unique_operators(), 6);
735            assert_eq!(metric.halstead.total_operators(), 12);
736            assert_eq!(metric.halstead.unique_operands(), 6);
737            assert_eq!(metric.halstead.total_operands(), 6);
738        });
739
740        // Pin the lesson-4 `n1 == dedupe(ops.operators)` invariant: the
741        // kind_id-keyed metrics store and the text-keyed `--ops` store are
742        // independent, so a modifier classified in one but not the other
743        // would diverge here.
744        let path = PathBuf::from("foo.cpp");
745        let parser = CppParser::new(source.as_bytes().to_vec(), &path, None);
746        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
747        let unique_operators: HashSet<&str> = ops.operators.iter().map(String::as_str).collect();
748        assert_eq!(
749            unique_operators.len(),
750            6,
751            "dedupe(ops.operators) must equal n1; operators were {:?}",
752            ops.operators
753        );
754        for modifier in ["unsigned", "signed", "long"] {
755            assert!(
756                unique_operators.contains(modifier),
757                "sized_type_specifier modifier {modifier:?} missing from ops.operators: {:?}",
758                ops.operators
759            );
760        }
761    }
762
763    /// C++20 spaceship operator `<=>` (`Cpp::LTEQGT`) is a comparison
764    /// operator and must be counted in Halstead, like its sibling
765    /// comparison operators `<`, `>`, `<=`, `>=`, `==`, `!=`. Prior to
766    /// this fix it fell through to the `Unknown` arm and was silently
767    /// dropped from `n1` / `N1`, under-reporting volume / effort on any
768    /// C++20+ codebase that defines `operator<=>`. Regression test for
769    /// issue #197.
770    #[test]
771    fn cpp_spaceship_operator_is_halstead_operator() {
772        check_metrics::<CppParser>(
773            "int f(int a, int b) {
774                 return (a <=> b) != 0;
775             }",
776            "foo.cpp",
777            |metric| {
778                // Unique operators (grammar collapses matched delimiters
779                // to a single kind_id): int, (), {}, <=>, !=, return, ;, ,
780                //   `<=>` is the regression target — without the fix it
781                //   would be Unknown and `u_operators` would be 7.
782                // Unique operands: f, a, b, 0
783                let s = &metric.halstead;
784                assert_eq!(s.unique_operators(), 8);
785                assert_eq!(s.unique_operands(), 4);
786                insta::assert_json_snapshot!(
787                    s,
788                    @r#"
789                {
790                  "unique_operators": 8,
791                  "total_operators": 11,
792                  "unique_operands": 4,
793                  "total_operands": 6,
794                  "length": 17,
795                  "estimated_program_length": 32.0,
796                  "purity_ratio": 1.8823529411764706,
797                  "vocabulary": 12,
798                  "volume": 60.94436251225965,
799                  "difficulty": 6.0,
800                  "level": 0.16666666666666666,
801                  "effort": 365.6661750735579,
802                  "time": 20.31478750408655,
803                  "bugs": 0.01704519358507665
804                }
805                "#
806                );
807            },
808        );
809    }
810
811    /// C++ compound subtract-assign `-=` (`Cpp::DASHEQ`) must be counted
812    /// in Halstead like every other compound assignment (`+=`, `*=`,
813    /// `/=`, etc.). Prior to the fix it fell through to the `Unknown`
814    /// arm and was silently dropped from `n1` / `N1` — under-reporting
815    /// volume / effort wherever C++ code subtracts in place. Regression
816    /// test for issue #198.
817    #[test]
818    fn cpp_dash_eq_is_halstead_operator() {
819        check_metrics::<CppParser>("void f(int a, int b) { a -= b; }", "foo.cpp", |metric| {
820            // Unique operators: void, (), {}, int, ,, -=, ;
821            //   `-=` is the regression target — without the fix it
822            //   would be Unknown and `u_operators` would be 6.
823            // Unique operands: f, a, b
824            let s = &metric.halstead;
825            assert_eq!(s.unique_operators(), 7);
826            assert_eq!(s.unique_operands(), 3);
827        });
828    }
829
830    /// C++ pointer-to-member access `.*` (`Cpp::DOTSTAR`) must be
831    /// counted in Halstead. Prior to the fix it fell through to the
832    /// `Unknown` arm and was silently dropped from `n1` / `N1`.
833    /// Regression test for issue #198.
834    ///
835    /// The snippet uses an `operator.*` declaration because that is
836    /// where the C++ tree-sitter grammar reliably emits a single
837    /// `DOTSTAR` leaf; in expression position (`a.*b`) some grammar
838    /// versions split the token into `DOT` + `STAR` and the regression
839    /// would be masked.
840    #[test]
841    fn cpp_dot_star_is_halstead_operator() {
842        check_metrics::<CppParser>("struct S { void operator.*(int); };", "foo.cpp", |metric| {
843            // Unique operators with fix: {}, ;, (), int, void, .*
844            //   `.*` is the regression target — without the fix it
845            //   falls through to `Unknown` and `u_operators` is 5.
846            // Unique operands: S
847            let s = &metric.halstead;
848            assert_eq!(s.unique_operators(), 6);
849            assert_eq!(s.unique_operands(), 1);
850        });
851    }
852
853    /// C++ pointer-to-member access through pointer `->*`
854    /// (`Cpp::DASHGTSTAR`) must be counted in Halstead. Prior to the
855    /// fix it fell through to the `Unknown` arm and was silently
856    /// dropped from `n1` / `N1`. Regression test for issue #198.
857    ///
858    /// The snippet uses an `operator->*` declaration because that is
859    /// where the C++ tree-sitter grammar reliably emits a single
860    /// `DASHGTSTAR` leaf; in expression position (`a->*b`) the grammar
861    /// splits the token into `DASHGT` + `STAR` and the regression would
862    /// be masked.
863    #[test]
864    fn cpp_dash_gt_star_is_halstead_operator() {
865        check_metrics::<CppParser>(
866            "struct S { void operator->*(int); };",
867            "foo.cpp",
868            |metric| {
869                // Unique operators with fix: {}, ;, (), int, void, ->*
870                //   `->*` is the regression target — without the fix it
871                //   falls through to `Unknown` and `u_operators` is 5.
872                // Unique operands: S
873                let s = &metric.halstead;
874                assert_eq!(s.unique_operators(), 6);
875                assert_eq!(s.unique_operands(), 1);
876            },
877        );
878    }
879
880    #[test]
881    fn cpp_raw_string_delimiter_is_not_an_operator() {
882        // Regression: issue #1314, the C++ sibling of Elixir #1256 and
883        // Ruby/Perl #1312. A `raw_string_literal` carries its `R"(`
884        // opener as a bare `LPAREN` child — the kind id a call uses —
885        // so `auto a = R"(raw)";` reported a `()` operator with no call
886        // in the source.
887        //
888        // The fixture holds both sides at once: two raw strings and one
889        // real call. A guard widened past the literal would drop
890        // `f(a)`'s parenthesis and fail here rather than silently
891        // passing.
892        //
893        // The second literal uses the custom-delimiter form to pin that
894        // shape too — it adds a `raw_string_delimiter` child but keeps
895        // the same `(` — and its distinct text makes n2 differ from N2.
896        //
897        // expected: operators `;` × 3, `=` × 3, `int`, `()` × 1 →
898        // n1 = 4, N1 = 8. Operands the two literals, `a` × 2, `b`, `c`,
899        // `f` → n2 = 6, N2 = 7. Before the guard the two openers added
900        // two more `()` → N1 = 10.
901        check_metrics::<CppParser>(
902            "auto a = R\"(raw)\";\nauto b = R\"tag(raw)tag\";\nint c = f(a);\n",
903            "foo.cpp",
904            |metric| {
905                assert_eq!(metric.halstead.unique_operators(), 4);
906                assert_eq!(metric.halstead.total_operators(), 8);
907                assert_eq!(metric.halstead.unique_operands(), 6);
908                assert_eq!(metric.halstead.total_operands(), 7);
909            },
910        );
911    }
912
913    #[test]
914    fn rust_operators_and_operands() {
915        check_metrics::<RustParser>(
916            "fn main() {
917              let a = 5; let b = 5; let c = 5;
918              let avg = (a + b + c) / 3;
919              println!(\"{}\", avg);
920            }",
921            "foo.rs",
922            |metric| {
923                // unique operators: fn, (), {}, let, =, +, /, ;, !, ,
924                // unique operands: main, a, b, c, avg, 5, 3, println, "{}"
925                insta::assert_json_snapshot!(
926                    metric.halstead,
927                    @r#"
928                {
929                  "unique_operators": 10,
930                  "total_operators": 23,
931                  "unique_operands": 9,
932                  "total_operands": 15,
933                  "length": 38,
934                  "estimated_program_length": 61.74860596185444,
935                  "purity_ratio": 1.624963314785643,
936                  "vocabulary": 19,
937                  "volume": 161.42124551085624,
938                  "difficulty": 8.333333333333334,
939                  "level": 0.12,
940                  "effort": 1345.177045923802,
941                  "time": 74.7320581068779,
942                  "bugs": 0.040619232256751396
943                }
944                "#
945                );
946            },
947        );
948    }
949
950    #[test]
951    fn rust_aliased_primitive_type_classification() {
952        // Regression for issue #95 (lesson #2): the Rust grammar emits 17
953        // distinct `kind_id`s for `primitive_type` (one base plus 16
954        // numeric-suffixed alias variants). `RustCode::is_primitive` in
955        // `src/checker.rs` must list every variant; if a future regression
956        // omits one, primitive type names emitted in that aliased position
957        // silently drop into the kind_id-keyed operators bucket instead of
958        // the text-keyed primitive_operators map, miscounting Halstead n1.
959        //
960        // The snippet exercises every primitive scalar type across many
961        // syntactic positions (function parameter types, return types,
962        // let-binding annotations, `as` casts, const items, type aliases,
963        // struct fields, function pointer types, tuple types, array types,
964        // reference types, generic type arguments). Empirically, ordinary
965        // Rust source emits the base `Rust::PrimitiveType` variant from
966        // all of these positions; the 16 suffixed alias variants are
967        // produced by specific grammar productions not reachable from
968        // user-written code. Mutation-verified: dropping
969        // `Rust::PrimitiveType` from `is_primitive` fails this test
970        // (u_operators 30→15). Dropping any single suffixed variant
971        // currently leaves the test passing; if a future grammar bump
972        // makes any suffixed variant reachable from idiomatic source,
973        // extend the snippet so the test fires for that variant too.
974        check_metrics::<RustParser>(
975            "const C: u8 = 0;
976            type T = i64;
977            struct S { x: u32, y: u64 }
978            fn g(p: fn(u8) -> u16) -> bool { let _ = p(0); true }
979            fn f(a: u8, b: u16, c: u32, d: u64) -> u128 {
980                let _x: i8 = 0;
981                let _y: i16 = 0;
982                let _z: i32 = 0;
983                let _w: i64 = 0;
984                let _v: i128 = 0;
985                let _p: f32 = 1.0;
986                let _q: f64 = 2.0;
987                let _r: bool = true;
988                let _s: char = 'x';
989                let _t: usize = 0;
990                let _u: isize = 0;
991                let _arr: [u32; 4] = [0; 4];
992                let _ref: &u8 = &0;
993                let _tup: (u32, u64) = (0, 0);
994                let _opt: Option<u32> = None;
995                a as u128 + b as u128 + c as u128 + d
996            }",
997            "foo.rs",
998            |metric| {
999                // Headline: u_operators is the load-bearing assertion —
1000                // the 16 distinct primitive type names dedupe by text in
1001                // the primitive_operators map. Total operators (N1) and
1002                // operand counts pin the rest of the Halstead state.
1003                // Grew from 30 → 33 with the issue #394 fix: `const`,
1004                // `type`, and `struct` keywords are now classified as
1005                // operators (one occurrence each).
1006                assert_eq!(metric.halstead.unique_operators(), 33);
1007                assert_eq!(metric.halstead.total_operators(), 121);
1008                // u_operands / operands grew (was 31/50 before #390): the
1009                // fix now classifies TypeIdentifier (`T`, `S`, `Option`)
1010                // and FieldIdentifier (struct fields `x`, `y`) as operands
1011                // alongside the existing primitive type names.
1012                assert_eq!(metric.halstead.unique_operands(), 36);
1013                assert_eq!(metric.halstead.total_operands(), 55);
1014            },
1015        );
1016    }
1017
1018    #[test]
1019    fn rust_field_identifier_is_operand() {
1020        // Regression for issue #390: prior to the fix, `FieldIdentifier`
1021        // (e.g. the `x` / `y` in `p.x`, `p.y`) fell through to
1022        // `HalsteadType::Unknown`, so the field names were not counted
1023        // as operands. Both C++ and Go already classify FieldIdentifier
1024        // as an operand. After the fix:
1025        //   unique operators: fn, (), {}, let, =, +, ;, .
1026        //   unique operands : main, p, Point, x, y, sum, 0, 1
1027        // Field names `x` and `y` each appear twice (`p.x + p.y` and
1028        // the struct literal `Point { x: 0, y: 1 }`).
1029        check_metrics::<RustParser>(
1030            "fn main() {
1031              let p = Point { x: 0, y: 1 };
1032              let sum = p.x + p.y;
1033            }",
1034            "foo.rs",
1035            |metric| {
1036                // Headline: pre-fix, FieldIdentifier (`x`, `y`) and
1037                // TypeIdentifier (`Point`) fell through to Unknown, so
1038                // u_operands was 5 (main, p, sum, 0, 1). After the
1039                // fix, +Point, +x, +y → 8 distinct names.
1040                assert_eq!(metric.halstead.unique_operands(), 8);
1041                assert_eq!(metric.halstead.total_operands(), 12);
1042                insta::assert_json_snapshot!(
1043                    metric.halstead,
1044                    @r#"
1045                {
1046                  "unique_operators": 9,
1047                  "total_operators": 14,
1048                  "unique_operands": 8,
1049                  "total_operands": 12,
1050                  "length": 26,
1051                  "estimated_program_length": 52.529325012980806,
1052                  "purity_ratio": 2.0203586543454155,
1053                  "vocabulary": 17,
1054                  "volume": 106.27403387250882,
1055                  "difficulty": 6.75,
1056                  "level": 0.14814814814814814,
1057                  "effort": 717.3497286394346,
1058                  "time": 39.85276270219081,
1059                  "bugs": 0.026711567292222575
1060                }
1061                "#
1062                );
1063            },
1064        );
1065    }
1066
1067    #[test]
1068    fn rust_type_identifier_is_operand() {
1069        // Regression for issue #390: `TypeIdentifier` (e.g. `Vec`,
1070        // `HashMap`, `String` when used as a path name) was dropped to
1071        // `HalsteadType::Unknown` for Rust. C++ and Go classify them as
1072        // operands. After the fix, u_operands = 8:
1073        //   main, v, m, Vec, HashMap, new, K, V
1074        // (`i32` is a primitive type, classified as an operator.)
1075        //
1076        // Also covers issue #394: `::` is now an operator. The snippet
1077        // has two `::` tokens (`Vec::new`, `HashMap::new`), so n1 grew
1078        // from 10 → 11 and N1 from 17 → 19.
1079        check_metrics::<RustParser>(
1080            "fn main() {
1081              let v: Vec<i32> = Vec::new();
1082              let m: HashMap<K, V> = HashMap::new();
1083            }",
1084            "foo.rs",
1085            |metric| {
1086                // Headline: u_operands includes `Vec`, `HashMap`, `K`,
1087                // `V` (and `i32` as a primitive operator). Without the
1088                // fix, Vec/HashMap/K/V silently dropped to Unknown.
1089                assert_eq!(metric.halstead.unique_operands(), 8);
1090                assert_eq!(metric.halstead.total_operands(), 11);
1091                // `::` appears twice (Vec::new, HashMap::new); without
1092                // the #394 fix u_operators was 10 and operators 17.
1093                assert_eq!(metric.halstead.unique_operators(), 11);
1094                assert_eq!(metric.halstead.total_operators(), 19);
1095                insta::assert_json_snapshot!(
1096                    metric.halstead,
1097                    @r#"
1098                {
1099                  "unique_operators": 11,
1100                  "total_operators": 19,
1101                  "unique_operands": 8,
1102                  "total_operands": 11,
1103                  "length": 30,
1104                  "estimated_program_length": 62.05374780501027,
1105                  "purity_ratio": 2.068458260167009,
1106                  "vocabulary": 19,
1107                  "volume": 127.43782540330756,
1108                  "difficulty": 7.5625,
1109                  "level": 0.1322314049586777,
1110                  "effort": 963.7485546125134,
1111                  "time": 53.54158636736186,
1112                  "bugs": 0.03252279825177962
1113                }
1114                "#
1115                );
1116            },
1117        );
1118    }
1119
1120    #[test]
1121    fn rust_path_separator_is_operator() {
1122        // Regression for issue #394: `::` (`COLONCOLON`) was missing
1123        // from the Rust `get_op_type` operator arm even though C++,
1124        // Java, C#, and Kotlin all classify it as an operator. Path-
1125        // heavy code (`std::collections::HashMap`, `Vec::new`,
1126        // `T::method`) had every `::` silently dropped into
1127        // HalsteadType::Unknown.
1128        //
1129        // Snippet has three `::` tokens (`std::collections::HashMap`,
1130        // counted as two `::` separators, plus `HashMap::new`).
1131        check_metrics::<RustParser>(
1132            "fn main() {
1133              let m = std::collections::HashMap::new();
1134            }",
1135            "foo.rs",
1136            |metric| {
1137                // `::` appears 3 times across the two path expressions
1138                // (`std::collections::HashMap` contributes two; the
1139                // `HashMap::new` contributes one). Pre-fix all three
1140                // dropped to Unknown: u_operators would be 6 (no `::`
1141                // distinct) and total_operators() would be 7 (minus 3 `::`
1142                // occurrences). With the fix u_operators=7 and
1143                // operators=10.
1144                //
1145                // unique operators (post-fix): fn, LPAREN, LBRACE,
1146                // let, =, ::, ;. unique operands: main, m, std,
1147                // collections, HashMap, new.
1148                assert_eq!(metric.halstead.unique_operators(), 7);
1149                assert_eq!(metric.halstead.total_operators(), 10);
1150                assert_eq!(metric.halstead.unique_operands(), 6);
1151                assert_eq!(metric.halstead.total_operands(), 6);
1152            },
1153        );
1154    }
1155
1156    #[test]
1157    fn rust_declaration_keywords_are_operators() {
1158        // Regression for issue #394: the Rust impl already accepted 17
1159        // keywords as operators (As, Async, Await, …, Fn) but omitted
1160        // 14 declaration / visibility keywords. The fix adds `Const`,
1161        // `Static`, `Enum`, `Struct`, `Trait`, `Impl`, `Use`, `Mod`,
1162        // `Pub`, `Type`, `Union`, `Where`, `Extern`, `Dyn`.
1163        //
1164        // Snippet exercises `use`, `pub`, `struct`, and `impl` (one of
1165        // each); together they account for 4 new operator occurrences
1166        // and 4 new unique operators.
1167        check_metrics::<RustParser>(
1168            "use std::fmt;
1169            pub struct S;
1170            impl S { fn n() -> u8 { 0 } }",
1171            "foo.rs",
1172            |metric| {
1173                // expected: unique operators (11) = use, ::, ;, pub,
1174                // struct, impl, LBRACE, fn, LPAREN, DASHGT, u8. Without
1175                // the #394 fix, `use`, `pub`, `struct`, and `impl`
1176                // would each drop to Unknown and u_operators would be
1177                // 7. unique operands (5): std, fmt, S, n, 0.
1178                assert_eq!(metric.halstead.unique_operators(), 11);
1179                assert_eq!(metric.halstead.total_operators(), 13);
1180                assert_eq!(metric.halstead.unique_operands(), 5);
1181                assert_eq!(metric.halstead.total_operands(), 6);
1182            },
1183        );
1184    }
1185
1186    #[test]
1187    fn javascript_operators_and_operands() {
1188        check_metrics::<JavascriptParser>(
1189            "function main() {
1190              var a, b, c, avg;
1191              a = 5; b = 5; c = 5;
1192              avg = (a + b + c) / 3;
1193              console.log(\"{}\", avg);
1194            }",
1195            "foo.js",
1196            |metric| {
1197                // unique operators: function, (), {}, var, =, +, /, ,, ., ;
1198                // unique operands: main, a, b, c, avg, 3, 5, console, log, "{}"
1199                // `console.log` is the `.` operator applied to the two
1200                // identifier leaves; the composite `member_expression`
1201                // text is deliberately not a third operand (#1263), so
1202                // n2/N2 are 10/20 rather than the pre-#1263 11/21.
1203                insta::assert_json_snapshot!(
1204                    metric.halstead,
1205                    @r#"
1206                {
1207                  "unique_operators": 10,
1208                  "total_operators": 24,
1209                  "unique_operands": 10,
1210                  "total_operands": 20,
1211                  "length": 44,
1212                  "estimated_program_length": 66.43856189774725,
1213                  "purity_ratio": 1.5099673158578921,
1214                  "vocabulary": 20,
1215                  "volume": 190.16483617504394,
1216                  "difficulty": 10.0,
1217                  "level": 0.1,
1218                  "effort": 1901.6483617504396,
1219                  "time": 105.64713120835775,
1220                  "bugs": 0.05116412536051621
1221                }
1222                "#
1223                );
1224            },
1225        );
1226    }
1227
1228    #[test]
1229    fn mozjs_operators_and_operands() {
1230        check_metrics::<MozjsParser>(
1231            "function main() {
1232              var a, b, c, avg;
1233              a = 5; b = 5; c = 5;
1234              avg = (a + b + c) / 3;
1235              console.log(\"{}\", avg);
1236            }",
1237            "foo.js",
1238            |metric| {
1239                // unique operators: function, (), {}, var, =, +, /, ,, ., ;
1240                // unique operands: main, a, b, c, avg, 3, 5, console, log, "{}"
1241                // `console.log` is the `.` operator applied to the two
1242                // identifier leaves; the composite `member_expression`
1243                // text is deliberately not a third operand (#1263), so
1244                // n2/N2 are 10/20 rather than the pre-#1263 11/21.
1245                insta::assert_json_snapshot!(
1246                    metric.halstead,
1247                    @r#"
1248                {
1249                  "unique_operators": 10,
1250                  "total_operators": 24,
1251                  "unique_operands": 10,
1252                  "total_operands": 20,
1253                  "length": 44,
1254                  "estimated_program_length": 66.43856189774725,
1255                  "purity_ratio": 1.5099673158578921,
1256                  "vocabulary": 20,
1257                  "volume": 190.16483617504394,
1258                  "difficulty": 10.0,
1259                  "level": 0.1,
1260                  "effort": 1901.6483617504396,
1261                  "time": 105.64713120835775,
1262                  "bugs": 0.05116412536051621
1263                }
1264                "#
1265                );
1266            },
1267        );
1268    }
1269
1270    #[test]
1271    fn typescript_operators_and_operands() {
1272        check_metrics::<TypescriptParser>(
1273            "function main() {
1274              var a, b, c, avg;
1275              a = 5; b = 5; c = 5;
1276              avg = (a + b + c) / 3;
1277              console.log(\"{}\", avg);
1278            }",
1279            "foo.ts",
1280            |metric| {
1281                // unique operators: function, (), {}, var, =, +, /, ,, ., ;
1282                // unique operands: main, a, b, c, avg, 3, 5, console, log, "{}"
1283                // `console.log` is the `.` operator applied to the two
1284                // identifier leaves; the composite `member_expression`
1285                // text is deliberately not a third operand (#1263), so
1286                // n2/N2 are 10/20 rather than the pre-#1263 11/21.
1287                insta::assert_json_snapshot!(
1288                    metric.halstead,
1289                    @r#"
1290                {
1291                  "unique_operators": 10,
1292                  "total_operators": 24,
1293                  "unique_operands": 10,
1294                  "total_operands": 20,
1295                  "length": 44,
1296                  "estimated_program_length": 66.43856189774725,
1297                  "purity_ratio": 1.5099673158578921,
1298                  "vocabulary": 20,
1299                  "volume": 190.16483617504394,
1300                  "difficulty": 10.0,
1301                  "level": 0.1,
1302                  "effort": 1901.6483617504396,
1303                  "time": 105.64713120835775,
1304                  "bugs": 0.05116412536051621
1305                }
1306                "#
1307                );
1308            },
1309        );
1310    }
1311
1312    #[test]
1313    fn tsx_operators_and_operands() {
1314        check_metrics::<TsxParser>(
1315            "function main() {
1316              var a, b, c, avg;
1317              a = 5; b = 5; c = 5;
1318              avg = (a + b + c) / 3;
1319              console.log(\"{}\", avg);
1320            }",
1321            "foo.ts",
1322            |metric| {
1323                // unique operators: function, (), {}, var, =, +, /, ,, ., ;
1324                // unique operands: main, a, b, c, avg, 3, 5, console, log, "{}"
1325                // `console.log` is the `.` operator applied to the two
1326                // identifier leaves; the composite `member_expression`
1327                // text is deliberately not a third operand (#1263), so
1328                // n2/N2 are 10/20 rather than the pre-#1263 11/21.
1329                insta::assert_json_snapshot!(
1330                    metric.halstead,
1331                    @r#"
1332                {
1333                  "unique_operators": 10,
1334                  "total_operators": 24,
1335                  "unique_operands": 10,
1336                  "total_operands": 20,
1337                  "length": 44,
1338                  "estimated_program_length": 66.43856189774725,
1339                  "purity_ratio": 1.5099673158578921,
1340                  "vocabulary": 20,
1341                  "volume": 190.16483617504394,
1342                  "difficulty": 10.0,
1343                  "level": 0.1,
1344                  "effort": 1901.6483617504396,
1345                  "time": 105.64713120835775,
1346                  "bugs": 0.05116412536051621
1347                }
1348                "#
1349                );
1350            },
1351        );
1352    }
1353
1354    #[test]
1355    fn javascript_template_string_plain_is_operand() {
1356        // Regression: issue #192. A backtick-delimited `` `hello` ``
1357        // without `${...}` is semantically identical to `"hello"` /
1358        // `'hello'` and must contribute exactly one operand — before
1359        // the fix `TemplateString` fell through to `HalsteadType::Unknown`
1360        // and contributed zero. expected: operands are `f` (function
1361        // name) and the wrapping `` `hello` `` template literal →
1362        // u_operands = 2, N2 = 2 (matches the equivalent
1363        // `function f() { return "hello"; }` baseline).
1364        check_metrics::<JavascriptParser>("function f() { return `hello`; }", "foo.js", |metric| {
1365            assert_eq!(metric.halstead.unique_operands(), 2);
1366            assert_eq!(metric.halstead.total_operands(), 2);
1367        });
1368    }
1369
1370    /// Regression for #695. The `get` / `set` property-accessor keywords
1371    /// are operators, matching the C# getter's `Get | Set | Init | Add |
1372    /// Remove` accessor arm. Before #695 the JS family classified them as
1373    /// operands, so the same accessor keyword landed in opposite Halstead
1374    /// groups across languages. This pins them in the operator store and
1375    /// out of the operand store.
1376    #[test]
1377    fn js_get_set_accessors_are_operators() {
1378        let source = "class C { get x() { return 1; } set x(v) { this._x = v; } }";
1379        let path = PathBuf::from("foo.js");
1380        let parser = JavascriptParser::new(source.as_bytes().to_vec(), &path, None);
1381        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
1382        assert!(
1383            ops.operators.iter().any(|o| o.as_str() == "get")
1384                && ops.operators.iter().any(|o| o.as_str() == "set"),
1385            "`get`/`set` accessors must be operators; operators were {:?}",
1386            ops.operators
1387        );
1388        assert!(
1389            !ops.operands.iter().any(|o| o.as_str() == "get")
1390                && !ops.operands.iter().any(|o| o.as_str() == "set"),
1391            "`get`/`set` accessors must not be operands; operands were {:?}",
1392            ops.operands
1393        );
1394    }
1395
1396    #[test]
1397    fn javascript_template_string_interpolation_no_double_count() {
1398        // Regression: issue #192. An interpolated template literal
1399        // `` `Hi ${name}!` `` used to fall through to `Unknown`,
1400        // dropping the wrapper from the count entirely; the inner
1401        // `name` was still walked and counted via the
1402        // `TemplateSubstitution` child. Mirrors #183 (C#), #191
1403        // (Kotlin), #199 (Perl): the wrapper is skipped when a
1404        // `TemplateSubstitution` child is present so the inner
1405        // expression is not double-counted.
1406        //
1407        // expected: for `function f(name) { return ` + "`Hi ${name}!`"
1408        // + `; }`, operands are `f` and `name` (twice — `name` as the
1409        // parameter, then again inside the interpolation), so
1410        // u_operands = 2 and N2 = 3. Without the wrapper-skip guard
1411        // the wrapping literal would also be counted, lifting
1412        // u_operands to 3 and N2 to 4.
1413        check_metrics::<JavascriptParser>(
1414            "function f(name) { return `Hi ${name}!`; }",
1415            "foo.js",
1416            |metric| {
1417                assert_eq!(metric.halstead.unique_operands(), 2);
1418                assert_eq!(metric.halstead.total_operands(), 3);
1419            },
1420        );
1421    }
1422
1423    #[test]
1424    fn mozjs_template_string_plain_is_operand() {
1425        // Regression: issue #192. Mirrors
1426        // `javascript_template_string_plain_is_operand` for the
1427        // Firefox-mode dialect — the four JS-family `get_op_type`
1428        // impls share the same template-literal handling.
1429        check_metrics::<MozjsParser>("function f() { return `hello`; }", "foo.js", |metric| {
1430            assert_eq!(metric.halstead.unique_operands(), 2);
1431            assert_eq!(metric.halstead.total_operands(), 2);
1432        });
1433    }
1434
1435    #[test]
1436    fn mozjs_template_string_interpolation_no_double_count() {
1437        // Regression: issue #192. Mirrors
1438        // `javascript_template_string_interpolation_no_double_count`
1439        // for the Firefox-mode dialect.
1440        check_metrics::<MozjsParser>(
1441            "function f(name) { return `Hi ${name}!`; }",
1442            "foo.js",
1443            |metric| {
1444                assert_eq!(metric.halstead.unique_operands(), 2);
1445                assert_eq!(metric.halstead.total_operands(), 3);
1446            },
1447        );
1448    }
1449
1450    #[test]
1451    fn typescript_template_string_plain_is_operand() {
1452        // Regression: issue #192. Mirrors
1453        // `javascript_template_string_plain_is_operand` for
1454        // TypeScript — the four JS-family `get_op_type` impls share
1455        // the same template-literal handling.
1456        //
1457        // The `: string` annotation contributes no operand — its
1458        // keyword counts once, as the text-keyed operator (#1261) — so
1459        // the operands are `f` and `` `hello` `` (2 each). The headline
1460        // of this test — that the plain template literal contributes
1461        // one operand — is unaffected.
1462        check_metrics::<TypescriptParser>(
1463            "function f(): string { return `hello`; }",
1464            "foo.ts",
1465            |metric| {
1466                assert_eq!(metric.halstead.unique_operands(), 2);
1467                assert_eq!(metric.halstead.total_operands(), 2);
1468            },
1469        );
1470    }
1471
1472    #[test]
1473    fn typescript_template_string_interpolation_no_double_count() {
1474        // Regression: issue #192. Mirrors
1475        // `javascript_template_string_interpolation_no_double_count`
1476        // for TypeScript.
1477        //
1478        // The `: string` annotations contribute no operands (#1261).
1479        // Unique operands: `f`, `name` (2). Total operands: `f`, `name`
1480        // (param), `name` (in the interpolation) (3). The interpolation
1481        // guard from #192 still holds — the wrapping `` `Hi ${name}!` ``
1482        // is `Unknown`, not double-counted.
1483        check_metrics::<TypescriptParser>(
1484            "function f(name: string): string { return `Hi ${name}!`; }",
1485            "foo.ts",
1486            |metric| {
1487                assert_eq!(metric.halstead.unique_operands(), 2);
1488                assert_eq!(metric.halstead.total_operands(), 3);
1489            },
1490        );
1491    }
1492
1493    #[test]
1494    fn tsx_template_string_plain_is_operand() {
1495        // Regression: issue #192. Mirrors
1496        // `javascript_template_string_plain_is_operand` for the
1497        // TSX (TypeScript + JSX) variant.
1498        //
1499        // TSX's type-keyword `string` (`String3`) contributes no
1500        // operand, mirroring TS::String2 (#1261): operands are `f` and
1501        // `` `hello` `` (2 each).
1502        check_metrics::<TsxParser>(
1503            "function f(): string { return `hello`; }",
1504            "foo.tsx",
1505            |metric| {
1506                assert_eq!(metric.halstead.unique_operands(), 2);
1507                assert_eq!(metric.halstead.total_operands(), 2);
1508            },
1509        );
1510    }
1511
1512    #[test]
1513    fn tsx_template_string_interpolation_no_double_count() {
1514        // Regression: issue #192. Mirrors
1515        // `javascript_template_string_interpolation_no_double_count`
1516        // for the TSX (TypeScript + JSX) variant.
1517        //
1518        // The `: string` annotations contribute no `String3` operands
1519        // (#1261); see `typescript_template_string_…` for the count
1520        // derivation.
1521        check_metrics::<TsxParser>(
1522            "function f(name: string): string { return `Hi ${name}!`; }",
1523            "foo.tsx",
1524            |metric| {
1525                assert_eq!(metric.halstead.unique_operands(), 2);
1526                assert_eq!(metric.halstead.total_operands(), 3);
1527            },
1528        );
1529    }
1530
1531    /// The JS-family regex fixture, asserted against all four grammars.
1532    ///
1533    /// `impl_js_family_get_op_type!` is instantiated four times against
1534    /// four distinct `kind_id` enums (`SLASH` 87/81/90/87, `Regex`
1535    /// 224/250/264/225), so each expansion is a separate compiled arm
1536    /// and a drift in one grammar is invisible if only one is checked
1537    /// (grammar-dispatch section 11).
1538    fn assert_js_family_counts(source: &str, expected: [u64; 4]) {
1539        assert_halstead_counts::<JavascriptParser>(source, "foo.js", expected, "javascript");
1540        assert_halstead_counts::<MozjsParser>(source, "foo.jsm", expected, "mozjs");
1541        assert_halstead_counts::<TypescriptParser>(source, "foo.ts", expected, "typescript");
1542        assert_halstead_counts::<TsxParser>(source, "foo.tsx", expected, "tsx");
1543    }
1544
1545    #[test]
1546    fn js_family_regex_delimiters_are_not_operators() {
1547        // Regression: issue #1314, the JS-family sibling of Elixir
1548        // #1256 and Ruby/Perl #1312. A `regex` literal spells both of
1549        // its delimiters `SLASH` — the kind id real division uses — so
1550        // `const a = /abc/g;` reported a `/` operator with no division
1551        // in the source, and n1/N1 counted the literal's punctuation as
1552        // arithmetic.
1553        //
1554        // The same fixture pins the second, independent half: `Regex`
1555        // was in neither arm, so the literal contributed no operand
1556        // either and reached the vocabulary from *neither* side.
1557        //
1558        // expected: operators `const`, `=`, `;`, `let` → n1 = 4;
1559        // `const` `=` `;` on line 1, `let` `=` `;` on line 2, `=` `;`
1560        // on line 3 → N1 = 8. Operands `a`, `/abc/g`, `b` → n2 = 3,
1561        // with `a` used three times and `b` twice → N2 = 6.
1562        //
1563        // Before the fix: n1 = 5 and N1 = 10 (the two fabricated `/`),
1564        // n2 = 2 and N2 = 5 (no operand for the literal).
1565        //
1566        // The four values are deliberately distinct so no transposition
1567        // of the unique-vs-total axes inside `assert_halstead_counts`
1568        // can pass (#1312).
1569        assert_js_family_counts("const a = /abc/g;\nlet b = a;\nb = a;\n", [4, 8, 3, 6]);
1570    }
1571
1572    #[test]
1573    fn js_family_division_survives_the_regex_guard() {
1574        // Control for #1314: the guard is scoped to a `Regex` parent,
1575        // so real division must still count. This fixture holds both
1576        // sides at once — two divisions and one regex literal — so a
1577        // guard widened to every `SLASH` fails here rather than
1578        // silently passing the test above.
1579        //
1580        // expected: operators `const`, `=`, `;`, `/` → n1 = 4; two
1581        // `const`, two `=`, two `;` and two `/` → N1 = 8. Operands
1582        // `q`, `a`, `b`, `c`, `r`, `/x/` → n2 = N2 = 6.
1583        assert_js_family_counts("const q = a / b / c;\nconst r = /x/;\n", [4, 8, 6, 6]);
1584    }
1585
1586    #[test]
1587    fn js_regex_delimiter_guard_is_parent_scoped_is_unobservable() {
1588        // Companion to the two above, and a statement of what they do
1589        // *not* cover. Ruby's guard has
1590        // `ruby_regex_guard_is_parent_scoped_not_ancestor_scoped`
1591        // because a division inside `#{…}` sits under a `Regex`
1592        // ancestor without being its child. No JS fixture can do that:
1593        // a regex literal admits no nested expression at all, its
1594        // `regex_pattern` and `regex_flags` children being leaves. So
1595        // the ancestor-scoped mutant of this guard — the one #1256's
1596        // post-mortem says survives every ordinary fixture — is
1597        // unobservable here. Measured, not assumed.
1598        //
1599        // Rather than write a fixture that would pass under both
1600        // spellings and read as coverage, pin the grammar property the
1601        // claim rests on: within a fixture that puts a division, a
1602        // template substitution and a regex in one file, every `SLASH`
1603        // reachable *below* a `Regex` is its immediate child. Should a
1604        // bump start nesting expressions inside a regex, this turns red
1605        // and the distinction becomes both observable and worth a real
1606        // test.
1607        //
1608        // Checked against all four grammars, not just JavaScript: the
1609        // guard is instantiated four times against four distinct enums,
1610        // and the property this test exists to watch could hold in one
1611        // and lapse in another.
1612        let source = b"const a = /abc/g;\nconst q = x / y;\nconst t = `p ${x / y} ${/zz/} q`;\n";
1613        assert_regex_slashes_are_immediate_children::<crate::langs::JavascriptCode>(
1614            source,
1615            Javascript::SLASH as u16,
1616            Javascript::Regex as u16,
1617            "javascript",
1618        );
1619        assert_regex_slashes_are_immediate_children::<crate::langs::MozjsCode>(
1620            source,
1621            Mozjs::SLASH as u16,
1622            Mozjs::Regex as u16,
1623            "mozjs",
1624        );
1625        assert_regex_slashes_are_immediate_children::<crate::langs::TypescriptCode>(
1626            source,
1627            Typescript::SLASH as u16,
1628            Typescript::Regex as u16,
1629            "typescript",
1630        );
1631        assert_regex_slashes_are_immediate_children::<crate::langs::TsxCode>(
1632            source,
1633            Tsx::SLASH as u16,
1634            Tsx::Regex as u16,
1635            "tsx",
1636        );
1637    }
1638
1639    /// Asserts every `slash` token below a `regex` node in `source` is
1640    /// that node's *immediate* child, for one grammar.
1641    ///
1642    /// Backs `js_regex_delimiter_guard_is_parent_scoped_is_unobservable`
1643    /// — see there for why the property is worth pinning.
1644    fn assert_regex_slashes_are_immediate_children<L: crate::traits::LanguageInfo>(
1645        source: &[u8],
1646        slash: u16,
1647        regex: u16,
1648        label: &str,
1649    ) {
1650        let mut slashes_below_a_regex = 0;
1651        let visited = for_each_node_with_chain::<L>(source, |node: &Node<'_>, chain| {
1652            if node.kind_id() != slash {
1653                return;
1654            }
1655            let Some(depth) = chain.iter().position(|a| a.kind_id() == regex) else {
1656                return;
1657            };
1658            slashes_below_a_regex += 1;
1659            assert_eq!(
1660                depth,
1661                chain.len() - 1,
1662                "{label}: a slash at row {} has a regex ancestor that is not its parent, so \
1663                 the parent-vs-ancestor mutant is now observable and needs a real test",
1664                node.start_row()
1665            );
1666        });
1667        assert!(visited > 20, "{label}: fixture is too small to prove much");
1668        // Without this the assertion above is vacuous whenever the
1669        // fixture stops containing a regex at all — the failure mode a
1670        // filter that matches nothing always has.
1671        assert_eq!(
1672            slashes_below_a_regex, 4,
1673            "{label}: expected the two regex literals' four delimiters; the fixture no \
1674             longer exercises what this test claims"
1675        );
1676    }
1677
1678    // Issue #281: optional chaining (`?.`) was double-counted as a
1679    // Halstead operator in TypeScript and TSX because the grammar
1680    // exposes both an `optional_chain` named wrapper AND a child
1681    // `?.` token, and both were classified as `Operator`. The fix
1682    // counts only the bare `?.` token (`QMARKDOT`) in TS/TSX so each
1683    // textual `?.` contributes exactly once, matching JS / MozJS
1684    // (whose grammars expose only `OptionalChain` — the `?.` token
1685    // itself).
1686    //
1687    // The four assertions below all compare against the same totals:
1688    // for `function f(a) { return a?.b?.c; }` the operator stream is
1689    // `function`, `(`, `{`, `return`, `?.`, `?.`, `;` (7 total, 6
1690    // unique — `LPAREN`/`LBRACE` count once, closing tokens are not
1691    // in the operator set). Before the fix, TS/TSX reported 9/7
1692    // instead of 7/6.
1693    #[test]
1694    fn javascript_optional_chain_not_double_counted_in_halstead_281() {
1695        check_metrics::<JavascriptParser>("function f(a) { return a?.b?.c; }", "foo.js", |m| {
1696            assert_eq!(m.halstead.unique_operators(), 6);
1697            assert_eq!(m.halstead.total_operators(), 7);
1698        });
1699    }
1700
1701    #[test]
1702    fn mozjs_optional_chain_not_double_counted_in_halstead_281() {
1703        check_metrics::<MozjsParser>("function f(a) { return a?.b?.c; }", "foo.js", |m| {
1704            assert_eq!(m.halstead.unique_operators(), 6);
1705            assert_eq!(m.halstead.total_operators(), 7);
1706        });
1707    }
1708
1709    #[test]
1710    fn typescript_optional_chain_not_double_counted_in_halstead_281() {
1711        // The TS grammar wraps member-expression `?.` in an
1712        // `optional_chain` named node containing the bare `?.`
1713        // token; classifying both as `Operator` double-counted the
1714        // chain. We now count only the bare token, so TS matches JS.
1715        check_metrics::<TypescriptParser>("function f(a) { return a?.b?.c; }", "foo.ts", |m| {
1716            assert_eq!(m.halstead.unique_operators(), 6);
1717            assert_eq!(m.halstead.total_operators(), 7);
1718        });
1719    }
1720
1721    #[test]
1722    fn tsx_optional_chain_not_double_counted_in_halstead_281() {
1723        check_metrics::<TsxParser>("function f(a) { return a?.b?.c; }", "foo.tsx", |m| {
1724            assert_eq!(m.halstead.unique_operators(), 6);
1725            assert_eq!(m.halstead.total_operators(), 7);
1726        });
1727    }
1728
1729    // Issue #299: parity guard for the JS-family `get_op_type` macro
1730    // on the optional-chain operator token (#281's prior regression
1731    // surface). All four languages must classify the bare `?.` token
1732    // identically — `OptionalChain` in JS/MozJS, `QMARKDOT` in
1733    // TS/TSX — and emit the same totals for
1734    // `function f(a) { return a?.b?.c; }`:
1735    //
1736    // * Operators: `function`, `(`, `{`, `return`, `?.`, `?.`, `;`
1737    //   (7 total, 6 unique).
1738    // * Operands: `f`, `a` (parameter), `a`, `b`, `c` — the identifier
1739    //   and property leaves only (5 total, 4 unique). Until #1263 the
1740    //   two wrapping member expressions (`a?.b`, `a?.b?.c`) were
1741    //   classified as `MemberExpression*` operands on top of the leaves
1742    //   they contain, making this 7 total / 6 unique.
1743    //
1744    // Verified by test-via-revert: dropping `OptionalChain` from
1745    // JS/MozJS, or `QMARKDOT` from TS/TSX, trips the test
1746    // (u_operators 6→5). This input does NOT exercise every operand
1747    // alias in the per-language `operand_extras` (`Identifier2`, the
1748    // JS/MozJS/TSX string-literal `String2`); drift in
1749    // those is out of scope for this regression guard and would need a
1750    // separate fixture. The `PredefinedType` operator path (`: void`
1751    // double-count) is now covered by `ts_void_return_type_single_operator_453`
1752    // below.
1753    #[test]
1754    fn js_family_get_op_type_parity_optional_chain_member_299() {
1755        // Non-capturing closure (coerced to the `fn` pointer that
1756        // `check_metrics` accepts) avoids the
1757        // `clippy::needless_pass_by_value` warning that a free `fn`
1758        // taking `CodeMetrics` by value would trigger.
1759        const SRC: &str = "function f(a) { return a?.b?.c; }";
1760        let check = |m: crate::CodeMetrics| {
1761            assert_eq!(m.halstead.unique_operators(), 6);
1762            assert_eq!(m.halstead.total_operators(), 7);
1763            assert_eq!(m.halstead.unique_operands(), 4);
1764            assert_eq!(m.halstead.total_operands(), 5);
1765        };
1766
1767        check_metrics::<JavascriptParser>(SRC, "foo.js", check);
1768        check_metrics::<MozjsParser>(SRC, "foo.js", check);
1769        check_metrics::<TypescriptParser>(SRC, "foo.ts", check);
1770        check_metrics::<TsxParser>(SRC, "foo.tsx", check);
1771    }
1772
1773    // Issue #1263: a member access contributes its leaves and the `.`
1774    // operator, never the `member_expression` composite as well. The
1775    // classification does not stop the walk, so `a` and `b` were always
1776    // counted; listing the wrapper billed a third operand keyed on the
1777    // whole `a.b` text, which no other language here does.
1778    //
1779    // expected, for `var r = a.b;`:
1780    //
1781    // * Operators: `var`, `=`, `.`, `;` — 4 total, 4 unique.
1782    // * Operands: `r`, `a`, `b` — 3 total, 3 unique. Before the fix
1783    //   the `member_expression` wrapper added `a.b`, making both 4.
1784    //
1785    // All four JS-family languages are asserted because
1786    // `impl_js_family_get_op_type!` emits one shared operand arm: the
1787    // lockstep is the point of the macro, and a per-language extras
1788    // list is exactly where a future edit could break it.
1789    #[test]
1790    fn js_family_member_access_counts_leaves_not_the_composite_1263() {
1791        const SRC: &str = "var r = a.b;";
1792        let check = |m: crate::CodeMetrics| {
1793            assert_eq!(m.halstead.unique_operators(), 4);
1794            assert_eq!(m.halstead.total_operators(), 4);
1795            assert_eq!(m.halstead.unique_operands(), 3);
1796            assert_eq!(m.halstead.total_operands(), 3);
1797        };
1798
1799        check_metrics::<JavascriptParser>(SRC, "foo.js", check);
1800        check_metrics::<MozjsParser>(SRC, "foo.js", check);
1801        check_metrics::<TypescriptParser>(SRC, "foo.ts", check);
1802        check_metrics::<TsxParser>(SRC, "foo.tsx", check);
1803    }
1804
1805    // Issue #1263, the grammar-dispatch section 6 half: dropping
1806    // `MemberExpression*` from the operand arm would have regressed
1807    // private-field access to *zero* operands for the field, because
1808    // `PrivatePropertyIdentifier` — the `#x` leaf — was in no operand
1809    // list and the composite `this.#x` had been its only count. Adding
1810    // the leaf also fixes the declaration site `#x = 1`, which no
1811    // wrapper covered and which therefore counted nothing at all.
1812    //
1813    // expected, for `class C { #x = 1; m() { return this.#x; } }`:
1814    //
1815    // * Operators: `{`×2 (class body, method body), `=`, `;`×2, `(`,
1816    //   `return`, `.` — 8 total, 6 unique. (`class` is not in the
1817    //   JS-family operator arm, so it contributes nothing; that is
1818    //   pre-existing and unrelated.)
1819    // * Operands: `C`, `#x`, `1`, `m`, `this`, `#x` — 6 total, 5
1820    //   unique under JS/MozJS. Under TS/TSX the class *name* `C`
1821    //   parses as `type_identifier`, which those getters do not
1822    //   classify, so both counts drop by one to 5/4 — a pre-existing
1823    //   divergence this fixture records rather than fixes.
1824    #[test]
1825    fn js_family_private_field_leaf_is_the_operand_1263() {
1826        const SRC: &str = "class C { #x = 1; m() { return this.#x; } }";
1827        let check_js = |m: crate::CodeMetrics| {
1828            assert_eq!(m.halstead.unique_operators(), 6);
1829            assert_eq!(m.halstead.total_operators(), 8);
1830            assert_eq!(m.halstead.unique_operands(), 5);
1831            assert_eq!(m.halstead.total_operands(), 6);
1832        };
1833        let check_ts = |m: crate::CodeMetrics| {
1834            assert_eq!(m.halstead.unique_operators(), 6);
1835            assert_eq!(m.halstead.total_operators(), 8);
1836            assert_eq!(m.halstead.unique_operands(), 4);
1837            assert_eq!(m.halstead.total_operands(), 5);
1838        };
1839
1840        check_metrics::<JavascriptParser>(SRC, "foo.js", check_js);
1841        check_metrics::<MozjsParser>(SRC, "foo.js", check_js);
1842        check_metrics::<TypescriptParser>(SRC, "foo.ts", check_ts);
1843        check_metrics::<TsxParser>(SRC, "foo.tsx", check_ts);
1844    }
1845
1846    // Issue #1263, the other section 6 half: `meta_property` is the one
1847    // composite the leaves-not-composites drop has to keep. `import.meta`
1848    // / `new.target` have no classified leaf — `meta` and `target` are
1849    // anonymous tokens in no arm — so with `MemberExpression*` gone the
1850    // meta-object contributed no operand at all while `this.env.x` still
1851    // yielded three.
1852    //
1853    // expected operands, for `var t = import.meta.url; function f() {
1854    // return new.target; }`: `t`, `import.meta`, `url`, `f`,
1855    // `new.target` — 5 total, 5 unique. Operators are deliberately not
1856    // asserted: the `import` / `new` keyword tokens inside the
1857    // meta-property keep their pre-existing operator classification,
1858    // which this fixture neither pins nor contests.
1859    #[test]
1860    fn js_family_meta_property_is_one_operand_1263() {
1861        const SRC: &str = "var t = import.meta.url; function f() { return new.target; }";
1862        let check = |m: crate::CodeMetrics| {
1863            assert_eq!(m.halstead.unique_operands(), 5);
1864            assert_eq!(m.halstead.total_operands(), 5);
1865        };
1866
1867        check_metrics::<JavascriptParser>(SRC, "foo.js", check);
1868        check_metrics::<MozjsParser>(SRC, "foo.js", check);
1869        check_metrics::<TypescriptParser>(SRC, "foo.ts", check);
1870        check_metrics::<TsxParser>(SRC, "foo.tsx", check);
1871    }
1872
1873    // Issue #1263: TS/TSX `nested_identifier` (`namespace N.M`) is the
1874    // same container/leaf double-count as `member_expression`.
1875    //
1876    // expected, for `namespace N.M { }`:
1877    //
1878    // * Operators: `.`, `{` — 2 total, 2 unique. (`namespace` is not in
1879    //   the JS-family operator arm.)
1880    // * Operands: `N`, `M` — 2 total, 2 unique. Before the fix the
1881    //   `nested_identifier` added `N.M`, making both 3.
1882    #[test]
1883    fn ts_nested_identifier_counts_leaves_not_the_composite_1263() {
1884        const SRC: &str = "namespace N.M { }";
1885        let check = |m: crate::CodeMetrics| {
1886            assert_eq!(m.halstead.unique_operators(), 2);
1887            assert_eq!(m.halstead.total_operators(), 2);
1888            assert_eq!(m.halstead.unique_operands(), 2);
1889            assert_eq!(m.halstead.total_operands(), 2);
1890        };
1891
1892        check_metrics::<TypescriptParser>(SRC, "foo.ts", check);
1893        check_metrics::<TsxParser>(SRC, "foo.tsx", check);
1894    }
1895
1896    // Issue #1261 (inverting the #313 pin): the `"string"` type-keyword
1897    // aliases the TS / TSX grammars expose must contribute NO operand.
1898    // #313 put them in `operand_extras` for parity with the then-wider
1899    // `Checker::is_string`, but the `predefined_type` wrapper already
1900    // counts as the text-keyed `"string"` operator, so one `: string`
1901    // token tallied as operator AND operand while `: number` counted
1902    // once. #1261 drops the aliases from both `operand_extras` and
1903    // `is_string`, so the keyword counts once, as the operator.
1904    //
1905    // For the input `let x: string = "y";`:
1906    //
1907    // * TypeScript emits `Typescript::String2` for the `string` type
1908    //   keyword (kind_id 135, in the type-keyword block of the enum).
1909    // * TSX emits `Tsx::String3` for the same role (kind_id 141).
1910    //
1911    // Verified by test-via-revert: restoring `String2` to TS's
1912    // `operand_extras` (or `String3` to TSX's) trips this test on
1913    // `u_operands` / `operands` for the affected language.
1914    #[test]
1915    fn ts_family_type_keyword_counts_once_1261() {
1916        const SRC: &str = "let x: string = \"y\";";
1917        // Operators (n1 = 5, N1 = 5):
1918        //   `let`, `:`, `=`, `;`, plus `string` (PredefinedType wrapper,
1919        //   routed through `is_primitive` so it's keyed by its lexeme
1920        //   `"string"` in `primitive_operators`).
1921        // Operands (n2 = 2, N2 = 2):
1922        //   `x` and the `"y"` literal. Under #313 the type-keyword
1923        //   child of `predefined_type` added a third, phantom
1924        //   `"string"` operand (n2 = 3 / N2 = 3).
1925        let check = |m: crate::CodeMetrics| {
1926            assert_eq!(m.halstead.unique_operators(), 5);
1927            assert_eq!(m.halstead.total_operators(), 5);
1928            assert_eq!(m.halstead.unique_operands(), 2);
1929            assert_eq!(m.halstead.total_operands(), 2);
1930        };
1931
1932        check_metrics::<TypescriptParser>(SRC, "foo.ts", check);
1933        check_metrics::<TsxParser>(SRC, "foo.tsx", check);
1934    }
1935
1936    // Issue #1261 regression, the issue's reproducer plus a literal
1937    // whose contents spell the keyword: `: string` and `: number` must
1938    // contribute symmetrically — one text-keyed operator each, zero
1939    // operands — while a string *literal* `"string"` stays an operand
1940    // (distinct from the keyword: TS kind `String`, TSX kind `String2`,
1941    // both quoted in the operand key).
1942    #[test]
1943    fn ts_family_string_annotation_symmetric_with_number_1261() {
1944        const SRC: &str = "let x: string = \"a\";\nlet y: number = 1;\nlet s = \"string\";";
1945        // Operators (n1 = 6, N1 = 13):
1946        //   `let` ×3, `:` ×2, `=` ×3, `;` ×3, `string` ×1, `number` ×1.
1947        //   Pre-fix N1 was identical — the wrapper operator was always
1948        //   counted; the defect was the extra operand below.
1949        // Operands (n2 = 6, N2 = 6):
1950        //   `x`, `"a"`, `y`, `1`, `s`, `"string"` — one each. Pre-fix
1951        //   the `: string` keyword added a bare `string` operand
1952        //   (n2 = 7 / N2 = 7) that `: number` had no analogue of.
1953        let check = |m: crate::CodeMetrics| {
1954            assert_eq!(m.halstead.unique_operators(), 6);
1955            assert_eq!(m.halstead.total_operators(), 13);
1956            assert_eq!(m.halstead.unique_operands(), 6);
1957            assert_eq!(m.halstead.total_operands(), 6);
1958        };
1959
1960        check_metrics::<TypescriptParser>(SRC, "foo.ts", check);
1961        check_metrics::<TsxParser>(SRC, "foo.tsx", check);
1962    }
1963
1964    /// Drift marker for #1261 (lesson 34 / grammar-dispatch §2): the
1965    /// anonymous `string` type-keyword token appears **only** as a
1966    /// `predefined_type` child.
1967    ///
1968    /// That is the whole argument for classifying the keyword without a
1969    /// parent guard — the wrapper is guaranteed to be there to carry the
1970    /// operator. The two tests above measure the consequence and would
1971    /// still pass if the grammar started emitting the keyword somewhere
1972    /// else, as long as their own two fixtures kept their counts; this one
1973    /// measures the premise, over every position #1261's dump probes
1974    /// covered: annotation, parameter and return type, union member,
1975    /// generic argument, and template-literal type. A string *literal*
1976    /// spelling `"string"` is a different kind and must not be confused
1977    /// for the keyword, so one is in the fixture too.
1978    #[test]
1979    fn ts_family_type_keyword_only_appears_under_predefined_type_1261() {
1980        // Exercises each position the keyword can take. Valid in both
1981        // grammars: no angle-bracket cast, which TSX would read as JSX.
1982        const SRC: &str = "const a: string = \"string\";\n\
1983                           function f(x: string): string {\n\
1984                               return x;\n\
1985                           }\n\
1986                           type U = string | number;\n\
1987                           type A = Array<string>;\n\
1988                           type M = Map<string, number>;\n\
1989                           type T = `id-${string}`;\n";
1990        // Seven type positions: `a`, `x`, `f`'s return, the union member,
1991        // `Array`'s argument, `Map`'s first argument, and the template
1992        // placeholder. The `"string"` initialiser is a literal, not the
1993        // keyword, and must not be among them.
1994        const EXPECTED_OCCURRENCES: usize = 7;
1995
1996        fn keyword_occurrences<P: ParserTrait>(
1997            path: &str,
1998            keyword: u16,
1999            predefined_type: u16,
2000        ) -> usize {
2001            let parser = P::new(SRC.as_bytes().to_vec(), &PathBuf::from(path), None);
2002            parser
2003                .root()
2004                .preorder()
2005                .filter(|node| node.kind_id() == keyword)
2006                .inspect(|node| {
2007                    assert_eq!(
2008                        node.parent().map(|parent| parent.kind_id()),
2009                        Some(predefined_type),
2010                        "the `string` type keyword surfaced outside \
2011                         `predefined_type` in {path}; the operator is carried \
2012                         by the wrapper, so `get_op_type` needs a parent guard \
2013                         before that arm can be trusted (#1261)",
2014                    );
2015                })
2016                .count()
2017        }
2018
2019        // Kind ids re-read from the generated enums, not carried over: TS
2020        // `String2` = 135, TSX `String3` = 141 (TSX's `String2` = 261 is the
2021        // string-literal production and stays an operand).
2022        assert_eq!(
2023            keyword_occurrences::<TypescriptParser>(
2024                "foo.ts",
2025                Typescript::String2 as u16,
2026                Typescript::PredefinedType as u16,
2027            ),
2028            EXPECTED_OCCURRENCES,
2029            "TypeScript no longer emits the `string` type keyword in every \
2030             position #1261 probed",
2031        );
2032        assert_eq!(
2033            keyword_occurrences::<TsxParser>(
2034                "foo.tsx",
2035                Tsx::String3 as u16,
2036                Tsx::PredefinedType as u16,
2037            ),
2038            EXPECTED_OCCURRENCES,
2039            "TSX no longer emits the `string` type keyword in every position \
2040             #1261 probed",
2041        );
2042    }
2043
2044    // Issue #453: a `void` return type must contribute exactly one
2045    // Halstead operator. The TS / TSX grammars parse `: void` as a
2046    // `predefined_type` wrapper around an inner `void` token. `is_primitive`
2047    // routes the wrapper into the text-keyed `primitive_operators` map as
2048    // `"void"`, while the inner `Void` token is independently a standalone
2049    // expression operator (`void 0`). Pre-fix both classified as operators
2050    // and one source `void` counted as TWO distinct Halstead operators.
2051    // The fix suppresses the wrapper when its child is a `Void` token, so
2052    // only the inner token carries the operator — matching expression
2053    // `void 0` and keeping the kind_id-keyed count consistent.
2054    //
2055    // For `function f(): void { return; }`:
2056    //
2057    // * Operators (n1 = 7, N1 = 7): `function`, `()`, `{}`, `:`, `return`,
2058    //   `;`, and a single `void`. (The untyped form is n1 = 5; the `: void`
2059    //   annotation adds the `:` operator and one `void`, NOT two — the
2060    //   issue's "n1 = 6" target overlooked the annotation colon.)
2061    //
2062    // Verified by test-via-revert: removing the `predefined_void` guard
2063    // restores the pre-fix `u_operators` 7 -> 8 with a duplicate `"void"`
2064    // (one kind_id-keyed, one in `primitive_operators`). Both `metrics()`
2065    // and the `ops`-list dedup invariant (`ts_void_return_and_expression_*`
2066    // in `ops.rs`) are pinned per lesson 4.
2067    #[test]
2068    fn ts_void_return_type_single_operator_453() {
2069        const SRC: &str = "function f(): void { return; }";
2070        let check = |m: crate::CodeMetrics| {
2071            assert_eq!(m.halstead.unique_operators(), 7);
2072            assert_eq!(m.halstead.total_operators(), 7);
2073        };
2074
2075        check_metrics::<TypescriptParser>(SRC, "foo.ts", check);
2076        check_metrics::<TsxParser>(SRC, "foo.tsx", check);
2077    }
2078
2079    // Issue #453 over-suppression guard: expression `void 0` (a
2080    // `unary_expression`, NOT a `predefined_type` wrapper) must still
2081    // count `void` as exactly one operator. The fix keys only on a
2082    // `predefined_type` whose child is a `Void` token, so the bare
2083    // expression operator is untouched.
2084    //
2085    // For `const x = void 0;`:
2086    //
2087    // * Operators (n1 = 4, N1 = 4): `const`, `=`, `void`, `;`.
2088    // * Operands (n2 = 2, N2 = 2): `x`, `0`.
2089    #[test]
2090    fn ts_void_expression_still_single_operator_453() {
2091        const SRC: &str = "const x = void 0;";
2092        let check = |m: crate::CodeMetrics| {
2093            assert_eq!(m.halstead.unique_operators(), 4);
2094            assert_eq!(m.halstead.total_operators(), 4);
2095            assert_eq!(m.halstead.unique_operands(), 2);
2096            assert_eq!(m.halstead.total_operands(), 2);
2097        };
2098
2099        check_metrics::<TypescriptParser>(SRC, "foo.ts", check);
2100        check_metrics::<TsxParser>(SRC, "foo.tsx", check);
2101    }
2102
2103    #[test]
2104    fn python_wrong_operators() {
2105        check_metrics::<PythonParser>("()[]{}", "foo.py", |metric| {
2106            insta::assert_json_snapshot!(
2107                metric.halstead,
2108                @r#"
2109            {
2110              "unique_operators": 0,
2111              "total_operators": 0,
2112              "unique_operands": 0,
2113              "total_operands": 0,
2114              "length": 0,
2115              "estimated_program_length": 0.0,
2116              "purity_ratio": 0.0,
2117              "vocabulary": 0,
2118              "volume": 0.0,
2119              "difficulty": 0.0,
2120              "level": 0.0,
2121              "effort": 0.0,
2122              "time": 0.0,
2123              "bugs": 0.0
2124            }
2125            "#
2126            );
2127        });
2128    }
2129
2130    #[test]
2131    fn python_check_metrics() {
2132        check_metrics::<PythonParser>(
2133            "def f():
2134                 pass",
2135            "foo.py",
2136            |metric| {
2137                insta::assert_json_snapshot!(
2138                    metric.halstead,
2139                    @r#"
2140                {
2141                  "unique_operators": 2,
2142                  "total_operators": 2,
2143                  "unique_operands": 1,
2144                  "total_operands": 1,
2145                  "length": 3,
2146                  "estimated_program_length": 2.0,
2147                  "purity_ratio": 0.6666666666666666,
2148                  "vocabulary": 3,
2149                  "volume": 4.754887502163468,
2150                  "difficulty": 1.0,
2151                  "level": 1.0,
2152                  "effort": 4.754887502163468,
2153                  "time": 0.26416041678685936,
2154                  "bugs": 0.0009425525573729414
2155                }
2156                "#
2157                );
2158            },
2159        );
2160    }
2161
2162    #[test]
2163    fn java_operators_and_operands() {
2164        check_metrics::<JavaParser>(
2165            "public class Main {
2166            public static void main(string args[]) {
2167                  int a, b, c, avg;
2168                  a = 5; b = 5; c = 5;
2169                  avg = (a + b + c) / 3;
2170                  MessageFormat.format(\"{0}\", avg);
2171                }
2172            }",
2173            "foo.java",
2174            |metric| {
2175                // Operators (n1=11): {} void () [] , . ; int = + /
2176                // Operands (n2=12): Main main args a b c avg 5 3 MessageFormat format "{0}"
2177                insta::assert_json_snapshot!(
2178                    metric.halstead,
2179                    @r#"
2180                {
2181                  "unique_operators": 11,
2182                  "total_operators": 26,
2183                  "unique_operands": 12,
2184                  "total_operands": 22,
2185                  "length": 48,
2186                  "estimated_program_length": 81.07329781366414,
2187                  "purity_ratio": 1.6890270377846697,
2188                  "vocabulary": 23,
2189                  "volume": 217.13097389073664,
2190                  "difficulty": 10.083333333333334,
2191                  "level": 0.09917355371900825,
2192                  "effort": 2189.4039867315946,
2193                  "time": 121.63355481842193,
2194                  "bugs": 0.05620341201461669
2195                }
2196                "#
2197                );
2198            },
2199        );
2200    }
2201
2202    #[test]
2203    fn java_primitive_types_and_booleans() {
2204        check_metrics::<JavaParser>(
2205            "public class Prims {
2206                byte a = 1;
2207                short b = 2;
2208                int c = 3;
2209                long d = 4;
2210                char e = 'x';
2211                float f = 1.0f;
2212                double g = 2.0;
2213                boolean h = true;
2214                boolean i = false;
2215            }",
2216            "foo.java",
2217            |metric| {
2218                // Verifies all 8 Java primitive-type keywords (byte, short, int, long,
2219                // char, float, double, boolean) are counted as distinct operators, and
2220                // that true/false are counted as operands.
2221                insta::assert_json_snapshot!(
2222                    metric.halstead,
2223                    @r#"
2224                {
2225                  "unique_operators": 11,
2226                  "total_operators": 28,
2227                  "unique_operands": 19,
2228                  "total_operands": 19,
2229                  "length": 47,
2230                  "estimated_program_length": 118.76437056043838,
2231                  "purity_ratio": 2.526901501285923,
2232                  "vocabulary": 30,
2233                  "volume": 230.62385799360038,
2234                  "difficulty": 5.5,
2235                  "level": 0.18181818181818182,
2236                  "effort": 1268.4312189648022,
2237                  "time": 70.46840105360012,
2238                  "bugs": 0.03905920146699976
2239                }
2240                "#
2241                );
2242            },
2243        );
2244    }
2245
2246    #[test]
2247    fn groovy_operators_and_operands() {
2248        check_metrics::<GroovyParser>(
2249            "class Main {
2250                static void main(String[] args) {
2251                    int a, b, c, avg;
2252                    a = 5; b = 5; c = 5;
2253                    avg = (a + b + c) / 3;
2254                    println(avg);
2255                }
2256            }",
2257            "foo.groovy",
2258            |metric| {
2259                // Groovy mirror of `java_operators_and_operands`. The juxt
2260                // call `println avg` exercises `juxt_function_call` in
2261                // place of Java's `MessageFormat.format(...)`. amaanq's
2262                // grammar inherits Java's tokenisation, so n1/N1/n2/N2
2263                // shapes match Java up to those substitutions.
2264                // The dekobon grammar parses primitive type names
2265                // (`void`, `int`, `String`) as `type_identifier`
2266                // rather than as distinct keyword tokens, so they
2267                // count as operands here — the prior amaanq grammar
2268                // treated them as operators. Net shift: −2 unique
2269                // operators (`void`, `int`), +2 unique operands
2270                // (`void`, `int` were the only two type_identifiers
2271                // not already counted as operands, since `String`
2272                // was already an identifier in the prior grammar's
2273                // counting).
2274                assert_eq!(metric.halstead.unique_operators(), 8);
2275                assert_eq!(metric.halstead.unique_operands(), 13);
2276                insta::assert_json_snapshot!(
2277                    metric.halstead,
2278                    @r#"
2279                {
2280                  "unique_operators": 8,
2281                  "total_operators": 22,
2282                  "unique_operands": 13,
2283                  "total_operands": 23,
2284                  "length": 45,
2285                  "estimated_program_length": 72.10571633583419,
2286                  "purity_ratio": 1.6023492519074265,
2287                  "vocabulary": 21,
2288                  "volume": 197.65428402504423,
2289                  "difficulty": 7.076923076923077,
2290                  "level": 0.14130434782608697,
2291                  "effort": 1398.7841638695438,
2292                  "time": 77.71023132608576,
2293                  "bugs": 0.04169134280255714
2294                }
2295                "#
2296                );
2297            },
2298        );
2299    }
2300
2301    #[test]
2302    fn groovy_primitive_types_and_booleans() {
2303        check_metrics::<GroovyParser>(
2304            "class Prims {
2305                byte a = 1
2306                short b = 2
2307                int c = 3
2308                long d = 4
2309                char e = 'x'
2310                float f = 1.0f
2311                double g = 2.0
2312                boolean h = true
2313                boolean i = false
2314            }",
2315            "foo.groovy",
2316            |metric| {
2317                // The dekobon grammar consolidates the 8 primitive
2318                // type names (`byte`, `short`, `int`, `long`, `char`,
2319                // `float`, `double`, `boolean`) under `type_identifier`
2320                // — so they count as operands, not as distinct
2321                // operators. Likewise numeric literals collapse to one
2322                // `NumberLiteral` shape (no Hex/Octal/Binary/Decimal
2323                // split), and `'x'` parses as `StringLiteral` (Groovy
2324                // single-quoted strings) rather than as
2325                // `CharacterLiteral`. Operators remaining in this
2326                // fixture: `=` and `class`-body braces (only `{` is in
2327                // the operator set). True/false collapse under one
2328                // `BooleanLiteral`.
2329                assert_eq!(metric.halstead.unique_operators(), 2);
2330                assert_eq!(metric.halstead.unique_operands(), 27);
2331                insta::assert_json_snapshot!(
2332                    metric.halstead,
2333                    @r#"
2334                {
2335                  "unique_operators": 2,
2336                  "total_operators": 10,
2337                  "unique_operands": 27,
2338                  "total_operands": 28,
2339                  "length": 38,
2340                  "estimated_program_length": 130.38196255841365,
2341                  "purity_ratio": 3.4311042778529908,
2342                  "vocabulary": 29,
2343                  "volume": 184.60327781484773,
2344                  "difficulty": 1.037037037037037,
2345                  "level": 0.9642857142857143,
2346                  "effort": 191.44043625243467,
2347                  "time": 10.635579791801925,
2348                  "bugs": 0.01107221547116606
2349                }
2350                "#
2351                );
2352            },
2353        );
2354    }
2355
2356    // Issue #1263 swept Groovy alongside the JS family and C#: its
2357    // operand arm listed `QualifiedName` (a `package` / `import` path)
2358    // and `QualifiedType` on top of the identifier leaves the walker
2359    // already reached.
2360    //
2361    // Only the `QualifiedName` half was observable. The runtime emits
2362    // `qualified_type` as the *alias* `QualifiedType2` (kind_id 228),
2363    // which the arm never named — a lesson-2 miss that, by accident,
2364    // made that half already leaves-only and is why #1263's issue body
2365    // recorded Groovy as compliant. Both kinds are gone rather than
2366    // completed.
2367    //
2368    // expected, for `package com.example`: operators `.` (1/1);
2369    // operands `com`, `example` (2/2). Pre-fix the `qualified_name`
2370    // added `com.example`, making the operand counts 3/3.
2371    #[test]
2372    fn groovy_qualified_name_counts_leaves_not_the_composite_1263() {
2373        check_metrics::<GroovyParser>("package com.example", "foo.groovy", |metric| {
2374            assert_eq!(metric.halstead.unique_operators(), 1);
2375            assert_eq!(metric.halstead.total_operators(), 1);
2376            assert_eq!(metric.halstead.unique_operands(), 2);
2377            assert_eq!(metric.halstead.total_operands(), 2);
2378        });
2379    }
2380
2381    // The type half of the same arm (#1352). #1263 dropped
2382    // `QualifiedType` alongside `QualifiedName`, but only the latter
2383    // was pinned: adding `QualifiedType2` back to the operand arm
2384    // failed none of the 3,523 tests then in the lib targets, so the
2385    // leaves-only reading of a qualified *type* was correct by accident
2386    // rather than by contract. The tempting "fix" for the alias miss
2387    // #1263 recorded is to complete the list with 228, which is exactly
2388    // the double count #1263 removed — this test is what stops that.
2389    //
2390    // The kind assertions are the grammar-dispatch section 1 / 2 drift
2391    // marker: if a grammar bump renumbers the alias, the operand
2392    // assertions below would keep passing while measuring a construct
2393    // this arm no longer describes.
2394    //
2395    // expected, for `java.util.List x = null`: operators `.` × 2 and
2396    // `=` → n1 = 2, N1 = 3; operands `java`, `util`, `List`, `x`,
2397    // `null` → n2 = 5, N2 = 5. Listing the wrapper would add the whole
2398    // span `java.util.List`, making the operand counts 6/6.
2399    #[test]
2400    fn groovy_qualified_type_counts_leaves_not_the_composite_1352() {
2401        const SOURCE: &str = "java.util.List x = null";
2402
2403        let parser = GroovyParser::new(
2404            SOURCE.as_bytes().to_vec(),
2405            &PathBuf::from("foo.groovy"),
2406            None,
2407        );
2408        assert!(
2409            ast_has_kind_id(&parser, Groovy::QualifiedType2 as u16),
2410            "the dekobon grammar no longer emits `qualified_type` as the \
2411             alias `QualifiedType2`; re-derive the Groovy operand arm \
2412             before trusting the counts below",
2413        );
2414        assert!(
2415            !ast_has_kind_id(&parser, Groovy::QualifiedType as u16),
2416            "the unsuffixed `QualifiedType` is now reachable; it is a \
2417             second wrapper this arm must keep excluded",
2418        );
2419
2420        check_metrics::<GroovyParser>(SOURCE, "foo.groovy", |metric| {
2421            assert_eq!(metric.halstead.unique_operators(), 2);
2422            assert_eq!(metric.halstead.total_operators(), 3);
2423            assert_eq!(metric.halstead.unique_operands(), 5);
2424            assert_eq!(metric.halstead.total_operands(), 5);
2425        });
2426    }
2427
2428    #[test]
2429    fn groovy_closure_operators_and_operands() {
2430        check_metrics::<GroovyParser>("def double = { x -> x * 2 }", "foo.groovy", |metric| {
2431            // Closure with arrow-style parameter list.
2432            // Distinct operators: def, =, {}, ->, * = 5.
2433            // Distinct operands: double, x, 2 = 3.
2434            assert_eq!(metric.halstead.unique_operators(), 5);
2435            assert_eq!(metric.halstead.unique_operands(), 3);
2436        });
2437    }
2438
2439    /// Regression for issue #247: every Groovy-specific operator the
2440    /// prior amaanq grammar dropped to ERROR or mis-shaped as a Java
2441    /// node now parses as a distinct lexer token in the dekobon
2442    /// grammar, so Halstead counts each one. The fixture below
2443    /// exercises Elvis `?:`, safe-nav `?.`, safe-chain `??.`,
2444    /// spread-dot `*.`, method-pointer `.&`, direct-field `.@`,
2445    /// identity `===` / `!==`, spaceship `<=>`, regex `=~` / `==~`,
2446    /// exclusive ranges `..<` / `<..` / `<..<`, `as` coercion, and
2447    /// `?[` safe index — every distinct operator kind must appear in
2448    /// `u_operators` (the count grows by exactly the number of new
2449    /// distinct operator tokens introduced).
2450    #[test]
2451    fn groovy_dekobon_operator_coverage_247() {
2452        check_metrics::<GroovyParser>(
2453            "def f(a, b, list, s) {
2454                def x = a ?: b
2455                def y = a?.field
2456                def z = a??.field
2457                def items = list*.size()
2458                def ptr = a.&size
2459                def fld = a.@field
2460                def id1 = a === b
2461                def id2 = a !== b
2462                def ship = a <=> b
2463                def find = s =~ /pat/
2464                def match = s ==~ /^pat\\$/
2465                def r1 = 0..<10
2466                def r2 = 0<..10
2467                def r3 = 0<..<10
2468                def cast = a as String
2469                def safe = list?[0]
2470                return x
2471            }",
2472            "foo.groovy",
2473            |metric| {
2474                // Exact pin: with the dekobon Groovy grammar this
2475                // fixture exercises 16 Groovy-specific tokens (`?:`,
2476                // `?.`, `??.`, `*.`, `.&`, `.@`, `===`, `!==`, `<=>`,
2477                // `=~`, `==~`, `..<`, `<..`, `<..<`, `as`, `?[`) plus
2478                // 6 ambient Java-shaped operators the fixture also
2479                // uses (`def`, `=`, `,`, `{}`, `()`, `return`), for a
2480                // total of 22 distinct operator kinds. A regression
2481                // that drops any one of the 16 #247 operators would
2482                // push the count below 22 and fail this assertion. The
2483                // complementary AST walk below pins each #247
2484                // operator's identity individually so a grammar change
2485                // that adds an unrelated operator (lifting
2486                // `u_operators` to 23) still flags the loss of a #247
2487                // operator at the per-token level.
2488                //
2489                // Was 23 until #1314. The extra entry was a `/` — the
2490                // fixture's two slashy literals (`/pat/`, `/^pat\$/`)
2491                // each spelled their closing delimiter with the
2492                // division kind, and the arm now guards them. The
2493                // enumeration above was wrong in two ways at that
2494                // count: it listed an ambient `[`, which this fixture
2495                // never emits (`list?[0]` is the single `?[` token),
2496                // and omitted the fabricated `/` that made up the
2497                // difference. Both are corrected here.
2498                assert_eq!(
2499                    metric.halstead.unique_operators(),
2500                    22,
2501                    "u_operators changed; check whether a #247 operator was dropped or an unrelated operator added (and update the comment / token list above accordingly)",
2502                );
2503            },
2504        );
2505    }
2506
2507    #[test]
2508    fn groovy_gstring_no_double_count() {
2509        // Issue #454: before the fix Groovy had no interpolation guard
2510        // at all — `StringLiteral` was classified as a plain operand, so
2511        // a GString counted the wrapping literal AND descended into its
2512        // interpolated expression, double-counting the inner identifier
2513        // in N2. The fix routes `StringLiteral` through
2514        // `string_operand_type` with both GString interpolation child
2515        // kinds (`gstring_brace_interpolation` / `gstring_dollar_-
2516        // interpolation`), so the wrapper is Unknown and only the inner
2517        // expression contributes.
2518        //
2519        // `def greet(name) {\n  return "Hi ${name}"\n}\n`
2520        //   operands by token text: `greet` × 1, `name` × 2 (param +
2521        //   inside `${name}`). The wrapping `"Hi ${name}"` is suppressed
2522        //   → u_operands = 2 (`greet`, `name`), N2 = 3. Without the fix
2523        //   the wrapping literal would also count → u_operands = 3,
2524        //   N2 = 4.
2525        let src = "def greet(name) {\n  return \"Hi ${name}\"\n}\n";
2526        check_metrics::<GroovyParser>(src, "foo.groovy", |metric| {
2527            assert_eq!(metric.halstead.unique_operands(), 2);
2528            assert_eq!(metric.halstead.total_operands(), 3);
2529        });
2530        assert_ops_operands::<GroovyParser>(src, "foo.groovy", 2, vec!["greet", "name"]);
2531    }
2532
2533    #[test]
2534    fn groovy_gstring_dollar_form_no_double_count() {
2535        // Issue #454: the short `$name` GString form emits a distinct
2536        // `gstring_dollar_interpolation` child whose inner `identifier`
2537        // text is `$name` (the grammar's identifier node spans the
2538        // leading `$`). The wrapper is suppressed; the inner `$name`
2539        // operand is distinct from the bare `name` param.
2540        //
2541        // `def greet(name) {\n  return "Hi $name"\n}\n`
2542        //   operands: `greet`, `name` (param), `$name` (interp) →
2543        //   u_operands = 3, N2 = 3. Without the fix the wrapping
2544        //   `"Hi $name"` would also count → u_operands = 4, N2 = 4.
2545        let src = "def greet(name) {\n  return \"Hi $name\"\n}\n";
2546        check_metrics::<GroovyParser>(src, "foo.groovy", |metric| {
2547            assert_eq!(metric.halstead.unique_operands(), 3);
2548            assert_eq!(metric.halstead.total_operands(), 3);
2549        });
2550        assert_ops_operands::<GroovyParser>(src, "foo.groovy", 3, vec!["greet", "name", "$name"]);
2551    }
2552
2553    #[test]
2554    fn groovy_plain_string_still_operand() {
2555        // Counterpart to `groovy_gstring_no_double_count`: a plain
2556        // non-interpolated literal has neither GString interpolation
2557        // child and must still contribute exactly one operand.
2558        //
2559        // `def f() {\n  return "plain"\n}\n`
2560        //   operands: `f`, `"plain"` → u_operands = 2, N2 = 2.
2561        let src = "def f() {\n  return \"plain\"\n}\n";
2562        check_metrics::<GroovyParser>(src, "foo.groovy", |metric| {
2563            assert_eq!(metric.halstead.unique_operands(), 2);
2564            assert_eq!(metric.halstead.total_operands(), 2);
2565        });
2566        assert_ops_operands::<GroovyParser>(src, "foo.groovy", 2, vec!["f", "\"plain\""]);
2567    }
2568
2569    #[test]
2570    fn groovy_slashy_string_delimiter_is_not_an_operator() {
2571        // Regression: issue #1314, the Groovy sibling of Elixir #1256
2572        // and Ruby/Perl #1312. A slashy string is a `StringLiteral`
2573        // whose closing delimiter is a `SLASH` — the kind id real
2574        // division uses — so `def b = /xyz/` reported a `/` operator
2575        // with no division in the source. Only the closer is a child
2576        // (the grammar folds the opening `/` into the literal's span),
2577        // so this fabricated one `/` per literal rather than Ruby's two.
2578        //
2579        // expected: operators `def` × 3, `=` × 3 → n1 = 2, N1 = 6.
2580        // Operands `b`, `/xyz/` × 2, `c`, `s` → n2 = 4, N2 = 6. Before
2581        // the guard the two closers added `/` → n1 = 3, N1 = 8.
2582        check_metrics::<GroovyParser>(
2583            "def b = /xyz/\ndef c = /xyz/\ndef s = b\n",
2584            "foo.groovy",
2585            |metric| {
2586                assert_eq!(metric.halstead.unique_operators(), 2);
2587                assert_eq!(metric.halstead.total_operators(), 6);
2588                assert_eq!(metric.halstead.unique_operands(), 4);
2589                assert_eq!(metric.halstead.total_operands(), 6);
2590            },
2591        );
2592    }
2593
2594    #[test]
2595    fn groovy_division_survives_the_slashy_guard() {
2596        // Control for #1314: the guard is scoped to a `StringLiteral`
2597        // parent, so real division must still count. Both sides are in
2598        // one fixture — two divisions and one slashy literal — so a
2599        // guard widened to every `SLASH` fails here rather than
2600        // silently passing the test above.
2601        //
2602        // expected: operators `def` × 2, `=` × 2, `/` × 2 → n1 = 3,
2603        // N1 = 6. Operands `q`, `a`, `b`, `c`, `r`, `/x/` → n2 = N2 = 6.
2604        check_metrics::<GroovyParser>("def q = a / b / c\ndef r = /x/\n", "foo.groovy", |metric| {
2605            assert_eq!(metric.halstead.unique_operators(), 3);
2606            assert_eq!(metric.halstead.total_operators(), 6);
2607            assert_eq!(metric.halstead.unique_operands(), 6);
2608            assert_eq!(metric.halstead.total_operands(), 6);
2609        });
2610    }
2611
2612    #[test]
2613    fn groovy_slashy_guard_is_parent_scoped_not_ancestor_scoped() {
2614        // The input that separates the parent-scoped guard from the
2615        // ancestor-scanning mutant of it — the mutant #1256's
2616        // post-mortem says survives every ordinary fixture. Groovy is
2617        // one of only two languages in #1314 where such an input
2618        // exists at all: a slashy string may carry a GString
2619        // interpolation, so `/x${a / b}y/` puts a real division under a
2620        // `StringLiteral` *ancestor* while its parent is the
2621        // `binary_expression`. An ancestor scan swallows it; the parent
2622        // check leaves it alone. (The JS, C++ and Tcl/iRules guards
2623        // have no such input — see
2624        // `js_regex_delimiter_guard_is_parent_scoped_is_unobservable`.)
2625        //
2626        // expected: operators `def` × 2, `=` × 2, `/` (the
2627        // interpolated division) → n1 = 3, N1 = 5. The wrapping literal
2628        // is not an operand — it carries an interpolation, so
2629        // `string_operand_type` yields `Unknown` and the inner
2630        // expression's operands carry the count (#454) — leaving `r`,
2631        // `a`, `b`, `s` → n2 = 4, with `a` twice → N2 = 5. Under the
2632        // ancestor-scoped mutant the division vanishes: n1 = 2, N1 = 4.
2633        check_metrics::<GroovyParser>(
2634            "def r = /x${a / b}y/\ndef s = a\n",
2635            "foo.groovy",
2636            |metric| {
2637                assert_eq!(metric.halstead.unique_operators(), 3);
2638                assert_eq!(metric.halstead.total_operators(), 5);
2639                assert_eq!(metric.halstead.unique_operands(), 4);
2640                assert_eq!(metric.halstead.total_operands(), 5);
2641            },
2642        );
2643    }
2644
2645    #[test]
2646    fn groovy_every_string_spelling_scores_alike() {
2647        // Companion to the three above (#1314). Groovy has five ways to
2648        // write an inert one-character string, and the choice is
2649        // spelling rather than semantics, so all five must score
2650        // identically. Before the guard the two slashy forms reported
2651        // an extra `/` operator that the other three did not — the
2652        // author's delimiter choice moved n1/N1.
2653        //
2654        // The dollar-slashy and quoted forms are no-change controls:
2655        // `$/…/$` closes with `/$` (kind 144) and `"…"` with `"` (134),
2656        // neither of which the operator arm classifies. `'x'` is a
2657        // childless leaf. The escaped-slash row is the one that would
2658        // regress if the guard were ever narrowed to a literal whose
2659        // *only* child is the closer.
2660        //
2661        // expected per spelling: operators `def` × 3, `=` × 3 → n1 = 2,
2662        // N1 = 6; operands `a`, the literal, `b`, `c` → n2 = 4, with
2663        // `a` used three times → N2 = 6.
2664        for literal in ["/x/", "$/x/$", "'x'", "\"x\"", r"/esc\/aped/"] {
2665            assert_halstead_counts::<GroovyParser>(
2666                &format!("def a = {literal}\ndef b = a\ndef c = a\n"),
2667                "foo.groovy",
2668                [2, 6, 4, 6],
2669                &format!("literal {literal}"),
2670            );
2671        }
2672    }
2673
2674    #[test]
2675    fn csharp_operators_and_operands() {
2676        // After issue #286, `void`, `string`, and `int` count as three
2677        // distinct Halstead operators rather than collapsing into one
2678        // `PredefinedType` kind_id entry, lifting u_operators from 13
2679        // to 15. Total operators (N1) is unchanged because the same
2680        // nodes are still counted, just keyed by lexeme.
2681        check_metrics::<CsharpParser>(
2682            "public class Main {
2683                public static void Run(string[] args) {
2684                    int a, b, c, avg;
2685                    a = 5; b = 5; c = 5;
2686                    avg = (a + b + c) / 3;
2687                    System.Console.WriteLine(\"{0}\", avg);
2688                }
2689            }",
2690            "foo.cs",
2691            |metric| {
2692                assert_eq!(metric.halstead.unique_operators(), 15);
2693                assert_eq!(metric.halstead.total_operators(), 32);
2694                assert_eq!(metric.halstead.unique_operands(), 13);
2695                assert_eq!(metric.halstead.total_operands(), 23);
2696                // Pin every Halstead field; values are whatever the
2697                // classifier produces and become the regression spec.
2698                insta::assert_json_snapshot!(metric.halstead);
2699            },
2700        );
2701    }
2702
2703    // Issue #1263: C#'s three name *containers* — `qualified_name`
2704    // (`System.Text`), `generic_name` (`List<int>`) and
2705    // `alias_qualified_name` (`global::Foo`) — were operands alongside
2706    // every leaf the walker already reached, so one occurrence of each
2707    // billed twice. `member_access_expression` never was, which is why
2708    // `csharp_operators_and_operands`' `System.Console.WriteLine` is
2709    // unaffected by this change: the bug lived in the *name* grammar,
2710    // not in member access.
2711    //
2712    // Three fixtures rather than one, so a regression names which
2713    // container came back. Each is hand-tallied; the removed composite
2714    // is called out per case.
2715    #[test]
2716    fn csharp_name_containers_count_leaves_not_the_composite_1263() {
2717        // expected: operators `using`, `.`, `;` (3/3); operands
2718        // `System`, `Text` (2/2). Pre-fix the `qualified_name` added
2719        // `System.Text`, making the operand counts 3/3.
2720        check_metrics::<CsharpParser>("using System.Text;", "foo.cs", |metric| {
2721            assert_eq!(metric.halstead.unique_operators(), 3);
2722            assert_eq!(metric.halstead.total_operators(), 3);
2723            assert_eq!(metric.halstead.unique_operands(), 2);
2724            assert_eq!(metric.halstead.total_operands(), 2);
2725        });
2726
2727        // expected: operators `class`, `{`×2, `void`, `(`, `;`, `<`,
2728        // `>`, `int` — 9 total, 8 unique (`int` is the text-keyed
2729        // primitive operator, per #286). Operands `C`, `M`, `List`, `l`
2730        // — 4/4. Pre-fix the `generic_name` added `List<int>`, making
2731        // them 5/5.
2732        check_metrics::<CsharpParser>(
2733            "class C { void M() { List<int> l; } }",
2734            "foo.cs",
2735            |metric| {
2736                assert_eq!(metric.halstead.unique_operators(), 8);
2737                assert_eq!(metric.halstead.total_operators(), 9);
2738                assert_eq!(metric.halstead.unique_operands(), 4);
2739                assert_eq!(metric.halstead.total_operands(), 4);
2740            },
2741        );
2742
2743        // expected: operators `class`, `{`×2, `void`, `(`, `;`, `=`,
2744        // `::`, `.` — 9 total, 8 unique. `var` has no operator arm.
2745        // Operands `C`, `M`, `x`, `global`, `Foo`, `Bar` — 6/6. Pre-fix
2746        // the `alias_qualified_name` added `global::Foo`, making them
2747        // 7/7. The `::` staying an operator is what makes the leaf-only
2748        // tally lossless here, so it is asserted by the operator count
2749        // rather than assumed.
2750        check_metrics::<CsharpParser>(
2751            "class C { void M() { var x = global::Foo.Bar; } }",
2752            "foo.cs",
2753            |metric| {
2754                assert_eq!(metric.halstead.unique_operators(), 8);
2755                assert_eq!(metric.halstead.total_operators(), 9);
2756                assert_eq!(metric.halstead.unique_operands(), 6);
2757                assert_eq!(metric.halstead.total_operands(), 6);
2758            },
2759        );
2760    }
2761
2762    #[test]
2763    fn csharp_primitive_types_and_booleans() {
2764        // After issue #286: each of `byte`, `short`, `int`, `long`,
2765        // `char`, `float`, `double`, `bool`, `object` is now a distinct
2766        // Halstead operator (9 primitives) rather than collapsing into
2767        // one `PredefinedType` kind_id entry. u_operators rises from 6
2768        // to 14 (5 non-primitive operators + 9 distinct primitives);
2769        // total operators (N1) is unchanged because the same nodes are
2770        // still counted, just keyed by lexeme.
2771        //
2772        // N2 dropped 23 → 21 with issue #1253: `true` and `false` each
2773        // reached the walker twice — once as `boolean_literal`, once as
2774        // the keyword leaf under it — so each added one spurious
2775        // occurrence. n2 is unchanged at 21 because operands are keyed
2776        // by source text, so the duplicate collapsed into the existing
2777        // vocabulary entry; that is exactly why the inflation was
2778        // invisible in n2. Every operand here is distinct, so
2779        // N2 == n2 == 21 after the fix.
2780        check_metrics::<CsharpParser>(
2781            "public class Prims {
2782                byte a = 1;
2783                short b = 2;
2784                int c = 3;
2785                long d = 4;
2786                char e = 'x';
2787                float f = 1.0f;
2788                double g = 2.0;
2789                bool h = true;
2790                bool i = false;
2791                object j = null;
2792            }",
2793            "foo.cs",
2794            |metric| {
2795                assert_eq!(metric.halstead.unique_operators(), 14);
2796                assert_eq!(metric.halstead.total_operators(), 33);
2797                assert_eq!(metric.halstead.unique_operands(), 21);
2798                assert_eq!(metric.halstead.total_operands(), 21);
2799                insta::assert_json_snapshot!(metric.halstead);
2800            },
2801        );
2802    }
2803
2804    #[test]
2805    fn csharp_boolean_literal_counts_once() {
2806        // Regression: issue #1253. `boolean_literal: choice('true',
2807        // 'false')` wraps the keyword leaf, and both kinds sat in the
2808        // operand arm, so every `true` / `false` occurrence added +1 to
2809        // N2. Operands are keyed by source text, so the duplicate
2810        // collapsed into the same vocabulary entry and n2 stayed
2811        // correct — which is why nothing caught it.
2812        //
2813        // Source repeats `true` so N2 exceeds n2 and the assertions can
2814        // tell "counted once per occurrence" from "deduplicated into
2815        // the vocabulary".
2816        //
2817        // Operands by text key: `A`, `M`, `a`, `b`, `c`, `d`, `true` × 2,
2818        // `false`, `null` ⇒ n2 = 9, N2 = 10. Before the fix the keyword
2819        // leaves added one occurrence per boolean ⇒ N2 = 13.
2820        check_metrics::<CsharpParser>(
2821            "class A {\n    void M() {\n        bool a = true;\n        bool b = false;\n        bool c = true;\n        object d = null;\n    }\n}\n",
2822            "foo.cs",
2823            |metric| {
2824                assert_eq!(metric.halstead.unique_operands(), 9);
2825                assert_eq!(metric.halstead.total_operands(), 10);
2826            },
2827        );
2828    }
2829
2830    #[test]
2831    fn csharp_boolean_keyword_outside_a_literal_still_counts() {
2832        // Companion to the test above (#1253): the suppression fires on
2833        // the *parent* kind, never on `True` / `False` alone. C#'s
2834        // overloadable-operator list emits a bare `true` / `false` token
2835        // with no `boolean_literal` wrapper — `operator_declaration` is
2836        // the grammar's only such position — so a blanket exclusion
2837        // would drop the operand that is the sole difference between
2838        // `operator true` and `operator false`, leaving two such
2839        // declarations with identical Halstead vocabularies whenever
2840        // their bodies match.
2841        //
2842        // Each declaration names one boolean and returns the other, so
2843        // the fixture exercises both the guarded and the unguarded
2844        // position for each keyword.
2845        //
2846        // Operands: `A` × 3 (class name, two parameter types), `a` × 2,
2847        // `true` × 2 (operator name + literal), `false` × 2 (likewise)
2848        // ⇒ n2 = 4, N2 = 9. A blanket exclusion gives N2 = 7; no guard
2849        // at all restores the double count at N2 = 11.
2850        check_metrics::<CsharpParser>(
2851            "class A {\n    public static bool operator true(A a) => false;\n    public static bool operator false(A a) => true;\n}\n",
2852            "foo.cs",
2853            |metric| {
2854                assert_eq!(metric.halstead.unique_operands(), 4);
2855                assert_eq!(metric.halstead.total_operands(), 9);
2856            },
2857        );
2858    }
2859
2860    #[test]
2861    fn csharp_predefined_types_keyed_by_lexeme() {
2862        // Regression: issue #286. The C# grammar emits one `PredefinedType`
2863        // kind_id for every keyword type (`int`, `string`, `bool`, …).
2864        // Without keying by source text the entire family collapses into
2865        // a single Halstead operator (n1 += 1) instead of one per distinct
2866        // keyword. This test pins the post-fix behaviour using four
2867        // distinct primitives — `int`, `string`, `bool`, `object` —
2868        // appearing as parameter types so no other operators interact
2869        // with the count.
2870        //
2871        // expected: operators are `class`, `void`, `M`, `{}`, `()`, `,`
2872        // (×3 between 4 params), plus the four distinct predefined types
2873        // → u_operators = 5 + 4 = 9. Without the fix the four primitives
2874        // collapse to one entry, giving u_operators = 6.
2875        check_metrics::<CsharpParser>(
2876            "class C { void M(int a, string b, bool c, object d) {} }",
2877            "foo.cs",
2878            |metric| {
2879                // The headline assertion: four distinct primitive
2880                // keywords contribute four distinct operators, not one.
2881                assert_eq!(metric.halstead.unique_operators(), 9);
2882            },
2883        );
2884    }
2885
2886    #[test]
2887    fn csharp_interpolated_string_no_double_count() {
2888        // Regression: issue #183. A C# `$"Hi {name}!"` used to be
2889        // classified as a Halstead operand (the wrapping
2890        // `InterpolatedStringExpression`) AND have its inner
2891        // `Interpolation`'s identifier classified as an operand too.
2892        // The fix routes `InterpolatedStringExpression` through a
2893        // conditional: when it has an `Interpolation` child, the inner
2894        // identifier already carries the operand contribution and the
2895        // wrapper is treated as `Unknown`; when it does not (static
2896        // `$"hello"`), the wrapper still counts as one operand.
2897        //
2898        // expected: operand contributions for
2899        //   `class C { void M(string name) { string s = $"Hi {name}!"; } }`
2900        // — `C` (class), `M` (method), `name` (param), `s` (local),
2901        // and the inner `name` (inside `{...}`). With the fix,
2902        // u_operands = 4 (C, M, name, s); N2 = 5 (`name` twice).
2903        // Without the fix, the wrapping `$"Hi {name}!"` would also
2904        // count → u_operands = 5, N2 = 6.
2905        check_metrics::<CsharpParser>(
2906            "class C { void M(string name) { string s = $\"Hi {name}!\"; } }",
2907            "foo.cs",
2908            |metric| {
2909                assert_eq!(metric.halstead.unique_operands(), 4);
2910                assert_eq!(metric.halstead.total_operands(), 5);
2911            },
2912        );
2913    }
2914
2915    #[test]
2916    fn csharp_static_interpolated_string_is_operand() {
2917        // Regression: issue #183. A `$"..."` with no `{...}` is
2918        // semantically identical to `"..."` and must still contribute
2919        // exactly one operand — the conditional `is_child(Interpolation)`
2920        // check distinguishes it from a true interpolation. expected:
2921        // operands are `C`, `M`, `s`, `$"hello"` → u_operands = 4, N2 = 4.
2922        // A naive "always Unknown" fix would yield u_operands = 3, N2 = 3,
2923        // diverging from the plain-string equivalent below.
2924        check_metrics::<CsharpParser>(
2925            "class C { void M() { string s = $\"hello\"; } }",
2926            "foo.cs",
2927            |metric| {
2928                assert_eq!(metric.halstead.unique_operands(), 4);
2929                assert_eq!(metric.halstead.total_operands(), 4);
2930            },
2931        );
2932    }
2933
2934    #[test]
2935    fn csharp_plain_string_still_operand() {
2936        // The fix for #183 only changes how `InterpolatedStringExpression`
2937        // is classified; plain `StringLiteral` (and `VerbatimStringLiteral`
2938        // / `RawStringLiteral`) must still contribute exactly one operand
2939        // each. expected: operands are `C`, `M`, `s`, `"hi"` →
2940        // u_operands = 4, N2 = 4.
2941        check_metrics::<CsharpParser>(
2942            "class C { void M() { string s = \"hi\"; } }",
2943            "foo.cs",
2944            |metric| {
2945                assert_eq!(metric.halstead.unique_operands(), 4);
2946                assert_eq!(metric.halstead.total_operands(), 4);
2947            },
2948        );
2949    }
2950
2951    #[test]
2952    fn go_operators_and_operands() {
2953        check_metrics::<GoParser>(
2954            "package main
2955            func sum(a, b int) int {
2956                return a + b
2957            }",
2958            "foo.go",
2959            |metric| {
2960                insta::assert_json_snapshot!(
2961                    metric.halstead,
2962                    @r#"
2963                {
2964                  "unique_operators": 7,
2965                  "total_operators": 7,
2966                  "unique_operands": 5,
2967                  "total_operands": 8,
2968                  "length": 15,
2969                  "estimated_program_length": 31.26112492884004,
2970                  "purity_ratio": 2.0840749952560027,
2971                  "vocabulary": 12,
2972                  "volume": 53.77443751081734,
2973                  "difficulty": 5.6,
2974                  "level": 0.17857142857142858,
2975                  "effort": 301.1368500605771,
2976                  "time": 16.729825003365395,
2977                  "bugs": 0.014975730436275946
2978                }
2979                "#
2980                );
2981            },
2982        );
2983    }
2984
2985    #[test]
2986    fn perl_operators_and_operands() {
2987        check_metrics::<PerlParser>(
2988            "sub sum {
2989                my ($a, $b) = @_;
2990                return $a + $b;
2991            }",
2992            "foo.pl",
2993            |metric| {
2994                insta::assert_json_snapshot!(
2995                    metric.halstead,
2996                    @r#"
2997                {
2998                  "unique_operators": 10,
2999                  "total_operators": 14,
3000                  "unique_operands": 4,
3001                  "total_operands": 6,
3002                  "length": 20,
3003                  "estimated_program_length": 41.219280948873624,
3004                  "purity_ratio": 2.0609640474436812,
3005                  "vocabulary": 14,
3006                  "volume": 76.14709844115208,
3007                  "difficulty": 7.5,
3008                  "level": 0.13333333333333333,
3009                  "effort": 571.1032383086406,
3010                  "time": 31.727957683813365,
3011                  "bugs": 0.02294502281013948
3012                }
3013                "#
3014                );
3015            },
3016        );
3017    }
3018
3019    #[test]
3020    fn perl_interpolated_string_no_double_count() {
3021        // Regression: issue #199. A `string_double_quoted` (and
3022        // `string_qq_quoted` / `backtick_quoted` / `command_qx_quoted`)
3023        // wrapping an `interpolation` child used to be counted as a
3024        // Halstead operand while the inner scalar/array/hash variable
3025        // was also walked and counted — double-counting the inner
3026        // variable's contribution to `N2`. Mirrors #180 (Bash/Elixir),
3027        // #183 (C#), #184 (PHP), #191 (Kotlin).
3028        //
3029        // expected: for
3030        //   sub greet { my $name = shift; my $msg = "Hi $name"; return $msg; }
3031        // — operands are `greet`, `$name`, `shift`, `$msg`. With the
3032        // fix the wrapping `"Hi $name"` is skipped (has `Interpolation`
3033        // child), so u_operands = 4 and N2 = 6 (`$name` x2 from the
3034        // `my` binding and the interpolation; `$msg` x2 from the `my`
3035        // binding and `return`; `greet`, `shift` once each). Without
3036        // the fix the wrapping literal would also be counted, lifting
3037        // u_operands to 5 and N2 to 7.
3038        check_metrics::<PerlParser>(
3039            "sub greet { my $name = shift; my $msg = \"Hi $name\"; return $msg; }",
3040            "foo.pl",
3041            |metric| {
3042                assert_eq!(metric.halstead.unique_operands(), 4);
3043                assert_eq!(metric.halstead.total_operands(), 6);
3044                insta::assert_json_snapshot!(metric.halstead);
3045            },
3046        );
3047    }
3048
3049    #[test]
3050    fn perl_plain_string_still_operand() {
3051        // The fix for #199 only skips wrapping literals that carry an
3052        // `Interpolation` child; a plain `"hello"` (no `$…` inside)
3053        // must still contribute exactly one operand. expected: operands
3054        // `greet`, `$msg`, `"hello"` → u_operands = 3, N2 = 4 (`$msg`
3055        // appears in the `my` binding and the `return`).
3056        check_metrics::<PerlParser>(
3057            "sub greet { my $msg = \"hello\"; return $msg; }",
3058            "foo.pl",
3059            |metric| {
3060                assert_eq!(metric.halstead.unique_operands(), 3);
3061                assert_eq!(metric.halstead.total_operands(), 4);
3062            },
3063        );
3064    }
3065
3066    #[test]
3067    fn perl_single_quoted_string_never_interpolates() {
3068        // Single-quoted (`'…'`) and `q{…}` literals are not subject to
3069        // interpolation in Perl, so even when their text contains a
3070        // `$name`-shaped sequence the wrapper is still counted as one
3071        // operand and the inner text is not parsed as a variable.
3072        // expected: operands `greet`, `$msg`, `'Hi $name'` →
3073        // u_operands = 3, N2 = 4 (`$msg` x2).
3074        check_metrics::<PerlParser>(
3075            "sub greet { my $msg = 'Hi $name'; return $msg; }",
3076            "foo.pl",
3077            |metric| {
3078                assert_eq!(metric.halstead.unique_operands(), 3);
3079                assert_eq!(metric.halstead.total_operands(), 4);
3080            },
3081        );
3082    }
3083
3084    #[test]
3085    fn perl_plain_heredoc_counts_as_one_operand() {
3086        // Regression: issue #287. A plain (non-interpolating) Perl
3087        // heredoc body used to be classified `HalsteadType::Unknown`,
3088        // so its visible `HeredocBodyStatement` node contributed
3089        // nothing to N2 even though it is a string literal. The fix
3090        // adds `HeredocBodyStatement` to the interpolation-aware
3091        // operand arm, so an inert heredoc counts as one operand.
3092        //
3093        // Source (heredoc body lives at the source_file level, not
3094        // inside any sub):
3095        //   my $msg = <<END;
3096        //   hello world
3097        //   END
3098        //
3099        // Operands traversed:
3100        //   * `$msg` (`scalar_variable`)                    × 1
3101        //   * heredoc body (`heredoc_body_statement`)       × 1
3102        // expected: u_operands = 2, N2 = 2.
3103        check_metrics::<PerlParser>("my $msg = <<END;\nhello world\nEND\n", "foo.pl", |metric| {
3104            assert_eq!(metric.halstead.unique_operands(), 2);
3105            assert_eq!(metric.halstead.total_operands(), 2);
3106        });
3107    }
3108
3109    #[test]
3110    fn perl_interpolated_heredoc_no_double_count() {
3111        // Regression: issue #287. An interpolating Perl heredoc
3112        // (`<<"TAG"` or bare `<<TAG`) carries an `Interpolation` child
3113        // when its body contains a `$var`. The wrapper must drop to
3114        // `Unknown` so the inner scalar variable carries the operand
3115        // count — same dispatch as the existing double-quoted /
3116        // backtick / qx wrappers (issue #199) and the PHP heredoc fix
3117        // (issue #184).
3118        //
3119        // Source:
3120        //   my $name = "x";
3121        //   my $msg = <<"END";
3122        //   hi $name
3123        //   END
3124        //
3125        // Operands by text key:
3126        //   * `$name` × 2 (my-binding + interpolation inside heredoc)
3127        //   * `"x"`  × 1 (inert double-quoted string)
3128        //   * `$msg` × 1
3129        // expected: u_operands = 3, N2 = 4. Without the
3130        // interpolation-aware drop the wrapping heredoc body would
3131        // also count, lifting u_operands to 4 and N2 to 5.
3132        check_metrics::<PerlParser>(
3133            "my $name = \"x\";\nmy $msg = <<\"END\";\nhi $name\nEND\n",
3134            "foo.pl",
3135            |metric| {
3136                assert_eq!(metric.halstead.unique_operands(), 3);
3137                assert_eq!(metric.halstead.total_operands(), 4);
3138            },
3139        );
3140    }
3141
3142    #[test]
3143    fn perl_bare_pattern_delimiters_are_not_operators() {
3144        // Regression: issue #1312, the Perl sibling of Elixir #1256.
3145        // The bare match form is the only one of Perl's regex literals
3146        // whose delimiters are spelled with an operator token kind —
3147        // `bca dump` shows `/abc/` emitting two `SLASH` under
3148        // `PatternMatcher` — so `$s =~ /abc/;` reported a `/` operator
3149        // with no division in the source.
3150        //
3151        // expected: operators `$` (the `scalar_variable` sigil), `=~`
3152        // and `;` → n1 = N1 = 3. Operands `$s` and the `/abc/` pattern
3153        // → n2 = N2 = 2. Before the guard the two delimiters added `/`
3154        // → n1 = 4, N1 = 5; the pattern operand arrived with #1314,
3155        // which promoted all three pattern spellings together (see
3156        // `perl_every_pattern_value_spelling_scores_alike`).
3157        check_metrics::<PerlParser>("$s =~ /abc/;\n", "foo.pl", |metric| {
3158            assert_eq!(metric.halstead.unique_operators(), 3);
3159            assert_eq!(metric.halstead.total_operators(), 3);
3160            assert_eq!(metric.halstead.unique_operands(), 2);
3161            assert_eq!(metric.halstead.total_operands(), 2);
3162        });
3163    }
3164
3165    #[test]
3166    fn perl_every_pattern_value_spelling_scores_alike() {
3167        // Companion to the test above (#1312, extended by #1314).
3168        // `m/abc/` is exactly `/abc/` in Perl and `qr/abc/` is the same
3169        // pattern as a value, so the three spellings of a pattern
3170        // *value* must score identically whatever delimiters they use.
3171        //
3172        // Until #1314 they scored alike at *zero*: no Perl pattern
3173        // wrapper was in the operand arm, so the literal never counted,
3174        // unlike Ruby's `Regex` and Elixir's `Sigil`. #1312 declined to
3175        // promote the bare form on its own precisely because that would
3176        // have scored `/abc/` at one operand and its synonyms at zero,
3177        // reintroducing the spelling sensitivity this test pins.
3178        // Promoting all three together closes the gap and keeps the
3179        // equality, which is what this test now asserts.
3180        //
3181        // `s///` and `tr///` are deliberately *not* rows here any more.
3182        // They are operations applied to a target rather than pattern
3183        // values, so #1314 made them operators; their own equality is
3184        // pinned by `perl_every_pattern_operation_spelling_scores_alike`
3185        // below. Splitting the one loop in two is the substantive
3186        // disagreement #1314 had with the reasoning recorded here: this
3187        // test governs *synonyms*, and `s///` is not a synonym of
3188        // `/abc/`.
3189        //
3190        // The fixture matches twice against one variable so that no two
3191        // of `[n1, N1, n2, N2]` are equal. A square tuple would leave
3192        // `assert_halstead_counts`' unique-vs-total axes unpinned —
3193        // transposing n1 with N1 inside the helper failed no test while
3194        // all three of its callers expected a square tuple.
3195        //
3196        // expected per variant: operators `$` × 2 (one per
3197        // `scalar_variable`), `=~` × 2, `and`, `;` → n1 = 4, N1 = 6.
3198        // The pattern contributes no *operator* — that is #1312's half
3199        // — and one operand, so with `$s` twice and the pattern twice
3200        // → n2 = 2, N2 = 4.
3201        for pattern in ["/abc/", "m/abc/", "m{abc}", "qr/abc/"] {
3202            assert_halstead_counts::<PerlParser>(
3203                &format!("$s =~ {pattern} and $s =~ {pattern};\n"),
3204                "foo.pl",
3205                [4, 6, 2, 4],
3206                &format!("pattern {pattern}"),
3207            );
3208        }
3209    }
3210
3211    #[test]
3212    fn perl_every_pattern_operation_spelling_scores_alike() {
3213        // The other half of the split (#1314). Substitution and
3214        // transliteration are operations applied to a target, so they
3215        // are operators — and like the value spellings, their delimiter
3216        // choice must not move the count. `y///` is a synonym of
3217        // `tr///` and shares `TransliterationTrOrY`, so the two fold to
3218        // one operator entry, which is why all four rows agree on n1.
3219        //
3220        // expected per variant: operators `$` × 2, `=~` × 2, `and`,
3221        // `;`, and the operation itself × 2 → n1 = 5, N1 = 8. The
3222        // pattern and replacement text is invisible to this grammar —
3223        // `substitution_pattern_s` emits only its keyword and
3224        // delimiters, no content node — so the sole operand is `$s`,
3225        // twice → n2 = 1, N2 = 2.
3226        for pattern in ["s/a/b/", "s{a}{b}", "tr/a/b/", "y/a/b/"] {
3227            assert_halstead_counts::<PerlParser>(
3228                &format!("$s =~ {pattern} and $s =~ {pattern};\n"),
3229                "foo.pl",
3230                [5, 8, 1, 2],
3231                &format!("pattern {pattern}"),
3232            );
3233        }
3234    }
3235
3236    #[test]
3237    fn perl_interpolated_pattern_operands_agree_but_operators_do_not() {
3238        // Two things at once (#1314), because they are the same
3239        // measurement: why the three pattern-value spellings route
3240        // through `string_operand_type` rather than a plain operand
3241        // arm, and what that routing does *not* fix.
3242        //
3243        // `m/$x/` and `qr/$x/` emit a real `Interpolation` wrapping a
3244        // `scalar_variable`, while the bare form keeps its `$x` inside
3245        // an unclassified `regex_pattern_content`. So:
3246        //
3247        // * Operands agree at n2 = N2 = 2 (`$s` plus one contribution
3248        //   from the pattern) only because of the interpolation guard.
3249        //   A plain operand arm would count the wrapper *and* the inner
3250        //   `$x` for the suffixed forms — n2 = 3 — reintroducing
3251        //   through the back door the divergence
3252        //   `perl_every_pattern_value_spelling_scores_alike` exists to
3253        //   prevent. Which node carries the one operand still differs
3254        //   by spelling: the wrapper for the bare form, the inner `$x`
3255        //   for the other two.
3256        // * Operators do *not* agree: the exposed `scalar_variable`
3257        //   brings a `$` sigil, an operator here, that the bare form
3258        //   has no node for. Over two matches N1 is 6 for `/$x/` and
3259        //   8 for the other two.
3260        //
3261        // The operator asymmetry is a grammar gap this classifier
3262        // cannot repair — there is nothing to classify in the bare
3263        // form — so it is pinned rather than papered over, the same
3264        // treatment `perl_division_emits_no_slash_token` gives the
3265        // missing division token. A bump that starts exposing the bare
3266        // form's interpolation turns this red, at which point the
3267        // expectations above need re-deriving.
3268        // Each fixture matches twice, so `N1 > n1` and `N2 > n2` and no
3269        // row is a square tuple that a transposition inside
3270        // `assert_halstead_counts` could pass (#1312).
3271        assert_halstead_counts::<PerlParser>(
3272            "$s =~ /$x/ and $s =~ /$x/;\n",
3273            "foo.pl",
3274            [4, 6, 2, 4],
3275            "bare /$x/",
3276        );
3277        for pattern in ["m/$x/", "qr/$x/"] {
3278            assert_halstead_counts::<PerlParser>(
3279                &format!("$s =~ {pattern} and $s =~ {pattern};\n"),
3280                "foo.pl",
3281                [4, 8, 2, 4],
3282                &format!("suffixed {pattern}"),
3283            );
3284        }
3285    }
3286
3287    #[test]
3288    fn perl_division_emits_no_slash_token() {
3289        // Drift marker, not an endorsement. Ruby's counterpart
3290        // (`ruby_division_survives_the_regex_guard`) proves #1312's
3291        // guard cannot swallow a real division; Perl has no such
3292        // fixture to write, because at the pinned grammar `$a / $b`
3293        // emits *no* `SLASH` token at all — `binary_expression`'s
3294        // children skip straight from one `scalar_variable` to the
3295        // other. Perl division therefore counts zero operators today,
3296        // a pre-existing grammar gap this fix neither causes nor
3297        // repairs.
3298        //
3299        // Pinning it keeps the gap in CI: a bump that starts emitting
3300        // the token turns this red, at which point the division would
3301        // begin counting (its parent is `BinaryExpression`, not
3302        // `PatternMatcher`, so the guard leaves it alone) and the
3303        // expectations above need re-deriving.
3304        //
3305        // The same gap is why no Perl test can distinguish the
3306        // parent-scoped guard from an ancestor-scoped one: with no
3307        // `SLASH` reachable below a `PatternMatcher`, that mutant is
3308        // unobservable here — measured, not assumed. Perl's guard is
3309        // parent-scoped for correctness by construction and for
3310        // symmetry with Ruby's, where the distinction *is* observable
3311        // and is pinned by
3312        // `ruby_regex_guard_is_parent_scoped_not_ancestor_scoped`.
3313        let path = PathBuf::from("foo.pl");
3314        let source = "my $z = $a / $b;\n";
3315        let parser = PerlParser::new(source.as_bytes().to_vec(), &path, None);
3316        assert!(
3317            !ast_has_kind_id(&parser, Perl::SLASH as u16),
3318            "tree-sitter-perl still emits no SLASH for `{source}`"
3319        );
3320        // Anchor the negative assertion to *this* fixture. Without it
3321        // the test stays green when `source` is edited to something
3322        // containing no division at all — measured: swapping in
3323        // `my $z = 1;` failed nothing.
3324        //
3325        // expected: operators `my`, `=`, `$` × 3 (one per
3326        // `scalar_variable`), `;` → n1 = 4, N1 = 6, with no `/` among
3327        // them. Operands `$z`, `$a`, `$b` → n2 = N2 = 3.
3328        check_metrics::<PerlParser>(source, "foo.pl", |metric| {
3329            assert_eq!(metric.halstead.unique_operators(), 4);
3330            assert_eq!(metric.halstead.total_operators(), 6);
3331            assert_eq!(metric.halstead.unique_operands(), 3);
3332            assert_eq!(metric.halstead.total_operands(), 3);
3333        });
3334        // Positive control: the same kind *is* reachable in this
3335        // grammar, so the assertion above is about division and not
3336        // about `Perl::SLASH` being enum-only dead weight.
3337        let matcher = PerlParser::new(b"$s =~ /abc/;\n".to_vec(), &path, None);
3338        assert!(
3339            ast_has_kind_id(&matcher, Perl::SLASH as u16),
3340            "Perl::SLASH must be the bare pattern delimiter kind"
3341        );
3342    }
3343
3344    /// Every (name wrapper, contained operand) pairing tree-sitter-perl's
3345    /// node-types.json admits, and the single source of truth for both
3346    /// halves of #1355's guard: its parent set is the distinct first
3347    /// components, the kinds it subsumes the distinct second ones.
3348    /// `perl_name_wrappers_bill_the_name_once_1355` witnesses every row
3349    /// and fails on an eleventh pairing.
3350    const PERL_NAME_WRAPPER_PAIRINGS: [(Perl, Perl); 10] = [
3351        (Perl::PackageName, Perl::Identifier),
3352        (Perl::PackageName, Perl::ScalarVariable),
3353        (Perl::PackageName, Perl::ArrayVariable),
3354        (Perl::PackageName, Perl::HashVariable),
3355        (Perl::PackageName, Perl::SpecialScalarVariable),
3356        (Perl::PackageName, Perl::Typeglob),
3357        (Perl::PackageName, Perl::PackageVariable),
3358        (Perl::PackageVariable, Perl::PackageName),
3359        (Perl::PackageVariable, Perl::ScalarVariable),
3360        (Perl::Typeglob, Perl::Identifier),
3361    ];
3362
3363    /// The anonymous tokens those wrappers also hold. `::` and `*` are
3364    /// operators (matched above the guard, so they keep that reading);
3365    /// `{` folds into the `{}` glyph and `}` has never been classified
3366    /// at all. Listing them is what lets
3367    /// `perl_name_wrappers_bill_the_name_once_1355` police *every*
3368    /// child rather than only the named ones — the operand arm carries
3369    /// token-shaped kinds too (`True`, `FILE`, `SUB`, …), and a bump
3370    /// that let one of those inside a wrapper would otherwise be
3371    /// silenced with nothing failing.
3372    const PERL_NAME_WRAPPER_TOKENS: [Perl; 4] =
3373        [Perl::COLONCOLON, Perl::STAR, Perl::LBRACE, Perl::RBRACE];
3374
3375    /// The occurrences #1355's guard suppresses in `source`, paired
3376    /// with the (wrapper kind, child kind) pairings they witness.
3377    ///
3378    /// Walks with `for_each_node_with_chain`, which maintains the
3379    /// ancestor chain exactly as `spaces::compute` does, so "parent"
3380    /// here means what `Ancestors::parent` means inside the guard
3381    /// rather than what a differently-built chain would say.
3382    fn perl_subsumed_operands(source: &str) -> (Vec<String>, HashSet<(u16, u16)>) {
3383        let wrappers: HashSet<u16> = PERL_NAME_WRAPPER_PAIRINGS
3384            .map(|(wrapper, _)| wrapper as u16)
3385            .into();
3386        let subsumed: HashSet<u16> = PERL_NAME_WRAPPER_PAIRINGS
3387            .map(|(_, child)| child as u16)
3388            .into();
3389        let tokens: HashSet<u16> = PERL_NAME_WRAPPER_TOKENS.map(|kind| kind as u16).into();
3390        let code = source.as_bytes();
3391        let mut hidden = Vec::new();
3392        let mut pairings = HashSet::new();
3393        for_each_node_with_chain::<PerlCode>(code, |node, chain| {
3394            let Some(parent) = chain.last() else { return };
3395            if !wrappers.contains(&parent.kind_id()) {
3396                return;
3397            }
3398            assert!(
3399                subsumed.contains(&node.kind_id()) || tokens.contains(&node.kind_id()),
3400                "`{source}`: a `{}` inside a `{}` is a child this guard was not \
3401                 derived against; re-read node-types.json before trusting it",
3402                node.kind(),
3403                parent.kind(),
3404            );
3405            if subsumed.contains(&node.kind_id()) {
3406                pairings.insert((parent.kind_id(), node.kind_id()));
3407                hidden.push(
3408                    node.utf8_text(code)
3409                        .expect("fixture is valid UTF-8")
3410                        .to_owned(),
3411                );
3412            }
3413        });
3414        (hidden, pairings)
3415    }
3416
3417    /// One row of `perl_name_wrappers_bill_the_name_once_1355`'s
3418    /// table: a fixture, what it must measure now, what it measured
3419    /// before #1355, and the operand text behind the counts.
3420    struct PerlNameWrapperCase {
3421        source: &'static str,
3422        /// `[n1, N1, n2, N2]` with the guard in place.
3423        counts: [u64; 4],
3424        /// `[n2, N2]` without it. Re-derived by the loop rather than
3425        /// trusted, so a stale row fails instead of misinforming.
3426        before: [u64; 2],
3427        operands: &'static [&'static str],
3428    }
3429
3430    /// Regression for #1355. `package_name`, `package_variable` and
3431    /// `typeglob` are operands spanning a whole name, so every
3432    /// operand-classified node *inside* one was billed a second time:
3433    /// `use strict;` scored `N2` 2 for one name, `our $Foo::count = 3;`
3434    /// n2 5 / N2 6 for two, and the vocabulary grew a bare `::` entry
3435    /// because a `package_variable`'s qualifier slot is itself a
3436    /// childless `package_name`.
3437    ///
3438    /// Each row's `before` column is what it measured without the
3439    /// guard, and the loop re-derives both halves from the current
3440    /// parse rather than trusting the column — the guard is the only
3441    /// difference between the two classifications, so the occurrences
3442    /// it removes are exactly the subsumed-kind children of a wrapper:
3443    ///
3444    /// - `N2` before minus `N2` after must equal how many of those
3445    ///   there are. That identity *is* the defect: one spurious operand
3446    ///   per contained name part.
3447    /// - `n2` before is the post-fix vocabulary unioned with their
3448    ///   texts. It exceeds `n2` after wherever a part's spelling is not
3449    ///   already an operand on its own (`Data`, `::`, `count`).
3450    ///
3451    /// The walk doubles as the grammar-dispatch §1 / §2 drift marker.
3452    /// It asserts that every *named* child of a wrapper is one of the
3453    /// eight subsumed kinds — which is what makes keying the arm on the
3454    /// parent alone safe — and that all ten pairings node-types.json
3455    /// admits are exercised here, so a bump that renumbers or re-parents
3456    /// one fails loudly instead of leaving the counts below measuring a
3457    /// construct the arm no longer reaches.
3458    ///
3459    /// Two mutants this does *not* catch, measured rather than assumed.
3460    /// Widening the guard from parent- to ancestor-scoped fails nothing,
3461    /// for the reason `perl_division_emits_no_slash_token` already
3462    /// records about the other guard in this getter: every operand-kinded
3463    /// descendant of a wrapper is also a direct child of one, and the
3464    /// intervening sigil tokens are matched by the operator arm above
3465    /// before the guard is reached. Parent-scoping stands on
3466    /// grammar-dispatch §5 and on symmetry with that guard, not on a
3467    /// test. What *is* pinned is the arm's position: moving it above the
3468    /// operator arm swallows `::`, `*` and the typeglob's opening brace,
3469    /// and the operator columns below fail.
3470    #[test]
3471    fn perl_name_wrappers_bill_the_name_once_1355() {
3472        let cases: [PerlNameWrapperCase; 12] = [
3473            // identifier under package_name, the single-segment form.
3474            PerlNameWrapperCase {
3475                source: "use strict;\n",
3476                counts: [2, 2, 1, 1],
3477                before: [1, 2],
3478                operands: &["strict"],
3479            },
3480            // …and the multi-segment one, twice over.
3481            PerlNameWrapperCase {
3482                source: "require Data::Dumper;\n",
3483                counts: [3, 3, 1, 1],
3484                before: [3, 3],
3485                operands: &["Data::Dumper"],
3486            },
3487            PerlNameWrapperCase {
3488                source: "package Foo::Bar;\n",
3489                counts: [3, 3, 1, 1],
3490                before: [3, 3],
3491                operands: &["Foo::Bar"],
3492            },
3493            // The `bar` of a qualified call is a *sibling* of the
3494            // `package_name`, not a child, so it still counts while the
3495            // `Foo` inside the wrapper does not. That is the row saying
3496            // the guard reads position and not kind: keying it on the
3497            // child kinds instead fails 14 tests here, this one among
3498            // them.
3499            PerlNameWrapperCase {
3500                source: "Foo::bar();\n",
3501                counts: [3, 3, 2, 2],
3502                before: [2, 3],
3503                operands: &["Foo", "bar"],
3504            },
3505            // identifier under typeglob, bare and brace-delimited.
3506            PerlNameWrapperCase {
3507                source: "my $g = *STDOUT;\n",
3508                counts: [5, 5, 2, 2],
3509                before: [3, 3],
3510                operands: &["$g", "*STDOUT"],
3511            },
3512            PerlNameWrapperCase {
3513                source: "my $t = *{Foo};\n",
3514                counts: [6, 6, 2, 2],
3515                before: [3, 3],
3516                operands: &["$t", "*{Foo}"],
3517            },
3518            // package_name and scalar_variable under package_variable,
3519            // and scalar_variable under package_name — the reported
3520            // fixture, where `$Foo` was billed twice and `::` once.
3521            PerlNameWrapperCase {
3522                source: "our $Foo::count = 3;\n",
3523                counts: [4, 4, 2, 2],
3524                before: [5, 6],
3525                operands: &["$Foo::count", "3"],
3526            },
3527            // array_variable / hash_variable / special_scalar_variable
3528            // under package_name: the same shape with the other sigils.
3529            PerlNameWrapperCase {
3530                source: "my @l = @Foo::list;\n",
3531                counts: [3, 3, 2, 2],
3532                before: [5, 6],
3533                operands: &["@l", "@Foo::list"],
3534            },
3535            PerlNameWrapperCase {
3536                source: "my %h = %Foo::hash;\n",
3537                counts: [3, 3, 2, 2],
3538                before: [5, 6],
3539                operands: &["%h", "%Foo::hash"],
3540            },
3541            PerlNameWrapperCase {
3542                source: "my $z = $_::x;\n",
3543                counts: [4, 5, 2, 2],
3544                before: [5, 6],
3545                operands: &["$z", "$_::x"],
3546            },
3547            // typeglob under package_name.
3548            PerlNameWrapperCase {
3549                source: "*Foo::glob = 1;\n",
3550                counts: [3, 3, 2, 2],
3551                before: [6, 7],
3552                operands: &["*Foo::glob", "1"],
3553            },
3554            // package_variable under package_name: the nesting that
3555            // makes qualifier depth unbounded. One variable reference
3556            // used to spell seven vocabulary entries.
3557            PerlNameWrapperCase {
3558                source: "my $x = $Foo::Bar::baz;\n",
3559                counts: [4, 5, 2, 2],
3560                before: [7, 10],
3561                operands: &["$x", "$Foo::Bar::baz"],
3562            },
3563        ];
3564
3565        let mut witnessed: HashSet<(u16, u16)> = HashSet::new();
3566        for PerlNameWrapperCase {
3567            source,
3568            counts,
3569            before: [n2_before, total_before],
3570            operands,
3571        } in cases
3572        {
3573            let (hidden, pairings) = perl_subsumed_operands(source);
3574            assert!(
3575                !hidden.is_empty(),
3576                "row {source:?} contains no name-wrapper child, so it witnesses nothing",
3577            );
3578            witnessed.extend(pairings);
3579
3580            // Phrased as an addition rather than a subtraction so a
3581            // future edit that inverts the two underflows nothing and
3582            // fails with the message below.
3583            assert_eq!(
3584                total_before,
3585                counts[3] + hidden.len() as u64,
3586                "row {source:?} must shed exactly one operand per contained \
3587                 name part; recorded N2_before {total_before}, parts {hidden:?}",
3588            );
3589            let mut vocabulary: HashSet<&str> = operands.iter().copied().collect();
3590            vocabulary.extend(hidden.iter().map(String::as_str));
3591            assert_eq!(
3592                vocabulary.len() as u64,
3593                n2_before,
3594                "row {source:?}: n2 before the fix is the post-fix vocabulary \
3595                 plus the contained name parts; got {vocabulary:?}",
3596            );
3597
3598            assert_halstead_counts::<PerlParser>(source, "foo.pl", counts, source);
3599            assert_ops_operands::<PerlParser>(source, "foo.pl", operands.len(), operands.to_vec());
3600        }
3601
3602        let mut got: Vec<(u16, u16)> = witnessed.into_iter().collect();
3603        got.sort_unstable();
3604        let mut expected_pairings: Vec<(u16, u16)> = PERL_NAME_WRAPPER_PAIRINGS
3605            .map(|(wrapper, child)| (wrapper as u16, child as u16))
3606            .into();
3607        expected_pairings.sort_unstable();
3608        assert_eq!(
3609            got, expected_pairings,
3610            "the table must exercise every (wrapper, child) pairing \
3611             node-types.json admits, and no other",
3612        );
3613    }
3614
3615    /// The over-suppression half of #1355 (grammar-dispatch §6 and §11).
3616    /// The guard is keyed on the parent, so the same kinds it silences
3617    /// inside a name wrapper have to keep counting everywhere else —
3618    /// otherwise "one operand per name" would have been bought by
3619    /// zeroing ordinary variables and calls.
3620    ///
3621    /// `module_name` rides along because the issue asserted it shares
3622    /// the `identifier` leaf and would be collateral damage. It does
3623    /// not: `use 'Foo.pm'` parses to a leaf holding only its two quote
3624    /// tokens, so it wraps nothing and is untouched either way.
3625    #[test]
3626    fn perl_qw_list_bills_one_operand_per_element() {
3627        // `qw(a b c)` was invisible to Halstead — neither the elements,
3628        // the wrapper nor the `qw` keyword had an arm — so it billed
3629        // nothing where its synonym `("a", "b", "c")` billed three
3630        // operands. Each `list_item` is now one operand and the
3631        // `word_list_qw` wrapper is gated on holding one, the #1353
3632        // Ruby `%w[]` rule: one operand per element, or one for the
3633        // empty literal.
3634        assert_ops_operands::<PerlParser>(
3635            "my @a = qw(a b c);\n",
3636            "foo.pl",
3637            4,
3638            vec!["@a", "a", "b", "c"],
3639        );
3640        assert_ops_operands::<PerlParser>("my @a = qw();\n", "foo.pl", 2, vec!["@a", "qw()"]);
3641        assert_ops_operands::<PerlParser>(
3642            "use POSIX qw(floor ceil);\n",
3643            "foo.pl",
3644            3,
3645            vec!["POSIX", "floor", "ceil"],
3646        );
3647        // expected: [n1, N1, n2, N2] = [3, 3, 4, 4] for every delimiter —
3648        // operators `my`, `=`, `;`; the `qw` keyword and its delimiters
3649        // are unclassified, as Ruby's `%w[` is, so the choice of
3650        // delimiter cannot move the score (#1312).
3651        for spelling in [
3652            "qw(a b c)",
3653            "qw/a b c/",
3654            "qw{a b c}",
3655            "qw[a b c]",
3656            "qw<a b c>",
3657        ] {
3658            assert_halstead_counts::<PerlParser>(
3659                &format!("my @a = {spelling};\n"),
3660                "foo.pl",
3661                [3, 3, 4, 4],
3662                spelling,
3663            );
3664        }
3665        // The synonym: the same four operands, plus the `()` and `,`
3666        // operators the list spelling carries.
3667        assert_halstead_counts::<PerlParser>(
3668            "my @a = (\"a\", \"b\", \"c\");\n",
3669            "foo.pl",
3670            [5, 6, 4, 4],
3671            "list literal",
3672        );
3673    }
3674
3675    #[test]
3676    fn perl_qualified_name_leaves_still_count_elsewhere_1355() {
3677        // expected: operators `my` × 4, `$` × 3 (one per `$`-sigilled
3678        // variable), `=` × 4, `;` × 5, `()` × 3 and the fat comma
3679        // → n1 = 6, N1 = 20. Operands are the four declared variables,
3680        // the three integers, the hash key, `$_` and the call target
3681        // → n2 = N2 = 10. Five of the eight kinds the guard silences
3682        // under a name wrapper appear among them — `scalar_variable`,
3683        // `array_variable`, `hash_variable`, `special_scalar_variable`
3684        // and `identifier` — and all five still count here.
3685        let bare = "my $x = 1;\nmy @a = (2);\nmy %h = (k => 3);\nmy $u = $_;\nfoo();\n";
3686        assert_halstead_counts::<PerlParser>(bare, "foo.pl", [6, 20, 10, 10], bare);
3687        assert_ops_operands::<PerlParser>(
3688            bare,
3689            "foo.pl",
3690            10,
3691            vec!["$x", "1", "@a", "2", "%h", "k", "3", "$u", "$_", "foo"],
3692        );
3693        let (hidden, _) = perl_subsumed_operands(bare);
3694        assert!(
3695            hidden.is_empty(),
3696            "no name wrapper appears here, so the guard must be inert; got {hidden:?}",
3697        );
3698
3699        // expected: operators `use`, `;` → n1 = N1 = 2; the quoted
3700        // module name is the sole operand → n2 = N2 = 1.
3701        let quoted = "use 'Some.pm';\n";
3702        assert_halstead_counts::<PerlParser>(quoted, "foo.pl", [2, 2, 1, 1], quoted);
3703        assert_ops_operands::<PerlParser>(quoted, "foo.pl", 1, vec!["'Some.pm'"]);
3704        assert!(
3705            ast_has_kind_id(
3706                &PerlParser::new(quoted.as_bytes().to_vec(), &PathBuf::from("foo.pl"), None),
3707                Perl::ModuleName as u16,
3708            ),
3709            "the quoted `use` form no longer parses to `module_name`, so this \
3710             row no longer says anything about it",
3711        );
3712    }
3713
3714    #[test]
3715    fn lua_operators_and_operands() {
3716        check_metrics::<LuaParser>(
3717            "local function add(a, b)
3718  local result = a + b
3719  if result > 0 then
3720    return result
3721  end
3722  return 0
3723end",
3724            "foo.lua",
3725            |metric| {
3726                // n1=11: local,function,(,,,=,+,if,>,then,return,end
3727                // (after #695 the `)` closer no longer counts — only the
3728                // folded `(` opener does; was n1=12).
3729                // n2=5: add,a,b,result,0
3730                insta::assert_json_snapshot!(metric.halstead, @r#"
3731                {
3732                  "unique_operators": 11,
3733                  "total_operators": 14,
3734                  "unique_operands": 5,
3735                  "total_operands": 10,
3736                  "length": 24,
3737                  "estimated_program_length": 49.66338827944708,
3738                  "purity_ratio": 2.0693078449769615,
3739                  "vocabulary": 16,
3740                  "volume": 96.0,
3741                  "difficulty": 11.0,
3742                  "level": 0.09090909090909091,
3743                  "effort": 1056.0,
3744                  "time": 58.666666666666664,
3745                  "bugs": 0.03456644293839657
3746                }
3747                "#);
3748            },
3749        );
3750    }
3751
3752    /// Regression for #695. Lua/Bash/Tcl/iRules/PHP/Ruby/Elixir used to
3753    /// classify the *closing* delimiter (`)`/`]`/`}`) as a separate
3754    /// operator, while the C-family majority folds each balanced pair to a
3755    /// single glyph via `get_operator_id_as_str` and counts only the
3756    /// opener. A balanced `(1)` therefore double-counted as `()` + `)`,
3757    /// inflating n1 and N1. With the fix only the folded `(` opener counts:
3758    /// `local x = (1)` yields operators `local`, `=`, `()` — n1 = N1 = 3,
3759    /// with no standalone `)`.
3760    #[test]
3761    fn lua_balanced_paren_counts_opener_only() {
3762        let source = "local x = (1)\n";
3763        let path = PathBuf::from("foo.lua");
3764        let parser = LuaParser::new(source.as_bytes().to_vec(), &path, None);
3765        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
3766        let paren = ops.operators.iter().filter(|o| o.as_str() == "()").count();
3767        assert_eq!(
3768            paren, 1,
3769            "balanced `(1)` must be one `()` operator; operators were {:?}",
3770            ops.operators
3771        );
3772        assert!(
3773            !ops.operators.iter().any(|o| o.as_str() == ")"),
3774            "the closing `)` must not be a separate operator; operators were {:?}",
3775            ops.operators
3776        );
3777    }
3778
3779    /// Guard for #768. Several `get_op_type` impls (Cpp/C/Objc/Mozcpp/
3780    /// Tcl/iRules/Php/Elixir/Ruby) classify a grammar's *second-alias*
3781    /// opener — `LPAREN2`, and for Elixir/Ruby `LBRACK2`/`LBRACK3` — as a
3782    /// Halstead operator alongside the base `LPAREN`/`LBRACK`. #768 worried
3783    /// that an alias opener would reach `compute_halstead` with a kind_id
3784    /// distinct from the base, inflating n1 (a second `()` entry) and
3785    /// rendering a bare `"("` instead of the folded `"()"`.
3786    ///
3787    /// That cannot happen: tree-sitter's runtime collapses each alias to
3788    /// its base via the grammar's `public_symbol_map` *before*
3789    /// `Node::kind_id()` (`ts_node_symbol`) ever returns. So the alias
3790    /// kind_id is unobservable to the metric layer and the alias match arms
3791    /// are defensive — they only fire if a future grammar bump drops that
3792    /// collapse. This test pins the invariant: parsing the exact
3793    /// constructs each grammar produces the alias for internally
3794    /// (pp-conditional `defined(...)` for Cpp; call arg-list / subscript /
3795    /// constant-array-pattern for Ruby) must yield **no** node carrying the
3796    /// alias kind_id, and the balanced opener must count once and render as
3797    /// the pair glyph. If a grammar bump makes an alias id observable, this
3798    /// goes red and signals that the alias arms must additionally fold to
3799    /// the base in `get_operator_id_as_str` (the fix #768 proposed).
3800    #[test]
3801    fn second_alias_opener_collapses_to_base_kind_id() {
3802        fn assert_no_alias<T: crate::ParserTrait>(
3803            source: &str,
3804            file: &str,
3805            alias_id: u16,
3806            alias_name: &str,
3807        ) {
3808            let path = PathBuf::from(file);
3809            let parser = T::new(source.as_bytes().to_vec(), &path, None);
3810            let mut stack = vec![parser.root()];
3811            while let Some(node) = stack.pop() {
3812                assert_ne!(
3813                    node.kind_id(),
3814                    alias_id,
3815                    "{alias_name} (kind_id {alias_id}) must never reach kind_id() \
3816                     for `{source}`; the runtime public_symbol_map should have \
3817                     collapsed it to the base opener. If this fires after a \
3818                     grammar bump, fold {alias_name} to its pair glyph in \
3819                     get_operator_id_as_str (issue #768)."
3820                );
3821                for child in node.children() {
3822                    stack.push(child);
3823                }
3824            }
3825        }
3826
3827        // Balanced openers must count once and render folded (no bare
3828        // `(`/`[`, no n1 inflation) — the property #768 feared was broken.
3829        fn assert_folded_openers<T: crate::ParserTrait>(source: &str, file: &str) {
3830            let path = PathBuf::from(file);
3831            let parser = T::new(source.as_bytes().to_vec(), &path, None);
3832            let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
3833            assert!(
3834                !ops.operators.iter().any(|o| o.as_str() == "("),
3835                "no bare `(` operator (must fold to `()`); operators were {:?}",
3836                ops.operators
3837            );
3838            assert!(
3839                !ops.operators.iter().any(|o| o.as_str() == "["),
3840                "no bare `[` operator (must fold to `[]`); operators were {:?}",
3841                ops.operators
3842            );
3843            // Each pair glyph appears at most once — the alias does not add
3844            // a second `()`/`[]` entry to n1.
3845            assert!(
3846                ops.operators.iter().filter(|o| o.as_str() == "()").count() <= 1,
3847                "`()` must be a single n1 entry; operators were {:?}",
3848                ops.operators
3849            );
3850            assert!(
3851                ops.operators.iter().filter(|o| o.as_str() == "[]").count() <= 1,
3852                "`[]` must be a single n1 entry; operators were {:?}",
3853                ops.operators
3854            );
3855        }
3856
3857        // Cpp/C/Mozcpp: LPAREN2 = 20. The grammar emits it internally only
3858        // inside preprocessor-conditional expressions (`#if defined(FOO)`).
3859        assert_no_alias::<crate::CppParser>(
3860            "#if defined(FOO)\n#endif\n",
3861            "a.cpp",
3862            20,
3863            "Cpp::LPAREN2",
3864        );
3865        assert_no_alias::<crate::CParser>("#if defined(FOO)\n#endif\n", "a.c", 20, "C::LPAREN2");
3866
3867        // Ruby: LPAREN2 = 47 (call arg-list), LBRACK3 = 155 (element-
3868        // reference subscript), LBRACK2 = 46 (constant array pattern).
3869        assert_no_alias::<crate::RubyParser>("f(1)\n", "a.rb", 47, "Ruby::LPAREN2");
3870        assert_no_alias::<crate::RubyParser>("a[0]\n", "a.rb", 155, "Ruby::LBRACK3");
3871        assert_no_alias::<crate::RubyParser>(
3872            "case p\nin Point[1, 2] then 1\nend\n",
3873            "a.rb",
3874            46,
3875            "Ruby::LBRACK2",
3876        );
3877
3878        // Elixir: LPAREN2 = 95 (immediate call paren), LBRACK2 = 96
3879        // (access / subscript).
3880        assert_no_alias::<crate::ElixirParser>("f(1)\n", "a.ex", 95, "Elixir::LPAREN2");
3881        assert_no_alias::<crate::ElixirParser>("x[0]\n", "a.ex", 96, "Elixir::LBRACK2");
3882
3883        assert_folded_openers::<crate::CppParser>("int main(){ int a[3]; return a[0]; }", "b.cpp");
3884        assert_folded_openers::<crate::RubyParser>("f(1)\nb = [1]\nb[0]\n", "b.rb");
3885    }
3886
3887    #[test]
3888    fn kotlin_halstead_basic() {
3889        check_metrics::<KotlinParser>(
3890            "fun add(a: Int, b: Int): Int {
3891                val result = a + b
3892                return result
3893            }",
3894            "foo.kt",
3895            |metric| {
3896                insta::assert_json_snapshot!(
3897                    metric.halstead,
3898                    @r#"
3899                {
3900                  "unique_operators": 9,
3901                  "total_operators": 11,
3902                  "unique_operands": 5,
3903                  "total_operands": 10,
3904                  "length": 21,
3905                  "estimated_program_length": 40.13896548741762,
3906                  "purity_ratio": 1.9113793089246487,
3907                  "vocabulary": 14,
3908                  "volume": 79.9544533632097,
3909                  "difficulty": 9.0,
3910                  "level": 0.1111111111111111,
3911                  "effort": 719.5900802688873,
3912                  "time": 39.97722668160485,
3913                  "bugs": 0.026767153565498338
3914                }
3915                "#
3916                );
3917            },
3918        );
3919    }
3920
3921    #[test]
3922    fn kotlin_string_template_no_double_count() {
3923        // Re-anchored for issue #454. The pre-#454 comment claimed
3924        // kotlin-ng emits an `identifier` node for the short `$name`
3925        // form whose bytes include the leading `$`. That is factually
3926        // false: AST dump shows the short form produces bare
3927        // `string_content` tokens (`$`, then `name`) with **no**
3928        // structured node. The old assertion (u_operands = 4, N2 = 5)
3929        // passed for the wrong reason (lesson 6): the wrapping literal
3930        // was counted (+1) and the inner `name` was dropped (-1), and
3931        // the two errors cancelled. The `$name!` it used also defeats
3932        // recovery because the grammar glues the trailing `!` onto the
3933        // name token.
3934        //
3935        // Correct mechanism (clean end-of-segment short form):
3936        // `fun greet(name: String): String {\n    return "Hi $name"\n}\n`
3937        //   operators: fun, (, ), :, {}, return → as classified.
3938        //   operands by token text:
3939        //     `greet` × 1, `name` × 2 (param + recovered short-interp),
3940        //     `String` × 2 (param type + return type).
3941        //   The wrapping `"Hi $name"` literal is suppressed and the
3942        //   inner `name` recovered → u_operands = 3 (`greet`, `name`,
3943        //   `String`), N2 = 5. Pre-#454: wrapper counted, inner dropped
3944        //   → u_operands = 4, N2 = 6.
3945        check_metrics::<KotlinParser>(
3946            "fun greet(name: String): String {\n    return \"Hi $name\"\n}\n",
3947            "foo.kt",
3948            |metric| {
3949                assert_eq!(metric.halstead.unique_operands(), 3);
3950                assert_eq!(metric.halstead.total_operands(), 5);
3951            },
3952        );
3953        // Lesson 4: the ops store agrees on n2 and the exact operand set
3954        // (inner `name` present, wrapper absent).
3955        assert_ops_operands::<KotlinParser>(
3956            "fun greet(name: String): String {\n    return \"Hi $name\"\n}\n",
3957            "foo.kt",
3958            3,
3959            vec!["greet", "name", "String"],
3960        );
3961    }
3962
3963    #[test]
3964    fn kotlin_short_interpolation_counts_inner_not_wrapper() {
3965        // Issue #454: the short `$name` template — distinct from the
3966        // long `${expr}` form, which the kotlin-ng grammar gives a
3967        // structured `interpolation` node (see
3968        // `kotlin_string_template_long_form_no_double_count`). The short
3969        // form has no such node; the variable arrives as a bare
3970        // `string_content` token preceded by a `$` `string_content`.
3971        // The fix recovers the clean-identifier variable as an operand
3972        // and suppresses the opaque wrapper.
3973        //
3974        // `fun f() { val x = 1; println("v=$x") }\n`
3975        //   operands by token text: `f`, `x` × 2 (decl + recovered),
3976        //   `println`, `1`. The wrapping `"v=$x"` is suppressed →
3977        //   u_operands = 4 (`f`, `x`, `println`, `1`), N2 = 5.
3978        // Pre-#454 the wrapper `"v=$x"` counted and the inner `x` was
3979        // dropped → u_operands = 4 but the wrapper, not `x`, was the
3980        // fourth operand, and N2 = 5 with the wrong member — the ops
3981        // assertion below pins the exact set so the cancellation cannot
3982        // hide it.
3983        let src = "fun f() { val x = 1; println(\"v=$x\") }\n";
3984        check_metrics::<KotlinParser>(src, "foo.kt", |metric| {
3985            assert_eq!(metric.halstead.unique_operands(), 4);
3986            assert_eq!(metric.halstead.total_operands(), 5);
3987        });
3988        assert_ops_operands::<KotlinParser>(src, "foo.kt", 4, vec!["f", "x", "println", "1"]);
3989    }
3990
3991    #[test]
3992    fn kotlin_short_interpolation_space_separated() {
3993        // Issue #454 follow-up: tree-sitter-kotlin-ng splits the literal
3994        // only at each `$`, so a `$name` segment's name token absorbs any
3995        // trailing inter-segment text into its byte range. For `"$a $b"`
3996        // the token after the first `$` is `"a "` (with the trailing
3997        // space). Pre-fix `kotlin_is_identifier("a ")` returned false and
3998        // the leading variable `a` was silently dropped, yielding
3999        // operands `{b, f, s}` (verified: `a` missing) — breaking parity
4000        // with the long form `"${a} ${b}"`, which recovers `{a, b, f, s}`.
4001        //
4002        // The fix takes the maximal leading-identifier prefix of the name
4003        // token, recovering `a` and keying it as the bare `"a"` (not
4004        // `"a "`). Short and long forms must now agree exactly.
4005        //
4006        // `fun f() { val s = "$a $b" }\n`
4007        //   operands by token text: `f`, `s`, `a` (recovered), `b`
4008        //   (recovered). Wrapper suppressed → u_operands = 4, N2 = 4.
4009        let short = "fun f() { val s = \"$a $b\" }\n";
4010        let long = "fun f() { val s = \"${a} ${b}\" }\n";
4011        check_metrics::<KotlinParser>(short, "foo.kt", |metric| {
4012            assert_eq!(metric.halstead.unique_operands(), 4);
4013            assert_eq!(metric.halstead.total_operands(), 4);
4014        });
4015        // Both `a` and `b` present, wrapper absent, n2 == dedupe(operands).
4016        assert_ops_operands::<KotlinParser>(short, "foo.kt", 4, vec!["f", "s", "a", "b"]);
4017        // Exact parity with the long `${a} ${b}` form.
4018        assert_ops_operands::<KotlinParser>(long, "foo.kt", 4, vec!["f", "s", "a", "b"]);
4019
4020        // Comma after the name (`"$a, $b"`): the first name token is
4021        // `"a, "`; its leading identifier prefix is `a`.
4022        let comma = "fun f() { val s = \"$a, $b\" }\n";
4023        assert_ops_operands::<KotlinParser>(comma, "foo.kt", 4, vec!["f", "s", "a", "b"]);
4024
4025        // Name preceded by literal text and at end-of-segment (`"x=$a"`):
4026        // the `a` token has no trailing text, so recovery is unchanged.
4027        let prefixed = "fun f() { val s = \"x=$a\" }\n";
4028        assert_ops_operands::<KotlinParser>(prefixed, "foo.kt", 3, vec!["f", "s", "a"]);
4029
4030        // Mid-prose `"$x is "`: the name token is `"x is "`. The leading
4031        // identifier prefix is `x`, matching the long form `"${x} is "`,
4032        // which also recovers `x` and treats `" is "` as literal text.
4033        let prose_short = "fun f() { val s = \"$x is \" }\n";
4034        let prose_long = "fun f() { val s = \"${x} is \" }\n";
4035        assert_ops_operands::<KotlinParser>(prose_short, "foo.kt", 3, vec!["f", "s", "x"]);
4036        assert_ops_operands::<KotlinParser>(prose_long, "foo.kt", 3, vec!["f", "s", "x"]);
4037    }
4038
4039    #[test]
4040    fn kotlin_dollar_non_identifier_stays_literal() {
4041        // Issue #454 boundary: a `$` not followed by a clean identifier
4042        // is literal text, not an interpolation. `"price: $5"` (digit
4043        // after `$`) must keep the wrapping literal as a single operand
4044        // and recover nothing.
4045        //
4046        // `fun f() { val a = "price: $5" }\n`
4047        //   operands: `f`, `a`, `"price: $5"` → u_operands = 3, N2 = 3.
4048        let src = "fun f() { val a = \"price: $5\" }\n";
4049        check_metrics::<KotlinParser>(src, "foo.kt", |metric| {
4050            assert_eq!(metric.halstead.unique_operands(), 3);
4051            assert_eq!(metric.halstead.total_operands(), 3);
4052        });
4053        assert_ops_operands::<KotlinParser>(src, "foo.kt", 3, vec!["f", "a", "\"price: $5\""]);
4054    }
4055
4056    #[test]
4057    fn kotlin_string_template_long_form_no_double_count() {
4058        // The `${expr}` long form of a Kotlin string template also
4059        // produces an `Interpolation` child. The fix must apply to it
4060        // identically.
4061        //
4062        // Source: `fun f(x: Int): String { return "v=${x}" }\n`
4063        // Operands by source-byte key:
4064        //   `f` × 1, `x` × 2 (param + inside `${x}`),
4065        //   `Int` × 1, `String` × 1.
4066        // With the fix u_operands = 4 (`f`, `x`, `Int`, `String`),
4067        // N2 = 5. Without the fix the wrapping `"v=${x}"` would also
4068        // count → u_operands = 5, N2 = 6.
4069        check_metrics::<KotlinParser>(
4070            "fun f(x: Int): String { return \"v=${x}\" }\n",
4071            "foo.kt",
4072            |metric| {
4073                assert_eq!(metric.halstead.unique_operands(), 4);
4074                assert_eq!(metric.halstead.total_operands(), 5);
4075            },
4076        );
4077    }
4078
4079    #[test]
4080    fn kotlin_plain_string_still_operand() {
4081        // The fix for #191 only skips wrapping templates that contain
4082        // an `Interpolation` child; a plain `"hello"` (no `$` interp)
4083        // must still contribute exactly one operand.
4084        //
4085        // Source: `fun f(): String { return "hello" }\n`
4086        // Operands: `f` × 1, `String` × 1, `"hello"` × 1 →
4087        // u_operands = 3, N2 = 3.
4088        check_metrics::<KotlinParser>(
4089            "fun f(): String { return \"hello\" }\n",
4090            "foo.kt",
4091            |metric| {
4092                assert_eq!(metric.halstead.unique_operands(), 3);
4093                assert_eq!(metric.halstead.total_operands(), 3);
4094            },
4095        );
4096    }
4097
4098    #[test]
4099    fn python_fstring_no_double_count() {
4100        // Regression: issue #191. A Python f-string (`f"Hi {name}!"`)
4101        // wraps an `Interpolation` child whose inner identifier
4102        // `name` is walked and counted as its own operand. Without
4103        // the `is_child(Interpolation)` guard the wrapping `String`
4104        // would also count, double-counting `name`'s contribution to
4105        // `N2`. Same pattern as #180 (Bash/Elixir) and #184 (PHP).
4106        //
4107        // Source: `def greet(name):\n    return f"Hi {name}!"\n`
4108        // Operands by source-byte key:
4109        //   `greet` × 1, `name` × 2 (param + inside `{name}`).
4110        // With the fix the wrapping `f"Hi {name}!"` is skipped →
4111        // u_operands = 2 (`greet`, `name`), N2 = 3. Without the fix
4112        // the wrapping literal would also count → u_operands = 3,
4113        // N2 = 4.
4114        check_metrics::<PythonParser>(
4115            "def greet(name):\n    return f\"Hi {name}!\"\n",
4116            "foo.py",
4117            |metric| {
4118                assert_eq!(metric.halstead.unique_operands(), 2);
4119                assert_eq!(metric.halstead.total_operands(), 3);
4120            },
4121        );
4122    }
4123
4124    #[test]
4125    fn python_plain_string_still_operand() {
4126        // The fix for #191 only skips wrapping `String` nodes that
4127        // contain an `Interpolation` child; a plain `"hi"` must still
4128        // contribute exactly one operand.
4129        //
4130        // Source: `def f():\n    return "hi"\n`
4131        // Operands: `f` × 1, `"hi"` × 1 → u_operands = 2, N2 = 2.
4132        // (The previous documentation-string filter is preserved:
4133        // a bare `"hi"` as a top-level `expression_statement` would
4134        // be skipped, but here it appears as `return "hi"`.)
4135        check_metrics::<PythonParser>("def f():\n    return \"hi\"\n", "foo.py", |metric| {
4136            assert_eq!(metric.halstead.unique_operands(), 2);
4137            assert_eq!(metric.halstead.total_operands(), 2);
4138        });
4139    }
4140
4141    #[test]
4142    fn python_concatenated_docstring_suppressed() {
4143        // Regression for #695. An implicit-concatenation docstring
4144        // (`"""doc""" "more"`) parses as `expression_statement >
4145        // concatenated_string > [string, string]`. The single-literal
4146        // docstring guard (`parent == expression_statement &&
4147        // child_count == 1`) never fired here, so each fragment counted
4148        // as a separate operand and the docstring's N2 contribution
4149        // depended on how many literals it was split into. With the fix,
4150        // every fragment of such a docstring is suppressed.
4151        //
4152        // Source: `def f():\n    """doc""" "more"\n    return 1\n`
4153        // Operands: `f`, `1` only — both docstring fragments suppressed →
4154        // u_operands = 2, N2 = 2.
4155        check_metrics::<PythonParser>(
4156            "def f():\n    \"\"\"doc\"\"\" \"more\"\n    return 1\n",
4157            "foo.py",
4158            |metric| {
4159                assert_eq!(metric.halstead.unique_operands(), 2);
4160                assert_eq!(metric.halstead.total_operands(), 2);
4161            },
4162        );
4163    }
4164
4165    #[test]
4166    fn python_concatenated_non_docstring_still_counts() {
4167        // The #695 fix must only suppress concatenated literals in the
4168        // *docstring* position (sole child of an `expression_statement`).
4169        // A concatenated string used as a value (`x = "a" "b"`) is not a
4170        // docstring — its `concatenated_string` parent's grandparent is
4171        // an assignment, not a single-child statement — so both fragments
4172        // must still be operands.
4173        //
4174        // Source: `def f():\n    x = "a" "b"\n    return x\n`
4175        // Operands: `f`, `x` (twice: assign + return), `"a"`, `"b"` →
4176        // u_operands = 4, N2 = 5.
4177        check_metrics::<PythonParser>(
4178            "def f():\n    x = \"a\" \"b\"\n    return x\n",
4179            "foo.py",
4180            |metric| {
4181                assert_eq!(metric.halstead.unique_operands(), 4);
4182                assert_eq!(metric.halstead.total_operands(), 5);
4183            },
4184        );
4185    }
4186
4187    #[test]
4188    fn python_empty_file_halstead() {
4189        check_metrics::<PythonParser>("", "empty.py", |metric| {
4190            let h = &metric.halstead;
4191            assert_eq!(h.unique_operators(), 0);
4192            assert_eq!(h.total_operands(), 0);
4193            assert_eq!(h.estimated_program_length(), 0.0);
4194            assert_eq!(h.purity_ratio(), 0.0);
4195            assert_eq!(h.volume(), 0.0);
4196            assert_eq!(h.difficulty(), 0.0);
4197            assert_eq!(h.level(), 0.0);
4198            assert_eq!(h.effort(), 0.0);
4199            assert_eq!(h.time(), 0.0);
4200            assert_eq!(h.bugs(), 0.0);
4201        });
4202    }
4203
4204    /// Regression #413, sub-fix (1): `await` was double-counted because the
4205    /// operator arm listed both the await-expression node (Await=237) and the
4206    /// nested `await` keyword token (Await2=95). Only the node should count,
4207    /// mirroring how `yield` counts only the Yield node.
4208    #[test]
4209    fn python_await_counted_once_per_use() {
4210        check_metrics::<PythonParser>(
4211            "async def f():\n    await a()\n    await b()\n    await c()\n",
4212            "foo.py",
4213            |metric| {
4214                // expected operators: async, def, await  (3 unique)
4215                //   await used three times -> N1 counts: async(1) def(1) await(3) = 5
4216                //   Before #413, Await + Await2 both matched, so `await` was a
4217                //   distinct operator twice: n1=4, N1=8.
4218                assert_eq!(metric.halstead.unique_operators(), 3);
4219                assert_eq!(metric.halstead.total_operators(), 5);
4220            },
4221        );
4222    }
4223
4224    /// Regression #413, sub-fix (3): `lambda` was dropped entirely. Only the
4225    /// `lambda` keyword token (Lambda3=73) is classified, not the wrapping
4226    /// Lambda/Lambda2 expression nodes, to avoid an await-style double count.
4227    #[test]
4228    fn python_lambda_counted_once() {
4229        check_metrics::<PythonParser>("g = lambda x: x + 1\n", "foo.py", |metric| {
4230            // expected operators: =, lambda, +  (3 unique, each used once)
4231            // Before #413, lambda was absent: only =, + were counted.
4232            assert_eq!(metric.halstead.unique_operators(), 3);
4233            assert_eq!(metric.halstead.total_operators(), 3);
4234        });
4235    }
4236
4237    /// Regression #413, sub-fix (2): `match` / `case` keyword tokens
4238    /// (Match=26, Case=27) were dropped. Each should now count as an operator,
4239    /// matching the cyclomatic metric which already counts every `case`.
4240    #[test]
4241    fn python_match_case_counted() {
4242        check_metrics::<PythonParser>(
4243            "match x:\n    case 1:\n        pass\n    case _:\n        pass\n",
4244            "foo.py",
4245            |metric| {
4246                // expected operators: match, case, pass  (3 unique)
4247                //   match(1) + case(2) + pass(2) = 5 total occurrences.
4248                // Before #413, neither match nor case was counted (only pass).
4249                assert_eq!(metric.halstead.unique_operators(), 3);
4250                assert_eq!(metric.halstead.total_operators(), 5);
4251            },
4252        );
4253    }
4254
4255    /// Regression #413, sub-fix (2): `nonlocal` (Nonlocal=41) was dropped while
4256    /// `global` was already classified. Both should count, for parity.
4257    #[test]
4258    fn python_nonlocal_and_global_counted() {
4259        check_metrics::<PythonParser>(
4260            "def f():\n    global a\n    nonlocal b\n",
4261            "foo.py",
4262            |metric| {
4263                // expected operators: def, global, nonlocal  (3 unique)
4264                // Before #413, nonlocal was absent: only def, global counted.
4265                assert_eq!(metric.halstead.unique_operators(), 3);
4266                assert_eq!(metric.halstead.total_operators(), 3);
4267            },
4268        );
4269    }
4270
4271    /// Regression #413, sub-fix (4): `not in` (Notin=193) and `is not`
4272    /// (Isnot=194) are single compound operators. The parent-guard suppresses
4273    /// the inner Not/In/Is leaves only under those compounds, so standalone
4274    /// `not x`, `a in b`, `a is b`, and `for x in y` still count their leaves.
4275    #[test]
4276    fn python_not_in_is_not_counted_as_single_operator() {
4277        check_metrics::<PythonParser>(
4278            "a not in b\na is not b\nnot c\nd in e\nf is g\nfor h in i:\n    pass\n",
4279            "foo.py",
4280            |metric| {
4281                // expected operators (7 unique):
4282                //   "not in" (compound, once), "is not" (compound, once),
4283                //   "not" (standalone `not c`, once),
4284                //   "in" (standalone `d in e` + `for h in i` = twice),
4285                //   "is" (standalone `f is g`, once),
4286                //   "for" (once), "pass" (once)
4287                // Total occurrences: 1+1+1+2+1+1+1 = 8.
4288                // Before #413, `a not in b` counted not+in (two) and
4289                // `a is not b` counted is+not (two); the compounds were
4290                // never classified.
4291                assert_eq!(metric.halstead.unique_operators(), 7);
4292                assert_eq!(metric.halstead.total_operators(), 8);
4293            },
4294        );
4295    }
4296
4297    #[test]
4298    fn bash_operators_and_operands() {
4299        check_metrics::<BashParser>(
4300            "#!/bin/bash
4301f() {
4302    local x=1
4303    if [ $x -eq 1 ]; then
4304        echo 'one'
4305    fi
4306}",
4307            "foo.sh",
4308            |metric| {
4309                // Operators (9 unique, 9 occurrences): the opening
4310                // delimiters `()`/`{}`/`[]` (each folded to one glyph and
4311                // counted once per balanced pair, #695 — the closers no
4312                // longer add a second operator), `local`, `=`, `if`,
4313                // `then`, `fi`, `;`.
4314                // Operands (6 unique, 7 occurrences): `f`, `x` (the
4315                // assignment LHS `variable_name`, kind 160), `1` (twice:
4316                // `=1` and `-eq 1`), `$x` (the `simple_expansion` — its
4317                // inner `variable_name` leaf is now suppressed so `$x`
4318                // counts once, #695), `echo`, `'one'`.
4319                // N2 was 8 before #1351: `echo` counted twice, once as the
4320                // `command_name` wrapper and once as the `word` it wraps.
4321                assert_eq!(metric.halstead.unique_operators(), 9);
4322                assert_eq!(metric.halstead.total_operators(), 9);
4323                assert_eq!(metric.halstead.unique_operands(), 6);
4324                assert_eq!(metric.halstead.total_operands(), 7);
4325                insta::assert_json_snapshot!(metric.halstead);
4326            },
4327        );
4328    }
4329
4330    #[test]
4331    fn bash_interpolated_string_no_double_count() {
4332        // Regression: issue #180. A double-quoted Bash string containing
4333        // `$name`, `${name[…]}`, or `$(cmd)` used to be classified as a
4334        // Halstead operand AND have its inner `simple_expansion` /
4335        // `expansion` / `command_substitution` children classified as
4336        // operands too. We now skip the wrapping literal when it has an
4337        // expansion child so only the inner expansion contributes.
4338        //
4339        // expected: operands across `a="plain"\nb="$x"\n` —
4340        //   line 1: variable_name `a`, plain string `"plain"` (no
4341        //     expansion, still operand) → 2.
4342        //   line 2: variable_name `b`, wrapping `"$x"` skipped (has
4343        //     expansion), `simple_expansion` `$x` (its inner
4344        //     variable_name `x` leaf is suppressed under #695) → 2.
4345        // Total unique operands: 4 (`a`, `b`, `"plain"`, `$x`), each
4346        // appearing once → N2 = 4. Before #695 the inner `x` leaf of
4347        // `$x` was also counted (u_operands = 5, N2 = 5); before the
4348        // earlier #180 fix the wrapping `"$x"` literal was counted too.
4349        // The `=` is the only operator; appears twice (N1 = 2, n1 = 1).
4350        check_metrics::<BashParser>("a=\"plain\"\nb=\"$x\"\n", "foo.sh", |metric| {
4351            assert_eq!(metric.halstead.unique_operators(), 1);
4352            assert_eq!(metric.halstead.total_operators(), 2);
4353            assert_eq!(metric.halstead.unique_operands(), 4);
4354            assert_eq!(metric.halstead.total_operands(), 4);
4355            insta::assert_json_snapshot!(metric.halstead);
4356        });
4357    }
4358
4359    #[test]
4360    fn elixir_interpolated_string_no_double_count() {
4361        // Regression: issue #180. Without the fix, an interpolated
4362        // Elixir `String` was classified as a single operand while its
4363        // inner `interpolation` identifier was also walked and
4364        // classified as its own operand — double-counting the
4365        // interpolated identifier's contribution to `N2`.
4366        //
4367        // expected: operand contributions for
4368        //   `def greet(name) do\n  msg = "Hi #{name}"\nend\n` —
4369        // `def`, `greet`, `name` (param), `msg`, and the inner `name`
4370        // (inside `#{...}`). With the fix, the wrapping
4371        // `"Hi #{name}"` literal is skipped (has `Interpolation`
4372        // child), so `name` is the only repeated operand:
4373        // u_operands = 4 (def, greet, name, msg), N2 = 5. Without the
4374        // fix, the wrapping literal would also count → u_operands = 5,
4375        // N2 = 6. Operators: `do`, `end`, `(`, `=` → u = N = 4.
4376        // Only the *opening* delimiters count after #695, so the `)`
4377        // and the `}` interpolation closer add no operator; #1314 then
4378        // dropped the `#{` opener too, on the rule that an
4379        // interpolation opener is spelling rather than an operation
4380        // (was 5 here, and 7 before #695).
4381        check_metrics::<ElixirParser>(
4382            "def greet(name) do\n  msg = \"Hi #{name}\"\nend\n",
4383            "foo.ex",
4384            |metric| {
4385                assert_eq!(metric.halstead.unique_operators(), 4);
4386                assert_eq!(metric.halstead.total_operators(), 4);
4387                assert_eq!(metric.halstead.unique_operands(), 4);
4388                assert_eq!(metric.halstead.total_operands(), 5);
4389                insta::assert_json_snapshot!(metric.halstead);
4390            },
4391        );
4392    }
4393
4394    #[test]
4395    fn elixir_plain_string_still_operand() {
4396        // The fix for #180 only skips wrapping literals that contain
4397        // interpolation; a plain `"hello"` must still contribute exactly
4398        // one operand. expected: `def`, `f`, `"hello"` → 3 unique
4399        // operands (n2 = 3), each appearing once (N2 = 3).
4400        check_metrics::<ElixirParser>("def f do\n  \"hello\"\nend\n", "foo.ex", |metric| {
4401            assert_eq!(metric.halstead.unique_operands(), 3);
4402            assert_eq!(metric.halstead.total_operands(), 3);
4403        });
4404    }
4405
4406    #[test]
4407    fn elixir_boolean_and_nil_literals_count_once() {
4408        // Regression: issue #1253. `boolean: choice("true", "false")`
4409        // and `nil: "nil"` each wrap a keyword leaf, and both the
4410        // wrapper and the leaf sat in the operand arm — so every
4411        // literal occurrence added +1 to N2. Operands are keyed by
4412        // source text, so the duplicate collapsed into the same
4413        // vocabulary entry and n2 stayed correct, which is why nothing
4414        // caught it.
4415        //
4416        // Source is the issue's reproducer plus a repeat of `true` and
4417        // `nil`, so N2 exceeds n2 and the assertions can tell "counted
4418        // once per occurrence" from "deduplicated into the vocabulary".
4419        // All three keywords appear, so restoring any one of `True`,
4420        // `False`, or `Nil2` to the operand arm trips this test.
4421        //
4422        // Operands by text key: `x`, `y`, `z`, `w`, `v`, `true` × 2,
4423        // `nil` × 2, `false` ⇒ n2 = 8, N2 = 10. Before the fix each of
4424        // the five literals counted twice ⇒ N2 = 15.
4425        //
4426        // This also guards the drift in the other direction. Elixir
4427        // classifies the wrapper and drops the leaf outright rather
4428        // than parent-guarding it, so a grammar bump that stopped
4429        // emitting `boolean` / `nil` would leave the leaves unclassified
4430        // and the literals would vanish from N2 entirely (⇒ 5 / 5)
4431        // rather than merely being miscounted.
4432        check_metrics::<ElixirParser>(
4433            "x = true\ny = nil\nz = false\nw = true\nv = nil\n",
4434            "foo.ex",
4435            |metric| {
4436                assert_eq!(metric.halstead.unique_operands(), 8);
4437                assert_eq!(metric.halstead.total_operands(), 10);
4438            },
4439        );
4440    }
4441
4442    #[test]
4443    fn elixir_reserved_word_after_a_dot_stays_an_operand() {
4444        // Companion to the test above (#1253). Elixir drops `True` /
4445        // `False` / `Nil2` from the operand arm outright, which is only
4446        // safe because the one grammar position that accepts a reserved
4447        // word outside the `boolean` / `nil` wrapper — the right-hand
4448        // side of a remote dot — aliases it to `identifier`. This pins
4449        // that alias: if a grammar bump emitted the bare keyword there
4450        // instead, `Foo.nil` and `Foo.true` would silently stop
4451        // contributing an operand.
4452        //
4453        // Source: a = Foo.nil / b = Foo.true / c = nil
4454        //
4455        // Operands by text key: `a`, `Foo` × 2, `nil` × 2 (the aliased
4456        // identifier and the real literal, which share a text key),
4457        // `b`, `true`, `c` ⇒ n2 = 6, N2 = 8. Losing the alias drops the
4458        // two dotted references ⇒ N2 = 6.
4459        check_metrics::<ElixirParser>("a = Foo.nil\nb = Foo.true\nc = nil\n", "foo.ex", |metric| {
4460            assert_eq!(metric.halstead.unique_operands(), 6);
4461            assert_eq!(metric.halstead.total_operands(), 8);
4462        });
4463    }
4464
4465    #[test]
4466    fn elixir_interpolated_sigil_no_double_count() {
4467        // Sigils mirror strings under #180. For `~r/foo#{name}/`, the
4468        // wrapping `Sigil` is skipped, but `SigilName` (`r`) and the
4469        // inner `name` identifier each contribute one operand.
4470        // expected: `def`, `f`, `name` (param), `re`, `r` (sigil name),
4471        // `name` (inside `#{...}`) → u_operands = 5, N2 = 6 (`name`
4472        // twice).
4473        check_metrics::<ElixirParser>(
4474            "def f(name) do\n  re = ~r/foo#{name}/\nend\n",
4475            "foo.ex",
4476            |metric| {
4477                assert_eq!(metric.halstead.unique_operands(), 5);
4478                assert_eq!(metric.halstead.total_operands(), 6);
4479            },
4480        );
4481    }
4482
4483    #[test]
4484    fn elixir_interpolated_charlist_no_double_count() {
4485        // Charlists mirror strings and sigils under #180. The
4486        // `E::String | E::Charlist | E::Sigil` arm in `get_op_type`
4487        // skips any wrapping literal that has an `Interpolation`
4488        // child; this test exercises the `Charlist` branch
4489        // specifically.
4490        //
4491        // expected: for `def f(name) do\n  cl = 'Hi #{name}'\nend\n` —
4492        // `def`, `f`, `name` (param), `cl`, and the inner `name`
4493        // (inside `#{...}`). With the fix, the wrapping
4494        // `'Hi #{name}'` is skipped → u_operands = 4 (def, f, name,
4495        // cl), N2 = 5 (`name` twice).
4496        check_metrics::<ElixirParser>(
4497            "def f(name) do\n  cl = 'Hi #{name}'\nend\n",
4498            "foo.ex",
4499            |metric| {
4500                assert_eq!(metric.halstead.unique_operands(), 4);
4501                assert_eq!(metric.halstead.total_operands(), 5);
4502            },
4503        );
4504    }
4505
4506    #[test]
4507    fn elixir_sigil_delimiters_are_not_operators() {
4508        // Regression: issue #1256. Sigil delimiter tokens share their
4509        // kind ids with real operators (`SLASH`, `LPAREN`, `LBRACE`,
4510        // …) and were classified unconditionally, so `~r/abc/`
4511        // fabricated two division operators and the author's delimiter
4512        // choice moved n1/N1. The parent guard suppresses them under
4513        // `Sigil`; `~` stays the single per-sigil operator.
4514        //
4515        // expected: operators `=` × 3 and `~` × 3 → n1 = 2, N1 = 6.
4516        // Without the guard the delimiters added `/` × 2, `(`, `{` →
4517        // n1 = 5, N1 = 10. Operands: `a`, `~r/abc/i`, `r`, `i` (sigil
4518        // modifiers), `b`, `~w(one two)`, `w`, `c`, `~s{hi}`, `s` →
4519        // n2 = N2 = 10.
4520        check_metrics::<ElixirParser>(
4521            "a = ~r/abc/i\nb = ~w(one two)\nc = ~s{hi}\n",
4522            "foo.ex",
4523            |metric| {
4524                assert_eq!(metric.halstead.unique_operators(), 2);
4525                assert_eq!(metric.halstead.total_operators(), 6);
4526                assert_eq!(metric.halstead.unique_operands(), 10);
4527                assert_eq!(metric.halstead.total_operands(), 10);
4528            },
4529        );
4530    }
4531
4532    #[test]
4533    fn elixir_sigil_delimiter_choice_is_invariant() {
4534        // Companion to the test above (#1256): two sigils differing
4535        // only in delimiter are the same literal, so every delimiter
4536        // choice must produce identical Halstead counts. `(` `[` `{`
4537        // `<` `/` `|` are the operator-kind delimiters the guard
4538        // covers; `"` and `'` never had an operator arm and pin the
4539        // already-correct path.
4540        //
4541        // expected per variant: operators `=`, `~` → n1 = 2, N1 = 2;
4542        // operands `x`, the sigil literal text, `w` (sigil name) →
4543        // n2 = 3, N2 = 3.
4544        for (open, close) in [
4545            ("(", ")"),
4546            ("[", "]"),
4547            ("{", "}"),
4548            ("<", ">"),
4549            ("/", "/"),
4550            ("|", "|"),
4551            ("\"", "\""),
4552            ("'", "'"),
4553        ] {
4554            assert_halstead_counts::<ElixirParser>(
4555                &format!("x = ~w{open}one two{close}\n"),
4556                "foo.ex",
4557                [2, 2, 3, 3],
4558                &format!("delimiter pair {open} {close}"),
4559            );
4560        }
4561    }
4562
4563    #[test]
4564    fn elixir_standalone_operators_survive_the_sigil_guard() {
4565        // Control for #1256: the guard is parent-scoped, so the same
4566        // token kinds outside a sigil still count. Covers every guarded
4567        // kind standalone: `/` (division), `<` / `>` (comparison), `[`
4568        // and `|` (list cons), `(` (call), `{` (map literal, with its
4569        // `%`).
4570        //
4571        // expected: operators `=` × 6, `/`, `<`, `>`, `[`, `|`, `(`,
4572        // `%`, `{` → n1 = 9, N1 = 14. Operands: `x`, `a`, `b`, `y`,
4573        // `c`, `d`, `z`, `e`, `f`, `q`, `h`, `t`, `p`, `g`, `1`, `m`,
4574        // the `k:` keyword, `2` → n2 = N2 = 18.
4575        check_metrics::<ElixirParser>(
4576            "x = a / b\ny = c < d\nz = e > f\nq = [h | t]\np = g(1)\nm = %{k: 2}\n",
4577            "foo.ex",
4578            |metric| {
4579                assert_eq!(metric.halstead.unique_operators(), 9);
4580                assert_eq!(metric.halstead.total_operators(), 14);
4581                assert_eq!(metric.halstead.unique_operands(), 18);
4582                assert_eq!(metric.halstead.total_operands(), 18);
4583            },
4584        );
4585    }
4586
4587    #[test]
4588    fn elixir_interpolated_sigil_keeps_inner_nodes_counting() {
4589        // Interpolation inside a sigil after #1256: the `{` delimiter
4590        // is suppressed (its parent is the `Sigil`), while the
4591        // `interpolation` child is a separate node whose inner
4592        // identifier must still count — the guard must not reach past
4593        // the delimiter tokens.
4594        //
4595        // expected: operators `=`, `~` → n1 = N1 = 2. Operands:
4596        // `v`, `s` (sigil name), `b` (interpolated identifier); the
4597        // wrapping sigil is skipped (`Interpolation` child, #180) and
4598        // `quoted_content` is unclassified → n2 = N2 = 3. The `#{`
4599        // marker was a third operator until #1314 dropped it.
4600        check_metrics::<ElixirParser>("v = ~s{a#{b} c}\n", "foo.ex", |metric| {
4601            assert_eq!(metric.halstead.unique_operators(), 2);
4602            assert_eq!(metric.halstead.total_operators(), 2);
4603            assert_eq!(metric.halstead.unique_operands(), 3);
4604            assert_eq!(metric.halstead.total_operands(), 3);
4605        });
4606
4607        // A guarded kind *inside* the interpolation: the `/` in
4608        // `#{a / b}` has `binary_operator` as its parent but the
4609        // `Sigil` as a further ancestor, so this input is the one
4610        // discriminator between the correct parent-scoped guard and a
4611        // wrong ancestor-scoped one that would swallow it.
4612        //
4613        // expected: operators `=`, `~`, `/` → n1 = N1 = 3 (the `#{`
4614        // opener stopped counting with #1314); operands `v`, `s`, `a`,
4615        // `b` → n2 = N2 = 4. The division is what this row is for, and
4616        // it still counts — the ancestor-scoped mutant drops it.
4617        check_metrics::<ElixirParser>("v = ~s{x #{a / b} y}\n", "foo.ex", |metric| {
4618            assert_eq!(metric.halstead.unique_operators(), 3);
4619            assert_eq!(metric.halstead.total_operators(), 3);
4620            assert_eq!(metric.halstead.unique_operands(), 4);
4621            assert_eq!(metric.halstead.total_operands(), 4);
4622        });
4623    }
4624
4625    #[test]
4626    fn bash_all_expansion_kinds_skip_wrapper() {
4627        // Exercises every node kind tested by
4628        // `bash_string_has_expansion`: `simple_expansion` (`$v`),
4629        // `expansion` (`${v[0]}`), `command_substitution` (`$(date)`),
4630        // and `arithmetic_expansion` (`$((1+2))`). A typo replacing
4631        // one kind with an aliased neighbour in `language_bash.rs`
4632        // (e.g., `ExpansionBody` instead of `Expansion`) would leave
4633        // the corresponding wrapping string counted as an operand and
4634        // shift the totals.
4635        //
4636        // expected: operands across the four lines —
4637        //   line 1 `a="$v"`: var_name `a`, simple_expansion `$v` (its
4638        //     inner var_name `v` leaf is suppressed under #695; wrapper
4639        //     skipped) → 2
4640        //   line 2 `b="${v[0]}"`: var_name `b`, var_name `v` (inside
4641        //     subscript — parent is `expansion`, not `simple_expansion`,
4642        //     so it still counts), number `0` (wrapper skipped,
4643        //     `expansion` itself is not in the operand list) → 3
4644        //   line 3 `c="$(date)"`: var_name `c`, the `word` `date` under
4645        //     the `command_name` (wrapper skipped, `command_substitution`
4646        //     not in operand list, and since #1351 the `command_name`
4647        //     wrapper is not either) → 2
4648        //   line 4 `d="$((1+2))"`: var_name `d`, numbers `1` and `2`
4649        //     (wrapper skipped, `arithmetic_expansion` not in operand
4650        //     list) → 3
4651        // Unique operands: a, b, c, d, $v, v, 0, date, 1, 2 → 10. Total
4652        // occurrences: 10 (`v` appears once — only line 2's subscript
4653        // leaf; line 1's `$v` inner leaf is suppressed — and `date` once,
4654        // as the `word`; before #1351 the `command_name` wrapping it
4655        // added a second `date` and N2 was 11). Operators after
4656        // #695: only the openers `[` (folded `[]`) and `+`, plus `=` four
4657        // times — the `}`/`)`/`))`/`]` closers no longer count.
4658        check_metrics::<BashParser>(
4659            "a=\"$v\"\nb=\"${v[0]}\"\nc=\"$(date)\"\nd=\"$((1+2))\"\n",
4660            "foo.sh",
4661            |metric| {
4662                assert_eq!(metric.halstead.unique_operators(), 3);
4663                assert_eq!(metric.halstead.total_operators(), 6);
4664                assert_eq!(metric.halstead.unique_operands(), 10);
4665                assert_eq!(metric.halstead.total_operands(), 10);
4666            },
4667        );
4668    }
4669
4670    /// Regression for #695. A bare `$x` (outside any string) parses as a
4671    /// `simple_expansion` wrapping a `variable_name` leaf — and `$?` / `$1`
4672    /// as a `simple_expansion` wrapping a `special_variable_name` leaf. Both
4673    /// the wrapper and the inner leaf used to be classified as operands, so
4674    /// each bare variable reference double-counted (the same hazard Tcl
4675    /// guards with its `Id2` exclusion and iRules with a parent check). The
4676    /// `variable_name` / `special_variable_name` arm now yields `Unknown`
4677    /// when its parent is a `simple_expansion`, so `$x` contributes exactly
4678    /// one operand while the assignment LHS `variable_name` (`x` in `x=…`,
4679    /// parent is `variable_assignment`) still counts.
4680    #[test]
4681    fn bash_bare_variable_no_double_count() {
4682        let source = "x=1\necho $x\necho $?\n";
4683        let path = PathBuf::from("foo.sh");
4684        let parser = BashParser::new(source.as_bytes().to_vec(), &path, None);
4685        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
4686        let bare_x = ops.operands.iter().filter(|o| o.as_str() == "$x").count();
4687        let special = ops.operands.iter().filter(|o| o.as_str() == "$?").count();
4688        // Each bare reference is exactly one operand; the inner leaf is not
4689        // double-counted. If the guard regressed, the inner `variable_name`
4690        // `x` would add a second `x` occurrence (text-colliding with the
4691        // assignment LHS) and the inner `special_variable_name` `?` would
4692        // appear as a standalone `?` operand.
4693        assert_eq!(
4694            bare_x, 1,
4695            "bare $x must be one operand; operands were {:?}",
4696            ops.operands
4697        );
4698        assert_eq!(
4699            special, 1,
4700            "bare $? must be one operand; operands were {:?}",
4701            ops.operands
4702        );
4703        assert!(
4704            !ops.operands.iter().any(|o| o.as_str() == "?"),
4705            "the inner special_variable_name `?` leaf must be suppressed; operands were {:?}",
4706            ops.operands
4707        );
4708        // The assignment LHS `variable_name` `x` (parent `variable_assignment`,
4709        // not `simple_expansion`) must still be an operand.
4710        assert!(
4711            ops.operands.iter().any(|o| o.as_str() == "x"),
4712            "assignment LHS `x` must still be an operand; operands were {:?}",
4713            ops.operands
4714        );
4715    }
4716
4717    /// Regression for #1351, the command-name sibling of #695's bare
4718    /// `$x`. `command_name` is a pure wrapper: the grammar gives it
4719    /// exactly one required child (a `_primary_expression` or a
4720    /// `concatenation`) and it adds no text of its own, so classifying it
4721    /// as an operand *and* letting the walk reach the child counted every
4722    /// command name twice in `N2`.
4723    ///
4724    /// The table is the grammar-dispatch §6 evidence that deleting the arm
4725    /// zeroes nothing: it names every kind `command_name` can wrap, and in
4726    /// every row the command name still contributes at least one operand
4727    /// once the trailing `arg` is discounted.
4728    ///
4729    /// `n2_before` / `N2_before` are what each row measured with the
4730    /// wrapper arm in place. They are not free-floating prose — the loop
4731    /// re-derives both from the current parse, because the arm was the
4732    /// only difference between the two classifications:
4733    ///
4734    /// - `N2_before - N2` must equal the number of `command_name` nodes.
4735    ///   That identity *is* the defect: one spurious operand per command
4736    ///   name.
4737    /// - `n2_before` must equal the size of the post-fix operand
4738    ///   vocabulary unioned with the `command_name` spellings. It exceeds
4739    ///   `n2` wherever the wrapper's whole text is not already an operand
4740    ///   in its own right — either because it differs from its single
4741    ///   child's (`"$cmd"`, `${cmd}`) or because it spans several
4742    ///   (`foo$x`, `$(which ls)`, `{1..3}`).
4743    ///
4744    /// A mistyped or stale column therefore fails rather than misinforming
4745    /// the next reader; one did, during review of this very fix.
4746    #[test]
4747    fn bash_command_name_wrapper_no_double_count() {
4748        // (source, [n1, N1, n2, N2], (n2_before, N2_before))
4749        let cases: [(&str, [u64; 4], (u64, u64)); 14] = [
4750            // word
4751            ("ls bar\n", [0, 0, 2, 2], (2, 3)),
4752            // number
4753            ("1 arg\n", [0, 0, 2, 2], (2, 3)),
4754            // string, inert
4755            ("\"ls\" arg\n", [0, 0, 2, 2], (2, 3)),
4756            // string wrapping an expansion: the wrapper string is already
4757            // skipped (#180), so before #1351 the `command_name` was the
4758            // only thing counting the quoted spelling — which also planted
4759            // a spurious `"$cmd"` entry in n2 beside `$cmd`.
4760            ("\"$cmd\" arg\n", [0, 0, 2, 2], (3, 3)),
4761            // raw_string
4762            ("'ls' arg\n", [0, 0, 2, 2], (2, 3)),
4763            // ansi_c_string
4764            ("$'ls' arg\n", [0, 0, 2, 2], (2, 3)),
4765            // translated_string. FIXME(#1358): N2 3 rather than 2 because
4766            // a `translated_string` wraps a `string` and both are
4767            // operands — the same wrapper/leaf shape as this fix, in the
4768            // same match, but reachable from an assignment RHS and a
4769            // `case` subject as well, so it is its own change. This row
4770            // pins today's wrong value; flip it with #1358.
4771            ("$\"ls\" arg\n", [0, 0, 3, 3], (3, 4)),
4772            // simple_expansion
4773            ("$cmd arg\n", [0, 0, 2, 2], (2, 3)),
4774            // brace expansion: counts through its inner `variable_name`,
4775            // whose `SimpleExpansion` parent guard does not apply here.
4776            ("${cmd} arg\n", [0, 0, 2, 2], (3, 3)),
4777            // command_substitution: counts through the nested command.
4778            // Two command names here — the outer one and `which`.
4779            ("$(which ls) arg\n", [0, 0, 3, 3], (4, 5)),
4780            // process_substitution, likewise two command names.
4781            ("<(ls) arg\n", [0, 0, 2, 2], (3, 4)),
4782            // arithmetic_expansion
4783            ("$((1+1)) arg\n", [1, 1, 2, 3], (3, 4)),
4784            // brace_expression
4785            ("{1..3} arg\n", [1, 1, 3, 3], (4, 4)),
4786            // concatenation
4787            ("foo$x arg\n", [0, 0, 3, 3], (4, 4)),
4788        ];
4789        let path = PathBuf::from("foo.sh");
4790        for (source, expected, (n2_before, total_before)) in cases {
4791            let code = source.as_bytes();
4792            let parser = BashParser::new(code.to_vec(), &path, None);
4793            let spellings: Vec<&str> = parser
4794                .root()
4795                .preorder()
4796                .filter(|node| node.kind_id() == Bash::CommandName as u16)
4797                .filter_map(|node| node.utf8_text(code))
4798                .collect();
4799            assert!(
4800                !spellings.is_empty(),
4801                "row {source:?} parses without a command_name, so it \
4802                 witnesses nothing",
4803            );
4804            // Phrased as an addition rather than `total_before -
4805            // expected[3]`: a future edit that inverts the two would
4806            // underflow `u64` and panic with a raw overflow message
4807            // instead of the one below.
4808            assert_eq!(
4809                total_before,
4810                expected[3] + spellings.len() as u64,
4811                "row {source:?} must shed exactly one operand per \
4812                 command_name; recorded N2_before {total_before}",
4813            );
4814
4815            let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
4816            let mut vocabulary: HashSet<&str> = ops.operands.iter().map(String::as_str).collect();
4817            vocabulary.extend(spellings);
4818            assert_eq!(
4819                vocabulary.len() as u64,
4820                n2_before,
4821                "row {source:?}: n2 before the fix is the post-fix \
4822                 vocabulary plus the command_name spellings; got \
4823                 {vocabulary:?}",
4824            );
4825
4826            assert!(
4827                expected[3] > 1,
4828                "row {source:?} must leave the command name at least one \
4829                 operand beside `arg`; a zero here means the deleted arm \
4830                 was load-bearing for this spelling",
4831            );
4832            assert_halstead_counts::<BashParser>(source, "foo.sh", expected, source);
4833        }
4834    }
4835
4836    /// Pins the one residue of #1351: a brace `expansion` with no
4837    /// `variable_name` leaf. `${#}` (positional-parameter count) and `${!}`
4838    /// (last background PID) hold only anonymous tokens, and
4839    /// `Bash::Expansion` is not an operand, so the whole expansion
4840    /// contributes nothing.
4841    ///
4842    /// That was already true in *argument* position before the fix, which
4843    /// is the reason grammar-dispatch §6's gate-don't-delete rule did not
4844    /// apply to the `command_name` arm: deleting it made command-name
4845    /// position agree with argument position rather than newly disagree.
4846    /// The parity is the load-bearing half of that argument and nothing
4847    /// else asserts it, so if a future arm starts classifying these the
4848    /// two positions have to move together.
4849    #[test]
4850    fn bash_operandless_expansion_scores_alike_in_both_positions() {
4851        // `${!}` carries a `!`, which the operator arm counts; `${#}`'s `#`
4852        // is not an operator. Both positions must agree per spelling.
4853        for (spelling, expected) in [("${#}", [0, 0, 1, 1]), ("${!}", [1, 1, 1, 1])] {
4854            let as_command_name = format!("{spelling} arg\n");
4855            let as_argument = format!("cmd {spelling}\n");
4856            assert_halstead_counts::<BashParser>(
4857                &as_command_name,
4858                "foo.sh",
4859                expected,
4860                &as_command_name,
4861            );
4862            assert_halstead_counts::<BashParser>(&as_argument, "foo.sh", expected, &as_argument);
4863        }
4864    }
4865
4866    /// Drift marker for #1351 (lesson 34 / grammar-dispatch §2). `_concat`
4867    /// (`Bash::Concat`) is a hidden zero-width external token the scanner
4868    /// emits between `concatenation` parts; the parser never surfaces it as
4869    /// a node, and it spells no operand, so `BashCode::get_op_type` lists
4870    /// neither it nor the visible `concatenation` wrapper. If a grammar
4871    /// bump starts emitting it, this fails and the classification must be
4872    /// re-derived rather than assumed still absent.
4873    ///
4874    /// Measured, not assumed: putting `Bash::Concat` back in the operand
4875    /// arm fails no test in the suite, because the token is unreachable.
4876    /// Unreachability is the only coverage such an arm can have, which is
4877    /// why this test asserts it directly instead of asserting a count.
4878    #[test]
4879    fn bash_hidden_concat_token_is_unreachable() {
4880        let source = "a=foo$x\nb=pre\"$y\"post\ncmd bar$z\n";
4881        let path = PathBuf::from("foo.sh");
4882        let parser = BashParser::new(source.as_bytes().to_vec(), &path, None);
4883        // Non-vacuity: the visible `concatenation` this source is written
4884        // to produce must actually be in the parse, so the negative below
4885        // is about `_concat` and not about a source that concatenates
4886        // nothing.
4887        assert!(
4888            ast_has_kind_id(&parser, Bash::Concatenation as u16),
4889            "expected a visible `concatenation` node in the parse",
4890        );
4891        assert!(
4892            !ast_has_kind_id(&parser, Bash::Concat as u16),
4893            "the hidden `_concat` token surfaced; re-derive its \
4894             classification in BashCode::get_op_type against the new grammar",
4895        );
4896    }
4897
4898    #[test]
4899    fn tcl_operators_and_operands() {
4900        check_metrics::<TclParser>(
4901            "proc f {a b} {
4902    set x [expr {$a + $b}]
4903    if {$x > 0 && $x != 0} {
4904        return $x
4905    }
4906    return 0
4907}",
4908            "foo.tcl",
4909            |metric| {
4910                // Anchored per the snapshot policy in AGENTS.md, which
4911                // this call predates. Operators `proc`, `set`, `[]`,
4912                // `{}`, `expr`, `+`, `if`, `>`, `&&`, `!=` → n1 = 10,
4913                // N1 = 14 (`{}` × 4 for the proc parameter list, the
4914                // proc body, the two `expr`/`if` conditions and the
4915                // `if` body — the `expr` braces are an `Expr`, the
4916                // bodies a `BracedWord`). Operands `f`, `a`, `b`, `x`,
4917                // `$a`, `$b`, `$x`, `0` and `return` → n2 = 9,
4918                // N2 = 14. Before #1354 the proc body and the `if`
4919                // body were operands too → 11 / 16.
4920                assert_eq!(metric.halstead.unique_operators(), 10);
4921                assert_eq!(metric.halstead.total_operators(), 14);
4922                assert_eq!(metric.halstead.unique_operands(), 9);
4923                assert_eq!(metric.halstead.total_operands(), 14);
4924                insta::assert_json_snapshot!(metric.halstead);
4925            },
4926        );
4927    }
4928
4929    #[test]
4930    fn tcl_bitwise_ternary_string_ops() {
4931        // Exercises operator families not covered by tcl_operators_and_operands:
4932        // bitwise (&, |, ^, ~, <<, >>), ternary (?), and string-comparison (eq, ne, in, ni).
4933        check_metrics::<TclParser>(
4934            "proc f {a b} {
4935    set bits [expr {$a & $b | $a ^ ~$b}]
4936    set sh [expr {$a << 1 | $b >> 1}]
4937    set t [expr {$a > 0 ? $a : $b}]
4938    if {$a eq {x} || $a ne {y}} {
4939        return $a
4940    }
4941    return $b
4942}",
4943            "foo.tcl",
4944            |metric| {
4945                // Anchored per the snapshot policy in AGENTS.md, which
4946                // this call predates. N1 fell 33 → 31 with #1314: the
4947                // `if` condition's `{x}` and `{y}` are braced *words*,
4948                // so their openers stopped fabricating a `{}` operator.
4949                // n1 is unchanged at 18 because the `{}` entry survives
4950                // on the proc body and the `expr` braces — which is
4951                // exactly why the fabrication was invisible in n1 and
4952                // is the reason to assert N1 as well (#1294).
4953                //
4954                // The operand columns fell 17 / 30 → 13 / 26 with
4955                // #1354, and the operator columns did not move: the two
4956                // script bodies (the proc's and the `if`'s) stopped
4957                // being operands, and the `x` / `y` inside the braced
4958                // words `{x}` and `{y}` are now part of the one operand
4959                // each word contributes.
4960                assert_eq!(metric.halstead.unique_operators(), 18);
4961                assert_eq!(metric.halstead.total_operators(), 31);
4962                assert_eq!(metric.halstead.unique_operands(), 13);
4963                assert_eq!(metric.halstead.total_operands(), 26);
4964                insta::assert_json_snapshot!(metric.halstead);
4965            },
4966        );
4967    }
4968
4969    #[test]
4970    fn tcl_array_reference_bills_the_reference_and_the_index() {
4971        // `$arr($i)` is the reference plus the index Tcl substitutes
4972        // inside the parens, and `arr(k)` as a `set` target is the name
4973        // plus the literal index; the `array_index` wrapper is neither.
4974        // The quoted spelling is deliberate — the vendored grammar
4975        // mis-parses a bare `$arr(k)` in command-word position. Pinned
4976        // per dialect and as a parity in `tests/parity/`, because the
4977        // iRules twin listed the wrapper as a third operand.
4978        assert_ops_operands::<TclParser>(
4979            "set arr(k) 1\nset z \"$arr($i)\"\n",
4980            "foo.tcl",
4981            6,
4982            vec!["arr", "k", "1", "z", "$arr($i)", "$i"],
4983        );
4984    }
4985
4986    #[test]
4987    fn tcl_bare_variable_operand() {
4988        // Bare `$varname` produces a VariableSubstitution node (already an operand).
4989        // Its anonymous Id2 child must NOT be counted separately; each reference is 1 operand.
4990        check_metrics::<TclParser>(
4991            "proc f {x} {
4992    return $x
4993}",
4994            "foo.tcl",
4995            |metric| {
4996                // Anchored per the snapshot policy in AGENTS.md, which
4997                // this call predates. Operators `proc` and `{}` × 2
4998                // (the parameter list and the body) → n1 = 2, N1 = 3.
4999                // Operands `f`, the parameter `x`, `return` and `$x` —
5000                // one occurrence each, so a re-counted `x` leaf inside
5001                // `$x` would show up in N2 even though it collides with
5002                // the parameter in n2. Before #1354 the proc body was a
5003                // fifth operand.
5004                assert_eq!(metric.halstead.unique_operators(), 2);
5005                assert_eq!(metric.halstead.total_operators(), 3);
5006                assert_eq!(metric.halstead.unique_operands(), 4);
5007                assert_eq!(metric.halstead.total_operands(), 4);
5008                insta::assert_json_snapshot!(metric.halstead);
5009            },
5010        );
5011    }
5012
5013    #[test]
5014    fn tcl_inert_quoted_word_counts_as_operand() {
5015        // Regression for #277. A `"..."` literal with no `$var` / `[cmd]`
5016        // interpolation must contribute exactly one operand (the wrapping
5017        // `QuotedWord`). The string content `hello world` is exposed as a
5018        // single `_quoted_word_content` token (not itself classified by
5019        // `get_op_type`), so the only operands here are `f`, `s`, and the
5020        // quoted string. `set` is the anonymous `Set2` keyword and is
5021        // classified as an operator, not an operand.
5022        check_metrics::<TclParser>(
5023            "proc f {} {
5024    set s \"hello world\"
5025}",
5026            "foo.tcl",
5027            |metric| {
5028                // Operands: `f`, the `set` target `s`, `"hello world"` —
5029                // 3 unique, 3 total. Before #1294 this read 3/3 for a
5030                // different reason, with `s` missing and the proc-body
5031                // `braced_word` making the count coincidentally
5032                // plausible; #1354 removed that body operand, so the
5033                // three named here are now the whole list. The wrapping
5034                // `QuotedWord` must still contribute exactly one operand
5035                // when it carries no interpolation children; dropping to 2
5036                // would mean the inert case was over-guarded.
5037                assert_eq!(metric.halstead.unique_operands(), 3);
5038                assert_eq!(metric.halstead.total_operands(), 3);
5039                insta::assert_json_snapshot!(metric.halstead);
5040            },
5041        );
5042    }
5043
5044    #[test]
5045    fn tcl_interpolated_quoted_word_no_double_count() {
5046        // Regression for #277. Before the fix, `"$x is $y"` produced an
5047        // extra operand for the wrapping `QuotedWord` on top of the two
5048        // inner `VariableSubstitution` operands (`$x`, `$y`), giving 7.
5049        // After the fix, the wrapper is `HalsteadType::Unknown` whenever
5050        // it carries an interpolation child, so operand attribution
5051        // belongs solely to the inner substitutions.
5052        check_metrics::<TclParser>(
5053            "proc f {x y} {
5054    set s \"$x is $y\"
5055}",
5056            "foo.tcl",
5057            |metric| {
5058                // Operands: `f`, `x`, `y` (proc args), the `set` target
5059                // `s`, `$x`, `$y` — 6 unique, 6 total. The wrapping
5060                // `QuotedWord` contributes nothing, and since #1354
5061                // neither does the proc-body `braced_word`. Before #277
5062                // the wrapper double-counted.
5063                assert_eq!(metric.halstead.unique_operands(), 6);
5064                assert_eq!(metric.halstead.total_operands(), 6);
5065                insta::assert_json_snapshot!(metric.halstead);
5066            },
5067        );
5068    }
5069
5070    #[test]
5071    fn tcl_command_substitution_quoted_word_no_double_count() {
5072        // Regression for #277. A `"...[cmd]..."` literal exposes the
5073        // bracketed command as a `command_substitution` child whose inner
5074        // identifiers/literals contribute their own operands. The wrapping
5075        // `QuotedWord` must not also be classified as an operand, or the
5076        // command's identifier would be counted alongside a phantom
5077        // wrapper operand.
5078        check_metrics::<TclParser>(
5079            "proc f {} {
5080    set s \"result: [foo]\"
5081}",
5082            "foo.tcl",
5083            |metric| {
5084                // Operands: `f`, the `set` target `s`, `foo` — 3 unique,
5085                // 3 total. The wrapping `QuotedWord` and the inert text
5086                // `result: ` do not contribute extra operands, and since
5087                // #1354 neither does the proc-body `braced_word`. Before
5088                // #277 the wrapper double-counted.
5089                assert_eq!(metric.halstead.unique_operands(), 3);
5090                assert_eq!(metric.halstead.total_operands(), 3);
5091                insta::assert_json_snapshot!(metric.halstead);
5092            },
5093        );
5094    }
5095
5096    /// Regression for #1294. The `set` target parses as the anonymous
5097    /// `id` token (`Tcl::Id2`) — the same kind as the leaf inside a
5098    /// `variable_substitution` — and the getter used to exclude that kind
5099    /// wholesale, so every variable a Tcl script assigned was absent from
5100    /// n2/N2. The guard is now parent-scoped: a target `id` counts, a
5101    /// var-sub leaf does not. Exact occurrence counts distinguish this
5102    /// fix from a regression in either direction: a re-blanketed
5103    /// exclusion drops `s`/`t` (total 2), while losing the guard
5104    /// double-counts the `$s` leaf as a second `s` (total 5).
5105    #[test]
5106    fn tcl_set_target_is_operand() {
5107        let source = "set s 1\nset t $s\n";
5108        let path = PathBuf::from("foo.tcl");
5109        let parser = TclParser::new(source.as_bytes().to_vec(), &path, None);
5110        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
5111        // expected operands: targets `s` and `t`, literal `1`, reference
5112        // `$s` (wrapper only) — 4 total, each exactly once.
5113        for operand in ["s", "t", "1", "$s"] {
5114            assert_eq!(
5115                ops.operands
5116                    .iter()
5117                    .filter(|o| o.as_str() == operand)
5118                    .count(),
5119                1,
5120                "`{operand}` must be exactly one operand; operands were {:?}",
5121                ops.operands
5122            );
5123        }
5124        assert_eq!(
5125            ops.operands.len(),
5126            4,
5127            "operands must be exactly s, t, 1, $s; got {:?}",
5128            ops.operands
5129        );
5130
5131        check_metrics::<TclParser>(source, "foo.tcl", |metric| {
5132            // expected: n2 = 4 (s, t, 1, $s), N2 = 4; operators are the
5133            // two `set` keywords — n1 = 1, N1 = 2.
5134            assert_eq!(metric.halstead.unique_operands(), 4);
5135            assert_eq!(metric.halstead.total_operands(), 4);
5136            assert_eq!(metric.halstead.unique_operators(), 1);
5137            assert_eq!(metric.halstead.total_operators(), 2);
5138        });
5139    }
5140
5141    /// Drift marker for #1294 (lesson 34 / grammar-dispatch §2): the
5142    /// *named* `id` rule (`Tcl::Id`, kind_id 84) never surfaces at the
5143    /// pinned tree-sitter-tcl — the parser emits the anonymous `Id2` in
5144    /// both positions the getter guards (the `set` target and the
5145    /// var-sub leaf). The `Tcl::Id` arm in `get_op_type` is therefore
5146    /// defensive; if a grammar bump starts emitting 84 this fails and
5147    /// the arm's classification must be re-derived instead of trusted.
5148    #[test]
5149    fn tcl_named_id_variant_is_unreachable() {
5150        let source = "proc f {x} {\n    set s $x\n    foreach v {1 2} { puts \"$v\" }\n}\n";
5151        let path = PathBuf::from("foo.tcl");
5152        let parser = TclParser::new(source.as_bytes().to_vec(), &path, None);
5153        // Non-vacuity: the anonymous token must be present in this parse
5154        // (both the `set` target and the `$x` / `$v` leaves emit it).
5155        assert!(
5156            ast_has_kind_id(&parser, Tcl::Id2 as u16),
5157            "expected the anonymous Tcl::Id2 token to appear in the parse",
5158        );
5159        assert!(
5160            !ast_has_kind_id(&parser, Tcl::Id as u16),
5161            "the named Tcl::Id rule surfaced; re-derive the defensive \
5162             `Tcl::Id` arm in TclCode::get_op_type against the new grammar",
5163        );
5164    }
5165
5166    #[test]
5167    fn tcl_braced_word_delimiter_is_not_an_operator() {
5168        // Regression: issue #1314, the Tcl sibling of Elixir #1256 and
5169        // Ruby/Perl #1312. A braced *word* — a literal value, not a
5170        // script — carries its `{` as an `LBRACE` child, the kind id a
5171        // real block uses, so `set a {braced word}` reported a `{}`
5172        // operator with no block in the source.
5173        //
5174        // expected: operator `set` × 3 → n1 = 1, N1 = 3. Operands
5175        // `a`, `b`, `c`, `$a` and `{braced word}` × 2 → n2 = 5,
5176        // N2 = 6. Before the guard the two openers added `{}` →
5177        // n1 = 2, N1 = 5; before #1354 the inner `braced` / `word`
5178        // counted alongside the word containing them → n2 = 7,
5179        // N2 = 10.
5180        check_metrics::<TclParser>(
5181            "set a {braced word}\nset b {braced word}\nset c $a\n",
5182            "foo.tcl",
5183            |metric| {
5184                assert_eq!(metric.halstead.unique_operators(), 1);
5185                assert_eq!(metric.halstead.total_operators(), 3);
5186                assert_eq!(metric.halstead.unique_operands(), 5);
5187                assert_eq!(metric.halstead.total_operands(), 6);
5188            },
5189        );
5190    }
5191
5192    #[test]
5193    fn tcl_script_bodies_keep_their_braces() {
5194        // Control for #1314, and the reason a kind-scoped guard is safe
5195        // in Tcl where it would not be elsewhere: the grammar gives the
5196        // literal and the block *different* kinds. A `proc` body, an
5197        // `if` body and an `if` condition are `BracedWord` (88) and
5198        // `Expr` (97); only the value form is `BracedWordSimple` (89).
5199        // This fixture nests a braced word inside a real script body,
5200        // so a guard that keyed on the brace alone would drop the
5201        // block's `{}` and fail here.
5202        //
5203        // expected: operators `proc`, `set` × 2, `if`, `>`, and `{}`
5204        // × 4 (the proc parameter list, the proc body, the `if`
5205        // condition, the `if` body) → n1 = 5, N1 = 9. Operands, all
5206        // distinct → n2 = N2 = 8: `p`, `x`, `a`, `$x`, `1`, `b` and
5207        // the two braced *words* `{v w}` and `{y}`, each one operand
5208        // rather than one per inner word.
5209        //
5210        // Before #1354 this read 13 / 13. The five extra entries were
5211        // the inner words `v`, `w`, `y` and the two *script* bodies,
5212        // which were `BracedWord` operands in their own right — so a
5213        // block counted twice over, once as the operand and once as
5214        // the `{}` operator this test is about. Both halves are gone;
5215        // the operator columns are what this test guards and they did
5216        // not move.
5217        check_metrics::<TclParser>(
5218            "proc p {x} {\n  set a {v w}\n  if {$x > 1} { set b {y} }\n}\n",
5219            "foo.tcl",
5220            |metric| {
5221                assert_eq!(metric.halstead.unique_operators(), 5);
5222                assert_eq!(metric.halstead.total_operators(), 9);
5223                assert_eq!(metric.halstead.unique_operands(), 8);
5224                assert_eq!(metric.halstead.total_operands(), 8);
5225            },
5226        );
5227    }
5228
5229    #[test]
5230    fn tcl_braced_word_guard_is_parent_scoped_not_ancestor_scoped() {
5231        // The input that separates the parent-scoped guard from the
5232        // ancestor-scanning mutant. I first recorded this distinction
5233        // as *unobservable* in Tcl, reasoning that a braced word holds
5234        // only simple words and nested braced words. `bca dump` says
5235        // otherwise: the grammar parses a `[…]` command substitution
5236        // inside a braced word, and the `if` inside it brings an `Expr`
5237        // condition and a `BracedWord` body, each with its own `{`,
5238        // both of them non-immediate descendants of the
5239        // `BracedWordSimple`. An ancestor scan swallows both.
5240        //
5241        // (Real Tcl does not substitute inside braces — this is the
5242        // grammar modelling structure it will not evaluate. What the
5243        // classifier sees is what the metric reports, so it is the
5244        // right fixture regardless.)
5245        //
5246        // expected: operators `set`, `[]`, `if`, and `{}` × 2 (the
5247        // `if` condition's `Expr` and its `BracedWord` body; the outer
5248        // value word's own `{` is suppressed) → n1 = 4, N1 = 5.
5249        // Operands `z`, the whole braced word, and `$q` / `puts` / `w`
5250        // from inside the command substitution → n2 = N2 = 5. Under
5251        // the ancestor-scoped mutant both surviving braces vanish:
5252        // n1 = 3, N1 = 3.
5253        //
5254        // #1354 widened the guard from the `{` alone to every direct
5255        // child of the braced word, which is why `x` and `v` are no
5256        // longer operands and the nested script body no longer is
5257        // either. It did not change the *scope*: the three operands
5258        // from inside the command substitution are grandchildren, and
5259        // an ancestor-scoped guard would drop them too.
5260        check_metrics::<TclParser>("set z {x [if {$q} {puts w}] v}\n", "foo.tcl", |metric| {
5261            assert_eq!(metric.halstead.unique_operators(), 4);
5262            assert_eq!(metric.halstead.total_operators(), 5);
5263            assert_eq!(metric.halstead.unique_operands(), 5);
5264            assert_eq!(metric.halstead.total_operands(), 5);
5265        });
5266    }
5267
5268    #[test]
5269    fn irules_braced_word_guard_is_parent_scoped_not_ancestor_scoped() {
5270        // The iRules twin of the test above — the two getters are
5271        // clones, so the mutant must fail in both.
5272        //
5273        // expected: operators `when`, `set`, `[]`, `if`, `{}` × 3 (the
5274        // handler body, the `if` condition and the `if` body) → n1 = 5,
5275        // N1 = 7. Operands `HTTP_REQUEST`, `z`, the whole braced word,
5276        // and `$q` / `log` / `w` from inside the command substitution →
5277        // n2 = N2 = 6 (10 before #1354, which also took the direct
5278        // children `x` / `v` and the two script bodies out of the
5279        // operand set without moving the operator columns this test
5280        // guards).
5281        check_metrics::<IrulesParser>(
5282            "when HTTP_REQUEST {\n  set z {x [if {$q} {log w}] v}\n}\n",
5283            "foo.irule",
5284            |metric| {
5285                assert_eq!(metric.halstead.unique_operators(), 5);
5286                assert_eq!(metric.halstead.total_operators(), 7);
5287                assert_eq!(metric.halstead.unique_operands(), 6);
5288                assert_eq!(metric.halstead.total_operands(), 6);
5289            },
5290        );
5291    }
5292
5293    /// How one dialect of the Tcl family spells the braced-word
5294    /// construct. The two grammars are deliberate clones with different
5295    /// id blocks, so #1354's guard is derived once and instantiated
5296    /// twice — and a fix that landed in only one dialect fails the
5297    /// second instantiation rather than going unnoticed.
5298    struct BracedWordKinds {
5299        /// `braced_word_simple`, the literal *value* form the guard
5300        /// keys on.
5301        wrapper: u16,
5302        /// `braced_word`, the *script* form #1354 gated on holding a
5303        /// command: an operand only when it holds none.
5304        script_body: u16,
5305        /// `comment`, the one named child of a script that is not a
5306        /// command and that the gate must not mistake for one.
5307        comment: u16,
5308        /// Every named kind node-types.json admits directly inside
5309        /// `wrapper`. A child outside this set means the grammar moved
5310        /// and the parent-keyed arm has to be re-derived
5311        /// (grammar-dispatch §1).
5312        children: [u16; 6],
5313        /// The `{` / `}` the wrapper also holds. Suppressing the opener
5314        /// was the whole of #1314's narrower guard; the closer has
5315        /// never been classified, since `get_operator_id_as_str` folds
5316        /// the pair to one `{}` glyph.
5317        delimiters: [u16; 2],
5318        /// Of `children`, the three the operand arm classified
5319        /// unconditionally before the guard.
5320        operand_children: [u16; 3],
5321        /// `quoted_word`, which was an operand only when *inert* —
5322        /// `string_operand_type`'s own rule, replicated here so the
5323        /// shed count below is what `N2` actually shed.
5324        quoted_word: u16,
5325        /// The interpolation kinds that decide that.
5326        interpolation: [u16; 2],
5327    }
5328
5329    const TCL_BRACED_WORD_KINDS: BracedWordKinds = BracedWordKinds {
5330        wrapper: Tcl::BracedWordSimple as u16,
5331        script_body: Tcl::BracedWord as u16,
5332        comment: Tcl::Comment as u16,
5333        children: [
5334            Tcl::SimpleWord as u16,
5335            Tcl::EscapedCharacter as u16,
5336            Tcl::QuotedWord as u16,
5337            Tcl::VariableSubstitution as u16,
5338            Tcl::CommandSubstitution as u16,
5339            Tcl::BracedWordSimple as u16,
5340        ],
5341        delimiters: [Tcl::LBRACE as u16, Tcl::RBRACE as u16],
5342        operand_children: [
5343            Tcl::SimpleWord as u16,
5344            Tcl::VariableSubstitution as u16,
5345            Tcl::BracedWordSimple as u16,
5346        ],
5347        quoted_word: Tcl::QuotedWord as u16,
5348        interpolation: [
5349            Tcl::VariableSubstitution as u16,
5350            Tcl::CommandSubstitution as u16,
5351        ],
5352    };
5353
5354    const IRULES_BRACED_WORD_KINDS: BracedWordKinds = BracedWordKinds {
5355        wrapper: Irules::BracedWordSimple as u16,
5356        script_body: Irules::BracedWord as u16,
5357        comment: Irules::Comment as u16,
5358        children: [
5359            Irules::SimpleWord as u16,
5360            Irules::EscapedCharacter as u16,
5361            Irules::QuotedWord as u16,
5362            Irules::VariableSubstitution as u16,
5363            Irules::CommandSubstitution as u16,
5364            Irules::BracedWordSimple as u16,
5365        ],
5366        delimiters: [Irules::LBRACE as u16, Irules::RBRACE as u16],
5367        operand_children: [
5368            Irules::SimpleWord as u16,
5369            Irules::VariableSubstitution as u16,
5370            Irules::BracedWordSimple as u16,
5371        ],
5372        quoted_word: Irules::QuotedWord as u16,
5373        interpolation: [
5374            Irules::VariableSubstitution as u16,
5375            Irules::CommandSubstitution as u16,
5376        ],
5377    };
5378
5379    /// The operand occurrences #1354 removed from `source`, as their
5380    /// source texts, plus the set of `braced_word_simple` child kinds
5381    /// the fixture witnessed.
5382    ///
5383    /// Walks with `for_each_node_with_chain`, which maintains the
5384    /// ancestor chain exactly as `spaces::compute` does, so "parent"
5385    /// here means what `Ancestors::parent` means inside the guard.
5386    /// Doubles as the grammar-dispatch §1 / §2 drift marker: a child of
5387    /// the wrapper outside `children` ∪ `delimiters` fails on the spot,
5388    /// which is what makes keying the arm on the parent alone — rather
5389    /// than on an enumerated child list — safe to rely on.
5390    fn braced_word_shed<L: crate::LanguageInfo>(
5391        source: &str,
5392        kinds: &BracedWordKinds,
5393    ) -> (Vec<String>, HashSet<u16>) {
5394        let code = source.as_bytes();
5395        let mut shed = Vec::new();
5396        let mut witnessed = HashSet::new();
5397        for_each_node_with_chain::<L>(code, |node, chain| {
5398            let text = || {
5399                node.utf8_text(code)
5400                    .expect("fixture is valid UTF-8")
5401                    .to_owned()
5402            };
5403            // The script form was an operand of its own wherever it
5404            // appeared until #1354, which now gates it on holding a
5405            // command — a named child that is not a comment. This is not
5406            // a child of the wrapper, so it is counted before the parent
5407            // test below.
5408            if node.kind_id() == kinds.script_body
5409                && node
5410                    .children()
5411                    .any(|child| child.is_named() && child.kind_id() != kinds.comment)
5412            {
5413                shed.push(text());
5414            }
5415            if chain.last().is_none_or(|p| p.kind_id() != kinds.wrapper) {
5416                return;
5417            }
5418            assert!(
5419                kinds.children.contains(&node.kind_id())
5420                    || kinds.delimiters.contains(&node.kind_id()),
5421                "`{source}`: a `{}` inside a braced word is a child this guard \
5422                 was not derived against; re-read node-types.json before \
5423                 trusting it",
5424                node.kind(),
5425            );
5426            if kinds.children.contains(&node.kind_id()) {
5427                witnessed.insert(node.kind_id());
5428            }
5429            let was_operand = kinds.operand_children.contains(&node.kind_id())
5430                || (node.kind_id() == kinds.quoted_word && !node.wraps_any(&kinds.interpolation));
5431            if was_operand {
5432                shed.push(text());
5433            }
5434        });
5435        (shed, witnessed)
5436    }
5437
5438    /// One row of the #1354 tables: a fixture, what it measures now,
5439    /// what it measured before, and the operand texts behind the counts.
5440    struct BracedWordCase {
5441        source: &'static str,
5442        /// `[n1, N1, n2, N2]` with the guard in place.
5443        counts: [u64; 4],
5444        /// `[n2, N2]` without it. Re-derived by the loop rather than
5445        /// trusted, so a stale row fails instead of misinforming.
5446        before: [u64; 2],
5447        operands: &'static [&'static str],
5448    }
5449
5450    /// Every row is measured in *both* dialects, so a fix applied to
5451    /// one getter and not its clone fails here. `braced_word_shed`'s
5452    /// drift assertion likewise runs against both grammars.
5453    fn check_braced_word_cases<T: crate::ParserTrait, L: crate::LanguageInfo>(
5454        cases: &[BracedWordCase],
5455        file: &str,
5456        kinds: &BracedWordKinds,
5457    ) -> HashSet<u16> {
5458        let mut witnessed = HashSet::new();
5459        for case in cases {
5460            let (shed, seen) = braced_word_shed::<L>(case.source, kinds);
5461            witnessed.extend(seen);
5462
5463            // Phrased as an addition rather than a subtraction so a
5464            // future edit that inverts the two underflows nothing.
5465            assert_eq!(
5466                case.before[1],
5467                case.counts[3] + shed.len() as u64,
5468                "{file} `{}`: N2 must shed exactly one occurrence per \
5469                 previously-billed part; recorded {}, parts {shed:?}",
5470                case.source,
5471                case.before[1],
5472            );
5473            let mut vocabulary: HashSet<&str> = case.operands.iter().copied().collect();
5474            vocabulary.extend(shed.iter().map(String::as_str));
5475            assert_eq!(
5476                vocabulary.len() as u64,
5477                case.before[0],
5478                "{file} `{}`: n2 before the fix is the post-fix vocabulary \
5479                 plus those parts; got {vocabulary:?}",
5480                case.source,
5481            );
5482
5483            assert_halstead_counts::<T>(case.source, file, case.counts, case.source);
5484            assert_ops_operands::<T>(
5485                case.source,
5486                file,
5487                case.operands.len(),
5488                case.operands.to_vec(),
5489            );
5490        }
5491        witnessed
5492    }
5493
5494    /// The rows shared by both dialects: one per named child kind
5495    /// `braced_word_simple` admits, the childless spelling, the
5496    /// repeated-value row that separates `n2` from `N2` (#1294), and
5497    /// the braced/quoted parity pair #1317 asks for.
5498    const BRACED_WORD_CASES: [BracedWordCase; 11] = [
5499        // simple_word, the reported fixture. Two words inside one
5500        // value scored two operands beside the value itself.
5501        BracedWordCase {
5502            source: "set x {literal here}\n",
5503            counts: [1, 1, 2, 2],
5504            before: [4, 4],
5505            operands: &["x", "{literal here}"],
5506        },
5507        // The childless spelling, and the reason the wrapper is kept
5508        // rather than dropped in favour of its contents
5509        // (grammar-dispatch §6): with nothing inside, the wrapper is
5510        // the empty string's only carrier. Nothing is shed here, so
5511        // this row asserts the two columns agree.
5512        BracedWordCase {
5513            source: "set y {}\n",
5514            counts: [1, 1, 2, 2],
5515            before: [2, 2],
5516            operands: &["y", "{}"],
5517        },
5518        // braced_word_simple inside braced_word_simple: the nesting
5519        // that makes the over-count unbounded in depth. One value
5520        // spelled six vocabulary entries.
5521        BracedWordCase {
5522            source: "set a {x {y z}}\n",
5523            counts: [1, 1, 2, 2],
5524            before: [6, 6],
5525            operands: &["a", "{x {y z}}"],
5526        },
5527        // quoted_word, inert — an operand in its own right elsewhere,
5528        // and shed here.
5529        BracedWordCase {
5530            source: "set a {x \"q w\" v}\n",
5531            counts: [1, 1, 2, 2],
5532            before: [5, 5],
5533            operands: &["a", "{x \"q w\" v}"],
5534        },
5535        // quoted_word carrying an interpolation, which was *not* an
5536        // operand before the guard either (`string_operand_type` had
5537        // already suppressed it) — so only `x` and `v` are shed. Its
5538        // `$q` is a grandchild of the braced word and still counts,
5539        // which is the parent-scoping this arm inherits from #1314.
5540        BracedWordCase {
5541            source: "set a {x \"$q\" v}\n",
5542            counts: [1, 1, 3, 3],
5543            before: [5, 5],
5544            operands: &["a", "$q", "{x \"$q\" v}"],
5545        },
5546        // escaped_character, never classified — so it sheds nothing
5547        // and the row measures the two `simple_word`s around it.
5548        BracedWordCase {
5549            source: "set a {x \\n y}\n",
5550            counts: [1, 1, 2, 2],
5551            before: [4, 4],
5552            operands: &["a", "{x \\n y}"],
5553        },
5554        // variable_substitution. Tcl substitutes nothing between
5555        // braces, so `{$x}` is the two-character string `$x` — the row
5556        // that makes "the wrapper is the value" more than a tie-break.
5557        BracedWordCase {
5558            source: "set a {$x}\n",
5559            counts: [1, 1, 2, 2],
5560            before: [3, 3],
5561            operands: &["a", "{$x}"],
5562        },
5563        // command_substitution, likewise never an operand itself. Its
5564        // interior is, and stays so: `$q`, `puts` and `w` are
5565        // grandchildren. The nested `{puts w}` is a *script* body and
5566        // sheds under the other half of #1354.
5567        BracedWordCase {
5568            source: "set z {x [if {$q} {puts w}] v}\n",
5569            counts: [4, 5, 5, 5],
5570            before: [8, 8],
5571            operands: &["z", "$q", "puts", "w", "{x [if {$q} {puts w}] v}"],
5572        },
5573        // The same value twice: n2 3 against N2 4, so a row that
5574        // asserted only the vocabulary could not tell the two axes
5575        // apart (#1294).
5576        BracedWordCase {
5577            source: "set a {b c}\nset d {b c}\n",
5578            counts: [1, 2, 3, 4],
5579            before: [5, 8],
5580            operands: &["a", "d", "{b c}"],
5581        },
5582        // The parity pair #1317 named: two spellings of one literal
5583        // value must score alike. They did not before — braced 4 / 4
5584        // against quoted 2 / 2 — which is the spelling sensitivity
5585        // #695, #1312 and #1314 each removed elsewhere.
5586        BracedWordCase {
5587            source: "set a {one two}\n",
5588            counts: [1, 1, 2, 2],
5589            before: [4, 4],
5590            operands: &["a", "{one two}"],
5591        },
5592        BracedWordCase {
5593            source: "set a \"one two\"\n",
5594            counts: [1, 1, 2, 2],
5595            before: [2, 2],
5596            operands: &["a", "\"one two\""],
5597        },
5598    ];
5599
5600    /// The script half of #1354, shared by both dialects: a
5601    /// `braced_word` holding commands is a block whose contents the
5602    /// walk already counts, so it is no longer also an operand
5603    /// spanning the whole block.
5604    ///
5605    /// The three childless rows are the gate, and the reason this is a
5606    /// gate and not a deletion (grammar-dispatch §6). `braced_word` is
5607    /// not only the script kind: it is the value slot of every command
5608    /// the grammar does not special-case, where `lappend l {}` is an
5609    /// empty list whose brace pair is its only carrier. Deleting the
5610    /// kind scored that zero while its `lappend l ""` synonym — the
5611    /// last row, the control — scored one. An empty `proc` body is
5612    /// spelled identically and so also keeps an operand; no
5613    /// kind-scoped arm can separate the two roles.
5614    const SCRIPT_BODY_CASES: [BracedWordCase; 9] = [
5615        BracedWordCase {
5616            source: "proc p {} { set b 1 }\n",
5617            counts: [3, 4, 3, 3],
5618            before: [4, 4],
5619            operands: &["p", "b", "1"],
5620        },
5621        BracedWordCase {
5622            source: "proc p {} {}\n",
5623            counts: [2, 3, 2, 2],
5624            before: [2, 2],
5625            operands: &["p", "{}"],
5626        },
5627        // A comment is a named child of the script but not a command,
5628        // and no arm bills it, so a comment-only body scores like the
5629        // empty one above rather than like nothing: the block's whole
5630        // text is its one operand. Gating on "any named child" billed
5631        // it zero — adding a comment to an empty block lowered N2.
5632        BracedWordCase {
5633            source: "proc p {} {\n    # only a comment\n}\n",
5634            counts: [2, 3, 2, 2],
5635            before: [2, 2],
5636            operands: &["p", "{\n    # only a comment\n}"],
5637        },
5638        BracedWordCase {
5639            source: "if {$q} {\n    # noop\n}\n",
5640            counts: [2, 3, 2, 2],
5641            before: [2, 2],
5642            operands: &["$q", "{\n    # noop\n}"],
5643        },
5644        // The control: a comment *beside* a command changes nothing, so a
5645        // gate keyed on "contains a comment" would fail this row.
5646        BracedWordCase {
5647            source: "proc p {} {\n    # c\n    set b 1\n}\n",
5648            counts: [3, 4, 3, 3],
5649            before: [4, 4],
5650            operands: &["p", "b", "1"],
5651        },
5652        // The value-role twin: inside a literal string Tcl performs no
5653        // substitution, so `# x` is not a comment at all, and the word
5654        // is its one operand exactly as `lappend l {}` below is.
5655        BracedWordCase {
5656            source: "lappend l {\n    # x\n}\n",
5657            counts: [1, 1, 3, 3],
5658            before: [3, 3],
5659            operands: &["lappend", "l", "{\n    # x\n}"],
5660        },
5661        // A `braced_word` in *value* position — the #1318 misparse,
5662        // where the same kind carries a literal list. Its interior
5663        // words counted before and still do; only the whole-block
5664        // operand that was double-billing them is gone.
5665        BracedWordCase {
5666            source: "lappend l {a b}\n",
5667            counts: [1, 1, 4, 4],
5668            before: [5, 5],
5669            operands: &["lappend", "l", "a", "b"],
5670        },
5671        // …and the childless spelling of that value, which sheds
5672        // nothing: the brace pair is the empty list's only carrier.
5673        BracedWordCase {
5674            source: "lappend l {}\nreturn {}\n",
5675            counts: [1, 2, 4, 5],
5676            before: [4, 5],
5677            operands: &["lappend", "l", "return", "{}"],
5678        },
5679        // The control the row above is measured against: the quoted
5680        // spelling of the same empty value, which never depended on
5681        // the arm and must keep scoring one operand.
5682        BracedWordCase {
5683            source: "lappend l \"\"\nreturn \"\"\n",
5684            counts: [0, 0, 4, 5],
5685            before: [4, 5],
5686            operands: &["lappend", "l", "return", "\"\""],
5687        },
5688    ];
5689
5690    /// The table must exercise every named child kind the grammar
5691    /// admits inside a braced word, and no other — the other half of
5692    /// the drift marker in `braced_word_shed`, which can only police
5693    /// kinds a fixture actually produces.
5694    fn assert_braced_word_children_witnessed(
5695        witnessed: &HashSet<u16>,
5696        kinds: &BracedWordKinds,
5697        dialect: &str,
5698    ) {
5699        let mut got: Vec<u16> = witnessed.iter().copied().collect();
5700        got.sort_unstable();
5701        let mut expected = kinds.children;
5702        expected.sort_unstable();
5703        assert_eq!(
5704            got.as_slice(),
5705            expected.as_slice(),
5706            "{dialect}: the fixtures must witness every child kind \
5707             node-types.json admits inside a braced word",
5708        );
5709    }
5710
5711    /// Regression for #1354 and #1317. `braced_word_simple` is a
5712    /// literal value and `braced_word` a script, and both were
5713    /// operands *beside* the content the walk counts anyway: a braced
5714    /// word was billed once per inner word plus once for itself, and a
5715    /// block once for every command in it plus once for its whole
5716    /// text. `set x {literal here}` scored n2 4 / N2 4 for one value.
5717    /// The name is the invariant either way — the value's content is
5718    /// the literal, the script's is its commands, and each is billed
5719    /// once.
5720    ///
5721    /// Each row's `before` column is re-derived by the loop from the
5722    /// current parse rather than trusted. That derivation is a replica
5723    /// of the pre-#1354 arms and could in principle drift from what
5724    /// they did, so every column was also measured directly against a
5725    /// build of the old getters — `lappend l {a b}` at n2 5 / N2 5,
5726    /// `lappend l {}` at 4 / 5, `proc p {} {}` at 2 / 2 — rather than
5727    /// derived only from the model here.
5728    ///
5729    /// The same walk doubles as the drift marker for the parent-keyed
5730    /// arm: it fails if the grammar ever puts a seventh kind directly
5731    /// inside a braced word, and the union assertion below fails if a
5732    /// bump stops emitting one of the six.
5733    #[test]
5734    fn tcl_braced_word_bills_its_content_once_1354() {
5735        let mut witnessed = check_braced_word_cases::<TclParser, TclCode>(
5736            &BRACED_WORD_CASES,
5737            "foo.tcl",
5738            &TCL_BRACED_WORD_KINDS,
5739        );
5740        witnessed.extend(check_braced_word_cases::<TclParser, TclCode>(
5741            &SCRIPT_BODY_CASES,
5742            "foo.tcl",
5743            &TCL_BRACED_WORD_KINDS,
5744        ));
5745        assert_braced_word_children_witnessed(&witnessed, &TCL_BRACED_WORD_KINDS, "tcl");
5746    }
5747
5748    /// The iRules twin. The tables are shared, so a fix that reached
5749    /// only `src/getter/tcl.rs` fails every row here — the two getters
5750    /// are deliberate clones and #1354 names both.
5751    #[test]
5752    fn irules_braced_word_bills_its_content_once_1354() {
5753        let mut witnessed = check_braced_word_cases::<IrulesParser, IrulesCode>(
5754            &BRACED_WORD_CASES,
5755            "foo.irule",
5756            &IRULES_BRACED_WORD_KINDS,
5757        );
5758        witnessed.extend(check_braced_word_cases::<IrulesParser, IrulesCode>(
5759            &SCRIPT_BODY_CASES,
5760            "foo.irule",
5761            &IRULES_BRACED_WORD_KINDS,
5762        ));
5763        // The `when` handler body is the iRules-only spelling of a
5764        // script body, and the largest instance of the defect: its
5765        // operand text was the entire event handler.
5766        let handlers: [BracedWordCase; 3] = [
5767            BracedWordCase {
5768                source: "when HTTP_REQUEST { set x 1 }\n",
5769                counts: [3, 3, 3, 3],
5770                before: [4, 4],
5771                operands: &["HTTP_REQUEST", "x", "1"],
5772            },
5773            BracedWordCase {
5774                source: "when HTTP_REQUEST {}\n",
5775                counts: [2, 2, 2, 2],
5776                before: [2, 2],
5777                operands: &["HTTP_REQUEST", "{}"],
5778            },
5779            // The comment-only twin of the empty handler above; see the
5780            // shared table for why it scores like it.
5781            BracedWordCase {
5782                source: "when HTTP_REQUEST {\n    # only a comment\n}\n",
5783                counts: [2, 2, 2, 2],
5784                before: [2, 2],
5785                operands: &["HTTP_REQUEST", "{\n    # only a comment\n}"],
5786            },
5787        ];
5788        witnessed.extend(check_braced_word_cases::<IrulesParser, IrulesCode>(
5789            &handlers,
5790            "foo.irule",
5791            &IRULES_BRACED_WORD_KINDS,
5792        ));
5793        assert_braced_word_children_witnessed(&witnessed, &IRULES_BRACED_WORD_KINDS, "irules");
5794    }
5795
5796    #[test]
5797    fn php_operators_and_operands() {
5798        check_metrics::<PhpParser>(
5799            "<?php
5800            function avg(int $a, int $b, int $c): int {
5801                return ($a + $b + $c) / 3;
5802            }",
5803            "foo.php",
5804            |metric| {
5805                // After #695 only the opening delimiters count: `()` and
5806                // `{}` fold to one operator each per balanced pair, so the
5807                // former `)`/`}` closers no longer inflate n1/N1 (was
5808                // 11 unique / 15 total).
5809                //
5810                // Operands after #1293, tallied by `get_id` (source bytes):
5811                //   `avg` × 1, `int` × 4 (the `primitive_type` wrapper at
5812                //   all four type positions — its `int` keyword child is
5813                //   suppressed under it), `$a` / `$b` / `$c` × 2 each,
5814                //   `3` × 1 ⇒ n2 = 6, N2 = 12. Between #1259 and #1293 the
5815                //   keyword leaf doubled the type count ⇒ 6 / 16; before
5816                //   #1259 each `$v` also contributed its sigil-less `name`
5817                //   leaf ⇒ 9 / 22.
5818                assert_eq!(metric.halstead.unique_operators(), 9);
5819                assert_eq!(metric.halstead.total_operators(), 12);
5820                assert_eq!(metric.halstead.unique_operands(), 6);
5821                assert_eq!(metric.halstead.total_operands(), 12);
5822                insta::assert_json_snapshot!(metric.halstead);
5823            },
5824        );
5825    }
5826
5827    #[test]
5828    fn php_simple_function() {
5829        check_metrics::<PhpParser>(
5830            "<?php
5831            function inc(int $x): int { return $x + 1; }",
5832            "foo.php",
5833            |metric| {
5834                // After #695 only opening delimiters count: the `)`/`}`
5835                // closers no longer add operators (was 9 unique / 9 total).
5836                //
5837                // Operands after #1293: `inc` × 1, `int` × 2 (the
5838                // `primitive_type` wrapper at both type positions, its
5839                // `int` keyword child suppressed under it), `$x` × 2,
5840                // `1` × 1 ⇒ n2 = 4, N2 = 6. Between #1259 and #1293 the
5841                // keyword leaf doubled the type count ⇒ 4 / 8; before
5842                // #1259 `$x` also contributed its `x` leaf twice ⇒ 5 / 10.
5843                assert_eq!(metric.halstead.unique_operators(), 7);
5844                assert_eq!(metric.halstead.total_operators(), 7);
5845                assert_eq!(metric.halstead.unique_operands(), 4);
5846                assert_eq!(metric.halstead.total_operands(), 6);
5847                insta::assert_json_snapshot!(metric.halstead);
5848            },
5849        );
5850    }
5851
5852    #[test]
5853    fn php_variable_reference_counts_once() {
5854        // Regression: issue #1259. `$x` parses as a `variable_name`
5855        // wrapping a `name` leaf, and both kinds were in the operand
5856        // arm — so every variable reference contributed twice to N2 and
5857        // planted a sigil-less twin (`x` beside `$x`) in the n2
5858        // vocabulary. Since `$var` is the most common token class in
5859        // PHP, that roughly doubled N2 for real files.
5860        //
5861        // Source: the issue's reproducer plus a re-reference of `$a` and
5862        // `$b`, so N2 exceeds n2 and the assertions can tell "counted
5863        // once per occurrence" from "deduplicated into the vocabulary".
5864        //   <?php $a = null; $b = true; $c = NULL; $a = $b;
5865        //
5866        // Operands by text key: `$a` × 2, `$b` × 2, `$c`, `null`, `true`,
5867        // `NULL` ⇒ n2 = 6, N2 = 8. Before the fix the `a` / `b` / `c`
5868        // leaves added 3 unique and 5 occurrences ⇒ 9 / 13.
5869        check_metrics::<PhpParser>(
5870            "<?php\n$a = null;\n$b = true;\n$c = NULL;\n$a = $b;\n",
5871            "foo.php",
5872            |metric| {
5873                assert_eq!(metric.halstead.unique_operands(), 6);
5874                assert_eq!(metric.halstead.total_operands(), 8);
5875            },
5876        );
5877    }
5878
5879    #[test]
5880    fn php_dynamic_variable_name_counts_once_at_any_depth() {
5881        // Regression: issue #1259. Variable-variable syntax nests the
5882        // wrappers, so the double count compounds: `$$a` is a
5883        // `dynamic_variable_name` → `variable_name` → `name` chain that
5884        // scored 3 for one reference, and `$$$b` scored 4. Only the
5885        // outermost wrapper may count.
5886        //
5887        // Source: <?php $$a = 1; $$$b = 2; ${$c} = 3; $$a = 4;
5888        // The trailing re-assignment repeats `$$a` so N2 exceeds n2 and
5889        // the assertions can tell "counted once per occurrence" from
5890        // "deduplicated into the vocabulary".
5891        //
5892        // Operands: `$$a` × 2, `$$$b`, `${$c}`, `1`, `2`, `3`, `4`
5893        // ⇒ n2 = 7, N2 = 8. Before the fix: 14 / 17 (measured), each
5894        // target contributing its whole nesting chain — `$$a` → `$a` →
5895        // `a` is 3 (twice over), `$$$b` → `$$b` → `$b` → `b` is 4, and
5896        // `${$c}` → `$c` → `c` is 3, plus the four integers.
5897        check_metrics::<PhpParser>(
5898            "<?php $$a = 1; $$$b = 2; ${$c} = 3; $$a = 4;",
5899            "foo.php",
5900            |metric| {
5901                assert_eq!(metric.halstead.unique_operands(), 7);
5902                assert_eq!(metric.halstead.total_operands(), 8);
5903            },
5904        );
5905    }
5906
5907    #[test]
5908    fn php_dynamic_variable_name_guard_is_parent_scoped() {
5909        // Companion to the two tests above (#1259): the guards fire on
5910        // the *parent* kind, never on the kind alone, so the two
5911        // positions where a nested node is a reference in its own right
5912        // keep counting.
5913        //
5914        // Source: <?php $y = "brace ${z} end"; $s = ${$a . 'b'};
5915        //
5916        // `"${z}"` is a `dynamic_variable_name` whose `name` child is
5917        // suppressed (the wrapper `${z}` carries the reference), while
5918        // `${$a . 'b'}` reaches its `$a` through a `binary_expression`,
5919        // so that `variable_name`'s parent is not a
5920        // `dynamic_variable_name` and it counts normally.
5921        //
5922        // Operands: `$y`, `${z}`, `$s`, `${$a . 'b'}`, `$a`, `'b'` — one
5923        // each ⇒ n2 = 6, N2 = 6. A guard written as a blanket kind
5924        // exclusion instead of a parent check would drop `$a` ⇒ 5 / 5.
5925        check_metrics::<PhpParser>(
5926            "<?php $y = \"brace ${z} end\"; $s = ${$a . 'b'};",
5927            "foo.php",
5928            |metric| {
5929                assert_eq!(metric.halstead.unique_operands(), 6);
5930                assert_eq!(metric.halstead.total_operands(), 6);
5931            },
5932        );
5933    }
5934
5935    #[test]
5936    fn php_type_wrappers_count_the_type_once() {
5937        // Regression: issue #1293. A parameter type nests wrapper nodes
5938        // whose text spans the node below them — `primitive_type` around
5939        // the `int` keyword token, `named_type` around a `name`,
5940        // `optional_type` around either — and every level was in the
5941        // operand arm, so `int` scored 2 and `?int` scored 3.
5942        //
5943        // Source: the issue's first reproducer.
5944        //   <?php
5945        //   function f(int $a, bool $b, float $c, string $d, array $e,
5946        //              Foo $g): ?int { return 0; }
5947        //
5948        // Operands by text key: `f`, the five `primitive_type` parameter
5949        // types, `Foo` (the `name` under its `named_type`), `$a`..`$g`,
5950        // the return `int`, and `0`. `int` occurs twice (parameter and
5951        // return) ⇒ n2 = 14, N2 = 15. Before the fix: 15 / 23 — the
5952        // extra vocabulary entry being `?int`, which the `?` operator
5953        // already accounts for.
5954        check_metrics::<PhpParser>(
5955            "<?php\nfunction f(int $a, bool $b, float $c, string $d, \
5956             array $e, Foo $g): ?int { return 0; }\n",
5957            "foo.php",
5958            |metric| {
5959                assert_eq!(metric.halstead.unique_operands(), 14);
5960                assert_eq!(metric.halstead.total_operands(), 15);
5961            },
5962        );
5963    }
5964
5965    #[test]
5966    fn php_qualified_name_counts_its_components_once() {
5967        // Regression: issue #1293. `Foo\Bar\Baz` parses as
5968        // `qualified_name` → `namespace_name` → `name` × N, and all
5969        // three kinds were operands, so one three-part path scored 5 and
5970        // planted `Foo`, `Foo\Bar` and `Foo\Bar\Baz` in the vocabulary.
5971        // The components carry the operand and `\` stays an operator,
5972        // matching how PHP's own `::` and `->` already read here.
5973        //
5974        // Source: the issue's second reproducer.
5975        //   <?php
5976        //   namespace App\Sub;
5977        //   use Foo\Bar\Baz;
5978        //   $o = new \Vendor\Pkg\Thing();
5979        //
5980        // Operands: `App`, `Sub`, `Foo`, `Bar`, `Baz`, `$o`, `Vendor`,
5981        // `Pkg`, `Thing` — one each ⇒ n2 = 9, N2 = 9. Before the fix:
5982        // 14 / 14.
5983        check_metrics::<PhpParser>(
5984            "<?php\nnamespace App\\Sub;\nuse Foo\\Bar\\Baz;\n\
5985             $o = new \\Vendor\\Pkg\\Thing();\n",
5986            "foo.php",
5987            |metric| {
5988                assert_eq!(metric.halstead.unique_operands(), 9);
5989                assert_eq!(metric.halstead.total_operands(), 9);
5990            },
5991        );
5992    }
5993
5994    #[test]
5995    fn php_nested_type_wrappers_count_once_at_any_depth() {
5996        // Companion to the two tests above (#1293): the type and
5997        // qualified-name wrappers compose, so a single annotation can
5998        // stack five levels — `?A\B` is `optional_type` → `named_type` →
5999        // `qualified_name` → `namespace_name` → `name`, which scored 6
6000        // operands for two identifiers. `union_type` and
6001        // `intersection_type` stack the same way over their members;
6002        // their `|` and `&` are already operators.
6003        //
6004        // Source:
6005        //   <?php function k(?A\B $p, int|string $q, C&D $r): ?A\B
6006        //   { return 0; }
6007        // The return type repeats `?A\B` so N2 exceeds n2 and the
6008        // assertions can tell "counted once per occurrence" from
6009        // "deduplicated into the vocabulary".
6010        //
6011        // Operands: `k`, `A` × 2, `B` × 2, `$p`, `int`, `string`, `$q`,
6012        // `C`, `D`, `$r`, `0` ⇒ n2 = 11, N2 = 13.
6013        check_metrics::<PhpParser>(
6014            "<?php function k(?A\\B $p, int|string $q, C&D $r): ?A\\B { return 0; }",
6015            "foo.php",
6016            |metric| {
6017                assert_eq!(metric.halstead.unique_operands(), 11);
6018                assert_eq!(metric.halstead.total_operands(), 13);
6019            },
6020        );
6021    }
6022
6023    #[test]
6024    fn php_childless_primitive_types_still_count() {
6025        // Guards the direction of the #1293 fix for `primitive_type`,
6026        // where — unlike the qualified-name wrappers — the *wrapper*
6027        // carries the operand and the keyword leaf is suppressed. The
6028        // grammar emits no token node under `primitive_type` for
6029        // `callable`, `iterable`, `mixed`, `void`, `false` or `true`
6030        // (verified with `bca dump`), so the other direction would score
6031        // those six types zero — grammar-dispatch §6.
6032        //
6033        // Source:
6034        //   <?php function q(callable $a, iterable $b, mixed $c,
6035        //                    false $d, true $e): void { }
6036        //
6037        // Operands: `q`, `callable`, `iterable`, `mixed`, `false`,
6038        // `true`, `void`, `$a`..`$e` ⇒ n2 = 12, N2 = 12. Dropping
6039        // `PrimitiveType` from the operand arm instead of gating its
6040        // leaf gives 6 / 6.
6041        check_metrics::<PhpParser>(
6042            "<?php function q(callable $a, iterable $b, mixed $c, \
6043             false $d, true $e): void { }",
6044            "foo.php",
6045            |metric| {
6046                assert_eq!(metric.halstead.unique_operands(), 12);
6047                assert_eq!(metric.halstead.total_operands(), 12);
6048            },
6049        );
6050    }
6051
6052    #[test]
6053    fn php_primitive_type_keyword_guard_is_parent_scoped() {
6054        // Companion to the test above (#1293): the keyword suppression
6055        // fires on the *parent* kind, never on the kind alone.
6056        // `array` is also the head token of an `array(…)` literal, where
6057        // it is the construct's only operand and must keep counting; a
6058        // `(int)` / `(string)` cast is a childless `cast_type` that
6059        // never reaches the guard at all.
6060        //
6061        // Source: <?php $x = array(1, 2); $y = (int) $x; $z = (string) $x;
6062        //
6063        // Operands: `$x` × 3, `array`, `1`, `2`, `$y`, `int`, `$z`,
6064        // `string` ⇒ n2 = 8, N2 = 10. A blanket kind exclusion instead
6065        // of a parent check would drop the `array` head ⇒ 7 / 9.
6066        check_metrics::<PhpParser>(
6067            "<?php $x = array(1, 2); $y = (int) $x; $z = (string) $x;",
6068            "foo.php",
6069            |metric| {
6070                assert_eq!(metric.halstead.unique_operands(), 8);
6071                assert_eq!(metric.halstead.total_operands(), 10);
6072            },
6073        );
6074    }
6075
6076    #[test]
6077    fn php_encapsed_string_interpolation_no_double_count() {
6078        // Regression: issue #184. A PHP `"Hello $name!"` used to be
6079        // classified as a Halstead operand (the wrapping
6080        // `encapsed_string`) AND have its inner `variable_name`
6081        // (`$name`) plus the inner `name` token classified as
6082        // operands too. With the fix, the wrapping literal drops to
6083        // `Unknown` when it carries any `$var` / `${name}` / `{$expr}`
6084        // child, so `$name` is counted exactly once at each text
6085        // occurrence.
6086        //
6087        // Source:
6088        //   <?php $name = "world"; echo "Hello $name!";
6089        //
6090        // Inert operand: `"world"` (no interpolation, still operand).
6091        // Operands by text key (`get_id` keys by source bytes):
6092        //   `$name` × 2 (assignment LHS and `$name` inside the
6093        //   interpolated string), `"world"` × 1.
6094        // u_operands = 2, N2 = 3.
6095        // Without the #184 fix the wrapping `"Hello $name!"` would also
6096        // count → 3 / 4. This test additionally pinned the *inner* `name`
6097        // leaf of each `variable_name` (a further 2 occurrences, 1 unique
6098        // ⇒ the historical 3 / 5) until #1259 recognised that as the same
6099        // double count one level down.
6100        check_metrics::<PhpParser>(
6101            "<?php $name = \"world\"; echo \"Hello $name!\";",
6102            "foo.php",
6103            |metric| {
6104                assert_eq!(metric.halstead.unique_operands(), 2);
6105                assert_eq!(metric.halstead.total_operands(), 3);
6106            },
6107        );
6108    }
6109
6110    #[test]
6111    fn php_encapsed_string_no_interpolation_still_operand() {
6112        // The fix for #184 only drops `EncapsedString`/`Heredoc` from
6113        // the operand arm when interpolation is present. An inert
6114        // double-quoted string must still count as exactly one
6115        // operand, identical to the single-quoted equivalent.
6116        //
6117        // Source: `<?php echo "Hello world!";`
6118        // Operands: `"Hello world!"` × 1 → u_operands = 1, N2 = 1.
6119        check_metrics::<PhpParser>("<?php echo \"Hello world!\";", "foo.php", |metric| {
6120            assert_eq!(metric.halstead.unique_operands(), 1);
6121            assert_eq!(metric.halstead.total_operands(), 1);
6122        });
6123    }
6124
6125    #[test]
6126    fn php_heredoc_interpolation_no_double_count() {
6127        // Regression: issue #184. A PHP heredoc whose body
6128        // interpolates `$name` previously counted both the wrapping
6129        // `heredoc` node and the inner `$name` as operands; the fix
6130        // drops the wrapper when its `heredoc_body` carries any
6131        // interpolation child.
6132        //
6133        // Source:
6134        //   <?php $name = "x"; echo <<<EOT
6135        //   hi $name
6136        //   EOT;
6137        //
6138        // Operands by text key: `$name` × 2, `"x"` × 1 (inert encapsed
6139        // string, still an operand). With the fix u_operands = 2,
6140        // N2 = 3. Without it the wrapping heredoc text would add one
6141        // more unique operand. The sigil-less `name` leaf inside each
6142        // `variable_name` was counted too until #1259.
6143        check_metrics::<PhpParser>(
6144            "<?php $name = \"x\"; echo <<<EOT\nhi $name\nEOT;\n",
6145            "foo.php",
6146            |metric| {
6147                assert_eq!(metric.halstead.unique_operands(), 2);
6148                assert_eq!(metric.halstead.total_operands(), 3);
6149            },
6150        );
6151    }
6152
6153    #[test]
6154    fn php_nowdoc_unaffected() {
6155        // `Nowdoc` (single-quoted heredoc) never interpolates and is
6156        // never matched by `php_string_has_interpolation`. It must
6157        // continue counting as exactly one operand regardless of the
6158        // text inside, mirroring single-quoted `String`.
6159        //
6160        // Source:
6161        //   <?php echo <<<'EOT'
6162        //   plain $name not interpolated
6163        //   EOT;
6164        //
6165        // Operands: the nowdoc literal × 1 → u_operands = 1, N2 = 1.
6166        check_metrics::<PhpParser>(
6167            "<?php echo <<<'EOT'\nplain $name not interpolated\nEOT;\n",
6168            "foo.php",
6169            |metric| {
6170                assert_eq!(metric.halstead.unique_operands(), 1);
6171                assert_eq!(metric.halstead.total_operands(), 1);
6172            },
6173        );
6174    }
6175
6176    #[test]
6177    fn php_encapsed_string_bare_member_access_no_double_count() {
6178        // Regression: issue #184 follow-up. The PHP grammar allows
6179        // bare `$obj->prop` interpolation inside `"…"` without
6180        // surrounding `{ … }`; tree-sitter-php emits this as a
6181        // direct `member_access_expression` child of
6182        // `encapsed_string` (kind_id 329 in the current grammar).
6183        // The wrapper must drop to `Unknown` for that form too —
6184        // otherwise the inner `$obj` and `prop` `name` tokens are
6185        // walked as operands while the wrapper also counts,
6186        // double-counting `N2`.
6187        //
6188        // Source:
6189        //   <?php $obj = new stdClass; $obj->prop = "x"; echo "Hi $obj->prop!";
6190        //
6191        // Operands tallied by `get_id` (keyed on source bytes):
6192        //   `$obj`        × 3 (LHS assignment, member-access target,
6193        //                      inside the interpolated string)
6194        //   `prop` (name) × 2 (member-access RHS twice — a bare `name`
6195        //                      outside any `variable_name`, so #1259's
6196        //                      guard leaves it an operand)
6197        //   `stdClass`    × 1
6198        //   `"x"`         × 1
6199        // ⇒ u_operands = 4, N2 = 7.
6200        // With the bug the wrapping `"Hi $obj->prop!"` text adds one
6201        // more unique operand and one more occurrence ⇒ 5 / 8.
6202        check_metrics::<PhpParser>(
6203            "<?php $obj = new stdClass; $obj->prop = \"x\"; echo \"Hi $obj->prop!\";",
6204            "foo.php",
6205            |metric| {
6206                assert_eq!(metric.halstead.unique_operands(), 4);
6207                assert_eq!(metric.halstead.total_operands(), 7);
6208            },
6209        );
6210    }
6211
6212    #[test]
6213    fn php_encapsed_string_bare_subscript_no_double_count() {
6214        // Regression: issue #184 follow-up. Bare `$arr[0]` inside
6215        // `"…"` produces a `subscript_expression` child of
6216        // `encapsed_string` (kind_id 351). The wrapper must drop to
6217        // `Unknown` for that form.
6218        //
6219        // Source:
6220        //   <?php $arr = [1]; echo "Hi $arr[0]!";
6221        //
6222        // Operands tallied by `get_id`:
6223        //   `$arr` × 2, `1` × 1, `0` × 1.
6224        // ⇒ u_operands = 3, N2 = 4.
6225        // With the bug the wrapping `"Hi $arr[0]!"` text adds 1 / 1.
6226        // The inner `arr` leaf of each `variable_name` added a further
6227        // 1 / 2 until #1259.
6228        check_metrics::<PhpParser>(
6229            "<?php $arr = [1]; echo \"Hi $arr[0]!\";",
6230            "foo.php",
6231            |metric| {
6232                assert_eq!(metric.halstead.unique_operands(), 3);
6233                assert_eq!(metric.halstead.total_operands(), 4);
6234            },
6235        );
6236    }
6237
6238    #[test]
6239    fn php_shell_command_expression_inert_is_operand() {
6240        // Regression: issue #288. Backtick command literals (PHP's
6241        // `shell_command_expression`) were filtered as strings by
6242        // `Checker::is_string` and `Alterator::alterate`, but never
6243        // classified as Halstead operands — so they contributed
6244        // nothing to N2 / eta2. An inert backtick literal must now
6245        // count as exactly one operand, matching `EncapsedString`
6246        // and `Heredoc`.
6247        //
6248        // Source: `<?php $out = ` + backtick `ls` + backtick + `;`
6249        // Operands tallied by `get_id`:
6250        //   `$out` × 1, backtick literal × 1.
6251        // ⇒ u_operands = 2, N2 = 2.
6252        // Before the fix the backtick literal vanished from the count
6253        // ⇒ u_operands = 1, N2 = 1. (The inner `out` leaf of the
6254        // `variable_name` added another 1 / 1 until #1259.)
6255        check_metrics::<PhpParser>("<?php $out = `ls`;", "foo.php", |metric| {
6256            assert_eq!(metric.halstead.unique_operands(), 2);
6257            assert_eq!(metric.halstead.total_operands(), 2);
6258        });
6259    }
6260
6261    #[test]
6262    fn php_shell_command_expression_interpolation_no_double_count() {
6263        // Regression: issue #288. PHP backtick literals DO support
6264        // `$var` interpolation (see tree-sitter-php node-types.json:
6265        // `shell_command_expression` children include `variable_name`,
6266        // `dynamic_variable_name`, `member_access_expression`,
6267        // `subscript_expression`). With the fix the wrapper drops to
6268        // `Unknown` when it carries any interpolation child, exactly
6269        // as `EncapsedString` does.
6270        //
6271        // Source: `<?php $dir = "/tmp"; $out = ` + backtick `ls $dir` +
6272        //   backtick + `;`
6273        //
6274        // Operands tallied by `get_id`:
6275        //   `$dir` × 2 (assignment LHS, inside backticks),
6276        //   `$out` × 1, `"/tmp"` × 1.
6277        // ⇒ u_operands = 3, N2 = 4.
6278        // Without the interpolation guard the wrapping backtick literal
6279        // would also count ⇒ u_operands = 4, N2 = 5. The sigil-less
6280        // `dir` / `out` leaves added a further 2 / 3 until #1259.
6281        check_metrics::<PhpParser>(
6282            "<?php $dir = \"/tmp\"; $out = `ls $dir`;",
6283            "foo.php",
6284            |metric| {
6285                assert_eq!(metric.halstead.unique_operands(), 3);
6286                assert_eq!(metric.halstead.total_operands(), 4);
6287            },
6288        );
6289    }
6290
6291    #[test]
6292    fn php_interpolation_opener_is_not_an_operator() {
6293        // Regression: issue #1314. `Php::LBRACE` is *both* the
6294        // compound-statement brace and the complex-interpolation
6295        // opener, so `"dq {$y} end"` reported a `{}` operator — and
6296        // reported it against the same vocabulary entry a real block
6297        // uses, which no other language does.
6298        //
6299        // expected: operators `=` × 2, `;` × 2 → n1 = 2, N1 = 4.
6300        // Operands `$s`, `$t`, `$y` × 2 → n2 = 3, N2 = 4. Before the
6301        // guard the two openers added `{}` → n1 = 3, N1 = 6.
6302        check_metrics::<PhpParser>(
6303            "<?php\n$s = \"dq {$y} end\";\n$t = \"dq {$y} end\";\n",
6304            "foo.php",
6305            |metric| {
6306                assert_eq!(metric.halstead.unique_operators(), 2);
6307                assert_eq!(metric.halstead.total_operators(), 4);
6308                assert_eq!(metric.halstead.unique_operands(), 3);
6309                assert_eq!(metric.halstead.total_operands(), 4);
6310            },
6311        );
6312    }
6313
6314    #[test]
6315    fn php_interpolation_opener_guard_covers_every_wrapper() {
6316        // The opener is a direct child of four distinct parents, and
6317        // each is an independent leg of the guard (grammar-dispatch
6318        // section 11) — a fixture covering only `encapsed_string`
6319        // leaves the other three dead.
6320        //
6321        // One row per parent, so a failure names the leg that broke.
6322        // The heredoc's brace hangs off `heredoc_body` rather than
6323        // `heredoc`; the backtick form is `shell_command_expression`;
6324        // and the bare `${$y}` variable-variable is a
6325        // `dynamic_variable_name`, the one position that is not inside
6326        // a string at all.
6327        //
6328        // expected per row: operators `=` × 2, `;` × 2 → n1 = 2,
6329        // N1 = 4; three distinct operands with one repeated → n2 = 3,
6330        // N2 = 4.
6331        for (label, source) in [
6332            (
6333                "encapsed_string",
6334                "<?php\n$s = \"dq {$y} end\";\n$t = \"dq {$y} end\";\n",
6335            ),
6336            (
6337                "heredoc_body",
6338                "<?php\n$h = <<<EOT\na {$y} b\nEOT;\n$i = <<<EOT\na {$y} b\nEOT;\n",
6339            ),
6340            (
6341                "shell_command_expression",
6342                "<?php\n$b = `ls {$y}`;\n$c = `ls {$y}`;\n",
6343            ),
6344            ("dynamic_variable_name", "<?php\n$q = ${$y};\n$r = ${$y};\n"),
6345        ] {
6346            assert_halstead_counts::<PhpParser>(source, "foo.php", [2, 4, 3, 4], label);
6347        }
6348    }
6349
6350    #[test]
6351    fn php_every_interpolation_spelling_scores_alike() {
6352        // The policy stated as a test (#1314). PHP writes one
6353        // interpolation three ways; the choice is spelling, so all
6354        // three must score identically. Before the guard the two
6355        // braced forms reported a `{}` the bare `$y` form did not.
6356        //
6357        // `"${y}"` is deprecated as of PHP 8.2 and removed in 9.0, but
6358        // the pinned grammar still parses it and it is still in the
6359        // wild, so it stays a row here.
6360        //
6361        // The fixture deliberately omits a `$y = …` declaration: with
6362        // one, the bare and `{$y}` forms key their operand as `$y` and
6363        // collapse into the declaration's entry while `${y}` keys as
6364        // `${y}` and does not, so n2 would differ for a reason that has
6365        // nothing to do with this guard.
6366        //
6367        // expected per spelling: operators `=` × 2, `;` × 2 → n1 = 2,
6368        // N1 = 4; operands `$s`, `$t`, the interpolated reference × 2
6369        // → n2 = 3, N2 = 4.
6370        for literal in ["\"a $y b\"", "\"a {$y} b\"", "\"a ${y} b\""] {
6371            assert_halstead_counts::<PhpParser>(
6372                &format!("<?php\n$s = {literal};\n$t = {literal};\n"),
6373                "foo.php",
6374                [2, 4, 3, 4],
6375                &format!("interpolation {literal}"),
6376            );
6377        }
6378    }
6379
6380    #[test]
6381    fn php_compound_statement_brace_still_counts() {
6382        // Control for #1314: the guard is scoped to the four
6383        // interpolating wrappers, so a real block keeps its `{}`. A
6384        // guard widened to every `LBRACE` would take `{}` out of the
6385        // operator set entirely and fail here.
6386        //
6387        // expected: operators `function`, `()`, `{}` × 2, `if`,
6388        // `return`, `;` → n1 = 6, N1 = 8. Operands `f`, `1`, `2` →
6389        // n2 = N2 = 3.
6390        check_metrics::<PhpParser>(
6391            "<?php\nfunction f() { if (1) { return 2; } }\n",
6392            "foo.php",
6393            |metric| {
6394                assert_eq!(metric.halstead.unique_operators(), 6);
6395                assert_eq!(metric.halstead.total_operators(), 8);
6396                assert_eq!(metric.halstead.unique_operands(), 3);
6397                assert_eq!(metric.halstead.total_operands(), 3);
6398            },
6399        );
6400    }
6401
6402    #[test]
6403    fn php_interpolation_guard_is_parent_scoped_not_ancestor_scoped() {
6404        // The input that separates the parent-scoped guard from the
6405        // ancestor-scanning mutant — the mutant #1256's post-mortem
6406        // says survives every ordinary fixture. PHP is one of only two
6407        // languages in #1314 where such an input exists: a closure
6408        // inside a complex interpolation puts a *compound-statement*
6409        // brace under an `encapsed_string` ancestor while its parent is
6410        // the `compound_statement`. An ancestor scan swallows it.
6411        //
6412        // expected: operators `=`, `;` × 2, `->`, `()` × 2, `function`,
6413        // `{}`, `return` → n1 = 7, N1 = 9. Operands `$s`, `$o`, `m`,
6414        // `1` → n2 = N2 = 4. Under the ancestor-scoped mutant the
6415        // closure's brace vanishes: n1 = 6, N1 = 8.
6416        check_metrics::<PhpParser>(
6417            "<?php\n$s = \"{$o->m(function() { return 1; })}\";\n",
6418            "foo.php",
6419            |metric| {
6420                assert_eq!(metric.halstead.unique_operators(), 7);
6421                assert_eq!(metric.halstead.total_operators(), 9);
6422                assert_eq!(metric.halstead.unique_operands(), 4);
6423                assert_eq!(metric.halstead.total_operands(), 4);
6424            },
6425        );
6426    }
6427
6428    #[test]
6429    fn elixir_operators_and_operands() {
6430        // Exercises every Halstead family classified in Elixir's
6431        // `get_op_type`: control-flow keywords (`do`, `end`, `fn`),
6432        // structural punctuation — only the *opening* delimiters `(`,
6433        // `[` count after #695 (the `)`/`]` closers were dropped), plus
6434        // `,`, `.`, `@`,
6435        // arithmetic (`+`, `-`, `*`, `/`), comparison (`==`, `>`),
6436        // logical (`&&`, `||`, `and`, `or`, `!`), pipe (`|>`), capture
6437        // (`&`), assignment/match (`=`), and the stab arrow (`->`).
6438        // The body mixes identifiers, integers, atoms, and a string.
6439        check_metrics::<ElixirParser>(
6440            "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",
6441            "foo.ex",
6442            |metric| {
6443                // Positive headline assertions on integer counts. After
6444                // #695 only opening delimiters count: the `)`/`]` closers
6445                // no longer add operators (was 15 unique / 23 total).
6446                assert_eq!(metric.halstead.unique_operators(), 13);
6447                assert_eq!(metric.halstead.total_operators(), 21);
6448                assert_eq!(metric.halstead.unique_operands(), 16);
6449                assert_eq!(metric.halstead.total_operands(), 27);
6450                insta::assert_json_snapshot!(
6451                    metric.halstead,
6452                    @r#"
6453                {
6454                  "unique_operators": 13,
6455                  "total_operators": 21,
6456                  "unique_operands": 16,
6457                  "total_operands": 27,
6458                  "length": 48,
6459                  "estimated_program_length": 112.10571633583419,
6460                  "purity_ratio": 2.3355357569965456,
6461                  "vocabulary": 29,
6462                  "volume": 233.18308776612344,
6463                  "difficulty": 10.96875,
6464                  "level": 0.09116809116809117,
6465                  "effort": 2557.7269939346666,
6466                  "time": 142.09594410748147,
6467                  "bugs": 0.062342115670886794
6468                }
6469                "#
6470                );
6471            },
6472        );
6473    }
6474
6475    #[test]
6476    fn ruby_operators_and_operands() {
6477        // A small Ruby method exercising operators (def/if/end keyword
6478        // tokens, `+`, `==`, `<=`, structural punctuation) and operands
6479        // (`n`, `1`, `factorial`). Anchors the unique/total counts on
6480        // both sides and snapshots the full Halstead derivation.
6481        //
6482        // Lesson 4 invariants: u_operators / u_operands here equal the
6483        // dedupe lengths the `--ops` accessor would emit on the same
6484        // source. Any future grammar bump that adds an aliased kind_id
6485        // to either side will trip this without snapshot drift.
6486        check_metrics::<RubyParser>(
6487            "def factorial(n)\n  return 1 if n <= 1\n  n * factorial(n - 1)\nend\n",
6488            "foo.rb",
6489            |metric| {
6490                // After #695 only the `(` opener counts (folded `()`); the
6491                // `)` closer — which appeared twice across the two calls —
6492                // no longer adds an operator (was 9 unique / 11 total).
6493                assert_eq!(metric.halstead.unique_operators(), 8);
6494                assert_eq!(metric.halstead.total_operators(), 9);
6495                assert_eq!(metric.halstead.unique_operands(), 3);
6496                assert_eq!(metric.halstead.total_operands(), 9);
6497                insta::assert_json_snapshot!(metric.halstead);
6498            },
6499        );
6500    }
6501
6502    #[test]
6503    fn ruby_halstead_plain_string_operand() {
6504        // A bare string literal contributes exactly one operand. The
6505        // counterpart to `ruby_halstead_interpolated_string_no_double_count`
6506        // — verifies the "no interpolation" branch of the same arm
6507        // (see `src/getter.rs::get_op_type`'s `R::String | …` case).
6508        // expected: operators = {def, end} = 2; operands = {f, "hello"} = 2.
6509        check_metrics::<RubyParser>("def f\n  \"hello\"\nend\n", "foo.rb", |metric| {
6510            assert_eq!(metric.halstead.unique_operators(), 2);
6511            assert_eq!(metric.halstead.total_operators(), 2);
6512            assert_eq!(metric.halstead.unique_operands(), 2);
6513            assert_eq!(metric.halstead.total_operands(), 2);
6514        });
6515    }
6516
6517    #[test]
6518    fn ruby_halstead_interpolated_string_no_double_count() {
6519        // Regression mirror for #180 (Bash) / #183 (C#): when a Ruby
6520        // string literal carries an `Interpolation` child, the
6521        // wrapping `String` node is intentionally classified as
6522        // `Unknown` so the inner expression's identifiers are not
6523        // double-counted as operands.
6524        //
6525        // expected: for `def f(name)\n  "Hi #{name}"\nend\n` —
6526        //   operators: def, (, ), #{, }, end → u_operators = 6.
6527        //   operands: f, name (param), name (inside `#{name}`). The
6528        //   wrapping `"…#{name}"` literal is skipped by the
6529        //   `is_child(R::Interpolation)` guard; the operand store
6530        //   keys by token text so the two `name` occurrences dedupe
6531        //   into one distinct entry → u_operands = 2, operands = 3
6532        //   (`f` once, `name` twice).
6533        // Without the guard, the wrapping literal would also count,
6534        // inflating u_operands to 3 and operands to 4.
6535        check_metrics::<RubyParser>("def f(name)\n  \"Hi #{name}\"\nend\n", "foo.rb", |metric| {
6536            assert_eq!(metric.halstead.unique_operands(), 2);
6537            assert_eq!(metric.halstead.total_operands(), 3);
6538        });
6539    }
6540
6541    #[test]
6542    fn ruby_halstead_symbol_literal_operand() {
6543        // `:foo` is a `SimpleSymbol` leaf — counts as a single
6544        // operand, no interpolation guard needed (only
6545        // `DelimitedSymbol` (`:"…#{x}…"`) can interpolate).
6546        // expected: operators = {def, end} = 2; operands = {f, :ok} = 2.
6547        check_metrics::<RubyParser>("def f\n  :ok\nend\n", "foo.rb", |metric| {
6548            assert_eq!(metric.halstead.unique_operators(), 2);
6549            assert_eq!(metric.halstead.unique_operands(), 2);
6550        });
6551    }
6552
6553    #[test]
6554    fn ruby_halstead_regex_operand() {
6555        // `/foo/` parses as a `Regex` node — one operand. Its two
6556        // `SLASH` delimiters used to fall through to the shared
6557        // arithmetic arm and add a `/` operator that is nowhere in the
6558        // source; #1312 parent-guards them to `Unknown`.
6559        // expected: u_operators = {def, (, =~, end} = 4, N1 = 4 (only
6560        // the `(` opener counts after #695 — the `)` closer was
6561        // dropped; was 5 with the fabricated `/`); u_operands =
6562        // {f, s, /foo/} = 3, N2 = 4 (`s` twice: parameter and use).
6563        check_metrics::<RubyParser>("def f(s)\n  s =~ /foo/\nend\n", "foo.rb", |metric| {
6564            assert_eq!(metric.halstead.unique_operators(), 4);
6565            assert_eq!(metric.halstead.total_operators(), 4);
6566            assert_eq!(metric.halstead.unique_operands(), 3);
6567            assert_eq!(metric.halstead.total_operands(), 4);
6568        });
6569    }
6570
6571    #[test]
6572    fn ruby_regex_delimiters_are_not_operators() {
6573        // Regression: issue #1312, the Ruby sibling of Elixir #1256.
6574        // Both of a `Regex` literal's delimiter tokens are `SLASH` —
6575        // the same kind id real division uses — so `x = /abc/`
6576        // reported a `/` operator with no division in the source.
6577        //
6578        // expected: operators `=` → n1 = N1 = 1. Operands `x` and the
6579        // `/abc/` literal → n2 = N2 = 2. Before the guard the two
6580        // delimiters added `/` → n1 = 2, N1 = 3.
6581        check_metrics::<RubyParser>("x = /abc/\n", "foo.rb", |metric| {
6582            assert_eq!(metric.halstead.unique_operators(), 1);
6583            assert_eq!(metric.halstead.total_operators(), 1);
6584            assert_eq!(metric.halstead.unique_operands(), 2);
6585            assert_eq!(metric.halstead.total_operands(), 2);
6586        });
6587    }
6588
6589    #[test]
6590    fn ruby_regex_delimiter_choice_is_invariant() {
6591        // Companion to the test above (#1312): `%r`-form regexes are
6592        // the same literal spelled differently, so every delimiter
6593        // choice must produce identical counts. tree-sitter-ruby
6594        // aliases all of them to `SLASH` — verified with `bca dump`,
6595        // which shows `%r{`/`}`, `%r(`/`)`, `%r[`/`]`, `%r<`/`>`,
6596        // `%r|`/`|` and `%r!`/`!` every one emitting kind `SLASH` —
6597        // so each row here genuinely exercises the guard rather than
6598        // reaching a different, already-clean path.
6599        //
6600        // expected per variant: operator `=` → n1 = N1 = 1; operands
6601        // `x` and the literal → n2 = N2 = 2.
6602        for literal in [
6603            "/abc/", "%r{abc}", "%r(abc)", "%r[abc]", "%r<abc>", "%r|abc|", "%r!abc!",
6604        ] {
6605            assert_halstead_counts::<RubyParser>(
6606                &format!("x = {literal}\n"),
6607                "foo.rb",
6608                [1, 1, 2, 2],
6609                &format!("regex literal {literal}"),
6610            );
6611        }
6612    }
6613
6614    #[test]
6615    fn ruby_division_survives_the_regex_guard() {
6616        // Control for #1312: the guard is scoped to a `Regex` parent,
6617        // so real division still counts. Two divisions, so a mutant
6618        // that collapsed repeated hits would move `N1` even though
6619        // `n1` held (the #1294 count-only-anchor lesson).
6620        //
6621        // expected: operators `=` and `/` × 2 → n1 = 2, N1 = 3.
6622        // Operands `z`, `a`, `b`, `c` → n2 = N2 = 4.
6623        check_metrics::<RubyParser>("z = a / b / c\n", "foo.rb", |metric| {
6624            assert_eq!(metric.halstead.unique_operators(), 2);
6625            assert_eq!(metric.halstead.total_operators(), 3);
6626            assert_eq!(metric.halstead.unique_operands(), 4);
6627            assert_eq!(metric.halstead.total_operands(), 4);
6628        });
6629    }
6630
6631    #[test]
6632    fn ruby_regex_guard_is_parent_scoped_not_ancestor_scoped() {
6633        // The one input that separates the correct parent-scoped guard
6634        // from the ancestor-scoped mutant of it (#1312, mirroring
6635        // #1256's Elixir case): a division *inside* a regex's `#{…}`
6636        // interpolation. Its `/` has `Binary` as its parent but the
6637        // `Regex` as a further ancestor, so an ancestor scan would
6638        // swallow it. Every other fixture in this file passes under
6639        // both spellings. Two interpolations, so the mutant moves both
6640        // n1 (2 → 1) and N1 (3 → 1).
6641        //
6642        // expected: operators `=`, `/` × 2 → n1 = 2, N1 = 3. Operands
6643        // `w`, `p`, `q`, `r`, `t` → n2 = N2 = 5; the wrapping `Regex`
6644        // is skipped because it carries an `Interpolation` child (the
6645        // #180 double-count guard).
6646        //
6647        // Was n1 = 3, N1 = 5 until #1314 dropped `#{` from the operator
6648        // arm. The mutant still moves both axes, so this fixture is as
6649        // discriminating as it was — it just no longer counts the two
6650        // interpolation openers alongside the two divisions.
6651        check_metrics::<RubyParser>("w = /a#{p / q}c#{r / t}b/\n", "foo.rb", |metric| {
6652            assert_eq!(metric.halstead.unique_operators(), 2);
6653            assert_eq!(metric.halstead.total_operators(), 3);
6654            assert_eq!(metric.halstead.unique_operands(), 5);
6655            assert_eq!(metric.halstead.total_operands(), 5);
6656        });
6657    }
6658
6659    #[test]
6660    fn ruby_regex_start_alias_never_reaches_kind_id() {
6661        // Drift marker for the `R::SLASH2` half of #1312's guard.
6662        // `SLASH2` is the aliased regex-start token: it sits in the
6663        // enum beside the other literal-start aliases (`DQUOTE`,
6664        // `COLONDQUOTE`, `BQUOTE2`, `PERCENTwLPAREN`) and the runtime
6665        // `public_symbol_map` collapses it to `SLASH` before
6666        // `kind_id()`, exactly like `LPAREN2` in #768. It is listed in
6667        // the guard rather than the arithmetic arm because a regex
6668        // delimiter is the only thing it could ever be; this pins that
6669        // it is currently unreachable, so a grammar bump that starts
6670        // emitting it fails here instead of silently changing a metric.
6671        let path = PathBuf::from("foo.rb");
6672        for source in ["x = /abc/\n", "x = %r{abc}\n"] {
6673            let parser = RubyParser::new(source.as_bytes().to_vec(), &path, None);
6674            assert!(
6675                !ast_has_kind_id(&parser, Ruby::SLASH2 as u16),
6676                "Ruby::SLASH2 must stay collapsed to Ruby::SLASH for `{source}`"
6677            );
6678            // Positive control: the id the guard actually fires on is
6679            // present, so the assertion above cannot pass merely
6680            // because no delimiter was parsed at all.
6681            assert!(
6682                ast_has_kind_id(&parser, Ruby::SLASH as u16),
6683                "Ruby::SLASH must be the delimiter kind for `{source}`"
6684            );
6685        }
6686    }
6687
6688    #[test]
6689    fn ruby_interpolation_opener_is_not_an_operator() {
6690        // Behaviour change, not a fabrication fix: #1314 drops
6691        // `HASHLBRACE` from Ruby's operator arm. `#{` is a token of its
6692        // own here — unlike PHP's `{`, which aliases the
6693        // compound-statement brace — so nothing was being miscounted;
6694        // the question was whether an interpolation opener is an
6695        // operation at all, and across the five interpolating languages
6696        // three already said no. Ruby Halstead operator counts drop for
6697        // interpolated literals as a result.
6698        //
6699        // Asserted as an invariance: the interpolated and plain
6700        // spellings of one string must now score identically, which is
6701        // the policy rather than a magic number. Before the change the
6702        // interpolated row was n1 = 2, N1 = 3.
6703        //
6704        // expected per row: operator `=` × 2 → n1 = 1, N1 = 2; operands
6705        // `s`, `t`, and the literal's content contribution × 2 →
6706        // n2 = 3, N2 = 4.
6707        for literal in ["\"a #{y} b\"", "\"a b\""] {
6708            assert_halstead_counts::<RubyParser>(
6709                &format!("s = {literal}\nt = {literal}\n"),
6710                "foo.rb",
6711                [1, 2, 3, 4],
6712                &format!("literal {literal}"),
6713            );
6714        }
6715    }
6716
6717    #[test]
6718    fn ruby_interpolation_opener_drop_covers_every_literal() {
6719        // `HASHLBRACE` is one arm, but it fires under every Ruby
6720        // literal that interpolates, so the drop is not specific to
6721        // double-quoted strings. A symbol and a regex — two literals
6722        // whose `#{…}` reaches the same token — must contribute no
6723        // operator for the opener either.
6724        //
6725        // expected: operator `=` × 3 → n1 = 1, N1 = 3. Operands `y`,
6726        // `1`, `a`, `b`, plus the two interpolated `y` references →
6727        // n2 = 4, N2 = 6. Before the change each `#{` added one →
6728        // n1 = 2, N1 = 5.
6729        check_metrics::<RubyParser>("y = 1\na = :\"s#{y}\"\nb = /r#{y}/\n", "foo.rb", |metric| {
6730            assert_eq!(metric.halstead.unique_operators(), 1);
6731            assert_eq!(metric.halstead.total_operators(), 3);
6732            assert_eq!(metric.halstead.unique_operands(), 4);
6733            assert_eq!(metric.halstead.total_operands(), 6);
6734        });
6735    }
6736
6737    #[test]
6738    fn ruby_element_containers_count_elements_not_the_composite_1353() {
6739        // #1353. `chained_string`, `string_array` and `symbol_array`
6740        // hold *classified operand* children instead of the raw
6741        // `string_content` every other string-like literal wraps, so
6742        // the arm's shared `Interpolation` guard never fired and the
6743        // wrapper was billed alongside each element. Every row below
6744        // scored n2 4 / N2 4 for three operands, the extra entry being
6745        // the wrapper's whole-span text — which made the vocabulary
6746        // depend on how the author grouped the literals.
6747        //
6748        // `assert_ops_operands` pins the operand *text*, not just the
6749        // count: a wrapper coming back would show up as `"one" "two"` /
6750        // `%w[x y]` / `%i[p q]` rather than as an off-by-one number.
6751        //
6752        // The kind assertions are the grammar-dispatch §1 / §2 drift
6753        // marker — a bump that renumbers a wrapper or an element would
6754        // otherwise leave these counts passing while measuring a
6755        // construct the arm no longer names.
6756        //
6757        // Two of the three rows also pin the arm's *membership*:
6758        // dropping `StringArray` or `SymbolArray` from it fails
6759        // `ruby_empty_word_and_symbol_arrays_still_bill_one_operand_1353`.
6760        // `ChainedString`'s membership is unobservable and no test can
6761        // pin it — `repeat1($.string)` guarantees the guard always
6762        // fires, so `Unknown` and "not in the arm at all" are the same
6763        // answer for every input. It is listed for symmetry with its
6764        // siblings, and correct-by-construction is the only defence
6765        // available there.
6766        for (source, wrapper, element, operands) in [
6767            (
6768                "a = \"one\" \"two\"\n",
6769                Ruby::ChainedString,
6770                Ruby::String,
6771                vec!["a", "\"one\"", "\"two\""],
6772            ),
6773            (
6774                "b = %w[x y]\n",
6775                Ruby::StringArray,
6776                Ruby::BareString,
6777                vec!["b", "x", "y"],
6778            ),
6779            (
6780                "c = %i[p q]\n",
6781                Ruby::SymbolArray,
6782                Ruby::BareSymbol,
6783                vec!["c", "p", "q"],
6784            ),
6785        ] {
6786            let parser =
6787                RubyParser::new(source.as_bytes().to_vec(), &PathBuf::from("foo.rb"), None);
6788            assert!(
6789                ast_has_kind_id(&parser, wrapper as u16),
6790                "the container kind this arm gates on is unreachable for `{source}`"
6791            );
6792            assert!(
6793                ast_has_kind_id(&parser, element as u16),
6794                "the element kind this arm gates on is unreachable for `{source}`"
6795            );
6796
6797            // expected: operator `=` → n1 = N1 = 1; operands are the
6798            // assignment target and the two elements → n2 = N2 = 3.
6799            assert_halstead_counts::<RubyParser>(source, "foo.rb", [1, 1, 3, 3], source);
6800            assert_ops_operands::<RubyParser>(source, "foo.rb", 3, operands);
6801        }
6802    }
6803
6804    #[test]
6805    fn ruby_empty_word_and_symbol_arrays_still_bill_one_operand_1353() {
6806        // The childless spelling, and the whole reason #1353 gates the
6807        // three container kinds instead of dropping them from the arm
6808        // (grammar-dispatch §6). `%w[]` / `%i[]` parse to a wrapper
6809        // holding nothing but its two delimiter tokens, so the tempting
6810        // "just delete the wrapper" fix — the one #1351 was right to
6811        // take for Bash's `command_name` — would score an empty literal
6812        // zero operands where the source plainly has a literal. This is
6813        // the assertion to watch fail against that alternative.
6814        //
6815        // expected: operator `=` → n1 = N1 = 1; operands the assignment
6816        // target and the empty literal itself → n2 = N2 = 2.
6817        for (source, operands) in [
6818            ("d = %w[]\n", vec!["d", "%w[]"]),
6819            ("e = %i[]\n", vec!["e", "%i[]"]),
6820        ] {
6821            assert_halstead_counts::<RubyParser>(source, "foo.rb", [1, 1, 2, 2], source);
6822            assert_ops_operands::<RubyParser>(source, "foo.rb", 2, operands);
6823        }
6824    }
6825
6826    #[test]
6827    fn ruby_interpolated_array_elements_are_not_double_counted_1353() {
6828        // `bare_string` and `bare_symbol` are two aliases of a single
6829        // grammar production (`_literal_contents`), one per array form,
6830        // so `%W[…]` and `%I[…]` must score identically. Until #1353
6831        // only `bare_string` carried the interpolation guard and
6832        // `bare_symbol` sat in the plain operand arm, so `%I[a#{n}b c]`
6833        // billed the element `a#{n}b` *and* the `n` inside it (n2 4)
6834        // where `%W[a#{n}b c]` billed 3.
6835        //
6836        // Asserted as an invariance over the two spellings, the way
6837        // `ruby_interpolation_opener_is_not_an_operator` is — the
6838        // policy is the claim, not the magic number. The third row is
6839        // the composed case: a `chained_string` whose own guard defers
6840        // to an element that is itself interpolated.
6841        //
6842        // expected per row: operator `=` → n1 = N1 = 1; operands the
6843        // assignment target, the interpolated expression `n`, and the
6844        // one inert element → n2 = N2 = 3. The operand *text* is pinned
6845        // too, because 3 is also the count an implementation that
6846        // suppressed the inert element instead of the wrapper would
6847        // report.
6848        for (source, operands) in [
6849            ("w = %W[a#{n}b c]\n", vec!["w", "n", "c"]),
6850            ("w = %I[a#{n}b c]\n", vec!["w", "n", "c"]),
6851            ("a = \"x#{n}\" \"y\"\n", vec!["a", "n", "\"y\""]),
6852        ] {
6853            assert_halstead_counts::<RubyParser>(source, "foo.rb", [1, 1, 3, 3], source);
6854            assert_ops_operands::<RubyParser>(source, "foo.rb", 3, operands);
6855        }
6856    }
6857
6858    /// Comprehensive iRules Halstead test exercising every operator family
6859    /// classified in `get_op_type`: declaration/control keywords (`proc`,
6860    /// `set`, `if`, `return`), structural punctuation (`{}` `[]` `()`),
6861    /// arithmetic (`+`), comparison (`>`), the word-form string comparator
6862    /// (`eq`), and short-circuit logical (`&&`). Anchored on the integer
6863    /// `n1`/`N1`/`n2`/`N2` headline values; the float fields are derived and
6864    /// bit-brittle, so they are not pinned.
6865    ///
6866    /// The second half pins the lesson-4 invariant: the independent
6867    /// text-keyed `operands_and_operators` store must dedupe to the same
6868    /// `n1`/`n2`. A classification change that moved one store without the
6869    /// other (e.g. a kind landing in both the operator and operand arms)
6870    /// would break this even though the snapshot stayed green.
6871    #[test]
6872    fn irules_operators_and_operands() {
6873        let source = "proc f { a b } {
6874    set x [expr { $a + $b }]
6875    if { $x > 0 && $a eq \"go\" } {
6876        return $x
6877    }
6878    return 0
6879}
6880";
6881        check_metrics::<IrulesParser>(source, "foo.irule", |metric| {
6882            // After #695 only opening delimiters count: the `}`/`]`
6883            // closers no longer add operators (was 12 unique / 20 total).
6884            assert_eq!(metric.halstead.unique_operators(), 10);
6885            assert_eq!(metric.halstead.total_operators(), 14);
6886            // Operands fell 12 / 16 → 10 / 14 with #1354: the proc body
6887            // and the `if` body are `BracedWord` script kinds and are no
6888            // longer operands beside the commands they contain.
6889            assert_eq!(metric.halstead.unique_operands(), 10);
6890            assert_eq!(metric.halstead.total_operands(), 14);
6891        });
6892
6893        let path = PathBuf::from("foo.irule");
6894        let parser = IrulesParser::new(source.as_bytes().to_vec(), &path, None);
6895        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
6896        let unique_operators: HashSet<&str> = ops.operators.iter().map(String::as_str).collect();
6897        let unique_operands: HashSet<&str> = ops.operands.iter().map(String::as_str).collect();
6898        assert_eq!(
6899            unique_operators.len(),
6900            10,
6901            "dedupe(ops.operators) must equal n1; operators were {:?}",
6902            ops.operators
6903        );
6904        assert_eq!(
6905            unique_operands.len(),
6906            10,
6907            "dedupe(ops.operands) must equal n2; operands were {:?}",
6908            ops.operands
6909        );
6910    }
6911
6912    /// An inert `"hello world"` double-quoted string (no `$var` / `[cmd]`
6913    /// interpolation child) contributes exactly **one** operand — the
6914    /// wrapping `QuotedWord`. Operands are `f`, `s` and `"hello world"` —
6915    /// n2 = 3, the same as Tcl since #1294 restored its `set` target to
6916    /// the operand count and #1354 dropped the proc-body `braced_word`
6917    /// from both. Mirrors `tcl_inert_quoted_word_counts_as_operand`
6918    /// (#277).
6919    #[test]
6920    fn irules_inert_quoted_word_counts_as_operand() {
6921        let source = "proc f {} {\n    set s \"hello world\"\n}\n";
6922        check_metrics::<IrulesParser>(source, "foo.irule", |metric| {
6923            // After #695 only the `{` opener counts; the `}` closer no
6924            // longer adds an operator (was 4 unique / 6 total).
6925            assert_eq!(metric.halstead.unique_operators(), 3);
6926            assert_eq!(metric.halstead.total_operators(), 4);
6927            assert_eq!(metric.halstead.unique_operands(), 3);
6928            assert_eq!(metric.halstead.total_operands(), 3);
6929        });
6930
6931        let path = PathBuf::from("foo.irule");
6932        let parser = IrulesParser::new(source.as_bytes().to_vec(), &path, None);
6933        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
6934        // The inert quoted word is present as exactly one operand (not
6935        // dropped, not split): dropping it would mean the inert branch was
6936        // over-guarded.
6937        let quoted = ops
6938            .operands
6939            .iter()
6940            .filter(|o| o.as_str() == "\"hello world\"")
6941            .count();
6942        assert_eq!(quoted, 1, "inert quoted word must be one operand");
6943        let unique_operands: HashSet<&str> = ops.operands.iter().map(String::as_str).collect();
6944        assert_eq!(unique_operands.len(), 3, "operands were {:?}", ops.operands);
6945    }
6946
6947    /// Regression for the `QuotedWord` interpolation guard (the #277 /
6948    /// Bash-#180 / C#-#183 / PHP-#184 pattern). An interpolated
6949    /// `"$x is $y"` must contribute **zero** operands for the wrapping
6950    /// `QuotedWord`; the inner `$x` / `$y` `variable_substitution` nodes are
6951    /// walked separately and count on their own. Operands are `f`, `x`, `y`,
6952    /// `s`, `$x`, `$y` = 6 (7 before #1354 dropped the proc-body
6953    /// `braced_word`). If the guard regressed (wrapper classified
6954    /// `Operand`), the wrapper string would add a 7th operand. This is the
6955    /// branch that had no test before.
6956    #[test]
6957    fn irules_interpolated_quoted_word_no_double_count() {
6958        let source = "proc f {x y} {\n    set s \"$x is $y\"\n}\n";
6959        check_metrics::<IrulesParser>(source, "foo.irule", |metric| {
6960            // After #695 only the `{` opener counts; the `}` closer no
6961            // longer adds an operator (was 4 unique / 6 total).
6962            assert_eq!(metric.halstead.unique_operators(), 3);
6963            assert_eq!(metric.halstead.total_operators(), 4);
6964            assert_eq!(metric.halstead.unique_operands(), 6);
6965            assert_eq!(metric.halstead.total_operands(), 6);
6966        });
6967
6968        let path = PathBuf::from("foo.irule");
6969        let parser = IrulesParser::new(source.as_bytes().to_vec(), &path, None);
6970        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
6971        // The wrapping interpolated string must NOT appear as an operand;
6972        // its inner substitutions must. The wrapper, if wrongly counted,
6973        // would surface as the quoted literal `"$x is $y"` (with quotes,
6974        // like the inert `"hello world"` operand). Match that exact token —
6975        // a substring check would false-match the proc-body `braced_word`
6976        // operand, which legitimately contains the source text.
6977        assert!(
6978            !ops.operands.iter().any(|o| o.as_str() == "\"$x is $y\""),
6979            "interpolated wrapper must not be an operand; operands were {:?}",
6980            ops.operands
6981        );
6982        assert!(
6983            ops.operands.iter().any(|o| o.as_str() == "$x")
6984                && ops.operands.iter().any(|o| o.as_str() == "$y"),
6985            "inner $x / $y substitutions must each be operands; operands were {:?}",
6986            ops.operands
6987        );
6988        let unique_operands: HashSet<&str> = ops.operands.iter().map(String::as_str).collect();
6989        assert_eq!(unique_operands.len(), 6, "operands were {:?}", ops.operands);
6990    }
6991
6992    /// Exercises the operator families not covered by
6993    /// `irules_operators_and_operands`: bitwise (`& | ^ ~ << >>`), ternary
6994    /// (`? :`), the keyword string comparators (`starts_with`, `ends_with`,
6995    /// `contains`, `matches`, `eq`, `ne`), and the keyword logical operator
6996    /// (`and`). Pins every operator-family arm in `get_op_type` plus the
6997    /// lesson-4 dedupe invariant.
6998    #[test]
6999    fn irules_bitwise_ternary_string_ops() {
7000        let source = "proc f { a b } {
7001    set bits [expr { $a & $b | $a ^ ~$b }]
7002    set sh [expr { $a << 2 | $b >> 1 }]
7003    set t [expr { $a > 0 ? $a : $b }]
7004    if { $a starts_with \"x\" && $b ends_with \"y\" } { return 1 }
7005    if { $a contains \"z\" || $b matches \"q\" } { return 2 }
7006    if { $a eq \"m\" and $b ne \"n\" } { return 3 }
7007    return $b
7008}
7009";
7010        check_metrics::<IrulesParser>(source, "foo.irule", |metric| {
7011            // After #695 only opening delimiters count: the `}`/`]`
7012            // closers no longer add operators (was 26 unique / 57 total).
7013            assert_eq!(metric.halstead.unique_operators(), 24);
7014            assert_eq!(metric.halstead.total_operators(), 43);
7015            // Operands fell 23 / 42 → 19 / 38 with #1354: the proc body
7016            // and the three single-statement `if` bodies are
7017            // `BracedWord` script kinds and no longer count as operands.
7018            assert_eq!(metric.halstead.unique_operands(), 19);
7019            assert_eq!(metric.halstead.total_operands(), 38);
7020        });
7021
7022        let path = PathBuf::from("foo.irule");
7023        let parser = IrulesParser::new(source.as_bytes().to_vec(), &path, None);
7024        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
7025        let unique_operators: HashSet<&str> = ops.operators.iter().map(String::as_str).collect();
7026        let unique_operands: HashSet<&str> = ops.operands.iter().map(String::as_str).collect();
7027        assert_eq!(
7028            unique_operators.len(),
7029            24,
7030            "dedupe(ops.operators) must equal n1; operators were {:?}",
7031            ops.operators
7032        );
7033        assert_eq!(
7034            unique_operands.len(),
7035            19,
7036            "dedupe(ops.operands) must equal n2; operands were {:?}",
7037            ops.operands
7038        );
7039    }
7040
7041    /// A bare `$x` produces one `variable_substitution` operand. Its inner
7042    /// `id` leaf (the *named* `Id` node — not the anonymous `Id2` token Tcl
7043    /// has there) must NOT be counted separately, or every variable
7044    /// reference double-counts. `get_op_type` excludes `Id` whose parent is
7045    /// a `VariableSubstitution`. Operands: `f`, the proc arg `x`, `return`
7046    /// and `$x` — four, with no duplicate (`total_operands()` == 4; the
7047    /// proc-body `braced_word` was a fifth before #1354). If the guard
7048    /// regressed, the inner `id` "x" would add a fifth operand occurrence
7049    /// (it text-collides with the proc arg `x`, so `u_operands` would stay
7050    /// 4 but `total_operands()` would rise to 5 — hence the total, not just
7051    /// the unique count, is asserted).
7052    #[test]
7053    fn irules_array_reference_bills_the_reference_and_the_index() {
7054        // The iRules twin of
7055        // `tcl_array_reference_bills_the_reference_and_the_index`. Until
7056        // `ArrayIndex` left the operand arm this fixture billed `(k)` and
7057        // `($i)` beside the six operands below — the grammar-dispatch §5
7058        // wrapper-plus-leaf count, and a divergence from Tcl.
7059        assert_ops_operands::<IrulesParser>(
7060            "set arr(k) 1\nset z \"$arr($i)\"\n",
7061            "foo.irule",
7062            6,
7063            vec!["arr", "k", "1", "z", "$arr($i)", "$i"],
7064        );
7065    }
7066
7067    #[test]
7068    fn irules_bare_variable_operand() {
7069        let source = "proc f {x} {\n    return $x\n}\n";
7070        check_metrics::<IrulesParser>(source, "foo.irule", |metric| {
7071            // After #695 only the `{` opener counts (folded `{}`); the
7072            // `}` closer no longer adds an operator (was 3 unique / 5 total).
7073            assert_eq!(metric.halstead.unique_operators(), 2);
7074            assert_eq!(metric.halstead.total_operators(), 3);
7075            assert_eq!(metric.halstead.unique_operands(), 4);
7076            assert_eq!(metric.halstead.total_operands(), 4);
7077        });
7078
7079        let path = PathBuf::from("foo.irule");
7080        let parser = IrulesParser::new(source.as_bytes().to_vec(), &path, None);
7081        let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds");
7082        let bare_var = ops.operands.iter().filter(|o| o.as_str() == "$x").count();
7083        assert_eq!(
7084            bare_var, 1,
7085            "bare $x must be exactly one operand (inner id leaf not double-counted); operands were {:?}",
7086            ops.operands
7087        );
7088    }
7089
7090    #[test]
7091    fn irules_braced_word_delimiter_is_not_an_operator() {
7092        // Regression: issue #1314. The iRules twin of
7093        // `tcl_braced_word_delimiter_is_not_an_operator` — the two
7094        // getters are deliberate clones, so the guard lands in both and
7095        // is asserted in both.
7096        //
7097        // One fixture covers the guard and its control: `{braced word}`
7098        // and `{y}` are values whose openers must not count, while the
7099        // handler body, the `if` condition and the `if` body are
7100        // `BracedWord` / `Expr` and must keep theirs. A guard keyed on
7101        // the brace alone would take `{}` out of the operator set
7102        // entirely and fail here.
7103        //
7104        // expected: operators `when`, `set` × 2, `if`, `contains`,
7105        // `[]`, `{}` × 3 → n1 = 6, N1 = 9. Every operand is distinct →
7106        // n2 = N2 = 7: `HTTP_REQUEST`, `a`, `b`, `HTTP::uri`, `"x"` and
7107        // the two braced words `{braced word}` and `{y}`. Before the
7108        // guard the two value openers added two more `{}` occurrences →
7109        // N1 = 11; before #1354 the handler and `if` bodies and the
7110        // inner `braced` / `word` / `y` were operands too → n2 = 12.
7111        check_metrics::<IrulesParser>(
7112            "when HTTP_REQUEST {\n  set a {braced word}\n  if {[HTTP::uri] contains \"x\"} { set b {y} }\n}\n",
7113            "foo.irule",
7114            |metric| {
7115                assert_eq!(metric.halstead.unique_operators(), 6);
7116                assert_eq!(metric.halstead.total_operators(), 9);
7117                assert_eq!(metric.halstead.unique_operands(), 7);
7118                assert_eq!(metric.halstead.total_operands(), 7);
7119            },
7120        );
7121    }
7122
7123    /// Regression for #563: the two Halstead `Display` labels must use the
7124    /// underscore key that matches the JSON/CSV field name, so a user can grep
7125    /// the same token across `Display` and JSON. The space-separated forms
7126    /// (`estimated program length` / `purity ratio`) were the only outliers,
7127    /// mirroring the `dump` fix in #562.
7128    #[test]
7129    fn display_halstead_labels_use_underscore_keys() {
7130        check_metrics::<CppParser>("int a = 42;", "foo.cpp", |metric| {
7131            let out = metric.halstead.to_string();
7132            assert!(
7133                out.contains("estimated_program_length: "),
7134                "Display must use the underscore key `estimated_program_length`:\n{out}"
7135            );
7136            assert!(
7137                out.contains("purity_ratio: "),
7138                "Display must use the underscore key `purity_ratio`:\n{out}"
7139            );
7140            assert!(
7141                !out.contains("estimated program length"),
7142                "Display must not emit the space-separated `estimated program length`:\n{out}"
7143            );
7144            assert!(
7145                !out.contains("purity ratio"),
7146                "Display must not emit the space-separated `purity ratio`:\n{out}"
7147            );
7148        });
7149    }
7150
7151    /// `@"…"` is one `string_literal` holding its `@` as a child, so the
7152    /// literal is the operand, keyed by its whole text, and the marker is
7153    /// not an operator on top — it was, which planted a phantom `@` in
7154    /// n1 for a file whose only `@` was in NSString literals and billed
7155    /// the same byte in both streams. Boxing (`@42`) keeps its `@`: there
7156    /// the token is a child of the `at_expression`, not of the literal.
7157    #[test]
7158    fn objc_nsstring_literal_is_one_operand() {
7159        // expected: [n1, N1, n2, N2]. Before the guard the first two rows
7160        // read [8, 8, 5, 5] and [8, 9, 6, 6] — one `@` operator per
7161        // literal; the boxing control is unchanged.
7162        let cases = [
7163            ("NSString *s = @\"str\";", [7, 7, 5, 5]),
7164            ("NSString *t = @\"x\" @\"y\";", [7, 7, 6, 6]),
7165            ("NSNumber *n = @42;", [8, 8, 5, 5]),
7166        ];
7167        for (body, counts) in cases {
7168            let source = format!("@implementation Foo\n- (void)m {{\n    {body}\n}}\n@end\n");
7169            assert_halstead_counts::<ObjcParser>(&source, "foo.m", counts, body);
7170        }
7171        assert_ops_operands::<ObjcParser>(
7172            "@implementation Foo\n- (void)m {\n    NSString *s = @\"str\";\n}\n@end\n",
7173            "foo.m",
7174            5,
7175            vec!["Foo", "m", "NSString", "s", "@\"str\""],
7176        );
7177    }
7178
7179    /// Comprehensive Objective-C Halstead fixture exercising a message
7180    /// send (`[self log:@"hi"]`), an ObjC string literal (`@"hi"`), an
7181    /// `if`, a short-circuit `&&`, arithmetic (`+`), comparisons, and
7182    /// assignment. Pins every field and enforces the lesson-4 invariants
7183    /// `unique_operators == n1` / `unique_operands == n2` via the
7184    /// independent `--ops` store.
7185    #[test]
7186    fn objc_operators_and_operands() {
7187        let source = "@implementation Foo
7188- (int)bar:(int)x {
7189    int y = x + 1;
7190    if (x > 0 && y < 10) {
7191        [self log:@\"hi\"];
7192    }
7193    return y;
7194}
7195@end
7196";
7197        check_metrics::<ObjcParser>(source, "foo.m", |metric| {
7198            // n1 = 14 unique operators:
7199            //   `&&`, `()`, `+`, `-`, `:`, `;`, `<`, `=`, `>`,
7200            //   `[]` (message send), `if`, `int`, `return`, `{}`.
7201            //   The `@` of `@"hi"` is part of the literal's operand
7202            //   key, not an operator (see `objc_nsstring_literal_is_one_operand`).
7203            // n2 = 10 unique operands:
7204            //   `Foo`, `bar`, `log`, `self`, `x`, `y`, `0`, `1`, `10`,
7205            //   `@"hi"` (the ObjC string literal).
7206            assert_eq!(metric.halstead.unique_operators(), 14);
7207            assert_eq!(metric.halstead.unique_operands(), 10);
7208            insta::assert_json_snapshot!(metric.halstead, @r#"
7209            {
7210              "unique_operators": 14,
7211              "total_operators": 22,
7212              "unique_operands": 10,
7213              "total_operands": 14,
7214              "length": 36,
7215              "estimated_program_length": 86.52224985768008,
7216              "purity_ratio": 2.403395829380002,
7217              "vocabulary": 24,
7218              "volume": 165.0586500259616,
7219              "difficulty": 9.8,
7220              "level": 0.1020408163265306,
7221              "effort": 1617.5747702544238,
7222              "time": 89.86526501413465,
7223              "bugs": 0.04593266617952463
7224            }
7225            "#);
7226        });
7227        // Lesson-4 invariant: dedupe(ops.operands) == n2 (10), via the
7228        // independent text-keyed `--ops` store.
7229        assert_ops_operands::<ObjcParser>(
7230            source,
7231            "foo.m",
7232            10,
7233            vec![
7234                "Foo", "bar", "log", "self", "x", "y", "0", "1", "10", "@\"hi\"",
7235            ],
7236        );
7237    }
7238
7239    /// #1316 fixture separating the vocabulary and occurrence axes
7240    /// (#1294): `'x'` appears twice, so a correct fix adds four `n2`
7241    /// entries and five `N2` hits. The `'ab'` multi-character constant
7242    /// is also the grammar-dispatch section 5 pin — that literal carries
7243    /// *two* `character` children and must still bill one operand.
7244    ///
7245    /// Both #1316 fixtures are plain C, which every C-family grammar
7246    /// parses to the same shape, so one source proves the same thing
7247    /// about each of the four clones.
7248    const C_FAMILY_CHAR_REPEATS: &str =
7249        "char a = 'x';\nchar b = 'x';\nchar c = 'y';\nchar d = '\\n';\nint e = 'ab';\n";
7250
7251    /// #1316 fixture holding all five spellings the grammars admit. Each
7252    /// opens on a distinct delimiter kind (`'`, `L'`, `u'`, `U'`, `u8'`),
7253    /// and operands key on source text, so the five are five vocabulary
7254    /// entries rather than one.
7255    const C_FAMILY_CHAR_PREFIXES: &str =
7256        "char a = 'x';\nchar b = L'x';\nchar c = u'x';\nchar d = U'x';\nchar e = u8'x';\n";
7257
7258    /// Asserts both #1316 fixtures for one C-family language, through
7259    /// the metrics store *and* the text-keyed `--ops` store (the
7260    /// lesson-4 invariant `n2 == len(dedupe(ops.operands))`).
7261    ///
7262    /// The `--ops` half pins *which text* each literal is billed under,
7263    /// which the counts cannot see: billing a literal's `character`
7264    /// payload instead of the whole literal keeps `n2` at 9 while the
7265    /// vocabulary silently becomes `x` rather than `'x'`. (Billing
7266    /// *both* is caught earlier, by the counts.) It is a second *walk*
7267    /// rather than a second classification — `ops_inner` reads the keys
7268    /// of the same `HalsteadMaps` — which is worth knowing before
7269    /// reading it as independent corroboration of the count.
7270    #[track_caller]
7271    fn assert_char_literal_operands<T: crate::ParserTrait>(file: &str, label: &str) {
7272        // `char` x4 and `int` are text-keyed primitive operators, so
7273        // n1 = 4 (`;`, `=`, `char`, `int`) and N1 = 5 + 5 + 4 + 1.
7274        // Operands: `a`..`e`, plus `'x'` (twice), `'y'`, `'\n'`, `'ab'`.
7275        let repeats = format!("{label}: repeated / escaped / multi-char literals");
7276        assert_halstead_counts::<T>(C_FAMILY_CHAR_REPEATS, file, [4, 15, 9, 10], &repeats);
7277        assert_ops_operands::<T>(
7278            C_FAMILY_CHAR_REPEATS,
7279            file,
7280            9,
7281            vec!["a", "b", "c", "d", "e", "'x'", "'y'", "'\\n'", "'ab'"],
7282        );
7283
7284        // Every declaration is `char` here, so the `int` primitive
7285        // operator of the other fixture is gone and n1 drops to 3. Ten
7286        // distinct operands, each seen once.
7287        let prefixes = format!("{label}: L / u / U / u8 prefixed literals");
7288        assert_halstead_counts::<T>(C_FAMILY_CHAR_PREFIXES, file, [3, 15, 10, 10], &prefixes);
7289        assert_ops_operands::<T>(
7290            C_FAMILY_CHAR_PREFIXES,
7291            file,
7292            10,
7293            vec![
7294                "a", "b", "c", "d", "e", "'x'", "L'x'", "u'x'", "U'x'", "u8'x'",
7295            ],
7296        );
7297    }
7298
7299    /// Regression for #1316: a C-family character literal is a Halstead
7300    /// operand.
7301    ///
7302    /// `char_literal` was in no arm of `CCode` / `CppCode` /
7303    /// `MozcppCode` / `ObjcCode`'s `get_op_type`, so a character literal
7304    /// contributed *nothing* — not an operator (correct) and not an
7305    /// operand (wrong) — while Rust, Java, Kotlin, C#, Go and Elixir all
7306    /// counted theirs. Both fixtures measured `n2` 5, `N2` 5 before the
7307    /// fix: the five declared identifiers and not one literal.
7308    ///
7309    /// Each language asserts separately over the same source rather than
7310    /// sharing one call, so reverting one clone's arm fails that row
7311    /// alone (grammar-dispatch section 11) — verified by perturbing each
7312    /// of the four arms in turn. A single shared assertion would be
7313    /// satisfied by whichever clone still had the arm.
7314    ///
7315    /// Mozcpp owns no file extension, so no integration snapshot ever
7316    /// reaches its clone; its row is the whole coverage that arm has.
7317    #[test]
7318    fn c_family_char_literals_are_operands() {
7319        assert_char_literal_operands::<CParser>("chars.c", "c");
7320        assert_char_literal_operands::<CppParser>("chars.cpp", "cpp");
7321        assert_char_literal_operands::<MozcppParser>("chars.cpp", "mozcpp");
7322        assert_char_literal_operands::<ObjcParser>("chars.m", "objc");
7323    }
7324
7325    /// ObjC boxes a character literal as `@'y'` — an `at_expression`
7326    /// wrapping the same `char_literal`, with the `@` counted as its own
7327    /// operator. The wrapper is in no operand arm, so the boxed form
7328    /// bills exactly the literal it wraps and stays distinct from a bare
7329    /// one (#1316).
7330    #[test]
7331    fn objc_boxed_char_literal_is_one_operand() {
7332        let source = "char a = 'x';\nid b = @'y';\n";
7333        // n1: `char`, `=`, `;`, `@`. N1: 1 + 2 + 2 + 1.
7334        // n2 / N2: `a`, `b`, `'x'`, `'y'` — `id` is a `typedefed_specifier`
7335        // and is classified by neither arm. Before #1316 this was n2 2,
7336        // N2 2.
7337        assert_halstead_counts::<ObjcParser>(source, "boxed.m", [4, 6, 4, 4], "objc @'y'");
7338        assert_ops_operands::<ObjcParser>(source, "boxed.m", 4, vec!["a", "b", "'x'", "'y'"]);
7339    }
7340
7341    /// Walks both #1316 fixtures under all four C-family `Getter`s and
7342    /// pins the two grammar facts the new operand arm rests on.
7343    ///
7344    /// * **Grammar-dispatch section 1.** In the positions these
7345    ///   fixtures exercise, every node the grammar spells `char_literal`
7346    ///   carries the one `kind_id` the arm lists. That is the weaker
7347    ///   half of the alias evidence — an alias arises in a *different*
7348    ///   syntactic position, which no fixture can enumerate. The strong
7349    ///   half is that these generated enums do carry numeric-suffix
7350    ///   aliases in quantity (`language_c.rs` alone has ninety) and none
7351    ///   of the four spells a `CharLiteral2`, so its absence is a
7352    ///   measurement rather than a silence. This loop is what notices if
7353    ///   a grammar bump changes that under an existing fixture.
7354    /// * **Grammar-dispatch section 5.** No child of a `char_literal` is
7355    ///   classified. That is what makes listing the wrapper safe rather
7356    ///   than a wrapper/leaf double count: the opening delimiter, the
7357    ///   closing `'`, and the `character` / `escape_sequence` payload
7358    ///   must all stay `Unknown`, or every literal would bill two
7359    ///   operands and a prefixed one three.
7360    ///
7361    /// Both loops are non-vacuous by assertion, since a fixture that
7362    /// stopped containing a character literal would otherwise make this
7363    /// test pass having checked nothing.
7364    #[test]
7365    fn c_family_char_literal_internals_stay_unclassified() {
7366        fn check<L: LanguageInfo + Getter>(char_literal: u16, label: &str) {
7367            let mut literals = 0_usize;
7368            let mut children = 0_usize;
7369            for source in [C_FAMILY_CHAR_REPEATS, C_FAMILY_CHAR_PREFIXES] {
7370                for_each_node_with_chain::<L>(source.as_bytes(), |node, chain| {
7371                    if node.kind() == "char_literal" {
7372                        assert_eq!(
7373                            node.kind_id(),
7374                            char_literal,
7375                            "{label}: a `char_literal` carries kind_id {} rather than the \
7376                             {char_literal} the operand arm lists — an alias the arm cannot see",
7377                            node.kind_id()
7378                        );
7379                        literals += 1;
7380                    }
7381                    if chain
7382                        .last()
7383                        .is_none_or(|parent| parent.kind_id() != char_literal)
7384                    {
7385                        return;
7386                    }
7387                    children += 1;
7388                    // `_with_code` is the spelling `compute_halstead`
7389                    // calls. The default forwards to the byte-less form,
7390                    // so today the two agree for every C-family
7391                    // language — which is exactly why asking the wrong
7392                    // one would read as correct right up until one of
7393                    // these four grew an override (grammar-dispatch
7394                    // section 7).
7395                    assert!(
7396                        matches!(
7397                            L::get_op_type_with_code(
7398                                node,
7399                                source.as_bytes(),
7400                                Ancestors::known(chain)
7401                            ),
7402                            HalsteadType::Unknown
7403                        ),
7404                        "{label}: `{}` inside a character literal is classified, so the \
7405                         literal now double-counts against its wrapper",
7406                        node.kind()
7407                    );
7408                });
7409            }
7410            // Ten literals across the two fixtures; each holds two
7411            // delimiters plus at least one payload leaf, and `'ab'` two.
7412            assert_eq!(literals, 10, "{label}: fixtures lost a character literal");
7413            assert_eq!(children, 31, "{label}: fixtures lost a literal's internals");
7414        }
7415
7416        check::<CCode>(C::CharLiteral as u16, "c");
7417        check::<CppCode>(Cpp::CharLiteral as u16, "cpp");
7418        check::<MozcppCode>(Mozcpp::CharLiteral as u16, "mozcpp");
7419        check::<ObjcCode>(Objc::CharLiteral as u16, "objc");
7420    }
7421
7422    /// Builds a `HalsteadMaps` from explicit occurrence counts.
7423    ///
7424    /// The per-language tests above reach these maps only through a
7425    /// parse, which cannot produce a *chosen* overlap between a child
7426    /// and its parent — the cases `merge` exists to get right.
7427    fn halstead_maps_of<'a>(
7428        operators: &[(u16, u64)],
7429        primitive_operators: &[(&'a [u8], u64)],
7430        operands: &[(&'a [u8], u64)],
7431    ) -> HalsteadMaps<'a> {
7432        HalsteadMaps {
7433            operators: operators.iter().copied().collect(),
7434            primitive_operators: primitive_operators.iter().copied().collect(),
7435            operands: operands.iter().copied().collect(),
7436        }
7437    }
7438
7439    /// `HalsteadMaps::operators` must stay on the crate's integer hasher.
7440    ///
7441    /// Swapping a hasher moves no metric value, so every other test in
7442    /// this file passes just as well with #1108 reverted. Both halves
7443    /// here are needed: the typed binding stops compiling if the field
7444    /// goes back to a default-hasher `HashMap`, and the `type_name`
7445    /// comparison still fails at runtime if `IntKeyHashMap` itself is
7446    /// ever redefined to wrap `RandomState`.
7447    ///
7448    /// The two text-keyed maps are pinned to SipHash in the same test,
7449    /// because moving *them* would be a regression rather than an
7450    /// optimisation. `crate::int_hash`'s module doc is the single place
7451    /// that argues why analysed source text does not qualify.
7452    #[test]
7453    fn halstead_operator_map_uses_the_int_key_hasher() {
7454        use std::any::{type_name, type_name_of_val};
7455        use std::hash::BuildHasherDefault;
7456
7457        use crate::int_hash::IntKeyHasher;
7458
7459        let maps = HalsteadMaps::new();
7460
7461        let operators: &IntKeyHashMap<u16, u64> = &maps.operators;
7462        assert_eq!(
7463            type_name_of_val(operators.hasher()),
7464            type_name::<BuildHasherDefault<IntKeyHasher>>(),
7465            "the kind_id-keyed operator map must use the int_hash hasher"
7466        );
7467
7468        let siphash = type_name::<std::collections::hash_map::RandomState>();
7469        assert_eq!(
7470            type_name_of_val(maps.operands.hasher()),
7471            siphash,
7472            "operand keys come from the analysed source, so the keyed hash \
7473             is what stops a crafted file from flooding this map"
7474        );
7475        assert_eq!(
7476            type_name_of_val(maps.primitive_operators.hasher()),
7477            siphash,
7478            "primitive-operator keys come from the analysed source, so the \
7479             keyed hash is what stops a crafted file from flooding this map"
7480        );
7481    }
7482
7483    /// `merge` sums overlapping keys and adopts disjoint ones, in all
7484    /// three maps, and `finalize` reads the union back as n1/N1/n2/N2.
7485    ///
7486    /// Every count differs from every other and none is zero, so a
7487    /// dropped key, an overwrite where an addition belongs, or a map
7488    /// crossed with its neighbour all change the totals.
7489    #[test]
7490    fn halstead_maps_merge_sums_overlaps_and_adopts_disjoint_keys() {
7491        let mut parent = halstead_maps_of(
7492            &[(1, 2), (2, 3)],
7493            &[(b"int", 1)],
7494            &[(b"alpha", 4), (b"beta", 7)],
7495        );
7496        let child = halstead_maps_of(
7497            &[(2, 5), (7, 11)],
7498            &[(b"double", 13)],
7499            &[(b"alpha", 17), (b"gamma", 19)],
7500        );
7501
7502        parent.merge(&child);
7503
7504        // expected: operators {1: 2, 2: 3+5, 7: 11}; primitives
7505        // {int: 1, double: 13}; operands {alpha: 4+17, beta: 7,
7506        // gamma: 19}.
7507        assert_eq!(
7508            parent,
7509            halstead_maps_of(
7510                &[(1, 2), (2, 8), (7, 11)],
7511                &[(b"int", 1), (b"double", 13)],
7512                &[(b"alpha", 21), (b"beta", 7), (b"gamma", 19)],
7513            )
7514        );
7515
7516        let mut stats = Stats::default();
7517        parent.finalize(&mut stats);
7518        // expected: n1 = 3 kind ids + 2 primitives; N1 = (2+8+11) +
7519        // (1+13); n2 = 3 texts; N2 = 21+7+19.
7520        assert_eq!(stats.unique_operators(), 5);
7521        assert_eq!(stats.total_operators(), 35);
7522        assert_eq!(stats.unique_operands(), 3);
7523        assert_eq!(stats.total_operands(), 47);
7524    }
7525
7526    /// Merging an empty child leaves the parent untouched.
7527    ///
7528    /// A space with no operators or operands is the common case for a
7529    /// leaf getter or an empty function body, and `finalize` runs on
7530    /// the parent afterwards either way.
7531    #[test]
7532    fn halstead_maps_merge_of_empty_child_is_a_no_op() {
7533        let mut parent = halstead_maps_of(&[(3, 5)], &[(b"char", 2)], &[(b"delta", 9)]);
7534        let before = parent.clone();
7535
7536        parent.merge(&HalsteadMaps::new());
7537
7538        assert_eq!(parent, before);
7539
7540        let mut stats = Stats::default();
7541        parent.finalize(&mut stats);
7542        // expected: n1 = 1 kind id + 1 primitive; N1 = 5 + 2; n2 = 1;
7543        // N2 = 9.
7544        assert_eq!(stats.unique_operators(), 2);
7545        assert_eq!(stats.total_operators(), 7);
7546        assert_eq!(stats.unique_operands(), 1);
7547        assert_eq!(stats.total_operands(), 9);
7548    }
7549
7550    /// Folding a chain of nested spaces bottom-up must reach the union
7551    /// of every level, re-merging already-merged maps on the way up.
7552    ///
7553    /// This is what `spaces.rs` and `ops.rs` actually do: each space is
7554    /// merged into its parent as the walk pops it, so by the time the
7555    /// root sees a grandchild's counts they have already passed through
7556    /// one `merge`. The literal expectation below is what discriminates
7557    /// — the `nested == flat` cross-check on its own does not, because
7558    /// any entry-wise fold over the same levels agrees with itself
7559    /// however it is associated, including a broken one.
7560    #[test]
7561    fn halstead_maps_merge_folds_a_nested_chain() {
7562        let levels = [
7563            halstead_maps_of(&[(1, 1)], &[(b"int", 1)], &[(b"a", 1)]),
7564            halstead_maps_of(&[(1, 2), (2, 3)], &[], &[(b"a", 2), (b"b", 4)]),
7565            halstead_maps_of(&[(2, 5)], &[(b"long", 6)], &[(b"b", 7)]),
7566            halstead_maps_of(&[(3, 8)], &[(b"int", 9)], &[(b"c", 10)]),
7567        ];
7568
7569        // Bottom-up: the deepest level folds into its parent, that
7570        // result into *its* parent, and so on up to the root.
7571        let mut nested = levels[levels.len() - 1].clone();
7572        for level in levels.iter().rev().skip(1) {
7573            let mut outer = level.clone();
7574            outer.merge(&nested);
7575            nested = outer;
7576        }
7577
7578        // Flat: every level merged directly into the root.
7579        let mut flat = levels[0].clone();
7580        for level in &levels[1..] {
7581            flat.merge(level);
7582        }
7583
7584        // expected: every key summed across the four levels — operators
7585        // {1: 1+2, 2: 3+5, 3: 8}, primitives {int: 1+9, long: 6},
7586        // operands {a: 1+2, b: 4+7, c: 10}.
7587        assert_eq!(
7588            nested,
7589            halstead_maps_of(
7590                &[(1, 3), (2, 8), (3, 8)],
7591                &[(b"int", 10), (b"long", 6)],
7592                &[(b"a", 3), (b"b", 11), (b"c", 10)],
7593            )
7594        );
7595        assert_eq!(nested, flat);
7596
7597        let mut stats = Stats::default();
7598        nested.finalize(&mut stats);
7599        // expected: n1 = 3 kind ids + 2 primitives; N1 = (3+8+8) +
7600        // (10+6); n2 = 3 texts; N2 = 3+11+10.
7601        assert_eq!(stats.unique_operators(), 5);
7602        assert_eq!(stats.total_operators(), 35);
7603        assert_eq!(stats.unique_operands(), 3);
7604        assert_eq!(stats.total_operands(), 24);
7605    }
7606
7607    /// A `kind_id` at the top of the `u16` range must behave like any
7608    /// other key.
7609    ///
7610    /// The largest grammar in the workspace (`mozcpp`) tops out around
7611    /// 640 symbols, so nothing near `u16::MAX` occurs today — but the
7612    /// map is keyed by the raw id, and a dense-array representation
7613    /// (the shape #1108 considered and rejected) is exactly what such a
7614    /// key would break. Pinning it keeps that trade-off honest if the
7615    /// representation is ever revisited.
7616    #[test]
7617    fn halstead_maps_handle_the_full_kind_id_range() {
7618        let mut parent = halstead_maps_of(&[(0, 3), (u16::MAX, 5)], &[], &[]);
7619        parent.merge(&halstead_maps_of(&[(u16::MAX, 7)], &[], &[]));
7620
7621        let mut stats = Stats::default();
7622        parent.finalize(&mut stats);
7623        // expected: two distinct kind ids, occurrences 3 and 5+7.
7624        assert_eq!(stats.unique_operators(), 2);
7625        assert_eq!(stats.total_operators(), 15);
7626    }
7627}