From 79a9fc8a6fc5ba3d6ea4890b2e4489f5e7efe6f2 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 28 Jun 2026 17:38:39 +0200 Subject: [PATCH 1/3] vm: Ruby hash/eql? semantics for object Hash keys + restore tracking stacks on throw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two VM primitives diverged from MRI and together blocked `require "puppet"` (full Pops/pcore boot): 1. object.Hash keyed every non-String key by Go pointer identity, so a fresh key that is #eql? to a stored one (and same #hash) missed — Array/Hash keys and user objects that override #hash/#eql? all failed by-value lookup. Puppet's loader registry keys a Hash by Pops::Loader::TypedName (custom #hash/#eql?), so `loader.load(:type, "annotation")` returned nil → "reference to unresolved type 'Annotation'". Now content-addresses Array/Hash recursively and routes a user object through CustomKeyHook (Ruby #hash bucket + #eql? collision lists), matching MRI for replace/delete/collision semantics. 2. Kernel#catch recovered a matching throw without restoring fileStack / frameNames / frameFiles / requireDirs to the catch's depth, so the unwound block frames leaked their pushes. racc's catch(:racc_end_parse)+throw drives the Pops parser, so every type parse leaked ~6 fileStack entries, corrupting __FILE__ and breaking a later `require_relative 'parser/compiler/...'`. Co-Authored-By: Claude Opus 4.8 --- internal/object/object.go | 106 ++++++++++++++++++++++++++++++++++---- internal/vm/builtins.go | 15 ++++++ internal/vm/vm.go | 52 +++++++++++++++++++ 3 files changed, 164 insertions(+), 9 deletions(-) diff --git a/internal/object/object.go b/internal/object/object.go index 4866b923..4124a48a 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -8,6 +8,7 @@ package object import ( + "fmt" "math" "math/big" "strconv" @@ -297,6 +298,11 @@ func (a *Array) Truthy() bool { return true } type Hash struct { Keys []Value // insertion order (string keys held as frozen snapshots) vals map[any]Value + // keyBucket maps a stored user-object key (snapshot) to the customBucket it + // hashes to, so reuseBucket can find an #eql? sibling and collapse it to the + // same entry. Only populated for keys routed through CustomKeyHook; lazily + // created on first such insertion. + keyBucket map[Value]customBucket // Default is the value returned for a missing key (Hash.new(default)); nil // means none. DefaultProc, when set, is a Proc (held as a Value to avoid an // import cycle) called with (hash, key) on a miss (Hash.new { … }); it takes @@ -318,18 +324,90 @@ type KeyUnwrapper interface { HashUnwrap() (Value, bool) } -func hashKey(k Value) any { +// CustomKeyHook lets the vm package supply Ruby-level hash/eql? semantics for a +// key that is neither a plain value type nor a built-in subclass — i.e. a user +// object that overrides #hash and #eql?. Given such a key it returns the int64 +// the object's #hash method yields and a predicate that reports whether a stored +// key is #eql? to it; ok is false for any key that should keep identity-based +// behaviour (the default Object#hash/#eql?). Set once by the VM at construction; +// nil in tests that never build a VM (then every object key uses identity). +var CustomKeyHook func(k Value) (rubyHash int64, eql func(stored Value) bool, ok bool) + +// customBucket is the comparable map key minted for a user object whose #hash and +// #eql? are overridden. seq disambiguates distinct (not #eql?) objects that share +// a #hash value; reuseBucket assigns matching keys the same bucket so #eql? keys +// collapse to one entry exactly as MRI's open-addressed table would. +type customBucket struct { + hash int64 + seq int +} + +// hashKey normalises a key to its comparable map form. Plain value types and the +// recursively content-addressable collections (Array/Hash) reduce to a content +// key so that #eql? values coincide; a user object with an overridden #hash/#eql? +// is routed through CustomKeyHook and reuseBucket so it follows Ruby semantics +// rather than Go pointer identity. +func (h *Hash) hashKey(k Value) any { if u, ok := k.(KeyUnwrapper); ok { if v, wrapped := u.HashUnwrap(); wrapped { k = v } } - if s, ok := k.(*String); ok { - return strKey(s.B) + switch kk := k.(type) { + case *String: + return strKey(kk.B) + case *Bignum: + return "\x00big:" + kk.I.String() + case *Array: + var b strings.Builder + b.WriteString("\x00arr:") + for _, e := range kk.Elems { + fmt.Fprintf(&b, "%v\x01", h.hashKey(e)) + } + return b.String() + case *Hash: + var b strings.Builder + b.WriteString("\x00hsh:") + for _, e := range kk.Keys { + v, _ := kk.Get(e) + fmt.Fprintf(&b, "%v\x02%v\x01", h.hashKey(e), h.valKey(v)) + } + return b.String() + } + if CustomKeyHook != nil { + if rh, eql, ok := CustomKeyHook(k); ok { + return h.reuseBucket(rh, eql) + } } return k } +// valKey is the content key for a value appearing on the *value* side of a nested +// Hash that is itself used as a Hash key. It reuses hashKey so the serialisation +// is consistent (two #eql? nested hashes produce the same key string). +func (h *Hash) valKey(v Value) any { return h.hashKey(v) } + +// reuseBucket returns the customBucket for a user object with Ruby hash rh and +// #eql? predicate eql: if an existing key in this hash is #eql?, its bucket is +// reused (so the new key updates that entry); otherwise a fresh seq is minted at +// the same hash so non-#eql? collisions stay distinct. +func (h *Hash) reuseBucket(rh int64, eql func(Value) bool) customBucket { + maxSeq := -1 + for _, key := range h.Keys { + cb, ok := h.keyBucket[key] + if !ok || cb.hash != rh { + continue + } + if eql(key) { + return cb + } + if cb.seq > maxSeq { + maxSeq = cb.seq + } + } + return customBucket{hash: rh, seq: maxSeq + 1} +} + // snapshotKey is the value remembered in Keys for iteration/inspect: a string // key is stored as a frozen copy so mutating the original does not change it. func snapshotKey(k Value) Value { @@ -345,13 +423,20 @@ func snapshotKey(k Value) Value { func NewHash() *Hash { return &Hash{vals: map[any]Value{}} } // Get returns the value for k and whether it is present. -func (h *Hash) Get(k Value) (Value, bool) { v, ok := h.vals[hashKey(k)]; return v, ok } +func (h *Hash) Get(k Value) (Value, bool) { v, ok := h.vals[h.hashKey(k)]; return v, ok } // Set inserts or updates k→v, preserving first-insertion order. func (h *Hash) Set(k, v Value) { - hk := hashKey(k) + hk := h.hashKey(k) if _, ok := h.vals[hk]; !ok { - h.Keys = append(h.Keys, snapshotKey(k)) + snap := snapshotKey(k) + h.Keys = append(h.Keys, snap) + if cb, isCustom := hk.(customBucket); isCustom { + if h.keyBucket == nil { + h.keyBucket = map[Value]customBucket{} + } + h.keyBucket[snap] = cb + } } h.vals[hk] = v } @@ -361,14 +446,17 @@ func (h *Hash) Len() int { return len(h.Keys) } // Delete removes k, returning its value and whether it was present. func (h *Hash) Delete(k Value) (Value, bool) { - hk := hashKey(k) + hk := h.hashKey(k) v, ok := h.vals[hk] if !ok { return NilV, false } delete(h.vals, hk) for i, key := range h.Keys { - if hashKey(key) == hk { + if h.hashKey(key) == hk { + if h.keyBucket != nil { + delete(h.keyBucket, key) + } h.Keys = append(h.Keys[:i], h.Keys[i+1:]...) break } @@ -386,7 +474,7 @@ func (h *Hash) repr() string { if i > 0 { b.WriteString(", ") } - v := h.vals[hashKey(k)] + v := h.vals[h.hashKey(k)] // Ruby 4.0 (since 3.4) inspect: symbol keys use the label form // `name: value`; all other keys use `key => value` with spaces. if sym, ok := k.(Symbol); ok { diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 4fc84227..d1260aa1 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -154,11 +154,26 @@ func (vm *VM) bootstrap() { if len(args) > 0 { tag = args[0] } + // Snapshot the per-frame tracking-stack depths so that, when a matching + // throw unwinds the block's frames straight to this recover (bypassing each + // frame's normal pop), __FILE__/require_relative resolution, caller and + // backtraces see this catch's state rather than the abandoned deep-frame + // entries. Without this the pushes leak (racc's catch(:racc_end_parse) + + // throw drives the Pops parser, so every type parse leaked ~6 entries — + // corrupting __FILE__ and breaking a later require_relative). + fileStackDepth := len(vm.fileStack) + frameNamesDepth := len(vm.frameNames) + frameFilesDepth := len(vm.frameFiles) + requireDirsDepth := len(vm.requireDirs) defer func() { if r := recover(); r != nil { // Tags match by identity (== on the interface): a Symbol by value, a // reference by pointer — exactly Ruby's equal?. if sig, ok := r.(throwSignal); ok && sig.tag == tag { + vm.fileStack = vm.fileStack[:fileStackDepth] + vm.frameNames = vm.frameNames[:frameNamesDepth] + vm.frameFiles = vm.frameFiles[:frameFilesDepth] + vm.requireDirs = vm.requireDirs[:requireDirsDepth] result = sig.value return } diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 8c151c32..ca0d7817 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -266,6 +266,57 @@ func (vm *VM) objectID(self object.Value) object.Value { // equally (so they key Hashes / Sets consistently), and other objects fall back // to their object_id. The exact numbers are unspecified in Ruby, so this uses a // stable internal scheme rather than matching MRI's. +// installHashKeyHook wires object.CustomKeyHook so that a user object used as a +// Hash key follows its Ruby-level #hash/#eql? rather than Go pointer identity. +// Built-in value types (Integer/Float/String/Symbol/Array/…) never reach the hook +// — object.hashKey content-addresses them first; the hook only fires for a key +// whose class overrides #hash away from the default Object#hash. Puppet's loader +// registry keys a Hash by Pops::Loader::TypedName, which does exactly that, so +// without this a lookup with a fresh-but-#eql? key would miss (issue: Annotation +// type unresolved during pcore init). +func (vm *VM) installHashKeyHook() { + object.CustomKeyHook = func(k object.Value) (int64, func(object.Value) bool, bool) { + if !vm.hasCustomHash(k) { + return 0, nil, false + } + hv := vm.send(k, "hash", nil, nil) + rh, ok := hashAsInt(hv) + if !ok { + // A non-integer #hash result is unusable as a bucket; fall back to + // identity so the key still works (just by object identity). + return 0, nil, false + } + eql := func(stored object.Value) bool { + return vm.send(k, "eql?", []object.Value{stored}, nil).Truthy() + } + return rh, eql, true + } +} + +// hasCustomHash reports whether v's method resolution finds a #hash defined +// somewhere other than the default Object#hash (or BasicObject), i.e. the user +// gave the type value semantics for hashing. Plain reference objects with no such +// override keep identity behaviour. +func (vm *VM) hasCustomHash(v object.Value) bool { + m := vm.findMethod(v, "hash") + if m == nil { + return false + } + return m.owner != vm.cObject && m.owner != vm.cBasicObject +} + +// hashAsInt coerces the result of a Ruby #hash call to an int64 bucket. MRI's +// Object#hash returns an Integer; a Bignum result is folded so any value works. +func hashAsInt(v object.Value) (int64, bool) { + switch x := v.(type) { + case object.Integer: + return int64(x), true + case *object.Bignum: + return int64(x.I.Int64()), true + } + return 0, false +} + func (vm *VM) hashValue(self object.Value) int64 { switch v := self.(type) { case object.Integer: @@ -412,6 +463,7 @@ func New(out io.Writer) *VM { vm.installPrelude() vm.registerEnumerator() // after the prelude so it can mix in Enumerable vm.registerLazy() // after Enumerator (Enumerator::Lazy is built on it) + vm.installHashKeyHook() return vm } From 52d73d0016c5452026d487f5c97dd8bd142f2786 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 28 Jun 2026 17:41:53 +0200 Subject: [PATCH 2/3] prelude: StringScanner#[] returns capture groups of the last match The Pops lexer (slurp_support) reads scn[1] after a scan to recover an interpolation terminator, but StringScanner only tracked @matched (the whole match) and had no #[], so parsing any manifest raised NoMethodError: undefined method '[]' for StringScanner. Store the MatchData from scan/scan_until and add #[] (0 = whole match, n = nth group, name = named group; nil for a no-match or out-of-range group), with getch/terminate/reset clearing it to match MRI. Frozen prelude regenerated. Co-Authored-By: Claude Opus 4.8 --- internal/vm/prelude.rb | 20 ++++++++++++++ internal/vm/prelude_frozen_gen.go | 45 +++++++++++++++++++++---------- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/internal/vm/prelude.rb b/internal/vm/prelude.rb index 0a3171d6..e6060c73 100644 --- a/internal/vm/prelude.rb +++ b/internal/vm/prelude.rb @@ -1464,6 +1464,19 @@ def initialize(str) @string = str @pos = 0 @matched = nil + @match = nil + end + + # [] returns a captured subgroup of the most recent match: 0 is the whole match, + # a positive Integer the nth group, a Symbol/String a named group. nil when there + # was no match or the group is out of range / unmatched, matching MRI. + def [](i) + if @match.nil? + # getch / a bare match with no captured groups still answers [0] with the + # matched text, while every group index / name is nil (matching MRI). + return (i == 0 || i == :"0") ? @matched : nil + end + @match[i] end # pos / charpos report the current scan position; pos= moves it (clamped into the @@ -1492,10 +1505,12 @@ def rest def scan(pattern) m = pattern.match(@string, @pos) if m && m.begin(0) == @pos + @match = m @matched = m[0] @pos = m.end(0) @matched else + @match = nil @matched = nil nil end @@ -1523,11 +1538,13 @@ def match?(pattern) def scan_until(pattern) m = pattern.match(@string, @pos) if m + @match = m @matched = m[0] result = @string[@pos...m.end(0)] @pos = m.end(0) result else + @match = nil @matched = nil nil end @@ -1538,6 +1555,7 @@ def scan_until(pattern) def getch return nil if eos? c = @string[@pos] + @match = nil @matched = c @pos += 1 c @@ -1552,6 +1570,7 @@ def peek(len) # matching MRI. def terminate @pos = @string.length + @match = nil @matched = nil self end @@ -1559,6 +1578,7 @@ def terminate # reset returns the position to the start and clears the match. def reset @pos = 0 + @match = nil @matched = nil self end diff --git a/internal/vm/prelude_frozen_gen.go b/internal/vm/prelude_frozen_gen.go index fc5f3324..ec11c0d0 100644 --- a/internal/vm/prelude_frozen_gen.go +++ b/internal/vm/prelude_frozen_gen.go @@ -3862,13 +3862,13 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "", - Insns: []bytecode.Instr{{Op: 5}, {Op: 1}, {Op: 1, A: 1}, {Op: 30, B: 2}, {Op: 9}, {Op: 44, A: 1}, {Op: 9}, {Op: 5}, {Op: 1, A: 2}, {Op: 30, B: 1}, {Op: 9}, {Op: 78, A: 2, B: 3}, {Op: 9}, {Op: 44, A: 4, B: 1}, {Op: 9}, {Op: 44, A: 5, B: 2}, {Op: 9}, {Op: 44, A: 6, B: 3}, {Op: 9}, {Op: 44, A: 7, B: 4}, {Op: 9}, {Op: 44, A: 8, B: 5}, {Op: 9}, {Op: 44, A: 9, B: 6}, {Op: 9}, {Op: 44, A: 10, B: 7}, {Op: 9}, {Op: 44, A: 11, B: 8}, {Op: 9}, {Op: 44, A: 12, B: 9}, {Op: 9}, {Op: 44, A: 13, B: 10}, {Op: 9}, {Op: 44, A: 14, B: 11}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 5}, {Op: 1}, {Op: 1, A: 1}, {Op: 30, B: 2}, {Op: 9}, {Op: 44, A: 1}, {Op: 9}, {Op: 44, A: 2, B: 1}, {Op: 9}, {Op: 5}, {Op: 1, A: 2}, {Op: 30, B: 1}, {Op: 9}, {Op: 78, A: 3, B: 4}, {Op: 9}, {Op: 44, A: 5, B: 2}, {Op: 9}, {Op: 44, A: 6, B: 3}, {Op: 9}, {Op: 44, A: 7, B: 4}, {Op: 9}, {Op: 44, A: 8, B: 5}, {Op: 9}, {Op: 44, A: 9, B: 6}, {Op: 9}, {Op: 44, A: 10, B: 7}, {Op: 9}, {Op: 44, A: 11, B: 8}, {Op: 9}, {Op: 44, A: 12, B: 9}, {Op: 9}, {Op: 44, A: 13, B: 10}, {Op: 9}, {Op: 44, A: 14, B: 11}, {Op: 9}, {Op: 44, A: 15, B: 12}, {Op: 53}}, Consts: []object.Value{ object.Symbol("string"), object.Symbol("matched"), object.Symbol("pos"), }, - Names: []string{"attr_reader", "initialize", "charpos", "pos", "pos=", "eos?", "rest", "scan", "skip", "match?", "scan_until", "getch", "peek", "terminate", "reset"}, + Names: []string{"attr_reader", "initialize", "[]", "charpos", "pos", "pos=", "eos?", "rest", "scan", "skip", "match?", "scan_until", "getch", "peek", "terminate", "reset"}, NumRequired: 0, SplatIndex: -1, KwRestSlot: -1, @@ -3877,11 +3877,11 @@ func embeddedPrelude() *bytecode.ISeq { Children: []*bytecode.ISeq{ &bytecode.ISeq{ Name: "initialize", - Insns: []bytecode.Instr{{Op: 11}, {Op: 32}, {Op: 9}, {Op: 1}, {Op: 32, A: 1}, {Op: 9}, {Op: 2}, {Op: 32, A: 2}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 11}, {Op: 32}, {Op: 9}, {Op: 1}, {Op: 32, A: 1}, {Op: 9}, {Op: 2}, {Op: 32, A: 2}, {Op: 9}, {Op: 2}, {Op: 32, A: 3}, {Op: 53}}, Consts: []object.Value{ object.Integer(0), }, - Names: []string{"@string", "@pos", "@matched"}, + Names: []string{"@string", "@pos", "@matched", "@match"}, Params: []string{"str"}, NumRequired: 1, SplatIndex: -1, @@ -3891,6 +3891,23 @@ func embeddedPrelude() *bytecode.ISeq { Locals: []string{"str"}, Super: "", }, + &bytecode.ISeq{ + Name: "[]", + Insns: []bytecode.Instr{{Op: 31}, {Op: 30, A: 1, Flags: 1}, {Op: 28, A: 18}, {Op: 11}, {Op: 1}, {Op: 22}, {Op: 10}, {Op: 27, A: 12}, {Op: 9}, {Op: 11}, {Op: 1, A: 1}, {Op: 22}, {Op: 28, A: 15}, {Op: 31, A: 2}, {Op: 26, A: 16}, {Op: 2}, {Op: 53}, {Op: 26, A: 19}, {Op: 2}, {Op: 9}, {Op: 31}, {Op: 11}, {Op: 30, A: 3, B: 1, Flags: 1}, {Op: 53}}, + Consts: []object.Value{ + object.Integer(0), + object.Symbol("0"), + }, + Names: []string{"@match", "nil?", "@matched", "[]"}, + Params: []string{"i"}, + NumRequired: 1, + SplatIndex: -1, + KwRestSlot: -1, + BlockSlot: -1, + NumLocals: 1, + Locals: []string{"i"}, + Super: "", + }, &bytecode.ISeq{ Name: "pos=", Insns: []bytecode.Instr{{Op: 11}, {Op: 1}, {Op: 18}, {Op: 28, A: 10}, {Op: 11}, {Op: 31}, {Op: 30, A: 1, Flags: 1}, {Op: 13}, {Op: 12}, {Op: 26, A: 11}, {Op: 2}, {Op: 9}, {Op: 11}, {Op: 32, A: 2}, {Op: 53}}, @@ -3934,11 +3951,11 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "scan", - Insns: []bytecode.Instr{{Op: 11}, {Op: 31}, {Op: 31, A: 1}, {Op: 30, A: 2, B: 2, Flags: 1}, {Op: 12, A: 1}, {Op: 9}, {Op: 11, A: 1}, {Op: 10}, {Op: 28, A: 15}, {Op: 9}, {Op: 11, A: 1}, {Op: 1}, {Op: 30, A: 3, B: 1, Flags: 1}, {Op: 31, A: 1}, {Op: 22}, {Op: 28, A: 28}, {Op: 11, A: 1}, {Op: 1}, {Op: 30, A: 4, B: 1, Flags: 1}, {Op: 32, A: 5}, {Op: 9}, {Op: 11, A: 1}, {Op: 1}, {Op: 30, A: 6, B: 1, Flags: 1}, {Op: 32, A: 1}, {Op: 9}, {Op: 31, A: 5}, {Op: 26, A: 32}, {Op: 2}, {Op: 32, A: 5}, {Op: 9}, {Op: 2}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 11}, {Op: 31}, {Op: 31, A: 1}, {Op: 30, A: 2, B: 2, Flags: 1}, {Op: 12, A: 1}, {Op: 9}, {Op: 11, A: 1}, {Op: 10}, {Op: 28, A: 15}, {Op: 9}, {Op: 11, A: 1}, {Op: 1}, {Op: 30, A: 3, B: 1, Flags: 1}, {Op: 31, A: 1}, {Op: 22}, {Op: 28, A: 31}, {Op: 11, A: 1}, {Op: 32, A: 4}, {Op: 9}, {Op: 11, A: 1}, {Op: 1}, {Op: 30, A: 5, B: 1, Flags: 1}, {Op: 32, A: 6}, {Op: 9}, {Op: 11, A: 1}, {Op: 1}, {Op: 30, A: 7, B: 1, Flags: 1}, {Op: 32, A: 1}, {Op: 9}, {Op: 31, A: 6}, {Op: 26, A: 38}, {Op: 2}, {Op: 32, A: 4}, {Op: 9}, {Op: 2}, {Op: 32, A: 6}, {Op: 9}, {Op: 2}, {Op: 53}}, Consts: []object.Value{ object.Integer(0), }, - Names: []string{"@string", "@pos", "match", "begin", "[]", "@matched", "end"}, + Names: []string{"@string", "@pos", "match", "begin", "@match", "[]", "@matched", "end"}, Params: []string{"pattern"}, NumRequired: 1, SplatIndex: -1, @@ -3979,11 +3996,11 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "scan_until", - Insns: []bytecode.Instr{{Op: 11}, {Op: 31}, {Op: 31, A: 1}, {Op: 30, A: 2, B: 2, Flags: 1}, {Op: 12, A: 1}, {Op: 9}, {Op: 11, A: 1}, {Op: 28, A: 29}, {Op: 11, A: 1}, {Op: 1}, {Op: 30, A: 3, B: 1, Flags: 1}, {Op: 32, A: 4}, {Op: 9}, {Op: 31}, {Op: 31, A: 1}, {Op: 11, A: 1}, {Op: 1}, {Op: 30, A: 5, B: 1, Flags: 1}, {Op: 8, A: 1}, {Op: 30, A: 3, B: 1, Flags: 1}, {Op: 12, A: 2}, {Op: 9}, {Op: 11, A: 1}, {Op: 1}, {Op: 30, A: 5, B: 1, Flags: 1}, {Op: 32, A: 1}, {Op: 9}, {Op: 11, A: 2}, {Op: 26, A: 33}, {Op: 2}, {Op: 32, A: 4}, {Op: 9}, {Op: 2}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 11}, {Op: 31}, {Op: 31, A: 1}, {Op: 30, A: 2, B: 2, Flags: 1}, {Op: 12, A: 1}, {Op: 9}, {Op: 11, A: 1}, {Op: 28, A: 32}, {Op: 11, A: 1}, {Op: 32, A: 3}, {Op: 9}, {Op: 11, A: 1}, {Op: 1}, {Op: 30, A: 4, B: 1, Flags: 1}, {Op: 32, A: 5}, {Op: 9}, {Op: 31}, {Op: 31, A: 1}, {Op: 11, A: 1}, {Op: 1}, {Op: 30, A: 6, B: 1, Flags: 1}, {Op: 8, A: 1}, {Op: 30, A: 4, B: 1, Flags: 1}, {Op: 12, A: 2}, {Op: 9}, {Op: 11, A: 1}, {Op: 1}, {Op: 30, A: 6, B: 1, Flags: 1}, {Op: 32, A: 1}, {Op: 9}, {Op: 11, A: 2}, {Op: 26, A: 39}, {Op: 2}, {Op: 32, A: 3}, {Op: 9}, {Op: 2}, {Op: 32, A: 5}, {Op: 9}, {Op: 2}, {Op: 53}}, Consts: []object.Value{ object.Integer(0), }, - Names: []string{"@string", "@pos", "match", "[]", "@matched", "end"}, + Names: []string{"@string", "@pos", "match", "@match", "[]", "@matched", "end"}, Params: []string{"pattern"}, NumRequired: 1, SplatIndex: -1, @@ -3995,11 +4012,11 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "getch", - Insns: []bytecode.Instr{{Op: 5}, {Op: 30}, {Op: 28, A: 6}, {Op: 2}, {Op: 53}, {Op: 26, A: 7}, {Op: 2}, {Op: 9}, {Op: 31, A: 1}, {Op: 31, A: 2}, {Op: 30, A: 3, B: 1, Flags: 1}, {Op: 12}, {Op: 9}, {Op: 11}, {Op: 32, A: 4}, {Op: 9}, {Op: 31, A: 2}, {Op: 1}, {Op: 13}, {Op: 32, A: 2}, {Op: 9}, {Op: 11}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 5}, {Op: 30}, {Op: 28, A: 6}, {Op: 2}, {Op: 53}, {Op: 26, A: 7}, {Op: 2}, {Op: 9}, {Op: 31, A: 1}, {Op: 31, A: 2}, {Op: 30, A: 3, B: 1, Flags: 1}, {Op: 12}, {Op: 9}, {Op: 2}, {Op: 32, A: 4}, {Op: 9}, {Op: 11}, {Op: 32, A: 5}, {Op: 9}, {Op: 31, A: 2}, {Op: 1}, {Op: 13}, {Op: 32, A: 2}, {Op: 9}, {Op: 11}, {Op: 53}}, Consts: []object.Value{ object.Integer(1), }, - Names: []string{"eos?", "@string", "@pos", "[]", "@matched"}, + Names: []string{"eos?", "@string", "@pos", "[]", "@match", "@matched"}, NumRequired: 0, SplatIndex: -1, KwRestSlot: -1, @@ -4026,8 +4043,8 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "terminate", - Insns: []bytecode.Instr{{Op: 31}, {Op: 30, A: 1, Flags: 1}, {Op: 32, A: 2}, {Op: 9}, {Op: 2}, {Op: 32, A: 3}, {Op: 9}, {Op: 5}, {Op: 53}}, - Names: []string{"@string", "length", "@pos", "@matched"}, + Insns: []bytecode.Instr{{Op: 31}, {Op: 30, A: 1, Flags: 1}, {Op: 32, A: 2}, {Op: 9}, {Op: 2}, {Op: 32, A: 3}, {Op: 9}, {Op: 2}, {Op: 32, A: 4}, {Op: 9}, {Op: 5}, {Op: 53}}, + Names: []string{"@string", "length", "@pos", "@match", "@matched"}, NumRequired: 0, SplatIndex: -1, KwRestSlot: -1, @@ -4037,11 +4054,11 @@ func embeddedPrelude() *bytecode.ISeq { }, &bytecode.ISeq{ Name: "reset", - Insns: []bytecode.Instr{{Op: 1}, {Op: 32}, {Op: 9}, {Op: 2}, {Op: 32, A: 1}, {Op: 9}, {Op: 5}, {Op: 53}}, + Insns: []bytecode.Instr{{Op: 1}, {Op: 32}, {Op: 9}, {Op: 2}, {Op: 32, A: 1}, {Op: 9}, {Op: 2}, {Op: 32, A: 2}, {Op: 9}, {Op: 5}, {Op: 53}}, Consts: []object.Value{ object.Integer(0), }, - Names: []string{"@pos", "@matched"}, + Names: []string{"@pos", "@match", "@matched"}, NumRequired: 0, SplatIndex: -1, KwRestSlot: -1, From e30c28faa05402edacaba38b6513d062c665da26 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 28 Jun 2026 17:56:01 +0200 Subject: [PATCH 3/3] vm: UnboundMethod bind to singleton-method receiver, Regexp#=== sets $~, Hash#clear/#member?, Regexp.last_match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Further small MRI divergences uncovered driving Puppet.initialize_settings past the pcore boot (each verified vs MRI 4.0.5 and fully tested): - checkBindable: an UnboundMethod taken from a per-object singleton class (Trollop's cloaker_ trick) could not bind back to that object — classOf(recv) never reaches the singleton in its super chain. Now also walks recv's own singleton class. - Regexp#=== matched without recording $~, so `case s; when /re/` left Regexp.last_match / $1 nil in the taken branch (Trollop derives :long this way). Now records the MatchData on success and clears it on a non-string operand or no match, like =~. - Added Regexp.last_match (with optional group arg), Hash#clear, Hash#member? (alias of key?). A key whose #hash returns a non-Integer now raises TypeError as MRI does, rather than silently falling back to identity. require "puppet" now fully loads in rbgo; Puppet.initialize_settings reaches the same point as MRI (LoadError on the unstaged `facter` gem). Cross-package coverage 100.0%. Co-Authored-By: Claude Opus 4.8 --- internal/object/object.go | 8 + internal/object/object_test.go | 53 +++++ internal/vm/builtins.go | 6 + internal/vm/puppet_pcore_loader_test.go | 276 ++++++++++++++++++++++++ internal/vm/reflection.go | 13 +- internal/vm/regexp.go | 31 ++- internal/vm/vm.go | 15 +- 7 files changed, 391 insertions(+), 11 deletions(-) create mode 100644 internal/vm/puppet_pcore_loader_test.go diff --git a/internal/object/object.go b/internal/object/object.go index 4124a48a..34a6c99f 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -444,6 +444,14 @@ func (h *Hash) Set(k, v Value) { // Len returns the number of entries. func (h *Hash) Len() int { return len(h.Keys) } +// Clear removes every entry, leaving an empty hash (the Default/DefaultProc are +// kept, as in MRI). +func (h *Hash) Clear() { + h.Keys = nil + h.vals = map[any]Value{} + h.keyBucket = nil +} + // Delete removes k, returning its value and whether it was present. func (h *Hash) Delete(k Value) (Value, bool) { hk := h.hashKey(k) diff --git a/internal/object/object_test.go b/internal/object/object_test.go index 64c5663b..ea8d1b29 100644 --- a/internal/object/object_test.go +++ b/internal/object/object_test.go @@ -2,6 +2,7 @@ package object import ( "math" + "math/big" "testing" ) @@ -67,6 +68,58 @@ func TestHashOps(t *testing.T) { } } +// TestHashContentKeysAndClear covers content-addressed Array/Hash/Bignum keys, +// Clear, Delete, and (with no CustomKeyHook installed) the identity fallback for a +// plain reference key. +func TestHashContentKeysAndClear(t *testing.T) { + h := NewHash() + // Array key by content. + h.Set(&Array{Elems: []Value{Integer(1), Integer(2)}}, NewString("a")) + if v, ok := h.Get(&Array{Elems: []Value{Integer(1), Integer(2)}}); !ok || v.(*String).Str() != "a" { + t.Fatalf("array key get = %v,%v", v, ok) + } + // Nested Hash key by content (exercises valKey on the value side). + inner := NewHash() + inner.Set(Symbol("x"), &Array{Elems: []Value{Integer(3)}}) + hk := NewHash() + hk.Set(Symbol("x"), &Array{Elems: []Value{Integer(3)}}) + h.Set(inner, NewString("b")) + if v, ok := h.Get(hk); !ok || v.(*String).Str() != "b" { + t.Fatalf("hash key get = %v,%v", v, ok) + } + // Bignum key by content. + big1, _ := new(big.Int).SetString("123456789012345678901234567890", 10) + big2, _ := new(big.Int).SetString("123456789012345678901234567890", 10) + h.Set(&Bignum{I: big1}, Integer(7)) + if v, ok := h.Get(&Bignum{I: big2}); !ok || v != Integer(7) { + t.Fatalf("bignum key get = %v,%v", v, ok) + } + // A plain reference key (no hook) is identity-keyed: a distinct Range misses. + r := &Range{Lo: Integer(1), Hi: Integer(2)} + h.Set(r, NewString("rng")) + if v, ok := h.Get(r); !ok || v.(*String).Str() != "rng" { + t.Fatalf("identity key get = %v,%v", v, ok) + } + if _, ok := h.Get(&Range{Lo: Integer(1), Hi: Integer(2)}); ok { + t.Fatal("distinct reference key should miss without a hook") + } + // Delete an Array key by content. + if _, ok := h.Delete(&Array{Elems: []Value{Integer(1), Integer(2)}}); !ok { + t.Fatal("delete array key") + } + if _, ok := h.Get(&Array{Elems: []Value{Integer(1), Integer(2)}}); ok { + t.Fatal("array key still present after delete") + } + // Clear empties everything. + h.Clear() + if h.Len() != 0 { + t.Fatalf("len after clear = %d", h.Len()) + } + if _, ok := h.Get(hk); ok { + t.Fatal("hash key present after clear") + } +} + func TestSingletons(t *testing.T) { if !True.Truthy() || False.Truthy() || NilV.Truthy() { t.Fatal("singleton truthiness wrong") diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index d1260aa1..fa3c8124 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -2404,6 +2404,11 @@ func (vm *VM) bootstrap() { vm.cHash.define("empty?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { return object.Bool(self.(*object.Hash).Len() == 0) }) + vm.cHash.define("clear", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + h := self.(*object.Hash) + h.Clear() + return h + }) hashKeyP := func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { _, ok := self.(*object.Hash).Get(args[0]) return object.Bool(ok) @@ -2411,6 +2416,7 @@ func (vm *VM) bootstrap() { vm.cHash.define("key?", hashKeyP) vm.cHash.define("has_key?", hashKeyP) vm.cHash.define("include?", hashKeyP) + vm.cHash.define("member?", hashKeyP) vm.cHash.define("keys", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { h := self.(*object.Hash) ks := make([]object.Value, len(h.Keys)) diff --git a/internal/vm/puppet_pcore_loader_test.go b/internal/vm/puppet_pcore_loader_test.go new file mode 100644 index 00000000..c2085f3b --- /dev/null +++ b/internal/vm/puppet_pcore_loader_test.go @@ -0,0 +1,276 @@ +package vm_test + +import "testing" + +// TestHashObjectKeySemantics covers the Hash key path that made Puppet's loader +// registry (keyed by a TypedName with a custom #hash/#eql?) miss in rbgo: a +// non-identical key that is #eql? with the same #hash must find the stored entry, +// and Array/Hash keys must compare by content. Asserted against MRI 4.0.5. +func TestHashObjectKeySemantics(t *testing.T) { + cases := []struct{ src, want string }{ + // User object with custom #hash/#eql? — fresh-but-eql? key hits. + {` +class K + attr_reader :name, :hash + def initialize(n); @name=n; @hash=n.hash; freeze; end + def ==(o); o.class==self.class && o.name==@name; end + alias eql? == +end +h={}; h[K.new("annotation")]="V" +p h[K.new("annotation")] +p h.key?(K.new("annotation")) +p h[K.new("other")] +`, "\"V\"\ntrue\nnil\n"}, + // Array key by content. + {`h={}; h[[1,2]]="a"; p h[[1,2]]`, "\"a\"\n"}, + // Hash key by content (exercises valKey on the nested value). + {`h={}; h[{x: [1,2]}]="b"; p h[{x: [1,2]}]`, "\"b\"\n"}, + // Bignum #hash result (hashAsInt Bignum branch). + {` +class Big; def hash; 10**40; end; def eql?(o); o.is_a?(Big); end; end +h={}; h[Big.new]="big"; p h[Big.new] +`, "\"big\"\n"}, + // A user subclass of String / Array used as a key unwraps to the value's + // content (the KeyUnwrapper path). + {` +class MyStr < String; end +h={}; h[MyStr.new("k")]=1; p h["k"]; p h[MyStr.new("k")] +`, "1\n1\n"}, + {` +class MyArr < Array; end +h={}; h[MyArr.new([1,2])]="v"; p h[[1,2]] +`, "\"v\"\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q\n got=%q\nwant=%q", c.src, got, c.want) + } + } +} + +// TestHashCustomKeyCollisionsAndDelete covers reuseBucket: distinct (not #eql?) +// keys that share a #hash stay separate, an #eql? re-insert replaces, and delete +// removes by value — matching MRI's open-addressed table. +func TestHashCustomKeyCollisionsAndDelete(t *testing.T) { + src := ` +class K + attr_reader :name + def initialize(n); @name=n; end + def hash; 42; end # everything collides on hash + def eql?(o); o.is_a?(K) && o.name==@name; end + def ==(o); eql?(o); end +end +h={} +h[K.new("a")]=1 +h[K.new("b")]=2 +h[K.new("a")]=3 # replace, not add +p h.size +p h[K.new("a")] +p h[K.new("b")] +h.delete(K.new("a")) +p h.size +p h[K.new("a")] +p h.keys.map(&:name) +` + want := "2\n3\n2\n1\nnil\n[\"b\"]\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestHashNonIntegerHashRaises matches MRI: a key whose #hash returns a non-Integer +// raises TypeError when used as a Hash key. +func TestHashNonIntegerHashRaises(t *testing.T) { + src := ` +class N; def hash; "notint"; end; def eql?(o); equal?(o); end; end +begin + h={}; h[N.new]="x" +rescue TypeError => e + puts e.message +end +` + want := "no implicit conversion of String into Integer\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestHashClear covers Hash#clear, including that a custom-key hash is fully reset. +func TestHashClear(t *testing.T) { + cases := []struct{ src, want string }{ + {`h={a:1,b:2}; r=h.clear; p r; p h.size`, "{}\n0\n"}, + {` +class K + attr_reader :name, :hash + def initialize(n); @name=n; @hash=n.hash; freeze; end + def eql?(o); o.is_a?(K) && o.name==@name; end +end +h={}; h[K.new("x")]=1; h.clear; p h.size; p h[K.new("x")] +`, "0\nnil\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestHashMember covers the Hash#member? alias for #key?. +func TestHashMember(t *testing.T) { + if got := eval(t, `h={a:1}; p h.member?(:a); p h.member?(:b)`); got != "true\nfalse\n" { + t.Errorf("got=%q", got) + } +} + +// TestCatchThrowRestoresStacks covers the per-frame tracking-stack restore on a +// matching throw: many deep throws must not leak frames, and __FILE__ stays the +// program's file rather than a leaked deep frame. (Without the fix, racc's +// catch+throw leaked ~6 fileStack entries per type parse, corrupting __FILE__ and +// breaking a later require_relative during Puppet's parse.) +func TestCatchThrowRestoresStacks(t *testing.T) { + src := ` +def deep(n) + n == 0 ? throw(:done, "v") : deep(n-1) +end +before = __FILE__ +50.times { catch(:done) { deep(8); "no" } } +p __FILE__ == before +p(catch(:done) { deep(3); "no" }) +` + if got := eval(t, src); got != "true\n\"v\"\n" { + t.Errorf("got=%q", got) + } +} + +// TestCatchThrowNonMatchingRepropagates covers the non-matching-tag branch of catch +// (the throw passes through an inner catch to the outer one). +func TestCatchThrowNonMatchingRepropagates(t *testing.T) { + src := ` +r = catch(:outer) do + catch(:inner) do + throw :outer, "hit" + end + "inner-returned" +end +p r +` + if got := eval(t, src); got != "\"hit\"\n" { + t.Errorf("got=%q", got) + } +} + +// TestRegexpCaseEqualSetsLastMatch covers Regexp#=== recording $~ (so a case/when +// over a string makes Regexp.last_match / $1 work in the taken branch) and the +// non-string operand clearing it — the Trollop :long derivation depended on this. +func TestRegexpCaseEqualSetsLastMatch(t *testing.T) { + cases := []struct{ src, want string }{ + {` +r = case "--version" + when /^--([^-].*)$/ then Regexp.last_match(1) + when /^[^-]/ then "bare" + else "else" + end +p r +`, "\"version\"\n"}, + {`/(\d+)/ === "abc42"; p $1`, "\"42\"\n"}, + {`/(\d+)/ === "abc42"; p($~ ? $~[0] : nil); p(/x/ === 99); p $~`, "\"42\"\nfalse\nnil\n"}, + {`p(/x/ === "no"); p $~`, "false\nnil\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestRegexpLastMatch covers Regexp.last_match with and without an argument, and +// when there has been no match. +func TestRegexpLastMatch(t *testing.T) { + cases := []struct{ src, want string }{ + {`"foo123" =~ /(\d+)/; p Regexp.last_match.class; p Regexp.last_match(1)`, "MatchData\n\"123\"\n"}, + {`"abc" =~ /z/; p Regexp.last_match`, "nil\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestStringScannerIndex covers StringScanner#[]: the whole match, a numbered group, +// a getch-then-[0], and out-of-range / no-match returning nil. +func TestStringScannerIndex(t *testing.T) { + src := ` +require "strscan" +s = StringScanner.new("foo bar") +s.scan(/(\w+)(\s*)/) +p s[0]; p s[1]; p s[2]; p s[5] +s2 = StringScanner.new("xy") +s2.getch +p s2[0]; p s2[1] +s2.terminate +p s2[0] +s3 = StringScanner.new("zz") +p s3.scan(/q/) +p s3[1] +` + want := "\"foo \"\n\"foo\"\n\" \"\nnil\n\"x\"\nnil\nnil\nnil\nnil\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q\nwant=%q", got, want) + } +} + +// TestStringScannerScanUntilIndex covers scan_until storing the match for #[]. +func TestStringScannerScanUntilIndex(t *testing.T) { + src := ` +require "strscan" +s = StringScanner.new("aXbY") +p s.scan_until(/(X)/) +p s[0]; p s[1] +` + if got := eval(t, src); got != "\"aX\"\n\"X\"\n\"X\"\n" { + t.Errorf("got=%q", got) + } +} + +// TestSingletonMethodBindCall covers the Trollop cloaker trick: an UnboundMethod +// taken from a per-object singleton class re-bound (bind_call) onto the same object. +func TestSingletonMethodBindCall(t *testing.T) { + src := ` +class Foo + def make(&b) + (class << self; self; end).class_eval do + define_method(:cloaker_, &b) + m = instance_method(:cloaker_) + remove_method(:cloaker_) + m + end + end +end +f = Foo.new +m = f.make { |x| "got #{x} in #{self.class}" } +p m.bind_call(f, 7) +` + if got := eval(t, src); got != "\"got 7 in Foo\"\n" { + t.Errorf("got=%q", got) + } +} + +// TestUnboundMethodBindRejectsIncompatible covers the checkBindable failure path: +// an UnboundMethod from one class cannot bind to an unrelated receiver. +func TestUnboundMethodBindRejectsIncompatible(t *testing.T) { + src := ` +class A; def foo; "a"; end; end +class B; end +m = A.instance_method(:foo) +begin + m.bind(B.new) +rescue TypeError => e + puts e.message +end +` + want := "bind argument must be an instance of A\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} diff --git a/internal/vm/reflection.go b/internal/vm/reflection.go index e678ba56..675822c6 100644 --- a/internal/vm/reflection.go +++ b/internal/vm/reflection.go @@ -88,7 +88,16 @@ func (vm *VM) registerReflection() { // checkBindable raises TypeError unless recv is an instance of the // UnboundMethod's owner (Ruby's bind compatibility rule). func (vm *VM) checkBindable(u *UnboundMethod, recv object.Value) { - if !classIsA(vm.classOf(recv), u.owner) { - raise("TypeError", "bind argument must be an instance of %s", u.owner.name) + // An UnboundMethod taken from a per-object singleton class (the Trollop + // `cloaker_` trick: `instance_method` on `class << self`) is owned by that + // singleton class. classOf(recv) returns the regular class and never reaches + // the singleton in its super chain, so also walk from recv's own singleton + // class — recv is trivially an instance of it — before rejecting the bind. + if classIsA(vm.classOf(recv), u.owner) { + return } + if sc := vm.objSingleton(recv); sc != nil && classIsA(sc, u.owner) { + return + } + raise("TypeError", "bind argument must be an instance of %s", u.owner.name) } diff --git a/internal/vm/regexp.go b/internal/vm/regexp.go index 7e5b2c9c..9089817a 100644 --- a/internal/vm/regexp.go +++ b/internal/vm/regexp.go @@ -842,6 +842,21 @@ func (vm *VM) installRegexp() { vm.cRegexp.smethods["escape"] = reEscape vm.cRegexp.smethods["quote"] = &Method{name: "quote", owner: vm.cRegexp, native: reEscape.native} + // Regexp.last_match returns the MatchData of the most recent match ($~), or + // with an Integer / name argument the corresponding capture (Regexp.last_match(1) + // == $1). nil when there has been no match, matching MRI. + vm.cRegexp.smethods["last_match"] = &Method{name: "last_match", owner: vm.cRegexp, + native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + md, ok := vm.lastMatch.(*MatchData) + if !ok { + return object.NilV + } + if len(args) == 0 { + return md + } + return vm.send(md, "[]", []object.Value{args[0]}, nil) + }} + // Regexp.union(pat, ...) / Regexp.union([pat, ...]) builds one Regexp matching // any of the patterns. A Regexp argument contributes its source; a String is // escaped to match literally. With no arguments it matches nothing, as MRI. @@ -914,12 +929,24 @@ func (vm *VM) installRegexp() { vm.cRegexp.define("=~", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { return vm.regexpMatchIndex(reArg(self), args[0]) }) - vm.cRegexp.define("===", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + vm.cRegexp.define("===", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { s, ok := stringLike(args[0]) if !ok { + // A non-string operand never matches and clears $~, as MRI does (so a + // case/when over a non-string subject leaves no stale last match). + vm.lastMatch = object.NilV + return object.False + } + re := reArg(self) + md := re.re.Match(s) + if md == nil { + vm.lastMatch = object.NilV return object.False } - return object.Bool(reArg(self).re.MatchString(s)) + // Like =~, a successful === records $~ so Regexp.last_match / $1 work in the + // taken case/when branch (Trollop derives an option's :long this way). + vm.lastMatch = &MatchData{md: md, subject: s, re: re} + return object.True }) mdArg := func(v object.Value) *MatchData { return v.(*MatchData) } diff --git a/internal/vm/vm.go b/internal/vm/vm.go index ca0d7817..4e801882 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -282,9 +282,10 @@ func (vm *VM) installHashKeyHook() { hv := vm.send(k, "hash", nil, nil) rh, ok := hashAsInt(hv) if !ok { - // A non-integer #hash result is unusable as a bucket; fall back to - // identity so the key still works (just by object identity). - return 0, nil, false + // MRI requires #hash to return an Integer and raises when a key whose + // #hash yields something else is used; mirror that rather than silently + // falling back to identity. + raise("TypeError", "no implicit conversion of %s into Integer", vm.classOf(hv).name) } eql := func(stored object.Value) bool { return vm.send(k, "eql?", []object.Value{stored}, nil).Truthy() @@ -298,11 +299,11 @@ func (vm *VM) installHashKeyHook() { // gave the type value semantics for hashing. Plain reference objects with no such // override keep identity behaviour. func (vm *VM) hasCustomHash(v object.Value) bool { + // findMethod returns nil only for a receiver with no #hash at all (a bare + // BasicObject); such a key keeps identity behaviour, same as a default + // Object#hash, so a single expression covers both. m := vm.findMethod(v, "hash") - if m == nil { - return false - } - return m.owner != vm.cObject && m.owner != vm.cBasicObject + return m != nil && m.owner != vm.cObject && m.owner != vm.cBasicObject } // hashAsInt coerces the result of a Ruby #hash call to an int64 bucket. MRI's