big_code_analysis/spaces.rs
1// Per-language metric and AST modules deliberately consume the macro-
2// generated tree-sitter token enums via `use crate::*` and `use Foo::*`
3// inside match expressions — explicit imports would list dozens of
4// variants per arm and obscure the per-language token sets that are the
5// point of these files. Allowed at the module level rather than per
6// function so the per-language impl blocks stay readable.
7#![allow(clippy::wildcard_imports, clippy::enum_glob_use)]
8// Metric counts (token, function, branch, argument, etc.) are stored as
9// `usize` and crossed with `f64` averages, ratios, and Halstead scores
10// across the cyclomatic / MI / Halstead computations. The `usize as f64`
11// and `f64 as usize` casts are intentional and snapshot-anchored — every
12// site is bounded by the count it came from. Allowing the lints at the
13// module level keeps the metric arithmetic legible.
14#![allow(
15 clippy::cast_precision_loss,
16 clippy::cast_possible_truncation,
17 clippy::cast_sign_loss
18)]
19
20use std::borrow::Cow;
21
22use std::fmt;
23use std::path::Path;
24use std::sync::Arc;
25
26use crate::int_hash::IntKeyHashMap;
27
28/// Per-node nesting state that `Cognitive` inherits down the walk.
29///
30/// A struct rather than a `(usize, usize, usize)` because the three are
31/// same-typed and summed symmetrically (`conditional + function_depth +
32/// lambda`), so a transposition is invisible at the point of use and
33/// surfaces only in the arms that read one field alone. Naming them
34/// makes the boundary — where the walk reads and writes this — the
35/// place a mix-up is visible.
36#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
37pub(crate) struct Nesting {
38 /// Structural nesting from enclosing control constructs.
39 pub(crate) conditional: usize,
40 /// Count of enclosing functions.
41 pub(crate) function_depth: usize,
42 /// Count of enclosing lambdas / closures.
43 pub(crate) lambda: usize,
44}
45
46impl Nesting {
47 /// The effective nesting level a construct at this state pays for:
48 /// the sum of all three channels.
49 ///
50 /// SonarSource's Cognitive Complexity raises the nesting level for
51 /// enclosing control constructs (`conditional`) *and* for nested
52 /// methods and method-like structures such as lambdas, so
53 /// `function_depth` (#696) and `lambda` are spec nesting increments
54 /// too — tracked apart only because the walk resets them at
55 /// different boundaries. All three add on equal footing.
56 pub(crate) const fn total(self) -> usize {
57 self.conditional + self.function_depth + self.lambda
58 }
59}
60
61/// Node-id keyed [`Nesting`] state; see
62/// `spaces::compute::propagate_nesting_to_children`.
63pub(crate) type NestingMap = IntKeyHashMap<usize, Nesting>;
64
65use crate::langs::LANG;
66use crate::metric_set::{Metric, MetricSet};
67use crate::preproc::PreprocResults;
68
69use crate::checker::Checker;
70use crate::error::MetricsError;
71use crate::node::{Ancestors, Cursor, Node};
72use crate::suppression::{
73 Suppression, SuppressionKind, SuppressionScope, parse_marker as parse_suppression_marker,
74};
75
76use crate::abc::{self, Abc};
77use crate::cognitive::{self, Cognitive};
78use crate::cyclomatic::{self, Cyclomatic};
79use crate::getter::Getter;
80use crate::halstead::{self, Halstead, HalsteadMaps};
81use crate::loc::{self, Loc};
82use crate::mi::{self, Mi};
83use crate::nargs::{self, NArgs};
84use crate::nexits::{self, Exit};
85use crate::nom::{self, Nom};
86use crate::npa::{self, Npa};
87use crate::npm::{self, Npm};
88use crate::tokens::{self, Tokens};
89use crate::wmc::{self, Wmc};
90
91mod compute;
92
93// Inherent / trait impl blocks for the public types defined below live
94// in per-type child modules to keep `spaces.rs` under its size budget.
95// Method and trait resolution is by type, not module path, so every
96// public path (`crate::spaces::Ast::parse`, etc.) is preserved.
97mod ast;
98mod code_metrics;
99mod options;
100mod source;
101
102// `analyze` is `pub` — re-exported from `lib.rs`, so it must stay
103// reachable at `crate::spaces::analyze`.
104pub use compute::analyze;
105// `SpaceKind` is defined beside `Getter` (whose `get_space_kind` returns
106// it) and re-exported here so `crate::spaces::SpaceKind` keeps resolving.
107pub use crate::space_kind::SpaceKind;
108// `metrics_inner` and `push_children` are `pub(crate)` — `metrics_inner`
109// is called from `crate::spaces::ast` (through `with_any_parser!`),
110// `crate::test_support` and `crate::spaces_tests`, and `push_children`
111// is consumed by `crate::ops`, so both must stay reachable at their
112// `crate::spaces::` paths. (Before #1376 `metrics_inner` was also
113// re-exported from `lib.rs` for the feature-gated `mk_action!` arms;
114// that re-export is gone with the arms.)
115pub(crate) use compute::{metrics_inner, push_children};
116// The inline `mod tests` drives `apply_suppression` via
117// `super::apply_suppression`; re-import the name into this module
118// (test-only, so it does not warn as unused in production builds) to
119// keep that path resolving after the move into `compute`.
120#[cfg(test)]
121use compute::apply_suppression;
122
123/// All metrics data.
124///
125/// The set of metrics actually computed is governed by
126/// [`MetricsOptions::with_only`]. By default every metric is
127/// populated; when `with_only` restricts the set, unselected fields
128/// remain at their `Default` value and are elided from
129/// `Serialize` output. The `selected` mask is the source of truth
130/// for which fields are populated — read it via
131/// [`CodeMetrics::selected`].
132#[derive(Default, Debug, Clone, PartialEq)]
133pub struct CodeMetrics {
134 /// `NArgs` data
135 pub nargs: nargs::Stats,
136 /// `NExits` data
137 pub nexits: nexits::Stats,
138 /// `Cognitive` data
139 pub cognitive: cognitive::Stats,
140 /// `Cyclomatic` data
141 pub cyclomatic: cyclomatic::Stats,
142 /// `Halstead` data
143 pub halstead: halstead::Stats,
144 /// `Loc` data
145 pub loc: loc::Stats,
146 /// `Nom` data
147 pub nom: nom::Stats,
148 /// `Tokens` data
149 pub tokens: tokens::Stats,
150 /// `Mi` data
151 pub mi: mi::Stats,
152 /// `Abc` data
153 pub abc: abc::Stats,
154 /// `Wmc` data
155 pub wmc: wmc::Stats,
156 /// `Npm` data
157 pub npm: npm::Stats,
158 /// `Npa` data
159 pub npa: npa::Stats,
160 /// Change-history (VCS) data for this space.
161 ///
162 /// Unlike every other field, this is *not* AST-derived and *not*
163 /// computed during the analysis walk: it is a signal set injected by
164 /// the caller after [`analyze`]. The top-level (file-level)
165 /// [`FuncSpace`] carries the per-file block projected from a
166 /// [`crate::vcs::HistoryIndex`]; nested function / method / class
167 /// spaces carry a per-function block derived from `git blame` only
168 /// when the caller opts into per-function attribution
169 /// ([`crate::vcs::PerFunctionBlame`], issue #329), and stay `None`
170 /// otherwise. Note the two levels use **different** computations:
171 /// the file block is windowed added+deleted churn, the per-function
172 /// block is current-blame surviving-line attribution, so their
173 /// `churn` figures are not comparable. `None` also distinguishes an
174 /// untracked file from a tracked one with zero in-window activity.
175 /// Gated behind the `vcs-git` backend feature.
176 #[cfg(feature = "vcs-git")]
177 pub vcs: Option<crate::vcs::Stats>,
178 /// Which metrics were actually computed for this space.
179 ///
180 /// Default is [`MetricSet::all`] — every metric was run, matching
181 /// the pre-#257 behaviour. After
182 /// [`MetricsOptions::with_only`] the bitfield is restricted to the
183 /// caller's selection plus auto-added dependencies.
184 ///
185 /// The [`Serialize`](serde::Serialize) impl consults this set to elide fields the
186 /// caller did not select. The field itself is not serialized.
187 pub selected: MetricSet,
188}
189
190/// Function space data.
191///
192/// `Serialize` is provided in [`crate::wire`] (it delegates to
193/// [`crate::wire::FuncSpace`], the single definition of the output shape);
194/// read the wire form back with `serde` via that module.
195#[derive(Debug, Clone, PartialEq)]
196pub struct FuncSpace {
197 /// The name of a function space.
198 ///
199 /// For the top-level (file-level) `FuncSpace`, this is the value
200 /// supplied via `Source::name` to [`analyze`] — typically a file
201 /// path or other display identifier chosen by the caller. The
202 /// library no longer derives this from a `&Path` or applies lossy
203 /// UTF-8 conversion; callers are expected to pass an
204 /// already-stringified identifier (or `None` if they have no
205 /// meaningful name to attach).
206 ///
207 /// For nested spaces, `None` means an error occurred in parsing the
208 /// name of the function space from the AST.
209 pub name: Option<String>,
210 /// The first line of a function space
211 pub start_line: usize,
212 /// The last line of a function space
213 pub end_line: usize,
214 /// The space kind
215 pub kind: SpaceKind,
216 /// All subspaces contained in a function space
217 pub spaces: Vec<FuncSpace>,
218 /// All metrics of a function space
219 pub metrics: CodeMetrics,
220 /// In-source suppression markers that apply to this space.
221 ///
222 /// Populated during the spaces pass from comment-embedded
223 /// directives. Each marker carries a [`SuppressionScope`] naming
224 /// the metrics it silences. The top-level (file-level) `FuncSpace`
225 /// aggregates every file-scoped marker; nested function spaces
226 /// aggregate every function-scoped marker whose comment lies
227 /// inside their source range. Metric computation itself is
228 /// unaffected — this field is consumed by downstream
229 /// *threshold-check* code (e.g. `bca check`), which consults a
230 /// [`crate::SuppressionPolicy`] to decide whether to honour the
231 /// markers or surface every violation regardless.
232 ///
233 /// Defaults to `SuppressionScope::default()` (an empty `Some`), so
234 /// pre-existing code paths that do not honor suppressions see no
235 /// behaviour change. The field is elided from JSON output when
236 /// empty so the existing schema is unchanged for files without
237 /// markers.
238 pub suppressed: SuppressionScope,
239}
240
241// Space nesting is caller-controlled, so the compiler-generated `Drop`
242// glue would recurse once per level and abort the process on a deep tree
243// (#1056). See [`crate::recursion`].
244crate::recursion::impl_iterative_drop!(FuncSpace, spaces);
245
246/// The 1-based, inclusive line span a space occupies, shared by the
247/// metrics walk ([`FuncSpace::new`]) and the ops walk
248/// ([`crate::ops::Ops::new`]) so the two can never report different
249/// spans for the same node (#1130).
250///
251/// The end line comes from [`Node::end_line`], which keys the 0-based to
252/// 1-based conversion on the node's end column. Keying it on
253/// `SpaceKind::Unit` instead — as both walks did until #1163 — put
254/// Perl's trailing `sub` a line past its parent unit and past EOF.
255pub(crate) fn line_span(node: &Node<'_>, kind: SpaceKind) -> (usize, usize) {
256 if kind == SpaceKind::Unit {
257 // The file-level unit starts at line 1 by definition — it *is*
258 // the file — so its start is anchored rather than measured. The
259 // root node's own `start_row` is not line 1 whenever the file
260 // opens with blank lines, because tree-sitter starts the root at
261 // the first token: `"\n\n\nfn a() {}\n"` put the unit at 4..4 of
262 // a 4-line file, omitting the three lines above it. A leading
263 // *comment* was always fine, since comments are in the tree.
264 //
265 // A childless root is the acute form of the same thing: with no
266 // token to start at, the node collapses to a point at the end of
267 // the whitespace, so `start_row` is the *last* row and the
268 // measured rule inverts outright — `"\n\n"` gives 3..2 (#1195).
269 //
270 // An empty file is the one input that genuinely has no lines.
271 // `end_line` is 0 there and `1..0` would be inverted in turn, so
272 // it keeps the degenerate `0..0` — and is therefore the one
273 // input `space_span_containment`'s `(1, line_count)` rule
274 // cannot be asked about.
275 let end = node.end_line();
276 return if end == 0 { (0, 0) } else { (1, end) };
277 }
278 (node.start_row() + 1, node.end_line())
279}
280
281impl FuncSpace {
282 /// Project this space into its [`crate::wire::FuncSpace`] form — the
283 /// plain, `Deserialize`-capable record that defines the serialized
284 /// shape. Serializing a `FuncSpace` produces exactly the same bytes as
285 /// serializing `self.to_wire()`.
286 #[must_use]
287 pub fn to_wire(&self) -> crate::wire::FuncSpace {
288 crate::wire::FuncSpace::from(self)
289 }
290
291 fn new<'a, T: Getter>(
292 node: &Node<'a>,
293 code: &[u8],
294 ancestors: Ancestors<'a, '_>,
295 kind: SpaceKind,
296 selected: MetricSet,
297 ) -> Self {
298 let (start_position, end_position) = line_span(node, kind);
299
300 // The top-level Unit's name is overwritten by `metrics_inner`
301 // with the caller-supplied name before returning, so computing
302 // it here is wasted work. Other kinds keep the AST-derived name.
303 let name = (kind != SpaceKind::Unit)
304 .then(|| {
305 T::get_func_space_name(node, code, ancestors)
306 .map(|name| name.split_whitespace().collect::<Vec<_>>().join(" "))
307 })
308 .flatten();
309
310 let mut metrics = CodeMetrics::with_selected(selected);
311 // Seed the cyclomatic per-function divisor: each function/closure
312 // space contributes 1 to `function_spaces`, which `Stats::merge`
313 // then sums across the subtree. Sourced here from the space kind
314 // rather than from the `Nom` metric so the cyclomatic averages
315 // stay correct even when `Nom` is not selected (#512).
316 if kind == SpaceKind::Function {
317 metrics.cyclomatic.note_function_space();
318 }
319
320 Self {
321 name,
322 spaces: Vec::new(),
323 metrics,
324 kind,
325 start_line: start_position,
326 end_line: end_position,
327 suppressed: SuppressionScope::default(),
328 }
329 }
330}
331
332#[derive(Debug, Clone)]
333struct State<'a> {
334 space: FuncSpace,
335 halstead_maps: HalsteadMaps<'a>,
336}
337
338/// In-memory source bundle handed to [`analyze`].
339///
340/// `Source` decouples the *display name* of the top-level
341/// [`FuncSpace`] (`Source::name`) from the optional *filesystem path*
342/// used by the C++ preprocessor lookup (`Source::preproc_path`). For
343/// in-memory snippets, code fetched over the network, or test
344/// fixtures, callers pass `Source` directly without manufacturing a
345/// `Path`.
346///
347/// Marked `#[non_exhaustive]` so future input fields can land
348/// additively. Downstream callers must construct via
349/// [`Source::new`] plus the `with_*` builder setters rather than
350/// struct-literal syntax (rustc rejects external struct literals on
351/// non-exhaustive types with E0639).
352///
353/// # Examples
354///
355/// Analysing an in-memory snippet with no on-disk path:
356///
357/// ```
358/// use big_code_analysis::{analyze, MetricsOptions, Source, LANG};
359///
360/// let source = Source::new(LANG::Rust, b"fn main() {}")
361/// .with_name(Some("snippet.rs".to_owned()));
362/// let space = analyze(source, MetricsOptions::default()).unwrap();
363/// assert_eq!(space.name.as_deref(), Some("snippet.rs"));
364/// // `Source` applies no normalisation: the bytes reach the parser
365/// // exactly as given, trailing newline or not, and the one-line
366/// // snippet above counts as one source line either way (#1067).
367/// assert_eq!(space.metrics.loc.sloc(), 1);
368/// ```
369#[non_exhaustive]
370#[derive(Debug, Clone)]
371pub struct Source<'a> {
372 /// The source language used to select the parser.
373 pub(crate) lang: LANG,
374 /// Raw source bytes, borrowed ([`Source::new`]) or owned
375 /// ([`Source::from_bytes`]). The parser needs an owned buffer:
376 /// borrowed bytes are copied at parse time, owned bytes move
377 /// through without a copy (the CLI walk's hot path).
378 pub(crate) code: Cow<'a, [u8]>,
379 /// Display / identifier name for the top-level [`FuncSpace`].
380 /// If `None`, the top-level [`FuncSpace::name`] is left `None`.
381 pub(crate) name: Option<String>,
382 /// Optional path used only by the C++ preprocessor lookup
383 /// (`get_fake_code`) to resolve macro definitions in
384 /// [`PreprocResults`]. For non-C++ languages this is ignored.
385 /// Defaults to `None`.
386 pub(crate) preproc_path: Option<&'a Path>,
387 /// Preprocessor results paired with `Source::preproc_path`.
388 /// Same shape as the `pr` arg on the deprecated entry points.
389 pub(crate) preproc: Option<Arc<PreprocResults>>,
390}
391
392/// Parse-once, compute-many handle.
393///
394/// Owns the parsed [`tree_sitter::Tree`] and the source bytes it was parsed
395/// from, so callers can run [`Ast::metrics`] repeatedly against the same
396/// parse — with different [`MetricsOptions`] subsets, interleaved with
397/// custom `tree_sitter` traversal via [`Ast::as_tree_sitter`], or cached
398/// across configuration changes in an analysis pipeline.
399///
400/// Build one via [`Ast::parse`] (the seam behind [`analyze`]) or
401/// [`Ast::from_tree_sitter`] to reuse a caller-supplied
402/// [`tree_sitter::Tree`], carrying an explicit display name.
403///
404/// `Ast` is a snapshot — it does not pick up changes to the source after
405/// construction. Incremental reparse via [`tree_sitter::InputEdit`] is out
406/// of scope for this seam.
407///
408/// # C++ preprocessor
409///
410/// When [`Ast::parse`] is given a [`Source`] carrying preprocessor inputs
411/// and the language is [`LANG::Cpp`], [`Ast::source`] returns the *expanded*
412/// bytes the parser actually saw (the macro pre-pass runs before
413/// `tree-sitter` does). [`Ast::from_tree_sitter`] adopts whatever tree the
414/// caller supplied; whatever expansion they applied before building it is
415/// what [`Ast::source`] reflects.
416///
417/// # Examples
418///
419/// Parse once, run two disjoint metric subsets without re-parsing:
420///
421/// ```
422/// use big_code_analysis::{Ast, LANG, Metric, MetricsOptions, Source};
423///
424/// let ast = Ast::parse(
425/// Source::new(LANG::Rust, b"fn f() { if true { 1 } else { 2 }; }"),
426/// )
427/// .expect("rust feature enabled");
428///
429/// let loc = ast
430/// .metrics(MetricsOptions::default().with_only(&[Metric::Loc]))
431/// .expect("walker succeeds");
432/// let cyc = ast
433/// .metrics(MetricsOptions::default().with_only(&[Metric::Cyclomatic]))
434/// .expect("walker succeeds");
435/// // Each call's `with_only` filters to its requested family — the other
436/// // metric stays at its `Default` (zero) value, confirming options are
437/// // honored per call rather than carried over.
438/// assert!(loc.metrics.loc.ploc() > 0);
439/// assert_eq!(loc.metrics.cyclomatic.cyclomatic_sum(), 0);
440/// assert!(cyc.metrics.cyclomatic.cyclomatic_sum() > 0);
441/// assert_eq!(cyc.metrics.loc.ploc(), 0);
442/// ```
443///
444/// Walk the underlying `tree_sitter::Tree` and then run metrics on the
445/// same parse:
446///
447/// ```
448/// use big_code_analysis::{Ast, LANG, MetricsOptions, Source};
449///
450/// let ast = Ast::parse(Source::new(LANG::Rust, b"fn f() {}"))
451/// .expect("rust feature enabled");
452/// let root = ast.as_tree_sitter().root_node();
453/// assert_eq!(root.kind(), "source_file");
454/// let _ = ast.metrics(MetricsOptions::default()).expect("walker succeeds");
455/// ```
456pub struct Ast {
457 inner: crate::langs::AnyParser,
458 name: Option<String>,
459}
460
461// `impl fmt::Debug for Ast` and `impl Ast` live in `spaces/ast.rs`.
462
463/// Per-traversal options for [`analyze`] / [`Ast::metrics`].
464///
465/// Marked `#[non_exhaustive]` so future option fields can land
466/// additively. Downstream callers must construct via the builder
467/// methods rather than struct-literal syntax (rustc rejects external
468/// struct literals on non-exhaustive types with E0639, including the
469/// `..Default::default()` spread form). The defaults preserve every
470/// metric value emitted by the pre-#182 [`analyze`] entry point.
471///
472/// ```
473/// use big_code_analysis::MetricsOptions;
474/// let opts = MetricsOptions::default().with_exclude_tests(true);
475/// ```
476#[derive(Clone, Copy, Debug, PartialEq, Eq)]
477#[non_exhaustive]
478pub struct MetricsOptions {
479 /// When true, the traversal asks the language module to skip
480 /// test-only subtrees (e.g. Rust `#[test]` / `#[cfg(test)]`
481 /// functions and modules). Only languages that override the
482 /// internal `should_skip_subtree` hook honor this; others ignore
483 /// the flag.
484 pub(crate) exclude_tests: bool,
485 /// Which metrics to compute. Defaults to [`MetricSet::all`] —
486 /// every metric is enabled, matching the pre-#257 behaviour.
487 /// Restrict via [`MetricsOptions::with_only`].
488 pub(crate) metrics: MetricSet,
489 /// When true (the default), Rust's `?` operator (the
490 /// `try_expression` grammar node) contributes `+1` to both
491 /// standard and modified cyclomatic complexity, matching upstream
492 /// rust-code-analysis. Set to `false` (via
493 /// [`MetricsOptions::with_count_cyclomatic_try`]) to treat `?` as
494 /// linear error propagation rather than a branch — useful when
495 /// cyclomatic is used as a maintainability gate that should not
496 /// penalize fallible-but-linear code. Rust-only: no other
497 /// language emits `try_expression`, so the flag is inert
498 /// elsewhere. Defaulting to `true` keeps every published metric
499 /// value unchanged (#409).
500 pub(crate) count_cyclomatic_try: bool,
501}
502
503#[cfg(test)]
504// The lossy-path / synthetic-Unit tests below drive the internal
505// `metrics_inner` walk core directly (the `Ast`-seam-friendly
506// counterpart of the retired path-positional entry points) so they
507// keep regression coverage on the synthetic top-level Unit and the
508// lossy-name handling.
509#[allow(
510 clippy::float_cmp,
511 clippy::cast_precision_loss,
512 clippy::cast_possible_truncation,
513 clippy::cast_sign_loss,
514 clippy::similar_names,
515 clippy::doc_markdown,
516 clippy::needless_raw_string_hashes,
517 clippy::too_many_lines
518)]
519#[path = "spaces_tests.rs"]
520mod tests;