From d178c05b63d0706500fd35d2472362758ed3d9be Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 8 Sep 2026 21:57:15 +1000 Subject: [PATCH] Added additional byte builtins --- cmd/cmsh/main.go | 1 + repl/evaldisplay.go | 22 ++++++++ ucl/builtins/bytes.go | 97 +++++++++++++++++++++++++++++++++- ucl/builtins/bytes_test.go | 103 +++++++++++++++++++++++++++++++++++++ ucl/objs.go | 12 ++++- 5 files changed, 232 insertions(+), 3 deletions(-) diff --git a/cmd/cmsh/main.go b/cmd/cmsh/main.go index bfcf4cd..21c0fa9 100644 --- a/cmd/cmsh/main.go +++ b/cmd/cmsh/main.go @@ -19,6 +19,7 @@ func main() { defer rl.Close() instRepl := repl.New( + ucl.WithModule(builtins.Bytes()), ucl.WithModule(builtins.CSV(nil)), ucl.WithModule(builtins.FS(nil)), ucl.WithModule(builtins.Log(nil)), diff --git a/repl/evaldisplay.go b/repl/evaldisplay.go index 4fc841d..e4e8c60 100644 --- a/repl/evaldisplay.go +++ b/repl/evaldisplay.go @@ -60,6 +60,28 @@ func (r *REPL) displayResult(ctx context.Context, w io.Writer, res any, concise if _, err = fmt.Fprintln(w, "(nil)"); err != nil { return err } + case []byte: + if _, err = fmt.Fprint(w, "bytes["); err != nil { + return err + } + for i, x := range v { + if i > 0 { + if _, err = fmt.Fprint(w, " "); err != nil { + return err + } + } + if _, err = fmt.Fprintf(w, "%02x", x); err != nil { + return err + } + } + if _, err = fmt.Fprint(w, "]"); err != nil { + return err + } + if !concise { + if _, err = fmt.Fprintln(w); err != nil { + return err + } + } case ucl.Listable: if concise { fmt.Fprintf(w, "[") diff --git a/ucl/builtins/bytes.go b/ucl/builtins/bytes.go index 98d1a3c..27fd8b0 100644 --- a/ucl/builtins/bytes.go +++ b/ucl/builtins/bytes.go @@ -1,12 +1,15 @@ package builtins import ( + "bytes" "context" "crypto/md5" "crypto/sha1" "crypto/sha256" "encoding/base64" + "encoding/hex" "errors" + "fmt" "ucl.lmika.dev/ucl" ) @@ -18,11 +21,15 @@ type bytesHandlers struct{} // Perform operations over byte slices. func Bytes() ucl.Module { + bh := bytesHandlers{} + return ucl.Module{ Name: "bytes", Builtins: map[string]ucl.BuiltinHandler{ - "hash": bytesHandlers{}.hash, - "base64": bytesHandlers{}.base64, + "from": bh.from, + "hash": bh.hash, + "base64": bh.base64, + "hex": bh.hex, }, } } @@ -82,6 +89,50 @@ func (c bytesHandlers) base64(ctx context.Context, args ucl.CallArgs) (any, erro return res, nil } +// :fn from +// +// :syntax INPUT ... +// +// Returns a byte slice from a given input. Input can be one of the following: +// +// - Nil, which will produce an empty byte slice +// - A string, which would produce a byte slice containing the UTF-8 encoded string +// - A number, which would produce a single byte slice containing the number as a byte +// - A byte slice, which would produce a copy of the byte slice +// +// A list or iter will consume the elements and apply the byte slice conversion recursively, combining +// the results into a single byte slice. +func (c bytesHandlers) from(ctx context.Context, args ucl.CallArgs) (any, error) { + var bfr bytes.Buffer + + var o ucl.Object + for args.NArgs() > 0 { + if err := args.Bind(&o); err != nil { + return nil, fmt.Errorf("failed to bind object: %w", err) + } + if err := writeObjToBytesBuffer(ctx, &bfr, o); err != nil { + return nil, err + } + } + + return bfr.Bytes(), nil +} + +// :fn hex +// +// :syntax BYTES +// +// Returns a string encoding the bytes slice as a hex string +func (c bytesHandlers) hex(ctx context.Context, args ucl.CallArgs) (any, error) { + inputBts, args, err := consumeStringOrBytes(args) + if err != nil { + return nil, err + } + + res := hex.EncodeToString(inputBts) + return res, nil +} + func consumeStringOrBytes(args ucl.CallArgs) (bs ucl.BytesObject, _ ucl.CallArgs, _ error) { if objs := args.RestAsObjects(); len(objs) > 0 { if b, ok := objs[0].(ucl.BytesObject); ok { @@ -96,3 +147,45 @@ func consumeStringOrBytes(args ucl.CallArgs) (bs ucl.BytesObject, _ ucl.CallArgs } return ucl.BytesObject(s), args, nil } + +func writeObjToBytesBuffer(ctx context.Context, bfr *bytes.Buffer, o ucl.Object) error { + if o == nil { + return nil + } + + switch v := o.(type) { + case ucl.StringObject: + bfr.Write([]byte(v)) + case ucl.IntObject: + bfr.WriteByte(byte(v)) + case ucl.StringListObject: + for _, el := range v { + bfr.Write([]byte(el)) + } + case ucl.BytesObject: + bfr.Write(v) + case ucl.Listable: + l := v.Len() + for i := 0; i < l; i++ { + if err := writeObjToBytesBuffer(ctx, bfr, v.Index(i)); err != nil { + return err + } + } + case ucl.Iterable: + for v.HasNext() { + n, err := v.Next(ctx) + if err != nil { + return err + } + if n == nil { + break + } + if err := writeObjToBytesBuffer(ctx, bfr, n); err != nil { + return err + } + } + default: + return fmt.Errorf("unsupported type %T", o) + } + return nil +} diff --git a/ucl/builtins/bytes_test.go b/ucl/builtins/bytes_test.go index 1c5554e..bb91d26 100644 --- a/ucl/builtins/bytes_test.go +++ b/ucl/builtins/bytes_test.go @@ -10,6 +10,76 @@ import ( "ucl.lmika.dev/ucl/builtins" ) +func TestBytes_From(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "no args", eval: `bytes:from`}, + {desc: "nil", eval: `bytes:from ()`}, + + {desc: "string 1", eval: `bytes:from "hello"`, want: []byte("hello")}, + {desc: "string 2", eval: `bytes:from ""`}, + {desc: "string utf-8", eval: `bytes:from "héllo"`, want: []byte("héllo")}, + + {desc: "single int", eval: `bytes:from 104`, want: []byte{104}}, + {desc: "zero int", eval: `bytes:from 0`, want: []byte{0x00}}, + {desc: "int wraps to byte", eval: `bytes:from 260`, want: []byte{4}}, + + {desc: "bytes input", eval: `bytes:from (bytes:from "hello")`, want: []byte("hello")}, + + {desc: "multiple args", eval: `bytes:from "he" 108 "lo"`, want: []byte("hello")}, + + {desc: "list of ints", eval: `bytes:from [104 101 108 108 111]`, want: []byte("hello")}, + {desc: "empty list", eval: `bytes:from []`}, + {desc: "list of strings", eval: `bytes:from ["he" "llo"]`, want: []byte("hello")}, + {desc: "list with nil", eval: `bytes:from ["he" () "llo"]`, want: []byte("hello")}, + {desc: "nested lists", eval: `bytes:from [["he"] [108 "lo"]]`, want: []byte("hello")}, + {desc: "list of bytes", eval: `bytes:from [(bytes:from "he") (bytes:from "llo")]`, want: []byte("hello")}, + + {desc: "iterator of ints", eval: `bytes:from (itrs:from [104 105])`, want: []byte("hi")}, + {desc: "iterator from seq", eval: `seq 4 | map { |x| add $x 97 } | bytes:from`, want: []byte("abcd")}, + + {desc: "round trip through str", eval: `str (bytes:from "hello")`, want: "hello"}, + + {desc: "err unsupported bool", eval: `bytes:from $true`, wantErr: true}, + {desc: "err unsupported map", eval: `bytes:from [a: 1]`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Bytes()), + ucl.WithModule(builtins.Itrs()), + ) + inst.SetVar("true", true) + + res, err := inst.EvalString(context.Background(), tt.eval) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + + if wantStr, ok := tt.want.(string); ok { + assert.Equal(t, wantStr, res) + return + } + + resBts, ok := res.([]byte) + if assert.True(t, ok, "expected []byte result, got %T", res) { + if tt.want == nil { + assert.Empty(t, resBts) + } else { + assert.Equal(t, tt.want, resBts) + } + } + } + }) + } +} + func TestBytes_Hash(t *testing.T) { tests := []struct { desc string @@ -83,3 +153,36 @@ func TestBytes_Base64(t *testing.T) { }) } } + +func TestBytes_Hex(t *testing.T) { + tests := []struct { + desc string + eval string + want string + wantErr bool + }{ + {desc: "encode string", eval: `bytes:hex "hello"`, want: "68656c6c6f"}, + {desc: "encode empty string", eval: `bytes:hex ""`, want: ""}, + {desc: "encode string with utf-8", eval: `bytes:hex "héllo"`, want: "68c3a96c6c6f"}, + + {desc: "encode bytes input", eval: `bytes:hex (bytes:from "hello")`, want: "68656c6c6f"}, + {desc: "encode hash result", eval: `bytes:hex (bytes:hash "hello" md5)`, want: "5d41402abc4b2a76b9719d911017c592"}, + + {desc: "err missing args", eval: `bytes:hex`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Bytes()), + ) + res, err := inst.EvalString(context.Background(), tt.eval) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, res) + } + }) + } +} diff --git a/ucl/objs.go b/ucl/objs.go index df0872d..f5e694c 100644 --- a/ucl/objs.go +++ b/ucl/objs.go @@ -121,7 +121,17 @@ func (ss StringListObject) Index(i int) Object { type BytesObject []byte func (bs BytesObject) String() string { - return fmt.Sprintf("[%v]bytes", len(bs)) + var sb strings.Builder + + sb.WriteString("bytes[") + for i, b := range bs { + if i > 0 { + sb.WriteString(" ") + } + sb.WriteString(fmt.Sprintf("%02x", b)) + } + sb.WriteString("]") + return sb.String() } func (bs BytesObject) Truthy() bool {