diff --git a/internal/vm/fileutils.go b/internal/vm/fileutils.go index 50da056c..9b3f7fb7 100644 --- a/internal/vm/fileutils.go +++ b/internal/vm/fileutils.go @@ -5,7 +5,9 @@ package vm import ( + "io/fs" "os" + "path/filepath" "time" "github.com/go-embedded-ruby/ruby/internal/object" @@ -24,6 +26,17 @@ var ( fuStat = os.Stat fuChtimes = os.Chtimes fuNow = time.Now + // fuWalkDir visits root and every descendant, invoking visit for each. It is a + // seam so a test can drive the error branch of the recursive operations. + fuWalkDir = func(root string, visit func(string)) error { + return filepath.WalkDir(root, func(p string, _ fs.DirEntry, err error) error { + if err != nil { + return err + } + visit(p) + return nil + }) + } ) // registerFileUtils installs the FileUtils module (require "fileutils"). The @@ -151,6 +164,48 @@ func (vm *VM) registerFileUtils() { return args[1] }) + // chown(user, group, list) sets ownership of each path, reusing File.chown's + // id coercion: a nil user/group leaves that id unchanged (POSIX -1) and it + // routes through the fileChown seam (a no-op on Windows, as in MRI). Puppet's + // FileSystem#replace_file calls this on the destination's existing owner when + // rewriting state.yaml / report files, so it must succeed rather than raise. + chownFn := func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + uid, gid := chownID(args[0]), chownID(args[1]) + list := pathsOf(args[2]) + for _, p := range list { + if err := fileChown(p, uid, gid); err != nil { + raise("Errno::ENOENT", "No such file or directory @ fileutils_chown - %s", p) + } + } + return args[2] + } + sdef("chown", chownFn) + // chown_R recurses into directories; the directory walk is shared with chmod_R. + sdef("chown_R", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + uid, gid := chownID(args[0]), chownID(args[1]) + for _, p := range pathsOf(args[2]) { + for _, q := range fuWalk(p) { + if err := fileChown(q, uid, gid); err != nil { + raise("Errno::ENOENT", "No such file or directory @ fileutils_chown - %s", q) + } + } + } + return args[2] + }) + // chmod_R recurses chmod into directories, as Puppet's report store driver does + // (FileUtils.chmod_R(0o750, dir)). + sdef("chmod_R", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + mode := os.FileMode(intArg(args[0]) & 0o7777) + for _, p := range pathsOf(args[1]) { + for _, q := range fuWalk(p) { + if err := fileChmod(q, mode); err != nil { + raise("Errno::ENOENT", "No such file or directory @ fileutils_chmod - %s", q) + } + } + } + return args[1] + }) + // Genuinely platform-specific / stream operations: a clear NotImplementedError // rather than a silently-wrong result. notImpl := func(what string) NativeFn { @@ -158,8 +213,19 @@ func (vm *VM) registerFileUtils() { return raise("NotImplementedError", "FileUtils.%s not yet supported", what) } } - for _, m := range []string{"chown", "chown_R", "chmod_R", "copy_stream", + for _, m := range []string{"copy_stream", "remove_entry_secure", "uptodate?", "ln", "ln_s", "ln_sf", "compare_file", "cp_r"} { sdef(m, notImpl(m)) } } + +// fuWalk returns root followed by every path beneath it (files and +// directories), for the recursive FileUtils operations. A path that cannot be +// walked yields just itself, so the caller still attempts (and reports) the op. +func fuWalk(root string) []string { + var out []string + if err := fuWalkDir(root, func(p string) { out = append(out, p) }); err != nil { + return []string{root} + } + return out +} diff --git a/internal/vm/fileutils_test.go b/internal/vm/fileutils_test.go index 2e2e688b..88c2ce33 100644 --- a/internal/vm/fileutils_test.go +++ b/internal/vm/fileutils_test.go @@ -97,7 +97,7 @@ func TestFileUtilsSuccess(t *testing.T) { func TestFileUtilsNotImplemented(t *testing.T) { vm := New(nil) - for _, m := range []string{"chown", "chown_R", "chmod_R", "copy_stream", + for _, m := range []string{"copy_stream", "remove_entry_secure", "uptodate?", "ln", "ln_s", "ln_sf", "compare_file", "cp_r"} { got := catchRaise(func() { callFU(t, vm, m, nil) }) if got != "NotImplementedError" { @@ -208,6 +208,114 @@ func TestFileUtilsTouchErrors(t *testing.T) { }() } +// TestFileUtilsChown covers chown / chown_R / chmod_R through fake seams so the +// id coercion and recursive walk are exercised without real ownership changes +// (the fileChown seam is a Windows no-op, so the test drives both forms here). +func TestFileUtilsChown(t *testing.T) { + s := func(x string) object.Value { return object.NewString(x) } + + // chown success: the fileChown seam records its calls; a nil user/group passes + // -1 (leave unchanged). + func() { + oc := fileChown + t.Cleanup(func() { fileChown = oc }) + var calls [][3]any + fileChown = func(p string, uid, gid int) error { + calls = append(calls, [3]any{p, uid, gid}) + return nil + } + vm := New(nil) + // chown(501, nil, "p") -> uid 501, gid -1. + r := callFU(t, vm, "chown", []object.Value{object.Integer(501), object.NilV, s("p")}) + if r.ToS() != "p" { + t.Fatalf("chown return: %v", r) + } + if len(calls) != 1 || calls[0][1] != 501 || calls[0][2] != -1 { + t.Fatalf("chown calls: %v", calls) + } + }() + + // chown_R recurses via the fuWalkDir seam. + func() { + oc, ow := fileChown, fuWalkDir + t.Cleanup(func() { fileChown, fuWalkDir = oc, ow }) + var chowned []string + fileChown = func(p string, _, _ int) error { chowned = append(chowned, p); return nil } + fuWalkDir = func(root string, visit func(string)) error { + visit(root) + visit(root + "/child") + return nil + } + vm := New(nil) + callFU(t, vm, "chown_R", []object.Value{object.Integer(0), object.Integer(0), s("d")}) + if len(chowned) != 2 || chowned[1] != "d/child" { + t.Fatalf("chown_R walked: %v", chowned) + } + }() + + // chmod_R recurses via fuWalkDir and the fileChmod seam. + func() { + oc, ow := fileChmod, fuWalkDir + t.Cleanup(func() { fileChmod, fuWalkDir = oc, ow }) + var chmodded []string + fileChmod = func(p string, _ os.FileMode) error { chmodded = append(chmodded, p); return nil } + fuWalkDir = func(root string, visit func(string)) error { visit(root); return nil } + vm := New(nil) + callFU(t, vm, "chmod_R", []object.Value{object.Integer(0o750), s("d")}) + if len(chmodded) != 1 || chmodded[0] != "d" { + t.Fatalf("chmod_R: %v", chmodded) + } + }() +} + +// TestFileUtilsChownErrors covers the Errno mapping when chown / chown_R / +// chmod_R fail, and the fuWalk fallback that yields the root on a walk error. +func TestFileUtilsChownErrors(t *testing.T) { + s := func(x string) object.Value { return object.NewString(x) } + perm := os.ErrPermission + + cases := []struct { + name string + args []object.Value + }{ + {"chown", []object.Value{object.Integer(1), object.Integer(1), s("p")}}, + {"chown_R", []object.Value{object.Integer(1), object.Integer(1), s("p")}}, + {"chmod_R", []object.Value{object.Integer(0o644), s("p")}}, + } + for _, c := range cases { + oc, ocm, ow := fileChown, fileChmod, fuWalkDir + fileChown = func(string, int, int) error { return perm } + fileChmod = func(string, os.FileMode) error { return perm } + // A walk error makes fuWalk fall back to just the root. + fuWalkDir = func(string, func(string)) error { return perm } + vm := New(nil) + got := catchRaise(func() { callFU(t, vm, c.name, c.args) }) + fileChown, fileChmod, fuWalkDir = oc, ocm, ow + if got != "Errno::ENOENT" { + t.Fatalf("%s error: got %q, want Errno::ENOENT", c.name, got) + } + } +} + +// TestFuWalk covers the real filepath walk over a small tree (root + a child +// file), and the single-root fallback on a missing path. +func TestFuWalk(t *testing.T) { + dir := t.TempDir() + child := filepath.Join(dir, "f.txt") + if err := os.WriteFile(child, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + got := fuWalk(dir) + if len(got) != 2 { + t.Fatalf("fuWalk(%q)=%v, want root + child", dir, got) + } + // A non-existent path yields just itself. + miss := filepath.Join(dir, "nope") + if g := fuWalk(miss); len(g) != 1 || g[0] != miss { + t.Fatalf("fuWalk(missing)=%v", g) + } +} + // fakeFileInfo is a minimal os.FileInfo standing in for an existing file so the // touch "exists" branch (Chtimes) is taken without any real filesystem state. type fakeFileInfo struct{} diff --git a/internal/vm/yaml.go b/internal/vm/yaml.go index 7a4a2ea1..8f2d8e1a 100644 --- a/internal/vm/yaml.go +++ b/internal/vm/yaml.go @@ -8,11 +8,10 @@ import ( "github.com/go-embedded-ruby/ruby/internal/object" ) -// registerYAML installs a loadable shell for the YAML / Psych standard library -// (require "yaml"). A real YAML parser/emitter is a separate large subsystem; for -// now the module and its constant/error tree exist so load-time references -// resolve (Puppet requires yaml at load but only calls it from method bodies), -// while dump / load / safe_load / parse raise NotImplementedError if invoked. +// 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. func (vm *VM) registerYAML() { psych := newClass("Psych", nil) psych.isModule = true @@ -35,10 +34,43 @@ func (vm *VM) registerYAML() { return raise("NotImplementedError", "YAML %s not yet supported (pure-Go YAML pending)", what) } } - for _, m := range []string{"load", "safe_load", "load_file", "parse", "parse_stream", "dump_tags"} { + // The low-level node API (parse / parse_stream) and dump_tags are not used by + // Puppet's local persistence and remain unimplemented. + for _, m := range []string{"parse", "parse_stream", "dump_tags"} { psych.smethods[m] = &Method{name: m, owner: psych, native: notImpl(m)} } + // 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. + 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..)") + } + 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.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)) + } + 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 @@ -51,11 +83,29 @@ func (vm *VM) registerYAML() { if len(args) == 0 { raise("ArgumentError", "wrong number of arguments (given 0, expected 1..)") } - doc := object.NewString(yamlDump(args[0])) + doc := object.NewString(yamlDump(vm, args[0])) if len(args) > 1 && args[1] != object.NilV { vm.send(args[1], "write", []object.Value{doc}, nil) return args[1] } return doc }} + + // Object#to_yaml (Psych installs this on Object) returns YAML.dump(self). It is + // what Puppet's report store terminus calls (`fh.print to_yaml`), so every + // object — including the full Puppet::Transaction::Report graph — serialises. + vm.cObject.methods["to_yaml"] = &Method{name: "to_yaml", owner: vm.cObject, + native: func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(yamlDump(vm, self)) + }} +} + +// yamlSourceArg coerces YAML.load's first argument to a string: a String yields +// its contents, and any other value its to_s, so an IO-ish or symbol argument +// does not crash the loader (Puppet always passes a String). +func yamlSourceArg(v object.Value) string { + if s, ok := v.(*object.String); ok { + return s.Str() + } + return v.ToS() } diff --git a/internal/vm/yaml_dump.go b/internal/vm/yaml_dump.go index ac5ab039..d6182b81 100644 --- a/internal/vm/yaml_dump.go +++ b/internal/vm/yaml_dump.go @@ -23,14 +23,40 @@ import ( // 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 + 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. -func yamlDump(v object.Value) string { - e := &yamlEncoder{} +// 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(' ') @@ -86,18 +112,67 @@ func (e *yamlEncoder) encodeNode(v object.Value, indent int) { 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.scalar(k)) + 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 { @@ -122,6 +197,9 @@ func (e *yamlEncoder) writeMapChild(v object.Value, indent int) { // 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 { @@ -160,10 +238,19 @@ func (e *yamlEncoder) encodeInlineFirst(v object.Value, indent int) { 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.scalar(k)) + e.b.WriteString(e.keyScalar(k)) e.b.WriteByte(':') e.writeMapChild(val, indent) } @@ -211,6 +298,17 @@ func (e *yamlEncoder) scalar(v object.Value) string { 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 "" diff --git a/internal/vm/yaml_internal_test.go b/internal/vm/yaml_internal_test.go new file mode 100644 index 00000000..7f367332 --- /dev/null +++ b/internal/vm/yaml_internal_test.go @@ -0,0 +1,478 @@ +// 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 new file mode 100644 index 00000000..65470f27 --- /dev/null +++ b/internal/vm/yaml_load.go @@ -0,0 +1,790 @@ +// 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 new file mode 100644 index 00000000..58f11ccb --- /dev/null +++ b/internal/vm/yaml_load_test.go @@ -0,0 +1,304 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm_test + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// tmpDirSlash returns a forward-slash temp dir path so file-path tests behave +// identically on Windows (the coverage gate runs on windows-latest too). +func tmpDirSlash(t *testing.T) string { + t.Helper() + return filepath.ToSlash(t.TempDir()) +} + +// writeFileT writes content to path, failing the test on error. +func writeFileT(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(filepath.FromSlash(path), []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +// rubyStr renders a Go string as a double-quoted Ruby string literal (paths are +// plain ASCII here, so only the quotes need escaping). +func rubyStr(s string) string { return strconv.Quote(s) } + +// TestYAMLLoadScalars covers YAML.load of the implicit scalar grammar: null, +// booleans, integers (decimal / hex / underscore / bignum), floats (incl. .inf / +// .nan), symbols, quoted strings and timestamps. Each expected output is the +// inspect of the same value loaded by MRI's Psych. +func TestYAMLLoadScalars(t *testing.T) { + cases := []struct{ src, want string }{ + {`p YAML.load("--- \n")`, "nil\n"}, + {`p YAML.load("--- ~\n")`, "nil\n"}, + {`p YAML.load("--- null\n")`, "nil\n"}, + {`p YAML.load("--- true\n")`, "true\n"}, + {`p YAML.load("--- false\n")`, "false\n"}, + {`p YAML.load("--- 42\n")`, "42\n"}, + {`p YAML.load("--- -7\n")`, "-7\n"}, + {`p YAML.load("--- 1_000\n")`, "1000\n"}, + {`p YAML.load("--- 0x1A\n")`, "26\n"}, + {`p YAML.load("--- -0xff\n")`, "-255\n"}, + {`p YAML.load("--- 1.5\n")`, "1.5\n"}, + {`p YAML.load("--- 100.0\n")`, "100.0\n"}, + {`p YAML.load("--- .inf\n")`, "Infinity\n"}, + {`p YAML.load("--- -.inf\n")`, "-Infinity\n"}, + {`p YAML.load("--- .nan\n")`, "NaN\n"}, + {`p YAML.load("--- :sym\n")`, ":sym\n"}, + {`p YAML.load("--- hello\n")`, "\"hello\"\n"}, + {`p YAML.load("--- '123'\n")`, "\"123\"\n"}, + {`p YAML.load(%q{--- "a\tb"} + "\n")`, "\"a\\tb\"\n"}, + // A 1000000000000000000000000000000 bignum scalar. + {`p YAML.load("--- 1000000000000000000000000000000\n")`, "1000000000000000000000000000000\n"}, + // An empty input loads as nil; a lone "---" marker loads as nil. + {`p YAML.load("")`, "nil\n"}, + {`p YAML.load("---")`, "nil\n"}, + // Whole-line comments and the document-end marker are ignored. + {"p YAML.load(\"# a comment\\n--- 5\\n\")", "5\n"}, + {"p YAML.load(\"--- 5\\n...\\n\")", "5\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLLoadCollections covers block / flow mappings and sequences and their +// nesting, including the same-indent block-sequence value Psych writes under a +// mapping key. +func TestYAMLLoadCollections(t *testing.T) { + cases := []struct{ src, want string }{ + {"p YAML.load(\"---\\na: 1\\nb: 2\\n\")", "{\"a\" => 1, \"b\" => 2}\n"}, + {"p YAML.load(\"---\\n- 1\\n- 2\\n\")", "[1, 2]\n"}, + // A block-sequence value aligned under its key. + {"p YAML.load(\"---\\nb:\\n- 2\\n- 3\\n\")", "{\"b\" => [2, 3]}\n"}, + // A nested mapping indented two deeper. + {"p YAML.load(\"---\\nk:\\n n: true\\n\")", "{\"k\" => {\"n\" => true}}\n"}, + // A nil mapping value. + {"p YAML.load(\"---\\nk:\\n\")", "{\"k\" => nil}\n"}, + // Flow collections. + {`p YAML.load("--- [1, 2, 3]\n")`, "[1, 2, 3]\n"}, + {`p YAML.load("--- []\n")`, "[]\n"}, + {`p YAML.load("--- {}\n")`, "{}\n"}, + {`p YAML.load("--- {a: 1, b: 2}\n")`, "{\"a\" => 1, \"b\" => 2}\n"}, + // A flow sequence nested in a flow sequence (splitFlow bracket depth). + {`p YAML.load("--- [[1, 2], [3]]\n")`, "[[1, 2], [3]]\n"}, + // A sequence of mappings. + {"p YAML.load(\"---\\n- a: 1\\n- b: 2\\n\")", "[{\"a\" => 1}, {\"b\" => 2}]\n"}, + // A multi-key mapping under a dash. + {"p YAML.load(\"---\\n- a: 1\\n b: 2\\n\")", "[{\"a\" => 1, \"b\" => 2}]\n"}, + // A nested sequence under a dash. + {"p YAML.load(\"---\\n- - 1\\n - 2\\n\")", "[[1, 2]]\n"}, + // A "-" dash whose value is the indented block below it. + {"p YAML.load(\"---\\n-\\n a: 1\\n\")", "[{\"a\" => 1}]\n"}, + // An empty collection under a dash / a key. + {"p YAML.load(\"---\\n- []\\n- {}\\n\")", "[[], {}]\n"}, + {"p YAML.load(\"---\\nx: []\\n\")", "{\"x\" => []}\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLLoadBlockScalar covers literal / folded block scalars with each chomp +// indicator, both at the document level and under a mapping key / sequence dash. +func TestYAMLLoadBlockScalar(t *testing.T) { + cases := []struct{ src, want string }{ + // Document-level literal block, strip chomp. + {"p YAML.load(\"--- |-\\n a\\n b\\n\")", "\"a\\nb\"\n"}, + // Clip chomp keeps one trailing newline. + {"p YAML.load(\"--- |\\n a\\n b\\n\")", "\"a\\nb\\n\"\n"}, + // Keep chomp. + {"p YAML.load(\"--- |+\\n a\\n\")", "\"a\\n\"\n"}, + // Folded scalar joins lines with spaces. + {"p YAML.load(\"--- >-\\n a\\n b\\n\")", "\"a b\"\n"}, + // Under a mapping key. + {"p YAML.load(\"---\\nx: |-\\n a\\n b\\n\")", "{\"x\" => \"a\\nb\"}\n"}, + // Under a sequence dash. + {"p YAML.load(\"---\\n- |-\\n a\\n b\\n\")", "[\"a\\nb\"]\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLLoadTime covers the ISO-8601 timestamp the emitter writes loading back +// to a Time, in both the "Z" and numeric-offset forms. +func TestYAMLLoadTime(t *testing.T) { + cases := []struct{ src, want string }{ + {`t = YAML.load("--- 1970-01-01 00:00:00.000000000 Z\n"); p t.class; p t.to_i`, "Time\n0\n"}, + {`t = YAML.load("--- 1970-01-01 01:00:00.000000000 +01:00\n"); p t.to_i`, "0\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLLoadStateRoundTrip covers the exact shape Puppet's state.yaml uses (a +// String -> {Symbol -> Time} mapping), which must round-trip through dump/load. +func TestYAMLLoadStateRoundTrip(t *testing.T) { + src := `require "yaml" +h = {"File[/x]" => {:checked => Time.at(0).utc, :synced => Time.at(0).utc}} +r = YAML.load(YAML.dump(h)) +p r.keys +p r["File[/x]"].keys +p r["File[/x]"][:checked].to_i` + want := "[\"File[/x]\"]\n[:checked, :synced]\n0\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestYAMLLoadRubyObject covers loading a !ruby/object: mapping back into an +// instance of the named class, with the mapping entries as instance variables — +// including a class the program already defines and one it does not (which the +// loader registers as a placeholder). +func TestYAMLLoadRubyObject(t *testing.T) { + cases := []struct{ src, want string }{ + // Known class: ivars are restored. + {`class Foo; attr_reader :a, :b; end +o = YAML.load("--- !ruby/object:Foo\na: 1\nb: hi\n") +p o.class +p o.a +p o.b`, "Foo\n1\n\"hi\"\n"}, + // Bare !ruby/object loads as an Object instance. + {`o = YAML.load("--- !ruby/object\na: 1\n") +p o.instance_variable_get(:@a)`, "1\n"}, + // Unknown class: a placeholder class of that name is created. + {`o = YAML.load("--- !ruby/object:Quux\nx: 5\n") +p o.class.name +p o.instance_variable_get(:@x)`, "\"Quux\"\n5\n"}, + // A qualified unknown class name. + {`o = YAML.load("--- !ruby/object:A::B\nx: 1\n") +p o.class.name`, "\"A::B\"\n"}, + // An empty-bodied object (the "tag {}" inline form). + {`class Empty; end +o = YAML.load("--- !ruby/object:Empty {}\n") +p o.class`, "Empty\n"}, + // A symbol-keyed object mapping (Psych object ivars). + {`o = YAML.load("--- !ruby/object:Quux2\n:x: 1\n") +p o.instance_variable_get(:@x)`, "1\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLLoadAnchors covers &anchor / *alias: a shared object referenced twice +// loads as the very same instance. +func TestYAMLLoadAnchors(t *testing.T) { + src := `class Node; attr_reader :n; end +doc = "---\n- &1 !ruby/object:Node\n n: 7\n- *1\n" +a = YAML.load(doc) +p a[0].equal?(a[1]) +p a[1].n` + want := "true\n7\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestYAMLLoadSafeAndFile covers the safe_load / unsafe_load aliases (same +// implementation) and load_file via a temp file. +func TestYAMLLoadSafeAndFile(t *testing.T) { + cases := []struct{ src, want string }{ + {`p YAML.safe_load("--- 7\n")`, "7\n"}, + {`p YAML.unsafe_load("--- 7\n")`, "7\n"}, + // Extra positional / keyword args Psych accepts are tolerated and ignored. + {`p YAML.safe_load("--- 7\n", permitted_classes: [Symbol])`, "7\n"}, + {`p YAML.load("--- 7\n", symbolize_names: true)`, "7\n"}, + // A non-String source is coerced via to_s (yamlSourceArg's fallback): a + // Symbol's to_s is its name, which the loader parses as a plain scalar. + {`p YAML.load(:hello)`, "\"hello\"\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLLoadFile covers YAML.load_file / safe_load_file reading from disk and +// the missing-file error. +func TestYAMLLoadFile(t *testing.T) { + dir := tmpDirSlash(t) + path := dir + "/doc.yaml" + writeFileT(t, path, "---\na: 1\nb: 2\n") + src := `p YAML.load_file(` + rubyStr(path) + `)` + if got := eval(t, src); got != "{\"a\" => 1, \"b\" => 2}\n" { + t.Errorf("load_file got=%q", got) + } + src2 := `p YAML.safe_load_file(` + rubyStr(path) + `)` + if got := eval(t, src2); got != "{\"a\" => 1, \"b\" => 2}\n" { + t.Errorf("safe_load_file got=%q", got) + } + // A missing file raises Errno::ENOENT. + err := runErr(t, `YAML.load_file("/no/such/yaml/file.yaml")`) + if err == nil || !strings.Contains(err.Error(), "ENOENT") { + t.Errorf("missing file: expected ENOENT, got %v", err) + } +} + +// TestYAMLLoadTaggedScalars covers the explicit !ruby/symbol and !ruby/string +// scalar tags, an unknown non-object tag (ignored), and an alias whose anchor is +// undefined (loads as nil). +func TestYAMLLoadTaggedScalars(t *testing.T) { + cases := []struct{ src, want string }{ + {`p YAML.load("--- !ruby/symbol sym\n")`, ":sym\n"}, + {`p YAML.load("--- !ruby/string 123\n")`, "\"123\"\n"}, + // An unknown tag on a mapping leaves a plain Hash. + {"p YAML.load(\"--- !something\\na: 1\\n\")", "{\"a\" => 1}\n"}, + // A standalone unknown tag with no body is an empty Hash (taggedEmpty). + {`p YAML.load("--- !something\n")`, "{}\n"}, + // An alias to a missing anchor loads as nil. + {`p YAML.load("--- *missing\n")`, "nil\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLLoadFlowEdges covers flow-map entries with a non-entry token (skipped) +// and an empty flow collection. +func TestYAMLLoadFlowEdges(t *testing.T) { + cases := []struct{ src, want string }{ + // A flow map whose stray token has no ":" is skipped. + {`p YAML.load("--- {a: 1, junk}\n")`, "{\"a\" => 1}\n"}, + {`p YAML.load("--- [ ]\n")`, "[]\n"}, + {`p YAML.load("--- { }\n")`, "{}\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLLoadArity covers the zero-argument errors of load / load_file. +func TestYAMLLoadArity(t *testing.T) { + for _, call := range []string{`YAML.load`, `YAML.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 new file mode 100644 index 00000000..3719f8b5 --- /dev/null +++ b/internal/vm/yaml_object.go @@ -0,0 +1,309 @@ +// 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 +} diff --git a/internal/vm/yaml_object_test.go b/internal/vm/yaml_object_test.go new file mode 100644 index 00000000..83b22327 --- /dev/null +++ b/internal/vm/yaml_object_test.go @@ -0,0 +1,213 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm_test + +import "testing" + +// TestYAMLDumpObject covers Object#to_yaml / YAML.dump of an arbitrary object as +// a !ruby/object:Class mapping of its instance variables. Each expected document +// is the exact Psych.dump output in MRI (the instance variables here are in +// alphabetical order, which the deterministic emitter preserves). +func TestYAMLDumpObject(t *testing.T) { + cases := []struct{ src, want string }{ + // A simple object via to_yaml. + {`class Foo; def initialize; @a=1; @b="hi"; @c=:sym; end; end +puts Foo.new.to_yaml`, "--- !ruby/object:Foo\na: 1\nb: hi\nc: :sym\n"}, + // Via YAML.dump as well. + {`class Foo2; def initialize; @a=1; end; end +puts YAML.dump(Foo2.new)`, "--- !ruby/object:Foo2\na: 1\n"}, + // An object with no instance variables: the inline "{}" form. + {`class E; end +puts E.new.to_yaml`, "--- !ruby/object:E {}\n"}, + // A nested object value is indented two under its key. + {`class Inner; def initialize; @v=1; end; end +class Outer; def initialize; @inner=Inner.new; end; end +puts Outer.new.to_yaml`, "--- !ruby/object:Outer\ninner: !ruby/object:Inner\n v: 1\n"}, + // An object held in a hash value. + {`class P; def initialize; @n=1; end; end +puts({"k" => P.new}.to_yaml)`, "---\nk: !ruby/object:P\n n: 1\n"}, + // An object in a sequence. + {`class Q; def initialize; @n=1; end; end +puts [Q.new].to_yaml`, "---\n- !ruby/object:Q\n n: 1\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLDumpRange covers a Range (!ruby/range begin/end/excl). +func TestYAMLDumpRange(t *testing.T) { + cases := []struct{ src, want string }{ + {`puts((1..5).to_yaml)`, "--- !ruby/range\nbegin: 1\nend: 5\nexcl: false\n"}, + {`puts((1...5).to_yaml)`, "--- !ruby/range\nbegin: 1\nend: 5\nexcl: true\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLDumpSet covers a Set, which Psych writes as !ruby/object:Set with its +// backing "hash" ivar. +func TestYAMLDumpSet(t *testing.T) { + cases := []struct{ src, want string }{ + {`require "set"; puts Set.new([1, 2]).to_yaml`, "--- !ruby/object:Set\nhash:\n 1: true\n 2: true\n"}, + {`require "set"; puts Set.new.to_yaml`, "--- !ruby/object:Set\nhash: {}\n"}, + // A Set held as a mapping value. + {`require "set"; puts({"t" => Set.new([1])}.to_yaml)`, "---\nt: !ruby/object:Set\n hash:\n 1: true\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLDumpRegexpAndClass covers the inline tagged scalars: a Regexp and a +// Class / Module reference. +func TestYAMLDumpRegexpAndClass(t *testing.T) { + cases := []struct{ src, want string }{ + {`puts(/ab/.to_yaml)`, "--- !ruby/regexp /ab/\n"}, + {`puts(/ab/i.to_yaml)`, "--- !ruby/regexp /ab/i\n"}, + {`puts({:k => String}.to_yaml)`, "---\n:k: !ruby/class 'String'\n"}, + {`puts({:k => Comparable}.to_yaml)`, "---\n:k: !ruby/module 'Comparable'\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLDumpComplexKeys covers Psych's explicit "?"/":" form for non-scalar +// mapping keys (objects, arrays, hashes) and the nil key ("! ”"). +func TestYAMLDumpComplexKeys(t *testing.T) { + cases := []struct{ src, want string }{ + {`class K; def initialize; @a=1; end; end +puts({K.new => 1}.to_yaml)`, "---\n? !ruby/object:K\n a: 1\n: 1\n"}, + {`puts({[1, 2] => 1}.to_yaml)`, "---\n? - 1\n - 2\n: 1\n"}, + {`puts({{"a" => 1} => 2}.to_yaml)`, "---\n? a: 1\n: 2\n"}, + {`puts({nil => 1}.to_yaml)`, "---\n! '': 1\n"}, + // A complex key that is the SECOND entry of a block mapping (after a scalar + // entry). + {`puts({"first" => 0, [1, 2] => 3}.to_yaml)`, "---\nfirst: 0\n? - 1\n - 2\n: 3\n"}, + // A complex key appearing as a sequence element's inline-first hash entry. + {`puts [{[1] => 2}].to_yaml`, "---\n- ? - 1\n : 2\n"}, + // A complex key that is the SECOND entry of an inline-first (under-a-dash) + // mapping. + {`puts [{"a" => 1, [2] => 3}].to_yaml`, "---\n- a: 1\n ? - 2\n : 3\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLDumpSharedAnchor covers a shared / repeated reference written once with +// "&N" and aliased "*N" thereafter, and a self-referential cycle (which must +// terminate). +func TestYAMLDumpSharedAnchor(t *testing.T) { + cases := []struct{ src, want string }{ + // The same object twice in an array. + {`class S; def initialize; @n=7; end; end +b = S.new +puts [b, b].to_yaml`, "---\n- &1 !ruby/object:S\n n: 7\n- *1\n"}, + // A shared array. + {`a = [1, 2] +puts [a, a].to_yaml`, "---\n- &1\n - 1\n - 2\n- *1\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLDumpCycleTerminates covers that a self-referential object graph dumps +// without looping forever (the value is emitted once, then aliased). +func TestYAMLDumpCycleTerminates(t *testing.T) { + src := `class C; attr_accessor :self_ref; end +c = C.new +c.self_ref = c +y = c.to_yaml +p y.include?("&1") +p y.include?("*1")` + want := "true\ntrue\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestYAMLDumpSharedHashMapChild covers a shared Hash referenced from two +// mapping values: written once with "&N" then aliased. +func TestYAMLDumpSharedHashMapChild(t *testing.T) { + src := `h = {"x" => 1} +puts({"a" => h, "b" => h}.to_yaml)` + want := "---\na: &1\n x: 1\nb: *1\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestYAMLDumpEmptyObjectChild covers an empty-ivar object held as a mapping +// value and a sequence element (the "tag {}" inline form in a child position). +func TestYAMLDumpEmptyObjectChild(t *testing.T) { + cases := []struct{ src, want string }{ + {`class Em; end +puts({"k" => Em.new}.to_yaml)`, "---\nk: !ruby/object:Em {}\n"}, + {`class Em2; end +puts [Em2.new].to_yaml`, "---\n- !ruby/object:Em2 {}\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestYAMLDumpBeginlessRange covers a Range with a nil begin bound (rangeBound's +// nil branch). +func TestYAMLDumpBeginlessRange(t *testing.T) { + src := `puts((..5).to_yaml)` + want := "--- !ruby/range\nbegin:\nend: 5\nexcl: false\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestYAMLDumpBareObject covers an Object instance (class Object), whose tag has +// no ":Class" suffix. +func TestYAMLDumpBareObject(t *testing.T) { + src := `o = Object.new +o.instance_variable_set(:@a, 1) +puts o.to_yaml` + want := "--- !ruby/object\na: 1\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestYAMLObjectRoundTrip covers dump-then-load of an object graph returning an +// equivalent structure (same class, same ivars, shared refs preserved). +func TestYAMLObjectRoundTrip(t *testing.T) { + src := `class Person; attr_reader :name, :age; end +p0 = Person.new +p0.instance_variable_set(:@name, "Ada") +p0.instance_variable_set(:@age, 36) +doc = [p0, p0].to_yaml +r = YAML.load(doc) +p r[0].class +p r[0].name +p r[0].age +p r[0].equal?(r[1])` + want := "Person\n\"Ada\"\n36\ntrue\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} diff --git a/internal/vm/yaml_test.go b/internal/vm/yaml_test.go index 571d5fa4..18d39bc7 100644 --- a/internal/vm/yaml_test.go +++ b/internal/vm/yaml_test.go @@ -32,15 +32,13 @@ func TestYAMLConstants(t *testing.T) { } // TestYAMLNotImplemented covers the Psych/YAML methods that still raise -// NotImplementedError until a real pure-Go YAML parser lands. dump is now -// implemented (see TestYAMLDump) and so is excluded here. +// NotImplementedError: the low-level node API (parse / parse_stream) and +// dump_tags, which Puppet's persistence does not use. dump / load / safe_load / +// load_file are implemented (see TestYAMLDump / TestYAMLLoad) and excluded here. func TestYAMLNotImplemented(t *testing.T) { - // load/safe_load/parse/parse_stream/dump_tags take args; load_file takes a - // path. Each must raise NotImplementedError naming the method. + // parse/parse_stream take a source; dump_tags takes none. Each must raise + // NotImplementedError naming the method. calls := map[string]string{ - "load": `YAML.load("a")`, - "safe_load": `YAML.safe_load("a")`, - "load_file": `YAML.load_file("x.yml")`, "parse": `YAML.parse("a")`, "parse_stream": `YAML.parse_stream("a")`, "dump_tags": `YAML.dump_tags`, @@ -205,10 +203,12 @@ p r.equal?(io)` } } -// TestYAMLDumpUnsupported covers the TypeError raised for a value outside the -// supported plain-value shapes, which the report YAML indirector rescues. +// TestYAMLDumpUnsupported covers the TypeError raised for a value with no Psych +// representation (a Proc), which the report YAML indirector rescues. Ordinary +// objects now serialise (see TestYAMLDumpObject), so the unsupported case is a +// genuinely undumpable value. func TestYAMLDumpUnsupported(t *testing.T) { - src := `class Foo; end; YAML.dump(Foo.new)` + src := `YAML.dump(proc {})` err := runErr(t, src) if err == nil || !strings.Contains(err.Error(), "TypeError") { t.Errorf("expected TypeError, got %v", err)