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