Added additional builtins for strings and lists
All checks were successful
Build / build (push) Successful in 2m17s

This commit is contained in:
Leon Mika 2025-10-27 22:05:14 +11:00
parent 05f6816c19
commit ebd8c61956
8 changed files with 404 additions and 23 deletions

View file

@ -3,6 +3,7 @@ package builtins
import (
"context"
"errors"
"ucl.lmika.dev/ucl"
)
@ -14,6 +15,7 @@ func Lists() ucl.Module {
"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())
}

View file

@ -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 {

View file

@ -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

View file

@ -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 {

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"ucl.lmika.dev/ucl"
)
@ -17,10 +18,68 @@ func Strs() ucl.Module {
"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 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))
}

View file

@ -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)
}
})
}
}

View file

@ -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))

View file

@ -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)
}