Files
pim d63ffd6a3a feat: golang-cli v1.0.0 — generic command-tree CLI library
Reusable, generics-based CLI extracted from vpp-evpn's cmd/evpnc:
a declarative command tree (Node[C]) from which dispatch, '?'-help and
TAB-completion are derived, an interactive Shell[C], dynamic slot
resolvers (context-dependent via captured args), text-or-JSON output
(Emit), and color helpers (Paint/Label/KV). Builds on Linux and OpenBSD
(readline termios override). Includes a self-contained example and a
design proposal under docs/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:48:48 +02:00

169 lines
5.5 KiB
Go

// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
// Package cli is a command-line interface library built around a declarative
// command tree from which dispatch, "?"-help, and tab-completion are all
// derived, with optional colorized or JSON output. It is
// generic over a client type C (typically a gRPC client) that is threaded,
// unchanged, into every Dynamic and Run function so command code receives the
// concrete client with no type assertions.
//
// The three building blocks:
//
// - the parse tree — Node, plus Walk to resolve a token list to a node;
// - dynamic nodes — Node.Dynamic, a slot that yields live completion
// candidates given the args captured by earlier slots on the path;
// - command functions — Node.Run, dispatched by Dispatch / Shell.Run.
//
// Build the tree once and hand it to Shell (interactive REPL) or Dispatch
// (one-shot). See the package README for a worked example.
package cli
import (
"context"
"strings"
)
// Node is one word in the command tree. Leaf nodes have a Run function. Slot
// nodes have Dynamic set (and a placeholder Word like "<id>"); they accept any
// single token as an argument and may have further Children. Dynamic returns
// the live completion candidates for the slot, given the args captured by slot
// nodes earlier on the path (so e.g. "<evpn>" can list the EVPN instances of
// the "<id>" already typed).
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
}
// Walk descends the tree following tokens. At each step it tries fixed
// children first (exact then unique prefix), then falls back to a slot child.
// Tokens consumed by slot children are collected as args. Returns the deepest
// node reached, the args collected, and any tokens that could not be matched.
// A non-empty remaining slice means the input held a token that neither matched
// a fixed child nor fed a slot — callers should treat that as "unknown command"
// rather than silently anchoring help at the root. Walk never invokes Dynamic,
// so it needs no client.
func Walk[C any](root *Node[C], tokens []string) (node *Node[C], args, remaining []string) {
node = root
for len(tokens) > 0 {
tok := tokens[0]
if next := matchFixedChild(node.Children, tok); next != nil {
node = next
tokens = tokens[1:]
continue
}
if slot := findSlotChild(node.Children); slot != nil {
args = append(args, tok)
tokens = tokens[1:]
node = slot
continue
}
break // dead end; unconsumed tail returned to caller
}
return node, args, tokens
}
// matchFixedChild returns the non-slot child matching tok by exact, then unique
// prefix.
func matchFixedChild[C any](children []*Node[C], tok string) *Node[C] {
var fixed []*Node[C]
for _, c := range children {
if c.Dynamic == nil {
fixed = append(fixed, c)
}
}
for _, c := range fixed {
if c.Word == tok {
return c
}
}
var matches []*Node[C]
for _, c := range fixed {
if strings.HasPrefix(c.Word, tok) {
matches = append(matches, c)
}
}
if len(matches) == 1 {
return matches[0]
}
return nil
}
// findSlotChild returns the first slot child (Dynamic != nil).
func findSlotChild[C any](children []*Node[C]) *Node[C] {
for _, c := range children {
if c.Dynamic != nil {
return c
}
}
return nil
}
// HelpLine is a (path, help) pair produced by ExpandPaths for "?"-help and the
// not-runnable-node help listing.
type HelpLine struct {
Path string
Help string
}
// ExpandPaths returns a HelpLine for every runnable node reachable from node,
// each path prefixed with prefix (e.g. "show instance"). It guards against
// self-referencing slot nodes (such as a circular watch-options slot).
func ExpandPaths[C any](node *Node[C], prefix string) []HelpLine {
return expandPaths(node, prefix, make(map[*Node[C]]bool))
}
func expandPaths[C any](node *Node[C], prefix string, visited map[*Node[C]]bool) []HelpLine {
if visited[node] {
return nil
}
visited[node] = true
var lines []HelpLine
if node.Run != nil {
lines = append(lines, HelpLine{Path: prefix, Help: node.Help})
}
for _, child := range node.Children {
cp := child.Word
if prefix != "" {
cp = prefix + " " + child.Word
}
lines = append(lines, expandPaths(child, cp, visited)...)
}
return lines
}
// Candidates returns the completable children at the current position given the
// confirmed tokens and the partial token being completed. Fixed children are
// filtered by partial; if a slot child is present its Dynamic values (resolved
// with the args captured while walking the confirmed tokens) are appended.
// Returns nil if a confirmed token was unknown, so completion offers nothing
// rather than misleading the user past a broken left context.
func Candidates[C any](ctx context.Context, client C, root *Node[C], tokens []string, partial string) []*Node[C] {
node, args, remaining := Walk(root, tokens)
if len(remaining) > 0 {
return nil
}
matches := filterFixedChildren(node.Children, partial)
if slot := findSlotChild(node.Children); slot != nil {
for _, v := range slot.Dynamic(ctx, client, args) {
if strings.HasPrefix(v, partial) {
matches = append(matches, &Node[C]{Word: v, Help: slot.Help})
}
}
}
return matches
}
func filterFixedChildren[C any](children []*Node[C], prefix string) []*Node[C] {
var out []*Node[C]
for _, c := range children {
if c.Dynamic == nil && strings.HasPrefix(c.Word, prefix) {
out = append(out, c)
}
}
return out
}