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 std::hash::BuildHasherDefault;
10
11use super::*;
12use crate::diag::warn;
13
14/// Derives the two metrics that read from a space's *complete* state:
15/// Halstead's `Stats` from the accumulated occurrence maps, and MI from
16/// the resulting volume plus the space's final LOC and cyclomatic.
17///
18/// Both are single-assignment — each call overwrites the previous
19/// result rather than accumulating — so running this before a space has
20/// absorbed all of its children is wasted work, not a partial sum. Only
21/// [`finalize_state`] calls it, once per space (#1106).
22#[inline]
23fn compute_halstead_and_mi<T: ParserTrait>(state: &mut State, selected: MetricSet) {
24 if selected.contains(Metric::Halstead) {
25 state
26 .halstead_maps
27 .finalize(&mut state.space.metrics.halstead);
28 }
29 if selected.contains(Metric::Mi) {
30 // `MetricsOptions::with_only` guarantees Mi's dependencies
31 // (Loc + Cyclomatic + Halstead) are also selected, so the
32 // Stats values feeding into the MI formula here are populated
33 // — not the zero defaults that would silently produce a
34 // garbage MI score.
35 T::Mi::compute(
36 &state.space.metrics.loc,
37 &state.space.metrics.cyclomatic,
38 &state.space.metrics.halstead,
39 &mut state.space.metrics.mi,
40 );
41 }
42}
43
44/// Records the space kind `wmc::Stats::merge` dispatches on, for the
45/// kinds WMC recognises, plus the cumulative cyclomatic those kinds
46/// contribute.
47///
48/// Unlike [`compute_halstead_and_mi`] this must also run on a *parent*
49/// before each child merges into it: `wmc::Stats::merge` routes the
50/// child's contribution on `self.space_kind`, which stays `Unknown`
51/// until this runs, and an `Unknown` parent silently drops every
52/// method's cyclomatic from its class WMC.
53#[inline]
54fn compute_wmc<T: ParserTrait>(state: &mut State, selected: MetricSet) {
55 if selected.contains(Metric::Wmc) {
56 T::Wmc::compute(
57 state.space.kind,
58 &state.space.metrics.cyclomatic,
59 &mut state.space.metrics.wmc,
60 );
61 }
62}
63
64/// Records the space kind that decides whether `npm` / `npa` are
65/// serialized on this space.
66///
67/// Both are emitted only on a member scope — a container, or the file
68/// unit that rolls its containers up — and since #1203 the space's own
69/// kind is the whole of that decision. Doing it here rather than letting
70/// each language raise a flag from its own grammar node kinds is what
71/// makes the rule hold for every language, including one added later:
72/// there is no per-language surface left to deviate on, in either
73/// direction.
74///
75/// `HAS_MEMBERS` is the one exception, and it is a language-level opt
76/// out rather than a per-space one: a grammar with no class-shaped
77/// construct anywhere (C, Bash, Lua, …) would otherwise report an
78/// all-zero block on every file root, since a unit is a member scope
79/// like any other.
80#[inline]
81fn note_member_scope<T: ParserTrait>(state: &mut State, selected: MetricSet) {
82 let kind = state.space.kind;
83 if selected.contains(Metric::Npm) && <T::Npm as Npm>::HAS_MEMBERS {
84 state.space.metrics.npm.set_space_kind(kind);
85 }
86 if selected.contains(Metric::Npa) && <T::Npa as Npa>::HAS_MEMBERS {
87 state.space.metrics.npa.set_space_kind(kind);
88 }
89}
90
91#[inline]
92fn compute_averages(state: &mut State, selected: MetricSet) {
93 // The per-function averages for Cognitive, Exit, and NArgs divide
94 // by counts sourced from `Nom`. `Metric::dependencies` declares
95 // `Nom` as a dependency of all three, so `with_only` pulls it into
96 // any selection that includes them and these divisors reflect the
97 // real function/closure counts. As defense-in-depth, each `average`
98 // accessor additionally guards its divisor with `.max(1)`, so even
99 // a zero divisor degrades to `sum / 1` rather than `inf`/`NaN`
100 // (#428). Compute the divisors once and feed them into each gated
101 // finalize.
102 let nom_functions = state.space.metrics.nom.functions_sum() as usize;
103 let nom_closures = state.space.metrics.nom.closures_sum() as usize;
104 let nom_total = state.space.metrics.nom.total() as usize;
105 // Cognitive average
106 if selected.contains(Metric::Cognitive) {
107 state.space.metrics.cognitive.finalize(nom_total);
108 }
109 // Nexit average
110 if selected.contains(Metric::Nexits) {
111 state.space.metrics.nexits.finalize(nom_total);
112 }
113 // Nargs average
114 if selected.contains(Metric::Nargs) {
115 state
116 .space
117 .metrics
118 .nargs
119 .finalize(nom_functions, nom_closures);
120 }
121}
122
123#[inline]
124fn compute_minmax(state: &mut State, selected: MetricSet) {
125 if selected.contains(Metric::Cyclomatic) {
126 state.space.metrics.cyclomatic.compute_minmax();
127 }
128 if selected.contains(Metric::Nexits) {
129 state.space.metrics.nexits.compute_minmax();
130 }
131 if selected.contains(Metric::Cognitive) {
132 state.space.metrics.cognitive.compute_minmax();
133 }
134 if selected.contains(Metric::Nargs) {
135 state.space.metrics.nargs.compute_minmax();
136 }
137 if selected.contains(Metric::Nom) {
138 state.space.metrics.nom.compute_minmax();
139 }
140 if selected.contains(Metric::Loc) {
141 state.space.metrics.loc.compute_minmax();
142 }
143 if selected.contains(Metric::Abc) {
144 state.space.metrics.abc.compute_minmax();
145 }
146 if selected.contains(Metric::Tokens) {
147 state.space.metrics.tokens.compute_minmax();
148 }
149}
150
151#[inline]
152fn compute_sum(state: &mut State, selected: MetricSet) {
153 if selected.contains(Metric::Wmc) {
154 state.space.metrics.wmc.compute_sum();
155 }
156 if selected.contains(Metric::Npm) {
157 state.space.metrics.npm.compute_sum();
158 }
159 if selected.contains(Metric::Npa) {
160 state.space.metrics.npa.compute_sum();
161 }
162}
163
164/// Runs the per-space finalization passes (min/max, sum, Halstead, MI,
165/// WMC, averages) on a single [`State`]. Shared by both the
166/// single-element and pop arms of [`finalize`] so the call sequence stays
167/// identical in both, and reached exactly once per space — every state is
168/// finalized either when it is popped or, for the root, in the
169/// single-element arm.
170///
171/// [`finalize`]'s pop arm additionally calls [`compute_wmc`] on the
172/// *parent* before each child merges into it, because `wmc::Stats::merge`
173/// dispatches on the parent's recorded `space_kind`. It deliberately does
174/// **not** re-run [`compute_halstead_and_mi`] there: `halstead::Stats` and
175/// `mi::Stats` both have no-op `merge`s, so nothing reads a parent's
176/// intermediate Halstead/MI, and this call overwrites them from the final
177/// maps anyway (#1106).
178fn finalize_state<T: ParserTrait>(state: &mut State, selected: MetricSet) {
179 compute_minmax(state, selected);
180 compute_sum(state, selected);
181 compute_halstead_and_mi::<T>(state, selected);
182 compute_wmc::<T>(state, selected);
183 note_member_scope::<T>(state, selected);
184 compute_averages(state, selected);
185}
186
187fn finalize<T: ParserTrait>(state_stack: &mut Vec<State>, diff_level: usize, selected: MetricSet) {
188 if state_stack.is_empty() {
189 return;
190 }
191 for _ in 0..diff_level {
192 if state_stack.len() == 1 {
193 let last_state = state_stack
194 .last_mut()
195 .expect("invariant: state_stack has exactly one element");
196 finalize_state::<T>(last_state, selected);
197 break;
198 }
199 let mut state = state_stack
200 .pop()
201 .expect("invariant: state_stack has more than one element");
202 finalize_state::<T>(&mut state, selected);
203
204 let last_state = state_stack
205 .last_mut()
206 .expect("invariant: state_stack has remaining elements after pop");
207 last_state.halstead_maps.merge(&state.halstead_maps);
208 compute_wmc::<T>(last_state, selected);
209
210 // Merge function spaces
211 last_state.space.metrics.merge(&state.space.metrics);
212 last_state.space.spaces.push(state.space);
213 }
214}
215
216/// Compute every metric for a [`Source`].
217///
218/// This is the recommended library entry point. It does not conflate
219/// the top-level [`FuncSpace::name`] with a filesystem path: callers
220/// supply an explicit `Source::name` and an optional
221/// `Source::preproc_path` for C++ preprocessor lookup.
222///
223/// `options` controls per-traversal flags (e.g.
224/// `MetricsOptions::default().with_exclude_tests(true)` to elide
225/// Rust `#[test]` / `#[cfg(test)]` subtrees).
226///
227/// # Errors
228///
229/// The return type carries [`MetricsError::EmptyRoot`] for forward
230/// compatibility, but the walker always pushes a synthetic top-level
231/// [`SpaceKind::Unit`][crate::SpaceKind] `FuncSpace` before walking,
232/// so this function does not return `Err` in practice today (see
233/// the variant doc).
234///
235/// # Examples
236///
237/// Analysing an in-memory snippet without constructing a `Path`:
238///
239/// ```
240/// use big_code_analysis::{analyze, MetricsOptions, Source, LANG};
241///
242/// let space = analyze(
243/// Source::new(LANG::Rust, b"fn main() { let x = 1 + 2; }")
244/// .with_name(Some("snippet.rs".to_owned())),
245/// MetricsOptions::default(),
246/// )
247/// .expect("snippet has a top-level FuncSpace");
248/// assert_eq!(space.name.as_deref(), Some("snippet.rs"));
249/// ```
250pub fn analyze(source: Source<'_>, options: MetricsOptions) -> Result<FuncSpace, MetricsError> {
251 Ast::parse(source)?.metrics(options)
252}
253
254/// Per-node classification the walker derives once and the metrics
255/// consume. Bundled rather than passed as loose `bool`s so the
256/// call site cannot transpose them — they are all same-typed flags
257/// about the node currently being visited.
258#[derive(Clone, Copy)]
259struct NodeFacts {
260 /// This node opens a new [`FuncSpace`].
261 func_space: bool,
262 /// Whether this node lies inside a comment subtree — the node
263 /// **itself** or any ancestor is a comment. Contrast
264 /// [`Walk::in_comment`], which covers ancestors only (#1052).
265 in_comment: bool,
266}
267
268// Per-node metric dispatch. Each `compute` call is paired with a bit
269// check against the caller's selection. The bit tests are cheap
270// (single AND-and-compare on the `MetricSet` bitfield) and an
271// unselected metric saves both the call overhead and any per-node
272// text-slice / token-table work the metric does internally — Halstead
273// in particular owns `HalsteadMaps` allocations and is the headline
274// cost saving for `with_only(&[Metric::Loc])`. Extracted from
275// `metrics_inner` so the walker stays under clippy's 100-line ceiling.
276#[inline]
277fn compute_per_node<'a, T: ParserTrait>(
278 state: &mut State<'a>,
279 node: &Node<'a>,
280 code: &'a [u8],
281 options: MetricsOptions,
282 facts: NodeFacts,
283 ancestors: Ancestors<'a, '_>,
284 nesting_map: &mut NestingMap,
285) {
286 let NodeFacts {
287 func_space,
288 in_comment,
289 } = facts;
290 let selected = options.metrics;
291 let last = &mut state.space;
292 if selected.contains(Metric::Cognitive) {
293 T::Cognitive::compute(
294 node,
295 code,
296 ancestors,
297 &mut last.metrics.cognitive,
298 nesting_map,
299 );
300 }
301 if selected.contains(Metric::Cyclomatic) {
302 T::Cyclomatic::compute_with_options(
303 node,
304 code,
305 ancestors,
306 &mut last.metrics.cyclomatic,
307 options.count_cyclomatic_try,
308 );
309 }
310 if selected.contains(Metric::Halstead) {
311 T::Halstead::compute(node, code, ancestors, &mut state.halstead_maps);
312 }
313 if selected.contains(Metric::Loc) {
314 T::Loc::compute(node, ancestors, &mut last.metrics.loc, func_space);
315 }
316 if selected.contains(Metric::Nom) {
317 T::Nom::compute(node, code, ancestors, &mut last.metrics.nom);
318 }
319 if selected.contains(Metric::Tokens) {
320 T::Tokens::compute(node, &mut last.metrics.tokens, in_comment);
321 }
322 if selected.contains(Metric::Nargs) {
323 T::NArgs::compute(node, code, ancestors, &mut last.metrics.nargs);
324 }
325 if selected.contains(Metric::Nexits) {
326 T::Exit::compute(node, code, &mut last.metrics.nexits);
327 }
328 if selected.contains(Metric::Abc) {
329 T::Abc::compute(node, code, ancestors, &mut last.metrics.abc);
330 }
331 if selected.contains(Metric::Npm) {
332 T::Npm::compute(node, code, ancestors, &mut last.metrics.npm);
333 }
334 if selected.contains(Metric::Npa) {
335 T::Npa::compute(node, code, ancestors, &mut last.metrics.npa);
336 }
337}
338
339/// Pushes a synthetic `Unit` root onto the state stack when the grammar
340/// hands us a non-`Unit` root.
341///
342/// Some grammars (e.g. tree-sitter-mozcpp on unparseable input) return a
343/// non-Unit root. Wrapping with a synthetic Unit space spanning the whole
344/// file keeps the top-level `FuncSpace` upholding the LOC invariant
345/// `blank = sloc - ploc - only_comment_lines >= 0`. A `Unit` root needs
346/// no wrapper, so nothing is pushed in that case.
347fn push_synthetic_unit_root<T: ParserTrait>(
348 state_stack: &mut Vec<State>,
349 node: &Node,
350 code: &[u8],
351 selected: MetricSet,
352) {
353 // `Ancestors::unknown()`: `node` is the tree root here, so it has no
354 // ancestors to hand over either way.
355 if T::Getter::get_space_kind_with_code(node, code, Ancestors::unknown()) != SpaceKind::Unit {
356 let mut synthetic = FuncSpace::new::<T::Getter>(
357 node,
358 code,
359 Ancestors::unknown(),
360 SpaceKind::Unit,
361 selected,
362 );
363 synthetic
364 .metrics
365 .loc
366 .init_unit_span(node.start_row(), node.end_line());
367 state_stack.push(State {
368 space: synthetic,
369 halstead_maps: HalsteadMaps::new(),
370 });
371 }
372}
373
374/// Pushes a new [`FuncSpace`] frame for `node` and returns the nesting
375/// level its children inherit.
376///
377/// Only called once the walker has decided `node` opens a space, so the
378/// `SpaceKind` lookup stays off the per-node path. That matters for some
379/// languages — notably Elixir, whose `get_space_kind_with_code` runs a
380/// per-`Call` source-text keyword scan, so it is far from a cheap enum
381/// compare (issue #522; the `Loc` unit flag that used to force it on
382/// every node went away with #1067).
383fn open_func_space<'a, T: ParserTrait>(
384 state_stack: &mut Vec<State<'a>>,
385 node: &Node<'a>,
386 code: &'a [u8],
387 ancestors: Ancestors<'a, '_>,
388 level: usize,
389 selected: MetricSet,
390) -> usize {
391 let kind = T::Getter::get_space_kind_with_code(node, code, ancestors);
392 state_stack.push(State {
393 space: FuncSpace::new::<T::Getter>(node, code, ancestors, kind, selected),
394 halstead_maps: HalsteadMaps::new(),
395 });
396 level + 1
397}
398
399/// Scans a comment node for a suppression marker and applies it against
400/// `state_stack` immediately.
401///
402/// Doing this inline during the walk (rather than queueing markers for a
403/// post-walk pass keyed on line number) pins each marker to the
404/// syntactically nearest enclosing function space — the only frame on the
405/// stack that the grammar nested the comment inside. Line-only matching
406/// was ambiguous when two sibling functions shared a source line and the
407/// first-by-source-order won regardless of which body actually contained
408/// the comment (issue #289).
409///
410/// Every complaint the parse produced is logged, and whatever directive
411/// it still yielded is applied: an unusable metric name costs its own
412/// name and nothing else, while a body that parses to no directive at all
413/// is logged and dropped (issue #1168). The walk never aborts — a typo in
414/// one file must not derail a workspace-wide pass — and dropping stays
415/// the conservative choice, since a marker can only ever lose coverage
416/// this way, never gain it.
417fn apply_comment_suppression(
418 state_stack: &mut Vec<State>,
419 node: &Node,
420 code: &[u8],
421 diagnostic_path: &str,
422 is_comment: bool,
423) {
424 if is_comment && let Some(text) = node.utf8_text(code) {
425 let scan = parse_suppression_marker(text);
426 for diagnostic in &scan.diagnostics {
427 // The `+ 1` converts tree-sitter's 0-based rows to the
428 // 1-based line numbers `FuncSpace::start_line` and the
429 // rest of this module report.
430 warn(format_args!(
431 "{}:{}: {diagnostic}",
432 diagnostic_path,
433 node.start_row() + 1
434 ));
435 }
436 if let Some(suppression) = &scan.suppression {
437 apply_suppression(state_stack, suppression);
438 }
439 }
440}
441
442/// Context carried down the metrics walk alongside each node.
443///
444/// `in_comment` replaces the per-leaf ancestor walk `Tokens::compute` used
445/// to do. That walk was `O(depth)` per leaf and, because `Node::parent`
446/// is itself `O(depth)`, made the metric `O(leaves × depth²)` — a few
447/// kilobytes of deeply nested source burned minutes of CPU (issue #1052).
448/// Propagating the flag down the traversal computes the same predicate in
449/// `O(1)` per node: a node is inside a comment iff its parent was, or the
450/// node itself is a comment.
451#[derive(Clone, Copy)]
452struct Walk {
453 /// Nesting level, used to close func-spaces on the way back up.
454 level: usize,
455 /// AST depth — the number of ancestors this node has, so the root
456 /// sits at `0`.
457 ///
458 /// Distinct from `level`, which only advances at func-space
459 /// boundaries. This one indexes the ancestor chain the walk keeps
460 /// for [`Ancestors`], which is why it has to count every step.
461 depth: usize,
462 /// Whether an **ancestor** of this node is a comment.
463 ///
464 /// Deliberately excludes the node itself — the walk ORs in
465 /// `is_comment(node)` on arrival to get the node's own membership,
466 /// and tags its children with that. Note this differs from
467 /// [`NodeFacts::in_comment`], which *does* include the node: passing
468 /// this field where that one is expected would stop excluding a
469 /// comment's own leaves and reintroduce the #1052 miscount.
470 in_comment: bool,
471}
472
473/// Seeds each child's `nesting_map` slot from `node`'s own, so
474/// `Cognitive` can read the [`Nesting`] it inherits without calling
475/// `Node::parent`.
476///
477/// A node's slot means two different things either side of its
478/// `compute`, and the distinction is load-bearing:
479///
480/// - **on entry** it holds what the node inherits — this is what
481/// `get_nesting_from_map` reads;
482/// - **on exit** each language's `compute` has overwritten it with the
483/// post-increment `Nesting` its children should see — this is what this
484/// function hands down.
485///
486/// So the write at the end of every `Cognitive::compute` must stay at the
487/// end. Moving it to the top would make every descendant inherit the
488/// pre-increment `Nesting` and silently under-count.
489///
490/// `or_insert`, not `insert`: Python's comprehension handling pre-writes
491/// its clause children's slots during the *comprehension's* `compute`
492/// (the #421 fix, so clause nesting does not depend on sibling traversal
493/// order), and those values deliberately differ from the comprehension's
494/// own `Nesting`. A blanket overwrite would clobber them — the
495/// `python_comprehension_*` tests in `metrics::cognitive` are what catch
496/// it.
497///
498/// Grammars whose `Cognitive` impl is the macro's no-op (`Preproc`,
499/// `Ccomment`) never write a slot, so the root lookup misses and the
500/// walk seeds nothing for them at all — no map, no allocation.
501fn propagate_nesting_to_children(
502 node: &Node,
503 children: &[(Node<'_>, Walk)],
504 nesting_map: &mut NestingMap,
505) {
506 // Leaves are roughly half of a real AST, so bail before hashing a key
507 // we would only read to iterate zero children.
508 if children.is_empty() {
509 return;
510 }
511 // A miss here is a *root-only* path. Every non-root slot is created
512 // by this function's `or_insert` below, before that node is ever
513 // popped, so reaching a node with no slot means it had no parent to
514 // seed it. The root's own slot exists iff the root's `compute` wrote
515 // one — which the two no-op grammars never do, so for them the walk
516 // seeds nothing and the map stays empty.
517 //
518 // Note this does *not* depend on every real impl writing on every
519 // path: a real impl that skipped its write would still leave its
520 // children seeded, and would show up as wrong nesting values, not as
521 // a missing slot.
522 let Some(&inherited) = nesting_map.get(&node.id()) else {
523 return;
524 };
525 for (child, _) in children {
526 nesting_map.entry(child.id()).or_insert(inherited);
527 }
528}
529
530/// Pushes `node`'s direct children onto the traversal `stack`, each tagged
531/// with `tag`.
532///
533/// The ordering is load-bearing: pushing in source order and reversing
534/// the freshly-pushed tail makes the LIFO `stack` yield children in
535/// source order, which in turn governs line-shared suppression
536/// attribution (issue #289).
537///
538/// `Tag` is generic because the two walkers carry different context down
539/// the tree: `ops` needs only the nesting level, while `metrics_inner`
540/// also propagates comment membership ([`Walk`], issue #1052).
541///
542/// Returns the children just pushed, as a slice borrowed from `stack` —
543/// empty for a leaf, and in reverse source order like the stack itself.
544///
545/// Borrowing rather than returning indices is what makes "these are
546/// exactly this node's children" a compiler-checked claim: the borrow
547/// forbids touching `stack` while the slice is alive, so the slice
548/// cannot drift from the pushes it describes. Recording `stack.len()`
549/// around the call instead only holds while the two stay adjacent, and
550/// nothing enforces that.
551pub(crate) fn push_children<'a, 's, Tag: Copy>(
552 cursor: &mut Cursor<'a>,
553 node: &Node<'a>,
554 tag: Tag,
555 stack: &'s mut Vec<(Node<'a>, Tag)>,
556) -> &'s [(Node<'a>, Tag)] {
557 // Children go on in source order and the freshly-pushed tail is
558 // reversed in place, so the LIFO `stack` yields the leftmost child
559 // first. Equivalent to the `children.drain(..).rev()` this replaced,
560 // without the caller-threaded scratch buffer, and each child is
561 // copied once rather than twice.
562 let first = stack.len();
563 stack.extend(node.children_with(cursor).map(|child| (child, tag)));
564 stack[first..].reverse();
565 &stack[first..]
566}
567
568pub(crate) fn metrics_inner<T: ParserTrait>(
569 parser: &T,
570 name: Option<String>,
571 options: MetricsOptions,
572) -> Result<FuncSpace, MetricsError> {
573 // bca: suppress(cognitive, abc)
574 // The single AST-walk loop. Per-node work is already factored into
575 // push_synthetic_unit_root / finalize / open_func_space /
576 // compute_per_node / apply_comment_suppression / push_children; the
577 // residual branches each guard a distinct walk invariant
578 // (#182/#289/#522/#722/#1084). There is no cohesive sub-loop left to
579 // lift without inventing a `walk_part2`.
580 // The suppression-warning diagnostic uses the caller-supplied
581 // name when present; otherwise we fall back to a placeholder so
582 // the warning still locates the offending line. All path-based
583 // shims pass a lossy-stringified path here, matching pre-#254
584 // behaviour byte-for-byte.
585 let diagnostic_path = name.as_deref().unwrap_or("<input>");
586 let selected = options.metrics;
587 let code = parser.code();
588 let node = parser.root();
589 let mut cursor = node.cursor();
590 let mut stack = Vec::new();
591 // Ancestor chain of the node currently being visited, root first.
592 // Maintained so per-node predicates can read an ancestor as a slice
593 // index instead of through `Node::parent`, which `tree_sitter`
594 // resolves by descending from the root (#1084).
595 let mut chain: Vec<Node<'_>> = Vec::new();
596 let mut state_stack: Vec<State> = Vec::new();
597 let mut last_level = 0;
598 // Per-node cognitive nesting, inherited down the walk. Deliberately
599 // not pre-seeded with the root: `get_nesting_from_map` already falls
600 // back to `Nesting::default()`, so a seed would change nothing for
601 // grammars that compute cognitive — while for the two whose impl is
602 // the macro's no-op it is the one write that would make the walk
603 // build an entry per node that nothing ever reads.
604 //
605 // Sized up front rather than grown: every real `Cognitive::compute`
606 // ends by writing its own node's slot, so the map converges on one
607 // entry per visited node and a default-capacity map rehashes its way
608 // there a doubling at a time. `descendant_count` is that final size,
609 // known in O(1) — an upper bound rather than an exact one only when
610 // `exclude_tests` prunes a subtree the walk never descends into.
611 //
612 // Both guards exist to keep an empty map unallocated: an unselected
613 // `Cognitive` never calls `compute` at all, and the two grammars
614 // whose impl is the macro's no-op (`Preproc`, `Ccomment`) report
615 // `SEEDS_NESTING = false` because they write no slot.
616 let mut nesting_map = if selected.contains(Metric::Cognitive)
617 && <T::Cognitive as Cognitive>::SEEDS_NESTING
618 {
619 NestingMap::with_capacity_and_hasher(node.descendant_count(), BuildHasherDefault::default())
620 } else {
621 NestingMap::default()
622 };
623
624 // Suppression markers are resolved inline during the walk rather
625 // than queued for a post-finalize pass. When we visit a comment
626 // node, the active `state_stack` already encodes the comment's
627 // syntactic context: the topmost `SpaceKind::Function` entry is
628 // the *innermost enclosing function* by construction, with no
629 // ambiguity when sibling functions share a source line (issue
630 // #289). The root `Unit` state — always at index 0 once the walk
631 // has visited the AST root — owns file-scoped markers.
632
633 push_synthetic_unit_root::<T>(&mut state_stack, &node, code, selected);
634
635 stack.push((
636 node,
637 Walk {
638 level: 0,
639 depth: 0,
640 in_comment: false,
641 },
642 ));
643
644 while let Some((
645 node,
646 Walk {
647 level,
648 depth,
649 in_comment,
650 },
651 )) = stack.pop()
652 {
653 // The ancestors of the node about to be visited, root first.
654 // Pre-order guarantees every one of them has already been
655 // visited and appended, so truncating to `depth` drops the
656 // sibling subtree we just finished and leaves exactly this
657 // node's chain (#1084). Correcting it here rather than on the
658 // way out also keeps it right across the `continue` below.
659 chain.truncate(depth);
660
661 // Close any spaces left open by a deeper, already-walked subtree
662 // before doing anything else with this node. This must run before
663 // the test-subtree prune below so that, when we skip a pruned
664 // node, `state_stack.last_mut()` is the node's true enclosing
665 // space (#722) — not a sibling's still-open function/impl space.
666 if level < last_level {
667 finalize::<T>(&mut state_stack, last_level - level, selected);
668 last_level = level;
669 }
670
671 // Bound above the prune because the prune reads it too: Rust's
672 // hook finds the `#[…]` run before an item through the parent,
673 // and `chain` is already correct here — the truncate above is
674 // the only thing that touches it between the pop and this
675 // point (#1100).
676 let ancestors = Ancestors::checked(&chain, &node);
677
678 // Prune test-only subtrees before any per-metric work runs.
679 // The hook is gated on `exclude_tests` so the default
680 // `metrics()` entry point keeps emitting the pre-#182
681 // numbers byte-for-byte.
682 if options.exclude_tests && T::Checker::should_skip_subtree(&node, code, ancestors) {
683 // `sloc` is span-based, not node-accumulated, so unlike every
684 // other loc sub-metric it does not shrink just because we
685 // skip the subtree. Record the pruned node's row span on the
686 // innermost enclosing func-space so its `sloc` drops in step
687 // (#722); `Sloc::merge` then folds that count upward so every
688 // enclosing space — including the unit, which feeds MI's SLOC
689 // term — drops too, even when the test item is nested in a
690 // retained `impl`/`trait`/closure (#741). Gated on the `Loc`
691 // selection so deselecting loc keeps the walk's work identical.
692 if selected.contains(Metric::Loc)
693 && let Some(state) = state_stack.last_mut()
694 {
695 state
696 .space
697 .metrics
698 .loc
699 .exclude_test_span(node.start_row(), node.end_line());
700 }
701 continue;
702 }
703
704 let func_space = T::Checker::promotes_to_func_space_with_code(&node, code, ancestors);
705
706 let new_level = if func_space {
707 last_level =
708 open_func_space::<T>(&mut state_stack, &node, code, ancestors, level, selected);
709 last_level
710 } else {
711 level
712 };
713
714 // Computed once and reused: suppression needs it for this node,
715 // and the children need it to inherit comment membership (#1052).
716 let is_comment = T::Checker::is_comment(&node);
717
718 // Pin each suppression marker to its innermost enclosing
719 // function space (issue #289); see `apply_comment_suppression`.
720 // Deliberately called before `subtree_in_comment` is bound: the
721 // two are adjacent same-typed `bool`s, and passing the inclusive
722 // one here would re-apply a marker once per descendant leaf.
723 apply_comment_suppression(&mut state_stack, &node, code, diagnostic_path, is_comment);
724
725 let subtree_in_comment = in_comment || is_comment;
726
727 if let Some(state) = state_stack.last_mut() {
728 compute_per_node::<T>(
729 state,
730 &node,
731 code,
732 options,
733 NodeFacts {
734 func_space,
735 in_comment: subtree_in_comment,
736 },
737 ancestors,
738 &mut nesting_map,
739 );
740 }
741
742 chain.push(node);
743
744 let pushed = push_children(
745 &mut cursor,
746 &node,
747 Walk {
748 level: new_level,
749 depth: depth + 1,
750 in_comment: subtree_in_comment,
751 },
752 &mut stack,
753 );
754
755 if selected.contains(Metric::Cognitive) {
756 propagate_nesting_to_children(&node, pushed, &mut nesting_map);
757 }
758 }
759
760 finalize::<T>(&mut state_stack, usize::MAX, selected);
761
762 // Reserved error path: `MetricsError::EmptyRoot` is unreachable
763 // today because the synthetic Unit push above (and every
764 // language's translation_unit / module / source_file being a
765 // `func_space`) keeps the state stack non-empty for every input,
766 // including empty / whitespace-only / comment-only sources. The
767 // `ok_or` is retained so a future walker change that legitimately
768 // drains the stack surfaces a distinct error variant rather than
769 // panicking or returning a bare `None`. See `MetricsError::EmptyRoot`
770 // for the matching variant doc.
771 let mut state = state_stack.pop().ok_or(MetricsError::EmptyRoot)?;
772 state.space.name = name;
773 Ok(state.space)
774}
775
776pub(super) fn apply_suppression(state_stack: &mut [State], suppression: &Suppression) {
777 // Both arms ultimately call `merge` on a `FuncSpace::suppressed`;
778 // they differ only in *which* frame on the stack to target.
779 //
780 // - `File`: the topmost `Unit` frame — by construction the root
781 // `state_stack[0]`, but we match on `SpaceKind::Unit` rather
782 // than index 0 so the invariant is runtime-checked. The
783 // synthetic Unit pushed by `metrics_inner` for non-Unit-root
784 // grammars and every translation-unit/module/source-file being
785 // a `func_space` keep `state_stack[0]` populated for every
786 // input; a marker with no Unit frame on the stack would be a
787 // bug elsewhere and is silently dropped rather than landing on
788 // an arbitrary frame.
789 // - `Function`: the topmost `SpaceKind::Function` frame — the
790 // syntactically nearest enclosing function body. Class / struct
791 // / trait spaces are skipped so a marker at class scope but
792 // outside any method does not silence thresholds on the entire
793 // class; authors who want class-wide suppression use `bca:
794 // suppress-file` or repeat the marker on each method. A marker
795 // outside every function body finds no `Function` frame and is
796 // silently dropped — the issue's "no enclosing function" rule.
797 let target = match suppression.kind {
798 SuppressionKind::File => state_stack
799 .iter_mut()
800 .find(|s| matches!(s.space.kind, SpaceKind::Unit)),
801 SuppressionKind::Function => state_stack
802 .iter_mut()
803 .rev()
804 .find(|s| matches!(s.space.kind, SpaceKind::Function)),
805 };
806 if let Some(state) = target {
807 state.space.suppressed.merge(&suppression.scope);
808 }
809}