|
1 | 1 | package cliui
|
2 | 2 |
|
3 | 3 | import (
|
| 4 | + "fmt" |
| 5 | + "reflect" |
4 | 6 | "strings"
|
| 7 | + "time" |
5 | 8 |
|
| 9 | + "github.com/fatih/structtag" |
6 | 10 | "github.com/jedib0t/go-pretty/v6/table"
|
| 11 | + "golang.org/x/xerrors" |
7 | 12 | )
|
8 | 13 |
|
9 | 14 | // Table creates a new table with standardized styles.
|
@@ -41,3 +46,258 @@ func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig
|
41 | 46 | }
|
42 | 47 | return columnConfigs
|
43 | 48 | }
|
| 49 | + |
| 50 | +// DisplayTable renders a table as a string. The input argument must be a slice |
| 51 | +// of structs. At least one field in the struct must have a `table:""` tag |
| 52 | +// containing the name of the column in the outputted table. |
| 53 | +// |
| 54 | +// Nested structs are processed if the field has the `table:"$NAME,recursive"` |
| 55 | +// tag and their fields will be named as `$PARENT_NAME $NAME`. If the tag is |
| 56 | +// malformed or a field is marked as recursive but does not contain a struct or |
| 57 | +// a pointer to a struct, this function will return an error (even with an empty |
| 58 | +// input slice). |
| 59 | +// |
| 60 | +// If sort is empty, the input order will be used. If filterColumns is empty or |
| 61 | +// nil, all available columns are included. |
| 62 | +func DisplayTable(out any, sort string, filterColumns []string) (string, error) { |
| 63 | + v := reflect.Indirect(reflect.ValueOf(out)) |
| 64 | + |
| 65 | + if v.Kind() != reflect.Slice { |
| 66 | + return "", xerrors.Errorf("DisplayTable called with a non-slice type") |
| 67 | + } |
| 68 | + |
| 69 | + // Get the list of table column headers. |
| 70 | + headersRaw, err := typeToTableHeaders(v.Type().Elem()) |
| 71 | + if err != nil { |
| 72 | + return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err) |
| 73 | + } |
| 74 | + if len(headersRaw) == 0 { |
| 75 | + return "", xerrors.New(`no table headers found on the input type, make sure there is at least one "table" struct tag`) |
| 76 | + } |
| 77 | + headers := make(table.Row, len(headersRaw)) |
| 78 | + for i, header := range headersRaw { |
| 79 | + headers[i] = header |
| 80 | + } |
| 81 | + |
| 82 | + // Verify that the given sort column and filter columns are valid. |
| 83 | + if sort != "" || len(filterColumns) != 0 { |
| 84 | + headersMap := make(map[string]string, len(headersRaw)) |
| 85 | + for _, header := range headersRaw { |
| 86 | + headersMap[strings.ToLower(header)] = header |
| 87 | + } |
| 88 | + |
| 89 | + if sort != "" { |
| 90 | + sort = strings.ToLower(strings.ReplaceAll(sort, "_", " ")) |
| 91 | + h, ok := headersMap[sort] |
| 92 | + if !ok { |
| 93 | + return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`)) |
| 94 | + } |
| 95 | + |
| 96 | + // Autocorrect |
| 97 | + sort = h |
| 98 | + } |
| 99 | + |
| 100 | + for i, column := range filterColumns { |
| 101 | + column := strings.ToLower(strings.ReplaceAll(column, "_", " ")) |
| 102 | + h, ok := headersMap[column] |
| 103 | + if !ok { |
| 104 | + return "", xerrors.Errorf("specified filter column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`)) |
| 105 | + } |
| 106 | + |
| 107 | + // Autocorrect |
| 108 | + filterColumns[i] = h |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + // Verify that the given sort column is valid. |
| 113 | + if sort != "" { |
| 114 | + sort = strings.ReplaceAll(sort, "_", " ") |
| 115 | + found := false |
| 116 | + for _, header := range headersRaw { |
| 117 | + if strings.EqualFold(sort, header) { |
| 118 | + found = true |
| 119 | + sort = header |
| 120 | + break |
| 121 | + } |
| 122 | + } |
| 123 | + if !found { |
| 124 | + return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`)) |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + // Setup the table formatter. |
| 129 | + tw := Table() |
| 130 | + tw.AppendHeader(headers) |
| 131 | + tw.SetColumnConfigs(FilterTableColumns(headers, filterColumns)) |
| 132 | + if sort != "" { |
| 133 | + tw.SortBy([]table.SortBy{{ |
| 134 | + Name: sort, |
| 135 | + }}) |
| 136 | + } |
| 137 | + |
| 138 | + // Write each struct to the table. |
| 139 | + for i := 0; i < v.Len(); i++ { |
| 140 | + // Format the row as a slice. |
| 141 | + rowMap, err := valueToTableMap(v.Index(i)) |
| 142 | + if err != nil { |
| 143 | + return "", xerrors.Errorf("get table row map %v: %w", i, err) |
| 144 | + } |
| 145 | + |
| 146 | + rowSlice := make([]any, len(headers)) |
| 147 | + for i, h := range headersRaw { |
| 148 | + v, ok := rowMap[h] |
| 149 | + if !ok { |
| 150 | + v = nil |
| 151 | + } |
| 152 | + |
| 153 | + // Special type formatting. |
| 154 | + switch val := v.(type) { |
| 155 | + case time.Time: |
| 156 | + v = val.Format(time.Stamp) |
| 157 | + case *time.Time: |
| 158 | + if val != nil { |
| 159 | + v = val.Format(time.Stamp) |
| 160 | + } |
| 161 | + } |
| 162 | + |
| 163 | + rowSlice[i] = v |
| 164 | + } |
| 165 | + |
| 166 | + tw.AppendRow(table.Row(rowSlice)) |
| 167 | + } |
| 168 | + |
| 169 | + return tw.Render(), nil |
| 170 | +} |
| 171 | + |
| 172 | +// parseTableStructTag returns the name of the field according to the `table` |
| 173 | +// struct tag. If the table tag does not exist or is "-", an empty string is |
| 174 | +// returned. If the table tag is malformed, an error is returned. |
| 175 | +// |
| 176 | +// The returned name is transformed from "snake_case" to "normal text". |
| 177 | +func parseTableStructTag(field reflect.StructField) (name string, recurse bool, err error) { |
| 178 | + tags, err := structtag.Parse(string(field.Tag)) |
| 179 | + if err != nil { |
| 180 | + return "", false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err) |
| 181 | + } |
| 182 | + |
| 183 | + tag, err := tags.Get("table") |
| 184 | + if err != nil || tag.Name == "-" { |
| 185 | + // tags.Get only returns an error if the tag is not found. |
| 186 | + return "", false, nil |
| 187 | + } |
| 188 | + |
| 189 | + recursive := false |
| 190 | + for _, opt := range tag.Options { |
| 191 | + if opt == "recursive" { |
| 192 | + recursive = true |
| 193 | + continue |
| 194 | + } |
| 195 | + |
| 196 | + return "", false, xerrors.Errorf("unknown option %q in struct field tag", opt) |
| 197 | + } |
| 198 | + |
| 199 | + return strings.ReplaceAll(tag.Name, "_", " "), recursive, nil |
| 200 | +} |
| 201 | + |
| 202 | +func isStructOrStructPointer(t reflect.Type) bool { |
| 203 | + return t.Kind() == reflect.Struct || (t.Kind() == reflect.Pointer && t.Elem().Kind() == reflect.Struct) |
| 204 | +} |
| 205 | + |
| 206 | +// typeToTableHeaders converts a type to a slice of column names. If the given |
| 207 | +// type is invalid (not a struct or a pointer to a struct, has invalid table |
| 208 | +// tags, etc.), an error is returned. |
| 209 | +func typeToTableHeaders(t reflect.Type) ([]string, error) { |
| 210 | + if !isStructOrStructPointer(t) { |
| 211 | + return nil, xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type") |
| 212 | + } |
| 213 | + if t.Kind() == reflect.Pointer { |
| 214 | + t = t.Elem() |
| 215 | + } |
| 216 | + |
| 217 | + headers := []string{} |
| 218 | + for i := 0; i < t.NumField(); i++ { |
| 219 | + field := t.Field(i) |
| 220 | + name, recursive, err := parseTableStructTag(field) |
| 221 | + if err != nil { |
| 222 | + return nil, xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err) |
| 223 | + } |
| 224 | + if name == "" { |
| 225 | + continue |
| 226 | + } |
| 227 | + |
| 228 | + fieldType := field.Type |
| 229 | + if recursive { |
| 230 | + if !isStructOrStructPointer(fieldType) { |
| 231 | + return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String()) |
| 232 | + } |
| 233 | + |
| 234 | + childNames, err := typeToTableHeaders(fieldType) |
| 235 | + if err != nil { |
| 236 | + return nil, xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err) |
| 237 | + } |
| 238 | + for _, childName := range childNames { |
| 239 | + headers = append(headers, fmt.Sprintf("%s %s", name, childName)) |
| 240 | + } |
| 241 | + continue |
| 242 | + } |
| 243 | + |
| 244 | + headers = append(headers, name) |
| 245 | + } |
| 246 | + |
| 247 | + return headers, nil |
| 248 | +} |
| 249 | + |
| 250 | +// valueToTableMap converts a struct to a map of column name to value. If the |
| 251 | +// given type is invalid (not a struct or a pointer to a struct, has invalid |
| 252 | +// table tags, etc.), an error is returned. |
| 253 | +func valueToTableMap(val reflect.Value) (map[string]any, error) { |
| 254 | + if !isStructOrStructPointer(val.Type()) { |
| 255 | + return nil, xerrors.Errorf("valueToTableMap called with a non-struct or a non-pointer-to-a-struct type") |
| 256 | + } |
| 257 | + if val.Kind() == reflect.Pointer { |
| 258 | + if val.IsNil() { |
| 259 | + // No data for this struct, so return an empty map. All values will |
| 260 | + // be rendered as nil in the resulting table. |
| 261 | + return map[string]any{}, nil |
| 262 | + } |
| 263 | + |
| 264 | + val = val.Elem() |
| 265 | + } |
| 266 | + |
| 267 | + row := map[string]any{} |
| 268 | + for i := 0; i < val.NumField(); i++ { |
| 269 | + field := val.Type().Field(i) |
| 270 | + fieldVal := val.Field(i) |
| 271 | + name, recursive, err := parseTableStructTag(field) |
| 272 | + if err != nil { |
| 273 | + return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err) |
| 274 | + } |
| 275 | + if name == "" { |
| 276 | + continue |
| 277 | + } |
| 278 | + |
| 279 | + // Recurse if it's a struct. |
| 280 | + fieldType := field.Type |
| 281 | + if recursive { |
| 282 | + if !isStructOrStructPointer(fieldType) { |
| 283 | + return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, fieldType.String()) |
| 284 | + } |
| 285 | + |
| 286 | + // valueToTableMap does nothing on pointers so we don't need to |
| 287 | + // filter here. |
| 288 | + childMap, err := valueToTableMap(fieldVal) |
| 289 | + if err != nil { |
| 290 | + return nil, xerrors.Errorf("get child field values for field %q in type %q: %w", field.Name, fieldType.String(), err) |
| 291 | + } |
| 292 | + for childName, childValue := range childMap { |
| 293 | + row[fmt.Sprintf("%s %s", name, childName)] = childValue |
| 294 | + } |
| 295 | + continue |
| 296 | + } |
| 297 | + |
| 298 | + // Otherwise, we just use the field value. |
| 299 | + row[name] = val.Field(i).Interface() |
| 300 | + } |
| 301 | + |
| 302 | + return row, nil |
| 303 | +} |
0 commit comments