Skip to content

Commit

Permalink
pkg/eval/vals: Integrate ScanMapToGo into ScanToGo.
Browse files Browse the repository at this point in the history
Also export getFieldMapKeys and use it to implement pkg/eval.scanOptions.

These changes establish field maps as a unified concept when converting between
Elvish maps and Go data structs.
  • Loading branch information
xiaq committed Aug 21, 2024
1 parent 7780036 commit 36e5070
Show file tree
Hide file tree
Showing 16 changed files with 256 additions and 176 deletions.
21 changes: 10 additions & 11 deletions pkg/eval/builtin_fn_time.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,38 +90,38 @@ type benchmarkOpts struct {
OnRunEnd Callable
MinRuns int
MinTime string
minTime time.Duration
}

func (o *benchmarkOpts) SetDefaultOptions() {
o.MinRuns = 5
o.minTime = time.Second
}

func (opts *benchmarkOpts) parse() error {
func (opts *benchmarkOpts) parse() (time.Duration, error) {
if opts.MinRuns < 0 {
return errs.BadValue{What: "min-runs option",
return 0, errs.BadValue{What: "min-runs option",
Valid: "non-negative integer", Actual: strconv.Itoa(opts.MinRuns)}
}

if opts.MinTime != "" {
d, err := time.ParseDuration(opts.MinTime)
if err != nil {
return errs.BadValue{What: "min-time option",
return 0, errs.BadValue{What: "min-time option",
Valid: "duration string", Actual: parse.Quote(opts.MinTime)}
}
if d < 0 {
return errs.BadValue{What: "min-time option",
return 0, errs.BadValue{What: "min-time option",
Valid: "non-negative duration", Actual: parse.Quote(opts.MinTime)}
}
opts.minTime = d
return d, nil
}

return nil
// Use 1s as the default minTime.
return time.Second, nil
}

func benchmark(fm *Frame, opts benchmarkOpts, f Callable) error {
if err := opts.parse(); err != nil {
minTime, err := opts.parse()
if err != nil {
return err
}

Expand All @@ -132,7 +132,6 @@ func benchmark(fm *Frame, opts benchmarkOpts, f Callable) error {
runs int64
total time.Duration
m2 float64
err error
)
for {
t0 := timeNow()
Expand Down Expand Up @@ -167,7 +166,7 @@ func benchmark(fm *Frame, opts benchmarkOpts, f Callable) error {
}
}

if runs >= int64(opts.MinRuns) && total >= opts.minTime {
if runs >= int64(opts.MinRuns) && total >= minTime {
break
}
}
Expand Down
40 changes: 27 additions & 13 deletions pkg/eval/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package eval

import (
"reflect"
"slices"

"src.elv.sh/pkg/eval/vals"
"src.elv.sh/pkg/parse"
Expand All @@ -22,26 +23,39 @@ func (e UnknownOption) Error() string {
// details.
type RawOptions map[string]any

// Takes a raw option map and a pointer to a struct, and populate the struct
// with options. A field named FieldName corresponds to the option named
// field-name. Options that don't have corresponding fields in the struct causes
// an error.
// Takes a raw option map and a pointer to a field-map struct, populates the
// struct with options.
//
// Similar to vals.ScanMapToGo, but requires rawOpts to contain a subset of keys
// supported by the struct.
// Similar to vals.ScanToGoOpts(rawOpts, ptr, vals.AllowMissingMapKey), except
// that rawOpts is a Go map rather than an Elvish map.
func scanOptions(rawOpts RawOptions, ptr any) error {
_, keyIdx := vals.StructFieldsInfo(reflect.TypeOf(ptr).Elem())
structValue := reflect.ValueOf(ptr).Elem()
for k, v := range rawOpts {
fieldIdx, ok := keyIdx[k]
dstValue := reflect.ValueOf(ptr).Elem()
keys := vals.GetFieldMapKeys(dstValue.Interface())
findUnknownOption := func() error {
for key := range rawOpts {
if !slices.Contains(keys, key) {
return UnknownOption{key}
}
}
panic("unreachable")
}
if len(rawOpts) > len(keys) {
return findUnknownOption()
}
usedOpts := 0
for i, key := range keys {
value, ok := rawOpts[key]
if !ok {
return UnknownOption{k}
continue
}

err := vals.ScanToGo(v, structValue.Field(fieldIdx).Addr().Interface())
err := vals.ScanToGo(value, dstValue.Field(i).Addr().Interface())
if err != nil {
return err
}
usedOpts++
}
if len(rawOpts) > usedOpts {
return findUnknownOption()
}
return nil
}
5 changes: 2 additions & 3 deletions pkg/eval/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ var Args = tt.Args

type opts struct {
Foo string
bar int
}

// Equal is required by cmp.Diff, since opts contains unexported fields.
Expand All @@ -29,7 +28,7 @@ func TestScanOptions(t *testing.T) {
tt.Test(t, tt.Fn(wrapper).Named("scanOptions"),
Args(RawOptions{"foo": "lorem ipsum"}, opts{}).
Rets(opts{Foo: "lorem ipsum"}, nil),
Args(RawOptions{"bar": 20}, opts{bar: 10}).
Rets(opts{bar: 10}, UnknownOption{"bar"}),
Args(RawOptions{"bar": 20}, opts{}).
Rets(opts{}, UnknownOption{"bar"}),
)
}
2 changes: 1 addition & 1 deletion pkg/eval/vals/assoc.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func Assoc(a, k, v any) (any, error) {
case Assocer:
return a.Assoc(k, v)
default:
if keys := getFieldMapKeys(a); keys != nil {
if keys := GetFieldMapKeys(a); keys != nil {
return promoteFieldMapToMap(a, keys).Assoc(k, v), nil
}
}
Expand Down
154 changes: 83 additions & 71 deletions pkg/eval/vals/conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@ import (
"math/big"
"reflect"
"strconv"
"sync"
"strings"
"unicode/utf8"

"src.elv.sh/pkg/eval/errs"
"src.elv.sh/pkg/strutil"
)

// WrongType is returned by ScanToGo if the source value doesn't have a
Expand Down Expand Up @@ -42,11 +41,19 @@ var (
errMustBeInteger = errors.New("must be integer")
)

// ScanToGo converts an Elvish value to a Go value, and stores it in *ptr.
// ScanToGo converts an Elvish value to a Go value, and stores it in *ptr. It
// panics if ptr is not a pointer.
//
// - If ptr has type *int, *float64, *Num or *rune, it performs a suitable
// conversion, and returns an error if the conversion fails.
//
// - If ptr is a pointer to a field map (*M) and src is a map or another field
// map, it converts each individual field recursively, failing if the
// scanning of any field fails.
//
// The set of keys in src must match the set of keys in M exactly. This
// behavior can be changed by using [ScanToGoOpts] instead.
//
// - In other cases, it tries to perform "*ptr = src" via reflection and
// returns an error if the assignment can't be done.
//
Expand Down Expand Up @@ -78,7 +85,25 @@ var (
// direction. For example, "1" may be converted into "1" or '1', depending on
// what the destination type is, and the process may fail. Thus ScanToGo takes
// the pointer to the destination as an argument, and returns an error.
func ScanToGo(src any, ptr any) error {
func ScanToGo(src, ptr any) error {
return ScanToGoOpts(src, ptr, 0)
}

// Options for [ScanToGoOpts].
type ScanOpt uint32

const (
// When scanning a map into a field map, allow the map to not have some keys
// of the field map.
AllowMissingMapKey ScanOpt = 1 << iota
// When scanning a map into a field map, allow the map to have keys that are
// not in the field map.
AllowExtraMapKey
)

// ScanToGoOpts is like [ScanToGo], but allows customization the behavior with
// the flag argument.
func ScanToGoOpts(src, ptr any, opt ScanOpt) error {
switch ptr := ptr.(type) {
case *int:
return convAndStore(elvToInt, src, ptr)
Expand All @@ -93,6 +118,13 @@ func ScanToGo(src any, ptr any) error {
case *rune:
return convAndStore(elvToRune, src, ptr)
default:
if keys := getFieldMapKeysT(reflect.TypeOf(ptr).Elem()); keys != nil {
if src, ok := src.(Map); ok || IsFieldMap(src) {
// TODO: If src and *ptr are the same type, perform a simple
// assignment instead.
return scanFieldMapFromMap(src, ptr, keys, opt)
}
}
return assignPtr(src, ptr)
}
}
Expand Down Expand Up @@ -151,13 +183,55 @@ func elvToRune(arg any) (rune, error) {
return r, nil
}

func scanFieldMapFromMap(src any, ptr any, dstKeys FieldMapKeys, opt ScanOpt) error {
makeErr := func(keysDescription string) error {
return errs.BadValue{
// TODO: Add path information in error messages.
What: "value",
Valid: fmt.Sprintf("map with keys %s [%s]", keysDescription, strings.Join(dstKeys, " ")),
Actual: ReprPlain(src),
}
}

switch opt & (AllowMissingMapKey | AllowExtraMapKey) {
case 0:
if Len(src) != len(dstKeys) {
return makeErr("being exactly")
}
case AllowMissingMapKey:
if Len(src) > len(dstKeys) {
return makeErr("constrained to")
}
case AllowExtraMapKey:
if Len(src) < len(dstKeys) {
return makeErr("containing at least")
}
}
dst := reflect.ValueOf(ptr).Elem()
usedSrcKeys := 0
for i, key := range dstKeys {
srcValue, err := Index(src, key)
if err != nil {
if opt&AllowMissingMapKey == 0 {
return makeErr("containing at least")
}
continue
}
err = ScanToGoOpts(srcValue, dst.Field(i).Addr().Interface(), opt)
if err != nil {
return err
}
usedSrcKeys++
}
if opt&AllowExtraMapKey == 0 && usedSrcKeys < Len(src) {
return makeErr("constrained to")
}
return nil
}

// Does "*ptr = src" via reflection.
func assignPtr(src, ptr any) error {
ptrType := TypeOf(ptr)
if ptrType.Kind() != reflect.Ptr {
return fmt.Errorf("internal bug: need pointer to scan to, got %T", ptr)
}
dstType := ptrType.Elem()
dstType := reflect.TypeOf(ptr).Elem()
// Allow using any(nil) as T(nil) for any T whose zero value is spelt
// nil.
if src == nil {
Expand Down Expand Up @@ -235,68 +309,6 @@ func ScanListElementsToGo(src List, ptrs ...any) error {
return nil
}

// ScanMapToGo scans map elements into ptr, which must be a pointer to a struct.
// Struct field names are converted to map keys with CamelToDashed.
//
// The map may contains keys that don't correspond to struct fields, and it
// doesn't have to contain all keys that correspond to struct fields.
func ScanMapToGo(src Map, ptr any) error {
// Iterate over the struct keys instead of the map: since extra keys are
// allowed, the map may be very big, while the size of the struct is bound.
keys, _ := StructFieldsInfo(reflect.TypeOf(ptr).Elem())
structValue := reflect.ValueOf(ptr).Elem()
for i, key := range keys {
if key == "" {
continue
}
val, ok := src.Index(key)
if !ok {
continue
}
err := ScanToGo(val, structValue.Field(i).Addr().Interface())
if err != nil {
return err
}
}
return nil
}

// StructFieldsInfo takes a type for a struct, and returns a slice for each
// field name, converted with CamelToDashed, and a reverse index. Unexported
// fields result in an empty string in the slice, and is omitted from the
// reverse index.
func StructFieldsInfo(t reflect.Type) ([]string, map[string]int) {
if info, ok := structFieldsInfoCache.Load(t); ok {
info := info.(structFieldsInfo)
return info.keys, info.keyIdx
}
info := makeStructFieldsInfo(t)
structFieldsInfoCache.Store(t, info)
return info.keys, info.keyIdx
}

var structFieldsInfoCache sync.Map

type structFieldsInfo struct {
keys []string
keyIdx map[string]int
}

func makeStructFieldsInfo(t reflect.Type) structFieldsInfo {
keys := make([]string, t.NumField())
keyIdx := make(map[string]int)
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" {
continue
}
key := strutil.CamelToDashed(field.Name)
keyIdx[key] = i
keys[i] = key
}
return structFieldsInfo{keys, keyIdx}
}

// FromGo converts a Go value to an Elvish value.
//
// Exact numbers are normalized to the smallest types that can hold them, and
Expand Down
Loading

0 comments on commit 36e5070

Please sign in to comment.