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:
2026-06-05 22:11:13 +02:00
parent e030cd28e9
commit 9e0a98ed07
12 changed files with 530 additions and 260 deletions
+52
View File
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
// Package keypress lets a streaming command stop on any keystroke. A
// watch-style command runs WaitForKey in a goroutine with a cancellable
// context; the first key pressed cancels the context, tearing down the stream.
//
// ctx, cancel := context.WithCancel(ctx)
// defer cancel()
// go keypress.WaitForKey(ctx, cancel)
// for { ev, err := stream.Recv(); ... } // returns when ctx is cancelled
//
// When standard input is not a terminal (piped, redirected, backgrounded) there
// is no keystroke to wait for, so WaitForKey just blocks until ctx ends and
// never cancels on its own.
package keypress
import (
"context"
"os"
)
// WaitForKey blocks until a key is pressed on standard input or ctx is done,
// whichever comes first; on a keypress it calls cancel. If stdin is not a
// terminal it waits on ctx only (it neither reads input nor calls cancel). The
// terminal is placed in cbreak mode for the duration and restored on return.
//
// Run it in its own goroutine. If ctx ends first, WaitForKey returns and
// restores the terminal; a read already blocked on stdin is left to be reaped
// when the process exits (there is no portable way to interrupt it).
func WaitForKey(ctx context.Context, cancel context.CancelFunc) {
fd := int(os.Stdin.Fd())
old, err := cbreak(fd)
if err != nil {
<-ctx.Done() // stdin is not a tty: nothing to read, just honor ctx
return
}
defer func() { _ = restore(fd, old) }()
readDone := make(chan struct{})
go func() {
defer close(readDone)
buf := make([]byte, 1)
_, _ = os.Stdin.Read(buf)
}()
select {
case <-readDone:
cancel()
case <-ctx.Done():
}
}