Added single dots
All checks were successful
Build / build (push) Successful in 2m3s

This commit is contained in:
Leon Mika 2026-03-15 10:06:00 +11:00
parent 33cf23b221
commit 23f730fb2f
7 changed files with 376 additions and 35 deletions

0
.beads/issues.jsonl Normal file
View file

View file

@ -63,8 +63,8 @@ func (ai *astIdentNames) String() string {
} }
type astElementPair struct { type astElementPair struct {
Left astDot `parser:"@@"` Left astArgOrJustDot `parser:"@@"`
Right *astDot `parser:"( COLON @@ )? NL?"` Right *astArgOrJustDot `parser:"( COLON @@ )? NL?"`
} }
type astListOrHash struct { type astListOrHash struct {
@ -97,16 +97,26 @@ type astDotSuffix struct {
Pipeline *astPipeline `parser:"| LP @@ RP"` Pipeline *astPipeline `parser:"| LP @@ RP"`
} }
type astDot struct { type astArgOrJustDot struct {
Arg *astArgDotSuffix `parser:"@@"`
Dot *astJustDotSuffix `parser:"| @@"`
}
type astArgDotSuffix struct {
Pos lexer.Position Pos lexer.Position
Arg astCmdArg `parser:"@@"` Arg astCmdArg `parser:"@@"`
DotSuffix []astDotSuffix `parser:"( DOT @@ )*"` DotSuffix []astDotSuffix `parser:"( DOT @@ )*"`
} }
type astJustDotSuffix struct {
Pos lexer.Position
DotSuffix []astDotSuffix `parser:" DOT ( @@ ( DOT @@ )* )?"`
}
type astCmd struct { type astCmd struct {
Pos lexer.Position Pos lexer.Position
Name astDot `parser:"@@"` Name astArgOrJustDot `parser:"@@"`
InvokeArgs []astDot `parser:"@@*"` InvokeArgs []astArgOrJustDot `parser:"@@*"`
} }
type astPipeline struct { type astPipeline struct {

View file

@ -2,7 +2,9 @@ package builtins
import ( import (
"context" "context"
"errors"
"time" "time"
"ucl.lmika.dev/ucl" "ucl.lmika.dev/ucl"
) )
@ -11,6 +13,8 @@ func Time() ucl.Module {
Name: "time", Name: "time",
Builtins: map[string]ucl.BuiltinHandler{ Builtins: map[string]ucl.BuiltinHandler{
"from-unix": timeFromUnix, "from-unix": timeFromUnix,
"to-unix": timeToUnix,
"now": timeNow,
"sleep": timeSleep, "sleep": timeSleep,
}, },
} }
@ -26,6 +30,23 @@ func timeFromUnix(ctx context.Context, args ucl.CallArgs) (any, error) {
return time.Unix(int64(ux), 0).UTC(), nil 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) { func timeSleep(ctx context.Context, args ucl.CallArgs) (any, error) {
var secs int var secs int
@ -40,3 +61,17 @@ func timeSleep(ctx context.Context, args ucl.CallArgs) (any, error) {
return nil, ctx.Err() 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
}

View file

@ -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) { func TestTime_Sleep(t *testing.T) {
t.Run("should terminate on cancelled context", func(t *testing.T) { t.Run("should terminate on cancelled context", func(t *testing.T) {
st := time.Now() st := time.Now()

View file

@ -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) { func (e evaluator) evalCmd(ctx context.Context, ec *evalCtx, currentPipe Object, ast *astCmd) (Object, error) {
switch { if name, ok := e.isArgOrDotAnIdent(ast.Name); ok {
case (ast.Name.Arg.Ident != nil) && len(ast.Name.DotSuffix) == 0:
name := ast.Name.Arg.Ident.String()
// Regular command // Regular command
if cmd := ec.lookupInvokable(name); cmd != nil { if cmd := ec.lookupInvokable(name); cmd != nil {
return e.evalInvokable(ctx, ec, currentPipe, ast, cmd) return e.evalInvokable(ctx, ec, currentPipe, ast, cmd)
@ -117,8 +114,8 @@ func (e evaluator) evalCmd(ctx context.Context, ec *evalCtx, currentPipe Object,
} else { } else {
return nil, errors.New("unknown command: " + name) return nil, errors.New("unknown command: " + name)
} }
case len(ast.InvokeArgs) > 0: } else if len(ast.InvokeArgs) > 0 {
nameElem, err := e.evalDot(ctx, ec, ast.Name) nameElem, err := e.evalArgOrDot(ctx, ec, ast.Name)
if err != nil { if err != nil {
return nil, err 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) 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 { if err != nil {
return nil, err return nil, err
} }
@ -142,7 +139,25 @@ func (e evaluator) assignCmd(ctx context.Context, ec *evalCtx, ast *astCmd, toVa
if len(ast.InvokeArgs) != 0 { if len(ast.InvokeArgs) != 0 {
return nil, errors.New("cannot assign to multiple values") 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) { 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) argsPtr.Append(currentPipe)
} }
for _, arg := range ast.InvokeArgs { 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 // Arg switch
if kwargs == nil { if kwargs == nil {
kwargs = make(map[string]*ListObject) kwargs = make(map[string]*ListObject)
} }
argsPtr = &ListObject{} argsPtr = &ListObject{}
kwargs[ident.String()[1:]] = argsPtr kwargs[ident[1:]] = argsPtr
} else { } else {
ae, err := e.evalDot(ctx, ec, arg) ae, err := e.evalArgOrDot(ctx, ec, arg)
if err != nil { if err != nil {
return nil, err 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) res, err := e.evalArg(ctx, ec, n.Arg)
if err != nil { if err != nil {
return nil, err return nil, err
@ -215,7 +278,16 @@ func (e evaluator) evalDot(ctx context.Context, ec *evalCtx, n astDot) (Object,
return res, nil 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 { if len(n.DotSuffix) == 0 {
return e.assignArg(ctx, ec, n.Arg, toVal) 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 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) { 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) // 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 // 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 return nil, nil
case n.PseudoVar != nil: case n.PseudoVar != nil:
if v, ok := ec.getPseudoVar(*n.PseudoVar); ok { return e.evalPseudoVar(ctx, ec, *n.PseudoVar)
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)
case n.MaybeSub != nil: case n.MaybeSub != nil:
sub := n.MaybeSub.Sub sub := n.MaybeSub.Sub
if sub == nil { 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") 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 { if err != nil {
return nil, err return nil, err
} }
v, err := e.evalDot(ctx, ec, *el.Right) v, err := e.evalArgOrDot(ctx, ec, *el.Right)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -371,7 +485,7 @@ func (e evaluator) evalListOrHash(ctx context.Context, ec *evalCtx, loh *astList
if el.Right != nil { if el.Right != nil {
return nil, errors.New("miss-match of lists and hash") 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 { if err != nil {
return nil, err return nil, err
} }

View file

@ -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 = ` var parseComments1 = `
proc lookup { |file| proc lookup { |file|
foreach { |toks| foreach { |toks|
@ -359,6 +403,19 @@ func (s *stringPseudoVar) Set(ctx context.Context, v any) error {
return nil 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{} type missingPseudoVarType struct{}
func (missingPseudoVarType) Get(ctx context.Context, name string) (any, error) { func (missingPseudoVarType) Get(ctx context.Context, name string) (any, error) {

View file

@ -308,6 +308,11 @@ func fromGoReflectValue(resVal reflect.Value) (Object, error) {
switch resVal.Kind() { switch resVal.Kind() {
case reflect.Slice: case reflect.Slice:
return listableProxyObject{v: resVal, orig: resVal}, nil 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: case reflect.Struct:
return newStructProxyObject(resVal, resVal), nil return newStructProxyObject(resVal, resVal), nil
case reflect.Pointer: case reflect.Pointer:
@ -346,11 +351,16 @@ func (ma macroArgs) identIs(ctx context.Context, n int, expectedIdent string) bo
return false 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 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 { if lit == nil {
return false return false
} }
@ -363,11 +373,16 @@ func (ma *macroArgs) shiftIdent(ctx context.Context) (string, bool) {
return "", false return "", false
} }
if len(ma.ast.InvokeArgs[ma.argShift].DotSuffix) != 0 { arg := ma.ast.InvokeArgs[ma.argShift].Arg
if arg == nil {
return "", false return "", false
} }
lit := ma.ast.InvokeArgs[ma.argShift].Arg.Ident if len(arg.DotSuffix) != 0 {
return "", false
}
lit := arg.Arg.Ident
if lit != nil { if lit != nil {
ma.argShift += 1 ma.argShift += 1
return lit.String(), true 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 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) { 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 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 { type OpaqueObject struct {
v any v any
} }