9e0a98ed07
Validate(root): optional startup/test check for tree authoring faults — >1 slot child per node, empty word, duplicate sibling words, dead-end node — traversing circular slots without looping (#3). keypress subpackage: WaitForKey(ctx, cancel) cancels a context on any keystroke for watch-style streaming commands, with per-GOOS cbreak (linux TCGETS/TCSETS, BSD TIOCGETA/TIOCSETA) and a non-tty/unsupported fallback that just waits on ctx. Lifts the last OpenBSD-specific bit out of evpnc/maglevc's watch.go (#6). docs: replace PROPOSAL.md with an RFC-2119 design.md (FR/NFR for the library). Example now dogfoods Validate (a unit test) and keypress (a bounded `watch` command). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
78 lines
2.2 KiB
Go
78 lines
2.2 KiB
Go
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Validate reports common authoring faults in a command tree. Walk tolerates
|
|
// all of them, so Validate is optional — but calling it once at startup, or
|
|
// from a unit test, catches mistakes that otherwise cause silent misdispatch.
|
|
// It returns all problems found, joined, or nil if the tree is clean.
|
|
//
|
|
// It reports:
|
|
// - a node with more than one slot child (Dynamic != nil): only the first is
|
|
// ever reachable (FR-1.7);
|
|
// - a non-root node with an empty Word: it can never be matched or displayed;
|
|
// - duplicate fixed (non-slot) words among a node's children: the second is
|
|
// shadowed by the first;
|
|
// - a node that neither runs nor has children: a dead end that can only print
|
|
// "<no completions>".
|
|
//
|
|
// Circular slots (a slot that is its own descendant, e.g. a watch-options node)
|
|
// are traversed once, not looped.
|
|
func Validate[C any](root *Node[C]) error {
|
|
var errs []error
|
|
validateNode(root, "", true, make(map[*Node[C]]bool), &errs)
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func validateNode[C any](n *Node[C], path string, isRoot bool, seen map[*Node[C]]bool, errs *[]error) {
|
|
if seen[n] {
|
|
return
|
|
}
|
|
seen[n] = true
|
|
|
|
where := path
|
|
if where == "" {
|
|
where = "<root>"
|
|
}
|
|
|
|
if !isRoot && n.Word == "" {
|
|
*errs = append(*errs, fmt.Errorf("%s: node has an empty Word", where))
|
|
}
|
|
if !isRoot && n.Run == nil && len(n.Children) == 0 {
|
|
*errs = append(*errs, fmt.Errorf("%s: node neither runs nor has children (dead end)", where))
|
|
}
|
|
|
|
slots := 0
|
|
fixedWords := make(map[string]bool)
|
|
for _, c := range n.Children {
|
|
if c.Dynamic != nil {
|
|
slots++
|
|
continue
|
|
}
|
|
if c.Word != "" {
|
|
if fixedWords[c.Word] {
|
|
*errs = append(*errs, fmt.Errorf("%s: duplicate child word %q", where, c.Word))
|
|
}
|
|
fixedWords[c.Word] = true
|
|
}
|
|
}
|
|
if slots > 1 {
|
|
*errs = append(*errs, fmt.Errorf("%s: %d slot children, but only the first is reachable", where, slots))
|
|
}
|
|
|
|
for _, c := range n.Children {
|
|
cp := c.Word
|
|
if path != "" {
|
|
cp = path + " " + c.Word
|
|
}
|
|
validateNode(c, strings.TrimSpace(cp), false, seen, errs)
|
|
}
|
|
}
|