diff --git a/go.mod b/go.mod index fe4e84c..630d2bf 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.13 require ( github.com/gofrs/flock v0.13.1 github.com/lathe-cli/kitup/go v0.1.3 + github.com/rivo/uniseg v0.4.7 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 86d3763..f738914 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lathe-cli/kitup/go v0.1.3 h1:7eEW8mDr5MbXFaTwr2dlnH8ebg+3Kmhj2/j4d9YfQNQ= github.com/lathe-cli/kitup/go v0.1.3/go.mod h1:dZgJDmFRKjaFBZyaP1qlzOB9IEafnf1/ai4KX4hMA4c= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= diff --git a/pkg/runtime/formatter_test.go b/pkg/runtime/formatter_test.go index 8b33366..45531b6 100644 --- a/pkg/runtime/formatter_test.go +++ b/pkg/runtime/formatter_test.go @@ -2,6 +2,8 @@ package runtime import ( "bytes" + "encoding/json" + "errors" "io" "strings" "testing" @@ -99,6 +101,75 @@ func TestFormatOutput_TableUsesConfiguredColumnLabels(t *testing.T) { } } +func TestFormatOutput_TableAlignsDisplayColumns(t *testing.T) { + cells := []struct { + text string + width int + }{ + {"abcd", 4}, + {"勿删", 4}, + {"AB", 4}, + {"東京", 4}, + {"한글", 4}, + {"e\u0301", 1}, + {"🇨🇳", 2}, + {"👩‍💻", 2}, + {"勿删-测试", 9}, + {"", 0}, + } + rows := make([]map[string]string, len(cells)) + for i, cell := range cells { + rows[i] = map[string]string{"name": cell.text, "status": "ok"} + } + rows = append(rows, map[string]string{"status": "ok"}) + data, err := json.Marshal(rows) + if err != nil { + t.Fatal(err) + } + for _, header := range []struct { + text string + width int + }{ + {"NAME", 4}, + {"资源显示名称", 12}, + } { + for _, format := range []string{"", "table"} { + t.Run(header.text+"/"+format, func(t *testing.T) { + var buf bytes.Buffer + err := FormatOutput(data, format, &buf, OutputHints{ + DefaultColumns: []string{"name", "status"}, + ColumnLabels: map[string]string{"name": header.text}, + }) + if err != nil { + t.Fatal(err) + } + columnWidth := max(9, header.width) + want := header.text + strings.Repeat(" ", columnWidth-header.width+2) + "STATUS\n" + for _, cell := range cells { + want += cell.text + strings.Repeat(" ", columnWidth-cell.width+2) + "ok\n" + } + want += strings.Repeat(" ", columnWidth+2) + "ok\n" + if buf.String() != want { + t.Fatalf("table columns are misaligned:\ngot %q\nwant %q", buf.String(), want) + } + }) + } + } +} + +func TestFormatOutput_TableWriterError(t *testing.T) { + reader, writer := io.Pipe() + defer func() { _ = writer.Close() }() + want := errors.New("output unavailable") + if err := reader.CloseWithError(want); err != nil { + t.Fatal(err) + } + err := FormatOutput([]byte(`[{"name":"勿删"}]`), "table", writer, OutputHints{}) + if !errors.Is(err, want) { + t.Fatalf("FormatOutput error = %v, want %v", err, want) + } +} + func TestFormatOutput_TableUsesExactCurrencyFormats(t *testing.T) { var buf bytes.Buffer data := []byte(`{"items":[{"amount":1000000},{"amount":1234567},{"amount":1},{"amount":-5000000},{"amount":9007199254740993},{"amount":"2500000"},{"amount":1.25}]}`) diff --git a/pkg/runtime/table.go b/pkg/runtime/table.go index 3c30c5c..ebd65d5 100644 --- a/pkg/runtime/table.go +++ b/pkg/runtime/table.go @@ -6,7 +6,8 @@ import ( "io" "sort" "strings" - "text/tabwriter" + + "github.com/rivo/uniseg" ) // PreferredColumns is the canonical ordering used both by codegen (to derive @@ -240,24 +241,39 @@ func stringify(v any) string { } func writeTable(cols []string, rows []map[string]any, labels map[string]string, formats map[string]ColumnFormat, w io.Writer) error { - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - for i, c := range cols { - if i > 0 { - fmt.Fprint(tw, "\t") + type cell struct { + text string + width int + } + cells := make([]cell, (len(rows)+1)*len(cols)) + widths := make([]int, len(cols)) + for row := range len(rows) + 1 { + for col, path := range cols { + value := &cells[row*len(cols)+col] + if row == 0 { + value.text = columnHeader(path, labels) + } else { + value.text = lookupPath(rows[row-1], path, formats) + } + if col < len(cols)-1 { + value.width = uniseg.StringWidth(value.text) + widths[col] = max(widths[col], value.width) + } } - fmt.Fprint(tw, columnHeader(c, labels)) } - fmt.Fprintln(tw) - for _, r := range rows { - for i, c := range cols { - if i > 0 { - fmt.Fprint(tw, "\t") + var output strings.Builder + for row := range len(rows) + 1 { + for col := range cols { + value := cells[row*len(cols)+col] + output.WriteString(value.text) + if col < len(cols)-1 { + output.WriteString(strings.Repeat(" ", widths[col]-value.width+2)) } - fmt.Fprint(tw, lookupPath(r, c, formats)) } - fmt.Fprintln(tw) + output.WriteByte('\n') } - return tw.Flush() + _, err := io.WriteString(w, output.String()) + return err } func columnHeader(path string, labels map[string]string) string {