From ebd8c6195630cea73988bbffc3c7420b510ede24 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 27 Oct 2025 22:05:14 +1100 Subject: [PATCH] Added additional builtins for strings and lists --- ucl/builtins/lists.go | 59 +++++++++++- ucl/builtins/lists_test.go | 44 ++++++++- ucl/builtins/os.go | 9 ++ ucl/builtins/os_test.go | 3 +- ucl/builtins/strs.go | 117 ++++++++++++++++++++--- ucl/builtins/strs_test.go | 189 ++++++++++++++++++++++++++++++++++++- ucl/inst.go | 1 + ucl/objs.go | 5 + 8 files changed, 404 insertions(+), 23 deletions(-) diff --git a/ucl/builtins/lists.go b/ucl/builtins/lists.go index 20df5b2..e62ed77 100644 --- a/ucl/builtins/lists.go +++ b/ucl/builtins/lists.go @@ -3,6 +3,7 @@ package builtins import ( "context" "errors" + "ucl.lmika.dev/ucl" ) @@ -10,10 +11,11 @@ func Lists() ucl.Module { return ucl.Module{ Name: "lists", Builtins: map[string]ucl.BuiltinHandler{ - "append": listAppend, - "first": listFirst, - "batch": listBatch, - "uniq": listUniq, + "append": listAppend, + "first": listFirst, + "batch": listBatch, + "uniq": listUniq, + "sublist": listSublist, }, } } @@ -189,3 +191,52 @@ func listBatch(ctx context.Context, args ucl.CallArgs) (any, error) { return groups, nil } + +func listSublist(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + l ucl.Listable + from, fromIdx int + to, toIdx int + ) + + if err := args.Bind(&l, &from); err != nil { + return nil, err + } + + if l == nil { + return nil, nil + } + + if args.NArgs() >= 1 { + if err := args.Bind(&to); err != nil { + return nil, err + } + + fromIdx, toIdx = listPos(l, from), listPos(l, to) + } else { + if from < 0 { + fromIdx, toIdx = listPos(l, from), l.Len() + } else { + fromIdx, toIdx = 0, listPos(l, from) + } + } + + if fromIdx > toIdx { + return ucl.NewListObject(), nil + } + + newList := ucl.NewListObjectOfLength(toIdx - fromIdx) + for i := fromIdx; i < toIdx; i++ { + if err := newList.SetIndex(i-fromIdx, l.Index(i)); err != nil { + return nil, err + } + } + return newList, nil +} + +func listPos(l ucl.Listable, pos int) int { + if pos < 0 { + return max(l.Len()+pos, 0) + } + return min(pos, l.Len()) +} diff --git a/ucl/builtins/lists_test.go b/ucl/builtins/lists_test.go index cf65a2c..b3cd919 100644 --- a/ucl/builtins/lists_test.go +++ b/ucl/builtins/lists_test.go @@ -2,8 +2,9 @@ package builtins_test import ( "context" - "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" "ucl.lmika.dev/ucl" "ucl.lmika.dev/ucl/builtins" ) @@ -150,6 +151,47 @@ func TestLists_Uniq(t *testing.T) { } } +func TestStrs_Sublist(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "sublist 1", eval: `lists:sublist [h e l l o ',' ' ' w o r l d] 5`, want: []any{"h", "e", "l", "l", "o"}}, + {desc: "sublist 2", eval: `lists:sublist [h e l l o ',' ' ' w o r l d] 5000`, want: []any{"h", "e", "l", "l", "o", ",", " ", "w", "o", "r", "l", "d"}}, + {desc: "sublist 3", eval: `lists:sublist [h e l l o ',' ' ' w o r l d] -5`, want: []any{"w", "o", "r", "l", "d"}}, + {desc: "sublist 4", eval: `lists:sublist [h e l l o ',' ' ' w o r l d] -5000`, want: []any{"h", "e", "l", "l", "o", ",", " ", "w", "o", "r", "l", "d"}}, + {desc: "sublist 5", eval: `lists:sublist [h e l l o ',' ' ' w o r l d] 0 5`, want: []any{"h", "e", "l", "l", "o"}}, + {desc: "sublist 6", eval: `lists:sublist [h e l l o ',' ' ' w o r l d] 3 10`, want: []any{"l", "o", ",", " ", "w", "o", "r"}}, + {desc: "sublist 7", eval: `lists:sublist [h e l l o ',' ' ' w o r l d] 3 10000`, want: []any{"l", "o", ",", " ", "w", "o", "r", "l", "d"}}, + {desc: "sublist 8", eval: `lists:sublist [h e l l o ',' ' ' w o r l d] 3 -5`, want: []any{"l", "o", ",", " "}}, + {desc: "sublist 9", eval: `lists:sublist [h e l l o ',' ' ' w o r l d] -9 -5`, want: []any{"l", "o", ",", " "}}, + {desc: "sublist 10", eval: `lists:sublist [h e l l o ',' ' ' w o r l d] 8 5`, want: []any{}}, + {desc: "sublist 11", eval: `lists:sublist [] 8 5`, want: []any{}}, + + {desc: "err 1", eval: `lists:sublist`, wantErr: true}, + {desc: "err 2", eval: `lists:sublist "asd"`, wantErr: true}, + {desc: "err 3", eval: `lists:sublist ["asd"]`, wantErr: true}, + {desc: "err 4", eval: `lists:sublist () 8 5`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Lists()), + ) + 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) + } + }) + } +} + func uclListOf(args ...any) *ucl.ListObject { newList := ucl.NewListObject() for _, arg := range args { diff --git a/ucl/builtins/os.go b/ucl/builtins/os.go index 9274bc7..e19c19f 100644 --- a/ucl/builtins/os.go +++ b/ucl/builtins/os.go @@ -5,6 +5,7 @@ import ( "errors" "os" "os/exec" + "strings" "ucl.lmika.dev/ucl" ) @@ -72,6 +73,14 @@ func (oh osHandlers) exec(ctx context.Context, args ucl.CallArgs) (any, error) { return nil, err } + if args.HasSwitch("in") { + var inVal string + if err := args.BindSwitch("in", &inVal); err != nil { + return nil, err + } + cmd.Stdin = strings.NewReader(inVal) + } + res, err := cmd.Output() if err != nil { return nil, err diff --git a/ucl/builtins/os_test.go b/ucl/builtins/os_test.go index 73dc1cb..b54b59f 100644 --- a/ucl/builtins/os_test.go +++ b/ucl/builtins/os_test.go @@ -44,7 +44,8 @@ func TestOS_Exec(t *testing.T) { want any }{ {descr: "run command 1", eval: `os:exec "echo" "hello, world"`, want: "hello, world\n"}, - {descr: "run command 1", eval: `os:exec "date" "+%Y%m%d"`, want: time.Now().Format("20060102") + "\n"}, + {descr: "run command 2", eval: `os:exec "date" "+%Y%m%d"`, want: time.Now().Format("20060102") + "\n"}, + {descr: "run command 3", eval: `os:exec "tr" "[a-z]" "[A-Z]" -in "hello"`, want: "HELLO"}, } for _, tt := range tests { diff --git a/ucl/builtins/strs.go b/ucl/builtins/strs.go index 281dd4c..c27ded5 100644 --- a/ucl/builtins/strs.go +++ b/ucl/builtins/strs.go @@ -4,6 +4,7 @@ import ( "context" "errors" "strings" + "ucl.lmika.dev/ucl" ) @@ -11,16 +12,74 @@ func Strs() ucl.Module { return ucl.Module{ Name: "strs", Builtins: map[string]ucl.BuiltinHandler{ - "to-upper": toUpper, - "to-lower": toLower, - "trim": trim, - "split": split, - "join": join, - "has-prefix": hasPrefix, + "to-upper": toUpper, + "to-lower": toLower, + "trim": trim, + "split": split, + "join": join, + "has-prefix": hasPrefix, + "has-suffix": hasSuffix, + "trim-prefix": trimPrefix, + "trim-suffix": trimSuffix, + "substr": strsSubstr, + "replace": strsReplace, }, } } +func strsReplace(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + s string + from string + to string + ) + + if err := args.Bind(&s, &from, &to); err != nil { + return nil, err + } + + var count = -1 + if args.HasSwitch("n") { + if err := args.BindSwitch("n", &count); err != nil { + return nil, err + } + } + + return strings.Replace(s, from, to, count), nil +} + +func strsSubstr(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + s string + from int + to int + ) + + if err := args.Bind(&s, &from); err != nil { + return nil, err + } + + if args.NArgs() >= 1 { + if err := args.Bind(&to); err != nil { + return nil, err + } + + ffr := strPos(s, from) + tfr := strPos(s, to) + if ffr > tfr { + return "", nil + } + return s[ffr:tfr], nil + + } else { + if from < 0 { + return s[strPos(s, from):], nil + } else { + return s[:strPos(s, from)], nil + } + } +} + func toUpper(ctx context.Context, args ucl.CallArgs) (any, error) { var s string if err := args.Bind(&s); err != nil { @@ -57,17 +116,40 @@ func hasPrefix(ctx context.Context, args ucl.CallArgs) (any, error) { return strings.HasPrefix(s, prefix), nil } -func split(ctx context.Context, args ucl.CallArgs) (any, error) { - var s string - if err := args.Bind(&s); err != nil { +func hasSuffix(ctx context.Context, args ucl.CallArgs) (any, error) { + var s, suffix string + if err := args.Bind(&s, &suffix); err != nil { return nil, err } - sep := "" - if args.NArgs() > 0 { - if err := args.Bind(&sep); err != nil { - return nil, err - } + return strings.HasSuffix(s, suffix), nil +} + +func trimPrefix(ctx context.Context, args ucl.CallArgs) (any, error) { + var s, prefix string + if err := args.Bind(&s, &prefix); err != nil { + return nil, err + } + + return strings.TrimPrefix(s, prefix), nil +} + +func trimSuffix(ctx context.Context, args ucl.CallArgs) (any, error) { + var s, suffix string + if err := args.Bind(&s, &suffix); err != nil { + return nil, err + } + + return strings.TrimSuffix(s, suffix), nil +} + +func split(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + s string + sep string + ) + if err := args.Bind(&s, &sep); err != nil { + return nil, err } n := -1 @@ -128,3 +210,10 @@ func join(ctx context.Context, args ucl.CallArgs) (any, error) { return nil, errors.New("expected listable or iterable as arg 1") } + +func strPos(s string, pos int) int { + if pos < 0 { + return max(len(s)+pos, 0) + } + return min(pos, len(s)) +} diff --git a/ucl/builtins/strs_test.go b/ucl/builtins/strs_test.go index 6dc5436..3335c92 100644 --- a/ucl/builtins/strs_test.go +++ b/ucl/builtins/strs_test.go @@ -2,8 +2,9 @@ package builtins_test import ( "context" - "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" "ucl.lmika.dev/ucl" "ucl.lmika.dev/ucl/builtins" ) @@ -145,14 +146,15 @@ func TestStrs_Split(t *testing.T) { {desc: "split 3", eval: `strs:split "" ";"`, want: []string{""}}, {desc: "split 4", eval: `strs:split " " ";"`, want: []string{" "}}, - {desc: "split by char 1", eval: `strs:split "123"`, want: []string{"1", "2", "3"}}, + {desc: "split by char 1", eval: `strs:split "123" ""`, want: []string{"1", "2", "3"}}, {desc: "split max 1", eval: `strs:split "1,2,3" "," -max 2`, want: []string{"1", "2,3"}}, {desc: "split max 2", eval: `strs:split "1,2,3" "," -max 5`, want: []string{"1", "2", "3"}}, - {desc: "split by char max 1", eval: `strs:split "12345" -max 3`, want: []string{"1", "2", "345"}}, + {desc: "split by char max 1", eval: `strs:split "12345" "" -max 3`, want: []string{"1", "2", "345"}}, {desc: "err 1", eval: `strs:split "1,2,3" -max []`, wantErr: true}, + {desc: "err 1", eval: `strs:split "1,2,3"`, wantErr: true}, } for _, tt := range tests { @@ -202,3 +204,184 @@ func TestStrs_Join(t *testing.T) { }) } } + +func TestStrs_HasSuffix(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "has suffix 1", eval: `strs:has-suffix "hello, world" "world"`, want: true}, + {desc: "has suffix 2", eval: `strs:has-suffix "hello, world" "hello"`, want: false}, + {desc: "has suffix 3", eval: `strs:has-suffix "" "world"`, want: false}, + {desc: "has suffix 4", eval: `strs:has-suffix "hello" ""`, want: true}, + {desc: "has suffix 5", eval: `strs:has-suffix "test.txt" ".txt"`, want: true}, + {desc: "has suffix 6", eval: `strs:has-suffix "test.txt" ".pdf"`, want: false}, + + {desc: "err 1", eval: `strs:has-suffix`, wantErr: true}, + {desc: "err 2", eval: `strs:has-suffix "asd"`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Strs()), + ) + 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) + } + }) + } +} + +func TestStrs_TrimPrefix(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "trim prefix 1", eval: `strs:trim-prefix "hello, world" "hello, "`, want: "world"}, + {desc: "trim prefix 2", eval: `strs:trim-prefix "goodbye, world" "hello"`, want: "goodbye, world"}, + {desc: "trim prefix 3", eval: `strs:trim-prefix "" "world"`, want: ""}, + {desc: "trim prefix 4", eval: `strs:trim-prefix "hello" ""`, want: "hello"}, + {desc: "trim prefix 5", eval: `strs:trim-prefix "test.txt" "test"`, want: ".txt"}, + {desc: "trim prefix 6", eval: `strs:trim-prefix "/path/to/file" "/path/"`, want: "to/file"}, + + {desc: "err 1", eval: `strs:trim-prefix`, wantErr: true}, + {desc: "err 2", eval: `strs:trim-prefix "asd"`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Strs()), + ) + 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) + } + }) + } +} + +func TestStrs_TrimSuffix(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "trim suffix 1", eval: `strs:trim-suffix "hello, world" ", world"`, want: "hello"}, + {desc: "trim suffix 2", eval: `strs:trim-suffix "hello, world" "goodbye"`, want: "hello, world"}, + {desc: "trim suffix 3", eval: `strs:trim-suffix "" "world"`, want: ""}, + {desc: "trim suffix 4", eval: `strs:trim-suffix "hello" ""`, want: "hello"}, + {desc: "trim suffix 5", eval: `strs:trim-suffix "test.txt" ".txt"`, want: "test"}, + {desc: "trim suffix 6", eval: `strs:trim-suffix "file.backup" ".backup"`, want: "file"}, + + {desc: "err 1", eval: `strs:trim-suffix`, wantErr: true}, + {desc: "err 2", eval: `strs:trim-suffix "asd"`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Strs()), + ) + 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) + } + }) + } +} + +func TestStrs_Substr(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "substr 1", eval: `strs:substr "hello, world" 5`, want: "hello"}, + {desc: "substr 2", eval: `strs:substr "hello, world" 5000`, want: "hello, world"}, + {desc: "substr 3", eval: `strs:substr "hello, world" -5`, want: "world"}, + {desc: "substr 4", eval: `strs:substr "hello, world" -5000`, want: "hello, world"}, + {desc: "substr 5", eval: `strs:substr "hello, world" 0 5`, want: "hello"}, + {desc: "substr 6", eval: `strs:substr "hello, world" 3 10`, want: "lo, wor"}, + {desc: "substr 7", eval: `strs:substr "hello, world" 3 10000`, want: "lo, world"}, + {desc: "substr 8", eval: `strs:substr "hello, world" 3 -5`, want: "lo, "}, + {desc: "substr 9", eval: `strs:substr "hello, world" -9 -5`, want: "lo, "}, + {desc: "substr 10", eval: `strs:substr "hello, world" 8 5`, want: ""}, + {desc: "substr 11", eval: `strs:substr "" 8 5`, want: ""}, + {desc: "substr 12", eval: `strs:substr () 8 5`, want: ""}, + + {desc: "err 1", eval: `strs:substr`, wantErr: true}, + {desc: "err 2", eval: `strs:substr "asd"`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Strs()), + ) + 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) + } + }) + } +} + +func TestStrs_Replace(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "replace 1", eval: `strs:replace "hello hello hello" "hello" "world"`, want: "world world world"}, + {desc: "replace 2", eval: `strs:replace "hello hi hello hi" "hello" "lo"`, want: "lo hi lo hi"}, + {desc: "replace 3", eval: `strs:replace "hello hello hello" "hello" "world" -n 1`, want: "world hello hello"}, + {desc: "replace 4", eval: `strs:replace "hello hello hello" "hello" "world" -n 2`, want: "world world hello"}, + {desc: "replace 5", eval: `strs:replace "hello hello hello" "hello" "world" -n 0`, want: "hello hello hello"}, + {desc: "replace 6", eval: `strs:replace "each one" "" "|"`, want: "|e|a|c|h| |o|n|e|"}, + {desc: "replace 7", eval: `strs:replace "hello hello hello" "hello" ""`, want: " "}, + {desc: "replace 8", eval: `strs:replace "nothing to replace here" "what" "why"`, want: "nothing to replace here"}, + {desc: "replace 9", eval: `strs:replace "" "what" "why"`, want: ""}, + {desc: "replace 10", eval: `strs:replace "" "hello" "world" -n 0`, want: ""}, + + {desc: "err 1", eval: `strs:replace`, wantErr: true}, + {desc: "err 2", eval: `strs:replace "asd"`, wantErr: true}, + {desc: "err 3", eval: `strs:replace "asd" "asd"`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Strs()), + ) + 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/inst.go b/ucl/inst.go index bdc4cb7..5b671c3 100644 --- a/ucl/inst.go +++ b/ucl/inst.go @@ -82,6 +82,7 @@ func New(opts ...InstOption) *Inst { rootEC.addCmd("str", invokableFunc(strBuiltin)) rootEC.addCmd("int", invokableFunc(intBuiltin)) + rootEC.addCmd("nil?", invokableFunc(notNilBuiltin)) rootEC.addCmd("!nil", invokableFunc(notNilBuiltin)) rootEC.addCmd("add", invokableFunc(addBuiltin)) diff --git a/ucl/objs.go b/ucl/objs.go index 76ea074..fedb25f 100644 --- a/ucl/objs.go +++ b/ucl/objs.go @@ -62,6 +62,11 @@ func NewListObject() *ListObject { return &ListObject{} } +func NewListObjectOfLength(l int) *ListObject { + o := make(ListObject, l) + return &o +} + func (lo *ListObject) Append(o Object) { *lo = append(*lo, o) }