From cfb09e723b2fe6ee9f2285ab16ab9fec601fea3b Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 30 Sep 2026 08:36:57 +0200 Subject: [PATCH] parser: unary plus dispatches #+@, except on a numeric literal `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 --- parser.go | 20 ++++++++++++- unary_plus_test.go | 72 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 unary_plus_test.go diff --git a/parser.go b/parser.go index 35d7f60..1884760 100644 --- a/parser.go +++ b/parser.go @@ -3130,8 +3130,26 @@ func (p *Parser) parseUnary() ast.Node { return &ast.UnaryExpr{Op: "-", Operand: p.parseUnary()} case token.PLUS: p.advance() + // A plus directly before a NUMERIC LITERAL is absorbed into it, exactly as + // the minus above is: `+1` is the Integer 1 and dispatches nothing, which + // ruby 4.0.5 confirms -- with Integer#+@ redefined, `+1` still answers 1. + // + // Everything else dispatches #+@, per `arg: tUPLUS arg` calling + // call_uni_op(p, $2, idUPlus) (parse.y-ruby_4_0:3962-3966). The fold is + // LEXICAL, not semantic: measured on 4.0.5, `+(1)` and `+(1+1)` both + // dispatch, and so does a variable holding a number. Only the bare literal + // token is absorbed. + // + // Eliding it instead was wrong in a way that only became easy to hit with + // the frozen_string_literal pragma: `+"..."` is THE idiom for asking a + // frozen literal for a mutable copy, and eliding the operator handed back + // the frozen literal itself, so `buf = +"start"; buf << "-more"` raised + // FrozenError where ruby prints "start-more". + if p.is(token.INT) || p.is(token.FLOAT) { + return p.parsePostfixTail(p.parsePrimary()) + } defer p.permitCommand(false)() - return p.parseUnary() // unary plus is a no-op + return &ast.UnaryExpr{Op: "+@", Operand: p.parseUnary()} case token.BANG: p.advance() // `!` is the ONE unary operator whose operand may be a paren-less command diff --git a/unary_plus_test.go b/unary_plus_test.go new file mode 100644 index 0000000..20e8a4c --- /dev/null +++ b/unary_plus_test.go @@ -0,0 +1,72 @@ +package parser_test + +import ( + "testing" + + "github.com/go-ruby-parser/parser" + "github.com/go-ruby-parser/parser/ast" +) + +// TestUnaryPlusDispatchesExceptOnANumericLiteral pins MRI's rule, which is +// narrower than either "always fold" or "always dispatch". +// +// `arg: tUPLUS arg` calls call_uni_op(p, $2, idUPlus) (parse.y-ruby_4_0:3962), +// so unary plus is a #+@ send. The one exception is LEXICAL: a plus directly +// before a numeric literal token is absorbed into the literal, exactly as the +// minus beside it is. Measured on ruby 4.0.5 with Integer#+@, Float#+@ and +// String#+@ all 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 +// +// The parser used to elide unary plus entirely. That was invisible until the +// frozen_string_literal pragma made `+"..."` the ordinary way to ask a frozen +// literal for a mutable copy: eliding handed back the frozen literal, so +// `buf = +"start"; buf << "-more"` raised FrozenError where ruby prints +// "start-more". +func TestUnaryPlusDispatchesExceptOnANumericLiteral(t *testing.T) { + for _, tc := range []struct { + src string + wantUnary bool // a #+@ send, rather than the bare operand + }{ + {"+1", false}, + {"+1.5", false}, + {`+"lit"`, true}, + {"x = 1; +x", true}, + {"+(1)", true}, + {"+(1 + 1)", true}, + {"+foo", true}, + {"+@ivar", true}, + {"+[1]", true}, + } { + t.Run(tc.src, func(t *testing.T) { + n := lastNode(t, tc.src) + u, isUnary := n.(*ast.UnaryExpr) + if isUnary != tc.wantUnary { + t.Fatalf("%s: got %T, want unary=%v", tc.src, n, tc.wantUnary) + } + if isUnary && u.Op != "+@" { + t.Errorf("%s: operator is %q, want %q — it is the method name the "+ + "consumer sends, and must not collide with binary +", tc.src, u.Op, "+@") + } + }) + } +} + +// TestUnaryPlusOnALiteralKeepsItsPostfix: the folded form is still an ordinary +// literal, so a method call or an exponent binds to it as it would without the +// sign. `+2**3` is 8 and `+1.abs` is 1 on ruby 4.0.5. +func TestUnaryPlusOnALiteralKeepsItsPostfix(t *testing.T) { + if n := lastNode(t, "+1.abs"); n == nil { + t.Fatal("+1.abs did not parse") + } else if _, isUnary := n.(*ast.UnaryExpr); isUnary { + t.Errorf("+1.abs built a unary node; the literal should have absorbed the sign") + } + if _, err := parser.Parse("+2**3"); err != nil { + t.Errorf("+2**3 did not parse: %v", err) + } +}