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>
131 lines
4.2 KiB
Go
131 lines
4.2 KiB
Go
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/chzyer/readline"
|
|
)
|
|
|
|
// defaultCompleteTimeout bounds a single Dynamic lookup triggered by TAB or '?'.
|
|
const defaultCompleteTimeout = 1 * time.Second
|
|
|
|
// SplitTokens splits a command line into whitespace-separated tokens.
|
|
func SplitTokens(s string) []string { return strings.Fields(s) }
|
|
|
|
// splitForCompletion separates the confirmed prefix tokens from the partial
|
|
// token being completed, based on whether the cursor sits after a space.
|
|
func splitForCompletion(before string) (prefix []string, partial string) {
|
|
tokens := SplitTokens(before)
|
|
if len(tokens) == 0 || (len(before) > 0 && before[len(before)-1] == ' ') {
|
|
return tokens, ""
|
|
}
|
|
return tokens[:len(tokens)-1], tokens[len(tokens)-1]
|
|
}
|
|
|
|
// completer implements readline.AutoCompleter over the command tree.
|
|
type completer[C any] struct {
|
|
root *Node[C]
|
|
client C
|
|
timeout time.Duration
|
|
}
|
|
|
|
// Do returns the completion suffixes for the token at the cursor.
|
|
func (co *completer[C]) Do(line []rune, pos int) (newLine [][]rune, length int) {
|
|
prefix, partial := splitForCompletion(string(line[:pos]))
|
|
ctx, cancel := context.WithTimeout(context.Background(), co.timeout)
|
|
defer cancel()
|
|
candidates := Candidates(ctx, co.client, co.root, prefix, partial)
|
|
var suffixes [][]rune
|
|
for _, c := range candidates {
|
|
suffixes = append(suffixes, []rune(c.Word[len(partial):]+" "))
|
|
}
|
|
return suffixes, len([]rune(partial))
|
|
}
|
|
|
|
// questionListener intercepts '?' and prints inline help at the cursor.
|
|
type questionListener[C any] struct {
|
|
root *Node[C]
|
|
client C
|
|
rl *readline.Instance
|
|
timeout time.Duration
|
|
}
|
|
|
|
func (ql *questionListener[C]) OnChange(line []rune, pos int, key rune) ([]rune, int, bool) {
|
|
if key != '?' {
|
|
return line, pos, false
|
|
}
|
|
before := strings.TrimSuffix(string(line[:pos]), "?")
|
|
tokens := SplitTokens(before)
|
|
prefix, partial := splitForCompletion(before)
|
|
|
|
// Walk the confirmed prefix, then try to advance one more step using the
|
|
// partial token (via prefix-match or slot fallback), so "sh?" expands "sh"
|
|
// to "show" and shows show's subtree.
|
|
node, args, remaining := Walk(ql.root, prefix)
|
|
displayPrefix := strings.Join(prefix, " ")
|
|
var unknownMsg string
|
|
if len(remaining) > 0 {
|
|
consumed := prefix[:len(prefix)-len(remaining)]
|
|
unknownMsg = unknownCommandError(consumed, remaining[0]).Error()
|
|
displayPrefix = strings.Join(consumed, " ")
|
|
} else if partial != "" {
|
|
if next := matchFixedChild(node.Children, partial); next != nil {
|
|
node = next
|
|
displayPrefix = strings.Join(tokens, " ")
|
|
} else if slot := findSlotChild(node.Children); slot != nil {
|
|
node = slot
|
|
displayPrefix = strings.Join(tokens, " ")
|
|
}
|
|
}
|
|
|
|
lines := expandPaths(node, displayPrefix, make(map[*Node[C]]bool))
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), ql.timeout)
|
|
defer cancel()
|
|
var dynValues []string
|
|
var dynWord string
|
|
if slot := findSlotChild(node.Children); slot != nil {
|
|
dynValues = slot.Dynamic(ctx, ql.client, args)
|
|
dynWord = slot.Word
|
|
}
|
|
|
|
maxLen := 0
|
|
for _, l := range lines {
|
|
if len(l.Path) > maxLen {
|
|
maxLen = len(l.Path)
|
|
}
|
|
}
|
|
// readline's wrapWriter erases the current input row before each Write and
|
|
// redraws the prompt after, so echo the full "prompt + line" ourselves as
|
|
// the first write: it lands on the just-cleaned row, and the help below it
|
|
// each redraws a fresh prompt.
|
|
_, _ = fmt.Fprintf(ql.rl.Stderr(), "%s%s\r\n", ql.rl.Config.Prompt, string(line))
|
|
if unknownMsg != "" {
|
|
_, _ = fmt.Fprintf(ql.rl.Stderr(), " %s\r\n", unknownMsg)
|
|
}
|
|
if len(lines) == 0 {
|
|
_, _ = fmt.Fprintf(ql.rl.Stderr(), " <no completions>\r\n")
|
|
} else {
|
|
for _, l := range lines {
|
|
if l.Help != "" {
|
|
_, _ = fmt.Fprintf(ql.rl.Stderr(), "%-*s %s\r\n", maxLen+2, l.Path, l.Help)
|
|
} else {
|
|
_, _ = fmt.Fprintf(ql.rl.Stderr(), "%s\r\n", l.Path)
|
|
}
|
|
}
|
|
if len(dynValues) > 0 {
|
|
_, _ = fmt.Fprintf(ql.rl.Stderr(), " %s: %s\r\n", dynWord, strings.Join(dynValues, " "))
|
|
}
|
|
}
|
|
|
|
// Remove the '?' from the line and step the cursor back one position.
|
|
newLine := append(append([]rune{}, line[:pos-1]...), line[pos:]...)
|
|
return newLine, pos - 1, true
|
|
}
|