First stab at maglevc
This commit is contained in:
307
cmd/maglevc/commands.go
Normal file
307
cmd/maglevc/commands.go
Normal file
@@ -0,0 +1,307 @@
|
||||
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"text/tabwriter"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
||||
)
|
||||
|
||||
const callTimeout = 10 * time.Second
|
||||
|
||||
// buildTree constructs the full command tree.
|
||||
func buildTree() *Node {
|
||||
root := &Node{Word: "", Help: ""}
|
||||
|
||||
show := &Node{Word: "show", Help: "show information"}
|
||||
set := &Node{Word: "set", Help: "modify configuration"}
|
||||
quit := &Node{Word: "quit", Help: "exit the shell", Run: runQuit}
|
||||
exit := &Node{Word: "exit", Help: "exit the shell", Run: runQuit}
|
||||
|
||||
// show frontends
|
||||
showFrontends := &Node{Word: "frontends", Help: "list all frontends", Run: runShowFrontends}
|
||||
// show frontend <name>
|
||||
showFrontendName := &Node{
|
||||
Word: "<name>",
|
||||
Help: "frontend name",
|
||||
Dynamic: dynFrontends,
|
||||
Run: runShowFrontend,
|
||||
}
|
||||
showFrontend := &Node{
|
||||
Word: "frontend",
|
||||
Help: "show a single frontend",
|
||||
Children: []*Node{showFrontendName},
|
||||
}
|
||||
|
||||
// show backends
|
||||
showBackends := &Node{Word: "backends", Help: "list all backends", Run: runShowBackends}
|
||||
// show backend <name>
|
||||
showBackendName := &Node{
|
||||
Word: "<name>",
|
||||
Help: "backend name",
|
||||
Dynamic: dynBackends,
|
||||
Run: runShowBackend,
|
||||
}
|
||||
showBackend := &Node{
|
||||
Word: "backend",
|
||||
Help: "show a single backend",
|
||||
Children: []*Node{showBackendName},
|
||||
}
|
||||
|
||||
// show healthchecks
|
||||
showHealthChecks := &Node{Word: "healthchecks", Help: "list all health checks", Run: runShowHealthChecks}
|
||||
// show healthcheck <name>
|
||||
showHealthCheckName := &Node{
|
||||
Word: "<name>",
|
||||
Help: "health check name",
|
||||
Dynamic: dynHealthChecks,
|
||||
Run: runShowHealthCheck,
|
||||
}
|
||||
showHealthCheck := &Node{
|
||||
Word: "healthcheck",
|
||||
Help: "show a single health check",
|
||||
Children: []*Node{showHealthCheckName},
|
||||
}
|
||||
|
||||
show.Children = []*Node{
|
||||
showFrontends, showFrontend,
|
||||
showBackends, showBackend,
|
||||
showHealthChecks, showHealthCheck,
|
||||
}
|
||||
|
||||
// set backend <name> pause|resume|disabled|enabled
|
||||
setPause := &Node{Word: "pause", Help: "pause health checking", Run: runPauseBackend}
|
||||
setResume := &Node{Word: "resume", Help: "resume health checking", Run: runResumeBackend}
|
||||
setDisabled := &Node{Word: "disabled", Help: "disable backend (not implemented)", Run: runNotImplemented}
|
||||
setEnabled := &Node{Word: "enabled", Help: "enable backend (not implemented)", Run: runNotImplemented}
|
||||
setBackendName := &Node{
|
||||
Word: "<name>",
|
||||
Help: "backend name",
|
||||
Dynamic: dynBackends,
|
||||
Children: []*Node{setPause, setResume, setDisabled, setEnabled},
|
||||
}
|
||||
setBackend := &Node{
|
||||
Word: "backend",
|
||||
Help: "modify a backend",
|
||||
Children: []*Node{setBackendName},
|
||||
}
|
||||
set.Children = []*Node{setBackend}
|
||||
|
||||
root.Children = []*Node{show, set, quit, exit}
|
||||
return root
|
||||
}
|
||||
|
||||
// ---- dynamic enumerators ---------------------------------------------------
|
||||
|
||||
func dynFrontends(ctx context.Context, client grpcapi.MaglevClient) []string {
|
||||
resp, err := client.ListFrontends(ctx, &grpcapi.ListFrontendsRequest{})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return resp.FrontendNames
|
||||
}
|
||||
|
||||
func dynBackends(ctx context.Context, client grpcapi.MaglevClient) []string {
|
||||
resp, err := client.ListBackends(ctx, &grpcapi.ListBackendsRequest{})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return resp.BackendNames
|
||||
}
|
||||
|
||||
func dynHealthChecks(ctx context.Context, client grpcapi.MaglevClient) []string {
|
||||
resp, err := client.ListHealthChecks(ctx, &grpcapi.ListHealthChecksRequest{})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return resp.Names
|
||||
}
|
||||
|
||||
// ---- run functions ---------------------------------------------------------
|
||||
|
||||
func runQuit(_ context.Context, _ grpcapi.MaglevClient, _ []string) error {
|
||||
return errQuit
|
||||
}
|
||||
|
||||
func runShowFrontends(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
resp, err := client.ListFrontends(ctx, &grpcapi.ListFrontendsRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range resp.FrontendNames {
|
||||
fmt.Println(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runShowFrontend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: show frontend <name>")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
info, err := client.GetFrontend(ctx, &grpcapi.GetFrontendRequest{Name: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "name\t%s\n", info.Name)
|
||||
fmt.Fprintf(w, "address\t%s\n", info.Address)
|
||||
fmt.Fprintf(w, "protocol\t%s\n", info.Protocol)
|
||||
fmt.Fprintf(w, "port\t%d\n", info.Port)
|
||||
for i, b := range info.BackendNames {
|
||||
if i == 0 {
|
||||
fmt.Fprintf(w, "backends\t%s\n", b)
|
||||
} else {
|
||||
fmt.Fprintf(w, "\t%s\n", b)
|
||||
}
|
||||
}
|
||||
if info.Description != "" {
|
||||
fmt.Fprintf(w, "description\t%s\n", info.Description)
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func runShowBackends(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
resp, err := client.ListBackends(ctx, &grpcapi.ListBackendsRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range resp.BackendNames {
|
||||
fmt.Println(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runShowBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: show backend <name>")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
info, err := client.GetBackend(ctx, &grpcapi.GetBackendRequest{Name: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "name\t%s\n", info.Name)
|
||||
fmt.Fprintf(w, "address\t%s\n", info.Address)
|
||||
fmt.Fprintf(w, "state\t%s\n", info.State)
|
||||
fmt.Fprintf(w, "enabled\t%v\n", info.Enabled)
|
||||
fmt.Fprintf(w, "weight\t%d\n", info.Weight)
|
||||
fmt.Fprintf(w, "healthcheck\t%s\n", info.Healthcheck)
|
||||
for i, t := range info.Transitions {
|
||||
if i == 0 {
|
||||
fmt.Fprintf(w, "transitions\t%s → %s\n", t.From, t.To)
|
||||
} else {
|
||||
fmt.Fprintf(w, "\t%s → %s\n", t.From, t.To)
|
||||
}
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func runShowHealthChecks(ctx context.Context, client grpcapi.MaglevClient, _ []string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
resp, err := client.ListHealthChecks(ctx, &grpcapi.ListHealthChecksRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range resp.Names {
|
||||
fmt.Println(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runShowHealthCheck(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: show healthcheck <name>")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
info, err := client.GetHealthCheck(ctx, &grpcapi.GetHealthCheckRequest{Name: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "name\t%s\n", info.Name)
|
||||
fmt.Fprintf(w, "type\t%s\n", info.Type)
|
||||
if info.Port > 0 {
|
||||
fmt.Fprintf(w, "port\t%d\n", info.Port)
|
||||
}
|
||||
fmt.Fprintf(w, "interval\t%s\n", time.Duration(info.IntervalNs))
|
||||
if info.FastIntervalNs > 0 {
|
||||
fmt.Fprintf(w, "fast-interval\t%s\n", time.Duration(info.FastIntervalNs))
|
||||
}
|
||||
if info.DownIntervalNs > 0 {
|
||||
fmt.Fprintf(w, "down-interval\t%s\n", time.Duration(info.DownIntervalNs))
|
||||
}
|
||||
fmt.Fprintf(w, "timeout\t%s\n", time.Duration(info.TimeoutNs))
|
||||
fmt.Fprintf(w, "rise\t%d\n", info.Rise)
|
||||
fmt.Fprintf(w, "fall\t%d\n", info.Fall)
|
||||
if info.ProbeIpv4Src != "" {
|
||||
fmt.Fprintf(w, "probe-ipv4-src\t%s\n", info.ProbeIpv4Src)
|
||||
}
|
||||
if info.ProbeIpv6Src != "" {
|
||||
fmt.Fprintf(w, "probe-ipv6-src\t%s\n", info.ProbeIpv6Src)
|
||||
}
|
||||
if h := info.Http; h != nil {
|
||||
fmt.Fprintf(w, "http.path\t%s\n", h.Path)
|
||||
if h.Host != "" {
|
||||
fmt.Fprintf(w, "http.host\t%s\n", h.Host)
|
||||
}
|
||||
fmt.Fprintf(w, "http.response-code\t%d-%d\n", h.ResponseCodeMin, h.ResponseCodeMax)
|
||||
if h.ResponseRegexp != "" {
|
||||
fmt.Fprintf(w, "http.response-regexp\t%s\n", h.ResponseRegexp)
|
||||
}
|
||||
}
|
||||
if t := info.Tcp; t != nil {
|
||||
fmt.Fprintf(w, "tcp.ssl\t%v\n", t.Ssl)
|
||||
if t.ServerName != "" {
|
||||
fmt.Fprintf(w, "tcp.server-name\t%s\n", t.ServerName)
|
||||
}
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func runPauseBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: set backend <name> pause")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
info, err := client.PauseBackend(ctx, &grpcapi.PauseResumeRequest{Name: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s: setting state to '%s'\n", info.Name, info.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runResumeBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: set backend <name> resume")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
info, err := client.ResumeBackend(ctx, &grpcapi.PauseResumeRequest{Name: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s: setting state to '%s'\n", info.Name, info.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runNotImplemented(_ context.Context, _ grpcapi.MaglevClient, _ []string) error {
|
||||
fmt.Println("not implemented yet")
|
||||
return nil
|
||||
}
|
||||
113
cmd/maglevc/complete.go
Normal file
113
cmd/maglevc/complete.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// line[:pos] includes the '?' just typed — strip it before tokenizing.
|
||||
before := string(line[:pos])
|
||||
if len(before) > 0 && before[len(before)-1] == '?' {
|
||||
before = before[:len(before)-1]
|
||||
}
|
||||
tokens := splitTokens(before)
|
||||
|
||||
var partial string
|
||||
var prefix []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]
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), completeTimeout)
|
||||
defer cancel()
|
||||
|
||||
candidates := Candidates(ql.root, prefix, partial, ctx, ql.client)
|
||||
|
||||
fmt.Fprintf(ql.rl.Stderr(), "\r\n")
|
||||
if len(candidates) == 0 {
|
||||
fmt.Fprintf(ql.rl.Stderr(), " <no completions>\r\n")
|
||||
} else {
|
||||
for _, c := range candidates {
|
||||
if c.Help != "" {
|
||||
fmt.Fprintf(ql.rl.Stderr(), " %-20s %s\r\n", c.Word, c.Help)
|
||||
} else {
|
||||
fmt.Fprintf(ql.rl.Stderr(), " %s\r\n", c.Word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the '?' from the line and return with cursor one step back.
|
||||
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)
|
||||
}
|
||||
49
cmd/maglevc/main.go
Normal file
49
cmd/maglevc/main.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
serverAddr := flag.String("server", "localhost:9090", "maglev server address")
|
||||
flag.Parse()
|
||||
|
||||
conn, err := grpc.NewClient(*serverAddr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect %s: %w", *serverAddr, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := grpcapi.NewMaglevClient(conn)
|
||||
ctx := context.Background()
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) == 0 {
|
||||
// Interactive shell.
|
||||
return runShell(ctx, client)
|
||||
}
|
||||
|
||||
// One-shot command from CLI arguments.
|
||||
root := buildTree()
|
||||
tokens := splitTokens(strings.Join(args, " "))
|
||||
return dispatch(ctx, root, client, tokens)
|
||||
}
|
||||
94
cmd/maglevc/shell.go
Normal file
94
cmd/maglevc/shell.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/chzyer/readline"
|
||||
|
||||
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
||||
)
|
||||
|
||||
// errQuit is a sentinel returned by runQuit to exit the REPL.
|
||||
var errQuit = errors.New("quit")
|
||||
|
||||
// runShell runs the interactive REPL until the user types quit/exit or EOF.
|
||||
func runShell(ctx context.Context, client grpcapi.MaglevClient) error {
|
||||
root := buildTree()
|
||||
|
||||
comp := &Completer{root: root, client: client}
|
||||
ql := &questionListener{root: root, client: client}
|
||||
|
||||
cfg := &readline.Config{
|
||||
Prompt: "maglev> ",
|
||||
AutoComplete: comp,
|
||||
InterruptPrompt: "^C",
|
||||
EOFPrompt: "exit",
|
||||
Listener: ql,
|
||||
}
|
||||
rl, err := readline.NewEx(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("readline init: %w", err)
|
||||
}
|
||||
ql.rl = rl
|
||||
defer rl.Close()
|
||||
|
||||
for {
|
||||
line, err := rl.Readline()
|
||||
if err == readline.ErrInterrupt {
|
||||
continue
|
||||
}
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tokens := splitTokens(line)
|
||||
if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := dispatch(ctx, root, client, tokens); err != nil {
|
||||
if errors.Is(err, errQuit) {
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(rl.Stderr(), "error: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch walks the tree and executes the matched command.
|
||||
func dispatch(ctx context.Context, root *Node, client grpcapi.MaglevClient, tokens []string) error {
|
||||
node, args := Walk(root, tokens)
|
||||
|
||||
if node.Run == nil {
|
||||
showHelp(root, tokens)
|
||||
return nil
|
||||
}
|
||||
|
||||
return node.Run(ctx, client, args)
|
||||
}
|
||||
|
||||
// showHelp prints available completions for the given token path.
|
||||
func showHelp(root *Node, tokens []string) {
|
||||
node, _ := Walk(root, tokens)
|
||||
|
||||
candidates := filterFixedChildren(node.Children, "")
|
||||
if len(candidates) == 0 {
|
||||
fmt.Println(" <no completions>")
|
||||
return
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if c.Help != "" {
|
||||
fmt.Printf(" %-20s %s\n", c.Word, c.Help)
|
||||
} else {
|
||||
fmt.Printf(" %s\n", c.Word)
|
||||
}
|
||||
}
|
||||
}
|
||||
127
cmd/maglevc/tree.go
Normal file
127
cmd/maglevc/tree.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
||||
)
|
||||
|
||||
// Node is one word in the command tree. Leaf nodes have a Run function.
|
||||
// Slot nodes have Dynamic set (and no fixed Word to match against); they
|
||||
// accept any single token as an argument and may have further Children.
|
||||
type Node struct {
|
||||
Word string
|
||||
Help string
|
||||
Dynamic func(context.Context, grpcapi.MaglevClient) []string // non-nil → slot node
|
||||
Children []*Node
|
||||
Run func(context.Context, grpcapi.MaglevClient, []string) error
|
||||
}
|
||||
|
||||
// Walk descends the tree following tokens. At each step it tries fixed
|
||||
// children first (exact then prefix), then falls back to a slot child
|
||||
// (Dynamic != nil). Tokens consumed by slot children are collected as args.
|
||||
// Returns the deepest node reached and the args collected from slot nodes.
|
||||
func Walk(root *Node, tokens []string) (*Node, []string) {
|
||||
node := root
|
||||
var args []string
|
||||
for len(tokens) > 0 {
|
||||
tok := tokens[0]
|
||||
|
||||
// Try fixed children (exact, then unique prefix).
|
||||
next := matchFixedChild(node.Children, tok)
|
||||
if next != nil {
|
||||
node = next
|
||||
tokens = tokens[1:]
|
||||
continue
|
||||
}
|
||||
|
||||
// Try a slot child.
|
||||
slot := findSlotChild(node.Children)
|
||||
if slot != nil {
|
||||
args = append(args, tok)
|
||||
tokens = tokens[1:]
|
||||
node = slot
|
||||
continue
|
||||
}
|
||||
|
||||
// Dead end — no match.
|
||||
break
|
||||
}
|
||||
return node, args
|
||||
}
|
||||
|
||||
// matchFixedChild returns the child matching tok by exact then unique prefix,
|
||||
// considering only non-slot children.
|
||||
func matchFixedChild(children []*Node, tok string) *Node {
|
||||
var fixed []*Node
|
||||
for _, c := range children {
|
||||
if c.Dynamic == nil {
|
||||
fixed = append(fixed, c)
|
||||
}
|
||||
}
|
||||
// Exact match.
|
||||
for _, c := range fixed {
|
||||
if c.Word == tok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
// Unique prefix match.
|
||||
var matches []*Node
|
||||
for _, c := range fixed {
|
||||
if strings.HasPrefix(c.Word, tok) {
|
||||
matches = append(matches, c)
|
||||
}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findSlotChild returns the first child that is a slot node (Dynamic != nil).
|
||||
func findSlotChild(children []*Node) *Node {
|
||||
for _, c := range children {
|
||||
if c.Dynamic != nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Candidates returns the completable children at the current position given
|
||||
// the already-typed tokens and the partial token being completed.
|
||||
func Candidates(root *Node, tokens []string, partial string, ctx context.Context, client grpcapi.MaglevClient) []*Node {
|
||||
// Walk the already-confirmed tokens.
|
||||
node, _ := Walk(root, tokens)
|
||||
|
||||
// Now look at what could follow at this node.
|
||||
// Check fixed children filtered by partial.
|
||||
fixedMatches := filterFixedChildren(node.Children, partial)
|
||||
|
||||
// Check dynamic slot child if present.
|
||||
var dynMatches []*Node
|
||||
slot := findSlotChild(node.Children)
|
||||
if slot != nil && slot.Dynamic != nil {
|
||||
vals := slot.Dynamic(ctx, client)
|
||||
for _, v := range vals {
|
||||
if strings.HasPrefix(v, partial) {
|
||||
dynMatches = append(dynMatches, &Node{Word: v, Help: slot.Help})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return append(fixedMatches, dynMatches...)
|
||||
}
|
||||
|
||||
func filterFixedChildren(children []*Node, prefix string) []*Node {
|
||||
var out []*Node
|
||||
for _, c := range children {
|
||||
if c.Dynamic == nil && strings.HasPrefix(c.Word, prefix) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user