diff --git a/cmd/ethkit/print.go b/cmd/ethkit/print.go new file mode 100644 index 00000000..9a00925b --- /dev/null +++ b/cmd/ethkit/print.go @@ -0,0 +1,179 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + "text/tabwriter" +) + +type printableFormat struct { + minwidth int + tabwidth int + padding int + padchar byte +} + +// NewPrintableFormat returns a customized configuration format +func NewPrintableFormat(minwidth, tabwidth, padding int, padchar byte) *printableFormat { + return &printableFormat{minwidth, tabwidth, padding, padchar} +} + +// Printable is a generic key-value (map) structure that could contain nested objects. +type Printable map[string]any + +// PrettyJSON prints an object in "prettified" JSON format +func PrettyJSON(toJSON any) (*string, error) { + b, err := json.MarshalIndent(toJSON, "", " ") + if err != nil { + return nil, err + } + jsonString := string(b) + + // remove the trailing newline character ("%") + if jsonString[len(jsonString)-1] == '\n' { + jsonString = jsonString[:len(jsonString)-1] + } + + return &jsonString, nil +} + +// FromStruct converts a struct into a Printable using, when available, JSON field names as keys +func (p *Printable) FromStruct(input any) error { + bytes, err := json.Marshal(input) + if err != nil { + return err + } + if err := json.Unmarshal(bytes, &p); err != nil { + return err + } + + return nil +} + +// Columnize returns a formatted-in-columns (vertically aligned) string based on a provided configuration. +func (p *Printable) Columnize(pf printableFormat) string { + var buf bytes.Buffer + w := tabwriter.NewWriter(&buf, pf.minwidth, pf.tabwidth, pf.padding, pf.padchar, tabwriter.Debug) + // NOTE: Order is not maintained whilst looping over map's . Results from different execution may differ. + for k, v := range *p { + printKeyValue(w, k, v) + } + w.Flush() + + return buf.String() +} + +func printKeyValue(w *tabwriter.Writer, key string, value any) { + switch t := value.(type) { + // NOTE: Printable is not directly inferred as map[string]any therefore explicit reference is necessary + case map[string]any: + fmt.Fprintln(w, key, "\t") + for tk, tv := range t { + printKeyValue(w, "\t "+tk, tv) + } + case []any: + fmt.Fprintln(w, key, "\t") + for _, elem := range t { + elemMap, ok := elem.(map[string]any) + if ok { + for tk, tv := range elemMap { + printKeyValue(w, "\t "+tk, tv) + } + fmt.Fprintln(w, "\t", "\t") + } else { + fmt.Fprintln(w, "\t", customFormat(elem)) + } + } + default: + // custom format for numbers to avoid scientific notation + fmt.Fprintf(w, "%s\t %s\n", key, customFormat(value)) + } +} + +func customFormat(value any) string { + switch v := value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return fmt.Sprintf("%d", v) + case float32, float64: + return formatFloat(v) + default: + return fmt.Sprintf("%v", base64ToHex(value)) + } +} + +func formatFloat(f any) string { + str := fmt.Sprintf("%v", f) + if strings.ContainsAny(str, "eE.") { + if floatValue, err := strconv.ParseFloat(str, 64); err == nil { + return strconv.FormatFloat(floatValue, 'f', -1, 64) + } + } + + return str +} + +func base64ToHex(str any) any { + _, ok := str.(string); if !ok { + return str + } + decoded, err := base64.StdEncoding.DecodeString(str.(string)) + if err != nil { + return str + } + + return "0x" + hex.EncodeToString(decoded) +} + +// GetValueByJSONTag returns the value of a struct field matching a JSON tag provided in input. +func GetValueByJSONTag(input any, jsonTag string) any { + // TODO: Refactor to support both nil values and errors when key not found + return findField(reflect.ValueOf(input), jsonTag) +} + +func findField(val reflect.Value, jsonTag string) any { + seen := make(map[uintptr]bool) + + // take the value the pointer val points to + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + // return if the element is not a struct + if val.Kind() != reflect.Struct { + return nil + } + + // check if the struct has already been processed to avoid infinite recursion + if val.CanAddr() { + ptr := val.Addr().Pointer() + if seen[ptr] { + return nil + } + seen[ptr] = true + } + + t := val.Type() + + for i := 0; i < val.NumField(); i++ { + field := t.Field(i) + tag := field.Tag.Get("json") + + fieldValue := val.Field(i) + if fieldValue.Kind() == reflect.Struct { + // recursively process fields including embedded ones + return findField(fieldValue, jsonTag) + } else { + if strings.EqualFold(strings.ToLower(tag), strings.ToLower(jsonTag)) { + return val.Field(i).Interface() + } + } + } + + return nil +} diff --git a/cmd/ethkit/print_test.go b/cmd/ethkit/print_test.go new file mode 100644 index 00000000..5591e5d5 --- /dev/null +++ b/cmd/ethkit/print_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + complex = struct { + Name string `json:"name"` + List []any `json:"list"` + Nested Nested `json:"nested"` + Object map[string]any `json:"object"` + ObjList []map[string]any `json:"objList"` + }{ + Name: "complex", + List: []any{ + "first", + "second", + }, + Nested: Nested{ + Title: "hello", + Value: 500000000, + }, + Object: map[string]any{ + "obj1": 1, + "obj2": -2, + }, + ObjList: []map[string]any{ + { + "item1": 1, + "item2": 2, + }, + { + "item3": 3e7, + "item4": 2E7, + "item5": 2.123456e7, + }, + }, + } + + s string + rows []string + p Printable + minwidth = 24 + tabwidth = 0 + padding = 0 + padchar = byte(' ') +) + +type Nested struct { + Title string `json:"title"` + Value uint `json:"value"` +} + +func setup() { + if err := p.FromStruct(complex); err != nil { + panic(err) + } + s = p.Columnize(*NewPrintableFormat(minwidth, tabwidth, padding, padchar)) + fmt.Println(s) + rows = strings.Split(s, "\n") +} + +func Test_Columnize(t *testing.T) { + setup() + for i := 0; i < len(rows); i++ { + if rows[i] != "" { + // the delimiter should be in the same position in all the rows + assert.Equal(t, strings.Index(rows[i], "|"), minwidth) + if strings.Contains(rows[i], "item5") { + v := strconv.FormatFloat(complex.ObjList[1]["item5"].(float64), 'f', -1, 64) + // the value of the nested object should be indented to the 3rd column and it should be parsed as an integer + // left bound: 2*\t + 1*' ' = 3 , right bound: 3 + len(21234560) + 1 = 11 + assert.Equal(t, rows[i][minwidth*2+3:minwidth*2+11], v) + } + } + } +} + +func Test_GetValueByJSONTag(t *testing.T) { + setup() + tag := "title" + assert.Equal(t, GetValueByJSONTag(complex, tag), complex.Nested.Title) +} + +func Test_GetValueByJSONTag_FailWhenNotStruct(t *testing.T) { + setup() + tag := "title" + assert.Nil(t, GetValueByJSONTag(p, tag)) +}