Skip to main content

big_code_analysis/spaces/
compute.rs

1//! Metric-computation and AST-traversal internals for [`super::analyze`].
2//!
3//! These free functions were split out of `spaces.rs` to keep that
4//! module focused on the public API types (`SpaceKind`, `CodeMetrics`,
5//! `FuncSpace`, `Source`, `Ast`, `MetricsOptions`). They are moved
6//! verbatim and re-exported from the parent so the public path
7//! `crate::spaces::analyze` (and `pub(crate) metrics_inner`) is preserved.
8
9use super::*;
10use crate::MetricSuite;
11use crate::SubtreeSkip;
12use crate::diag::warn;
13
14// Walks that ended with a cognitive nesting slot still live. Freeing
15// each slot when its node is popped keeps the map bounded by the
16// traversal stack instead of the tree (#1375); reverting that changes
17// no output, only the peak, so this is what a test can observe.
18crate::observation::counter!(nesting_slots_retained);
19
20/// Derives the two metrics that read from a space's *complete* state:
21/// Halstead's `Stats` from the accumulated occurrence maps, and MI from
22/// the resulting volume plus the space's final LOC and cyclomatic.
23///
24/// Both are single-assignment — each call overwrites the previous
25/// result rather than accumulating — so running this before a space has
26/// absorbed all of its children is wasted work, not a partial sum. Only
27/// [`finalize_state`] calls it, once per space (#1106).
28#[inline]
29fn compute_halstead_and_mi<T: MetricSuite>(state: &mut State, options: MetricsOptions) {
30    let selected = options.metrics;
31    if selected.contains(Metric::Halstead) {
32        state
33            .halstead_maps
34            .finalize(&mut state.space.metrics.halstead);
35    }
36    if selected.contains(Metric::Mi) {
37        // `MetricsOptions::with_only` guarantees Mi's dependencies
38        // (Loc + Cyclomatic + Halstead) are also selected, so the
39        // Stats values feeding into the MI formula here are populated
40        // — not the zero defaults that would silently produce a
41        // garbage MI score.
42        T::Mi::compute(
43            &state.space.metrics.loc,
44            &state.space.metrics.cyclomatic,
45            &state.space.metrics.halstead,
46            &mut state.space.metrics.mi,
47            options.mi_charges_docs,
48        );
49    }
50}
51
52/// Records the space kind `wmc::Stats::merge` dispatches on, for the
53/// kinds WMC recognises, plus the cumulative cyclomatic those kinds
54/// contribute.
55///
56/// Unlike [`compute_halstead_and_mi`] this must also run on a *parent*
57/// before each child merges into it: `wmc::Stats::merge` routes the
58/// child's contribution on `self.space_kind`, which stays `Unknown`
59/// until this runs, and an `Unknown` parent silently drops every
60/// method's cyclomatic from its class WMC.
61#[inline]
62fn compute_wmc<T: MetricSuite>(state: &mut State, selected: MetricSet) {
63    if selected.contains(Metric::Wmc) {
64        T::Wmc::compute(
65            state.space.kind,
66            &state.space.metrics.cyclomatic,
67            &mut state.space.metrics.wmc,
68        );
69    }
70}
71
72/// Records the space kind that decides whether `npm` / `npa` are
73/// serialized on this space.
74///
75/// Both are emitted only on a member scope — a container, or the file
76/// unit that rolls its containers up — and since #1203 the space's own
77/// kind is the whole of that decision. Doing it here rather than letting
78/// each language raise a flag from its own grammar node kinds is what
79/// makes the rule hold for every language, including one added later:
80/// there is no per-language surface left to deviate on, in either
81/// direction.
82///
83/// `HAS_MEMBERS` is the one exception, and it is a language-level opt
84/// out rather than a per-space one: a grammar with no class-shaped
85/// construct anywhere (C, Bash, Lua, …) would otherwise report an
86/// all-zero block on every file root, since a unit is a member scope
87/// like any other.
88#[inline]
89fn note_member_scope<T: MetricSuite>(state: &mut State, selected: MetricSet) {
90    let kind = state.space.kind;
91    if selected.contains(Metric::Npm) && <T::Npm as Npm>::HAS_MEMBERS {
92        state.space.metrics.npm.set_space_kind(kind);
93    }
94    if selected.contains(Metric::Npa) && <T::Npa as Npa>::HAS_MEMBERS {
95        state.space.metrics.npa.set_space_kind(kind);
96    }
97}
98
99#[inline]
100fn compute_averages(state: &mut State, selected: MetricSet) {
101    // The per-function averages for Cognitive, Exit, and NArgs divide
102    // by counts sourced from `Nom`. `Metric::dependencies` declares
103    // `Nom` as a dependency of all three, so `with_only` pulls it into
104    // any selection that includes them and these divisors reflect the
105    // real function/closure counts. As defense-in-depth, each `average`
106    // accessor additionally guards its divisor with `.max(1)`, so even
107    // a zero divisor degrades to `sum / 1` rather than `inf`/`NaN`
108    // (#428). Compute the divisors once and feed them into each gated
109    // finalize.
110    let nom_functions = state.space.metrics.nom.functions_sum() as usize;
111    let nom_closures = state.space.metrics.nom.closures_sum() as usize;
112    let nom_total = state.space.metrics.nom.total() as usize;
113    // Cognitive average
114    if selected.contains(Metric::Cognitive) {
115        state.space.metrics.cognitive.finalize(nom_total);
116    }
117    // Nexit average
118    if selected.contains(Metric::Nexits) {
119        state.space.metrics.nexits.finalize(nom_total);
120    }
121    // Nargs average
122    if selected.contains(Metric::Nargs) {
123        state
124            .space
125            .metrics
126            .nargs
127            .finalize(nom_functions, nom_closures);
128    }
129}
130
131#[inline]
132fn compute_minmax(state: &mut State, selected: MetricSet) {
133    if selected.contains(Metric::Cyclomatic) {
134        state.space.metrics.cyclomatic.compute_minmax();
135    }
136    if selected.contains(Metric::Nexits) {
137        state.space.metrics.nexits.compute_minmax();
138    }
139    if selected.contains(Metric::Cognitive) {
140        state.space.metrics.cognitive.compute_minmax();
141    }
142    if selected.contains(Metric::Nargs) {
143        state.space.metrics.nargs.compute_minmax();
144    }
145    if selected.contains(Metric::Nom) {
146        state.space.metrics.nom.compute_minmax();
147    }
148    if selected.contains(Metric::Loc) {
149        state.space.metrics.loc.compute_minmax();
150    }
151    if selected.contains(Metric::Abc) {
152        state.space.metrics.abc.compute_minmax();
153    }
154    if selected.contains(Metric::Tokens) {
155        state.space.metrics.tokens.compute_minmax();
156    }
157}
158
159#[inline]
160fn compute_sum(state: &mut State, selected: MetricSet) {
161    if selected.contains(Metric::Wmc) {
162        state.space.metrics.wmc.compute_sum();
163    }
164    if selected.contains(Metric::Npm) {
165        state.space.metrics.npm.compute_sum();
166    }
167    if selected.contains(Metric::Npa) {
168        state.space.metrics.npa.compute_sum();
169    }
170}
171
172/// Re-anchors the file-level unit's `sloc` row span to the span the unit
173/// itself reports, discarding the measured start row seeded during the
174/// walk.
175///
176/// Three places held a copy of "where does the unit's row span start",
177/// and #1195 anchored only one of them — [`crate::spaces::line_span`],
178/// which gives the unit line 1 because the unit *is* the file.
179/// `loc::shared::init` seeds `sloc.start` from the visited node's first
180/// token for every func-space, the root included, so a file opening with
181/// blank lines dropped those rows from `sloc` (and so from `blank`, and
182/// from MI's `ln(sloc)` term) while its reported `start_line..end_line`
183/// still covered them. A leading *comment* was always fine, since
184/// comments are in the tree — which is what made byte-identical content
185/// score differently for being shifted down by one comment row (#1247).
186///
187/// Reading the span back off the finished [`FuncSpace`] leaves
188/// `line_span` the single owner of the rule, degenerate cases included:
189/// an empty file reports `0..0`, which converts to the default `0..0`
190/// sloc span and keeps `sloc` at 0.
191///
192/// Done here rather than in [`FuncSpace::new`] because the root node's
193/// own `Loc::compute` runs *after* construction and would overwrite an
194/// anchor set there; and here rather than in the 20-odd per-language
195/// `Loc` impls because none of them knows the space kind — re-widening
196/// the trait with a unit flag is exactly what #1067 removed for perf.
197#[inline]
198fn anchor_unit_sloc_span(state: &mut State, selected: MetricSet) {
199    if selected.contains(Metric::Loc) && state.space.kind == SpaceKind::Unit {
200        // `start_line` is 1-based and `Sloc::start` is a 0-based row;
201        // `saturating_sub` covers the empty-file `0..0`, whose 0-based
202        // start is 0 either way.
203        let start_row = state.space.start_line.saturating_sub(1);
204        state
205            .space
206            .metrics
207            .loc
208            .init_unit_span(start_row, state.space.end_line);
209    }
210}
211
212/// Discards PLOC / CLOC rows a grammar's error recovery placed outside
213/// the space's own row span, so `ploc <= sloc` holds for malformed input
214/// as it already does for well-formed input (#1398).
215///
216/// Runs per space rather than only on the unit, because the phantom row
217/// lands on whichever space was open when the recovery token was
218/// visited — an unterminated Elixir `do` block violated the contract on
219/// its own `defmodule` space as well as on the file. Child spaces are
220/// clamped before they merge upward and a parent's span contains each
221/// child's, so a parent's later clamp cannot take back a row a child
222/// legitimately contributed.
223///
224/// Ordered after [`anchor_unit_sloc_span`] so it clamps against the
225/// unit's *final* span. Before that pass the unit's recorded span still
226/// starts at the root node's first token, and while no row currently
227/// sits between that token and line 1 that either line set records —
228/// blank rows are in neither, and a leading comment is in the tree, so
229/// the root starts on it — depending on that is the coupling #1247
230/// removed rather than a property to lean on again.
231#[inline]
232fn clamp_loc_line_sets(state: &mut State, selected: MetricSet) {
233    if selected.contains(Metric::Loc) {
234        state.space.metrics.loc.clamp_line_sets_to_span();
235    }
236}
237
238/// Drops from the `exclude_tests`-pruned row set the rows that retained
239/// code or comments also occupy, so `sloc()` subtracts only what the
240/// prune took (#1417).
241///
242/// Ordered after [`clamp_loc_line_sets`], which supplies the premise its
243/// `ploc <= sloc` assertion rests on, and before `compute_minmax` and
244/// `compute_halstead_and_mi`, both of which read the settled `sloc()` —
245/// the former into `sloc_min`/`sloc_max`, the latter into MI's `ln(sloc)`
246/// term.
247///
248/// Runs per space and unconditionally, like the clamp: a space that
249/// pruned nothing holds an empty, unallocated set, so the three
250/// subtractions visit no words. The gate is the same
251/// `selected.contains(Metric::Loc)`, so deselecting loc keeps the walk's
252/// work identical.
253#[inline]
254fn settle_loc_excluded_rows(state: &mut State, selected: MetricSet) {
255    if selected.contains(Metric::Loc) {
256        state.space.metrics.loc.settle_excluded_rows();
257    }
258}
259
260/// Runs the per-space finalization passes (unit-span anchoring, line-set
261/// clamping, min/max, sum, Halstead, MI, WMC, averages) on a single
262/// [`State`]. Shared by both the single-element and pop arms of
263/// [`finalize`] so the call sequence stays identical in both, and
264/// reached exactly once per space — every state is finalized either
265/// when it is popped or, for the root, in the single-element arm.
266///
267/// [`anchor_unit_sloc_span`] runs first because everything after it reads
268/// the span it fixes: `compute_minmax` folds `sloc` into the unit's
269/// `sloc_min`/`sloc_max`, and `compute_halstead_and_mi` feeds it into MI's
270/// `ln(sloc)` term. [`settle_loc_excluded_rows`] sits between
271/// [`clamp_loc_line_sets`] and `compute_minmax` for both halves of that
272/// same reason: it needs the clamped line sets as its input, and it moves
273/// `sloc()`, which those two later passes consume (#1417).
274///
275/// [`finalize`]'s pop arm additionally calls [`compute_wmc`] on the
276/// *parent* before each child merges into it, because `wmc::Stats::merge`
277/// dispatches on the parent's recorded `space_kind`. It deliberately does
278/// **not** re-run [`compute_halstead_and_mi`] there: `halstead::Stats` and
279/// `mi::Stats` both have no-op `merge`s, so nothing reads a parent's
280/// intermediate Halstead/MI, and this call overwrites them from the final
281/// maps anyway (#1106).
282fn finalize_state<T: MetricSuite>(state: &mut State, options: MetricsOptions) {
283    let selected = options.metrics;
284    anchor_unit_sloc_span(state, selected);
285    clamp_loc_line_sets(state, selected);
286    settle_loc_excluded_rows(state, selected);
287    compute_minmax(state, selected);
288    compute_sum(state, selected);
289    compute_halstead_and_mi::<T>(state, options);
290    compute_wmc::<T>(state, selected);
291    note_member_scope::<T>(state, selected);
292    compute_averages(state, selected);
293}
294
295fn finalize<T: MetricSuite>(
296    state_stack: &mut Vec<State>,
297    diff_level: usize,
298    options: MetricsOptions,
299) {
300    if state_stack.is_empty() {
301        return;
302    }
303    let selected = options.metrics;
304    for _ in 0..diff_level {
305        if state_stack.len() == 1 {
306            let last_state = state_stack
307                .last_mut()
308                .expect("invariant: state_stack has exactly one element");
309            finalize_state::<T>(last_state, options);
310            break;
311        }
312        let mut state = state_stack
313            .pop()
314            .expect("invariant: state_stack has more than one element");
315        finalize_state::<T>(&mut state, options);
316
317        let last_state = state_stack
318            .last_mut()
319            .expect("invariant: state_stack has remaining elements after pop");
320        last_state.halstead_maps.merge(&state.halstead_maps);
321        compute_wmc::<T>(last_state, selected);
322
323        // Merge function spaces
324        last_state.space.metrics.merge(&state.space.metrics);
325        last_state.space.spaces.push(state.space);
326    }
327}
328
329/// Compute every metric for a [`Source`].
330///
331/// This is the recommended library entry point. It does not conflate
332/// the top-level [`FuncSpace::name`] with a filesystem path: callers
333/// supply an explicit `Source::name` and an optional
334/// `Source::preproc_path` for C++ preprocessor lookup.
335///
336/// `options` controls per-traversal flags (e.g.
337/// `MetricsOptions::default().with_exclude_tests(true)` to elide
338/// Rust `#[test]` / `#[cfg(test)]` subtrees).
339///
340/// # Errors
341///
342/// The return type carries [`MetricsError::EmptyRoot`] for forward
343/// compatibility, but the walker always pushes a synthetic top-level
344/// [`SpaceKind::Unit`][crate::SpaceKind] `FuncSpace` before walking,
345/// so this function does not return `Err` in practice today (see
346/// the variant doc).
347///
348/// # Examples
349///
350/// Analysing an in-memory snippet without constructing a `Path`:
351///
352/// ```
353/// use big_code_analysis::{analyze, MetricsOptions, Source, LANG};
354///
355/// let space = analyze(
356///     Source::new(LANG::Rust, b"fn main() { let x = 1 + 2; }")
357///         .with_name(Some("snippet.rs".to_owned())),
358///     MetricsOptions::default(),
359/// )
360/// .expect("snippet has a top-level FuncSpace");
361/// assert_eq!(space.name.as_deref(), Some("snippet.rs"));
362/// ```
363pub fn analyze(source: Source<'_>, options: MetricsOptions) -> Result<FuncSpace, MetricsError> {
364    Ast::parse(source)?.metrics(options)
365}
366
367/// Per-node classification the walker derives once and the metrics
368/// consume. Bundled rather than passed as loose `bool`s so the
369/// call site cannot transpose them — they are all same-typed flags
370/// about the node currently being visited.
371#[derive(Clone, Copy)]
372struct NodeFacts {
373    /// This node opens a new [`FuncSpace`].
374    func_space: bool,
375    /// Whether this node lies inside a comment subtree — the node
376    /// **itself** or any ancestor is a comment. Contrast
377    /// [`Walk::in_comment`], which covers ancestors only (#1052).
378    in_comment: bool,
379}
380
381// Per-node metric dispatch. Each `compute` call is paired with a bit
382// check against the caller's selection. The bit tests are cheap
383// (single AND-and-compare on the `MetricSet` bitfield) and an
384// unselected metric saves both the call overhead and any per-node
385// text-slice / token-table work the metric does internally — Halstead
386// in particular owns `HalsteadMaps` allocations and is the headline
387// cost saving for `with_only(&[Metric::Loc])`. Extracted from
388// `metrics_inner` so the walker stays under clippy's 100-line ceiling.
389#[inline]
390fn compute_per_node<'a, T: MetricSuite>(
391    state: &mut State<'a>,
392    node: &Node<'a>,
393    code: &'a [u8],
394    options: MetricsOptions,
395    facts: NodeFacts,
396    ancestors: Ancestors<'a, '_>,
397    nesting_map: &mut NestingMap,
398) {
399    let NodeFacts {
400        func_space,
401        in_comment,
402    } = facts;
403    let selected = options.metrics;
404    let last = &mut state.space;
405    if selected.contains(Metric::Cognitive) {
406        T::Cognitive::compute(
407            node,
408            code,
409            ancestors,
410            &mut last.metrics.cognitive,
411            nesting_map,
412        );
413    }
414    if selected.contains(Metric::Cyclomatic) {
415        T::Cyclomatic::compute_with_options(
416            node,
417            code,
418            ancestors,
419            &mut last.metrics.cyclomatic,
420            options.count_cyclomatic_try,
421        );
422    }
423    if selected.contains(Metric::Halstead) {
424        T::Halstead::compute(node, code, ancestors, &mut state.halstead_maps);
425    }
426    if selected.contains(Metric::Loc) {
427        T::Loc::compute(node, code, ancestors, &mut last.metrics.loc, func_space);
428    }
429    if selected.contains(Metric::Nom) {
430        T::Nom::compute(node, code, ancestors, &mut last.metrics.nom);
431    }
432    if selected.contains(Metric::Tokens) {
433        T::Tokens::compute(node, &mut last.metrics.tokens, in_comment);
434    }
435    if selected.contains(Metric::Nargs) {
436        T::NArgs::compute(node, code, ancestors, &mut last.metrics.nargs);
437    }
438    if selected.contains(Metric::Nexits) {
439        T::Exit::compute(node, code, ancestors, &mut last.metrics.nexits);
440    }
441    if selected.contains(Metric::Abc) {
442        T::Abc::compute(node, code, ancestors, &mut last.metrics.abc);
443    }
444    if selected.contains(Metric::Npm) {
445        T::Npm::compute(node, code, ancestors, &mut last.metrics.npm);
446    }
447    if selected.contains(Metric::Npa) {
448        T::Npa::compute(node, code, ancestors, &mut last.metrics.npa);
449    }
450}
451
452/// Pushes a synthetic `Unit` root onto the state stack when the grammar
453/// hands us a non-`Unit` root.
454///
455/// Some grammars (e.g. tree-sitter-mozcpp on unparseable input) return a
456/// non-Unit root. Wrapping with a synthetic Unit space spanning the whole
457/// file keeps the top-level `FuncSpace` upholding the LOC invariant
458/// `blank = sloc - ploc - only_comment_lines >= 0`. A `Unit` root needs
459/// no wrapper, so nothing is pushed in that case.
460///
461/// The frame's `loc` row span is *not* seeded here. The walk never visits
462/// this synthetic node, so before #1247 this was the one place that could
463/// give it one — a third copy of "where does the unit's row span start",
464/// and one that copied the measured start row the anchored reported span
465/// had already stopped agreeing with. [`anchor_unit_sloc_span`] now
466/// derives every unit's span from the one [`crate::spaces::line_span`]
467/// recorded, this frame included.
468fn push_synthetic_unit_root<T: MetricSuite>(
469    state_stack: &mut Vec<State>,
470    node: &Node,
471    code: &[u8],
472    selected: MetricSet,
473) {
474    // `Ancestors::unknown()`: `node` is the tree root here, so it has no
475    // ancestors to hand over either way.
476    if T::Getter::get_space_kind_with_code(node, code, Ancestors::unknown()) != SpaceKind::Unit {
477        let synthetic = FuncSpace::new::<T::Getter>(
478            node,
479            code,
480            Ancestors::unknown(),
481            SpaceKind::Unit,
482            selected,
483        );
484        state_stack.push(State {
485            space: synthetic,
486            halstead_maps: HalsteadMaps::new(),
487        });
488    }
489}
490
491/// Pushes a new [`FuncSpace`] frame for `node` and returns the nesting
492/// level its children inherit.
493///
494/// Only called once the walker has decided `node` opens a space, so the
495/// `SpaceKind` lookup stays off the per-node path. That matters for some
496/// languages — notably Elixir, whose `get_space_kind_with_code` runs a
497/// per-`Call` source-text keyword scan, so it is far from a cheap enum
498/// compare (issue #522; the `Loc` unit flag that used to force it on
499/// every node went away with #1067).
500fn open_func_space<'a, T: MetricSuite>(
501    state_stack: &mut Vec<State<'a>>,
502    node: &Node<'a>,
503    code: &'a [u8],
504    ancestors: Ancestors<'a, '_>,
505    level: usize,
506    selected: MetricSet,
507) -> usize {
508    let kind = T::Getter::get_space_kind_with_code(node, code, ancestors);
509    let mut space = FuncSpace::new::<T::Getter>(node, code, ancestors, kind, selected);
510    // Membership is decided here, at the one point that still holds the
511    // node the space was opened from — `finalize` sees only the finished
512    // `FuncSpace`. A function the enclosing container does not own (e.g.
513    // a C++ inline `friend`) keeps its own space and its own metrics;
514    // only the container's WMC roll-up declines it (#1301).
515    if selected.contains(Metric::Wmc) && T::Checker::is_non_member_function(node, code, ancestors) {
516        space.metrics.wmc.mark_non_member();
517    }
518    state_stack.push(State {
519        space,
520        halstead_maps: HalsteadMaps::new(),
521    });
522    level + 1
523}
524
525/// Scans a comment node for a suppression marker and applies it against
526/// `state_stack` immediately.
527///
528/// Doing this inline during the walk (rather than queueing markers for a
529/// post-walk pass keyed on line number) pins each marker to the
530/// syntactically nearest enclosing function space — the only frame on the
531/// stack that the grammar nested the comment inside. Line-only matching
532/// was ambiguous when two sibling functions shared a source line and the
533/// first-by-source-order won regardless of which body actually contained
534/// the comment (issue #289).
535///
536/// Every complaint the parse produced is logged, and whatever directive
537/// it still yielded is applied: an unusable metric name costs its own
538/// name and nothing else, while a body that parses to no directive at all
539/// is logged and dropped (issue #1168). The walk never aborts — a typo in
540/// one file must not derail a workspace-wide pass — and dropping stays
541/// the conservative choice, since a marker can only ever lose coverage
542/// this way, never gain it.
543fn apply_comment_suppression(
544    state_stack: &mut Vec<State>,
545    node: &Node,
546    code: &[u8],
547    diagnostic_path: &str,
548    is_comment: bool,
549) {
550    if is_comment && let Some(text) = node.utf8_text(code) {
551        let scan = parse_suppression_marker(text);
552        for diagnostic in &scan.diagnostics {
553            // The `+ 1` converts tree-sitter's 0-based rows to the
554            // 1-based line numbers `FuncSpace::start_line` and the
555            // rest of this module report.
556            warn(format_args!(
557                "{}:{}: {diagnostic}",
558                diagnostic_path,
559                node.start_row() + 1
560            ));
561        }
562        if let Some(suppression) = &scan.suppression {
563            apply_suppression(state_stack, suppression);
564        }
565    }
566}
567
568/// Context carried down the metrics walk alongside each node.
569///
570/// `in_comment` replaces the per-leaf ancestor walk `Tokens::compute` used
571/// to do. That walk was `O(depth)` per leaf and, because `Node::parent`
572/// is itself `O(depth)`, made the metric `O(leaves × depth²)` — a few
573/// kilobytes of deeply nested source burned minutes of CPU (issue #1052).
574/// Propagating the flag down the traversal computes the same predicate in
575/// `O(1)` per node: a node is inside a comment iff its parent was, or the
576/// node itself is a comment.
577/// `Default` is the root's tag, and the only place one is built from
578/// nothing: level 0, depth 0, and outside any comment are what "no
579/// ancestors yet" means for all three fields at once. Every other tag
580/// is derived from a parent's.
581#[derive(Clone, Copy, Default)]
582struct Walk {
583    /// Nesting level, used to close func-spaces on the way back up.
584    level: usize,
585    /// AST depth — the number of ancestors this node has, so the root
586    /// sits at `0`.
587    ///
588    /// Distinct from `level`, which only advances at func-space
589    /// boundaries. This one indexes the ancestor chain the walk keeps
590    /// for [`Ancestors`], which is why it has to count every step.
591    depth: usize,
592    /// Whether an **ancestor** of this node is a comment.
593    ///
594    /// Deliberately excludes the node itself — the walk ORs in
595    /// `is_comment(node)` on arrival to get the node's own membership,
596    /// and tags its children with that. Note this differs from
597    /// [`NodeFacts::in_comment`], which *does* include the node: passing
598    /// this field where that one is expected would stop excluding a
599    /// comment's own leaves and reintroduce the #1052 miscount.
600    in_comment: bool,
601}
602
603/// Seeds each child's `nesting_map` slot from `node`'s own, so
604/// `Cognitive` can read the [`Nesting`] it inherits without calling
605/// `Node::parent`.
606///
607/// A node's slot means two different things either side of its
608/// `compute`, and the distinction is load-bearing:
609///
610/// - **on entry** it holds what the node inherits — this is what
611///   `get_nesting_from_map` reads;
612/// - **on exit** each language's `compute` has overwritten it with the
613///   post-increment `Nesting` its children should see — this is what this
614///   function hands down.
615///
616/// So the write at the end of every `Cognitive::compute` must stay at the
617/// end. Moving it to the top would make every descendant inherit the
618/// pre-increment `Nesting` and silently under-count.
619///
620/// `or_insert`, not `insert`: Python's comprehension handling pre-writes
621/// its clause children's slots during the *comprehension's* `compute`
622/// (the #421 fix, so clause nesting does not depend on sibling traversal
623/// order), and those values deliberately differ from the comprehension's
624/// own `Nesting`. A blanket overwrite would clobber them — the
625/// `python_comprehension_*` tests in `metrics::cognitive` are what catch
626/// it.
627///
628/// Grammars whose `Cognitive` impl is the macro's no-op (`Preproc`,
629/// `Ccomment`) never write a slot, so the root lookup misses and the
630/// walk seeds nothing for them at all — no map, no allocation.
631fn propagate_nesting_to_children(
632    node: &Node,
633    children: &[(Node<'_>, Walk)],
634    nesting_map: &mut NestingMap,
635) {
636    // `remove`, not `get`: this is the slot's last reader — its own
637    // `compute` has already consumed it and the children seeded below
638    // each carry a copy — so freeing it here is what keeps the map
639    // bounded by the traversal stack rather than the tree (#1375; see
640    // the declaration in `metrics_inner`). Leaves free theirs too, which
641    // is why there is no longer an empty-children bail-out ahead of the
642    // hash: it only ever skipped a read, and the slot stayed behind.
643    //
644    // A miss here is a *root-only* path. Every non-root slot is created
645    // by this function's `or_insert` below, before that node is ever
646    // popped, so reaching a node with no slot means it had no parent to
647    // seed it. The root's own slot exists iff the root's `compute` wrote
648    // one — which the two no-op grammars never do, so for them the walk
649    // seeds nothing and the map stays empty.
650    //
651    // Note this does *not* depend on every real impl writing on every
652    // path: a real impl that skipped its write would still leave its
653    // children seeded, and would show up as wrong nesting values, not as
654    // a missing slot.
655    let Some(inherited) = nesting_map.remove(&node.id()) else {
656        return;
657    };
658    // One allocation for the whole sibling set rather than a doubling
659    // chain through it. Dropping the walk's up-front reserve left this
660    // as the only place the map grows, and a generated parser table can
661    // hang half a million children off one `initializer_list` — which
662    // from an empty map is ~18 doublings and twice that many re-inserts.
663    // A `reserve` that the current capacity already covers is a compare,
664    // so the ordinary two- or three-child node pays nothing.
665    nesting_map.reserve(children.len());
666    for (child, _) in children {
667        nesting_map.entry(child.id()).or_insert(inherited);
668    }
669}
670
671/// Pushes `node`'s direct children onto the traversal `stack`, each tagged
672/// with `tag`.
673///
674/// The ordering is load-bearing: pushing in source order and reversing
675/// the freshly-pushed tail makes the LIFO `stack` yield children in
676/// source order, which in turn governs line-shared suppression
677/// attribution (issue #289).
678///
679/// `Tag` is generic because the two walkers carry different context down
680/// the tree: `ops` needs only the nesting level, while `metrics_inner`
681/// also propagates comment membership ([`Walk`], issue #1052).
682///
683/// Returns the children just pushed, as a slice borrowed from `stack` —
684/// empty for a leaf, and in reverse source order like the stack itself.
685///
686/// Borrowing rather than returning indices is what makes "these are
687/// exactly this node's children" a compiler-checked claim: the borrow
688/// forbids touching `stack` while the slice is alive, so the slice
689/// cannot drift from the pushes it describes. Recording `stack.len()`
690/// around the call instead only holds while the two stay adjacent, and
691/// nothing enforces that.
692pub(crate) fn push_children<'a, 's, Tag: Copy>(
693    cursor: &mut Cursor<'a>,
694    node: &Node<'a>,
695    tag: Tag,
696    stack: &'s mut Vec<(Node<'a>, Tag)>,
697) -> &'s [(Node<'a>, Tag)] {
698    // Children go on in source order and the freshly-pushed tail is
699    // reversed in place, so the LIFO `stack` yields the leftmost child
700    // first. Equivalent to the `children.drain(..).rev()` this replaced,
701    // without the caller-threaded scratch buffer, and each child is
702    // copied once rather than twice.
703    let first = stack.len();
704    stack.extend(node.children_with(cursor).map(|child| (child, tag)));
705    stack[first..].reverse();
706    &stack[first..]
707}
708
709/// Whether `exclude_tests` elides `node` and its descendants, reusing
710/// `run_verdict` when the classifier's last answer already reached this
711/// far.
712///
713/// The reach exists because some verdicts are about a *run* of
714/// siblings rather than one node: Rust's `#[…]` rows all decorate the
715/// same item, so a classifier that read the run to answer for the first
716/// row has answered for the rest of them too. Asking per row instead
717/// re-derived the run per row, which is `O(run^2)` on a file deep
718/// enough to keep the run inside the forward-scan budget — 5.2 s at
719/// depth 2 000 (#1446).
720///
721/// Pre-order visits `start_byte` non-decreasing, so a reach the walk
722/// has passed can never be re-entered and the stale-answer case does
723/// not arise. `SubtreeSkip::RETAIN` reaches nothing, which is what the
724/// walk starts from and what every classifier that answers per node
725/// keeps returning.
726fn prunes_subtree<'a, T: MetricSuite>(
727    node: &Node<'a>,
728    code: &[u8],
729    ancestors: Ancestors<'a, '_>,
730    run_verdict: &mut SubtreeSkip,
731) -> bool {
732    if !run_verdict.answers_for(node.start_byte()) {
733        *run_verdict = T::Checker::should_skip_subtree(node, code, ancestors);
734    }
735    run_verdict.is_skipped()
736}
737
738pub(crate) fn metrics_inner<T: MetricSuite>(
739    parser: &T,
740    name: Option<String>,
741    options: MetricsOptions,
742) -> Result<FuncSpace, MetricsError> {
743    // bca: suppress(cognitive, abc)
744    // The single AST-walk loop. Per-node work is already factored into
745    // push_synthetic_unit_root / finalize / open_func_space /
746    // compute_per_node / apply_comment_suppression / push_children; the
747    // residual branches each guard a distinct walk invariant
748    // (#182/#289/#522/#722/#1084). There is no cohesive sub-loop left to
749    // lift without inventing a `walk_part2`.
750    // The suppression-warning diagnostic uses the caller-supplied
751    // name when present; otherwise we fall back to a placeholder so
752    // the warning still locates the offending line. All path-based
753    // shims pass a lossy-stringified path here, matching pre-#254
754    // behaviour byte-for-byte.
755    let diagnostic_path = name.as_deref().unwrap_or("<input>");
756    let selected = options.metrics;
757    let code = parser.code();
758    let node = parser.root();
759    let mut cursor = node.cursor();
760    let mut stack = Vec::new();
761    // Ancestor chain of the node currently being visited, root first.
762    // Maintained so per-node predicates can read an ancestor as a slice
763    // index instead of through `Node::parent`, which `tree_sitter`
764    // resolves by descending from the root (#1084).
765    let mut chain: Vec<Node<'_>> = Vec::new();
766    // The children of each wide node on `chain`, so a predicate asking
767    // for a sibling in a flat `ERROR` does not scan it (#1584).
768    let mut siblings = SiblingCache::default();
769    let folds = FoldCache::default();
770    let mut state_stack: Vec<State> = Vec::new();
771    let mut last_level = 0;
772    // Per-node cognitive nesting, inherited down the walk. Deliberately
773    // not pre-seeded with the root: `get_nesting_from_map` already falls
774    // back to `Nesting::default()`, so a seed would change nothing for
775    // grammars that compute cognitive — while for the two whose impl is
776    // the macro's no-op it is the one write that would make the walk
777    // seed and free a slot per node that nothing ever reads.
778    //
779    // The map holds only nodes that have been seeded and not yet
780    // popped: `propagate_nesting_to_children` frees a slot as its last
781    // reader, so the live set is bounded by the traversal `stack`, never
782    // by the tree. It used to be reserved at `descendant_count` on the
783    // reasoning that it would converge on one entry per node — which it
784    // did, and on a 28 MB generated `parser.c` that reserve alone was
785    // 540 MB of a 1.27 GB peak (#1375). Grown from empty, it now peaks
786    // at the widest pending sibling set instead, and stays unallocated
787    // for good when nothing seeds the root: an unselected `Cognitive`
788    // never calls `compute`, and the two grammars whose impl is the
789    // macro's no-op (`Preproc`, `Ccomment`) write no slot.
790    let mut nesting_map = NestingMap::default();
791
792    // Suppression markers are resolved inline during the walk rather
793    // than queued for a post-finalize pass. When we visit a comment
794    // node, the active `state_stack` already encodes the comment's
795    // syntactic context: the topmost `SpaceKind::Function` entry is
796    // the *innermost enclosing function* by construction, with no
797    // ambiguity when sibling functions share a source line (issue
798    // #289). The root `Unit` state — always at index 0 once the walk
799    // has visited the AST root — owns file-scoped markers.
800
801    // A classifier verdict that answered for a whole run of siblings,
802    // carried past the member it was asked about. See `prunes_subtree`
803    // (#1446).
804    let mut run_verdict = SubtreeSkip::RETAIN;
805
806    push_synthetic_unit_root::<T>(&mut state_stack, &node, code, selected);
807
808    stack.push((node, Walk::default()));
809
810    while let Some((
811        node,
812        Walk {
813            level,
814            depth,
815            in_comment,
816        },
817    )) = stack.pop()
818    {
819        // The ancestors of the node about to be visited, root first.
820        // Pre-order guarantees every one of them has already been
821        // visited and appended, so truncating to `depth` drops the
822        // sibling subtree we just finished and leaves exactly this
823        // node's chain (#1084). Correcting it here rather than on the
824        // way out also keeps it right across the `continue` below.
825        chain.truncate(depth);
826
827        // Close any spaces left open by a deeper, already-walked subtree
828        // before doing anything else with this node. This must run before
829        // the test-subtree prune below so that, when we skip a pruned
830        // node, `state_stack.last_mut()` is the node's true enclosing
831        // space (#722) — not a sibling's still-open function/impl space.
832        if level < last_level {
833            finalize::<T>(&mut state_stack, last_level - level, options);
834            last_level = level;
835        }
836
837        // Bound above the prune because the prune reads it too: Rust's
838        // hook finds the `#[…]` run before an item through the parent,
839        // and `chain` is already correct here — the truncate above is
840        // the only thing that touches it between the pop and this
841        // point (#1100).
842        let ancestors = Ancestors::checked(&chain, &node)
843            .with_siblings(&siblings)
844            .with_folds(&folds);
845
846        // Prune test-only subtrees before any per-metric work runs.
847        // The hook is gated on `exclude_tests` so the default
848        // `metrics()` entry point keeps emitting the pre-#182
849        // numbers byte-for-byte.
850        if options.exclude_tests && prunes_subtree::<T>(&node, code, ancestors, &mut run_verdict) {
851            // `sloc` is span-based, not node-accumulated, so unlike every
852            // other loc sub-metric it does not shrink just because we
853            // skip the subtree. Record the pruned node's rows on the
854            // innermost enclosing func-space so its `sloc` drops in step
855            // (#722); `Sloc::merge` then unions those rows upward so every
856            // enclosing space — including the unit, which feeds MI's SLOC
857            // term — drops too, even when the test item is nested in a
858            // retained `impl`/`trait`/closure (#741). Gated on the `Loc`
859            // selection so deselecting loc keeps the walk's work identical.
860            //
861            // The whole span goes in, shared rows included; the retained
862            // rows of this space are still arriving, so nothing here can
863            // tell a row the prune took from one it only touched.
864            // `Stats::settle_excluded_rows` subtracts the shared ones at
865            // finalization, when the retained sets are complete (#1417).
866            if selected.contains(Metric::Loc)
867                && let Some(state) = state_stack.last_mut()
868            {
869                state
870                    .space
871                    .metrics
872                    .loc
873                    .exclude_test_span(node.start_row(), node.end_line());
874            }
875            // The pruned node was seeded by its parent and is neither
876            // computed nor propagated, so nothing else frees its slot.
877            if selected.contains(Metric::Cognitive) {
878                nesting_map.remove(&node.id());
879            }
880            continue;
881        }
882
883        let func_space = T::Checker::promotes_to_func_space_with_code(&node, code, ancestors);
884
885        let new_level = if func_space {
886            last_level =
887                open_func_space::<T>(&mut state_stack, &node, code, ancestors, level, selected);
888            last_level
889        } else {
890            level
891        };
892
893        // Computed once and reused: suppression needs it for this node,
894        // and the children need it to inherit comment membership (#1052).
895        let is_comment = T::Checker::is_comment(&node);
896
897        // Pin each suppression marker to its innermost enclosing
898        // function space (issue #289); see `apply_comment_suppression`.
899        // Deliberately called before `subtree_in_comment` is bound: the
900        // two are adjacent same-typed `bool`s, and passing the inclusive
901        // one here would re-apply a marker once per descendant leaf.
902        apply_comment_suppression(&mut state_stack, &node, code, diagnostic_path, is_comment);
903
904        let subtree_in_comment = in_comment || is_comment;
905
906        if let Some(state) = state_stack.last_mut() {
907            compute_per_node::<T>(
908                state,
909                &node,
910                code,
911                options,
912                NodeFacts {
913                    func_space,
914                    in_comment: subtree_in_comment,
915                },
916                ancestors,
917                &mut nesting_map,
918            );
919        }
920
921        chain.push(node);
922
923        let pushed = push_children(
924            &mut cursor,
925            &node,
926            Walk {
927                level: new_level,
928                depth: depth + 1,
929                in_comment: subtree_in_comment,
930            },
931            &mut stack,
932        );
933        siblings.record(&node, depth, pushed.iter().rev().map(|(child, _)| *child));
934
935        if selected.contains(Metric::Cognitive) {
936            propagate_nesting_to_children(&node, pushed, &mut nesting_map);
937        }
938    }
939
940    // Every slot is freed by the node that last reads it, so a non-empty
941    // map here means a seeded node was popped without freeing — the
942    // O(nodes) growth #1375 removed, which no metric value can reveal.
943    //
944    // Two guards, covering different ground. The counter is what the
945    // named regression test reads, and it survives into release builds.
946    // The assertion generalises that test to every other walk the suite
947    // runs — twenty languages of corpus fixtures rather than the four
948    // the test names — which is the coverage that matters here, because
949    // the freeing rule now has two owners (this walk's prune arm and
950    // `propagate_nesting_to_children`) and a third `continue` added
951    // later would leak in silence.
952    debug_assert!(
953        nesting_map.is_empty(),
954        "every seeded nesting slot must be freed by the node that last reads it (#1375)"
955    );
956    if !nesting_map.is_empty() {
957        nesting_slots_retained::record();
958    }
959
960    finalize::<T>(&mut state_stack, usize::MAX, options);
961
962    // Reserved error path: `MetricsError::EmptyRoot` is unreachable
963    // today because the synthetic Unit push above (and every
964    // language's translation_unit / module / source_file being a
965    // `func_space`) keeps the state stack non-empty for every input,
966    // including empty / whitespace-only / comment-only sources. The
967    // `ok_or` is retained so a future walker change that legitimately
968    // drains the stack surfaces a distinct error variant rather than
969    // panicking or returning a bare `None`. See `MetricsError::EmptyRoot`
970    // for the matching variant doc.
971    let mut state = state_stack.pop().ok_or(MetricsError::EmptyRoot)?;
972    state.space.name = name;
973    Ok(state.space)
974}
975
976pub(super) fn apply_suppression(state_stack: &mut [State], suppression: &Suppression) {
977    // Both arms ultimately call `merge` on a `FuncSpace::suppressed`;
978    // they differ only in *which* frame on the stack to target.
979    //
980    // - `File`: the topmost `Unit` frame — by construction the root
981    //   `state_stack[0]`, but we match on `SpaceKind::Unit` rather
982    //   than index 0 so the invariant is runtime-checked. The
983    //   synthetic Unit pushed by `metrics_inner` for non-Unit-root
984    //   grammars and every translation-unit/module/source-file being
985    //   a `func_space` keep `state_stack[0]` populated for every
986    //   input; a marker with no Unit frame on the stack would be a
987    //   bug elsewhere and is silently dropped rather than landing on
988    //   an arbitrary frame.
989    // - `Function`: the topmost `SpaceKind::Function` frame — the
990    //   syntactically nearest enclosing function body. Class / struct
991    //   / trait spaces are skipped so a marker at class scope but
992    //   outside any method does not silence thresholds on the entire
993    //   class; authors who want class-wide suppression use `bca:
994    //   suppress-file` or repeat the marker on each method. A marker
995    //   outside every function body finds no `Function` frame and is
996    //   silently dropped — the issue's "no enclosing function" rule.
997    let target = match suppression.kind {
998        SuppressionKind::File => state_stack
999            .iter_mut()
1000            .find(|s| matches!(s.space.kind, SpaceKind::Unit)),
1001        SuppressionKind::Function => state_stack
1002            .iter_mut()
1003            .rev()
1004            .find(|s| matches!(s.space.kind, SpaceKind::Function)),
1005    };
1006    if let Some(state) = target {
1007        state.space.suppressed.merge(&suppression.scope);
1008    }
1009}