Skip to content

ast: record the brace on a hash literal (HashLit.Braced) - #42

Merged
tannevaled merged 1 commit into
mainfrom
wave-h-hashlit-braced
Sep 28, 2026
Merged

tannevaled merged 1 commit into
mainfrom
wave-h-hashlit-braced

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Closes #40.

The gap

f({k: 1}) and f(k: 1) built structurally identical ast.HashLits. In Ruby 3+ they are different calls — a positional Hash and keywords — and ast.Call carries no position, so a consumer could not re-read the source to recover the brace either. Reproduction from the issue, against ruby 4.0.5:

def f(*a, **k) = [a, k]
p f({x: 1})   # MRI [[{x: 1}], {}]   consumer [[], {x: 1}]   <- diverges
p f(x: 1)     # MRI [[], {x: 1}]     consumer [[], {x: 1}]
h = {y: 2}
p f(h)        # MRI [[{y: 2}], {}]   consumer [[{y: 2}], {}]  <- a VARIABLE is fine

Only the braced literal at a call site diverges, which is what a missing bit predicts.

The grammar says to record it here

MRI carries this exact bit, in this exact place, and I followed it rather than inventing a shape:

rule / function line in parse.y-ruby_4_0 what it does
primary : tLBRACE assoc_list '}' 4415-4419 sets RNODE_HASH($$)->nd_brace = TRUE
rb_node_hash_new 11784-11791 leaves nd_brace = 0 for every hash the grammar assembles itself
aryset_check 13669 reads !nd_brace back to mean "these are keywords"
parser_yylex, case '{' 11141-11153 picks tLBRACE (hash) vs '{' / tLBRACE_ARG (block) in the lexer
assoc : tDSTAR arg_value 6682-6686 **h, reached from inside braces and from a bare argument list alike

So parseHashLiteral sets Braced and addKwPair does not — one line each, mirroring MRI one to one.

The bit says only "written with braces", and is deliberately not conditional on being an argument, again as in MRI: x = {a: 1} is Braced too. A consumer asking "positional Hash argument?" asks Braced of a node it already found in Call.Args. Making the parser guess the call-site condition would put the consumer's question into the parser.

The bit takes both values

Measured by the tests added here. Braced is neither always true nor always false, and the two spellings that must differ do differ:

spelling Braced pairs note
f({k: 1}) true 1 positional Hash — the bug
f(k: 1) false 1 keywords — identical Keys/Values
f({}) true 0 empty braces still positional
f() no HashLit — so the contrast above is the brace, not the arg count
f(h) no HashLit — a variable was already read correctly
f({**h}) true 1
f(**h) false 1 same assoc: tDSTAR production, different form
f(k: 1, **h) false 2
f({1 => 2}) true 1 hashrocket
f(*a, {k: 1}) true 1 braced after a splat
f(a, k: 1) false 1
[1, {k: 2}] true 1 the array literal shares the argument grammar
[1, k: 2] false 1 …so it must stay distinguishable there too
x = {a: 1} true 1 braced, not an argument — as in MRI
obj.m {} no HashLit — the braces are a block
obj.m { |x| x } no HashLit —
obj.m({k: 1}) { |y| y } true 1 a real hash and a real block, both read right

obj.m {} is where a careless rule breaks the language, and ours cannot: a { after a call never reaches parseHashLiteral, structurally the same defence as MRI's lexer deciding before the grammar sees it. There is no HashLit there to mislabel, and a test asserts that.

Negative control: with Braced: true reverted to &ast.HashLit{}, 11 assertions in the new tests fail. The tests measure the change rather than passing alongside it.

Existing consumers are unaffected

  • No existing test was changed. git diff --name-only origin/main is ast/ast.go and parser.go only; the one new file is hash_braced_test.go.
  • Full suite green before and after (parser, ast, lexer, token), go vet ./... clean, gofmt -l clean on both changed files.
  • gofmt -l . does report paren_singleton_def_test.go — that is pre-existing on main (verified by formatting a pristine copy of origin/main:paren_singleton_def_test.go) and untouched here, so I left it rather than pad this diff.

The zero value is false, so anyone who does not read the field sees nothing.

Why this is a separate PR from #41

The two changes share no code: different structs, disjoint parser sites, no common helper. They are the same idea — one bit of written form, with RangeLit.Exclusive as precedent — but nothing would be reused by merging them, and split they can be reviewed and merged independently. See #41's PR for the other half.

What the consumer will need (separately)

Not in this PR. go-embedded-ruby/ruby needs a released parser version first, and a sibling wave is working there. When it picks this up: at a call site, a trailing *ast.HashLit argument with Braced == true is a positional argument and must not be spliced into keywords; only Braced == false is the keyword hash. f({}) must pass an empty positional Hash where f() passes nothing.

🤖 Generated with Claude Code

`f({k: 1})` and `f(k: 1)` built structurally identical `ast.HashLit`s, so
nothing downstream could tell a POSITIONAL Hash argument from KEYWORDS.
`ast.Call` carries no position either, so a consumer could not re-read the
source to recover the brace.

Add one bit of written form, following MRI, which records exactly this bit
in exactly this place:

  * `primary: tLBRACE assoc_list '}'` sets `RNODE_HASH($$)->nd_brace = TRUE`
    (parse.y-ruby_4_0:4415-4419);
  * `rb_node_hash_new` leaves it 0 for every hash the grammar assembles
    itself (parse.y-ruby_4_0:11784-11791);
  * `aryset_check` reads `!nd_brace` back to mean "keywords"
    (parse.y-ruby_4_0:13669).

So `parseHashLiteral` sets Braced and `addKwPair` does not. The bit says
only "written with braces" and is deliberately not conditional on being an
argument, again as in MRI: `x = {a: 1}` is Braced too. A consumer asking
"positional Hash argument?" asks Braced of a node it already found in
Call.Args.

`obj.m {}`, where the braces are a BLOCK, cannot be mislabelled: a `{`
after a call never reaches parseHashLiteral, structurally the same defence
as MRI's lexer, which picks tLBRACE (hash) or '{' / tLBRACE_ARG (block)
before the grammar sees it (parse.y-ruby_4_0:11141-11153). There is no
HashLit there to mislabel, and a test asserts it.

The zero value is false, so no existing consumer changes. No existing test
was modified; the full suite passes unchanged.

Closes #40

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit bc6e44a into main Sep 28, 2026
11 checks passed
@tannevaled
tannevaled deleted the wave-h-hashlit-braced branch September 28, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ast: HashLit cannot distinguish f({k: 1}) from f(k: 1)

1 participant