|
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 | returncolumnConfigs
|
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 | +funcDisplayTable(outany,sortstring,filterColumns []string) (string,error) { |
| 63 | +v:=reflect.Indirect(reflect.ValueOf(out)) |
| 64 | + |
| 65 | +ifv.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 | +iferr!=nil { |
| 72 | +return"",xerrors.Errorf("get table headers recursively for type %q: %w",v.Type().Elem().String(),err) |
| 73 | +} |
| 74 | +iflen(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 | +fori,header:=rangeheadersRaw { |
| 79 | +headers[i]=header |
| 80 | +} |
| 81 | + |
| 82 | +// Verify that the given sort column and filter columns are valid. |
| 83 | +ifsort!=""||len(filterColumns)!=0 { |
| 84 | +headersMap:=make(map[string]string,len(headersRaw)) |
| 85 | +for_,header:=rangeheadersRaw { |
| 86 | +headersMap[strings.ToLower(header)]=header |
| 87 | +} |
| 88 | + |
| 89 | +ifsort!="" { |
| 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 | +fori,column:=rangefilterColumns { |
| 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 | +ifsort!="" { |
| 114 | +sort=strings.ReplaceAll(sort,"_"," ") |
| 115 | +found:=false |
| 116 | +for_,header:=rangeheadersRaw { |
| 117 | +ifstrings.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 | +ifsort!="" { |
| 133 | +tw.SortBy([]table.SortBy{{ |
| 134 | +Name:sort, |
| 135 | +}}) |
| 136 | +} |
| 137 | + |
| 138 | +// Write each struct to the table. |
| 139 | +fori:=0;i<v.Len();i++ { |
| 140 | +// Format the row as a slice. |
| 141 | +rowMap,err:=valueToTableMap(v.Index(i)) |
| 142 | +iferr!=nil { |
| 143 | +return"",xerrors.Errorf("get table row map %v: %w",i,err) |
| 144 | +} |
| 145 | + |
| 146 | +rowSlice:=make([]any,len(headers)) |
| 147 | +fori,h:=rangeheadersRaw { |
| 148 | +v,ok:=rowMap[h] |
| 149 | +if!ok { |
| 150 | +v=nil |
| 151 | +} |
| 152 | + |
| 153 | +// Special type formatting. |
| 154 | +switchval:=v.(type) { |
| 155 | +case time.Time: |
| 156 | +v=val.Format(time.Stamp) |
| 157 | +case*time.Time: |
| 158 | +ifval!=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 | +returntw.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 | +funcparseTableStructTag(field reflect.StructField) (namestring,recursebool,errerror) { |
| 178 | +tags,err:=structtag.Parse(string(field.Tag)) |
| 179 | +iferr!=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 | +iferr!=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:=rangetag.Options { |
| 191 | +ifopt=="recursive" { |
| 192 | +recursive=true |
| 193 | +continue |
| 194 | +} |
| 195 | + |
| 196 | +return"",false,xerrors.Errorf("unknown option %q in struct field tag",opt) |
| 197 | +} |
| 198 | + |
| 199 | +returnstrings.ReplaceAll(tag.Name,"_"," "),recursive,nil |
| 200 | +} |
| 201 | + |
| 202 | +funcisStructOrStructPointer(t reflect.Type)bool { |
| 203 | +returnt.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 | +functypeToTableHeaders(t reflect.Type) ([]string,error) { |
| 210 | +if!isStructOrStructPointer(t) { |
| 211 | +returnnil,xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type") |
| 212 | +} |
| 213 | +ift.Kind()==reflect.Pointer { |
| 214 | +t=t.Elem() |
| 215 | +} |
| 216 | + |
| 217 | +headers:= []string{} |
| 218 | +fori:=0;i<t.NumField();i++ { |
| 219 | +field:=t.Field(i) |
| 220 | +name,recursive,err:=parseTableStructTag(field) |
| 221 | +iferr!=nil { |
| 222 | +returnnil,xerrors.Errorf("parse struct tags for field %q in type %q: %w",field.Name,t.String(),err) |
| 223 | +} |
| 224 | +ifname=="" { |
| 225 | +continue |
| 226 | +} |
| 227 | + |
| 228 | +fieldType:=field.Type |
| 229 | +ifrecursive { |
| 230 | +if!isStructOrStructPointer(fieldType) { |
| 231 | +returnnil,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 | +iferr!=nil { |
| 236 | +returnnil,xerrors.Errorf("get child field header names for field %q in type %q: %w",field.Name,fieldType.String(),err) |
| 237 | +} |
| 238 | +for_,childName:=rangechildNames { |
| 239 | +headers=append(headers,fmt.Sprintf("%s %s",name,childName)) |
| 240 | +} |
| 241 | +continue |
| 242 | +} |
| 243 | + |
| 244 | +headers=append(headers,name) |
| 245 | +} |
| 246 | + |
| 247 | +returnheaders,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 | +funcvalueToTableMap(val reflect.Value) (map[string]any,error) { |
| 254 | +if!isStructOrStructPointer(val.Type()) { |
| 255 | +returnnil,xerrors.Errorf("valueToTableMap called with a non-struct or a non-pointer-to-a-struct type") |
| 256 | +} |
| 257 | +ifval.Kind()==reflect.Pointer { |
| 258 | +ifval.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 | +returnmap[string]any{},nil |
| 262 | +} |
| 263 | + |
| 264 | +val=val.Elem() |
| 265 | +} |
| 266 | + |
| 267 | +row:=map[string]any{} |
| 268 | +fori:=0;i<val.NumField();i++ { |
| 269 | +field:=val.Type().Field(i) |
| 270 | +fieldVal:=val.Field(i) |
| 271 | +name,recursive,err:=parseTableStructTag(field) |
| 272 | +iferr!=nil { |
| 273 | +returnnil,xerrors.Errorf("parse struct tags for field %q in type %T: %w",field.Name,val,err) |
| 274 | +} |
| 275 | +ifname=="" { |
| 276 | +continue |
| 277 | +} |
| 278 | + |
| 279 | +// Recurse if it's a struct. |
| 280 | +fieldType:=field.Type |
| 281 | +ifrecursive { |
| 282 | +if!isStructOrStructPointer(fieldType) { |
| 283 | +returnnil,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 | +iferr!=nil { |
| 290 | +returnnil,xerrors.Errorf("get child field values for field %q in type %q: %w",field.Name,fieldType.String(),err) |
| 291 | +} |
| 292 | +forchildName,childValue:=rangechildMap { |
| 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 | +returnrow,nil |
| 303 | +} |