2 Commits

Author SHA1 Message Date
Pim van Pelt d35e1f2832 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>
2026-06-05 23:13:42 +02:00
Pim van Pelt 496557858d feat(app): make -json opt-in via App.JSON (v1.3.0)
App now registers -json only when App.JSON is true, so a CLI whose
commands do not use cli.Emit never advertises a flag it cannot honor.
Driven by the first real consumer (evpnc), whose commands print text
directly and are not yet converted to Emit. The example opts in
(JSON: true). Backward-additive: existing App users that want -json set
JSON: true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 22:18:27 +02:00
6 changed files with 349 additions and 7 deletions
+11 -3
View File
@@ -55,6 +55,10 @@ type App[C any] struct {
Root *Node[C] Root *Node[C]
// Greeting, if set, is printed after the version banner in interactive mode. // Greeting, if set, is printed after the version banner in interactive mode.
Greeting string Greeting string
// JSON, when true, registers a -json flag that switches Emit to JSON output.
// Leave it false for CLIs whose commands do not (yet) use cli.Emit, so they
// never advertise a flag they cannot honor.
JSON bool
// DefaultServer is the -server default. If both it and ServerEnv are empty, // DefaultServer is the -server default. If both it and ServerEnv are empty,
// no -server flag is registered (local CLI). // no -server flag is registered (local CLI).
@@ -109,7 +113,10 @@ func (a *App[C]) Run(ctx context.Context, argv []string) error {
serverFlag = fs.String("server", defaultServer, usage) serverFlag = fs.String("server", defaultServer, usage)
} }
color := fs.Bool("color", true, "colorize output (default: on in the shell, off one-shot)") color := fs.Bool("color", true, "colorize output (default: on in the shell, off one-shot)")
jsonOut := fs.Bool("json", false, "emit JSON instead of text") var jsonOut *bool
if a.JSON {
jsonOut = fs.Bool("json", false, "emit JSON instead of text")
}
showVersion := fs.Bool("version", false, "print version and exit") showVersion := fs.Bool("version", false, "print version and exit")
if a.RegisterFlags != nil { if a.RegisterFlags != nil {
a.RegisterFlags(fs) a.RegisterFlags(fs)
@@ -125,7 +132,8 @@ func (a *App[C]) Run(ctx context.Context, argv []string) error {
fmt.Println(a.versionLine()) fmt.Println(a.versionLine())
return nil return nil
} }
if *jsonOut { jsonMode := jsonOut != nil && *jsonOut
if jsonMode {
SetFormat(FormatJSON) SetFormat(FormatJSON)
} }
@@ -145,7 +153,7 @@ func (a *App[C]) Run(ctx context.Context, argv []string) error {
if colorExplicit { if colorExplicit {
colorOn = *color colorOn = *color
} }
if *jsonOut { if jsonMode {
colorOn = false colorOn = false
} }
SetColor(colorOn) SetColor(colorOn)
+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
+13 -3
View File
@@ -7,7 +7,7 @@ SPDX-License-Identifier: Apache-2.0
| | | | | |
| --- | --- | | --- | --- |
| **Status** | Describes shipped behavior as of `v1.2.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,11 +92,21 @@ 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**
- **FR-5.1** `App` MUST register `-color`, `-json`, `-version`, and `-server` - **FR-5.1** `App` MUST register `-color` and `-version`; `-server` only when a
(only when a server is configured). server is configured; and `-json` only when the app opts in (its commands use
`Emit`), so a CLI never advertises a flag it cannot honor.
- **FR-5.2** `App` MUST NOT dial anything; the client is built in a caller's - **FR-5.2** `App` MUST NOT dial anything; the client is built in a caller's
`Connect` callback. `Connect` callback.
- **FR-5.3** `-version` MUST print and exit without connecting. - **FR-5.3** `-version` MUST print and exit without connecting.
+22 -1
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),
@@ -201,9 +221,10 @@ func buildTree() *cli.Node[inventory] {
func main() { func main() {
(&cli.App[inventory]{ (&cli.App[inventory]{
Name: "example", Name: "example",
Version: "1.1.0", Version: "1.3.0",
Prompt: "inv> ", Prompt: "inv> ",
Root: buildTree(), Root: buildTree(),
JSON: true, // commands use cli.Emit, so advertise -json
Greeting: "golang-cli example — try: show server, ping db1, colors, '?' for help, TAB to complete", Greeting: "golang-cli example — try: show server, ping db1, colors, '?' for help, TAB to complete",
// Local CLI: no -server flag. Connect just hands over the in-memory data, // Local CLI: no -server flag. Connect just hands over the in-memory data,
// proving App is transport-agnostic (it never dials anything itself). // proving App is transport-agnostic (it never dials anything itself).
+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)
}
}