Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 105 additions & 9 deletions internal/object/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
package object

import (
"fmt"
"math"
"math/big"
"strconv"
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -345,30 +423,48 @@ 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
}

// 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 := 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
}
Expand All @@ -386,7 +482,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 {
Expand Down
53 changes: 53 additions & 0 deletions internal/object/object_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package object

import (
"math"
"math/big"
"testing"
)

Expand Down Expand Up @@ -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")
Expand Down
21 changes: 21 additions & 0 deletions internal/vm/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -2389,13 +2404,19 @@ 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)
}
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))
Expand Down
20 changes: 20 additions & 0 deletions internal/vm/prelude.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -1538,6 +1555,7 @@ def scan_until(pattern)
def getch
return nil if eos?
c = @string[@pos]
@match = nil
@matched = c
@pos += 1
c
Expand All @@ -1552,13 +1570,15 @@ def peek(len)
# matching MRI.
def terminate
@pos = @string.length
@match = nil
@matched = nil
self
end

# reset returns the position to the start and clears the match.
def reset
@pos = 0
@match = nil
@matched = nil
self
end
Expand Down
Loading
Loading