diff --git a/README.md b/README.md index 12eb3d3..aa4516b 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,35 @@ 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: diff --git a/docs/PROPOSAL.md b/docs/PROPOSAL.md deleted file mode 100644 index e60f751..0000000 --- a/docs/PROPOSAL.md +++ /dev/null @@ -1,260 +0,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 `` - can list the EVPNs *of the `` 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 - Makefile check (= fixstyle vet lint test), build, cross (linux + openbsd) - tree.go Node[C], Walk, matchFixedChild, findSlotChild, expandPaths, Candidates - tree_test.go table tests over a fixture tree (no client needed; Walk ignores Dynamic) - builder.go For[C] / Builder[C]: Root/Dir/Cmd/SlotDir/Slot (no repeated [C]) - complete.go completer[C], questionListener[C], SplitTokens, splitForCompletion - shell.go Shell[C] (config + Run), Dispatch, showHelpAt, unknownCommandError, ErrQuit - app.go App[C]: standard flags + banner + connect + one-shot/shell -> Main() - 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 (uses Builder + App) - 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. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..c8e0ec0 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,158 @@ + + +# golang-cli Design Document + +| | | +| --- | --- | +| **Status** | Describes shipped behavior as of `v1.2.0` | +| **Author** | Pim van Pelt `` | +| **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-5 `App` entry point** + +- **FR-5.1** `App` MUST register `-color`, `-json`, `-version`, and `-server` + (only when a server is configured). +- **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. diff --git a/example/main.go b/example/main.go index 6ce9cd0..8047e01 100644 --- a/example/main.go +++ b/example/main.go @@ -35,6 +35,7 @@ import ( "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 @@ -154,6 +155,28 @@ 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 +} + func runQuit(context.Context, inventory, []string) error { return cli.ErrQuit } // buildTree is the single source of truth for the command set, built with the @@ -168,6 +191,7 @@ func buildTree() *cli.Node[inventory] { b.Slot("", "show one service", dynServices, runShowServerService))))), 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("colors", "show the ANSI color palette", runColors), b.Cmd("quit", "exit the shell", runQuit), b.Cmd("exit", "exit the shell", runQuit), diff --git a/example/main_test.go b/example/main_test.go new file mode 100644 index 0000000..2ca2ce3 --- /dev/null +++ b/example/main_test.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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) + } +} diff --git a/keypress/cbreak_bsd.go b/keypress/cbreak_bsd.go new file mode 100644 index 0000000..4ef3a83 --- /dev/null +++ b/keypress/cbreak_bsd.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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) +} diff --git a/keypress/cbreak_linux.go b/keypress/cbreak_linux.go new file mode 100644 index 0000000..0e4201e --- /dev/null +++ b/keypress/cbreak_linux.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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) +} diff --git a/keypress/cbreak_other.go b/keypress/cbreak_other.go new file mode 100644 index 0000000..46fb8b2 --- /dev/null +++ b/keypress/cbreak_other.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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 } diff --git a/keypress/keypress.go b/keypress/keypress.go new file mode 100644 index 0000000..9bf248f --- /dev/null +++ b/keypress/keypress.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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(): + } +} diff --git a/keypress/keypress_test.go b/keypress/keypress_test.go new file mode 100644 index 0000000..2183f4a --- /dev/null +++ b/keypress/keypress_test.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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") + } +} diff --git a/validate.go b/validate.go new file mode 100644 index 0000000..8bd0b79 --- /dev/null +++ b/validate.go @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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 +// "". +// +// 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 = "" + } + + 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) + } +} diff --git a/validate_test.go b/validate_test.go new file mode 100644 index 0000000..eb985f7 --- /dev/null +++ b/validate_test.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt +// 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: "", Dynamic: dyn, Run: runNoop}, + {Word: "", 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) + } + } +}