Files
vpp-maglev/cmd/client/tree.go

169 lines
4.7 KiB
Go

// SPDX-License-Identifier: Apache-2.0
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, the args collected from slot nodes,
// and any tokens that could not be matched. A non-empty remaining slice
// means the input contained a token that neither matched a fixed child
// at the current node nor fed into a slot — callers should treat that
// as "unknown command" rather than silently anchoring help at the root.
func Walk(root *Node, tokens []string) (*Node, []string, []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. The caller gets the still-unconsumed tail
// in the third return value.
break
}
return node, args, tokens
}
// 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
}
// helpLine is a (path, help) pair used when displaying '?' output.
type helpLine struct {
path string
help string
}
// expandPaths returns all (path, help) pairs for every node reachable from
// node that has a Run function. prefix is the display string accumulated so
// far (e.g. "show frontend"). visited prevents infinite loops through
// self-referencing slot nodes like watchEventsOptSlot.
func expandPaths(node *Node, prefix string, visited map[*Node]bool) []helpLine {
if visited[node] {
return nil
}
visited[node] = true
var lines []helpLine
if node.Run != nil {
lines = append(lines, helpLine{path: prefix, help: node.Help})
}
for _, child := range node.Children {
childPrefix := child.Word
if prefix != "" {
childPrefix = prefix + " " + child.Word
}
lines = append(lines, expandPaths(child, childPrefix, visited)...)
}
return lines
}
// 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. If any of them are unknown,
// offer no completions at all — continuing to suggest children off
// the partially-walked node would mislead the user into "completing"
// an invalid command.
node, _, remaining := Walk(root, tokens)
if len(remaining) > 0 {
return nil
}
// 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
}