Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d35e1f2832 | |||
| 496557858d | |||
| 9e0a98ed07 | |||
| e030cd28e9 |
@@ -0,0 +1,33 @@
|
||||
# SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.PHONY: help all check test fixstyle vet lint build cross
|
||||
|
||||
help: ## Show this help
|
||||
@printf "Usage: make <target>\n\nTargets:\n"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
||||
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
all: check ## Alias for check
|
||||
|
||||
check: fixstyle vet lint test ## Run the full pre-commit gate (format, vet, lint, test)
|
||||
|
||||
test: ## Run all Go unit tests
|
||||
go test ./...
|
||||
|
||||
fixstyle: ## Format the Go tree (gofmt)
|
||||
gofmt -w .
|
||||
|
||||
vet: ## Run go vet across the tree
|
||||
go vet ./...
|
||||
|
||||
lint: ## Run golangci-lint across the Go tree
|
||||
golangci-lint run ./...
|
||||
|
||||
build: ## Build all packages for the host platform
|
||||
go build ./...
|
||||
|
||||
cross: ## Verify the tree builds on Linux and OpenBSD
|
||||
GOOS=linux GOARCH=amd64 go build ./...
|
||||
GOOS=linux GOARCH=arm64 go build ./...
|
||||
GOOS=openbsd GOARCH=amd64 go build ./...
|
||||
@@ -72,6 +72,76 @@ Return `cli.ErrQuit` from a command's `Run` to stop the REPL. `Shell.FormatError
|
||||
lets you render command errors however you like (e.g. unwrap a gRPC status to its
|
||||
message and color it); it defaults to `err.Error()`.
|
||||
|
||||
## Less boilerplate: `Builder` and `App`
|
||||
|
||||
`cli.For[C]()` returns a `Builder` so no node repeats the `[C]` type parameter.
|
||||
The names are symmetric — `Dir`/`Cmd` for fixed keywords (without/with an
|
||||
action), `SlotDir`/`Slot` for dynamic argument nodes:
|
||||
|
||||
```go
|
||||
b := cli.For[inventory]()
|
||||
root := b.Root(
|
||||
b.Dir("show", "show state",
|
||||
b.Cmd("server", "list servers", runShowServers,
|
||||
b.Slot("<name>", "show one", dynServers, runShowServer))),
|
||||
b.Cmd("quit", "exit", runQuit),
|
||||
)
|
||||
```
|
||||
|
||||
`cli.App[C]` wraps the whole process entry point — the standard flags
|
||||
(`-color`, `-json`, `-version`, and `-server` when configured), mode-aware color
|
||||
defaults, the version banner, connecting the client, and the one-shot-vs-shell
|
||||
split — into a single `Main()`. It is transport-agnostic: it never dials
|
||||
anything itself, so supply `Connect` to build the client (dial gRPC, open a
|
||||
socket, or return an in-memory value):
|
||||
|
||||
```go
|
||||
func main() {
|
||||
(&cli.App[inventory]{
|
||||
Name: "inv", Version: "1.1.0", Prompt: "inv> ", Root: buildTree(),
|
||||
// Local CLI: no -server flag. A networked CLI sets DefaultServer/ServerEnv
|
||||
// and dials inside Connect.
|
||||
Connect: func(context.Context, string) (inventory, func(), error) {
|
||||
return newInventory(), nil, nil
|
||||
},
|
||||
FormatError: func(err error) string { return cli.Paint(err.Error(), cli.Red) },
|
||||
}).Main()
|
||||
}
|
||||
```
|
||||
|
||||
Both are additive conveniences: every builder method returns a plain
|
||||
`*cli.Node[C]`, so builder and struct-literal construction interoperate, and you
|
||||
can still drive the lifecycle yourself with `Shell`/`Dispatch` instead of `App`.
|
||||
|
||||
`cli.Validate(root)` reports common authoring faults (more than one slot child
|
||||
under a node, an empty word, duplicate sibling words, a dead-end node). It is
|
||||
optional; the idiomatic use is a one-line unit test so a malformed tree fails the
|
||||
build:
|
||||
|
||||
```go
|
||||
func TestTreeValid(t *testing.T) {
|
||||
if err := cli.Validate(buildTree()); err != nil { t.Fatal(err) }
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming commands
|
||||
|
||||
For a `watch`-style command that streams until interrupted, the
|
||||
[`keypress`](keypress) subpackage stops it on any keystroke:
|
||||
|
||||
```go
|
||||
func runWatch(ctx context.Context, c Client, _ []string) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go keypress.WaitForKey(ctx, cancel) // any key cancels ctx
|
||||
stream, _ := c.Watch(ctx, req)
|
||||
for { ev, err := stream.Recv(); /* returns when ctx is cancelled */ }
|
||||
}
|
||||
```
|
||||
|
||||
When stdin is not a terminal it simply waits on the context, so piped/one-shot
|
||||
use never blocks on a keypress.
|
||||
|
||||
## Output: color and JSON
|
||||
|
||||
A command describes its result **once** and the framework renders it:
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errUsage is returned by App.Run when flag parsing fails. The flag package has
|
||||
// already printed the error and usage to stderr, so Main exits without printing
|
||||
// it again.
|
||||
var errUsage = errors.New("usage error")
|
||||
|
||||
// App wires a command tree to a process entry point: it registers the standard
|
||||
// flags (-color, -json, -version, and -server when a server is configured),
|
||||
// applies mode-aware color defaults, connects the client, and then either runs
|
||||
// one command (when arguments are given) or starts the interactive shell.
|
||||
//
|
||||
// App is transport-agnostic: it never dials anything itself. Supply Connect to
|
||||
// build the client from the resolved -server address (dial gRPC, open a socket,
|
||||
// or just return an in-memory value). Leave DefaultServer and ServerEnv empty
|
||||
// for a local CLI with no -server flag.
|
||||
//
|
||||
// Typical use:
|
||||
//
|
||||
// func main() {
|
||||
// (&cli.App[pb.EvpnrClient]{
|
||||
// Name: "evpnc", Version: version, Commit: commit, Date: date,
|
||||
// Prompt: "evpn> ", Root: buildTree(),
|
||||
// DefaultServer: "localhost:8900", ServerEnv: "EVPNC_SERVER",
|
||||
// Connect: func(ctx context.Context, server string) (pb.EvpnrClient, func(), error) {
|
||||
// conn, err := grpc.NewClient(netutil.EnsurePort(server, "8900"), ...)
|
||||
// if err != nil { return nil, nil, err }
|
||||
// return pb.NewEvpnrClient(conn), func() { _ = conn.Close() }, nil
|
||||
// },
|
||||
// FormatError: formatError,
|
||||
// }).Main()
|
||||
// }
|
||||
type App[C any] struct {
|
||||
// Name is the program name, used in the -version line and as the banner.
|
||||
Name string
|
||||
// Version, Commit, Date populate the -version line. Commit/Date are optional.
|
||||
Version string
|
||||
Commit string
|
||||
Date string
|
||||
// Prompt is the interactive shell prompt, e.g. "evpn> ".
|
||||
Prompt string
|
||||
// Root is the command tree. Required.
|
||||
Root *Node[C]
|
||||
// Greeting, if set, is printed after the version banner in interactive mode.
|
||||
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,
|
||||
// no -server flag is registered (local CLI).
|
||||
DefaultServer string
|
||||
// ServerEnv, if set, is an environment variable that overrides DefaultServer.
|
||||
ServerEnv string
|
||||
// Connect builds the client from the resolved server address and returns it
|
||||
// with a cleanup function (called on exit; may be nil). If Connect is nil,
|
||||
// the zero value of C is used.
|
||||
Connect func(ctx context.Context, server string) (client C, cleanup func(), err error)
|
||||
|
||||
// FormatError renders command and fatal errors. Defaults to err.Error().
|
||||
FormatError func(error) string
|
||||
// RegisterFlags, if set, registers app-specific flags before parsing.
|
||||
RegisterFlags func(*flag.FlagSet)
|
||||
}
|
||||
|
||||
// Main runs the app with os.Args and exits the process: status 0 on success,
|
||||
// 2 on a usage error, 1 on any other error (printed via FormatError).
|
||||
func (a *App[C]) Main() {
|
||||
err := a.Run(context.Background(), os.Args[1:])
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errUsage) {
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, a.formatErr()(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Run parses argv, connects, and dispatches one command or starts the shell. It
|
||||
// returns a fatal error (connect failure, one-shot command error, readline
|
||||
// error); per-command errors in the interactive shell are printed there and do
|
||||
// not stop the loop. Use Run directly when you manage the process lifecycle
|
||||
// yourself; otherwise call Main.
|
||||
func (a *App[C]) Run(ctx context.Context, argv []string) error {
|
||||
fs := flag.NewFlagSet(a.Name, flag.ContinueOnError)
|
||||
|
||||
var serverFlag *string
|
||||
defaultServer := a.DefaultServer
|
||||
if a.ServerEnv != "" {
|
||||
if v := os.Getenv(a.ServerEnv); v != "" {
|
||||
defaultServer = v
|
||||
}
|
||||
}
|
||||
if defaultServer != "" || a.ServerEnv != "" {
|
||||
usage := "server address"
|
||||
if a.ServerEnv != "" {
|
||||
usage += " (env: " + a.ServerEnv + ")"
|
||||
}
|
||||
serverFlag = fs.String("server", defaultServer, usage)
|
||||
}
|
||||
color := fs.Bool("color", true, "colorize output (default: on in the shell, off one-shot)")
|
||||
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")
|
||||
if a.RegisterFlags != nil {
|
||||
a.RegisterFlags(fs)
|
||||
}
|
||||
if err := fs.Parse(argv); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil // flag already printed usage
|
||||
}
|
||||
return errUsage // flag already printed the error and usage
|
||||
}
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println(a.versionLine())
|
||||
return nil
|
||||
}
|
||||
jsonMode := jsonOut != nil && *jsonOut
|
||||
if jsonMode {
|
||||
SetFormat(FormatJSON)
|
||||
}
|
||||
|
||||
positional := fs.Args()
|
||||
interactive := len(positional) == 0
|
||||
|
||||
// Mode-aware color default: on in the interactive shell, off one-shot (so
|
||||
// piped output is script-safe). An explicit -color overrides either way;
|
||||
// JSON forces it off.
|
||||
colorExplicit := false
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "color" {
|
||||
colorExplicit = true
|
||||
}
|
||||
})
|
||||
colorOn := interactive
|
||||
if colorExplicit {
|
||||
colorOn = *color
|
||||
}
|
||||
if jsonMode {
|
||||
colorOn = false
|
||||
}
|
||||
SetColor(colorOn)
|
||||
|
||||
client, cleanup, err := a.connect(ctx, serverFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if interactive {
|
||||
fmt.Println(a.versionLine())
|
||||
if a.Greeting != "" {
|
||||
fmt.Println(a.Greeting)
|
||||
}
|
||||
sh := &Shell[C]{Root: a.Root, Client: client, Prompt: a.Prompt, FormatError: a.FormatError}
|
||||
return sh.Run(ctx)
|
||||
}
|
||||
return Dispatch(ctx, a.Root, client, SplitTokens(strings.Join(positional, " ")))
|
||||
}
|
||||
|
||||
func (a *App[C]) connect(ctx context.Context, serverFlag *string) (C, func(), error) {
|
||||
if a.Connect == nil {
|
||||
var zero C
|
||||
return zero, func() {}, nil
|
||||
}
|
||||
server := ""
|
||||
if serverFlag != nil {
|
||||
server = *serverFlag
|
||||
}
|
||||
client, cleanup, err := a.Connect(ctx, server)
|
||||
if cleanup == nil {
|
||||
cleanup = func() {}
|
||||
}
|
||||
return client, cleanup, err
|
||||
}
|
||||
|
||||
func (a *App[C]) formatErr() func(error) string {
|
||||
if a.FormatError != nil {
|
||||
return a.FormatError
|
||||
}
|
||||
return func(err error) string { return err.Error() }
|
||||
}
|
||||
|
||||
func (a *App[C]) versionLine() string {
|
||||
s := a.Name + " " + a.Version
|
||||
if a.Commit != "" || a.Date != "" {
|
||||
s += fmt.Sprintf(" (commit %s, built %s)", a.Commit, a.Date)
|
||||
}
|
||||
return s
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAppOneShotDispatch runs a one-shot command through App.Run and checks the
|
||||
// positional args reach the command, the captured slot arg is correct, and the
|
||||
// client from Connect is threaded through.
|
||||
func TestAppOneShotDispatch(t *testing.T) {
|
||||
var gotArg string
|
||||
var gotClient fakeClient
|
||||
b := For[fakeClient]()
|
||||
root := b.Root(
|
||||
b.Dir("ping", "",
|
||||
b.Slot("<name>", "", func(context.Context, fakeClient, []string) []string { return nil },
|
||||
func(_ context.Context, c fakeClient, args []string) error {
|
||||
gotClient = c
|
||||
gotArg = args[0]
|
||||
return nil
|
||||
})),
|
||||
)
|
||||
app := &App[fakeClient]{
|
||||
Name: "t",
|
||||
Root: root,
|
||||
Connect: func(context.Context, string) (fakeClient, func(), error) {
|
||||
return fakeClient{instances: []string{"sentinel"}}, nil, nil
|
||||
},
|
||||
}
|
||||
if err := app.Run(context.Background(), []string{"ping", "host9"}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if gotArg != "host9" {
|
||||
t.Errorf("arg = %q, want host9", gotArg)
|
||||
}
|
||||
if len(gotClient.instances) != 1 || gotClient.instances[0] != "sentinel" {
|
||||
t.Errorf("client not threaded from Connect: %+v", gotClient)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppVersion checks -version short-circuits before connecting.
|
||||
func TestAppVersion(t *testing.T) {
|
||||
connected := false
|
||||
app := &App[fakeClient]{
|
||||
Name: "t",
|
||||
Version: "9.9.9",
|
||||
Root: For[fakeClient]().Root(),
|
||||
Connect: func(context.Context, string) (fakeClient, func(), error) {
|
||||
connected = true
|
||||
return fakeClient{}, nil, nil
|
||||
},
|
||||
}
|
||||
if err := app.Run(context.Background(), []string{"-version"}); err != nil {
|
||||
t.Fatalf("Run -version: %v", err)
|
||||
}
|
||||
if connected {
|
||||
t.Error("-version should not connect")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppNilConnect checks a local CLI with no Connect uses the zero client.
|
||||
func TestAppNilConnect(t *testing.T) {
|
||||
ran := false
|
||||
b := For[fakeClient]()
|
||||
app := &App[fakeClient]{
|
||||
Name: "t",
|
||||
Root: b.Root(b.Cmd("go", "", func(context.Context, fakeClient, []string) error { ran = true; return nil })),
|
||||
}
|
||||
if err := app.Run(context.Background(), []string{"go"}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !ran {
|
||||
t.Error("command did not run with nil Connect")
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cli
|
||||
|
||||
import "context"
|
||||
|
||||
// Builder constructs Node[C] values without repeating the [C] type parameter at
|
||||
// every node. Obtain one with For[C], then use Root/Dir/Cmd/SlotDir/Slot:
|
||||
//
|
||||
// b := cli.For[Client]()
|
||||
// root := b.Root(
|
||||
// b.Dir("show", "show state",
|
||||
// b.Cmd("version", "print version", runVersion),
|
||||
// b.Cmd("server", "list servers (add <name> for one)", runServers,
|
||||
// b.Slot("<name>", "show one", dynServers, runServer))),
|
||||
// b.Cmd("quit", "exit", runQuit),
|
||||
// )
|
||||
//
|
||||
// The naming is symmetric: Dir/Cmd are fixed keyword nodes (without/with an
|
||||
// action), SlotDir/Slot are dynamic argument nodes (without/with an action).
|
||||
// The builder is a zero-size value, so constructing many nodes from one is free.
|
||||
//
|
||||
// It is purely a convenience: every method returns a plain *Node[C], so builder
|
||||
// and struct-literal construction interoperate freely — you can mix the two,
|
||||
// and a node built either way can still be mutated afterward (e.g. to wire a
|
||||
// circular slot: opt := b.Slot(...); opt.Children = []*cli.Node[C]{opt}).
|
||||
type Builder[C any] struct{}
|
||||
|
||||
// For returns a Builder bound to client type C.
|
||||
func For[C any]() Builder[C] { return Builder[C]{} }
|
||||
|
||||
// Root returns the (wordless) root node holding the given top-level children.
|
||||
func (Builder[C]) Root(children ...*Node[C]) *Node[C] {
|
||||
return &Node[C]{Children: children}
|
||||
}
|
||||
|
||||
// Dir is a fixed keyword node with no action of its own — a grouping of
|
||||
// subcommands (e.g. "show", "create").
|
||||
func (Builder[C]) Dir(word, help string, children ...*Node[C]) *Node[C] {
|
||||
return &Node[C]{Word: word, Help: help, Children: children}
|
||||
}
|
||||
|
||||
// Cmd is a fixed keyword node that runs an action. It may also carry children:
|
||||
// a node that both does something and has subcommands (e.g. "show instance",
|
||||
// which lists members and also accepts "<id>").
|
||||
func (Builder[C]) Cmd(word, help string, run func(context.Context, C, []string) error, children ...*Node[C]) *Node[C] {
|
||||
return &Node[C]{Word: word, Help: help, Run: run, Children: children}
|
||||
}
|
||||
|
||||
// SlotDir is a dynamic slot that captures any token as an argument but has no
|
||||
// action of its own — a navigational placeholder (e.g. "<id>" in
|
||||
// "instance <id> evpn ..."). dyn yields the live completion candidates, given
|
||||
// the args captured by earlier slots on the path.
|
||||
func (Builder[C]) SlotDir(word, help string, dyn func(context.Context, C, []string) []string, children ...*Node[C]) *Node[C] {
|
||||
return &Node[C]{Word: word, Help: help, Dynamic: dyn, Children: children}
|
||||
}
|
||||
|
||||
// Slot is a dynamic slot that both captures a token argument and runs an action
|
||||
// (e.g. "delete group <id>"). It may also carry children. dyn yields the live
|
||||
// completion candidates for the slot, given the args captured by earlier slots.
|
||||
func (Builder[C]) Slot(word, help string, dyn func(context.Context, C, []string) []string, run func(context.Context, C, []string) error, children ...*Node[C]) *Node[C] {
|
||||
return &Node[C]{Word: word, Help: help, Dynamic: dyn, Run: run, Children: children}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildFixtureB constructs the same tree shape as buildFixture (tree_test.go)
|
||||
// but via the Builder, so the two are interchangeable. dynInstances, dynEvpns,
|
||||
// dynWatchOpts, runNoop and runQuit are defined in tree_test.go (same package).
|
||||
func buildFixtureB() *Node[fakeClient] {
|
||||
b := For[fakeClient]()
|
||||
|
||||
// Circular watch-options slot: built then wired to itself.
|
||||
watchOpt := b.Slot("<opts>", "", dynWatchOpts, runNoop)
|
||||
watchOpt.Children = []*Node[fakeClient]{watchOpt}
|
||||
|
||||
return b.Root(
|
||||
b.Dir("show", "show state",
|
||||
b.Cmd("instance", "list fleet members (add <id> for one)", runNoop,
|
||||
b.Slot("<id>", "show one member", dynInstances, runNoop,
|
||||
b.Cmd("evpn", "list the member's EVPNs", runNoop,
|
||||
b.Slot("<evpn>", "show one EVPN", dynEvpns, runNoop))))),
|
||||
b.Dir("watch", "stream events",
|
||||
b.Cmd("events", "watch events", runNoop, watchOpt)),
|
||||
b.Cmd("quit", "exit", runQuit),
|
||||
)
|
||||
}
|
||||
|
||||
// TestBuilderMatchesLiteral checks the Builder produces a tree that Walks
|
||||
// identically to the struct-literal fixture: same runnable nodes, captured
|
||||
// args, prefix abbreviation, and circular-slot handling.
|
||||
func TestBuilderMatchesLiteral(t *testing.T) {
|
||||
root := buildFixtureB()
|
||||
cases := []struct {
|
||||
toks []string
|
||||
runnable bool
|
||||
help string
|
||||
args []string
|
||||
remaining int
|
||||
}{
|
||||
{[]string{"show", "instance"}, true, "list fleet members (add <id> for one)", nil, 0},
|
||||
{[]string{"show", "instance", "host1"}, true, "show one member", []string{"host1"}, 0},
|
||||
{[]string{"show", "instance", "host1", "evpn", "blue"}, true, "show one EVPN", []string{"host1", "blue"}, 0},
|
||||
{[]string{"sh", "inst"}, true, "list fleet members (add <id> for one)", nil, 0},
|
||||
{[]string{"watch", "events", "num", "5", "crud"}, true, "", []string{"num", "5", "crud"}, 0},
|
||||
{[]string{"show", "bogus"}, false, "", nil, 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
node, args, remaining := Walk(root, tc.toks)
|
||||
if len(remaining) != tc.remaining {
|
||||
t.Errorf("Walk(%v) remaining=%v, want %d", tc.toks, remaining, tc.remaining)
|
||||
continue
|
||||
}
|
||||
if tc.remaining > 0 {
|
||||
continue
|
||||
}
|
||||
if got := node.Run != nil; got != tc.runnable {
|
||||
t.Errorf("Walk(%v) runnable=%v, want %v", tc.toks, got, tc.runnable)
|
||||
}
|
||||
if tc.runnable && node.Help != tc.help {
|
||||
t.Errorf("Walk(%v) help=%q, want %q", tc.toks, node.Help, tc.help)
|
||||
}
|
||||
if strings.Join(args, ",") != strings.Join(tc.args, ",") {
|
||||
t.Errorf("Walk(%v) args=%v, want %v", tc.toks, args, tc.args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuilderDynamicCandidates checks a Builder-built slot still resolves
|
||||
// context-dependent candidates through its Dynamic function.
|
||||
func TestBuilderDynamicCandidates(t *testing.T) {
|
||||
root := buildFixtureB()
|
||||
client := fakeClient{
|
||||
instances: []string{"host1", "host2"},
|
||||
evpns: map[string][]string{"host1": {"blue", "red"}},
|
||||
}
|
||||
got := Candidates(context.Background(), client, root, []string{"show", "instance", "host1", "evpn"}, "")
|
||||
if len(got) != 2 || got[0].Word != "blue" || got[1].Word != "red" {
|
||||
t.Errorf("Candidates(... host1 evpn) = %v, want [blue red]", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
|
||||
# Proposal: extract the command-tree CLI into a reusable `golang-cli` package
|
||||
|
||||
Status: draft / for discussion. Nothing in `vpp-evpn` or `vpp-maglev` has been
|
||||
changed. This document describes how to lift the command-tree CLI out of
|
||||
`cmd/evpnc` (vpp-evpn) and `cmd/client` (vpp-maglev) into a standalone module at
|
||||
`~/src/golang-cli`, and re-import it in both with **no functional difference**.
|
||||
|
||||
## 1. Why
|
||||
|
||||
Both projects carry the same hand-rolled CLI: a declarative command tree from
|
||||
which dispatch, `?`-help, and tab-completion are all derived, driven by
|
||||
`chzyer/readline`. The two copies have already drifted (see §3), and a third
|
||||
copy would just cargo-cult the drift forward. The parser, the dynamic-node
|
||||
mechanism, and the command registration are genuinely generic — only the gRPC
|
||||
*client type* and the *tree contents* are app-specific.
|
||||
|
||||
## 2. The three components (as you framed them)
|
||||
|
||||
| Your name | Concrete artifact today | Reusable? |
|
||||
|---|---|---|
|
||||
| "the parsing tree" | `Node` struct + `Walk` / `matchFixedChild` / `findSlotChild` / `expandPaths` (`tree.go`) | **Yes, verbatim** (modulo client type) |
|
||||
| "dynamic node function registration" | `Node.Dynamic func(ctx, client, args) []string`, resolved in `Candidates` and the `?` listener | **Yes** — this is the only real API-shape decision (see §3.1) |
|
||||
| "command function registration" | `Node.Run func(ctx, client, args) error`, dispatched by `dispatch` | **Yes, verbatim** |
|
||||
|
||||
Everything that *consumes* the tree is also generic and moves with it:
|
||||
`Completer` (readline `AutoCompleter`), `questionListener` (the `?` key handler),
|
||||
`dispatch`, `showHelpAt`, `unknownCommandError`, the REPL loop (`runShell`),
|
||||
`splitTokens` / `splitForCompletion`, and the OpenBSD termios shim
|
||||
(`shell_term_openbsd.go` / `_default.go`).
|
||||
|
||||
What does **not** move — it stays in each app:
|
||||
- `buildTree()` and every `run*` / `dyn*` function (`commands.go`) — the actual
|
||||
command set.
|
||||
- `color.go` — the `formatError` gRPC-status unwrap stays in-app (wired via
|
||||
`Shell.FormatError`). The generic `label`/`paint`/`colorEnabled` half **moved
|
||||
into the library** as `cli.Label`/`cli.Paint`/`cli.SetColor` (see `color.go`,
|
||||
`output.go`).
|
||||
- `watch.go` (gRPC server-stream consumer) — app/proto-specific. *But* its
|
||||
generic half — "cancel a context on any keypress" — can optionally move (§4.3).
|
||||
- `main.go` — flag parsing, gRPC dial, color-mode defaults.
|
||||
|
||||
## 3. What differs between the two copies today
|
||||
|
||||
The copies are ~95% identical. The differences, and how the library reconciles
|
||||
them so neither app changes behaviour:
|
||||
|
||||
### 3.1 `Dynamic` signature — the one real decision
|
||||
|
||||
- `vpp-evpn`: `func(context.Context, pb.EvpnrClient, []string) []string` — the
|
||||
trailing `[]string` is the args captured by earlier slot nodes, so `<evpn>`
|
||||
can list the EVPNs *of the `<id>` already typed*.
|
||||
- `vpp-maglev`: `func(context.Context, grpcapi.MaglevClient) []string` — no args.
|
||||
|
||||
The evpn signature is a strict superset. **The library adopts the args-bearing
|
||||
form.** maglev's `dyn*` functions gain an ignored `_ []string` parameter — a
|
||||
purely mechanical edit, zero behaviour change.
|
||||
|
||||
### 3.2 Client type — solved with generics
|
||||
|
||||
Both projects are `go 1.25`, so generics are available. The package is generic
|
||||
over the client type `C`:
|
||||
|
||||
```go
|
||||
type Node[C any] struct {
|
||||
Word string
|
||||
Help string
|
||||
Dynamic func(context.Context, C, []string) []string // non-nil => slot node
|
||||
Children []*Node[C]
|
||||
Run func(context.Context, C, []string) error
|
||||
}
|
||||
```
|
||||
|
||||
`C` is `pb.EvpnrClient` in evpnc and `grpcapi.MaglevClient` in maglevc. Run/Dynamic
|
||||
funcs receive the concrete client — **no `any`, no type assertions** in app code.
|
||||
(Alternative considered: a non-generic `any` client with assertions in every
|
||||
`run*`. Rejected — it pushes boilerplate and runtime panics into the apps. See §7.)
|
||||
|
||||
### 3.3 Cosmetic drift the library erases (all no-ops behaviourally)
|
||||
|
||||
- `Candidates` arg order: evpnc `(ctx, client, root, tokens, partial)` vs maglev
|
||||
`(root, tokens, partial, ctx, client)`. Library picks the evpnc order.
|
||||
- evpnc's `Candidates` passes captured `args` to `Dynamic`; maglev discards them
|
||||
(`node, _, remaining`). Library passes them (superset; maglev's funcs ignore).
|
||||
- `RunShell`: evpnc calls `applyTermFuncs(cfg)` (OpenBSD fix), maglev does not.
|
||||
Library always calls it — it's a no-op off OpenBSD, so maglev gains the fix
|
||||
with no change on Linux/macOS.
|
||||
- `splitTokens` lives in `complete.go` (maglev) vs `complete.go` (evpnc) — same
|
||||
body; library exports one `SplitTokens`.
|
||||
- The `?` listener's partial-token branch carries different comments but identical
|
||||
logic; unified.
|
||||
|
||||
## 4. Proposed package shape
|
||||
|
||||
Module: `git.ipng.ch/ipng/golang-cli` (matches your git host). Single package
|
||||
`cli` to start; the optional keypress helper can be a subpackage.
|
||||
|
||||
```
|
||||
~/src/golang-cli/
|
||||
go.mod module git.ipng.ch/ipng/golang-cli, go 1.25
|
||||
go.sum
|
||||
LICENSE Apache-2.0 (matches the SPDX headers already in the files)
|
||||
README.md
|
||||
tree.go Node[C], Walk, matchFixedChild, findSlotChild, expandPaths, Candidates
|
||||
tree_test.go table tests over a fixture tree (no client needed; Walk ignores Dynamic)
|
||||
complete.go completer[C], questionListener[C], SplitTokens, splitForCompletion
|
||||
shell.go Shell[C] (config + Run), Dispatch, showHelpAt, unknownCommandError, ErrQuit
|
||||
color.go SetColor, Paint, Label + the ANSI palette (Red/Green/Blue/Yellow/Cyan)
|
||||
output.go SetFormat, Emit (text-or-JSON), KV, IsJSON
|
||||
term_openbsd.go applyTermFuncs (//go:build openbsd) <- from shell_term_openbsd.go
|
||||
term_default.go applyTermFuncs no-op (//go:build !openbsd) <- from shell_term_default.go
|
||||
example/main.go self-contained, dependency-free demo CLI
|
||||
keypress/ (optional) WaitForKeypress + cbreak shim, per-GOOS <- §4.3
|
||||
```
|
||||
|
||||
### 4.1 The one new type: `Shell[C]`
|
||||
|
||||
The REPL loop today hard-codes the prompt, the banner, the `errQuit` sentinel,
|
||||
and `formatError`. Those become a small config struct so each app keeps its
|
||||
exact strings and error rendering:
|
||||
|
||||
```go
|
||||
type Shell[C any] struct {
|
||||
Root *Node[C]
|
||||
Client C
|
||||
Prompt string // "evpn> " / "maglev> "
|
||||
FormatError func(error) string // default: err.Error(); evpnc/maglev pass their gRPC-desc unwrap
|
||||
}
|
||||
|
||||
func (s *Shell[C]) Run(ctx context.Context) error // the readline REPL loop
|
||||
func Dispatch[C any](ctx context.Context, root *Node[C], client C, tokens []string) error
|
||||
|
||||
var ErrQuit = errors.New("quit") // a quit/exit Run func returns this; Run() stops on it
|
||||
```
|
||||
|
||||
Banner printing stays in `main.go` (it needs `buildinfo.Version()` etc., which
|
||||
is app-specific) — `main` prints the banner, then calls `shell.Run(ctx)`.
|
||||
`FormatError` is how each app keeps its gRPC `desc =` unwrap + red coloring
|
||||
without the library depending on gRPC at all.
|
||||
|
||||
### 4.2 What the app's `main.go` looks like after (evpnc)
|
||||
|
||||
```go
|
||||
root := buildTree() // returns *cli.Node[pb.EvpnrClient]
|
||||
if len(args) == 0 {
|
||||
fmt.Printf("evpnc %s ...\n", buildinfo.Version(), ...) // banner
|
||||
return (&cli.Shell[pb.EvpnrClient]{
|
||||
Root: root, Client: client, Prompt: "evpn> ", FormatError: formatError,
|
||||
}).Run(ctx)
|
||||
}
|
||||
return cli.Dispatch(ctx, root, client, cli.SplitTokens(strings.Join(args, " ")))
|
||||
```
|
||||
|
||||
`buildTree`, `runQuit` (`return cli.ErrQuit`), all `run*`/`dyn*`, `color.go`,
|
||||
`watch.go` are unchanged except `Node` → `cli.Node[pb.EvpnrClient]` and the
|
||||
`dyn*` signature tweak in maglev.
|
||||
|
||||
### 4.3 Optional: the keypress helper
|
||||
|
||||
`watch.go` in both apps shares "put stdin in cbreak, cancel ctx on any key", with
|
||||
per-GOOS termios ioctls (`watch_linux.go` / `watch_bsd.go` in evpnc; inline in
|
||||
maglev). The proto-streaming half is app-specific and stays, but
|
||||
`WaitForKeypress(ctx, cancel)` + the cbreak shim are reusable. Suggest a
|
||||
`cli/keypress` subpackage so apps don't recopy the ioctl tables. Low priority —
|
||||
it's the least-drifted, most-self-contained piece. Can land in a follow-up.
|
||||
|
||||
## 5. Migration plan
|
||||
|
||||
**Phase 0 — stand up the module (no app changes).**
|
||||
1. `git init ~/src/golang-cli`; `go mod init git.ipng.ch/ipng/golang-cli`; `go 1.25`.
|
||||
2. Copy `tree.go`, `complete.go`, `shell.go` pieces, `shell_term_*.go` in.
|
||||
Make `Node`, `Walk`, `Candidates`, `Completer`, `questionListener`, `Dispatch`,
|
||||
`Shell` generic over `C`. Add `Shell.FormatError` and `ErrQuit`.
|
||||
3. Port `tree_test.go` to a self-contained fixture tree (a tiny `Node[*fakeClient]`
|
||||
with a couple of slot nodes + a circular watch-opts node) so the package tests
|
||||
`Walk`/`expandPaths`/`Candidates` with no gRPC dependency. `go test ./...` green.
|
||||
4. Dependencies: `github.com/chzyer/readline`, `golang.org/x/sys/unix` (both
|
||||
already in each app's `go.sum`). Tag `v0.1.0`.
|
||||
|
||||
**Phase 1 — convert vpp-evpn (the args-bearing reference, less work).**
|
||||
1. `go get git.ipng.ch/ipng/golang-cli@v0.1.0`; during local dev add a
|
||||
`replace git.ipng.ch/ipng/golang-cli => ../golang-cli` to iterate.
|
||||
2. Delete `tree.go`, `complete.go`, `shell.go` body, `shell_term_*.go` from
|
||||
`cmd/evpnc`. Keep `commands.go`, `color.go`, `watch*.go`, `main.go`.
|
||||
3. Replace `*Node` with `*cli.Node[pb.EvpnrClient]`; `runQuit` returns
|
||||
`cli.ErrQuit`; `main` builds a `cli.Shell` (§4.2). `Walk`/`expandPaths` refs in
|
||||
`watch.go`? none — good.
|
||||
4. Run the existing checkpoint: `make fixstyle test lint vet`. The
|
||||
`cmd/evpnc/tree_test.go` `TestWalk`/`TestExpandPaths` move to exercising
|
||||
`buildTree()` through the library's exported `Walk`/`ExpandPaths` (rename as
|
||||
needed) — they assert the *app's* tree, so they stay in the app.
|
||||
|
||||
**Phase 2 — convert vpp-maglev.**
|
||||
1. Same wiring. Plus the mechanical edits from §3.1: add `_ []string` to every
|
||||
`dyn*`; `Candidates`/`Dynamic` call sites now come from the library.
|
||||
2. maglev's `main.go` color-default logic is equivalent to evpnc's; unchanged.
|
||||
3. `make` checkpoint green.
|
||||
|
||||
**Phase 3 — drop the replace directives, tag a real version, pin both apps.**
|
||||
|
||||
## 6. Risks / things to verify during the port
|
||||
|
||||
- **Generics + method values.** `Node[C]` is fine; the only subtlety is that
|
||||
`Completer[C]` and `questionListener[C]` must carry the type param through to
|
||||
their `readline` interface methods. `readline.AutoCompleter` / `Listener` are
|
||||
non-generic interfaces, but a concrete `*Completer[pb.EvpnrClient]` satisfies
|
||||
them — verified shape, no reflection needed.
|
||||
- **The `?` echo trick** (`questionListener.OnChange` writing the prompt+line to
|
||||
`rl.Stderr()` before help) depends on readline's wrapWriter behaviour. It's
|
||||
copied verbatim; no change.
|
||||
- **OpenBSD.** evpnc has the termios shim and a `make pkg-openbsd`; the library
|
||||
must keep the `//go:build openbsd` split so maglev gets it without pulling
|
||||
OpenBSD code into its Linux builds. Verify a `GOOS=openbsd go build` of the
|
||||
library.
|
||||
- **No hidden client coupling.** `Walk`/`expandPaths` never touch the client
|
||||
(the existing `TestWalk` comment confirms it) — so they could even be
|
||||
non-generic with `Dynamic any`. Keeping them generic is simpler and uniform.
|
||||
|
||||
## 7. Open decisions for you
|
||||
|
||||
1. **Generics vs `any` client.** Recommended: generics (§3.2) — type-safe,
|
||||
zero boilerplate in apps. The cost is `[]*cli.Node[pb.EvpnrClient]` verbosity
|
||||
in `commands.go`; a per-app `type node = cli.Node[pb.EvpnrClient]` alias hides
|
||||
it entirely.
|
||||
2. **Module host/name.** `git.ipng.ch/ipng/golang-cli` (consistent) vs a GitHub
|
||||
mirror if you want it public. Package name `cli` vs something less likely to
|
||||
collide (`cmdtree`, `cmdline`).
|
||||
3. **Scope of v0.1.** Minimum = tree + completion + shell. Stretch = the
|
||||
`keypress` subpackage (§4.3) and maybe a tiny `color` helper if a third client
|
||||
wants it. Suggest shipping the minimum first; both apps prove the API before
|
||||
it ossifies.
|
||||
4. **Whose `Dynamic` shape wins** — already answered (args-bearing, §3.1) unless
|
||||
you'd rather keep maglev argless and have evpnc pass args by closure capture.
|
||||
The superset is cleaner.
|
||||
|
||||
## 8. Bottom line
|
||||
|
||||
"What would it take to make this its own standalone package?" — concretely:
|
||||
|
||||
1. A new `go 1.25` module with three files made generic over the client type
|
||||
(`tree.go`, `complete.go`, `shell.go`) plus the two OpenBSD term files and a
|
||||
self-contained test — ~1 day including tests.
|
||||
2. One genuine API choice (args-bearing `Dynamic`, generic `C`) and one new
|
||||
config type (`Shell[C]` with an injectable `FormatError`). Everything else is
|
||||
moved verbatim.
|
||||
3. Mechanical edits in each app: swap `Node` for `cli.Node[ClientT]`, route the
|
||||
REPL through `cli.Shell`, return `cli.ErrQuit` from quit, and (maglev only)
|
||||
add an ignored args param to each `dyn*`. No behaviour changes; both `make`
|
||||
checkpoints stay green.
|
||||
|
||||
The tree, the dynamic-node mechanism, and the command/run registration are all
|
||||
already cleanly separated in these files — the refactor is mostly *relocation +
|
||||
generics*, not redesign.
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
|
||||
# golang-cli Design Document
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **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 |
|
||||
|
||||
The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are
|
||||
used as described in [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119),
|
||||
and are reserved for requirements enforced in code. Plain-language "can"/"will"/
|
||||
"does" are descriptive, not normative.
|
||||
|
||||
## Summary
|
||||
|
||||
`golang-cli` builds network-daemon-style CLIs: an interactive shell with
|
||||
tab-completion and `?`-help, plus a one-shot mode for scripts, both driven by one
|
||||
declarative command tree. It is the shared core extracted from `evpnc`
|
||||
(`vpp-evpn`) and `maglevc` (`vpp-maglev`), which had each grown their own copy.
|
||||
It is generic over a *client* type, and renders command output as colorized text
|
||||
or JSON from the same code.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **One tree, one truth.** Dispatch, help, and completion all derive from a
|
||||
single command tree, so they cannot drift.
|
||||
2. **Type-safe over any backend.** Command code receives its concrete client, no
|
||||
runtime type assertions.
|
||||
3. **Drop-in for `evpnc` and `maglevc`** with no functional regression.
|
||||
4. **Low ceremony.** A tree and a `main()` in a few lines, not a copied file.
|
||||
5. **Text or JSON** from the same command code.
|
||||
6. **Linux and OpenBSD** are both first-class.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Not a flag parser (uses stdlib `flag`) and not a TUI toolkit.
|
||||
- Does not own transport: it never dials, connects, or speaks a wire protocol.
|
||||
- Defines no command set, no output format — those are the caller's.
|
||||
- Holds no state beyond two process-global render switches (color, format).
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
**FR-1 Command tree and resolution**
|
||||
|
||||
- **FR-1.1** The command set MUST be one tree of `Node[C]`; dispatch, `?`-help,
|
||||
and completion MUST all derive from it, with no second command table.
|
||||
- **FR-1.2** `Walk` MUST prefer a fixed child over the slot child, matching fixed
|
||||
by exact word then unique prefix.
|
||||
- **FR-1.3** A unique-prefix token MUST resolve to that child; an ambiguous
|
||||
prefix MUST NOT resolve.
|
||||
- **FR-1.4** A node MAY both run and have children.
|
||||
- **FR-1.5** A slot node (`Dynamic != nil`) MUST capture any token as an
|
||||
argument; captured args MUST reach the matched node's `Run` in order.
|
||||
- **FR-1.6** An unconsumable token MUST be returned to the caller and reported as
|
||||
an unknown-command error naming the first bad token, not anchored at the root.
|
||||
- **FR-1.7** At most one slot child per node is reachable; `Validate` SHOULD flag
|
||||
more than one.
|
||||
|
||||
**FR-2 Completion and help**
|
||||
|
||||
- **FR-2.1** `Dynamic` MUST receive the args captured by earlier slots, so
|
||||
candidates can depend on tokens already typed.
|
||||
- **FR-2.2** Completion MUST offer fixed children and slot values that have the
|
||||
partial token as a prefix.
|
||||
- **FR-2.3** If a confirmed token is unknown, completion MUST offer nothing.
|
||||
- **FR-2.4** Each completion/`?` `Dynamic` call MUST be timeout-bounded
|
||||
(default 1s).
|
||||
- **FR-2.5** `?` MUST list the reachable runnable paths with help and live slot
|
||||
values, without submitting the line.
|
||||
|
||||
**FR-3 Shell and one-shot**
|
||||
|
||||
- **FR-3.1** No positional args MUST start the REPL; positional args MUST run one
|
||||
command and exit.
|
||||
- **FR-3.2** A command MUST be able to stop the REPL by returning `ErrQuit`.
|
||||
- **FR-3.3** A REPL command error MUST be printed and MUST NOT stop the loop; a
|
||||
one-shot command error MUST exit non-zero.
|
||||
- **FR-3.4** `Ctrl-C` MUST abandon the line and continue; `Ctrl-D` MUST exit.
|
||||
|
||||
**FR-4 Output**
|
||||
|
||||
- **FR-4.1** A command MUST be able to describe one result once and have it
|
||||
rendered as text or JSON per a process-global format (`Emit`).
|
||||
- **FR-4.2** In JSON mode the framework MUST marshal the command's machine value;
|
||||
supplying it is the command's responsibility.
|
||||
- **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**
|
||||
|
||||
- **FR-5.1** `App` MUST register `-color` and `-version`; `-server` only when a
|
||||
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
|
||||
`Connect` callback.
|
||||
- **FR-5.3** `-version` MUST print and exit without connecting.
|
||||
- **FR-5.4** The server address MUST resolve from `-server`, else the configured
|
||||
env var, else the configured default.
|
||||
|
||||
**FR-6 Authoring helpers**
|
||||
|
||||
- **FR-6.1** `For[C]()`/`Builder` MUST build the tree without repeating `[C]` per
|
||||
node, returning plain `*Node[C]` so builder and literal construction mix.
|
||||
- **FR-6.2** `Validate` MUST report: >1 slot child per node, an empty word on a
|
||||
non-root node, duplicate fixed words among siblings, and a node that neither
|
||||
runs nor has children; it MUST handle circular slots without looping.
|
||||
|
||||
**FR-7 Cancel-on-keypress (`keypress` subpackage)**
|
||||
|
||||
- **FR-7.1** A streaming command MUST be able to cancel a context on any keypress.
|
||||
- **FR-7.2** When stdin is not a terminal, it MUST NOT consume input or cancel
|
||||
spuriously; it MUST just wait on the context.
|
||||
- **FR-7.3** It MUST use cbreak mode (single keystroke, echo off, output
|
||||
post-processing intact) and MUST restore the terminal on return.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- **NFR-1** The library MUST be generic over the client type `C`; no `any`-typed
|
||||
clients, no reflection on the client.
|
||||
- **NFR-2** `Builder`, `App`, and `Emit` MUST be optional — a caller MUST be able
|
||||
to use struct-literal `Node`s with `Shell`/`Dispatch` directly.
|
||||
- **NFR-3** MUST build for `linux` and `openbsd` (SHOULD also for other BSDs and
|
||||
macOS). The OpenBSD `readline` termios workaround MUST be transparent and a
|
||||
no-op elsewhere.
|
||||
- **NFR-4** Core dependencies MUST be only `chzyer/readline` and `golang.org/x/sys`
|
||||
— no gRPC, protobuf, or web dependency.
|
||||
- **NFR-5** Releases MUST be semver tags; the import path is stable across
|
||||
`v0`/`v1`; a breaking `v2` MUST use the `/v2` suffix.
|
||||
- **NFR-6** Requires Go 1.25+ (generics with methods on generic types).
|
||||
- **NFR-7** `make check` (gofmt, vet, golangci-lint, tests) MUST pass before a
|
||||
commit; new behavior SHOULD ship with a server-free test.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
argv ──▶ App.Run ──┬─ no args ─▶ Shell.Run ─▶ readline loop ─▶ Dispatch ─▶ Node.Run
|
||||
│ ▲ ▲
|
||||
│ Completer ─┘ └─ '?' listener
|
||||
└─ args ─────────▶ Dispatch ───────────────────────────▶ Node.Run
|
||||
```
|
||||
|
||||
Both modes share everything below `Dispatch`. Completion and `?` read the same
|
||||
tree the dispatcher walks (FR-1.1) — that is what keeps them from drifting.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Quoted arguments.** Tokenizing is `strings.Fields`, so no argument can
|
||||
contain a space; a shell-style splitter would be needed for quoted values.
|
||||
- **Output sink.** `Emit` writes to `os.Stdout`; an injectable `io.Writer` would
|
||||
make ported commands' output unit-testable. Not needed yet.
|
||||
- **JSON errors.** One-shot errors print as text on stderr even under `-json`;
|
||||
`{"error": "..."}` may be worth it once a consumer needs it.
|
||||
+77
-63
@@ -6,13 +6,13 @@
|
||||
// It builds a tiny "server inventory" CLI over an in-memory client — no gRPC,
|
||||
// no external services. It shows the building blocks of the package:
|
||||
//
|
||||
// - a declarative command tree (buildTree);
|
||||
// - the tree built with cli.For/Builder (no repeated [inventory] type param);
|
||||
// - dynamic slot resolvers, including a context-dependent one (dynServices
|
||||
// lists the services of the <server> captured earlier on the path);
|
||||
// - command functions that hand one result to cli.Emit, which renders it as
|
||||
// colorized text or, under -json, as JSON — the command supplies both;
|
||||
// - the color helpers: cli.KV paints the "key=" of a key=value pair blue and
|
||||
// cli.Paint colors status words, both honoring the -color flag.
|
||||
// - cli.App, which wires flags (-color/-json/-version), the banner, and the
|
||||
// one-shot-vs-interactive split into a single Main().
|
||||
//
|
||||
// Run it interactively (TAB to complete, '?' for help):
|
||||
//
|
||||
@@ -21,22 +21,21 @@
|
||||
// inv> show server web1 service ? # lists web1's services
|
||||
// inv> colors # the ANSI palette
|
||||
//
|
||||
// Or one-shot — text or JSON:
|
||||
// Or one-shot — text or JSON (one-shot is script-safe: color off unless -color):
|
||||
//
|
||||
// go run ./example show server web1
|
||||
// go run ./example -json show server web1
|
||||
// go run ./example -color=false show server web1
|
||||
// go run ./example -color show server web1
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
cli "git.ipng.ch/ipng/golang-cli"
|
||||
"git.ipng.ch/ipng/golang-cli/keypress"
|
||||
)
|
||||
|
||||
// inventory is the "client" C that the tree is generic over. In a real app this
|
||||
@@ -156,67 +155,82 @@ func runColors(context.Context, inventory, []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runWatch streams a few simulated events, stopping early if a key is pressed.
|
||||
// In a real CLI this would be a server-side stream; here it is a bounded loop so
|
||||
// the demo never hangs when stdin is not a terminal. Press any key (in a TTY) to
|
||||
// stop it before it finishes.
|
||||
func runWatch(ctx context.Context, _ inventory, _ []string) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go keypress.WaitForKey(ctx, cancel)
|
||||
|
||||
for i := 1; i <= 5; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil // key pressed
|
||||
default:
|
||||
}
|
||||
if err := cli.Emit(cli.KV("event", fmt.Sprintf("%d", i)), map[string]any{"event": i}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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: dispatch, help,
|
||||
// and tab-completion are all derived from it.
|
||||
// buildTree is the single source of truth for the command set, built with the
|
||||
// cli.Builder so no node repeats the [inventory] type parameter.
|
||||
func buildTree() *cli.Node[inventory] {
|
||||
// show server [<name> [service [<svc>]]]
|
||||
serviceName := &cli.Node[inventory]{Word: "<svc>", Help: "show one service", Dynamic: dynServices, Run: runShowServerService}
|
||||
service := &cli.Node[inventory]{Word: "service", Help: "list a server's services", Run: runShowServerServices, Children: []*cli.Node[inventory]{serviceName}}
|
||||
serverName := &cli.Node[inventory]{Word: "<name>", Help: "show one server", Dynamic: dynServers, Run: runShowServer, Children: []*cli.Node[inventory]{service}}
|
||||
server := &cli.Node[inventory]{Word: "server", Help: "list servers (add <name> for one)", Run: runShowServers, Children: []*cli.Node[inventory]{serverName}}
|
||||
show := &cli.Node[inventory]{Word: "show", Help: "show inventory state", Children: []*cli.Node[inventory]{server}}
|
||||
|
||||
// ping <server>
|
||||
ping := &cli.Node[inventory]{Word: "ping", Help: "ping a server", Children: []*cli.Node[inventory]{
|
||||
{Word: "<name>", Help: "ping this server", Dynamic: dynServers, Run: runPing},
|
||||
}}
|
||||
|
||||
return &cli.Node[inventory]{Children: []*cli.Node[inventory]{
|
||||
show,
|
||||
ping,
|
||||
{Word: "colors", Help: "show the ANSI color palette", Run: runColors},
|
||||
{Word: "quit", Help: "exit the shell", Run: runQuit},
|
||||
{Word: "exit", Help: "exit the shell", Run: runQuit},
|
||||
}}
|
||||
b := cli.For[inventory]()
|
||||
return b.Root(
|
||||
b.Dir("show", "show inventory state",
|
||||
b.Cmd("server", "list servers (add <name> for one)", runShowServers,
|
||||
b.Slot("<name>", "show one server", dynServers, runShowServer,
|
||||
b.Cmd("service", "list a server's services", runShowServerServices,
|
||||
b.Slot("<svc>", "show one service", dynServices, runShowServerService))))),
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
func main() {
|
||||
color := flag.Bool("color", true, "colorize output: blue key= labels and painted status words")
|
||||
jsonOut := flag.Bool("json", false, "emit JSON instead of text (one-shot mode)")
|
||||
flag.Parse()
|
||||
|
||||
if *jsonOut {
|
||||
cli.SetFormat(cli.FormatJSON)
|
||||
}
|
||||
// JSON output is machine-facing, so suppress ANSI escapes in that mode.
|
||||
cli.SetColor(*color && !*jsonOut)
|
||||
|
||||
inv := newInventory()
|
||||
root := buildTree()
|
||||
ctx := context.Background()
|
||||
|
||||
// With arguments: run one command and exit (script-friendly).
|
||||
if args := flag.Args(); len(args) > 0 {
|
||||
if err := cli.Dispatch(ctx, root, inv, cli.SplitTokens(strings.Join(args, " "))); err != nil {
|
||||
fmt.Fprintln(os.Stderr, cli.Paint(err.Error(), cli.Red))
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No arguments: interactive REPL with completion and '?'-help. Errors are
|
||||
// painted red via FormatError.
|
||||
fmt.Println("golang-cli example — try: show server, ping db1, colors, '?' for help, TAB to complete")
|
||||
shell := &cli.Shell[inventory]{
|
||||
Root: root,
|
||||
Client: inv,
|
||||
Prompt: "inv> ",
|
||||
(&cli.App[inventory]{
|
||||
Name: "example",
|
||||
Version: "1.3.0",
|
||||
Prompt: "inv> ",
|
||||
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",
|
||||
// Local CLI: no -server flag. Connect just hands over the in-memory data,
|
||||
// proving App is transport-agnostic (it never dials anything itself).
|
||||
Connect: func(context.Context, string) (inventory, func(), error) {
|
||||
return newInventory(), nil, nil
|
||||
},
|
||||
FormatError: func(err error) string { return cli.Paint(err.Error(), cli.Red) },
|
||||
}
|
||||
if err := shell.Run(ctx); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}).Main()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
cli "git.ipng.ch/ipng/golang-cli"
|
||||
)
|
||||
|
||||
// TestTreeValid keeps the example's command tree free of the authoring faults
|
||||
// Validate checks for. It doubles as the recommended way to use Validate: from
|
||||
// a unit test, so a malformed tree fails the build rather than misdispatching
|
||||
// at runtime.
|
||||
func TestTreeValid(t *testing.T) {
|
||||
if err := cli.Validate(buildTree()); err != nil {
|
||||
t.Fatalf("buildTree() has authoring faults:\n%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//go:build openbsd || freebsd || netbsd || dragonfly || darwin
|
||||
|
||||
package keypress
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// cbreak puts the terminal into cbreak mode (no canonical input/echo) so a
|
||||
// single keystroke is available, leaving output post-processing intact. The
|
||||
// BSDs use the TIOCGETA/TIOCSETA termios ioctls (Linux uses TCGETS/TCSETS). It
|
||||
// returns the previous termios for restore, or an error if fd is not a tty.
|
||||
func cbreak(fd int) (*unix.Termios, error) {
|
||||
old, err := unix.IoctlGetTermios(fd, unix.TIOCGETA)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := *old
|
||||
t.Lflag &^= unix.ICANON | unix.ECHO | unix.ECHOE | unix.ECHOK | unix.ECHONL
|
||||
t.Cc[unix.VMIN] = 1
|
||||
t.Cc[unix.VTIME] = 0
|
||||
if err := unix.IoctlSetTermios(fd, unix.TIOCSETA, &t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return old, nil
|
||||
}
|
||||
|
||||
// restore reverts the terminal to the settings captured by cbreak.
|
||||
func restore(fd int, old *unix.Termios) error {
|
||||
return unix.IoctlSetTermios(fd, unix.TIOCSETAF, old)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//go:build linux
|
||||
|
||||
package keypress
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// cbreak puts the terminal into cbreak mode (no canonical input/echo) so a
|
||||
// single keystroke is available, leaving output post-processing intact. Linux
|
||||
// uses the TCGETS/TCSETS termios ioctls (the BSDs use TIOCGETA/TIOCSETA). It
|
||||
// returns the previous termios for restore, or an error if fd is not a tty.
|
||||
func cbreak(fd int) (*unix.Termios, error) {
|
||||
old, err := unix.IoctlGetTermios(fd, unix.TCGETS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := *old
|
||||
t.Lflag &^= unix.ICANON | unix.ECHO | unix.ECHOE | unix.ECHOK | unix.ECHONL
|
||||
t.Cc[unix.VMIN] = 1
|
||||
t.Cc[unix.VTIME] = 0
|
||||
if err := unix.IoctlSetTermios(fd, unix.TCSETS, &t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return old, nil
|
||||
}
|
||||
|
||||
// restore reverts the terminal to the settings captured by cbreak.
|
||||
func restore(fd int, old *unix.Termios) error {
|
||||
return unix.IoctlSetTermios(fd, unix.TCSETSF, old)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//go:build !linux && !openbsd && !freebsd && !netbsd && !dragonfly && !darwin
|
||||
|
||||
package keypress
|
||||
|
||||
import "errors"
|
||||
|
||||
// termiosState is a placeholder so cbreak/restore have a consistent signature on
|
||||
// platforms without a supported termios path. On those platforms cbreak always
|
||||
// fails, so WaitForKey degrades to waiting on the context (it never reads stdin).
|
||||
type termiosState struct{}
|
||||
|
||||
func cbreak(int) (*termiosState, error) {
|
||||
return nil, errors.New("keypress: cbreak is unsupported on this platform")
|
||||
}
|
||||
|
||||
func restore(int, *termiosState) error { return nil }
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package keypress lets a streaming command stop on any keystroke. A
|
||||
// watch-style command runs WaitForKey in a goroutine with a cancellable
|
||||
// context; the first key pressed cancels the context, tearing down the stream.
|
||||
//
|
||||
// ctx, cancel := context.WithCancel(ctx)
|
||||
// defer cancel()
|
||||
// go keypress.WaitForKey(ctx, cancel)
|
||||
// for { ev, err := stream.Recv(); ... } // returns when ctx is cancelled
|
||||
//
|
||||
// When standard input is not a terminal (piped, redirected, backgrounded) there
|
||||
// is no keystroke to wait for, so WaitForKey just blocks until ctx ends and
|
||||
// never cancels on its own.
|
||||
package keypress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
)
|
||||
|
||||
// WaitForKey blocks until a key is pressed on standard input or ctx is done,
|
||||
// whichever comes first; on a keypress it calls cancel. If stdin is not a
|
||||
// terminal it waits on ctx only (it neither reads input nor calls cancel). The
|
||||
// terminal is placed in cbreak mode for the duration and restored on return.
|
||||
//
|
||||
// Run it in its own goroutine. If ctx ends first, WaitForKey returns and
|
||||
// restores the terminal; a read already blocked on stdin is left to be reaped
|
||||
// when the process exits (there is no portable way to interrupt it).
|
||||
func WaitForKey(ctx context.Context, cancel context.CancelFunc) {
|
||||
fd := int(os.Stdin.Fd())
|
||||
old, err := cbreak(fd)
|
||||
if err != nil {
|
||||
<-ctx.Done() // stdin is not a tty: nothing to read, just honor ctx
|
||||
return
|
||||
}
|
||||
defer func() { _ = restore(fd, old) }()
|
||||
|
||||
readDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readDone)
|
||||
buf := make([]byte, 1)
|
||||
_, _ = os.Stdin.Read(buf)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-readDone:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package keypress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestWaitForKeyReturnsOnContextCancel checks WaitForKey unblocks when the
|
||||
// context ends, both on the non-tty path (cbreak fails → wait on ctx) and the
|
||||
// tty path (select returns on ctx.Done). Under `go test` stdin is normally not
|
||||
// a terminal, exercising the former.
|
||||
func TestWaitForKeyReturnsOnContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // already done
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
WaitForKey(ctx, func() {})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("WaitForKey did not return after the context was cancelled")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Validate reports common authoring faults in a command tree. Walk tolerates
|
||||
// all of them, so Validate is optional — but calling it once at startup, or
|
||||
// from a unit test, catches mistakes that otherwise cause silent misdispatch.
|
||||
// It returns all problems found, joined, or nil if the tree is clean.
|
||||
//
|
||||
// It reports:
|
||||
// - a node with more than one slot child (Dynamic != nil): only the first is
|
||||
// ever reachable (FR-1.7);
|
||||
// - a non-root node with an empty Word: it can never be matched or displayed;
|
||||
// - duplicate fixed (non-slot) words among a node's children: the second is
|
||||
// shadowed by the first;
|
||||
// - a node that neither runs nor has children: a dead end that can only print
|
||||
// "<no completions>".
|
||||
//
|
||||
// Circular slots (a slot that is its own descendant, e.g. a watch-options node)
|
||||
// are traversed once, not looped.
|
||||
func Validate[C any](root *Node[C]) error {
|
||||
var errs []error
|
||||
validateNode(root, "", true, make(map[*Node[C]]bool), &errs)
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func validateNode[C any](n *Node[C], path string, isRoot bool, seen map[*Node[C]]bool, errs *[]error) {
|
||||
if seen[n] {
|
||||
return
|
||||
}
|
||||
seen[n] = true
|
||||
|
||||
where := path
|
||||
if where == "" {
|
||||
where = "<root>"
|
||||
}
|
||||
|
||||
if !isRoot && n.Word == "" {
|
||||
*errs = append(*errs, fmt.Errorf("%s: node has an empty Word", where))
|
||||
}
|
||||
if !isRoot && n.Run == nil && len(n.Children) == 0 {
|
||||
*errs = append(*errs, fmt.Errorf("%s: node neither runs nor has children (dead end)", where))
|
||||
}
|
||||
|
||||
slots := 0
|
||||
fixedWords := make(map[string]bool)
|
||||
for _, c := range n.Children {
|
||||
if c.Dynamic != nil {
|
||||
slots++
|
||||
continue
|
||||
}
|
||||
if c.Word != "" {
|
||||
if fixedWords[c.Word] {
|
||||
*errs = append(*errs, fmt.Errorf("%s: duplicate child word %q", where, c.Word))
|
||||
}
|
||||
fixedWords[c.Word] = true
|
||||
}
|
||||
}
|
||||
if slots > 1 {
|
||||
*errs = append(*errs, fmt.Errorf("%s: %d slot children, but only the first is reachable", where, slots))
|
||||
}
|
||||
|
||||
for _, c := range n.Children {
|
||||
cp := c.Word
|
||||
if path != "" {
|
||||
cp = path + " " + c.Word
|
||||
}
|
||||
validateNode(c, strings.TrimSpace(cp), false, seen, errs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidateClean checks that a well-formed tree (the builder fixture, which
|
||||
// includes a circular slot) validates without error.
|
||||
func TestValidateClean(t *testing.T) {
|
||||
if err := Validate(buildFixtureB()); err != nil {
|
||||
t.Errorf("Validate(clean tree) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFaults checks each fault class is reported.
|
||||
func TestValidateFaults(t *testing.T) {
|
||||
dyn := func(context.Context, fakeClient, []string) []string { return nil }
|
||||
|
||||
root := &Node[fakeClient]{Children: []*Node[fakeClient]{
|
||||
// two slot children under one node
|
||||
{Word: "twoslots", Help: "", Children: []*Node[fakeClient]{
|
||||
{Word: "<a>", Dynamic: dyn, Run: runNoop},
|
||||
{Word: "<b>", Dynamic: dyn, Run: runNoop},
|
||||
}},
|
||||
// duplicate fixed words
|
||||
{Word: "dup", Children: []*Node[fakeClient]{
|
||||
{Word: "x", Run: runNoop},
|
||||
{Word: "x", Run: runNoop},
|
||||
}},
|
||||
// dead end: neither runs nor has children
|
||||
{Word: "deadend"},
|
||||
// empty word
|
||||
{Word: "", Run: runNoop},
|
||||
}}
|
||||
|
||||
err := Validate(root)
|
||||
if err == nil {
|
||||
t.Fatal("Validate(broken tree) = nil, want errors")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"only the first is reachable",
|
||||
`duplicate child word "x"`,
|
||||
"dead end",
|
||||
"empty Word",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("Validate missing %q in:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user