Files
vpp-maglev/cmd/maglevc/complete.go
Pim van Pelt 3bd30b69f4 Refactor CLI: birdc-style help, collapsed nouns, ReloadConfig, bug fixes
maglevc
- Rewrite '?' handler (birdc-style): show full command paths from current
  position to every leaf, right-aligned help column, dynamic slot values
  displayed as an indented block when cursor is at a slot position.
- Collapse show frontends/frontend, backends/backend, healthchecks/healthcheck
  into single plural-noun nodes with an optional <name> slot. Allows
  'sh ba' (list all) and 'sh ba nginx0' (show one) without ambiguity.
- Add 'config reload' command.
- Fix tabwriter ANSI alignment: continuation lines in transition output
  now carry the same label() byte overhead as the header line.
- Fix broken Walk for 'set frontend' command: setFrontendPoolName and
  setWeightValue were fixed-word nodes that couldn't capture user input;
  mark them as slot nodes with dynNone.
- Add tree_test.go covering expandPaths, cycle detection, prefix matching,
  and the full weight-command walk.

gRPC / proto
- Add ReloadConfig RPC: checks config then applies it to the running
  checker, returning ok/parse_error/semantic_error/reload_error.
- Add logging to CheckConfig (config-check-start/config-check-done at
  INFO level).

maglevd
- SIGHUP handler now calls maglevServer.TriggerReload(), sharing the
  same code path as the gRPC ReloadConfig RPC.

docs
- Collapse show command documentation to use [<name>] optional syntax.
- Remove developer-facing 'Command tree and parser' section.
- Document 'config reload'.
2026-04-11 18:20:43 +02:00

155 lines
4.5 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, _ := Walk(ql.root, prefix)
displayPrefix := strings.Join(prefix, " ")
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.
fmt.Fprintf(ql.rl.Stderr(), "\r\n")
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)
}