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)"