gitlink-cli/internal/output/formatter.go

449 lines
12 KiB
Go

package output
import (
"encoding/json"
"fmt"
"io"
"os"
"reflect"
"strconv"
"strings"
"text/tabwriter"
"unicode"
"gopkg.in/yaml.v3"
)
func Print(envelope *Envelope, format string) error {
if format == "" {
format = "json"
}
return PrintTo(os.Stdout, envelope, format)
}
func PrintTo(w io.Writer, envelope *Envelope, format string) error {
switch format {
case "json":
return printJSON(w, envelope)
case "yaml":
return printYAML(w, envelope)
case "table":
return printTable(w, envelope)
default:
return printJSON(w, envelope)
}
}
func printJSON(w io.Writer, envelope *Envelope) error {
data, err := json.MarshalIndent(envelope, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(data))
return err
}
func printYAML(w io.Writer, envelope *Envelope) error {
data, err := yaml.Marshal(envelope)
if err != nil {
return err
}
_, err = fmt.Fprint(w, string(data))
return err
}
func printTable(w io.Writer, envelope *Envelope) error {
if !envelope.OK {
if envelope.Error != nil {
fmt.Fprintf(w, "Error: %s\n", envelope.Error.Message)
if envelope.Error.Suggestion != "" {
fmt.Fprintf(w, "Suggestion: %s\n", envelope.Error.Suggestion)
}
}
return nil
}
if envelope.Data == nil {
fmt.Fprintln(w, "No data")
return nil
}
// Try to render as table if data is a slice of maps
switch data := envelope.Data.(type) {
case []interface{}:
return printSliceTable(w, data)
case map[string]interface{}:
// If the map contains a list of objects, render that list as a table
// (picking the largest such list, which is usually the primary payload).
if slice := findLargestSlice(data); slice != nil {
return printSliceTable(w, slice)
}
// Single-object detail: render as a key/value table with nested maps
// expanded inline (indented sub-rows) for readability.
return printMapTable(w, data)
default:
// Fallback to JSON
return printJSON(w, envelope)
}
}
// findLargestSlice scans the values of m for the largest slice whose elements
// are maps (e.g. []interface{} of map[string]interface{}, or native
// []map[string]interface{}/[]map[string]string built locally by some commands).
// Elements are normalized to map[string]interface{} so the table renderer has a
// consistent shape. Returns nil if no suitable slice is found.
func findLargestSlice(m map[string]interface{}) []interface{} {
var best []interface{}
for _, v := range m {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Slice || rv.Len() == 0 {
continue
}
// Every element must be a map for it to be table-renderable.
normalized := make([]interface{}, 0, rv.Len())
ok := true
for i := 0; i < rv.Len(); i++ {
el, alright := normalizeMap(rv.Index(i).Interface())
if !alright {
ok = false
break
}
normalized = append(normalized, el)
}
if !ok {
continue
}
if best == nil || len(normalized) > len(best) {
best = normalized
}
}
return best
}
// normalizeMap coerces a map value (map[string]interface{} or a native
// map[string]X built by command code) into map[string]interface{} for uniform
// rendering. Returns ok=false if v is not a string-keyed map.
func normalizeMap(v interface{}) (map[string]interface{}, bool) {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Map {
return nil, false
}
out := make(map[string]interface{}, rv.Len())
for _, key := range rv.MapKeys() {
ks, ok := key.Interface().(string)
if !ok {
return nil, false
}
out[ks] = rv.MapIndex(key).Interface()
}
return out, true
}
// maxTableColumns caps the number of columns rendered in a slice table so the
// output stays readable on standard terminal widths. Fields beyond this limit
// are dropped (priority fields are kept first; see collectKeys).
const maxTableColumns = 8
func printSliceTable(w io.Writer, items []interface{}) error {
if len(items) == 0 {
fmt.Fprintln(w, "No results")
return nil
}
// If the first item isn't a map, fall back to JSON (unchanged behavior).
if _, ok := items[0].(map[string]interface{}); !ok {
data, _ := json.MarshalIndent(items, "", " ")
fmt.Fprintln(w, string(data))
return nil
}
// Headers are the UNION of every row's keys, so a column appears whenever
// any row carries the field. GitLink payloads (e.g. branch lists) do not
// always include every key on every item; taking headers from items[0]
// alone made columns like `protected` appear or vanish depending on which
// item happened to rank first.
headers := collectKeysUnion(items)
omitted := 0
if len(headers) > maxTableColumns {
omitted = len(headers) - maxTableColumns
headers = headers[:maxTableColumns]
}
// minwidth=0, tabwidth=2, padding=2 -> tighter columns than the old
// minwidth=4 setting, which helps when there are several wide fields.
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
// Print headers
fmt.Fprintln(tw, strings.Join(headers, "\t"))
dashes := make([]string, len(headers))
for i, h := range headers {
dashes[i] = strings.Repeat("-", len(h))
}
fmt.Fprintln(tw, strings.Join(dashes, "\t"))
// Print rows
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
vals := make([]string, len(headers))
for i, h := range headers {
vals[i] = formatValue(m[h])
}
fmt.Fprintln(tw, strings.Join(vals, "\t"))
}
tw.Flush()
if omitted > 0 {
// Note appended after the table so it does not perturb column alignment.
fmt.Fprintf(w, "(已省略 %d 列)\n", omitted)
}
return nil
}
// printMapTable renders a single-object detail as a two-column KEY/VALUE table.
// Top-level keys are ordered by collectKeys (identity/status fields first).
// Nested map values are expanded as indented sub-rows so rich detail (e.g. the
// commit/tagger block of a tag) stays readable instead of collapsing to a
// summary token or a JSON blob.
func printMapTable(w io.Writer, m map[string]interface{}) error {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "KEY\tVALUE")
fmt.Fprintln(tw, "---\t-----")
for _, k := range collectKeys(m) {
writeMapRow(tw, k, m[k], 0)
}
return tw.Flush()
}
// writeMapRow writes one row for a key/value pair. Nested maps recurse with
// increasing indent so sub-fields appear under their parent.
func writeMapRow(tw *tabwriter.Writer, key string, v interface{}, depth int) {
indent := strings.Repeat(" ", depth)
displayKey := key
if depth > 0 {
displayKey = "└ " + key
}
if m, ok := v.(map[string]interface{}); ok {
// Parent header row, then each child indented one level deeper.
fmt.Fprintf(tw, "%s%s\t\n", indent, displayKey)
for _, ck := range collectKeys(m) {
writeMapRow(tw, ck, m[ck], depth+1)
}
return
}
fmt.Fprintf(tw, "%s%s\t%s\n", indent, displayKey, formatValue(v))
}
// preferredHeaders lists keys that are surfaced first (and therefore survive the
// maxTableColumns cap). Keep the most human-meaningful identity/status fields
// at the top so that even when a row has 11+ keys the table still shows the
// columns a user actually scans.
var preferredHeaders = []string{
"id", "name", "login", "title", "subject", "number",
"status", "state", "protected", "last_commit", "commit_time",
"type", "time_ago", "created_time", "created_at", "updated_at",
}
// orderKeySet returns the keys of keySet ordered by preferredHeaders: priority
// fields come first in their defined order, the rest follow in set-iteration
// order. Keys absent from preferredHeaders still appear, just after the
// priority fields.
func orderKeySet(keySet map[string]bool) []string {
keys := make([]string, 0, len(keySet))
seen := map[string]bool{}
for _, p := range preferredHeaders {
if keySet[p] {
keys = append(keys, p)
seen[p] = true
}
}
for k := range keySet {
if !seen[k] {
keys = append(keys, k)
}
}
return keys
}
// collectKeysUnion merges the keys of every map in items into a single
// priority-ordered header list. Used by printSliceTable so a column shows
// whenever any row has the field — preventing columns from disappearing when
// the first row happens to omit a key (e.g. `protected` on GitLink branches).
func collectKeysUnion(items []interface{}) []string {
keySet := map[string]bool{}
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
for k := range m {
keySet[k] = true
}
}
return orderKeySet(keySet)
}
func collectKeys(m map[string]interface{}) []string {
keySet := make(map[string]bool, len(m))
for k := range m {
keySet[k] = true
}
return orderKeySet(keySet)
}
func formatValue(v interface{}) string {
if v == nil {
return ""
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Map:
if m, ok := v.(map[string]interface{}); ok {
return formatNestedMap(m)
}
// Native map (e.g. map[string]string built locally): normalize then summarize.
if m, ok := normalizeMap(v); ok {
return formatNestedMap(m)
}
// Non-string-keyed map: fall back to JSON, truncated.
data, _ := json.Marshal(v)
return truncateString(string(data), 40)
case reflect.Slice:
// For slices, show the element count rather than an inline JSON blob,
// which is almost always wider than the terminal.
return fmt.Sprintf("[%d items]", rv.Len())
case reflect.Float32, reflect.Float64:
f := rv.Float()
// Render whole-valued floats as integers (e.g. 1779200970 instead of
// 1.77920097e+09); otherwise use plain 'f' formatting to avoid the
// scientific notation %v picks for large magnitudes.
if f == float64(int64(f)) {
return strconv.FormatInt(int64(f), 10)
}
return strconv.FormatFloat(f, 'f', -1, 64)
default:
return truncateString(fmt.Sprintf("%v", v), 40)
}
}
// truncateString shortens s to at most maxLen bytes, appending "..." when it is
// truncated. Strings already within the limit are returned unchanged.
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
// formatNestedMap produces a compact, human-readable summary of a nested map by
// extracting a few well-known fields (name/login/title/message/sha/...) instead
// of dumping the full JSON. Unknown shapes collapse to "<object>". This keeps
// table cells narrow even for richly-nested API payloads (e.g. commit metadata).
func formatNestedMap(m map[string]interface{}) string {
// Ordered candidates: earlier keys win and become the leading part.
type candidate struct {
key string
max int // 0 = no truncation
}
cands := []candidate{
{"name", 0},
{"login", 0},
{"title", 30},
{"subject", 30},
{"message", 30},
{"sha", 8},
{"id", 0},
}
var parts []string
for _, c := range cands {
raw, ok := m[c.key]
if !ok || raw == nil {
continue
}
s := formatValue(raw)
if c.max > 0 {
if c.key == "sha" {
// Git-style short SHA: bare prefix, no ellipsis.
if len(s) > c.max {
s = s[:c.max]
}
} else {
s = truncateString(s, c.max)
}
}
if s == "" {
continue
}
// For text-ish fields, join with ":"; for sha/id use a bare token.
switch c.key {
case "sha", "id":
parts = append(parts, s)
default:
parts = append(parts, c.key+":"+s)
}
}
if len(parts) == 0 {
return "<object>"
}
return strings.Join(parts, " -> ")
}
// ShouldUseColor returns true if colorized output should be used.
func ShouldUseColor(mode string) bool {
switch mode {
case "always":
return true
case "never":
return false
default: // "auto"
if os.Getenv("NO_COLOR") != "" {
return false
}
if os.Getenv("TERM") == "dumb" {
return false
}
if os.Getenv("WT_SESSION") != "" || os.Getenv("ConEmuANSI") == "ON" {
return true
}
if os.Getenv("COLORTERM") != "" {
return true
}
if os.Getenv("TERM") != "" {
return true
}
return false
}
}
// runeWidth returns the display width of a rune (2 for CJK, 1 for others).
func runeWidth(r rune) int {
if unicode.Is(unicode.Han, r) {
return 2
}
if r >= 0xFF01 && r <= 0xFF60 {
return 2
}
if r >= 0x3000 && r <= 0x303F {
return 2
}
return 1
}
// StringWidth returns the display width of a string accounting for CJK characters.
func StringWidth(s string) int {
w := 0
for _, r := range s {
w += runeWidth(r)
}
return w
}