fix: report unknown --json fields with the fields a command outputs

tea issue list --json number,titel failed with "accepts 0 arg(s),
received 1": a field list with an unknown name stayed a positional
argument. A field-shaped word after --json now binds to it unless the
command takes the word as an argument, and an unknown field lists the
available fields. --json= is a usage error instead of printing text.

--jq alone now implies --json, except on commands annotated
tea.rawjson that filter raw JSON themselves; -q is short for --jq and
-y for --yes. Fields are listed alphabetically, and terminal.JSONOutput
can indent and color JSON for a terminal.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 22:55:26 +02:00
1 parent 023724d4c9
commit eb51dc8619
4 files changed
+216 -42

No files matched your search

+100 -13
View File
@@ -4,17 +4,36 @@
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
// 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
// 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
@@ -23,7 +42,7 @@ func WriteJSON(w io.Writer, value any, fields, query string) error {
if err := json.Unmarshal(data, &normalized); err != nil {
return err
}
if names := fieldNames(fields); names != nil {
if names := fieldNames(o.Fields); names != nil {
selectFields := func(item any) (any, error) {
object, ok := item.(map[string]any)
if !ok {
@@ -54,12 +73,10 @@ func WriteJSON(w io.Writer, value any, fields, query string) error {
}
}
}
encoder := json.NewEncoder(w)
encoder.SetEscapeHTML(false)
if query == "" {
return encoder.Encode(normalized)
if o.Query == "" {
return o.encode(w, normalized)
}
parsed, err := parseQuery(query)
parsed, err := parseQuery(o.Query)
if err != nil {
return err
}
@@ -73,25 +90,95 @@ func WriteJSON(w io.Writer, value any, fields, query string) error {
return fmt.Errorf("evaluate --jq: %w", err)
}
if str, ok := value.(string); ok {
if o.Pretty {
str = SafeText(str)
}
if _, err := fmt.Fprintln(w, str); err != nil {
return err
}
} else if err := encoder.Encode(value); err != nil {
} 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 {
// 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
}
// 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 {
known := map[string]bool{}
for _, name := range allowed {
known[name] = true
}
for _, name := range names {
if !known[name] {
return fmt.Errorf("unknown JSON field %q", name)
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, ", "))
}
}
}