diff --git a/menu/fields.go b/menu/fields.go index 40d5772..1a43a3a 100644 --- a/menu/fields.go +++ b/menu/fields.go @@ -2,6 +2,7 @@ package menu import ( "fmt" + "reflect" "strconv" ) @@ -111,3 +112,120 @@ func (f *menuField) getFieldName() string { } return f.name } + +// structData holds onto values returned from +// reflect calls related to the input interface. +type structData struct { + v reflect.Value + t reflect.Type + fields map[int]*reflect.StructField + values map[int]*reflect.Value +} + +func (d *structData) init(structValue reflect.Value) { + d.v = structValue + d.t = d.v.Type() + d.fields = getFields(d.t) + + values := map[int]*reflect.Value{} + for i := 0; i < d.t.NumField(); i++ { + val := d.v.FieldByName(d.fields[i].Name) + values[i] = &val + } + d.values = values +} + +// getFields returns a map of indeces to reflect.StructField pointers, +// given a reflect.Type. It does not account for tags whatsoever. +func getFields(t reflect.Type) map[int]*reflect.StructField { + fields := map[int]*reflect.StructField{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + fields[i] = &f + } + return fields +} + +// getOrderedFields accounts for the presence of `idx` and `bl` +// tags in StructFields to provide a map that defines the order +// by which each struct fields ought be rendered in the terminal. +// +// If the `idx` tag is in use on the struct, it returns a map of `idx` +// tag values corresponding to the indeces of struct fields within the +// struct represented by the given reflect.Type, in the order they +// are declared. The presence or non-presence of the tags is validated +// when reading each field. Fields blacklisted at the type level with +// the `bl` tag are expected not to have an idx tag. +// +// If the first non-blacklisted field is found to have an `idx` tag, +// all others will be expected to have one as well. Likewise, if it +// does not have the tag, all others will be expected not to have the tag. +// When no `idx` tags are used, the map keys and values will match, unless +// offset by 1 after and for each instance where a field is found to be +// blacklisted with the `bl` tag. +// +// Where validation fails, a nil map and error are returned. +func getOrderedFields(t map[int]*reflect.StructField) (map[int]int, error) { + wantIdx := struct { + val bool + isSet bool + }{val: false, isSet: false} + + idxTagVals := map[int]int{} + blacklistCount := 0 + for i := 0; i < len(t); i++ { + field := t[i] + if field == nil { + return nil, fmt.Errorf("encountered nil struct field") + } + + _, isBlacklisted := field.Tag.Lookup("bl") + tagValue, isIndexed := field.Tag.Lookup("idx") + + if isBlacklisted { + if isIndexed { + return nil, fmt.Errorf("incompatible struct tags; unexpected `idx` tag found on `bl`-tagged field %s", field.Name) + } + blacklistCount++ + continue + } + + // NOTE: at this point, can't possibly be blacklisted + if !wantIdx.isSet { + wantIdx.val = (len(idxTagVals) == 0 && isIndexed) + wantIdx.isSet = true + } + + if wantIdx.val { + if !isIndexed { + return nil, fmt.Errorf("no `idx` tag found on struct field %s", field.Name) + } + idx, err := strconv.Atoi(tagValue) + if err != nil || idx < 0 { + return nil, fmt.Errorf("value for `idx` tag on field %s must be an integer >= 0", field.Name) + } + if _, ok := idxTagVals[idx]; ok { + return nil, fmt.Errorf("value %d for `idx` tag on field %s already assigned to another field", idx, field.Name) + } + idxTagVals[idx] = i + + } else if isIndexed { + return nil, fmt.Errorf("unexpected `idx` tag found on field %s", field.Name) + } else { + idxTagVals[i-blacklistCount] = i + } + + } + + for i := 0; i < len(idxTagVals); i++ { + if _, ok := idxTagVals[i]; !ok { + if wantIdx.val { + return nil, fmt.Errorf("expected to find idx value of %d on some field, but found none", i) + } + return nil, fmt.Errorf("expected sequential indeces for map, but index %d is missing", i) + + } + } + + return idxTagVals, nil +} diff --git a/menu/idx_test.go b/menu/fields_test.go similarity index 95% rename from menu/idx_test.go rename to menu/fields_test.go index 446ba16..0afc1eb 100644 --- a/menu/idx_test.go +++ b/menu/fields_test.go @@ -170,12 +170,12 @@ func TestGetOrderedFields(t *testing.T) { for _, tc := range tb.batch { t.Run(tc.name, func(t *testing.T) { rType := reflect.TypeOf(tc.input) - tags, err := getOrderedFields(rType) + orderedFields, err := getOrderedFields(getFields(rType)) if (err != nil) != tc.wantErr { t.Errorf("got unexpected error: %v", err) } - if !maps.Equal(tags, tc.expected) { - t.Errorf("expected: %v, got: %v", tc.expected, tags) + if !maps.Equal(orderedFields, tc.expected) { + t.Errorf("expected: %v, got: %v", tc.expected, orderedFields) } }) } diff --git a/menu/idx.go b/menu/idx.go deleted file mode 100644 index 3c22cbf..0000000 --- a/menu/idx.go +++ /dev/null @@ -1,88 +0,0 @@ -package menu - -import ( - "fmt" - "reflect" - "strconv" -) - -// getOrderedFields accounts for the presence of `idx` and `bl` -// tags to provide a map that defines the order by which each -// struct fields ought be rendered in the terminal. -// -// If the `idx` tag is in use on the struct,it returns a map of `idx` -// tag values corresponding to the indeces of struct fields within the -// struct represented by the given reflect.Type, in the order they -// are declared. The presence or non-presence of the tags is validated -// when reading each field. Fields blacklisted at the type level with -// the `bl` tag are expected not to have an idx tag. -// -// If the first non-blacklisted field is found to have an `idx` tag, -// all others will be expected to have one as well. Likewise, if it -// does not have the tag, all others will be expected not to have the tag. -// When no `idx` tags are used, the map keys and values will match, unless -// offset by 1 after and for each instance where a field is found to be -// blacklisted with the `bl` tag. -// -// Where validation fails, a nil map and error are returned. -func getOrderedFields(t reflect.Type) (map[int]int, error) { - wantIdx := struct { - val bool - isSet bool - }{val: false, isSet: false} - - idxTagVals := map[int]int{} - blacklistCount := 0 - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - - _, isBlacklisted := field.Tag.Lookup("bl") - tagValue, isIndexed := field.Tag.Lookup("idx") - - if isBlacklisted { - if isIndexed { - return nil, fmt.Errorf("incompatible struct tags; unexpected `idx` tag found on `bl`-tagged field %s", field.Name) - } - blacklistCount++ - continue - } - - // NOTE: at this point, can't possibly be blacklisted - if !wantIdx.isSet { - wantIdx.val = (len(idxTagVals) == 0 && isIndexed) - wantIdx.isSet = true - } - - if wantIdx.val { - if !isIndexed { - return nil, fmt.Errorf("no `idx` tag found on struct field %s", field.Name) - } - idx, err := strconv.Atoi(tagValue) - if err != nil || idx < 0 { - return nil, fmt.Errorf("value for `idx` tag on field %s must be an integer >= 0", field.Name) - } - if _, ok := idxTagVals[idx]; ok { - return nil, fmt.Errorf("value %d for `idx` tag on field %s already assigned to another field", idx, field.Name) - } - idxTagVals[idx] = i - - } else if isIndexed { - return nil, fmt.Errorf("unexpected `idx` tag found on field %s", field.Name) - } else { - idxTagVals[i-blacklistCount] = i - } - - } - - for i := 0; i < len(idxTagVals); i++ { - if _, ok := idxTagVals[i]; !ok { - if wantIdx.val { - return nil, fmt.Errorf("expected to find idx value of %d on some field, but found none", i) - } - return nil, fmt.Errorf("expected sequential indeces for map, but index %d is missing", i) - - } - } - - return idxTagVals, nil -} diff --git a/menu/model.go b/menu/model.go index 68d4769..2bb9d1c 100644 --- a/menu/model.go +++ b/menu/model.go @@ -24,6 +24,7 @@ type EndState struct { type Model struct { // MENU STATE // fields which can be edited; populated dynamically + data structData menuFields []menuField options MenuOptions state *state @@ -52,8 +53,13 @@ func (m *Model) getFieldUnderCursor() *menuField { // of either of the indicator constants used to define exception lists. // The Black() and White() functions exist as convenience wrappers to // provide this functionally. -func NewMenu(i any, exceptions ...string) (Model, error) { - return generateNewMenu(i, nil, exceptions...) +func NewMenu(structlyPtr any, exceptions ...string) (Model, error) { + v, err := validateStructPtr(structlyPtr) + if err != nil { + return Model{}, err + } + + return generateNewMenu(v, nil, exceptions...) } // NewMenuWithOptions operates just as NewMenu does, but exposes @@ -61,32 +67,26 @@ func NewMenu(i any, exceptions ...string) (Model, error) { // this function is necessarily deliberate, it will helpfully // return an error if no options are passed in. func NewMenuWithOptions(structlyPtr any, options *MenuOptions, list ...string) (Model, error) { - if options == nil { - return Model{}, fmt.Errorf("new menu requested with options, but no options were provided") - } - return generateNewMenu(structlyPtr, options, list...) -} - -// generateNewMenu validates and creates a new struct menu from the given -// parameters. If custom options are not provided, the menu will fall back -// to defaults. -func generateNewMenu(obj any, options *MenuOptions, exceptions ...string) (Model, error) { - // if fieldList is empty, all fields are exposed to users; otherwise, it is used as a whitelist. - // if bool parameter 'asBlacklist' is 'true', the fieldList is used as a blacklist instead of a whitelist. m := Model{} - t := reflect.TypeOf(obj) - v := reflect.ValueOf(obj) - if t.Kind() == reflect.Pointer { - t = t.Elem() - v = v.Elem() - } else { - return m, fmt.Errorf("obj should be a pointer to struct, so as to have addressable fields") + if options == nil { + return m, fmt.Errorf("new menu requested with options, but no options were provided") } - if t.Kind() != reflect.Struct { - return m, fmt.Errorf("input obj found not to be a struct") + + v, err := validateStructPtr(structlyPtr) + if err != nil { + return m, err } - m = Model{ + + return generateNewMenu(v, options, list...) +} + +// generateNewMenu expects a reflect.Value validated as a struct value and +// generates a new menu model from the given parameters. If custom options +// are not provided, the menu will fall back to defaults. +func generateNewMenu(v reflect.Value, options *MenuOptions, exceptions ...string) (Model, error) { + m := Model{ + data: structData{}, menuFields: []menuField{}, options: *NewMenuOptions(), state: &state{ @@ -97,12 +97,13 @@ func generateNewMenu(obj any, options *MenuOptions, exceptions ...string) (Model QuitWithCancel: false, }, } + m.data.init(v) if options != nil { m.options = *options } - orderedFields, err := getOrderedFields(t) + orderedFields, err := getOrderedFields(m.data.fields) if err != nil { return m, err } @@ -117,7 +118,7 @@ func generateNewMenu(obj any, options *MenuOptions, exceptions ...string) (Model if !ok { return m, fmt.Errorf("could not resolve struct field to display by declaration index %d", i) } - field := t.Field(j) + field := m.data.fields[j] if len(exceptions) != 0 { switch exceptionListIndicator { @@ -136,7 +137,7 @@ func generateNewMenu(obj any, options *MenuOptions, exceptions ...string) (Model } } - fieldVal := v.FieldByName(field.Name) + fieldVal := m.data.values[j] if !fieldVal.CanSet() { log.Printf("Warning: Field '%s' left unexposed (cannot be set; unexported or not addressable).\n", field.Name) continue @@ -174,12 +175,27 @@ func generateNewMenu(obj any, options *MenuOptions, exceptions ...string) (Model return m, nil } -func (m Model) ParseStruct(obj any) error { - v := reflect.ValueOf(obj) - if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct { - return fmt.Errorf("expected a pointer to a struct, got %v", v.Kind()) +// validateStructPtr takes in an interface and ensures that +// it is a pointer to a struct type before returning then +// returning the struct as a reflect.Value. +func validateStructPtr(i any) (reflect.Value, error) { + v := reflect.ValueOf(i) + if v.Kind() != reflect.Pointer { + return v, fmt.Errorf("input interface should be a pointer to a struct, so as to have addressable fields") } v = v.Elem() + if v.Kind() != reflect.Struct { + return v, fmt.Errorf("input ptr found not to point to a struct") + } + + return v, nil +} + +func (m Model) ParseStruct(structlyPtr any) error { + v, err := validateStructPtr(structlyPtr) + if err != nil { + return err + } for _, f := range m.menuFields { field := v.FieldByName(f.name)