/* Copyright © 2026 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package printer import ( "encoding/json" "fmt" "regexp" "sort" "strconv" "strings" "codeberg.org/scip/esctl/pkg/cfg" "github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter/renderer" "github.com/olekukonko/tablewriter/tw" "github.com/seeruk/go-wordwrap" "gopkg.in/yaml.v3" ) type Table struct { Mode string // table, markdown, json Headers []string Entries [][]string lenHeaders []int alignInts bool maxwidth int } func NewTable(conf *cfg.Config, columns, rows int) *Table { table := Table{Mode: conf.Output, maxwidth: cfg.GetTermWidth()} table.Headers = make([]string, columns) table.Entries = make([][]string, rows) table.lenHeaders = make([]int, columns) table.alignInts = conf.AlignInts return &table } func (data *Table) Print() error { switch data.Mode { case "markdown", "md": return data.PrintMarkdown() case "json": return data.PrintJSON() case "yaml": return data.PrintYAML() default: return data.PrintTSV() } } var ( ansiCtrlSeq = regexp.MustCompile(`\033.[0-9;]+m`) ) // needed for json and yaml output func (data *Table) toMap() []map[string]string { raw := make([]map[string]string, len(data.Entries)) for idx, entries := range data.Entries { raw[idx] = make(map[string]string, len(data.Headers)) for eidx, entry := range entries { raw[idx][data.Headers[eidx]] = entry } } return raw } func (data *Table) PrintYAML() error { raw := data.toMap() body, err := yaml.Marshal(raw) if err != nil { return fmt.Errorf("failed to produce YAML output: %s", err) } fmt.Println(string(body)) return nil } func (data *Table) PrintJSON() error { raw := data.toMap() body, err := json.MarshalIndent(raw, "", " ") if err != nil { return fmt.Errorf("failed to produce JSON output: %s", err) } fmt.Println(string(body)) return nil } // Well. tablewriter should be able to print in TSV mode, but it // doesn't work because of the various ansic color escapes we're // using. Therefore we do it the hard way and use our own TSV printer. func (data *Table) PrintTSV() error { // determine lenght's for idx, head := range data.Headers { data.lenHeaders[idx] = visibleLen(head) } for _, entries := range data.Entries { currentWidth := 0 for idx, entry := range entries { length := visibleLen(entry) if data.lenHeaders[idx] < length { if length > currentWidth+data.maxwidth { data.lenHeaders[idx] = data.maxwidth - currentWidth } else { data.lenHeaders[idx] = length } } currentWidth += data.lenHeaders[idx] } } // output headers for idx, header := range data.Headers { if idx+1 != len(data.Headers) { fmt.Print(header, strings.Repeat(" ", data.lenHeaders[idx]-visibleLen(header))) } else { // no padding for last header fmt.Print(header) } if idx < len(data.Headers)-1 { fmt.Print(" ") } } fmt.Println() for _, entries := range data.Entries { currentWidth := 0 for idx, entry := range entries { length := visibleLen(entry) if length+currentWidth > data.maxwidth { // text is too wide to be put into one line, wrap it wrapper := wordwrap.Wrapper(data.maxwidth-currentWidth, false) wrapped := wrapper(entry) // and indent it for idx, line := range strings.Split(wrapped, "\n") { if idx == 0 { entry = line } else { entry += "\n " + strings.Repeat(" ", currentWidth) + line } } } currentWidth += data.lenHeaders[idx] if isInt(entry) && data.alignInts { // align right fmt.Print(strings.Repeat(" ", data.lenHeaders[idx]-length), entry) } else if length < data.lenHeaders[idx] && idx+1 != len(entries) { // pad right, if required fmt.Print(entry, strings.Repeat(" ", data.lenHeaders[idx]-length)) } else { // no padding for last entry fmt.Print(entry) } if idx < len(data.Headers)-1 { fmt.Print(" ") } } fmt.Println() } return nil } func (data *Table) PrintTabularFail() error { OFS := " " tableString := &strings.Builder{} styleTSV := tw.NewSymbolCustom("space").WithColumn("\t") table := tablewriter.NewTable(tableString, tablewriter.WithRenderer( renderer.NewBlueprint(tw.Rendition{ Borders: tw.BorderNone, Symbols: styleTSV, Settings: tw.Settings{ Separators: tw.SeparatorsNone, Lines: tw.LinesNone, }, })), tablewriter.WithConfig(tablewriter.Config{ Header: tw.CellConfig{ Formatting: tw.CellFormatting{ AutoFormat: tw.Off, }, Padding: tw.CellPadding{Global: tw.Padding{Left: "", Right: OFS}}, }, Row: tw.CellConfig{ Formatting: tw.CellFormatting{ AutoWrap: tw.WrapNone, Alignment: tw.AlignLeft, }, Padding: tw.CellPadding{Global: tw.Padding{Right: OFS}}, }, Debug: true, }), ) table.Header(data.Headers) if err := table.Bulk(data.Entries); err != nil { return fmt.Errorf("failed to add data to table renderer: %s", err) } if err := table.Render(); err != nil { return fmt.Errorf("failed to render table: %s", err) } // we need to trim our output here, because tablewriter appends // excess whitespace to our rows. cleanedString := &strings.Builder{} for _, row := range strings.Split(strings.TrimSpace(tableString.String()), "\n") { cleanedString.WriteString(strings.TrimSpace(row)) cleanedString.WriteString("\n") } fmt.Println(cleanedString.String()) return nil } func (data *Table) PrintMarkdown() error { tableString := &strings.Builder{} table := tablewriter.NewTable(tableString, tablewriter.WithRenderer( renderer.NewBlueprint( tw.Rendition{ Borders: tw.Border{ Left: tw.On, Right: tw.On, Top: tw.Off, Bottom: tw.Off, }, Settings: tw.Settings{ Separators: tw.Separators{ ShowHeader: tw.On, ShowFooter: tw.Off, BetweenRows: tw.Off, BetweenColumns: 0, }, }, Symbols: tw.NewSymbols(tw.StyleMarkdown), })), tablewriter.WithConfig( tablewriter.Config{ Header: tw.CellConfig{ Formatting: tw.CellFormatting{ Alignment: tw.AlignLeft, AutoFormat: tw.Off, }, }, Row: tw.CellConfig{ Formatting: tw.CellFormatting{ Alignment: tw.AlignLeft, }, }, }, ), ) table.Header(data.Headers) if err := table.Bulk(data.Entries); err != nil { return fmt.Errorf("failed to add data to table renderer: %s", err) } if err := table.Render(); err != nil { return fmt.Errorf("failed to render table: %s", err) } fmt.Println(tableString.String()) return nil } func (data *Table) Sort() { // sanity checks if len(data.Entries) == 0 { return } sort.Slice(data.Entries, func(i, j int) bool { return data.Entries[i][0] < data.Entries[j][0] }) } func (data *Table) Addheaders(headers ...string) { for idx, header := range headers { switch data.Mode { case "json", "yaml": data.Headers[idx] = strings.ReplaceAll(strings.ToLower(header), " ", "_") default: data.Headers[idx] = bold(strings.ReplaceAll(strings.ToUpper(header), " ", "-")) } } } func (data *Table) AddRow(fields ...string) { data.Entries = append(data.Entries, fields) } // return the length of a string but only visible chars, w/o ansi color escapes func visibleLen(word string) int { return len(ansiCtrlSeq.ReplaceAllLiteralString(word, "")) } func isInt(num string) bool { if _, err := strconv.Atoi(num); err == nil { return true } return false }