feat(render): default JSON-model renderer + bright-white values (v1.4.0)
Render(v) treats JSON as the model: in -json mode it prints v as JSON; otherwise it paints it as text — object scalars on one line as key=value (keys blue, values bright white: a structural key/value distinction, not semantic color), nested objects indented, arrays one block per element, field order preserved via an order-keeping JSON decode. EmitJSON(v) is the JSON-only arm for commands that paint their own text. Operates on JSON only, so core stays protobuf-free (NFR-4). Adds White to the palette. The example gains an `inspect` command demoing Render (text vs -json). design.md FR-4.5/4.6 document the renderer and the "JSON is always the full record; synopsis-vs-detail is text-only" principle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Render emits v in the current output format, treating JSON as the model. In
|
||||
// JSON mode it prints v as indented JSON. Otherwise it paints v as text: each
|
||||
// object's scalar fields render on one line as blue "key=value" pairs (keys
|
||||
// blue, values bright white, so the data stands out from the labels), nested
|
||||
// objects indent under a blue "key:" header, and arrays render as one block per
|
||||
// element.
|
||||
//
|
||||
// Pass a json.RawMessage (e.g. from protojson) to control the exact JSON and
|
||||
// preserve field order; pass a struct or map and encoding/json handles it
|
||||
// (structs keep field order, maps sort keys).
|
||||
//
|
||||
// Render is the default presentation, equivalent to "no printer". A command that
|
||||
// wants bespoke text — semantic color, tables, custom layout — should instead
|
||||
// branch on IsJSON(), calling EmitJSON in the JSON arm and its own painter
|
||||
// otherwise. The library never applies semantic (red/green) color; that is the
|
||||
// command's choice, because only it knows what a value means.
|
||||
func Render(v any) error {
|
||||
raw, err := toRawJSON(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if IsJSON() {
|
||||
return printIndentedJSON(raw)
|
||||
}
|
||||
s, err := renderText(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprint(os.Stdout, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderText paints raw JSON as text (blue keys, white values). Split out so it
|
||||
// can be tested without capturing stdout.
|
||||
func renderText(raw json.RawMessage) (string, error) {
|
||||
val, err := parseOrdered(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var b strings.Builder
|
||||
paintValue(&b, val, "")
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// EmitJSON prints v as indented JSON to stdout, regardless of the output format.
|
||||
// Use it in the JSON arm of a command that paints its own text.
|
||||
func EmitJSON(v any) error {
|
||||
raw, err := toRawJSON(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printIndentedJSON(raw)
|
||||
}
|
||||
|
||||
func toRawJSON(v any) (json.RawMessage, error) {
|
||||
if rm, ok := v.(json.RawMessage); ok {
|
||||
return rm, nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal json: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func printIndentedJSON(raw json.RawMessage) error {
|
||||
var buf bytes.Buffer
|
||||
if err := json.Indent(&buf, raw, "", " "); err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stdout, string(raw)) // not indentable JSON — print as-is
|
||||
return nil
|
||||
}
|
||||
_, _ = fmt.Fprintln(os.Stdout, buf.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// orderedField is one key/value of a JSON object, preserving source order so the
|
||||
// painted text follows the order of the (proto) JSON rather than Go map order.
|
||||
type orderedField struct {
|
||||
key string
|
||||
val any // string, json.Number, bool, nil, []any, or []orderedField
|
||||
}
|
||||
|
||||
func parseOrdered(raw json.RawMessage) (any, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
v, err := parseOrderedValue(dec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse json: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func parseOrderedValue(dec *json.Decoder) (any, error) {
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d, ok := t.(json.Delim); ok {
|
||||
switch d {
|
||||
case '{':
|
||||
return parseOrderedObject(dec)
|
||||
case '[':
|
||||
return parseOrderedArray(dec)
|
||||
}
|
||||
}
|
||||
return t, nil // string, json.Number, bool, or nil
|
||||
}
|
||||
|
||||
func parseOrderedObject(dec *json.Decoder) ([]orderedField, error) {
|
||||
var fields []orderedField
|
||||
for dec.More() {
|
||||
keyTok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
val, err := parseOrderedValue(dec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields = append(fields, orderedField{key: keyTok.(string), val: val})
|
||||
}
|
||||
_, err := dec.Token() // closing '}'
|
||||
return fields, err
|
||||
}
|
||||
|
||||
func parseOrderedArray(dec *json.Decoder) ([]any, error) {
|
||||
var arr []any
|
||||
for dec.More() {
|
||||
v, err := parseOrderedValue(dec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr = append(arr, v)
|
||||
}
|
||||
_, err := dec.Token() // closing ']'
|
||||
return arr, err
|
||||
}
|
||||
|
||||
func paintValue(b *strings.Builder, v any, indent string) {
|
||||
switch t := v.(type) {
|
||||
case []orderedField:
|
||||
paintObject(b, t, indent)
|
||||
case []any:
|
||||
paintArray(b, t, indent)
|
||||
default:
|
||||
fmt.Fprintf(b, "%s%s\n", indent, Paint(scalarString(v), White))
|
||||
}
|
||||
}
|
||||
|
||||
func paintObject(b *strings.Builder, fields []orderedField, indent string) {
|
||||
var scalars []string
|
||||
var nested []orderedField
|
||||
for _, f := range fields {
|
||||
switch f.val.(type) {
|
||||
case []orderedField, []any:
|
||||
nested = append(nested, f)
|
||||
default:
|
||||
scalars = append(scalars, Label(f.key)+"="+Paint(scalarString(f.val), White))
|
||||
}
|
||||
}
|
||||
if len(scalars) > 0 {
|
||||
fmt.Fprintf(b, "%s%s\n", indent, strings.Join(scalars, " "))
|
||||
}
|
||||
for _, f := range nested {
|
||||
fmt.Fprintf(b, "%s%s:\n", indent, Label(f.key))
|
||||
paintValue(b, f.val, indent+" ")
|
||||
}
|
||||
}
|
||||
|
||||
func paintArray(b *strings.Builder, arr []any, indent string) {
|
||||
for _, el := range arr {
|
||||
switch el.(type) {
|
||||
case []orderedField, []any:
|
||||
paintValue(b, el, indent)
|
||||
default:
|
||||
fmt.Fprintf(b, "%s%s\n", indent, Paint(scalarString(el), White))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scalarString(v any) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case bool:
|
||||
if x {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case json.Number:
|
||||
return x.String()
|
||||
case string:
|
||||
return x
|
||||
default:
|
||||
return fmt.Sprintf("%v", x)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user