Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 16 additions & 1 deletion docs/nrepl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
37 changes: 36 additions & 1 deletion internal/lisp/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"math"
"math/big"
"os"
"sort"
"strings"
"sync"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions internal/nrepl/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading