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}