Files
golang-cli/complete.go
T
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

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
}