From 5b1e6782cb372d091a352a134641894d4a473506 Mon Sep 17 00:00:00 2001 From: Beforerr Date: Thu, 11 Jun 2026 19:33:38 -0700 Subject: [PATCH] feat: in-session file eval with script args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - repld [flags] [--] file [args...] — mirrors plain julia/python CLI - first existing-file positional = script, rest = its argv; no ext check (shebang / justfile tmp scripts work) - evals per call in warm session: edits take effect, file never a launch arg, keying untouched - argv per language (ARGS / sys.argv / commandArgs shadow / $ScriptCommandLine), set for eval, restored in finally - "--" forces split when space-form flag value names a file (-L setup.jl) --- docs/architecture.md | 11 +++++----- go/julia_integration_test.go | 41 +++++++++++++---------------------- go/main.go | 26 ++++++++++++++++------ go/parse_test.go | 18 +++++++++++---- go/python_integration_test.go | 11 +++++----- 5 files changed, 58 insertions(+), 49 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 95dabb8..af41089 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,12 +14,11 @@ The interpreter is an explicit leading positional. The language is resolved *before* ``; anything after forwards verbatim to the interpreter, except the eval flag (`-e`/`-c`/`-E`), which repld captures. -**File mode**: with no eval flag captured, the first non-flag positional after -`` naming an existing regular file (no ext check; missing → launch arg; -subcommand-named → `./` prefix) evals in-session; the rest of argv is its script -args. Sent abs-ified as `protocolRequest.File`/`FileArgs`, never read client-side -and never a launch arg: the daemon wraps it via `Adapter.EvalFileStmt` through -the normal eval path, so it re-evals in the warm session on every call. +**File mode** (mirrors ` [switches] [--] programfile args...`): with no eval flag captured, the first non-flag positional after `` naming an existing regular file (missing → launch arg) evals in-session; the rest of argv is its script args. `--` forces +the split when a space-form flag value names a file (`-L setup.jl`). Sent +abs-ified as `protocolRequest.File`/`FileArgs`, never read client-side and +never a launch arg: the daemon wraps it via `Adapter.EvalFileStmt` through the +normal eval path, so it re-evals in the warm session on every call. The engine in `go/` is language-agnostic; per-language specifics live behind `Adapter` (`go/adapter.go`), implemented in `go/julia/`, `go/python/`, `go/r/`, and `go/wolfram/`. `langs` diff --git a/go/julia_integration_test.go b/go/julia_integration_test.go index af80d71..53fa0ad 100644 --- a/go/julia_integration_test.go +++ b/go/julia_integration_test.go @@ -100,6 +100,11 @@ func TestJuliaWarmSession(t *testing.T) { require.Equal(t, expected, run("testdata/compute.jl"), "relative path resolved against session cwd") }) + t.Run("missing script", func(t *testing.T) { + res := repldErr(t, socketPath, cwd, "julia", "nonexistent.jl") + require.Contains(t, res.stderr, "No such file") + }) + t.Run("trace saved", func(t *testing.T) { res := repldErr(t, socketPath, cwd, "julia", "-e", `let f = () -> error("boom"); g = () -> f(); g(); end`) require.Empty(t, res.stdout) @@ -269,18 +274,16 @@ func TestInterruptIdleSession(t *testing.T) { // covers `timeout 30 repld ...` scenario func TestClientDisconnectInterruptsEval(t *testing.T) { socketPath := sharedDaemon(t) + cwd := sharedJuliaCwd(t) - cwd, err := os.Getwd() - require.NoError(t, err) - - // Prime the session and set state that must survive the disconnect-interrupt. - repldOK(t, socketPath, cwd, "--session", "disc", "julia", "-e", "marker = 5678") + // Set state that must survive the disconnect-interrupt. + repldOK(t, socketPath, cwd, "julia", "-e", "marker = 5678") // Start a long eval on its own connection. conn, err := net.Dial("unix", socketPath) require.NoError(t, err) require.NoError(t, json.NewEncoder(conn).Encode(protocolRequest{ - Action: "eval", Lang: "julia", Session: "disc", Code: "sleep(60)", Cwd: cwd, + Action: "eval", Lang: "julia", Code: "sleep(60)", Cwd: cwd, })) // Wait until the session reports busy. @@ -304,7 +307,7 @@ func TestClientDisconnectInterruptsEval(t *testing.T) { // Session must be usable again AND have survived with state intact doneCh := make(chan cliResult, 1) go func() { - doneCh <- repldCLI(t, socketPath, cwd, "--session", "disc", "julia", "-e", "print(marker)") + doneCh <- repldCLI(t, socketPath, cwd, "julia", "-e", "print(marker)") }() select { case r := <-doneCh: @@ -388,18 +391,6 @@ func TestKillRunsAtexitHooks(t *testing.T) { require.FileExists(t, marker, "graceful shutdown should run atexit hooks, not SIGKILL") } -func TestCLIJuliaMissingScriptBehavesLikeInteractiveInterpreter(t *testing.T) { - if _, err := exec.LookPath(julia.Adapter{}.DefaultExe()); err != nil { - t.Skipf("%s not installed", julia.Adapter{}.DefaultExe()) - } - socketPath := sharedDaemon(t) - - // Own cwd → a cold session, so `x=1` reaches a fresh julia as a launch arg - // (a warm session would eval it as code). - res := repldOK(t, socketPath, sessionCwd(t), "julia", "x=1") - require.Contains(t, res.stderr, "No such file") - require.NotContains(t, res.stderr, "repld: persistent REPL daemon") -} func TestJuliaFileEvalArgsAndState(t *testing.T) { if _, err := exec.LookPath(julia.Adapter{}.DefaultExe()); err != nil { @@ -427,18 +418,16 @@ func TestJuliaFileEvalArgsAndState(t *testing.T) { // A request queued behind another eval must not interrupt running eval when client disconnects. func TestQueuedDisconnectDoesNotInterruptRunningEval(t *testing.T) { socketPath := sharedDaemon(t) - - cwd, err := os.Getwd() - require.NoError(t, err) - repldOK(t, socketPath, cwd, "--session", "q", "julia", "-e", "1") + cwd := sharedJuliaCwd(t) + repldOK(t, socketPath, cwd, "julia", "-e", "1") // conn1: long enough to still be busy when conn2 queues and disconnects. conn1, err := net.Dial("unix", socketPath) require.NoError(t, err) defer conn1.Close() require.NoError(t, json.NewEncoder(conn1).Encode(protocolRequest{ - Action: "eval", Lang: "julia", Session: "q", Cwd: cwd, - Code: "sleep(4); 4321", PrintResult: true, + Action: "eval", Lang: "julia", Cwd: cwd, + Code: "sleep(1.5); 4321", PrintResult: true, })) require.Eventually(t, func() bool { @@ -449,7 +438,7 @@ func TestQueuedDisconnectDoesNotInterruptRunningEval(t *testing.T) { conn2, err := net.Dial("unix", socketPath) require.NoError(t, err) require.NoError(t, json.NewEncoder(conn2).Encode(protocolRequest{ - Action: "eval", Lang: "julia", Session: "q", Cwd: cwd, Code: "1+1", + Action: "eval", Lang: "julia", Cwd: cwd, Code: "1+1", })) time.Sleep(300 * time.Millisecond) require.NoError(t, conn2.Close()) diff --git a/go/main.go b/go/main.go index ab036a9..76e367e 100644 --- a/go/main.go +++ b/go/main.go @@ -208,7 +208,7 @@ func usage() { fmt.Fprintf(os.Stderr, `repld: persistent REPL daemon for multiple interpreters Usage: - repld [interp-args] (-- CODE | | -) + repld [interp-args] (-- CODE | [--] [script-args] | -) repld [] [--session L] # target a session: trace, interrupt repld # daemon-wide: sessions, stop, daemon @@ -296,6 +296,21 @@ func parseArgs(args []string) parsed { i++ continue } + // "--" marks the next token as the eval file, mirroring + // ` [switches] -- [programfile] [args...]`. + if t == "--" && p.exe != "" && p.evalMode == "" && p.sub == "" { + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "missing file after --") + usage() + } + f := args[i+1] + if fi, err := os.Stat(f); err != nil || !fi.Mode().IsRegular() { + fmt.Fprintf(os.Stderr, "file not found: %s\n", f) + os.Exit(1) + } + p.file, p.fileArgs = f, args[i+2:] + break + } if strings.HasPrefix(t, "-") { name := flagName(t) dst, isRepld := repld[name] @@ -323,12 +338,9 @@ func parseArgs(args []string) parsed { i++ continue } - // File mode: the first interpreter token naming an existing regular file - // evals in-session; the rest of argv is its script args. No ext check - // (shebang/justfile temp scripts are extensionless); a missing path - // forwards as a launch arg; subcommand names need a ./ prefix. - if p.exe != "" && p.evalMode == "" && len(p.fwd) == 0 && !subcommands[t] { - if fi, err := os.Stat(t); err == nil && fi.Mode().IsRegular() { + // File mode (mirrors ` [options] programfile args...`) + if p.exe != "" && p.evalMode == "" && !subcommands[t] { + if fi, err := os.Stat(t); (err == nil && fi.Mode().IsRegular()) || (len(p.fwd) == 0 && !strings.HasPrefix(t, "+")) { p.file = t i++ continue diff --git a/go/parse_test.go b/go/parse_test.go index a3a1361..61b6428 100644 --- a/go/parse_test.go +++ b/go/parse_test.go @@ -115,11 +115,21 @@ func TestParseArgsFileMode(t *testing.T) { require.NoError(t, os.WriteFile(recipe, []byte("#!/usr/bin/env -S repld julia\n1\n"), 0755)) require.Equal(t, recipe, parseCLI([]string{"julia", recipe, "x"}).file) - // An eval flag disables detection; a missing path forwards as a launch arg. + // An eval flag disables detection; a missing path still enters file mode. got = parseCLI([]string{"julia", "-e", "1", jl}) require.Equal(t, "", got.file) require.Equal(t, []string{jl}, got.fwd) - require.Equal(t, "", parseCLI([]string{"julia", filepath.Join(dir, "nope.jl")}).file) + require.Equal(t, filepath.Join(dir, "nope.jl"), parseCLI([]string{"julia", filepath.Join(dir, "nope.jl")}).file) + + for _, argv := range [][]string{ + {"julia", "--project=test", jl, "a"}, + {"julia", "--project=test", "--", jl, "a"}, + } { + got = parseCLI(argv) + require.Equal(t, []string{"--project=test"}, got.fwd) + require.Equal(t, jl, got.file) + require.Equal(t, []string{"a"}, got.fileArgs) + } } func TestParseArgs(t *testing.T) { @@ -142,8 +152,8 @@ func TestParseArgs(t *testing.T) { parsed{fwd: []string{"--startup-file=no"}}}, {"juliaup channel forwards after exe", []string{"julia", "+1.11", "-e", "c"}, parsed{exe: "julia", evalMode: "eval", code: "c", fwd: []string{"+1.11"}}}, - // Positionals after launch args are never file mode. - {"no file mode once forwarding starts", []string{"julia", "--project", "/env", "script.jl", "arg1"}, + // Missing files forward as launch args even after flags. + {"missing file forwards after launch flags", []string{"julia", "--project", "/env", "script.jl", "arg1"}, parsed{exe: "julia", fwd: []string{"--project", "/env", "script.jl", "arg1"}}}, {"subcommand", []string{"sessions"}, parsed{sub: "sessions"}}, {"flags before subcommand", []string{"--socket", "x", "sessions"}, diff --git a/go/python_integration_test.go b/go/python_integration_test.go index ac8c6eb..c27625b 100644 --- a/go/python_integration_test.go +++ b/go/python_integration_test.go @@ -90,13 +90,12 @@ func TestCLIPythonEvalDoesNotLeakInteractivePrompts(t *testing.T) { func TestCLIPythonMissingScriptBehavesLikeInteractiveInterpreter(t *testing.T) { socketPath, _ := pythonTestDaemon(t) + cwd := sessionCwd(t) - // Own cwd → a cold session, so `x=1` is passed as a launch arg to a fresh - // python that fails to open it (a warm session would eval it as code). - res := repldOK(t, socketPath, sessionCwd(t), "python3", "x=1") - require.Contains(t, res.stderr, "can't open file") - require.NotContains(t, res.stderr, ">>>") - require.NotContains(t, res.stderr, "repld: persistent REPL daemon") + repldOK(t, socketPath, cwd, "python3", "-c", "pass") + res := repldErr(t, socketPath, cwd, "python3", "x=1") + require.Contains(t, res.stderr, "FileNotFoundError") + require.Contains(t, res.stderr, "No such file") } func TestPythonTracebackLineNumbers(t *testing.T) {