Skip to content

parser: unary plus dispatches #+@, except on a numeric literal - #45

Merged
tannevaled merged 1 commit into
mainfrom
uplus-dispatch
Sep 30, 2026
Merged

tannevaled merged 1 commit into
mainfrom
uplus-dispatch

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

arg: tUPLUS arg calls call_uni_op(p, $2, idUPlus) (parse.y-ruby_4_0:3962), so unary plus is a #+@ send. This parser elided it entirely:

case token.PLUS:
    p.advance()
    return p.parseUnary() // unary plus is a no-op

MRI's rule is narrower than "always fold" or "always dispatch"

The exception is lexical. Measured on ruby 4.0.5 with Integer#+@, Float#+@ and String#+@ each redefined to return a marker:

source ruby 4.0.5
+1 1 the literal; nothing dispatched
+1.5 1.5 likewise
+"lit" the marker
+n the marker a variable holding a number still dispatches
+(1) the marker parenthesised, so no longer a bare literal token
+(1 + 1) the marker

So only a plus directly before a numeric literal token is absorbed — exactly as the minus beside it in parseUnary already was, and the two now read alike.

Why it matters now

The elision was invisible while every string literal was mutable. The frozen_string_literal pragma makes +"..." the ordinary way to ask a frozen literal for a mutable copy, and eliding the operator handed back the frozen literal itself:

# frozen_string_literal: true
buf = +"start"
buf << "-more"
ruby 4.0.5 "start-more"
with the elision FrozenError

A consumer is implementing that pragma now, which is how this surfaced.

Measured through the consumer

Built go-embedded-ruby/ruby at its current main with a local replace onto this branch:

  • all eight rows of the table above match ruby 4.0.5 byte for byte;
  • the precedence cases are unchanged — +2**3 is 8, +1.abs is 1, +"a".size is 1;
  • its conformance sweep (2,206 language/+core/ specs, real-path frame) moves core/string/uplus_spec.rb 2 → 3, with core/string/uminus_spec.rb unmoved as the control. Nothing else moved and nothing regressed.

The consumer's frozen-prelude drift test fires, as it should: the compiler now emits a send where it emitted nothing. Regenerating is 6 lines and belongs in the consumer's bump PR.

The node

Op is "+@" — the method name a consumer sends, so it cannot be confused with binary +. ~ already follows that convention.

Tests

Nine spellings across the fold/dispatch boundary, plus the postfix cases. Ablated: restoring the elision fails +"lit" and x = 1; +x.

(gofmt -l reports paren_singleton_def_test.go, which is pre-existing and untouched here.)

🤖 Generated with Claude Code

`arg: tUPLUS arg` calls call_uni_op(p, $2, idUPlus) (parse.y-ruby_4_0:3962), so
unary plus is a method send. This parser elided it entirely -- `return
p.parseUnary() // unary plus is a no-op`.

MRI's rule is narrower than either "always fold" or "always dispatch", and the
exception is LEXICAL. Measured on ruby 4.0.5 with Integer#+@, Float#+@ and
String#+@ each redefined to return a marker:

    +1        1            the literal; nothing dispatched
    +1.5      1.5          likewise
    +"lit"    the marker
    +n        the marker   a VARIABLE holding a number still dispatches
    +(1)      the marker   parenthesised, so no longer a bare literal token
    +(1+1)    the marker

So only a plus directly before a numeric literal token is absorbed, exactly as
the minus beside it already was.

WHY IT MATTERS NOW. The elision was invisible while every string literal was
mutable. The frozen_string_literal pragma makes `+"..."` the ordinary way to ask
a frozen literal for a mutable copy, and eliding the operator handed back the
frozen literal itself:

    # frozen_string_literal: true
    buf = +"start"
    buf << "-more"
      ruby 4.0.5   "start-more"
      elided       FrozenError

Measured through the consumer with a local replace: all eight rows of the table
above now match ruby 4.0.5 byte for byte, the precedence cases (`+2**3` is 8,
`+1.abs` is 1) are unchanged, and its conformance sweep moves
core/string/uplus_spec.rb 2 -> 3 with core/string/uminus_spec.rb unmoved as the
control. No other file moved and nothing regressed.

The operator on the node is "+@", the method name a consumer sends, so it cannot
be confused with binary +; `~` already follows that convention.

Ablated: restoring the elision fails the new test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 994aedb into main Sep 30, 2026
11 checks passed
@tannevaled
tannevaled deleted the uplus-dispatch branch September 30, 2026 10:57
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.

1 participant