diff --git a/ucl/ast.go b/ucl/ast.go index 97374cc..8b118ad 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 5d1ac79..ca20469 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 8e18095..71575c4 100644 --- a/ucl/objs.go +++ b/ucl/objs.go @@ -7,34 +7,35 @@ import ( "reflect" "strconv" "strings" + "time" "github.com/lmika/gopkgs/fp/slices" ) -type object interface { +type Object interface { String() string Truthy() bool } -type listable interface { +type Listable interface { Len() int - Index(i int) object + Index(i int) Object } type hashable interface { Len() int - Value(k string) object - Each(func(k string, v object) error) error + Value(k string) Object + Each(func(k string, v Object) error) error } -type listObject []object +type listObject []Object -func (lo *listObject) Append(o object) { +func (lo *listObject) Append(o Object) { *lo = append(*lo, o) } func (s listObject) String() string { - return fmt.Sprintf("%v", []object(s)) + return fmt.Sprintf("%v", []Object(s)) } func (s listObject) Truthy() bool { @@ -45,11 +46,11 @@ func (s listObject) Len() int { return len(s) } -func (s listObject) Index(i int) object { +func (s listObject) Index(i int) Object { return s[i] } -type hashObject map[string]object +type hashObject map[string]Object func (s hashObject) String() string { if len(s) == 0 { @@ -78,11 +79,11 @@ func (s hashObject) Len() int { return len(s) } -func (s hashObject) Value(k string) object { +func (s hashObject) Value(k string) Object { return s[k] } -func (s hashObject) Each(fn func(k string, v object) error) error { +func (s hashObject) Each(fn func(k string, v Object) error) error { for k, v := range s { if err := fn(k, v); err != nil { return err @@ -91,13 +92,13 @@ func (s hashObject) Each(fn func(k string, v object) error) error { return nil } -type strObject string +type StringObject string -func (s strObject) String() string { +func (s StringObject) String() string { return string(s) } -func (s strObject) Truthy() bool { +func (s StringObject) Truthy() bool { return string(s) != "" } @@ -124,16 +125,28 @@ func (b boolObject) Truthy() bool { return bool(b) } -func toGoValue(obj object) (interface{}, bool) { +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 nil: return nil, true - case strObject: + case StringObject: return string(v), true case intObject: 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 { @@ -157,42 +170,62 @@ func toGoValue(obj object) (interface{}, bool) { case proxyObject: return v.p, true case listableProxyObject: - return v.v.Interface(), true + return v.orig.Interface(), true case structProxyObject: - return v.v.Interface(), true + return v.orig.Interface(), true } return nil, false } -func fromGoValue(v any) (object, error) { +func fromGoValue(v any) (Object, error) { switch t := v.(type) { + case Object: + return t, nil case nil: return nil, nil case string: - return strObject(t), nil + return StringObject(t), nil case int: return intObject(t), nil case bool: return boolObject(t), nil + case time.Time: + return timeObject(t), nil + } + + return fromGoReflectValue(reflect.ValueOf(v)) +} + +func fromGoReflectValue(resVal reflect.Value) (Object, error) { + if !resVal.IsValid() { + return nil, nil } - resVal := reflect.ValueOf(v) switch resVal.Kind() { case reflect.Slice: - return listableProxyObject{resVal}, nil + return listableProxyObject{v: resVal, orig: resVal}, nil case reflect.Struct: - return newStructProxyObject(resVal), nil + return newStructProxyObject(resVal, resVal), nil + case reflect.Pointer: + switch resVal.Elem().Kind() { + case reflect.Slice: + return listableProxyObject{v: resVal.Elem(), orig: resVal}, nil + case reflect.Struct: + return newStructProxyObject(resVal.Elem(), resVal), nil + } + + return fromGoReflectValue(resVal.Elem()) } - return proxyObject{v}, nil + return proxyObject{resVal.Interface()}, nil } type macroArgs struct { eval evaluator ec *evalCtx hasPipe bool - pipeArg object + pipeArg Object ast *astCmd argShift int } @@ -239,7 +272,7 @@ func (ma *macroArgs) shiftIdent(ctx context.Context) (string, bool) { return "", false } -func (ma macroArgs) evalArg(ctx context.Context, n int) (object, error) { +func (ma macroArgs) evalArg(ctx context.Context, n int) (Object, error) { if n >= len(ma.ast.Args[ma.argShift:]) { return nil, errors.New("not enough arguments") // FIX } @@ -247,7 +280,7 @@ func (ma macroArgs) evalArg(ctx context.Context, n int) (object, error) { return ma.eval.evalDot(ctx, ma.ec, ma.ast.Args[ma.argShift+n]) } -func (ma macroArgs) evalBlock(ctx context.Context, n int, args []object, pushScope bool) (object, error) { +func (ma macroArgs) evalBlock(ctx context.Context, n int, args []Object, pushScope bool) (Object, error) { obj, err := ma.evalArg(ctx, n) if err != nil { return nil, err @@ -266,7 +299,7 @@ func (ma macroArgs) evalBlock(ctx context.Context, n int, args []object, pushSco } return ma.eval.evalBlock(ctx, ec, v.block) - case strObject: + case StringObject: iv := ma.ec.lookupInvokable(string(v)) if iv == nil { return nil, errors.New("'" + string(v) + "' is not invokable") @@ -297,7 +330,7 @@ type invocationArgs struct { eval evaluator inst *Inst ec *evalCtx - args []object + args []Object kwargs map[string]*listObject } @@ -340,7 +373,7 @@ func (ia invocationArgs) invokableArg(i int) (invokable, error) { switch v := ia.args[i].(type) { case invokable: return v, nil - case strObject: + case StringObject: iv := ia.ec.lookupInvokable(string(v)) if iv == nil { return nil, errors.New("'" + string(v) + "' is not invokable") @@ -350,7 +383,7 @@ func (ia invocationArgs) invokableArg(i int) (invokable, error) { return nil, errors.New("expected an invokable arg") } -func (ia invocationArgs) fork(args []object) invocationArgs { +func (ia invocationArgs) fork(args []Object) invocationArgs { return invocationArgs{ eval: ia.eval, inst: ia.inst, @@ -373,27 +406,28 @@ func (ia invocationArgs) shift(i int) invocationArgs { } } -// invokable is an object that can be executed as a command +// invokable is an Object that can be executed as a command type invokable interface { - invoke(ctx context.Context, args invocationArgs) (object, error) + invoke(ctx context.Context, args invocationArgs) (Object, error) } type macroable interface { - invokeMacro(ctx context.Context, args macroArgs) (object, error) + invokeMacro(ctx context.Context, args macroArgs) (Object, error) } type pipeInvokable interface { invokable } -type invokableFunc func(ctx context.Context, args invocationArgs) (object, error) +type invokableFunc func(ctx context.Context, args invocationArgs) (Object, error) -func (i invokableFunc) invoke(ctx context.Context, args invocationArgs) (object, error) { +func (i invokableFunc) invoke(ctx context.Context, args invocationArgs) (Object, error) { return i(ctx, args) } type blockObject struct { - block *astBlock + block *astBlock + closedEC *evalCtx } func (bo blockObject) String() string { @@ -404,8 +438,8 @@ func (bo blockObject) Truthy() bool { return len(bo.block.Statements) > 0 } -func (bo blockObject) invoke(ctx context.Context, args invocationArgs) (object, error) { - ec := args.ec.fork() +func (bo blockObject) invoke(ctx context.Context, args invocationArgs) (Object, error) { + ec := bo.closedEC.fork() for i, n := range bo.block.Names { if i < len(args.args) { ec.setOrDefineVar(n, args.args[i]) @@ -415,13 +449,13 @@ func (bo blockObject) invoke(ctx context.Context, args invocationArgs) (object, return args.eval.evalBlock(ctx, ec, bo.block) } -type macroFunc func(ctx context.Context, args macroArgs) (object, error) +type macroFunc func(ctx context.Context, args macroArgs) (Object, error) -func (i macroFunc) invokeMacro(ctx context.Context, args macroArgs) (object, error) { +func (i macroFunc) invokeMacro(ctx context.Context, args macroArgs) (Object, error) { return i(ctx, args) } -func isTruthy(obj object) bool { +func isTruthy(obj Object) bool { if obj == nil { return false } @@ -441,7 +475,8 @@ func (p proxyObject) Truthy() bool { } type listableProxyObject struct { - v reflect.Value + v reflect.Value + orig reflect.Value } func (p listableProxyObject) String() string { @@ -456,7 +491,7 @@ func (p listableProxyObject) Len() int { return p.v.Len() } -func (p listableProxyObject) Index(i int) object { +func (p listableProxyObject) Index(i int) Object { e, err := fromGoValue(p.v.Index(i).Interface()) if err != nil { return nil @@ -465,14 +500,16 @@ func (p listableProxyObject) Index(i int) object { } type structProxyObject struct { - v reflect.Value - vf []reflect.StructField + v reflect.Value + orig reflect.Value + vf []reflect.StructField } -func newStructProxyObject(v reflect.Value) structProxyObject { +func newStructProxyObject(v reflect.Value, orig reflect.Value) structProxyObject { return structProxyObject{ - v: v, - vf: slices.Filter(reflect.VisibleFields(v.Type()), func(t reflect.StructField) bool { return t.IsExported() }), + v: v, + orig: orig, + vf: slices.Filter(reflect.VisibleFields(v.Type()), func(t reflect.StructField) bool { return t.IsExported() }), } } @@ -488,7 +525,7 @@ func (s structProxyObject) Len() int { return len(s.vf) } -func (s structProxyObject) Value(k string) object { +func (s structProxyObject) Value(k string) Object { f := s.v.FieldByName(k) if !f.IsValid() { return nil @@ -508,7 +545,7 @@ func (s structProxyObject) Value(k string) object { return e } -func (s structProxyObject) Each(fn func(k string, v object) error) error { +func (s structProxyObject) Each(fn func(k string, v Object) error) error { for _, f := range s.vf { v, err := fromGoValue(s.v.FieldByName(f.Name).Interface()) if err != nil { @@ -522,9 +559,25 @@ func (s structProxyObject) Each(fn func(k string, v object) error) error { return nil } +type OpaqueObject struct { + v any +} + +func Opaque(v any) OpaqueObject { + return OpaqueObject{v: v} +} + +func (p OpaqueObject) String() string { + return fmt.Sprintf("opaque{%T}", p.v) +} + +func (p OpaqueObject) Truthy() bool { + return p.v != nil +} + type errBreak struct { isCont bool - ret object + ret Object } func (e errBreak) Error() string { @@ -535,7 +588,7 @@ func (e errBreak) Error() string { } type errReturn struct { - ret object + ret Object } func (e errReturn) Error() string {