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