From 6da7760d75e2c21ed72141b6d854dd6e842df211 Mon Sep 17 00:00:00 2001 From: Associate 1 Date: Mon, 23 Feb 2026 17:30:09 -0700 Subject: [PATCH] Raw terminal mode for keyboard channel (#90) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use golang.org/x/term to switch stdin to raw mode in the generated entry harness so characters are available immediately as typed, without waiting for Enter. Falls back to buffered bufio.NewReader when stdin is not a terminal (piped input). Handles Ctrl+C in raw mode, LF→CRLF in output writers, and restores terminal state on signals. Co-Authored-By: Claude Opus 4.6 --- codegen/codegen.go | 84 +++++++++++++++++++++++++++++++++++-- codegen/codegen_test.go | 26 ++++++++++++ codegen/e2e_harness_test.go | 25 +++++++++++ codegen/e2e_helpers_test.go | 67 +++++++++++++++++++++++++++++ go.mod | 5 +++ go.sum | 4 ++ 6 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 codegen/e2e_harness_test.go create mode 100644 go.sum diff --git a/codegen/codegen.go b/codegen/codegen.go index 4211f74..e91bc12 100644 --- a/codegen/codegen.go +++ b/codegen/codegen.go @@ -20,6 +20,7 @@ type Generator struct { needBufio bool // track if we need bufio package import needReflect bool // track if we need reflect package import needBoolHelper bool // track if we need _boolToInt helper + needTerm bool // track if we need golang.org/x/term package import // Track procedure signatures for proper pointer handling procSigs map[string][]ast.ProcParam @@ -105,6 +106,7 @@ func (g *Generator) Generate(program *ast.Program) string { g.needBufio = false g.needReflect = false g.needBoolHelper = false + g.needTerm = false g.procSigs = make(map[string][]ast.ProcParam) g.refParams = make(map[string]bool) g.protocolDefs = make(map[string]*ast.ProtocolDecl) @@ -217,6 +219,7 @@ func (g *Generator) Generate(program *ast.Program) string { g.needOs = true g.needSync = true g.needBufio = true + g.needTerm = true } } @@ -225,7 +228,7 @@ func (g *Generator) Generate(program *ast.Program) string { g.writeLine("") // Write imports - if g.needSync || g.needFmt || g.needTime || g.needOs || g.needMath || g.needMathBits || g.needBufio || g.needReflect { + if g.needSync || g.needFmt || g.needTime || g.needOs || g.needMath || g.needMathBits || g.needBufio || g.needReflect || g.needTerm { g.writeLine("import (") g.indent++ if g.needBufio { @@ -243,15 +246,25 @@ func (g *Generator) Generate(program *ast.Program) string { if g.needOs { g.writeLine(`"os"`) } + if g.needTerm { + g.writeLine(`"os/signal"`) + } if g.needReflect { g.writeLine(`"reflect"`) } if g.needSync { g.writeLine(`"sync"`) } + if g.needTerm { + g.writeLine(`"syscall"`) + } if g.needTime { g.writeLine(`"time"`) } + if g.needTerm { + g.writeLine("") + g.writeLine(`"golang.org/x/term"`) + } g.indent-- g.writeLine(")") g.writeLine("") @@ -415,7 +428,9 @@ func (g *Generator) findEntryProc(procDecls []ast.Statement) *ast.ProcDecl { } // generateEntryHarness emits a func main() that wires stdin/stdout/stderr -// to channels and calls the entry PROC. +// to channels and calls the entry PROC. When stdin is a terminal, the +// harness switches to raw mode (via golang.org/x/term) so that keyboard +// input is available character-by-character without waiting for Enter. func (g *Generator) generateEntryHarness(proc *ast.ProcDecl) { name := goIdent(proc.Name) g.writeLine("func main() {") @@ -427,12 +442,41 @@ func (g *Generator) generateEntryHarness(proc *ast.ProcDecl) { g.writeLine("_error := make(chan byte, 256)") g.writeLine("") + // Raw terminal mode setup + g.writeLine("// Raw terminal mode — gives character-at-a-time keyboard input") + g.writeLine("var rawMode bool") + g.writeLine("var oldState *term.State") + g.writeLine("fd := int(os.Stdin.Fd())") + g.writeLine("if term.IsTerminal(fd) {") + g.indent++ + g.writeLine("var err error") + g.writeLine("oldState, err = term.MakeRaw(fd)") + g.writeLine("if err == nil {") + g.indent++ + g.writeLine("rawMode = true") + g.writeLine("defer term.Restore(fd, oldState)") + g.writeLine("// Restore terminal on external signals") + g.writeLine("sigCh := make(chan os.Signal, 1)") + g.writeLine("signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)") + g.writeLine("go func() {") + g.indent++ + g.writeLine("<-sigCh") + g.writeLine("term.Restore(fd, oldState)") + g.writeLine("os.Exit(1)") + g.indent-- + g.writeLine("}()") + g.indent-- + g.writeLine("}") + g.indent-- + g.writeLine("}") + g.writeLine("") + // WaitGroup for writer goroutines to finish draining g.writeLine("var wg sync.WaitGroup") g.writeLine("wg.Add(2)") g.writeLine("") - // Screen writer goroutine + // Screen writer goroutine — in raw mode, insert CR before LF g.writeLine("go func() {") g.indent++ g.writeLine("defer wg.Done()") @@ -445,6 +489,9 @@ func (g *Generator) generateEntryHarness(proc *ast.ProcDecl) { g.indent-- g.writeLine("} else {") g.indent++ + g.writeLine(`if rawMode && b == '\n' {`) + g.writeLine(`w.WriteByte('\r')`) + g.writeLine("}") g.writeLine("w.WriteByte(b)") g.indent-- g.writeLine("}") @@ -455,7 +502,7 @@ func (g *Generator) generateEntryHarness(proc *ast.ProcDecl) { g.writeLine("}()") g.writeLine("") - // Error writer goroutine + // Error writer goroutine — same CR/LF handling g.writeLine("go func() {") g.indent++ g.writeLine("defer wg.Done()") @@ -468,6 +515,9 @@ func (g *Generator) generateEntryHarness(proc *ast.ProcDecl) { g.indent-- g.writeLine("} else {") g.indent++ + g.writeLine(`if rawMode && b == '\n' {`) + g.writeLine(`w.WriteByte('\r')`) + g.writeLine("}") g.writeLine("w.WriteByte(b)") g.indent-- g.writeLine("}") @@ -481,6 +531,30 @@ func (g *Generator) generateEntryHarness(proc *ast.ProcDecl) { // Keyboard reader goroutine g.writeLine("go func() {") g.indent++ + g.writeLine("if rawMode {") + g.indent++ + g.writeLine("buf := make([]byte, 1)") + g.writeLine("for {") + g.indent++ + g.writeLine("n, err := os.Stdin.Read(buf)") + g.writeLine("if err != nil || n == 0 {") + g.indent++ + g.writeLine("close(keyboard)") + g.writeLine("return") + g.indent-- + g.writeLine("}") + g.writeLine("if buf[0] == 3 { // Ctrl+C") + g.indent++ + g.writeLine("term.Restore(fd, oldState)") + g.writeLine("os.Exit(1)") + g.indent-- + g.writeLine("}") + g.writeLine("keyboard <- buf[0]") + g.indent-- + g.writeLine("}") + g.indent-- + g.writeLine("} else {") + g.indent++ g.writeLine("r := bufio.NewReader(os.Stdin)") g.writeLine("for {") g.indent++ @@ -495,6 +569,8 @@ func (g *Generator) generateEntryHarness(proc *ast.ProcDecl) { g.indent-- g.writeLine("}") g.indent-- + g.writeLine("}") + g.indent-- g.writeLine("}()") g.writeLine("") diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go index 4882a43..486e9a5 100644 --- a/codegen/codegen_test.go +++ b/codegen/codegen_test.go @@ -852,3 +852,29 @@ func TestMultiDimProcParamCodegen(t *testing.T) { t.Errorf("expected 'func fill(grid [][]chan int)' in output, got:\n%s", output) } } + +func TestEntryHarnessRawTerminal(t *testing.T) { + input := `PROC echo(CHAN OF BYTE keyboard?, screen!, error!) + BYTE ch: + SEQ + keyboard ? ch + screen ! ch +: +` + output := transpile(t, input) + + // Should contain raw terminal mode setup + for _, want := range []string{ + "term.IsTerminal", + "term.MakeRaw", + "term.Restore", + `"golang.org/x/term"`, + `"os/signal"`, + `"syscall"`, + "rawMode", + } { + if !strings.Contains(output, want) { + t.Errorf("expected %q in entry harness output, got:\n%s", want, output) + } + } +} diff --git a/codegen/e2e_harness_test.go b/codegen/e2e_harness_test.go new file mode 100644 index 0000000..afefa7d --- /dev/null +++ b/codegen/e2e_harness_test.go @@ -0,0 +1,25 @@ +package codegen + +import "testing" + +func TestE2EEntryHarnessEcho(t *testing.T) { + // An echoing program that reads characters until 'Z' and echoes each one. + // Uses the standard occam entry-point PROC signature. + input := `PROC echo(CHAN OF BYTE keyboard?, screen!, error!) + BYTE ch: + SEQ + keyboard ? ch + WHILE ch <> 'Z' + SEQ + screen ! ch + keyboard ? ch +: +` + // Pipe "hello Z" — the program should echo "hello " (everything before Z) + output := transpileCompileRunWithInput(t, input, "hello Z") + + expected := "hello " + if output != expected { + t.Errorf("expected %q, got %q", expected, output) + } +} diff --git a/codegen/e2e_helpers_test.go b/codegen/e2e_helpers_test.go index 47f0f55..dae94ca 100644 --- a/codegen/e2e_helpers_test.go +++ b/codegen/e2e_helpers_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/codeassociates/occam2go/lexer" @@ -80,3 +81,69 @@ func transpileCompileRunFromFile(t *testing.T, mainFile string, includePaths []s return transpileCompileRun(t, expanded) } + +// transpileCompileRunWithInput takes Occam source that uses the entry-point +// PROC pattern (CHAN OF BYTE keyboard?, screen!, error!), transpiles to Go, +// initialises a Go module (needed for golang.org/x/term), compiles, pipes +// the given input to stdin, and returns the stdout output. +func transpileCompileRunWithInput(t *testing.T, occamSource, stdin string) string { + t.Helper() + + // Transpile + l := lexer.New(occamSource) + p := parser.New(l) + program := p.ParseProgram() + + if len(p.Errors()) > 0 { + for _, err := range p.Errors() { + t.Errorf("parser error: %s", err) + } + t.FailNow() + } + + gen := New() + goCode := gen.Generate(program) + + // Create temp directory + tmpDir, err := os.MkdirTemp("", "occam2go-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Write Go source + goFile := filepath.Join(tmpDir, "main.go") + if err := os.WriteFile(goFile, []byte(goCode), 0644); err != nil { + t.Fatalf("failed to write Go file: %v", err) + } + + // Initialise Go module (needed for golang.org/x/term dependency) + modInit := exec.Command("go", "mod", "init", "test") + modInit.Dir = tmpDir + if out, err := modInit.CombinedOutput(); err != nil { + t.Fatalf("go mod init failed: %v\n%s", err, out) + } + modTidy := exec.Command("go", "mod", "tidy") + modTidy.Dir = tmpDir + if out, err := modTidy.CombinedOutput(); err != nil { + t.Fatalf("go mod tidy failed: %v\n%s\nGo code:\n%s", err, out, goCode) + } + + // Compile + binFile := filepath.Join(tmpDir, "main") + compileCmd := exec.Command("go", "build", "-o", binFile, ".") + compileCmd.Dir = tmpDir + if out, err := compileCmd.CombinedOutput(); err != nil { + t.Fatalf("compilation failed: %v\nOutput: %s\nGo code:\n%s", err, out, goCode) + } + + // Run with piped stdin + runCmd := exec.Command(binFile) + runCmd.Stdin = strings.NewReader(stdin) + output, err := runCmd.CombinedOutput() + if err != nil { + t.Fatalf("execution failed: %v\nOutput: %s", err, output) + } + + return string(output) +} diff --git a/go.mod b/go.mod index 70c29f1..38cf26c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module github.com/codeassociates/occam2go go 1.25.6 + +require ( + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..64c0afa --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=