big-code-analysis 2.4.0

Tool to compute and export code metrics
Documentation
//! Cross-language `Loc` helper functions shared by the per-language
//! submodules.
//!
//! The public metric `Stats` (and `Sloc` / `Ploc` / `Cloc` / `Lloc`),
//! the `Loc` trait, and `min_or_zero` stay in the parent module; only
//! the cross-language line-counting helpers live here so the parent
//! clears the 800-SLOC self-scan limit (#976).
//!
//! # The `Loc` catch-all arm is fail-*open*, unlike every sibling metric
//!
//! `Cognitive`, `Cyclomatic`, and `Abc` end their per-language matches
//! with `_ => {}`, so a token the author never considered costs
//! nothing. Every `Loc` implementation instead ends with
//!
//! ```ignore
//! _ => {
//!     check_comment_ends_on_code_line(stats, start);
//!     stats.ploc.lines.insert(start);
//! }
//! ```
//!
//! which asserts *this row is code*. The default is inverted from the
//! one the neighbouring metric modules train you to expect: an
//! unrecognised token does not fall through harmlessly, it adds a
//! physical line of code — and via [`check_comment_ends_on_code_line`]
//! can reclassify a comment-only row as code-and-comment.
//!
//! Two shipped defects came from exactly this. Tcl and iRules are the
//! only grammars that surface the row terminator as a token child of
//! the root; its start row is the row it *terminates*, so every
//! comment-only and whitespace-only row was credited to PLOC — a
//! realistic fourteen-row Tcl file reported `ploc 13` against a true
//! `7` (#1135). Perl's `#` sits *inside* its `comments` node and did
//! the same to every Perl file carrying a comment row (#1137).
//!
//! When adding a language, enumerate the tokens its grammar emits that
//! are not code — row terminators, comment punctuation, string-internal
//! tokens — and give them an explicit no-op arm. `bca dump` over a
//! small fixture is the fastest way to see them. The parent module's
//! `a_comment_row_is_never_counted_as_code` and
//! `whitespace_only_input_is_uniform_across_grammars` sweep every
//! language for both shapes.
#![allow(
    clippy::enum_glob_use,
    clippy::match_same_arms,
    clippy::struct_field_names,
    clippy::wildcard_imports
)]
#![allow(
    clippy::cast_precision_loss,
    clippy::cast_possible_truncation,
    clippy::cast_sign_loss
)]

use super::*;
use crate::lang_helpers::{
    bound_doc_run, documents_unbound, documents_where_it_sits, leading_doc_run,
};

#[inline]
pub(crate) fn init(node: &Node, stats: &mut Stats, is_func_space: bool) -> usize {
    let start = node.start_row();

    if is_func_space {
        stats.sloc.start = start;
        // `Node::end_line`, not the raw end row, and not a unit/non-unit
        // flag: the end column decides whether the final row belongs to
        // this span, and that rule lives in one place (#1067, #1163).
        stats.sloc.end_line = node.end_line();
    }
    // The span written above is the *measured* one, and for the file-level
    // unit that is wrong whenever the file opens with blank lines — the
    // root node starts at the first token, not at line 1. Nothing is done
    // about it here because this helper cannot tell a unit from any other
    // func-space without re-widening the `Loc` trait, which #1067 removed
    // for perf. `spaces::compute::anchor_unit_sloc_span` overwrites the
    // unit's span at finalization instead (#1247).
    start
}

#[inline]
// Discriminates among the comments that are *after* a code line and
// the ones that are on an independent line.
// This difference is necessary in order to avoid having
// a wrong count for the blank metric.
//
// Requires `end >= start`. Every caller passes the row span returned by
// `init`, and tree-sitter guarantees a node's end row is never before its
// start row — so the only way to violate this is for a caller to adjust
// `end` downwards itself, as Rust's `LineComment` arm does for doc
// comments (#1051).
//
// Scope of the assert, deliberately narrow: it fires only for
// `end < start`, and only in debug — the release profile disables both
// `debug-assertions` and `overflow-checks`. It cannot catch a caller
// whose own arithmetic already wrapped, because `(0, usize::MAX)`
// satisfies `end >= start`. The real guard against that is refusing to
// underflow in the first place, at the site that adjusts the span.
//
// The `saturating_sub` below narrows what release does with an
// inverted span, and the effect is smaller than it looks: `comment_diff`
// feeds only the branch tests, never a row index. `0` routes to the
// `== 0` arm rather than the block-comment arm a wrapped `usize::MAX`
// selected, but the two differ only by an `add_only_comment_lines(start
// + 1, end)` that `LineSet::insert_range` already rejects as inverted —
// so this buys a truthful classification, not an observable metric
// change (#1152).
fn add_cloc_lines(stats: &mut Stats, start: usize, end: usize) {
    debug_assert!(end >= start, "add_cloc_lines: end {end} < start {start}");
    let comment_diff = end.saturating_sub(start);
    let is_comment_after_code_line = stats.ploc.lines.contains(start);
    if is_comment_after_code_line && comment_diff == 0 {
        // A comment is *entirely* next to a code line
        add_code_comment_line(stats, start);
    } else if is_comment_after_code_line && comment_diff > 0 {
        // A block comment that starts next to a code line and ends on
        // independent lines.
        add_code_comment_line(stats, start);
        add_only_comment_lines(stats, start.saturating_add(1), end);
    } else {
        // A comment on an independent line AND
        // a block comment on independent lines OR
        // a comment *before* a code line.
        //
        // Insert each spanned row into the comment-only set rather than
        // bumping a counter: a multi-line block still contributes one
        // line per distinct row, but two standalone comments on a
        // single physical line (`/*a*/ /*b*/`) share the row and count
        // once (issue #461 follow-up).
        add_only_comment_lines(stats, start, end);
        // Save line end of a comment to check whether
        // a comment *before* a code line is considered
        stats.cloc.comment_line_end = Some(end);
    }
}

