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