From 766bbfcedeb2e2fbebebdc93d5a1a121d19793c6 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 30 Jun 2026 23:00:42 +0200 Subject: [PATCH 1/2] json: single-pass parse/generate via go-ruby-json streaming API Close the JSON.parse (~3.9x) / JSON.generate (~3.7x) gap vs MRI 4.0.5. The binding previously converted the whole document twice each way: parse built a go-ruby-json `any` tree that fromJSON then walked to build the rbgo object graph; generate built an intermediate json value tree that the library then walked. Both are now one pass over the new streaming API: - jsonParse drives json.ParseInto with an objBuilder that materialises the rbgo object graph directly (no intermediate tree), interning small integers (object.IntValue) and pre-sizing each Hash/Array to its parsed width (new object.NewHashCap). - jsonGenerate / jsonPrettyGenerate push the rbgo graph straight into the library's json.Encoder via an objSource, with no intermediate json tree. Output stays byte-identical to MRI 4.0.5 (verified end-to-end: key order, floats, escaping, unicode/surrogates, bignums, -0.0, duplicate keys, generate vs pretty_generate, symbolize_names). Re-pins go-ruby-json to the streaming-API release. Measured rbgo-vs-MRI 4.0.5 (M4 Max, best-of-8), ratio before -> after: small object parse 3.73x -> 2.67x generate 4.10x -> 2.50x 1k-array parse 6.10x -> 3.90x generate 4.85x -> 2.19x deep nested parse 4.80x -> 4.82x generate 4.24x -> 1.63x big strings parse 18.6x -> 5.0x generate 15.7x -> 4.0x 100% coverage on every file touched; gofmt/vet clean; CGO=0 builds on all six 64-bit arches. Co-Authored-By: Claude Opus 4.8 --- go.mod | 2 +- go.sum | 4 +- internal/object/object.go | 10 ++ internal/object/object_test.go | 17 ++ internal/vm/json_bind.go | 229 ++++++++++++++++--------- internal/vm/json_bind_internal_test.go | 173 ++++++++++++------- 6 files changed, 284 insertions(+), 151 deletions(-) diff --git a/go.mod b/go.mod index 7171667b..a49766e8 100644 --- a/go.mod +++ b/go.mod @@ -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-20260630205811-cf0dfd806588 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 diff --git a/go.sum b/go.sum index fd76aba5..3d618571 100644 --- a/go.sum +++ b/go.sum @@ -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-20260630205811-cf0dfd806588 h1:ndQhXq6mVu1yJU/d8n9E/kIVwDnw0ln/ebYEToKn+fg= +github.com/go-ruby-json/json v0.0.0-20260630205811-cf0dfd806588/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= diff --git a/internal/object/object.go b/internal/object/object.go index 34a6c99f..d1f4352e 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -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 } diff --git a/internal/object/object_test.go b/internal/object/object_test.go index ea8d1b29..1f4c0851 100644 --- a/internal/object/object_test.go +++ b/internal/object/object_test.go @@ -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. diff --git a/internal/vm/json_bind.go b/internal/vm/json_bind.go index 13c27a93..62ebf17a 100644 --- a/internal/vm/json_bind.go +++ b/internal/vm/json_bind.go @@ -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) } @@ -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) } @@ -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 @@ -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 -} diff --git a/internal/vm/json_bind_internal_test.go b/internal/vm/json_bind_internal_test.go index 7367fcaa..c039369f 100644 --- a/internal/vm/json_bind_internal_test.go +++ b/internal/vm/json_bind_internal_test.go @@ -13,52 +13,68 @@ import ( "github.com/go-embedded-ruby/ruby/internal/object" ) -// TestJSONBindToJSON covers the rbgo->library value mapping (toJSON) across every -// arm, including the scalar shapes the document tests do not all reach directly -// (a plain Go nil, a Bignum, a Symbol) and the to_s-of-unknown default. -func TestJSONBindToJSON(t *testing.T) { +// TestJSONBindGenerate covers the rbgo->library generate mapping (objSource / +// emitValue) across every arm by round-tripping each shape through jsonGenerate, +// including the scalar shapes the document tests do not all reach directly (a +// plain Go nil, a Bignum, a Symbol) and the to_s-of-unknown default. +func TestJSONBindGenerate(t *testing.T) { cases := []struct { name string in object.Value - want json.Value + want string }{ - {"go-nil", nil, nil}, - {"nil", object.NilV, nil}, - {"true", object.Bool(true), true}, - {"false", object.Bool(false), false}, - {"int", object.Integer(7), int64(7)}, - {"float", object.Float(2.5), float64(2.5)}, - {"string", object.NewString("hi"), "hi"}, - {"symbol", object.Symbol("sym"), json.Symbol("sym")}, + {"go-nil", nil, "null"}, + {"nil", object.NilV, "null"}, + {"true", object.Bool(true), "true"}, + {"false", object.Bool(false), "false"}, + {"int", object.Integer(7), "7"}, + {"float", object.Float(2.5), "2.5"}, + {"string", object.NewString("hi"), `"hi"`}, + {"symbol", object.Symbol("sym"), `"sym"`}, + {"empty-array", &object.Array{}, "[]"}, + {"array", &object.Array{Elems: []object.Value{object.Integer(1)}}, "[1]"}, } for _, c := range cases { - if got := toJSON(c.in); got != c.want { - t.Errorf("%s: toJSON=%#v want %#v", c.name, got, c.want) + if got := jsonGenerate(c.in); got != c.want { + t.Errorf("%s: jsonGenerate=%q want %q", c.name, got, c.want) } } - // A Bignum maps to its *big.Int. + // A Bignum emits its full decimal form. bg := new(big.Int).Exp(big.NewInt(10), big.NewInt(30), nil) - if got, ok := toJSON(object.NormInt(bg)).(*big.Int); !ok || got.Cmp(bg) != 0 { - t.Errorf("bignum -> %#v", toJSON(object.NormInt(bg))) - } - // An Array maps to []json.Value, a Hash to an ordered *Map (keys coerced). - arr := toJSON(&object.Array{Elems: []object.Value{object.Integer(1)}}) - if s, ok := arr.([]json.Value); !ok || len(s) != 1 || s[0] != int64(1) { - t.Errorf("array -> %#v", arr) + if got := jsonGenerate(object.NormInt(bg)); got != bg.String() { + t.Errorf("bignum -> %q", got) } + // A Hash emits an ordered object with coerced keys (Symbol key -> bare name). h := object.NewHash() h.Set(object.Symbol("k"), object.Integer(2)) - m, ok := toJSON(h).(*json.Map) - if !ok || m.Len() != 1 { - t.Fatalf("hash -> %#v", toJSON(h)) + if got := jsonGenerate(h); got != `{"k":2}` { + t.Errorf("hash -> %q", got) } - if v, ok := m.Get("k"); !ok || v != int64(2) { - t.Errorf("hash key -> %#v present=%v", v, ok) + // An empty Hash emits "{}". + if got := jsonGenerate(object.NewHash()); got != "{}" { + t.Errorf("empty hash -> %q", got) } // The to_s-of-unknown default: a Range serialises by its Ruby to_s string. rng := &object.Range{Lo: object.Integer(1), Hi: object.Integer(2)} - if got := toJSON(rng); got != "1..2" { - t.Errorf("range default -> %#v", got) + if got := jsonGenerate(rng); got != `"1..2"` { + t.Errorf("range default -> %q", got) + } +} + +// TestJSONBindGenerateNestedError covers emitValue's error-propagation arms: a +// non-finite float nested inside an Array and inside a Hash re-raises +// JSON::GeneratorError (exercising the err returns of the Array/Object callbacks). +func TestJSONBindGenerateNestedError(t *testing.T) { + one := object.Float(1.0) + inf := object.Float(float64(one) / float64(object.Float(0))) // +Inf + arr := &object.Array{Elems: []object.Value{inf}} + if re := rubyErr(t, func() { jsonGenerate(arr) }); re.Class != "JSON::GeneratorError" { + t.Errorf("array+inf -> class=%q", re.Class) + } + h := object.NewHash() + h.Set(object.NewString("k"), inf) + if re := rubyErr(t, func() { jsonGenerate(h) }); re.Class != "JSON::GeneratorError" { + t.Errorf("hash+inf -> class=%q", re.Class) } } @@ -76,46 +92,77 @@ func TestJSONBindKeyString(t *testing.T) { } } -// TestJSONBindFromJSON covers the library->rbgo value mapping (fromJSON) across -// every arm, including the cases the document tests do not all reach directly (a -// *big.Int, a Symbol) and the defensive default. -func TestJSONBindFromJSON(t *testing.T) { - if fromJSON(nil) != object.NilV { - t.Error("nil -> not NilV") - } - if v, ok := fromJSON(true).(object.Bool); !ok || !bool(v) { - t.Errorf("bool -> %#v", fromJSON(true)) - } - if v, ok := fromJSON(int64(9)).(object.Integer); !ok || int64(v) != 9 { - t.Errorf("int64 -> %#v", fromJSON(int64(9))) +// TestJSONBindObjBuilder covers the library->rbgo parse mapping (objBuilder) +// across every Builder method by driving the builder directly, including the +// scalar shapes (a *big.Int via Big, a symbolized Key) and the nested +// array-inside-object structure the document tests do not all reach directly. +func TestJSONBindObjBuilder(t *testing.T) { + // Scalar arms, each as the sole top-level value. + var bNull objBuilder + bNull.Null() + if bNull.Result() != object.NilV { + t.Error("Null -> not NilV") + } + var bBool objBuilder + bBool.Bool(true) + if v, ok := bBool.Result().(object.Bool); !ok || !bool(v) { + t.Errorf("Bool -> %#v", bBool.Result()) + } + var bInt objBuilder + bInt.Int(9) + if v, ok := bInt.Result().(object.Integer); !ok || int64(v) != 9 { + t.Errorf("Int -> %#v", bInt.Result()) } bg := new(big.Int).Exp(big.NewInt(10), big.NewInt(30), nil) - if v, ok := fromJSON(bg).(*object.Bignum); !ok || v.I.Cmp(bg) != 0 { - t.Errorf("*big.Int -> %#v", fromJSON(bg)) - } - if v, ok := fromJSON(float64(2.5)).(object.Float); !ok || float64(v) != 2.5 { - t.Errorf("float64 -> %#v", fromJSON(float64(2.5))) + var bBig objBuilder + bBig.Big(bg) + if v, ok := bBig.Result().(*object.Bignum); !ok || v.I.Cmp(bg) != 0 { + t.Errorf("Big -> %#v", bBig.Result()) } - if v, ok := fromJSON("s").(*object.String); !ok || v.Str() != "s" { - t.Errorf("string -> %#v", fromJSON("s")) + var bFloat objBuilder + bFloat.Float(2.5) + if v, ok := bFloat.Result().(object.Float); !ok || float64(v) != 2.5 { + t.Errorf("Float -> %#v", bFloat.Result()) } - if v, ok := fromJSON(json.Symbol("y")).(object.Symbol); !ok || string(v) != "y" { - t.Errorf("Symbol -> %#v", fromJSON(json.Symbol("y"))) + var bStr objBuilder + bStr.Str("s") + if v, ok := bStr.Result().(*object.String); !ok || v.Str() != "s" { + t.Errorf("Str -> %#v", bStr.Result()) } - arr := fromJSON([]json.Value{int64(1), "x"}) - a, ok := arr.(*object.Array) + + // An object containing an array value, with a symbolized key — exercises + // BeginObject/Key(symbolize)/BeginArray/elements/EndArray/EndObject and emit + // into both an open Hash and an open Array. + var b objBuilder + b.BeginObject(1) + b.Key("k", true) // symbolize -> Symbol key + b.BeginArray(2) + b.Int(1) + b.Str("x") + b.EndArray() + b.EndObject() + h, ok := b.Result().(*object.Hash) + if !ok || len(h.Keys) != 1 { + t.Fatalf("object -> %#v", b.Result()) + } + if _, ok := h.Keys[0].(object.Symbol); !ok { + t.Errorf("symbolized key -> %#v", h.Keys[0]) + } + v, _ := h.Get(object.Symbol("k")) + a, ok := v.(*object.Array) if !ok || len(a.Elems) != 2 { - t.Fatalf("[]Value -> %#v", arr) - } - m := json.NewMap() - m.Set("k", int64(3)) - hv := fromJSON(m) - if hh, ok := hv.(*object.Hash); !ok || len(hh.Keys) != 1 { - t.Errorf("*Map -> %#v", hv) + t.Fatalf("array value -> %#v", v) } - // A value the parser never produces (a bare int) maps to nil (defensive arm). - if fromJSON(123) != object.NilV { - t.Errorf("unmapped -> %#v", fromJSON(123)) + + // A non-symbolized Key yields a String key. + var b2 objBuilder + b2.BeginObject(1) + b2.Key("s", false) + b2.Null() + b2.EndObject() + h2 := b2.Result().(*object.Hash) + if _, ok := h2.Keys[0].(*object.String); !ok { + t.Errorf("string key -> %#v", h2.Keys[0]) } } From 261b0a934515b78816fd5cda827fb50fb6572338 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 30 Jun 2026 23:04:12 +0200 Subject: [PATCH 2/2] Merge main into perf/json-fastpath; re-pin go-ruby-json to merged main Brings in the set-algebra perf merge (#87) and re-pins go-ruby-json to its squash-merged main pseudo-version after lib PR #1 landed. Co-Authored-By: Claude Opus 4.8 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1117a8a4..21ca9616 100644 --- a/go.mod +++ b/go.mod @@ -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-20260630205811-cf0dfd806588 + 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 diff --git a/go.sum b/go.sum index e502660e..3967c1d7 100644 --- a/go.sum +++ b/go.sum @@ -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-20260630205811-cf0dfd806588 h1:ndQhXq6mVu1yJU/d8n9E/kIVwDnw0ln/ebYEToKn+fg= -github.com/go-ruby-json/json v0.0.0-20260630205811-cf0dfd806588/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=