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