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:
2026-06-05 23:13:42 +02:00
parent 496557858d
commit d35e1f2832
5 changed files with 333 additions and 1 deletions
+1
View File
@@ -13,6 +13,7 @@ const (
Blue = "\x1b[94m" // bright blue Blue = "\x1b[94m" // bright blue
Yellow = "\x1b[93m" // bright yellow Yellow = "\x1b[93m" // bright yellow
Cyan = "\x1b[96m" // bright cyan Cyan = "\x1b[96m" // bright cyan
White = "\x1b[97m" // bright white
) )
// colorEnabled is process-global, toggled once at startup via SetColor. It // colorEnabled is process-global, toggled once at startup via SetColor. It
+10 -1
View File
@@ -7,7 +7,7 @@ SPDX-License-Identifier: Apache-2.0
| | | | | |
| --- | --- | | --- | --- |
| **Status** | Describes shipped behavior as of `v1.3.0` | | **Status** | Describes shipped behavior as of `v1.4.0` |
| **Author** | Pim van Pelt `<pim@ipng.ch>` | | **Author** | Pim van Pelt `<pim@ipng.ch>` |
| **Last updated** | 2026-06-05 | | **Last updated** | 2026-06-05 |
| **Audience** | Contributors, and authors of CLIs built on this library | | **Audience** | Contributors, and authors of CLIs built on this library |
@@ -92,6 +92,15 @@ or JSON from the same code.
- **FR-4.3** With color off or JSON selected, no ANSI escapes MUST be emitted. - **FR-4.3** With color off or JSON selected, no ANSI escapes MUST be emitted.
- **FR-4.4** Color MUST default on in the shell, off one-shot; `-color` overrides; - **FR-4.4** Color MUST default on in the shell, off one-shot; `-color` overrides;
`-json` forces it off. `-json` forces it off.
- **FR-4.5** `Render` MUST treat JSON as the model: in JSON mode it prints the
value as JSON; otherwise it paints it as text — object scalars on one line as
`key=value` (keys blue, values bright white, a purely *structural* distinction),
nested objects indented, arrays one block per element, source field order
preserved. It MUST NOT apply semantic (red/green) color; that is the caller's
job via its own printer (EmitJSON in the JSON arm, a painter otherwise).
- **FR-4.6** JSON MUST always be the full record. The synopsis-vs-detail choice
(a one-line overview list vs an expanded section) is a text-only concern; the
same command emits complete JSON in either case.
**FR-5 `App` entry point** **FR-5 `App` entry point**
+20
View File
@@ -177,6 +177,25 @@ func runWatch(ctx context.Context, _ inventory, _ []string) error {
return nil return nil
} }
// runInspect demonstrates the default renderer: it builds one value (the model)
// and hands it to cli.Render, which prints it as JSON under -json or as painted
// text (blue keys, bright-white values, nested objects indented) otherwise — no
// per-command text code.
func runInspect(_ context.Context, inv inventory, _ []string) error {
type host struct {
Name string `json:"name"`
Services []string `json:"services"`
}
f := struct {
Count int `json:"count"`
Hosts []host `json:"hosts"`
}{Count: len(inv.servers)}
for _, n := range inv.names() {
f.Hosts = append(f.Hosts, host{Name: n, Services: inv.servers[n]})
}
return cli.Render(f)
}
func runQuit(context.Context, inventory, []string) error { return cli.ErrQuit } func runQuit(context.Context, inventory, []string) error { return cli.ErrQuit }
// buildTree is the single source of truth for the command set, built with the // buildTree is the single source of truth for the command set, built with the
@@ -192,6 +211,7 @@ func buildTree() *cli.Node[inventory] {
b.Dir("ping", "ping a server", b.Dir("ping", "ping a server",
b.Slot("<name>", "ping this server", dynServers, runPing)), b.Slot("<name>", "ping this server", dynServers, runPing)),
b.Cmd("watch", "stream a few events (any key stops it)", runWatch), b.Cmd("watch", "stream a few events (any key stops it)", runWatch),
b.Cmd("inspect", "render the fleet via the default renderer (try -json / -color)", runInspect),
b.Cmd("colors", "show the ANSI color palette", runColors), b.Cmd("colors", "show the ANSI color palette", runColors),
b.Cmd("quit", "exit the shell", runQuit), b.Cmd("quit", "exit the shell", runQuit),
b.Cmd("exit", "exit the shell", runQuit), b.Cmd("exit", "exit the shell", runQuit),
+210
View File
@@ -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)
}
}
+92
View File
@@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"encoding/json"
"strings"
"testing"
)
// TestRenderTextNoColor checks the plain text shape: object scalars on one line
// as key=value, nested objects indented under "key:", arrays as one block per
// element, and source field order preserved (not sorted).
func TestRenderTextNoColor(t *testing.T) {
SetColor(false)
raw := json.RawMessage(`{
"instanceId": "host1",
"connected": true,
"version": "1.2.3",
"labels": {"site": "ams", "rack": "b3"},
"bvis": [
{"evpnId": "blue", "installed": true},
{"evpnId": "red", "installed": false}
]
}`)
got, err := renderText(raw)
if err != nil {
t.Fatalf("renderText: %v", err)
}
want := "instanceId=host1 connected=true version=1.2.3\n" +
"labels:\n" +
" site=ams rack=b3\n" +
"bvis:\n" +
" evpnId=blue installed=true\n" +
" evpnId=red installed=false\n"
if got != want {
t.Errorf("renderText mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
// TestRenderTextColor checks keys are blue and values bright white when color is
// on.
func TestRenderTextColor(t *testing.T) {
SetColor(true)
defer SetColor(false)
got, err := renderText(json.RawMessage(`{"a": "x"}`))
if err != nil {
t.Fatalf("renderText: %v", err)
}
want := Blue + "a" + Reset + "=" + White + "x" + Reset + "\n"
if got != want {
t.Errorf("colored render = %q, want %q", got, want)
}
}
// TestRenderPreservesNumberAndNull checks json.Number passes through verbatim
// (no float reformatting) and null renders as "null".
func TestRenderPreservesNumberAndNull(t *testing.T) {
SetColor(false)
got, err := renderText(json.RawMessage(`{"vni": 10000000, "primary": null}`))
if err != nil {
t.Fatalf("renderText: %v", err)
}
if !strings.Contains(got, "vni=10000000") {
t.Errorf("number not verbatim: %q", got)
}
if !strings.Contains(got, "primary=null") {
t.Errorf("null not rendered: %q", got)
}
}
// TestRenderTextFromStruct checks a Go struct (not RawMessage) is accepted and
// keeps struct field order.
func TestRenderTextFromStruct(t *testing.T) {
SetColor(false)
v := struct {
Name string `json:"name"`
Count int `json:"count"`
}{Name: "web1", Count: 3}
raw, err := toRawJSON(v)
if err != nil {
t.Fatalf("toRawJSON: %v", err)
}
got, err := renderText(raw)
if err != nil {
t.Fatalf("renderText: %v", err)
}
if got != "name=web1 count=3\n" {
t.Errorf("struct render = %q", got)
}
}