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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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():
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user