/* 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" "gopkg.in/yaml.v3" ) type Table struct { Mode string // tsv, json, yaml Headers []string RawHeaders []string Entries [][]any rows [][]string // representation used for printing processed bool lenHeaders []int alignInts bool maxwidth int debugGoRoutines bool } func NewTable(conf *cfg.Config, columns, rows int) *Table { table := new(Table{ Mode: conf.Output, maxwidth: cfg.GetTermWidth(), debugGoRoutines: conf.DebugGoRoutines, }) table.Headers = make([]string, columns) table.RawHeaders = make([]string, columns) table.Entries = make([][]any, rows) table.lenHeaders = make([]int, columns) table.alignInts = conf.AlignInts return table } func NewTableEmpty(conf *cfg.Config) *Table { table := new(Table{ Mode: conf.Output, maxwidth: cfg.GetTermWidth(), debugGoRoutines: conf.DebugGoRoutines, }) table.alignInts = conf.AlignInts return table } func (table *Table) WithHeaders(headers ...string) *Table { count := len(headers) table.Entries = [][]any{} table.lenHeaders = make([]int, count) table.Headers = make([]string, count) table.RawHeaders = make([]string, count) table.Addheaders(headers...) return table } func (table *Table) Print() error { var err error switch table.Mode { case "json": err = table.PrintJSON() case "yaml": err = table.PrintYAML() case "csv": err = table.PrintCSV() default: err = table.PrintTSV() } if table.debugGoRoutines { printGoRoutineMetrics() } return err } var ( ansiCtrlSeq = regexp.MustCompile(`\033.[0-9;]+m`) ) func (table *Table) PrintYAML() error { raw := table.toMap() body, err := yaml.Marshal(raw) if err != nil { return fmt.Errorf("failed to produce YAML output: %w", err) } fmt.Println(string(body)) return nil } func (table *Table) PrintJSON() error { raw := table.toMap() body, err := json.MarshalIndent(raw, "", " ") if err != nil { return fmt.Errorf("failed to produce JSON output: %w", err) } fmt.Println(string(body)) return nil } func (table *Table) PrintTSV() error { // length's, convert cell types table.preprocessRows() // output headers for idx, header := range table.Headers { if idx+1 != len(table.Headers) { fmt.Print(header, strings.Repeat(" ", table.lenHeaders[idx]-visibleLen(header))) } else { // no padding for last header fmt.Print(header) } if idx < len(table.Headers)-1 { fmt.Print(" ") } } fmt.Println() for _, entries := range table.rows { currentWidth := 0 columns := len(entries) for idx, entry := range entries { length := visibleLen(entry) if length+currentWidth > table.maxwidth && table.maxwidth-currentWidth > 1 && idx == columns-1 { // text is too wide to be put into one line, and // it's the last cell, so wrap it entry = wrap(table.maxwidth-currentWidth, currentWidth+2, entry) } currentWidth += table.lenHeaders[idx] switch { case isInt(entry) && table.alignInts: // align right fmt.Print(strings.Repeat(" ", table.lenHeaders[idx]-length), entry) case length < table.lenHeaders[idx] && idx+1 != len(entries): // pad right, if required fmt.Print(entry, strings.Repeat(" ", table.lenHeaders[idx]-length)) default: // no padding for last entry fmt.Print(entry) } if idx < len(table.Headers)-1 { fmt.Print(" ") } } fmt.Println() } return nil } // Wrap a text into multiple lines, first line is not indented, all // further lines will be indented. Used within Print() to print large // cell text. func wrap(width, indent int, text string) string { if len(text) <= width { return text } wrapped := "" line := "" for word := range strings.FieldsSeq(text) { if len(line)+len(word)+1 <= width { // appending word to current line doesn't exceed width if line != "" { line += " " } line += word } else { // it exceeds it, so we need to wrap if wrapped == "" { // beginning of output, no indenting here wrapped = line + "\n" } else { // we're in the middle of the text, so add the indent wrapped += strings.Repeat(" ", indent) + line + "\n" } // remember the current word for the next round line = word } } if line != "" { // last line, no newline needed here wrapped += strings.Repeat(" ", indent) + line } return wrapped } func (table *Table) PrintCSV() error { table.preprocessRows() fmt.Println(strings.Join(table.RawHeaders, ",")) for _, entries := range table.rows { row := make([]string, len(entries)) for idx, entry := range entries { if strings.Contains(entry, " ") || strings.Contains(entry, ",") { row[idx] = `"` + entry + `"` } else { row[idx] = entry } } fmt.Println(strings.Join(row, ",")) } return nil } func (table *Table) Sort() { // sanity checks if len(table.Entries) == 0 { return } table.preprocessRows() sort.Slice(table.rows, func(i, j int) bool { return table.rows[i][0] < table.rows[j][0] }) } func (table *Table) Addheaders(headers ...string) { for idx, header := range headers { switch table.Mode { case "json", "yaml": table.Headers[idx] = strings.ReplaceAll(strings.ToLower(header), " ", "_") default: table.Headers[idx] = Bold(strings.ReplaceAll(strings.ToUpper(header), " ", "-")) } table.RawHeaders[idx] = header } } func (table *Table) AddRow(fields ...any) { table.Entries = append(table.Entries, fields) } func (table *Table) AddRowLate(fields ...any) { table.AddRow(fields) if !table.processed { return } row := make([]string, len(fields)) for idx, field := range fields { row[idx] = any2string(field) } table.rows = append(table.rows, row) } // needed for json and yaml output func (table *Table) toMap() []map[string]any { raw := make([]map[string]any, len(table.Entries)) for idx, entries := range table.Entries { raw[idx] = make(map[string]any, len(table.Headers)) for eidx, entry := range entries { raw[idx][table.Headers[eidx]] = entry } } return raw } // 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 }