From fc43c2ce7dc503beb953f006db27b05166ae9591 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 18 Jan 2025 09:54:21 +1100 Subject: [PATCH] Added subscript support for long var interpolation - Modified long var interpolation to support dot lookups - Added a time:from-unix function and added time.Time as an object --- ucl/ast.go | 29 ++++++++++++++++++++++------ ucl/builtins/time.go | 26 +++++++++++++++++++++++++ ucl/builtins/time_test.go | 37 ++++++++++++++++++++++++++++++++++++ ucl/eval.go | 40 ++++++++++++++++++++++++++++++++++++--- ucl/inst_test.go | 14 +++++++++----- ucl/objs.go | 15 +++++++++++++++ 6 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 ucl/builtins/time.go create mode 100644 ucl/builtins/time_test.go diff --git a/ucl/ast.go b/ucl/ast.go index 44efd35..14106be 100644 --- a/ucl/ast.go +++ b/ucl/ast.go @@ -13,13 +13,24 @@ type astStringStringSpan struct { Chars *string `parser:"@SingleChar"` } +type astLongIdentDotSuffix struct { + KeyName *string `parser:"@LIIdent"` + Pipeline *astPipeline `parser:"| LILp @@ RP"` +} + +type astLongIdent struct { + Pos lexer.Position + VarName string `parser:"@LIIdent"` + DotSuffix []astLongIdentDotSuffix `parser:"( LIDot @@ )*"` +} + type astDoubleStringSpan struct { Pos lexer.Position - Chars *string `parser:"@Char"` - Escaped *string `parser:"| @Escaped"` - IdentRef *string `parser:"| @IdentRef"` - LongIdentRef *string `parser:"| @LongIdentRef"` - SubExpr *astPipeline `parser:"| StartSubExpr @@ RP"` + Chars *string `parser:"@Char"` + Escaped *string `parser:"| @Escaped"` + IdentRef *string `parser:"| @IdentRef"` + LongIdentRef *astLongIdent `parser:"| LongIdentRef @@ LIEnd"` + SubExpr *astPipeline `parser:"| StartSubExpr @@ RP"` } type astDoubleString struct { @@ -134,10 +145,16 @@ var scanner = lexer.MustStateful(lexer.Rules{ {"Escaped", `\\.`, nil}, {"StringEnd", `"`, lexer.Pop()}, {"IdentRef", `\$[-]*[a-zA-Z_][\w-]*`, nil}, - {"LongIdentRef", `\$[{][^}]*[}]`, nil}, + {"LongIdentRef", `\$[{]`, lexer.Push("LongIdent")}, {"StartSubExpr", `\$[(]`, lexer.Push("Root")}, {"Char", `[^$"\\]+`, nil}, }, + "LongIdent": { + {"LIIdent", `[-]*[a-zA-Z_][\w-]*`, nil}, + {"LIDot", `[.]`, nil}, + {"LILp", `\(`, lexer.Push("Root")}, + {"LIEnd", `\}`, lexer.Pop()}, + }, "SingleString": { {"SingleStringEnd", `'`, lexer.Pop()}, {"SingleChar", `[^']+`, nil}, diff --git a/ucl/builtins/time.go b/ucl/builtins/time.go new file mode 100644 index 0000000..bfbf495 --- /dev/null +++ b/ucl/builtins/time.go @@ -0,0 +1,26 @@ +package builtins + +import ( + "context" + "time" + "ucl.lmika.dev/ucl" +) + +func Time() ucl.Module { + return ucl.Module{ + Name: "time", + Builtins: map[string]ucl.BuiltinHandler{ + "from-unix": timeFromUnix, + }, + } +} + +func timeFromUnix(ctx context.Context, args ucl.CallArgs) (any, error) { + var ux int + + if err := args.Bind(&ux); err != nil { + return nil, err + } + + return time.Unix(int64(ux), 0).UTC(), nil +} diff --git a/ucl/builtins/time_test.go b/ucl/builtins/time_test.go new file mode 100644 index 0000000..2e14c59 --- /dev/null +++ b/ucl/builtins/time_test.go @@ -0,0 +1,37 @@ +package builtins_test + +import ( + "context" + "github.com/stretchr/testify/assert" + "testing" + "time" + "ucl.lmika.dev/ucl" + "ucl.lmika.dev/ucl/builtins" +) + +func TestTime_FromUnix(t *testing.T) { + tests := []struct { + desc string + eval string + want any + wantErr bool + }{ + {desc: "from unix 1", eval: `time:from-unix 0`, want: time.Unix(0, 0).UTC()}, + {desc: "from unix 2", eval: `time:from-unix 0 | cat`, want: "1970-01-01T00:00:00Z"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + inst := ucl.New( + ucl.WithModule(builtins.Time()), + ) + 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/eval.go b/ucl/eval.go index 6edea36..ad33e63 100644 --- a/ucl/eval.go +++ b/ucl/eval.go @@ -3,6 +3,7 @@ package ucl import ( "context" "errors" + "fmt" "strings" ) @@ -299,10 +300,11 @@ func (e evaluator) interpolateDoubleQuotedString(ctx context.Context, ec *evalCt sb.WriteString(v.String()) } case n.LongIdentRef != nil: - identVal := (*n.LongIdentRef)[2 : len(*n.LongIdentRef)-1] - if v, ok := ec.getVar(identVal); ok && v != nil { - sb.WriteString(v.String()) + v, err := e.interpolateLongIdent(ctx, ec, n.LongIdentRef) + if err != nil { + return nil, err } + sb.WriteString(v) case n.SubExpr != nil: res, err := e.evalPipeline(ctx, ec, n.SubExpr) if err != nil { @@ -316,6 +318,38 @@ func (e evaluator) interpolateDoubleQuotedString(ctx context.Context, ec *evalCt return StringObject(sb.String()), nil } +func (e evaluator) interpolateLongIdent(ctx context.Context, ec *evalCtx, n *astLongIdent) (_ string, err error) { + res, ok := ec.getVar(n.VarName) + if !ok { + return "", nil + } + + for _, dot := range n.DotSuffix { + if res == nil { + return "", errorWithPos{fmt.Errorf("attempt to get field from nil value '%v'", n.VarName), n.Pos} + } + + var idx Object + if dot.KeyName != nil { + idx = StringObject(*dot.KeyName) + } else { + idx, err = e.evalPipeline(ctx, ec, dot.Pipeline) + if err != nil { + return "", err + } + } + + res, err = indexLookup(ctx, res, idx) + if err != nil { + return "", err + } + } + if res == nil { + return "", nil + } + return res.String(), nil +} + func (e evaluator) evalSub(ctx context.Context, ec *evalCtx, n *astPipeline) (Object, error) { pipelineRes, err := e.evalPipeline(ctx, ec, n) if err != nil { diff --git a/ucl/inst_test.go b/ucl/inst_test.go index 93beb80..1da27e5 100644 --- a/ucl/inst_test.go +++ b/ucl/inst_test.go @@ -25,11 +25,15 @@ func TestInst_Eval(t *testing.T) { {desc: "interpolate string 1", expr: `set what "world" ; firstarg "hello $what"`, want: "hello world"}, {desc: "interpolate string 2", expr: `set what "world" ; set when "now" ; firstarg "$when, hello $what"`, want: "now, hello world"}, {desc: "interpolate string 3", expr: `set what "world" ; set when "now" ; firstarg "${when}, hello ${what}"`, want: "now, hello world"}, - {desc: "interpolate string 4", expr: `set "crazy var" "unknown" ; firstarg "hello ${crazy var}"`, want: "hello unknown"}, - {desc: "interpolate string 5", expr: `set what "world" ; firstarg "hello $($what)"`, want: "hello world"}, - {desc: "interpolate string 6", expr: `firstarg "hello $([1 2 3] | len)"`, want: "hello 3"}, - {desc: "interpolate string 7", expr: `firstarg "hello $(add (add 1 2) 3)"`, want: "hello 6"}, - {desc: "interpolate string 8", expr: `firstarg ("$(add 2 (add 1 1)) + $([1 2 3].(1) | cat ("$("")")) = $(("$(add 2 (4))"))")`, want: "4 + 2 = 6"}, + {desc: "interpolate string 4", expr: `set crazy [far: "unknown"] ; firstarg "hello ${crazy.far}"`, want: "hello unknown"}, + {desc: "interpolate string 5", expr: `set oldWords ["hither" "thither" "yonder"] ; firstarg "hello ${oldWords.(1)}"`, want: "hello thither"}, + {desc: "interpolate string 6", expr: `set oldWords ["hither" "thither" "yonder"] ; firstarg "hello ${oldWords.(add 1 1)}"`, want: "hello yonder"}, + {desc: "interpolate string 7", expr: `set oldWords ["hither" "thither" "yonder"] ; firstarg "hello ${oldWords.(add 2 | sub (sub 2 1) | sub 1)}"`, want: "hello hither"}, + {desc: "interpolate string 8", expr: `set words ["old": ["hither" "thither" "yonder"] "new": ["near" "far"]] ; firstarg "hello ${words.old.(2)}"`, want: "hello yonder"}, + {desc: "interpolate string 9", expr: `set what "world" ; firstarg "hello $($what)"`, want: "hello world"}, + {desc: "interpolate string 10", expr: `firstarg "hello $([1 2 3] | len)"`, want: "hello 3"}, + {desc: "interpolate string 11", expr: `firstarg "hello $(add (add 1 2) 3)"`, want: "hello 6"}, + {desc: "interpolate string 12", expr: `firstarg ("$(add 2 (add 1 1)) + $([1 2 3].(1) | cat ("$("")")) = $(("$(add 2 (4))"))")`, want: "4 + 2 = 6"}, // Sub-expressions {desc: "sub expression 1", expr: `firstarg (sjoin "hello")`, want: "hello"}, diff --git a/ucl/objs.go b/ucl/objs.go index faed869..a533d99 100644 --- a/ucl/objs.go +++ b/ucl/objs.go @@ -7,6 +7,7 @@ import ( "reflect" "strconv" "strings" + "time" "github.com/lmika/gopkgs/fp/slices" ) @@ -124,6 +125,16 @@ func (b boolObject) Truthy() bool { return bool(b) } +type timeObject time.Time + +func (t timeObject) String() string { + return time.Time(t).Format(time.RFC3339) +} + +func (t timeObject) Truthy() bool { + return !time.Time(t).IsZero() +} + func toGoValue(obj Object) (interface{}, bool) { switch v := obj.(type) { case OpaqueObject: @@ -136,6 +147,8 @@ func toGoValue(obj Object) (interface{}, bool) { return int(v), true case boolObject: return bool(v), true + case timeObject: + return time.Time(v), true case listObject: xs := make([]interface{}, 0, len(v)) for _, va := range v { @@ -181,6 +194,8 @@ func fromGoValue(v any) (Object, error) { return intObject(t), nil case bool: return boolObject(t), nil + case time.Time: + return timeObject(t), nil } return fromGoReflectValue(reflect.ValueOf(v))