// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt // 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 ""); 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. "" can list the EVPN instances of // the "" 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 }