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
Yellow = "\x1b[93m" // bright yellow
Cyan = "\x1b[96m" // bright cyan
White = "\x1b[97m" // bright white
)
// 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>` |
| **Last updated** | 2026-06-05 |
| **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.4** Color MUST default on in the shell, off one-shot; `-color` overrides;
`-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**
+20
View File
@@ -177,6 +177,25 @@ func runWatch(ctx context.Context, _ inventory, _ []string) error {
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 }
// 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.Slot("<name>", "ping this server", dynServers, runPing)),
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("quit", "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)
}
}