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>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user