diff --git a/cmd/freeze-prelude/main.go b/cmd/freeze-prelude/main.go index 2eb61890..0bb70492 100644 --- a/cmd/freeze-prelude/main.go +++ b/cmd/freeze-prelude/main.go @@ -3,9 +3,15 @@ // bytecode out as a Go literal (embeddedPrelude), so a closed-world binary can // load the standard library without linking the front-end. // -// Run from the module root (see the //go:generate directive in prelude.go): +// It finds the module root itself, so both spellings work: // -// go run ./cmd/freeze-prelude +// go run ./cmd/freeze-prelude # from the module root +// go generate ./internal/vm # which runs it FROM internal/vm +// +// That matters because the second is what TestEmbeddedPreludeMatchesSource +// tells you to run, and with paths taken relative to the working directory it +// could not work at all: go:generate runs a command in the directory of the +// file carrying the directive. // // TestEmbeddedPreludeMatchesSource fails if the committed file drifts from the // prelude source, prompting a regeneration. @@ -14,6 +20,7 @@ package main import ( "fmt" "os" + "path/filepath" "github.com/go-embedded-ruby/ruby/internal/aot" "github.com/go-embedded-ruby/ruby/internal/compiler" @@ -21,11 +28,36 @@ import ( ) const ( - srcPath = "internal/vm/prelude.rb" - outPath = "internal/vm/prelude_frozen_gen.go" + srcRel = "internal/vm/prelude.rb" + outRel = "internal/vm/prelude_frozen_gen.go" ) +// moduleRoot walks up from the working directory to the directory holding +// go.mod, so the paths above can be module-relative wherever this is invoked +// from. A missing go.mod is fatal rather than a silent fall back to the working +// directory: writing the generated file to the wrong place would leave the +// committed one stale while reporting success. +func moduleRoot() string { + dir, err := os.Getwd() + if err != nil { + fatal("getwd: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + fatal("no go.mod above %q: run this inside the module", dir) + } + dir = parent + } +} + func main() { + root := moduleRoot() + srcPath := filepath.Join(root, srcRel) + outPath := filepath.Join(root, outRel) src, err := os.ReadFile(srcPath) if err != nil { fatal("read %s: %v", srcPath, err) diff --git a/go.mod b/go.mod index 561861c0..2c93189a 100644 --- a/go.mod +++ b/go.mod @@ -129,7 +129,7 @@ require ( github.com/go-ruby-pagy/pagy v0.0.0-20260717071719-997d15eee011 github.com/go-ruby-paper-trail/paper-trail v0.0.0-20260717071745-42a249656e5a github.com/go-ruby-parquet/parquet v0.0.0-20260923211642-bc75bcb9031e - github.com/go-ruby-parser/parser v0.8.0 + github.com/go-ruby-parser/parser v0.9.0 github.com/go-ruby-pathname/pathname v0.0.0-20260916100446-0824484b665f github.com/go-ruby-pg/pg v0.0.0-20260916100523-3831ec811a67 github.com/go-ruby-prawn/prawn v0.0.0-20260829111617-4a543d91acca diff --git a/go.sum b/go.sum index 27e5e87f..bd3a4421 100644 --- a/go.sum +++ b/go.sum @@ -529,6 +529,8 @@ github.com/go-ruby-parquet/parquet v0.0.0-20260923211642-bc75bcb9031e h1:pxhP3nI github.com/go-ruby-parquet/parquet v0.0.0-20260923211642-bc75bcb9031e/go.mod h1:wC+J6qfQ8C0aKgi5qsbR03DYrLLuXXezUBHl967am/E= github.com/go-ruby-parser/parser v0.8.0 h1:QV+7YJqlWreeNjWgeg06jNaxI0awlWwerhzAhpwSzQk= github.com/go-ruby-parser/parser v0.8.0/go.mod h1:NRbX4dYcQ6CgcaSaMmu1dh95QuKGq+RdenUoWU73214= +github.com/go-ruby-parser/parser v0.9.0 h1:UKt69Ltd8w9ICfmxLU8wy4zYt5BPFse7krkop5dlJQU= +github.com/go-ruby-parser/parser v0.9.0/go.mod h1:NRbX4dYcQ6CgcaSaMmu1dh95QuKGq+RdenUoWU73214= github.com/go-ruby-pathname/pathname v0.0.0-20260916100446-0824484b665f h1:9pt89wlAp12vbCNGFYmbSqp/PJTHRwUx1yNDXW5hWRI= github.com/go-ruby-pathname/pathname v0.0.0-20260916100446-0824484b665f/go.mod h1:IvW5q5Kyl5l3hrYwiHylOEmk5hpGSo7XqcmUXBHF5H0= github.com/go-ruby-pg/pg v0.0.0-20260916100523-3831ec811a67 h1:X9MKhEAV60oYoL94tqb11lfLw3yW097mVqfjDdy5PK0= diff --git a/internal/bytecode/bytecode.go b/internal/bytecode/bytecode.go index 65778ecc..27403a75 100644 --- a/internal/bytecode/bytecode.go +++ b/internal/bytecode/bytecode.go @@ -249,11 +249,12 @@ const ( // a call site whose last argument is a Hash LITERAL — see below) keeps the // older "peel a trailing hash" behaviour rather than silently losing keywords. // - // The one shape it cannot yet decide is a braced hash literal: go-ruby-parser - // v0.3.0 parses `foo(1, 2, 3, {key: 42})` and `foo(1, 2, 3, key: 42)` into the - // SAME *ast.HashLit, with no record of the braces, so the compiler cannot tell - // them apart. Those sites are left unflagged (keywords), which is what rbgo - // did before. Distinguishing them needs a `Braced` bit on ast.HashLit upstream. + // A braced hash literal IS decided, since go-ruby-parser v0.9.0 carries + // ast.HashLit.Braced — MRI's own nd_brace bit (parse.y-ruby_4_0:4415-4419), + // which rb_node_hash_new leaves 0 for a hash the grammar assembled from bare + // `k: v` pairs (parse.y-ruby_4_0:11784-11791). So `foo(1, 2, 3, {key: 42})` is + // flagged positional and `foo(1, 2, 3, key: 42)` is not, exactly as MRI reads + // the same bit in aryset_check (parse.y-ruby_4_0:13669). FlagSendNoKW // FlagSendKWSplat marks an array-dispatch send (OpSendArray, OpSendArrayBlockArg, @@ -292,14 +293,15 @@ const ( // (vm_eval.c:927) then builds with rb_name_err_new instead of // rb_nomethod_err_new. #name and #receiver are set either way. // - // KNOWN GAP, measured on go-ruby-parser v0.8.0: `foo` and `foo()` parse to the - // IDENTICAL *ast.Call (Recv nil, Args empty, Block nil), so the compiler cannot - // tell a bare name from an empty argument list and this flag is set for both. - // Distinguishing them needs a `Paren` bit on ast.Call upstream — the same shape - // of gap as the `Braced` bit ast.HashLit needs for FlagSendNoKW above. The flag - // is therefore set on the shape the parser CAN prove is receiver-less, - // argument-less and block-less, and `nope()` reports NameError where MRI - // reports NoMethodError. + // Since go-ruby-parser v0.9.0 the compiler decides this EXACTLY, and the flag + // is the whole rule rather than an approximation of it. ast.Call.Paren + // separates `foo` from `foo()` — MRI's `fcall: operation` feeding + // `method_call: fcall paren_args` (parse.y-ruby_4_0:3572-3577 and 5242-5248) + // builds NEW_FCALL where a bare name reaching gettable builds NEW_VCALL — and + // the compiler also tests the shape of the NAME, because `operation` admits + // tFID: `foo!` and `foo?` are parenthesis-less FCALLs + // (parse.y-ruby_4_0:4370-4374 and 6696-6698). See compiler.vcallName. The VM + // therefore reads this flag alone; it holds no second copy of the rule. FlagSendVCall ) diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index aa49e822..9293f6e8 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -1264,7 +1264,7 @@ func (c *Compiler) rewriteAnonKwSplat(h *ast.HashLit) (*ast.HashLit, bool) { vals[i] = c.anonLocal("**", fwdKwName) } } - return &ast.HashLit{Keys: keys, Values: vals}, true + return &ast.HashLit{Keys: keys, Values: vals, Braced: h.Braced}, true } func (c *Compiler) compileCall(v *ast.Call) { @@ -1369,13 +1369,28 @@ func (c *Compiler) compileCall(v *ast.Call) { // block-pass is pulled out below, because the pull-out is an implementation // detail of how the value is stacked, not a change to what was written. noKW := lastArgIsPositional(callArgs) - // vcall marks MRI's NODE_VCALL shape: a receiver-less, argument-less, - // block-less name that the local-variable check above did NOT resolve - // (gettable's "method call without arguments", parse.y-ruby_4_0:13086). It - // travels on the send so a miss can raise NameError rather than NoMethodError - // — see bytecode.FlagSendVCall, which also records what this test cannot yet - // see (`foo` vs `foo()`). - vcall := v.Recv == nil && v.Block == nil && !v.Safe && len(v.Args) == 0 + // vcall marks MRI's NODE_VCALL shape: a receiver-less, PARENTHESIS-less, + // argument-less, block-less plain identifier that the local-variable check + // above did NOT resolve (gettable's "method call without arguments", + // parse.y-ruby_4_0:13086). It travels on the send so a miss can raise + // NameError rather than NoMethodError — see bytecode.FlagSendVCall. + // + // Every conjunct is one of MRI's productions, and each excludes a shape that + // is an FCALL instead: + // + // - !v.Paren: `fcall: operation` feeding `method_call: fcall paren_args` + // (parse.y-ruby_4_0:3572-3577 and 5242-5248) builds NEW_FCALL, so + // `nope()` raises NoMethodError where `nope` raises NameError. The bit is + // ast.Call.Paren, go-ruby-parser v0.9.0. + // - vcallName: gettable reaches NEW_VCALL only under case ID_LOCAL, i.e. + // for a plain tIDENTIFIER. A name ending in '!' or '?' lexes as tFID and + // `primary: tFID` builds NEW_FCALL (parse.y-ruby_4_0:4370-4374, 6696-6698). + // - v.Block == nil: `primary: fcall brace_block` + // (parse.y-ruby_4_0:4458-4463) is an FCALL too. + // + // Measured on ruby 4.0.5 under BOTH `--parser=parse.y` and `--parser=prism`, + // which agree on all of it. + vcall := v.Recv == nil && v.Block == nil && !v.Safe && !v.Paren && len(v.Args) == 0 && vcallName(v.Name) sendFlags := func(at int) int { if explicit { b.insns[at].Flags |= bytecode.FlagSendExplicit @@ -1755,7 +1770,7 @@ func (c *Compiler) compileSplatItemsMode(items []ast.Node, keepEmptyKwSplat bool b.emit(bytecode.OpConcatArray, 0, 0) continue } - if h, ok := it.(*ast.HashLit); ok && i == len(items)-1 && isKwSplatHash(h) { + if h, ok := it.(*ast.HashLit); ok && i == len(items)-1 && !h.Braced && isKwSplatHash(h) { c.appendKwSplat(h, keepEmptyKwSplat) continue } @@ -1792,6 +1807,24 @@ func (c *Compiler) appendKwSplat(h *ast.HashLit, keepEmpty bool) { b.patch(done, b.here()) } +// vcallName reports whether name can be MRI's NODE_VCALL at all: gettable +// reaches NEW_VCALL only under case ID_LOCAL (parse.y-ruby_4_0:13086), i.e. for +// a plain tIDENTIFIER. A name ending in '!' or '?' lexes as tFID and +// `primary: tFID` builds NEW_FCALL instead (parse.y-ruby_4_0:4370-4374), so +// `nope!` and `nope?` raise NoMethodError; a setter name (`nope=`) is likewise +// never a bare identifier. A capitalised bare name is ID_CONST and never reaches +// a send at all, so it needs no test here. +func vcallName(name string) bool { + if name == "" { + return false + } + switch name[len(name)-1] { + case '!', '?', '=': + return false + } + return true +} + // lastArgIsPositional reports whether the LAST argument of a call is // syntactically a positional value — so a Hash arriving there must stay a // positional argument and must not be re-read as keyword arguments. @@ -1808,18 +1841,28 @@ func (c *Compiler) appendKwSplat(h *ast.HashLit, keepEmpty bool) { // anywhere in the list), so it is skipped. An empty list has no last argument // and answers false: there is nothing to protect. // -// It answers false for an *ast.HashLit because go-ruby-parser v0.3.0 gives -// `f(k: 1)` and `f({k: 1})` the SAME node with no record of the braces. Both are -// therefore left to the older behaviour — keywords — which is what `f(k: 1)` -// needs and what `f({k: 1})` got before. Fixing that shape needs a `Braced` bit -// on ast.HashLit upstream; see FlagSendNoKW. +// A trailing *ast.HashLit answers from ast.HashLit.Braced (go-ruby-parser +// v0.9.0), which records the WRITTEN FORM exactly as MRI's own parser does: +// `primary: tLBRACE assoc_list '}'` sets RNODE_HASH(...)->nd_brace = TRUE +// (parse.y-ruby_4_0:4415-4419) while rb_node_hash_new leaves it 0 for every hash +// the grammar assembles from bare `k: v` pairs (parse.y-ruby_4_0:11784-11791). +// The braced spelling is a POSITIONAL Hash and the bare one is KEYWORDS — MRI +// reads the same bit back for exactly that question in aryset_check +// (parse.y-ruby_4_0:13669). Measured on ruby 4.0.5 under both parsers: +// +// def f(*a, **k) = [a, k] +// f({x: 1}) # [[{x: 1}], {}] positional +// f(x: 1) # [[], {x: 1}] keywords +// f({}) # [[{}], {}] an empty POSITIONAL hash, unlike f() func lastArgIsPositional(args []ast.Node) bool { for i := len(args) - 1; i >= 0; i-- { if _, isBP := args[i].(*ast.BlockPass); isBP { continue } - _, isHash := args[i].(*ast.HashLit) - return !isHash + if h, isHash := args[i].(*ast.HashLit); isHash { + return h.Braced + } + return true } return false } @@ -1827,12 +1870,17 @@ func lastArgIsPositional(args []ast.Node) bool { // hasTrailingKwSplat reports whether the last argument is a keyword-splat hash // (a HashLit carrying at least one `**` entry, key == nil), which the call path // must build dynamically so an empty splat is dropped. +// +// A BRACED hash is never that, whatever it contains: `f({**h})` writes a +// positional Hash whose contents happen to come from a splat, and MRI passes it +// as one — `f({**h})` gives [[{y: 2}], {}] where `f(**h)` gives [[], {y: 2}] +// (ruby 4.0.5, both parsers). See ast.HashLit.Braced. func hasTrailingKwSplat(items []ast.Node) bool { if len(items) == 0 { return false } h, ok := items[len(items)-1].(*ast.HashLit) - return ok && isKwSplatHash(h) + return ok && !h.Braced && isKwSplatHash(h) } // isKwSplatHash reports whether a HashLit contains a `**splat` entry (a nil key). diff --git a/internal/compiler/wave29_kwargs_test.go b/internal/compiler/wave29_kwargs_test.go index 23219a05..3fa7e792 100644 --- a/internal/compiler/wave29_kwargs_test.go +++ b/internal/compiler/wave29_kwargs_test.go @@ -46,9 +46,10 @@ func sendFlagsOf(t *testing.T, src string) int { // TestSendNoKWFlagAtTheCallSite pins the compile-time half of MRI's // keyword/positional decision: FlagSendNoKW is set exactly when the last written -// argument is something other than a hash literal, which is the only case -// go-ruby-parser v0.3.0 lets the compiler decide (setup_parameters_complex reads -// VM_CALL_KWARG / VM_CALL_KW_SPLAT, vm_args.c v3_4_0:591). +// argument is something other than a BARE `k: v` hash — a braced `{k: v}` is a +// positional Hash and is flagged (ast.HashLit.Braced, go-ruby-parser v0.9.0; +// setup_parameters_complex reads VM_CALL_KWARG / VM_CALL_KW_SPLAT, +// vm_args.c v3_4_0:591). func TestSendNoKWFlagAtTheCallSite(t *testing.T) { for _, tc := range []struct { src string @@ -57,7 +58,7 @@ func TestSendNoKWFlagAtTheCallSite(t *testing.T) { }{ {src: "f(1, 2, h)", noKW: true}, // a local/method value: positional {src: "f(1, 2, k: 42)", noKW: false}, // literal keywords - {src: "f(1, 2, {k: 42})", noKW: false}, // SAME AST as the line above (parser gap) + {src: "f(1, 2, {k: 42})", noKW: true}, // BRACED: a positional Hash, not keywords {src: "f(1, 2, **h)", noKW: false, kwspl: true}, // a keyword splat {src: "f(*a)", noKW: true}, // a splat is not keywords {src: "f(*a, h)", noKW: true}, // splat + positional tail @@ -112,7 +113,10 @@ func TestLastArgIsPositional(t *testing.T) { t.Error("an integer literal: want true") } if lastArgIsPositional([]ast.Node{&ast.HashLit{}}) { - t.Error("a hash literal: want false (the parser cannot say whether it was braced)") + t.Error("a BARE hash literal (Braced false): want false — those are keywords") + } + if !lastArgIsPositional([]ast.Node{&ast.HashLit{Braced: true}}) { + t.Error("a BRACED hash literal: want true — `f({})` passes a positional Hash") } } diff --git a/internal/compiler/wave_e_cpath_vcall_test.go b/internal/compiler/wave_e_cpath_vcall_test.go index 4200b32a..8c302689 100644 --- a/internal/compiler/wave_e_cpath_vcall_test.go +++ b/internal/compiler/wave_e_cpath_vcall_test.go @@ -88,7 +88,10 @@ func TestWaveECPathProductionsAreThreeShapes(t *testing.T) { // (parse.y-ruby_4_0:13086, gettable's "method call without arguments"). // // The false rows are the known-bad control: a probe that set the flag on every -// send would pass every true row and fail all four false ones. +// send would pass every true row and fail all five false ones. `nope_xyz()` is +// what ast.Call.Paren buys (go-ruby-parser v0.9.0): before it, the compiler +// could not see the parentheses and flagged that row true. The tFID names +// `nope_xyz!` / `nope_xyz?` are pinned in TestWaveIVCallFlagIsNotSetForATFID. func TestWaveEVCallFlag(t *testing.T) { tests := []struct { name string @@ -97,6 +100,7 @@ func TestWaveEVCallFlag(t *testing.T) { }{ {"bare_name", "nope_xyz", true}, {"bare_name_in_method", "def m; nope_xyz; end", true}, + {"empty_parens", "nope_xyz()", false}, // `fcall paren_args` is an FCALL {"with_argument", "nope_xyz 1", false}, {"with_paren_argument", "nope_xyz(1)", false}, {"with_block", "nope_xyz {}", false}, diff --git a/internal/compiler/wave_i_braced_paren_test.go b/internal/compiler/wave_i_braced_paren_test.go new file mode 100644 index 00000000..1c3de314 --- /dev/null +++ b/internal/compiler/wave_i_braced_paren_test.go @@ -0,0 +1,118 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package compiler + +import ( + "testing" + + "github.com/go-embedded-ruby/ruby/internal/bytecode" + "github.com/go-ruby-parser/parser/ast" +) + +// TestWaveIVCallName drives the predicate directly over the shapes the Ruby +// sources cannot reach. gettable makes a VCALL only under case ID_LOCAL +// (parse.y-ruby_4_0:13086); a '!' or '?' suffix lexes as tFID and +// `primary: tFID` makes an FCALL (parse.y-ruby_4_0:4370-4374). +func TestWaveIVCallName(t *testing.T) { + for name, want := range map[string]bool{ + "nope": true, "_x": true, "nope!": false, "nope?": false, + "nope=": false, "": false, "+": true, + } { + if got := vcallName(name); got != want { + t.Errorf("vcallName(%q) = %v, want %v", name, got, want) + } + } +} + +// TestWaveIBracedDecidesTheKeywordFlags pins both call-site flags onto +// ast.HashLit.Braced. The pairs differ only by the braces, so a compiler that +// ignored the bit again would fail exactly the braced rows. +func TestWaveIBracedDecidesTheKeywordFlags(t *testing.T) { + for _, tc := range []struct { + src string + noKW bool + kwspl bool + }{ + {src: "f({k: 42})", noKW: true}, // braced: a positional Hash + {src: "f(k: 42)", noKW: false}, // bare: keywords + {src: "f({})", noKW: true}, // an empty POSITIONAL Hash + {src: "f({**h})", noKW: true}, // braced, so NOT a keyword splat + {src: "f(**h)", noKW: false, kwspl: true}, // bare: a keyword splat + {src: "f(1, {k: 42})", noKW: true}, // + {src: "f(*a, {k: 42})", noKW: true}, // + {src: "f(*a, k: 42)", noKW: false}, // + {src: "f({k: 42}, &blk)", noKW: true}, // a block-pass is not an argument + {src: "o.m({k: 42})", noKW: true}, // an explicit receiver changes nothing + {src: "yield({k: 42})", noKW: true}, // yield is an ordinary call site + {src: "yield(k: 42)", noKW: false}, // + {src: "def m; super({k: 1}); end", noKW: true}, // and so is an explicit super + {src: "def m; super(k: 1); end", noKW: false}, // + {src: "def m; super({**h}); end", noKW: true}, // + {src: "def m; super(**h); end", kwspl: true}, // + } { + src := tc.src + if len(src) > 6 && src[:6] == "def m;" { + src = "class K\n" + src + "\nend" + } + flags := sendFlagsOf(t, src) + if got := flags&bytecode.FlagSendNoKW != 0; got != tc.noKW { + t.Errorf("%q: FlagSendNoKW = %v, want %v", tc.src, got, tc.noKW) + } + if got := flags&bytecode.FlagSendKWSplat != 0; got != tc.kwspl { + t.Errorf("%q: FlagSendKWSplat = %v, want %v", tc.src, got, tc.kwspl) + } + } +} + +// TestWaveIHasTrailingKwSplatConsultsBraced drives the helper directly: `{**h}` +// and `**h` build HashLits that differ only in Braced, and only the second is a +// keyword splat. +func TestWaveIHasTrailingKwSplatConsultsBraced(t *testing.T) { + kw := func(braced bool) []ast.Node { + return []ast.Node{&ast.HashLit{ + Keys: []ast.Node{nil}, + Values: []ast.Node{&ast.VarRef{Name: "h"}}, + Braced: braced, + }} + } + if !hasTrailingKwSplat(kw(false)) { + t.Error("`**h`: want a trailing keyword splat") + } + if hasTrailingKwSplat(kw(true)) { + t.Error("`{**h}`: want NO keyword splat — it is a positional Hash") + } +} + +// TestWaveIVCallFlagIsNotSetForATFID: a name ending in '!' or '?' lexes as tFID +// and `primary: tFID` builds NEW_FCALL (parse.y-ruby_4_0:4370-4374), so the flag +// must stay clear even though the call site's SHAPE is the VCALL shape. The +// plain name beside them is the control: it must still be set. +func TestWaveIVCallFlagIsNotSetForATFID(t *testing.T) { + for _, tc := range []struct { + src string + want bool + }{ + {"nope_xyz!", false}, + {"nope_xyz?", false}, + {"nope_xyz", true}, + } { + iseq := compileSrc(t, tc.src) + found := false + for _, in := range iseq.Insns { + switch in.Op { + case bytecode.OpSend, bytecode.OpSendArray, bytecode.OpSendBlockArg, bytecode.OpSendArrayBlockArg: + if in.A < len(iseq.Names) && iseq.Names[in.A] == tc.src { + found = true + if got := in.Flags&bytecode.FlagSendVCall != 0; got != tc.want { + t.Errorf("%s: FlagSendVCall = %v, want %v", tc.src, got, tc.want) + } + } + } + } + if !found { + t.Fatalf("%s: no send of %s was emitted", tc.src, tc.src) + } + } +} diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 7833d146..aee962e4 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -1084,8 +1084,15 @@ func (vm *VM) bootstrap() { // (vm_eval.c:927) then builds with rb_name_err_new rather than // rb_nomethod_err_new. #name and #receiver are set either way; #args is not, // and a VCALL carries no arguments, so callArgs is nil here regardless. + // + // bytecode.FlagSendVCall is now the WHOLE rule and this reads it alone. It + // used to be re-checked here against the shape of the NAME, because the + // compiler could not see the parentheses and set the flag for `nope!` and + // `nope?` as well; since go-ruby-parser v0.9.0 gave it ast.Call.Paren the + // compiler states the rule exactly (compiler.vcallName), and a second copy + // of it here would be two guards for one rule. cls, undef := "NoMethodError", "undefined method '" - if vm.missingVCall && vcallNameShape(string(nameSym)) { + if vm.missingVCall { cls, undef = "NameError", "undefined local variable or method '" } vm.missingVCall = false @@ -12466,33 +12473,3 @@ func (vm *VM) cmpIntValue(v object.Value) int { } return 0 } - -// vcallNameShape reports whether name can be MRI's NODE_VCALL at all. Two -// grammar rules decide it and the VM has to honour both, because -// bytecode.FlagSendVCall records only the SHAPE of the call site (no receiver, -// no parentheses, no arguments, no block) and not the shape of the NAME: -// -// - gettable reaches NEW_VCALL only under case ID_LOCAL (parse.y-ruby_4_0:13086), -// i.e. for a plain tIDENTIFIER; -// - a name ending in '!' or '?' lexes as tFID, and `primary : tFID` builds -// NEW_FCALL instead (parse.y-ruby_4_0:4370). -// -// So `nope!` and `nope?` are FCALLs and raise NoMethodError. Measured on ruby -// 4.0.5 under BOTH parsers — `ruby --parser=parse.y` and `ruby --parser=prism` -// agree here — so this is not one of the places where the two instruments -// differ. A setter name (`nope=`) is likewise never a bare identifier. -// -// The test belongs in the compiler, beside the shape test that sets the flag -// (internal/compiler/compiler.go); it is here because the compiler is outside -// this change's file cluster, and it costs nothing on the hot path because it -// runs only where an error is already being raised. -func vcallNameShape(name string) bool { - if name == "" { - return false - } - switch name[len(name)-1] { - case '!', '?', '=': - return false - } - return true -} diff --git a/internal/vm/prelude_frozen_gen.go b/internal/vm/prelude_frozen_gen.go index 040bd27e..d2d13f57 100644 --- a/internal/vm/prelude_frozen_gen.go +++ b/internal/vm/prelude_frozen_gen.go @@ -3027,7 +3027,7 @@ func embeddedPrelude() *bytecode.ISeq { Children: []*bytecode.ISeq{ &bytecode.ISeq{ Name: "numerator", - Insns: []bytecode.Instr{{Op: 5}, {Op: 30, Flags: 8}, {Op: 28, A: 7}, {Op: 5}, {Op: 30, A: 1, Flags: 8}, {Op: 30, A: 2, Flags: 1}, {Op: 26, A: 8}, {Op: 5}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 5}, {Op: 30}, {Op: 28, A: 7}, {Op: 5}, {Op: 30, A: 1, Flags: 8}, {Op: 30, A: 2, Flags: 1}, {Op: 26, A: 8}, {Op: 5}, {Op: 53}}, Names: []string{"finite?", "to_r", "numerator"}, NumRequired: 0, SplatIndex: -1, @@ -3041,7 +3041,7 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "denominator", - Insns: []bytecode.Instr{{Op: 5}, {Op: 30, Flags: 8}, {Op: 28, A: 7}, {Op: 5}, {Op: 30, A: 1, Flags: 8}, {Op: 30, A: 2, Flags: 1}, {Op: 26, A: 8}, {Op: 1}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 5}, {Op: 30}, {Op: 28, A: 7}, {Op: 5}, {Op: 30, A: 1, Flags: 8}, {Op: 30, A: 2, Flags: 1}, {Op: 26, A: 8}, {Op: 1}, {Op: 53}}, Consts: []object.Value{ object.IntValue(1), }, @@ -3131,7 +3131,7 @@ func embeddedPrelude() *bytecode.ISeq { Children: []*bytecode.ISeq{ &bytecode.ISeq{ Name: "==", - Insns: []bytecode.Instr{{Op: 5}, {Op: 11}, {Op: 30, B: 1, Flags: 2}, {Op: 28, A: 7}, {Op: 3}, {Op: 53}, {Op: 26, A: 8}, {Op: 2}, {Op: 9}, {Op: 11}, {Op: 33, A: 1}, {Op: 30, A: 2, B: 1, Flags: 3}, {Op: 25}, {Op: 28, A: 17}, {Op: 4}, {Op: 53}, {Op: 26, A: 18}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 30, A: 3}, {Op: 11}, {Op: 30, A: 3, Flags: 1}, {Op: 22}, {Op: 10}, {Op: 28, A: 32}, {Op: 9}, {Op: 5}, {Op: 30, A: 4}, {Op: 11}, {Op: 30, A: 4, Flags: 1}, {Op: 22}, {Op: 10}, {Op: 28, A: 40}, {Op: 9}, {Op: 5}, {Op: 30, A: 5, Flags: 8}, {Op: 11}, {Op: 30, A: 5, Flags: 1}, {Op: 22}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 5}, {Op: 11}, {Op: 30, B: 1, Flags: 2}, {Op: 28, A: 7}, {Op: 3}, {Op: 53}, {Op: 26, A: 8}, {Op: 2}, {Op: 9}, {Op: 11}, {Op: 33, A: 1}, {Op: 30, A: 2, B: 1, Flags: 3}, {Op: 25}, {Op: 28, A: 17}, {Op: 4}, {Op: 53}, {Op: 26, A: 18}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 30, A: 3}, {Op: 11}, {Op: 30, A: 3, Flags: 1}, {Op: 22}, {Op: 10}, {Op: 28, A: 32}, {Op: 9}, {Op: 5}, {Op: 30, A: 4}, {Op: 11}, {Op: 30, A: 4, Flags: 1}, {Op: 22}, {Op: 10}, {Op: 28, A: 40}, {Op: 9}, {Op: 5}, {Op: 30, A: 5}, {Op: 11}, {Op: 30, A: 5, Flags: 1}, {Op: 22}, {Op: 53}}, Names: []string{"equal?", "Range", "is_a?", "begin", "end", "exclude_end?"}, Params: []string{"other"}, NumRequired: 1, @@ -3147,7 +3147,7 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "eql?", - Insns: []bytecode.Instr{{Op: 5}, {Op: 11}, {Op: 30, B: 1, Flags: 2}, {Op: 28, A: 7}, {Op: 3}, {Op: 53}, {Op: 26, A: 8}, {Op: 2}, {Op: 9}, {Op: 11}, {Op: 33, A: 1}, {Op: 30, A: 2, B: 1, Flags: 3}, {Op: 25}, {Op: 28, A: 17}, {Op: 4}, {Op: 53}, {Op: 26, A: 18}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 30, A: 3}, {Op: 11}, {Op: 30, A: 3, Flags: 1}, {Op: 30, A: 4, B: 1, Flags: 3}, {Op: 10}, {Op: 28, A: 32}, {Op: 9}, {Op: 5}, {Op: 30, A: 5}, {Op: 11}, {Op: 30, A: 5, Flags: 1}, {Op: 30, A: 4, B: 1, Flags: 3}, {Op: 10}, {Op: 28, A: 40}, {Op: 9}, {Op: 5}, {Op: 30, A: 6, Flags: 8}, {Op: 11}, {Op: 30, A: 6, Flags: 1}, {Op: 22}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 5}, {Op: 11}, {Op: 30, B: 1, Flags: 2}, {Op: 28, A: 7}, {Op: 3}, {Op: 53}, {Op: 26, A: 8}, {Op: 2}, {Op: 9}, {Op: 11}, {Op: 33, A: 1}, {Op: 30, A: 2, B: 1, Flags: 3}, {Op: 25}, {Op: 28, A: 17}, {Op: 4}, {Op: 53}, {Op: 26, A: 18}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 30, A: 3}, {Op: 11}, {Op: 30, A: 3, Flags: 1}, {Op: 30, A: 4, B: 1, Flags: 3}, {Op: 10}, {Op: 28, A: 32}, {Op: 9}, {Op: 5}, {Op: 30, A: 5}, {Op: 11}, {Op: 30, A: 5, Flags: 1}, {Op: 30, A: 4, B: 1, Flags: 3}, {Op: 10}, {Op: 28, A: 40}, {Op: 9}, {Op: 5}, {Op: 30, A: 6}, {Op: 11}, {Op: 30, A: 6, Flags: 1}, {Op: 22}, {Op: 53}}, Names: []string{"equal?", "Range", "is_a?", "begin", "eql?", "end", "exclude_end?"}, Params: []string{"other"}, NumRequired: 1, @@ -3163,7 +3163,7 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "hash", - Insns: []bytecode.Instr{{Op: 5}, {Op: 30}, {Op: 5}, {Op: 30, A: 1}, {Op: 5}, {Op: 30, A: 2, Flags: 8}, {Op: 6, A: 3}, {Op: 30, A: 3, Flags: 1}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 5}, {Op: 30}, {Op: 5}, {Op: 30, A: 1}, {Op: 5}, {Op: 30, A: 2}, {Op: 6, A: 3}, {Op: 30, A: 3, Flags: 1}, {Op: 53}}, Names: []string{"begin", "end", "exclude_end?", "hash"}, NumRequired: 0, SplatIndex: -1, @@ -3606,7 +3606,7 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "replace", - Insns: []bytecode.Instr{{Op: 11}, {Op: 33}, {Op: 30, A: 1, B: 1, Flags: 3}, {Op: 28, A: 12}, {Op: 5}, {Op: 11}, {Op: 30, A: 2, Flags: 1}, {Op: 30, A: 3, B: 1, Flags: 2}, {Op: 9}, {Op: 11}, {Op: 30, A: 4, C: 1, Flags: 1}, {Op: 26, A: 38}, {Op: 11}, {Op: 1}, {Op: 30, A: 5, B: 1, Flags: 3}, {Op: 10}, {Op: 27, A: 21}, {Op: 9}, {Op: 11}, {Op: 1, A: 1}, {Op: 30, A: 5, B: 1, Flags: 3}, {Op: 25}, {Op: 28, A: 28}, {Op: 5}, {Op: 33, A: 6}, {Op: 1, A: 2}, {Op: 30, A: 7, B: 2, Flags: 2}, {Op: 26, A: 29}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 5}, {Op: 30, A: 2, Flags: 8}, {Op: 30, A: 3, B: 1, Flags: 2}, {Op: 9}, {Op: 5}, {Op: 11}, {Op: 30, A: 8, B: 1, C: 2, Flags: 2}, {Op: 9}, {Op: 5}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 11}, {Op: 33}, {Op: 30, A: 1, B: 1, Flags: 3}, {Op: 28, A: 12}, {Op: 5}, {Op: 11}, {Op: 30, A: 2, Flags: 1}, {Op: 30, A: 3, B: 1, Flags: 2}, {Op: 9}, {Op: 11}, {Op: 30, A: 4, C: 1, Flags: 1}, {Op: 26, A: 38}, {Op: 11}, {Op: 1}, {Op: 30, A: 5, B: 1, Flags: 3}, {Op: 10}, {Op: 27, A: 21}, {Op: 9}, {Op: 11}, {Op: 1, A: 1}, {Op: 30, A: 5, B: 1, Flags: 3}, {Op: 25}, {Op: 28, A: 28}, {Op: 5}, {Op: 33, A: 6}, {Op: 1, A: 2}, {Op: 30, A: 7, B: 2, Flags: 2}, {Op: 26, A: 29}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 5}, {Op: 30, A: 2}, {Op: 30, A: 3, B: 1, Flags: 2}, {Op: 9}, {Op: 5}, {Op: 11}, {Op: 30, A: 8, B: 1, C: 2, Flags: 2}, {Op: 9}, {Op: 5}, {Op: 53}}, Consts: []object.Value{ object.Symbol("each_entry"), object.Symbol("each"), @@ -3661,7 +3661,7 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "reset", - Insns: []bytecode.Instr{{Op: 5}, {Op: 30, Flags: 8}, {Op: 12}, {Op: 9}, {Op: 5}, {Op: 5}, {Op: 30, A: 1, Flags: 8}, {Op: 30, A: 2, B: 1, Flags: 2}, {Op: 9}, {Op: 11}, {Op: 30, A: 3, C: 1, Flags: 1}, {Op: 9}, {Op: 5}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 5}, {Op: 30, Flags: 8}, {Op: 12}, {Op: 9}, {Op: 5}, {Op: 5}, {Op: 30, A: 1}, {Op: 30, A: 2, B: 1, Flags: 2}, {Op: 9}, {Op: 11}, {Op: 30, A: 3, C: 1, Flags: 1}, {Op: 9}, {Op: 5}, {Op: 53}}, Names: []string{"to_a", "compare_by_identity?", "__replace_cbi", "each"}, NumRequired: 0, SplatIndex: -1, @@ -3863,7 +3863,7 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "==", - Insns: []bytecode.Instr{{Op: 5}, {Op: 11}, {Op: 30, B: 1, Flags: 2}, {Op: 28, A: 7}, {Op: 3}, {Op: 53}, {Op: 26, A: 8}, {Op: 2}, {Op: 9}, {Op: 11}, {Op: 33, A: 1}, {Op: 30, A: 2, B: 1, Flags: 3}, {Op: 25}, {Op: 28, A: 17}, {Op: 4}, {Op: 53}, {Op: 26, A: 18}, {Op: 2}, {Op: 9}, {Op: 11}, {Op: 1}, {Op: 30, A: 3, B: 1, Flags: 3}, {Op: 10}, {Op: 28, A: 30}, {Op: 9}, {Op: 5}, {Op: 30, A: 4, Flags: 8}, {Op: 11}, {Op: 30, A: 4, Flags: 1}, {Op: 23}, {Op: 28, A: 34}, {Op: 4}, {Op: 53}, {Op: 26, A: 35}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 30, A: 5, Flags: 8}, {Op: 11}, {Op: 30, A: 5, Flags: 1}, {Op: 22}, {Op: 25}, {Op: 28, A: 46}, {Op: 4}, {Op: 53}, {Op: 26, A: 47}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 30, A: 6, C: 1}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 5}, {Op: 11}, {Op: 30, B: 1, Flags: 2}, {Op: 28, A: 7}, {Op: 3}, {Op: 53}, {Op: 26, A: 8}, {Op: 2}, {Op: 9}, {Op: 11}, {Op: 33, A: 1}, {Op: 30, A: 2, B: 1, Flags: 3}, {Op: 25}, {Op: 28, A: 17}, {Op: 4}, {Op: 53}, {Op: 26, A: 18}, {Op: 2}, {Op: 9}, {Op: 11}, {Op: 1}, {Op: 30, A: 3, B: 1, Flags: 3}, {Op: 10}, {Op: 28, A: 30}, {Op: 9}, {Op: 5}, {Op: 30, A: 4}, {Op: 11}, {Op: 30, A: 4, Flags: 1}, {Op: 23}, {Op: 28, A: 34}, {Op: 4}, {Op: 53}, {Op: 26, A: 35}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 30, A: 5, Flags: 8}, {Op: 11}, {Op: 30, A: 5, Flags: 1}, {Op: 22}, {Op: 25}, {Op: 28, A: 46}, {Op: 4}, {Op: 53}, {Op: 26, A: 47}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 30, A: 6, C: 1}, {Op: 53}}, Consts: []object.Value{ object.Symbol("compare_by_identity?"), }, @@ -4437,7 +4437,7 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "flatten", - Insns: []bytecode.Instr{{Op: 5}, {Op: 30}, {Op: 30, A: 1, Flags: 1}, {Op: 5}, {Op: 7}, {Op: 30, A: 2, B: 2, Flags: 1}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 5}, {Op: 30}, {Op: 30, A: 1, Flags: 1}, {Op: 5}, {Op: 7}, {Op: 30, A: 2, B: 2, Flags: 3}, {Op: 53}}, Names: []string{"class", "new", "flatten_merge"}, NumRequired: 0, SplatIndex: -1, @@ -4720,7 +4720,7 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "release", - Insns: []bytecode.Instr{{Op: 5}, {Op: 30, Flags: 8}, {Op: 25}, {Op: 28, A: 7}, {Op: 5}, {Op: 53}, {Op: 26, A: 8}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 30, A: 1, Flags: 8}, {Op: 12}, {Op: 9}, {Op: 11}, {Op: 30, A: 2, Flags: 1}, {Op: 25}, {Op: 10}, {Op: 28, A: 24}, {Op: 9}, {Op: 11}, {Op: 30, A: 3, Flags: 1}, {Op: 33, A: 4}, {Op: 30, A: 5, B: 1, Flags: 3}, {Op: 25}, {Op: 28, A: 29}, {Op: 11}, {Op: 30, A: 6, Flags: 1}, {Op: 9}, {Op: 26, A: 13}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 30, A: 7}, {Op: 11}, {Op: 1}, {Op: 30, A: 8, B: 1, Flags: 3}, {Op: 30, A: 9, B: 1, Flags: 3}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 5}, {Op: 30}, {Op: 25}, {Op: 28, A: 7}, {Op: 5}, {Op: 53}, {Op: 26, A: 8}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 30, A: 1, Flags: 8}, {Op: 12}, {Op: 9}, {Op: 11}, {Op: 30, A: 2, Flags: 1}, {Op: 25}, {Op: 10}, {Op: 28, A: 24}, {Op: 9}, {Op: 11}, {Op: 30, A: 3, Flags: 1}, {Op: 33, A: 4}, {Op: 30, A: 5, B: 1, Flags: 3}, {Op: 25}, {Op: 28, A: 29}, {Op: 11}, {Op: 30, A: 6, Flags: 1}, {Op: 9}, {Op: 26, A: 13}, {Op: 2}, {Op: 9}, {Op: 5}, {Op: 30, A: 7}, {Op: 11}, {Op: 1}, {Op: 30, A: 8, B: 1, Flags: 3}, {Op: 30, A: 9, B: 1, Flags: 3}, {Op: 53}}, Consts: []object.Value{ object.NewString("."), }, @@ -6143,7 +6143,7 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "relative?", - Insns: []bytecode.Instr{{Op: 5}, {Op: 30, Flags: 8}, {Op: 25}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 5}, {Op: 30}, {Op: 25}, {Op: 53}}, Names: []string{"absolute?"}, NumRequired: 0, SplatIndex: -1, diff --git a/internal/vm/wave_g_vcall_test.go b/internal/vm/wave_g_vcall_test.go index ceff0ec6..fb25f5e3 100644 --- a/internal/vm/wave_g_vcall_test.go +++ b/internal/vm/wave_g_vcall_test.go @@ -232,12 +232,17 @@ func TestWaveGVCallVerdictIsClearedForACompiledBody(t *testing.T) { } } -// TestWaveGVCallNameShape pins the half of MRI's rule that the call-site SHAPE -// cannot express. `nope!` and `nope?` are written with no receiver, no -// parentheses, no arguments and no block — so bytecode.FlagSendVCall is set for -// them — yet they lex as tFID and `primary : tFID` builds NEW_FCALL -// (parse.y-ruby_4_0:4370), while gettable reaches NEW_VCALL only for a plain -// tIDENTIFIER (parse.y-ruby_4_0:13086). Both MRI 4.0.5 parsers were run: +// TestWaveGVCallNameShape pins, end to end, the half of MRI's rule that the +// call-site SHAPE cannot express. `nope!` and `nope?` are written with no +// receiver, no parentheses, no arguments and no block, yet they lex as tFID and +// `primary : tFID` builds NEW_FCALL (parse.y-ruby_4_0:4370), while gettable +// reaches NEW_VCALL only for a plain tIDENTIFIER (parse.y-ruby_4_0:13086). +// +// The rule now lives once, in the compiler (compiler.vcallName), so +// bytecode.FlagSendVCall is NOT set for these two and the VM reads the flag +// alone; the predicate that used to re-check the name here is gone. This test +// keeps the observable half, which is what MRI actually promises. Both MRI +// 4.0.5 parsers were run: // // --parser=parse.y --parser=prism (the default) // nope_xyz NameError NameError @@ -262,15 +267,6 @@ func TestWaveGVCallNameShape(t *testing.T) { t.Errorf("%s\n got %s\nwant %s", c.src, got, c.want) } } - // The predicate itself, on the shapes the Ruby cases above cannot reach. - for name, want := range map[string]bool{ - "nope": true, "_x": true, "nope!": false, "nope?": false, - "nope=": false, "": false, "+": true, - } { - if got := vcallNameShape(name); got != want { - t.Errorf("vcallNameShape(%q) = %v, want %v", name, got, want) - } - } } // TestWaveGVCallHashShorthandFollowsParseY records, rather than asserts as diff --git a/internal/vm/wave_i_braced_paren_test.go b/internal/vm/wave_i_braced_paren_test.go new file mode 100644 index 00000000..7f42a7e1 --- /dev/null +++ b/internal/vm/wave_i_braced_paren_test.go @@ -0,0 +1,123 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "strings" + "testing" +) + +// TestWaveIBracedHashAtACallSite pins the observable half of ast.HashLit.Braced +// (go-ruby-parser v0.9.0): a hash WRITTEN with braces is a positional argument, +// and a bare `k: v` list is keywords. +// +// Every `want` below was read off ruby 4.0.5 before the fix, under BOTH +// `--parser=parse.y` and `--parser=prism`, which agree on all of it. rbgo used +// to answer the keyword reading for every braced row. +// +// The probe varies with its input by construction: the rows come in pairs that +// differ only by the braces, plus `f(h)` — a VARIABLE holding a hash, which was +// already correct — so a change that made every trailing hash positional would +// fail all six keyword rows, and one that made none positional would fail all +// the braced ones. +func TestWaveIBracedHashAtACallSite(t *testing.T) { + const prelude = "def f(*a, **k) = [a, k]\nh = {y: 2}\n" + for _, c := range []struct{ src, want string }{ + // --- braced: POSITIONAL --- + {`p f({x: 1})`, `[[{x: 1}], {}]`}, + {`p f({})`, `[[{}], {}]`}, // and NOT f(), which passes nothing + {`p f({**h})`, `[[{y: 2}], {}]`}, + {`p f(1, {x: 1})`, `[[1, {x: 1}], {}]`}, + {`p f({x: 1}, {y: 2})`, `[[{x: 1}, {y: 2}], {}]`}, + {`p f(*[1], {x: 1})`, `[[1, {x: 1}], {}]`}, + {`p f({x: 1}) { }`, `[[{x: 1}], {}]`}, + {`p f({"s" => 1})`, `[[{"s" => 1}], {}]`}, + // --- bare: KEYWORDS (unchanged) --- + {`p f(x: 1)`, `[[], {x: 1}]`}, + {`p f(**h)`, `[[], {y: 2}]`}, + {`p f(1, x: 1)`, `[[1], {x: 1}]`}, + {`p f(*[1], x: 1)`, `[[1], {x: 1}]`}, + {`p f("s" => 1)`, `[[], {"s" => 1}]`}, + {`p f()`, `[[], {}]`}, + // --- a VARIABLE holding a hash was ALWAYS positional: the honest scope --- + {`p f(h)`, `[[{y: 2}], {}]`}, + // --- and the arity a braced hash now changes --- + {`def k2(**k) = k +begin; k2({x: 1}); rescue => e; p [e.class, e.message]; end`, + `[ArgumentError, "wrong number of arguments (given 1, expected 0)"]`}, + {`def p1(a = 9, **k) = [a, k] +p p1({x: 1})`, `[{x: 1}, {}]`}, + {`def p1(a = 9, **k) = [a, k] +p p1(x: 1)`, `[9, {x: 1}]`}, + } { + if got := strings.TrimSpace(eval(t, prelude+c.src)); got != c.want { + t.Errorf("%s\n got %s\nwant %s", c.src, got, c.want) + } + } +} + +// TestWaveIBracedHashElsewhere: Braced says only "written with braces", exactly +// as MRI's nd_brace does — it is not conditional on being an argument +// (parse.y-ruby_4_0:4415-4419). So a braced hash away from a call site, and a +// yield or a super, must keep behaving as before. Read off ruby 4.0.5. +func TestWaveIBracedHashElsewhere(t *testing.T) { + for _, c := range []struct{ src, want string }{ + {"x = {a: 1}\np x", `{a: 1}`}, + {"p [{a: 1}]", `[{a: 1}]`}, + {"h = {a: 1}\np({**h, b: 2})", `{a: 1, b: 2}`}, + // yield is an ordinary call site and follows the same rule. + {"def y1; yield({x: 1}); end\ny1 { |*a, **k| p [a, k] }", `[[{x: 1}], {}]`}, + {"def y1; yield(x: 1); end\ny1 { |*a, **k| p [a, k] }", `[[], {x: 1}]`}, + // and so does super. + {`class A; def m(*a, **k) = [a, k]; end +class B < A; def m = super({x: 1}); end +p B.new.m`, `[[{x: 1}], {}]`}, + {`class A; def m(*a, **k) = [a, k]; end +class B < A; def m = super(x: 1); end +p B.new.m`, `[[], {x: 1}]`}, + } { + if got := strings.TrimSpace(eval(t, c.src)); got != c.want { + t.Errorf("%s\n got %s\nwant %s", c.src, got, c.want) + } + } +} + +// TestWaveIParenSeparatesVCallFromFCall pins ast.Call.Paren end to end: `nope` +// is MRI's NODE_VCALL and raises NameError, `nope()` is an FCALL and raises +// NoMethodError (parse.y-ruby_4_0:13086 against 3572-3577 + 5242-5248). Read off +// ruby 4.0.5 under both parsers, which agree. +// +// Only the FIRST row is a VCALL. The rest are the near-miss shapes, and they are +// the known-bad control: a change that raised NameError for every receiver-less +// name fails all nine of them. +func TestWaveIParenSeparatesVCallFromFCall(t *testing.T) { + for _, c := range []struct{ src, want string }{ + {`nope_xyz`, "NameError"}, + {`nope_xyz()`, "NoMethodError"}, + {`nope_xyz!`, "NoMethodError"}, + {`nope_xyz?`, "NoMethodError"}, + {`nope_xyz!()`, "NoMethodError"}, + {`nope_xyz?()`, "NoMethodError"}, + {`nope_xyz { }`, "NoMethodError"}, + {`nope_xyz() { }`, "NoMethodError"}, + {`self.nope_xyz`, "NoMethodError"}, + {`nope_xyz(1)`, "NoMethodError"}, + } { + src := "begin; " + c.src + "; rescue Exception => e; p e.class; end" + if got := strings.TrimSpace(eval(t, src)); got != c.want { + t.Errorf("%s: got %s, want %s", c.src, got, c.want) + } + } + // The message moves with the class, and only for the VCALL. + for _, c := range []struct{ src, want string }{ + {`nope_xyz`, `"undefined local variable or method 'nope_xyz' for main"`}, + {`nope_xyz()`, `"undefined method 'nope_xyz' for main"`}, + } { + src := "begin; " + c.src + "; rescue Exception => e; p e.message; end" + if got := strings.TrimSpace(eval(t, src)); got != c.want { + t.Errorf("%s: got %s, want %s", c.src, got, c.want) + } + } +} diff --git a/scripts/conformance/rubyspec/spec_helper.rb b/scripts/conformance/rubyspec/spec_helper.rb index d3bd6281..be05671b 100644 --- a/scripts/conformance/rubyspec/spec_helper.rb +++ b/scripts/conformance/rubyspec/spec_helper.rb @@ -210,7 +210,19 @@ def failure_message "expected a warning#{@pat ? " matching #{@pat.inspect}" : ''}, got #{@out.inspect}" end end -def complain(pat = nil, verbose: nil); ComplainMatcher.new(pat, verbose); end +# Upstream mspec is `complain(complaint = nil, options = {})` -- a POSITIONAL +# options Hash, not a keyword. The shim used `verbose:` instead, which worked +# only because the compiler could not tell `complain(re, {verbose: true})` from +# `complain(re, verbose: true)` and read both as keywords. With +# ast.HashLit.Braced (go-ruby-parser v0.9.0) the braced form is correctly +# positional, and a keyword-only signature then rejects it -- which is how +# core/regexp/shared/new.rb:148 started raising ArgumentError. +# +# One signature covers every call site in the corpus, both the 18 written with +# bare `verbose: true` and the 2 written with braces: Ruby converts keyword +# arguments to a positional Hash when the callee declares no keyword parameters, +# and rbgo does the same (measured against ruby 4.0.5). +def complain(pat = nil, options = {}); ComplainMatcher.new(pat, options[:verbose]); end # output: capture $stdout (and, with a second argument, $stderr) around the block # and match each against a String (exact) or Regexp. Mirrors mspec's