#[inline]
// Records a span of comment rows exactly as `add_cloc_lines` does and,
// when they are documentation, the same rows in the `dloc` set that
// `loc.dloc` reports, so `dloc() <= cloc()` holds by construction
// (#1597). The callers are `add_comment_lines` below, Rust's
// line-comment arm and Python's bare-string arms. The `dloc` set's two
// other writers, `add_leading_doc_run` and Python's documentation-string
// and Sphinx-comment runs, only re-bill rows already billed here.
pub(crate) fn add_comment_rows(stats: &mut Stats, start: usize, end: usize, documentation: bool) {
    add_cloc_lines(stats, start, end);
    if documentation {
        stats.dloc.lines.insert_range(start, end);
    }
}

#[inline]
// Documentation by position rather than by marker (Go, Ruby, Lua): bills
// the comment run `node` ends as `dloc` when it sits directly above a
// declaration the language documents, or its first member is a language's
// run opener (LDoc `---`, RDoc `##`, YARD `@!`). Called
// from the comment arm after `add_comment_lines`, so every row it credits
// is one a run member's own arm already billed to this space's cloc sets,
// and `dloc() <= cloc()` still holds (#1597).
pub(crate) fn add_leading_doc_run<'a, T: Checker>(
    node: &Node<'a>,
    code: &[u8],
    ancestors: Ancestors<'a, '_>,
    stats: &mut Stats,
) {
    leading_doc_run::<T>(node, code, ancestors, |first, last| {
        stats.dloc.lines.insert_range(first, last);
    });
}

#[inline]
// Bills a comment node's rows, as documentation when `is_documentation`
// says so. Every language's comment arm routes here — Rust's comment
// arm bills through `add_comment_rows` itself, since its
// `Checker::is_doc_comment` answer also sizes its row span — so "which
// comments are documentation" is answered in one place per language, in
// the `Checker` impl, and never drifts from `is_comment` (#1597).
//
// The rows are the comment node's own, exactly as `init` hands them to
// every comment arm; reading them here keeps each call site to the node
// and its context.
pub(crate) fn add_comment_lines<'a, T: Checker>(
    node: &Node<'a>,
    code: &[u8],
    ancestors: Ancestors<'a, '_>,
    stats: &mut Stats,
) {
    let documentation = is_documentation::<T>(node, code, ancestors);
    add_comment_rows(stats, node.start_row(), node.end_row(), documentation);
    add_bound_doc_run::<T>(node, code, ancestors, stats);
}

// Whether a comment is documentation by where it sits: spelled as a doc
// comment (`Checker::is_doc_comment`), not one a `Checker::DOC_BINDING`
// attaches forward (`documents_unbound`; `add_bound_doc_run` bills
// those), and, per `documents_where_it_sits`, not a working note in a
// function body (#1597, #1613).
fn is_documentation<'a, T: Checker>(
    node: &Node<'a>,
    code: &[u8],
    ancestors: Ancestors<'a, '_>,
) -> bool {
    T::is_doc_comment(node, code) && documents_by_placement::<T>(node, code, ancestors)
}

// The placement half of `is_documentation`, for a caller that already
// holds the `Checker::is_doc_comment` answer (Rust's comment arm).
pub(crate) fn documents_by_placement<'a, T: Checker>(
    node: &Node<'a>,
    code: &[u8],
    ancestors: Ancestors<'a, '_>,
) -> bool {
    documents_unbound::<T>(node, code, ancestors)
        && documents_where_it_sits::<T>(node, code, ancestors)
}

#[inline]
// Bills as `dloc` the doc comments a bound language's tool attaches to
// the declaration after them, once `node` ends their stretch
// (`bound_doc_run`, #1613). Each member's rows are the ones its own
// comment arm billed to `cloc`, so `dloc() <= cloc()` still holds; every
// member is a sibling of `node`, so they were billed to this space's
// `stats`. Rust's arm, whose `///` rows are not its node's, calls
// `bound_doc_run` itself.
fn add_bound_doc_run<'a, T: Checker>(
    node: &Node<'a>,
    code: &[u8],
    ancestors: Ancestors<'a, '_>,
    stats: &mut Stats,
) {
    bound_doc_run::<T>(node, code, ancestors, |member| {
        stats
            .dloc
            .lines
            .insert_range(member.start_row(), member.end_row());
    });
}

