diff --git a/internal/vm/io.go b/internal/vm/io.go index 1aeaabb..88a6db2 100644 --- a/internal/vm/io.go +++ b/internal/vm/io.go @@ -24,6 +24,9 @@ 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 + lineno int // #lineno — advanced by each successful line read (gets/readline) + rdClosed bool // #close_read was called — reads raise "not opened for reading" + wrClosed bool // #close_write was called — writes raise "not opened for writing" // 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 @@ -109,11 +112,14 @@ func (vm *VM) registerIO() { vm.consts["IO"] = cIO defIOWrite(cIO) defStringIORead(cIO) // IO carries the read protocol too ($stdin, File streams) + defIOReadExtra(cIO) + defIOSeekable(cIO) // pread/pwrite/sysseek/binmode?/autoclose — IO+File, not StringIO cStringIO := newClass("StringIO", vm.cObject) vm.consts["StringIO"] = cStringIO defIOWrite(cStringIO) defStringIORead(cStringIO) + defIOReadExtra(cStringIO) cStringIO.smethods["new"] = &Method{name: "new", owner: cStringIO, native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { o := &IOObj{cls: cStringIO, isStr: true} if len(args) > 0 { @@ -473,6 +479,7 @@ func defStringIORead(cls *RClass) { }) cls.define("read", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { o := self.(*IOObj) + ioCheckReadable(o) o.pipeRefresh() // An optional second argument is an output-buffer String: the read fills it // (and is returned in its place), and it is cleared when the read yields nil. @@ -519,6 +526,8 @@ func defStringIORead(cls *RClass) { }) cls.define("getc", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { o := self.(*IOObj) + ioCheckReadable(o) + o.pipeRefresh() if o.pos >= len(o.buf) { return object.NilV } @@ -529,6 +538,7 @@ func defStringIORead(cls *RClass) { }) gets := func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { o := self.(*IOObj) + ioCheckReadable(o) return ioGets(o, args) } cls.define("gets", gets) @@ -541,6 +551,8 @@ func defStringIORead(cls *RClass) { }) cls.define("readlines", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { o := self.(*IOObj) + ioCheckReadable(o) + checkGetsLimit(args, "readlines") var lines []object.Value for { v := ioGets(o, args) @@ -553,6 +565,8 @@ func defStringIORead(cls *RClass) { }) cls.define("each_line", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { o := self.(*IOObj) + ioCheckReadable(o) + checkGetsLimit(args, "each_line") for { v := ioGets(o, args) if v == object.NilV { @@ -574,38 +588,109 @@ 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. +// StringIO, returning nil at end of input. It accepts MRI's (sep, limit, chomp:) +// argument shapes: a leading Integer positional is the byte limit (separator +// defaults to "\n"); a String or nil is the separator, optionally followed by an +// Integer limit; a trailing chomp: true strips the separator from the result. A +// successful (non-nil) read advances #lineno. func ioGets(o *IOObj, args []object.Value) object.Value { o.pipeRefresh() - if o.pos >= len(o.buf) { - return object.NilV + sep, limit, chomp := parseGetsArgs(args) + v := ioGetsLine(o, sep, limit, chomp) + if v != object.NilV { + o.lineno++ + } + return v +} + +// parseGetsArgs decodes the (sep, limit, chomp:) arguments of gets/readline/ +// each_line. sepSet is false when the separator defaults to "\n"; a nil separator +// (read the whole remainder) is reported as sepSet with sep == "" and nilSep. +func parseGetsArgs(args []object.Value) (sep getsSep, limit int, chomp bool) { + sep, limit = getsSep{s: "\n"}, -1 + if h, ok := lastHash(args); ok { + if v, ok := h.Get(object.Symbol("chomp")); ok { + chomp = v.Truthy() + } + args = args[:len(args)-1] } - sep := "\n" if len(args) > 0 { switch a := args[0].(type) { + case object.Integer: + limit = int(a) case *object.String: - sep = a.Str() - if sep == "" { // an empty separator selects paragraph mode - return ioGetsParagraph(o) - } + sep = getsSep{s: a.Str(), set: true} default: - if args[0] == object.NilV { // a nil separator reads the entire remainder - s := object.NewString(string(o.buf[o.pos:])) - o.pos = len(o.buf) - return s + if args[0] == object.NilV { + sep = getsSep{s: "", set: true, nilSep: true} } } } + if len(args) > 1 { + if n, ok := args[1].(object.Integer); ok { + limit = int(n) + } + } + return sep, limit, chomp +} + +// checkGetsLimit raises ArgumentError for an explicit limit of 0 on a +// line-iterating read (readlines/each_line/foreach/each): a 0-byte line never +// advances the cursor, so MRI rejects it rather than looping. A single #gets(0) +// is allowed (it just returns "") and does not go through this guard. +func checkGetsLimit(args []object.Value, meth string) { + if _, limit, _ := parseGetsArgs(args); limit == 0 { + raise("ArgumentError", "invalid limit: 0 for %s", meth) + } +} + +// getsSep is a resolved line separator for ioGetsLine. +type getsSep struct { + s string + set bool // an explicit separator was given (else the "\n" default) + nilSep bool // an explicit nil separator: read the whole remainder as one line +} + +// ioGetsLine reads one line honouring the separator, an optional byte limit +// (negative for none) and chomp, advancing the cursor. It returns nil at EOF. +func ioGetsLine(o *IOObj, sep getsSep, limit int, chomp bool) object.Value { + if o.pos >= len(o.buf) { + return object.NilV + } + if sep.set && sep.s == "" && !sep.nilSep { // an empty separator selects paragraph mode + return ioGetsParagraph(o) + } rest := o.buf[o.pos:] - if i := strings.Index(string(rest), sep); i >= 0 { - end := o.pos + i + len(sep) - s := object.NewString(string(o.buf[o.pos:end])) - o.pos = end - return s + end := len(o.buf) // default: read to end (nil separator, or separator not found) + if !sep.nilSep { + if i := strings.Index(string(rest), sep.s); i >= 0 { + end = o.pos + i + len(sep.s) + } } - s := object.NewString(string(rest)) - o.pos = len(o.buf) - return s + if limit >= 0 && o.pos+limit < end { + end = o.pos + limit + } + line := o.buf[o.pos:end] + o.pos = end + if chomp && !sep.nilSep { + line = getsChomp(line, sep.s) + } + return object.NewString(string(line)) +} + +// getsChomp removes a single trailing separator run from line (the "\n" default +// also strips a preceding "\r", matching MRI's universal-newline chomp). +func getsChomp(line []byte, sep string) []byte { + if sep == "\n" { + if n := len(line); n > 0 && line[n-1] == '\n' { + line = line[:n-1] + if n := len(line); n > 0 && line[n-1] == '\r' { + line = line[:n-1] + } + } + return line + } + return []byte(strings.TrimSuffix(string(line), sep)) } // ioGetsParagraph implements gets/each_line paragraph mode (an empty separator): @@ -707,11 +792,27 @@ func (vm *VM) inspectStr(v object.Value) string { return v.Inspect() } -// ioCheckOpen raises IOError when writing to a closed stream. +// ioCheckOpen raises IOError when writing to a closed stream (fully closed, or +// with its write half shut by #close_write). func ioCheckOpen(o *IOObj) { if o.closed { raise("IOError", "closed stream") } + if o.wrClosed { + raise("IOError", "not opened for writing") + } +} + +// ioCheckReadable raises IOError when reading from a stream whose read half is +// unavailable: a fully closed stream ("closed stream") or one shut for reading by +// #close_read ("not opened for reading"). +func ioCheckReadable(o *IOObj) { + if o.closed { + raise("IOError", "closed stream") + } + if o.rdClosed { + raise("IOError", "not opened for reading") + } } // toInt coerces a small Integer position/length argument to int64 (raising for diff --git a/internal/vm/io_class.go b/internal/vm/io_class.go index 2e87ac0..19776b1 100644 --- a/internal/vm/io_class.go +++ b/internal/vm/io_class.go @@ -39,10 +39,8 @@ func (vm *VM) registerIOClassMethods(cIO, cFile *RClass) { raise("ArgumentError", "wrong number of arguments (given 0, expected 1+)") } o := openFileIO(cFile, pathArg(vm, pos[0]), "r") - var sep []object.Value - if len(pos) > 1 { - sep = pos[1:2] - } + sep := pos[1:] + checkGetsLimit(sep, "foreach") var lines []object.Value for v := ioGets(o, sep); v != object.NilV; v = ioGets(o, sep) { if blk != nil { @@ -62,10 +60,8 @@ func (vm *VM) registerIOClassMethods(cIO, cFile *RClass) { raise("ArgumentError", "wrong number of arguments (given 0, expected 1+)") } o := openFileIO(cFile, pathArg(vm, pos[0]), "r") - var sep []object.Value - if len(pos) > 1 { - sep = pos[1:2] - } + sep := pos[1:] + checkGetsLimit(sep, "readlines") var lines []object.Value for v := ioGets(o, sep); v != object.NilV; v = ioGets(o, sep) { lines = append(lines, v) diff --git a/internal/vm/io_descriptors.go b/internal/vm/io_descriptors.go new file mode 100644 index 0000000..92a12c1 --- /dev/null +++ b/internal/vm/io_descriptors.go @@ -0,0 +1,279 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "unicode/utf8" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// defIOReadExtra installs the byte/char-oriented read protocol and the +// descriptor-state methods shared by IO, File and StringIO: getbyte/readbyte, +// ungetbyte/ungetc, readchar, each_byte, sysread/syswrite, #lineno and the +// half-close methods (close_read/close_write and their predicates). They operate +// on the same in-memory buffer + cursor the rest of the read protocol uses. +func defIOReadExtra(cls *RClass) { + cls.define("getbyte", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + ioCheckReadable(o) + o.pipeRefresh() + if o.pos >= len(o.buf) { + return object.NilV + } + b := o.buf[o.pos] + o.pos++ + return object.IntValue(int64(b)) + }) + cls.define("readbyte", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + ioCheckReadable(o) + o.pipeRefresh() + if o.pos >= len(o.buf) { + raise("EOFError", "end of file reached") + } + b := o.buf[o.pos] + o.pos++ + return object.IntValue(int64(b)) + }) + cls.define("readchar", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + ioCheckReadable(o) + o.pipeRefresh() + if o.pos >= len(o.buf) { + raise("EOFError", "end of file reached") + } + r, sz := utf8.DecodeRune(o.buf[o.pos:]) + o.pos += sz + return object.NewString(string(r)) + }) + cls.define("ungetbyte", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + ioCheckReadable(o) + switch a := args[0].(type) { + case object.Integer: + ioUnget(o, []byte{byte(a)}) + case *object.String: + ioUnget(o, a.Bytes()) + default: + if args[0] != object.NilV { // a nil argument is a no-op, as in MRI + raise("TypeError", "no implicit conversion of %s into Integer", classNameOf(args[0])) + } + } + return object.NilV + }) + cls.define("ungetc", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + ioCheckReadable(o) + switch a := args[0].(type) { + case object.Integer: + ioUnget(o, []byte(string(rune(a)))) + case *object.String: + ioUnget(o, a.Bytes()) + default: + if args[0] != object.NilV { + raise("TypeError", "no implicit conversion of %s into String", classNameOf(args[0])) + } + } + return object.NilV + }) + cls.define("each_byte", func(vm *VM, self object.Value, _ []object.Value, blk *Proc) object.Value { + o := self.(*IOObj) + ioCheckReadable(o) + o.pipeRefresh() + for o.pos < len(o.buf) { + b := o.buf[o.pos] + o.pos++ + vm.callBlock(blk, []object.Value{object.IntValue(int64(b))}) + } + return self + }) + each := func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + o := self.(*IOObj) + ioCheckReadable(o) + checkGetsLimit(args, "each_line") + for { + v := ioGets(o, args) + if v == object.NilV { + break + } + vm.callBlock(blk, []object.Value{v}) + } + return self + } + cls.define("each", each) + cls.define("sysread", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + ioCheckReadable(o) + o.pipeRefresh() + n := int(intArg(args[0])) + if n < 0 { + raise("ArgumentError", "negative length %d given", n) + } + var buf *object.String + if len(args) > 1 { + if b, ok := args[1].(*object.String); ok { + buf = b + } + } + if n == 0 { + return ioReadResult(nil, buf) // a zero-length sysread is "" even at EOF + } + if o.pos >= len(o.buf) { + raise("EOFError", "end of file reached") + } + end := min(o.pos+n, len(o.buf)) + data := o.buf[o.pos:end] + o.pos = end + return ioReadResult(data, buf) + }) + cls.define("syswrite", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + ioCheckOpen(o) + return object.IntValue(int64(o.writeStr(args[0].ToS()))) + }) + cls.define("lineno", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(self.(*IOObj).lineno)) + }) + cls.define("lineno=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + self.(*IOObj).lineno = int(intArg(args[0])) + return args[0] + }) + cls.define("close_read", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + o.rdClosed = true + if o.wrClosed { // both halves shut ⇒ the stream is fully closed + o.closed = true + } + return object.NilV + }) + cls.define("close_write", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + ioFlush(o) + o.wrClosed = true + if o.rdClosed { + o.closed = true + } + return object.NilV + }) + cls.define("closed_read?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + return object.Bool(o.closed || o.rdClosed) + }) + cls.define("closed_write?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + return object.Bool(o.closed || o.wrClosed) + }) +} + +// defIOSeekable installs the positioned + descriptor methods that MRI defines on +// IO (and hence File) but not on StringIO: pread/pwrite, sysseek and the +// binary-mode / autoclose / fdatasync accessors. pread/pwrite address the buffer +// by absolute offset without disturbing the cursor. +func defIOSeekable(cls *RClass) { + cls.define("pread", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + ioCheckReadable(o) + o.pipeRefresh() + n := int(intArg(args[0])) + if n < 0 { + raise("ArgumentError", "negative string size (or size too big)") + } + off := int(intArg(args[1])) + if off < 0 { + raise("Errno::EINVAL", "Invalid argument - pread") + } + var buf *object.String + if len(args) > 2 { + if b, ok := args[2].(*object.String); ok { + buf = b + } + } + if n == 0 { + return ioReadResult(nil, buf) + } + if off >= len(o.buf) { + raise("EOFError", "end of file reached") + } + data := o.buf[off:min(off+n, len(o.buf))] + return ioReadResult(data, buf) + }) + cls.define("pwrite", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + ioCheckOpen(o) + off := int(intArg(args[1])) + if off < 0 { + raise("Errno::EINVAL", "Invalid argument - pwrite") + } + data := []byte(args[0].ToS()) + if end := off + len(data); end > len(o.buf) { + o.buf = append(o.buf, make([]byte, end-len(o.buf))...) + } + copy(o.buf[off:], data) + o.writable = true // a written File flushes its buffer back on flush/close + return object.IntValue(int64(len(data))) + }) + cls.define("sysseek", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := self.(*IOObj) + amount := int(intArg(args[0])) + switch whenceArg(args) { + case 1: // SEEK_CUR + o.pos += amount + case 2: // SEEK_END + o.pos = len(o.buf) + amount + default: // SEEK_SET + o.pos = amount + } + return object.IntValue(int64(o.pos)) + }) + cls.define("binmode?", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(false) + }) + cls.define("autoclose?", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(true) + }) + cls.define("autoclose=", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return args[0] + }) + cls.define("fdatasync", func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(0) + }) +} + +// whenceArg returns the SEEK_* whence of a seek-style argument list (default 0). +func whenceArg(args []object.Value) int { + if len(args) > 1 { + return int(intArg(args[1])) + } + return 0 +} + +// ioUnget inserts p immediately before the cursor (leaving the cursor on the +// re-inserted bytes) so a following read returns them first — the pushback model +// shared by ungetbyte and ungetc. +func ioUnget(o *IOObj, p []byte) { + if len(p) == 0 { + return + } + out := make([]byte, 0, len(o.buf)+len(p)) + out = append(out, o.buf[:o.pos]...) + out = append(out, p...) + out = append(out, o.buf[o.pos:]...) + o.buf = out +} + +// ioReadResult returns data as a fresh String, or fills the caller's output +// buffer String and returns it — the shared return convention of the length-taking +// read methods (sysread/pread). +func ioReadResult(data []byte, buf *object.String) object.Value { + if buf != nil { + if buf.Frozen { + raise("FrozenError", "can't modify frozen String: %s", buf.Inspect()) + } + buf.SetBytes(append([]byte(nil), data...)) + return buf + } + return object.NewStringBytes(append([]byte(nil), data...)) +} diff --git a/internal/vm/io_descriptors_test.go b/internal/vm/io_descriptors_test.go new file mode 100644 index 0000000..fb4e283 --- /dev/null +++ b/internal/vm/io_descriptors_test.go @@ -0,0 +1,158 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm_test + +import ( + "fmt" + "path/filepath" + "strings" + "testing" +) + +// TestIODescriptorsStringIO covers the byte/char read protocol, gets limit/chomp, +// #lineno, half-close and sysread/syswrite on StringIO — asserted against MRI +// Ruby 4.0.5. StringIO exercises the buffer-backed path shared with File and the +// standard streams. +func TestIODescriptorsStringIO(t *testing.T) { + cases := []struct{ src, want string }{ + // getbyte / readbyte. + {`require "stringio"; s = StringIO.new("AB"); p [s.getbyte, s.getbyte, s.getbyte]`, "[65, 66, nil]\n"}, + {`require "stringio"; s = StringIO.new("A"); s.getbyte; p s.readbyte rescue p :eof`, ":eof\n"}, + {`require "stringio"; s = StringIO.new("ab"); p s.readbyte`, "97\n"}, + // readchar. + {`require "stringio"; p StringIO.new("é").readchar`, "\"é\"\n"}, + // ungetbyte / ungetc. + {`require "stringio"; s = StringIO.new("AB"); s.getbyte; s.ungetbyte(65); p s.read`, "\"AB\"\n"}, + {`require "stringio"; s = StringIO.new("Z"); s.getbyte; s.ungetbyte("AB"); p s.read`, "\"AB\"\n"}, + {`require "stringio"; s = StringIO.new("AB"); c = s.getc; s.ungetc(c); p s.read`, "\"AB\"\n"}, + {`require "stringio"; s = StringIO.new("ab"); s.ungetc("Z"); p s.read`, "\"Zab\"\n"}, + {`require "stringio"; s = StringIO.new("ab"); s.getc; s.ungetc(65); p s.read`, "\"Ab\"\n"}, + {`require "stringio"; s = StringIO.new("ab"); p s.ungetbyte(nil)`, "nil\n"}, + {`require "stringio"; s = StringIO.new("ab"); p s.ungetc(nil)`, "nil\n"}, + {`require "stringio"; s = StringIO.new("ab"); s.ungetbyte(""); p s.read`, "\"ab\"\n"}, + // each_byte / each. + {`require "stringio"; s = StringIO.new("ab"); a = []; p s.each_byte { |b| a << b }.class; p a`, "StringIO\n[97, 98]\n"}, + {`require "stringio"; s = StringIO.new("x\ny\n"); a = []; p s.each { |l| a << l }.class; p a`, "StringIO\n[\"x\\n\", \"y\\n\"]\n"}, + // gets limit / chomp. + {`require "stringio"; p StringIO.new("hello\nworld").gets(3)`, "\"hel\"\n"}, + {`require "stringio"; p StringIO.new("hello\nworld").gets("\n", 3)`, "\"hel\"\n"}, + {`require "stringio"; p StringIO.new("hello\nx").gets(chomp: true)`, "\"hello\"\n"}, + {`require "stringio"; p StringIO.new("a\r\nb").gets(chomp: true)`, "\"a\"\n"}, + {`require "stringio"; p StringIO.new("a;b;").gets(";", chomp: true)`, "\"a\"\n"}, + {`require "stringio"; p StringIO.new("abc").gets(nil, 2)`, "\"ab\"\n"}, + {`require "stringio"; p StringIO.new("abc").gets(chomp: true)`, "\"abc\"\n"}, + // a single gets with a 0 limit yields "" (it does not loop). + {`require "stringio"; p StringIO.new("ab").gets(0)`, "\"\"\n"}, + {`require "stringio"; s = StringIO.new("a\nb\nc\n"); s.readlines; p s.lineno`, "3\n"}, + {`require "stringio"; s = StringIO.new("a\nb\n"); p s.readlines(chomp: true)`, "[\"a\", \"b\"]\n"}, + // lineno. + {`require "stringio"; s = StringIO.new("x\ny\n"); s.gets; s.gets; p s.lineno`, "2\n"}, + {`require "stringio"; s = StringIO.new("x\ny\n"); s.lineno = 5; p s.lineno`, "5\n"}, + {`require "stringio"; p StringIO.new("x").lineno`, "0\n"}, + // sysread / syswrite. + {`require "stringio"; p StringIO.new("hello").sysread(3)`, "\"hel\"\n"}, + {`require "stringio"; s = StringIO.new("hello"); b = "xxxx"; r = s.sysread(3, b); p [r, b, r.equal?(b)]`, "[\"hel\", \"hel\", true]\n"}, + {`require "stringio"; p StringIO.new("ab").sysread(0)`, "\"\"\n"}, + {`require "stringio"; s = StringIO.new; p s.syswrite("abc"); p s.string`, "3\n\"abc\"\n"}, + // half-close. + {`require "stringio"; s = StringIO.new("ab"); s.close_read; p [s.closed_read?, s.closed?]`, "[true, false]\n"}, + {`require "stringio"; s = StringIO.new("ab"); s.close_write; p [s.closed_write?, s.closed?]`, "[true, false]\n"}, + {`require "stringio"; s = StringIO.new("ab"); s.close_read; s.close_write; p s.closed?`, "true\n"}, + {`require "stringio"; s = StringIO.new("ab"); s.close_write; s.close_read; p s.closed?`, "true\n"}, + {`require "stringio"; s = StringIO.new("ab"); s.close_read; s.write("x"); p s.string`, "\"xb\"\n"}, + {`require "stringio"; p [StringIO.new("").closed_read?, StringIO.new("").closed_write?]`, "[false, false]\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q\n got=%q\nwant=%q", c.src, got, c.want) + } + } +} + +// TestIODescriptorsFile covers the IO-only positioned methods (pread/pwrite/ +// sysseek) and the descriptor accessors on a File, using a per-test temp dir so +// no real machine files are touched. +func TestIODescriptorsFile(t *testing.T) { + dir := t.TempDir() + path := filepath.ToSlash(filepath.Join(dir, "io.txt")) + q := func(s string) string { return `"` + s + `"` } + cases := []struct{ src, want string }{ + // pread does not disturb the cursor. + {fmt.Sprintf(`File.write(%s, "hello world"); File.open(%s, "r+") { |f| x = f.pread(5, 0); y = f.pread(5, 6); z = f.read(2); p [x, y, z] }`, q(path), q(path)), + "[\"hello\", \"world\", \"he\"]\n"}, + // pread into a supplied buffer. + {fmt.Sprintf(`File.write(%s, "abcdef"); File.open(%s) { |f| b = " "; r = f.pread(3, 0, b); p [r, b, r.equal?(b)] }`, q(path), q(path)), + "[\"abc\", \"abc\", true]\n"}, + // zero-length pread is "" without hitting EOF. + {fmt.Sprintf(`File.write(%s, "abc"); File.open(%s) { |f| p f.pread(0, 0) }`, q(path), q(path)), "\"\"\n"}, + // pwrite writes at an offset, returns the count, leaves the cursor alone. + {fmt.Sprintf(`File.write(%s, "0123456789"); File.open(%s, "r+") { |f| f.read(3); p f.pwrite("XX", 8); p f.read }; p File.read(%s)`, q(path), q(path), q(path)), + "2\n\"34567XX\"\n\"01234567XX\"\n"}, + // pwrite extending past the current end grows the file. + {fmt.Sprintf(`File.write(%s, "ab"); File.open(%s, "r+") { |f| f.pwrite("Z", 5) }; p File.read(%s).bytes`, q(path), q(path), q(path)), + "[97, 98, 0, 0, 0, 90]\n"}, + // sysseek returns the resulting absolute position for each whence. + {fmt.Sprintf(`File.write(%s, "hello"); File.open(%s) { |f| a = [f.sysseek(1)]; a << f.sysseek(2, 1); a << f.sysseek(-1, 2); p a }`, q(path), q(path)), + "[1, 3, 4]\n"}, + // descriptor accessors. + {fmt.Sprintf(`File.write(%s, "x"); File.open(%s) { |f| p [f.binmode?, f.autoclose?, (f.autoclose = false), f.fdatasync] }`, q(path), q(path)), + "[false, true, false, 0]\n"}, + // pread/pwrite are IO-only: StringIO does not answer them. + {`require "stringio"; p StringIO.new("ab").respond_to?(:pread)`, "false\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q\n got=%q\nwant=%q", c.src, got, c.want) + } + } +} + +// TestIODescriptorsErrors covers the error branches (EOFError, IOError for a +// closed / half-closed stream, ArgumentError / Errno::EINVAL / TypeError / +// FrozenError) at MRI-exact classes and messages. +func TestIODescriptorsErrors(t *testing.T) { + dir := t.TempDir() + path := filepath.ToSlash(filepath.Join(dir, "e.txt")) + q := func(s string) string { return `"` + s + `"` } + errs := []struct{ src, want string }{ + // EOFError at end of input. + {`require "stringio"; StringIO.new("").readbyte`, "end of file reached"}, + {`require "stringio"; StringIO.new("").readchar`, "end of file reached"}, + {`require "stringio"; StringIO.new("").sysread(1)`, "end of file reached"}, + // sysread argument validation. + {`require "stringio"; StringIO.new("ab").sysread(-1)`, "negative length"}, + // a 0 limit on a line-iterating read is rejected (it would never advance). + {`require "stringio"; StringIO.new("ab").readlines(0)`, "invalid limit: 0 for readlines"}, + {`require "stringio"; StringIO.new("ab").each_line(0) { |x| }`, "invalid limit: 0 for each_line"}, + {`require "stringio"; StringIO.new("ab").each(0) { |x| }`, "invalid limit: 0 for each_line"}, + // unget type errors. + {`require "stringio"; StringIO.new("ab").ungetbyte([])`, "into Integer"}, + {`require "stringio"; StringIO.new("ab").ungetc([])`, "into String"}, + // half-close then read / write. + {`require "stringio"; s = StringIO.new("ab"); s.close_read; s.read`, "not opened for reading"}, + {`require "stringio"; s = StringIO.new("ab"); s.close_read; s.gets`, "not opened for reading"}, + {`require "stringio"; s = StringIO.new("ab"); s.close_read; s.getbyte`, "not opened for reading"}, + {`require "stringio"; s = StringIO.new("ab"); s.close_write; s.write("x")`, "not opened for writing"}, + {`require "stringio"; s = StringIO.new("ab"); s.close_write; s.syswrite("x")`, "not opened for writing"}, + // fully closed stream. + {`require "stringio"; s = StringIO.new("ab"); s.close; s.getc`, "closed stream"}, + {`require "stringio"; s = StringIO.new("ab"); s.close; s.read`, "closed stream"}, + // pread / pwrite validation on a File. + {fmt.Sprintf(`File.write(%s, "abc"); File.open(%s) { |f| f.pread(-1, 0) }`, q(path), q(path)), "negative string size"}, + {fmt.Sprintf(`File.write(%s, "abc"); File.open(%s) { |f| f.pread(2, -1) }`, q(path), q(path)), "Invalid argument"}, + {fmt.Sprintf(`File.write(%s, "abc"); File.open(%s) { |f| f.pread(2, 9) }`, q(path), q(path)), "end of file reached"}, + {fmt.Sprintf(`File.write(%s, "abc"); File.open(%s, "r+") { |f| f.pwrite("x", -1) }`, q(path), q(path)), "Invalid argument"}, + // pread into a frozen buffer. + {fmt.Sprintf(`File.write(%s, "abc"); File.open(%s) { |f| f.pread(2, 0, "xx".freeze) }`, q(path), q(path)), "frozen String"}, + // class-method line iterators reject a 0 limit too. + {fmt.Sprintf(`File.write(%s, "a\nb\n"); IO.foreach(%s, 0) { |x| }`, q(path), q(path)), "invalid limit: 0 for foreach"}, + {fmt.Sprintf(`File.write(%s, "a\nb\n"); IO.readlines(%s, 0)`, q(path), q(path)), "invalid limit: 0 for readlines"}, + } + for _, c := range errs { + if err := runErr(t, c.src); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("src=%q got=%v want error containing %q", c.src, err, c.want) + } + } +}