From e10a44c0c2617c125285286a81a137b43a9526e7 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Tue, 3 Nov 2026 11:22:09 +0200 Subject: [PATCH] Add nbb and let-go to the compatibility matrix Both are easy to install in CI: nbb from npm and let-go from its releases. Every server in the matrix is also something proof serve can act like, so this adds scenarios for what they do differently: - no-eval-error: an error comes with err and ex, but without the eval-error status (nbb and let-go). - ansi-errors: error messages are colored with ANSI escape codes (let-go). - read-line-waits: there's no stdin op, and reading input waits until the eval is interrupted or its session closed (let-go reads the terminal it runs in, where its own REPL takes the lines). - no-version-string: versions.nrepl has the numbers of the version as strings, but no version-string (let-go). let-go has no *1, so its session-state snippet switches namespaces, which let-go shares between sessions. Its interrupt can't stop an eval (it says session-idle from another connection, and waits for the eval on the same one), and describe doesn't list it, so the interrupt checks skip. Conflicting scenarios are groups now, as there are three ways to read input without a stdin op. --- .github/workflows/compat.yml | 20 +++++++++-- CHANGELOG.md | 1 + PLAN.md | 7 +++- README.md | 8 ++--- doc/usage.md | 30 +++++++++------- internal/serve/matrix_test.go | 32 +++++++++++------ internal/serve/scenarios.go | 66 ++++++++++++++++++++++------------- internal/serve/serve.go | 41 +++++++++++++++++----- internal/serve/serve_test.go | 62 ++++++++++++++++++++++++++------ profiles/let-go.toml | 63 +++++++++++++++++++++++++++++++++ profiles/nbb.toml | 63 +++++++++++++++++++++++++++++++++ 11 files changed, 320 insertions(+), 73 deletions(-) create mode 100644 profiles/let-go.toml create mode 100644 profiles/nbb.toml diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index 07d7bea..3cb66d0 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - profile: [clojure, babashka, clojure-clr, basilisp, jank, dialtone, repartee] + profile: [clojure, babashka, nbb, clojure-clr, basilisp, jank, let-go, dialtone, repartee] steps: # The reports say which version of proof made them. Go 1.24 and newer # work it out from the tags and the commit, like for go install. @@ -48,6 +48,13 @@ jobs: with: bb: latest + - if: matrix.profile == 'nbb' + uses: actions/setup-node@v7 + with: + node-version: lts/* + - if: matrix.profile == 'nbb' + run: npm install -g nbb + - if: matrix.profile == 'clojure-clr' uses: actions/setup-dotnet@v6 with: @@ -73,6 +80,15 @@ jobs: sudo apt-get update sudo apt-get install -y jank + - if: matrix.profile == 'let-go' + env: + GH_TOKEN: ${{ github.token }} + run: | + # The binary from the latest release, which is called lg. + gh release download --repo nooga/let-go --pattern '*_linux_amd64.tar.gz' --pattern checksums.txt --dir let-go + (cd let-go && sha256sum --check --ignore-missing checksums.txt && tar xzf let-go_*_linux_amd64.tar.gz lg) + echo "$PWD/let-go" >> "$GITHUB_PATH" + - if: matrix.profile == 'dialtone' || matrix.profile == 'repartee' uses: erlef/setup-beam@v1.24.1 with: @@ -127,7 +143,7 @@ jobs: run: | # The reference implementation goes first. reports=() - for p in clojure babashka clojure-clr basilisp jank dialtone repartee; do + for p in clojure babashka nbb clojure-clr basilisp jank let-go dialtone repartee; do [ -f "reports/report-$p.json" ] && reports+=("reports/report-$p.json") done bin/proof matrix "${reports[@]}" > matrix.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0493130..022e6b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - [#18](https://github.com/nrepl/proof/pull/18): The `version` snippet, so reports and the compatibility matrix show which version of a server proof checked. - [#18](https://github.com/nrepl/proof/pull/18): `proof matrix -html`, which builds the compatibility matrix as a web page, and the daily matrix of the bundled profiles on [GitHub Pages](https://nrepl.github.io/proof/). +- [#19](https://github.com/nrepl/proof/pull/19): Profiles for nbb and let-go, and scenarios for `proof serve` that act like them. ## 0.1.0 (2026-10-26) diff --git a/PLAN.md b/PLAN.md index 841a67e..11f9842 100644 --- a/PLAN.md +++ b/PLAN.md @@ -153,7 +153,12 @@ clients. [GitHub Pages](https://nrepl.github.io/proof/), with the version of each server, which comes from the new `version` snippet. `proof matrix -html` builds the same page out of any reports. -- nbb and let-go, which are easy to install in CI. +- Done: nbb and let-go, and scenarios for what they do differently in + `proof serve`. Neither says `eval-error` when the code throws or sends + what it prints to stderr, nbb stops taking connections after a frame + that isn't a dict, and let-go shares the current namespace between + sessions and doesn't list `interrupt` in `describe` (even though it + has it). ### More Clients diff --git a/README.md b/README.md index db43df7..93a7d9f 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,10 @@ value = "3" ``` The [profiles](profiles) folder has complete profiles for nREPL itself, -Babashka, Basilisp, ClojureCLR, jank, dialtone (Erlang) and repartee -(Elixir), which you can use as a starting point. They come with proof as -well, so `proof run babashka` will check Babashka (provided you have it -installed). The [compatibility matrix](https://nrepl.github.io/proof/) +Babashka, nbb, Basilisp, ClojureCLR, jank, let-go, dialtone (Erlang) and +repartee (Elixir), which you can use as a starting point. They come with +proof as well, so `proof run babashka` will check Babashka (provided you +have it installed). The [compatibility matrix](https://nrepl.github.io/proof/) shows how all of them do, and it's updated every day. Now you can run the checks: diff --git a/doc/usage.md b/doc/usage.md index eae880b..0d6ea44 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -34,11 +34,11 @@ $ go build -o bin/proof ./cmd/proof proof needs a profile for your server. That's a small TOML file that explains how to start the server and provides a few snippets of code in its language. The [profiles](../profiles) folder has profiles for nREPL -itself, Babashka, Basilisp, ClojureCLR, jank, dialtone (Erlang) and -repartee (Elixir), and it's a good idea to start from the one that's -closest to your server. If you're working on a Clojure dialect you can -probably use `clojure.toml` almost as is, otherwise `dialtone.toml` is a -better starting point. +itself, Babashka, nbb, Basilisp, ClojureCLR, jank, let-go, dialtone +(Erlang) and repartee (Elixir), and it's a good idea to start from the +one that's closest to your server. If you're working on a Clojure +dialect you can probably use `clojure.toml` almost as is, otherwise +`dialtone.toml` is a better starting point. The profiles in this folder come with proof, so you can also use them by name instead of passing a file (e.g. `proof run babashka`). If you've @@ -354,20 +354,24 @@ the scenarios, and every one of them is something a real server does |---|---|---| | `split-output` | Output comes one character per message | nREPL splits long output, Basilisp sends `println`'s newline on its own | | `empty-messages` | Replies to `eval` include messages with nothing but `id` and `session` | jank | -| `last-value` | Only the value of the last form is sent | Basilisp, jank, dialtone | -| `no-err` | What the code prints to stderr never reaches the client | Basilisp, dialtone, repartee | +| `last-value` | Only the value of the last form is sent | Basilisp, jank, let-go, dialtone | +| `no-err` | What the code prints to stderr never reaches the client | Basilisp, nbb, let-go, dialtone, repartee | | `error-with-done` | `eval-error` comes in the same message as `done` | jank | -| `no-op-echo` | Replies to unknown ops don't say which op it was | ClojureCLR, Basilisp, jank, dialtone, repartee | +| `no-eval-error` | An error comes with `err` and `ex`, but without the `eval-error` status | nbb, let-go | +| `ansi-errors` | Error messages are colored with ANSI escape codes | let-go | +| `no-op-echo` | Replies to unknown ops don't say which op it was | ClojureCLR, Basilisp, jank, nbb, let-go, dialtone, repartee | | `no-close-op` | `describe` doesn't list `close`, even though `close` works | jank | -| `no-interrupt` | There's no `interrupt` op | ClojureCLR, Basilisp, jank | +| `no-interrupt` | There's no `interrupt` op | ClojureCLR, Basilisp, jank, nbb, and let-go doesn't list it, nor stop an eval with it | | `no-stdin` | There's no `stdin` op, and reading input gets `nil` right away | ClojureCLR and Basilisp started in the background, as they read their own stdin, where Basilisp gets an empty string | -| `read-line-throws` | There's no `stdin` op, and reading input throws | jank | +| `read-line-throws` | There's no `stdin` op, and reading input throws | jank, and nbb, which has no `read-line` | +| `read-line-waits` | There's no `stdin` op, and reading input waits until the eval is interrupted or its session closed | let-go, which reads the terminal it runs in, where its own REPL takes the lines | | `string-versions` | `versions.proof` is a plain string rather than a dict | Babashka for `versions.babashka`, ClojureCLR for `versions.clojure.tools.nrepl` | +| `no-version-string` | `versions` has `proof` and `nrepl` with the numbers of their versions as strings, but no `version-string` | let-go for `versions.let-go` and `versions.nrepl` | | `no-session-closed` | `close` replies with `done` alone | Basilisp, jank, dialtone, repartee | -| `shared-state` | Sessions on the same connection share `*1`, `*e` and the current namespace | ClojureCLR, Basilisp, jank | +| `shared-state` | Sessions on the same connection share `*1`, `*e` and the current namespace | ClojureCLR, Basilisp, jank, nbb, and let-go for the current namespace | | `socket-sessions` | A session only exists on the connection that cloned it | ClojureCLR, Basilisp, jank | -| `any-session` | Requests for sessions that don't exist run in a new session | ClojureCLR, Basilisp, jank | -| `ns-fallback` | An eval in a namespace that doesn't exist runs in the current one | jank | +| `any-session` | Requests for sessions that don't exist run in a new session | ClojureCLR, Basilisp, jank, nbb, let-go | +| `ns-fallback` | An eval in a namespace that doesn't exist runs in the current one | jank, nbb, let-go | | `ns-error` | An eval in a namespace that doesn't exist fails without `namespace-not-found` | Basilisp | | `eof-error` | Reading past the end of input fails instead of returning `nil` | nREPL 1.7.0 | | `late-exception` | An interrupted eval's exception comes after its `done`, and the rest of its code still runs | nREPL 1.8.0 and Babashka | diff --git a/internal/serve/matrix_test.go b/internal/serve/matrix_test.go index c115c5b..8c54f8c 100644 --- a/internal/serve/matrix_test.go +++ b/internal/serve/matrix_test.go @@ -72,6 +72,10 @@ var noInterrupts = func() map[string]check.Verdict { return v }() +// noStdin is what servers without stdin get from the stdin checks. +var noStdin = map[string]check.Verdict{"stdin.need-input": check.Skipped, "stdin.roundtrip": check.Skipped, + "stdin.eof": check.Skipped} + // proof serve has nREPL's completions and lookup, but not the ops of // cider-nrepl that clients prefer. var noCiderOps = map[string]check.Verdict{"completion.complete": check.Skipped, "completion.complete-symbol": check.Skipped, @@ -85,7 +89,6 @@ func TestPassesEveryServerCheck(t *testing.T) { // verdicts they get in the compatibility matrix. func TestScenariosGetTheVerdictsOfTheirServers(t *testing.T) { F, W, S := check.Failed, check.Warned, check.Skipped - noStdin := map[string]check.Verdict{"stdin.need-input": S, "stdin.roundtrip": S, "stdin.eof": S} cases := []struct { scenarios []string want map[string]check.Verdict @@ -97,12 +100,16 @@ func TestScenariosGetTheVerdictsOfTheirServers(t *testing.T) { {[]string{"last-value"}, map[string]check.Verdict{"eval.multiple-forms": F, "fireplace.connect": F}}, {[]string{"no-err"}, map[string]check.Verdict{"eval.stderr": F}}, {[]string{"error-with-done"}, nil}, + {[]string{"no-eval-error"}, map[string]check.Verdict{"eval.error-status": F, "load-file.error": F}}, + {[]string{"ansi-errors"}, map[string]check.Verdict{"eval.error-report": W}}, {[]string{"no-op-echo"}, map[string]check.Verdict{"op.unknown-echo": W}}, {[]string{"no-close-op"}, map[string]check.Verdict{"describe.required-ops": F}}, {[]string{"no-interrupt"}, noInterrupts}, {[]string{"no-stdin"}, noStdin}, {[]string{"read-line-throws"}, noStdin}, + {[]string{"read-line-waits"}, noStdin}, {[]string{"string-versions"}, nil}, + {[]string{"no-version-string"}, map[string]check.Verdict{"describe.versions": W}}, {[]string{"no-session-closed"}, map[string]check.Verdict{"session.close": F}}, {[]string{"shared-state"}, map[string]check.Verdict{"session.isolated": F}}, {[]string{"socket-sessions"}, map[string]check.Verdict{"session.across-connections": W, @@ -144,16 +151,21 @@ func TestPresetsGetTheColumnsOfTheirServers(t *testing.T) { columns := map[string]map[string]check.Verdict{ "clojure": nreplInterrupts, "babashka": nreplInterrupts, - "clojure-clr": checktest.Merged(noJava, noInterrupts, map[string]check.Verdict{"op.unknown-echo": W, "session.across-connections": W, - "session.isolated": F, "session.unknown": F, "session.closed": F, "stdin.need-input": S, - "stdin.roundtrip": S, "stdin.eof": S}), - "basilisp": checktest.Merged(noJava, noInterrupts, map[string]check.Verdict{"op.unknown-echo": W, "session.across-connections": W, - "session.isolated": F, "session.close": F, "session.unknown": F, "session.closed": F, "eval.stderr": F, - "eval.multiple-forms": F, "eval.unknown-ns": F, "stdin.need-input": S, "stdin.roundtrip": S, "stdin.eof": S}), - "jank": checktest.Merged(noJava, noInterrupts, map[string]check.Verdict{"describe.required-ops": F, "op.unknown-echo": W, + "nbb": checktest.Merged(noJava, noInterrupts, noStdin, map[string]check.Verdict{"op.unknown-echo": W, + "session.isolated": F, "session.unknown": F, "session.closed": F, "eval.stderr": F, "eval.error-status": F, + "eval.unknown-ns": F, "load-file.error": F}), + "clojure-clr": checktest.Merged(noJava, noInterrupts, noStdin, map[string]check.Verdict{"op.unknown-echo": W, + "session.across-connections": W, "session.isolated": F, "session.unknown": F, "session.closed": F}), + "basilisp": checktest.Merged(noJava, noInterrupts, noStdin, map[string]check.Verdict{"op.unknown-echo": W, "session.across-connections": W, "session.isolated": F, "session.close": F, "session.unknown": F, - "session.closed": F, "eval.multiple-forms": F, "eval.unknown-ns": F, "stdin.need-input": S, - "stdin.roundtrip": S, "stdin.eof": S, "wire.canonical": W}), + "session.closed": F, "eval.stderr": F, "eval.multiple-forms": F, "eval.unknown-ns": F}), + "jank": checktest.Merged(noJava, noInterrupts, noStdin, map[string]check.Verdict{"describe.required-ops": F, + "op.unknown-echo": W, "session.across-connections": W, "session.isolated": F, "session.close": F, + "session.unknown": F, "session.closed": F, "eval.multiple-forms": F, "eval.unknown-ns": F, "wire.canonical": W}), + "let-go": checktest.Merged(noJava, noInterrupts, noStdin, map[string]check.Verdict{"describe.versions": W, + "op.unknown-echo": W, "session.isolated": F, "session.unknown": F, "session.closed": F, "eval.stderr": F, + "eval.error-status": F, "eval.error-report": W, "eval.multiple-forms": F, "eval.unknown-ns": F, + "load-file.error": F}), "dialtone": checktest.Merged(noJava, noLanguage, map[string]check.Verdict{"op.unknown-echo": W, "session.close": F, "eval.stderr": F, "eval.multiple-forms": F}), "repartee": checktest.Merged(noJava, noLanguage, map[string]check.Verdict{"op.unknown-echo": W, "session.close": F, diff --git a/internal/serve/scenarios.go b/internal/serve/scenarios.go index 7653f7d..91df8bf 100644 --- a/internal/serve/scenarios.go +++ b/internal/serve/scenarios.go @@ -20,7 +20,9 @@ type Scenario struct { // behavior is the set of scenarios a server runs with. type behavior struct { splitOutput, emptyMessages, lastValue, noErr, errorWithDone bool - noOpEcho, noCloseOp, noInterrupt, stringVersions bool + noEvalError, ansiErrors bool + noOpEcho, noCloseOp, noInterrupt bool + stringVersions, noVersionString bool noSessionClosed, sharedState, socketSessions, anySession bool nsFallback, nsError, eofError bool dropQueued, interruptElsewhere, lateException bool @@ -35,9 +37,10 @@ const ( // askForInput is what nREPL does: ask with need-input, then read what // the stdin op sends. askForInput stdinMode = iota - // The other two are for servers without a stdin op. + // The others are for servers without a stdin op. endOfInput throwOnRead + waitForTerminal ) // scenario is a Scenario, the behavior it sets, and the profiles of the @@ -54,36 +57,49 @@ var catalog = []scenario{ func(b *behavior) { b.splitOutput = true }, []string{"basilisp"}}, {Scenario{"empty-messages", "Replies to eval include messages with nothing but id and session", "jank"}, func(b *behavior) { b.emptyMessages = true }, []string{"jank"}}, - {Scenario{"last-value", "Only the value of the last form is sent", "Basilisp, jank, dialtone"}, - func(b *behavior) { b.lastValue = true }, []string{"basilisp", "jank", "dialtone"}}, - {Scenario{"no-err", "What the code prints to stderr never reaches the client", "Basilisp, dialtone, repartee"}, - func(b *behavior) { b.noErr = true }, []string{"basilisp", "dialtone", "repartee"}}, + {Scenario{"last-value", "Only the value of the last form is sent", "Basilisp, jank, let-go, dialtone"}, + func(b *behavior) { b.lastValue = true }, []string{"basilisp", "jank", "let-go", "dialtone"}}, + {Scenario{"no-err", "What the code prints to stderr never reaches the client", "Basilisp, nbb, let-go, dialtone, repartee"}, + func(b *behavior) { b.noErr = true }, []string{"basilisp", "nbb", "let-go", "dialtone", "repartee"}}, {Scenario{"error-with-done", "eval-error comes in the same message as done", "jank"}, func(b *behavior) { b.errorWithDone = true }, []string{"jank"}}, - {Scenario{"no-op-echo", "Replies to unknown ops don't say which op it was", "ClojureCLR, Basilisp, jank, dialtone, repartee"}, - func(b *behavior) { b.noOpEcho = true }, []string{"clojure-clr", "basilisp", "jank", "dialtone", "repartee"}}, + {Scenario{"no-eval-error", "An error comes with err and ex, but without the eval-error status", "nbb, let-go"}, + func(b *behavior) { b.noEvalError = true }, []string{"nbb", "let-go"}}, + {Scenario{"ansi-errors", "Error messages are colored with ANSI escape codes", "let-go"}, + func(b *behavior) { b.ansiErrors = true }, []string{"let-go"}}, + {Scenario{"no-op-echo", "Replies to unknown ops don't say which op it was", + "ClojureCLR, Basilisp, jank, nbb, let-go, dialtone, repartee"}, + func(b *behavior) { b.noOpEcho = true }, []string{"clojure-clr", "basilisp", "jank", "nbb", "let-go", "dialtone", "repartee"}}, {Scenario{"no-close-op", "describe doesn't list close, even though close works", "jank"}, func(b *behavior) { b.noCloseOp = true }, []string{"jank"}}, - {Scenario{"no-interrupt", "There's no interrupt op", "ClojureCLR, Basilisp, jank"}, - func(b *behavior) { b.noInterrupt = true }, []string{"clojure-clr", "basilisp", "jank"}}, + {Scenario{"no-interrupt", "There's no interrupt op", + "ClojureCLR, Basilisp, jank, nbb, and let-go doesn't list it, nor stop an eval with it"}, + func(b *behavior) { b.noInterrupt = true }, []string{"clojure-clr", "basilisp", "jank", "nbb", "let-go"}}, {Scenario{"no-stdin", "There's no stdin op, and reading input gets nil right away", "ClojureCLR and Basilisp started in the background, as they read their own stdin, where Basilisp gets an empty string"}, func(b *behavior) { b.stdin = endOfInput }, []string{"clojure-clr", "basilisp"}}, - {Scenario{"read-line-throws", "There's no stdin op, and reading input throws", "jank"}, - func(b *behavior) { b.stdin = throwOnRead }, []string{"jank"}}, + {Scenario{"read-line-throws", "There's no stdin op, and reading input throws", "jank, and nbb, which has no read-line"}, + func(b *behavior) { b.stdin = throwOnRead }, []string{"jank", "nbb"}}, + {Scenario{"read-line-waits", "There's no stdin op, and reading input waits until the eval is interrupted or its session closed", + "let-go, which reads the terminal it runs in, where its own REPL takes the lines"}, + func(b *behavior) { b.stdin = waitForTerminal }, []string{"let-go"}}, {Scenario{"string-versions", "versions.proof is a plain string rather than a dict", "Babashka for versions.babashka, ClojureCLR for versions.clojure.tools.nrepl"}, func(b *behavior) { b.stringVersions = true }, []string{"babashka", "clojure-clr"}}, + {Scenario{"no-version-string", "versions has proof and nrepl with the numbers of their versions as strings, but no version-string", + "let-go for versions.let-go and versions.nrepl"}, + func(b *behavior) { b.noVersionString = true }, []string{"let-go"}}, {Scenario{"no-session-closed", "close replies with done alone, without session-closed", "Basilisp, jank, dialtone, repartee"}, func(b *behavior) { b.noSessionClosed = true }, []string{"basilisp", "jank", "dialtone", "repartee"}}, - {Scenario{"shared-state", "Sessions on the same connection share *1, *e and the current namespace", "ClojureCLR, Basilisp, jank"}, - func(b *behavior) { b.sharedState = true }, []string{"clojure-clr", "basilisp", "jank"}}, + {Scenario{"shared-state", "Sessions on the same connection share *1, *e and the current namespace", + "ClojureCLR, Basilisp, jank, nbb, and let-go for the current namespace"}, + func(b *behavior) { b.sharedState = true }, []string{"clojure-clr", "basilisp", "jank", "nbb", "let-go"}}, {Scenario{"socket-sessions", "A session only exists on the connection that cloned it", "ClojureCLR, Basilisp, jank"}, func(b *behavior) { b.socketSessions = true }, []string{"clojure-clr", "basilisp", "jank"}}, - {Scenario{"any-session", "Requests for sessions that don't exist run in a new session", "ClojureCLR, Basilisp, jank"}, - func(b *behavior) { b.anySession = true }, []string{"clojure-clr", "basilisp", "jank"}}, - {Scenario{"ns-fallback", "An eval in a namespace that doesn't exist runs in the current one", "jank"}, - func(b *behavior) { b.nsFallback = true }, []string{"jank"}}, + {Scenario{"any-session", "Requests for sessions that don't exist run in a new session", "ClojureCLR, Basilisp, jank, nbb, let-go"}, + func(b *behavior) { b.anySession = true }, []string{"clojure-clr", "basilisp", "jank", "nbb", "let-go"}}, + {Scenario{"ns-fallback", "An eval in a namespace that doesn't exist runs in the current one", "jank, nbb, let-go"}, + func(b *behavior) { b.nsFallback = true }, []string{"jank", "nbb", "let-go"}}, {Scenario{"ns-error", "An eval in a namespace that doesn't exist fails without namespace-not-found", "Basilisp"}, func(b *behavior) { b.nsError = true }, []string{"basilisp"}}, {Scenario{"eof-error", "Reading past the end of input fails instead of returning nil", "nREPL 1.7.0"}, @@ -123,7 +139,7 @@ type Preset struct { } // servers are the profiles of the servers with scenarios. -var servers = []string{"clojure", "babashka", "clojure-clr", "basilisp", "jank", "dialtone", "repartee"} +var servers = []string{"clojure", "babashka", "nbb", "clojure-clr", "basilisp", "jank", "let-go", "dialtone", "repartee"} // Presets lists every server proof serve can behave like. func Presets() []Preset { @@ -150,8 +166,10 @@ func Like(name string, extra []string) ([]string, error) { return append(presets[i].Scenarios, extra...), nil } -// conflicts are scenarios that can't be combined. -var conflicts = [][2]string{{"ns-fallback", "ns-error"}, {"byte-writes", "batched-writes"}, {"no-stdin", "read-line-throws"}} +// conflicts are groups of scenarios that can't be combined, as each of +// them does the same thing differently. +var conflicts = [][]string{{"ns-fallback", "ns-error"}, {"byte-writes", "batched-writes"}, + {"no-stdin", "read-line-throws", "read-line-waits"}, {"string-versions", "no-version-string"}} func behave(names []string) (behavior, error) { var b behavior @@ -164,9 +182,9 @@ func behave(names []string) (behavior, error) { catalog[i].set(&b) chosen[name] = true } - for _, pair := range conflicts { - if chosen[pair[0]] && chosen[pair[1]] { - return b, fmt.Errorf("scenarios %s don't go together", strings.Join(pair[:], " and ")) + for _, group := range conflicts { + if both := slices.DeleteFunc(slices.Clone(group), func(name string) bool { return !chosen[name] }); len(both) > 1 { + return b, fmt.Errorf("scenarios %s don't go together", strings.Join(both, " and ")) } } return b, nil diff --git a/internal/serve/serve.go b/internal/serve/serve.go index dadcd29..492e049 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -283,13 +283,22 @@ func (s *Server) describe() map[string]any { listed[op] = map[string]any{} } } - var version any = s.version - if !s.b.stringVersions { - version = versionDict(s.version) + versions := map[string]any{"proof": versionDict(s.version)} + switch { + case s.b.stringVersions: + versions["proof"] = s.version + case s.b.noVersionString: + // What let-go says, for itself and for nREPL. + d := versionDict(s.version) + delete(d, "version-string") + for k, v := range d { + d[k] = fmt.Sprint(v) + } + versions = map[string]any{"proof": d, "nrepl": d} } return map[string]any{ "ops": listed, - "versions": map[string]any{"proof": version}, + "versions": versions, "aux": map[string]any{"current-ns": "user"}, "status": done, } @@ -436,12 +445,23 @@ func (s *Server) eval(c *conn, req nrepl.Message, sess *session) { // that ends the eval. In error-with-done that message carries the error. func (s *Server) fail(reply func(map[string]any), t *thrown, ns string) map[string]any { text, ex, rootEx := errorReport(t, ns) + if s.b.ansiErrors { + // let-go's bold red. + text = "\x1b[1;31merror:\x1b[0m " + text + } reply(map[string]any{"err": text}) - fields := map[string]any{"ex": ex, "root-ex": rootEx, "status": []any{"eval-error"}} + fields := map[string]any{"ex": ex, "root-ex": rootEx} + status := []any{"eval-error"} + if s.b.noEvalError { + status = nil + } if s.b.errorWithDone { - fields["status"] = []any{"eval-error", "done"} + fields["status"] = append(status, "done") return fields } + if status != nil { + fields["status"] = status + } reply(fields) return map[string]any{"status": done} } @@ -489,15 +509,20 @@ func (s *Server) runEval(sess *session, j *job) { } }, readLine: func(interrupts <-chan struct{}) (any, error) { + ask := func() { reply(map[string]any{"status": []any{"need-input"}}) } switch s.b.stdin { case throwOnRead: - // What jank says. + // What jank says. nbb has no read-line at all. return nil, throwf("clojure.lang.ExceptionInfo", "TODO: port read-line") case endOfInput: // The end of the server's own stdin. return nil, nil + case waitForTerminal: + // A line from the terminal, which never comes, as there's no + // stdin op to send one. What's been printed goes out first. + ask = c.flush } - line, ok, err := sess.readLine(s.ctx, interrupts, func() { reply(map[string]any{"status": []any{"need-input"}}) }) + line, ok, err := sess.readLine(s.ctx, interrupts, ask) switch { case err != nil: return nil, err diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index 4621a22..933f4ef 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -419,6 +419,12 @@ func TestScenarioShapes(t *testing.T) { }}, {"last-value", "1 2 3", func(r nrepl.Response) bool { return slices.Equal(r.Values(), []string{"3"}) }}, {"last-value", "1 (/ 1 0) 3", func(r nrepl.Response) bool { return len(r.Values()) == 0 && r.HasStatus("eval-error") }}, + {"no-eval-error", "(/ 1 0)", func(r nrepl.Response) bool { + return r.Err() != "" && r.First("ex").Has("ex") && !r.HasStatus("eval-error") + }}, + {"ansi-errors", "(/ 1 0)", func(r nrepl.Response) bool { + return strings.HasPrefix(r.Err(), "\x1b[1;31merror:\x1b[0m Execution error") && r.HasStatus("eval-error") + }}, } for _, tc := range cases { t.Run(tc.scenario, func(t *testing.T) { @@ -431,16 +437,21 @@ func TestScenarioShapes(t *testing.T) { } func TestDescribe(t *testing.T) { + dict := map[string]any{"major": int64(0), "minor": int64(1), "incremental": int64(0), "version-string": "0.1.0-dev"} + strings := map[string]any{"major": "0", "minor": "1", "incremental": "0"} cases := []struct { scenarios []string ops []string - version any + versions map[string]any }{ {nil, []string{"clone", "close", "completions", "describe", "eval", "interrupt", "load-file", "lookup", "stdin"}, - map[string]any{"major": int64(0), "minor": int64(1), "incremental": int64(0), "version-string": "0.1.0-dev"}}, - {[]string{"no-close-op", "no-interrupt", "no-stdin", "string-versions"}, []string{"clone", "completions", "describe", "eval", "load-file", "lookup"}, "0.1.0-dev"}, + map[string]any{"proof": dict}}, + {[]string{"no-close-op", "no-interrupt", "no-stdin", "string-versions"}, []string{"clone", "completions", "describe", "eval", "load-file", "lookup"}, + map[string]any{"proof": "0.1.0-dev"}}, {[]string{"read-line-throws"}, []string{"clone", "close", "completions", "describe", "eval", "interrupt", "load-file", "lookup"}, - map[string]any{"major": int64(0), "minor": int64(1), "incremental": int64(0), "version-string": "0.1.0-dev"}}, + map[string]any{"proof": dict}}, + {[]string{"no-version-string"}, []string{"clone", "close", "completions", "describe", "eval", "interrupt", "load-file", "lookup", "stdin"}, + map[string]any{"proof": strings, "nrepl": strings}}, } for _, tc := range cases { c := connect(t, serveFor(t, tc.scenarios...)) @@ -449,15 +460,15 @@ func TestDescribe(t *testing.T) { if got := slices.Sorted(maps.Keys(ops)); !slices.Equal(got, tc.ops) { t.Errorf("%v: ops %v, want %v", tc.scenarios, got, tc.ops) } - versions, _ := r.Get("versions").(map[string]any) - if !reflect.DeepEqual(versions["proof"], tc.version) { - t.Errorf("%v: versions %v", tc.scenarios, versions) + if versions := r.Get("versions"); !reflect.DeepEqual(versions, tc.versions) { + t.Errorf("%v: versions %v, want %v", tc.scenarios, versions, tc.versions) } } } func TestOpsTheScenariosTakeAway(t *testing.T) { - for scenario, op := range map[string]string{"no-interrupt": "interrupt", "no-stdin": "stdin", "read-line-throws": "stdin"} { + for scenario, op := range map[string]string{"no-interrupt": "interrupt", "no-stdin": "stdin", "read-line-throws": "stdin", + "read-line-waits": "stdin"} { c := connect(t, serveFor(t, scenario)) if r := request(t, c, nrepl.Message{"op": op, "session": clone(t, c)}); !r.HasStatus("unknown-op") { t.Errorf("%s: %s got %v", scenario, op, r.Status()) @@ -583,10 +594,39 @@ func TestBrokenFramesEndTheConnection(t *testing.T) { } } +// With read-line-waits, reading input waits for a line that never comes, +// without asking for it, until the eval is interrupted. What the code +// printed before goes out first, even when replies are batched. +func TestReadingInputWaits(t *testing.T) { + c := connect(t, serveFor(t, "read-line-waits", "batched-writes")) + session := clone(t, c) + id, err := c.Send(nrepl.Message{"op": "eval", "code": `(do (print "reading") (read-line))`, "session": session}) + if err != nil { + t.Fatal(err) + } + if _, err := c.WaitFor(func(m nrepl.Message) bool { return m.Str("id") == id && m.Has("out") }, timeout); err != nil { + t.Fatal(err) + } + if _, err := c.WaitFor(func(m nrepl.Message) bool { return m.Str("id") == id && !m.Has("out") }, 100*time.Millisecond); err == nil { + t.Fatalf("the eval went on: %v", received(c, id).Messages) + } + request(t, c, nrepl.Message{"op": "interrupt", "session": session, "interrupt-id": id}) + if r, err := c.Collect(id, timeout); err != nil || !r.HasStatus("interrupted") { + t.Errorf("interrupting the eval: %v %v", r.Messages, err) + } +} + func TestScenarioNames(t *testing.T) { - for _, names := range [][]string{{"no-such-thing"}, {"ns-fallback", "ns-error"}, {"byte-writes", "batched-writes"}} { - if _, err := Listen("127.0.0.1:0", "0", names); err == nil { - t.Errorf("%v: no error", names) + if _, err := Listen("127.0.0.1:0", "0", []string{"no-such-thing"}); err == nil { + t.Error("no error for a scenario that doesn't exist") + } + for _, group := range conflicts { + for i, a := range group { + for _, b := range group[i+1:] { + if _, err := behave([]string{a, b}); err == nil { + t.Errorf("%s and %s: no error", a, b) + } + } } } for _, sc := range Scenarios() { diff --git a/profiles/let-go.toml b/profiles/let-go.toml new file mode 100644 index 0000000..affe877 --- /dev/null +++ b/profiles/let-go.toml @@ -0,0 +1,63 @@ +# let-go's nREPL server, for a Clojure dialect written in Go. Needs lg +# (from a release of let-go) on PATH. lg -n starts the terminal REPL as +# well, which reads the stdin proof keeps open. +name = "let-go" +homepage = "https://github.com/nooga/let-go" + +[launch] +command = ["lg", "-n", "-p", "0"] +port-pattern = 'nREPL server started on port (\d+)' +startup-timeout = "30s" + +[capabilities] +namespaces = true +clojure = true + +[snippets.value] +code = "(+ 1 2)" +value = "3" + +[snippets.stdout] +code = '(print "proof")' +out = "proof" + +[snippets.stderr] +code = '(binding [*out* *err*] (print "proof"))' +err = "proof" + +[snippets.throw] +code = '(throw (ex-info "proof" {}))' + +[snippets.define] +code = "(def proof-answer 42)" +use = "proof-answer" +value = "42" + +# Leaves session-local state behind. There's no *1, but the current +# namespace belongs to the session in nREPL. +[snippets.session-state] +code = "(in-ns 'proof-marker)" +use = "(ns-name *ns*)" +value = "proof-marker" + +[snippets.multiple] +code = "1 2" +values = ["1", "2"] + +# Prints something and then runs until it's interrupted. +[snippets.sleep] +code = '(do (print "proof") (sleep 60000))' +out = "proof" + +# A prefix, and something it completes to. +[snippets.completion] +code = "printl" +value = "println" + +# A function to look up. +[snippets.lookup] +code = "println" + +# The version of let-go, for the report. +[snippets.version] +code = '(System/getProperty "let-go.version")' diff --git a/profiles/nbb.toml b/profiles/nbb.toml new file mode 100644 index 0000000..d47e406 --- /dev/null +++ b/profiles/nbb.toml @@ -0,0 +1,63 @@ +# nbb's nREPL server, ClojureScript on Node.js. Needs nbb on PATH: +# +# npm install -g nbb +name = "nbb" +homepage = "https://github.com/babashka/nbb" + +[launch] +command = ["nbb", "nrepl-server", ":port", "0"] +port-pattern = 'nREPL server started on port (\d+)' +startup-timeout = "30s" + +[capabilities] +namespaces = true +clojure = true + +[snippets.value] +code = "(+ 1 2)" +value = "3" + +[snippets.stdout] +code = '(print "proof")' +out = "proof" + +[snippets.stderr] +code = '(binding [*print-fn* *print-err-fn*] (print "proof"))' +err = "proof" + +[snippets.throw] +code = '(throw (ex-info "proof" {}))' + +[snippets.define] +code = "(def proof-answer 42)" +use = "proof-answer" +value = "42" + +# Leaves session-local state behind (*1 is per session). +[snippets.session-state] +code = ":proof-marker" +use = "*1" +value = ":proof-marker" + +[snippets.multiple] +code = "1 2" +values = ["1", "2"] + +# Prints something and then runs until it's interrupted. nbb waits for +# a promise before it replies. +[snippets.sleep] +code = '(do (print "proof") (js/Promise. (fn [resolve] (js/setTimeout resolve 60000))))' +out = "proof" + +# A prefix, and something it completes to. +[snippets.completion] +code = "printl" +value = "println" + +# A function to look up. +[snippets.lookup] +code = "println" + +# The version of nbb, for the report. +[snippets.version] +code = "(nbb.core/version)"