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}