package printer /* 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 . */ import ( "encoding/json" "fmt" "regexp" "sort" "strconv" "strings" "codeberg.org/scip/esctl/pkg/cfg" "gopkg.in/yaml.v3" ) var ( // ansiCtrlSeq is being used to remove ANSI control sequences so // that we can properly determine string lenght's ansiCtrlSeq = regexp.MustCompile(`\033.[0-9;]+m`) ) // Table stores tabular data for printing type Table struct { Mode string // tsv, json, yaml Headers []string // colored headers RawHeaders []string // plain string headers Entries [][]any // rows of cells as fed in by pkg/es rows [][]string // representation used for printing, cells are stringified processed bool lenHeaders []int alignInts bool maxwidth int debugGoRoutines bool } // NewTable returns a new empty table object with unaligned storage func NewTable(conf *cfg.Config) *Table { return new(Table{ Mode: conf.Output, maxwidth: cfg.GetTermWidth(), debugGoRoutines: conf.DebugGoRoutines, Headers: []string{}, RawHeaders: []string{}, lenHeaders: []int{}, alignInts: conf.AlignInts, }) } // WithSize configures the dimensions of the table, allocs aligned storage func (t *Table) WithSize(columns, rows int) *Table { if columns > 0 { t.Headers = make([]string, columns) t.RawHeaders = make([]string, columns) t.lenHeaders = make([]int, columns) } if rows > 0 { t.Entries = make([][]any, rows) } return t } // WithHeaders sets table headers func (t *Table) WithHeaders(headers ...string) *Table { count := len(headers) return t.WithSize(count, 0).formatHeaders(headers...) } // Print outputs the tabular data according to Table.Mode func (t *Table) Print() error { var err error switch t.Mode { case "json": err = t.PrintJSON() case "yaml": err = t.PrintYAML() case "csv": err = t.PrintCSV() default: err = t.PrintTSV() } if t.debugGoRoutines { printGoRoutineMetrics() } return err } func (t *Table) PrintYAML() error { raw := t.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 (t *Table) PrintJSON() error { raw := t.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 (t *Table) PrintCSV() error { t.preprocessRows() fmt.Println(strings.Join(t.RawHeaders, ",")) for _, entries := range t.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 } // Sort tabular data by first column func (t *Table) Sort() { // sanity checks if len(t.Entries) == 0 { return } t.preprocessRows() sort.Slice(t.rows, func(i, j int) bool { return t.rows[i][0] < t.rows[j][0] }) } func (t *Table) AddRow(fields ...any) { t.Entries = append(t.Entries, fields) } func (t *Table) AddRowLate(fields ...any) { t.AddRow(fields) if !t.processed { return } row := make([]string, len(fields)) for idx, field := range fields { row[idx] = stringer(field) } t.rows = append(t.rows, row) } // PrintTSV is the default printer, it outputs in tab-separated-value format func (t *Table) PrintTSV() error { t.preprocessRows() t.printTsvHeaders() return t.printTsvRows() } func (t *Table) printTsvRows() error { for _, entries := range t.rows { currentWidth := 0 columns := len(entries) for idx, entry := range entries { length := visibleLen(entry) if length+currentWidth > t.maxwidth && t.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(t.maxwidth-currentWidth, currentWidth+2, entry) } currentWidth += t.lenHeaders[idx] switch { case isInt(entry) && t.alignInts: // align right fmt.Print(strings.Repeat(" ", t.lenHeaders[idx]-length), entry) case length < t.lenHeaders[idx] && idx+1 != len(entries): // pad right, if required fmt.Print(entry, strings.Repeat(" ", t.lenHeaders[idx]-length)) default: // no padding for last entry fmt.Print(entry) } if idx < len(t.Headers)-1 { fmt.Print(" ") } } fmt.Println() } return nil } // printTsvHeaders outputs TSV headers func (t *Table) printTsvHeaders() { for idx, header := range t.Headers { if idx+1 != len(t.Headers) { fmt.Print(header, strings.Repeat(" ", t.lenHeaders[idx]-visibleLen(header))) } else { // no padding for last header fmt.Print(header) } if idx < len(t.Headers)-1 { fmt.Print(" ") } } fmt.Println() } // formatHeaders formats header fields according to output mode func (t *Table) formatHeaders(headers ...string) *Table { for idx, header := range headers { switch t.Mode { case "json", "yaml": t.Headers[idx] = strings.ReplaceAll(strings.ToLower(header), " ", "_") default: t.Headers[idx] = Bold(strings.ReplaceAll(strings.ToUpper(header), " ", "-")) } t.RawHeaders[idx] = header } return t } // 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 } // needed for json and yaml output func (t *Table) toMap() []map[string]any { raw := make([]map[string]any, len(t.Entries)) for idx, entries := range t.Entries { raw[idx] = make(map[string]any, len(t.Headers)) for eidx, entry := range entries { raw[idx][t.Headers[eidx]] = entry } } return raw } func (t *Table) preprocessRows() { if t.processed { // only do it once return } // convert entries to strings t.rows = make([][]string, len(t.Entries)) for rowidx, entries := range t.Entries { t.rows[rowidx] = make([]string, len(entries)) for colidx, entry := range t.Entries[rowidx] { t.rows[rowidx][colidx] = stringer(entry) } } // determine header lenght's for idx, head := range t.Headers { t.lenHeaders[idx] = visibleLen(head) } // determine max width per column for _, entries := range t.rows { currentWidth := 0 for idx, entry := range entries { length := visibleLen(entry) if t.lenHeaders[idx] < length { if length > currentWidth+t.maxwidth { t.lenHeaders[idx] = t.maxwidth - currentWidth } else { t.lenHeaders[idx] = length } } currentWidth += t.lenHeaders[idx] } } t.processed = true } func isInt(num string) bool { if _, err := strconv.Atoi(num); err == nil { return true } return false }