From 19af3e3e3ecb9d80d4b12f52264c4df3468a0648 Mon Sep 17 00:00:00 2001 From: buty4649 Date: Fri, 17 Apr 2026 17:13:41 +0900 Subject: [PATCH] =?UTF-8?q?table:=20=E3=83=98=E3=83=83=E3=83=80=E4=B8=8B?= =?UTF-8?q?=E7=B7=9A=E4=BB=98=E3=81=8D=E3=81=AE=E3=83=86=E3=83=BC=E3=83=96?= =?UTF-8?q?=E3=83=AB/=E3=83=AA=E3=82=B9=E3=83=88=E5=87=BA=E5=8A=9B?= =?UTF-8?q?=E3=81=AB=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tabwriter をやめて独自の tablePrinter を導入。go-runewidth で CJK を含むセル幅を正しく計算し、TTY 出力時のみヘッダ行を緑色+下線で 装飾する。列間ギャップと末尾カラムのターミナル幅残り分も下線に 含めて連続した見た目にする。キー/値表示用に newList も追加。 Co-Authored-By: Claude Opus 4.7 --- cmd/approval.go | 34 ++++----- cmd/document.go | 60 +++++++-------- cmd/document_attachment.go | 15 ++-- cmd/form.go | 19 ++--- cmd/me.go | 13 ++-- cmd/output.go | 146 +++++++++++++++++++++++++++++++++++++ cmd/output_test.go | 99 +++++++++++++++++++++++++ cmd/query.go | 13 ++-- cmd/system.go | 21 ++---- go.mod | 6 +- go.sum | 10 +++ 11 files changed, 330 insertions(+), 106 deletions(-) diff --git a/cmd/approval.go b/cmd/approval.go index 1ee73b4..e493b24 100644 --- a/cmd/approval.go +++ b/cmd/approval.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "strings" - "text/tabwriter" "github.com/pepabo/xpoint-cli/internal/xpoint" "github.com/spf13/cobra" @@ -139,16 +138,14 @@ func runApprovalList(cmd *cobra.Command, args []string) error { } return render(res, resolveOutputFormat(approvalListOutput), approvalListJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() fmt.Fprintf(os.Stdout, "total: %d\n", res.TotalCount) - fmt.Fprintln(w, "DOCID\tSTATUS\tFORM_NAME\tAPPLY_USER\tAPPROVERS\tAPPLY_DATETIME\tTITLE1") + w := newTable(os.Stdout, + "DOCID", "STATUS", "FORM_NAME", "APPLY_USER", "APPROVERS", "APPLY_DATETIME", "TITLE1") for _, a := range res.ApprovalList { - fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", - a.DocID, a.DisplayStatus, a.FormName, a.ApplyUser, - strings.Join(a.ApprovalUser, ","), a.ApplyDatetime, a.Title1, - ) + w.AddRow(a.DocID, a.DisplayStatus, a.FormName, a.ApplyUser, + strings.Join(a.ApprovalUser, ","), a.ApplyDatetime, a.Title1) } + w.Print() return nil }) } @@ -179,22 +176,20 @@ func runApprovalWait(cmd *cobra.Command, args []string) error { } return render(res, resolveOutputFormat(approvalWaitOutput), approvalWaitJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "TYPE\tNAME\tCOUNT") + w := newTable(os.Stdout, "TYPE", "NAME", "COUNT") for _, s := range res.StatusList { - fmt.Fprintf(w, "%d\t%s\t%d\n", s.Type, s.Name, s.Count) + w.AddRow(s.Type, s.Name, s.Count) } - w.Flush() + w.Print() if len(res.WaitList) > 0 { fmt.Fprintln(os.Stdout) fmt.Fprintln(os.Stdout, "最新の承認待ち書類:") - w2 := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w2, "DOCID\tFORM_NAME\tWRITER\tWRITE_DATE\tTITLE") + w2 := newTable(os.Stdout, "DOCID", "FORM_NAME", "WRITER", "WRITE_DATE", "TITLE") for _, d := range res.WaitList { - fmt.Fprintf(w2, "%d\t%s\t%s\t%s\t%s\n", d.DocID, d.Name, d.WriterName, d.WriteDate, d.Title) + w2.AddRow(d.DocID, d.Name, d.WriterName, d.WriteDate, d.Title) } - w2.Flush() + w2.Print() } return nil }) @@ -228,12 +223,11 @@ func runApprovalHidden(cmd *cobra.Command, args []string) error { return render(res, resolveOutputFormat(approvalHiddenOutput), approvalHiddenJQ, func() error { fmt.Fprintf(os.Stdout, "message: %s\n", res.Message) - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "DOCID") + w := newTable(os.Stdout, "DOCID") for _, id := range res.DocID { - fmt.Fprintf(w, "%d\n", id) + w.AddRow(id) } + w.Print() return nil }) } diff --git a/cmd/document.go b/cmd/document.go index c07578e..93790a3 100644 --- a/cmd/document.go +++ b/cmd/document.go @@ -9,7 +9,6 @@ import ( "path/filepath" "strconv" "strings" - "text/tabwriter" "time" "github.com/pepabo/xpoint-cli/internal/xpoint" @@ -487,15 +486,13 @@ func runDocumentSearch(cmd *cobra.Command, args []string) error { } return render(res, resolveOutputFormat(docSearchOutput), docSearchJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() fmt.Fprintf(os.Stdout, "total: %d\n", res.TotalCount) - fmt.Fprintln(w, "DOCID\tFORM_NAME\tWRITER\tWRITE_DATETIME\tSTEP\tSTAT\tTITLE1") + w := newTable(os.Stdout, + "DOCID", "FORM_NAME", "WRITER", "WRITE_DATETIME", "STEP", "STAT", "TITLE1") for _, it := range res.Items { - fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%d\t%s\n", - it.DocID, it.Form.Name, it.Writer, it.WriteDatetime, it.Step, it.Stat, it.Title1, - ) + w.AddRow(it.DocID, it.Form.Name, it.Writer, it.WriteDatetime, it.Step, it.Stat, it.Title1) } + w.Print() return nil }) } @@ -534,10 +531,9 @@ func runDocumentCreate(cmd *cobra.Command, args []string) error { } return render(&view, resolveOutputFormat(docCreateOutput), docCreateJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "DOCID\tMESSAGE_TYPE\tMESSAGE\tURL") - fmt.Fprintf(w, "%d\t%d\t%s\t%s\n", view.DocID, view.MessageType, view.Message, view.URL) + w := newTable(os.Stdout, "DOCID", "MESSAGE_TYPE", "MESSAGE", "URL") + w.AddRow(view.DocID, view.MessageType, view.Message, view.URL) + w.Print() return nil }) } @@ -586,10 +582,9 @@ func runDocumentEdit(cmd *cobra.Command, args []string) error { } return render(res, resolveOutputFormat(docEditOutput), docEditJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "DOCID\tMESSAGE_TYPE\tMESSAGE") - fmt.Fprintf(w, "%d\t%d\t%s\n", res.DocID, res.MessageType, res.Message) + w := newTable(os.Stdout, "DOCID", "MESSAGE_TYPE", "MESSAGE") + w.AddRow(res.DocID, res.MessageType, res.Message) + w.Print() return nil }) } @@ -615,10 +610,9 @@ func runDocumentDelete(cmd *cobra.Command, args []string) error { } return render(res, resolveOutputFormat(docDeleteOutput), docDeleteJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "MESSAGE_TYPE\tMESSAGE") - fmt.Fprintf(w, "%d\t%s\n", res.MessageType, res.Message) + w := newTable(os.Stdout, "MESSAGE_TYPE", "MESSAGE") + w.AddRow(res.MessageType, res.Message) + w.Print() return nil }) } @@ -916,10 +910,9 @@ func runDocumentCommentAdd(cmd *cobra.Command, args []string) error { return err } return render(res, resolveOutputFormat(docCommentAddOutput), docCommentAddJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "DOCID\tSEQ\tMESSAGE_TYPE\tMESSAGE") - fmt.Fprintf(w, "%d\t%d\t%d\t%s\n", res.DocID, res.Seq, res.MessageType, res.Message) + w := newTable(os.Stdout, "DOCID", "SEQ", "MESSAGE_TYPE", "MESSAGE") + w.AddRow(res.DocID, res.Seq, res.MessageType, res.Message) + w.Print() return nil }) } @@ -938,16 +931,15 @@ func runDocumentCommentGet(cmd *cobra.Command, args []string) error { return err } return render(res, resolveOutputFormat(docCommentGetOutput), docCommentGetJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "SEQ\tATTENTION\tWRITER\tWRITE_DATE\tCONTENT") + w := newTable(os.Stdout, "SEQ", "ATTENTION", "WRITER", "WRITE_DATE", "CONTENT") for _, cm := range res.CommentList { attention := "-" if cm.AttentionFlg { attention = "*" } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", cm.SeqNo, attention, cm.WriterName, cm.WriteDate, cm.Content) + w.AddRow(cm.SeqNo, attention, cm.WriterName, cm.WriteDate, cm.Content) } + w.Print() return nil }) } @@ -990,10 +982,9 @@ func runDocumentCommentEdit(cmd *cobra.Command, args []string) error { return err } return render(res, resolveOutputFormat(docCommentEditOutput), docCommentEditJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "DOCID\tSEQ\tMESSAGE_TYPE\tMESSAGE") - fmt.Fprintf(w, "%d\t%d\t%d\t%s\n", res.DocID, res.Seq, res.MessageType, res.Message) + w := newTable(os.Stdout, "DOCID", "SEQ", "MESSAGE_TYPE", "MESSAGE") + w.AddRow(res.DocID, res.Seq, res.MessageType, res.Message) + w.Print() return nil }) } @@ -1019,10 +1010,9 @@ func runDocumentCommentDelete(cmd *cobra.Command, args []string) error { return err } return render(res, resolveOutputFormat(docCommentDeleteOutput), docCommentDeleteJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "DOCID\tSEQ\tMESSAGE_TYPE\tMESSAGE") - fmt.Fprintf(w, "%d\t%d\t%d\t%s\n", res.DocID, res.Seq, res.MessageType, res.Message) + w := newTable(os.Stdout, "DOCID", "SEQ", "MESSAGE_TYPE", "MESSAGE") + w.AddRow(res.DocID, res.Seq, res.MessageType, res.Message) + w.Print() return nil }) } diff --git a/cmd/document_attachment.go b/cmd/document_attachment.go index 316acde..4e8acc3 100644 --- a/cmd/document_attachment.go +++ b/cmd/document_attachment.go @@ -7,7 +7,6 @@ import ( "path/filepath" "strconv" "strings" - "text/tabwriter" "github.com/pepabo/xpoint-cli/internal/xpoint" "github.com/spf13/cobra" @@ -219,12 +218,11 @@ func runDocumentAttachmentList(cmd *cobra.Command, args []string) error { return err } return render(res, resolveOutputFormat(docAttachListOutput), docAttachListJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "SEQ\tNAME\tSIZE\tCONTENT_TYPE\tREMARKS") + w := newTable(os.Stdout, "SEQ", "NAME", "SIZE", "CONTENT_TYPE", "REMARKS") for _, a := range res.Attachments { - fmt.Fprintf(w, "%d\t%s\t%d\t%s\t%s\n", a.Seq, a.Name, a.Size, a.ContentType, a.Remarks) + w.AddRow(a.Seq, a.Name, a.Size, a.ContentType, a.Remarks) } + w.Print() return nil }) } @@ -338,10 +336,9 @@ func runDocumentAttachmentDelete(cmd *cobra.Command, args []string) error { } func renderAttachmentMutation(res *xpoint.AttachmentMutationResponse) error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "DOCID\tSEQ\tMESSAGE_TYPE\tMESSAGE\tDETAIL") - fmt.Fprintf(w, "%d\t%d\t%d\t%s\t%s\n", res.DocID, res.Seq, res.MessageType, res.Message, res.Detail) + w := newTable(os.Stdout, "DOCID", "SEQ", "MESSAGE_TYPE", "MESSAGE", "DETAIL") + w.AddRow(res.DocID, res.Seq, res.MessageType, res.Message, res.Detail) + w.Print() return nil } diff --git a/cmd/form.go b/cmd/form.go index 7847bec..943cbe4 100644 --- a/cmd/form.go +++ b/cmd/form.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "strconv" - "text/tabwriter" "github.com/pepabo/xpoint-cli/internal/xpoint" "github.com/spf13/cobra" @@ -65,18 +64,17 @@ func runFormList(cmd *cobra.Command, args []string) error { } return render(res, resolveOutputFormat(formListOutput), formListJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "GROUP_ID\tGROUP_NAME\tFORM_ID\tFORM_CODE\tFORM_NAME") + w := newTable(os.Stdout, "GROUP_ID", "GROUP_NAME", "FORM_ID", "FORM_CODE", "FORM_NAME") for _, g := range res.FormGroup { if len(g.Form) == 0 { - fmt.Fprintf(w, "%d\t%s\t-\t-\t-\n", g.ID, g.Name) + w.AddRow(g.ID, g.Name, "-", "-", "-") continue } for _, f := range g.Form { - fmt.Fprintf(w, "%d\t%s\t%d\t%s\t%s\n", g.ID, g.Name, f.ID, f.Code, f.Name) + w.AddRow(g.ID, g.Name, f.ID, f.Code, f.Name) } } + w.Print() return nil }) } @@ -100,16 +98,13 @@ func runFormShow(cmd *cobra.Command, args []string) error { return render(res, resolveOutputFormat(formShowOutput), formShowJQ, func() error { form := res.Form fmt.Fprintf(os.Stdout, "FORM: %s %s MAX_STEP: %d\n", form.Code, form.Name, form.MaxStep) - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "PAGE\tFIELD_ID\tTYPE\tREQUIRED\tUNIQUE\tARRAYSIZE\tLABEL") + w := newTable(os.Stdout, "PAGE", "FIELD_ID", "TYPE", "REQUIRED", "UNIQUE", "ARRAYSIZE", "LABEL") for _, p := range form.Pages { for _, f := range p.Fields { - fmt.Fprintf(w, "%d\t%s\t%d\t%t\t%t\t%d\t%s\n", - p.PageNo, f.FieldID, f.FieldType, f.Required, f.Unique, f.ArraySize, f.Label, - ) + w.AddRow(p.PageNo, f.FieldID, f.FieldType, f.Required, f.Unique, f.ArraySize, f.Label) } } + w.Print() return nil }) } diff --git a/cmd/me.go b/cmd/me.go index b9082d8..911ccf0 100644 --- a/cmd/me.go +++ b/cmd/me.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "os" - "text/tabwriter" "github.com/spf13/cobra" ) @@ -48,12 +47,12 @@ func runMe(cmd *cobra.Command, args []string) error { } return render(info, resolveOutputFormat(meOutput), meJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintf(w, "user_code:\t%s\n", info.AtledExt.UserCode) - fmt.Fprintf(w, "user_name:\t%s\n", info.UserName) - fmt.Fprintf(w, "display_name:\t%s\n", info.DisplayName) - fmt.Fprintf(w, "scim_id:\t%s\n", info.ID) + w := newList(os.Stdout) + w.AddRow("user_code:", info.AtledExt.UserCode) + w.AddRow("user_name:", info.UserName) + w.AddRow("display_name:", info.DisplayName) + w.AddRow("scim_id:", info.ID) + w.Print() return nil }) } diff --git a/cmd/output.go b/cmd/output.go index b4e567d..668e229 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -3,9 +3,13 @@ package cmd import ( "encoding/json" "fmt" + "io" "os" + "strings" "github.com/itchyny/gojq" + "github.com/mattn/go-runewidth" + "golang.org/x/term" ) // resolveOutputFormat returns "json" or "table" based on the flag and TTY state. @@ -48,6 +52,148 @@ func writeJSON(out *os.File, v any) error { return enc.Encode(v) } +// tablePrinter renders tab-aligned rows to out. Column widths are computed +// from the widest cell (header or row) using go-runewidth, so CJK wide +// characters align correctly. When out is a TTY, header cells are wrapped in +// the ANSI underline SGR code (no other decoration). +type tablePrinter struct { + out io.Writer + isTTY bool + headers []string + rows [][]string + showHead bool +} + +// newTable creates a table printer with the given header labels. +func newTable(out io.Writer, headers ...string) *tablePrinter { + return &tablePrinter{ + out: out, + isTTY: isWriterTerminal(out), + headers: headers, + showHead: true, + } +} + +// newList creates a no-header two-column printer for key/value output. +func newList(out io.Writer) *tablePrinter { + return &tablePrinter{ + out: out, + isTTY: isWriterTerminal(out), + } +} + +func (t *tablePrinter) AddRow(vals ...any) { + row := make([]string, len(vals)) + for i, v := range vals { + row[i] = fmt.Sprint(v) + } + t.rows = append(t.rows, row) +} + +func (t *tablePrinter) Print() { + numCols := 0 + if t.showHead { + numCols = len(t.headers) + } + for _, r := range t.rows { + if len(r) > numCols { + numCols = len(r) + } + } + if numCols == 0 { + return + } + + widths := make([]int, numCols) + if t.showHead { + for i, h := range t.headers { + if w := runewidth.StringWidth(h); w > widths[i] { + widths[i] = w + } + } + } + for _, r := range t.rows { + for i, c := range r { + if w := runewidth.StringWidth(c); w > widths[i] { + widths[i] = w + } + } + } + + if t.showHead && len(t.headers) > 0 { + t.writeRow(t.headers, widths, t.isTTY) + } + for _, r := range t.rows { + t.writeRow(r, widths, false) + } +} + +// writeRow prints a single row. If underline is true, each non-empty cell is +// wrapped in the ANSI underline SGR so the header text itself is underlined +// without drawing a separator row. +func (t *tablePrinter) writeRow(row []string, widths []int, underline bool) { + const gap = 2 + last := len(widths) - 1 + used := 0 + for i := 0; i < len(widths); i++ { + var cell string + if i < len(row) { + cell = row[i] + } + pad := widths[i] - runewidth.StringWidth(cell) + if pad < 0 { + pad = 0 + } + if i == last { + trailing := 0 + if underline { + cellW := runewidth.StringWidth(cell) + if tw := t.termWidth(); tw > used+cellW { + trailing = tw - used - cellW + } + } + fmt.Fprint(t.out, decorate(cell+strings.Repeat(" ", trailing), underline)) + } else { + padded := cell + strings.Repeat(" ", pad+gap) + fmt.Fprint(t.out, decorate(padded, underline)) + used += widths[i] + gap + } + } + fmt.Fprintln(t.out) +} + +// termWidth returns the terminal width for the output writer, or 0 when +// unavailable (non-TTY or the fd cannot be queried). +func (t *tablePrinter) termWidth() int { + if !t.isTTY { + return 0 + } + f, ok := t.out.(*os.File) + if !ok { + return 0 + } + w, _, err := term.GetSize(int(f.Fd())) + if err != nil { + return 0 + } + return w +} + +func decorate(s string, underline bool) string { + if !underline || s == "" { + return s + } + return "\x1b[4;32m" + s + "\x1b[0m" +} + +func isWriterTerminal(out io.Writer) bool { + f, ok := out.(*os.File) + if !ok { + return false + } + return isTerminal(f) +} + func runJQ(v any, expr string) error { b, err := json.Marshal(v) if err != nil { diff --git a/cmd/output_test.go b/cmd/output_test.go index e71132f..2007261 100644 --- a/cmd/output_test.go +++ b/cmd/output_test.go @@ -7,6 +7,8 @@ import ( "os" "strings" "testing" + + "github.com/mattn/go-runewidth" ) func captureStdout(t *testing.T, fn func() error) (string, error) { @@ -155,3 +157,100 @@ func TestRender_UnknownFormat(t *testing.T) { t.Errorf("err = %v", err) } } + +func TestTablePrinter_NoAnsiWhenNotTTY(t *testing.T) { + var buf bytes.Buffer + w := newTable(&buf, "A", "B") + w.AddRow(1, "foo") + w.AddRow(22, "barbaz") + w.Print() + if strings.Contains(buf.String(), "\x1b[") { + t.Errorf("expected no ANSI codes on non-TTY writer, got:\n%q", buf.String()) + } + // Header labels and cell values should be present. + for _, want := range []string{"A", "B", "foo", "barbaz"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("missing %q in output:\n%s", want, buf.String()) + } + } +} + +func TestTablePrinter_AlignmentCJK(t *testing.T) { + var buf bytes.Buffer + w := newTable(&buf, "A", "B") + w.AddRow("あ", "いう") + w.AddRow("xx", "y") + w.Print() + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("want 3 lines (header + 2 rows), got:\n%s", buf.String()) + } + // A column width = 2 (widest of "A"=1, "あ"=2, "xx"=2); gap = 2. So + // the B column must start at display offset 4 on every row. + skipCells := func(s string, n int) string { + w := 0 + for i, r := range s { + if w >= n { + return s[i:] + } + w += runewidth.RuneWidth(r) + } + return "" + } + wantBCol := []string{"B", "いう", "y"} + for i, l := range lines { + if got := skipCells(l, 4); got != wantBCol[i] { + t.Errorf("line %d B column = %q, want %q (full line: %q)", + i, got, wantBCol[i], l) + } + } +} + +func TestTablePrinter_HeaderUnderlinedOnTTY(t *testing.T) { + f := ttyFile(t) + defer func() { _ = f.Close() }() + // Write directly to the TTY through newTable; we cannot read it back, so + // instead verify the helper path by calling decorate directly. + if got := decorate("HEAD", true); !strings.HasPrefix(got, "\x1b[4;32m") || !strings.HasSuffix(got, "\x1b[0m") { + t.Errorf("expected underlined+green header, got %q", got) + } + if got := decorate("HEAD", false); got != "HEAD" { + t.Errorf("plain path changed output: %q", got) + } + if got := decorate("", true); got != "" { + t.Errorf("empty cell should stay empty, got %q", got) + } + _ = f // already used as TTY availability check +} + +func TestList_NoHeaderAndPlain(t *testing.T) { + var buf bytes.Buffer + w := newList(&buf) + w.AddRow("user_code:", "abc") + w.AddRow("display_name:", "alice") + w.Print() + out := buf.String() + if strings.Contains(out, "\x1b[") { + t.Errorf("list should not emit ANSI on non-TTY writer:\n%s", out) + } + for _, want := range []string{"user_code:", "display_name:", "abc", "alice"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in output:\n%s", want, out) + } + } +} + +// ttyFile returns an *os.File that isTerminal reports as a TTY, or skips the +// test if /dev/tty isn't available (e.g. CI). +func ttyFile(t *testing.T) *os.File { + t.Helper() + f, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0) + if err != nil { + t.Skipf("cannot open /dev/tty: %v", err) + } + if !isTerminal(f) { + _ = f.Close() + t.Skip("/dev/tty is not reported as a terminal in this env") + } + return f +} diff --git a/cmd/query.go b/cmd/query.go index 5403f4e..12405dc 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "strings" - "text/tabwriter" "github.com/pepabo/xpoint-cli/internal/xpoint" "github.com/spf13/cobra" @@ -93,20 +92,18 @@ func runQueryList(cmd *cobra.Command, args []string) error { return err } return render(res, resolveOutputFormat(queryListOutput), queryListJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "GROUP_ID\tGROUP_NAME\tQUERY_ID\tQUERY_CODE\tQUERY_NAME\tQUERY_TYPE\tFORM") + w := newTable(os.Stdout, + "GROUP_ID", "GROUP_NAME", "QUERY_ID", "QUERY_CODE", "QUERY_NAME", "QUERY_TYPE", "FORM") for _, g := range res.QueryGroups { if len(g.Queries) == 0 { - fmt.Fprintf(w, "%d\t%s\t-\t-\t-\t-\t-\n", g.QueryGroupID, g.QueryGroupName) + w.AddRow(g.QueryGroupID, g.QueryGroupName, "-", "-", "-", "-", "-") continue } for _, q := range g.Queries { - fmt.Fprintf(w, "%d\t%s\t%d\t%s\t%s\t%s\t%s\n", - g.QueryGroupID, g.QueryGroupName, q.QueryID, q.QueryCode, q.QueryName, q.QueryType, formatQueryForm(q), - ) + w.AddRow(g.QueryGroupID, g.QueryGroupName, q.QueryID, q.QueryCode, q.QueryName, q.QueryType, formatQueryForm(q)) } } + w.Print() return nil }) } diff --git a/cmd/system.go b/cmd/system.go index 5402a04..c7a1c26 100644 --- a/cmd/system.go +++ b/cmd/system.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "strconv" - "text/tabwriter" "github.com/pepabo/xpoint-cli/internal/xpoint" "github.com/spf13/cobra" @@ -74,20 +73,17 @@ func runSystemFormList(cmd *cobra.Command, args []string) error { } return render(res, resolveOutputFormat(systemFormListOutput), systemFormListJQ, func() error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "GROUP_ID\tGROUP_NAME\tFORMS\tFORM_ID\tFORM_CODE\tFORM_NAME\tPAGES\tTABLE") + w := newTable(os.Stdout, "GROUP_ID", "GROUP_NAME", "FORMS", "FORM_ID", "FORM_CODE", "FORM_NAME", "PAGES", "TABLE") for _, g := range res.FormGroup { if len(g.Form) == 0 { - fmt.Fprintf(w, "%s\t%s\t%d\t-\t-\t-\t-\t-\n", g.ID, g.Name, g.FormCount) + w.AddRow(g.ID, g.Name, g.FormCount, "-", "-", "-", "-", "-") continue } for _, f := range g.Form { - fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\t%s\t%d\t%s\n", - g.ID, g.Name, g.FormCount, f.ID, f.Code, f.Name, f.PageCount, f.TableName, - ) + w.AddRow(g.ID, g.Name, g.FormCount, f.ID, f.Code, f.Name, f.PageCount, f.TableName) } } + w.Print() return nil }) } @@ -111,16 +107,13 @@ func runSystemFormShow(cmd *cobra.Command, args []string) error { return render(res, resolveOutputFormat(systemFormShowOutput), systemFormShowJQ, func() error { form := res.Form fmt.Fprintf(os.Stdout, "FORM: %s %s MAX_STEP: %d\n", form.Code, form.Name, form.MaxStep) - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - fmt.Fprintln(w, "PAGE\tFIELD_ID\tTYPE\tREQUIRED\tUNIQUE\tARRAYSIZE\tLABEL") + w := newTable(os.Stdout, "PAGE", "FIELD_ID", "TYPE", "REQUIRED", "UNIQUE", "ARRAYSIZE", "LABEL") for _, p := range form.Pages { for _, f := range p.Fields { - fmt.Fprintf(w, "%d\t%s\t%d\t%t\t%t\t%d\t%s\n", - p.PageNo, f.FieldID, f.FieldType, f.Required, f.Unique, f.ArraySize, f.Label, - ) + w.AddRow(p.PageNo, f.FieldID, f.FieldType, f.Required, f.Unique, f.ArraySize, f.Label) } } + w.Print() return nil }) } diff --git a/go.mod b/go.mod index 91d6706..7d91879 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,19 @@ go 1.26.1 require ( github.com/itchyny/gojq v0.12.19 + github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 ) require ( + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/spf13/pflag v1.0.10 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index 079d0df..f93f890 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= @@ -11,6 +15,8 @@ github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -28,6 +34,10 @@ github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cma go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=