diff --git a/cmd/cmsh/main.go b/cmd/cmsh/main.go index 817e72d..44f1921 100644 --- a/cmd/cmsh/main.go +++ b/cmd/cmsh/main.go @@ -24,6 +24,7 @@ func main() { ucl.WithModule(builtins.Itrs()), ucl.WithModule(builtins.OS()), ucl.WithModule(builtins.Strs()), + ucl.WithModule(builtins.Lists()), ucl.WithModule(builtins.Time()), ) ctx := context.Background() diff --git a/cmd/playwasm/jsiter.go b/cmd/playwasm/jsiter.go index c1201ab..72723cd 100644 --- a/cmd/playwasm/jsiter.go +++ b/cmd/playwasm/jsiter.go @@ -27,6 +27,7 @@ func initJS(ctx context.Context) { ucl.WithModule(builtins.Strs()), ucl.WithModule(builtins.Time()), ucl.WithModule(builtins.Itrs()), + ucl.WithModule(builtins.Lists()), ucl.WithOut(ucl.LineHandler(func(line string) { invokeUCLCallback("onOutLine", line) })), diff --git a/ucl/builtins.go b/ucl/builtins.go index 56c8f56..96db044 100644 --- a/ucl/builtins.go +++ b/ucl/builtins.go @@ -451,12 +451,58 @@ func callBuiltin(ctx context.Context, args invocationArgs) (Object, error) { return nil, err } - inv, ok := args.args[0].(invokable) - if !ok { - return nil, errors.New("expected invokable") + var inv invokable + switch t := args.args[0].(type) { + case invokable: + inv = t + case StringObject: + inv = args.ec.lookupInvokable(t.String()) + if inv == nil { + return nil, errors.New("no such invokable: " + t.String()) + } + default: + return nil, errors.New("expected string or invokable") } - return inv.invoke(ctx, args.shift(1)) + var calledArgs []Object + + args = args.shift(1) + if len(args.args) > 0 { + argList, ok := args.args[0].(Listable) + if !ok { + return nil, errors.New("expected listable arg") + } + calledArgs = make([]Object, argList.Len()) + for i := 0; i < argList.Len(); i++ { + calledArgs[i] = argList.Index(i) + } + args.shift(1) + } + + invArgs := args.fork(calledArgs) + + args = args.shift(1) + if len(args.args) > 0 { + kwArgs, ok := args.args[0].(Hashable) + if !ok { + return nil, errors.New("expected hashable arg") + } + kwArgs.Each(func(k string, v Object) error { + if invArgs.kwargs == nil { + invArgs.kwargs = make(map[string]*ListObject) + } + if invArgs.kwargs[k] == nil { + invArgs.kwargs[k] = &ListObject{} + } + + kwArg := *(invArgs.kwargs[k]) + kwArg = append(kwArg, v) + invArgs.kwargs[k] = &kwArg + return nil + }) + } + + return inv.invoke(ctx, invArgs) } func lenBuiltin(ctx context.Context, args invocationArgs) (Object, error) { @@ -495,6 +541,8 @@ func indexLookup(ctx context.Context, obj, elem Object) (Object, error) { } if int(intIdx) >= 0 && int(intIdx) < v.Len() { return v.Index(int(intIdx)), nil + } else if int(intIdx) < 0 && int(intIdx) >= -v.Len() { + return v.Index(v.Len() + int(intIdx)), nil } return nil, nil case Hashable: diff --git a/ucl/builtins/lists.go b/ucl/builtins/lists.go new file mode 100644 index 0000000..f81f3e2 --- /dev/null +++ b/ucl/builtins/lists.go @@ -0,0 +1,60 @@ +package builtins + +import ( + "context" + "errors" + "ucl.lmika.dev/ucl" +) + +func Lists() ucl.Module { + return ucl.Module{ + Name: "lists", + Builtins: map[string]ucl.BuiltinHandler{ + "first": listFirst, + }, + } +} + +func listFirst(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + what ucl.Object + count int + ) + + if err := args.Bind(&what, &count); err != nil { + return nil, err + } + + if count == 0 { + return ucl.NewListObject(), nil + } + + newList := ucl.NewListObject() + + switch t := what.(type) { + case ucl.Listable: + if count < 0 { + count = t.Len() + count + } + + for i := 0; i < min(count, t.Len()); i++ { + newList.Append(t.Index(i)) + } + case ucl.Iterable: + if count < 0 { + return nil, errors.New("negative counts not supported on iters") + } + + for i := 0; t.HasNext() && i < count; i++ { + v, err := t.Next(ctx) + if err != nil { + return nil, err + } + newList.Append(v) + } + default: + return nil, errors.New("expected listable") + } + + return newList, nil +} diff --git a/ucl/builtins/lists_test.go b/ucl/builtins/lists_test.go new file mode 100644 index 0000000..ca86731 --- /dev/null +++ b/ucl/builtins/lists_test.go @@ -0,0 +1,59 @@ +package builtins_test + +import ( + "context" + "github.com/stretchr/testify/assert" + "testing" + "ucl.lmika.dev/ucl" + "ucl.lmika.dev/ucl/builtins" +) + +func TestLists_First(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "firsts 1", eval: `lists:first [1 2 3 4 5] 0`, want: []any{}}, + {desc: "firsts 2", eval: `lists:first [1 2 3 4 5] 3`, want: []any{1, 2, 3}}, + {desc: "firsts 3", eval: `lists:first [1 2 3] 5`, want: []any{1, 2, 3}}, + {desc: "firsts 4", eval: `lists:first [1 2 3] 1`, want: []any{1}}, + {desc: "firsts 5", eval: `lists:first [1 2 3 4 5] -1`, want: []any{1, 2, 3, 4}}, + {desc: "firsts 6", eval: `lists:first [1 2 3 4 5] -3`, want: []any{1, 2}}, + {desc: "firsts 7", eval: `lists:first [1 2 3 4 5] -8`, want: []any{}}, + {desc: "firsts 8", eval: `lists:first (itrs:from [1 2 3 4 5]) 3`, want: []any{1, 2, 3}}, + {desc: "firsts 9", eval: `lists:first (itrs:from [1 2 3]) 5`, want: []any{1, 2, 3}}, + + {desc: "err 1", eval: `lists:first (itrs:from [1 2 3]) -3`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Itrs()), + ucl.WithModule(builtins.Lists()), + ) + res, err := inst.Eval(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 { + switch t := arg.(type) { + case int: + newList.Append(ucl.IntObject(t)) + default: + panic("unhandled type") + } + } + return newList +} diff --git a/ucl/builtins/strs.go b/ucl/builtins/strs.go index a10893d..fb6a3d3 100644 --- a/ucl/builtins/strs.go +++ b/ucl/builtins/strs.go @@ -2,6 +2,7 @@ package builtins import ( "context" + "errors" "strings" "ucl.lmika.dev/ucl" ) @@ -10,9 +11,12 @@ func Strs() ucl.Module { return ucl.Module{ Name: "strs", Builtins: map[string]ucl.BuiltinHandler{ - "to-upper": toUpper, - "to-lower": toLower, - "trim": trim, + "to-upper": toUpper, + "to-lower": toLower, + "trim": trim, + "split": split, + "join": join, + "has-prefix": hasPrefix, }, } } @@ -43,3 +47,86 @@ func trim(ctx context.Context, args ucl.CallArgs) (any, error) { return strings.TrimSpace(s), nil } + +func hasPrefix(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.HasPrefix(s, prefix), nil +} + +func split(ctx context.Context, args ucl.CallArgs) (any, error) { + var s string + if err := args.Bind(&s); err != nil { + return nil, err + } + + sep := "" + if args.NArgs() > 0 { + if err := args.Bind(&sep); err != nil { + return nil, err + } + } + + n := -1 + if args.HasSwitch("max") { + if err := args.BindSwitch("max", &n); err != nil { + return nil, err + } + } + + return StringSlice(strings.SplitN(s, sep, n)), nil +} + +func join(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + what ucl.Object + tok string + ) + + if err := args.Bind(&what); err != nil { + return nil, err + } + + if args.NArgs() > 0 { + if err := args.Bind(&tok); err != nil { + return nil, err + } + } + + switch t := what.(type) { + case ucl.Listable: + var sb strings.Builder + for i := 0; i < t.Len(); i++ { + if i > 0 { + sb.WriteString(tok) + } + sb.WriteString(t.Index(i).String()) + } + return sb.String(), nil + case ucl.Iterable: + first := true + + var sb strings.Builder + for t.HasNext() { + v, err := t.Next(ctx) + if err != nil { + return nil, err + } + if !first { + sb.WriteString(tok) + } else { + first = false + } + sb.WriteString(v.String()) + } + + return sb.String(), nil + } + + return nil, errors.New("expected listable or iterable as arg 1") +} + +type StringSlice []string diff --git a/ucl/builtins/strs_test.go b/ucl/builtins/strs_test.go index c6ef90a..6af7082 100644 --- a/ucl/builtins/strs_test.go +++ b/ucl/builtins/strs_test.go @@ -100,3 +100,104 @@ func TestStrs_Trim(t *testing.T) { }) } } + +func TestStrs_HasPrefix(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "has prefix 1", eval: `strs:has-prefix "hello, world" "hello"`, want: true}, + {desc: "has prefix 2", eval: `strs:has-prefix "goodbye, world" "hello"`, want: false}, + {desc: "has prefix 3", eval: `strs:has-prefix "" "world"`, want: false}, + {desc: "has prefix 4", eval: `strs:has-prefix "hello" ""`, want: true}, + + {desc: "err 1", eval: `strs:has-prefix`, wantErr: true}, + {desc: "err 1", eval: `strs:has-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.Eval(context.Background(), tt.eval) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, res) + } + }) + } +} + +func TestStrs_Split(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "split 1", eval: `strs:split "1,2,3" ","`, want: builtins.StringSlice{"1", "2", "3"}}, + {desc: "split 2", eval: `strs:split "1,2,3" ";"`, want: builtins.StringSlice{"1,2,3"}}, + {desc: "split 3", eval: `strs:split "" ";"`, want: builtins.StringSlice{""}}, + {desc: "split 4", eval: `strs:split " " ";"`, want: builtins.StringSlice{" "}}, + + {desc: "split by char 1", eval: `strs:split "123"`, want: builtins.StringSlice{"1", "2", "3"}}, + + {desc: "split max 1", eval: `strs:split "1,2,3" "," -max 2`, want: builtins.StringSlice{"1", "2,3"}}, + {desc: "split max 2", eval: `strs:split "1,2,3" "," -max 5`, want: builtins.StringSlice{"1", "2", "3"}}, + + {desc: "split by char max 1", eval: `strs:split "12345" -max 3`, want: builtins.StringSlice{"1", "2", "345"}}, + + {desc: "err 1", eval: `strs:split "1,2,3" -max []`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Strs()), + ) + res, err := inst.Eval(context.Background(), tt.eval) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, res) + } + }) + } +} + +func TestStrs_Join(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "join 1", eval: `strs:join [1 2 3] ","`, want: "1,2,3"}, + {desc: "join 2", eval: `strs:join [a b c] " "`, want: "a b c"}, + {desc: "join 3", eval: `strs:join [a b c] ""`, want: "abc"}, + {desc: "join 4", eval: `strs:join [a b c]`, want: "abc"}, + {desc: "join 5", eval: `strs:join (itrs:from [a b c]) ","`, want: "a,b,c"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Itrs()), + ucl.WithModule(builtins.Strs()), + ) + res, err := inst.Eval(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/builtins/time.go b/ucl/builtins/time.go index bfbf495..fcc804d 100644 --- a/ucl/builtins/time.go +++ b/ucl/builtins/time.go @@ -11,6 +11,7 @@ func Time() ucl.Module { Name: "time", Builtins: map[string]ucl.BuiltinHandler{ "from-unix": timeFromUnix, + "sleep": timeSleep, }, } } @@ -24,3 +25,18 @@ func timeFromUnix(ctx context.Context, args ucl.CallArgs) (any, error) { return time.Unix(int64(ux), 0).UTC(), nil } + +func timeSleep(ctx context.Context, args ucl.CallArgs) (any, error) { + var secs int + + if err := args.Bind(&secs); err != nil { + return nil, err + } + + select { + case <-time.After(time.Duration(secs) * time.Second): + return nil, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} diff --git a/ucl/builtins/time_test.go b/ucl/builtins/time_test.go index 2e14c59..4c95b5a 100644 --- a/ucl/builtins/time_test.go +++ b/ucl/builtins/time_test.go @@ -35,3 +35,21 @@ func TestTime_FromUnix(t *testing.T) { }) } } + +func TestTime_Sleep(t *testing.T) { + t.Run("should terminate on cancelled context", func(t *testing.T) { + st := time.Now() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + inst := ucl.New( + ucl.WithModule(builtins.Time()), + ) + + _, err := inst.Eval(ctx, `time:sleep 1`) + assert.Error(t, err) + assert.Equal(t, "context canceled", err.Error()) + assert.True(t, time.Now().Sub(st) < time.Second) + }) +} diff --git a/ucl/inst_test.go b/ucl/inst_test.go index 47e8eff..429b35b 100644 --- a/ucl/inst_test.go +++ b/ucl/inst_test.go @@ -80,20 +80,25 @@ func TestInst_Eval(t *testing.T) { {desc: "map 6", expr: `set x [a:"A" b:"B" c:"C"] ; firstarg ["one":$x.c "two":$x.b "three":$x.a]`, want: map[string]any{"one": "C", "two": "B", "three": "A"}}, // Dots - {desc: "dot 1", expr: `set x [1 2 3] ; $x.(0)`, want: 1}, - {desc: "dot 2", expr: `set x [1 2 3] ; $x.(1)`, want: 2}, - {desc: "dot 3", expr: `set x [1 2 3] ; $x.(2)`, want: 3}, - {desc: "dot 4", expr: `set x [1 2 3] ; $x.(3)`, want: nil}, - {desc: "dot 5", expr: `set x [1 2 3] ; $x.(add 1 1)`, want: 3}, - {desc: "dot 6", expr: `set x [alpha:"hello" bravo:"world"] ; $x.alpha`, want: "hello"}, - {desc: "dot 7", expr: `set x [alpha:"hello" bravo:"world"] ; $x.bravo`, want: "world"}, - {desc: "dot 8", expr: `set x [alpha:"hello" bravo:"world"] ; $x.charlie`, want: nil}, - {desc: "dot 9", expr: `set x [alpha:"hello" bravo:"world"] ; $x.("alpha")`, want: "hello"}, - {desc: "dot 10", expr: `set x [alpha:"hello" bravo:"world"] ; $x.("bravo")`, want: "world"}, - {desc: "dot 11", expr: `set x [alpha:"hello" bravo:"world"] ; $x.("charlie")`, want: nil}, - {desc: "dot 12", expr: `set x [MORE:"stuff"] ; $x.("more" | toUpper)`, want: "stuff"}, - {desc: "dot 13", expr: `set x [MORE:"stuff"] ; $x.(toUpper ("more"))`, want: "stuff"}, - {desc: "dot 14", expr: `set x [MORE:"stuff"] ; x.y`, want: nil}, + {desc: "dot expr 1", expr: `set x [1 2 3] ; $x.(0)`, want: 1}, + {desc: "dot expr 2", expr: `set x [1 2 3] ; $x.(1)`, want: 2}, + {desc: "dot expr 3", expr: `set x [1 2 3] ; $x.(2)`, want: 3}, + {desc: "dot expr 4", expr: `set x [1 2 3] ; $x.(3)`, want: nil}, + {desc: "dot expr 5", expr: `set x [1 2 3] ; $x.(add 1 1)`, want: 3}, + {desc: "dot expr 6", expr: `set x [1 2 3] ; $x.(-1)`, want: 3}, + {desc: "dot expr 7", expr: `set x [1 2 3] ; $x.(-2)`, want: 2}, + {desc: "dot expr 8", expr: `set x [1 2 3] ; $x.(-3)`, want: 1}, + {desc: "dot expr 9", expr: `set x [1 2 3] ; $x.(-4)`, want: nil}, + + {desc: "dot idents 1", expr: `set x [alpha:"hello" bravo:"world"] ; $x.alpha`, want: "hello"}, + {desc: "dot idents 2", expr: `set x [alpha:"hello" bravo:"world"] ; $x.bravo`, want: "world"}, + {desc: "dot idents 3", expr: `set x [alpha:"hello" bravo:"world"] ; $x.charlie`, want: nil}, + {desc: "dot idents 4", expr: `set x [alpha:"hello" bravo:"world"] ; $x.("alpha")`, want: "hello"}, + {desc: "dot idents 5", expr: `set x [alpha:"hello" bravo:"world"] ; $x.("bravo")`, want: "world"}, + {desc: "dot idents 6", expr: `set x [alpha:"hello" bravo:"world"] ; $x.("charlie")`, want: nil}, + {desc: "dot idents 7", expr: `set x [MORE:"stuff"] ; $x.("more" | toUpper)`, want: "stuff"}, + {desc: "dot idents 8", expr: `set x [MORE:"stuff"] ; $x.(toUpper ("more"))`, want: "stuff"}, + {desc: "dot idents 9", expr: `set x [MORE:"stuff"] ; x.y`, want: nil}, {desc: "parse comments 1", expr: parseComments1, wantErr: ucl.ErrNotConvertable}, {desc: "parse comments 2", expr: parseComments2, wantErr: ucl.ErrNotConvertable}, diff --git a/ucl/objs.go b/ucl/objs.go index b4b0c2a..e2bfda9 100644 --- a/ucl/objs.go +++ b/ucl/objs.go @@ -437,7 +437,7 @@ func (ia invocationArgs) fork(args []Object) invocationArgs { inst: ia.inst, ec: ia.ec, args: args, - kwargs: make(map[string]*ListObject), + kwargs: nil, } } diff --git a/ucl/testbuiltins_test.go b/ucl/testbuiltins_test.go index 8cb8cfe..e138a1c 100644 --- a/ucl/testbuiltins_test.go +++ b/ucl/testbuiltins_test.go @@ -493,7 +493,7 @@ func TestBuiltins_Procs(t *testing.T) { set goodbye (makeGreeter "Goodbye cruel") $goodbye "world" - call (makeGreeter "Quick") "call me" + call (makeGreeter "Quick") ["call me"] `, want: "Hello, world\nGoodbye cruel, world\nQuick, call me\n(nil)\n"}, {desc: "modifying closed over variables", expr: ` @@ -505,8 +505,8 @@ func TestBuiltins_Procs(t *testing.T) { } set er (makeSetter) - echo (call $er "xxx") - echo (call $er "yyy") + echo (call $er ["xxx"]) + echo (call $er ["yyy"]) `, want: "Xxxx\nXxxxyyy\n(nil)\n"}, }