#[inline]
// Detects the comments that are on a code line but *before* the code part.
// This difference is necessary in order to avoid having
// a wrong count for the blank metric.
pub(crate) fn check_comment_ends_on_code_line(stats: &mut Stats, start_code_line: usize) {
    if let Some(end) = stats.cloc.comment_line_end
        && end == start_code_line
        && !stats.ploc.lines.contains(start_code_line)
    {
        // Comment entirely *before* a code line: reclassify the line
        // from comment-only to code-comment. Remove it from the
        // comment-only set (so `blank` no longer credits it) and add it
        // to the code-comment set. Both operations are idempotent, so a
        // line already reclassified stays correct (issue #461).
        stats.cloc.only_comment_line_starts.remove(start_code_line);
        add_code_comment_line(stats, start_code_line);
    }
}

#[inline]
// Records a physical line carrying both code and comment. Backed by a
// per-line set so several inline block comments on one code line
// (`f(int /*a*/, int /*b*/)`) yield a single comment line, not one per
// comment node (issue #461). Mirrors `Ploc`'s per-line de-duplication.
pub(crate) fn add_code_comment_line(stats: &mut Stats, line: usize) {
    stats.cloc.code_comment_line_starts.insert(line);
}

#[inline]
// Records the inclusive row range `start..=end` as comment-only lines.
// Backed by a per-line set so two standalone block comments on one
// physical line (`/*a*/ /*b*/`) count once, while each distinct row of
// a genuine multi-line block comment still counts (issue #461).
pub(crate) fn add_only_comment_lines(stats: &mut Stats, start: usize, end: usize) {
    stats.cloc.only_comment_line_starts.insert_range(start, end);
}

// Adds every physical row spanned by a multi-line string literal to PLOC.
//
// Interior rows of a multi-line string hold real source text, not blank
// lines: classifying them as blank (which happens whenever a language
// no-ops its string nodes, so the rows reach neither PLOC nor CLOC and
// `blank = sloc - ploc - cloc` mislabels them) diverges from Python's
// established behaviour, where a non-docstring multi-line string credits
// all of its rows to PLOC (#415). This helper makes every other language
// agree with that decision (#778).
//
// Mirrors Python's `String` arm: the opening row is inserted only when the
// enclosing statement begins on an earlier row — otherwise that row is
// already attributed to the parent — and the interior and closing rows are
// always inserted.
//
// **That parent gate assumes some other node credits the opening row.**
// It is a skip, and it is only safe where the language's catch-all
// credits every node's start row, as it does in all eighteen languages
// #778 covered. Where the catch-all is leaf-gated
// (`if node.child_count() == 0`, as in `bash.rs` and `elixir.rs`) a
// container parent contributes nothing, so a *childless* literal is the
// only node covering its own row and the gate deletes it: `'ls'` alone
// in a Bash file reported `ploc 0, blank 1` (#1260). Elixir is
// unaffected because a `quoted_content`'s parent always carries its
// opening delimiter as a leaf token on that row. Bash's arm owns its
// opening row outright and calls
// [`add_string_interior_ploc`] instead — do not reach for this one
// there, where the gate can only be wrong.
#[inline]
pub(crate) fn add_multiline_string_ploc(
    node: &Node,
    ancestors: Ancestors<'_, '_>,
    stats: &mut Stats,
    start: usize,
) {
    if ancestors
        .parent(node)
        .is_none_or(|parent| parent.start_row() != start)
    {
        check_comment_ends_on_code_line(stats, start);
        stats.ploc.lines.insert(start);
    }
    add_string_interior_ploc(node, stats, start);
}

// Adds a multi-row literal's interior and closing rows to PLOC, leaving
// its opening row to the caller. The half of
// [`add_multiline_string_ploc`] a language whose catch-all is leaf-gated
// wants on its own: those callers must credit the opening row
// unconditionally, so the parent gate above is dead weight for them and
// re-running its two statements after the fact reads as a second rule.
//
// The last row is derived from [`Node::end_line`] rather than the raw
// end row: a node whose end column is 0 finished at the *start* of the
// row below its last content row, so that row is not part of its span,
// and crediting it claims a row the literal does not cover.
//
// `insert_range` rather than a row-at-a-time loop: the range is one
// bitmap span, and Bash's heredoc bodies push thousands of rows through
// here where the loop paid a reserve and a bounds check per row.
//
// The name records where the rule came from, not the only node it
// applies to: #1423 routed the four C-family `PreprocArg` arms here
// too, since an unexpanded macro body is the same question — which rows
// does this multi-row node cover — and had the same off-by-one bound
// open-coded.
#[inline]
pub(crate) fn add_string_interior_ploc(node: &Node, stats: &mut Stats, start: usize) {
    // Inclusive, and `insert_range` no-ops on an inverted span, so a
    // single-row literal inserts nothing here.
    stats
        .ploc
        .lines
        .insert_range(start.saturating_add(1), node.end_line().saturating_sub(1));
}