From af5132627dda77e5e9b1ef8851b28a071e40591b Mon Sep 17 00:00:00 2001 From: Adam Luzsi Date: Mon, 25 Jul 2022 22:27:31 +0200 Subject: [PATCH] add support for pretty printing --- pp/Diff.go | 164 +++++++++++++++++++++++++++++++ pp/Diff_test.go | 133 +++++++++++++++++++++++++ pp/Format.go | 145 +++++++++++++++++++++++++++ pp/Format_test.go | 232 ++++++++++++++++++++++++++++++++++++++++++++ pp/examples_test.go | 31 ++++++ 5 files changed, 705 insertions(+) create mode 100644 pp/Diff.go create mode 100644 pp/Diff_test.go create mode 100644 pp/Format.go create mode 100644 pp/Format_test.go create mode 100644 pp/examples_test.go diff --git a/pp/Diff.go b/pp/Diff.go new file mode 100644 index 0000000..f616e8c --- /dev/null +++ b/pp/Diff.go @@ -0,0 +1,164 @@ +package pp + +import ( + "bufio" + "bytes" + "fmt" + "strings" + "text/tabwriter" +) + +// Diff format the values in pp.Format and compare the results line by line in a side-by-side style. +func Diff(v1, v2 any) string { + return DiffString(Format(v1), Format(v2)) +} + +// DiffString compare strings line by line in a side-by-side style. +// The diff style is similar to GNU "diff -y". +func DiffString(val, oth string) string { + var ( + rows []diffTableRow + valPos int + othPos int + valLines = toLines(val) + othLines = toLines(oth) + ) +wrk: + for { + var ( + hasVal bool + hasOth bool + valLine string + othLine string + ) + if valPos < len(valLines) { + hasVal = true + valLine = valLines[valPos] + } + if othPos < len(othLines) { + hasOth = true + othLine = othLines[othPos] + } + if !hasVal && !hasOth { + break wrk + } + // only "val" has more lines, "oth" is finished + if hasVal && !hasOth { + rows = append(rows, diffTableRow{ + Left: valLine, + Right: "", + Separator: "<", + }) + valPos++ + continue wrk + } + // only "oth" has more lines, "val" is finished + if !hasVal && hasOth { + rows = append(rows, diffTableRow{ + Left: "", + Right: othLine, + Separator: ">", + }) + othPos++ + continue wrk + } + + if valLine == othLine { + rows = append(rows, diffTableRow{ + Left: valLine, + Right: othLine, + Separator: "", + }) + valPos++ + othPos++ + continue wrk + } + /////////////////////////////////// + // not equals, both line present // + /////////////////////////////////// + + // othLine is part of "val", + // flush out "val" lines until we reach the current "oth" line there + if contains(valLines[valPos:], othLine) { + rows = append(rows, diffTableRow{ + Left: valLine, + Right: "", + Separator: "<", + }) + valPos++ + continue wrk + } + + // "val"'s line part of other eventually + // flush out "oth" lines until we reach the current "val" line + if contains(othLines[othPos:], valLine) { + rows = append(rows, diffTableRow{ + Left: "", + Right: othLine, + Separator: ">", + }) + othPos++ + continue wrk + } + + rows = append(rows, diffTableRow{ + Left: valLine, + Right: othLine, + Separator: "|", + }) + valPos++ + othPos++ + continue wrk + } + return toTable(rows) +} + +type diffTableRow struct { + Left string + Right string + Separator string +} + +func contains(lines []string, str string) bool { + for _, line := range lines { + if line == str { + return true + } + } + return false +} + +func toTable(rows []diffTableRow) string { + var mLen int + for _, row := range rows { + if nLen := len(row.Left); mLen < nLen { + mLen = nLen + } + } + escape := func(str string) string { + return strings.ReplaceAll(str, "\t", " ") + } + padded := func(str string) string { + //paddingLen := (mLen) / 2 / 2 + padding := strings.Repeat(" ", 2) + return fmt.Sprintf("%s%s%s", padding, str, padding) + } + buf := &bytes.Buffer{} + w := tabwriter.NewWriter(buf, 0, 0, 0, ' ', 0) + for _, row := range rows { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", escape(row.Left), padded(row.Separator), escape(row.Right)) + } + _ = w.Flush() + return buf.String() +} + +func toLines(str string) []string { + scanner := bufio.NewScanner(strings.NewReader(str)) + scanner.Split(bufio.ScanLines) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + _ = scanner.Err() + return lines +} diff --git a/pp/Diff_test.go b/pp/Diff_test.go new file mode 100644 index 0000000..f227af5 --- /dev/null +++ b/pp/Diff_test.go @@ -0,0 +1,133 @@ +package pp_test + +import ( + "bufio" + "strings" + "testing" + + "github.com/adamluzsi/testcase/assert" + "github.com/adamluzsi/testcase/pp" +) + +const DiffOutput = ` +pp_test.X{ pp_test.X{ + A: 1, | A: 2, + B: 2, B: 2, +} } +` + +func TestDiff_smoke(t *testing.T) { + type X struct{ A, B int } + v1 := X{A: 1, B: 2} + v2 := X{A: 2, B: 2} + tr := strings.TrimSpace + assert.Equal(t, tr(DiffOutput), tr(pp.Diff(v1, v2))) +} + +const DiffStringA = ` +aaa +bbb +ccc +ddd +eee +fff +ggg +` + +const DiffStringB = ` +aaa +bbbdiff +ccc +eee +123 +fff +` + +const DiffStringOut = ` +aaa aaa +bbb | bbbdiff +ccc ccc +ddd < +eee eee + > 123 +fff fff +ggg < +` + +func TestPrettyPrinter_DiffString_smoke(t *testing.T) { + t.Run("E2E", func(t *testing.T) { + + tr := strings.TrimSpace + got := pp.DiffString(tr(DiffStringA), tr(DiffStringB)) + t.Logf("\n%s", got) + exp := tr(DiffStringOut) + act := tr(got) + t.Logf("\n\nexpected:\n%s\n\nactual:\n%s", exp, act) + assert.Equal(t, exp, act) + }) + tr := func(str string) string { + var strs []string + s := bufio.NewScanner(strings.NewReader(str)) + s.Split(bufio.ScanLines) + for s.Scan() { + strs = append(strs, strings.TrimSpace(s.Text())) + } + return strings.Join(strs, "\n") + } + type TestCase struct { + Desc string + A string + B string + Diff string + } + for _, tc := range []TestCase{ + { + Desc: "when only A has value", + A: "aaa", + B: "", + Diff: "aaa <", + }, + { + Desc: "when only B has value", + A: "", + B: "bbb", + Diff: "> bbb", + }, + { + Desc: "when A and B not equals", + A: "aaa", + B: "bbb", + Diff: "aaa | bbb", + }, + { + Desc: "when A has values as B plus more in the middle", + A: "aaa\n123\nbbb", + B: "aaa\nbbb\n", + Diff: "aaa aaa\n123 <\nbbb bbb", + }, + { + Desc: "when B has values as A plus more in the middle", + A: "aaa\nbbb", + B: "aaa\n123\nbbb\n", + Diff: "aaa aaa\n> 123\nbbb bbb", + }, + { + Desc: "when A has values as B plus more afterwards", + A: "aaa\nbbb\n123", + B: "aaa\nbbb\n", + Diff: "aaa aaa\nbbb bbb\n123 <", + }, + { + Desc: "when B has values as A plus more afterwards", + A: "aaa\nbbb\n", + B: "aaa\nbbb\n123", + Diff: "aaa aaa\nbbb bbb\n> 123", + }, + } { + tc := tc + t.Run(tc.Desc, func(t *testing.T) { + diff := pp.DiffString(tr(tc.A), tr(tc.B)) + assert.Equal(t, tr(tc.Diff), tr(diff)) + }) + } +} diff --git a/pp/Format.go b/pp/Format.go new file mode 100644 index 0000000..89ebd23 --- /dev/null +++ b/pp/Format.go @@ -0,0 +1,145 @@ +package pp + +import ( + "bytes" + "fmt" + "io" + "reflect" + "sort" + "strings" + "unsafe" +) + +func Format(v any) string { + return formatter{}.Format(v) +} + +type formatter struct{} + +func (f formatter) Format(v any) string { + buf := &bytes.Buffer{} + rv := reflect.ValueOf(v) + f.visit(buf, rv, 0) + return buf.String() +} + +func (f formatter) visit(w io.Writer, v reflect.Value, depth int) { + switch v.Kind() { + case reflect.Array, reflect.Slice: + fmt.Fprintf(w, "%s{", v.Type().String()) + vLen := v.Len() + for i := 0; i < vLen; i++ { + f.newLine(w, depth+1) + f.visit(w, v.Index(i), depth+1) + fmt.Fprintf(w, ",") + } + if 0 < vLen { + f.newLine(w, depth) + } + fmt.Fprint(w, "}") + + case reflect.Map: + fmt.Fprintf(w, "%s{", v.Type().String()) + keys := v.MapKeys() + f.sortMapKeys(keys) + for _, key := range keys { + f.newLine(w, depth+1) + f.visit(w, key, depth+1) // key + fmt.Fprintf(w, ": ") + f.visit(w, v.MapIndex(key), depth+1) // value + fmt.Fprintf(w, ",") + } + if 0 < len(keys) { + f.newLine(w, depth) + } + fmt.Fprint(w, "}") + + case reflect.Struct: + // hack, cleanup this with recursion handling + _ = fmt.Sprintf("%#v", v.Interface()) + + fmt.Fprintf(w, "%s{", v.Type().String()) + fieldNum := v.NumField() + for i, fNum := 0, fieldNum; i < fNum; i++ { + name := v.Type().Field(i).Name + field := v.FieldByName(name) + // if reflect pkg change and int and other values no longer be accessible, then this can skip unexported fields + //if !field.CanInterface() { + // continue + //} + f.newLine(w, depth+1) + fmt.Fprintf(w, "%s: ", name) + f.visit(w, field, depth+1) + fmt.Fprintf(w, ",") + } + if 0 < fieldNum { + f.newLine(w, depth) + } + fmt.Fprint(w, "}") + + case reflect.Interface: + fmt.Fprintf(w, "(%s)(", v.Type().String()) + f.visit(w, v.Elem(), depth) + fmt.Fprint(w, ")") + + case reflect.Pointer: + fmt.Fprintf(w, "&") + f.visit(w, v.Elem(), depth) + + case reflect.Invalid: + fmt.Fprint(w, "nil") + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + fmt.Fprintf(w, "%#v", v.Int()) + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + fmt.Fprintf(w, "%#v", v.Uint()) + + case reflect.Float32, reflect.Float64: + fmt.Fprintf(w, "%#v", v.Float()) + + case reflect.String: + fmt.Fprintf(w, "%#v", v.String()) + + default: + if v.CanInterface() { + fmt.Fprintf(w, "%#v", v.Interface()) + } else { + fmt.Fprint(w, "") + } + } +} + +func (f formatter) newLine(w io.Writer, depth int) { + _, _ = w.Write([]byte("\n")) + f.indent(w, depth) +} + +func (f formatter) indent(w io.Writer, depth int) { + const defaultIndent = "\t" + _, _ = w.Write([]byte(strings.Repeat(defaultIndent, depth))) +} + +func (f formatter) sortMapKeys(keys []reflect.Value) { + if 0 == len(keys) { + return + } + kind := keys[0].Kind() + sort.Slice(keys, func(i, j int) bool { + switch kind { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return keys[i].Int() < keys[j].Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return keys[i].Uint() < keys[j].Uint() + case reflect.Float32, reflect.Float64: + return keys[i].Float() < keys[j].Float() + case reflect.String: + return keys[i].String() < keys[j].String() + } + return false + }) +} + +func (f formatter) getUnexportedValue(rf reflect.Value) reflect.Value { + return reflect.NewAt(rf.Type(), unsafe.Pointer(rf.UnsafeAddr())).Elem() +} diff --git a/pp/Format_test.go b/pp/Format_test.go new file mode 100644 index 0000000..88c6aff --- /dev/null +++ b/pp/Format_test.go @@ -0,0 +1,232 @@ +package pp_test + +import ( + "strings" + "testing" + + "github.com/adamluzsi/testcase/assert" + "github.com/adamluzsi/testcase/pp" +) + +const FormatPartialOutput = ` +pp_test.PrintStruct1{ + F1: "foo/bar/baz", + F2: 42, + F3: pp_test.PrintStruct2{ + F1: map[string]string{ + "baz": "qux", + "foo": "bar", + }, + F2: []string{ + "foo", + "bar", + "baz", + }, + F3: []pp_test.PrintStruct3{ + pp_test.PrintStruct3{ + F1: (pp_test.SomeInterface)("Hello, world!"), + }, + }, + }, +} +` + +func TestFormat_smoke(t *testing.T) { + type SomeInterface interface{} + type PrintStruct3 struct { + F1 SomeInterface + } + type PrintStruct2 struct { + F1 map[string]string + F2 []string + F3 []PrintStruct3 + } + type PrintStruct1 struct { + F1 string + F2 int + F3 PrintStruct2 + } + v := PrintStruct1{ + F1: "foo/bar/baz", + F2: 42, + F3: PrintStruct2{ + F1: map[string]string{ + "foo": "bar", + "baz": "qux", + }, + F2: []string{"foo", "bar", "baz"}, + F3: []PrintStruct3{ + {F1: SomeInterface("Hello, world!")}, + }, + }, + } + assert.Equal(t, + strings.TrimSpace(FormatPartialOutput), + pp.Format(v)) +} + +func TestFormat_nil(t *testing.T) { + assert.Equal(t, "nil", pp.Format(nil)) +} + +func TestFormat_unexportedFields(t *testing.T) { + type X struct { + a int + b uint + c float32 + d string + e map[int]int + } + v := X{ + a: 1, + b: 2, + c: 3, + d: "4", + e: map[int]int{5: 6}, + } + expected := "pp_test.X{\n\ta: 1,\n\tb: 0x2,\n\tc: 3,\n\td: \"4\",\n\te: map[int]int{\n\t\t5: 6,\n\t},\n}" + assert.Equal(t, expected, pp.Format(v)) +} + +func TestFormat_map(t *testing.T) { + type TestCase struct { + Desc string + In any + Out string + } + + for _, tc := range []TestCase{ + { + Desc: "map[string]...", + In: map[string]int{ + "b": 42, + "a": 42, + "c": 42, + }, + Out: "map[string]int{\n\t\"a\": 42,\n\t\"b\": 42,\n\t\"c\": 42,\n}", + }, + { + Desc: "map[int]...", + In: map[int]int{ + 2: 42, + 1: 42, + 3: 42, + }, + Out: "map[int]int{\n\t1: 42,\n\t2: 42,\n\t3: 42,\n}", + }, + { + Desc: "map[int8]...", + In: map[int8]int{ + 2: 42, + 1: 42, + 3: 42, + }, + Out: "map[int8]int{\n\t1: 42,\n\t2: 42,\n\t3: 42,\n}", + }, + { + Desc: "map[int8]...", + In: map[int8]int{ + 2: 42, + 1: 42, + 3: 42, + }, + Out: "map[int8]int{\n\t1: 42,\n\t2: 42,\n\t3: 42,\n}", + }, + { + Desc: "map[int16]...", + In: map[int16]int{ + 2: 42, + 1: 42, + 3: 42, + }, + Out: "map[int16]int{\n\t1: 42,\n\t2: 42,\n\t3: 42,\n}", + }, + { + Desc: "map[int32]...", + In: map[int32]int{ + 2: 42, + 1: 42, + 3: 42, + }, + Out: "map[int32]int{\n\t1: 42,\n\t2: 42,\n\t3: 42,\n}", + }, + { + Desc: "map[int64]...", + In: map[int64]int{ + 2: 42, + 1: 42, + 3: 42, + }, + Out: "map[int64]int{\n\t1: 42,\n\t2: 42,\n\t3: 42,\n}", + }, + { + Desc: "map[uint]...", + In: map[uint]int{ + 2: 42, + 1: 42, + 3: 42, + }, + Out: "map[uint]int{\n\t0x1: 42,\n\t0x2: 42,\n\t0x3: 42,\n}", + }, + { + Desc: "map[uint8]...", + In: map[uint8]int{ + 2: 42, + 1: 42, + 3: 42, + }, + Out: "map[uint8]int{\n\t0x1: 42,\n\t0x2: 42,\n\t0x3: 42,\n}", + }, + { + Desc: "map[uint16]...", + In: map[uint16]int{ + 2: 42, + 1: 42, + 3: 42, + }, + Out: "map[uint16]int{\n\t0x1: 42,\n\t0x2: 42,\n\t0x3: 42,\n}", + }, + { + Desc: "map[uint32]...", + In: map[uint32]int{ + 2: 42, + 1: 42, + 3: 42, + }, + Out: "map[uint32]int{\n\t0x1: 42,\n\t0x2: 42,\n\t0x3: 42,\n}", + }, + { + Desc: "map[uint64]...", + In: map[uint64]int{ + 2: 42, + 1: 42, + 3: 42, + }, + Out: "map[uint64]int{\n\t0x1: 42,\n\t0x2: 42,\n\t0x3: 42,\n}", + }, + { + Desc: "map[float32]...", + In: map[float32]int{ + 2: 42, + 1: 42, + 3: 42, + }, + Out: "map[float32]int{\n\t1: 42,\n\t2: 42,\n\t3: 42,\n}", + }, + { + Desc: "map[float64]...", + In: map[float64]int{ + 2: 42, + 1: 42, + 3: 42, + }, + Out: "map[float64]int{\n\t1: 42,\n\t2: 42,\n\t3: 42,\n}", + }, + } { + t.Run(tc.Desc, func(t *testing.T) { + assert.Equal(t, + pp.Format(tc.In), + strings.TrimSpace(tc.Out)) + }) + } +} diff --git a/pp/examples_test.go b/pp/examples_test.go new file mode 100644 index 0000000..f754dcb --- /dev/null +++ b/pp/examples_test.go @@ -0,0 +1,31 @@ +package pp_test + +import ( + "github.com/adamluzsi/testcase/pp" +) + +type ExampleStruct struct { + A string + B int +} + +func ExampleFormat() { + _ = pp.Format(ExampleStruct{ + A: "The Answer", + B: 42, + }) +} + +func ExampleDiff() { + _ = pp.Diff(ExampleStruct{ + A: "The Answer", + B: 42, + }, ExampleStruct{ + A: "The Question", + B: 42, + }) +} + +func ExampleDiffString() { + _ = pp.Diff("aaa\nbbb\nccc\n", "aaa\nccc\n") +}