Skip to main content

big_code_analysis/metrics/
npa.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(clippy::wildcard_imports, clippy::enum_glob_use)]
8// Metric counts (token, function, branch, argument, etc.) are stored as
9// `usize` and crossed with `f64` averages, ratios, and Halstead scores
10// across the cyclomatic / MI / Halstead computations. The `usize as f64`
11// and `f64 as usize` casts are intentional and snapshot-anchored — every
12// site is bounded by the count it came from. Allowing the lints at the
13// module level keeps the metric arithmetic legible.
14#![allow(
15    clippy::cast_precision_loss,
16    clippy::cast_possible_truncation,
17    clippy::cast_sign_loss
18)]
19
20use std::fmt;
21
22use crate::checker::Checker;
23use crate::langs::*;
24use crate::macros::{csharp_var_decl_kinds, csharp_var_declarator_kinds, implement_metric_trait};
25use crate::node::Node;
26use crate::*;
27
28/// The `Npa` metric.
29///
30/// This metric counts the number of public attributes
31/// of classes/interfaces.
32#[derive(Clone, Debug, Default, PartialEq)]
33#[non_exhaustive]
34pub struct Stats {
35    class_npa: usize,
36    interface_npa: usize,
37    class_na: usize,
38    interface_na: usize,
39    class_npa_sum: usize,
40    interface_npa_sum: usize,
41    class_na_sum: usize,
42    interface_na_sum: usize,
43    is_class_space: bool,
44}
45
46impl fmt::Display for Stats {
47    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
48        write!(
49            f,
50            "classes: {}, interfaces: {}, class_attributes: {}, interface_attributes: {}, class_cda: {}, interface_cda: {}, total: {}, total_attributes: {}, cda: {}",
51            self.class_npa_sum(),
52            self.interface_npa_sum(),
53            self.class_na_sum(),
54            self.interface_na_sum(),
55            self.class_cda(),
56            self.interface_cda(),
57            self.total_npa(),
58            self.total_na(),
59            self.total_cda()
60        )
61    }
62}
63
64impl Stats {
65    /// Merges a second `Npa` metric into the first one
66    pub fn merge(&mut self, other: &Stats) {
67        self.class_npa_sum += other.class_npa_sum;
68        self.interface_npa_sum += other.interface_npa_sum;
69        self.class_na_sum += other.class_na_sum;
70        self.interface_na_sum += other.interface_na_sum;
71    }
72
73    /// Returns the number of class public attributes in a space.
74    #[inline]
75    #[must_use]
76    pub fn class_npa(&self) -> u64 {
77        self.class_npa as u64
78    }
79
80    /// Returns the number of interface public attributes in a space.
81    #[inline]
82    #[must_use]
83    pub fn interface_npa(&self) -> u64 {
84        self.interface_npa as u64
85    }
86
87    /// Returns the number of class attributes in a space.
88    #[inline]
89    #[must_use]
90    pub fn class_na(&self) -> u64 {
91        self.class_na as u64
92    }
93
94    /// Returns the number of interface attributes in a space.
95    #[inline]
96    #[must_use]
97    pub fn interface_na(&self) -> u64 {
98        self.interface_na as u64
99    }
100
101    /// Returns the number of class public attributes sum in a space.
102    #[inline]
103    #[must_use]
104    pub fn class_npa_sum(&self) -> u64 {
105        self.class_npa_sum as u64
106    }
107
108    /// Returns the number of interface public attributes sum in a space.
109    #[inline]
110    #[must_use]
111    pub fn interface_npa_sum(&self) -> u64 {
112        self.interface_npa_sum as u64
113    }
114
115    /// Returns the number of class attributes sum in a space.
116    #[inline]
117    #[must_use]
118    pub fn class_na_sum(&self) -> u64 {
119        self.class_na_sum as u64
120    }
121
122    /// Returns the number of interface attributes sum in a space.
123    #[inline]
124    #[must_use]
125    pub fn interface_na_sum(&self) -> u64 {
126        self.interface_na_sum as u64
127    }
128
129    /// Returns the class `Cda` metric value
130    ///
131    /// The `Class Data Accessibility` metric value for a class
132    /// is computed by dividing the `Npa` value of the class
133    /// by the total number of attributes defined in the class.
134    ///
135    /// This metric is an adaptation of the `Classified Class Data Accessibility` (`CCDA`)
136    /// security metric for not classified attributes.
137    /// Paper: <https://ieeexplore.ieee.org/abstract/document/5381538>
138    #[inline]
139    #[must_use]
140    pub fn class_cda(&self) -> f64 {
141        accessibility_ratio(self.class_npa_sum() as f64, self.class_na_sum() as f64)
142    }
143
144    /// Returns the interface `Cda` metric value
145    ///
146    /// The `Class Data Accessibility` metric value for an interface
147    /// is computed by dividing the `Npa` value of the interface
148    /// by the total number of attributes defined in the interface.
149    ///
150    /// This metric is an adaptation of the `Classified Class Data Accessibility` (`CCDA`)
151    /// security metric for not classified attributes.
152    /// Paper: <https://ieeexplore.ieee.org/abstract/document/5381538>
153    #[inline]
154    #[must_use]
155    pub fn interface_cda(&self) -> f64 {
156        // Java interface fields are implicitly public, so when every counted
157        // attribute is public (`npa == na != 0`) the ratio is exactly 1.0 and
158        // the division is skipped. The empty case falls through to
159        // `accessibility_ratio`, which is guarded to return a finite 0.0 (not
160        // `NaN`) for a zero denominator (#438).
161        if self.interface_npa_sum == self.interface_na_sum && self.interface_npa_sum != 0 {
162            1.0
163        } else {
164            accessibility_ratio(
165                self.interface_npa_sum() as f64,
166                self.interface_na_sum() as f64,
167            )
168        }
169    }
170
171    /// Returns the total `Cda` metric value
172    ///
173    /// The total `Class Data Accessibility` metric value
174    /// is computed by dividing the total `Npa` value
175    /// by the total number of attributes.
176    ///
177    /// This metric is an adaptation of the `Classified Class Data Accessibility` (`CCDA`)
178    /// security metric for not classified attributes.
179    /// Paper: <https://ieeexplore.ieee.org/abstract/document/5381538>
180    #[inline]
181    #[must_use]
182    pub fn total_cda(&self) -> f64 {
183        accessibility_ratio(self.total_npa() as f64, self.total_na() as f64)
184    }
185
186    /// Returns the total number of public attributes in a space.
187    #[inline]
188    #[must_use]
189    pub fn total_npa(&self) -> u64 {
190        self.class_npa_sum() + self.interface_npa_sum()
191    }
192
193    /// Returns the total number of attributes in a space.
194    #[inline]
195    #[must_use]
196    pub fn total_na(&self) -> u64 {
197        self.class_na_sum() + self.interface_na_sum()
198    }
199
200    // Accumulates the number of class and interface
201    // public and not public attributes into the sums
202    #[inline]
203    pub(crate) fn compute_sum(&mut self) {
204        self.class_npa_sum += self.class_npa;
205        self.interface_npa_sum += self.interface_npa;
206        self.class_na_sum += self.class_na;
207        self.interface_na_sum += self.interface_na;
208    }
209
210    // Checks if the `Npa` metric is disabled
211    #[inline]
212    pub(crate) fn is_disabled(&self) -> bool {
213        !self.is_class_space
214    }
215}
216
217// Computes an accessibility ratio (public members / total members),
218// guarding the empty case. A class/interface with no attributes or
219// methods has no exposed surface, so the defined value is `0.0` rather
220// than `0.0 / 0.0 = NaN` (which serializes to JSON `null`). Shared by
221// `Npa`'s CDA accessors and `Npm`'s COA accessors (issue #438).
222#[inline]
223pub(crate) fn accessibility_ratio(public: f64, total: f64) -> f64 {
224    if total == 0.0 { 0.0 } else { public / total }
225}
226
227#[doc(hidden)]
228/// Per-language counting of public attributes.
229pub(crate) trait Npa
230where
231    Self: Checker,
232{
233    /// Walk `node` and update `stats` with this metric for the language
234    /// implementing the trait.
235    ///
236    /// `code` is the raw source-bytes buffer; languages whose visibility
237    /// rules are encoded in identifier text (Ruby's keyword-style
238    /// `private` / `public` / `protected`) read identifier text from
239    /// it. Languages whose visibility rules are encoded purely in
240    /// distinct token kinds (Java's `Public` / `Private`, PHP's
241    /// `VisibilityModifier`) ignore the parameter.
242    fn compute<'a>(node: &Node<'a>, code: &'a [u8], stats: &mut Stats);
243}
244
245// Java and Groovy share their grammar tokens for class/interface
246// bodies, so `Npa::compute` differs only by the language enum.
247// `impl_npa_java_like!` emits the same body against each enum
248// (issue #280).
249//
250// `ClassBody` covers classes and records (records reuse `class_body`
251// for their explicit declaration body). Record components in
252// `formal_parameters` are implicit public final fields, but only
253// explicit body members are counted here for parity with C#'s record
254// handling (lesson 11). `EnumBodyDeclarations` is the optional
255// declarations block inside `EnumBody`, following the enum constants.
256// Annotation type bodies hold `ConstantDeclaration`s with the same
257// implicit `public static final` rule as interfaces
258// (https://docs.oracle.com/javase/specs/jls/se7/html/jls-9.html).
259//
260// Groovy note: `def field` at class scope is parsed as a
261// `FieldDeclaration` with `Def` in the modifiers list (no `Public`),
262// so it's correctly excluded from `class_npa` unless explicitly
263// annotated `public` — consistent with Groovy's access semantics
264// (default class members are package-private under `@CompileStatic`,
265// public otherwise; we conservatively follow Java).
266macro_rules! impl_npa_java_like {
267    ($code:ty, $lang:ident) => {
268        impl Npa for $code {
269            fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
270                use $lang::*;
271
272                if Self::is_func_space(node) && stats.is_disabled() {
273                    stats.is_class_space = true;
274                }
275
276                match node.kind_id().into() {
277                    ClassBody | EnumBodyDeclarations => {
278                        for declaration in node
279                            .children()
280                            .filter(|n| matches!(n.kind_id().into(), FieldDeclaration))
281                        {
282                            let attributes = declaration
283                                .children()
284                                .filter(|n| matches!(n.kind_id().into(), VariableDeclarator))
285                                .count();
286                            stats.class_na += attributes;
287                            // The first child node contains the list of
288                            // attribute modifiers. Source:
289                            // https://docs.oracle.com/javase/tutorial/reflect/member/fieldModifiers.html
290                            if declaration.child(0).is_some_and(|modifiers| {
291                                matches!(modifiers.kind_id().into(), Modifiers)
292                                    && modifiers.first_child(|id| id == Public).is_some()
293                            }) {
294                                stats.class_npa += attributes;
295                            }
296                        }
297                    }
298                    InterfaceBody | AnnotationTypeBody => {
299                        stats.interface_na += node
300                            .children()
301                            .filter(|n| matches!(n.kind_id().into(), ConstantDeclaration))
302                            .flat_map(|n| n.children())
303                            .filter(|n| matches!(n.kind_id().into(), VariableDeclarator))
304                            .count();
305                        stats.interface_npa = stats.interface_na;
306                    }
307                    _ => {}
308                }
309            }
310        }
311    };
312}
313
314mod shared;
315pub(crate) use shared::*;
316
317// TypeScript / TSX share the same OOP node shape: `class_declaration`
318// and `abstract_class_declaration` both contain a `class_body`;
319// `interface_declaration` contains an `interface_body`. The
320// `ts_npa_compute!` macro expands the same compute logic for each enum,
321// so TS and TSX cannot drift.
322//
323// Visibility rule: a `public_field_definition` or `method_definition`
324// is considered public unless it carries an explicit
325// `accessibility_modifier` child whose only child is `private` or
326// `protected`. Default (no modifier) is public, matching TypeScript's
327// own semantics.
328//
329// Parameter properties (`constructor(private x: number)`) are class
330// attributes: each `required_parameter` carrying an
331// `accessibility_modifier` *or* a bare `readonly` keyword adds one to
332// the enclosing class's `na` (and to `npa` when the modifier is
333// `public` or absent). `readonly` is a distinct keyword child, not an
334// `accessibility_modifier`, so a `readonly`-only parameter property
335// (`constructor(readonly b: number)`) is public and must be detected
336// separately — matching `readonly` class fields, which already count
337// (see `typescript_readonly_field`). `private readonly` carries both
338// children but `first_child` matches at most one and we increment once,
339// so it is never double-counted. The
340// grammar allows accessibility modifiers on parameters of any
341// `method_definition`, not only `constructor` — TypeScript itself
342// rejects that at type-check time, but accepting any method here
343// avoids fragile name-matching against the `constructor` identifier
344// (the grammar does not expose a dedicated constructor token).
345//
346// Interface decision: `property_signature` children of
347// `interface_body` count toward `interface_npa` / `interface_na`.
348// All interface members are implicitly public (TypeScript spec).
349// `index_signature` and `method_signature` are NOT attributes — they
350// belong to `npm`.
351macro_rules! ts_npa_compute {
352    ($lang:ident) => {
353        fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
354            use $lang::*;
355
356            if Self::is_func_space(node) && stats.is_disabled() {
357                stats.is_class_space = true;
358            }
359
360            match node.kind_id().into() {
361                ClassBody => {
362                    for member in node.children() {
363                        match member.kind_id().into() {
364                            // Plain field declaration (`x: T = expr;`, `private x: T;`,
365                            // `static x: T = expr;`). Each is one attribute.
366                            // Skip fields whose initializer is an arrow function or
367                            // function expression — those are methods written as
368                            // field initializers and are counted by `npm` instead.
369                            PublicFieldDefinition
370                                if member
371                                    .first_child(|id| {
372                                        id == $lang::ArrowFunction
373                                            || id == $lang::FunctionExpression
374                                    })
375                                    .is_none() =>
376                            {
377                                stats.class_na += 1;
378                                if ts_member_is_public!($lang, member) {
379                                    stats.class_npa += 1;
380                                }
381                            }
382                            // Parameter properties on any `method_definition`. In
383                            // practice these only appear on the constructor.
384                            // Scan formal_parameters at the class-body level so
385                            // the attribute lands on the class space, not the
386                            // method's own function space.
387                            MethodDefinition => {
388                                let Some(params) =
389                                    member.first_child(|id| id == $lang::FormalParameters)
390                                else {
391                                    continue;
392                                };
393                                for param in params.children().filter(|c| {
394                                    matches!(
395                                        c.kind_id().into(),
396                                        RequiredParameter | RequiredParameter2
397                                    )
398                                }) {
399                                    if param
400                                        .first_child(|id| {
401                                            id == $lang::AccessibilityModifier
402                                                || id == $lang::Readonly
403                                        })
404                                        .is_some()
405                                    {
406                                        stats.class_na += 1;
407                                        if ts_member_is_public!($lang, param) {
408                                            stats.class_npa += 1;
409                                        }
410                                    }
411                                }
412                            }
413                            _ => {}
414                        }
415                    }
416                }
417                InterfaceBody => {
418                    let count = node
419                        .children()
420                        .filter(|c| matches!(c.kind_id().into(), PropertySignature))
421                        .count();
422                    stats.interface_na += count;
423                    stats.interface_npa = stats.interface_na;
424                }
425                _ => {}
426            }
427        }
428    };
429}
430
431// Class members are public unless they declare an explicit
432// `accessibility_modifier` whose only child is `private` or `protected`.
433// Missing modifier means public, matching TypeScript's spec. The helper
434// is a macro rather than a generic function so both TS and TSX expand
435// the same code against their own enum without a marker trait.
436macro_rules! ts_member_is_public {
437    ($lang:ident, $member:expr) => {{
438        match $member.first_child(|id| id == $lang::AccessibilityModifier) {
439            None => true,
440            Some(m) => m
441                .first_child(|id| id == $lang::Private || id == $lang::Protected)
442                .is_none(),
443        }
444    }};
445}
446pub(crate) use ts_member_is_public;
447
448// JavaScript / Mozjs share the same class vocabulary. JS has no
449// `accessibility_modifier` — every class member is public, so each
450// class field maps 1:1 to both `na` and `npa`.
451//
452// We count ES2022 class fields (`class Foo { x = 1; }`):
453// `field_definition` direct children of `class_body`. Fields whose
454// initializer is an `arrow_function` or `function_expression` are
455// methods written as field initializers and belong to `Npm`, not
456// `Npa`.
457//
458// Prototype-based attribute assignments (`Foo.prototype.x = 5;`)
459// would also be legitimate JS attributes per Fenton's metric
460// taxonomy, but detecting them requires matching the `prototype`
461// property-identifier text. They are not yet detected by this impl,
462// so modern ES2015+ class syntax — the dominant style — is
463// unaffected, while legacy prototype-only files under-report. The
464// `code` source bytes are already available (bound as `_code`
465// below), so implementing prototype detection requires no trait
466// signature change (see `Abc::compute` for the existing pattern).
467
468macro_rules! js_npa_compute {
469    ($lang:ident) => {
470        fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
471            use $lang::*;
472
473            if Self::is_func_space(node) && stats.is_disabled() {
474                stats.is_class_space = true;
475            }
476
477            if !matches!(node.kind_id().into(), ClassBody) {
478                return;
479            }
480
481            for member in node.children() {
482                if matches!(member.kind_id().into(), FieldDefinition)
483                    && member
484                        .first_child(|id| {
485                            id == $lang::ArrowFunction || id == $lang::FunctionExpression
486                        })
487                        .is_none()
488                {
489                    stats.class_na += 1;
490                    stats.class_npa += 1;
491                }
492            }
493        }
494    };
495}
496
497// Per-language `Npa` impls live in sibling modules. The `mod`
498// declarations sit after the local `macro_rules!` so textual macro
499// scoping reaches the child files (mirrors `metrics::npm` and
500// `metrics::cyclomatic`).
501mod cpp;
502mod csharp;
503mod elixir;
504mod go;
505mod groovy;
506mod java;
507mod javascript;
508mod kotlin;
509mod mozcpp;
510mod mozjs;
511mod objc;
512mod php;
513mod python;
514mod ruby;
515mod rust;
516mod tsx;
517mod typescript;
518
519// Default no-op `Npa` impls. Audited in #188.
520//
521// Real defaults (no first-class class / OO grammar construct, so the
522// metric is genuinely 0):
523//   - PreprocCode, CcommentCode: no executable code.
524//   - BashCode: shell has no class concept.
525//   - PerlCode, LuaCode, TclCode: prototype / table / package-based
526//     OO is convention-only, not a grammar construct the audit treats
527//     as class-shaped.
528// Elixir Npa is implemented below (#275).
529implement_metric_trait!(
530    Npa,
531    CCode,
532    PreprocCode,
533    CcommentCode,
534    PerlCode,
535    BashCode,
536    LuaCode,
537    TclCode,
538    IrulesCode
539);
540
541#[cfg(test)]
542#[allow(
543    clippy::float_cmp,
544    clippy::cast_precision_loss,
545    clippy::cast_possible_truncation,
546    clippy::cast_sign_loss,
547    clippy::similar_names,
548    clippy::doc_markdown,
549    clippy::needless_raw_string_hashes,
550    clippy::too_many_lines
551)]
552mod tests {
553    use crate::tools::{assert_child_space_kind, check_func_space, check_metrics};
554
555    use super::*;
556
557    #[test]
558    fn java_single_attributes() {
559        check_metrics::<JavaParser>(
560            "class X {
561                public byte a;      // +1
562                public short b;     // +1
563                public int c;       // +1
564                public long d;      // +1
565                public float e;     // +1
566                public double f;    // +1
567                public boolean g;   // +1
568                public char h;      // +1
569                byte i;
570                short j;
571                int k;
572                long l;
573                float m;
574                double n;
575                boolean o;
576                char p;
577            }",
578            "foo.java",
579            |metric| {
580                insta::assert_json_snapshot!(
581                    metric.npa,
582                    @r#"
583                {
584                  "class_npa_sum": 8,
585                  "interface_npa_sum": 0,
586                  "class_attributes": 16,
587                  "interface_attributes": 0,
588                  "class_cda": 0.5,
589                  "interface_cda": 0.0,
590                  "total": 8,
591                  "total_attributes": 16,
592                  "cda": 0.5
593                }
594                "#
595                );
596            },
597        );
598    }
599
600    #[test]
601    fn java_multiple_attributes() {
602        check_metrics::<JavaParser>(
603            "class X {
604                public byte a1;                 // +1
605                public short b1, b2;            // +2
606                public int c1, c2, c3;          // +3
607                public long d1, d2, d3, d4;     // +4
608                public float e1, e2, e3, e4;    // +4
609                public double f1, f2, f3;       // +3
610                public boolean g1, g2;          // +2
611                public char h1;                 // +1
612                byte i1, i2, i3, i4;
613                short j1, j2, j3;
614                int k1, k2;
615                long l1;
616                float m1;
617                double n1, n2;
618                boolean o1, o2, o3;
619                char p1, p2, p3, p4;
620            }",
621            "foo.java",
622            |metric| {
623                insta::assert_json_snapshot!(
624                    metric.npa,
625                    @r#"
626                {
627                  "class_npa_sum": 20,
628                  "interface_npa_sum": 0,
629                  "class_attributes": 40,
630                  "interface_attributes": 0,
631                  "class_cda": 0.5,
632                  "interface_cda": 0.0,
633                  "total": 20,
634                  "total_attributes": 40,
635                  "cda": 0.5
636                }
637                "#
638                );
639            },
640        );
641    }
642
643    #[test]
644    fn java_initialized_attributes() {
645        check_metrics::<JavaParser>(
646            "class X {
647                public byte a1 = 1;                             // +1
648                public short b1 = 2, b2;                        // +2
649                public int c1, c2 = 3, c3;                      // +3
650                public long d1 = 4, d2, d3, d4 = 5;             // +4
651                public float e1, e2 = 6.0f, e3 = 7.0f, e4;      // +4
652                public double f1 = 8.0, f2 = 9.0, f3 = 10.0;    // +3
653                public boolean g1 = true, g2;                   // +2
654                public char h1 = 'a';                           // +1
655                byte i1 = 1, i2 = 2, i3 = 3, i4 = 4;
656                short j1 = 5, j2, j3 = 6;
657                int k1, k2 = 7;
658                long l1 = 8;
659                float m1 = 9.0f;
660                double n1, n2 = 10.0;
661                boolean o1, o2 = false, o3;
662                char p1 = 'a', p2 = 'b', p3 = 'c', p4 = 'd';
663            }",
664            "foo.java",
665            |metric| {
666                insta::assert_json_snapshot!(
667                    metric.npa,
668                    @r#"
669                {
670                  "class_npa_sum": 20,
671                  "interface_npa_sum": 0,
672                  "class_attributes": 40,
673                  "interface_attributes": 0,
674                  "class_cda": 0.5,
675                  "interface_cda": 0.0,
676                  "total": 20,
677                  "total_attributes": 40,
678                  "cda": 0.5
679                }
680                "#
681                );
682            },
683        );
684    }
685
686    #[test]
687    fn java_array_attributes() {
688        check_metrics::<JavaParser>(
689            "class X {
690                public byte[] a1, a2, a3, a4;                       // +4
691                public short b1[], b2[], b3[];                      // +3
692                public int[] c1 = { 1 }, c2;                        // +2
693                public long d1[] = { 1 };                           // +1
694                public float[] e1 = { 1.0f, 2.0f, 3.0f };           // +1
695                public double f1[] = { 1.0, 2.0, 3.0 }, f2[];       // +2
696                public boolean[] g1 = new boolean[5], g2, g3;       // +3
697                public char[] h1 = new char[5], h2[], h3[], h4[];   // +4
698                byte[] i1;
699                short j1[], j2[];
700                int[] k1, k2, k3 = { 1 };
701                long l1[], l2[] = { 1 }, l3[] = { 2 }, l4[];
702                float[] m1, m2, m3, m4 = { 1.0f, 2.0f, 3.0f };
703                double n1[], n2[] = { 1.0, 2.0, 3.0 }, n3[];
704                boolean[] o1, o2 = new boolean[5];
705                char[] p1 = new char[5];
706            }",
707            "foo.java",
708            |metric| {
709                insta::assert_json_snapshot!(
710                    metric.npa,
711                    @r#"
712                {
713                  "class_npa_sum": 20,
714                  "interface_npa_sum": 0,
715                  "class_attributes": 40,
716                  "interface_attributes": 0,
717                  "class_cda": 0.5,
718                  "interface_cda": 0.0,
719                  "total": 20,
720                  "total_attributes": 40,
721                  "cda": 0.5
722                }
723                "#
724                );
725            },
726        );
727    }
728
729    #[test]
730    fn java_object_attributes() {
731        check_metrics::<JavaParser>(
732            "class X {
733                public Integer[] a1 = { 1 };                                    // +1
734                public Integer b1, b2;                                          // +2
735                public String[] c1 = { \"Hello\" }, c2, c3 = { \"World!\" };    // +3
736                public String d1[][] = { { \"Hello\" }, { \"World!\" } };       // +1
737                public Y[] e1, e2[];                                            // +2
738                public Y f1[], f2[][], f3[][][];                                // +3
739                Integer[] g1 = { new Integer(1) };
740                Integer h1 = new Integer(1), h2 = new Integer(2);
741                String[] i1, i2 = { \"Hello World!\" }, i3;
742                String j1 = \"Hello World!\";
743                Y[] k1[], k2;
744                Y l1[][], l2[], l3 = new Y();
745            }",
746            "foo.java",
747            |metric| {
748                insta::assert_json_snapshot!(
749                    metric.npa,
750                    @r#"
751                {
752                  "class_npa_sum": 12,
753                  "interface_npa_sum": 0,
754                  "class_attributes": 24,
755                  "interface_attributes": 0,
756                  "class_cda": 0.5,
757                  "interface_cda": 0.0,
758                  "total": 12,
759                  "total_attributes": 24,
760                  "cda": 0.5
761                }
762                "#
763                );
764            },
765        );
766    }
767
768    #[test]
769    fn groovy_no_attributes() {
770        check_metrics::<GroovyParser>("class A { void foo() {} }", "foo.groovy", |metric| {
771            assert_eq!(metric.npa.total_na(), 0);
772            assert_eq!(metric.npa.total_npa(), 0);
773        });
774    }
775
776    #[test]
777    fn groovy_public_attributes() {
778        check_metrics::<GroovyParser>(
779            "class A {
780                public int x
781                public String name
782                private int hidden
783            }",
784            "foo.groovy",
785            |metric| {
786                // 3 total attributes, 2 public
787                assert_eq!(metric.npa.class_na_sum(), 3);
788                assert_eq!(metric.npa.class_npa_sum(), 2);
789            },
790        );
791    }
792
793    #[test]
794    fn groovy_def_attributes_not_public() {
795        // `def field` at class scope is a FieldDeclaration whose
796        // modifier list contains `Def`, not `Public`. Mirror Java's
797        // semantics: only explicit `public` is counted.
798        check_metrics::<GroovyParser>(
799            "class A {
800                def field1
801                def field2
802            }",
803            "foo.groovy",
804            |metric| {
805                // Both `def` fields parse as FieldDeclarations.
806                assert_eq!(metric.npa.class_na_sum(), 2);
807                assert_eq!(metric.npa.class_npa_sum(), 0);
808            },
809        );
810    }
811
812    #[test]
813    fn groovy_interface_attributes() {
814        // Structural `assert_child_space_kind` guards against an
815        // `InterfaceDeclaration` revert in `GroovyCode::is_func_space`
816        // — see #311.
817        check_func_space::<GroovyParser, _>(
818            "interface I {
819                public static final int A = 1
820                public static final int B = 2
821            }",
822            "foo.groovy",
823            |func_space| {
824                let metric = &func_space.metrics;
825                // Interface fields are implicitly public+static+final.
826                assert_eq!(metric.npa.interface_na_sum(), 2);
827                assert_eq!(metric.npa.interface_npa_sum(), 2);
828                assert_child_space_kind(&func_space, "I", SpaceKind::Interface);
829            },
830        );
831    }
832
833    #[test]
834    fn groovy_no_attributes_in_unit_scope() {
835        check_metrics::<GroovyParser>("int x = 1", "foo.groovy", |metric| {
836            assert_eq!(metric.npa.total_na(), 0);
837        });
838    }
839
840    #[test]
841    fn groovy_multiple_classes() {
842        check_metrics::<GroovyParser>(
843            "class A { public int a }
844            class B { public int b }",
845            "foo.groovy",
846            |metric| {
847                assert_eq!(metric.npa.class_na_sum(), 2);
848                assert_eq!(metric.npa.class_npa_sum(), 2);
849            },
850        );
851    }
852
853    #[test]
854    fn groovy_initialized_attributes() {
855        // Mirror of `java_initialized_attributes`: each
856        // `variable_declarator` inside a `field_declaration` counts
857        // as one attribute, with or without an initializer; `public`
858        // modifier promotes them all to NPA.
859        check_metrics::<GroovyParser>(
860            "class X {
861                public int a1 = 1, a2
862                public int b1 = 2
863                int c1, c2 = 3
864            }",
865            "foo.groovy",
866            |metric| {
867                // 5 attributes total, 3 public.
868                assert_eq!(metric.npa.class_na_sum(), 5);
869                assert_eq!(metric.npa.class_npa_sum(), 3);
870            },
871        );
872    }
873
874    #[test]
875    fn groovy_object_attributes() {
876        // Object-typed attributes (boxed primitives, user types,
877        // String, arrays). Each declarator is one attribute.
878        check_metrics::<GroovyParser>(
879            "class X {
880                public Integer a1
881                public String b1 = 'hello'
882                public Y[] c1
883            }",
884            "foo.groovy",
885            |metric| {
886                assert_eq!(metric.npa.class_na_sum(), 3);
887                assert_eq!(metric.npa.class_npa_sum(), 3);
888            },
889        );
890    }
891
892    #[test]
893    fn groovy_attribute_modifiers() {
894        // Multiple modifier orderings (public/static/final/transient/
895        // volatile etc.) must all be detected — what matters for NPA
896        // is whether the `Modifiers` block contains `Public`.
897        check_metrics::<GroovyParser>(
898            "class X {
899                public static int a
900                static public int b
901                public final int c = 1
902                final public int d = 2
903                private static int e
904                int f
905            }",
906            "foo.groovy",
907            |metric| {
908                // 6 attributes total, 4 public (regardless of order).
909                assert_eq!(metric.npa.class_na_sum(), 6);
910                assert_eq!(metric.npa.class_npa_sum(), 4);
911            },
912        );
913    }
914
915    #[test]
916    #[ignore = "dekobon Groovy grammar v1 does not yet support inner classes inside class bodies (https://github.com/dekobon/tree-sitter-groovy SPECIFICATION.md §4 — 'Field declarations, static initialisers, and inner classes land later')"]
917    fn groovy_nested_inner_classes() {
918        // Each nested `class` declaration is its own class space
919        // with its own NPA. Mirrors `java_nested_inner_classes`.
920        check_metrics::<GroovyParser>(
921            "class X {
922                public int a
923                class Y {
924                    public boolean b
925                    class Z {
926                        public char c
927                    }
928                }
929            }",
930            "foo.groovy",
931            |metric| {
932                // 3 classes, 3 public attributes.
933                assert_eq!(metric.npa.class_na_sum(), 3);
934                assert_eq!(metric.npa.class_npa_sum(), 3);
935            },
936        );
937    }
938
939    #[test]
940    fn groovy_array_attributes() {
941        // Array-typed attributes. Mirrors `java_array_attributes`.
942        check_metrics::<GroovyParser>(
943            "class X {
944                public int[] a
945                public String[] b
946                int[] c
947            }",
948            "foo.groovy",
949            |metric| {
950                assert_eq!(metric.npa.class_na_sum(), 3);
951                assert_eq!(metric.npa.class_npa_sum(), 2);
952            },
953        );
954    }
955
956    #[test]
957    fn groovy_anonymous_inner_class() {
958        // Object-creation expression containing a `class_body` —
959        // anonymous inner class. Its attributes are counted in a
960        // separate class space.
961        check_metrics::<GroovyParser>(
962            "class X {
963                public Runnable r = new Runnable() {
964                    public int x
965                    void run() {}
966                }
967            }",
968            "foo.groovy",
969            |metric| {
970                // outer X has 1 public attr `r`; inner anonymous
971                // has 1 public attr `x` => total 2.
972                assert_eq!(metric.npa.class_na_sum(), 2);
973                assert_eq!(metric.npa.class_npa_sum(), 2);
974            },
975        );
976    }
977
978    // Regression for issue #280: Groovy mirrors Java's enum / record /
979    // annotation handling. Record support in the dekobon Groovy grammar
980    // lags behind groovyc, but the grammar exposes `record_declaration`
981    // and the `Npa` body walker treats it identically.
982    #[test]
983    fn groovy_enum_counts_explicit_public_fields() {
984        check_metrics::<GroovyParser>(
985            "enum Status {
986                ACTIVE, INACTIVE;
987                public int code;
988                private int hidden;
989            }",
990            "foo.groovy",
991            |metric| {
992                assert_eq!(metric.npa.class_na_sum(), 2);
993                assert_eq!(metric.npa.class_npa_sum(), 1);
994            },
995        );
996    }
997
998    #[test]
999    fn groovy_annotation_type_counts_constants_as_implicit_public() {
1000        // The dekobon Groovy grammar parses `@interface` like Java
1001        // (modifier required, statements terminated with `;`). Mirror of
1002        // `java_annotation_type_counts_constants_as_implicit_public`
1003        // — the body-walker count is identical whether or not
1004        // Groovy's `AnnotationTypeDeclaration` is wired into
1005        // `is_func_space`, so the structural `check_func_space`
1006        // assertion is what catches a revert.
1007        check_func_space::<GroovyParser, _>(
1008            "public @interface Marker {
1009                int VERSION = 1;
1010                String NAME = \"x\";
1011            }",
1012            "foo.groovy",
1013            |func_space| {
1014                assert_eq!(func_space.metrics.npa.interface_na_sum(), 2);
1015                assert_eq!(func_space.metrics.npa.interface_npa_sum(), 2);
1016                assert_child_space_kind(&func_space, "Marker", SpaceKind::Interface);
1017            },
1018        );
1019    }
1020
1021    #[test]
1022    fn java_generic_attributes() {
1023        check_metrics::<JavaParser>(
1024            "class X<T, S extends T> {
1025                public T a1;                            // +1
1026                public Entry<T, S> b1, b2[];            // +2
1027                public ArrayList<T> c1, c2, c3;         // +3
1028                public HashMap<Long, Double> d1, d2;    // +2
1029                public TreeSet<String> e1;              // +1
1030                S f1;
1031                Entry<S, T> g1[], g2;
1032                ArrayList<S> h1, h2, h3;
1033                HashMap<Long, Float> i1, i2;
1034                TreeSet<Entry<S, T>> j1;
1035            }",
1036            "foo.java",
1037            |metric| {
1038                insta::assert_json_snapshot!(
1039                    metric.npa,
1040                    @r#"
1041                {
1042                  "class_npa_sum": 9,
1043                  "interface_npa_sum": 0,
1044                  "class_attributes": 18,
1045                  "interface_attributes": 0,
1046                  "class_cda": 0.5,
1047                  "interface_cda": 0.0,
1048                  "total": 9,
1049                  "total_attributes": 18,
1050                  "cda": 0.5
1051                }
1052                "#
1053                );
1054            },
1055        );
1056    }
1057
1058    #[test]
1059    fn java_attribute_modifiers() {
1060        check_metrics::<JavaParser>(
1061            "class X {
1062                public transient volatile static int a;     // +1
1063                transient public volatile static int b;     // +1
1064                transient volatile public static int c;     // +1
1065                transient volatile static public int d;     // +1
1066                public transient static final int e = 1;    // +1
1067                transient public static final int f = 2;    // +1
1068                transient static public final int g = 3;    // +1
1069                transient static final public int h = 4;    // +1
1070                protected transient volatile static int i;
1071                transient volatile static protected int j;
1072                private transient volatile static int k;
1073                transient volatile static private int l;
1074                transient volatile static int m;
1075                transient static final int n = 5;
1076                static public final int o = 6;              // +1
1077                final public int p = 7;                     // +1
1078            }",
1079            "foo.java",
1080            |metric| {
1081                insta::assert_json_snapshot!(
1082                    metric.npa,
1083                    @r#"
1084                {
1085                  "class_npa_sum": 10,
1086                  "interface_npa_sum": 0,
1087                  "class_attributes": 16,
1088                  "interface_attributes": 0,
1089                  "class_cda": 0.625,
1090                  "interface_cda": 0.0,
1091                  "total": 10,
1092                  "total_attributes": 16,
1093                  "cda": 0.625
1094                }
1095                "#
1096                );
1097            },
1098        );
1099    }
1100
1101    #[test]
1102    fn java_classes() {
1103        check_metrics::<JavaParser>(
1104            "class X {
1105                public int a;       // +1
1106                public boolean b;   // +1
1107                private char c;
1108            }
1109            class Y {
1110                private double d;
1111                private long e;
1112                public float f;      // +1
1113            }",
1114            "foo.java",
1115            |metric| {
1116                insta::assert_json_snapshot!(
1117                    metric.npa,
1118                    @r#"
1119                {
1120                  "class_npa_sum": 3,
1121                  "interface_npa_sum": 0,
1122                  "class_attributes": 6,
1123                  "interface_attributes": 0,
1124                  "class_cda": 0.5,
1125                  "interface_cda": 0.0,
1126                  "total": 3,
1127                  "total_attributes": 6,
1128                  "cda": 0.5
1129                }
1130                "#
1131                );
1132            },
1133        );
1134    }
1135
1136    #[test]
1137    fn java_nested_inner_classes() {
1138        check_metrics::<JavaParser>(
1139            "class X {
1140                public int a;           // +1
1141                class Y {
1142                    public boolean b;   // +1
1143                    class Z {
1144                        public char c;  // +1
1145                    }
1146                }
1147            }",
1148            "foo.java",
1149            |metric| {
1150                insta::assert_json_snapshot!(
1151                    metric.npa,
1152                    @r#"
1153                {
1154                  "class_npa_sum": 3,
1155                  "interface_npa_sum": 0,
1156                  "class_attributes": 3,
1157                  "interface_attributes": 0,
1158                  "class_cda": 1.0,
1159                  "interface_cda": 0.0,
1160                  "total": 3,
1161                  "total_attributes": 3,
1162                  "cda": 1.0
1163                }
1164                "#
1165                );
1166            },
1167        );
1168    }
1169
1170    #[test]
1171    fn java_local_inner_classes() {
1172        check_metrics::<JavaParser>(
1173            "class X {
1174                public int a;                   // +1
1175                void x() {
1176                    class Y {
1177                        public boolean b;       // +1
1178                        void y() {
1179                            class Z {
1180                                public char c;  // +1
1181                                void z() {}
1182                            }
1183                        }
1184                    }
1185                }
1186            }",
1187            "foo.java",
1188            |metric| {
1189                insta::assert_json_snapshot!(
1190                    metric.npa,
1191                    @r#"
1192                {
1193                  "class_npa_sum": 3,
1194                  "interface_npa_sum": 0,
1195                  "class_attributes": 3,
1196                  "interface_attributes": 0,
1197                  "class_cda": 1.0,
1198                  "interface_cda": 0.0,
1199                  "total": 3,
1200                  "total_attributes": 3,
1201                  "cda": 1.0
1202                }
1203                "#
1204                );
1205            },
1206        );
1207    }
1208
1209    #[test]
1210    fn java_anonymous_inner_classes() {
1211        check_metrics::<JavaParser>(
1212            "abstract class X {
1213                public int a;               // +1
1214            }
1215            abstract class Y {
1216                boolean b;
1217            }
1218            class Z {
1219                public char c;              // +1
1220                public void z(){
1221                    X x1 = new X() {
1222                        public double d;    // +1
1223                    };
1224                    Y y1 = new Y() {
1225                        long e;
1226                    };
1227                }
1228            }",
1229            "foo.java",
1230            |metric| {
1231                insta::assert_json_snapshot!(
1232                    metric.npa,
1233                    @r#"
1234                {
1235                  "class_npa_sum": 3,
1236                  "interface_npa_sum": 0,
1237                  "class_attributes": 5,
1238                  "interface_attributes": 0,
1239                  "class_cda": 0.6,
1240                  "interface_cda": 0.0,
1241                  "total": 3,
1242                  "total_attributes": 5,
1243                  "cda": 0.6
1244                }
1245                "#
1246                );
1247            },
1248        );
1249    }
1250
1251    #[test]
1252    fn java_interface() {
1253        check_metrics::<JavaParser>(
1254            "interface X {
1255                public int a = 0;           // +1
1256                static boolean b = false;   // +1
1257                final char c = ' ';         // +1
1258            }",
1259            "foo.java",
1260            |metric| {
1261                insta::assert_json_snapshot!(
1262                    metric.npa,
1263                    @r#"
1264                {
1265                  "class_npa_sum": 0,
1266                  "interface_npa_sum": 3,
1267                  "class_attributes": 0,
1268                  "interface_attributes": 3,
1269                  "class_cda": 0.0,
1270                  "interface_cda": 1.0,
1271                  "total": 3,
1272                  "total_attributes": 3,
1273                  "cda": 1.0
1274                }
1275                "#
1276                );
1277            },
1278        );
1279    }
1280
1281    // Regression for issue #280: Java `EnumDeclaration` must be
1282    // classified as a class space so `Npa` walks its body and counts
1283    // explicit public fields declared after the enum constants.
1284    #[test]
1285    fn java_enum_counts_explicit_public_fields() {
1286        check_metrics::<JavaParser>(
1287            "enum Status {
1288                ACTIVE, INACTIVE;
1289                public static final int FLAG = 1;   // implicit static final, still public
1290                public int code;                    // +1 explicit public
1291                private int hidden;                 // not public
1292            }",
1293            "foo.java",
1294            |metric| {
1295                // 1 class space (the enum), 3 total fields, 2 explicit public.
1296                assert_eq!(metric.npa.class_na_sum(), 3);
1297                assert_eq!(metric.npa.class_npa_sum(), 2);
1298            },
1299        );
1300    }
1301
1302    // Regression for issue #280: Java `RecordDeclaration` reuses
1303    // `ClassBody` for its explicit body, so explicit fields declared
1304    // inside it count. Record components in the parameter list are
1305    // implicit public final fields at the bytecode level but are NOT
1306    // counted here, matching the C# precedent (only explicit body
1307    // members count).
1308    #[test]
1309    fn java_record_counts_explicit_body_fields() {
1310        check_metrics::<JavaParser>(
1311            "record Point(int x, int y) {
1312                public static int origin = 0;       // explicit body, public
1313                private int cached;                 // explicit body, private
1314            }",
1315            "foo.java",
1316            |metric| {
1317                // Only explicit body fields are counted; the `x` / `y`
1318                // record components are not.
1319                assert_eq!(metric.npa.class_na_sum(), 2);
1320                assert_eq!(metric.npa.class_npa_sum(), 1);
1321            },
1322        );
1323    }
1324
1325    #[test]
1326    fn java_annotation_type_counts_constants_as_implicit_public() {
1327        // Asserting only `interface_na_sum` / `interface_npa_sum`
1328        // would pass vacuously if `AnnotationTypeDeclaration` were
1329        // dropped from `JavaCode::is_func_space`: the body walker
1330        // counts annotation-type constants regardless of the
1331        // surrounding FuncSpace kind, so the file-level Unit would
1332        // still report 2.0 for both. The `check_func_space`
1333        // assertion catches that revert by requiring the annotation
1334        // type to actually open an `Interface` FuncSpace.
1335        check_func_space::<JavaParser, _>(
1336            "@interface Marker {
1337                int VERSION = 1;        // implicit public static final
1338                String NAME = \"x\";    // implicit public static final
1339            }",
1340            "foo.java",
1341            |func_space| {
1342                assert_eq!(func_space.metrics.npa.interface_na_sum(), 2);
1343                assert_eq!(func_space.metrics.npa.interface_npa_sum(), 2);
1344                assert_child_space_kind(&func_space, "Marker", SpaceKind::Interface);
1345            },
1346        );
1347    }
1348
1349    #[test]
1350    fn php_no_class_attributes() {
1351        check_metrics::<PhpParser>(
1352            "<?php class A { public function f(): void {} }",
1353            "foo.php",
1354            |metric| insta::assert_json_snapshot!(metric.npa),
1355        );
1356    }
1357
1358    #[test]
1359    fn csharp_single_attributes() {
1360        check_metrics::<CsharpParser>(
1361            "class X {
1362                public byte a;
1363                public short b;
1364                public int c;
1365                public long d;
1366                public float e;
1367                public double f;
1368                public bool g;
1369                public char h;
1370                byte i;
1371                short j;
1372                int k;
1373                long l;
1374                float m;
1375                double n;
1376                bool o;
1377                char p;
1378            }",
1379            "foo.cs",
1380            |metric| {
1381                assert_eq!(metric.npa.class_npa_sum(), 8);
1382                assert_eq!(metric.npa.class_na_sum(), 16);
1383                assert_eq!(metric.npa.interface_na_sum(), 0);
1384                insta::assert_json_snapshot!(metric.npa);
1385            },
1386        );
1387    }
1388
1389    #[test]
1390    fn csharp_multiple_attributes() {
1391        check_metrics::<CsharpParser>(
1392            "class X {
1393                public byte a1;
1394                public short b1, b2;
1395                public int c1, c2, c3;
1396                public long d1, d2, d3, d4;
1397                public bool g1, g2;
1398                byte i1, i2, i3, i4;
1399                int k1, k2;
1400            }",
1401            "foo.cs",
1402            |metric| {
1403                assert_eq!(metric.npa.class_npa_sum(), 12);
1404                assert_eq!(metric.npa.class_na_sum(), 18);
1405                assert_eq!(metric.npa.interface_na_sum(), 0);
1406                insta::assert_json_snapshot!(metric.npa);
1407            },
1408        );
1409    }
1410
1411    #[test]
1412    fn csharp_initialized_attributes() {
1413        check_metrics::<CsharpParser>(
1414            "class X {
1415                public int a = 1;
1416                public bool b = true;
1417                public string c = \"hello\";
1418                public double d = 3.14;
1419                int e = 0;
1420            }",
1421            "foo.cs",
1422            |metric| {
1423                assert_eq!(metric.npa.class_npa_sum(), 4);
1424                assert_eq!(metric.npa.class_na_sum(), 5);
1425                assert_eq!(metric.npa.interface_na_sum(), 0);
1426                insta::assert_json_snapshot!(metric.npa);
1427            },
1428        );
1429    }
1430
1431    #[test]
1432    fn csharp_array_attributes() {
1433        check_metrics::<CsharpParser>(
1434            "class X {
1435                public int[] a;
1436                public string[] b = new string[5];
1437                int[] c;
1438            }",
1439            "foo.cs",
1440            |metric| {
1441                assert_eq!(metric.npa.class_npa_sum(), 2);
1442                assert_eq!(metric.npa.class_na_sum(), 3);
1443                assert_eq!(metric.npa.interface_na_sum(), 0);
1444                insta::assert_json_snapshot!(metric.npa);
1445            },
1446        );
1447    }
1448
1449    #[test]
1450    fn csharp_object_attributes() {
1451        check_metrics::<CsharpParser>(
1452            "class Point { public int X, Y; }
1453             class Shape {
1454                public Point origin;
1455                public Point endpoint = new Point();
1456                Point hidden;
1457             }",
1458            "foo.cs",
1459            |metric| {
1460                assert_eq!(metric.npa.class_npa_sum(), 4);
1461                assert_eq!(metric.npa.class_na_sum(), 5);
1462                assert_eq!(metric.npa.interface_na_sum(), 0);
1463                insta::assert_json_snapshot!(metric.npa);
1464            },
1465        );
1466    }
1467
1468    #[test]
1469    fn csharp_generic_attributes() {
1470        check_metrics::<CsharpParser>(
1471            "class X {
1472                public System.Collections.Generic.List<int> a;
1473                public System.Collections.Generic.Dictionary<string, int> b;
1474                System.Collections.Generic.List<string> c;
1475            }",
1476            "foo.cs",
1477            |metric| {
1478                assert_eq!(metric.npa.class_npa_sum(), 2);
1479                assert_eq!(metric.npa.class_na_sum(), 3);
1480                assert_eq!(metric.npa.interface_na_sum(), 0);
1481                insta::assert_json_snapshot!(metric.npa);
1482            },
1483        );
1484    }
1485
1486    #[test]
1487    fn csharp_attribute_modifiers() {
1488        check_metrics::<CsharpParser>(
1489            "class X {
1490                public int a;
1491                private int b;
1492                protected int c;
1493                internal int d;
1494                public static int e;
1495                public readonly int f;
1496                public const int g = 1;
1497            }",
1498            "foo.cs",
1499            |metric| {
1500                // Modifiers test: 4 of 7 fields are explicitly `public`. The
1501                // visibility-filter split is the spec.
1502                assert_eq!(metric.npa.class_npa_sum(), 4);
1503                assert_eq!(metric.npa.class_na_sum(), 7);
1504                assert_eq!(metric.npa.interface_na_sum(), 0);
1505                insta::assert_json_snapshot!(metric.npa);
1506            },
1507        );
1508    }
1509
1510    #[test]
1511    fn csharp_classes() {
1512        check_metrics::<CsharpParser>(
1513            "class A {
1514                public int a;
1515                public int b;
1516                int c;
1517            }
1518            class B {
1519                public string s;
1520                int n;
1521            }",
1522            "foo.cs",
1523            |metric| {
1524                assert_eq!(metric.npa.class_npa_sum(), 3);
1525                assert_eq!(metric.npa.class_na_sum(), 5);
1526                assert_eq!(metric.npa.interface_na_sum(), 0);
1527                insta::assert_json_snapshot!(metric.npa);
1528            },
1529        );
1530    }
1531
1532    #[test]
1533    fn csharp_nested_inner_classes() {
1534        check_metrics::<CsharpParser>(
1535            "class Outer {
1536                public int a;
1537                int b;
1538                public class Inner {
1539                    public string s;
1540                    int n;
1541                }
1542            }",
1543            "foo.cs",
1544            |metric| {
1545                assert_eq!(metric.npa.class_npa_sum(), 2);
1546                assert_eq!(metric.npa.class_na_sum(), 4);
1547                assert_eq!(metric.npa.interface_na_sum(), 0);
1548                insta::assert_json_snapshot!(metric.npa);
1549            },
1550        );
1551    }
1552
1553    #[test]
1554    fn csharp_struct_attributes() {
1555        // C#-only: structs declare fields like classes; visibility rule
1556        // applies the same way (default is private).
1557        check_metrics::<CsharpParser>(
1558            "struct Point {
1559                public int X;
1560                public int Y;
1561                int Hidden;
1562            }",
1563            "foo.cs",
1564            |metric| {
1565                assert_eq!(metric.npa.class_npa_sum(), 2);
1566                assert_eq!(metric.npa.class_na_sum(), 3);
1567                assert_eq!(metric.npa.interface_na_sum(), 0);
1568                insta::assert_json_snapshot!(metric.npa);
1569            },
1570        );
1571    }
1572
1573    #[test]
1574    fn csharp_record_attributes() {
1575        // C#-only: records can declare body fields just like classes.
1576        // Positional record properties are not modelled (EC9).
1577        check_metrics::<CsharpParser>(
1578            "record Person {
1579                public string Name;
1580                int Age;
1581            }",
1582            "foo.cs",
1583            |metric| {
1584                assert_eq!(metric.npa.class_npa_sum(), 1);
1585                assert_eq!(metric.npa.class_na_sum(), 2);
1586                assert_eq!(metric.npa.interface_na_sum(), 0);
1587                insta::assert_json_snapshot!(metric.npa);
1588            },
1589        );
1590    }
1591
1592    #[test]
1593    fn csharp_interface() {
1594        // EC14 — interface members default to public; all fields count.
1595        // Structural `assert_child_space_kind` guards against an
1596        // `InterfaceDeclaration` revert in `CsharpCode::is_func_space`
1597        // — see #311.
1598        check_func_space::<CsharpParser, _>(
1599            "interface I {
1600                static int A = 1;
1601                static string B = \"hello\";
1602            }",
1603            "foo.cs",
1604            |func_space| {
1605                let metric = &func_space.metrics;
1606                assert_eq!(metric.npa.class_na_sum(), 0);
1607                assert_eq!(metric.npa.interface_na_sum(), 2);
1608                // No explicit modifier means default-public: both fields
1609                // count as public attributes (#780 regression guard).
1610                assert_eq!(metric.npa.interface_npa_sum(), 2);
1611                insta::assert_json_snapshot!(metric.npa);
1612                assert_child_space_kind(&func_space, "I", SpaceKind::Interface);
1613            },
1614        );
1615    }
1616
1617    #[test]
1618    fn csharp_interface_explicit_modifiers() {
1619        // #780 — C# 8+ permits explicit `private`/`protected` on interface
1620        // members. Default-public members count toward npa; an explicit
1621        // private/protected member does not. Here `Hidden` is private, so
1622        // only `Shown` and the unmodified `Implicit` are public.
1623        check_metrics::<CsharpParser>(
1624            "interface I {
1625                private static int Hidden = 1;
1626                protected static int AlsoHidden = 2;
1627                public static int Shown = 3;
1628                static int Implicit = 4;
1629            }",
1630            "foo.cs",
1631            |metric| {
1632                assert_eq!(metric.npa.interface_na_sum(), 4);
1633                // Only `Shown` (explicit public) and `Implicit` (default
1634                // public) count; `Hidden`/`AlsoHidden` are excluded.
1635                assert_eq!(metric.npa.interface_npa_sum(), 2);
1636                assert_eq!(metric.npa.class_na_sum(), 0);
1637                insta::assert_json_snapshot!(metric.npa);
1638            },
1639        );
1640    }
1641
1642    #[test]
1643    fn csharp_interface_multi_declarator_modifier() {
1644        // The visibility modifier applies to every declarator of a field, so
1645        // the public/private split is per-declaration. `private int a, b;`
1646        // contributes two attributes, neither public; `int c, d;` (default
1647        // public) contributes two public attributes.
1648        check_metrics::<CsharpParser>(
1649            "interface I {
1650                private static int a = 1, b = 2;
1651                static int c = 3, d = 4;
1652            }",
1653            "foo.cs",
1654            |metric| {
1655                assert_eq!(metric.npa.interface_na_sum(), 4);
1656                assert_eq!(metric.npa.interface_npa_sum(), 2);
1657                assert_eq!(metric.npa.class_na_sum(), 0);
1658                insta::assert_json_snapshot!(metric.npa);
1659            },
1660        );
1661    }
1662
1663    #[test]
1664    fn php_one_public_attribute() {
1665        check_metrics::<PhpParser>(
1666            "<?php class A { public int $x = 0; }",
1667            "foo.php",
1668            |metric| insta::assert_json_snapshot!(metric.npa),
1669        );
1670    }
1671
1672    #[test]
1673    fn php_one_private_attribute() {
1674        check_metrics::<PhpParser>(
1675            "<?php class A { private int $x = 0; }",
1676            "foo.php",
1677            |metric| insta::assert_json_snapshot!(metric.npa),
1678        );
1679    }
1680
1681    #[test]
1682    fn php_one_protected_attribute() {
1683        check_metrics::<PhpParser>(
1684            "<?php class A { protected int $x = 0; }",
1685            "foo.php",
1686            |metric| insta::assert_json_snapshot!(metric.npa),
1687        );
1688    }
1689
1690    #[test]
1691    fn php_mixed_visibility_attributes() {
1692        check_metrics::<PhpParser>(
1693            "<?php
1694            class A {
1695                public int $a = 0;
1696                public int $b = 0;
1697                private int $c = 0;
1698                protected int $d = 0;
1699            }",
1700            "foo.php",
1701            |metric| insta::assert_json_snapshot!(metric.npa),
1702        );
1703    }
1704
1705    #[test]
1706    fn php_static_public_attribute() {
1707        check_metrics::<PhpParser>(
1708            "<?php class A { public static int $x = 0; }",
1709            "foo.php",
1710            |metric| insta::assert_json_snapshot!(metric.npa),
1711        );
1712    }
1713
1714    #[test]
1715    fn php_readonly_public_attribute() {
1716        check_metrics::<PhpParser>(
1717            "<?php class A { public readonly int $x; }",
1718            "foo.php",
1719            |metric| insta::assert_json_snapshot!(metric.npa),
1720        );
1721    }
1722
1723    #[test]
1724    fn php_multiple_attributes_per_declaration() {
1725        // A single property_declaration can declare several
1726        // property_elements; each counts.
1727        check_metrics::<PhpParser>(
1728            "<?php class A { public int $a = 0, $b = 0, $c = 0; }",
1729            "foo.php",
1730            |metric| insta::assert_json_snapshot!(metric.npa),
1731        );
1732    }
1733
1734    #[test]
1735    fn php_interface_constants() {
1736        // Interface constants are implicitly public.
1737        check_metrics::<PhpParser>(
1738            "<?php
1739            interface I {
1740                const A = 1;
1741                const B = 2;
1742            }",
1743            "foo.php",
1744            |metric| insta::assert_json_snapshot!(metric.npa),
1745        );
1746    }
1747
1748    #[test]
1749    fn php_enum_cases_not_counted() {
1750        // #781: enum cases are sum-type tags, not data fields, so they
1751        // contribute zero npa attributes — matching the Java, Kotlin,
1752        // Rust, and C# convention. Before #781 this enum reported
1753        // class_na = class_npa = 3 (one per case); it must now be 0.
1754        check_metrics::<PhpParser>(
1755            "<?php
1756            enum Color {
1757                case Red;
1758                case Green;
1759                case Blue;
1760            }",
1761            "foo.php",
1762            |metric| {
1763                assert_eq!(metric.npa.class_na_sum(), 0);
1764                assert_eq!(metric.npa.class_npa_sum(), 0);
1765                assert_eq!(metric.npa.interface_na_sum(), 0);
1766                insta::assert_json_snapshot!(metric.npa);
1767            },
1768        );
1769    }
1770
1771    #[test]
1772    fn php_enum_const_not_counted() {
1773        // #781: a PHP enum body may declare `const`s alongside its
1774        // cases, but class-level `const`s are not counted as attributes
1775        // outside an enum either (only `PropertyDeclaration` counts), so
1776        // the enum const is consistently excluded. This enum reports 0.
1777        check_metrics::<PhpParser>(
1778            "<?php
1779            enum Suit: string {
1780                case Hearts = 'H';
1781                case Diamonds = 'D';
1782                const Wild = 'W';
1783                public function label(): string { return $this->name; }
1784            }",
1785            "foo.php",
1786            |metric| {
1787                assert_eq!(metric.npa.class_na_sum(), 0);
1788                assert_eq!(metric.npa.class_npa_sum(), 0);
1789            },
1790        );
1791    }
1792
1793    #[test]
1794    fn php_enum_npa_matches_java_enum_npa() {
1795        // #781 cross-language parity: an enum whose only members are
1796        // cases reports the same npa (0) in PHP and Java. The cases are
1797        // sum-type tags, excluded by both languages. `check_metrics`
1798        // takes a non-capturing `fn`, so each side asserts the shared
1799        // target (0, 0) independently; parity follows by transitivity.
1800        check_metrics::<PhpParser>(
1801            "<?php
1802            enum Color {
1803                case Red;
1804                case Green;
1805                case Blue;
1806            }",
1807            "foo.php",
1808            |metric| {
1809                assert_eq!(metric.npa.class_na_sum(), 0);
1810                assert_eq!(metric.npa.class_npa_sum(), 0);
1811            },
1812        );
1813        check_metrics::<JavaParser>(
1814            "enum Color {
1815                RED, GREEN, BLUE;
1816            }",
1817            "foo.java",
1818            |metric| {
1819                assert_eq!(metric.npa.class_na_sum(), 0);
1820                assert_eq!(metric.npa.class_npa_sum(), 0);
1821            },
1822        );
1823    }
1824
1825    #[test]
1826    fn php_trait_attributes() {
1827        check_metrics::<PhpParser>(
1828            "<?php
1829            trait T {
1830                public int $a = 0;
1831                private int $b = 0;
1832            }",
1833            "foo.php",
1834            |metric| insta::assert_json_snapshot!(metric.npa),
1835        );
1836    }
1837
1838    #[test]
1839    fn php_no_explicit_visibility_excluded() {
1840        // PHP 8.x deprecates implicit-public for properties; we follow
1841        // Java's strict-explicit rule and do NOT count properties without
1842        // an explicit `public` modifier.
1843        check_metrics::<PhpParser>("<?php class A { var $x = 0; }", "foo.php", |metric| {
1844            // The property is excluded from the public-count (npa) because
1845            // `var` is not an explicit `public` modifier, but still
1846            // contributes to the total-count (na). This split is the spec.
1847            assert_eq!(metric.npa.class_npa_sum(), 0);
1848            assert_eq!(metric.npa.class_na_sum(), 1);
1849            assert_eq!(metric.npa.interface_na_sum(), 0);
1850            insta::assert_json_snapshot!(metric.npa);
1851        });
1852    }
1853
1854    #[test]
1855    fn php_anonymous_class_attributes() {
1856        // Anonymous classes have their own DeclarationList space and
1857        // their public properties count. The Npa impl branches on
1858        // `parent_kind == AnonymousClass` and this test exercises that
1859        // arm.
1860        check_metrics::<PhpParser>(
1861            "<?php
1862            $obj = new class {
1863                public int $a = 0;
1864                private int $b = 0;
1865            };",
1866            "foo.php",
1867            |metric| insta::assert_json_snapshot!(metric.npa),
1868        );
1869    }
1870
1871    #[test]
1872    fn php_property_promotion_excluded() {
1873        // Constructor property promotion (PHP 8.0+) declares both a
1874        // parameter AND a property in one syntax. The promoted property
1875        // lives under `formal_parameters`, NOT under
1876        // `declaration_list`, so the current Npa impl naturally
1877        // excludes it. This is a documented limitation; this test
1878        // pins the behavior so a future change that starts counting
1879        // promoted properties has to update the test deliberately.
1880        check_metrics::<PhpParser>(
1881            "<?php
1882            class A {
1883                public function __construct(public string $x, public int $y) {}
1884            }",
1885            "foo.php",
1886            |metric| insta::assert_json_snapshot!(metric.npa),
1887        );
1888    }
1889
1890    // --- Kotlin NPA tests -------------------------------------------------
1891    //
1892    // Reference: Kotlin properties (`val` / `var`) declared inside a class
1893    // body are attributes. Default visibility is `public`. Primary
1894    // constructor parameters carrying `val` / `var` are parameter
1895    // properties and count. Companion-object members fold into the
1896    // enclosing class. Top-level properties belong to the `Unit` space
1897    // and are excluded.
1898
1899    #[test]
1900    fn kotlin_empty_class_no_attributes() {
1901        check_metrics::<KotlinParser>("class C {}", "foo.kt", |metric| {
1902            assert_eq!(metric.npa.class_npa_sum(), 0);
1903            assert_eq!(metric.npa.class_na_sum(), 0);
1904            assert_eq!(metric.npa.interface_na_sum(), 0);
1905            insta::assert_json_snapshot!(metric.npa);
1906        });
1907    }
1908
1909    #[test]
1910    fn kotlin_public_val_var_default() {
1911        // Kotlin's default visibility is public — no modifier means public.
1912        check_metrics::<KotlinParser>(
1913            "class C {
1914                val a: Int = 1
1915                var b: Int = 2
1916                val c: String = \"hi\"
1917            }",
1918            "foo.kt",
1919            |metric| {
1920                assert_eq!(metric.npa.class_npa_sum(), 3);
1921                assert_eq!(metric.npa.class_na_sum(), 3);
1922                insta::assert_json_snapshot!(metric.npa);
1923            },
1924        );
1925    }
1926
1927    #[test]
1928    fn kotlin_private_val_var() {
1929        // Private properties contribute to total `na` but not to `npa`.
1930        check_metrics::<KotlinParser>(
1931            "class C {
1932                val a: Int = 1               // public
1933                private val b: Int = 2       // not public
1934                var c: Int = 3               // public
1935                private var d: Int = 4       // not public
1936            }",
1937            "foo.kt",
1938            |metric| {
1939                assert_eq!(metric.npa.class_npa_sum(), 2);
1940                assert_eq!(metric.npa.class_na_sum(), 4);
1941                insta::assert_json_snapshot!(metric.npa);
1942            },
1943        );
1944    }
1945
1946    #[test]
1947    fn kotlin_protected_internal_excluded_from_public() {
1948        check_metrics::<KotlinParser>(
1949            "open class C {
1950                protected val a: Int = 1
1951                internal val b: Int = 2
1952                public val c: Int = 3        // explicit public
1953            }",
1954            "foo.kt",
1955            |metric| {
1956                assert_eq!(metric.npa.class_npa_sum(), 1);
1957                assert_eq!(metric.npa.class_na_sum(), 3);
1958                insta::assert_json_snapshot!(metric.npa);
1959            },
1960        );
1961    }
1962
1963    #[test]
1964    fn kotlin_primary_constructor_parameter_property() {
1965        // `val`/`var` on primary constructor parameters declares both a
1966        // parameter AND a property. Bare `name: Type` parameters are NOT
1967        // attributes.
1968        check_metrics::<KotlinParser>(
1969            "class C(val a: Int, var b: Int, c: Int) {
1970                val d: Int = c
1971            }",
1972            "foo.kt",
1973            |metric| {
1974                // a, b, d -> public; c -> not an attribute (no val/var)
1975                assert_eq!(metric.npa.class_npa_sum(), 3);
1976                assert_eq!(metric.npa.class_na_sum(), 3);
1977                insta::assert_json_snapshot!(metric.npa);
1978            },
1979        );
1980    }
1981
1982    #[test]
1983    fn kotlin_primary_constructor_private_param_property() {
1984        check_metrics::<KotlinParser>(
1985            "class C(private val a: Int, val b: Int)",
1986            "foo.kt",
1987            |metric| {
1988                assert_eq!(metric.npa.class_npa_sum(), 1);
1989                assert_eq!(metric.npa.class_na_sum(), 2);
1990                insta::assert_json_snapshot!(metric.npa);
1991            },
1992        );
1993    }
1994
1995    #[test]
1996    fn kotlin_secondary_constructor_does_not_add_attrs() {
1997        // Secondary constructors are methods, not attribute declarations.
1998        check_metrics::<KotlinParser>(
1999            "class C {
2000                private var a: Int = 0
2001                constructor(n: Int) { a = n }
2002            }",
2003            "foo.kt",
2004            |metric| {
2005                assert_eq!(metric.npa.class_npa_sum(), 0);
2006                assert_eq!(metric.npa.class_na_sum(), 1);
2007                insta::assert_json_snapshot!(metric.npa);
2008            },
2009        );
2010    }
2011
2012    #[test]
2013    fn kotlin_companion_object_attributes() {
2014        // Companion-object properties fold into the enclosing class as
2015        // "static" attributes.
2016        check_metrics::<KotlinParser>(
2017            "class Holder {
2018                val instance: Int = 1
2019                companion object {
2020                    val SCALE: Int = 10
2021                    private val SECRET: Int = 7
2022                }
2023            }",
2024            "foo.kt",
2025            |metric| {
2026                // instance (public) + SCALE (public) = 2 public
2027                // SECRET counts toward total na but not npa
2028                assert_eq!(metric.npa.class_npa_sum(), 2);
2029                assert_eq!(metric.npa.class_na_sum(), 3);
2030                insta::assert_json_snapshot!(metric.npa);
2031            },
2032        );
2033    }
2034
2035    #[test]
2036    fn kotlin_data_class_attributes() {
2037        // `data class` parameters are the canonical positional attributes.
2038        check_metrics::<KotlinParser>(
2039            "data class Point(val x: Int, val y: Int)",
2040            "foo.kt",
2041            |metric| {
2042                assert_eq!(metric.npa.class_npa_sum(), 2);
2043                assert_eq!(metric.npa.class_na_sum(), 2);
2044                insta::assert_json_snapshot!(metric.npa);
2045            },
2046        );
2047    }
2048
2049    #[test]
2050    fn kotlin_object_singleton_attributes() {
2051        check_metrics::<KotlinParser>(
2052            "object Config {
2053                val DEFAULT: Int = 42
2054                private val SEED: Int = 0
2055                var debug: Boolean = false
2056            }",
2057            "foo.kt",
2058            |metric| {
2059                // DEFAULT, debug -> public; SEED -> not.
2060                assert_eq!(metric.npa.class_npa_sum(), 2);
2061                assert_eq!(metric.npa.class_na_sum(), 3);
2062                insta::assert_json_snapshot!(metric.npa);
2063            },
2064        );
2065    }
2066
2067    #[test]
2068    fn kotlin_interface_attributes() {
2069        // Interface members are implicitly public; all properties count
2070        // toward `interface_npa` and `interface_na`. Structural
2071        // `assert_child_space_kind` guards against an
2072        // `InterfaceDeclaration` revert in `KotlinCode::is_func_space`
2073        // — see #311.
2074        check_func_space::<KotlinParser, _>(
2075            "interface I {
2076                val a: Int
2077                val b: String
2078            }",
2079            "foo.kt",
2080            |func_space| {
2081                let metric = &func_space.metrics;
2082                assert_eq!(metric.npa.interface_npa_sum(), 2);
2083                assert_eq!(metric.npa.interface_na_sum(), 2);
2084                assert_eq!(metric.npa.class_na_sum(), 0);
2085                insta::assert_json_snapshot!(metric.npa);
2086                assert_child_space_kind(&func_space, "I", SpaceKind::Interface);
2087            },
2088        );
2089    }
2090
2091    #[test]
2092    fn kotlin_nested_class_attributes() {
2093        // Each class space has its own attribute count; nested class
2094        // attributes do not leak into the outer class.
2095        check_metrics::<KotlinParser>(
2096            "class Outer {
2097                val o1: Int = 1
2098                class Nested {
2099                    val n1: Int = 1
2100                    val n2: Int = 2
2101                }
2102            }",
2103            "foo.kt",
2104            |metric| {
2105                // 2 classes total — Outer's 1 + Nested's 2 = 3 attributes
2106                assert_eq!(metric.npa.class_npa_sum(), 3);
2107                assert_eq!(metric.npa.class_na_sum(), 3);
2108                insta::assert_json_snapshot!(metric.npa);
2109            },
2110        );
2111    }
2112
2113    #[test]
2114    fn kotlin_inner_class_attributes() {
2115        check_metrics::<KotlinParser>(
2116            "class Outer {
2117                val o1: Int = 1
2118                inner class Inner {
2119                    val i1: Int = 1
2120                }
2121            }",
2122            "foo.kt",
2123            |metric| {
2124                assert_eq!(metric.npa.class_npa_sum(), 2);
2125                assert_eq!(metric.npa.class_na_sum(), 2);
2126                insta::assert_json_snapshot!(metric.npa);
2127            },
2128        );
2129    }
2130
2131    #[test]
2132    fn kotlin_top_level_properties_excluded() {
2133        // Top-level `val` belongs to `Unit`, not a class — must not
2134        // contribute to `class_na`.
2135        check_metrics::<KotlinParser>(
2136            "val topVal: Int = 0
2137            var topVar: Int = 1
2138            class C { val x: Int = 0 }",
2139            "foo.kt",
2140            |metric| {
2141                assert_eq!(metric.npa.class_npa_sum(), 1);
2142                assert_eq!(metric.npa.class_na_sum(), 1);
2143                insta::assert_json_snapshot!(metric.npa);
2144            },
2145        );
2146    }
2147
2148    #[test]
2149    fn kotlin_multiple_classes_attributes() {
2150        check_metrics::<KotlinParser>(
2151            "class A {
2152                val a1: Int = 0
2153                var a2: Int = 0
2154            }
2155            class B {
2156                val b1: Int = 0
2157                private val b2: Int = 0
2158            }",
2159            "foo.kt",
2160            |metric| {
2161                // A: 2 public; B: 1 public + 1 private = 2 total, 1 public
2162                assert_eq!(metric.npa.class_npa_sum(), 3);
2163                assert_eq!(metric.npa.class_na_sum(), 4);
2164                insta::assert_json_snapshot!(metric.npa);
2165            },
2166        );
2167    }
2168
2169    #[test]
2170    fn kotlin_class_with_methods_no_attrs() {
2171        // Methods are not attributes.
2172        check_metrics::<KotlinParser>(
2173            "class C {
2174                fun m1() {}
2175                fun m2(): Int = 0
2176            }",
2177            "foo.kt",
2178            |metric| {
2179                assert_eq!(metric.npa.class_npa_sum(), 0);
2180                assert_eq!(metric.npa.class_na_sum(), 0);
2181                insta::assert_json_snapshot!(metric.npa);
2182            },
2183        );
2184    }
2185
2186    // --- TypeScript / TSX NPA tests --------------------------------------
2187    //
2188    // TypeScript class fields are `public_field_definition` direct children
2189    // of `class_body`. Default visibility is public; an explicit
2190    // `accessibility_modifier` whose only child is `private`/`protected`
2191    // demotes a field. Constructor parameter properties
2192    // (`constructor(private x: number)`) count as class attributes.
2193    // Fields whose initializer is an arrow function are methods, not
2194    // attributes. Interface property signatures count as implicitly
2195    // public attributes.
2196
2197    #[test]
2198    fn typescript_empty_class_no_attributes() {
2199        check_metrics::<TypescriptParser>("class C {}", "foo.ts", |metric| {
2200            assert_eq!(metric.npa.class_npa_sum(), 0);
2201            assert_eq!(metric.npa.class_na_sum(), 0);
2202            insta::assert_json_snapshot!(metric.npa);
2203        });
2204    }
2205
2206    #[test]
2207    fn typescript_default_public_fields() {
2208        // No accessibility modifier means public.
2209        check_metrics::<TypescriptParser>(
2210            "class C {
2211                a: number = 1;
2212                b: string = \"\";
2213                c: boolean = false;
2214            }",
2215            "foo.ts",
2216            |metric| {
2217                assert_eq!(metric.npa.class_npa_sum(), 3);
2218                assert_eq!(metric.npa.class_na_sum(), 3);
2219                insta::assert_json_snapshot!(metric.npa);
2220            },
2221        );
2222    }
2223
2224    #[test]
2225    fn typescript_visibility_modifiers() {
2226        // Public / private / protected. Default public.
2227        check_metrics::<TypescriptParser>(
2228            "class C {
2229                public a: number = 1;
2230                private b: number = 2;
2231                protected c: number = 3;
2232                d: number = 4;
2233            }",
2234            "foo.ts",
2235            |metric| {
2236                // public + default(public) = 2 npa; total na = 4.
2237                assert_eq!(metric.npa.class_npa_sum(), 2);
2238                assert_eq!(metric.npa.class_na_sum(), 4);
2239                insta::assert_json_snapshot!(metric.npa);
2240            },
2241        );
2242    }
2243
2244    #[test]
2245    fn typescript_static_fields() {
2246        // `static` is orthogonal to visibility — the field still counts.
2247        check_metrics::<TypescriptParser>(
2248            "class C {
2249                static a: number = 0;
2250                public static b: number = 0;
2251                private static c: number = 0;
2252            }",
2253            "foo.ts",
2254            |metric| {
2255                // a (default public) + b (public) = 2 npa; c is private.
2256                assert_eq!(metric.npa.class_npa_sum(), 2);
2257                assert_eq!(metric.npa.class_na_sum(), 3);
2258                insta::assert_json_snapshot!(metric.npa);
2259            },
2260        );
2261    }
2262
2263    #[test]
2264    fn typescript_parameter_properties() {
2265        // Constructor parameter properties are class attributes.
2266        check_metrics::<TypescriptParser>(
2267            "class C {
2268                constructor(public a: number, private b: string, c: boolean) {}
2269            }",
2270            "foo.ts",
2271            |metric| {
2272                // a, b are parameter properties (modifiered); c is a plain
2273                // parameter and does NOT count. a is public, b is private.
2274                assert_eq!(metric.npa.class_npa_sum(), 1);
2275                assert_eq!(metric.npa.class_na_sum(), 2);
2276                insta::assert_json_snapshot!(metric.npa);
2277            },
2278        );
2279    }
2280
2281    #[test]
2282    fn typescript_readonly_constructor_param_property() {
2283        // A bare `readonly` constructor parameter is a public parameter
2284        // property (regression for #459): `readonly` is a distinct keyword,
2285        // not an `accessibility_modifier`, yet must count like a `readonly`
2286        // class field. `e` is a plain parameter and does NOT count.
2287        // `private readonly d` carries both modifier children but must count
2288        // exactly once (and as non-public, so it lands in na but not npa).
2289        check_metrics::<TypescriptParser>(
2290            "class C {
2291                constructor(private a: number, readonly b: number, public c: number, e: number, private readonly d: number) {}
2292            }",
2293            "foo.ts",
2294            |metric| {
2295                // Properties: a (private), b (readonly→public), c (public),
2296                // d (private readonly). e is not a property. na = 4.
2297                // npa counts the public ones: b, c. npa = 2.
2298                assert_eq!(metric.npa.class_na_sum(), 4);
2299                assert_eq!(metric.npa.class_npa_sum(), 2);
2300                insta::assert_json_snapshot!(metric.npa);
2301            },
2302        );
2303    }
2304
2305    #[test]
2306    fn typescript_readonly_field() {
2307        // `readonly` is a non-visibility modifier — the field still counts
2308        // and stays public unless paired with private/protected.
2309        check_metrics::<TypescriptParser>(
2310            "class C {
2311                readonly a: number = 1;
2312                private readonly b: number = 2;
2313            }",
2314            "foo.ts",
2315            |metric| {
2316                assert_eq!(metric.npa.class_npa_sum(), 1);
2317                assert_eq!(metric.npa.class_na_sum(), 2);
2318                insta::assert_json_snapshot!(metric.npa);
2319            },
2320        );
2321    }
2322
2323    #[test]
2324    fn typescript_abstract_class_attributes() {
2325        // `abstract_class_declaration` opens its own class space; fields
2326        // count just like a concrete class.
2327        check_metrics::<TypescriptParser>(
2328            "abstract class C {
2329                public a: number = 1;
2330                protected b: number = 2;
2331                abstract m(): void;
2332            }",
2333            "foo.ts",
2334            |metric| {
2335                // a (public) + b (protected) = 2 attrs; npa = 1.
2336                // `abstract m()` is a method, not an attribute.
2337                assert_eq!(metric.npa.class_npa_sum(), 1);
2338                assert_eq!(metric.npa.class_na_sum(), 2);
2339                insta::assert_json_snapshot!(metric.npa);
2340            },
2341        );
2342    }
2343
2344    #[test]
2345    fn typescript_arrow_field_is_method_not_attribute() {
2346        // A field whose initializer is an arrow function is counted by
2347        // npm, not npa.
2348        check_metrics::<TypescriptParser>(
2349            "class C {
2350                a: number = 0;
2351                arrow = () => this.a;
2352            }",
2353            "foo.ts",
2354            |metric| {
2355                assert_eq!(metric.npa.class_npa_sum(), 1);
2356                assert_eq!(metric.npa.class_na_sum(), 1);
2357                insta::assert_json_snapshot!(metric.npa);
2358            },
2359        );
2360    }
2361
2362    #[test]
2363    fn typescript_interface_property_signatures() {
2364        // Interface property signatures count as implicitly-public
2365        // attributes; method signatures are not attributes.
2366        // Structural `assert_child_space_kind` guards against an
2367        // `InterfaceDeclaration` revert in
2368        // `TypescriptCode::is_func_space` — see #311.
2369        check_func_space::<TypescriptParser, _>(
2370            "interface I {
2371                a: number;
2372                b: string;
2373                m(): void;
2374            }",
2375            "foo.ts",
2376            |func_space| {
2377                let metric = &func_space.metrics;
2378                assert_eq!(metric.npa.interface_npa_sum(), 2);
2379                assert_eq!(metric.npa.interface_na_sum(), 2);
2380                assert_eq!(metric.npa.class_na_sum(), 0);
2381                insta::assert_json_snapshot!(metric.npa);
2382                assert_child_space_kind(&func_space, "I", SpaceKind::Interface);
2383            },
2384        );
2385    }
2386
2387    #[test]
2388    fn typescript_generic_class_attributes() {
2389        // Type parameters on the class do not contribute attributes.
2390        check_metrics::<TypescriptParser>(
2391            "class Box<T, U> {
2392                value: T;
2393                other: U;
2394                constructor(v: T, o: U) { this.value = v; this.other = o; }
2395            }",
2396            "foo.ts",
2397            |metric| {
2398                assert_eq!(metric.npa.class_npa_sum(), 2);
2399                assert_eq!(metric.npa.class_na_sum(), 2);
2400                insta::assert_json_snapshot!(metric.npa);
2401            },
2402        );
2403    }
2404
2405    #[test]
2406    fn typescript_getters_setters_not_attributes() {
2407        // `get x()` / `set x(v)` are method_definitions, not attributes.
2408        check_metrics::<TypescriptParser>(
2409            "class C {
2410                private _x: number = 0;
2411                get x(): number { return this._x; }
2412                set x(v: number) { this._x = v; }
2413            }",
2414            "foo.ts",
2415            |metric| {
2416                // Only `_x` counts as an attribute (private → not public).
2417                assert_eq!(metric.npa.class_npa_sum(), 0);
2418                assert_eq!(metric.npa.class_na_sum(), 1);
2419                insta::assert_json_snapshot!(metric.npa);
2420            },
2421        );
2422    }
2423
2424    #[test]
2425    fn typescript_multiple_classes_and_interface() {
2426        check_func_space::<TypescriptParser, _>(
2427            "class A { x: number = 0; }
2428             class B { private y: number = 0; }
2429             interface I { z: number; }",
2430            "foo.ts",
2431            |func_space| {
2432                let metric = &func_space.metrics;
2433                // A: 1 npa / 1 na (public). B: 0 npa / 1 na (private).
2434                // I: 1 interface_npa / 1 interface_na.
2435                assert_eq!(metric.npa.class_npa_sum(), 1);
2436                assert_eq!(metric.npa.class_na_sum(), 2);
2437                assert_eq!(metric.npa.interface_npa_sum(), 1);
2438                assert_eq!(metric.npa.interface_na_sum(), 1);
2439                insta::assert_json_snapshot!(metric.npa);
2440                assert_child_space_kind(&func_space, "A", SpaceKind::Class);
2441                assert_child_space_kind(&func_space, "B", SpaceKind::Class);
2442                assert_child_space_kind(&func_space, "I", SpaceKind::Interface);
2443            },
2444        );
2445    }
2446
2447    #[test]
2448    fn typescript_nested_class_attributes_independent() {
2449        // Each class space tracks its own attributes; the outer class's
2450        // sum gets the inner-class sum via merge. The Outer class has
2451        // two `public_field_definition` direct children — `a` and the
2452        // `Inner` static field whose value is a class expression.
2453        // The class expression itself opens a separate `class` space
2454        // with its own two fields. Total counted across both spaces:
2455        // 2 (Outer: a + Inner) + 2 (inner anonymous class: b, c) = 4.
2456        check_metrics::<TypescriptParser>(
2457            "class Outer {
2458                a: number = 0;
2459                static Inner = class {
2460                    b: number = 0;
2461                    c: number = 0;
2462                };
2463            }",
2464            "foo.ts",
2465            |metric| {
2466                assert_eq!(metric.npa.class_npa_sum(), 4);
2467                assert_eq!(metric.npa.class_na_sum(), 4);
2468                insta::assert_json_snapshot!(metric.npa);
2469            },
2470        );
2471    }
2472
2473    // TSX parity tests — mirror the TS rules to confirm the shared helper
2474    // expansion behaves identically on the TSX grammar.
2475
2476    #[test]
2477    fn tsx_empty_class_no_attributes() {
2478        check_metrics::<TsxParser>("class C {}", "foo.tsx", |metric| {
2479            assert_eq!(metric.npa.class_npa_sum(), 0);
2480            assert_eq!(metric.npa.class_na_sum(), 0);
2481            insta::assert_json_snapshot!(metric.npa);
2482        });
2483    }
2484
2485    #[test]
2486    fn tsx_default_public_fields() {
2487        check_metrics::<TsxParser>(
2488            "class C {
2489                a: number = 1;
2490                b: string = \"\";
2491            }",
2492            "foo.tsx",
2493            |metric| {
2494                assert_eq!(metric.npa.class_npa_sum(), 2);
2495                assert_eq!(metric.npa.class_na_sum(), 2);
2496                insta::assert_json_snapshot!(metric.npa);
2497            },
2498        );
2499    }
2500
2501    #[test]
2502    fn tsx_visibility_modifiers() {
2503        check_metrics::<TsxParser>(
2504            "class C {
2505                public a: number = 1;
2506                private b: number = 2;
2507                protected c: number = 3;
2508            }",
2509            "foo.tsx",
2510            |metric| {
2511                assert_eq!(metric.npa.class_npa_sum(), 1);
2512                assert_eq!(metric.npa.class_na_sum(), 3);
2513                insta::assert_json_snapshot!(metric.npa);
2514            },
2515        );
2516    }
2517
2518    #[test]
2519    fn tsx_parameter_properties() {
2520        check_metrics::<TsxParser>(
2521            "class C {
2522                constructor(public a: number, private b: string) {}
2523            }",
2524            "foo.tsx",
2525            |metric| {
2526                assert_eq!(metric.npa.class_npa_sum(), 1);
2527                assert_eq!(metric.npa.class_na_sum(), 2);
2528                insta::assert_json_snapshot!(metric.npa);
2529            },
2530        );
2531    }
2532
2533    #[test]
2534    fn tsx_readonly_constructor_param_property() {
2535        // TSX sibling of `typescript_readonly_constructor_param_property`
2536        // (#459): a bare `readonly` constructor parameter is a public
2537        // parameter property; `e` is not a property; `private readonly d`
2538        // counts once and is non-public.
2539        check_metrics::<TsxParser>(
2540            "class C {
2541                constructor(private a: number, readonly b: number, public c: number, e: number, private readonly d: number) {}
2542            }",
2543            "foo.tsx",
2544            |metric| {
2545                assert_eq!(metric.npa.class_na_sum(), 4);
2546                assert_eq!(metric.npa.class_npa_sum(), 2);
2547                insta::assert_json_snapshot!(metric.npa);
2548            },
2549        );
2550    }
2551
2552    #[test]
2553    fn tsx_abstract_class_attributes() {
2554        check_metrics::<TsxParser>(
2555            "abstract class C {
2556                public a: number = 1;
2557                private b: number = 2;
2558                abstract m(): void;
2559            }",
2560            "foo.tsx",
2561            |metric| {
2562                assert_eq!(metric.npa.class_npa_sum(), 1);
2563                assert_eq!(metric.npa.class_na_sum(), 2);
2564                insta::assert_json_snapshot!(metric.npa);
2565            },
2566        );
2567    }
2568
2569    #[test]
2570    fn tsx_interface_property_signatures() {
2571        check_func_space::<TsxParser, _>(
2572            "interface I {
2573                a: number;
2574                b: string;
2575                m(): void;
2576            }",
2577            "foo.tsx",
2578            |func_space| {
2579                let metric = &func_space.metrics;
2580                assert_eq!(metric.npa.interface_npa_sum(), 2);
2581                assert_eq!(metric.npa.interface_na_sum(), 2);
2582                insta::assert_json_snapshot!(metric.npa);
2583                assert_child_space_kind(&func_space, "I", SpaceKind::Interface);
2584            },
2585        );
2586    }
2587
2588    #[test]
2589    fn tsx_arrow_field_is_method_not_attribute() {
2590        check_metrics::<TsxParser>(
2591            "class C {
2592                a: number = 0;
2593                arrow = () => this.a;
2594            }",
2595            "foo.tsx",
2596            |metric| {
2597                assert_eq!(metric.npa.class_npa_sum(), 1);
2598                assert_eq!(metric.npa.class_na_sum(), 1);
2599                insta::assert_json_snapshot!(metric.npa);
2600            },
2601        );
2602    }
2603
2604    #[test]
2605    fn tsx_static_fields() {
2606        check_metrics::<TsxParser>(
2607            "class C {
2608                static a: number = 0;
2609                private static b: number = 0;
2610            }",
2611            "foo.tsx",
2612            |metric| {
2613                assert_eq!(metric.npa.class_npa_sum(), 1);
2614                assert_eq!(metric.npa.class_na_sum(), 2);
2615                insta::assert_json_snapshot!(metric.npa);
2616            },
2617        );
2618    }
2619
2620    #[test]
2621    fn tsx_readonly_field() {
2622        check_metrics::<TsxParser>(
2623            "class C {
2624                readonly a: number = 1;
2625                private readonly b: number = 2;
2626            }",
2627            "foo.tsx",
2628            |metric| {
2629                assert_eq!(metric.npa.class_npa_sum(), 1);
2630                assert_eq!(metric.npa.class_na_sum(), 2);
2631                insta::assert_json_snapshot!(metric.npa);
2632            },
2633        );
2634    }
2635
2636    #[test]
2637    fn tsx_generic_class_attributes() {
2638        check_metrics::<TsxParser>("class Box<T> { value: T; }", "foo.tsx", |metric| {
2639            assert_eq!(metric.npa.class_npa_sum(), 1);
2640            assert_eq!(metric.npa.class_na_sum(), 1);
2641            insta::assert_json_snapshot!(metric.npa);
2642        });
2643    }
2644
2645    #[test]
2646    fn tsx_getters_setters_not_attributes() {
2647        check_metrics::<TsxParser>(
2648            "class C {
2649                private _x: number = 0;
2650                get x(): number { return this._x; }
2651                set x(v: number) { this._x = v; }
2652            }",
2653            "foo.tsx",
2654            |metric| {
2655                assert_eq!(metric.npa.class_npa_sum(), 0);
2656                assert_eq!(metric.npa.class_na_sum(), 1);
2657                insta::assert_json_snapshot!(metric.npa);
2658            },
2659        );
2660    }
2661
2662    #[test]
2663    fn tsx_multiple_classes_and_interface() {
2664        check_func_space::<TsxParser, _>(
2665            "class A { x: number = 0; }
2666             class B { private y: number = 0; }
2667             interface I { z: number; }",
2668            "foo.tsx",
2669            |func_space| {
2670                let metric = &func_space.metrics;
2671                assert_eq!(metric.npa.class_npa_sum(), 1);
2672                assert_eq!(metric.npa.class_na_sum(), 2);
2673                assert_eq!(metric.npa.interface_npa_sum(), 1);
2674                assert_eq!(metric.npa.interface_na_sum(), 1);
2675                insta::assert_json_snapshot!(metric.npa);
2676                assert_child_space_kind(&func_space, "A", SpaceKind::Class);
2677                assert_child_space_kind(&func_space, "B", SpaceKind::Class);
2678                assert_child_space_kind(&func_space, "I", SpaceKind::Interface);
2679            },
2680        );
2681    }
2682
2683    // --- Ruby NPA tests ---------------------------------------------------
2684    //
2685    // Ruby has no field-declaration syntax; class-scope instance and
2686    // class variables are introduced by direct assignment in the class
2687    // body (`@var = …`, `@@var = …`). `attr_accessor` / `attr_reader`
2688    // / `attr_writer` macros synthesise reader/writer pairs and also
2689    // introduce attributes. Visibility flows from keyword markers as
2690    // in `Npm`.
2691
2692    #[test]
2693    fn ruby_no_class_attributes() {
2694        check_metrics::<RubyParser>(
2695            "class A\n  def f\n    1\n  end\nend\n",
2696            "foo.rb",
2697            |metric| {
2698                assert_eq!(metric.npa.class_npa_sum(), 0);
2699                assert_eq!(metric.npa.class_na_sum(), 0);
2700                insta::assert_json_snapshot!(metric.npa);
2701            },
2702        );
2703    }
2704
2705    #[test]
2706    fn ruby_instance_variable_attribute() {
2707        // Bare `@x = …` at class scope is one public attribute.
2708        check_metrics::<RubyParser>("class A\n  @x = 1\nend\n", "foo.rb", |metric| {
2709            assert_eq!(metric.npa.class_npa_sum(), 1);
2710            assert_eq!(metric.npa.class_na_sum(), 1);
2711            insta::assert_json_snapshot!(metric.npa);
2712        });
2713    }
2714
2715    #[test]
2716    fn ruby_class_variable_attribute() {
2717        // `@@y = …` at class scope is one attribute.
2718        check_metrics::<RubyParser>("class A\n  @@y = 1\nend\n", "foo.rb", |metric| {
2719            assert_eq!(metric.npa.class_npa_sum(), 1);
2720            assert_eq!(metric.npa.class_na_sum(), 1);
2721            insta::assert_json_snapshot!(metric.npa);
2722        });
2723    }
2724
2725    #[test]
2726    fn ruby_attr_accessor_counts_symbols() {
2727        // `attr_accessor :x, :y, :z` declares three attributes.
2728        check_metrics::<RubyParser>(
2729            "class A\n  attr_accessor :x, :y, :z\nend\n",
2730            "foo.rb",
2731            |metric| {
2732                assert_eq!(metric.npa.class_npa_sum(), 3);
2733                assert_eq!(metric.npa.class_na_sum(), 3);
2734                insta::assert_json_snapshot!(metric.npa);
2735            },
2736        );
2737    }
2738
2739    #[test]
2740    fn ruby_attr_reader_and_writer() {
2741        check_metrics::<RubyParser>(
2742            "class A\n  attr_reader :r1, :r2\n  attr_writer :w\nend\n",
2743            "foo.rb",
2744            |metric| {
2745                assert_eq!(metric.npa.class_npa_sum(), 3);
2746                assert_eq!(metric.npa.class_na_sum(), 3);
2747                insta::assert_json_snapshot!(metric.npa);
2748            },
2749        );
2750    }
2751
2752    #[test]
2753    fn ruby_mixed_attributes_and_assignments() {
2754        check_metrics::<RubyParser>(
2755            "class A\n  attr_accessor :x, :y\n  @z = 1\n  @@w = 2\nend\n",
2756            "foo.rb",
2757            |metric| {
2758                assert_eq!(metric.npa.class_npa_sum(), 4);
2759                assert_eq!(metric.npa.class_na_sum(), 4);
2760                insta::assert_json_snapshot!(metric.npa);
2761            },
2762        );
2763    }
2764
2765    #[test]
2766    fn ruby_private_attributes() {
2767        // Bare `private` flips visibility for the subsequent attr.
2768        check_metrics::<RubyParser>(
2769            "class A\n  attr_accessor :pub\n  private\n  attr_accessor :hidden\nend\n",
2770            "foo.rb",
2771            |metric| {
2772                assert_eq!(metric.npa.class_npa_sum(), 1);
2773                assert_eq!(metric.npa.class_na_sum(), 2);
2774                insta::assert_json_snapshot!(metric.npa);
2775            },
2776        );
2777    }
2778
2779    #[test]
2780    fn ruby_visibility_public_resets_private() {
2781        // `private` then `public` returns to default-public.
2782        check_metrics::<RubyParser>(
2783            "class A\n  attr_reader :a\n  private\n  attr_reader :b\n  public\n  attr_reader :c\nend\n",
2784            "foo.rb",
2785            |metric| {
2786                assert_eq!(metric.npa.class_npa_sum(), 2);
2787                assert_eq!(metric.npa.class_na_sum(), 3);
2788                insta::assert_json_snapshot!(metric.npa);
2789            },
2790        );
2791    }
2792
2793    #[test]
2794    fn ruby_method_scope_assignments_excluded() {
2795        // `@x = 1` inside a method does NOT count — it's a method-local
2796        // instance-variable write, not a class-scope attribute
2797        // declaration.
2798        check_metrics::<RubyParser>(
2799            "class A\n  def init\n    @x = 1\n    @@y = 2\n  end\nend\n",
2800            "foo.rb",
2801            |metric| {
2802                assert_eq!(metric.npa.class_npa_sum(), 0);
2803                assert_eq!(metric.npa.class_na_sum(), 0);
2804                insta::assert_json_snapshot!(metric.npa);
2805            },
2806        );
2807    }
2808
2809    #[test]
2810    fn ruby_module_attributes_not_counted() {
2811        // `module M` is a `Namespace` space — its attr_* macros and
2812        // class-variable assignments do NOT contribute to NPA.
2813        check_metrics::<RubyParser>(
2814            "module M\n  attr_accessor :x\n  @@m = 1\nend\n",
2815            "foo.rb",
2816            |metric| {
2817                assert_eq!(metric.npa.class_npa_sum(), 0);
2818                assert_eq!(metric.npa.class_na_sum(), 0);
2819                insta::assert_json_snapshot!(metric.npa);
2820            },
2821        );
2822    }
2823
2824    #[test]
2825    fn ruby_inheritance_attributes() {
2826        // Inheritance does not change the attribute count for this class.
2827        check_metrics::<RubyParser>(
2828            "class A < B\n  attr_accessor :x\n  @y = 0\nend\n",
2829            "foo.rb",
2830            |metric| {
2831                assert_eq!(metric.npa.class_npa_sum(), 2);
2832                assert_eq!(metric.npa.class_na_sum(), 2);
2833                insta::assert_json_snapshot!(metric.npa);
2834            },
2835        );
2836    }
2837
2838    #[test]
2839    fn ruby_constant_assignments_excluded() {
2840        // `CONST = …` at class scope binds a constant, not an
2841        // attribute; the LHS is a `Constant`, not an
2842        // `InstanceVariable` / `ClassVariable`.
2843        check_metrics::<RubyParser>(
2844            "class A\n  PI = 3.14\n  E = 2.71\n  attr_reader :x\nend\n",
2845            "foo.rb",
2846            |metric| {
2847                // Only `attr_reader :x` counts.
2848                assert_eq!(metric.npa.class_npa_sum(), 1);
2849                assert_eq!(metric.npa.class_na_sum(), 1);
2850                insta::assert_json_snapshot!(metric.npa);
2851            },
2852        );
2853    }
2854
2855    #[test]
2856    fn ruby_multiple_classes_attribute_rollup() {
2857        check_metrics::<RubyParser>(
2858            "class A\n  attr_accessor :x\nend\nclass B\n  private\n  attr_accessor :y\nend\n",
2859            "foo.rb",
2860            |metric| {
2861                // A: 1 public attr. B: 0 public, 1 total.
2862                assert_eq!(metric.npa.class_npa_sum(), 1);
2863                assert_eq!(metric.npa.class_na_sum(), 2);
2864                insta::assert_json_snapshot!(metric.npa);
2865            },
2866        );
2867    }
2868
2869    // ---------------------------------------------------------------
2870    // Default-impl placeholder smoke tests (audited in #188).
2871    //
2872    // Each test feeds a class / struct with public attributes to a
2873    // language whose `Npa` is currently the default no-op. The
2874    // assertion pins the current 0 value with a TODO pointing at the
2875    // follow-up issue — when the real impl lands the assertion will
2876    // fire and force a test update, which is the gate.
2877    // ---------------------------------------------------------------
2878
2879    // --- Python NPA ---------------------------------------------------
2880
2881    #[test]
2882    fn python_empty_class_no_attributes() {
2883        check_metrics::<PythonParser>("class C:\n    pass\n", "foo.py", |metric| {
2884            assert_eq!(metric.npa.class_na_sum(), 0);
2885            assert_eq!(metric.npa.class_npa_sum(), 0);
2886            assert_eq!(metric.npa.interface_na_sum(), 0);
2887            insta::assert_json_snapshot!(metric.npa);
2888        });
2889    }
2890
2891    #[test]
2892    fn python_class_level_assignments_are_attributes() {
2893        // Two class-level `=` assignments → 2 attributes, all public
2894        // (Python has no visibility keyword).
2895        check_metrics::<PythonParser>("class C:\n    x = 1\n    y = 2\n", "foo.py", |metric| {
2896            assert_eq!(metric.npa.class_na_sum(), 2);
2897            assert_eq!(metric.npa.class_npa_sum(), 2);
2898            insta::assert_json_snapshot!(metric.npa);
2899        });
2900    }
2901
2902    #[test]
2903    fn python_bare_type_annotation_not_attribute() {
2904        // `x: int` is a bare annotation (declares a type, binds
2905        // nothing); only `y: int = 2` actually creates an attribute.
2906        check_metrics::<PythonParser>(
2907            "class C:\n    x: int\n    y: int = 2\n",
2908            "foo.py",
2909            |metric| {
2910                assert_eq!(metric.npa.class_na_sum(), 1);
2911                insta::assert_json_snapshot!(metric.npa);
2912            },
2913        );
2914    }
2915
2916    #[test]
2917    fn python_self_attributes_in_init() {
2918        // `self.x` and `self.y` assigned in `__init__` → 2 instance
2919        // attributes attributed to the class space.
2920        check_metrics::<PythonParser>(
2921            "class C:\n    def __init__(self):\n        self.x = 1\n        self.y = 2\n",
2922            "foo.py",
2923            |metric| {
2924                assert_eq!(metric.npa.class_na_sum(), 2);
2925                assert_eq!(metric.npa.class_npa_sum(), 2);
2926                insta::assert_json_snapshot!(metric.npa);
2927            },
2928        );
2929    }
2930
2931    #[test]
2932    fn python_self_attributes_in_nested_control_flow() {
2933        // `self.z = 1` and `self.z = 2` in if/else now count once —
2934        // #215 added identifier-text deduplication. Both branches
2935        // bind the same attribute `z`, so `class_na == 1`.
2936        check_metrics::<PythonParser>(
2937            "class C:\n    def __init__(self, flag):\n        if flag:\n            self.z = 1\n        else:\n            self.z = 2\n",
2938            "foo.py",
2939            |metric| {
2940                assert_eq!(metric.npa.class_na_sum(), 1);
2941                insta::assert_json_snapshot!(metric.npa);
2942            },
2943        );
2944    }
2945
2946    /// Regression #215: `self.value = …` bound in `__init__` and again
2947    /// in `reset()` should count the attribute exactly once. Before
2948    /// identifier-text deduplication, each binding inflated
2949    /// `class_na` by one — the defensive re-init pattern reported 2.
2950    ///
2951    /// The two assignments use DIFFERENT right-hand sides (`None`
2952    /// vs `0`) so a hypothetical byte-content-of-Assignment dedup
2953    /// (rather than identifier-name dedup) would NOT collapse them.
2954    /// This pins the rule to the attribute *name*, not the
2955    /// assignment text.
2956    #[test]
2957    fn python_defensive_reinit_self_attribute_counts_once() {
2958        check_metrics::<PythonParser>(
2959            "class C:\n    def __init__(self):\n        self.value = None\n    def reset(self):\n        self.value = 0\n",
2960            "foo.py",
2961            |metric| {
2962                assert_eq!(metric.npa.class_na_sum(), 1);
2963                assert_eq!(metric.npa.class_npa_sum(), 1);
2964                insta::assert_json_snapshot!(metric.npa);
2965            },
2966        );
2967    }
2968
2969    /// Distinct attribute names still accumulate normally — the
2970    /// dedup is per-name, not per-method.
2971    #[test]
2972    fn python_distinct_self_attributes_count_independently() {
2973        check_metrics::<PythonParser>(
2974            "class C:\n    def __init__(self):\n        self.x = 1\n        self.y = 2\n        self.z = 3\n",
2975            "foo.py",
2976            |metric| {
2977                assert_eq!(metric.npa.class_na_sum(), 3);
2978                insta::assert_json_snapshot!(metric.npa);
2979            },
2980        );
2981    }
2982
2983    /// Annotated `self.x: int = 1` inside a method body parses as
2984    /// `Assignment(target=Attribute(self, x), type, value)` in
2985    /// tree-sitter-python — the same node type as plain `self.x = 1`.
2986    /// The dedup helper must see both forms and treat them as the
2987    /// same attribute. Regression guard for the review finding on
2988    /// #215: ensure annotated assignments aren't missed.
2989    #[test]
2990    fn python_self_attribute_annotated_assignment_dedupes() {
2991        check_metrics::<PythonParser>(
2992            "class C:\n    def __init__(self):\n        self.value: int = 1\n    def reset(self):\n        self.value = 0\n",
2993            "foo.py",
2994            |metric| {
2995                assert_eq!(metric.npa.class_na_sum(), 1);
2996                insta::assert_json_snapshot!(metric.npa);
2997            },
2998        );
2999    }
3000
3001    #[test]
3002    fn python_class_level_and_self_attrs_combine() {
3003        // 1 class-level + 2 instance = 3 total attributes.
3004        check_metrics::<PythonParser>(
3005            "class C:\n    counter = 0\n    def __init__(self):\n        self.name = 'a'\n        self.value = 1\n",
3006            "foo.py",
3007            |metric| {
3008                assert_eq!(metric.npa.class_na_sum(), 3);
3009                insta::assert_json_snapshot!(metric.npa);
3010            },
3011        );
3012    }
3013
3014    #[test]
3015    fn python_self_attrs_isolated_per_class() {
3016        // Nested class `Inner` opens its own class space; its
3017        // `self.z = …` belongs to Inner. The class_na_sum aggregates
3018        // across class spaces in the file, so we see both attributes
3019        // (Outer.x + Inner.z) in the unit-level sum; the snapshot
3020        // pins the per-space breakdown.
3021        check_metrics::<PythonParser>(
3022            "class Outer:\n\
3023             \x20   def __init__(self):\n\
3024             \x20       self.x = 1\n\
3025             \x20   class Inner:\n\
3026             \x20       def __init__(self):\n\
3027             \x20           self.z = 2\n",
3028            "foo.py",
3029            |metric| {
3030                assert_eq!(metric.npa.class_na_sum(), 2);
3031                insta::assert_json_snapshot!(metric.npa);
3032            },
3033        );
3034    }
3035
3036    #[test]
3037    fn python_decorated_methods_do_not_inflate_attrs() {
3038        // `@property` / `@staticmethod` wrap a `FunctionDefinition` in
3039        // `DecoratedDefinition`. These contribute methods, not
3040        // attributes — Npa must stay at 0.
3041        check_metrics::<PythonParser>(
3042            "class C:\n\
3043             \x20   @property\n\
3044             \x20   def p(self):\n\
3045             \x20       return 1\n\
3046             \x20   @staticmethod\n\
3047             \x20   def s():\n\
3048             \x20       return 2\n",
3049            "foo.py",
3050            |metric| {
3051                assert_eq!(metric.npa.class_na_sum(), 0);
3052                insta::assert_json_snapshot!(metric.npa);
3053            },
3054        );
3055    }
3056
3057    #[test]
3058    fn python_module_level_assignments_not_attributes() {
3059        // `x = 1` at module scope is not a class attribute.
3060        check_metrics::<PythonParser>("x = 1\ny = 2\nclass C:\n    a = 3\n", "foo.py", |metric| {
3061            // Only `a = 3` lives in the class space.
3062            assert_eq!(metric.npa.class_na_sum(), 1);
3063            insta::assert_json_snapshot!(metric.npa);
3064        });
3065    }
3066
3067    /// #412 (a): a write to a *foreign* object's attribute
3068    /// (`db.connection = …`, `logger.level = …`) is not an attribute of
3069    /// the class. Only `self.name` — whose receiver is the `self` alias
3070    /// — counts. The prior structural-only check treated every
3071    /// `obj.x = …` as an instance attribute, reporting 3.
3072    #[test]
3073    fn python_foreign_object_writes_not_attributes() {
3074        check_metrics::<PythonParser>(
3075            "class Service:\n\
3076             \x20   def __init__(self, db, logger):\n\
3077             \x20       self.name = \"svc\"\n\
3078             \x20       db.connection = None\n\
3079             \x20       logger.level = \"INFO\"\n",
3080            "foo.py",
3081            |metric| {
3082                // Only self.name; db.* and logger.* are foreign.
3083                assert_eq!(metric.npa.class_na_sum(), 1);
3084                assert_eq!(metric.npa.class_npa_sum(), 1);
3085                insta::assert_json_snapshot!(metric.npa);
3086            },
3087        );
3088    }
3089
3090    /// #412 (b): tuple-unpacking instance attributes. The target of
3091    /// `self.a, self.b = 1, 2` is a `pattern_list`, not a single
3092    /// `Attribute`; the prior code bailed on non-Attribute targets and
3093    /// missed both `a` and `b`, reporting 1 (only `self.c`).
3094    #[test]
3095    fn python_self_attribute_unpacking_counts_each() {
3096        check_metrics::<PythonParser>(
3097            "class C:\n\
3098             \x20   def __init__(self):\n\
3099             \x20       self.a, self.b = 1, 2\n\
3100             \x20       self.c = 3\n",
3101            "foo.py",
3102            |metric| {
3103                // a, b, c.
3104                assert_eq!(metric.npa.class_na_sum(), 3);
3105                assert_eq!(metric.npa.class_npa_sum(), 3);
3106                insta::assert_json_snapshot!(metric.npa);
3107            },
3108        );
3109    }
3110
3111    /// Nested unpacking of instance attributes: `self.a, (self.b, self.c)
3112    /// = …` nests a `tuple_pattern` inside the outer `pattern_list`. The
3113    /// shared `python_walk_target_elements` recursion descends into the
3114    /// nested pattern so `b` and `c` are counted, not just `a` (review
3115    /// follow-up to #412 (b); a flat iteration reports 1).
3116    #[test]
3117    fn python_self_attribute_nested_unpacking_counts_each() {
3118        check_metrics::<PythonParser>(
3119            "class C:\n\
3120             \x20   def __init__(self):\n\
3121             \x20       self.a, (self.b, self.c) = 1, (2, 3)\n",
3122            "foo.py",
3123            |metric| {
3124                // a, b, c — all three, including the nested b and c.
3125                assert_eq!(metric.npa.class_na_sum(), 3);
3126                assert_eq!(metric.npa.class_npa_sum(), 3);
3127            },
3128        );
3129    }
3130
3131    /// Nested unpacking at class level: `(a, (b, c)) = 1, (2, 3)` nests a
3132    /// `tuple_pattern` inside the target. Each bound name — including the
3133    /// nested `b` and `c` — contributes one attribute (review follow-up to
3134    /// #412 (c); a flat iteration reports 1).
3135    #[test]
3136    fn python_class_level_nested_unpacking_counts_each() {
3137        check_metrics::<PythonParser>(
3138            "class C:\n\
3139             \x20   (a, (b, c)) = 1, (2, 3)\n",
3140            "foo.py",
3141            |metric| {
3142                // a, b, c.
3143                assert_eq!(metric.npa.class_na_sum(), 3);
3144                assert_eq!(metric.npa.class_npa_sum(), 3);
3145            },
3146        );
3147    }
3148
3149    /// Minimal regression for the hidden-alias bug: a flat *parenthesized*
3150    /// or *bracketed* class-level unpacking target (`(p, q) = …`,
3151    /// `[m, n] = …`) parses to the live `tuple_pattern` (179) /
3152    /// `list_pattern` (180) node, not the bare `pattern_list` the
3153    /// unparenthesized `p, q = …` form uses. Matching only the hidden
3154    /// supertype aliases (168 / 167) dropped these entirely; both bound
3155    /// names must be counted (#419 hidden-alias discipline).
3156    #[test]
3157    fn python_class_level_parenthesized_unpacking_counts_each() {
3158        check_metrics::<PythonParser>(
3159            "class C:\n\
3160             \x20   (p, q) = 1, 2\n\
3161             \x20   [m, n] = 3, 4\n",
3162            "foo.py",
3163            |metric| {
3164                // p, q, m, n.
3165                assert_eq!(metric.npa.class_na_sum(), 4);
3166                assert_eq!(metric.npa.class_npa_sum(), 4);
3167            },
3168        );
3169    }
3170
3171    /// #412 (b) edge: unpacking that mixes a self attribute with a
3172    /// foreign / local target (`self.a, x = …`) counts only the self
3173    /// attribute.
3174    #[test]
3175    fn python_self_attribute_unpacking_skips_non_self_targets() {
3176        check_metrics::<PythonParser>(
3177            "class C:\n\
3178             \x20   def __init__(self):\n\
3179             \x20       self.a, x = 1, 2\n",
3180            "foo.py",
3181            |metric| {
3182                // Only `a`; the bare local `x` is not an attribute.
3183                assert_eq!(metric.npa.class_na_sum(), 1);
3184                insta::assert_json_snapshot!(metric.npa);
3185            },
3186        );
3187    }
3188
3189    /// #412 (c): a multi-target class-level assignment binds one
3190    /// attribute per name. `a = b = 3` (chained) binds two; `p, q = 1,
3191    /// 2` (unpacking) binds two; with `x = 1` that is five names. The
3192    /// prior code counted one per `=` statement, reporting 3.
3193    #[test]
3194    fn python_class_level_multi_target_counts_each_name() {
3195        check_metrics::<PythonParser>(
3196            "class C:\n    x = 1\n    a = b = 3\n    p, q = 1, 2\n",
3197            "foo.py",
3198            |metric| {
3199                // x, a, b, p, q.
3200                assert_eq!(metric.npa.class_na_sum(), 5);
3201                assert_eq!(metric.npa.class_npa_sum(), 5);
3202                insta::assert_json_snapshot!(metric.npa);
3203            },
3204        );
3205    }
3206
3207    /// #412 (b)/(c): a chained instance assignment `self.a = self.b = 1`
3208    /// binds both `a` and `b` on `self`. The nested `Assignment` in the
3209    /// value is visited by the subtree walk, so both are counted.
3210    #[test]
3211    fn python_chained_self_assignment_counts_each() {
3212        check_metrics::<PythonParser>(
3213            "class C:\n    def __init__(self):\n        self.a = self.b = 1\n",
3214            "foo.py",
3215            |metric| {
3216                assert_eq!(metric.npa.class_na_sum(), 2);
3217                assert_eq!(metric.npa.class_npa_sum(), 2);
3218                insta::assert_json_snapshot!(metric.npa);
3219            },
3220        );
3221    }
3222
3223    /// #412 (a): a classmethod binds class attributes through the `cls`
3224    /// alias; `cls.registry = …` counts, while a foreign `other.thing =
3225    /// …` write in the same body does not.
3226    #[test]
3227    fn python_classmethod_cls_attribute_counts() {
3228        check_metrics::<PythonParser>(
3229            "class C:\n\
3230             \x20   @classmethod\n\
3231             \x20   def make(cls, other):\n\
3232             \x20       cls.registry = {}\n\
3233             \x20       other.thing = 1\n",
3234            "foo.py",
3235            |metric| {
3236                // Only cls.registry; other.thing is foreign.
3237                assert_eq!(metric.npa.class_na_sum(), 1);
3238                assert_eq!(metric.npa.class_npa_sum(), 1);
3239                insta::assert_json_snapshot!(metric.npa);
3240            },
3241        );
3242    }
3243
3244    /// #412 (a) edge: a nested-attribute write `self.f.g = 1` sets `g`
3245    /// on `self.f`; it does NOT introduce a new attribute of the class.
3246    /// The receiver of the outer Attribute is itself an Attribute
3247    /// (`self.f`), not the `self` Identifier, so it is rejected.
3248    #[test]
3249    fn python_nested_self_attribute_not_counted() {
3250        check_metrics::<PythonParser>(
3251            "class C:\n    def __init__(self):\n        self.f.g = 1\n",
3252            "foo.py",
3253            |metric| {
3254                assert_eq!(metric.npa.class_na_sum(), 0);
3255                insta::assert_json_snapshot!(metric.npa);
3256            },
3257        );
3258    }
3259
3260    /// #412 dedup: a class default `x = 1` and an instance write
3261    /// `self.x = 2` name the same attribute; the instance binding
3262    /// shadows the class default, so `x` counts once. The class-level
3263    /// and instance passes share one dedup set.
3264    #[test]
3265    fn python_class_default_and_self_attr_dedupe() {
3266        check_metrics::<PythonParser>(
3267            "class C:\n    x = 1\n    def __init__(self):\n        self.x = 2\n",
3268            "foo.py",
3269            |metric| {
3270                assert_eq!(metric.npa.class_na_sum(), 1);
3271                assert_eq!(metric.npa.class_npa_sum(), 1);
3272                insta::assert_json_snapshot!(metric.npa);
3273            },
3274        );
3275    }
3276
3277    #[test]
3278    fn rust_empty_unit_no_attributes() {
3279        check_metrics::<RustParser>("", "empty.rs", |metric| {
3280            assert_eq!(metric.npa.class_na_sum(), 0);
3281            assert_eq!(metric.npa.class_npa_sum(), 0);
3282            assert_eq!(metric.npa.interface_na_sum(), 0);
3283            assert_eq!(metric.npa.interface_npa_sum(), 0);
3284            insta::assert_json_snapshot!(metric.npa);
3285        });
3286    }
3287
3288    #[test]
3289    fn rust_struct_fields_are_attributes() {
3290        // 3 named fields → class_na = 3. `pub a` and `pub c` are public
3291        // → class_npa = 2. `b` is private, so it's not in `npa`.
3292        check_metrics::<RustParser>(
3293            "struct Foo { pub a: i32, b: String, pub c: bool }",
3294            "foo.rs",
3295            |metric| {
3296                assert_eq!(metric.npa.class_na_sum(), 3);
3297                assert_eq!(metric.npa.class_npa_sum(), 2);
3298                insta::assert_json_snapshot!(metric.npa);
3299            },
3300        );
3301    }
3302
3303    #[test]
3304    fn rust_pub_self_field_is_private() {
3305        // Regression for #460. A `pub(self)` / `pub(in self)` field
3306        // restricts to the current module and is private, like no
3307        // modifier. The widening forms (`pub(crate)`, `pub(super)`,
3308        // `pub`, `pub(in <path>)`) stay public. → 7 fields, 4 public
3309        // (b, d, e, f). Pre-fix `a`/`a2` over-counted (class_npa_sum=6,
3310        // revert-verified). Asserts `pub(super)`/`pub(crate)` are NOT
3311        // over-suppressed.
3312        check_metrics::<RustParser>(
3313            "struct S {\n\
3314             \x20   pub(self) a: i32,\n\
3315             \x20   pub(in self) a2: i32,\n\
3316             \x20   pub(crate) b: i32,\n\
3317             \x20   pub(super) d: i32,\n\
3318             \x20   pub(in crate::x) e: i32,\n\
3319             \x20   pub f: i32,\n\
3320             \x20   c: i32,\n\
3321             }",
3322            "foo.rs",
3323            |metric| {
3324                assert_eq!(metric.npa.class_na_sum(), 7);
3325                assert_eq!(metric.npa.class_npa_sum(), 4);
3326            },
3327        );
3328    }
3329
3330    #[test]
3331    fn rust_pub_self_assoc_const_is_private() {
3332        // Regression for #460 on the associated-const path. `pub(self)`
3333        // and `pub(in self)` associated consts are private; `pub(crate)`
3334        // and `pub` are public. → 4 consts, 2 public (B, D). Pre-fix
3335        // `A`/`A2` over-counted (class_npa_sum=4, revert-verified).
3336        check_metrics::<RustParser>(
3337            "struct Foo;\n\
3338             impl Foo {\n\
3339             \x20   pub(self) const A: i32 = 1;\n\
3340             \x20   pub(in self) const A2: i32 = 2;\n\
3341             \x20   pub(crate) const B: i32 = 3;\n\
3342             \x20   pub const D: i32 = 4;\n\
3343             }\n",
3344            "foo.rs",
3345            |metric| {
3346                assert_eq!(metric.npa.class_na_sum(), 4);
3347                assert_eq!(metric.npa.class_npa_sum(), 2);
3348            },
3349        );
3350    }
3351
3352    #[test]
3353    fn rust_pub_self_tuple_field_is_private() {
3354        // Regression for #460 on the tuple-struct positional path.
3355        // `pub(self)` / `pub(in self)` fields are private; `pub(crate)`
3356        // and bare `pub` stay public. 5 positional fields; only the
3357        // `pub(crate) i32` and `pub u8` are public → 2 public (the
3358        // trailing `String` carries no modifier). Pre-fix the two
3359        // `self`-restricted fields over-counted (class_npa_sum=4,
3360        // revert-verified).
3361        check_metrics::<RustParser>(
3362            "struct Bar(pub(self) i32, pub(in self) i32, pub(crate) i32, pub u8, String);",
3363            "foo.rs",
3364            |metric| {
3365                assert_eq!(metric.npa.class_na_sum(), 5);
3366                assert_eq!(metric.npa.class_npa_sum(), 2);
3367            },
3368        );
3369    }
3370
3371    #[test]
3372    fn rust_tuple_struct_fields_are_attributes() {
3373        // Tuple-struct field counting is positional. `Bar(pub i32,
3374        // String)` → 2 fields, 1 public.
3375        check_metrics::<RustParser>("struct Bar(pub i32, String);", "foo.rs", |metric| {
3376            assert_eq!(metric.npa.class_na_sum(), 2);
3377            assert_eq!(metric.npa.class_npa_sum(), 1);
3378            insta::assert_json_snapshot!(metric.npa);
3379        });
3380    }
3381
3382    #[test]
3383    fn rust_unit_struct_has_no_attributes() {
3384        // `struct Empty;` is a unit struct (no fields). 0 attributes.
3385        check_metrics::<RustParser>("struct Empty;", "foo.rs", |metric| {
3386            assert_eq!(metric.npa.class_na_sum(), 0);
3387            insta::assert_json_snapshot!(metric.npa);
3388        });
3389    }
3390
3391    #[test]
3392    fn rust_empty_struct_body_has_no_attributes() {
3393        // `struct Empty {}` is named-field with zero fields.
3394        check_metrics::<RustParser>("struct Empty { }", "foo.rs", |metric| {
3395            assert_eq!(metric.npa.class_na_sum(), 0);
3396            insta::assert_json_snapshot!(metric.npa);
3397        });
3398    }
3399
3400    #[test]
3401    fn rust_impl_associated_consts_are_attributes() {
3402        // `const X` and `pub const Y` and `static Z` and `pub static W`
3403        // → 4 associated attributes, 2 public.
3404        check_metrics::<RustParser>(
3405            "struct Foo;\n\
3406             impl Foo {\n\
3407             \x20   const X: i32 = 1;\n\
3408             \x20   pub const Y: i32 = 2;\n\
3409             \x20   static Z: i32 = 3;\n\
3410             \x20   pub static W: i32 = 4;\n\
3411             }\n",
3412            "foo.rs",
3413            |metric| {
3414                // The Impl-space class_na is 4; rolled up to Unit
3415                // class_na_sum it is also 4 (no struct fields in `Foo;`).
3416                assert_eq!(metric.npa.class_na_sum(), 4);
3417                assert_eq!(metric.npa.class_npa_sum(), 2);
3418                insta::assert_json_snapshot!(metric.npa);
3419            },
3420        );
3421    }
3422
3423    #[test]
3424    fn rust_trait_consts_and_associated_types_are_attributes() {
3425        // `const DEFAULT_COLOR` + `type Item` → 2 interface attributes,
3426        // both public by trait convention. Structural
3427        // `assert_child_space_kind` pins the trait FuncSpace against
3428        // an `is_func_space` revert (see #311).
3429        check_func_space::<RustParser, _>(
3430            "trait Drawable { const DEFAULT_COLOR: u32; type Item; }",
3431            "foo.rs",
3432            |func_space| {
3433                let metric = &func_space.metrics;
3434                assert_eq!(metric.npa.interface_na_sum(), 2);
3435                assert_eq!(metric.npa.interface_npa_sum(), 2);
3436                assert_eq!(metric.npa.class_na_sum(), 0);
3437                insta::assert_json_snapshot!(metric.npa);
3438                assert_child_space_kind(&func_space, "Drawable", SpaceKind::Trait);
3439            },
3440        );
3441    }
3442
3443    #[test]
3444    fn rust_multiple_impls_aggregate() {
3445        // Two `impl Foo` blocks each have one associated const. The
3446        // unit-level rollup should be class_na_sum = 2.
3447        check_metrics::<RustParser>(
3448            "struct Foo;\n\
3449             impl Foo { const X: i32 = 1; }\n\
3450             impl Foo { pub const Y: i32 = 2; }\n",
3451            "foo.rs",
3452            |metric| {
3453                assert_eq!(metric.npa.class_na_sum(), 2);
3454                assert_eq!(metric.npa.class_npa_sum(), 1);
3455                insta::assert_json_snapshot!(metric.npa);
3456            },
3457        );
3458    }
3459
3460    #[test]
3461    fn rust_module_level_consts_not_attributes() {
3462        // `const PI: f64 = 3.14;` at file scope is a free-standing
3463        // constant — NOT a class attribute. Only consts INSIDE an
3464        // `impl` / `trait` body count.
3465        check_metrics::<RustParser>(
3466            "const PI: f64 = 3.14;\nstatic Q: i32 = 0;\n",
3467            "foo.rs",
3468            |metric| {
3469                assert_eq!(metric.npa.class_na_sum(), 0);
3470                assert_eq!(metric.npa.interface_na_sum(), 0);
3471                insta::assert_json_snapshot!(metric.npa);
3472            },
3473        );
3474    }
3475
3476    // ----- Go -----
3477
3478    #[test]
3479    fn go_empty_unit_no_attributes() {
3480        // Package-only file declares no struct → npa stays disabled,
3481        // class_na_sum = 0.
3482        check_metrics::<GoParser>("package main\n", "empty.go", |metric| {
3483            assert_eq!(metric.npa.class_na_sum(), 0);
3484            insta::assert_json_snapshot!(metric.npa);
3485        });
3486    }
3487
3488    #[test]
3489    fn go_empty_struct_has_no_attributes() {
3490        // `type Empty struct{}` has an empty FieldDeclarationList →
3491        // 0 fields → npa stays disabled.
3492        check_metrics::<GoParser>("package main\ntype Empty struct{}\n", "foo.go", |metric| {
3493            assert_eq!(metric.npa.class_na_sum(), 0);
3494            insta::assert_json_snapshot!(metric.npa);
3495        });
3496    }
3497
3498    #[test]
3499    fn go_struct_fields_are_attributes() {
3500        // Three named fields: `X int`, `y string`, `Z float64` → 3
3501        // attributes. Go visibility is lexical (issue #458): `X` and
3502        // `Z` are exported, `y` is not → class_npa_sum = 2.
3503        check_metrics::<GoParser>(
3504            "package main\ntype Foo struct { X int; y string; Z float64 }\n",
3505            "foo.go",
3506            |metric| {
3507                assert_eq!(metric.npa.class_na_sum(), 3);
3508                assert_eq!(metric.npa.class_npa_sum(), 2);
3509                insta::assert_json_snapshot!(metric.npa);
3510            },
3511        );
3512    }
3513
3514    #[test]
3515    fn go_grouped_struct_fields_each_count() {
3516        // `X, Y int` declares two field names in one
3517        // field_declaration; each name is its own attribute
3518        // (issue #458). With the trailing `Z` → 3 attributes total,
3519        // all exported → class_npa_sum = 3.
3520        check_metrics::<GoParser>(
3521            "package main\ntype Point struct { X, Y int; Z float64 }\n",
3522            "foo.go",
3523            |metric| {
3524                assert_eq!(metric.npa.class_na_sum(), 3);
3525                assert_eq!(metric.npa.class_npa_sum(), 3);
3526                insta::assert_json_snapshot!(metric.npa);
3527            },
3528        );
3529    }
3530
3531    #[test]
3532    fn go_embedded_type_counts_as_attribute() {
3533        // `io.Reader` and `*Foo` are embedded types — field
3534        // declarations with no name, just a type; the embedded
3535        // type's base name (`Reader`, `Foo`) is the attribute name
3536        // and decides its visibility (issue #458). Both are
3537        // exported; `n int` is not → class_na_sum = 3,
3538        // class_npa_sum = 2.
3539        check_metrics::<GoParser>(
3540            "package main\nimport \"io\"\ntype Bar struct { io.Reader; *Foo; n int }\ntype Foo struct {}\n",
3541            "foo.go",
3542            |metric| {
3543                assert_eq!(metric.npa.class_na_sum(), 3);
3544                assert_eq!(metric.npa.class_npa_sum(), 2);
3545                insta::assert_json_snapshot!(metric.npa);
3546            },
3547        );
3548    }
3549
3550    #[test]
3551    fn go_multiple_structs_aggregate_at_unit() {
3552        // Two structs declared at file scope each contribute their
3553        // fields to the same Unit space (no per-receiver class
3554        // grouping in Go). `Foo` has 1 field, `Bar` has 2 → total
3555        // class_na_sum = 3. All three names are lowercase
3556        // (unexported), so class_npa_sum = 0 (issue #458).
3557        check_metrics::<GoParser>(
3558            "package main\ntype Foo struct { x int }\ntype Bar struct { a int; b string }\n",
3559            "foo.go",
3560            |metric| {
3561                assert_eq!(metric.npa.class_na_sum(), 3);
3562                assert_eq!(metric.npa.class_npa_sum(), 0);
3563                insta::assert_json_snapshot!(metric.npa);
3564            },
3565        );
3566    }
3567
3568    #[test]
3569    fn go_top_level_var_const_not_attributes() {
3570        // Package-level `var` and `const` declarations are NOT
3571        // struct fields — they are free-standing identifiers.
3572        // Expected class_na_sum = 0.
3573        check_metrics::<GoParser>(
3574            "package main\nvar Counter int\nconst Pi = 3.14\n",
3575            "foo.go",
3576            |metric| {
3577                assert_eq!(metric.npa.class_na_sum(), 0);
3578                insta::assert_json_snapshot!(metric.npa);
3579            },
3580        );
3581    }
3582
3583    #[test]
3584    fn go_npa_excludes_unexported() {
3585        // Issue #458: mixed exported / unexported fields exercising a
3586        // multi-name declaration (`A, b int`), an embedded field
3587        // (`io.Reader`), the blank identifier (`_`), and a Unicode
3588        // uppercase first char (`Ärger`).
3589        //
3590        // Names: Name(exp), secret(no), A(exp), b(no), Reader(exp),
3591        //   _(no), Ärger(exp) → na = 7, npa = 4. Revert-verified
3592        //   against the old all-public code, which counted every
3593        //   FieldDeclaration node once (class_npa_sum = class_na_sum
3594        //   = 6, undercounting the grouped names).
3595        check_metrics::<GoParser>(
3596            "package main\nimport \"io\"\n\
3597             type T struct { Name string; secret int; A, b int; io.Reader; _ int; Ärger bool }\n",
3598            "foo.go",
3599            |metric| {
3600                assert_eq!(metric.npa.class_na_sum(), 7);
3601                assert_eq!(metric.npa.class_npa_sum(), 4);
3602                insta::assert_json_snapshot!(metric.npa);
3603            },
3604        );
3605    }
3606
3607    // ----- Elixir -----
3608
3609    // Issue #275: `defstruct` is Elixir's closest analog to a class
3610    // field-set declaration. We count its field arguments as
3611    // (public) attributes.
3612    #[test]
3613    fn elixir_npa_defstruct_keyword_list() {
3614        check_metrics::<ElixirParser>(
3615            "defmodule User do\n  defstruct name: nil, age: 0, email: nil\nend\n",
3616            "foo.ex",
3617            |metric| {
3618                // Three keyword pairs → 3 fields, all public.
3619                assert_eq!(metric.npa.class_na_sum(), 3);
3620                assert_eq!(metric.npa.class_npa_sum(), 3);
3621            },
3622        );
3623    }
3624
3625    #[test]
3626    fn elixir_npa_defstruct_atom_list() {
3627        check_metrics::<ElixirParser>(
3628            "defmodule User do\n  defstruct [:name, :age, :email]\nend\n",
3629            "foo.ex",
3630            |metric| {
3631                assert_eq!(metric.npa.class_na_sum(), 3);
3632                assert_eq!(metric.npa.class_npa_sum(), 3);
3633            },
3634        );
3635    }
3636
3637    #[test]
3638    fn elixir_npa_defstruct_bracketed_keyword_list() {
3639        check_metrics::<ElixirParser>(
3640            "defmodule User do\n  defstruct [name: nil, age: 0]\nend\n",
3641            "foo.ex",
3642            |metric| {
3643                assert_eq!(metric.npa.class_na_sum(), 2);
3644                assert_eq!(metric.npa.class_npa_sum(), 2);
3645            },
3646        );
3647    }
3648
3649    #[test]
3650    fn elixir_npa_defstruct_single_field() {
3651        check_metrics::<ElixirParser>(
3652            "defmodule Box do\n  defstruct value: nil\nend\n",
3653            "foo.ex",
3654            |metric| {
3655                assert_eq!(metric.npa.class_na_sum(), 1);
3656                assert_eq!(metric.npa.class_npa_sum(), 1);
3657            },
3658        );
3659    }
3660
3661    #[test]
3662    fn elixir_npa_no_defstruct_is_zero() {
3663        check_metrics::<ElixirParser>(
3664            "defmodule Foo do\n  def m, do: :ok\nend\n",
3665            "foo.ex",
3666            |metric| {
3667                assert_eq!(metric.npa.class_na_sum(), 0);
3668                assert_eq!(metric.npa.class_npa_sum(), 0);
3669            },
3670        );
3671    }
3672
3673    // ----- Objective-C -----
3674
3675    #[test]
3676    fn objc_npa() {
3677        // `@property` is always a public attribute. Instance variables
3678        // default to `@protected`; a visibility marker flips the current
3679        // visibility for the fields that *follow* — including flipping
3680        // back to non-public — and a multi-declarator `int a, b;` is two
3681        // attributes. Here:
3682        //   ivars: `_prot` (default @protected), `_priv` (@private),
3683        //          `_pub1` + `_pub2` (@public → 2 public), `_prot2`
3684        //          (@protected, resetting the visibility) → 5 total, 2
3685        //          public.
3686        //   properties: `count`, `name` → 2 public.
3687        // → interface_na = 7, interface_npa = 4. The trailing `@protected`
3688        // resets visibility, so `_prot2` is NOT public.
3689        check_metrics::<ObjcParser>(
3690            "@interface Foo : NSObject {\n\
3691                 int _prot;\n\
3692             @private\n\
3693                 int _priv;\n\
3694             @public\n\
3695                 int _pub1, _pub2;\n\
3696             @protected\n\
3697                 int _prot2;\n\
3698             }\n\
3699             @property (nonatomic) int count;\n\
3700             @property (copy) NSString *name;\n\
3701             @end\n",
3702            "foo.m",
3703            |metric| {
3704                assert_eq!(metric.npa.interface_na_sum(), 7);
3705                assert_eq!(metric.npa.interface_npa_sum(), 4);
3706            },
3707        );
3708    }
3709
3710    #[test]
3711    fn objc_npa_protocol() {
3712        // A `@protocol`'s `@property` after an `@optional` / `@required`
3713        // marker nests under a `qualified_protocol_interface_declaration`;
3714        // it must still be counted (regression for the direct-children
3715        // walk that missed it).
3716        check_metrics::<ObjcParser>(
3717            "@protocol Drawable <NSObject>\n\
3718             @optional\n\
3719             @property (readonly) int z;\n\
3720             @end\n",
3721            "foo.m",
3722            |metric| {
3723                assert_eq!(metric.npa.interface_na_sum(), 1);
3724                assert_eq!(metric.npa.interface_npa_sum(), 1);
3725            },
3726        );
3727    }
3728
3729    // ----- C++ -----
3730
3731    #[test]
3732    fn cpp_empty_unit_no_attributes() {
3733        // No code → no class spaces → npa = 0. Establishes the trait
3734        // is wired and the per-language compute is reachable.
3735        check_metrics::<CppParser>("", "empty.cpp", |metric| {
3736            assert_eq!(metric.npa.class_na_sum(), 0);
3737            assert_eq!(metric.npa.class_npa_sum(), 0);
3738            insta::assert_json_snapshot!(metric.npa);
3739        });
3740    }
3741
3742    #[test]
3743    fn cpp_empty_class_no_attributes() {
3744        // `class Foo {};` has no fields. Marked as class space (npa
3745        // becomes visible) but counts stay at 0.
3746        check_metrics::<CppParser>("class Foo {};", "foo.cpp", |metric| {
3747            assert_eq!(metric.npa.class_na_sum(), 0);
3748            assert_eq!(metric.npa.class_npa_sum(), 0);
3749            insta::assert_json_snapshot!(metric.npa);
3750        });
3751    }
3752
3753    #[test]
3754    fn cpp_class_public_attributes() {
3755        // `class` defaults to private. `public:` flips visibility →
3756        // `int a; int b, c;` becomes 3 public attributes (multi-
3757        // declarator declaration emits one `field_identifier` per
3758        // name). Total: class_na = 3, class_npa = 3.
3759        check_metrics::<CppParser>(
3760            "class Foo { public: int a; int b, c; };",
3761            "foo.cpp",
3762            |metric| {
3763                assert_eq!(metric.npa.class_na_sum(), 3);
3764                assert_eq!(metric.npa.class_npa_sum(), 3);
3765                insta::assert_json_snapshot!(metric.npa);
3766            },
3767        );
3768    }
3769
3770    #[test]
3771    fn cpp_class_private_default_visibility() {
3772        // No access specifier → `class` keeps its default private
3773        // visibility → `int value_;` counts as 1 attribute but 0 are
3774        // public. class_na = 1, class_npa = 0.
3775        check_metrics::<CppParser>("class Foo { int value_; };", "foo.cpp", |metric| {
3776            assert_eq!(metric.npa.class_na_sum(), 1);
3777            assert_eq!(metric.npa.class_npa_sum(), 0);
3778            insta::assert_json_snapshot!(metric.npa);
3779        });
3780    }
3781
3782    #[test]
3783    fn cpp_struct_default_public_visibility() {
3784        // `struct` defaults to public — opposite of `class`. The same
3785        // field counts once and is public.
3786        check_metrics::<CppParser>("struct Bar { int value_; };", "foo.cpp", |metric| {
3787            assert_eq!(metric.npa.class_na_sum(), 1);
3788            assert_eq!(metric.npa.class_npa_sum(), 1);
3789            insta::assert_json_snapshot!(metric.npa);
3790        });
3791    }
3792
3793    #[test]
3794    fn cpp_mixed_visibility_sections() {
3795        // Public section: 1 field. Protected section (bucketed with
3796        // private for npa): 1 field. Private section: 1 field.
3797        // class_na = 3, class_npa = 1.
3798        check_metrics::<CppParser>(
3799            "class Foo {\n\
3800                 public: int a;\n\
3801                 protected: int b;\n\
3802                 private: int c;\n\
3803             };",
3804            "foo.cpp",
3805            |metric| {
3806                assert_eq!(metric.npa.class_na_sum(), 3);
3807                assert_eq!(metric.npa.class_npa_sum(), 1);
3808                insta::assert_json_snapshot!(metric.npa);
3809            },
3810        );
3811    }
3812
3813    #[test]
3814    fn cpp_methods_not_counted_as_attributes() {
3815        // Inline-defined methods (`function_definition`) and
3816        // declaration-only methods (`field_declaration` containing
3817        // `function_declarator`) must NOT be counted as attributes.
3818        // Only the data field `value_` adds to `class_na`.
3819        check_metrics::<CppParser>(
3820            "class Foo {\n\
3821                 public:\n\
3822                     void method1() {}\n\
3823                     void method2();\n\
3824                 private:\n\
3825                     int value_;\n\
3826             };",
3827            "foo.cpp",
3828            |metric| {
3829                assert_eq!(metric.npa.class_na_sum(), 1);
3830                assert_eq!(metric.npa.class_npa_sum(), 0);
3831                insta::assert_json_snapshot!(metric.npa);
3832            },
3833        );
3834    }
3835
3836    #[test]
3837    fn cpp_pointer_array_fields_count() {
3838        // `int* p;` wraps the `field_identifier` inside
3839        // `pointer_declarator`. `int a[10];` wraps it inside
3840        // `array_declarator`. Both must be reached by the recursive
3841        // helper. Plus a plain `int x;` → 3 attributes total.
3842        check_metrics::<CppParser>(
3843            "struct S {\n\
3844                 int* p;\n\
3845                 int a[10];\n\
3846                 int x;\n\
3847             };",
3848            "foo.cpp",
3849            |metric| {
3850                assert_eq!(metric.npa.class_na_sum(), 3);
3851                // Struct → all public.
3852                assert_eq!(metric.npa.class_npa_sum(), 3);
3853                insta::assert_json_snapshot!(metric.npa);
3854            },
3855        );
3856    }
3857
3858    #[test]
3859    fn cpp_multiple_classes_aggregate_at_unit() {
3860        // Two classes in one file. Each contributes to its own
3861        // class space; the file-level (Unit) class_na_sum aggregates
3862        // both. Foo has 2 attrs (1 public, 1 private). Bar has 1.
3863        // Total class_na_sum at Unit = 3.
3864        check_metrics::<CppParser>(
3865            "class Foo { public: int a; private: int b; };\nstruct Bar { int c; };",
3866            "foo.cpp",
3867            |metric| {
3868                assert_eq!(metric.npa.class_na_sum(), 3);
3869                // Public: Foo::a (1) + Bar::c (1) = 2.
3870                assert_eq!(metric.npa.class_npa_sum(), 2);
3871                insta::assert_json_snapshot!(metric.npa);
3872            },
3873        );
3874    }
3875
3876    #[test]
3877    fn javascript_empty_unit_no_attributes() {
3878        // Wires up the trait and ensures no spurious attribute counts
3879        // on an empty file.
3880        check_metrics::<JavascriptParser>("", "empty.js", |metric| {
3881            assert_eq!(metric.npa.class_na_sum(), 0);
3882            assert_eq!(metric.npa.class_npa_sum(), 0);
3883            insta::assert_json_snapshot!(metric.npa);
3884        });
3885    }
3886
3887    #[test]
3888    fn javascript_empty_class_no_attributes() {
3889        // A class with no body and no fields has zero attributes.
3890        check_metrics::<JavascriptParser>("class Foo {}", "foo.js", |metric| {
3891            assert_eq!(metric.npa.class_na_sum(), 0);
3892            assert_eq!(metric.npa.class_npa_sum(), 0);
3893            insta::assert_json_snapshot!(metric.npa);
3894        });
3895    }
3896
3897    #[test]
3898    fn javascript_class_fields_count() {
3899        // ES2022 class fields: `class Foo { x = 1; y; static z = 2; }`.
3900        // All three are `field_definition` direct children of
3901        // `class_body`. JS has no visibility — everything is public.
3902        // class_na = class_npa = 3.
3903        check_metrics::<JavascriptParser>(
3904            "class Foo { x = 1; y; static z = 2; }",
3905            "foo.js",
3906            |metric| {
3907                assert_eq!(metric.npa.class_na_sum(), 3);
3908                assert_eq!(metric.npa.class_npa_sum(), 3);
3909                insta::assert_json_snapshot!(metric.npa);
3910            },
3911        );
3912    }
3913
3914    #[test]
3915    fn javascript_arrow_field_is_method_not_attribute() {
3916        // `class Foo { x = () => {} }` declares a method, not an
3917        // attribute. The arrow function initializer makes this an
3918        // `Npm` member, not an `Npa` member.
3919        check_metrics::<JavascriptParser>(
3920            "class Foo { x = () => {}; y = function() {}; z = 1; }",
3921            "foo.js",
3922            |metric| {
3923                // Only `z = 1` is an attribute.
3924                assert_eq!(metric.npa.class_na_sum(), 1);
3925                assert_eq!(metric.npa.class_npa_sum(), 1);
3926                insta::assert_json_snapshot!(metric.npa);
3927            },
3928        );
3929    }
3930
3931    #[test]
3932    fn javascript_methods_not_counted_as_attributes() {
3933        // `method_definition` direct children of `class_body` are
3934        // methods, not fields. They must not show up in `npa`.
3935        check_metrics::<JavascriptParser>(
3936            "class Foo { constructor() {} bar() {} get baz() { return 1; } x = 1; }",
3937            "foo.js",
3938            |metric| {
3939                // Only `x = 1` is a true attribute.
3940                assert_eq!(metric.npa.class_na_sum(), 1);
3941                assert_eq!(metric.npa.class_npa_sum(), 1);
3942                insta::assert_json_snapshot!(metric.npa);
3943            },
3944        );
3945    }
3946
3947    #[test]
3948    fn javascript_multiple_classes_aggregate_at_unit() {
3949        // Two classes contribute their attribute counts to the
3950        // Unit-level rollup. Foo has 2 fields; Bar has 1. Total
3951        // class_na_sum = 3.
3952        check_metrics::<JavascriptParser>(
3953            "class Foo { a = 1; b = 2; }\nclass Bar { c = 3; }",
3954            "foo.js",
3955            |metric| {
3956                assert_eq!(metric.npa.class_na_sum(), 3);
3957                assert_eq!(metric.npa.class_npa_sum(), 3);
3958                insta::assert_json_snapshot!(metric.npa);
3959            },
3960        );
3961    }
3962
3963    #[test]
3964    fn mozjs_class_fields_count() {
3965        // Mozjs shares JS's class vocabulary. Same expectation as the
3966        // JS parity test above.
3967        check_metrics::<MozjsParser>(
3968            "class Foo { x = 1; y; static z = 2; }",
3969            "foo.js",
3970            |metric| {
3971                assert_eq!(metric.npa.class_na_sum(), 3);
3972                assert_eq!(metric.npa.class_npa_sum(), 3);
3973                insta::assert_json_snapshot!(metric.npa);
3974            },
3975        );
3976    }
3977
3978    // Regression for #438: an empty class has zero attributes, so the
3979    // CDA accessors divide 0.0 / 0.0. Before the zero-guard this yielded
3980    // NaN (serialized to JSON `null`). The defined value is 0.0 — an
3981    // attribute-less class exposes no public surface. Asserting
3982    // `!is_nan()` proves the guard fires; the `== 0.0` checks pin the
3983    // chosen convention. Exercised across the explicit-visibility OO
3984    // languages (Java, C#, Kotlin, PHP).
3985    #[test]
3986    fn empty_class_cda_is_zero_not_nan() {
3987        let assert_zero = |metric: crate::CodeMetrics| {
3988            assert_eq!(metric.npa.class_na_sum(), 0);
3989            assert!(!metric.npa.class_cda().is_nan());
3990            assert!(!metric.npa.total_cda().is_nan());
3991            assert_eq!(metric.npa.class_cda(), 0.0);
3992            assert_eq!(metric.npa.total_cda(), 0.0);
3993        };
3994        check_metrics::<JavaParser>("class Foo {}", "foo.java", assert_zero);
3995        check_metrics::<CsharpParser>("class Foo {}", "foo.cs", assert_zero);
3996        check_metrics::<KotlinParser>("class Foo {}", "foo.kt", assert_zero);
3997        check_metrics::<PhpParser>("<?php class Foo {}", "foo.php", assert_zero);
3998    }
3999
4000    // Regression for #438: an empty interface has zero attributes; the
4001    // existing all-public guard explicitly excludes the empty case
4002    // (`!= 0`), so without the divisor guard `interface_cda` returned
4003    // 0.0 / 0.0 = NaN. The defined value is 0.0.
4004    #[test]
4005    fn empty_interface_cda_is_zero_not_nan() {
4006        let assert_zero = |metric: crate::CodeMetrics| {
4007            assert_eq!(metric.npa.interface_na_sum(), 0);
4008            assert!(!metric.npa.interface_cda().is_nan());
4009            assert_eq!(metric.npa.interface_cda(), 0.0);
4010        };
4011        check_metrics::<JavaParser>("interface Foo {}", "foo.java", assert_zero);
4012        check_metrics::<CsharpParser>("interface Foo {}", "foo.cs", assert_zero);
4013    }
4014}