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'.
155 lines
4.5 KiB
Go
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)
|
|
}
|