Files
vpp-maglev/cmd/client/complete.go
Pim van Pelt bc6ccaa844 v1.0.0 — first release
Bump VERSION to 1.0.0 and cut the first tagged release of vpp-maglev.

Also in this commit:

- maglevc: MAGLEV_SERVER env var as an alternative to the --server
  flag, matching the MAGLEV_CONFIG / MAGLEV_GRPC_ADDR convention on
  the other binaries. The flag takes precedence when both are set.
- Rename cmd/maglevd -> cmd/server and cmd/maglevc -> cmd/client so
  the source directory names are decoupled from binary names (the
  frontend and tester commands already followed this convention).
  Build outputs and the Debian packages are unchanged.
2026-04-15 15:29:31 +02:00

184 lines
6.0 KiB
Go

// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
package main
import (
"context"
"fmt"
"strings"
"time"
"github.com/chzyer/readline"
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
)
const completeTimeout = 1 * time.Second
// Completer implements readline.AutoCompleter for the command tree.
type Completer struct {
root *Node
client grpcapi.MaglevClient
}
// Do implements readline.AutoCompleter.
// line is the full current line; pos is the cursor position.
// Returns (newLine [][]rune, length int) where length is how many rune bytes
// before pos should be replaced by each candidate in newLine.
func (co *Completer) Do(line []rune, pos int) (newLine [][]rune, length int) {
before := string(line[:pos])
tokens := splitTokens(before)
// Determine the partial token being completed.
var partial string
var prefix []string
if len(tokens) == 0 || (len(before) > 0 && before[len(before)-1] == ' ') {
// Cursor is after a space — completing a new token.
prefix = tokens
partial = ""
} else {
// Cursor is within the last token.
prefix = tokens[:len(tokens)-1]
partial = tokens[len(tokens)-1]
}
ctx, cancel := context.WithTimeout(context.Background(), completeTimeout)
defer cancel()
candidates := Candidates(co.root, prefix, partial, ctx, co.client)
var suffixes [][]rune
for _, c := range candidates {
suffix := c.Word[len(partial):]
suffixes = append(suffixes, []rune(suffix+" "))
}
return suffixes, len([]rune(partial))
}
// questionListener intercepts the '?' key and prints inline help.
type questionListener struct {
root *Node
client grpcapi.MaglevClient
rl *readline.Instance
}
func (ql *questionListener) OnChange(line []rune, pos int, key rune) (newLine []rune, newPos int, ok bool) {
if key != '?' {
return line, pos, false
}
// Strip the '?' that was just appended to line[:pos].
before := string(line[:pos])
if len(before) > 0 && before[len(before)-1] == '?' {
before = before[:len(before)-1]
}
tokens := splitTokens(before)
// Split into confirmed prefix tokens and the partial token being typed.
var prefix []string
var partial string
if len(before) == 0 || before[len(before)-1] == ' ' {
prefix = tokens
partial = ""
} else if len(tokens) > 0 {
prefix = tokens[:len(tokens)-1]
partial = tokens[len(tokens)-1]
}
// Walk the confirmed prefix to the current node, then try to advance one
// more step using the partial token (via prefix-match or slot fallback).
// This mirrors birdc: "sh?" expands "sh" to "show" and shows show's subtree.
node, _, remaining := Walk(ql.root, prefix)
displayPrefix := strings.Join(prefix, " ")
var unknownMsg string
if len(remaining) > 0 {
// One of the confirmed prefix tokens was unknown. Show an
// "unknown" banner, then list what's available at the deepest
// node we *did* reach so the operator can see what they could
// have typed instead. The partial at the cursor is irrelevant
// once the left context is already broken — no downstream
// branch reads it after we enter this branch, so we don't
// bother clearing it.
consumed := prefix[:len(prefix)-len(remaining)]
bad := remaining[0]
if len(consumed) == 0 {
unknownMsg = fmt.Sprintf("unknown command: %s", bad)
} else {
unknownMsg = fmt.Sprintf("unknown subcommand %q after %q", bad, strings.Join(consumed, " "))
}
displayPrefix = strings.Join(consumed, " ")
} else if partial != "" {
if next := matchFixedChild(node.Children, partial); next != nil {
// Partial uniquely matched a fixed child — descend into it.
node = next
displayPrefix = strings.Join(tokens, " ")
} else if slot := findSlotChild(node.Children); slot != nil {
// Partial is filling a slot node.
node = slot
displayPrefix = strings.Join(tokens, " ")
}
// If partial matched nothing (ambiguous or dead end), stay at the
// current node and show its subcommands with the confirmed prefix.
}
// Expand all leaf paths reachable from the current node.
lines := expandPaths(node, displayPrefix, make(map[*Node]bool))
// If the cursor is at a position where the next input is a dynamic slot,
// fetch live values now and show them below the syntax lines.
ctx, cancel := context.WithTimeout(context.Background(), completeTimeout)
defer cancel()
var dynValues []string
var dynWord string
if slot := findSlotChild(node.Children); slot != nil && slot.Dynamic != nil {
dynValues = slot.Dynamic(ctx, ql.client)
dynWord = slot.Word
}
// Right-align the help column at the width of the longest path + 2.
maxLen := 0
for _, l := range lines {
if len(l.path) > maxLen {
maxLen = len(l.path)
}
}
// Emit output. Raw terminal mode requires \r\n.
//
// readline's wrapWriter wraps every Write in a clean-write-print
// cycle: it erases the current input line, runs our closure, and
// redraws the prompt+buffer afterwards. That means starting the
// output with a bare "\r\n" leaves the original row blank, so the
// operator loses sight of what they typed. Instead we echo the
// full "maglev> show vpp lb ?" ourselves as the first write —
// that lands on the just-cleaned row, birdc-style, and the
// subsequent Fprintfs each redraw a fresh prompt below the help.
_, _ = 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 cursor back one position.
newLine = append(append([]rune{}, line[:pos-1]...), line[pos:]...)
return newLine, pos - 1, true
}
// splitTokens splits a string into whitespace-separated tokens.
func splitTokens(s string) []string {
return strings.Fields(s)
}