Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): add methods to print to console #111

Merged
merged 4 commits into from
Jan 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions cmd/ethkit/print.go
Original file line number Diff line number Diff line change
@@ -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
}
95 changes: 95 additions & 0 deletions cmd/ethkit/print_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading