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=