diff --git a/color.go b/color.go index 061327e..b93adfe 100644 --- a/color.go +++ b/color.go @@ -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 diff --git a/docs/design.md b/docs/design.md index 5333a4c..d603706 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 `` | | **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** diff --git a/example/main.go b/example/main.go index 761475d..d8a5a32 100644 --- a/example/main.go +++ b/example/main.go @@ -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("", "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), diff --git a/render.go b/render.go new file mode 100644 index 0000000..7fcf8b8 --- /dev/null +++ b/render.go @@ -0,0 +1,210 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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) + } +} diff --git a/render_test.go b/render_test.go new file mode 100644 index 0000000..03304db --- /dev/null +++ b/render_test.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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) + } +}