// 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) } }