From 7a2b0128333eca174dd659e763b8670ba31fa7c8 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 17 Jul 2025 01:21:42 +0200 Subject: [PATCH] Added the 'in' builtin and fns:uni --- cmd/cmsh/main.go | 1 + go.mod | 3 +- go.sum | 3 ++ ucl/builtins.go | 46 ++++++++++++++++++++++++++++ ucl/builtins/fns.go | 64 +++++++++++++++++++++++++++++++++++++++ ucl/builtins_test.go | 71 ++++++++++++++++++++++++++++++++++++++++++++ ucl/inst.go | 1 + ucl/objs.go | 19 ++++++++++++ ucl/userbuiltin.go | 35 ++++++++++++++++------ 9 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 ucl/builtins/fns.go diff --git a/cmd/cmsh/main.go b/cmd/cmsh/main.go index 44f1921..cc5d919 100644 --- a/cmd/cmsh/main.go +++ b/cmd/cmsh/main.go @@ -26,6 +26,7 @@ func main() { ucl.WithModule(builtins.Strs()), ucl.WithModule(builtins.Lists()), ucl.WithModule(builtins.Time()), + ucl.WithModule(builtins.Fns()), ) ctx := context.Background() diff --git a/go.mod b/go.mod index cbf4d2d..b65ae1b 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/alecthomas/participle/v2 v2.1.1 github.com/chzyer/readline v1.5.1 github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 ) require ( @@ -17,4 +17,5 @@ require ( go.abhg.dev/goldmark/frontmatter v0.2.0 // indirect golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + lmika.dev/pkg/modash v0.0.0-20250619112300-0be0b6b35b1b // indirect ) diff --git a/go.sum b/go.sum index f9cdee7..a9528c9 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= go.abhg.dev/goldmark/frontmatter v0.2.0 h1:P8kPG0YkL12+aYk2yU3xHv4tcXzeVnN+gU0tJ5JnxRw= @@ -33,3 +34,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lmika.dev/pkg/modash v0.0.0-20250619112300-0be0b6b35b1b h1:Oymcj66pgyJ2CtGk9lPh06P4FOekllE1iPehDwaL0vw= +lmika.dev/pkg/modash v0.0.0-20250619112300-0be0b6b35b1b/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI= diff --git a/ucl/builtins.go b/ucl/builtins.go index 689e09e..59edecd 100644 --- a/ucl/builtins.go +++ b/ucl/builtins.go @@ -636,6 +636,52 @@ func (mi mappedIter) Next(ctx context.Context) (Object, error) { return mi.inv.invoke(ctx, mi.args.fork([]Object{v})) } +func inBuiltin(ctx context.Context, args invocationArgs) (Object, error) { + if err := args.expectArgn(2); err != nil { + return nil, err + } + + r := args.args[1] + + if args.args[0] == nil { + return BoolObject(false), nil + } + + switch t := args.args[0].(type) { + case StringObject: + var rs string + if r != nil { + rs = r.String() + } + return BoolObject(strings.Contains(t.String(), rs)), nil + case Listable: + l := t.Len() + for i := 0; i < l; i++ { + v := t.Index(i) + + if ObjectsEqual(v, r) { + return BoolObject(true), nil + } + } + return BoolObject(false), nil + case Hashable: + v := t.Value(r.String()) + return BoolObject(v != nil), nil + case Iterable: + for t.HasNext() { + v, err := t.Next(ctx) + if err != nil { + return nil, err + } + if ObjectsEqual(v, r) { + return BoolObject(true), nil + } + } + return BoolObject(false), nil + } + return nil, errors.New("expected listable") +} + func mapBuiltin(ctx context.Context, args invocationArgs) (Object, error) { if err := args.expectArgn(2); err != nil { return nil, err diff --git a/ucl/builtins/fns.go b/ucl/builtins/fns.go new file mode 100644 index 0000000..4cc31b0 --- /dev/null +++ b/ucl/builtins/fns.go @@ -0,0 +1,64 @@ +package builtins + +import ( + "context" + + "lmika.dev/pkg/modash/moslice" + "ucl.lmika.dev/ucl" +) + +func Fns() ucl.Module { + return ucl.Module{ + Name: "fns", + Builtins: map[string]ucl.BuiltinHandler{ + "ident": fnsIdent, + "uni": fnsUni, + }, + } +} + +func fnsIdent(ctx context.Context, args ucl.CallArgs) (any, error) { + var inv ucl.Invokable + + if err := args.Bind(&inv); err != nil { + return nil, err + } + + return ucl.GoFunction(func(ctx context.Context, args ucl.CallArgs) (any, error) { + if args.NArgs() == 0 { + return nil, nil + } + + var x ucl.Object + if err := args.Bind(&x); err != nil { + return nil, err + } + + return inv.Invoke(ctx, x) + }), nil +} + +func fnsUni(ctx context.Context, args ucl.CallArgs) (any, error) { + var inv ucl.Invokable + + if err := args.Bind(&inv); err != nil { + return nil, err + } + + restArgs := moslice.Map(args.RestAsObjects(), func(o ucl.Object) any { return o }) + + return ucl.GoFunction(func(ctx context.Context, args ucl.CallArgs) (any, error) { + fwdArgs := make([]any, len(restArgs)+1) + + var o ucl.Object + if args.NArgs() != 0 { + if err := args.Bind(&o); err != nil { + return nil, err + } + } + fwdArgs[0] = o + copy(fwdArgs[1:], restArgs) + + return inv.Invoke(ctx, fwdArgs...) + }), nil +} diff --git a/ucl/builtins_test.go b/ucl/builtins_test.go index 8dea035..281fd82 100644 --- a/ucl/builtins_test.go +++ b/ucl/builtins_test.go @@ -1069,6 +1069,77 @@ func TestBuiltins_Seq(t *testing.T) { } +func TestBuiltins_Call(t *testing.T) { + tests := []struct { + desc string + expr string + want string + }{ + {desc: "call simple", expr: ` + call add [1 2] + `, want: "3\n"}, + {desc: "call with proc", expr: ` + call (proc { |x y| add $x $y }) [3 4] + `, want: "7\n"}, + {desc: "meta call", expr: ` + call "call" ["add" [1 3]] + `, want: "4\n"}, + {desc: "curry proc", expr: ` + proc curry { |name b| + proc { |a| call $name [$a $b] } + } + add2 = curry add 2 + map [1 2 3] $add2 | cat + `, want: "[3 4 5]\n"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ctx := context.Background() + outW := bytes.NewBuffer(nil) + + inst := New(WithOut(outW), WithTestBuiltin()) + err := evalAndDisplay(ctx, inst, tt.expr) + + assert.NoError(t, err) + assert.Equal(t, tt.want, outW.String()) + }) + } +} + +func TestBuiltins_In(t *testing.T) { + tests := []struct { + desc string + expr string + want string + }{ + {desc: "in str 1", expr: `in "absolute" "sol"`, want: "true\n"}, + {desc: "in str 2", expr: `in "absolute" "not here"`, want: "false\n"}, + {desc: "in list 1", expr: `in [1 2 3] 2`, want: "true\n"}, + {desc: "in list 2", expr: `in [1 2 3] 4`, want: "false\n"}, + {desc: "in map as key 1", expr: `in [a:1 b:2 c:3] a`, want: "true\n"}, + {desc: "in map as key 2", expr: `in [a:1 b:2 c:3] gad`, want: "false\n"}, + {desc: "in itr 1", expr: `in (itr) 2`, want: "true\n"}, + {desc: "in itr 2", expr: `in (itr) 8`, want: "false\n"}, + {desc: "in itr 3", expr: `itr = itr ; in $itr 2 ; head $itr`, want: "3\n"}, + {desc: "in itr 4", expr: `itr = itr ; in $itr 8 ; head $itr`, want: "(nil)\n"}, + {desc: "in nil", expr: `in () 4`, want: "false\n"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ctx := context.Background() + outW := bytes.NewBuffer(nil) + + inst := New(WithOut(outW), WithTestBuiltin()) + err := evalAndDisplay(ctx, inst, tt.expr) + + assert.NoError(t, err) + assert.Equal(t, tt.want, outW.String()) + }) + } +} + func TestBuiltins_Map(t *testing.T) { tests := []struct { desc string diff --git a/ucl/inst.go b/ucl/inst.go index de58436..bdc4cb7 100644 --- a/ucl/inst.go +++ b/ucl/inst.go @@ -68,6 +68,7 @@ func New(opts ...InstOption) *Inst { rootEC.addCmd("filter", invokableFunc(filterBuiltin)) rootEC.addCmd("reduce", invokableFunc(reduceBuiltin)) rootEC.addCmd("head", invokableFunc(firstBuiltin)) + rootEC.addCmd("in", invokableFunc(inBuiltin)) rootEC.addCmd("keys", invokableFunc(keysBuiltin)) diff --git a/ucl/objs.go b/ucl/objs.go index b2ddc6a..cf98303 100644 --- a/ucl/objs.go +++ b/ucl/objs.go @@ -523,6 +523,25 @@ func (i invokableFunc) invoke(ctx context.Context, args invocationArgs) (Object, return i(ctx, args) } +type GoFunction func(ctx context.Context, args CallArgs) (any, error) + +func (gf GoFunction) String() string { + return "(proc)" +} + +func (gf GoFunction) Truthy() bool { + return gf != nil +} + +func (gf GoFunction) invoke(ctx context.Context, args invocationArgs) (Object, error) { + v, err := gf(ctx, CallArgs{args: args}) + if err != nil { + return nil, err + } + + return fromGoValue(v) +} + type blockObject struct { block *astBlock closedEC *evalCtx diff --git a/ucl/userbuiltin.go b/ucl/userbuiltin.go index d915eea..4bf40b9 100644 --- a/ucl/userbuiltin.go +++ b/ucl/userbuiltin.go @@ -34,6 +34,10 @@ func (ca *CallArgs) Bind(vars ...interface{}) error { return nil } +func (ca *CallArgs) RestAsObjects() []Object { + return ca.args.args +} + func (ca *CallArgs) CanBind(vars ...interface{}) bool { if len(ca.args.args) < len(vars) { return false @@ -107,17 +111,30 @@ func (ca CallArgs) bindArg(v interface{}, arg Object) error { *t, _ = toGoValue(arg) return nil case *Invokable: - i, ok := arg.(invokable) - if !ok { + switch ait := arg.(type) { + case invokable: + *t = Invokable{ + inv: ait, + eval: ca.args.eval, + inst: ca.args.inst, + ec: ca.args.ec, + } + return nil + case StringObject: + iv := ca.args.ec.lookupInvokable(string(ait)) + if iv == nil { + return errors.New("'" + string(ait) + "' is not invokable") + } + *t = Invokable{ + inv: iv, + eval: ca.args.eval, + inst: ca.args.inst, + ec: ca.args.ec, + } + return nil + default: return errors.New("exepected invokable") } - *t = Invokable{ - inv: i, - eval: ca.args.eval, - inst: ca.args.inst, - ec: ca.args.ec, - } - return nil case *Listable: i, ok := arg.(Listable) if !ok {