Skip to content

fix(getter/bash): translated_string double-counts its string in Halstead N2 #1358

Description

@dekobon

Summary

BashCode::get_op_type (src/getter/bash.rs) classifies both
Bash::TranslatedString and Bash::String as HalsteadType::Operand.
At the pinned tree-sitter-bash 0.25.1, translated_string (238) has
exactly one required child — a string (237) — so a $"…" literal is
counted twice: once as the wrapper, once as the string it wraps.

This is the same wrapper/leaf shape as #1351 (command_name), found while
fixing it, in the same match.

Reproduction

$ printf 'a=$"x"\n' > a.sh
$ bca dump --no-config -p a.sh
╰─ {variable_assignment:209} : a=$"x"
   ├─ {variable_name:160} : a
   ├─ {=:9} : =
   ╰─ {translated_string:238} : $"x"
      ├─ {$:101} : $
      ╰─ {string:237} : "x"

$ bca ops --no-config -p a.sh
operands: $"x", "x", a      # honest: $"x", a

It also defeats the #180 interpolation guard

bash_string_has_expansion inspects the node's own children. A
translated_string has only $ and string as children, so it never sees
the expansion nested one level down and is classified as an operand
unconditionally — while the inner string is correctly skipped:

$ printf 'b=$"$y"\n' > b.sh
$ bca ops --no-config -p b.sh
operands: $"$y", $y, b      # honest: $y, b

So the wrapper reintroduces exactly the double count #180 removed, for the
$"…" spelling only.

Where it is reachable

translated_string surfaces on an assignment RHS, as a case subject, and
in command-name position:

a=$"x"            # variable_assignment  → translated_string → string
case $"y" in *) ;; esac
$"ls" arg         # command_name         → translated_string → string

It is not emitted in ordinary argument position — echo $"hello" parses
as a bare $ anonymous token followed by a string, with no
translated_string node at all. That asymmetry is why the bug is easy to
miss: the common spelling is already correct.

Fix

Per .claude/rules/grammar-dispatch.md §5/§6 the keeper is the node that
exists for every spelling of the construct. Here the string is present
in all three positions and the translated_string only in some, so the
wrapper is the arm to drop — but check first whether dropping it loses the
$ prefix from the recorded operand text in a way that matters for bca ops, and re-derive against case/assignment/command-name positions rather
than assuming.

Whichever direction is chosen, the $"$y" interpolating form must end up
counting $y once and nothing else, matching what #180 already guarantees
for a plain "$y".

Tests

src/metrics/halstead.rs already pins the current (wrong) value: the
translated_string row of bash_command_name_wrapper_no_double_count
records $"ls" arg as [0, 0, 3, 3] and carries a FIXME pointing here.
That row is the one to flip, plus a new case for a=$"x" and one for
b=$"$y".

Resolution

Status: Fixed (pending merge) — branch fix/issue-1358, commits
69a7f787 (fix + tests) and 6853f8cc (test-doc accuracy).

BashCode::get_op_type classified the translated_string wrapper and
the single required string child the grammar gives it, so every $"…"
was counted twice in Halstead N2 — and, because
bash_string_has_expansion inspects only a node's own children, the
wrapper also hid a nested simple_expansion and reintroduced the double
count #180 removed. The wrapper arm is gone; the string carries the
count, which is what argument position already did.

Two corrections to this issue, both measured:

  • The translated_string row's _before pair becomes (3, 3), not the
    (3, 4) written above — two stacked wrappers make it a counterfactual
    against today's tree rather than a historical measurement.
  • translated_string is emitted in at least eight positions, not the
    three listed; and $"${#}" now scores zero, matching what a plain
    "${#}" already scored.

See the resolution comment for the sibling sweep: the same wrapper/leaf
shape exists in Perl, Elixir, PHP, Java and the JS family, and wants
separate issues.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions