diff --git a/go.mod b/go.mod index c2c3db74..46fd695b 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/go-ruby-marshal/marshal v0.0.0-20260622114304-27ed1baddd9f github.com/go-ruby-parser/parser v0.0.0-20260626192347-1bbe8b4672d0 github.com/go-ruby-regexp/regexp v0.0.0-20260628133338-5a3a8b96feac + github.com/go-ruby-yaml/yaml v0.0.0-20260629093916-8035038027bd ) require ( diff --git a/go.sum b/go.sum index 70e2cf79..e14e6645 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/go-ruby-parser/parser v0.0.0-20260626192347-1bbe8b4672d0 h1:Q3ryvK9hP github.com/go-ruby-parser/parser v0.0.0-20260626192347-1bbe8b4672d0/go.mod h1:NRbX4dYcQ6CgcaSaMmu1dh95QuKGq+RdenUoWU73214= github.com/go-ruby-regexp/regexp v0.0.0-20260628133338-5a3a8b96feac h1:jYaeh86ngvgRe2Kn4hUmSO+DV0okQv5PcdC+gMiyP2U= github.com/go-ruby-regexp/regexp v0.0.0-20260628133338-5a3a8b96feac/go.mod h1:+0nK2X+aCPMO91bPMM/DT1OFUIzFQeoTPGkkOR3kd84= +github.com/go-ruby-yaml/yaml v0.0.0-20260629093916-8035038027bd h1:4HGjgCdTIuIEM+iZXJ9hJJzt0IBMtxPqdJq72mr0Y64= +github.com/go-ruby-yaml/yaml v0.0.0-20260629093916-8035038027bd/go.mod h1:HtbQgnZpSTJBAhPg1jOKgHycZl3tXgf9ZdOu4dnHWjQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/internal/vm/yaml.go b/internal/vm/yaml.go index 8f2d8e1a..bb7a8a93 100644 --- a/internal/vm/yaml.go +++ b/internal/vm/yaml.go @@ -9,9 +9,12 @@ import ( ) // registerYAML installs the YAML / Psych standard library (require "yaml"). The -// module, its constant / error tree, the dump emitter and the load / safe_load / -// load_file parser are pure-Go; parse / parse_stream / dump_tags (the low-level -// node API) still raise NotImplementedError as Puppet does not call them. +// module, its constant / error tree, and the dump / load / safe_load / load_file +// methods are pure-Go: the Psych-compatible emitter and loader live in the +// github.com/go-ruby-yaml/yaml library and rbgo binds them here, mapping its own +// object graph to and from that library's value model (see yaml_bind.go). The +// low-level node API (parse / parse_stream / dump_tags) still raises +// NotImplementedError as Puppet does not call it. func (vm *VM) registerYAML() { psych := newClass("Psych", nil) psych.isModule = true @@ -41,12 +44,9 @@ func (vm *VM) registerYAML() { } // YAML.load(source[, ...]) / Psych.load parse a YAML document string to a tree - // of Ruby values (Hash / Array / String / Symbol / Integer / Float / true / - // false / nil / Time, and !ruby/object: instances). Leading keyword/positional - // options Psych accepts (permitted_classes, aliases, filename, …) are tolerated - // and ignored: this loader is already safe by construction (it instantiates - // only the classes named by !ruby/object: tags actually present), so safe_load - // shares the same implementation. + // of Ruby values. unsafe_load shares the same implementation. Leading + // keyword/positional options Psych accepts (filename, symbolize_names, …) are + // tolerated and ignored. loadFn := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { if len(args) == 0 { raise("ArgumentError", "wrong number of arguments (given 0, expected 1..)") @@ -54,28 +54,41 @@ func (vm *VM) registerYAML() { return yamlLoad(vm, yamlSourceArg(args[0])) } psych.smethods["load"] = &Method{name: "load", owner: psych, native: loadFn} - psych.smethods["safe_load"] = &Method{name: "safe_load", owner: psych, native: loadFn} psych.smethods["unsafe_load"] = &Method{name: "unsafe_load", owner: psych, native: loadFn} + // YAML.safe_load(source[, permitted_classes: [...]]) restricts which + // !ruby/object: classes materialise; the loader is already safe by construction, + // so the allow-list is the only observable difference from load. + safeLoadFn := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1..)") + } + return yamlSafeLoad(vm, yamlSourceArg(args[0]), permittedClassesArg(args[1:])) + } + psych.smethods["safe_load"] = &Method{name: "safe_load", owner: psych, native: safeLoadFn} + // YAML.load_file(path[, ...]) reads the file and parses its contents. loadFileFn := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { if len(args) == 0 { raise("ArgumentError", "wrong number of arguments (given 0, expected 1..)") } - data, err := fuReadFile(strArg(args[0])) - if err != nil { - raise("Errno::ENOENT", "No such file or directory @ rb_sysopen - %s", strArg(args[0])) - } - return yamlLoad(vm, string(data)) + return yamlLoad(vm, yamlReadFile(args[0])) } psych.smethods["load_file"] = &Method{name: "load_file", owner: psych, native: loadFileFn} - psych.smethods["safe_load_file"] = &Method{name: "safe_load_file", owner: psych, native: loadFileFn} - // YAML.dump(obj[, io]) serialises a tree of plain values (Hash/Array/String/ - // Symbol/Integer/Float/true/false/nil/Time) to a Psych-compatible document. - // With a second IO argument it writes the document there and returns the IO - // (as Psych does), so Puppet::Util::Yaml.dump(structure, fh) persists state - // and run-summary files; with one argument it returns the String. A value + // YAML.safe_load_file(path[, permitted_classes: [...]]) is safe_load over a file. + safeLoadFileFn := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1..)") + } + return yamlSafeLoad(vm, yamlReadFile(args[0]), permittedClassesArg(args[1:])) + } + psych.smethods["safe_load_file"] = &Method{name: "safe_load_file", owner: psych, native: safeLoadFileFn} + + // YAML.dump(obj[, io]) serialises a tree of Ruby values to a Psych-compatible + // document. With a second IO argument it writes the document there and returns + // the IO (as Psych does), so Puppet::Util::Yaml.dump(structure, fh) persists + // state and run-summary files; with one argument it returns the String. A value // outside the supported shapes raises TypeError, which the report YAML // indirector rescues and logs, leaving puppet apply otherwise clean. psych.smethods["dump"] = &Method{name: "dump", owner: psych, @@ -109,3 +122,46 @@ func yamlSourceArg(v object.Value) string { } return v.ToS() } + +// yamlReadFile reads the file named by v (coerced to a path string), raising +// Errno::ENOENT when it cannot be opened (matching Psych.load_file). +func yamlReadFile(v object.Value) string { + path := strArg(v) + data, err := fuReadFile(path) + if err != nil { + raise("Errno::ENOENT", "No such file or directory @ rb_sysopen - %s", path) + } + return string(data) +} + +// permittedClassesArg extracts the permitted_classes: keyword from safe_load's +// trailing arguments, returning the class names (so SafeLoad restricts to them), +// or nil when the keyword is absent (permitting all classes). Class / Module +// values list by name; any other element is rendered via to_s, matching how +// Psych accepts a list of class objects or names. +func permittedClassesArg(rest []object.Value) []string { + if len(rest) == 0 { + return nil + } + h, ok := rest[len(rest)-1].(*object.Hash) + if !ok { + return nil + } + val, ok := h.Get(object.Symbol("permitted_classes")) + if !ok { + return nil + } + arr, ok := val.(*object.Array) + if !ok { + return nil + } + names := make([]string, 0, len(arr.Elems)) + for _, el := range arr.Elems { + if c, ok := el.(*RClass); ok { + names = append(names, c.ToS()) + continue + } + names = append(names, el.ToS()) + } + return names +} diff --git a/internal/vm/yaml_bind.go b/internal/vm/yaml_bind.go new file mode 100644 index 00000000..988ad533 --- /dev/null +++ b/internal/vm/yaml_bind.go @@ -0,0 +1,393 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "math/big" + "strings" + stdtime "time" + + gotime "github.com/go-composites/time/src" + yaml "github.com/go-ruby-yaml/yaml" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// yamlResolveClass resolves a (possibly `::`-qualified) class name used in a +// `!ruby/object:Name` / `!ruby/class` / `!ruby/module` tag to its RClass, +// registering a fresh placeholder class (a plain subclass of Object) under the +// top-level name when the program has not defined it — so loading an object whose +// class is unknown still yields a typed instance rather than failing. +func (vm *VM) yamlResolveClass(name string) *RClass { + parts := strings.Split(name, "::") + cur := vm.cObject + var resolved *RClass + for i, p := range parts { + v, ok := vm.resolveConst(cur, p) + if !ok { + resolved = nil + break + } + c, isClass := v.(*RClass) + if !isClass { + resolved = nil + break + } + resolved = c + if i < len(parts)-1 { + cur = c + } + } + if resolved != nil { + return resolved + } + // Unknown class: register a placeholder under its qualified name so repeated + // loads reuse one class object. + if v, ok := vm.cObject.consts[name]; ok { + if c, isClass := v.(*RClass); isClass { + return c + } + } + c := newClass(name, vm.cObject) + vm.cObject.consts[name] = c + return c +} + +// This file is the thin binding between rbgo's Ruby object graph (object.Value +// and the VM's RObject / Set / Time / Regexp / RClass shells) and the +// interpreter-independent value model of github.com/go-ruby-yaml/yaml. The +// emitter and loader themselves live in that library (ported, byte-for-byte, +// from rbgo's former internal YAML); rbgo only translates its values to and from +// the library's `any` model around a single yaml.Dump / yaml.Load call, so the +// Psych-compatible behaviour Puppet's state/report persistence relies on is +// preserved by construction. + +// yamlDump serialises a Ruby value to a Psych-compatible document by mapping it +// into the library's value model and calling yaml.Dump. A value with no Psych +// representation (e.g. a Proc) raises a Ruby TypeError, matching the former +// emitter's contract that the report indirector rescues. +func yamlDump(vm *VM, v object.Value) string { + out, err := yaml.Dump(toYAML(vm, v)) + if err != nil { + raise("TypeError", "%s", err.Error()) + } + return out +} + +// yamlLoad parses a YAML document string into a Ruby value by calling yaml.Load +// and mapping the result back into the rbgo object graph. A malformed document +// raises Psych::SyntaxError; an empty document loads as nil. +func yamlLoad(vm *VM, src string) object.Value { + v, err := yaml.Load(src) + if err != nil { + raise("Psych::SyntaxError", "%s", yamlErrMessage(err)) + } + return fromYAML(vm, v) +} + +// yamlSafeLoad parses src like yamlLoad but threads the permitted_classes +// allow-list through the library's SafeLoad. +func yamlSafeLoad(vm *VM, src string, permitted []string) object.Value { + var opts []yaml.Option + if permitted != nil { + opts = append(opts, yaml.WithPermittedClasses(permitted...)) + } + v, err := yaml.SafeLoad(src, opts...) + if err != nil { + raise("Psych::SyntaxError", "%s", yamlErrMessage(err)) + } + return fromYAML(vm, v) +} + +// yamlErrMessage extracts the human-readable text of a library error, preferring +// the SyntaxError.Message (Psych-style) over the wrapped Error() form. +func yamlErrMessage(err error) string { + if se, ok := err.(*yaml.SyntaxError); ok { + return se.Message + } + return err.Error() +} + +// --- rbgo value -> library value (for Dump) -------------------------------- + +// toYAML maps a Ruby value to the go-ruby-yaml value model. The shapes Puppet +// persists (Hash / Array / String / Symbol / Integer / Float / true / false / +// nil / Time, plus the report graph's RObject / Range / Set) all translate; an +// unmapped value is returned as-is so the library raises the dump TypeError. +// +// A per-dump identity cache preserves shared / cyclic references: a Ruby +// reference value (Array / Hash / RObject / Range / Set) reached more than once +// maps to the very same library value, so the library's anchor emitter writes it +// once behind "&N" and aliases it thereafter — matching the former emitter and +// terminating cycles. +func toYAML(vm *VM, v object.Value) yaml.Value { + return (&yamlToCtx{vm: vm, seen: map[object.Value]yaml.Value{}}).conv(v) +} + +// yamlToCtx carries the per-dump identity cache for the rbgo->library mapping. +type yamlToCtx struct { + vm *VM + seen map[object.Value]yaml.Value +} + +// conv maps one Ruby value to its library equivalent. +func (c *yamlToCtx) conv(v object.Value) yaml.Value { + switch n := v.(type) { + case nil: + return nil + case object.Nil: + return nil + case object.Bool: + return bool(n) + case object.Integer: + return int64(n) + case *object.Bignum: + return n.I + case object.Float: + return float64(n) + case *object.String: + return n.Str() + case object.Symbol: + return yaml.Symbol(string(n)) + case *object.Array: + return c.convArray(n) + case *object.Hash: + return c.convHash(n) + case *Time: + return stdtime.Unix(n.t.ToUnix(), 0).UTC() + case *object.Range: + return c.convRange(n) + case *RObject: + return c.convObject(n) + case *Set: + return c.convSet(n) + case *RClass: + if n.isModule { + return yaml.Module(n.ToS()) + } + return yaml.Class(n.ToS()) + case *Regexp: + return &yaml.Regexp{Source: n.source, Flags: orderFlags(n.flags)} + } + // An unmapped value (e.g. a Proc): hand it to the library, which returns the + // dump error yamlDump turns into a Ruby TypeError. + return v +} + +// convBound maps a Range endpoint, where a Go-nil bound (a beginless / endless +// range) becomes a library nil. +func (c *yamlToCtx) convBound(v object.Value) yaml.Value { + if v == nil { + return nil + } + return c.conv(v) +} + +// convArray maps a Ruby Array to a library []any, registered in the identity +// cache before its elements so a cyclic / shared array maps to one slice. +func (c *yamlToCtx) convArray(a *object.Array) yaml.Value { + if cached, ok := c.seen[a]; ok { + return cached + } + out := make([]yaml.Value, len(a.Elems)) + c.seen[a] = out // the backing array is fixed; elements are filled in place + for i, el := range a.Elems { + out[i] = c.conv(el) + } + return out +} + +// convHash maps an ordered Ruby Hash to the library's ordered *Map, preserving +// key insertion order and caching identity for shared / cyclic hashes. +func (c *yamlToCtx) convHash(h *object.Hash) yaml.Value { + if cached, ok := c.seen[h]; ok { + return cached + } + m := yaml.NewMap() + c.seen[h] = m + for _, k := range h.Keys { + val, _ := h.Get(k) + m.Set(c.conv(k), c.conv(val)) + } + return m +} + +// convRange maps a Ruby Range to a library *Range, caching identity. +func (c *yamlToCtx) convRange(r *object.Range) yaml.Value { + if cached, ok := c.seen[r]; ok { + return cached + } + out := &yaml.Range{Exclusive: r.Exclusive} + c.seen[r] = out + out.Begin = c.convBound(r.Lo) + out.End = c.convBound(r.Hi) + return out +} + +// convObject maps an ordinary Ruby object to a !ruby/object tagged value: its +// class name and its instance variables with the leading "@" stripped (the +// library orders them lexicographically, matching the former emitter). The +// library value is cached before its ivars so a self-referential object graph +// terminates. +func (c *yamlToCtx) convObject(o *RObject) yaml.Value { + if cached, ok := c.seen[o]; ok { + return cached + } + className := "" + if o.class != nil { + className = o.class.name + } + out := &yaml.Object{Class: className, IVars: map[string]yaml.Value{}} + c.seen[o] = out + for name, val := range o.ivars { + out.IVars[ivarBareName(name)] = c.conv(val) + } + return out +} + +// convSet maps a Ruby Set to its Psych shape (!ruby/object:Set with a backing +// "hash" ivar of element->true, in insertion order), caching identity. +func (c *yamlToCtx) convSet(s *Set) yaml.Value { + if cached, ok := c.seen[s]; ok { + return cached + } + out := &yaml.Object{Class: "Set", IVars: map[string]yaml.Value{}} + c.seen[s] = out + inner := yaml.NewMap() + for _, k := range s.order { + inner.Set(c.conv(s.vals[k]), true) + } + out.IVars["hash"] = inner + return out +} + +// ivarBareName strips a leading "@" from an instance-variable name (the library +// holds ivars by bare name). +func ivarBareName(name string) string { + if len(name) > 0 && name[0] == '@' { + return name[1:] + } + return name +} + +// --- library value -> rbgo value (for Load) -------------------------------- + +// fromYAML maps a value produced by yaml.Load / yaml.SafeLoad back into the rbgo +// object graph. Shared references the library preserves (an *Object / *Map seen +// twice via an alias is the same Go pointer) are mapped once and cached, so an +// aliased node round-trips to a single rbgo instance. +func fromYAML(vm *VM, v yaml.Value) object.Value { + return (&yamlFromCtx{vm: vm, seen: map[yaml.Value]object.Value{}}).conv(v) +} + +// yamlFromCtx carries the per-load identity cache that preserves shared / aliased +// references across the conversion. +type yamlFromCtx struct { + vm *VM + seen map[yaml.Value]object.Value +} + +// conv maps one library value to its rbgo equivalent. +func (c *yamlFromCtx) conv(v yaml.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 yaml.Symbol: + return object.Symbol(string(n)) + case []yaml.Value: + return c.convSeq(n) + case *yaml.Map: + return c.convMap(n) + case stdtime.Time: + return &Time{t: gotime.FromUnix(n.Unix())} + case *yaml.Range: + return c.convRange(n) + case *yaml.Object: + return c.convObject(n) + case yaml.Class: + return c.vm.yamlResolveClass(string(n)) + case yaml.Module: + return c.vm.yamlResolveClass(string(n)) + case *yaml.Regexp: + return c.vm.compileRegexp(n.Source, n.Flags) + } + // Any value the library does not model maps to nil (defensive: the loader only + // ever produces the cases above). + return object.NilV +} + +// convSeq maps a library sequence to a Ruby Array, caching identity so a shared +// (aliased) sequence becomes a single Array. +func (c *yamlFromCtx) convSeq(s []yaml.Value) object.Value { + arr := &object.Array{Elems: make([]object.Value, len(s))} + for i, el := range s { + arr.Elems[i] = c.conv(el) + } + return arr +} + +// convMap maps a library ordered *Map to a Ruby Hash, caching identity so a +// shared (aliased) mapping becomes a single Hash. +func (c *yamlFromCtx) convMap(m *yaml.Map) object.Value { + if cached, ok := c.seen[m]; ok { + return cached + } + h := object.NewHash() + c.seen[m] = h + for _, p := range m.Pairs() { + h.Set(c.conv(p.Key), c.conv(p.Val)) + } + return h +} + +// convRange maps a library *Range to a Ruby Range, a nil bound modelling a +// beginless / endless range. +func (c *yamlFromCtx) convRange(r *yaml.Range) object.Value { + return &object.Range{ + Lo: c.convBound(r.Begin), + Hi: c.convBound(r.End), + Exclusive: r.Exclusive, + } +} + +// convBound maps a Range endpoint, where a library nil bound becomes a Go-nil +// Range bound (rbgo's beginless / endless representation). +func (c *yamlFromCtx) convBound(v yaml.Value) object.Value { + if v == nil { + return nil + } + return c.conv(v) +} + +// convObject maps a library *Object back into a Ruby instance of the named class +// (resolved through the VM, registering a placeholder for an unknown class), +// re-prefixing each ivar name with "@". Identity is cached so a shared (aliased) +// object becomes a single instance. +func (c *yamlFromCtx) convObject(o *yaml.Object) object.Value { + if cached, ok := c.seen[o]; ok { + return cached + } + name := o.Class + if name == "" { + name = "Object" + } + cls := c.vm.yamlResolveClass(name) + obj := &RObject{class: cls, ivars: map[string]object.Value{}} + c.seen[o] = obj + for k, val := range o.IVars { + obj.ivars["@"+k] = c.conv(val) + } + return obj +} diff --git a/internal/vm/yaml_bind_internal_test.go b/internal/vm/yaml_bind_internal_test.go new file mode 100644 index 00000000..c128f9c8 --- /dev/null +++ b/internal/vm/yaml_bind_internal_test.go @@ -0,0 +1,308 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "errors" + "math/big" + "testing" + stdtime "time" + + yaml "github.com/go-ruby-yaml/yaml" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// rubyErr runs fn and returns the RubyError it panics with (failing the test if +// it does not panic with one). It is the white-box analogue of the black-box +// runErr helper, used to assert the binding's raise() paths directly. +func rubyErr(t *testing.T, fn func()) RubyError { + t.Helper() + var got RubyError + func() { + defer func() { + re, ok := recover().(RubyError) + if !ok { + t.Fatalf("expected a RubyError panic, got %v", recover()) + } + got = re + }() + fn() + }() + return got +} + +// TestYAMLBindErrMessage covers yamlErrMessage's SyntaxError and generic-error +// branches. +func TestYAMLBindErrMessage(t *testing.T) { + if got := yamlErrMessage(&yaml.SyntaxError{Message: "boom"}); got != "boom" { + t.Errorf("SyntaxError message=%q", got) + } + if got := yamlErrMessage(errors.New("plain")); got != "plain" { + t.Errorf("plain error message=%q", got) + } +} + +// TestYAMLBindLoadSyntaxError covers the yamlLoad / yamlSafeLoad error branches: +// a tab character used for indentation makes the library return a SyntaxError, +// which the binding raises as Psych::SyntaxError. +func TestYAMLBindLoadSyntaxError(t *testing.T) { + vm := New(nil) + doc := "---\na:\n\t- 1\n" // a tab under "a:" is invalid indentation + for _, call := range []struct { + name string + fn func() + }{ + {"load", func() { yamlLoad(vm, doc) }}, + {"safe_load", func() { yamlSafeLoad(vm, doc, nil) }}, + {"safe_load/permitted", func() { yamlSafeLoad(vm, doc, []string{"Foo"}) }}, + } { + re := rubyErr(t, call.fn) + if re.Class != "Psych::SyntaxError" { + t.Errorf("%s: class=%q message=%q", call.name, re.Class, re.Message) + } + } +} + +// TestYAMLBindDumpUnmapped covers toYAML's default branch (a value with no Psych +// representation) surfacing as the dump TypeError. +func TestYAMLBindDumpUnmapped(t *testing.T) { + vm := New(nil) + // A Proc has no library mapping; toYAML returns it as-is and the library's + // Dump rejects it, which yamlDump turns into a TypeError. + re := rubyErr(t, func() { yamlDump(vm, &Proc{}) }) + if re.Class != "TypeError" { + t.Errorf("class=%q message=%q", re.Class, re.Message) + } +} + +// TestYAMLBindToScalars covers the scalar arms of the rbgo->library mapping that +// the document tests do not all reach directly (bignum, float, plain Go nil). +func TestYAMLBindToScalars(t *testing.T) { + vm := New(nil) + c := &yamlToCtx{vm: vm, seen: map[object.Value]yaml.Value{}} + big30 := object.NormInt(new(big.Int).Exp(big.NewInt(10), big.NewInt(30), nil)) + if v, ok := c.conv(big30).(*big.Int); !ok || v.Sign() <= 0 { + t.Errorf("bignum -> %T %v", c.conv(big30), c.conv(big30)) + } + if v := c.conv(object.Float(1.5)); v != float64(1.5) { + t.Errorf("float -> %v", v) + } + // A Go-nil object.Value maps to a library nil (the defensive first arm). + if v := c.conv(nil); v != nil { + t.Errorf("nil -> %v", v) + } + // convBound's nil and non-nil arms. + if v := c.convBound(nil); v != nil { + t.Errorf("convBound(nil) -> %v", v) + } + if v := c.convBound(object.Integer(3)); v != int64(3) { + t.Errorf("convBound(3) -> %v", v) + } +} + +// TestYAMLBindToSharedCache covers the identity cache hit on the dump side for a +// shared Array / Hash / Range / RObject / Set referenced twice (the cached +// branches), so each maps to the very same library value (enabling the library's +// anchor emission). +func TestYAMLBindToSharedCache(t *testing.T) { + vm := New(nil) + check := func(name string, v object.Value) { + c := &yamlToCtx{vm: vm, seen: map[object.Value]yaml.Value{}} + first := c.conv(v) + second := c.conv(v) + // For pointer-typed library values, identity is the pointer; for a slice it + // is the backing array (same len + same first element address). + switch f := first.(type) { + case *yaml.Map: + if f != second.(*yaml.Map) { + t.Errorf("%s: map not cached", name) + } + case *yaml.Range: + if f != second.(*yaml.Range) { + t.Errorf("%s: range not cached", name) + } + case *yaml.Object: + if f != second.(*yaml.Object) { + t.Errorf("%s: object not cached", name) + } + case []yaml.Value: + s := second.([]yaml.Value) + if len(f) == 0 || len(f) != len(s) || &f[0] != &s[0] { + t.Errorf("%s: slice not cached", name) + } + } + } + arr := &object.Array{Elems: []object.Value{object.Integer(1)}} + check("array", arr) + h := object.NewHash() + h.Set(object.NewString("k"), object.Integer(1)) + check("hash", h) + check("range", &object.Range{Lo: object.Integer(1), Hi: object.Integer(5)}) + check("object", &RObject{class: newClass("O", vm.cObject), ivars: map[string]object.Value{"@a": object.Integer(1)}}) + set := newSet() + set.add(object.Integer(1)) + check("set", set) +} + +// TestYAMLBindIvarBareName covers both arms of ivarBareName (a leading "@" is +// stripped; a name without one is returned verbatim). +func TestYAMLBindIvarBareName(t *testing.T) { + if got := ivarBareName("@x"); got != "x" { + t.Errorf("@x -> %q", got) + } + if got := ivarBareName("y"); got != "y" { + t.Errorf("y -> %q", got) + } + if got := ivarBareName(""); got != "" { + t.Errorf("empty -> %q", got) + } +} + +// TestYAMLBindFromScalars covers the library->rbgo scalar arms not reached by the +// round-trip tests: a *big.Int, a float64, a time.Time, a Class, a Module, a +// *Regexp, and the unmapped default (nil). +func TestYAMLBindFromScalars(t *testing.T) { + vm := New(nil) + c := &yamlFromCtx{vm: vm, seen: map[yaml.Value]object.Value{}} + bg := new(big.Int).Exp(big.NewInt(10), big.NewInt(30), nil) + if v, ok := c.conv(bg).(*object.Bignum); !ok || v.I.Cmp(bg) != 0 { + t.Errorf("*big.Int -> %T", c.conv(bg)) + } + if v, ok := c.conv(float64(2.5)).(object.Float); !ok || float64(v) != 2.5 { + t.Errorf("float64 -> %v", c.conv(float64(2.5))) + } + tm := c.conv(stdtime.Unix(0, 0).UTC()) + if rt, ok := tm.(*Time); !ok || rt.t.ToUnix() != 0 { + t.Errorf("time.Time -> %T", tm) + } + if cl, ok := c.conv(yaml.Class("String")).(*RClass); !ok || cl.ToS() != "String" { + t.Errorf("Class -> %T", c.conv(yaml.Class("String"))) + } + if md, ok := c.conv(yaml.Module("Comparable")).(*RClass); !ok || md.ToS() != "Comparable" { + t.Errorf("Module -> %T", c.conv(yaml.Module("Comparable"))) + } + if rx, ok := c.conv(&yaml.Regexp{Source: "ab", Flags: "i"}).(*Regexp); !ok || rx.source != "ab" { + t.Errorf("Regexp -> %T", c.conv(&yaml.Regexp{Source: "ab"})) + } + // An unmodelled value maps to nil (the defensive final arm). A bare int (not + // int64) is never produced by Load, so it exercises the default. + if v := c.conv(123); v != object.NilV { + t.Errorf("unmapped -> %v", v) + } +} + +// TestYAMLBindFromRangeAndBound covers the library->rbgo Range mapping and both +// convBound arms (a present bound and a nil beginless / endless bound). +func TestYAMLBindFromRangeAndBound(t *testing.T) { + vm := New(nil) + c := &yamlFromCtx{vm: vm, seen: map[yaml.Value]object.Value{}} + r := c.conv(&yaml.Range{Begin: nil, End: int64(5), Exclusive: true}) + rng, ok := r.(*object.Range) + if !ok || rng.Lo != nil || !rng.Exclusive { + t.Fatalf("range -> %T %v", r, r) + } + if hi, ok := rng.Hi.(object.Integer); !ok || int64(hi) != 5 { + t.Errorf("range end -> %v", rng.Hi) + } +} + +// TestYAMLBindFromSharedCache covers the library->rbgo identity cache hit for a +// shared *Map / *Object referenced twice (the cached branches), so an aliased +// node round-trips to one rbgo instance. +func TestYAMLBindFromSharedCache(t *testing.T) { + vm := New(nil) + c := &yamlFromCtx{vm: vm, seen: map[yaml.Value]object.Value{}} + m := yaml.NewMap() + m.Set("k", int64(1)) + if c.conv(m) != c.conv(m) { + t.Error("map not cached on load side") + } + o := &yaml.Object{Class: "Foo", IVars: map[string]yaml.Value{"a": int64(1)}} + if c.conv(o) != c.conv(o) { + t.Error("object not cached on load side") + } + // A bare object (empty class) resolves to the Object class. + bare := c.conv(&yaml.Object{Class: "", IVars: map[string]yaml.Value{}}) + if ro, ok := bare.(*RObject); !ok || ro.class.name != "Object" { + t.Errorf("bare object -> %T", bare) + } +} + +// TestYAMLBindResolveClass covers yamlResolveClass through the Class/Module load +// path: a known top-level class is returned, a qualified known class resolves, +// and an unknown name registers a reusable placeholder. +func TestYAMLBindResolveClass(t *testing.T) { + vm := New(nil) + if c := vm.yamlResolveClass("String"); c == nil || c.ToS() != "String" { + t.Errorf("known class -> %v", c) + } + outer := newClass("Outer", vm.cObject) + inner := newClass("Inner", vm.cObject) + outer.consts["Inner"] = inner + vm.cObject.consts["Outer"] = outer + if c := vm.yamlResolveClass("Outer::Inner"); c != inner { + t.Errorf("qualified known -> %v", c) + } + first := vm.yamlResolveClass("Made::Up") + if second := vm.yamlResolveClass("Made::Up"); second != first { + t.Error("placeholder not reused") + } + // A name that resolves to a non-class constant degrades to a placeholder. + vm.cObject.consts["NotAClass"] = object.Integer(5) + if c := vm.yamlResolveClass("NotAClass"); c == nil || c.name != "NotAClass" { + t.Errorf("non-class const -> %v", c) + } + if c := vm.yamlResolveClass("NotAClass"); c == nil || c.name != "NotAClass" { + t.Error("non-class placeholder not reused") + } + // A qualified name whose first segment is a non-class const also degrades. + if c := vm.yamlResolveClass("NotAClass::Y"); c == nil || c.name != "NotAClass::Y" { + t.Errorf("qualified non-class -> %v", c) + } +} + +// TestYAMLBindPermittedClassesArg covers permittedClassesArg's branches: no +// trailing args, a trailing non-Hash, a Hash without the keyword, a non-Array +// value, and the populated case with a Class element and a string element. +func TestYAMLBindPermittedClassesArg(t *testing.T) { + if got := permittedClassesArg(nil); got != nil { + t.Errorf("no args -> %v", got) + } + if got := permittedClassesArg([]object.Value{object.Integer(1)}); got != nil { + t.Errorf("non-hash trailing -> %v", got) + } + if got := permittedClassesArg([]object.Value{object.NewHash()}); got != nil { + t.Errorf("hash without keyword -> %v", got) + } + // permitted_classes whose value is not an Array is ignored. + h := object.NewHash() + h.Set(object.Symbol("permitted_classes"), object.Integer(7)) + if got := permittedClassesArg([]object.Value{h}); got != nil { + t.Errorf("non-array value -> %v", got) + } + // A populated list: a Class element lists by name, a String element by to_s. + vm := New(nil) + h2 := object.NewHash() + arr := &object.Array{Elems: []object.Value{vm.consts["String"], object.NewString("Symbol")}} + h2.Set(object.Symbol("permitted_classes"), arr) + got := permittedClassesArg([]object.Value{h2}) + if len(got) != 2 || got[0] != "String" || got[1] != "Symbol" { + t.Errorf("populated -> %v", got) + } +} + +// TestYAMLBindFromTimeConstruct guards the time round-trip helper independently +// (a non-zero instant) so the gotime.FromUnix arm is exercised with a real value. +func TestYAMLBindFromTimeConstruct(t *testing.T) { + vm := New(nil) + c := &yamlFromCtx{vm: vm, seen: map[yaml.Value]object.Value{}} + want := int64(1_600_000_000) + v := c.conv(stdtime.Unix(want, 0).UTC()) + rt, ok := v.(*Time) + if !ok || rt.t.ToUnix() != want { + t.Fatalf("time round trip -> %T %v", v, v) + } +} diff --git a/internal/vm/yaml_dump.go b/internal/vm/yaml_dump.go deleted file mode 100644 index d6182b81..00000000 --- a/internal/vm/yaml_dump.go +++ /dev/null @@ -1,469 +0,0 @@ -// Copyright (c) the go-embedded-ruby/ruby authors -// -// SPDX-License-Identifier: BSD-3-Clause - -package vm - -import ( - "fmt" - "math" - "regexp" - "strconv" - "strings" - - "github.com/go-embedded-ruby/ruby/internal/object" -) - -// yamlEncoder serialises a tree of plain Ruby values (Hash, Array, String, -// Symbol, Integer, Float, true/false/nil, Time) to a Psych-compatible YAML -// document. It deliberately covers only the value shapes Puppet's local -// persistence writes — the run-summary Hash (last_run_summary.yaml) and the -// agent state Hash (state.yaml) — and raises a Ruby TypeError for any other -// object, so the indirector that wraps it degrades cleanly (its rescue logs a -// Puppet.err) for the full !ruby/object: report graph that a complete Psych -// emitter would be needed to dump. -type yamlEncoder struct { - b strings.Builder - vm *VM - root object.Value // the value being dumped, used to compute the reference graph - // anchors maps an already-emitted reference value (by identity) to its anchor - // number, so a shared / cyclic node in the report graph is written once with - // "&N" and referenced thereafter with "*N". - anchors map[object.Value]int - // refcount holds how many times each anchorable node is reachable from root; - // a count above one forces an anchor. Computed lazily on the first shared() - // query and cached. - refcount map[object.Value]int - seq int -} - -// yamlDump renders v as a complete YAML document beginning with the "---" -// directive, matching Psych.dump's output for the supported value shapes. vm -// supplies class names and ivar tables for generic object emission; it may be nil -// for the plain-value tests, in which case an arbitrary object raises TypeError. -func yamlDump(vm *VM, v object.Value) string { - e := &yamlEncoder{vm: vm, root: v, anchors: map[object.Value]int{}} - e.b.WriteString("---") - if isYAMLComplex(v) { - // A tagged complex value (object/range) writes "--- " then its mapping - // body at indent 0. - e.b.WriteByte(' ') - e.writeAnchorTag(v) - if e.tagBodyEmpty(v) { - e.b.WriteString(" {}\n") - return e.b.String() - } - e.b.WriteByte('\n') - e.encodePairs(e.tagBody(v), 0) - return e.b.String() - } - if isYAMLInline(v) { - // A scalar, or an empty collection, follows "--- " on the document line. - e.b.WriteByte(' ') - e.b.WriteString(e.inlineEmpty(v)) - e.b.WriteByte('\n') - return e.b.String() - } - e.b.WriteByte('\n') - e.encodeNode(v, 0) - return e.b.String() -} - -// isYAMLInline reports whether v renders on the document line: any scalar, or an -// empty Array/Hash (Psych writes "--- []" / "--- {}"). -func isYAMLInline(v object.Value) bool { - switch n := v.(type) { - case *object.Array: - return len(n.Elems) == 0 - case *object.Hash: - return n.Len() == 0 - } - return true -} - -// inlineEmpty renders the document-line value: "[]"/"{}" for an empty collection, -// otherwise the scalar form. -func (e *yamlEncoder) inlineEmpty(v object.Value) string { - switch n := v.(type) { - case *object.Array: - _ = n - return "[]" - case *object.Hash: - _ = n - return "{}" - } - return e.scalar(v) -} - -// encodeNode writes a non-empty collection at the given indentation. It follows -// Psych's default block style: a block sequence that is a mapping value sits at -// the SAME indent as its key (the dashes align under the key), while a nested -// mapping is indented two spaces deeper; under a sequence dash the child's first -// line continues on the dash line ("- - 1", "- a: 1"). -func (e *yamlEncoder) encodeNode(v object.Value, indent int) { - pad := strings.Repeat(" ", indent) - switch n := v.(type) { - case *object.Array: - for _, el := range n.Elems { - e.b.WriteString(pad) - e.b.WriteByte('-') - e.writeSeqChild(el, indent) - } - case *object.Hash: - for _, k := range n.Keys { - val, _ := n.Get(k) - if e.writeComplexKey(k, val, indent, pad) { - continue - } - e.b.WriteString(pad) - e.b.WriteString(e.keyScalar(k)) - e.b.WriteByte(':') - e.writeMapChild(val, indent) - } - } -} - -// keyScalar renders a mapping key scalar. It differs from scalar only for a nil -// key, which Psych writes as "! ”" (the plain empty form scalar would emit is -// ambiguous as a key), so the "key:" line always parses. -func (e *yamlEncoder) keyScalar(k object.Value) string { - if _, ok := k.(object.Nil); ok { - return "! ''" - } - return e.scalar(k) -} - -// writeComplexKey emits a mapping entry whose key is itself a non-scalar value -// (an Array, Hash or object), using Psych's explicit "? " / ": " -// block form, and reports whether it handled the entry. openPad is written before -// the "?" (empty when it already sits on a parent dash line); the ":" line is -// always written at the mapping's own indent. A scalar key returns false so the -// caller uses the inline "key:" form. -func (e *yamlEncoder) writeComplexKey(k, val object.Value, indent int, openPad string) bool { - if !isComplexKey(k) { - return false - } - e.b.WriteString(openPad) - e.b.WriteByte('?') - // The key block opens on the "?" line exactly as a sequence-dash child would - // (object tag / nested collection), reusing writeSeqChild's layout. - e.writeSeqChild(k, indent) - e.b.WriteString(strings.Repeat(" ", indent)) - e.b.WriteByte(':') - e.writeMapChild(val, indent) - return true -} - -// isComplexKey reports whether a mapping key must be written with the explicit -// "?"/":" block form (any non-scalar value). -func isComplexKey(v object.Value) bool { - switch n := v.(type) { - case *object.Array: - return len(n.Elems) > 0 - case *object.Hash: - return n.Len() > 0 - } - return isYAMLComplex(v) -} - -// writeMapChild emits the value of a mapping entry (after "key:"): a scalar -// inline, or a nested collection on the following lines. A block sequence stays -// at the key's indent; a nested mapping is indented two deeper. -func (e *yamlEncoder) writeMapChild(v object.Value, indent int) { - if e.writeComplexChild(v, indent) { - return - } - switch n := v.(type) { - case *object.Array: - if len(n.Elems) == 0 { - e.b.WriteString(" []\n") - return - } - e.b.WriteByte('\n') - e.encodeNode(v, indent) // sequence aligns under the key - case *object.Hash: - if n.Len() == 0 { - e.b.WriteString(" {}\n") - return - } - e.b.WriteByte('\n') - e.encodeNode(v, indent+2) - default: - e.writeInlineScalar(v) - } -} - -// writeSeqChild emits the value following a sequence dash. A scalar is inline; -// a nested collection continues on the dash line, its remaining lines indented -// two spaces deeper (Psych's "- - 1" / "- a: 1" layout). -func (e *yamlEncoder) writeSeqChild(v object.Value, indent int) { - if e.writeComplexSeqChild(v, indent) { - return - } - switch n := v.(type) { - case *object.Array: - if len(n.Elems) == 0 { - e.b.WriteString(" []\n") - return - } - e.b.WriteByte(' ') - e.encodeInlineFirst(v, indent+2) - case *object.Hash: - if n.Len() == 0 { - e.b.WriteString(" {}\n") - return - } - e.b.WriteByte(' ') - e.encodeInlineFirst(v, indent+2) - default: - e.writeInlineScalar(v) - } -} - -// encodeInlineFirst renders a collection whose first line was already opened on -// the parent's dash line (the cursor sits just after "- "): the first element is -// written without its leading indent, and subsequent elements at the given -// indent. -func (e *yamlEncoder) encodeInlineFirst(v object.Value, indent int) { - pad := strings.Repeat(" ", indent) - switch n := v.(type) { - case *object.Array: - for i, el := range n.Elems { - if i > 0 { - e.b.WriteString(pad) - } - e.b.WriteByte('-') - e.writeSeqChild(el, indent) - } - case *object.Hash: - for i, k := range n.Keys { - val, _ := n.Get(k) - // The "?" opener of a complex key carries the row indent for every entry - // except the first (which already sits on the parent dash line). - openPad := pad - if i == 0 { - openPad = "" - } - if e.writeComplexKey(k, val, indent, openPad) { - continue - } - if i > 0 { - e.b.WriteString(pad) - } - e.b.WriteString(e.keyScalar(k)) - e.b.WriteByte(':') - e.writeMapChild(val, indent) - } - } -} - -// writeInlineScalar writes a scalar value on the current line after a "-" or -// "key:". A nil renders as nothing after the indicator (Psych's bare "key:"). -func (e *yamlEncoder) writeInlineScalar(v object.Value) { - s := e.scalar(v) - if s == "" { - e.b.WriteByte('\n') - return - } - e.b.WriteByte(' ') - e.b.WriteString(s) - e.b.WriteByte('\n') -} - -// scalar renders a single non-collection value to its Psych scalar form. -func (e *yamlEncoder) scalar(v object.Value) string { - switch n := v.(type) { - case object.Nil: - return "" - case object.Bool: - if bool(n) { - return "true" - } - return "false" - case object.Integer: - return n.ToS() - case *object.Bignum: - return n.ToS() - case object.Float: - return yamlFloat(float64(n)) - case object.Symbol: - return yamlSymbol(string(n)) - case *object.String: - return yamlString(n.Str()) - case *Time: - // Psych emits a Time as an unquoted ISO-8601 timestamp, using "Z" for a - // UTC instant and the numeric "+HH:MM" offset otherwise. - ts := n.t.Format("2006-01-02 15:04:05.000000000 -07:00") - if strings.HasSuffix(ts, " +00:00") { - ts = strings.TrimSuffix(ts, " +00:00") + " Z" - } - return ts - case *Regexp: - // Psych emits a Regexp inline as "!ruby/regexp /source/flags". - return "!ruby/regexp /" + n.source + "/" + orderFlags(n.flags) - case *RClass: - // Psych emits a Class / Module reference inline as a single-quoted name - // behind the !ruby/class or !ruby/module tag. - tag := "!ruby/class " - if n.isModule { - tag = "!ruby/module " - } - return tag + "'" + n.ToS() + "'" - } - raise("TypeError", "can't dump %s to YAML", classNameOf(v)) - return "" -} - -// yamlFloat renders a Float the way Psych does (.inf / .nan, and a trailing -// ".0" for integral values). -func yamlFloat(f float64) string { - switch { - case math.IsInf(f, 1): - return ".inf" - case math.IsInf(f, -1): - return "-.inf" - case math.IsNaN(f): - return ".nan" - } - s := strconv.FormatFloat(f, 'g', -1, 64) - if !strings.ContainsAny(s, ".eE") { - s += ".0" - } - return s -} - -// yamlSymbol renders a Ruby Symbol as `:name`. Psych writes the bare `:name` -// form for the symbols that occur in Puppet's persisted state (`:checked`, -// `:synced`, and even names with spaces/dashes like `:a b`); a name carrying a -// newline is escaped via the double-quoted form. This covers the realistic -// symbol shapes; the rare empty / control-bearing symbol is not exercised. -func yamlSymbol(name string) string { - if strings.ContainsAny(name, "\n\t") { - return ":" + yamlDoubleQuote(name) - } - return ":" + name -} - -// yamlString renders a Ruby String following Psych's plain/single/double-quote -// and block-scalar selection. -func yamlString(s string) string { - if s == "" { - return "''" - } - if strings.Contains(s, "\n") { - return yamlBlockScalar(s) - } - if needsDoubleQuote(s) { - return yamlDoubleQuote(s) - } - if needsSingleQuote(s) { - return "'" + strings.ReplaceAll(s, "'", "''") + "'" - } - return s -} - -// yamlBlockScalar renders a multi-line string as a literal block scalar (`|-`, -// stripping the trailing newline chomp), indented two spaces under its key. -func yamlBlockScalar(s string) string { - chomp := "-" - body := s - if strings.HasSuffix(s, "\n") { - chomp = "" - body = strings.TrimRight(s, "\n") - } - var b strings.Builder - b.WriteString("|" + chomp) - for _, line := range strings.Split(body, "\n") { - b.WriteString("\n ") - b.WriteString(line) - } - return b.String() -} - -// needsDoubleQuote reports whether a string contains a byte that forces -// double-quoting (control characters or a non-printable rune). -func needsDoubleQuote(s string) bool { - for _, r := range s { - if r < 0x20 || r == 0x7f { - return true - } - } - // Leading indicator characters that single-quoting cannot rescue safely are - // rendered by Psych with double quotes. - switch s[0] { - case '-', '?', ':', '@', '`', '*', '&', '!', '%', '#', '~', '|', '>', '"', '\'', '{', '}', '[', ']', ',': - // Psych double-quotes a leading "-" only when not a valid plain token; it is - // simpler and always valid to double-quote these leading-indicator strings. - return true - } - return false -} - -// needsSingleQuote reports whether a string that is otherwise plain must be -// single-quoted because it would round-trip as a non-string (a bool/null/number/ -// timestamp keyword) or carries a YAML indicator that breaks the plain flow. A -// "#" only needs quoting at the start or after a space (a comment indicator), and -// a ":" only when it ends the string or is followed by a space (a mapping -// indicator) — Psych leaves "a#b" and "File[/x]" unquoted. -func needsSingleQuote(s string) bool { - if yamlReservedWord(s) || looksNumericYAML(s) { - return true - } - if strings.HasPrefix(s, "#") || strings.Contains(s, " #") { - return true - } - if strings.HasSuffix(s, ":") || strings.Contains(s, ": ") { - return true - } - return false -} - -var yamlReserved = map[string]bool{ - "yes": true, "no": true, "true": true, "false": true, - "null": true, "~": true, "on": true, "off": true, - "y": true, "n": true, -} - -func yamlReservedWord(s string) bool { return yamlReserved[strings.ToLower(s)] } - -var yamlNumRe = regexp.MustCompile(`\A[-+]?(\d[\d_]*)(\.\d*)?([eE][-+]?\d+)?\z`) -var yamlDateRe = regexp.MustCompile(`\A\d{4}-\d{2}-\d{2}`) -var yamlTimeRe = regexp.MustCompile(`\A\d{1,2}:\d{2}`) -var yamlHexRe = regexp.MustCompile(`\A0x[0-9A-Fa-f]+\z`) - -// looksNumericYAML reports whether a plain string would be parsed back as a -// number, timestamp, or hex literal and so must be quoted to stay a string. -func looksNumericYAML(s string) bool { - return yamlNumRe.MatchString(s) || yamlDateRe.MatchString(s) || yamlTimeRe.MatchString(s) || yamlHexRe.MatchString(s) -} - -// yamlDoubleQuote renders s as a double-quoted YAML scalar, escaping the -// characters Psych escapes. -func yamlDoubleQuote(s string) string { - var b strings.Builder - b.WriteByte('"') - for _, r := range s { - switch r { - case '"': - b.WriteString(`\"`) - case '\\': - b.WriteString(`\\`) - case '\n': - b.WriteString(`\n`) - case '\t': - b.WriteString(`\t`) - case '\r': - b.WriteString(`\r`) - case 0: - b.WriteString(`\0`) - default: - if r < 0x20 || r == 0x7f { - fmt.Fprintf(&b, `\x%02X`, r) - } else { - b.WriteRune(r) - } - } - } - b.WriteByte('"') - return b.String() -} diff --git a/internal/vm/yaml_internal_test.go b/internal/vm/yaml_internal_test.go deleted file mode 100644 index 7f367332..00000000 --- a/internal/vm/yaml_internal_test.go +++ /dev/null @@ -1,478 +0,0 @@ -// Copyright (c) the go-embedded-ruby/ruby authors -// -// SPDX-License-Identifier: BSD-3-Clause - -package vm - -import ( - "math" - "testing" - - "github.com/go-embedded-ruby/ruby/internal/object" -) - -// TestYAMLUnquoteDouble covers every escape the double-quoted decoder reverses, -// plus the dangling-backslash and unknown-escape fallbacks. -func TestYAMLUnquoteDouble(t *testing.T) { - cases := map[string]string{ - `"a\nb"`: "a\nb", - `"a\tb"`: "a\tb", - `"a\rb"`: "a\rb", - `"a\0b"`: "a\x00b", - `"a\"b"`: "a\"b", - `"a\\b"`: "a\\b", - `"a\x41b"`: "aAb", - `"a\qb"`: "aqb", // unknown escape drops the backslash - `"a\x"`: "ax", // truncated \x falls back to a literal x - `"trail\"`: "trail\\", // a trailing backslash with no following char - `plain`: "plain", // not quoted: returned as-is - `""`: "", - } - for in, want := range cases { - if got := unquoteDouble(in); got != want { - t.Errorf("unquoteDouble(%q)=%q want %q", in, got, want) - } - } -} - -// TestYAMLUnquoteSingleAndScalar covers single-quote decoding and the -// unquoteScalar dispatcher (single / double / bare). -func TestYAMLUnquoteSingleAndScalar(t *testing.T) { - if got := unquoteSingle("'it''s'"); got != "it's" { - t.Errorf("unquoteSingle: %q", got) - } - if got := unquoteSingle("bare"); got != "bare" { - t.Errorf("unquoteSingle bare: %q", got) - } - if got := unquoteScalar(`'x'`); got != "x" { - t.Errorf("unquoteScalar single: %q", got) - } - if got := unquoteScalar(`"x"`); got != "x" { - t.Errorf("unquoteScalar double: %q", got) - } - if got := unquoteScalar(`x`); got != "x" { - t.Errorf("unquoteScalar bare: %q", got) - } -} - -// TestYAMLSplitFlow covers flow-body splitting across quotes and nested -// brackets / braces. -func TestYAMLSplitFlow(t *testing.T) { - got := splitFlow(`1, "a, b", [2, 3], {x: 4}`) - want := []string{"1", `"a, b"`, "[2, 3]", "{x: 4}"} - if len(got) != len(want) { - t.Fatalf("splitFlow len=%d want %d (%v)", len(got), len(want), got) - } - for i := range want { - if got[i] != want[i] { - t.Errorf("splitFlow[%d]=%q want %q", i, got[i], want[i]) - } - } - // A single-quoted span hides a comma too. - if parts := splitFlow(`'a, b', c`); len(parts) != 2 { - t.Errorf("splitFlow single-quote: %v", parts) - } -} - -// TestYAMLSplitTagAnchor covers peeling tag and anchor prefixes in either order, -// and firstWord's no-space branch. -func TestYAMLSplitTagAnchor(t *testing.T) { - tag, anc, rest := splitTagAnchor("&1 !ruby/object:Foo a: 1") - if tag != "!ruby/object:Foo" || anc != "1" || rest != "a: 1" { - t.Errorf("anchor-then-tag: tag=%q anc=%q rest=%q", tag, anc, rest) - } - tag, anc, rest = splitTagAnchor("!ruby/symbol &2 sym") - if tag != "!ruby/symbol" || anc != "2" || rest != "sym" { - t.Errorf("tag-then-anchor: tag=%q anc=%q rest=%q", tag, anc, rest) - } - // A lone anchor with nothing after it (firstWord no-space branch). - tag, anc, rest = splitTagAnchor("&9") - if tag != "" || anc != "9" || rest != "" { - t.Errorf("lone anchor: tag=%q anc=%q rest=%q", tag, anc, rest) - } -} - -// TestYAMLMapColon covers the key/value separator scan, including a quoted ":" -// that is not a separator and a missing separator. -func TestYAMLMapColon(t *testing.T) { - if i := mapColon(`a: 1`); i != 1 { - t.Errorf("mapColon plain: %d", i) - } - if i := mapColon(`"a:b": 1`); i != 5 { - t.Errorf("mapColon quoted: %d", i) - } - if i := mapColon(`'x': 1`); i != 3 { - t.Errorf("mapColon single-quoted: %d", i) - } - // A trailing ":" is a separator. - if i := mapColon(`k:`); i != 1 { - t.Errorf("mapColon trailing: %d", i) - } - // A ":" not followed by a space is not a separator (resource ref). - if i := mapColon(`File[/x]`); i != -1 { - t.Errorf("mapColon none: %d", i) - } - if _, _, ok := splitMapEntry(`nosep`); ok { - t.Errorf("splitMapEntry should fail on a non-entry") - } -} - -// TestYAMLRubyObjectTag covers the bare and qualified !ruby/object tag forms and -// a non-matching tag. -func TestYAMLRubyObjectTag(t *testing.T) { - if n, ok := rubyObjectTag("!ruby/object"); !ok || n != "Object" { - t.Errorf("bare: %q %v", n, ok) - } - if n, ok := rubyObjectTag("!ruby/object:Foo::Bar"); !ok || n != "Foo::Bar" { - t.Errorf("qualified: %q %v", n, ok) - } - if _, ok := rubyObjectTag("!ruby/symbol"); ok { - t.Errorf("non-object tag matched") - } -} - -// TestYAMLBlockScalarTag covers each block indicator and the non-block input. -func TestYAMLBlockScalarTag(t *testing.T) { - for _, c := range []struct { - in string - style, chomp byte - ok bool - }{ - {"|", '|', 0, true}, - {"|-", '|', '-', true}, - {"|+", '|', '+', true}, - {">", '>', 0, true}, - {">-", '>', '-', true}, - {"", 0, 0, false}, - {"plain", 0, 0, false}, - {"|x", 0, 0, false}, // a trailing non-chomp char is not a block indicator - } { - style, chomp, ok := blockScalarTag(c.in) - if style != c.style || chomp != c.chomp || ok != c.ok { - t.Errorf("blockScalarTag(%q)=(%q,%q,%v) want (%q,%q,%v)", c.in, style, chomp, ok, c.style, c.chomp, c.ok) - } - } -} - -// TestYAMLParseYAMLInteger covers decimal, signed, underscored, hex and the -// non-integer fallback. -func TestYAMLParseYAMLInteger(t *testing.T) { - for _, c := range []struct { - in string - want int64 - ok bool - }{ - {"42", 42, true}, - {"-7", -7, true}, - {"1_000", 1000, true}, - {"0x1A", 26, true}, - {"+0xff", 255, true}, - {"-0x10", -16, true}, - {"", 0, false}, - {"0xZZ", 0, false}, - {"notnum", 0, false}, - } { - v, ok := parseYAMLInteger(c.in) - if ok != c.ok { - t.Errorf("parseYAMLInteger(%q) ok=%v want %v", c.in, ok, c.ok) - continue - } - if ok { - if iv, isInt := v.(object.Integer); !isInt || int64(iv) != c.want { - t.Errorf("parseYAMLInteger(%q)=%v want %d", c.in, v, c.want) - } - } - } -} - -// TestYAMLParseFloatAndTime covers the float / timestamp grammar fallbacks. -func TestYAMLParseFloatAndTime(t *testing.T) { - if f, ok := parseYAMLFloat("1.5"); !ok || f != 1.5 { - t.Errorf("parseYAMLFloat 1.5: %v %v", f, ok) - } - if _, ok := parseYAMLFloat("123"); ok { - t.Error("parseYAMLFloat should reject a bare integer") - } - if _, ok := parseYAMLFloat("1.x"); ok { - t.Error("parseYAMLFloat should reject 1.x") - } - if _, ok := parseYAMLTime("not a time"); ok { - t.Error("parseYAMLTime should reject a non-timestamp") - } - if _, ok := parseYAMLTime("2026-06-29 05:18:32 Z"); !ok { - t.Error("parseYAMLTime should accept a seconds-only Z timestamp") - } -} - -// TestYAMLFloatSpecials covers posInf / negInf / nan and the float keyword scalar -// parse. -func TestYAMLFloatSpecials(t *testing.T) { - if !math.IsInf(posInf(), 1) || !math.IsInf(negInf(), -1) || !math.IsNaN(nan()) { - t.Error("float specials wrong") - } - if v := parsePlainScalar("+.inf"); !math.IsInf(float64(v.(object.Float)), 1) { - t.Error("+.inf not parsed") - } -} - -// TestYAMLKeyName covers the Symbol / String / other key-name reductions. -func TestYAMLKeyName(t *testing.T) { - if keyName(object.Symbol("s")) != "s" { - t.Error("symbol key name") - } - if keyName(object.NewString("str")) != "str" { - t.Error("string key name") - } - if keyName(object.Integer(3)) != "" { - t.Error("other key name should be empty") - } -} - -// TestYAMLDumpScalarRClassNil covers keyScalar's nil branch and that dumping nil -// as a value differs from nil as a key. -func TestYAMLDumpNilKeyVsValue(t *testing.T) { - e := &yamlEncoder{} - if got := e.keyScalar(object.NilV); got != "! ''" { - t.Errorf("keyScalar(nil)=%q", got) - } - if got := e.keyScalar(object.Integer(1)); got != "1" { - t.Errorf("keyScalar(1)=%q", got) - } -} - -// TestYAMLObjectClassSuffix covers the tag-suffix reductions: a named class, the -// bare Object class, an anonymous class (empty name), and a nil class. -func TestYAMLObjectClassSuffix(t *testing.T) { - if s := objectClassSuffix(&RObject{class: newClass("Foo", nil)}); s != ":Foo" { - t.Errorf("named: %q", s) - } - if s := objectClassSuffix(&RObject{class: newClass("Object", nil)}); s != "" { - t.Errorf("Object: %q", s) - } - if s := objectClassSuffix(&RObject{class: newClass("", nil)}); s != "" { - t.Errorf("anonymous: %q", s) - } - if s := objectClassSuffix(&RObject{class: nil}); s != "" { - t.Errorf("nil class: %q", s) - } -} - -// TestYAMLRangeBound covers the nil (beginless / endless) and present bounds. -func TestYAMLRangeBound(t *testing.T) { - if rangeBound(nil) != object.NilV { - t.Error("nil bound should map to NilV") - } - if v := rangeBound(object.Integer(3)); v != object.Integer(3) { - t.Errorf("present bound: %v", v) - } -} - -// TestYAMLCountRefs covers the reference-count walk over each anchorable container -// (Array, Hash, RObject, Range, Set) and the nil short-circuit, asserting a shared -// node is counted more than once. -func TestYAMLCountRefs(t *testing.T) { - shared := &RObject{class: newClass("N", nil), ivars: map[string]object.Value{}} - // The same object reachable via a Set element, a Range bound, an RObject ivar, - // an Array and a Hash — five references in one graph. - set := newSet() - set.add(shared) - // A beginless range exercises countRefs's Go-nil bound short-circuit. - rng := &object.Range{Lo: nil, Hi: shared} - holder := &RObject{class: newClass("H", nil), ivars: map[string]object.Value{"@x": shared}} - arr := &object.Array{Elems: []object.Value{shared, object.NilV}} - h := object.NewHash() - h.Set(object.NewString("k"), shared) - root := &object.Array{Elems: []object.Value{set, rng, holder, arr, h}} - - e := &yamlEncoder{root: root} - if !e.shared(shared) { - t.Error("shared object should be detected as shared") - } - if e.refcount[shared] < 2 { - t.Errorf("shared refcount=%d, want >= 2", e.refcount[shared]) - } - // A node seen once is not shared. - once := object.NewString("solo") - if e.refcount[once] > 1 { - t.Error("a non-shared node should not be counted twice") - } -} - -// TestYAMLEncoderEmptyBodies covers a Set and a Range emitted directly through -// the encoder so the openTag / tagBody Set+Range default branches run. -func TestYAMLEncoderComplexBodies(t *testing.T) { - // An empty Set: "hash: {}". - if got := yamlDump(nil, newSet()); got != "--- !ruby/object:Set\nhash: {}\n" { - t.Errorf("empty set: %q", got) - } - // A Range value. - rng := &object.Range{Lo: object.Integer(1), Hi: object.Integer(5)} - if got := yamlDump(nil, rng); got != "--- !ruby/range\nbegin: 1\nend: 5\nexcl: false\n" { - t.Errorf("range: %q", got) - } -} - -// TestYAMLLoaderParseBlockEmpty covers parseBlock's empty-body branch (a -// standalone object tag with no following lines) and taggedEmpty's object branch. -func TestYAMLLoaderParseBlockEmpty(t *testing.T) { - vm := New(nil) - // A document that is just a standalone !ruby/object tag with no body. - v := yamlLoad(vm, "--- !ruby/object:Lonely\n") - o, ok := v.(*RObject) - if !ok || o.class.name != "Lonely" { - t.Fatalf("standalone object tag: %T", v) - } - if len(o.ivars) != 0 { - t.Errorf("expected no ivars, got %v", o.ivars) - } -} - -// TestYAMLLoaderBlockScalarShortLine covers parseBlockScalar's clamp branch for a -// continuation line whose raw length is shorter than the block's base indent (a -// malformed but tolerated under-indented line yields an empty content line). -func TestYAMLLoaderBlockScalarShortLine(t *testing.T) { - vm := New(nil) - // First body line " aaaa" sets the base indent at 4; the next line " z" has - // raw length 3 (< 4), so the clamp slices it to the empty string. - doc := "---\nx: |-\n aaaa\n z\n" - v := yamlLoad(vm, doc) - h := v.(*object.Hash) - got, _ := h.Get(object.NewString("x")) - s := got.(*object.String) - if s.Str() != "aaaa\n" { - t.Errorf("block scalar short line: %q", s.Str()) - } -} - -// TestYAMLLoaderStructuralEdges covers the loader's structural break / EOF / -// fallthrough branches that the round-trip cases do not reach. -func TestYAMLLoaderStructuralEdges(t *testing.T) { - vm := New(nil) - // A bare document with no "---" marker (yamlLoad fallthrough to parseNode). - if v := yamlLoad(vm, "a: 1\n"); v.(*object.Hash).Len() != 1 { - t.Error("bare-document mapping") - } - // A document-root tag whose body is a SEQUENCE (an unusual / defensive shape: - // the tag is ignored for a sequence body, which loads as a plain Array). - v := yamlLoad(vm, "--- !something\n- 1\n- 2\n") - if a, ok := v.(*object.Array); !ok || len(a.Elems) != 2 { - t.Errorf("root tag with seq body: %T", v) - } - // A block scalar followed by a sibling at the parent indent (parseBlockScalar - // break on indent <= parentIndent). - doc := "---\nx: |-\n a\ny: 2\n" - h := yamlLoad(vm, doc).(*object.Hash) - if h.Len() != 2 { - t.Errorf("block scalar then sibling: len=%d", h.Len()) - } - // A sequence that ends because the next line is at a shallower indent. - d2 := "---\nk:\n- 1\nm: 2\n" - h2 := yamlLoad(vm, d2).(*object.Hash) - if h2.Len() != 2 { - t.Errorf("seq then sibling key: len=%d", h2.Len()) - } - // A standalone "-" dash whose value is a nested mapping (parseBlock seq via the - // dash, plus parseSequence's dash-with-block element). - d3 := "---\n-\n a: 1\n- 2\n" - a := yamlLoad(vm, d3).(*object.Array) - if len(a.Elems) != 2 { - t.Errorf("dash block element: %d", len(a.Elems)) - } -} - -// TestYAMLLoaderMoreEdges covers the last structural branches: an inline tag -// whose body is a sequence, a tag with no body at all, a "-" dash at the very end -// of input, and a complex key that is not a mapping's first entry. -func TestYAMLLoaderMoreEdges(t *testing.T) { - vm := New(nil) - // A mapping value that is a tagged node whose body is a SEQUENCE (parseBlock's - // sequence branch). - v := yamlLoad(vm, "---\nk: !something\n - 1\n - 2\n") - h := v.(*object.Hash) - val, _ := h.Get(object.NewString("k")) - if a, ok := val.(*object.Array); !ok || len(a.Elems) != 2 { - t.Errorf("inline tag seq body: %T", val) - } - // A mapping value that is a tag with no body (parseBlock empty -> taggedEmpty). - v2 := yamlLoad(vm, "---\nk: !ruby/object:Bodyless\nj: 1\n") - h2 := v2.(*object.Hash) - kv, _ := h2.Get(object.NewString("k")) - if _, ok := kv.(*RObject); !ok { - t.Errorf("bodyless inline tag: %T", kv) - } - // A trailing "-" dash with no value at end of input (parseNode at EOF -> nil - // element). - a := yamlLoad(vm, "---\n- 1\n-\n").(*object.Array) - if len(a.Elems) != 2 || a.Elems[1] != object.NilV { - t.Errorf("trailing dash: %v", a.Elems) - } - // A nested mapping whose block ends because the next same-indent line is not a - // "key:" entry (parseMapping's splitMapEntry !ok break): here a deeper sequence - // dash ends the inner mapping. - d4 := "---\nouter:\n a: 1\n- top\n" - h4 := yamlLoad(vm, d4).(*object.Hash) - if _, ok := h4.Get(object.NewString("outer")); !ok { - t.Error("nested mapping then dash") - } - // parseNode at end-of-input returns nil (the defensive EOF guard). - empty := &yamlLoader{vm: vm, anchors: map[string]object.Value{}} - if empty.parseNode(0) != object.NilV { - t.Error("parseNode at EOF should be nil") - } -} - -// TestYAMLLoaderEmptyAndPlain covers parsePlainScalar's empty-string branch and -// parseNode at end-of-input. -func TestYAMLLoaderEmptyAndPlain(t *testing.T) { - if v := parsePlainScalar(""); v != object.NilV { - t.Errorf("empty plain scalar: %v", v) - } - // A "key:" with nothing after it and nothing deeper -> nil value (parseNode at - // the end of input through parseMapping). - vm := New(nil) - h := yamlLoad(vm, "---\nk:\n").(*object.Hash) - val, _ := h.Get(object.NewString("k")) - if val != object.NilV { - t.Errorf("trailing empty value: %v", val) - } -} - -// TestYAMLResolveClassKnownQualified covers resolving a multi-segment class name -// whose segments all exist, and reusing a const that already holds a class. -func TestYAMLResolveClassKnownQualified(t *testing.T) { - vm := New(nil) - outer := newClass("Outer", vm.cObject) - inner := newClass("Inner", vm.cObject) - outer.consts["Inner"] = inner - vm.cObject.consts["Outer"] = outer - if c := vm.yamlResolveClass("Outer::Inner"); c != inner { - t.Errorf("qualified known class: %v", c) - } - // A placeholder registered under a qualified name is returned as a class on a - // second resolution (the consts[name] is-a-class reuse path). - first := vm.yamlResolveClass("Made::Up::Name") - if second := vm.yamlResolveClass("Made::Up::Name"); second != first { - t.Error("qualified placeholder not reused") - } -} - -// TestYAMLResolveClassNonClassConst covers yamlResolveClass when a name resolves -// to a non-class constant (falls back to a placeholder) and when a placeholder is -// reused on a second load. -func TestYAMLResolveClassEdges(t *testing.T) { - vm := New(nil) - vm.cObject.consts["NotAClass"] = object.Integer(5) - c1 := vm.yamlResolveClass("NotAClass") - if c1 == nil || c1.name != "NotAClass" { - t.Fatalf("placeholder for non-class const: %v", c1) - } - // A second resolution returns the same placeholder. - if c2 := vm.yamlResolveClass("NotAClass"); c2 != c1 { - t.Error("placeholder not reused") - } - // A qualified name whose first segment is a non-class const also degrades. - vm.cObject.consts["X"] = object.Integer(1) - if c := vm.yamlResolveClass("X::Y"); c == nil || c.name != "X::Y" { - t.Errorf("qualified non-class: %v", c) - } -} diff --git a/internal/vm/yaml_load.go b/internal/vm/yaml_load.go deleted file mode 100644 index 65470f27..00000000 --- a/internal/vm/yaml_load.go +++ /dev/null @@ -1,790 +0,0 @@ -// Copyright (c) the go-embedded-ruby/ruby authors -// -// SPDX-License-Identifier: BSD-3-Clause - -package vm - -import ( - "math" - "math/big" - "strconv" - "strings" - stdtime "time" - - gotime "github.com/go-composites/time/src" - - "github.com/go-embedded-ruby/ruby/internal/object" -) - -// posInf, negInf and nan name the float specials the loader maps the YAML -// keywords .inf / -.inf / .nan onto. -func posInf() float64 { return math.Inf(1) } -func negInf() float64 { return math.Inf(-1) } -func nan() float64 { return math.NaN() } - -// stdParseTime parses value with the Go reference layout, exposed so the -// timestamp grammar stays in one place. -func stdParseTime(layout, value string) (stdtime.Time, error) { - return stdtime.Parse(layout, value) -} - -// yamlResolveClass resolves a (possibly `::`-qualified) class name used in a -// `!ruby/object:Name` tag to its RClass, registering a fresh placeholder class -// (a plain subclass of Object) under the top-level name when the program has not -// defined it — so loading an object whose class is unknown still yields a typed -// instance rather than failing. -func (vm *VM) yamlResolveClass(name string) *RClass { - parts := strings.Split(name, "::") - cur := vm.cObject - var resolved *RClass - for i, p := range parts { - v, ok := vm.resolveConst(cur, p) - if !ok { - resolved = nil - break - } - c, isClass := v.(*RClass) - if !isClass { - resolved = nil - break - } - resolved = c - if i < len(parts)-1 { - cur = c - } - } - if resolved != nil { - return resolved - } - // Unknown class: register a placeholder under its qualified name so repeated - // loads reuse one class object. - if v, ok := vm.cObject.consts[name]; ok { - if c, isClass := v.(*RClass); isClass { - return c - } - } - c := newClass(name, vm.cObject) - vm.cObject.consts[name] = c - return c -} - -// yamlLoader parses the Psych-compatible YAML subset that yamlEncoder emits — and -// that Puppet's local persistence (state.yaml / last_run_summary.yaml) round-trips -// — back into Ruby values. It covers block mappings and sequences, flow `[]` / -// `{}`, plain / single- / double-quoted / literal-block scalars, the Psych scalar -// keywords (null, booleans, integers, floats, ISO-8601 timestamps), Symbols -// (`:name`), and the `!ruby/symbol` / `!ruby/object:` tags with `&anchor` / `*alias` -// references, so YAML.load(YAML.dump(x)) round-trips the state and report graphs. -type yamlLoader struct { - vm *VM - lines []yamlLine // significant (non-blank, non-comment) input lines - pos int // index of the next unconsumed line - anchors map[string]object.Value -} - -// yamlLine is one physical input line split into its leading indentation (count -// of spaces) and the remaining content (indentation stripped). -type yamlLine struct { - indent int - content string - raw string // the full original line, used by literal block scalars -} - -// yamlLoad parses a complete YAML document string into a Ruby value. A blank or -// marker-only document loads as nil (matching Psych's empty-document behaviour). -func yamlLoad(vm *VM, src string) object.Value { - l := &yamlLoader{vm: vm, anchors: map[string]object.Value{}} - l.tokenize(src) - if len(l.lines) == 0 { - return object.NilV - } - // A leading "---" document marker carries its value (or a tag) inline or on the - // following lines; strip the marker and parse what follows. - first := l.lines[0] - if first.content == "---" || strings.HasPrefix(first.content, "--- ") { - rest := "" - if len(first.content) > 4 { - rest = strings.TrimSpace(first.content[4:]) - } - if rest == "" { - // No inline value: either an empty document (nil) or a header whose body - // follows on the next lines. - l.pos = 1 - if l.peek() == nil { - return object.NilV - } - return l.parseNode(0) - } - if style, chomp, ok := blockScalarTag(rest); ok { - // A document-level literal/folded block scalar ("--- |-"). - l.pos = 1 - return l.parseBlockScalar(style, chomp, first.indent) - } - if tag, _, content := splitTagAnchor(rest); tag != "" && content == "" { - // A document-level tag ("--- !ruby/object:Foo") whose body is the - // following lines at the document indent (0) — Psych does not indent a - // root object's mapping. Parse that body directly. - l.pos = 1 - if l.peek() == nil { - return l.taggedEmpty(tag) - } - if isSeqEntry(l.peek().content) { - return l.parseSequence(l.peek().indent, tag) - } - return l.parseMapping(l.peek().indent, tag) - } - l.lines[0].content = rest - l.lines[0].indent = first.indent - // An inline value (scalar, flow collection, or an inline-tagged scalar like - // "--- !ruby/regexp /x/") parses from this line as a node at indent 0. - return l.parseNode(0) - } - return l.parseNode(0) -} - -// tokenize splits src into significant lines, recording each line's indentation -// and content. Blank lines and whole-line comments are dropped; the document-end -// marker "..." is treated as end-of-input. -func (l *yamlLoader) tokenize(src string) { - for _, raw := range strings.Split(src, "\n") { - trimmed := strings.TrimRight(raw, "\r") - content := strings.TrimLeft(trimmed, " ") - if content == "" { - continue - } - if content == "..." { - break - } - if strings.HasPrefix(content, "#") { - continue - } - indent := len(trimmed) - len(content) - l.lines = append(l.lines, yamlLine{indent: indent, content: content, raw: trimmed}) - } -} - -// blockScalarTag reports whether content is a literal/folded block-scalar -// indicator ("|", "|-", "|+", ">", ">-", ">+"), returning the style byte ('|' or -// '>') and the chomp indicator ('-' strip, '+' keep, 0 clip). -func blockScalarTag(content string) (style, chomp byte, ok bool) { - if content == "" { - return 0, 0, false - } - if content[0] != '|' && content[0] != '>' { - return 0, 0, false - } - rest := content[1:] - if rest == "" { - return content[0], 0, true - } - if rest == "-" || rest == "+" { - return content[0], rest[0], true - } - return 0, 0, false -} - -// parseBlockScalar reads a literal/folded block scalar whose indicator is on the -// current "key:"/dash line: the body is the following lines indented deeper than -// parentIndent. It supports the literal style "|" (newlines preserved) and the -// folded style ">" (single newlines become spaces), with strip/clip/keep chomp. -func (l *yamlLoader) parseBlockScalar(style, chomp byte, parentIndent int) object.Value { - var body []string - bodyIndent := -1 - for l.pos < len(l.lines) { - line := l.lines[l.pos] - if line.indent <= parentIndent { - break - } - if bodyIndent < 0 { - bodyIndent = line.indent - } - // Strip the block's base indentation, clamping for a continuation line that - // is less indented than the first (defensive: malformed but tolerated). - cut := bodyIndent - if cut > len(line.raw) { - cut = len(line.raw) - } - body = append(body, line.raw[cut:]) - l.pos++ - } - var s string - if style == '>' { - s = strings.Join(body, " ") - } else { - s = strings.Join(body, "\n") - } - switch chomp { - case '-': // strip: no trailing newline - case '+': // keep: a single trailing newline (the emitter writes one block line) - s += "\n" - default: // clip: exactly one trailing newline - s += "\n" - } - return object.NewString(s) -} - -// parseNode parses the node whose first line is l.lines[l.pos], expected at the -// given minimum indentation. It dispatches on the line's shape: a sequence entry -// ("- …"), a mapping entry ("key: …"), or a bare scalar/flow value. -func (l *yamlLoader) parseNode(minIndent int) object.Value { - if l.pos >= len(l.lines) { - return object.NilV - } - line := l.lines[l.pos] - // A node may carry a tag and/or anchor as a prefix on its first line - // ("!ruby/object:Foo", "&1 …"). Peel those before deciding the shape. - tag, anchorName, content := splitTagAnchor(line.content) - if content == "" { - // The tag/anchor stood alone: the value is the indented block that follows. - l.lines[l.pos].content = "" - l.pos++ - v := l.parseBlock(line.indent, tag) - l.bind(anchorName, v) - return v - } - // Rewrite the working line to its content (tag/anchor stripped) and dispatch. - l.lines[l.pos].content = content - if isSeqEntry(content) { - v := l.parseSequence(line.indent, tag) - l.bind(anchorName, v) - return v - } - // A flow collection ("[...]" / "{...}") or any other lone scalar is parsed by - // scalarValue; only a genuine block-mapping line ("key: ...", not opening with - // a flow bracket) is parsed as a mapping. - if !isFlowStart(content) { - if _, _, ok := splitMapEntry(content); ok { - v := l.parseMapping(line.indent, tag) - l.bind(anchorName, v) - return v - } - } - // A lone scalar (or flow collection) on this line. - l.pos++ - v := l.scalarValue(content, tag) - l.bind(anchorName, v) - return v -} - -// parseBlock parses the block that follows a standalone tag / anchor. The tag has -// already consumed its own line; its body is the following lines indented at -// least to parentIndent (Psych aligns an inline-tagged object's mapping under the -// position where the tag began, i.e. the same indent — not strictly deeper). -func (l *yamlLoader) parseBlock(parentIndent int, tag string) object.Value { - if l.pos >= len(l.lines) || l.lines[l.pos].indent < parentIndent { - // No body at this depth: an empty tagged node (e.g. "!ruby/object:Foo" with - // no body) is an empty object/mapping. - return l.taggedEmpty(tag) - } - child := l.lines[l.pos] - if isSeqEntry(child.content) { - return l.parseSequence(child.indent, tag) - } - return l.parseMapping(child.indent, tag) -} - -// parseSequence parses a block sequence: consecutive "- …" lines at exactly -// indent. Each dash either carries an inline value or opens a nested block. -func (l *yamlLoader) parseSequence(indent int, tag string) object.Value { - arr := &object.Array{} - for l.pos < len(l.lines) { - line := l.lines[l.pos] - if line.indent != indent || !isSeqEntry(line.content) { - break - } - rest := strings.TrimPrefix(line.content, "-") - rest = strings.TrimPrefix(rest, " ") - if rest == "" { - // "-" alone: the element is the indented block on the following lines, or - // nil when nothing deeper follows. - l.pos++ - if l.peek() != nil && l.peek().indent > indent { - arr.Elems = append(arr.Elems, l.parseBlock(indent, "")) - } else { - arr.Elems = append(arr.Elems, object.NilV) - } - continue - } - if style, chomp, ok := blockScalarTag(rest); ok { - l.pos++ - arr.Elems = append(arr.Elems, l.parseBlockScalar(style, chomp, indent)) - continue - } - // The element starts on the dash line. Re-point the current line at the - // content after "- " (its effective indent is indent+2) and parse it. - l.lines[l.pos].content = rest - l.lines[l.pos].indent = indent + 2 - arr.Elems = append(arr.Elems, l.parseNode(indent+1)) - } - return l.applySeqTag(arr, tag) -} - -// parseMapping parses a block mapping: consecutive "key: …" lines at exactly -// indent. Each value is inline (after the colon) or an indented block. -func (l *yamlLoader) parseMapping(indent int, tag string) object.Value { - h := object.NewHash() - for l.pos < len(l.lines) { - line := l.lines[l.pos] - if line.indent != indent { - break - } - keyStr, val, ok := splitMapEntry(line.content) - if !ok { - break - } - key := l.scalarValue(keyStr, "") - if strings.TrimSpace(val) == "" { - // The value is on the following lines, or nil if none belong to it. A - // block sequence value sits at the SAME indent as its key (Psych aligns - // the dashes under the key), whereas any other nested block is deeper. - l.pos++ - if next := l.peek(); next != nil && (next.indent > indent || (next.indent == indent && isSeqEntry(next.content))) { - h.Set(key, l.parseNode(indent)) - } else { - h.Set(key, object.NilV) - } - continue - } - if style, chomp, ok := blockScalarTag(strings.TrimSpace(val)); ok { - l.pos++ - h.Set(key, l.parseBlockScalar(style, chomp, indent)) - continue - } - // Inline value on the same line. - l.lines[l.pos].content = strings.TrimSpace(val) - l.lines[l.pos].indent = indent + 1 - h.Set(key, l.parseNode(indent+1)) - } - return l.applyMapTag(h, tag) -} - -// scalarValue parses a single scalar token (already stripped of any block tag) to -// a Ruby value, honouring an explicit tag (`!ruby/symbol`, `*alias`) and the -// implicit Psych scalar grammar otherwise. -func (l *yamlLoader) scalarValue(s string, tag string) object.Value { - s = strings.TrimSpace(s) - if strings.HasPrefix(s, "*") { - // An alias to a previously-anchored node. - if v, ok := l.anchors[strings.TrimSpace(s[1:])]; ok { - return v - } - return object.NilV - } - switch { - case s == "[]": - return l.applySeqTag(&object.Array{}, tag) - case s == "{}": - // An inline " {}" empty mapping (e.g. "!ruby/object:Foo {}") builds the - // tagged empty value. - return l.applyMapTag(object.NewHash(), tag) - case strings.HasPrefix(s, "["): - return l.parseFlowSeq(s) - case strings.HasPrefix(s, "{"): - return l.parseFlowMap(s) - } - switch tag { - case "!ruby/symbol", "!ruby/sym": - return object.Symbol(unquoteScalar(s)) - case "!ruby/string", "!str", "tag:yaml.org,2002:str": - return object.NewString(unquoteScalar(s)) - } - return parsePlainScalar(s) -} - -// parseFlowSeq parses a single-line flow sequence "[a, b, c]". -func (l *yamlLoader) parseFlowSeq(s string) object.Value { - inner := strings.TrimSpace(s[1 : len(s)-1]) - arr := &object.Array{} - if inner == "" { - return arr - } - for _, item := range splitFlow(inner) { - arr.Elems = append(arr.Elems, l.scalarValue(item, "")) - } - return arr -} - -// parseFlowMap parses a single-line flow mapping "{a: 1, b: 2}". -func (l *yamlLoader) parseFlowMap(s string) object.Value { - inner := strings.TrimSpace(s[1 : len(s)-1]) - h := object.NewHash() - if inner == "" { - return h - } - for _, item := range splitFlow(inner) { - k, v, ok := splitMapEntry(item) - if !ok { - continue - } - h.Set(l.scalarValue(k, ""), l.scalarValue(v, "")) - } - return h -} - -// peek returns the next unconsumed line, or nil at end of input. -func (l *yamlLoader) peek() *yamlLine { - if l.pos >= len(l.lines) { - return nil - } - return &l.lines[l.pos] -} - -// bind records v under anchorName for later *alias references (no-op for ""). -func (l *yamlLoader) bind(anchorName string, v object.Value) { - if anchorName != "" { - l.anchors[anchorName] = v - } -} - -// applySeqTag adapts a parsed sequence to its tag. Only the plain sequence is -// meaningful for the round-trip set; an unknown tag is ignored (the array stands). -func (l *yamlLoader) applySeqTag(arr *object.Array, _ string) object.Value { - return arr -} - -// applyMapTag adapts a parsed mapping to its tag: a `!ruby/object:Class` mapping -// becomes an instance of Class whose ivars are the mapping entries; other tags -// leave the Hash as-is (Puppet only round-trips Hash/Array/scalars locally). -func (l *yamlLoader) applyMapTag(h *object.Hash, tag string) object.Value { - if cls, ok := rubyObjectTag(tag); ok { - return l.buildObject(cls, h) - } - return h -} - -// taggedEmpty builds the value for a tag with no body: an empty object for -// `!ruby/object`, otherwise an empty Hash. -func (l *yamlLoader) taggedEmpty(tag string) object.Value { - if cls, ok := rubyObjectTag(tag); ok { - return l.buildObject(cls, object.NewHash()) - } - return object.NewHash() -} - -// buildObject materialises a `!ruby/object:Class` instance: an RObject of the -// named class (resolved through the VM, or a freshly registered placeholder -// class if unknown) with one ivar per mapping entry. It is the loader half of -// the generic object emitter, so a round-tripped report graph reconstructs. -func (l *yamlLoader) buildObject(className string, h *object.Hash) object.Value { - cls := l.vm.yamlResolveClass(className) - obj := &RObject{class: cls, ivars: map[string]object.Value{}} - for _, k := range h.Keys { - v, _ := h.Get(k) - obj.ivars["@"+keyName(k)] = v - } - return obj -} - -// keyName renders a mapping key as the bare ivar name (a Symbol or String key, -// both used by Psych object mappings). -func keyName(k object.Value) string { - switch kk := k.(type) { - case object.Symbol: - return string(kk) - case *object.String: - return kk.Str() - } - return "" -} - -// --- scalar grammar ------------------------------------------------------- - -// parsePlainScalar maps an unquoted / quoted scalar token to a Ruby value using -// Psych's implicit typing: null, booleans, symbols, integers, floats, timestamps, -// or a String otherwise. Quoted forms are always Strings. -func parsePlainScalar(s string) object.Value { - if s == "" { - return object.NilV - } - if s[0] == '\'' { - return object.NewString(unquoteSingle(s)) - } - if s[0] == '"' { - return object.NewString(unquoteDouble(s)) - } - switch s { - case "~", "null", "Null", "NULL": - return object.NilV - case "true", "True", "TRUE": - return object.Bool(true) - case "false", "False", "FALSE": - return object.Bool(false) - case ".inf", ".Inf", ".INF", "+.inf": - return object.Float(posInf()) - case "-.inf", "-.Inf", "-.INF": - return object.Float(negInf()) - case ".nan", ".NaN", ".NAN": - return object.Float(nan()) - } - if strings.HasPrefix(s, ":") { - return object.Symbol(unquoteScalar(s[1:])) - } - if t, ok := parseYAMLTime(s); ok { - return t - } - if v, ok := parseYAMLInteger(s); ok { - return v - } - if fv, ok := parseYAMLFloat(s); ok { - return object.Float(fv) - } - return object.NewString(s) -} - -// parseYAMLInteger parses a YAML integer scalar (decimal, with optional sign and -// underscore separators, or 0x hex) to an Integer or, on overflow, a Bignum, -// reporting failure for anything else. -func parseYAMLInteger(s string) (object.Value, bool) { - clean := strings.ReplaceAll(s, "_", "") - if clean == "" { - return nil, false - } - base := 10 - digits := clean - if hx := stripHexPrefix(clean); hx != "" { - base, digits = 16, hx - } - bi, ok := new(big.Int).SetString(digits, base) - if !ok { - return nil, false - } - return object.NormInt(bi), true -} - -// stripHexPrefix returns the hex digits of a "0x" / "-0x" / "+0x" literal -// (preserving the sign), or "" when s is not a hex literal. -func stripHexPrefix(s string) string { - sign := "" - body := s - if len(body) > 0 && (body[0] == '+' || body[0] == '-') { - if body[0] == '-' { - sign = "-" - } - body = body[1:] - } - if !strings.HasPrefix(body, "0x") && !strings.HasPrefix(body, "0X") { - return "" - } - return sign + body[2:] -} - -// parseYAMLFloat parses a YAML float scalar (it must carry a '.' or exponent so a -// bare integer does not match here). -func parseYAMLFloat(s string) (float64, bool) { - if !strings.ContainsAny(s, ".eE") { - return 0, false - } - clean := strings.ReplaceAll(s, "_", "") - f, err := strconv.ParseFloat(clean, 64) - if err != nil { - return 0, false - } - return f, true -} - -// parseYAMLTime parses the Psych ISO-8601 timestamp the emitter writes -// ("2026-06-29 05:18:32.000000000 Z" / "… -07:00"), to whole-second resolution. -func parseYAMLTime(s string) (object.Value, bool) { - layouts := []string{ - "2006-01-02 15:04:05.000000000 Z", - "2006-01-02 15:04:05.000000000 -07:00", - "2006-01-02 15:04:05 Z", - "2006-01-02 15:04:05 -07:00", - "2006-01-02T15:04:05Z07:00", - } - for _, layout := range layouts { - if t, err := stdParseTime(layout, s); err == nil { - return &Time{t: gotime.FromUnix(t.Unix())}, true - } - } - return nil, false -} - -// --- token helpers -------------------------------------------------------- - -// isSeqEntry reports whether content is a block-sequence entry: "-" alone or -// "- value". -func isSeqEntry(content string) bool { - return content == "-" || strings.HasPrefix(content, "- ") -} - -// isFlowStart reports whether content opens a flow collection ("[" or "{"), which -// is parsed as a scalar value rather than a block mapping. -func isFlowStart(content string) bool { - return strings.HasPrefix(content, "[") || strings.HasPrefix(content, "{") -} - -// splitMapEntry splits "key: value" into its key and (possibly empty) value, -// honouring quoted keys so a ":" inside quotes is not a separator. It reports -// false when content is not a mapping entry. -func splitMapEntry(content string) (key, value string, ok bool) { - i := mapColon(content) - if i < 0 { - return "", "", false - } - key = strings.TrimSpace(content[:i]) - value = strings.TrimSpace(content[i+1:]) - return key, value, true -} - -// mapColon returns the index of the key/value separator ": " (or a trailing ":") -// at the top flow level, skipping over quoted spans, or -1 if there is none. -func mapColon(content string) int { - var quote byte - for i := 0; i < len(content); i++ { - c := content[i] - if quote != 0 { - if c == quote { - quote = 0 - } - continue - } - switch c { - case '\'', '"': - quote = c - case ':': - if i == len(content)-1 || content[i+1] == ' ' { - return i - } - } - } - return -1 -} - -// splitTagAnchor peels a leading "!tag" and/or "&anchor" from a node's first -// line, returning the tag (with leading '!'), the anchor name, and the remaining -// content. Either or both may be absent. -func splitTagAnchor(content string) (tag, anchor, rest string) { - rest = content - for { - rest = strings.TrimLeft(rest, " ") - switch { - case strings.HasPrefix(rest, "&"): - anchor, rest = firstWord(rest[1:]) - case strings.HasPrefix(rest, "!"): - tag, rest = firstWord(rest) - default: - return tag, anchor, strings.TrimLeft(rest, " ") - } - } -} - -// firstWord splits s at its first space, returning the word and the remainder. -func firstWord(s string) (word, rest string) { - if i := strings.IndexByte(s, ' '); i >= 0 { - return s[:i], s[i+1:] - } - return s, "" -} - -// rubyObjectTag reports whether tag is a `!ruby/object[:Class]` tag and returns -// the class name ("Object" for the bare form). -func rubyObjectTag(tag string) (string, bool) { - const p = "!ruby/object" - if tag == p { - return "Object", true - } - if strings.HasPrefix(tag, p+":") { - return tag[len(p)+1:], true - } - return "", false -} - -// splitFlow splits a flow-collection body on top-level commas, respecting quotes -// and nested brackets/braces. -func splitFlow(s string) []string { - var parts []string - depth := 0 - var quote byte - start := 0 - for i := 0; i < len(s); i++ { - c := s[i] - if quote != 0 { - if c == quote { - quote = 0 - } - continue - } - switch c { - case '\'', '"': - quote = c - case '[', '{': - depth++ - case ']', '}': - depth-- - case ',': - if depth == 0 { - parts = append(parts, strings.TrimSpace(s[start:i])) - start = i + 1 - } - } - } - parts = append(parts, strings.TrimSpace(s[start:])) - return parts -} - -// unquoteScalar strips surrounding single/double quotes (and applies their -// escaping) if present, returning the bare string otherwise. -func unquoteScalar(s string) string { - s = strings.TrimSpace(s) - if len(s) >= 2 && s[0] == '\'' { - return unquoteSingle(s) - } - if len(s) >= 2 && s[0] == '"' { - return unquoteDouble(s) - } - return s -} - -// unquoteSingle decodes a single-quoted YAML scalar (” is a literal quote). -func unquoteSingle(s string) string { - body := s - if len(body) >= 2 && body[0] == '\'' && body[len(body)-1] == '\'' { - body = body[1 : len(body)-1] - } - return strings.ReplaceAll(body, "''", "'") -} - -// unquoteDouble decodes a double-quoted YAML scalar, reversing the escapes the -// emitter writes (\\, \", \n, \t, \r, \0, \xNN). -func unquoteDouble(s string) string { - body := s - if len(body) >= 2 && body[0] == '"' && body[len(body)-1] == '"' { - body = body[1 : len(body)-1] - } - var b strings.Builder - for i := 0; i < len(body); i++ { - c := body[i] - if c != '\\' || i+1 >= len(body) { - b.WriteByte(c) - continue - } - i++ - switch body[i] { - case 'n': - b.WriteByte('\n') - case 't': - b.WriteByte('\t') - case 'r': - b.WriteByte('\r') - case '0': - b.WriteByte(0) - case '"': - b.WriteByte('"') - case '\\': - b.WriteByte('\\') - case 'x': - if i+2 < len(body) { - if n, err := strconv.ParseUint(body[i+1:i+3], 16, 8); err == nil { - b.WriteByte(byte(n)) - i += 2 - continue - } - } - b.WriteByte('x') - default: - b.WriteByte(body[i]) - } - } - return b.String() -} diff --git a/internal/vm/yaml_load_test.go b/internal/vm/yaml_load_test.go index 58f11ccb..0d4a7db0 100644 --- a/internal/vm/yaml_load_test.go +++ b/internal/vm/yaml_load_test.go @@ -295,7 +295,7 @@ func TestYAMLLoadFlowEdges(t *testing.T) { // TestYAMLLoadArity covers the zero-argument errors of load / load_file. func TestYAMLLoadArity(t *testing.T) { - for _, call := range []string{`YAML.load`, `YAML.load_file`} { + for _, call := range []string{`YAML.load`, `YAML.load_file`, `YAML.safe_load`, `YAML.safe_load_file`} { err := runErr(t, call) if err == nil || !strings.Contains(err.Error(), "wrong number of arguments") { t.Errorf("%s: expected ArgumentError, got %v", call, err) diff --git a/internal/vm/yaml_object.go b/internal/vm/yaml_object.go deleted file mode 100644 index 3719f8b5..00000000 --- a/internal/vm/yaml_object.go +++ /dev/null @@ -1,309 +0,0 @@ -// Copyright (c) the go-embedded-ruby/ruby authors -// -// SPDX-License-Identifier: BSD-3-Clause - -package vm - -import ( - "sort" - "strings" - - "github.com/go-embedded-ruby/ruby/internal/object" -) - -// This file extends the YAML emitter (yaml_dump.go) to serialise arbitrary Ruby -// objects the way Psych does — as a `!ruby/object:ClassName` mapping of their -// instance variables, recursively, plus the `!ruby/range` mapping for a Range. -// It is the generic-object half the report terminus needs: Object#to_yaml and a -// Psych.dump of the report graph (which holds repeated references to resource -// statuses) work by tagging each value and emitting shared / cyclic nodes once -// behind a YAML anchor, referencing them with an alias thereafter. - -// isYAMLComplex reports whether v is a complex value the emitter writes with a -// block tag (an ordinary object or a Range). Scalars (including Regexp, which is -// an inline tagged scalar) and plain collections are not complex. -func isYAMLComplex(v object.Value) bool { - switch v.(type) { - case *RObject: - return true - case *object.Range: - return true - case *Set: - return true - } - return false -} - -// openTag returns the tag string to write on the opening line of a complex value -// ("!ruby/object:Foo", "!ruby/range"), or "" when v is not a tagged complex -// value. It also reserves an anchor number the first time a shareable reference -// value is seen, so writeComplexChild can decide between "&N " and "*N". -func (e *yamlEncoder) openTag(v object.Value) string { - switch n := v.(type) { - case *RObject: - return "!ruby/object" + objectClassSuffix(n) - case *object.Range: - return "!ruby/range" - default: // *Set — the only other isYAMLComplex type - return "!ruby/object:Set" - } -} - -// objectClassSuffix renders the ":ClassName" suffix of a !ruby/object tag, or "" -// for a bare/anonymous Object (Psych writes "!ruby/object" without a suffix when -// the class is Object itself). -func objectClassSuffix(o *RObject) string { - name := "" - if o.class != nil { - name = o.class.name - } - if name == "" || name == "Object" { - return "" - } - return ":" + name -} - -// tagBodyEmpty reports whether the complex value has no body lines (an object -// with no instance variables), which Psych writes inline as " {}". -func (e *yamlEncoder) tagBodyEmpty(v object.Value) bool { - // Only an object with no instance variables has an empty body; a Range always - // has its begin/end/excl triple and a Set always its "hash" entry. - if o, ok := v.(*RObject); ok { - return len(o.ivars) == 0 - } - return false -} - -// yamlPair is one entry of a complex value's block body: a plain identifier key -// (an ivar / member name, written unquoted as Psych does) and its value. -type yamlPair struct { - key string - val object.Value -} - -// tagBody returns the ordered key/value pairs that form the block body of a -// complex value: the instance variables of an object (bare names, stably -// ordered), the begin/end/excl triple of a Range, or the backing "hash" of a Set. -func (e *yamlEncoder) tagBody(v object.Value) []yamlPair { - switch n := v.(type) { - case *RObject: - return ivarPairs(n.ivars) - case *object.Range: - return []yamlPair{ - {"begin", rangeBound(n.Lo)}, - {"end", rangeBound(n.Hi)}, - {"excl", object.Bool(n.Exclusive)}, - } - default: // *Set: MRI's Set#encode_with writes its backing hash (each element - // mapped to true) under the "hash" ivar, so it round-trips as a - // !ruby/object:Set. - set := n.(*Set) - inner := object.NewHash() - for _, k := range set.order { - inner.Set(set.vals[k], object.Bool(true)) - } - return []yamlPair{{"hash", inner}} - } -} - -// encodePairs writes a complex value's body as a block mapping at the given -// indent, each key a plain identifier (unquoted, as Psych writes ivar / member -// names) and each value via the shared value-emission path. -func (e *yamlEncoder) encodePairs(pairs []yamlPair, indent int) { - pad := strings.Repeat(" ", indent) - for _, p := range pairs { - e.b.WriteString(pad) - e.b.WriteString(p.key) - e.b.WriteByte(':') - e.writeMapChild(p.val, indent) - } -} - -// rangeBound maps a Range endpoint, where a beginless / endless bound is nil. -func rangeBound(v object.Value) object.Value { - if v == nil { - return object.NilV - } - return v -} - -// ivarPairs renders an ivar table as ordered (name, value) pairs with the leading -// "@" stripped, sorted by name for deterministic output (the backing Go map has -// no insertion order). -func ivarPairs(ivars map[string]object.Value) []yamlPair { - names := make([]string, 0, len(ivars)) - for k := range ivars { - names = append(names, k) - } - sort.Strings(names) - pairs := make([]yamlPair, len(names)) - for i, k := range names { - pairs[i] = yamlPair{strings.TrimPrefix(k, "@"), ivars[k]} - } - return pairs -} - -// writeComplexChild emits a complex value (object / range) as a mapping entry's -// value (after "key:"). It honours shared references via anchors: the first time -// a reference value is written it carries "&N" before its tag; a later sighting -// is written as " *N". Returns false when v is not complex so the scalar / -// collection path handles it. -func (e *yamlEncoder) writeComplexChild(v object.Value, indent int) bool { - // A previously-emitted shared value (object OR collection) is aliased. - if n, ok := e.alias(v); ok { - e.b.WriteString(" *") - e.b.WriteString(itoa(n)) - e.b.WriteByte('\n') - return true - } - // A first sighting of a shared plain collection carries an anchor and its body - // at the key's indent (sequence) or two deeper (mapping). - if e.shared(v) { - switch v.(type) { - case *object.Array, *object.Hash: - // "&N" with no tag suffix for a plain collection, body two deeper. - e.b.WriteByte(' ') - e.writeAnchorTag(v) - e.b.WriteByte('\n') - e.encodeNode(v, indent+2) - return true - } - } - if !isYAMLComplex(v) { - return false - } - e.b.WriteByte(' ') - e.writeAnchorTag(v) - if e.tagBodyEmpty(v) { - e.b.WriteString(" {}\n") - return true - } - e.b.WriteByte('\n') - e.encodePairs(e.tagBody(v), indent+2) - return true -} - -// writeComplexSeqChild emits a complex / shared value following a sequence dash. -func (e *yamlEncoder) writeComplexSeqChild(v object.Value, indent int) bool { - if n, ok := e.alias(v); ok { - e.b.WriteString(" *") - e.b.WriteString(itoa(n)) - e.b.WriteByte('\n') - return true - } - if e.shared(v) { - switch v.(type) { - case *object.Array, *object.Hash: - e.b.WriteByte(' ') - e.writeAnchorTag(v) - e.b.WriteByte('\n') - e.encodeNode(v, indent+2) - return true - } - } - if !isYAMLComplex(v) { - return false - } - e.b.WriteByte(' ') - e.writeAnchorTag(v) - if e.tagBodyEmpty(v) { - e.b.WriteString(" {}\n") - return true - } - e.b.WriteByte('\n') - e.encodePairs(e.tagBody(v), indent+2) - return true -} - -// writeAnchorTag writes a value's opening tag, prefixed by "&N " when the value -// is shared (appears more than once in the graph). The anchor number is assigned -// on this first emission so later sightings alias it. -func (e *yamlEncoder) writeAnchorTag(v object.Value) { - // A plain (untagged) collection contributes no tag; only the complex types - // carry a "!ruby/..." tag. - tag := "" - if isYAMLComplex(v) { - tag = e.openTag(v) - } - if e.shared(v) { - e.seq++ - e.anchors[v] = e.seq - e.b.WriteByte('&') - e.b.WriteString(itoa(e.seq)) - // A plain collection has no tag, so its anchor stands alone ("&1"); a tagged - // value separates the anchor from the tag with a space ("&1 !ruby/object:X"). - if tag != "" { - e.b.WriteByte(' ') - } - } - e.b.WriteString(tag) -} - -// alias reports whether v has already been emitted under an anchor (so it should -// be written as an alias) and returns that anchor's number. -func (e *yamlEncoder) alias(v object.Value) (int, bool) { - n, ok := e.anchors[v] - return n, ok -} - -// shared reports whether v occurs more than once in the value graph, in which -// case it must be emitted behind an anchor. The reference count is computed once -// per dump and cached. -func (e *yamlEncoder) shared(v object.Value) bool { - if e.refcount == nil { - e.refcount = map[object.Value]int{} - e.countRefs(e.root, map[object.Value]bool{}) - } - return e.refcount[v] > 1 -} - -// countRefs walks the value graph from v, incrementing the reference count of -// every reference-typed (anchorable) node and stopping recursion at a node it has -// already entered (so cycles terminate). -func (e *yamlEncoder) countRefs(v object.Value, seen map[object.Value]bool) { - if v == nil { - return - } - if !anchorable(v) { - // A scalar value cannot be shared behind an anchor and holds no children. - return - } - e.refcount[v]++ - if seen[v] { - return // already descended through this node; avoid re-walking a cycle - } - seen[v] = true - switch n := v.(type) { - case *object.Array: - for _, el := range n.Elems { - e.countRefs(el, seen) - } - case *object.Hash: - for _, k := range n.Keys { - val, _ := n.Get(k) - e.countRefs(val, seen) - } - case *RObject: - for _, iv := range n.ivars { - e.countRefs(iv, seen) - } - case *object.Range: - e.countRefs(n.Lo, seen) - e.countRefs(n.Hi, seen) - case *Set: - for _, k := range n.order { - e.countRefs(n.vals[k], seen) - } - } -} - -// anchorable reports whether v is a reference type that can be shared behind a -// YAML anchor (collections and objects). Scalars are value-typed and never get -// anchors. -func anchorable(v object.Value) bool { - switch v.(type) { - case *object.Array, *object.Hash, *RObject, *object.Range, *Set: - return true - } - return false -}