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