From 0fed4e0d2c332635d97a93d53d76bfc62b08eacb Mon Sep 17 00:00:00 2001 From: ccamel Date: Tue, 23 Dec 2025 17:19:21 +0100 Subject: [PATCH 1/4] feat(engine): add read_write mode for bidirectional file I/O Implements ioModeReadWrite constant to support bidirectional file access. This non-standard ISO Prolog extension allows files to be opened for both reading and writing, enabling VFS device use cases. --- engine/atom.go | 1 + engine/builtin.go | 15 ++++++++++----- engine/stream.go | 21 +++++++++++++-------- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/engine/atom.go b/engine/atom.go index d27760c..cf810e3 100644 --- a/engine/atom.go +++ b/engine/atom.go @@ -149,6 +149,7 @@ var ( atomPrologFlag = NewAtom("prolog_flag") atomQuoted = NewAtom("quoted") atomRead = NewAtom("read") + atomReadWrite = NewAtom("read_write") atomReadOption = NewAtom("read_option") atomRem = NewAtom("rem") atomReposition = NewAtom("reposition") diff --git a/engine/builtin.go b/engine/builtin.go index fbe1af1..b16ec71 100644 --- a/engine/builtin.go +++ b/engine/builtin.go @@ -1193,7 +1193,7 @@ func SetInput(vm *VM, streamOrAlias Term, k Cont, env *Env) *Promise { return Error(err) } - if s.mode != ioModeRead { + if s.mode != ioModeRead && s.mode != ioModeReadWrite { return Error(permissionError(operationInput, permissionTypeStream, streamOrAlias, env)) } @@ -1208,7 +1208,7 @@ func SetOutput(vm *VM, streamOrAlias Term, k Cont, env *Env) *Promise { return Error(err) } - if s.mode != ioModeWrite && s.mode != ioModeAppend { + if s.mode != ioModeWrite && s.mode != ioModeAppend && s.mode != ioModeReadWrite { return Error(permissionError(operationOutput, permissionTypeStream, streamOrAlias, env)) } @@ -1254,9 +1254,10 @@ func Open(vm *VM, sourceSink, mode, stream, options Term, k Cont, env *Env) *Pro case Atom: var ok bool streamMode, ok = map[Atom]ioMode{ - atomRead: ioModeRead, - atomWrite: ioModeWrite, - atomAppend: ioModeAppend, + atomRead: ioModeRead, + atomWrite: ioModeWrite, + atomAppend: ioModeAppend, + atomReadWrite: ioModeReadWrite, }[m] if !ok { return Error(domainError(validDomainIOMode, m, env)) @@ -1275,6 +1276,10 @@ func Open(vm *VM, sourceSink, mode, stream, options Term, k Cont, env *Env) *Pro if s.mode == ioModeRead { s.source = f _ = s.initRead() + } else if s.mode == ioModeReadWrite { + s.source = f + s.sink = f + _ = s.initRead() } else { s.sink = f } diff --git a/engine/stream.go b/engine/stream.go index f4bb347..e16c7c0 100644 --- a/engine/stream.go +++ b/engine/stream.go @@ -268,7 +268,7 @@ func (s *Stream) Flush() error { Sync() error } - if s.mode != ioModeWrite && s.mode != ioModeAppend { + if s.mode != ioModeWrite && s.mode != ioModeAppend && s.mode != ioModeReadWrite { return errWrongIOMode } @@ -304,7 +304,7 @@ func (s *Stream) Close() error { } func (s *Stream) initRead() error { - if s.mode != ioModeRead { + if s.mode != ioModeRead && s.mode != ioModeReadWrite { return errWrongIOMode } @@ -325,7 +325,7 @@ func (s *Stream) initRead() error { } func (s *Stream) reset() { - if s.mode != ioModeRead { + if s.mode != ioModeRead && s.mode != ioModeReadWrite { return } @@ -379,6 +379,8 @@ func (s *Stream) properties() []Term { ps = append(ps, atomInput) case ioModeWrite, ioModeAppend: ps = append(ps, atomOutput) + case ioModeReadWrite: + ps = append(ps, atomInput, atomOutput) } if s.alias != "" { @@ -403,7 +405,7 @@ func (s *Stream) properties() []Term { } func (s *Stream) textWriter() (textWriter, error) { - if s.mode != ioModeWrite && s.mode != ioModeAppend { + if s.mode != ioModeWrite && s.mode != ioModeAppend && s.mode != ioModeReadWrite { return textWriter{}, errWrongIOMode } @@ -415,7 +417,7 @@ func (s *Stream) textWriter() (textWriter, error) { } func (s *Stream) binaryWriter() (binaryWriter, error) { - if s.mode != ioModeWrite && s.mode != ioModeAppend { + if s.mode != ioModeWrite && s.mode != ioModeAppend && s.mode != ioModeReadWrite { return binaryWriter{}, errWrongIOMode } @@ -463,13 +465,16 @@ const ( ioModeWrite = ioMode(os.O_CREATE | os.O_WRONLY) // ioModeAppend means you can append to the stream. ioModeAppend = ioMode(os.O_APPEND) | ioModeWrite + // ioModeReadWrite means you can both read from and write to an existing stream. + ioModeReadWrite = ioMode(os.O_RDWR) ) func (m ioMode) Term() Term { return [...]Term{ - ioModeRead: atomRead, - ioModeWrite: atomWrite, - ioModeAppend: atomAppend, + ioModeRead: atomRead, + ioModeWrite: atomWrite, + ioModeAppend: atomAppend, + ioModeReadWrite: atomReadWrite, }[m] } From 04f01f0478dc04a5c17164b22f9ca80aa2393f1e Mon Sep 17 00:00:00 2001 From: ccamel Date: Tue, 23 Dec 2025 17:25:06 +0100 Subject: [PATCH 2/4] test(engine): add comprehensive tests for stream read_write mode --- engine/builtin_test.go | 100 ++++++++++++++++++++++++++++++++++++ engine/stream_test.go | 113 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 2 deletions(-) diff --git a/engine/builtin_test.go b/engine/builtin_test.go index ec145cb..52293dd 100644 --- a/engine/builtin_test.go +++ b/engine/builtin_test.go @@ -3364,6 +3364,7 @@ func TestSetInput(t *testing.T) { foo, bar := NewAtom("foo"), NewAtom("bar") input := Stream{mode: ioModeRead, alias: foo} output := Stream{mode: ioModeAppend} + readWrite := Stream{mode: ioModeReadWrite, source: os.Stdin, sink: os.Stdout} stream := NewVariable() var vm VM @@ -3378,6 +3379,7 @@ func TestSetInput(t *testing.T) { }{ {title: "stream", streamOrAlias: &input, ok: true, input: &input}, {title: "alias", streamOrAlias: foo, ok: true, input: &input}, + {title: "read-write stream", streamOrAlias: &readWrite, ok: true, input: &readWrite}, // 8.11.3.3 Errors {title: "a", streamOrAlias: stream, err: InstantiationError(nil)}, @@ -3400,6 +3402,7 @@ func TestSetOutput(t *testing.T) { foo, bar := NewAtom("foo"), NewAtom("bar") input := Stream{mode: ioModeRead} output := Stream{mode: ioModeAppend, alias: foo} + readWrite := Stream{mode: ioModeReadWrite, source: os.Stdin, sink: os.Stdout} stream := NewVariable() var vm VM @@ -3414,6 +3417,7 @@ func TestSetOutput(t *testing.T) { }{ {title: "stream", streamOrAlias: &output, ok: true, output: &output}, {title: "alias", streamOrAlias: foo, ok: true, output: &output}, + {title: "read-write stream", streamOrAlias: &readWrite, ok: true, output: &readWrite}, // 8.11.4.3 Errors {title: "a", streamOrAlias: stream, err: InstantiationError(nil)}, @@ -3684,6 +3688,102 @@ func TestOpen(t *testing.T) { assert.True(t, ok) }) + t.Run("read_write", func(t *testing.T) { + f, err := os.CreateTemp("", "open_test_read_write") + assert.NoError(t, err) + defer func() { + assert.NoError(t, os.Remove(f.Name())) + }() + + _, err = fmt.Fprintf(f, "initial content\n") + assert.NoError(t, err) + assert.NoError(t, f.Close()) + + t.Run("can read and write", func(t *testing.T) { + v := NewVariable() + ok, err := Open(&vm, NewAtom(f.Name()), atomReadWrite, v, List(), func(env *Env) *Promise { + ref, ok := env.lookup(v) + assert.True(t, ok) + s, ok := ref.(*Stream) + assert.True(t, ok) + + // Should be able to read + assert.NoError(t, s.initRead()) + b := make([]byte, 7) + n, err := s.source.(io.Reader).Read(b) + assert.NoError(t, err) + assert.Equal(t, 7, n) + assert.Equal(t, "initial", string(b)) + + // Should be able to write + _, err = fmt.Fprintf(s.sink, " + new") + assert.NoError(t, err) + + return Bool(true) + }, nil).Force(context.Background()) + assert.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("stream has both input and output properties", func(t *testing.T) { + v := NewVariable() + ok, err := Open(&vm, NewAtom(f.Name()), atomReadWrite, v, List(), func(env *Env) *Promise { + ref, ok := env.lookup(v) + assert.True(t, ok) + s, ok := ref.(*Stream) + assert.True(t, ok) + + ps := s.properties() + hasInput := false + hasOutput := false + for _, p := range ps { + if atom, ok := p.(Atom); ok { + if atom == atomInput { + hasInput = true + } + if atom == atomOutput { + hasOutput = true + } + } + } + assert.True(t, hasInput) + assert.True(t, hasOutput) + + return Bool(true) + }, nil).Force(context.Background()) + assert.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("can be used with both SetInput and SetOutput", func(t *testing.T) { + v := NewVariable() + ok, err := Open(&vm, NewAtom(f.Name()), atomReadWrite, v, List(), func(env *Env) *Promise { + ref, ok := env.lookup(v) + assert.True(t, ok) + s, ok := ref.(*Stream) + assert.True(t, ok) + + // Should work as input stream + ok2, err2 := SetInput(&vm, s, func(_ *Env) *Promise { + return Bool(true) + }, nil).Force(context.Background()) + assert.NoError(t, err2) + assert.True(t, ok2) + + // Should work as output stream + ok3, err3 := SetOutput(&vm, s, func(_ *Env) *Promise { + return Bool(true) + }, nil).Force(context.Background()) + assert.NoError(t, err3) + assert.True(t, ok3) + + return Bool(true) + }, nil).Force(context.Background()) + assert.NoError(t, err) + assert.True(t, ok) + }) + }) + t.Run("append", func(t *testing.T) { f, err := os.CreateTemp("", "open_test_append") assert.NoError(t, err) diff --git a/engine/stream_test.go b/engine/stream_test.go index 0c03381..3a57899 100644 --- a/engine/stream_test.go +++ b/engine/stream_test.go @@ -335,6 +335,13 @@ func TestStream_ReadByte(t *testing.T) { s: &Stream{source: bytes.NewReader([]byte{1, 2, 3}), mode: ioModeAppend}, err: errWrongIOMode, }, + { + title: "read-write binary", + s: &Stream{source: bytes.NewReader([]byte{1, 2, 3}), mode: ioModeReadWrite, streamType: streamTypeBinary}, + b: 1, + pos: 1, + eos: endOfStreamNot, + }, } for _, tt := range tests { @@ -446,6 +453,14 @@ func TestStream_ReadRune(t *testing.T) { s: &Stream{source: bytes.NewReader([]byte("abc")), mode: ioModeAppend}, err: errWrongIOMode, }, + { + title: "read-write text", + s: &Stream{source: bytes.NewReader([]byte("abc")), mode: ioModeReadWrite, streamType: streamTypeText}, + r: 'a', + size: 1, + pos: 1, + eos: endOfStreamNot, + }, } for _, tt := range tests { @@ -547,7 +562,7 @@ func TestStream_Seek(t *testing.T) { func TestStream_WriteByte(t *testing.T) { var m mockWriter - m.On("Write", []byte("a")).Return(1, nil).Once() + m.On("Write", []byte("a")).Return(1, nil).Twice() defer m.AssertExpectations(t) tests := []struct { @@ -563,6 +578,12 @@ func TestStream_WriteByte(t *testing.T) { c: byte('a'), pos: 1, }, + { + title: "read-write", + s: &Stream{sink: &m, mode: ioModeReadWrite, streamType: streamTypeBinary}, + c: byte('a'), + pos: 1, + }, { title: "input", s: &Stream{mode: ioModeRead, streamType: streamTypeBinary}, @@ -591,7 +612,7 @@ func TestStream_WriteByte(t *testing.T) { func TestStream_WriteRune(t *testing.T) { var m mockWriter - m.On("Write", []byte("a")).Return(1, nil).Once() + m.On("Write", []byte("a")).Return(1, nil).Twice() defer m.AssertExpectations(t) tests := []struct { @@ -609,6 +630,13 @@ func TestStream_WriteRune(t *testing.T) { n: 1, pos: 1, }, + { + title: "read-write", + s: &Stream{sink: &m, mode: ioModeReadWrite, streamType: streamTypeText}, + r: 'a', + n: 1, + pos: 1, + }, { title: "input", s: &Stream{mode: ioModeRead, streamType: streamTypeText}, @@ -698,6 +726,87 @@ func newNonAbruptReader(b []byte) nonAbruptReader { } } +// TestStream_ReadWrite tests the read-write mode functionality +func TestStream_ReadWrite(t *testing.T) { + t.Run("read and write operations allowed", func(t *testing.T) { + f, err := os.CreateTemp("", "read_write_test") + assert.NoError(t, err) + defer func() { + assert.NoError(t, f.Close()) + assert.NoError(t, os.Remove(f.Name())) + }() + + // Write initial content + _, err = f.WriteString("test") + assert.NoError(t, err) + assert.NoError(t, f.Sync()) + _, err = f.Seek(0, 0) + assert.NoError(t, err) + + // Create read-write stream + s := &Stream{ + source: f, + sink: f, + mode: ioModeReadWrite, + streamType: streamTypeText, + reposition: true, + } + + // Should be able to initialize reading (no error) + assert.NoError(t, s.initRead()) + + // Should be able to get textWriter (no error) + tw, err := s.textWriter() + assert.NoError(t, err) + assert.NotNil(t, tw) + + // Should actually be able to read + r, _, err := s.ReadRune() + assert.NoError(t, err) + assert.Equal(t, 't', r) + + // Should actually be able to write + _, err = s.WriteRune('X') + assert.NoError(t, err) + }) + + t.Run("properties of read-write stream", func(t *testing.T) { + f, err := os.CreateTemp("", "read_write_properties") + assert.NoError(t, err) + defer func() { + assert.NoError(t, f.Close()) + assert.NoError(t, os.Remove(f.Name())) + }() + + s := &Stream{ + source: f, + sink: f, + mode: ioModeReadWrite, + streamType: streamTypeText, + reposition: true, + } + + ps := s.properties() + + // Should have both input and output properties + hasInput := false + hasOutput := false + for _, p := range ps { + if atom, ok := p.(Atom); ok { + if atom == atomInput { + hasInput = true + } + if atom == atomOutput { + hasOutput = true + } + } + } + + assert.True(t, hasInput, "read-write stream should have input property") + assert.True(t, hasOutput, "read-write stream should have output property") + }) +} + func (r nonAbruptReader) Read(b []byte) (int, error) { n, err := r.Reader.Read(b) if err == nil && r.Reader.Len() == 0 { From 9fa1795a78d1d261d9c36312a32aa894b599fc08 Mon Sep 17 00:00:00 2001 From: ccamel Date: Tue, 23 Dec 2025 17:29:32 +0100 Subject: [PATCH 3/4] test(engine): add test cases for SetStreamPosition with invalid position types --- engine/builtin_test.go | 43 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/engine/builtin_test.go b/engine/builtin_test.go index 52293dd..005585d 100644 --- a/engine/builtin_test.go +++ b/engine/builtin_test.go @@ -6498,6 +6498,34 @@ func TestSetStreamPosition(t *testing.T) { assert.False(t, ok) }) + t.Run("position is not an integer", func(t *testing.T) { + tests := []struct { + name string + position Term + }{ + {name: "atom", position: NewAtom("foo")}, + {name: "float", position: newFloatFromFloat64Must(3.14)}, + {name: "compound", position: NewAtom("foo").Apply(Integer(1), Integer(2))}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := os.Open("testdata/empty.txt") + assert.NoError(t, err) + defer func() { + assert.NoError(t, f.Close()) + }() + + s := &Stream{source: f, mode: ioModeRead, reposition: true} + + var vm VM + ok, err := SetStreamPosition(&vm, s, tt.position, Success, nil).Force(context.Background()) + assert.Equal(t, typeError(validTypeInteger, tt.position, nil), err) + assert.False(t, ok) + }) + } + }) + t.Run("streamOrAlias is neither a variable nor a stream term or alias", func(t *testing.T) { var vm VM ok, err := SetStreamPosition(&vm, Integer(2), Integer(0), Success, nil).Force(context.Background()) @@ -6526,6 +6554,21 @@ func TestSetStreamPosition(t *testing.T) { assert.Equal(t, permissionError(operationReposition, permissionTypeStream, s, env), err) assert.False(t, ok) }) + + t.Run("reposition false with direct stream", func(t *testing.T) { + f, err := os.Open("testdata/empty.txt") + assert.NoError(t, err) + defer func() { + assert.NoError(t, f.Close()) + }() + + s := &Stream{source: f, mode: ioModeRead, reposition: false} + + var vm VM + ok, err := SetStreamPosition(&vm, s, Integer(0), Success, nil).Force(context.Background()) + assert.Equal(t, permissionError(operationReposition, permissionTypeStream, s, nil), err) + assert.False(t, ok) + }) } func TestCharConversion(t *testing.T) { From a2f6e9672bd6796a5bc28c88210fe3d60725de67 Mon Sep 17 00:00:00 2001 From: ccamel Date: Tue, 23 Dec 2025 17:40:27 +0100 Subject: [PATCH 4/4] docs(README): document read_write mode deviation --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b11ae6a..4b9e803 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The following customizations have been made to adapt the original `ichiban/prolo - Removed support for trigonometric functions (`sin`, `cos`, `tan`, `asin`, `acos`, `atan`). - Introduced VM hooks for enhanced Prolog execution control. - Added support for the `Dict` term. +- Added support for `read_write` mode for bidirectional file I/O, enabling half-duplex transactional devices in the host's VFS. - `halt/0` and `halt/1` are forbidden and will throw an error. ## License