Files
esctl/pkg/printer/table.go

381 lines
8.0 KiB
Go
Raw Normal View History

2026-08-09 21:53:47 +02:00
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 <http://www.gnu.org/licenses/>.
*/
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"gopkg.in/yaml.v3"
)
2026-08-09 21:53:47 +02:00
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 {
2026-08-09 21:53:47 +02:00
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
2026-08-09 21:53:47 +02:00
rows [][]string // representation used for printing, cells are stringified
2026-07-13 14:33:41 +02:00
processed bool
lenHeaders []int
alignInts bool
maxwidth int
debugGoRoutines bool
}
2026-08-09 21:53:47 +02:00
// NewTable returns a new empty table object with unaligned storage
func NewTable(conf *cfg.Config) *Table {
return new(Table{
2026-07-13 14:33:41 +02:00
Mode: conf.Output,
maxwidth: cfg.GetTermWidth(),
debugGoRoutines: conf.DebugGoRoutines,
2026-08-09 21:53:47 +02:00
Headers: []string{},
RawHeaders: []string{},
lenHeaders: []int{},
alignInts: conf.AlignInts,
2026-07-13 14:33:41 +02:00
})
}
2026-08-09 21:53:47 +02:00
// 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)
}
2026-07-07 23:46:43 +02:00
2026-08-09 21:53:47 +02:00
return t
}
2026-08-09 21:53:47 +02:00
// WithHeaders sets table headers
func (t *Table) WithHeaders(headers ...string) *Table {
count := len(headers)
2026-08-09 21:53:47 +02:00
return t.WithSize(count, 0).formatHeaders(headers...)
}
2026-08-09 21:53:47 +02:00
// Print outputs the tabular data according to Table.Mode
func (t *Table) Print() error {
2026-07-13 14:33:41 +02:00
var err error
2026-08-09 21:53:47 +02:00
switch t.Mode {
case "json":
2026-08-09 21:53:47 +02:00
err = t.PrintJSON()
case "yaml":
2026-08-09 21:53:47 +02:00
err = t.PrintYAML()
2026-07-10 15:07:57 +02:00
case "csv":
2026-08-09 21:53:47 +02:00
err = t.PrintCSV()
default:
2026-08-09 21:53:47 +02:00
err = t.PrintTSV()
}
2026-07-13 14:33:41 +02:00
2026-08-09 21:53:47 +02:00
if t.debugGoRoutines {
2026-07-13 14:33:41 +02:00
printGoRoutineMetrics()
}
return err
}
2026-08-09 21:53:47 +02:00
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
}
2026-08-09 21:53:47 +02:00
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
}
2026-08-09 21:53:47 +02:00
func (t *Table) PrintCSV() error {
t.preprocessRows()
2026-08-09 21:53:47 +02:00
fmt.Println(strings.Join(t.RawHeaders, ","))
2026-08-09 21:53:47 +02:00
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
}
}
2026-08-09 21:53:47 +02:00
fmt.Println(strings.Join(row, ","))
}
2026-07-07 23:46:43 +02:00
2026-08-09 21:53:47 +02:00
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()
}
2026-08-09 21:53:47 +02:00
func (t *Table) printTsvRows() error {
for _, entries := range t.rows {
currentWidth := 0
2026-07-17 10:09:05 +02:00
columns := len(entries)
for idx, entry := range entries {
length := visibleLen(entry)
2026-08-09 21:53:47 +02:00
if length+currentWidth > t.maxwidth &&
t.maxwidth-currentWidth > 1 &&
2026-07-17 10:09:05 +02:00
idx == columns-1 {
// text is too wide to be put into one line, and
// it's the last cell, so wrap it
2026-08-09 21:53:47 +02:00
entry = wrap(t.maxwidth-currentWidth, currentWidth+2, entry)
}
2026-08-09 21:53:47 +02:00
currentWidth += t.lenHeaders[idx]
2026-07-07 23:46:43 +02:00
switch {
2026-08-09 21:53:47 +02:00
case isInt(entry) && t.alignInts:
// align right
2026-08-09 21:53:47 +02:00
fmt.Print(strings.Repeat(" ", t.lenHeaders[idx]-length), entry)
case length < t.lenHeaders[idx] && idx+1 != len(entries):
// pad right, if required
2026-08-09 21:53:47 +02:00
fmt.Print(entry, strings.Repeat(" ", t.lenHeaders[idx]-length))
2026-07-07 23:46:43 +02:00
default:
// no padding for last entry
fmt.Print(entry)
}
2026-08-09 21:53:47 +02:00
if idx < len(t.Headers)-1 {
fmt.Print(" ")
}
}
2026-07-07 23:46:43 +02:00
fmt.Println()
}
return nil
}
2026-08-09 21:53:47 +02:00
// 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
}
2026-07-17 09:52:55 +02:00
// 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 {
2026-07-17 10:09:05 +02:00
if len(text) <= width {
return text
}
2026-07-17 09:52:55 +02:00
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 += " "
}
2026-07-17 10:13:32 +02:00
2026-07-17 09:52:55 +02:00
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
}
2026-08-09 21:53:47 +02:00
// needed for json and yaml output
func (t *Table) toMap() []map[string]any {
raw := make([]map[string]any, len(t.Entries))
2026-07-10 15:07:57 +02:00
2026-08-09 21:53:47 +02:00
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
2026-07-10 15:07:57 +02:00
}
}
2026-08-09 21:53:47 +02:00
return raw
2026-07-10 15:07:57 +02:00
}
2026-08-09 21:53:47 +02:00
func (t *Table) preprocessRows() {
if t.processed {
// only do it once
return
}
2026-08-09 21:53:47 +02:00
// convert entries to strings
t.rows = make([][]string, len(t.Entries))
for rowidx, entries := range t.Entries {
t.rows[rowidx] = make([]string, len(entries))
2026-08-09 21:53:47 +02:00
for colidx, entry := range t.Entries[rowidx] {
t.rows[rowidx][colidx] = stringer(entry)
}
}
2026-08-09 21:53:47 +02:00
// determine header lenght's
for idx, head := range t.Headers {
t.lenHeaders[idx] = visibleLen(head)
}
2026-08-09 21:53:47 +02:00
// determine max width per column
for _, entries := range t.rows {
currentWidth := 0
2026-08-09 21:53:47 +02:00
for idx, entry := range entries {
length := visibleLen(entry)
2026-08-09 21:53:47 +02:00
if t.lenHeaders[idx] < length {
if length > currentWidth+t.maxwidth {
t.lenHeaders[idx] = t.maxwidth - currentWidth
} else {
t.lenHeaders[idx] = length
}
}
2026-07-07 23:46:43 +02:00
2026-08-09 21:53:47 +02:00
currentWidth += t.lenHeaders[idx]
2026-07-07 23:46:43 +02:00
}
}
2026-08-09 21:53:47 +02:00
t.processed = true
}
func isInt(num string) bool {
if _, err := strconv.Atoi(num); err == nil {
return true
}
return false
}