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