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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ cov.out

# Editor / OS noise
.DS_Store
/rbgo.exe
9 changes: 9 additions & 0 deletions internal/vm/array_mutators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ func TestArrayMutators(t *testing.T) {
// reverse_each: block iterates in reverse; no block yields an Enumerator.
{`r = []; [1, 2, 3].reverse_each { |x| r << x }; p r`, "[3, 2, 1]\n"},
{`p [[1, 2, 3].reverse_each.to_a, [1, 2, 3].reverse_each.map { |x| x * 2 }]`, "[[3, 2, 1], [6, 4, 2]]\n"},
// flatten!: mutates in place, returning self when it flattened, nil when
// there was nothing to flatten (including a depth-0 / empty / flat array),
// and an optional depth limits the recursion.
{`a = [1, [2, [3, 4]], 5]; p [a.flatten!, a]`, "[[1, 2, 3, 4, 5], [1, 2, 3, 4, 5]]\n"},
{`a = [1, 2, 3]; p [a.flatten!, a]`, "[nil, [1, 2, 3]]\n"},
{`p [[].flatten!, [1, [2]].flatten!(0)]`, "[nil, nil]\n"},
{`a = [1, [2, [3]]]; p [a.flatten!(1), a]`, "[[1, 2, [3]], [1, 2, [3]]]\n"},
// flatten! detects a change even when the length is unchanged ([[1]] -> [1]).
{`a = [[1]]; p [a.flatten!, a]`, "[[1], [1]]\n"},
}
for _, c := range cases {
if got := eval(t, c.src); got != c.want {
Expand Down
42 changes: 42 additions & 0 deletions internal/vm/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ func (vm *VM) bootstrap() {
vm.registerDir() // Dir (reuses the Errno module set up by registerFile)
vm.registerTmpdir() // Dir.tmpdir / Dir.mktmpdir (layers onto Dir; require "tmpdir")
vm.registerProcess() // Process module — identity + clock_gettime
vm.registerSpawn() // IO.pipe / read_nonblock / select + Process.spawn/waitpid2 + Kernel.fork/exec
vm.registerObjectSpace() // ObjectSpace module — finalizer API + reflective no-ops
vm.registerOpenSSL() // OpenSSL (real digest/HMAC/random + PKI/TLS shell); needs StandardError
vm.registerNetHTTP() // net/http + net/https loadable shell; needs StandardError
Expand Down Expand Up @@ -1990,6 +1991,19 @@ func (vm *VM) bootstrap() {
}
return &object.Array{Elems: flattenDepth(self.(*object.Array).Elems, depth)}
})
vm.cArray.define("flatten!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value {
depth := -1
if len(args) > 0 {
depth = int(intArg(args[0]))
}
a := self.(*object.Array)
out, changed := flattenDepthChanged(a.Elems, depth)
if !changed {
return object.NilV
}
a.Elems = out
return self
})
vm.cArray.define("sum", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value {
acc := object.Value(object.Integer(0))
if len(args) > 0 {
Expand Down Expand Up @@ -2346,6 +2360,15 @@ func (vm *VM) bootstrap() {
}
return object.Bool(args[0].Truthy())
})
// nil =~ anything is always nil in MRI (NilClass#=~), so `value =~ /re/`
// short-circuits when value is nil — Puppet's useradd provider relies on this
// when building its command from possibly-absent properties.
vm.cNilClass.define("=~", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
if len(args) != 1 {
raise("ArgumentError", "wrong number of arguments (given %d, expected 1)", len(args))
}
return object.NilV
})
vm.cArray.define("sort_by", func(vm *VM, self object.Value, _ []object.Value, blk *Proc) object.Value {
if blk == nil {
return enumFor(self, "sort_by")
Expand Down Expand Up @@ -4457,6 +4480,25 @@ func flattenDepth(elems []object.Value, depth int) []object.Value {
return out
}

// flattenDepthChanged flattens like flattenDepth but also reports whether any
// nested array was actually expanded. flatten!/flatten share semantics, but the
// bang form must return nil when nothing changed, which length alone cannot
// detect (e.g. [[1]] flattens to [1] with the same length).
func flattenDepthChanged(elems []object.Value, depth int) ([]object.Value, bool) {
var out []object.Value
changed := false
for _, e := range elems {
if sub, ok := e.(*object.Array); ok && depth != 0 {
changed = true
inner, _ := flattenDepthChanged(sub.Elems, depth-1)
out = append(out, inner...)
} else {
out = append(out, e)
}
}
return out, changed
}

// joinArray renders an array as a string, recursively joining nested arrays.
func joinArray(a *object.Array, sep string) string {
var b strings.Builder
Expand Down
5 changes: 5 additions & 0 deletions internal/vm/file_open_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ func TestFileStreams(t *testing.T) {
// Non-block open: write, then close flushes.
{fmt.Sprintf(`f = File.open(%q, "w"); f.write("Z"); f.close; p File.read(%q)`, w, w), "\"Z\"\n"},
{fmt.Sprintf(`p File.open(%q, "w").class`, w), "File\n"},
// File.open / readlines / foreach accept a Pathname (anything answering
// #to_path), matching MRI — Puppet's FileSystem layer passes Pathnames.
{fmt.Sprintf(`require "pathname"; p File.open(Pathname.new(%q)) { |io| io.read(3) }`, hello), "\"hel\"\n"},
{fmt.Sprintf(`require "pathname"; p File.readlines(Pathname.new(%q))`, lines), "[\"one\\n\", \"two\\n\"]\n"},
{fmt.Sprintf(`require "pathname"; r = []; File.foreach(Pathname.new(%q)) { |l| r << l.chomp }; p r`, pq), "[\"p\", \"q\"]\n"},
}
for _, c := range cases {
if got := eval(t, c.src); got != c.want {
Expand Down
62 changes: 58 additions & 4 deletions internal/vm/io.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ type IOObj struct {
label string // "STDOUT"/"STDERR"/"STDIN" for inspect
path string // backing file path for a File stream (else "")
writable bool // a File opened for writing — flush the buffer on flush/close

// Pipe ends (IO.pipe) share a single byte buffer in *pipe. The write end
// appends; the read end drains from pipe.rpos. Because subprocess execution
// in this VM is synchronous (Process.spawn / Kernel.exec run to completion
// before returning), the buffer is fully populated by the time a reader
// drains it, so a buffered model faithfully reproduces the blocking-read and
// EOF behaviour Puppet's execute loop relies on. reopened tracks the IO a
// standard stream (STDOUT/STDERR) was rebound to via #reopen, so a forked
// block's writes (and Kernel.exec's captured output) land on the pipe.
pipe *pipeBuf
isWriteEnd bool
reopened *IOObj
}

// pipeBuf is the shared byte channel behind an IO.pipe reader/writer pair.
type pipeBuf struct {
data []byte
rpos int
wClosed bool
}

func (o *IOObj) ToS() string {
Expand All @@ -38,6 +57,19 @@ func (o *IOObj) Truthy() bool { return true }
// writeBytes appends p to the stream (advancing the StringIO cursor, overwriting
// then extending) and returns the byte count.
func (o *IOObj) writeBytes(p []byte) int {
// A standard stream reopened onto another IO (Puppet's safe_posix_fork does
// STDOUT.reopen(pipe_writer)) forwards its writes to that target. Follow the
// chain iteratively with a cycle guard so a degenerate self/loop reopen
// (e.g. STDOUT.reopen($stdout)) cannot recurse without bound.
for cur, seen := o, map[*IOObj]bool{}; cur.reopened != nil && cur.reopened != cur && !seen[cur]; {
seen[cur] = true
cur = cur.reopened
o = cur
}
if o.pipe != nil && o.isWriteEnd {
o.pipe.data = append(o.pipe.data, p...)
return len(p)
}
if o.isStr {
if end := o.pos + len(p); end > len(o.buf) {
o.buf = append(o.buf, make([]byte, end-len(o.buf))...)
Expand All @@ -52,6 +84,21 @@ func (o *IOObj) writeBytes(p []byte) int {

func (o *IOObj) writeStr(s string) int { return o.writeBytes([]byte(s)) }

// pipeRefresh snapshots a pipe reader's shared buffer into the IOObj's own
// buf/pos view so the existing StringIO read methods (read/gets/eof?) operate on
// the latest pipe contents. It is a no-op for non-pipe streams.
func (o *IOObj) pipeRefresh() {
if o.pipe != nil && !o.isWriteEnd {
o.buf = o.pipe.data
}
}

// pipeWriterClosed reports whether the write end of a pipe reader has been
// closed (EOF) — used by read_nonblock and IO.select to model EOF.
func (o *IOObj) pipeWriterClosed() bool {
return o.pipe != nil && o.pipe.wClosed
}

// registerIO installs the IO class with the writing methods, the StringIO class
// (read + write), and the standard streams as both globals ($stdout/$stderr/
// $stdin) and constants (STDOUT/STDERR/STDIN). Kernel#puts/print/p are routed
Expand Down Expand Up @@ -100,7 +147,7 @@ func (vm *VM) registerIO() {
cFile := vm.consts["File"].(*RClass)
cFile.super = cIO // File < IO, inheriting the read+write protocol; is_a?(IO) holds
cFile.smethods["open"] = &Method{name: "open", owner: cFile, native: func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value {
o := openFileIO(cFile, strArg(args[0]), fileMode(args))
o := openFileIO(cFile, pathArg(vm, args[0]), fileMode(args))
if blk != nil {
defer ioFlushClose(o)
return vm.callBlock(blk, []object.Value{o})
Expand Down Expand Up @@ -134,16 +181,16 @@ func (vm *VM) registerIO() {
}
return object.Integer(0)
})
cFile.smethods["readlines"] = &Method{name: "readlines", owner: cFile, native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
o := openFileIO(cFile, strArg(args[0]), "r")
cFile.smethods["readlines"] = &Method{name: "readlines", owner: cFile, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
o := openFileIO(cFile, pathArg(vm, args[0]), "r")
var lines []object.Value
for v := ioGets(o, nil); v != object.NilV; v = ioGets(o, nil) {
lines = append(lines, v)
}
return &object.Array{Elems: lines}
}}
cFile.smethods["foreach"] = &Method{name: "foreach", owner: cFile, native: func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value {
o := openFileIO(cFile, strArg(args[0]), "r")
o := openFileIO(cFile, pathArg(vm, args[0]), "r")
for v := ioGets(o, nil); v != object.NilV; v = ioGets(o, nil) {
vm.callBlock(blk, []object.Value{v})
}
Expand Down Expand Up @@ -337,6 +384,9 @@ func defIOWrite(cls *RClass) {
cls.define("close", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value {
o := self.(*IOObj)
ioFlush(o)
if o.pipe != nil && o.isWriteEnd {
o.pipe.wClosed = true // signal EOF to the read end
}
o.closed = true
return object.NilV
})
Expand All @@ -362,10 +412,12 @@ func defStringIORead(cls *RClass) {
})
cls.define("eof?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value {
o := self.(*IOObj)
o.pipeRefresh()
return object.Bool(o.pos >= len(o.buf))
})
cls.define("eof", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value {
o := self.(*IOObj)
o.pipeRefresh()
return object.Bool(o.pos >= len(o.buf))
})
cls.define("pos", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value {
Expand Down Expand Up @@ -411,6 +463,7 @@ func defStringIORead(cls *RClass) {
})
cls.define("read", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value {
o := self.(*IOObj)
o.pipeRefresh()
if len(args) > 0 && args[0] != object.NilV {
n := int(toInt(args[0]))
if o.pos >= len(o.buf) {
Expand Down Expand Up @@ -485,6 +538,7 @@ func defStringIORead(cls *RClass) {
// ioGets reads one line (up to and including the separator, default "\n") from a
// StringIO, returning nil at end of input.
func ioGets(o *IOObj, args []object.Value) object.Value {
o.pipeRefresh()
if o.pos >= len(o.buf) {
return object.NilV
}
Expand Down
4 changes: 4 additions & 0 deletions internal/vm/nilclass_conv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ func TestNilClassConversions(t *testing.T) {
{"xor_false", `p(nil ^ false)`, "false\n"},
{"xor_obj", `p(nil ^ 7)`, "true\n"},
{"xor_nil", `p(nil ^ nil)`, "false\n"},
// nil =~ anything is nil (NilClass#=~), so `x =~ /re/` short-circuits on nil.
{"match_re", `p(nil =~ /x/)`, "nil\n"},
{"match_str", `p(nil =~ "x")`, "nil\n"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
Expand All @@ -45,6 +48,7 @@ func TestNilClassBooleanArity(t *testing.T) {
`nil.send(:&)`,
`nil.send(:|)`,
`nil.send(:^)`,
`nil.send(:=~)`,
} {
class, _ := evalErr(t, src)
if class != "ArgumentError" {
Expand Down
Loading
Loading