d63ffd6a3a
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>
169 lines
5.5 KiB
Go
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
|
|
}
|