From 36864867a5c32a17ba499f3aa102cae41a07dd21 Mon Sep 17 00:00:00 2001 From: norbot7865 Date: Wed, 15 Jul 2026 10:52:56 +0000 Subject: [PATCH] Add brepl-compatible load-file evaluation --- README.md | 2 +- docs/nrepl.md | 17 +++++++++++++++- internal/lisp/runtime.go | 37 ++++++++++++++++++++++++++++++++++- internal/nrepl/server_test.go | 17 ++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b71ba72..84b9300 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Start an nREPL listener on an OS-selected local port: # nREPL listening on 127.0.0.1:43817 ``` -The resolved port is written to `.nrepl-port` by default for CIDER, Calva, and Conjure discovery. nREPL binds only to loopback unless `--nrepl-bind` explicitly requests another address. Remote evaluation uses the same serialized let-go boundary and normal music APIs as the terminal REPL; it never evaluates Lisp on the audio callback. See [the nREPL guide](docs/nrepl.md) for operations, editor setup, security, and interrupt limitations. +The resolved port is written to `.nrepl-port` by default for CIDER, Calva, Conjure, and CLI clients such as [brepl](https://github.com/licht1stein/brepl). nREPL binds only to loopback unless `--nrepl-bind` explicitly requests another address. Remote evaluation uses the same serialized let-go boundary and normal music APIs as the terminal REPL; it never evaluates Lisp on the audio callback. See [the nREPL guide](docs/nrepl.md) for operations, editor setup, security, and interrupt limitations. ## Deterministic rendering and validation diff --git a/docs/nrepl.md b/docs/nrepl.md index 59c2b41..c46602e 100644 --- a/docs/nrepl.md +++ b/docs/nrepl.md @@ -21,10 +21,25 @@ lgs repl --nrepl 7888 --nrepl-bind 0.0.0.0 `lgs` prints a security warning when configured this way. Use host firewalling and a trusted network. -## Editor connections +## Editor and CLI clients Read the port from `.nrepl-port`, then connect to `127.0.0.1` from CIDER, Calva, Conjure, or another nREPL client. The pinned let-go protocol implementation has raw-protocol integration coverage; full CIDER compatibility certification is not part of Phase 3. +### brepl + +[`brepl`](https://github.com/licht1stein/brepl) v2.7.1 was validated with Babashka v1.12.218 against `lgs repl --no-audio --nrepl 0`: + +```bash +brepl -e '(+ 1 2 3)' # => 6 +brepl -e '(do (println "hello") (* 6 7))' # stdout, then 42 +brepl -m '{"op" "describe"}' --verbose +brepl -f path/inside/this/project/script.lg +``` + +Expression evaluation, stdout/value responses, raw `describe`, structured nonzero error exits, `.nrepl-port` discovery, and file evaluation all work. For `-f`, brepl searches upward from the evaluated file for `.nrepl-port`; use `-p PORT` when the file is outside the project tree. + +brepl implements `-f` by evaluating `(load-file "...")` rather than sending the nREPL `load-file` operation. `music.core/load-file` supports that client behavior, evaluates top-level forms sequentially in the session namespace, and limits files to 16 MiB. + Supported operations: - `clone` diff --git a/internal/lisp/runtime.go b/internal/lisp/runtime.go index 5de5518..e780a3c 100644 --- a/internal/lisp/runtime.go +++ b/internal/lisp/runtime.go @@ -6,6 +6,7 @@ import ( "io" "math" "math/big" + "os" "sort" "strings" "sync" @@ -79,7 +80,7 @@ func New(engine *audio.Engine, t *clock.Transport, q *scheduler.Scheduler, a *in // music.core defines transport `now`, intentionally shadowing core/now. rt.NS("music.core").Exclude("now") r := &Runtime{lg: lg, engine: engine, transport: t, queue: q, allocator: a, provider: p, patchRegistry: registry, stdout: stdout, stderr: stderr} - defs := map[string]func([]vm.Value) (vm.Value, error){"play": r.play, "release": r.release, "at": r.at, "tempo": r.tempo, "now": r.now, "stop-all": r.stopAll, "instruments": r.instrumentsFn, "note-number": r.noteNumber} + defs := map[string]func([]vm.Value) (vm.Value, error){"play": r.play, "release": r.release, "at": r.at, "tempo": r.tempo, "now": r.now, "stop-all": r.stopAll, "instruments": r.instrumentsFn, "note-number": r.noteNumber, "load-file": r.loadFile} for name, f := range defs { v, e := vm.NativeFnType.Wrap(f) if e != nil { @@ -208,6 +209,40 @@ func splitTopLevelForms(src string) []string { } func (r *Runtime) EvalScript(src string) (vm.Value, error) { return r.Eval("(do\n" + src + "\n)") } + +// loadFile provides the Clojure-compatible function used by clients such as +// brepl. It runs inside the caller's already-serialized evaluation context, so +// namespace changes persist in that nREPL session without recursively taking +// evalMu. Files are bounded to avoid accidental unbounded editor requests. +func (r *Runtime) loadFile(args []vm.Value) (vm.Value, error) { + if len(args) != 1 { + return vm.NIL, fmt.Errorf("load-file expects one path") + } + path, ok := args[0].(vm.String) + if !ok || path == "" { + return vm.NIL, fmt.Errorf("load-file path must be a non-empty string") + } + const maximumLoadFileBytes = 16 << 20 + info, err := os.Stat(string(path)) + if err != nil { + return vm.NIL, fmt.Errorf("load-file: %w", err) + } + if info.Size() > maximumLoadFileBytes { + return vm.NIL, fmt.Errorf("load-file exceeds %d-byte limit", maximumLoadFileBytes) + } + source, err := os.ReadFile(string(path)) + if err != nil { + return vm.NIL, fmt.Errorf("load-file: %w", err) + } + value := vm.Value(vm.NIL) + for _, form := range splitTopLevelForms(string(source)) { + value, err = r.lg.Run(form) + if err != nil { + return vm.NIL, err + } + } + return value, nil +} func (r *Runtime) REPL(in io.Reader, out io.Writer) error { s := bufio.NewScanner(in) var pending strings.Builder diff --git a/internal/nrepl/server_test.go b/internal/nrepl/server_test.go index fe3a751..e3b6ae0 100644 --- a/internal/nrepl/server_test.go +++ b/internal/nrepl/server_test.go @@ -168,6 +168,23 @@ func TestEvalSessionNamespaceAndMusicAPI(t *testing.T) { } } +func TestBreplStyleLoadFileEval(t *testing.T) { + _, server := startServer(t, 0) + c := dial(t, server) + path := t.TempDir() + "/brepl-file.lg" + source := "(in-ns 'music.core)\n(def brepl-file-value 40)\n(+ brepl-file-value 2)\n" + if err := os.WriteFile(path, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + responses := c.request(t, map[string]any{"op": "eval", "id": "brepl-load-file", "code": `(load-file "` + path + `")`}) + if errText := responseField(responses, "err"); errText != "" { + t.Fatalf("brepl-style load-file: %s", errText) + } + if value := responseField(responses, "value"); value != "42" { + t.Fatalf("load-file value %q in %#v", value, responses) + } +} + func TestNREPLPlayRendersAudio(t *testing.T) { a, server := startServer(t, 0) c := dial(t, server)