diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/ucl/ast.go b/ucl/ast.go index e27ec01..d794ef8 100644 --- a/ucl/ast.go +++ b/ucl/ast.go @@ -63,8 +63,8 @@ func (ai *astIdentNames) String() string { } type astElementPair struct { - Left astDot `parser:"@@"` - Right *astDot `parser:"( COLON @@ )? NL?"` + Left astArgOrJustDot `parser:"@@"` + Right *astArgOrJustDot `parser:"( COLON @@ )? NL?"` } type astListOrHash struct { @@ -97,16 +97,26 @@ type astDotSuffix struct { Pipeline *astPipeline `parser:"| LP @@ RP"` } -type astDot struct { +type astArgOrJustDot struct { + Arg *astArgDotSuffix `parser:"@@"` + Dot *astJustDotSuffix `parser:"| @@"` +} + +type astArgDotSuffix struct { Pos lexer.Position Arg astCmdArg `parser:"@@"` DotSuffix []astDotSuffix `parser:"( DOT @@ )*"` } +type astJustDotSuffix struct { + Pos lexer.Position + DotSuffix []astDotSuffix `parser:" DOT ( @@ ( DOT @@ )* )?"` +} + type astCmd struct { Pos lexer.Position - Name astDot `parser:"@@"` - InvokeArgs []astDot `parser:"@@*"` + Name astArgOrJustDot `parser:"@@"` + InvokeArgs []astArgOrJustDot `parser:"@@*"` } type astPipeline struct { diff --git a/ucl/builtins/time.go b/ucl/builtins/time.go index fcc804d..583c270 100644 --- a/ucl/builtins/time.go +++ b/ucl/builtins/time.go @@ -2,7 +2,9 @@ package builtins import ( "context" + "errors" "time" + "ucl.lmika.dev/ucl" ) @@ -11,6 +13,8 @@ func Time() ucl.Module { Name: "time", Builtins: map[string]ucl.BuiltinHandler{ "from-unix": timeFromUnix, + "to-unix": timeToUnix, + "now": timeNow, "sleep": timeSleep, }, } @@ -26,6 +30,23 @@ func timeFromUnix(ctx context.Context, args ucl.CallArgs) (any, error) { return time.Unix(int64(ux), 0).UTC(), nil } +func timeToUnix(ctx context.Context, args ucl.CallArgs) (any, error) { + tval, err := getTimeArg(&args) + if err != nil { + return nil, err + } + + return int(tval.Unix()), nil +} + +func timeNow(ctx context.Context, args ucl.CallArgs) (any, error) { + if err := args.Bind(); err != nil { + return nil, err + } + + return time.Now().UTC(), nil +} + func timeSleep(ctx context.Context, args ucl.CallArgs) (any, error) { var secs int @@ -40,3 +61,17 @@ func timeSleep(ctx context.Context, args ucl.CallArgs) (any, error) { return nil, ctx.Err() } } + +func getTimeArg(args *ucl.CallArgs) (time.Time, error) { + var t any + if err := args.Bind(&t); err != nil { + return time.Time{}, err + } + + tval, ok := t.(time.Time) + if !ok { + return time.Time{}, errors.New("expected time.Time") + } + + return tval, nil +} diff --git a/ucl/builtins/time_test.go b/ucl/builtins/time_test.go index a0087fa..72921c6 100644 --- a/ucl/builtins/time_test.go +++ b/ucl/builtins/time_test.go @@ -36,6 +36,60 @@ func TestTime_FromUnix(t *testing.T) { } } +func TestTime_Now(t *testing.T) { + tests := []struct { + desc string + eval string + wantErr bool + }{ + {desc: "now returns time", eval: `time:now`, wantErr: false}, + {desc: "now with to-unix and from-unix roundtrip", eval: `time:to-unix (time:now) | time:from-unix`, wantErr: false}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Time()), + ) + res, err := inst.EvalString(context.Background(), tt.eval) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, res) + } + }) + } +} + +func TestTime_ToUnix(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "to-unix from epoch", eval: `time:to-unix (time:from-unix 0)`, want: 0}, + {desc: "to-unix from specific time", eval: `time:to-unix (time:from-unix 1234567890)`, want: 1234567890}, + {desc: "roundtrip", eval: `time:to-unix (time:from-unix 999999999)`, want: 999999999}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Time()), + ) + 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 TestTime_Sleep(t *testing.T) { t.Run("should terminate on cancelled context", func(t *testing.T) { st := time.Now() diff --git a/ucl/eval.go b/ucl/eval.go index 3d8c14e..fbb35f8 100644 --- a/ucl/eval.go +++ b/ucl/eval.go @@ -103,10 +103,7 @@ func (e evaluator) evalPipeline(ctx context.Context, ec *evalCtx, n *astPipeline } func (e evaluator) evalCmd(ctx context.Context, ec *evalCtx, currentPipe Object, ast *astCmd) (Object, error) { - switch { - case (ast.Name.Arg.Ident != nil) && len(ast.Name.DotSuffix) == 0: - name := ast.Name.Arg.Ident.String() - + if name, ok := e.isArgOrDotAnIdent(ast.Name); ok { // Regular command if cmd := ec.lookupInvokable(name); cmd != nil { return e.evalInvokable(ctx, ec, currentPipe, ast, cmd) @@ -117,8 +114,8 @@ func (e evaluator) evalCmd(ctx context.Context, ec *evalCtx, currentPipe Object, } else { return nil, errors.New("unknown command: " + name) } - case len(ast.InvokeArgs) > 0: - nameElem, err := e.evalDot(ctx, ec, ast.Name) + } else if len(ast.InvokeArgs) > 0 { + nameElem, err := e.evalArgOrDot(ctx, ec, ast.Name) if err != nil { return nil, err } @@ -131,7 +128,7 @@ func (e evaluator) evalCmd(ctx context.Context, ec *evalCtx, currentPipe Object, return e.evalInvokable(ctx, ec, currentPipe, ast, inv) } - nameElem, err := e.evalDot(ctx, ec, ast.Name) + nameElem, err := e.evalArgOrDot(ctx, ec, ast.Name) if err != nil { return nil, err } @@ -142,7 +139,25 @@ func (e evaluator) assignCmd(ctx context.Context, ec *evalCtx, ast *astCmd, toVa if len(ast.InvokeArgs) != 0 { return nil, errors.New("cannot assign to multiple values") } - return e.assignDot(ctx, ec, ast.Name, toVal) + return e.assignArgOrDot(ctx, ec, ast.Name, toVal) +} + +func (e evaluator) isArgOrDotAnIdent(argOrDot astArgOrJustDot) (string, bool) { + nonDotArg := argOrDot.Arg + if nonDotArg == nil { + return "", false + } + + if len(nonDotArg.DotSuffix) != 0 { + return "", false + } + + ident := nonDotArg.Arg.Ident + if ident == nil { + return "", false + } + + return ident.String(), true } func (e evaluator) evalInvokable(ctx context.Context, ec *evalCtx, currentPipe Object, ast *astCmd, cmd invokable) (Object, error) { @@ -157,16 +172,16 @@ func (e evaluator) evalInvokable(ctx context.Context, ec *evalCtx, currentPipe O argsPtr.Append(currentPipe) } for _, arg := range ast.InvokeArgs { - if ident := arg.Arg.Ident; len(arg.DotSuffix) == 0 && ident != nil && ident.String()[0] == '-' { + if ident, ok := e.isArgOrDotAnIdent(arg); ok && ident[0] == '-' { // Arg switch if kwargs == nil { kwargs = make(map[string]*ListObject) } argsPtr = &ListObject{} - kwargs[ident.String()[1:]] = argsPtr + kwargs[ident[1:]] = argsPtr } else { - ae, err := e.evalDot(ctx, ec, arg) + ae, err := e.evalArgOrDot(ctx, ec, arg) if err != nil { return nil, err } @@ -188,7 +203,55 @@ func (e evaluator) evalMacro(ctx context.Context, ec *evalCtx, hasPipe bool, pip }) } -func (e evaluator) evalDot(ctx context.Context, ec *evalCtx, n astDot) (Object, error) { +func (e evaluator) evalArgOrDot(ctx context.Context, ec *evalCtx, n astArgOrJustDot) (Object, error) { + if n.Arg != nil { + return e.evalArgWithDot(ctx, ec, *n.Arg) + } else if n.Dot != nil { + return e.evalJustDot(ctx, ec, *n.Dot) + } + return nil, errors.New("unhandled arg or dot type") +} + +func (e evaluator) evalPseudoVar(ctx context.Context, ec *evalCtx, name string) (Object, error) { + if v, ok := ec.getPseudoVar(name); ok { + return v.get(ctx, name) + } + + if mph := e.inst.missingPseudoVarHandler; mph != nil { + return mph.get(ctx, name) + } + + return nil, fmt.Errorf("unknown pseudo-variable: '%v'", name) +} + +func (e evaluator) evalJustDot(ctx context.Context, ec *evalCtx, n astJustDotSuffix) (Object, error) { + res, err := e.evalPseudoVar(ctx, ec, ".") + if err != nil { + return nil, err + } else if len(n.DotSuffix) == 0 { + return res, nil + } + + for _, dot := range n.DotSuffix { + var idx Object + if dot.KeyIdent != nil { + idx = StringObject(dot.KeyIdent.String()) + } else { + idx, err = e.evalPipeline(ctx, ec, dot.Pipeline) + if err != nil { + return nil, err + } + } + + res, err = indexLookup(ctx, res, idx, n.Pos) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (e evaluator) evalArgWithDot(ctx context.Context, ec *evalCtx, n astArgDotSuffix) (Object, error) { res, err := e.evalArg(ctx, ec, n.Arg) if err != nil { return nil, err @@ -215,7 +278,16 @@ func (e evaluator) evalDot(ctx context.Context, ec *evalCtx, n astDot) (Object, return res, nil } -func (e evaluator) assignDot(ctx context.Context, ec *evalCtx, n astDot, toVal Object) (Object, error) { +func (e evaluator) assignArgOrDot(ctx context.Context, ec *evalCtx, n astArgOrJustDot, toVal Object) (Object, error) { + if n.Arg != nil { + return e.assignArgWithDot(ctx, ec, *n.Arg, toVal) + } else if n.Dot != nil { + return e.assignJustDot(ctx, ec, *n.Dot, toVal) + } + return nil, errors.New("unhandled arg or dot type") +} + +func (e evaluator) assignArgWithDot(ctx context.Context, ec *evalCtx, n astArgDotSuffix, toVal Object) (Object, error) { if len(n.DotSuffix) == 0 { return e.assignArg(ctx, ec, n.Arg, toVal) } @@ -251,6 +323,56 @@ func (e evaluator) assignDot(ctx context.Context, ec *evalCtx, n astDot, toVal O return val, nil } +func (e evaluator) assignJustDot(ctx context.Context, ec *evalCtx, n astJustDotSuffix, toVal Object) (Object, error) { + if len(n.DotSuffix) == 0 { + pvar, ok := ec.getPseudoVar(".") + if ok { + if err := pvar.set(ctx, ".", toVal); err != nil { + return nil, err + } + return toVal, nil + } + + if pvar := e.inst.missingPseudoVarHandler; pvar != nil { + if err := pvar.set(ctx, ".", toVal); err != nil { + return nil, err + } + return toVal, nil + } + return nil, fmt.Errorf("unknown pseudo-variable: .") + } + + val, err := e.evalPseudoVar(ctx, ec, ".") + if err != nil { + return nil, err + } + + for i, dot := range n.DotSuffix { + isLast := i == len(n.DotSuffix)-1 + + var idx Object + if dot.KeyIdent != nil { + idx = StringObject(dot.KeyIdent.String()) + } else { + idx, err = e.evalPipeline(ctx, ec, dot.Pipeline) + if err != nil { + return nil, err + } + } + + if isLast { + val, err = indexAssign(ctx, val, idx, toVal, n.Pos) + } else { + val, err = indexLookup(ctx, val, idx, n.Pos) + } + if err != nil { + return nil, err + } + } + + return val, nil +} + func (e evaluator) evalArgForDotAssign(ctx context.Context, ec *evalCtx, n astCmdArg) (Object, error) { // Special case for dot assigns of 'a.b = c' where a is actually a var deref (i.e. $a) // which is unnecessary for assignments. Likewise, having '$a.b = c' should be dissallowed @@ -279,15 +401,7 @@ func (e evaluator) evalArg(ctx context.Context, ec *evalCtx, n astCmdArg) (Objec } return nil, nil case n.PseudoVar != nil: - if v, ok := ec.getPseudoVar(*n.PseudoVar); ok { - return v.get(ctx, *n.PseudoVar) - } - - if mph := e.inst.missingPseudoVarHandler; mph != nil { - return mph.get(ctx, *n.PseudoVar) - } - - return nil, errors.New("unknown pseudo-variable: " + *n.PseudoVar) + return e.evalPseudoVar(ctx, ec, *n.PseudoVar) case n.MaybeSub != nil: sub := n.MaybeSub.Sub if sub == nil { @@ -351,12 +465,12 @@ func (e evaluator) evalListOrHash(ctx context.Context, ec *evalCtx, loh *astList return nil, errors.New("miss-match of lists and hash") } - n, err := e.evalDot(ctx, ec, el.Left) + n, err := e.evalArgOrDot(ctx, ec, el.Left) if err != nil { return nil, err } - v, err := e.evalDot(ctx, ec, *el.Right) + v, err := e.evalArgOrDot(ctx, ec, *el.Right) if err != nil { return nil, err } @@ -371,7 +485,7 @@ func (e evaluator) evalListOrHash(ctx context.Context, ec *evalCtx, loh *astList if el.Right != nil { return nil, errors.New("miss-match of lists and hash") } - v, err := e.evalDot(ctx, ec, el.Left) + v, err := e.evalArgOrDot(ctx, ec, el.Left) if err != nil { return nil, err } diff --git a/ucl/inst_test.go b/ucl/inst_test.go index 041c367..9735dcc 100644 --- a/ucl/inst_test.go +++ b/ucl/inst_test.go @@ -307,6 +307,50 @@ func TestInst_SetPseudoVar(t *testing.T) { } } +func TestInst_ParseDot(t *testing.T) { + strPVal := &stringPseudoVar{str: "this is dot"} + slicePVal := &anyPseudoVar{val: []string{"item1", "item2"}} + mapPVal := &anyPseudoVar{val: map[string]string{"key1": "value1", "key2": "value2"}} + + tests := []struct { + desc string + pvalHandler ucl.PseudoVarHandler + expr string + wantRes any + wantBarVar string + wantErr bool + }{ + {desc: "read dot 1", pvalHandler: strPVal, expr: `.`, wantRes: "this is dot"}, + {desc: "read dot 2", pvalHandler: strPVal, expr: `toUpper .`, wantRes: "THIS IS DOT"}, + {desc: "read dot 3", pvalHandler: strPVal, expr: `. | toUpper`, wantRes: "THIS IS DOT"}, + {desc: "read dot 4", pvalHandler: slicePVal, expr: `.(0)`, wantRes: "item1"}, + {desc: "read dot 5", pvalHandler: slicePVal, expr: `.(1)`, wantRes: "item2"}, + {desc: "read dot 6", pvalHandler: slicePVal, expr: `. | len`, wantRes: 2}, + {desc: "read dot 7", pvalHandler: mapPVal, expr: `.key1`, wantRes: "value1"}, + {desc: "read dot 8", pvalHandler: mapPVal, expr: `.key2`, wantRes: "value2"}, + + // Always keep last as this will modify the pvals + {desc: "write dot 1", pvalHandler: strPVal, expr: `. = "hello" ; .`, wantRes: "hello"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New(ucl.WithTestBuiltin()) + inst.SetPseudoVar(".", tt.pvalHandler) + + res, err := inst.EvalString(t.Context(), tt.expr) + + if tt.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.wantRes, res) + }) + } +} + var parseComments1 = ` proc lookup { |file| foreach { |toks| @@ -359,6 +403,19 @@ func (s *stringPseudoVar) Set(ctx context.Context, v any) error { return nil } +type anyPseudoVar struct { + val any +} + +func (s *anyPseudoVar) Get(ctx context.Context) (any, error) { + return s.val, nil +} + +func (s *anyPseudoVar) Set(ctx context.Context, v any) error { + s.val = v + return nil +} + type missingPseudoVarType struct{} func (missingPseudoVarType) Get(ctx context.Context, name string) (any, error) { diff --git a/ucl/objs.go b/ucl/objs.go index 70f7cd8..d77f13f 100644 --- a/ucl/objs.go +++ b/ucl/objs.go @@ -308,6 +308,11 @@ func fromGoReflectValue(resVal reflect.Value) (Object, error) { switch resVal.Kind() { case reflect.Slice: return listableProxyObject{v: resVal, orig: resVal}, nil + case reflect.Map: + if resVal.Type().Key().Kind() == reflect.String { + return mapProxyObject{v: resVal, orig: resVal}, nil + } + return nil, errors.New("map keys must be strings") case reflect.Struct: return newStructProxyObject(resVal, resVal), nil case reflect.Pointer: @@ -346,11 +351,16 @@ func (ma macroArgs) identIs(ctx context.Context, n int, expectedIdent string) bo return false } - if len(ma.ast.InvokeArgs[ma.argShift+n].DotSuffix) != 0 { + arg := ma.ast.InvokeArgs[ma.argShift+n].Arg + if arg == nil { return false } - lit := ma.ast.InvokeArgs[ma.argShift+n].Arg.Ident + if len(arg.DotSuffix) != 0 { + return false + } + + lit := arg.Arg.Ident if lit == nil { return false } @@ -363,11 +373,16 @@ func (ma *macroArgs) shiftIdent(ctx context.Context) (string, bool) { return "", false } - if len(ma.ast.InvokeArgs[ma.argShift].DotSuffix) != 0 { + arg := ma.ast.InvokeArgs[ma.argShift].Arg + if arg == nil { return "", false } - lit := ma.ast.InvokeArgs[ma.argShift].Arg.Ident + if len(arg.DotSuffix) != 0 { + return "", false + } + + lit := arg.Arg.Ident if lit != nil { ma.argShift += 1 return lit.String(), true @@ -380,7 +395,7 @@ func (ma macroArgs) evalArg(ctx context.Context, n int) (Object, error) { return nil, errors.New("not enough arguments") // FIX } - return ma.eval.evalDot(ctx, ma.ec, ma.ast.InvokeArgs[ma.argShift+n]) + return ma.eval.evalArgOrDot(ctx, ma.ec, ma.ast.InvokeArgs[ma.argShift+n]) } func (ma macroArgs) evalBlock(ctx context.Context, n int, args []Object, pushScope bool) (Object, error) { @@ -670,6 +685,62 @@ func (s structProxyObject) Each(fn func(k string, v Object) error) error { return nil } +type mapProxyObject struct { + v reflect.Value + orig reflect.Value +} + +func newMapProxyObject(v reflect.Value, orig reflect.Value) structProxyObject { + return structProxyObject{ + v: v, + orig: orig, + } +} + +func (s mapProxyObject) String() string { + return fmt.Sprintf("mapProxyObject{%v}", s.v.Type()) +} + +func (p mapProxyObject) Truthy() bool { + return p.v.Len() > 0 +} + +func (p mapProxyObject) Len() int { + return p.v.Len() +} + +func (p mapProxyObject) Value(k string) Object { + val := p.v.MapIndex(reflect.ValueOf(k)) + if !val.IsValid() || val.IsZero() { + return nil + } + + e, err := fromGoValue(val.Interface()) + if err != nil { + return nil + } + return e +} + +func (s mapProxyObject) Each(fn func(k string, v Object) error) error { + for _, k := range s.v.MapKeys() { + val := k.MapIndex(reflect.ValueOf(k)) + if !val.IsValid() || val.IsZero() { + continue + } + + v, err := fromGoValue(val.Interface()) + if err != nil { + v = nil + } + + if err := fn(k.String(), v); err != nil { + return err + } + } + return nil +} + type OpaqueObject struct { v any }