1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
//! `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);
}
}
}
}
}