Files
pim 9e0a98ed07 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>
2026-06-05 22:11:13 +02:00

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)
}
}