This is an automated email from the ASF dual-hosted git repository.

hgruszecki pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 6180d88e6 feat(go): implement binary reader/writer (#2986)
6180d88e6 is described below

commit 6180d88e62f3fd5c9738962ce6a882e57da6e69b
Author: Chengxi Luo <[email protected]>
AuthorDate: Mon Mar 23 07:35:28 2026 -0400

    feat(go): implement binary reader/writer (#2986)
---
 foreign/go/internal/codec/reader.go         | 217 +++++++++++++++++
 foreign/go/internal/codec/reader_test.go    | 358 ++++++++++++++++++++++++++++
 foreign/go/internal/codec/roundtrip_test.go | 105 ++++++++
 foreign/go/internal/codec/writer.go         | 151 ++++++++++++
 foreign/go/internal/codec/writer_test.go    | 128 ++++++++++
 5 files changed, 959 insertions(+)

diff --git a/foreign/go/internal/codec/reader.go 
b/foreign/go/internal/codec/reader.go
new file mode 100644
index 000000000..c6b7cd8e4
--- /dev/null
+++ b/foreign/go/internal/codec/reader.go
@@ -0,0 +1,217 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package codec
+
+import (
+       "encoding"
+       "encoding/binary"
+       "fmt"
+       "math"
+       "runtime"
+)
+
+// Reader is a cursor over a byte slice. The first out-of-bounds read sets err;
+// all subsequent reads are no-ops. Call Err() once after all reads to check.
+type Reader struct {
+       p   []byte
+       pos int
+       err error
+}
+
+func NewReader(p []byte) *Reader {
+       return &Reader{p: p}
+}
+
+// overrun sets r.err to a descriptive error message including the caller's
+// file and line number.
+func (r *Reader) overrun(need int) {
+       _, file, line, _ := runtime.Caller(2)
+       r.err = fmt.Errorf(
+               "reader: need %d bytes at offset %d, only %d remaining (%s:%d)",
+               need, r.pos, len(r.p)-r.pos, file, line)
+}
+
+func (r *Reader) U8() uint8 {
+       if r.err != nil {
+               return 0
+       }
+       if r.pos+1 > len(r.p) {
+               r.overrun(1)
+               return 0
+       }
+       v := r.p[r.pos]
+       r.pos++
+       return v
+}
+
+func (r *Reader) U16() uint16 {
+       if r.err != nil {
+               return 0
+       }
+       if r.pos+2 > len(r.p) {
+               r.overrun(2)
+               return 0
+       }
+       v := binary.LittleEndian.Uint16(r.p[r.pos : r.pos+2])
+       r.pos += 2
+       return v
+}
+
+func (r *Reader) U32() uint32 {
+       if r.err != nil {
+               return 0
+       }
+       if r.pos+4 > len(r.p) {
+               r.overrun(4)
+               return 0
+       }
+       v := binary.LittleEndian.Uint32(r.p[r.pos : r.pos+4])
+       r.pos += 4
+       return v
+}
+
+func (r *Reader) U64() uint64 {
+       if r.err != nil {
+               return 0
+       }
+       if r.pos+8 > len(r.p) {
+               r.overrun(8)
+               return 0
+       }
+       v := binary.LittleEndian.Uint64(r.p[r.pos : r.pos+8])
+       r.pos += 8
+       return v
+}
+
+func (r *Reader) F32() float32 {
+       if r.err != nil {
+               return 0
+       }
+       if r.pos+4 > len(r.p) {
+               r.overrun(4)
+               return 0
+       }
+       v := math.Float32frombits(binary.LittleEndian.Uint32(r.p[r.pos : 
r.pos+4]))
+       r.pos += 4
+       return v
+}
+
+// str reads exactly n bytes and returns a copy as a string.
+func (r *Reader) str(n int) string {
+       v := string(r.p[r.pos : r.pos+n])
+       r.pos += n
+       return v
+}
+
+// raw reads exactly n bytes and returns a copy.
+func (r *Reader) raw(n int) []byte {
+       v := make([]byte, n)
+       copy(v, r.p[r.pos:r.pos+n])
+       r.pos += n
+       return v
+}
+
+// Raw reads exactly n bytes and returns a copy.
+func (r *Reader) Raw(n int) []byte {
+       if r.err != nil {
+               return nil
+       }
+       if r.pos+n > len(r.p) {
+               r.overrun(n)
+               return nil
+       }
+       return r.raw(n)
+}
+
+// Str reads exactly n bytes and returns a copy as a string. Use U8LenStr or
+// U32LenStr instead if the data is length-prefixed:
+//
+//     [length: 1 byte][data: N bytes]   → U8LenStr
+//     [length: 4 bytes][data: N bytes]  → U32LenStr
+func (r *Reader) Str(n int) string {
+       if r.err != nil {
+               return ""
+       }
+       if r.pos+n > len(r.p) {
+               r.overrun(n)
+               return ""
+       }
+       return r.str(n)
+}
+
+// U32LenStr reads a length-prefixed string where the length is a 4-byte
+// little-endian unsigned integer.
+func (r *Reader) U32LenStr() string {
+       if r.err != nil {
+               return ""
+       }
+       if r.pos+4 > len(r.p) {
+               r.overrun(4)
+               return ""
+       }
+       n := int(binary.LittleEndian.Uint32(r.p[r.pos : r.pos+4]))
+       r.pos += 4
+       if r.pos+n > len(r.p) {
+               r.overrun(n)
+               return ""
+       }
+       return r.str(n)
+}
+
+// U8LenStr reads a length-prefixed string where the length is a single byte.
+func (r *Reader) U8LenStr() string {
+       if r.err != nil {
+               return ""
+       }
+       if r.pos+1 > len(r.p) {
+               r.overrun(1)
+               return ""
+       }
+       n := int(r.p[r.pos])
+       r.pos++
+       if r.pos+n > len(r.p) {
+               r.overrun(n)
+               return ""
+       }
+       return r.str(n)
+}
+
+// Obj reads n bytes and decodes them into v.
+func (r *Reader) Obj(n int, v encoding.BinaryUnmarshaler) {
+       if r.err != nil {
+               return
+       }
+       if r.pos+n > len(r.p) {
+               r.overrun(n)
+               return
+       }
+       err := v.UnmarshalBinary(r.raw(n))
+       if err != nil {
+               _, file, line, _ := runtime.Caller(1)
+               r.err = fmt.Errorf("%w (%s:%d)", err, file, line)
+               return
+       }
+}
+
+// Remaining returns the number of unread bytes.
+func (r *Reader) Remaining() int {
+       return len(r.p) - r.pos
+}
+
+// Err returns the first error encountered during reading, or nil.
+func (r *Reader) Err() error { return r.err }
diff --git a/foreign/go/internal/codec/reader_test.go 
b/foreign/go/internal/codec/reader_test.go
new file mode 100644
index 000000000..3f5085484
--- /dev/null
+++ b/foreign/go/internal/codec/reader_test.go
@@ -0,0 +1,358 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package codec
+
+import (
+       "bytes"
+       "encoding/binary"
+       "errors"
+       "fmt"
+       "math"
+       "runtime"
+       "strings"
+       "testing"
+)
+
+// --- byte-construction helpers ---
+
+func u16le(v uint16) []byte {
+       b := make([]byte, 2)
+       binary.LittleEndian.PutUint16(b, v)
+       return b
+}
+
+func u32le(v uint32) []byte {
+       b := make([]byte, 4)
+       binary.LittleEndian.PutUint32(b, v)
+       return b
+}
+
+func u64le(v uint64) []byte {
+       b := make([]byte, 8)
+       binary.LittleEndian.PutUint64(b, v)
+       return b
+}
+
+func cat(slices ...[]byte) []byte {
+       var out []byte
+       for _, s := range slices {
+               out = append(out, s...)
+       }
+       return out
+}
+
+// testPoint is a simple type that implements BinaryMarshaler/BinaryUnmarshaler
+// as [x, y] two-byte encoding.
+type testPoint struct {
+       x, y uint8
+}
+
+func (p *testPoint) MarshalBinary() ([]byte, error) {
+       return []byte{p.x, p.y}, nil
+}
+
+func (p *testPoint) UnmarshalBinary(b []byte) error {
+       if len(b) < 2 {
+               return fmt.Errorf("testPoint: need 2 bytes, got %d", len(b))
+       }
+       p.x = b[0]
+       p.y = b[1]
+       return nil
+}
+
+// errMarshaler always returns an error from MarshalBinary.
+type errMarshaler struct{}
+
+func (errMarshaler) MarshalBinary() ([]byte, error) {
+       return nil, errors.New("marshal error")
+}
+
+// errUnmarshaler always returns an error from UnmarshalBinary.
+type errUnmarshaler struct{}
+
+func (e *errUnmarshaler) UnmarshalBinary(_ []byte) error {
+       return errors.New("unmarshal error")
+}
+
+// TestReader_reads exercises every read method in sequence.
+func TestReader_reads(t *testing.T) {
+       const wantU8 uint8 = math.MaxUint8
+       const wantU16 uint16 = math.MaxUint16
+       const wantU32 uint32 = math.MaxUint32
+       const wantU64 uint64 = math.MaxUint64
+       const wantF32 float32 = math.Pi
+       const wantRem = 1
+
+       wantStr := "str"
+       wantU32LenStr := "uint32"
+       wantU8LenStr := "uint8"
+       wantRaw := []byte{0xDE, 0xAD}
+       wantObj := testPoint{1, 2}
+
+       payload := cat(
+               []byte{wantU8},                                           // U8
+               u16le(wantU16),                                           // U16
+               u32le(wantU32),                                           // U32
+               u64le(wantU64),                                           // U64
+               u32le(math.Float32bits(wantF32)),                         // F32
+               []byte(wantStr),                                          // 
Str(len(wantStr))
+               u32le(uint32(len(wantU32LenStr))), []byte(wantU32LenStr), // 
U32LenStr
+               []byte{uint8(len(wantU8LenStr))}, []byte(wantU8LenStr), // 
U8LenStr
+               wantRaw,      // Raw(len(wantRaw))
+               []byte{1, 2}, // Obj(testPoint{1, 2})
+               []byte{0xFF}, // wantRem trailing bytes for Remaining()
+       )
+
+       r := NewReader(payload)
+       u8 := r.U8()
+       u16 := r.U16()
+       u32 := r.U32()
+       u64 := r.U64()
+       f32 := r.F32()
+       str := r.Str(len(wantStr))
+       u32LenStr := r.U32LenStr()
+       u8LenStr := r.U8LenStr()
+       raw := r.Raw(len(wantRaw))
+       var obj testPoint
+       r.Obj(2, &obj)
+       rem := r.Remaining()
+       if err := r.Err(); err != nil {
+               t.Fatalf("unexpected error: %v", err)
+       }
+
+       if u8 != wantU8 {
+               t.Errorf("U8: got %#x, want %#x", u8, wantU8)
+       }
+       if u16 != wantU16 {
+               t.Errorf("U16: got %#x, want %#x", u16, wantU16)
+       }
+       if u32 != wantU32 {
+               t.Errorf("U32: got %#x, want %#x", u32, wantU32)
+       }
+       if u64 != wantU64 {
+               t.Errorf("U64: got %#x, want %#x", u64, wantU64)
+       }
+       if f32 != wantF32 {
+               t.Errorf("F32: got %v, want %v", f32, wantF32)
+       }
+       if str != wantStr {
+               t.Errorf("Str: got %q, want %q", str, wantStr)
+       }
+       if u32LenStr != wantU32LenStr {
+               t.Errorf("U32LenStr: got %q, want %q", u32LenStr, wantU32LenStr)
+       }
+       if u8LenStr != wantU8LenStr {
+               t.Errorf("U8LenStr: got %q, want %q", u8LenStr, wantU8LenStr)
+       }
+       if !bytes.Equal(raw, wantRaw) {
+               t.Errorf("Raw: got %v, want %v", raw, wantRaw)
+       }
+       if obj != wantObj {
+               t.Errorf("Obj: got %v, want %v", obj, wantObj)
+       }
+       if rem != wantRem {
+               t.Errorf("Remaining: got %d, want %d", rem, wantRem)
+       }
+}
+
+// TestReader_truncation verifies that every read method returns a descriptive 
error
+// when the buffer is too short, including mid-sequence truncation.
+func TestReader_truncation(t *testing.T) {
+       cases := []struct {
+               name    string
+               payload []byte
+               read    func(*Reader)
+       }{
+               {"U8", []byte{}, func(r *Reader) { r.U8() }},
+               {"U16", []byte{0x01}, func(r *Reader) { r.U16() }},             
      // 1 byte, need 2
+               {"U32", []byte{0x01, 0x02, 0x03}, func(r *Reader) { r.U32() }}, 
      // 3 bytes, need 4
+               {"U64", []byte{0x01, 0x02, 0x03, 0x04}, func(r *Reader) { 
r.U64() }}, // 4 bytes, need 8
+               {"F32", []byte{0x01, 0x02, 0x03}, func(r *Reader) { r.F32() }}, 
      // 3 bytes, need 4
+               {"Str", []byte("hi"), func(r *Reader) { r.Str(5) }},            
      // claims 5, has 2
+               {"Raw", []byte("hi"), func(r *Reader) { r.Raw(5) }},            
      // claims 5, has 2
+               {"Obj", []byte{1}, func(r *Reader) {
+                       var p testPoint
+                       r.Obj(2, &p)
+               }},
+               {"U32LenStr/short-len-prefix", []byte{0x05, 0x00}, func(r 
*Reader) { r.U32LenStr() }},        // len prefix needs 4 bytes, got 2
+               {"U32LenStr/short-body", cat(u32le(10), []byte("short")), 
func(r *Reader) { r.U32LenStr() }}, // claims 10, has 5
+               {"U8LenStr/short-len-prefix", []byte{}, func(r *Reader) { 
r.U8LenStr() }},                    // len prefix needs 1 byte, got 0
+               {"U8LenStr/short-body", cat([]byte{10}, []byte("short")), 
func(r *Reader) { r.U8LenStr() }},  // claims 10, has 5
+               {"mid-sequence", cat(u32le(1), []byte{0xFF}), func(r *Reader) { 
r.U32(); r.U32() }},
+       }
+
+       for _, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       r := NewReader(tc.payload)
+                       tc.read(r)
+                       err := r.Err()
+                       if err == nil || !strings.HasPrefix(err.Error(), 
"reader: need ") {
+                               t.Fatalf("got %v, want overrun error", err)
+                       }
+               })
+       }
+}
+
+// TestReader_errSentinel verifies that once an error is set, all subsequent
+// read methods are no-ops and return zero values without overwriting the 
error.
+func TestReader_errSentinel(t *testing.T) {
+       r := NewReader([]byte{})
+       r.U8() // triggers overrun, sets r.err
+       if r.Err() == nil {
+               t.Fatal("expected error after overrun, got nil")
+       }
+       err := r.Err()
+
+       if v := r.U8(); v != 0 {
+               t.Errorf("U8: got %v, want 0", v)
+       }
+       if v := r.U16(); v != 0 {
+               t.Errorf("U16: got %v, want 0", v)
+       }
+       if v := r.U32(); v != 0 {
+               t.Errorf("U32: got %v, want 0", v)
+       }
+       if v := r.U64(); v != 0 {
+               t.Errorf("U64: got %v, want 0", v)
+       }
+       if v := r.F32(); v != 0 {
+               t.Errorf("F32: got %v, want 0", v)
+       }
+       if v := r.Str(1); v != "" {
+               t.Errorf("Str: got %q, want empty", v)
+       }
+       if v := r.Raw(1); v != nil {
+               t.Errorf("Raw: got %v, want nil", v)
+       }
+       if v := r.U32LenStr(); v != "" {
+               t.Errorf("U32LenStr: got %q, want empty", v)
+       }
+       if v := r.U8LenStr(); v != "" {
+               t.Errorf("U8LenStr: got %q, want empty", v)
+       }
+       var p testPoint
+       r.Obj(2, &p)
+       if p != (testPoint{}) {
+               t.Errorf("Obj: got %v, want zero value", p)
+       }
+       if r.Err() != err {
+               t.Errorf("error was overwritten: got %v, want %v", r.Err(), err)
+       }
+}
+
+// TestReader_Obj_unmarshalError verifies that Reader.Obj propagates an error
+// returned by UnmarshalBinary.
+func TestReader_Obj_unmarshalError(t *testing.T) {
+       r := NewReader([]byte{1, 2})
+       _, file, line, _ := runtime.Caller(0)
+       r.Obj(2, &errUnmarshaler{})
+       checkLoc(t, r.Err(), file, line+1)
+}
+
+// TestReader_overrun_error_location verifies that the error message contains
+// the file and line of the call site that triggered the overrun, for every
+// public read method.
+func TestReader_overrun_error_location(t *testing.T) {
+       cases := []struct {
+               name    string
+               payload []byte
+               fn      func(r *Reader) (wantFile string, wantLine int)
+       }{
+               {"U8", []byte{}, func(r *Reader) (string, int) {
+                       _, file, line, _ := runtime.Caller(0)
+                       r.U8()
+                       return file, line + 1
+               }},
+               {"U16", []byte{}, func(r *Reader) (string, int) {
+                       _, file, line, _ := runtime.Caller(0)
+                       r.U16()
+                       return file, line + 1
+               }},
+               {"U32", []byte{}, func(r *Reader) (string, int) {
+                       _, file, line, _ := runtime.Caller(0)
+                       r.U32()
+                       return file, line + 1
+               }},
+               {"U64", []byte{}, func(r *Reader) (string, int) {
+                       _, file, line, _ := runtime.Caller(0)
+                       r.U64()
+                       return file, line + 1
+               }},
+               {"F32", []byte{}, func(r *Reader) (string, int) {
+                       _, file, line, _ := runtime.Caller(0)
+                       r.F32()
+                       return file, line + 1
+               }},
+               {"Str", []byte{}, func(r *Reader) (string, int) {
+                       _, file, line, _ := runtime.Caller(0)
+                       r.Str(1)
+                       return file, line + 1
+               }},
+               {"Raw", []byte{}, func(r *Reader) (string, int) {
+                       _, file, line, _ := runtime.Caller(0)
+                       r.Raw(1)
+                       return file, line + 1
+               }},
+               {"Obj", []byte{1}, func(r *Reader) (string, int) {
+                       var p testPoint
+                       _, file, line, _ := runtime.Caller(0)
+                       r.Obj(2, &p)
+                       return file, line + 1
+               }},
+               {"U32LenStr/prefix", []byte{}, func(r *Reader) (string, int) {
+                       _, file, line, _ := runtime.Caller(0)
+                       r.U32LenStr()
+                       return file, line + 1
+               }},
+               {"U32LenStr/body", cat(u32le(100), []byte("short")), func(r 
*Reader) (string, int) {
+                       _, file, line, _ := runtime.Caller(0)
+                       r.U32LenStr()
+                       return file, line + 1
+               }},
+               {"U8LenStr/prefix", []byte{}, func(r *Reader) (string, int) {
+                       _, file, line, _ := runtime.Caller(0)
+                       r.U8LenStr()
+                       return file, line + 1
+               }},
+               {"U8LenStr/body", cat([]byte{100}, []byte("short")), func(r 
*Reader) (string, int) {
+                       _, file, line, _ := runtime.Caller(0)
+                       r.U8LenStr()
+                       return file, line + 1
+               }},
+       }
+
+       for _, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       r := NewReader(tc.payload)
+                       wantFile, wantLine := tc.fn(r)
+                       checkLoc(t, r.Err(), wantFile, wantLine)
+               })
+       }
+}
+
+func checkLoc(t *testing.T, err error, wantFile string, wantLine int) {
+       t.Helper()
+       if err == nil {
+               t.Error("expected error, got nil")
+               return
+       }
+       wantLoc := fmt.Sprintf("%s:%d", wantFile, wantLine)
+       if !strings.Contains(err.Error(), wantLoc) {
+               t.Errorf("error %q does not contain location %q", err.Error(), 
wantLoc)
+       }
+}
diff --git a/foreign/go/internal/codec/roundtrip_test.go 
b/foreign/go/internal/codec/roundtrip_test.go
new file mode 100644
index 000000000..ea0c52b48
--- /dev/null
+++ b/foreign/go/internal/codec/roundtrip_test.go
@@ -0,0 +1,105 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package codec
+
+import (
+       "bytes"
+       "math"
+       "testing"
+)
+
+// Test_RoundTrip writes every method in sequence then reads back with
+// Reader and verifies the values survive the round-trip.
+func Test_RoundTrip(t *testing.T) {
+       const wantU8 uint8 = math.MaxUint8
+       const wantU16 uint16 = math.MaxUint16
+       const wantU32 uint32 = math.MaxUint32
+       const wantU64 uint64 = math.MaxUint64
+       const wantF32 float32 = math.Pi
+
+       wantStr := "str"
+       wantU32LenStr := "uint32"
+       wantU8LenStr := "uint8"
+       wantRaw := []byte{0x01, 0x02, 0x03}
+       wantObj := testPoint{x: 1, y: 2}
+
+       w := NewWriter()
+       w.U8(wantU8)
+       w.U16(wantU16)
+       w.U32(wantU32)
+       w.U64(wantU64)
+       w.F32(wantF32)
+       w.Str(wantStr)
+       w.U8LenStr(wantU8LenStr)
+       w.U32LenStr(wantU32LenStr)
+       w.Raw(wantRaw)
+       w.Obj(&wantObj)
+       if err := w.Err(); err != nil {
+               t.Fatalf("unexpected write error: %v", err)
+       }
+
+       r := NewReader(w.Bytes())
+       u8 := r.U8()
+       u16 := r.U16()
+       u32 := r.U32()
+       u64 := r.U64()
+       f32 := r.F32()
+       str := r.Str(len(wantStr))
+       u8LenStr := r.U8LenStr()
+       u32LenStr := r.U32LenStr()
+       raw := r.Raw(len(wantRaw))
+       var obj testPoint
+       r.Obj(2, &obj)
+       if r.Remaining() != 0 {
+               t.Errorf("unexpected trailing bytes: %d", r.Remaining())
+       }
+       if err := r.Err(); err != nil {
+               t.Fatalf("unexpected read error: %v", err)
+       }
+
+       if u8 != wantU8 {
+               t.Errorf("U8: got %#x, want %#x", u8, wantU8)
+       }
+       if u16 != wantU16 {
+               t.Errorf("U16: got %#x, want %#x", u16, wantU16)
+       }
+       if u32 != wantU32 {
+               t.Errorf("U32: got %#x, want %#x", u32, wantU32)
+       }
+       if u64 != wantU64 {
+               t.Errorf("U64: got %#x, want %#x", u64, wantU64)
+       }
+       if f32 != wantF32 {
+               t.Errorf("F32: got %v, want %v", f32, wantF32)
+       }
+       if str != wantStr {
+               t.Errorf("Str: got %q, want %q", str, wantStr)
+       }
+       if u8LenStr != wantU8LenStr {
+               t.Errorf("U8LenStr: got %q, want %q", u8LenStr, wantU8LenStr)
+       }
+       if u32LenStr != wantU32LenStr {
+               t.Errorf("U32LenStr: got %q, want %q", u32LenStr, wantU32LenStr)
+       }
+       if !bytes.Equal(raw, wantRaw) {
+               t.Errorf("Raw: got %v, want %v", raw, wantRaw)
+       }
+       if obj != wantObj {
+               t.Errorf("Obj: got %+v, want %+v", obj, wantObj)
+       }
+}
diff --git a/foreign/go/internal/codec/writer.go 
b/foreign/go/internal/codec/writer.go
new file mode 100644
index 000000000..353aeac31
--- /dev/null
+++ b/foreign/go/internal/codec/writer.go
@@ -0,0 +1,151 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package codec
+
+import (
+       "encoding"
+       "encoding/binary"
+       "fmt"
+       "math"
+       "runtime"
+)
+
+// Writer appends encoded values to a growing byte slice. The first encoding
+// error sets err; all subsequent writes are no-ops. Call Err() once after all
+// writes to check.
+type Writer struct {
+       p   []byte
+       err error
+}
+
+// NewWriter returns a new Writer with an empty internal buffer.
+// Use NewWriterCap instead if the final size is known.
+func NewWriter() *Writer {
+       return &Writer{}
+}
+
+// NewWriterCap returns a Writer with its internal buffer pre-allocated to n
+// bytes. Use this when the final size is known in advance to avoid
+// reallocations.
+func NewWriterCap(n int) *Writer {
+       return &Writer{p: make([]byte, 0, n)}
+}
+
+func (w *Writer) U8(v uint8) {
+       if w.err != nil {
+               return
+       }
+       w.p = append(w.p, v)
+}
+
+func (w *Writer) U16(v uint16) {
+       if w.err != nil {
+               return
+       }
+       w.p = binary.LittleEndian.AppendUint16(w.p, v)
+}
+
+func (w *Writer) U32(v uint32) {
+       if w.err != nil {
+               return
+       }
+       w.p = binary.LittleEndian.AppendUint32(w.p, v)
+}
+
+func (w *Writer) U64(v uint64) {
+       if w.err != nil {
+               return
+       }
+       w.p = binary.LittleEndian.AppendUint64(w.p, v)
+}
+
+func (w *Writer) F32(v float32) {
+       if w.err != nil {
+               return
+       }
+       w.p = binary.LittleEndian.AppendUint32(w.p, math.Float32bits(v))
+}
+
+// Str writes a string with no length prefix. Use U8LenStr or U32LenStr
+// instead if the reader expects a length prefix.
+func (w *Writer) Str(v string) {
+       if w.err != nil {
+               return
+       }
+       w.str(v)
+}
+
+// U32LenStr writes a length-prefixed string where the length is a 4-byte
+// little-endian unsigned integer.
+func (w *Writer) U32LenStr(v string) {
+       if w.err != nil {
+               return
+       }
+       w.p = binary.LittleEndian.AppendUint32(w.p, uint32(len(v)))
+       w.str(v)
+}
+
+// U8LenStr writes a length-prefixed string where the length is a single byte.
+// Sets w.err if len(v) exceeds 255.
+func (w *Writer) U8LenStr(v string) {
+       if w.err != nil {
+               return
+       }
+       if len(v) > math.MaxUint8 {
+               _, file, line, _ := runtime.Caller(1)
+               w.err = fmt.Errorf("string length %d exceeds 255 (%s:%d)", 
len(v), file, line)
+               return
+       }
+       w.p = append(w.p, uint8(len(v)))
+       w.str(v)
+}
+
+func (w *Writer) str(v string) {
+       w.p = append(w.p, v...)
+}
+
+// Raw appends a raw byte slice with no length prefix.
+func (w *Writer) Raw(v []byte) {
+       if w.err != nil {
+               return
+       }
+       w.p = append(w.p, v...)
+}
+
+// Obj encodes v and appends the result to the buffer.
+func (w *Writer) Obj(v encoding.BinaryMarshaler) {
+       if w.err != nil {
+               return
+       }
+       b, err := v.MarshalBinary()
+       if err != nil {
+               _, file, line, _ := runtime.Caller(1)
+               w.err = fmt.Errorf("%w (%s:%d)", err, file, line)
+               return
+       }
+       w.p = append(w.p, b...)
+}
+
+// Bytes returns the accumulated buffer directly. The slice is valid only
+// until the next write. The caller must not mutate the returned slice.
+func (w *Writer) Bytes() []byte {
+       return w.p
+}
+
+// Err returns the first error encountered during writing, or nil.
+func (w *Writer) Err() error { return w.err }
diff --git a/foreign/go/internal/codec/writer_test.go 
b/foreign/go/internal/codec/writer_test.go
new file mode 100644
index 000000000..035428164
--- /dev/null
+++ b/foreign/go/internal/codec/writer_test.go
@@ -0,0 +1,128 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package codec
+
+import (
+       "bytes"
+       "math"
+       "regexp"
+       "runtime"
+       "strings"
+       "testing"
+)
+
+// TestWriter_writes verifies that every write method encodes the expected 
bytes.
+func TestWriter_writes(t *testing.T) {
+       cases := []struct {
+               name  string
+               write func(*Writer)
+               want  []byte
+       }{
+               {"U8", func(w *Writer) { w.U8(0xAB) }, []byte{0xAB}},
+               {"U16", func(w *Writer) { w.U16(0x0102) }, []byte{0x02, 0x01}},
+               {"U32", func(w *Writer) { w.U32(0x01020304) }, []byte{0x04, 
0x03, 0x02, 0x01}},
+               {"U64", func(w *Writer) { w.U64(0x0102030405060708) }, 
[]byte{0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01}},
+               {"F32", func(w *Writer) { w.F32(1.0) }, []byte{0x00, 0x00, 
0x80, 0x3F}},
+               {"Str", func(w *Writer) { w.Str("ab") }, []byte{'a', 'b'}},
+               {"U32LenStr", func(w *Writer) { w.U32LenStr("ab") }, 
[]byte{0x02, 0x00, 0x00, 0x00, 'a', 'b'}},
+               {"U8LenStr", func(w *Writer) { w.U8LenStr("ab") }, []byte{0x02, 
'a', 'b'}},
+               {"Raw", func(w *Writer) { w.Raw([]byte{0xDE, 0xAD}) }, 
[]byte{0xDE, 0xAD}},
+               {"Obj", func(w *Writer) { w.Obj(&testPoint{1, 2}) }, 
[]byte{0x01, 0x02}},
+       }
+       for _, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       w := NewWriter()
+                       tc.write(w)
+                       if err := w.Err(); err != nil {
+                               t.Fatalf("unexpected error: %v", err)
+                       }
+                       if got := w.Bytes(); !bytes.Equal(got, tc.want) {
+                               t.Errorf("got %v, want %v", got, tc.want)
+                       }
+               })
+       }
+}
+
+// TestWriter_errSentinel verifies that once an error is set, all subsequent
+// write methods are no-ops and the buffer remains empty.
+func TestWriter_errSentinel(t *testing.T) {
+       w := NewWriter()
+       w.Obj(errMarshaler{})
+       if w.Err() == nil {
+               t.Fatal("expected error after errMarshaler, got nil")
+       }
+       err := w.Err()
+       w.U8(1)
+       w.U16(2)
+       w.U32(3)
+       w.U64(4)
+       w.F32(5)
+       w.Str("x")
+       w.U32LenStr("y")
+       w.U8LenStr("z")
+       w.Raw([]byte{0xFF})
+       w.Obj(&testPoint{1, 2})
+       if got := w.Bytes(); len(got) != 0 {
+               t.Errorf("expected empty buffer after error, got %d bytes: %v", 
len(got), got)
+       }
+       if w.Err() != err {
+               t.Errorf("error was overwritten: got %v, want %v", w.Err(), err)
+       }
+}
+
+// TestWriter_U8LenStr_overflow verifies that U8LenStr sets w.err for strings
+// longer than 255 bytes.
+func TestWriter_U8LenStr_overflow(t *testing.T) {
+       w := NewWriter()
+       _, file, line, _ := runtime.Caller(0)
+       w.U8LenStr(strings.Repeat("a", math.MaxUint8+1))
+       err := w.Err()
+       if err == nil {
+               t.Fatal("expected error for string > 255 bytes, got nil")
+       }
+       re := regexp.MustCompile(`^string length (\d+) exceeds 255`)
+       if !re.MatchString(err.Error()) {
+               t.Errorf("unexpected error message: %v", err)
+       }
+       checkLoc(t, err, file, line+1)
+}
+
+// TestWriterCap_noAlloc verifies that NewWriterCap avoids
+// reallocations when the provided capacity is sufficient.
+func TestWriterCap_noAlloc(t *testing.T) {
+       const n = 4 + 1 + len("name") // U32 + U8LenStr("name")
+       w := NewWriterCap(n)
+       capBefore := cap(w.p)
+       w.U32(42)
+       w.U8LenStr("name")
+       if cap(w.p) != capBefore {
+               t.Errorf("reallocation occurred: cap before=%d, after=%d", 
capBefore, cap(w.p))
+       }
+       if len(w.p) != n {
+               t.Errorf("unexpected length: got %d, want %d", len(w.p), n)
+       }
+}
+
+// TestWriter_Obj_error_location verifies that the error message contains
+// the file and line of the call site that trigger the error.
+func TestWriter_Obj_error_location(t *testing.T) {
+       w := NewWriter()
+       _, file, line, _ := runtime.Caller(0)
+       w.Obj(&errMarshaler{})
+       checkLoc(t, w.Err(), file, line+1)
+}

Reply via email to