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
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
//go:build openbsd || freebsd || netbsd || dragonfly || darwin
package keypress
import "golang.org/x/sys/unix"
// cbreak puts the terminal into cbreak mode (no canonical input/echo) so a
// single keystroke is available, leaving output post-processing intact. The
// BSDs use the TIOCGETA/TIOCSETA termios ioctls (Linux uses TCGETS/TCSETS). It
// returns the previous termios for restore, or an error if fd is not a tty.
func cbreak(fd int) (*unix.Termios, error) {
old, err := unix.IoctlGetTermios(fd, unix.TIOCGETA)
if err != nil {
return nil, err
}
t := *old
t.Lflag &^= unix.ICANON | unix.ECHO | unix.ECHOE | unix.ECHOK | unix.ECHONL
t.Cc[unix.VMIN] = 1
t.Cc[unix.VTIME] = 0
if err := unix.IoctlSetTermios(fd, unix.TIOCSETA, &t); err != nil {
return nil, err
}
return old, nil
}
// restore reverts the terminal to the settings captured by cbreak.
func restore(fd int, old *unix.Termios) error {
return unix.IoctlSetTermios(fd, unix.TIOCSETAF, old)
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
//go:build linux
package keypress
import "golang.org/x/sys/unix"
// cbreak puts the terminal into cbreak mode (no canonical input/echo) so a
// single keystroke is available, leaving output post-processing intact. Linux
// uses the TCGETS/TCSETS termios ioctls (the BSDs use TIOCGETA/TIOCSETA). It
// returns the previous termios for restore, or an error if fd is not a tty.
func cbreak(fd int) (*unix.Termios, error) {
old, err := unix.IoctlGetTermios(fd, unix.TCGETS)
if err != nil {
return nil, err
}
t := *old
t.Lflag &^= unix.ICANON | unix.ECHO | unix.ECHOE | unix.ECHOK | unix.ECHONL
t.Cc[unix.VMIN] = 1
t.Cc[unix.VTIME] = 0
if err := unix.IoctlSetTermios(fd, unix.TCSETS, &t); err != nil {
return nil, err
}
return old, nil
}
// restore reverts the terminal to the settings captured by cbreak.
func restore(fd int, old *unix.Termios) error {
return unix.IoctlSetTermios(fd, unix.TCSETSF, old)
}
+19
View File
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
//go:build !linux && !openbsd && !freebsd && !netbsd && !dragonfly && !darwin
package keypress
import "errors"
// termiosState is a placeholder so cbreak/restore have a consistent signature on
// platforms without a supported termios path. On those platforms cbreak always
// fails, so WaitForKey degrades to waiting on the context (it never reads stdin).
type termiosState struct{}
func cbreak(int) (*termiosState, error) {
return nil, errors.New("keypress: cbreak is unsupported on this platform")
}
func restore(int, *termiosState) error { return nil }
+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():
}
}
+31
View File
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package keypress
import (
"context"
"testing"
"time"
)
// TestWaitForKeyReturnsOnContextCancel checks WaitForKey unblocks when the
// context ends, both on the non-tty path (cbreak fails → wait on ctx) and the
// tty path (select returns on ctx.Done). Under `go test` stdin is normally not
// a terminal, exercising the former.
func TestWaitForKeyReturnsOnContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // already done
done := make(chan struct{})
go func() {
WaitForKey(ctx, func() {})
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("WaitForKey did not return after the context was cancelled")
}
}