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
This commit is contained in:
Leon Mika 2025-01-18 09:54:21 +11:00
parent 1632a0a294
commit fc43c2ce7d
6 changed files with 147 additions and 14 deletions

View file

@ -13,13 +13,24 @@ type astStringStringSpan struct {
Chars *string `parser:"@SingleChar"` 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 { type astDoubleStringSpan struct {
Pos lexer.Position Pos lexer.Position
Chars *string `parser:"@Char"` Chars *string `parser:"@Char"`
Escaped *string `parser:"| @Escaped"` Escaped *string `parser:"| @Escaped"`
IdentRef *string `parser:"| @IdentRef"` IdentRef *string `parser:"| @IdentRef"`
LongIdentRef *string `parser:"| @LongIdentRef"` LongIdentRef *astLongIdent `parser:"| LongIdentRef @@ LIEnd"`
SubExpr *astPipeline `parser:"| StartSubExpr @@ RP"` SubExpr *astPipeline `parser:"| StartSubExpr @@ RP"`
} }
type astDoubleString struct { type astDoubleString struct {
@ -134,10 +145,16 @@ var scanner = lexer.MustStateful(lexer.Rules{
{"Escaped", `\\.`, nil}, {"Escaped", `\\.`, nil},
{"StringEnd", `"`, lexer.Pop()}, {"StringEnd", `"`, lexer.Pop()},
{"IdentRef", `\$[-]*[a-zA-Z_][\w-]*`, nil}, {"IdentRef", `\$[-]*[a-zA-Z_][\w-]*`, nil},
{"LongIdentRef", `\$[{][^}]*[}]`, nil}, {"LongIdentRef", `\$[{]`, lexer.Push("LongIdent")},
{"StartSubExpr", `\$[(]`, lexer.Push("Root")}, {"StartSubExpr", `\$[(]`, lexer.Push("Root")},
{"Char", `[^$"\\]+`, nil}, {"Char", `[^$"\\]+`, nil},
}, },
"LongIdent": {
{"LIIdent", `[-]*[a-zA-Z_][\w-]*`, nil},
{"LIDot", `[.]`, nil},
{"LILp", `\(`, lexer.Push("Root")},
{"LIEnd", `\}`, lexer.Pop()},
},
"SingleString": { "SingleString": {
{"SingleStringEnd", `'`, lexer.Pop()}, {"SingleStringEnd", `'`, lexer.Pop()},
{"SingleChar", `[^']+`, nil}, {"SingleChar", `[^']+`, nil},

26
ucl/builtins/time.go Normal file
View file

@ -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
}

37
ucl/builtins/time_test.go Normal file
View file

@ -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)
}
})
}
}

View file

@ -3,6 +3,7 @@ package ucl
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strings" "strings"
) )
@ -299,10 +300,11 @@ func (e evaluator) interpolateDoubleQuotedString(ctx context.Context, ec *evalCt
sb.WriteString(v.String()) sb.WriteString(v.String())
} }
case n.LongIdentRef != nil: case n.LongIdentRef != nil:
identVal := (*n.LongIdentRef)[2 : len(*n.LongIdentRef)-1] v, err := e.interpolateLongIdent(ctx, ec, n.LongIdentRef)
if v, ok := ec.getVar(identVal); ok && v != nil { if err != nil {
sb.WriteString(v.String()) return nil, err
} }
sb.WriteString(v)
case n.SubExpr != nil: case n.SubExpr != nil:
res, err := e.evalPipeline(ctx, ec, n.SubExpr) res, err := e.evalPipeline(ctx, ec, n.SubExpr)
if err != nil { if err != nil {
@ -316,6 +318,38 @@ func (e evaluator) interpolateDoubleQuotedString(ctx context.Context, ec *evalCt
return StringObject(sb.String()), nil 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) { func (e evaluator) evalSub(ctx context.Context, ec *evalCtx, n *astPipeline) (Object, error) {
pipelineRes, err := e.evalPipeline(ctx, ec, n) pipelineRes, err := e.evalPipeline(ctx, ec, n)
if err != nil { if err != nil {

View file

@ -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 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 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 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 4", expr: `set crazy [far: "unknown"] ; firstarg "hello ${crazy.far}"`, want: "hello unknown"},
{desc: "interpolate string 5", expr: `set what "world" ; firstarg "hello $($what)"`, want: "hello world"}, {desc: "interpolate string 5", expr: `set oldWords ["hither" "thither" "yonder"] ; firstarg "hello ${oldWords.(1)}"`, want: "hello thither"},
{desc: "interpolate string 6", expr: `firstarg "hello $([1 2 3] | len)"`, want: "hello 3"}, {desc: "interpolate string 6", expr: `set oldWords ["hither" "thither" "yonder"] ; firstarg "hello ${oldWords.(add 1 1)}"`, want: "hello yonder"},
{desc: "interpolate string 7", expr: `firstarg "hello $(add (add 1 2) 3)"`, want: "hello 6"}, {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: `firstarg ("$(add 2 (add 1 1)) + $([1 2 3].(1) | cat ("$("")")) = $(("$(add 2 (4))"))")`, want: "4 + 2 = 6"}, {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 // Sub-expressions
{desc: "sub expression 1", expr: `firstarg (sjoin "hello")`, want: "hello"}, {desc: "sub expression 1", expr: `firstarg (sjoin "hello")`, want: "hello"},

View file

@ -7,6 +7,7 @@ import (
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/lmika/gopkgs/fp/slices" "github.com/lmika/gopkgs/fp/slices"
) )
@ -124,6 +125,16 @@ func (b boolObject) Truthy() bool {
return bool(b) 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) { func toGoValue(obj Object) (interface{}, bool) {
switch v := obj.(type) { switch v := obj.(type) {
case OpaqueObject: case OpaqueObject:
@ -136,6 +147,8 @@ func toGoValue(obj Object) (interface{}, bool) {
return int(v), true return int(v), true
case boolObject: case boolObject:
return bool(v), true return bool(v), true
case timeObject:
return time.Time(v), true
case listObject: case listObject:
xs := make([]interface{}, 0, len(v)) xs := make([]interface{}, 0, len(v))
for _, va := range v { for _, va := range v {
@ -181,6 +194,8 @@ func fromGoValue(v any) (Object, error) {
return intObject(t), nil return intObject(t), nil
case bool: case bool:
return boolObject(t), nil return boolObject(t), nil
case time.Time:
return timeObject(t), nil
} }
return fromGoReflectValue(reflect.ValueOf(v)) return fromGoReflectValue(reflect.ValueOf(v))