From f1112f6fbefc0b21537efe5ff6b70e85f6ce84b3 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Fri, 23 Oct 2026 17:06:30 +0300 Subject: [PATCH] Check what clients need from completions and lookups CIDER, Calva, Conjure and vim-fireplace complete code and look symbols up with nREPL's completions and lookup or cider-nrepl's complete, info and eldoc, each with its own parameters and its own way of reading the replies. The new checks send what each of them sends to whichever of those ops a server lists, and fail where a client breaks: completions that aren't in the message with done, candidates that aren't dicts, info without no-info for a symbol that doesn't exist (which takes CIDER's docs down), columns counting from 0, files that aren't URIs. The checks of completions and lookup skip when the server also has complete and info, as clients use those instead. proof serve gets nREPL's completions and lookup for its piece of Clojure. --- CHANGELOG.md | 2 + PLAN.md | 19 ++-- README.md | 6 +- doc/design.md | 8 +- doc/hacking.md | 4 +- doc/profiles.md | 12 +++ doc/spec-changes.md | 16 +++ doc/usage.md | 6 +- internal/checks/all.go | 2 + internal/checks/checks_test.go | 70 ++++++++++--- internal/checks/completion.go | 151 ++++++++++++++++++++++++++++ internal/checks/fake_test.go | 103 ++++++++++++++++++- internal/checks/lookup.go | 174 +++++++++++++++++++++++++++++++++ internal/checks/refs.go | 21 ++++ internal/checks/stdin.go | 19 +++- internal/serve/eval.go | 16 ++- internal/serve/lookups.go | 93 ++++++++++++++++++ internal/serve/matrix_test.go | 28 +++++- internal/serve/serve.go | 6 +- internal/serve/serve_test.go | 35 ++++++- profiles/babashka.toml | 9 ++ profiles/basilisp.toml | 9 ++ profiles/clojure-clr.toml | 9 ++ profiles/clojure.toml | 9 ++ profiles/dialtone.toml | 9 ++ profiles/jank.toml | 9 ++ profiles/repartee.toml | 9 ++ 27 files changed, 804 insertions(+), 50 deletions(-) create mode 100644 internal/checks/completion.go create mode 100644 internal/checks/lookup.go create mode 100644 internal/serve/lookups.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 93a14e3..8a5c3ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,3 +32,5 @@ Initial release. scenarios for nREPL's interrupt bugs in `proof serve`. - [#12](https://github.com/nrepl/proof/pull/12): Checks for `load-file`, and `load-file` in `proof serve`. +- [#13](https://github.com/nrepl/proof/pull/13): Checks for completions and + lookups, and nREPL's `completions` and `lookup` in `proof serve`. diff --git a/PLAN.md b/PLAN.md index 75b60ff..9834e3c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -101,14 +101,17 @@ lookups and completions, plus requests that go wrong. ### Lookups and Completions -- Checks for nREPL's `lookup` and `completions` and for cider-nrepl's - `info`, `complete` and `eldoc`, for servers that list them in - `describe`. nREPL has the former, Basilisp the latter and Babashka - both. CIDER asks for cider-nrepl's ops by their `cider/` names, falls - back to the old names and then to nREPL's ops, and the checks should - do the same. -- Client profile checks for the completion and documentation requests of - CIDER and Calva. +- Done: checks for nREPL's `completions` and `lookup` and cider-nrepl's + `complete`, `info` and `eldoc`, for servers that list them, sending + CIDER's `prefix` and `sym` and the `symbol` of Calva, Conjure and + vim-fireplace. The checks of `completions` and `lookup` skip on + servers that have `complete` and `info`, as clients use those then. + Babashka, Basilisp and ClojureCLR don't say `no-info` for a symbol + that doesn't exist, Basilisp counts columns from 0 and ClojureCLR's + `file` isn't a URI, which Calva needs to jump to a definition. +- Done: `proof serve` has nREPL's `completions` and `lookup`. +- Not done: scenarios for cider-nrepl's ops in `proof serve`, so `-like` + could act like Babashka, Basilisp and ClojureCLR there too. - [spec#9](https://github.com/nrepl/spec.nrepl.org/pull/9) (`arglists-str` and `no-info`) should be settled by then. diff --git a/README.md b/README.md index e15da34..19f946b 100644 --- a/README.md +++ b/README.md @@ -131,14 +131,14 @@ the replies). `-like jank` turns on all of jank's scenarios at once, and ## Status proof is still in its early days. Right now it covers the core of the -protocol (`describe`, unknown ops, `eval`, sessions, `stdin`, `interrupt`, `load-file` and the wire format), +protocol (`describe`, unknown ops, `eval`, sessions, `stdin`, `interrupt`, `load-file`, completions, lookups and the wire format), what CIDER, Calva, Conjure, vim-fireplace, REPLy and neorepl send while connecting, evaluating code and loading files, the requests clients send and the server differences clients have to deal with, and `proof list` will show you all the checks. -Checks for lookups, completions and malformed requests are coming next, -along with the first release. +Checks for malformed requests are coming next, along with the first +release. [PLAN.md](PLAN.md) has all the details. ## Why "proof"? diff --git a/doc/design.md b/doc/design.md index 9c76d5c..379a8c9 100644 --- a/doc/design.md +++ b/doc/design.md @@ -193,8 +193,9 @@ into, and the tests make sure it stays that way. proof runs its own checks against every scenario, and a scenario has to get the same verdicts as the servers it names. `-like` turns on all the scenarios of a server, and together they have to get that server's column of the matrix -(apart from `eval.no-code`, which no client sends, and the checks that -come down to the server's language). +(apart from `eval.no-code`, which no client sends, the checks that come +down to the server's language and those of completions and lookups, +which are nREPL's in proof serve). The grading works the other way around here. proof can't see what a client shows its users, so the client's own tests have to do that. Most @@ -306,6 +307,7 @@ to have a single test suite that every server can be checked against. ## Future Plans At this point proof covers the core of the protocol (`describe`, unknown -ops, sessions, `eval`, `stdin`, `interrupt`, `load-file` and the wire format), the requests of +ops, sessions, `eval`, `stdin`, `interrupt`, `load-file`, completions, +lookups and the wire format), the requests of clients and the server differences clients have to deal with. [PLAN.md](../PLAN.md) has what's planned next. diff --git a/doc/hacking.md b/doc/hacking.md index 7eafce9..5c6d7f6 100644 --- a/doc/hacking.md +++ b/doc/hacking.md @@ -134,8 +134,8 @@ Here's what each of its fields means: | `Requires` | The checks this check depends on. Everything that creates a session requires `session.clone`, and everything that looks at the response of `describe` requires `describe.reply`. | Checks are grouped by area in `describe.go`, `op.go`, `session.go`, -`eval.go`, `stdin.go` and `interrupt.go`, and they run in the order -listed in `all.go`. If you're adding +`eval.go`, `stdin.go`, `interrupt.go`, `loadfile.go`, `completion.go` +and `lookup.go`, and they run in the order listed in `all.go`. If you're adding a new area, put its checks in a new file and add them to `all.go`. In `Run` a check talks to the server via `t`: diff --git a/doc/profiles.md b/doc/profiles.md index c41458c..6625f39 100644 --- a/doc/profiles.md +++ b/doc/profiles.md @@ -65,6 +65,13 @@ eof = "nil" code = '(do (print "proof") (flush) (Thread/sleep 60000))' out = "proof" +[snippets.completion] +code = "printl" +value = "println" + +[snippets.lookup] +code = "println" + [expected-failures] "eval.multiple-forms" = "only the last value is returned, see #42" ``` @@ -139,6 +146,8 @@ snippet is missing, all the checks that need it are skipped. | `multiple` | `code`, `values` | consist of several forms that evaluate to `values` (in order) | `eval.multiple-forms` | | `read-line` | `code`, `value`, `eof` | read a line from stdin, evaluating to `value` when the line is `proof` and to `eof` at the end of input | `stdin.need-input`, `stdin.roundtrip`, `stdin.eof` | | `sleep` | `code`, `out` | print `out` and then keep running for longer than the `timeout` (e.g. a minute), so there's something to interrupt | all the `interrupt.*` checks | +| `completion` | `code`, `value` | `code` is the start of a name, which completes to `value` | all the `completion.*` checks | +| `lookup` | `code` | `code` is the name of a function | `lookup.lookup`, `lookup.info`, `lookup.eldoc` | A few tips for writing snippets: @@ -166,6 +175,9 @@ A few tips for writing snippets: - proof evaluates the `use` of `session-state` in a new session as well, where it must not return `value` (an error is fine). +- The checks of completions and lookups evaluate `value` first, and send + the `ns` that comes with its value (if any) along with their requests, + just like clients send the namespace of the code they complete. - proof interrupts `sleep` as soon as its `out` arrives, so make sure your server sends the output right away (in Clojure that takes a `flush`, as there's no newline). diff --git a/doc/spec-changes.md b/doc/spec-changes.md index 35b3282..f4cd9fa 100644 --- a/doc/spec-changes.md +++ b/doc/spec-changes.md @@ -252,6 +252,22 @@ checks `lookup-error`), so omitting `info` works for it. Conjure's `(or msg.info msg)` returns the whole message. The spec should adopt `no-info`. +cider-nrepl's `info` (which the spec doesn't have, but Babashka, +Basilisp and ClojureCLR do) is a different matter. CIDER treats any +reply without `no-info` as the info about a symbol it found, so a plain +`done` for a symbol that doesn't exist breaks its docs and jumping to a +definition. Babashka, Basilisp and ClojureCLR all do that today. + +### Completions and Lookups + +The spec has `completions` and `lookup`, but it doesn't say that the +completions should come in one message along with `done` (Conjure reads +them from the message with `done`, and Calva from the first message of +the reply to `complete`), that each candidate is a dict with a +`candidate`, that `line` and `column` in `info` are integers counting +from 1 (Conjure moves the cursor to `column - 1`), or that `file` is a +URI (Calva can't jump to a plain path). + ### `status` is a Set, but Some Clients Compare Lists `nrepl.cmdline` expects exactly `["done" "session-idle"]` when diff --git a/doc/usage.md b/doc/usage.md index ed89ec2..9993cce 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -390,6 +390,8 @@ servers. There are a couple of exceptions. No scenario covers `eval.no-code`, as clients always send some code, and the code is still Clojure, even with `-like dialtone`. So the checks the profiles of dialtone and repartee skip for their languages (e.g. `eval.ns`) pass. +And the completions and lookups of `proof serve` are always nREPL's +(`completions` and `lookup`), whatever server it acts like. Most scenarios change only how the replies look on the wire, not what a user should end up seeing. Evaluating `(println "hi") (+ 1 2)` should @@ -428,7 +430,9 @@ gets a read error. That's enough for CIDER, Calva, Conjure and vim-fireplace to connect and work, and it's all you need for checking output, values, errors, input and interrupts. `load-file` evaluates what's in the file the same way, and like nREPL it sends only the value -of the last form and stops at the first error. +of the last form and stops at the first error. `completions` and +`lookup` know the functions in the table above and whatever the code +defines. When you stop it, `proof serve` checks the requests your client sent, just like `proof proxy` does, with the same report, exit codes and diff --git a/internal/checks/all.go b/internal/checks/all.go index ade0a2f..0a6bc66 100644 --- a/internal/checks/all.go +++ b/internal/checks/all.go @@ -12,6 +12,8 @@ func All() []*check.Check { all = append(all, stdinChecks()...) all = append(all, interruptChecks()...) all = append(all, loadFileChecks()...) + all = append(all, completionChecks()...) + all = append(all, lookupChecks()...) all = append(all, clientProfileChecks()...) return all } diff --git a/internal/checks/checks_test.go b/internal/checks/checks_test.go index 5b2ed3d..8d0bae5 100644 --- a/internal/checks/checks_test.go +++ b/internal/checks/checks_test.go @@ -27,6 +27,8 @@ var fakeProfile = &profile.Profile{ "multiple": {Code: "1 2", Values: []string{"1", "2"}}, "read-line": {Code: "read", Value: `"proof"`, Eof: "nil"}, "sleep": {Code: "sleep", Out: "proof"}, + "completion": {Code: "pro", Value: "proof"}, + "lookup": {Code: "proof"}, }, } @@ -41,8 +43,30 @@ func runFakeChecks(t *testing.T, q quirks, checks []*check.Check, rules ...*chec return checktest.ByID(check.Run(env, checks, rules)) } +// skipped is what checks get from a server that doesn't have their op. +func skipped(checks ...[]*check.Check) map[string]check.Verdict { + v := map[string]check.Verdict{} + for _, cs := range checks { + for _, c := range cs { + v[c.ID] = check.Skipped + } + } + return v +} + +var ( + // The fake has both cider-nrepl's complete and info and nREPL's + // completions and lookup, which clients only use without the former. + fallbacks = map[string]check.Verdict{"completion.completions": check.Skipped, "lookup.lookup": check.Skipped, + "lookup.lookup-unknown": check.Skipped} + // With nREPL's ops alone, it's the other way around. + nreplLookups = checktest.Merged(skipped(completionChecks(), lookupChecks()), map[string]check.Verdict{ + "completion.completions": check.Pass, "lookup.lookup": check.Pass, "lookup.lookup-unknown": check.Pass}) +) + func TestWellBehavedServerPassesEverything(t *testing.T) { - checktest.Verdicts(t, runFake(t, quirks{}), nil) + checktest.Verdicts(t, runFake(t, quirks{}), fallbacks) + checktest.Verdicts(t, runFake(t, quirks{nreplLookups: true}), nreplLookups) } // Each misbehaviour must produce exactly the listed verdicts, and every @@ -52,15 +76,9 @@ func TestChecksCatchMisbehaviour(t *testing.T) { // What every client but REPLy goes through first. connects := map[string]check.Verdict{"cider.connect": F, "calva.connect": F, "conjure.connect": F, "fireplace.connect": F, "neorepl.connect": F} - // The interrupt checks, which can't tell whether the server has the op. - noInterrupts := map[string]check.Verdict{} - for _, c := range interruptChecks() { - noInterrupts[c.ID] = S - } - noLoadFile := map[string]check.Verdict{} - for _, c := range loadFileChecks() { - noLoadFile[c.ID] = S - } + // The checks that can't tell whether the server has their op. + noInterrupts, noLoadFile := skipped(interruptChecks()), skipped(loadFileChecks()) + noLookups := skipped(completionChecks(), lookupChecks()) // What CIDER, Calva and Conjure send to load a file. clientsLoad := map[string]check.Verdict{"cider.load-file": F, "calva.load-file": F, "conjure.load-file": F} cases := []struct { @@ -69,11 +87,11 @@ func TestChecksCatchMisbehaviour(t *testing.T) { want map[string]check.Verdict }{ // Conjure does without the features that need ops. - {"ops as a list", quirks{opsList: true}, checktest.Merged(connects, noInterrupts, noLoadFile, map[string]check.Verdict{"describe.ops-dict": F, + {"ops as a list", quirks{opsList: true}, checktest.Merged(connects, noInterrupts, noLoadFile, noLookups, map[string]check.Verdict{"describe.ops-dict": F, "describe.required-ops": S, "stdin.need-input": S, "stdin.roundtrip": S, "stdin.eof": S, "conjure.connect": check.Pass})}, {"clone not advertised", quirks{noClone: true}, map[string]check.Verdict{"describe.required-ops": F}}, {"no versions", quirks{noVersions: true}, map[string]check.Verdict{"describe.versions": W}}, - {"describe kills the connection", quirks{crashOnDescribe: true}, checktest.Merged(connects, noInterrupts, noLoadFile, map[string]check.Verdict{ + {"describe kills the connection", quirks{crashOnDescribe: true}, checktest.Merged(connects, noInterrupts, noLoadFile, noLookups, map[string]check.Verdict{ "describe.reply": F, "describe.ops-dict": S, "describe.required-ops": S, "describe.versions": S, "stdin.need-input": S, "stdin.roundtrip": S, "stdin.eof": S})}, {"no unknown-op", quirks{noUnknownOp: true}, map[string]check.Verdict{"op.unknown": F, "op.unknown-echo": W}}, @@ -83,7 +101,8 @@ func TestChecksCatchMisbehaviour(t *testing.T) { "op.unknown": F, "op.unknown-echo": F, "wire.status-type": F, "conjure.connect": F}}, {"no session-closed", quirks{noSessionClosed: true}, map[string]check.Verdict{"session.close": F}}, // Only clients put dicts and lists in their requests. - {"flat fields only", quirks{flatFields: true}, checktest.Merged(clientsLoad, map[string]check.Verdict{ + // Conjure's complete has extra-metadata, a list. + {"flat fields only", quirks{flatFields: true}, checktest.Merged(clientsLoad, map[string]check.Verdict{"completion.complete-symbol": F, "cider.connect": F, "cider.eval": F, "cider.repl": F, "calva.connect": F, "calva.eval": F, "conjure.eval": F, "neorepl.eval": F})}, {"any session accepted", quirks{acceptAnySession: true}, map[string]check.Verdict{"session.unknown": F, "session.closed": F}}, @@ -145,11 +164,34 @@ func TestChecksCatchMisbehaviour(t *testing.T) { {"no load-file op", quirks{noLoadFileOp: true}, checktest.Merged(noLoadFile, clientsLoad)}, {"load-file without a value", quirks{loadFileNoValue: true}, checktest.Merged(clientsLoad, map[string]check.Verdict{"load-file.value": F})}, + {"nREPL's lookups", quirks{nreplLookups: true}, nreplLookups}, + {"completions before done", quirks{completionsLate: true}, map[string]check.Verdict{"completion.complete-symbol": F}}, + {"completions before done, nREPL's", quirks{completionsLate: true, nreplLookups: true}, checktest.Merged(nreplLookups, + map[string]check.Verdict{"completion.completions": F})}, + {"string candidates", quirks{stringCandidates: true}, map[string]check.Verdict{ + "completion.complete": F, "completion.complete-symbol": F}}, + {"string candidates, nREPL's", quirks{stringCandidates: true, nreplLookups: true}, checktest.Merged(nreplLookups, + map[string]check.Verdict{"completion.completions": F})}, + {"complete takes only a prefix", quirks{completePrefix: true}, map[string]check.Verdict{"completion.complete-symbol": F}}, + {"empty info for nothing", quirks{emptyLookupInfo: true, nreplLookups: true}, checktest.Merged(nreplLookups, + map[string]check.Verdict{"lookup.lookup-unknown": F})}, + {"line as a string", quirks{lineString: true}, map[string]check.Verdict{"lookup.info": F}}, + {"line as a string, nREPL's", quirks{lineString: true, nreplLookups: true}, checktest.Merged(nreplLookups, + map[string]check.Verdict{"lookup.lookup": F})}, + {"columns from 0", quirks{columnZero: true}, map[string]check.Verdict{"lookup.info": F}}, + {"no no-info", quirks{noNoInfo: true}, map[string]check.Verdict{"lookup.info-unknown": F}}, + {"eldoc of strings", quirks{eldocStrings: true}, map[string]check.Verdict{"lookup.eldoc": F}}, + // Calva can't jump to a path. + {"file as a path", quirks{pathFile: true}, map[string]check.Verdict{"lookup.info": F}}, + // CIDER finds them, but Calva, Conjure and vim-fireplace don't, and + // Conjure falls back to completions and lookup. + {"cider/ ops alone", quirks{ciderPrefixed: true}, map[string]check.Verdict{"completion.complete-symbol": S, + "completion.completions": check.Pass, "lookup.lookup": check.Pass, "lookup.lookup-unknown": check.Pass}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() - checktest.Verdicts(t, runFake(t, c.q), c.want) + checktest.Verdicts(t, runFake(t, c.q), checktest.Merged(fallbacks, c.want)) }) } } diff --git a/internal/checks/completion.go b/internal/checks/completion.go new file mode 100644 index 0000000..14637e6 --- /dev/null +++ b/internal/checks/completion.go @@ -0,0 +1,151 @@ +package checks + +import ( + "slices" + + "github.com/nrepl/proof/internal/check" + "github.com/nrepl/proof/nrepl" +) + +func completionChecks() []*check.Check { + return []*check.Check{ + { + ID: "completion.completions", + Title: "completions offers what the code could be", + Severity: check.Fail, + Why: "CIDER and Conjure complete code with completions when the server has no complete. Conjure reads the completions only from the message with done, and CIDER stops completing with an error at a candidate that isn't a dict with a candidate.", + Refs: []check.Ref{ciderCompleteOps, ciderCandidates, conjureComplete, nreplCompletions}, + Snippets: []string{"completion", "value"}, + Requires: []string{"describe.reply", "describe.ops-dict", "session.clone"}, + Run: func(t *check.T) { + requireFallbackOp(t, "completions", "complete") + s := t.Snippet("completion") + c, fields := toolingSession(t) + resp := t.Request(c, fields.With(nrepl.Message{"op": "completions", "prefix": s.Code})) + completionsWhere(t, "completions", resp, false) + checkCandidates(t, "completions", resp, s.Value) + }, + }, + { + ID: "completion.complete", + Title: "complete offers what the code could be, given a prefix", + Severity: check.Fail, + Why: "CIDER completes code with complete and its prefix, and stops completing with an error at a candidate that isn't a dict with a candidate.", + Refs: []check.Ref{ciderCompleteOps, ciderComplete, ciderCandidates}, + Snippets: []string{"completion", "value"}, + Requires: []string{"describe.reply", "describe.ops-dict", "session.clone"}, + Run: func(t *check.T) { + op := ciderOp(t, "complete") + s := t.Snippet("completion") + c, fields := toolingSession(t) + resp := t.Request(c, fields.With(nrepl.Message{"op": op, "prefix": s.Code, "context": "nil", + "sort-order": "by-name", "enhanced-cljs-completion?": "t"})) + checkCandidates(t, op+" with a prefix", resp, s.Value) + }, + }, + { + ID: "completion.complete-symbol", + Title: "complete offers what the code could be, given a symbol", + Severity: check.Fail, + Why: "Calva, Conjure and vim-fireplace send complete a symbol rather than a prefix (and Calva a context of 0 outside a form). Calva reads the completions from the first message only and loses them all at a candidate that isn't a dict, and Conjure reads them from the message with done only.", + Refs: []check.Ref{calvaComplete, calvaFirstReply, calvaCandidates, conjureComplete, fireplaceComplete}, + Snippets: []string{"completion", "value"}, + Requires: []string{"describe.reply", "describe.ops-dict", "session.clone"}, + Run: func(t *check.T) { + requireOp(t, "complete") + s := t.Snippet("completion") + c, fields := toolingSession(t) + for _, req := range []struct { + client string + msg nrepl.Message + }{ + {"Calva's", nrepl.Message{"symbol": s.Code, "context": 0, "enhanced-cljs-completion?": "t"}}, + {"Conjure's", nrepl.Message{"symbol": s.Code, "extra-metadata": []any{"arglists", "doc"}, "enhanced-cljs-completion?": "t"}}, + } { + what := "complete with " + req.client + " symbol" + resp := t.Request(c, fields.With(nrepl.Message{"op": "complete"}).With(req.msg)) + completionsWhere(t, what, resp, true) + checkCandidates(t, what, resp, s.Value) + } + }, + }, + } +} + +// completionsWhere makes sure the completions of a reply come with done, +// and in its first message if first. +func completionsWhere(t *check.T, what string, resp nrepl.Response, first bool) { + if last := resp.Messages[len(resp.Messages)-1]; !last.Has("completions") { + t.Violatef("%s: the completions didn't come with done: %s", what, last) + } + if first && !resp.Messages[0].Has("completions") { + t.Violatef("%s: the completions didn't come in the first message: %s", what, resp.Messages[0]) + } +} + +// checkCandidates makes sure each candidate of a reply is a dict with a +// candidate, and that want is among them. +func checkCandidates(t *check.T, what string, resp nrepl.Response, want string) { + var candidates []string + for _, c := range field[[]any](t, resp, "completions", what) { + d, ok := c.(map[string]any) + candidate, _ := d["candidate"].(string) + if !ok || candidate == "" { + t.Stopf("%s: a completion is %v, not a dict with a candidate", what, c) + } + candidates = append(candidates, candidate) + } + if !slices.Contains(candidates, want) { + t.Violatef("%s: %q isn't among the candidates %q", what, want, candidates) + } +} + +// field returns a field of a reply, stopping the check when it's missing +// or isn't a T. what says what the reply was for. +func field[T any](t *check.T, resp nrepl.Response, key, what string) T { + v := resp.Get(key) + x, ok := v.(T) + switch { + case v == nil: + t.Stopf("%s gave no %s", what, key) + case !ok: + t.Stopf("%s gave %s that is %s, not %s", what, key, typeName(v), typeName(x)) + } + return x +} + +// requireFallbackOp skips the check unless describe lists op, or when it +// lists preferred, which clients use instead. +func requireFallbackOp(t *check.T, op, preferred string) { + if requireOp(t, op)[preferred] != nil { + t.Skipf("clients use %s instead of %s, which the server has too", preferred, op) + } +} + +// ciderOp returns what CIDER calls op: cider/op, which newer versions of +// cider-nrepl list, or else op. It skips the check when the server has +// neither. +func ciderOp(t *check.T, op string) string { + ops := describedOps(t, op) + switch { + case ops["cider/"+op] != nil: + return "cider/" + op + case ops[op] != nil: + return op + } + t.Skipf("the server doesn't advertise %s or cider/%s", op, op) + return "" +} + +// toolingSession opens a connection and a session for the ops that look +// things up, and returns the fields clients send with them: the session, +// and the namespace the session's evals run in, if the server says. +func toolingSession(t *check.T) (*nrepl.Conn, nrepl.Message) { + c := t.Connect() + fields := t.Session(c) + resp := t.Eval(c, t.Snippet("value").Code, fields) + if ns := resp.Str("ns"); ns != "" { + fields = fields.With(nrepl.Message{"ns": ns}) + } + return c, fields +} diff --git a/internal/checks/fake_test.go b/internal/checks/fake_test.go index 0bd88e2..c42b273 100644 --- a/internal/checks/fake_test.go +++ b/internal/checks/fake_test.go @@ -65,6 +65,17 @@ type quirks struct { diesOnInterrupt bool // an interrupted session never answers again noLoadFileOp bool // there's no load-file op loadFileNoValue bool // load-file sends no value + nreplLookups bool // only nREPL's completions and lookup, without cider-nrepl's ops + completionsLate bool // the completions come before the message with done + stringCandidates bool // completions are strings rather than dicts + completePrefix bool // complete only takes a prefix, not a symbol + emptyLookupInfo bool // lookup gives an empty dict for a symbol that doesn't exist + lineString bool // the line of a symbol is a string + columnZero bool // info counts columns from 0 + noNoInfo bool // info for a symbol that doesn't exist gets a plain done + eldocStrings bool // eldoc's arglists are strings rather than lists + pathFile bool // the file of a symbol is a path rather than a URI + ciderPrefixed bool // cider-nrepl's ops go by their cider/ names only } var unknownSession = map[string]any{"status": []any{"error", "unknown-session", "done"}} @@ -219,6 +230,13 @@ func (s *fakeServer) handle(c net.Conn, req nrepl.Message, local map[string]bool if !q.noLoadFileOp { names = append(names, "load-file") } + names = append(names, "completions", "lookup") + switch { + case q.ciderPrefixed: + names = append(names, "cider/complete", "cider/info", "cider/eldoc") + case !q.nreplLookups: + names = append(names, "complete", "info", "eldoc") + } var ops any if q.opsList { l := []any{} @@ -244,7 +262,7 @@ func (s *fakeServer) handle(c net.Conn, req nrepl.Message, local map[string]bool session = "7:session" + strconv.Itoa(len(sess)) + ":" + sess } c.Write([]byte("d6:statusl4:donee2:id" + strconv.Itoa(len(req.Str("id"))) + ":" + req.Str("id") + - "3:opsd8:describede4:evalde5:clonede5:closede5:stdinde9:interruptde9:load-filedee" + session + + "3:opsd8:describede4:evalde5:clonede5:closede5:stdinde9:interruptde9:load-filede11:completionsde6:lookupde8:completede4:infode5:eldocdee" + session + "8:versionsd4:faked14:version-string3:1.0eee")) return true } @@ -282,12 +300,95 @@ func (s *fakeServer) handle(c net.Conn, req nrepl.Message, local map[string]bool } // Like nREPL, an eval of what's in the file. s.eval(c, req.With(nrepl.Message{"code": req["file"]}), local) + case "completions": + s.complete(c, req) + case "lookup": + s.lookUp(c, req) + case "complete", "info", "eldoc", "cider/complete", "cider/info", "cider/eldoc": + plain := nrepl.Message{"op": strings.TrimPrefix(op, "cider/")} + switch { + case q.nreplLookups, q.ciderPrefixed != strings.HasPrefix(op, "cider/"): + s.unknownOp(c, req) + case plain.Str("op") == "complete": + s.complete(c, req.With(plain)) + default: + s.lookUp(c, req.With(plain)) + } default: s.unknownOp(c, req) } return true } +// complete offers proof for a prefix of it, like nREPL's completions and +// cider-nrepl's complete do. +func (s *fakeServer) complete(c net.Conn, req nrepl.Message) { + q := s.q + prefix := req.Str("prefix") + if req.Str("op") == "complete" && !q.completePrefix && prefix == "" { + prefix = req.Str("symbol") + } + completions := []any{} + if prefix != "" && strings.HasPrefix("proof", prefix) { + var candidate any = map[string]any{"candidate": "proof", "ns": "user", "type": "function"} + if q.stringCandidates { + candidate = "proof" + } + completions = append(completions, candidate) + } + if q.completionsLate { + s.send(c, req, map[string]any{"completions": completions}) + s.send(c, req, map[string]any{"status": []any{"done"}}) + return + } + s.send(c, req, map[string]any{"completions": completions, "status": []any{"done"}}) +} + +// lookUp says what proof is, like nREPL's lookup and cider-nrepl's info +// and eldoc do. +func (s *fakeServer) lookUp(c net.Conn, req nrepl.Message) { + q := s.q + op, sym := req.Str("op"), req.Str("sym") + if op == "info" && sym == "" { + sym = req.Str("symbol") + } + found := sym == "proof" + info := map[string]any{"name": "proof", "ns": "user", "doc": "The proof.", "arglists-str": "([x])", + "file": "file:/proof.clj", "line": int64(1), "column": int64(1)} + if q.lineString { + info["line"] = "1" + } + if q.columnZero { + info["column"] = int64(0) + } + if q.pathFile { + info["file"] = "/proof.clj" + } + done := []any{"done"} + switch { + case op == "lookup" && !found && q.emptyLookupInfo: + s.send(c, req, map[string]any{"info": map[string]any{}, "status": done}) + case op == "lookup" && !found: + s.send(c, req, map[string]any{"info": []any{}, "status": done}) + case op == "lookup": + s.send(c, req, map[string]any{"info": info, "status": done}) + case op == "info" && !found && q.noNoInfo: + s.send(c, req, map[string]any{"status": done}) + case !found: + s.send(c, req, map[string]any{"status": []any{"done", "no-" + op}}) + case op == "info": + info["status"] = done + s.send(c, req, info) + default: + var arglist any = []any{"x"} + if q.eldocStrings { + arglist = "[x]" + } + s.send(c, req, map[string]any{"name": "proof", "ns": "user", "docstring": "The proof.", "type": "function", + "eldoc": []any{arglist}, "status": done}) + } +} + func (s *fakeServer) unknownOp(c net.Conn, req nrepl.Message) { switch { case s.q.statusString: diff --git a/internal/checks/lookup.go b/internal/checks/lookup.go new file mode 100644 index 0000000..d449826 --- /dev/null +++ b/internal/checks/lookup.go @@ -0,0 +1,174 @@ +package checks + +import ( + "fmt" + "net/url" + + "github.com/nrepl/proof/internal/check" + "github.com/nrepl/proof/nrepl" +) + +// A symbol no server has. +const noSuchSymbol = "proof-no-such-thing" + +func lookupChecks() []*check.Check { + return []*check.Check{ + { + ID: "lookup.lookup", + Title: "lookup says what a symbol is", + Severity: check.Fail, + Why: "CIDER and Conjure look symbols up with lookup when the server has no info. CIDER's docs need its info to be a dict with a name or an ns and strings where it expects them, jumping to a definition needs line to be an integer, and Conjure needs column to count from 1.", + Refs: []check.Ref{ciderInfoOps, ciderLookup, ciderDocHeading, ciderFindVar, conjureInfo, conjureColumn, nreplLookup}, + Snippets: []string{"lookup", "value"}, + Requires: []string{"describe.reply", "describe.ops-dict", "session.clone"}, + Run: func(t *check.T) { + requireFallbackOp(t, "lookup", "info") + s := t.Snippet("lookup") + what := fmt.Sprintf("looking up %q", s.Code) + c, fields := toolingSession(t) + resp := t.Request(c, fields.With(nrepl.Message{"op": "lookup", "sym": s.Code})) + for _, p := range infoProblems(field[map[string]any](t, resp, "info", what)) { + t.Violatef("%s: %s", what, p) + } + }, + }, + { + ID: "lookup.lookup-unknown", + Title: "lookup finds nothing for a symbol that doesn't exist", + Severity: check.Fail, + Why: "CIDER takes any dict in lookup's info for a symbol it found, so an empty one for a symbol that doesn't exist breaks its docs and jumping to a definition with an Emacs error, instead of saying it can't resolve the symbol.", + Refs: []check.Ref{ciderLookup, ciderDocHeading, nreplLookup}, + Snippets: []string{"value"}, + Requires: []string{"describe.reply", "describe.ops-dict", "session.clone"}, + Run: func(t *check.T) { + requireFallbackOp(t, "lookup", "info") + c, fields := toolingSession(t) + resp := t.Request(c, fields.With(nrepl.Message{"op": "lookup", "sym": noSuchSymbol})) + if info, ok := resp.Get("info").(map[string]any); ok && !resp.HasStatus("lookup-error") { + t.Violatef("looking up a symbol that doesn't exist gave the info %v", info) + } + }, + }, + { + ID: "lookup.info", + Title: "info says what a symbol is", + Severity: check.Fail, + Why: "CIDER, Calva, Conjure and vim-fireplace look symbols up with info, CIDER with sym and the others with symbol. Calva reads only the first message of the reply, status included, and needs file to be a URI to jump to a definition, CIDER's docs need a name or an ns and strings where it expects them, jumping to a definition needs line to be an integer, and Conjure needs column to count from 1.", + Refs: []check.Ref{ciderInfo, ciderDocHeading, ciderFindVar, calvaInfo, calvaHover, conjureInfo, conjureColumn, fireplaceInfo}, + Snippets: []string{"lookup", "value"}, + Requires: []string{"describe.reply", "describe.ops-dict", "session.clone"}, + Run: func(t *check.T) { + op := ciderOp(t, "info") + s := t.Snippet("lookup") + c, fields := toolingSession(t) + for _, req := range infoRequests(t, op, s.Code) { + what := fmt.Sprintf("looking up %q with %s", s.Code, req.client) + first := t.Request(c, fields.With(req.msg)).Messages[0] + if !first.Has("status") { + t.Violatef("%s: the first message of the reply has no status: %s", what, first) + } + problems := infoProblems(first) + if file, ok := first["file"].(string); ok && req.client != "CIDER's sym" { + if u, err := url.Parse(file); err != nil || u.Scheme == "" { + problems = append(problems, fmt.Sprintf("file %q isn't a URI, so Calva can't jump to it", file)) + } + } + for _, p := range problems { + t.Violatef("%s: %s", what, p) + } + } + }, + }, + { + ID: "lookup.info-unknown", + Title: "info says no-info for a symbol that doesn't exist", + Severity: check.Fail, + Why: "CIDER takes any reply to info without no-info for a symbol it found, so for a symbol that doesn't exist its docs and jumping to a definition fail with an Emacs error, instead of saying it can't resolve the symbol. Conjure, which sends symbol, shows the whole reply instead.", + Refs: []check.Ref{ciderInfo, ciderDocHeading, conjureInfo}, + Snippets: []string{"value"}, + Requires: []string{"describe.reply", "describe.ops-dict", "session.clone"}, + Run: func(t *check.T) { + op := ciderOp(t, "info") + c, fields := toolingSession(t) + for _, req := range infoRequests(t, op, noSuchSymbol) { + if resp := t.Request(c, fields.With(req.msg)); !resp.HasStatus("no-info") { + t.Violatef("looking up a symbol that doesn't exist with %s gave status %v, without no-info", req.client, resp.Status()) + } + } + }, + }, + { + ID: "lookup.eldoc", + Title: "eldoc gives the arglists of a function", + Severity: check.Fail, + Why: "CIDER shows the arglists of the function at the cursor from eldoc, and fails with an error on every move of the cursor when eldoc isn't a list of lists of strings, or ns or docstring isn't a string.", + Refs: []check.Ref{ciderEldoc}, + Snippets: []string{"lookup", "value"}, + Requires: []string{"describe.reply", "describe.ops-dict", "session.clone"}, + Run: func(t *check.T) { + op := ciderOp(t, "eldoc") + s := t.Snippet("lookup") + what := fmt.Sprintf("eldoc for %q", s.Code) + c, fields := toolingSession(t) + resp := t.Request(c, fields.With(nrepl.Message{"op": op, "sym": s.Code, "context": "nil"})) + for _, a := range field[[]any](t, resp, "eldoc", what) { + if !stringList(a) { + t.Violatef("%s has the arglist %v, which isn't a list of strings", what, a) + } + } + wrongTypes[string](resp.First("eldoc"), []string{"ns", "docstring"}, func(problem, _ string) { + t.Violatef("%s: %s, not a string", what, problem) + }) + }, + }, + } +} + +// infoRequest is how a client asks info about a symbol. +type infoRequest struct { + client string + msg nrepl.Message +} + +// infoRequests are the info requests of CIDER (with op, which may be +// cider/info) and, when the server has info, of the other clients. +func infoRequests(t *check.T, op, sym string) []infoRequest { + reqs := []infoRequest{{"CIDER's sym", nrepl.Message{"op": op, "sym": sym, "context": "nil", "download-sources-jar": "1"}}} + if op == "info" { + reqs = append(reqs, infoRequest{"the symbol of Calva, Conjure and vim-fireplace", nrepl.Message{"op": "info", "symbol": sym}}) + } else { + t.Notef("the server has no info, which Calva, Conjure and vim-fireplace ask for, only %s", op) + } + return reqs +} + +// infoProblems lists what clients can't use in the info about a symbol. +func infoProblems(info map[string]any) []string { + var problems []string + report := func(problem, _ string) { problems = append(problems, problem) } + m := nrepl.Message(info) + if info["name"] == nil && info["ns"] == nil { + problems = append(problems, "there's neither a name nor an ns") + } + wrongTypes[string](m, []string{"name", "ns", "doc", "arglists-str", "file", "added", "deprecated"}, report) + wrongTypes[int64](m, []string{"line", "column"}, report) + wrongTypes[[]any](m, []string{"see-also"}, report) + if n, ok := info["column"].(int64); ok && n < 1 { + problems = append(problems, fmt.Sprintf("column is %d, but columns count from 1", n)) + } + return problems +} + +// stringList reports whether v is a list of strings. +func stringList(v any) bool { + l, ok := v.([]any) + if !ok { + return false + } + for _, x := range l { + if _, ok := x.(string); !ok { + return false + } + } + return true +} diff --git a/internal/checks/refs.go b/internal/checks/refs.go index 691f2a9..dacd34e 100644 --- a/internal/checks/refs.go +++ b/internal/checks/refs.go @@ -83,6 +83,27 @@ var ( calvaLoadFileError = ref("Calva tells a failed load by its ex", calvaBase+"#L1461-L1463") conjureLoadFile = ref("Conjure shows out, err and value and nothing else", conjureSrc+"client/clojure/nrepl/ui.fnl#L24-L50") nreplLoadFile = ref("nREPL's load-file", nreplBase+"middleware/load_file.clj#L41-L70") + ciderCompleteOps = ref("CIDER asks for complete, then completions", ciderBase+"cider-completion.el#L128-L145") + ciderCandidates = ref("CIDER reads each candidate as a dict with a candidate", ciderBase+"cider-completion.el#L118-L126") + ciderComplete = ref("CIDER's complete request", ciderBase+"cider-client.el#L933-L944") + calvaComplete = ref("Calva's complete request", calvaBase+"#L838-L859") + calvaFirstReply = ref("Calva takes the first message of a reply", calvaBase+"#L53-L62") + calvaCandidates = ref("Calva reads each candidate as an object", calvaSrc+"providers/completion.ts#L151-L205") + conjureComplete = ref("Conjure's complete and completions requests", conjureSrc+"client/clojure/nrepl/action.fnl#L698-L728") + fireplaceComplete = ref("vim-fireplace's complete request", fireplaceBase+"fireplace.vim#L263-L291") + nreplCompletions = ref("nREPL's completions", nreplBase+"middleware/completion.clj#L33-L69") + ciderInfoOps = ref("CIDER asks for info, then lookup", ciderBase+"cider-client.el#L697-L705") + ciderLookup = ref("CIDER reads lookup's info", ciderBase+"cider-client.el#L987-L996") + ciderInfo = ref("CIDER's info request and no-info", ciderBase+"cider-client.el#L952-L978") + ciderDocHeading = ref("CIDER's doc buffer needs name or ns", ciderBase+"cider-doc.el#L669-L679") + ciderFindVar = ref("CIDER jumps to file and line", ciderBase+"cider-common.el#L266-L268") + ciderEldoc = ref("CIDER reads eldoc as lists of strings", ciderBase+"cider-eldoc.el#L258-L294") + calvaInfo = ref("Calva's info request", calvaBase+"#L861-L879") + calvaHover = ref("Calva's hover reads status from the first message", calvaSrc+"providers/hover.ts#L13-L91") + conjureInfo = ref("Conjure's info and lookup requests, and no-info", conjureSrc+"client/clojure/nrepl/action.fnl#L112-L132") + conjureColumn = ref("Conjure puts the cursor at column - 1", conjureSrc+"editor.fnl#L27-L35") + fireplaceInfo = ref("vim-fireplace's info request", fireplaceBase+"fireplace.vim#L1933-L1973") + nreplLookup = ref("nREPL's lookup", nreplBase+"middleware/lookup.clj#L27-L62") ciderNeedInput = ref("CIDER need-input handling", ciderBase+"nrepl-client.el#L948-L950") nreplStdinEOF = ref("nREPL treats an empty stdin as EOF", nreplBase+"middleware/session.clj#L386-L396") diff --git a/internal/checks/stdin.go b/internal/checks/stdin.go index 070ca64..a1b9b83 100644 --- a/internal/checks/stdin.go +++ b/internal/checks/stdin.go @@ -91,13 +91,22 @@ func needInput(id string) func(nrepl.Message) bool { return func(m nrepl.Message) bool { return m.Str("id") == id && m.HasStatus("need-input") } } -// requireOp skips the check unless describe lists op. -func requireOp(t *check.T, op string) { +// requireOp skips the check unless describe lists op, and returns the ops +// it lists. +func requireOp(t *check.T, op string) map[string]any { + ops := describedOps(t, op) + if _, ok := ops[op]; !ok { + t.Skipf("the server doesn't advertise %s", op) + } + return ops +} + +// describedOps returns the ops describe lists, skipping the check when it +// can't tell whether there's op. +func describedOps(t *check.T, op string) map[string]any { ops, ok := describe(t).Get("ops").(map[string]any) if !ok { t.Skipf("can't tell whether the server supports %s (see describe.ops-dict)", op) } - if _, ok := ops[op]; !ok { - t.Skipf("the server doesn't advertise %s", op) - } + return ops } diff --git a/internal/serve/eval.go b/internal/serve/eval.go index d91126f..ffd4269 100644 --- a/internal/serve/eval.go +++ b/internal/serve/eval.go @@ -93,6 +93,11 @@ func (d *definitions) lookup(qualified string) (any, bool) { return v, ok } +func (d *definitions) has(qualified string) bool { + _, ok := d.lookup(qualified) + return ok +} + func (d *definitions) define(qualified string, v any) { d.mu.Lock() defer d.mu.Unlock() @@ -248,16 +253,19 @@ func (e *evaluation) resolve(name string) (any, error) { return nil, compileError("", "Unable to resolve symbol: %s in this context", name) } -// qualify names the var a symbol refers to: clojure.core's if it has one -// by that name, and otherwise one in the current namespace. -func (e *evaluation) qualify(name string) string { +// qualify names the var a symbol refers to in the current namespace. +func (e *evaluation) qualify(name string) string { return qualifyIn(e.ns, name) } + +// qualifyIn names the var a symbol refers to in ns: clojure.core's if it +// has one by that name, and otherwise one in ns. +func qualifyIn(ns, name string) string { switch { case qualified(name): return name case builtins[name] != nil: return "clojure.core/" + name } - return e.ns + "/" + name + return ns + "/" + name } // qualified reports whether a symbol names its namespace (like diff --git a/internal/serve/lookups.go b/internal/serve/lookups.go new file mode 100644 index 0000000..2b299c6 --- /dev/null +++ b/internal/serve/lookups.go @@ -0,0 +1,93 @@ +package serve + +import ( + "maps" + "slices" + "strings" + + "github.com/nrepl/proof/nrepl" +) + +// A var as completions and lookup describe it. +type entry struct { + name, ns, kind string +} + +// visible lists what code in ns can refer to by a plain name, the way eval +// resolves it: special forms first, then clojure.core, then ns. +func (d *definitions) visible(ns string) []entry { + byName := map[string]entry{} + d.mu.Lock() + for qualified, v := range d.vars { + vns, name, _ := strings.Cut(qualified, "/") + if vns != ns && vns != "clojure.core" || qualifyIn(ns, name) != qualified { + continue + } + kind := "var" + if _, ok := v.(*function); ok { + kind = "function" + } + byName[name] = entry{name, vns, kind} + } + d.mu.Unlock() + for name := range specials { + byName[name] = entry{name, "clojure.core", "special-form"} + } + entries := slices.Collect(maps.Values(byName)) + slices.SortFunc(entries, func(a, b entry) int { return strings.Compare(a.name, b.name) }) + return entries +} + +// lookupNS is the namespace completions and lookup look in: the request's, +// or else the session's. ok is false for a namespace that doesn't exist. +func (s *Server) lookupNS(req nrepl.Message, sess *session) (ns string, ok bool) { + if ns, given := req["ns"].(string); given { + return ns, s.defs.hasNS(ns) + } + return sess.b.currentNS(), true +} + +// completions offers the vars whose names start with the prefix, as +// nREPL's completions does, in a single message. Like nREPL, it falls back +// to clojure.core for a namespace that doesn't exist. +func (s *Server) completions(c *conn, req nrepl.Message, sess *session) { + prefix, ok := req["prefix"].(string) + if !ok { + c.reply(req, map[string]any{"status": []any{"done", "completions-error", "error"}, "message": "no prefix"}) + return + } + ns, _ := s.lookupNS(req, sess) + completions := []any{} + for _, e := range s.defs.visible(ns) { + if strings.HasPrefix(e.name, prefix) { + completions = append(completions, map[string]any{"candidate": e.name, "ns": e.ns, "type": e.kind}) + } + } + c.reply(req, map[string]any{"completions": completions, "status": done}) +} + +// lookup says what a symbol refers to, as nREPL's lookup does: an info +// dict, or an empty list for nothing. +func (s *Server) lookup(c *conn, req nrepl.Message, sess *session) { + sym, _ := req["sym"].(string) + ns, known := s.lookupNS(req, sess) + message := "" + switch { + case sym == "": + message = "no conversion to symbol" + case !known: + message = "No namespace: " + ns + " found" + } + if message != "" { + c.reply(req, map[string]any{"status": []any{"done", "error", "lookup-error"}, "message": message}) + return + } + var info any = []any{} + if specials[sym] != nil { + info = map[string]any{"name": sym, "ns": "clojure.core", "special-form": "true"} + } else if q := qualifyIn(ns, sym); s.defs.has(q) { + vns, name, _ := strings.Cut(q, "/") + info = map[string]any{"name": name, "ns": vns} + } + c.reply(req, map[string]any{"info": info, "status": done}) +} diff --git a/internal/serve/matrix_test.go b/internal/serve/matrix_test.go index 1deb8ae..7b710ee 100644 --- a/internal/serve/matrix_test.go +++ b/internal/serve/matrix_test.go @@ -45,12 +45,17 @@ func runChecks(t *testing.T, scenarios ...string) map[string]check.Result { // runChecksAs runs them with the snippets of nREPL's profile and the // capabilities of server's, so the checks for other languages skip. func runChecksAs(t *testing.T, server string, scenarios ...string) map[string]check.Result { + t.Helper() + return runSomeChecksAs(t, server, checks.All(), scenarios...) +} + +func runSomeChecksAs(t *testing.T, server string, cs []*check.Check, scenarios ...string) map[string]check.Result { t.Helper() p := loadProfile(t, "clojure") p.Capabilities = loadProfile(t, server).Capabilities p.Timeout = time.Second env := &check.Env{Profile: p, Addr: serveFor(t, scenarios...).Addr(), Settle: 20 * time.Millisecond} - return checktest.ByID(check.Run(env, checks.All(), checks.WireRules())) + return checktest.ByID(check.Run(env, cs, checks.WireRules())) } // noInterrupts is what the interrupt checks get from servers without the @@ -65,8 +70,13 @@ var noInterrupts = func() map[string]check.Verdict { return v }() +// 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, + "lookup.info": check.Skipped, "lookup.info-unknown": check.Skipped, "lookup.eldoc": check.Skipped} + func TestPassesEveryServerCheck(t *testing.T) { - checktest.Verdicts(t, runChecks(t), nil) + checktest.Verdicts(t, runChecks(t), noCiderOps) } // A scenario has to do what the servers it names do, so it gets the @@ -110,14 +120,17 @@ func TestScenariosGetTheVerdictsOfTheirServers(t *testing.T) { for _, c := range cases { t.Run(fmt.Sprint(c.scenarios), func(t *testing.T) { t.Parallel() - checktest.Verdicts(t, runChecks(t, c.scenarios...), c.want) + checktest.Verdicts(t, runChecks(t, c.scenarios...), checktest.Merged(noCiderOps, c.want)) }) } } // A server's scenarios together get its column of the compatibility // matrix. The columns leave out eval.no-code, as no client sends an eval -// without code. +// without code, and the checks of completions and lookups, which are +// nREPL's in proof serve. That's also why Basilisp's column has no +// wire.canonical warning, and ClojureCLR's no wire.field-types failure, +// which their info replies get in the matrix. func TestPresetsGetTheColumnsOfTheirServers(t *testing.T) { F, W, S := check.Failed, check.Warned, check.Skipped noJava := map[string]check.Verdict{"fireplace.connect": S, "fireplace.eval": S} @@ -150,7 +163,12 @@ func TestPresetsGetTheColumnsOfTheirServers(t *testing.T) { if err != nil { t.Fatal(err) } - checktest.Verdicts(t, runChecksAs(t, name, scenarios...), want) + // proof serve's completions and lookups are nREPL's whatever + // server it acts like. + cs := slices.DeleteFunc(checks.All(), func(c *check.Check) bool { + return strings.HasPrefix(c.ID, "completion.") || strings.HasPrefix(c.ID, "lookup.") + }) + checktest.Verdicts(t, runSomeChecksAs(t, name, cs, scenarios...), want) }) } for _, p := range Presets() { diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 8096a8e..dadcd29 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -211,7 +211,7 @@ func (s *Server) encode(msg map[string]any) []byte { var ( done = []any{"done"} unknownSession = map[string]any{"status": []any{"error", "unknown-session", "done"}} - ops = []string{"clone", "close", "describe", "eval", "interrupt", "load-file", "stdin"} + ops = []string{"clone", "close", "completions", "describe", "eval", "interrupt", "load-file", "lookup", "stdin"} ) // offers reports whether the server supports an op. @@ -263,6 +263,10 @@ func (s *Server) handle(c *conn, req nrepl.Message) bool { req = req.With(nrepl.Message{"code": req["file"]}) } s.eval(c, req, sess) + case "completions": + s.completions(c, req, sess) + case "lookup": + s.lookup(c, req, sess) case "stdin": sess.give(req.Str("stdin")) c.reply(req, map[string]any{"status": done}) diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index 62465c6..4621a22 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -338,6 +338,35 @@ func TestInterrupt(t *testing.T) { } } +// completions and lookup resolve names the way eval does. +func TestLookups(t *testing.T) { + c := connect(t, serveFor(t)) + session := clone(t, c) + eval(t, c, session, "(def inc 1) (ns proof.other) (def x 1) (in-ns 'user)") + lookup := func(sym string) any { + return request(t, c, nrepl.Message{"op": "lookup", "sym": sym, "session": session}).Get("info") + } + for sym, want := range map[string]any{ + "inc": map[string]any{"name": "inc", "ns": "clojure.core"}, + "proof.other/x": map[string]any{"name": "x", "ns": "proof.other"}, + "Thread/sleep": map[string]any{"name": "sleep", "ns": "Thread"}, + "if": map[string]any{"name": "if", "ns": "clojure.core", "special-form": "true"}, + "x": []any{}, + } { + if got := lookup(sym); !reflect.DeepEqual(got, want) { + t.Errorf("lookup %s: %v, want %v", sym, got, want) + } + } + r := request(t, c, nrepl.Message{"op": "completions", "prefix": "in", "session": session}) + var names []string + for _, cand := range r.Get("completions").([]any) { + names = append(names, cand.(map[string]any)["candidate"].(string)) + } + if !slices.Equal(names, []string{"in-ns", "inc"}) { + t.Errorf("completions for in: %v", names) + } +} + func TestClosingASessionInterruptsItsEval(t *testing.T) { c := connect(t, serveFor(t)) session := clone(t, c) @@ -407,10 +436,10 @@ func TestDescribe(t *testing.T) { ops []string version any }{ - {nil, []string{"clone", "close", "describe", "eval", "interrupt", "load-file", "stdin"}, + {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", "describe", "eval", "load-file"}, "0.1.0-dev"}, - {[]string{"read-line-throws"}, []string{"clone", "close", "describe", "eval", "interrupt", "load-file"}, + {[]string{"no-close-op", "no-interrupt", "no-stdin", "string-versions"}, []string{"clone", "completions", "describe", "eval", "load-file", "lookup"}, "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"}}, } for _, tc := range cases { diff --git a/profiles/babashka.toml b/profiles/babashka.toml index 99eab66..66406e9 100644 --- a/profiles/babashka.toml +++ b/profiles/babashka.toml @@ -52,3 +52,12 @@ eof = "nil" [snippets.sleep] code = '(do (print "proof") (flush) (Thread/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" diff --git a/profiles/basilisp.toml b/profiles/basilisp.toml index 53fb6cc..f0c50cd 100644 --- a/profiles/basilisp.toml +++ b/profiles/basilisp.toml @@ -46,3 +46,12 @@ values = ["1", "2"] code = "(read-line)" value = '"proof"' eof = "nil" + +# A prefix, and something it completes to. +[snippets.completion] +code = "printl" +value = "println" + +# A function to look up. +[snippets.lookup] +code = "println" diff --git a/profiles/clojure-clr.toml b/profiles/clojure-clr.toml index 8cb3e0d..6754836 100644 --- a/profiles/clojure-clr.toml +++ b/profiles/clojure-clr.toml @@ -55,3 +55,12 @@ values = ["1", "2"] code = "(read-line)" value = '"proof"' eof = "nil" + +# A prefix, and something it completes to. +[snippets.completion] +code = "printl" +value = "println" + +# A function to look up. +[snippets.lookup] +code = "println" diff --git a/profiles/clojure.toml b/profiles/clojure.toml index 43c4208..7137bf8 100644 --- a/profiles/clojure.toml +++ b/profiles/clojure.toml @@ -52,3 +52,12 @@ eof = "nil" [snippets.sleep] code = '(do (print "proof") (flush) (Thread/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" diff --git a/profiles/dialtone.toml b/profiles/dialtone.toml index 94a26b5..6d07d24 100644 --- a/profiles/dialtone.toml +++ b/profiles/dialtone.toml @@ -48,3 +48,12 @@ eof = "eof" [snippets.sleep] code = 'io:format("proof"), timer:sleep(60000).' out = "proof" + +# A prefix, and something it completes to. +[snippets.completion] +code = "lists:ma" +value = "lists:map" + +# A function to look up. +[snippets.lookup] +code = "lists:map" diff --git a/profiles/jank.toml b/profiles/jank.toml index 3abd2bb..36bd534 100644 --- a/profiles/jank.toml +++ b/profiles/jank.toml @@ -47,3 +47,12 @@ values = ["1", "2"] code = "(read-line)" value = '"proof"' eof = "nil" + +# A prefix, and something it completes to. +[snippets.completion] +code = "printl" +value = "println" + +# A function to look up. +[snippets.lookup] +code = "println" diff --git a/profiles/repartee.toml b/profiles/repartee.toml index 2ed3be7..5cfd5ff 100644 --- a/profiles/repartee.toml +++ b/profiles/repartee.toml @@ -44,3 +44,12 @@ eof = ":eof" [snippets.sleep] code = 'IO.write("proof"); Process.sleep(60_000)' out = "proof" + +# A prefix, and something it completes to. +[snippets.completion] +code = "Enum.ma" +value = "Enum.map" + +# A function to look up. +[snippets.lookup] +code = "Enum.map"