From e71699d5a78add731e45d73a13ff3f4487885417 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 28 May 2025 21:39:03 +1000 Subject: [PATCH] Added list:uniq and the !nil builtin --- ucl/ast.go | 2 +- ucl/builtins.go | 26 +++++++++++---- ucl/builtins/lists.go | 67 ++++++++++++++++++++++++++++++++++++++ ucl/builtins/lists_test.go | 31 ++++++++++++++++++ ucl/builtins/strs.go | 4 +-- ucl/builtins/strs_test.go | 1 + ucl/inst.go | 2 ++ ucl/inst_test.go | 2 +- ucl/objs.go | 7 ++++ ucl/testbuiltins_test.go | 38 +++++++++++++++++++++ 10 files changed, 169 insertions(+), 11 deletions(-) diff --git a/ucl/ast.go b/ucl/ast.go index 3b567d9..54aaa64 100644 --- a/ucl/ast.go +++ b/ucl/ast.go @@ -148,7 +148,7 @@ var scanner = lexer.MustStateful(lexer.Rules{ {"NL", `[;\n][; \n\t]*`, nil}, {"PIPE", `\|`, nil}, {"EQ", `=`, nil}, - {"Ident", `[-]*[a-zA-Z_][\w-!?]*`, nil}, + {"Ident", `[-!?]*[a-zA-Z_!?-][\w-!?]*`, nil}, }, "String": { {"Escaped", `\\.`, nil}, diff --git a/ucl/builtins.go b/ucl/builtins.go index ddb065a..cc946b3 100644 --- a/ucl/builtins.go +++ b/ucl/builtins.go @@ -188,7 +188,7 @@ func eqBuiltin(ctx context.Context, args invocationArgs) (Object, error) { l := args.args[0] r := args.args[1] - return BoolObject(objectsEqual(l, r)), nil + return BoolObject(ObjectsEqual(l, r)), nil } func neBuiltin(ctx context.Context, args invocationArgs) (Object, error) { @@ -199,7 +199,7 @@ func neBuiltin(ctx context.Context, args invocationArgs) (Object, error) { l := args.args[0] r := args.args[1] - return BoolObject(!objectsEqual(l, r)), nil + return BoolObject(!ObjectsEqual(l, r)), nil } func ltBuiltin(ctx context.Context, args invocationArgs) (Object, error) { @@ -223,7 +223,7 @@ func leBuiltin(ctx context.Context, args invocationArgs) (Object, error) { if err != nil { return nil, err } - return BoolObject(isLess || objectsEqual(args.args[0], args.args[1])), nil + return BoolObject(isLess || ObjectsEqual(args.args[0], args.args[1])), nil } func gtBuiltin(ctx context.Context, args invocationArgs) (Object, error) { @@ -247,7 +247,7 @@ func geBuiltin(ctx context.Context, args invocationArgs) (Object, error) { if err != nil { return nil, err } - return BoolObject(isGreater || objectsEqual(args.args[0], args.args[1])), nil + return BoolObject(isGreater || ObjectsEqual(args.args[0], args.args[1])), nil } func andBuiltin(ctx context.Context, args invocationArgs) (Object, error) { @@ -286,7 +286,7 @@ func notBuiltin(ctx context.Context, args invocationArgs) (Object, error) { var errObjectsNotEqual = errors.New("objects not equal") -func objectsEqual(l, r Object) bool { +func ObjectsEqual(l, r Object) bool { if l == nil || r == nil { return l == nil && r == nil } @@ -314,7 +314,7 @@ func objectsEqual(l, r Object) bool { return false } for i := 0; i < lv.Len(); i++ { - if !objectsEqual(lv.Index(i), rv.Index(i)) { + if !ObjectsEqual(lv.Index(i), rv.Index(i)) { return false } } @@ -332,7 +332,7 @@ func objectsEqual(l, r Object) bool { rkv := rv.Value(k) if rkv == nil { return errObjectsNotEqual - } else if !objectsEqual(lkv, rkv) { + } else if !ObjectsEqual(lkv, rkv) { return errObjectsNotEqual } return nil @@ -370,6 +370,18 @@ func strBuiltin(ctx context.Context, args invocationArgs) (Object, error) { return StringObject(args.args[0].String()), nil } +func notNilBuiltin(ctx context.Context, args invocationArgs) (Object, error) { + if err := args.expectArgn(1); err != nil { + return nil, err + } + + if args.args[0] == nil { + return BoolObject(false), nil + } + + return BoolObject(true), nil +} + func intBuiltin(ctx context.Context, args invocationArgs) (Object, error) { if err := args.expectArgn(1); err != nil { return nil, err diff --git a/ucl/builtins/lists.go b/ucl/builtins/lists.go index f81f3e2..4e70095 100644 --- a/ucl/builtins/lists.go +++ b/ucl/builtins/lists.go @@ -11,6 +11,7 @@ func Lists() ucl.Module { Name: "lists", Builtins: map[string]ucl.BuiltinHandler{ "first": listFirst, + "uniq": listUniq, }, } } @@ -58,3 +59,69 @@ func listFirst(ctx context.Context, args ucl.CallArgs) (any, error) { return newList, nil } + +func eachListOrIterItem(ctx context.Context, o ucl.Object, f func(int, ucl.Object) error) error { + switch t := o.(type) { + case ucl.Listable: + for i := 0; i < t.Len(); i++ { + if err := f(i, t.Index(i)); err != nil { + return err + } + } + return nil + case ucl.Iterable: + idx := 0 + for t.HasNext() { + v, err := t.Next(ctx) + if err != nil { + return err + } + if err := f(idx, v); err != nil { + return err + } + idx++ + } + } + return errors.New("expected listable") +} + +type uniqKey struct { + sVal string + iVal int +} + +func listUniq(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + what ucl.Object + ) + + if err := args.Bind(&what); err != nil { + return nil, err + } + + seen := make(map[uniqKey]bool) + found := ucl.NewListObject() + + if err := eachListOrIterItem(ctx, what, func(idx int, v ucl.Object) error { + var key uniqKey + switch v := v.(type) { + case ucl.StringObject: + key = uniqKey{sVal: string(v)} + case ucl.IntObject: + key = uniqKey{iVal: int(v)} + default: + return errors.New("expected string or int") + } + + if !seen[key] { + seen[key] = true + found.Append(v) + } + + return nil + }); err != nil { + return nil, err + } + + return found, nil +} diff --git a/ucl/builtins/lists_test.go b/ucl/builtins/lists_test.go index 526176e..72ccf65 100644 --- a/ucl/builtins/lists_test.go +++ b/ucl/builtins/lists_test.go @@ -45,6 +45,37 @@ func TestLists_First(t *testing.T) { } } +func TestLists_Uniq(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "uniq 1", eval: `lists:uniq [a a a a b b b c c c]`, want: []any{"a", "b", "c"}}, + {desc: "uniq 2", eval: `lists:uniq [1 2 1 3 2 4 2 5 3]`, want: []any{1, 2, 3, 4, 5}}, + {desc: "uniq 3", eval: `lists:uniq [1 a 2 b 3 b 2 a 1]5`, want: []any{1, "a", 2, "b", 3}}, + + {desc: "uniq err 1", eval: `lists:uniq [[1 2 3] [a:2] ()]`, 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.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/strs.go b/ucl/builtins/strs.go index 03801d0..281dd4c 100644 --- a/ucl/builtins/strs.go +++ b/ucl/builtins/strs.go @@ -103,7 +103,7 @@ func join(ctx context.Context, args ucl.CallArgs) (any, error) { if i > 0 { sb.WriteString(tok) } - sb.WriteString(t.Index(i).String()) + sb.WriteString(ucl.ObjectToString(t.Index(i))) } return sb.String(), nil case ucl.Iterable: @@ -120,7 +120,7 @@ func join(ctx context.Context, args ucl.CallArgs) (any, error) { } else { first = false } - sb.WriteString(v.String()) + sb.WriteString(ucl.ObjectToString(v)) } return sb.String(), nil diff --git a/ucl/builtins/strs_test.go b/ucl/builtins/strs_test.go index c38065b..6dc5436 100644 --- a/ucl/builtins/strs_test.go +++ b/ucl/builtins/strs_test.go @@ -183,6 +183,7 @@ func TestStrs_Join(t *testing.T) { {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"}, + {desc: "join 6", eval: `strs:join [a () c () e]`, want: "ace"}, } for _, tt := range tests { diff --git a/ucl/inst.go b/ucl/inst.go index 074433e..de58436 100644 --- a/ucl/inst.go +++ b/ucl/inst.go @@ -81,6 +81,8 @@ func New(opts ...InstOption) *Inst { rootEC.addCmd("str", invokableFunc(strBuiltin)) rootEC.addCmd("int", invokableFunc(intBuiltin)) + rootEC.addCmd("!nil", invokableFunc(notNilBuiltin)) + rootEC.addCmd("add", invokableFunc(addBuiltin)) rootEC.addCmd("sub", invokableFunc(subBuiltin)) rootEC.addCmd("mup", invokableFunc(mupBuiltin)) diff --git a/ucl/inst_test.go b/ucl/inst_test.go index 7a9149a..e2dbf60 100644 --- a/ucl/inst_test.go +++ b/ucl/inst_test.go @@ -21,7 +21,7 @@ func TestInst_Eval(t *testing.T) { {desc: "simple string", expr: `firstarg "hello"`, want: "hello"}, {desc: "simple int 1", expr: `firstarg 123`, want: 123}, {desc: "simple int 2", expr: `firstarg -234`, want: -234}, - {desc: "simple ident", expr: `firstarg a-test`, want: "a-test"}, + {desc: "simple ident 1", expr: `firstarg a-test`, want: "a-test"}, // String interpolation {desc: "interpolate string 1", expr: `$what = "world" ; firstarg "hello $what"`, want: "hello world"}, diff --git a/ucl/objs.go b/ucl/objs.go index 816bbaf..981f7ab 100644 --- a/ucl/objs.go +++ b/ucl/objs.go @@ -680,3 +680,10 @@ func isBreakErr(err error) bool { return errors.As(err, &errBreak{}) || errors.As(err, &errReturn{}) || errors.Is(err, ErrHalt) } + +func ObjectToString(obj Object) string { + if obj == nil { + return "" + } + return obj.String() +} diff --git a/ucl/testbuiltins_test.go b/ucl/testbuiltins_test.go index 9ac1d61..9361ae4 100644 --- a/ucl/testbuiltins_test.go +++ b/ucl/testbuiltins_test.go @@ -1749,6 +1749,44 @@ func TestBuiltins_Cat(t *testing.T) { } } +func TestBuiltins_NotNil(t *testing.T) { + tests := []struct { + desc string + expr string + want any + }{ + {desc: "not nil 1", expr: `!nil "hello"`, want: true}, + {desc: "not nil 2", expr: `!nil ""`, want: true}, + {desc: "not nil 3", expr: `!nil 4`, want: true}, + {desc: "not nil 4", expr: `!nil 0`, want: true}, + {desc: "not nil 5", expr: `!nil $true`, want: true}, + {desc: "not nil 6", expr: `!nil $false`, want: true}, + {desc: "not nil 7", expr: `!nil [1 2 3]`, want: true}, + {desc: "not nil 8", expr: `!nil []`, want: true}, + {desc: "not nil 9", expr: `!nil [a:1 b:21]`, want: true}, + {desc: "not nil 10", expr: `!nil [:]`, want: true}, + + {desc: "not nil 11", expr: `!nil ()`, want: false}, + + {desc: "not nil 12", expr: `[1 () 2 () 3] | filter !nil | reduce "" { |x a| "$a $x" }`, want: " 1 2 3"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ctx := context.Background() + outW := bytes.NewBuffer(nil) + + inst := New(WithOut(outW), WithTestBuiltin()) + inst.SetVar("true", true) + inst.SetVar("false", false) + + eqRes, err := inst.EvalString(ctx, tt.expr) + assert.NoError(t, err) + assert.Equal(t, tt.want, eqRes) + }) + } +} + func evalAndDisplay(ctx context.Context, inst *Inst, expr string) error { res, err := inst.eval(ctx, strings.NewReader(expr), evalOptions{}) if err != nil {