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