feat: Validate + keypress subpackage; RFC-style design.md (v1.2.0)
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>
This commit is contained in:
+77
@@ -0,0 +1,77 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user