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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ require (
github.com/go-ruby-format/format v0.0.0-20260629094031-e294417ab31c
github.com/go-ruby-getoptlong/getoptlong v0.0.0-20260629150025-1a1bfd19bc49
github.com/go-ruby-ipaddr/ipaddr v0.0.0-20260630052208-78ca85dc054b
github.com/go-ruby-json/json v0.0.0-20260629113256-b42f13b6cb06
github.com/go-ruby-json/json v0.0.0-20260630210255-96013117f0d5
github.com/go-ruby-logger/logger v0.0.0-20260630081511-870e2ee3f277
github.com/go-ruby-marshal/marshal v0.0.0-20260622114304-27ed1baddd9f
github.com/go-ruby-matrix/matrix v0.0.0-20260630052510-d60a23f08aca
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ github.com/go-ruby-getoptlong/getoptlong v0.0.0-20260629150025-1a1bfd19bc49 h1:v
github.com/go-ruby-getoptlong/getoptlong v0.0.0-20260629150025-1a1bfd19bc49/go.mod h1:nGvVSBSIsYrhM2qs4yQ2k8uDbXCFPWQXXqkowleHMDs=
github.com/go-ruby-ipaddr/ipaddr v0.0.0-20260630052208-78ca85dc054b h1:1iw1eRBOG7D7jCC/P4hzWllM4IkYiR/jP9Bwuo3ds6c=
github.com/go-ruby-ipaddr/ipaddr v0.0.0-20260630052208-78ca85dc054b/go.mod h1:NbAjqDjT7Qx83yBMHYGCfPFzi+iAvQtrE3JT3nqS/+U=
github.com/go-ruby-json/json v0.0.0-20260629113256-b42f13b6cb06 h1:3m+pFs699msdrHRMXTtv5N2H/Qj2k8YSbndLlCyO8Ww=
github.com/go-ruby-json/json v0.0.0-20260629113256-b42f13b6cb06/go.mod h1:nHXcH3zaGNmCslZStZVeylXkv1wLp+nzT6qNj+m+QXU=
github.com/go-ruby-json/json v0.0.0-20260630210255-96013117f0d5 h1:KFU3OKsop0CF8Zv1TQ9FwGY3fWcdGGCcqJEjx9zxykU=
github.com/go-ruby-json/json v0.0.0-20260630210255-96013117f0d5/go.mod h1:nHXcH3zaGNmCslZStZVeylXkv1wLp+nzT6qNj+m+QXU=
github.com/go-ruby-logger/logger v0.0.0-20260630081511-870e2ee3f277 h1:XfUzDZqZWgOUTJ22GdwNOgkZB9MZKhxsPTq0xL1u5Io=
github.com/go-ruby-logger/logger v0.0.0-20260630081511-870e2ee3f277/go.mod h1:9+5E2AAIpErKRtzJ7zqZSfptjlD/XcD/ni6H9jpO37Y=
github.com/go-ruby-marshal/marshal v0.0.0-20260622114304-27ed1baddd9f h1:IIBuHgiQJbssGPhrUbagvP74febWhmKxiNk7IQq1//g=
Expand Down
10 changes: 10 additions & 0 deletions internal/object/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,16 @@ func snapshotKey(k Value) Value {
// NewHash returns an empty hash.
func NewHash() *Hash { return &Hash{vals: map[any]Value{}} }

// NewHashCap returns an empty hash whose backing map and key slice are pre-sized
// for about n entries, so a bulk builder (e.g. JSON.parse of a known-width
// object) fills it without re-growing. A negative n behaves like NewHash.
func NewHashCap(n int) *Hash {
if n < 0 {
n = 0
}
return &Hash{vals: make(map[any]Value, n), Keys: make([]Value, 0, n)}
}

// Get returns the value for k and whether it is present.
func (h *Hash) Get(k Value) (Value, bool) { v, ok := h.vals[h.hashKey(k)]; return v, ok }

Expand Down
17 changes: 17 additions & 0 deletions internal/object/object_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,23 @@ func TestHashOps(t *testing.T) {
}
}

// TestNewHashCap covers the pre-sized hash constructor: a positive capacity
// builds an empty, fully usable hash, and a negative capacity clamps to zero
// (behaving like NewHash) rather than panicking.
func TestNewHashCap(t *testing.T) {
h := NewHashCap(4)
if h.Len() != 0 {
t.Fatalf("pre-sized hash len = %d want 0", h.Len())
}
h.Set(NewString("k"), Integer(1))
if v, ok := h.Get(NewString("k")); !ok || v != Integer(1) {
t.Fatalf("pre-sized hash get = %v,%v", v, ok)
}
if neg := NewHashCap(-1); neg.Len() != 0 {
t.Fatalf("negative-cap hash len = %d want 0", neg.Len())
}
}

// TestHashContentKeysAndClear covers content-addressed Array/Hash/Bignum keys,
// Clear, Delete, and (with no CustomKeyHook installed) the identity fallback for a
// plain reference key.
Expand Down
229 changes: 144 additions & 85 deletions internal/vm/json_bind.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,30 @@ import (
// and the interpreter-independent value model of github.com/go-ruby-json/json.
// The parser and generator themselves live in that library (ported, byte-for-byte,
// from rbgo's former internal/vm/json.go); rbgo only translates its values to and
// from the library's `any` model around a single json.Parse / json.Generate /
// json.PrettyGenerate call, so the MRI-compatible behaviour Puppet's multi_json
// fallback relies on is preserved by construction. The library's typed errors are
// re-raised as the matching Ruby exception via their RubyClass().

// jsonParse parses a JSON document into a Ruby value by calling json.Parse with
// the given options and mapping the result back into the rbgo object graph. A
// malformed document raises JSON::ParserError; exceeding the nesting limit
// from the library in a single streaming pass: parsing drives a json.Builder
// (objBuilder) that materialises the rbgo object graph directly — no intermediate
// json.Value tree and no second conversion — and generation pushes the rbgo graph
// straight into the library's json.Encoder via a json.Source (objSource). The
// library's typed errors are re-raised as the matching Ruby exception via their
// RubyClass().

// jsonParse parses a JSON document into a Ruby value by driving json.ParseInto
// with an objBuilder, building the rbgo object graph in one pass. A malformed
// document raises JSON::ParserError; exceeding the nesting limit
// JSON::NestingError.
func jsonParse(src string, opts ...json.Option) object.Value {
v, err := json.Parse(src, opts...)
if err != nil {
var b objBuilder
if err := json.ParseInto(src, &b, opts...); err != nil {
raiseJSONError(err)
}
return fromJSON(v)
return b.Result().(object.Value)
}

// jsonGenerate renders a Ruby value to a compact JSON document via json.Generate,
// re-raising a non-finite-float / over-deep value as the matching Ruby exception.
// jsonGenerate renders a Ruby value to a compact JSON document via
// json.GenerateSource, re-raising a non-finite-float / over-deep value as the
// matching Ruby exception.
func jsonGenerate(v object.Value, opts ...json.Option) string {
out, err := json.Generate(toJSON(v), opts...)
out, err := json.GenerateSource(objSource{v}, opts...)
if err != nil {
raiseJSONError(err)
}
Expand All @@ -45,7 +48,7 @@ func jsonGenerate(v object.Value, opts ...json.Option) string {

// jsonPrettyGenerate renders a Ruby value with MRI's JSON.pretty_generate layout.
func jsonPrettyGenerate(v object.Value, opts ...json.Option) string {
out, err := json.PrettyGenerate(toJSON(v), opts...)
out, err := json.PrettyGenerateSource(objSource{v}, opts...)
if err != nil {
raiseJSONError(err)
}
Expand All @@ -61,48 +64,143 @@ func raiseJSONError(err error) {
raise(je.RubyClass(), "%s", je.Error())
}

// --- rbgo value -> library value (for Generate) ----------------------------
// --- library parse events -> rbgo object graph (objBuilder) ----------------

// objBuilder is the json.Builder that materialises rbgo values directly as the
// parser reads them. It keeps an explicit stack of open Array/Hash containers and
// attaches each emitted value to the innermost one (an array element, or the
// value of the pending Hash key), so parsing builds the object graph in a single
// allocation-light pass: arrays and hashes are pre-sized to their exact length,
// small integers are interned (object.IntValue), and strings come straight from
// the parser's zero-copy slices.
type objBuilder struct {
stack []objFrame
root object.Value
}

// objFrame is one open container on the builder stack: an *object.Array while an
// array is open, or an *object.Hash plus its pending key while an object is open.
type objFrame struct {
arr *object.Array
h *object.Hash
key object.Value // pending Hash key set by Key, consumed by the next emit
}

// emit attaches v to the innermost open container, or records it as the parse
// result at the top level.
func (b *objBuilder) emit(v object.Value) {
if n := len(b.stack); n > 0 {
f := &b.stack[n-1]
if f.h != nil {
f.h.Set(f.key, v)
return
}
f.arr.Elems = append(f.arr.Elems, v)
return
}
b.root = v
}

func (b *objBuilder) Null() { b.emit(object.NilV) }
func (b *objBuilder) Bool(x bool) { b.emit(object.Bool(x)) }
func (b *objBuilder) Int(n int64) { b.emit(object.IntValue(n)) }
func (b *objBuilder) Float(f float64) { b.emit(object.Float(f)) }
func (b *objBuilder) Str(s string) { b.emit(object.NewString(s)) }

func (b *objBuilder) Big(n *big.Int) { b.emit(object.NormInt(n)) }

func (b *objBuilder) BeginArray(n int) {
b.stack = append(b.stack, objFrame{arr: &object.Array{Elems: make([]object.Value, 0, n)}})
}

func (b *objBuilder) EndArray() {
f := b.stack[len(b.stack)-1]
b.stack = b.stack[:len(b.stack)-1]
b.emit(f.arr)
}

func (b *objBuilder) BeginObject(n int) {
b.stack = append(b.stack, objFrame{h: object.NewHashCap(n)})
}

func (b *objBuilder) Key(s string, symbolize bool) {
f := &b.stack[len(b.stack)-1]
if symbolize {
f.key = object.Symbol(s)
} else {
f.key = object.NewString(s)
}
}

func (b *objBuilder) EndObject() {
f := b.stack[len(b.stack)-1]
b.stack = b.stack[:len(b.stack)-1]
b.emit(f.h)
}

// Result returns the top-level value (as json.Value, per the Builder interface);
// jsonParse asserts it back to object.Value.
func (b *objBuilder) Result() json.Value { return b.root }

// --- rbgo object graph -> library generate events (objSource) --------------

// objSource is the json.Source that streams a Ruby value into the library's
// Encoder, walking the rbgo object graph once with no intermediate json value.
// The JSON value shapes (nil / true / false / Integer / Bignum / Float / String /
// Symbol / Array / ordered Hash) all map directly; any other value emits its Ruby
// to_s as a JSON string, matching the former generator's to_s-of-unknown fallback
// (e.g. a Range serialises as "1..2").
type objSource struct{ v object.Value }

// EmitTo pushes the wrapped value (and, recursively, its children) into e.
func (s objSource) EmitTo(e *json.Encoder) error { return emitValue(e, s.v) }

// toJSON maps a Ruby value to the go-ruby-json value model. The JSON value
// shapes (nil / true / false / Integer / Bignum / Float / String / Symbol /
// Array / ordered Hash) all translate; any other value maps to its Ruby to_s so
// the library emits it as a JSON string, matching the former generator's
// to_s-of-unknown fallback (e.g. a Range serialises as "1..2").
func toJSON(v object.Value) json.Value {
// emitValue renders one rbgo value through the encoder.
func emitValue(e *json.Encoder, v object.Value) error {
switch n := v.(type) {
case nil:
return nil
case object.Nil:
return nil
case nil, object.Nil:
e.Null()
case object.Bool:
return bool(n)
e.Bool(bool(n))
case object.Integer:
return int64(n)
e.Int(int64(n))
case *object.Bignum:
return n.I
e.Big(n.I)
case object.Float:
return float64(n)
return e.Float(float64(n))
case *object.String:
return n.Str()
e.Str(n.Str())
case object.Symbol:
return json.Symbol(string(n))
e.Str(string(n))
case *object.Array:
out := make([]json.Value, len(n.Elems))
for i, el := range n.Elems {
out[i] = toJSON(el)
}
return out
elems := n.Elems
return e.Array(len(elems), func() error {
for _, el := range elems {
e.Elem()
if err := emitValue(e, el); err != nil {
return err
}
}
return nil
})
case *object.Hash:
m := json.NewMap()
for _, k := range n.Keys {
val, _ := n.Get(k)
m.Set(jsonKeyString(k), toJSON(val))
}
return m
keys := n.Keys
return e.Object(len(keys), func() error {
for _, k := range keys {
val, _ := n.Get(k)
e.Key(jsonKeyString(k))
if err := emitValue(e, val); err != nil {
return err
}
}
return nil
})
default:
// Any other value (a Range, an RObject, …): its Ruby to_s, emitted as a JSON
// string. The former generator did the same via jsonStr(v.ToS()).
e.Str(v.ToS())
}
// Any other value (a Range, an RObject, …): its Ruby to_s, emitted as a JSON
// string. The former generator did the same via jsonStr(v.ToS()).
return v.ToS()
return nil
}

// jsonKeyString renders a Hash key as the string JSON requires: a String keeps
Expand All @@ -117,42 +215,3 @@ func jsonKeyString(k object.Value) string {
}
return k.ToS()
}

// --- library value -> rbgo value (for Parse) -------------------------------

// fromJSON maps a value produced by json.Parse back into the rbgo object graph:
// nil / bool / int64 / *big.Int / float64 / string / Symbol / []any / ordered
// *Map cover every shape the parser yields.
func fromJSON(v json.Value) object.Value {
switch n := v.(type) {
case nil:
return object.NilV
case bool:
return object.Bool(n)
case int64:
return object.Integer(n)
case *big.Int:
return object.NormInt(n)
case float64:
return object.Float(n)
case string:
return object.NewString(n)
case json.Symbol:
return object.Symbol(string(n))
case []json.Value:
arr := &object.Array{Elems: make([]object.Value, len(n))}
for i, el := range n {
arr.Elems[i] = fromJSON(el)
}
return arr
case *json.Map:
h := object.NewHash()
for _, p := range n.Pairs() {
h.Set(fromJSON(p.Key), fromJSON(p.Val))
}
return h
}
// The parser only produces the cases above; any other value maps to nil
// defensively.
return object.NilV
}
Loading
Loading