Skip to main content

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;