Skip to main content

big_code_analysis/spaces/
compute.rs

1//! Metric-computation and AST-traversal internals for [`super::analyze`].
2//!
3//! These free functions were split out of `spaces.rs` to keep that
4//! module focused on the public API types (`SpaceKind`, `CodeMetrics`,
5//! `FuncSpace`, `Source`, `Ast`, `MetricsOptions`). They are moved
6//! verbatim and re-exported from the parent so the public path
7//! `crate::spaces::analyze` (and `pub(crate) metrics_inner`) is preserved.
8
9use super::*;
10
11#[inline]
12fn compute_halstead_mi_and_wmc<T: ParserTrait>(state: &mut State, selected: MetricSet) {
13    if selected.contains(Metric::Halstead) {
14        state
15            .halstead_maps
16            .finalize(&mut state.space.metrics.halstead);
17    }
18    if selected.contains(Metric::Mi) {
19        // `MetricsOptions::with_only` guarantees Mi's dependencies
20        // (Loc + Cyclomatic + Halstead) are also selected, so the
21        // Stats values feeding into the MI formula here are populated
22        // — not the zero defaults that would silently produce a
23        // garbage MI score.
24        T::Mi::compute(
25            &state.space.metrics.loc,
26            &state.space.metrics.cyclomatic,
27            &state.space.metrics.halstead,
28            &mut state.space.metrics.mi,
29        );
30    }
31    if selected.contains(Metric::Wmc) {
32        T::Wmc::compute(
33            state.space.kind,
34            &state.space.metrics.cyclomatic,
35            &mut state.space.metrics.wmc,
36        );
37    }
38}
39
40#[inline]
41fn compute_averages(state: &mut State, selected: MetricSet) {
42    // The per-function averages for Cognitive, Exit, and NArgs divide
43    // by counts sourced from `Nom`. `Metric::dependencies` declares
44    // `Nom` as a dependency of all three, so `with_only` pulls it into
45    // any selection that includes them and these divisors reflect the
46    // real function/closure counts. As defense-in-depth, each `average`
47    // accessor additionally guards its divisor with `.max(1)`, so even
48    // a zero divisor degrades to `sum / 1` rather than `inf`/`NaN`
49    // (#428). Compute the divisors once and feed them into each gated
50    // finalize.
51    let nom_functions = state.space.metrics.nom.functions_sum() as usize;
52    let nom_closures = state.space.metrics.nom.closures_sum() as usize;
53    let nom_total = state.space.metrics.nom.total() as usize;
54    // Cognitive average
55    if selected.contains(Metric::Cognitive) {
56        state.space.metrics.cognitive.finalize(nom_total);
57    }
58    // Nexit average
59    if selected.contains(Metric::Nexits) {
60        state.space.metrics.nexits.finalize(nom_total);
61    }
62    // Nargs average
63    if selected.contains(Metric::Nargs) {
64        state
65            .space
66            .metrics
67            .nargs
68            .finalize(nom_functions, nom_closures);
69    }
70}
71
72#[inline]
73fn compute_minmax(state: &mut State, selected: MetricSet) {
74    if selected.contains(Metric::Cyclomatic) {
75        state.space.metrics.cyclomatic.compute_minmax();
76    }
77    if selected.contains(Metric::Nexits) {
78        state.space.metrics.nexits.compute_minmax();
79    }
80    if selected.contains(Metric::Cognitive) {
81        state.space.metrics.cognitive.compute_minmax();
82    }
83    if selected.contains(Metric::Nargs) {
84        state.space.metrics.nargs.compute_minmax();
85    }
86    if selected.contains(Metric::Nom) {
87        state.space.metrics.nom.compute_minmax();
88    }
89    if selected.contains(Metric::Loc) {
90        state.space.metrics.loc.compute_minmax();
91    }
92    if selected.contains(Metric::Abc) {
93        state.space.metrics.abc.compute_minmax();
94    }
95    if selected.contains(Metric::Tokens) {
96        state.space.metrics.tokens.compute_minmax();
97    }
98}
99
100#[inline]
101fn compute_sum(state: &mut State, selected: MetricSet) {
102    if selected.contains(Metric::Wmc) {
103        state.space.metrics.wmc.compute_sum();
104    }
105    if selected.contains(Metric::Npm) {
106        state.space.metrics.npm.compute_sum();
107    }
108    if selected.contains(Metric::Npa) {
109        state.space.metrics.npa.compute_sum();
110    }
111}
112
113/// Runs the four per-space finalization passes (min/max, sum, Halstead +
114/// MI + WMC, averages) on a single [`State`]. Shared by both the
115/// single-element and pop arms of [`finalize`] so the call sequence stays
116/// identical in both. The pop arm performs an *additional* Halstead
117/// recompute on the parent after merging the child's maps — that extra
118/// pass is intentionally left in [`finalize`], not folded in here.
119fn finalize_state<T: ParserTrait>(state: &mut State, selected: MetricSet) {
120    compute_minmax(state, selected);
121    compute_sum(state, selected);
122    compute_halstead_mi_and_wmc::<T>(state, selected);
123    compute_averages(state, selected);
124}
125
126fn finalize<T: ParserTrait>(state_stack: &mut Vec<State>, diff_level: usize, selected: MetricSet) {
127    if state_stack.is_empty() {
128        return;
129    }
130    for _ in 0..diff_level {
131        if state_stack.len() == 1 {
132            let last_state = state_stack
133                .last_mut()
134                .expect("invariant: state_stack has exactly one element");
135            finalize_state::<T>(last_state, selected);
136            break;
137        }
138        let mut state = state_stack
139            .pop()
140            .expect("invariant: state_stack has more than one element");
141        finalize_state::<T>(&mut state, selected);
142
143        let last_state = state_stack
144            .last_mut()
145            .expect("invariant: state_stack has remaining elements after pop");
146        last_state.halstead_maps.merge(&state.halstead_maps);
147        compute_halstead_mi_and_wmc::<T>(last_state, selected);
148
149        // Merge function spaces
150        last_state.space.metrics.merge(&state.space.metrics);
151        last_state.space.spaces.push(state.space);
152    }
153}
154
155/// Compute every metric for a [`Source`].
156///
157/// This is the recommended library entry point. It does not conflate
158/// the top-level [`FuncSpace::name`] with a filesystem path: callers
159/// supply an explicit `Source::name` and an optional
160/// `Source::preproc_path` for C++ preprocessor lookup.
161///
162/// `options` controls per-traversal flags (e.g.
163/// `MetricsOptions::default().with_exclude_tests(true)` to elide
164/// Rust `#[test]` / `#[cfg(test)]` subtrees).
165///
166/// # Errors
167///
168/// The return type carries [`MetricsError::EmptyRoot`] for forward
169/// compatibility, but the walker always pushes a synthetic top-level
170/// [`SpaceKind::Unit`][crate::SpaceKind] `FuncSpace` before walking,
171/// so this function does not return `Err` in practice today (see
172/// the variant doc).
173///
174/// # Examples
175///
176/// Analysing an in-memory snippet without constructing a `Path`:
177///
178/// ```
179/// use big_code_analysis::{analyze, MetricsOptions, Source, LANG};
180///
181/// let space = analyze(
182///     Source::new(LANG::Rust, b"fn main() { let x = 1 + 2; }")
183///         .with_name(Some("snippet.rs".to_owned())),
184///     MetricsOptions::default(),
185/// )
186/// .expect("snippet has a top-level FuncSpace");
187/// assert_eq!(space.name.as_deref(), Some("snippet.rs"));
188/// ```
189pub fn analyze(source: Source<'_>, options: MetricsOptions) -> Result<FuncSpace, MetricsError> {
190    Ast::parse(source)?.metrics(options)
191}
192
193// Per-node metric dispatch. Each `compute` call is paired with a bit
194// check against the caller's selection. The bit tests are cheap
195// (single AND-and-compare on the `MetricSet` bitfield) and an
196// unselected metric saves both the call overhead and any per-node
197// text-slice / token-table work the metric does internally — Halstead
198// in particular owns `HalsteadMaps` allocations and is the headline
199// cost saving for `with_only(&[Metric::Loc])`. Extracted from
200// `metrics_inner` so the walker stays under clippy's 100-line ceiling.
201#[inline]
202fn compute_per_node<'a, T: ParserTrait>(
203    state: &mut State<'a>,
204    node: &Node<'a>,
205    code: &'a [u8],
206    options: MetricsOptions,
207    func_space: bool,
208    unit: bool,
209    nesting_map: &mut HashMap<usize, (usize, usize, usize)>,
210) {
211    let selected = options.metrics;
212    let last = &mut state.space;
213    if selected.contains(Metric::Cognitive) {
214        T::Cognitive::compute(node, code, &mut last.metrics.cognitive, nesting_map);
215    }
216    if selected.contains(Metric::Cyclomatic) {
217        T::Cyclomatic::compute_with_options(
218            node,
219            code,
220            &mut last.metrics.cyclomatic,
221            options.count_cyclomatic_try,
222        );
223    }
224    if selected.contains(Metric::Halstead) {
225        T::Halstead::compute(node, code, &mut state.halstead_maps);
226    }
227    if selected.contains(Metric::Loc) {
228        T::Loc::compute(node, &mut last.metrics.loc, func_space, unit);
229    }
230    if selected.contains(Metric::Nom) {
231        T::Nom::compute(node, code, &mut last.metrics.nom);
232    }
233    if selected.contains(Metric::Tokens) {
234        T::Tokens::compute(node, &mut last.metrics.tokens);
235    }
236    if selected.contains(Metric::Nargs) {
237        T::NArgs::compute(node, &mut last.metrics.nargs);
238    }
239    if selected.contains(Metric::Nexits) {
240        T::Exit::compute(node, code, &mut last.metrics.nexits);
241    }
242    if selected.contains(Metric::Abc) {
243        T::Abc::compute(node, code, &mut last.metrics.abc);
244    }
245    if selected.contains(Metric::Npm) {
246        T::Npm::compute(node, code, &mut last.metrics.npm);
247    }
248    if selected.contains(Metric::Npa) {
249        T::Npa::compute(node, code, &mut last.metrics.npa);
250    }
251}
252
253/// Pushes a synthetic `Unit` root onto the state stack when the grammar
254/// hands us a non-`Unit` root.
255///
256/// Some grammars (e.g. tree-sitter-mozcpp on unparseable input) return a
257/// non-Unit root. Wrapping with a synthetic Unit space spanning the whole
258/// file keeps the top-level `FuncSpace` upholding the LOC invariant
259/// `blank = sloc - ploc - only_comment_lines >= 0`. A `Unit` root needs
260/// no wrapper, so nothing is pushed in that case.
261fn push_synthetic_unit_root<T: ParserTrait>(
262    state_stack: &mut Vec<State>,
263    node: &Node,
264    code: &[u8],
265    selected: MetricSet,
266) {
267    if T::Getter::get_space_kind_with_code(node, code) != SpaceKind::Unit {
268        let mut synthetic = FuncSpace::new::<T::Getter>(node, code, SpaceKind::Unit, selected);
269        synthetic
270            .metrics
271            .loc
272            .init_unit_span(node.start_row(), node.end_row());
273        state_stack.push(State {
274            space: synthetic,
275            halstead_maps: HalsteadMaps::new(),
276        });
277    }
278}
279
280/// Scans a comment node for a suppression marker and applies it against
281/// `state_stack` immediately.
282///
283/// Doing this inline during the walk (rather than queueing markers for a
284/// post-walk pass keyed on line number) pins each marker to the
285/// syntactically nearest enclosing function space — the only frame on the
286/// stack that the grammar nested the comment inside. Line-only matching
287/// was ambiguous when two sibling functions shared a source line and the
288/// first-by-source-order won regardless of which body actually contained
289/// the comment (issue #289).
290///
291/// A malformed marker is logged and dropped (no scope attached) rather
292/// than aborting the walk: a typo in one file must not derail a
293/// workspace-wide pass, and dropping is the conservative choice — a typo
294/// should not accidentally silence anything.
295fn apply_comment_suppression<T: ParserTrait>(
296    state_stack: &mut Vec<State>,
297    node: &Node,
298    code: &[u8],
299    diagnostic_path: &str,
300) {
301    if T::Checker::is_comment(node)
302        && let Some(text) = node.utf8_text(code)
303    {
304        match parse_suppression_marker(text) {
305            Ok(Some(s)) => apply_suppression(state_stack, &s),
306            Ok(None) => {}
307            Err(e) => {
308                // The `+ 1` converts tree-sitter's 0-based rows to the
309                // 1-based line numbers `FuncSpace::start_line` and the
310                // rest of this module report.
311                eprintln!("warning: {}:{}: {e}", diagnostic_path, node.start_row() + 1);
312            }
313        }
314    }
315}
316
317/// Pushes `node`'s direct children onto the traversal `stack`, each tagged
318/// with `new_level`.
319///
320/// The `children.drain(..).rev()` ordering is load-bearing: it makes the
321/// LIFO `stack` yield children in source order, which in turn governs
322/// line-shared suppression attribution (issue #289). The `children`
323/// scratch buffer is drained empty here so callers can reuse its
324/// allocation across iterations.
325pub(crate) fn push_children<'a>(
326    cursor: &mut Cursor<'a>,
327    node: &Node<'a>,
328    new_level: usize,
329    children: &mut Vec<(Node<'a>, usize)>,
330    stack: &mut Vec<(Node<'a>, usize)>,
331) {
332    cursor.reset(node);
333    if cursor.goto_first_child() {
334        loop {
335            children.push((cursor.node(), new_level));
336            if !cursor.goto_next_sibling() {
337                break;
338            }
339        }
340        for child in children.drain(..).rev() {
341            stack.push(child);
342        }
343    }
344}
345
346pub(crate) fn metrics_inner<T: ParserTrait>(
347    parser: &T,
348    name: Option<String>,
349    options: MetricsOptions,
350) -> Result<FuncSpace, MetricsError> {
351    // bca: suppress(cognitive, abc)
352    // The single AST-walk loop. Per-node work is already factored into
353    // push_synthetic_unit_root / finalize / compute_per_node /
354    // apply_comment_suppression / push_children; the residual branches
355    // each guard a distinct walk invariant (#182/#289/#522/#722). There
356    // is no cohesive sub-loop to lift without inventing a `walk_part2`.
357    // The suppression-warning diagnostic uses the caller-supplied
358    // name when present; otherwise we fall back to a placeholder so
359    // the warning still locates the offending line. All path-based
360    // shims pass a lossy-stringified path here, matching pre-#254
361    // behaviour byte-for-byte.
362    let diagnostic_path = name.as_deref().unwrap_or("<input>");
363    let selected = options.metrics;
364    let code = parser.code();
365    let node = parser.root();
366    let mut cursor = node.cursor();
367    let mut stack = Vec::new();
368    let mut children = Vec::new();
369    let mut state_stack: Vec<State> = Vec::new();
370    let mut last_level = 0;
371    // Initialize nesting_map used for storing nesting information for cognitive
372    // Three type of nesting info: conditionals, functions and lambdas
373    let mut nesting_map = HashMap::<usize, (usize, usize, usize)>::default();
374    nesting_map.insert(node.id(), (0, 0, 0));
375
376    // Suppression markers are resolved inline during the walk rather
377    // than queued for a post-finalize pass. When we visit a comment
378    // node, the active `state_stack` already encodes the comment's
379    // syntactic context: the topmost `SpaceKind::Function` entry is
380    // the *innermost enclosing function* by construction, with no
381    // ambiguity when sibling functions share a source line (issue
382    // #289). The root `Unit` state — always at index 0 once the walk
383    // has visited the AST root — owns file-scoped markers.
384
385    push_synthetic_unit_root::<T>(&mut state_stack, &node, code, selected);
386
387    stack.push((node, 0));
388
389    while let Some((node, level)) = stack.pop() {
390        // Close any spaces left open by a deeper, already-walked subtree
391        // before doing anything else with this node. This must run before
392        // the test-subtree prune below so that, when we skip a pruned
393        // node, `state_stack.last_mut()` is the node's true enclosing
394        // space (#722) — not a sibling's still-open function/impl space.
395        if level < last_level {
396            finalize::<T>(&mut state_stack, last_level - level, selected);
397            last_level = level;
398        }
399
400        // Prune test-only subtrees before any per-metric work runs.
401        // The hook is gated on `exclude_tests` so the default
402        // `metrics()` entry point keeps emitting the pre-#182
403        // numbers byte-for-byte.
404        if options.exclude_tests && T::Checker::should_skip_subtree(&node, code) {
405            // `sloc` is span-based, not node-accumulated, so unlike every
406            // other loc sub-metric it does not shrink just because we
407            // skip the subtree. Record the pruned node's row span on the
408            // innermost enclosing func-space so its `sloc` drops in step
409            // (#722); `Sloc::merge` then folds that count upward so every
410            // enclosing space — including the unit, which feeds MI's SLOC
411            // term — drops too, even when the test item is nested in a
412            // retained `impl`/`trait`/closure (#741). Gated on the `Loc`
413            // selection so deselecting loc keeps the walk's work identical.
414            if selected.contains(Metric::Loc)
415                && let Some(state) = state_stack.last_mut()
416            {
417                state
418                    .space
419                    .metrics
420                    .loc
421                    .exclude_test_span(node.start_row(), node.end_row());
422            }
423            continue;
424        }
425
426        let func_space = T::Checker::promotes_to_func_space_with_code(&node, code);
427
428        // `kind` is consumed in exactly two places: `FuncSpace::new`
429        // (only when `func_space` is true) and the `unit` flag, which
430        // flows solely into `Loc::compute` (only when `Loc` is
431        // selected). For some languages — notably Elixir, whose
432        // `get_space_kind_with_code` runs a per-`Call` source-text
433        // keyword scan — this lookup is far from a cheap enum compare,
434        // so we skip it entirely when neither consumer is active.
435        // When it IS computed it returns the same value as before, so
436        // both consumers observe byte-identical results (issue #522).
437        let kind = if func_space || selected.contains(Metric::Loc) {
438            T::Getter::get_space_kind_with_code(&node, code)
439        } else {
440            // Unused on this path: `func_space` is false (so
441            // `FuncSpace::new` is not called) and `Loc` is deselected
442            // (so the `unit` flag below is never read by `Loc::compute`).
443            SpaceKind::Unknown
444        };
445        let unit = kind == SpaceKind::Unit;
446
447        let new_level = if func_space {
448            let state = State {
449                space: FuncSpace::new::<T::Getter>(&node, code, kind, selected),
450                halstead_maps: HalsteadMaps::new(),
451            };
452            state_stack.push(state);
453            last_level = level + 1;
454            last_level
455        } else {
456            level
457        };
458
459        // Pin each suppression marker to its innermost enclosing
460        // function space (issue #289); see `apply_comment_suppression`.
461        apply_comment_suppression::<T>(&mut state_stack, &node, code, diagnostic_path);
462
463        if let Some(state) = state_stack.last_mut() {
464            compute_per_node::<T>(
465                state,
466                &node,
467                code,
468                options,
469                func_space,
470                unit,
471                &mut nesting_map,
472            );
473        }
474
475        push_children(&mut cursor, &node, new_level, &mut children, &mut stack);
476    }
477
478    finalize::<T>(&mut state_stack, usize::MAX, selected);
479
480    // Reserved error path: `MetricsError::EmptyRoot` is unreachable
481    // today because the synthetic Unit push above (and every
482    // language's translation_unit / module / source_file being a
483    // `func_space`) keeps the state stack non-empty for every input,
484    // including empty / whitespace-only / comment-only sources. The
485    // `ok_or` is retained so a future walker change that legitimately
486    // drains the stack surfaces a distinct error variant rather than
487    // panicking or returning a bare `None`. See `MetricsError::EmptyRoot`
488    // for the matching variant doc.
489    let mut state = state_stack.pop().ok_or(MetricsError::EmptyRoot)?;
490    state.space.name = name;
491    Ok(state.space)
492}
493
494pub(super) fn apply_suppression(state_stack: &mut [State], suppression: &Suppression) {
495    // Both arms ultimately call `merge` on a `FuncSpace::suppressed`;
496    // they differ only in *which* frame on the stack to target.
497    //
498    // - `File`: the topmost `Unit` frame — by construction the root
499    //   `state_stack[0]`, but we match on `SpaceKind::Unit` rather
500    //   than index 0 so the invariant is runtime-checked. The
501    //   synthetic Unit pushed by `metrics_inner` for non-Unit-root
502    //   grammars and every translation-unit/module/source-file being
503    //   a `func_space` keep `state_stack[0]` populated for every
504    //   input; a marker with no Unit frame on the stack would be a
505    //   bug elsewhere and is silently dropped rather than landing on
506    //   an arbitrary frame.
507    // - `Function`: the topmost `SpaceKind::Function` frame — the
508    //   syntactically nearest enclosing function body. Class / struct
509    //   / trait spaces are skipped so a marker at class scope but
510    //   outside any method does not silence thresholds on the entire
511    //   class; authors who want class-wide suppression use `bca:
512    //   suppress-file` or repeat the marker on each method. A marker
513    //   outside every function body finds no `Function` frame and is
514    //   silently dropped — the issue's "no enclosing function" rule.
515    let target = match suppression.kind {
516        SuppressionKind::File => state_stack
517            .iter_mut()
518            .find(|s| matches!(s.space.kind, SpaceKind::Unit)),
519        SuppressionKind::Function => state_stack
520            .iter_mut()
521            .rev()
522            .find(|s| matches!(s.space.kind, SpaceKind::Function)),
523    };
524    if let Some(state) = target {
525        state.space.suppressed.merge(&suppression.scope);
526    }
527}