big-code-analysis 2.4.0

Tool to compute and export code metrics
Documentation
//! `Loc` implementation for Bash.
#![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::*;

impl Loc for BashCode {
    fn compute<'a>(
        node: &Node<'a>,
        code: &[u8],
        ancestors: Ancestors<'a, '_>,
        stats: &mut Stats,
        is_func_space: bool,
    ) {
        use Bash::*;

        let start = init(node, stats, is_func_space);

        match node.kind_id().into() {
            Program => {}
            Comment => {
                add_comment_lines::<Self>(node, code, ancestors, stats);
            }
            // Every Bash literal that can span rows, so its interior rows
            // reach PLOC instead of being mislabelled blank by
            // `blank = sloc - ploc - cloc` (#1260) — the decision #778 took
            // for eighteen other languages and #415 took for Python.
            //
            // `String` looks covered without an arm and is not: the grammar
            // emits one `string_content` child per row that *has* text, so
            // `"a\n\nb"` credits rows 1 and 3 through those leaves and leaves
            // row 2 blank. Every other language routes the whole literal and
            // counts an empty interior row as code, so Bash does too.
            // `RawString` (`'…'`) and `AnsiCString` (`$'…'`) are childless,
            // so the leaf-gated `_` arm below reached only their opening
            // row. `TranslatedString` (`$"…"`) needs no arm: it wraps a
            // `String` that this one already covers.
            //
            // The heredoc is routed through its **wrapper**,
            // `heredoc_redirect`, rather than through `heredoc_body`, for
            // `.claude/rules/grammar-dispatch.md` section 6's keeper rule —
            // the same call #1396 made for PHP. `heredoc_body`'s span
            // excludes a leading empty body row and collapses to zero width
            // when the body is empty throughout, so those rows sat inside no
            // node at all and `blank = sloc - ploc - cloc` claimed them
            // (#1412). `bca dump` on `cat <<EOT\n\nEOT\n`:
            //
            //     {heredoc_redirect:213} from (1, 5) to (3, 4)
            //       {<<:36}              from (1, 5) to (1, 7)
            //       {heredoc_start:152}  from (1, 7) to (1, 10)
            //       {heredoc_body:218}   from (3, 1) to (3, 1)
            //       {heredoc_end:156}    from (3, 1) to (3, 4)
            //
            // The body node is on the *terminator's* row and empty; row 2 is
            // covered only by the wrapper.
            //
            // `HeredocBody` / `HeredocBody2` stay listed **alongside** the
            // wrapper rather than being replaced by it — section 6's "narrow
            // with a gate, never by deletion". `node-types.json` does list
            // `heredoc_body` only as a child of `heredoc_redirect`, but that
            // describes the well-formed grammar and says nothing about error
            // recovery, where tree-sitter-bash emits an **orphan** body with
            // no wrapper anywhere. A single-line compound carrying a heredoc
            // is the shape, and all three spellings are valid, executable
            // Bash:
            //
            //     f() { cat <<EOT; }        if true; then cat <<EOT; fi
            //     body                      x
            //     EOT                       EOT
            //
            // `bca dump` on the first gives an `{ERROR}` root whose direct
            // child is `{heredoc_body:218} from (2, 1) to (4, 1)`. Dropping
            // the body kinds left that node to the leaf-gated catch-all,
            // which credits only its start row, so the terminator row fell
            // to `blank` — #1412's own symptom, reintroduced. Keeping both
            // costs nothing where the wrapper does exist: the body's row
            // range is contained in the wrapper's, and `insert`,
            // `insert_range` and `check_comment_ends_on_code_line` are all
            // idempotent. #1398 was an unterminated-heredoc ERROR shape in
            // this same arm, so recovery trees were already known to reach
            // it.
            //
            // A knowing divergence from section 7's parity cross-walk:
            // `BashCode::is_string` (`big-code-analysis-ast/src/checker/bash.rs`)
            // does **not** list `HeredocRedirect`, and must not — a
            // redirection is an operator, not a string literal, and
            // `find string` / Halstead operand classification would be wrong
            // to report one. This arm answers a different question, "which
            // physical rows hold source text", and the wrapper is the node
            // that answers it. PHP's arm agrees with its `is_string` because
            // there the keeper (`heredoc`) *is* the literal; here it is not.
            //
            // This arm owns its opening row, which is why it calls
            // `add_string_interior_ploc` rather than the parent-gated
            // `add_multiline_string_ploc` its siblings use. That
            // gate *skips* the opening row when the parent starts on it,
            // which is safe only where the catch-all credits every node's
            // start row — and Bash's, below, is leaf-gated, so a container
            // parent contributes nothing. A childless `raw_string` /
            // `ansi_c_string` is then the only node covering its own row,
            // and the skip deletes it: `'ls'` alone in a file reported
            // `ploc 0, blank 1`. Re-derived for `heredoc_redirect`: its
            // parent `redirected_statement` starts on the same row, so the
            // gate would skip there too — harmless only because the `<<`
            // token is a leaf on that row and the catch-all picks it up.
            // Depending on that is a second rule for no gain, so the
            // wrapper takes the unconditional form its siblings do.
            String | RawString | AnsiCString | HeredocBody | HeredocBody2 => {
                check_comment_ends_on_code_line(stats, start);
                stats.ploc.lines.insert(start);
                add_string_interior_ploc(node, stats, start);
            }
            // The wrapper spans the command-line *prefix* as well as the
            // literal, and the grammar lets that prefix cross rows — a
            // `pipeline` is one of its children — so this range can
            // over-credit. #1443 narrowed it to the literal's own rows and
            // that was **reverted**; the narrowing is restored only when
            // #1445 lands. The reasoning is worth keeping, because the
            // narrowing looks obviously right:
            //
            // - What it fixed is unreachable. A *blank or comment-only*
            //   prefix row needs `bash -n` to reject the file: bash begins
            //   the body on the line after the one carrying `<<`, and a `\`
            //   continuation splices the rows rather than leaving one
            //   empty. All four spellings — bare and `\`-continued, blank
            //   row and comment row — are syntax errors.
            // - What it broke is not. A continuation row holding only `\`
            //   is valid Bash and is code, and it carries no leaf for the
            //   leaf-gated catch-all below to credit, so narrowing the
            //   range dropped it: `cat <<EOT | \` + `\` + `  grep x` went
            //   from `ploc 5, blank 0` to `ploc 4, blank 1`.
            //
            // That is #1445, a general Bash gap — `echo a | \` + `\` +
            // `  grep b` shows it with no heredoc in sight — which this
            // blanket range happens to mask here. Masking it is the better
            // trade until #1445 closes it: the rows it over-credits belong
            // to files that do not run, and the row it saves belongs to
            // files that do. Distinguishing a `\`-only row from an empty
            // one needs the source bytes, which `Loc::compute` has carried
            // since #1597, so #1445 can now be fixed here.
            HeredocRedirect => {
                check_comment_ends_on_code_line(stats, start);
                stats.ploc.lines.insert(start);
                add_string_interior_ploc(node, stats, start);
            }
            // An assignment standing as a statement of its own is one
            // logical line — but only then.
            //
            // `VariableAssignment2` is the id observed parse trees
            // actually carry for `variable_assignment`, in every
            // position: at top level, in a `compound_statement` or
            // `subshell` or `do_group`, under a `declaration_command`
            // (`local n=5`, `export A=1`, `declare -a B=(1 2)`,
            // `readonly C=3`) and as a `command`'s environment prefix
            // (`X=1 cmd`). The unsuffixed `VariableAssignment` below is
            // never emitted, so listing only it scored `a=1` zero
            // logical lines (`.claude/rules/grammar-dispatch.md` §1);
            // it stays as a defensive arm rather than being swapped.
            //
            // The last two positions need the parent gate. Both
            // `declaration_command` and `command` are counted as
            // logical lines by the arm below, and the assignment is
            // *part* of that line rather than another one, so listing
            // the kind ungated billed `local n=5` twice (§5's
            // container/leaf double count). Gated rather than dropped
            // per §6: the standalone spelling has no other arm to fall
            // back on.
            VariableAssignment2
                if !ancestors.parent(node).is_some_and(|p| {
                    matches!(p.kind_id().into(), DeclarationCommand | Command)
                }) =>
            {
                stats.lloc.count_logical_line();
            }
            // LLOC: leaf statement nodes. Pipeline, Subshell, and
            // RedirectedStatement are excluded because they wrap inner
            // Command nodes that are already counted here.
            Command | VariableAssignment | DeclarationCommand | UnsetCommand | IfStatement
            | ForStatement | CStyleForStatement | WhileStatement | CaseStatement
            | FunctionDefinition => {
                stats.lloc.count_logical_line();
            }
            _ => {
                if node.child_count() == 0 {
                    check_comment_ends_on_code_line(stats, start);
                    stats.ploc.lines.insert(start);
                }
            }
        }
    }
}