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 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, FoldCache, Node, SiblingCache};
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    /// When true, `mi.sei`'s comment ratio counts documentation rows as
502    /// comments, `cloc / sloc`. The default, `false`, leaves the
503    /// documentation rows out of both sides of the ratio, so a space
504    /// scores the same with and without its documentation. The size term
505    /// is `ploc` in both modes (#1607), so only `mi.sei` moves. Set via
506    /// [`MetricsOptions::with_mi_charges_docs`].
507    pub(crate) mi_charges_docs: bool,
508}
509
510#[cfg(test)]
511// The lossy-path / synthetic-Unit tests below drive the internal
512// `metrics_inner` walk core directly (the `Ast`-seam-friendly
513// counterpart of the retired path-positional entry points) so they
514// keep regression coverage on the synthetic top-level Unit and the
515// lossy-name handling.
516#[allow(
517    clippy::float_cmp,
518    clippy::cast_precision_loss,
519    clippy::cast_possible_truncation,
520    clippy::cast_sign_loss,
521    clippy::similar_names,
522    clippy::doc_markdown,
523    clippy::needless_raw_string_hashes,
524    clippy::too_many_lines
525)]
526#[path = "spaces_tests.rs"]
527mod tests;