Files
gitea-tea/internal/terminal/output.go
T
2026-10-18 17:38:52 +02:00

235 lines
6.1 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"slices"
"strings"
"github.com/itchyny/gojq"
)
// JSONOutput selects, filters, and formats command results as JSON.
type JSONOutput struct {
// Fields holds the comma-separated fields to keep; "" and "*" keep every field.
Fields string
// Allowed lists the fields the command documents. A listed field that an item omits (an empty
// omitempty value) is selected as null instead of being reported unknown; nil allows none.
Allowed []string
// Query is a jq expression applied after field selection.
Query string
// Pretty indents the JSON for a terminal and strips control characters from raw jq strings.
Pretty bool
// Safe strips control characters from raw jq strings and encoded text like Pretty, for terminal
// output that must stay one line per value.
Safe bool
// Color highlights pretty JSON keys and values.
Color bool
}
// WriteJSON projects explicit result fields and applies an in-process jq filter.
func WriteJSON(w io.Writer, value any, fields, query string) error {
return JSONOutput{Fields: fields, Query: query}.Write(w, value)
}
// Write projects explicit result fields of value, applies the jq filter, and writes the results.
func (o JSONOutput) Write(w io.Writer, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
var normalized any
if err := json.Unmarshal(data, &normalized); err != nil {
return err
}
if names := fieldNames(o.Fields); names != nil {
selectFields := func(item any) (any, error) {
object, ok := item.(map[string]any)
if !ok {
return nil, errors.New("field selection requires resource objects")
}
selected := map[string]any{}
for _, name := range names {
v, exists := object[name]
if !exists && slices.Contains(o.Allowed, name) {
v, exists = nil, true
}
if !exists {
return nil, fmt.Errorf("unknown JSON field %q", name)
}
selected[name] = v
}
return selected, nil
}
switch v := normalized.(type) {
case []any:
for i := range v {
v[i], err = selectFields(v[i])
if err != nil {
return err
}
}
default:
normalized, err = selectFields(v)
if err != nil {
return err
}
}
}
if o.Query == "" {
return o.encode(w, normalized)
}
parsed, err := parseQuery(o.Query)
if err != nil {
return err
}
iter := parsed.Run(normalized)
for {
value, ok := iter.Next()
if !ok {
return nil
}
if err, ok := value.(error); ok {
return fmt.Errorf("evaluate --jq: %w", err)
}
if str, ok := value.(string); ok {
if o.Pretty || o.Safe {
str = SafeText(str)
}
if _, err := fmt.Fprintln(w, str); err != nil {
return err
}
} else if err := o.encode(w, value); err != nil {
return err
}
}
}
// encode writes one JSON value followed by a newline.
func (o JSONOutput) encode(w io.Writer, value any) error {
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if o.Pretty {
encoder.SetIndent("", " ")
}
if err := encoder.Encode(value); err != nil {
return err
}
text := buf.String()
if o.Pretty || o.Safe {
// The encoder escapes control characters but not format characters such as bidirectional overrides.
text = SafeText(text)
}
if o.Color {
text = colorJSON(text)
}
_, err := io.WriteString(w, text)
return err
}
// IndentJSON formats JSON text for a terminal like Write with Pretty, but keeps the key order of the
// text: indented, sanitized, ending with a newline and, with color, highlighted.
func IndentJSON(data []byte, color bool) (string, error) {
var buf bytes.Buffer
if err := json.Indent(&buf, bytes.TrimSpace(data), "", " "); err != nil {
return "", err
}
text := SafeText(buf.String()) + "\n"
if color {
text = colorJSON(text)
}
return text, nil
}
// JSON colors, following gh: bold blue keys, green strings, yellow booleans, cyan null.
const (
jsonKeyColor = "\x1b[1;34m"
jsonStringColor = "\x1b[32m"
jsonBoolColor = "\x1b[33m"
jsonNullColor = "\x1b[36m"
jsonResetColor = "\x1b[0m"
)
// colorJSON highlights the keys and literal values of encoded JSON text.
func colorJSON(text string) string {
var b strings.Builder
for i := 0; i < len(text); {
color, end := "", i+1
switch c := text[i]; {
case c == '"':
for end < len(text) && text[end] != '"' {
if text[end] == '\\' {
end++
}
end++
}
end++
color = jsonStringColor
if strings.HasPrefix(strings.TrimLeft(text[end:], " "), ":") {
color = jsonKeyColor
}
case strings.HasPrefix(text[i:], "true"):
color, end = jsonBoolColor, i+len("true")
case strings.HasPrefix(text[i:], "false"):
color, end = jsonBoolColor, i+len("false")
case strings.HasPrefix(text[i:], "null"):
color, end = jsonNullColor, i+len("null")
}
if color == "" {
b.WriteString(text[i:end])
} else {
b.WriteString(color + text[i:end] + jsonResetColor)
}
i = end
}
return b.String()
}
// ValidateOutput catches invalid projection/filter syntax before a service mutates data.
// An unknown field error lists the allowed fields.
func ValidateOutput(fields, query string, allowed []string) error {
if names := fieldNames(fields); names != nil && allowed != nil {
for _, name := range names {
if name == "" {
return errors.New("--json field names cannot be empty; use --json alone for every field")
}
if !slices.Contains(allowed, name) {
return fmt.Errorf("unknown JSON field %q\navailable fields: %s", name, strings.Join(allowed, ", "))
}
}
}
if query != "" {
if _, err := parseQuery(query); err != nil {
return err
}
}
return nil
}
// fieldNames returns the selected JSON fields, or nil when every field is selected.
func fieldNames(fields string) []string {
if fields == "" || fields == "*" {
return nil
}
names := strings.Split(fields, ",")
for i := range names {
names[i] = strings.TrimSpace(names[i])
}
return names
}
func parseQuery(query string) (*gojq.Query, error) {
parsed, err := gojq.Parse(query)
if err != nil {
return nil, fmt.Errorf("invalid --jq expression: %w", err)
}
return parsed, nil
}