diff --git a/README.md b/README.md index 85dc607..3607a0a 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,9 @@ something differently from the reference nREPL implementation, you'll get a warning instead. Some of the checks send what a particular client sends, the way it sends -it, e.g. `cider.connect` sends the requests CIDER sends while connecting. That way -the report tells you directly whether CIDER will work with your server. +it, e.g. `cider.connect` sends the requests CIDER sends while connecting. +That way the report tells you directly whether CIDER, Calva, Conjure and +vim-fireplace will work with your server. ## Checking Clients @@ -127,7 +128,8 @@ the replies). `-like jank` turns on all of jank's scenarios at once, and proof is still in its early days. Right now it covers the core of the protocol (`describe`, unknown ops, `eval`, sessions, `stdin` and the wire format), -what CIDER sends while connecting and evaluating code, the requests +what CIDER, Calva, Conjure and vim-fireplace send while connecting and +evaluating code, the requests clients send and the server differences clients have to deal with, and `proof list` will show you all the checks. @@ -136,8 +138,6 @@ Here's what's coming next: - checks for `interrupt`, `load-file`, `completions` and `lookup` - robustness checks (malformed messages, fields of the wrong type, clients disconnecting in the middle of an evaluation) -- client profiles for more clients (e.g. Calva, Conjure and - vim-fireplace) - publishing the compatibility matrix somewhere nicer than a CI job summary diff --git a/doc/design.md b/doc/design.md index f2e3e54..a0f09c2 100644 --- a/doc/design.md +++ b/doc/design.md @@ -131,8 +131,9 @@ needs from each reply, along with a link to the client code that needs it. Each situation becomes a check that sends those requests, the way the client sends them, and fails at the first reply the client couldn't use. Replies that only bother the client (e.g. an error the client just -shows to the user) get a note. Right now there's a profile for CIDER, -built from what CIDER sends with its default settings. +shows to the user) get a note. Right now there are profiles for CIDER, +Calva, Conjure and vim-fireplace, built from what they send with their +default settings. A new profile starts with a recording, as `proof proxy -record` saves what a client sent as a profile with a check for each connection. What @@ -205,10 +206,11 @@ requests, though, just like `proof proxy` does. proof is not a Clojure implementation, so `proof serve` understands only a small piece of Clojure. That's enough for the snippets of the nREPL -profile, the code CIDER sends when it connects and what client tests -need (output, values, errors, input, something to interrupt), and it -gives the same replies as nREPL 1.7.0 for the same code. Code in other -languages wouldn't help, as no client's tests send Erlang to a server. +profile, the code CIDER and vim-fireplace send when they connect and +what client tests need (output, values, errors, input, something to +interrupt), and it gives the same replies as nREPL 1.7.0 for the same +code. Code in other languages wouldn't help, as no client's tests send +Erlang to a server. Some rules are about what a client leaves behind - sessions that were never closed and `need-input` that was never answered. They apply only @@ -311,8 +313,6 @@ what's planned next: support them) - robustness checks (malformed messages, fields of the wrong type, clients disconnecting in the middle of an evaluation) -- client profiles for more clients (e.g. Calva, Conjure and - vim-fireplace) - more servers in the compatibility matrix and a proper home for the matrix itself - incorporating [Spec Changes](spec-changes.md) into the spec, so that diff --git a/doc/hacking.md b/doc/hacking.md index 9fe20e7..75e3bb9 100644 --- a/doc/hacking.md +++ b/doc/hacking.md @@ -252,6 +252,7 @@ besides `done`: |---|---| | `new-session` | A `new-session`, which later steps can use as `$` and this name (e.g. `session = "$repl"`). | | `snippet` | The value of this snippet of the server's profile, with all the parts it came in joined together. Its code goes in the request, as it stands for the user's code. | +| `values` | At least this many values, told apart by the `ns` that comes with each one (or after it), which is how some clients tell them apart. | | `dicts` | These fields have to be dicts (or empty lists), if the reply has them. Each one is a list of keys, e.g. `["versions", "clojure"]`. | `why` says what happens in the client when the reply doesn't have what @@ -259,7 +260,10 @@ the step needs, and `refs` point at the client code in question, starting from the profile's `code` (which is pinned to a commit, just like the links in `refs.go`). A check that sends code in some language should list the capability for it in `needs`, unless the client sends -that code to any server (like CIDER's startup code). +that code to any server and carries on when it fails (like CIDER's +startup code). vim-fireplace sends its classpath code to any server too, +but it can't connect without it, so its checks need `java` and servers +it was never meant for don't fail them. To find out what a client sends, run it through `proof proxy -record client.toml`. That gives you a profile with a check for each connection, diff --git a/doc/profiles.md b/doc/profiles.md index 7e67487..ed05d7b 100644 --- a/doc/profiles.md +++ b/doc/profiles.md @@ -109,7 +109,8 @@ capability your profile doesn't declare are skipped. | Capability | When to set it | Checks that need it | |---|---|---| | `namespaces` | Your language has a notion of a current namespace that can be set with the `ns` field of a request (like Clojure). | `eval.ns`, `eval.unknown-ns` | -| `clojure` | Your server evaluates Clojure, or a dialect close enough that the code Clojure clients send (e.g. `ns` forms) runs. | `cider.eval` | +| `clojure` | Your server evaluates Clojure, or a dialect close enough that the code Clojure clients send (e.g. `ns` forms) runs. | `cider.eval`, `calva.eval`, `conjure.eval` | +| `java` | Your server evaluates Clojure with Java interop (e.g. `System/getProperty`), like Clojure on the JVM and Babashka. | `fireplace.connect`, `fireplace.eval` | ## Snippets @@ -120,7 +121,7 @@ snippet is missing, all the checks that need it are skipped. | Snippet | Options | What it should do | Checks that use it | |---|---|---|---| -| `value` | `code`, `value` | evaluate to `value` | `eval.value`, `eval.survives-error`, `eval.ns`, `eval.unknown-ns`, `session.ephemeral`, `session.unknown`, `session.closed`, `cider.eval`, `cider.repl` | +| `value` | `code`, `value` | evaluate to `value` | `eval.value`, `eval.survives-error`, `eval.ns`, `eval.unknown-ns`, `session.ephemeral`, `session.unknown`, `session.closed`, `cider.eval`, `cider.repl`, `calva.eval`, `conjure.eval`, `fireplace.eval` | | `stdout` | `code`, `out` | print `out` to the standard output | `eval.stdout`, `eval.stdout-order` | | `stderr` | `code`, `err` | print `err` to the standard error | `eval.stderr` | | `throw` | `code` | raise an error | `eval.error-status`, `eval.error-report`, `eval.survives-error` | diff --git a/doc/usage.md b/doc/usage.md index f361cba..78653f9 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -89,15 +89,16 @@ eval see: nrepl/nrepl#147 https://github.com/nrepl/nrepl/issues/147 ``` -The checks in the `cider` group send what CIDER sends while connecting, -evaluating code from a source buffer and evaluating code in its REPL, -with all the extra fields CIDER puts in its requests. If one of them -fails, CIDER won't work properly with your server, and the report says -what CIDER does with the reply it got (e.g. "CIDER gives up +The checks in the `cider`, `calva`, `conjure` and `fireplace` groups send +what those clients send while connecting and evaluating code, with all +the extra fields they put in their requests. If one of them fails, the +client won't work properly with your server, and the report says what +the client does with the reply it got (e.g. "CIDER gives up connecting"). The code they evaluate for the user is the `value` -snippet of your profile. Before evaluating code from a source buffer, -CIDER evaluates the buffer's `ns` form, so `cider.eval` also needs the -`clojure` capability. +snippet of your profile. Before evaluating code from a source file, +CIDER, Calva and Conjure evaluate an `ns` form for the file's namespace, +so those checks also need the `clojure` capability. vim-fireplace finds the classpath +with Java interop while connecting, so its checks need `java`. Each check gets one of the following verdicts: @@ -388,13 +389,15 @@ for the same code: | `(def x 1)`, `x`, `#'x`, `(resolve 'x)`, `@#'x` | Definitions, which all sessions share | | `(ns foo)`, `(in-ns 'foo)`, `*ns*` | Namespaces | | `*1`, `*2`, `*3`, `*e` | The last results and the last exception in the session | -| `do`, `if`, `when`, `let`, `when-let` | The usual | +| `do`, `if`, `when`, `or`, `let`, `when-let` | The usual | +| `(System/getProperty "user.dir")` | The path separator, the working directory and `src` as the classpath, which is what vim-fireplace asks for | | `(require ...)` | Nothing, as there's nothing to load | Other functions get the error Clojure gives for a symbol it can't resolve, and syntax proof doesn't read (e.g. sets or anonymous functions) -gets a read error. That's enough for CIDER to connect and work, and it's -all you need for checking output, values, errors, input and interrupts. +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. 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/check/checktest/checktest.go b/internal/check/checktest/checktest.go index d2cdfe0..133ac2f 100644 --- a/internal/check/checktest/checktest.go +++ b/internal/check/checktest/checktest.go @@ -3,6 +3,7 @@ package checktest import ( + "maps" "sort" "testing" @@ -43,3 +44,12 @@ func Verdicts(t testing.TB, results map[string]check.Result, want map[string]che } } } + +// Merged merges sets of verdicts, where the later ones win. +func Merged(verdicts ...map[string]check.Verdict) map[string]check.Verdict { + all := map[string]check.Verdict{} + for _, v := range verdicts { + maps.Copy(all, v) + } + return all +} diff --git a/internal/checks/checks_test.go b/internal/checks/checks_test.go index 5cd2ddd..de0de19 100644 --- a/internal/checks/checks_test.go +++ b/internal/checks/checks_test.go @@ -1,6 +1,7 @@ package checks import ( + "slices" "strings" "testing" "time" @@ -8,13 +9,14 @@ import ( "github.com/nrepl/proof/internal/check" "github.com/nrepl/proof/internal/check/checktest" "github.com/nrepl/proof/internal/profile" + "github.com/nrepl/proof/nrepl" ) // The fake server understands these made-up forms; see fakeServer.eval. var fakeProfile = &profile.Profile{ Name: "fake", Timeout: time.Second, - Capabilities: map[string]bool{"namespaces": true, "clojure": true}, + Capabilities: map[string]bool{"namespaces": true, "clojure": true, "java": true}, Snippets: map[string]profile.Snippet{ "value": {Code: "value", Value: "3"}, "stdout": {Code: "stdout", Out: "proof"}, @@ -46,37 +48,41 @@ func TestWellBehavedServerPassesEverything(t *testing.T) { // other check and rule must still pass. func TestChecksCatchMisbehaviour(t *testing.T) { F, W, S := check.Failed, check.Warned, check.Skipped + // What every client goes through first. + connects := map[string]check.Verdict{"cider.connect": F, "calva.connect": F, "conjure.connect": F, "fireplace.connect": F} cases := []struct { name string q quirks want map[string]check.Verdict }{ - {"ops as a list", quirks{opsList: true}, map[string]check.Verdict{"describe.ops-dict": F, "describe.required-ops": S, - "stdin.need-input": S, "stdin.roundtrip": S, "stdin.eof": S, "cider.connect": F}}, + // Conjure does without the features that need ops. + {"ops as a list", quirks{opsList: true}, checktest.Merged(connects, 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}, map[string]check.Verdict{ + {"describe kills the connection", quirks{crashOnDescribe: true}, checktest.Merged(connects, 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, "cider.connect": F}}, + "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}}, {"no op echo", quirks{noOpEcho: true}, map[string]check.Verdict{"op.unknown-echo": W}}, + // Conjure asks for ls-sessions, which the fake doesn't know. {"status is a string", quirks{statusString: true}, map[string]check.Verdict{ - "op.unknown": F, "op.unknown-echo": F, "wire.status-type": F}}, + "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}, map[string]check.Verdict{ - "cider.connect": F, "cider.eval": F, "cider.repl": F}}, + "cider.connect": F, "cider.eval": F, "cider.repl": F, "calva.connect": F, "calva.eval": F, "conjure.eval": F}}, {"any session accepted", quirks{acceptAnySession: true}, map[string]check.Verdict{"session.unknown": F, "session.closed": F}}, {"shared session state", quirks{sharedState: true}, map[string]check.Verdict{"session.isolated": F}}, {"sessions tied to sockets", quirks{socketSessions: true}, map[string]check.Verdict{"session.across-connections": W}}, - {"no ephemeral sessions", quirks{noEphemeral: true}, map[string]check.Verdict{"session.ephemeral": F}}, - {"last value only", quirks{lastValueOnly: true}, map[string]check.Verdict{"eval.multiple-forms": F}}, + {"no ephemeral sessions", quirks{noEphemeral: true}, map[string]check.Verdict{"session.ephemeral": F, "fireplace.connect": F}}, + {"last value only", quirks{lastValueOnly: true}, map[string]check.Verdict{"eval.multiple-forms": F, "fireplace.connect": F}}, {"stderr dropped", quirks{dropErr: true}, map[string]check.Verdict{"eval.stderr": F}}, {"output after value", quirks{outAfterValue: true}, map[string]check.Verdict{"eval.stdout-order": W}}, {"no eval-error", quirks{noEvalError: true}, map[string]check.Verdict{"eval.error-status": F}}, {"no ex", quirks{noEx: true}, map[string]check.Verdict{"eval.error-report": W}}, {"no no-code", quirks{noNoCode: true}, map[string]check.Verdict{"eval.no-code": W}}, - {"no ns", quirks{noNs: true}, map[string]check.Verdict{"eval.ns": W}}, + {"no ns", quirks{noNs: true}, map[string]check.Verdict{"eval.ns": W, "fireplace.connect": F}}, {"ns fallback", quirks{nsFallback: true}, map[string]check.Verdict{"eval.unknown-ns": F}}, {"two dones", quirks{twoDones: true}, map[string]check.Verdict{"wire.one-done": W}}, {"value after done", quirks{valueAfterDone: true}, map[string]check.Verdict{"wire.after-done": W, "wire.error-terminal": F}}, @@ -87,7 +93,7 @@ func TestChecksCatchMisbehaviour(t *testing.T) { "wire.id": W, "eval.stdout": F, "eval.stderr": F, "eval.error-report": W}}, {"integer value", quirks{intValue: true}, map[string]check.Verdict{ "wire.field-types": F, "eval.value": F, "eval.survives-error": F, "eval.multiple-forms": F, - "session.ephemeral": F, "session.persistent": F, "cider.eval": F, "cider.repl": F}}, + "session.ephemeral": F, "session.persistent": F, "cider.eval": F, "cider.repl": F, "calva.eval": F, "conjure.eval": F, "fireplace.eval": F}}, {"unsorted keys", quirks{unsortedKeys: true}, map[string]check.Verdict{"wire.canonical": W}}, {"invalid UTF-8", quirks{badUTF8: true}, map[string]check.Verdict{"wire.utf8": W, "eval.stdout": F}}, {"no stdin op", quirks{noStdinOp: true}, map[string]check.Verdict{ @@ -132,3 +138,39 @@ func TestBrokenCloneSkipsSessionChecksQuickly(t *testing.T) { } } } + +// nREPL goes on with the next form after one throws, so what the next form +// sends isn't late. +func TestOnlyTheFormThatThrewIsOver(t *testing.T) { + // Only Clojure code can be told apart into forms. + for code, want := range map[string]check.Verdict{`(/ 1 0) (println "proof")`: check.Pass, "(/ 1 0)": check.Failed, + `raise "proof"`: check.Failed} { + events := []nrepl.Event{ + {Dir: nrepl.Sent, Msg: nrepl.Message{"id": "1", "op": "eval", "code": code}}, + {Dir: nrepl.Received, Msg: nrepl.Message{"id": "1", "status": []any{"eval-error"}}}, + {Dir: nrepl.Received, Msg: nrepl.Message{"id": "1", "out": "proof"}}, + {Dir: nrepl.Received, Msg: nrepl.Message{"id": "1", "status": []any{"done"}}}, + } + results := checktest.ByID(check.Grade(WireRules(), []check.Traffic{{Label: "test", Events: events}})) + if got := results["wire.error-terminal"].Verdict; got != want { + t.Errorf("%q: got %s, want %s", code, got, want) + } + } +} + +func TestForms(t *testing.T) { + cases := map[string][]string{ + "value": {"value"}, + "1 2\n": {"1", "2"}, + `(f "a ) b" [1 2]) {:a 1}`: {`(f "a ) b" [1 2])`, "{:a 1}"}, + `(str "\"" ")") x`: {`(str "\"" ")")`, "x"}, + "1, 2 ; (3 \"\n4": {"1", "2", "4"}, + `(= c \() (a)(b) x;c` + "\ny": {`(= c \()`, "(a)", "(b)", "x", "y"}, + "": nil, + } + for code, want := range cases { + if got := forms(code); !slices.Equal(got, want) { + t.Errorf("forms(%q) = %q, want %q", code, got, want) + } + } +} diff --git a/internal/checks/client_profiles.go b/internal/checks/client_profiles.go index 08d1b60..04df4a9 100644 --- a/internal/checks/client_profiles.go +++ b/internal/checks/client_profiles.go @@ -51,6 +51,10 @@ type clientStep struct { Snippet string `toml:"snippet"` // NewSession names the session the reply has to hand back. NewSession string `toml:"new-session"` + // Values is how many values the reply has to have at least, told apart + // the way some clients do it: by the ns that comes with each one, or + // after it. + Values int `toml:"values"` // Dicts are fields that have to be dicts if the reply has them, each // a path of keys (e.g. ["versions", "clojure"]). An empty list will do // too, as clients can't tell the two apart. @@ -167,6 +171,9 @@ func (c clientCheck) replay(t *check.T) { t.Stopf("%s gave the value %q instead of %q, so %s", step, got, want, s.Why) } } + if n := valuesApart(resp); n < s.Values { + t.Stopf("%s gave %d values that can be told apart by their ns, not %d, so %s", step, n, s.Values, s.Why) + } for _, path := range s.Dicts { if v := nonDictField(resp, path); v != nil { t.Stopf("%s has %s that is %s, not a dict, so %s", step, strings.Join(path, "."), typeName(v), s.Why) @@ -175,6 +182,25 @@ func (c clientCheck) replay(t *check.T) { } } +// valuesApart counts the values of a reply the way clients that tell +// them apart by ns do, where the parts of a value up to the next ns are +// one value. +func valuesApart(resp nrepl.Response) int { + n, open := 0, false + for _, m := range resp.Messages { + if v, _ := m["value"].(string); v != "" { + open = true + } + if m.Has("ns") && open { + n, open = n+1, false + } + } + if open { + n++ + } + return n +} + // nonDictField returns the field at path in a reply (e.g. versions, then // clojure) unless it's a dict, an empty list or missing. func nonDictField(resp nrepl.Response, path []string) any { diff --git a/internal/checks/client_profiles_test.go b/internal/checks/client_profiles_test.go index 2d4a560..8743c38 100644 --- a/internal/checks/client_profiles_test.go +++ b/internal/checks/client_profiles_test.go @@ -21,7 +21,8 @@ func TestClientProfiles(t *testing.T) { } pinned := regexp.MustCompile(`^https://github\.com/[^/]+/[^/]+/blob/[0-9a-f]{40}/`) // The links in refs.go to the same clients. - bases := map[string]string{"CIDER": ciderBase} + bases := map[string]string{"CIDER": ciderBase, "Calva": strings.TrimSuffix(calvaBase, "nrepl/index.ts"), + "Conjure": strings.TrimSuffix(conjureBase, "client/clojure/nrepl/server.fnl"), "vim-fireplace": fireplaceBase} for _, p := range clientProfiles() { if p.Name == "" || !pinned.MatchString(p.Code) { t.Errorf("%q needs a name and links pinned to a commit, got %q", p.Name, p.Code) @@ -38,12 +39,15 @@ func TestClientProfiles(t *testing.T) { if s.Send["op"] == nil || len(s.Refs) == 0 { t.Errorf("%s step %d: needs an op and a link to the client's code", c.ID, i+1) } - if (s.NewSession != "" || s.Snippet != "" || len(s.Dicts) > 0) && s.Why == "" { + if (s.NewSession != "" || s.Snippet != "" || s.Values > 0 || len(s.Dicts) > 0) && s.Why == "" { t.Errorf("%s step %d: doesn't say what happens to the client without what it needs", c.ID, i+1) } if name, ok := s.Send["session"].(string); ok && strings.HasPrefix(name, "$") && !sessions[name[1:]] { t.Errorf("%s step %d: uses %s before a step gets it", c.ID, i+1, name) } + if code, _ := s.Send["code"].(string); s.Values > len(forms(code)) { + t.Errorf("%s step %d: wants %d values from %d forms", c.ID, i+1, s.Values, len(forms(code))) + } if s.NewSession != "" { sessions[s.NewSession] = true } @@ -88,3 +92,21 @@ func TestNonDictField(t *testing.T) { } } } + +func TestValuesApart(t *testing.T) { + cases := []struct { + replies []nrepl.Message + want int + }{ + {[]nrepl.Message{{"value": "1", "ns": "user"}, {"value": "2", "ns": "user"}}, 2}, + // An ns on its own ends the value before it. + {[]nrepl.Message{{"value": "1"}, {"ns": "user"}, {"value": "2"}}, 2}, + {[]nrepl.Message{{"value": "1"}, {"value": "2"}}, 1}, + {[]nrepl.Message{{"ns": "user"}, {"status": []any{"done"}}}, 0}, + } + for _, c := range cases { + if got := valuesApart(nrepl.Response{Messages: c.replies}); got != c.want { + t.Errorf("%v: got %d, want %d", c.replies, got, c.want) + } + } +} diff --git a/internal/checks/clients/calva.toml b/internal/checks/clients/calva.toml new file mode 100644 index 0000000..479bd17 --- /dev/null +++ b/internal/checks/clients/calva.toml @@ -0,0 +1,81 @@ +# What Calva sends while connecting to a server without cider-nrepl, and +# when it evaluates code from a source file, with its default settings. +# The links point at the code that needs what the steps check. +name = "Calva" +code = "https://github.com/BetterThanTomorrow/calva/blob/ad00dd7e518e0d08262810673111eaeddc5c2fdd/src/" + +# Calva sends its startup code to any server, so this check runs on servers +# for other languages too. +[[checks]] +id = "calva.connect" +title = "Calva can connect" +why = "Calva evaluates *ns* without a session, clones a session, asks for describe and evaluates its startup code before its REPL window shows a prompt, so a server that gets any of it wrong leaves Calva users without a REPL." + +# Calva clones a session once this is done. +[[checks.steps]] +send = { op = "eval", code = "*ns*" } +refs = ["nrepl/index.ts#L262-L276", "nrepl/index.ts#L297-L299"] + +[[checks.steps]] +send = { op = "clone", client-name = "Calva", client-version = "2.0.599" } +new-session = "calva" +why = "Calva gives up connecting after 30 seconds" +refs = ["nrepl/index.ts#L277-L286", "nrepl/index.ts#L243-L253"] + +# Calva sends only the ops it finds in the first reply. +[[checks.steps]] +send = { op = "describe", session = "$calva", verbose = 1 } +dicts = [["ops"]] +why = "Calva never evaluates anything" +refs = ["nrepl/index.ts#L260-L261", "nrepl/index.ts#L491-L493"] + +# The REPL window shows its prompt once the startup code is done. An error +# is fine: Calva shows it and carries on. +[[checks.steps]] +refs = ["connector.ts#L547-L558"] + +[checks.steps.send] +op = "eval" +session = "$calva" +ns = "user" +code = "(when-let [requires (resolve 'clojure.main/repl-requires)] (clojure.core/apply clojure.core/require @requires))" +file = "/home/user/project/.calva/repl.calva-repl" +line = 17 +column = 1 +pprint = 1 +"nrepl.middleware.print/print" = "cider.nrepl.pprint/pprint" +"nrepl.middleware.print/options" = { right-margin = 120, length = 50 } + +[[checks]] +id = "calva.eval" +title = "Calva can evaluate code from a source file" +why = "Evaluating code from a source file sends the file's ns form first, and then the code with its file, line and column and options for printing the value, so a server that can't handle either breaks evaluation in Calva." +needs = ["clojure"] + +[[checks.steps]] +send = { op = "clone", client-name = "Calva", client-version = "2.0.599" } +new-session = "calva" +why = "Calva gives up connecting after 30 seconds" +refs = ["nrepl/index.ts#L277-L286", "nrepl/index.ts#L243-L253"] + +# The REPL window's namespace is user until a reply says otherwise, so the +# file's ns form goes first. Its reply isn't checked. +[[checks.steps]] +send = { op = "eval", session = "$calva", ns = "user", code = "(ns proof.calva)", pprint = 0 } +refs = ["evaluate.ts#L185-L187", "nrepl/index.ts#L644-L650"] + +[[checks.steps]] +snippet = "value" +why = "users don't see the result of their code" +refs = ["evaluate.ts#L191-L200", "nrepl/index.ts#L1447-L1530"] + +[checks.steps.send] +op = "eval" +session = "$calva" +ns = "proof.calva" +file = "/home/user/project/src/proof/calva.clj" +line = 3 +column = 1 +pprint = 1 +"nrepl.middleware.print/print" = "cider.nrepl.pprint/pprint" +"nrepl.middleware.print/options" = { right-margin = 120, length = 50 } diff --git a/internal/checks/clients/conjure.toml b/internal/checks/clients/conjure.toml new file mode 100644 index 0000000..5133c1b --- /dev/null +++ b/internal/checks/clients/conjure.toml @@ -0,0 +1,151 @@ +# What Conjure sends while connecting and when it evaluates code from a +# source buffer, with its default settings. The links point at the code +# that needs what the steps check. +name = "Conjure" +code = "https://github.com/Olical/conjure/blob/9842bf38464df071f72546f65fa6ec44942ca7b2/fnl/conjure/" + +# Conjure sends its own code to any server, so this check runs on servers +# for other languages too. +[[checks]] +id = "conjure.connect" +title = "Conjure can connect" +why = "Before it evaluates anything for the user, Conjure asks for describe, clones a session (unless there's one it can use) and evaluates its own code in it, so a server that gets any of it wrong keeps Conjure users waiting for 10 seconds, and maybe without a session after that." + +# Conjure looks up the ops it needs in the first reply, and does without +# the features that need the ones it can't find. +[[checks.steps]] +send = { op = "describe" } +refs = ["client/clojure/nrepl/server.fnl#L304-L313", "client/clojure/nrepl/server.fnl#L315-L341"] + +# Conjure uses the first session it finds, and clones one when there are +# none (or the server doesn't know ls-sessions). +[[checks.steps]] +send = { op = "ls-sessions" } +refs = ["client/clojure/nrepl/server.fnl#L167-L177", "client/clojure/nrepl/server.fnl#L274-L287"] + +[[checks.steps]] +send = { op = "clone", client-name = "Conjure" } +new-session = "conjure" +why = "Conjure gives up waiting after 10 seconds and evaluates code without a session, so nothing (e.g. *1 or *e) carries over from one evaluation to the next" +refs = ["client/clojure/nrepl/server.fnl#L259-L272", "client/clojure/nrepl/server.fnl#L379-L392", "remote/nrepl.fnl#L48-L57"] + +# What kind of Clojure the session runs, which Conjure only shows. An error +# is fine. +[[checks.steps]] +send = { op = "eval", session = "$conjure", code = "#?(:clj 'clj :cljs 'cljs :cljr 'cljr :default 'unknown)" } +refs = ["client/clojure/nrepl/server.fnl#L187-L221"] + +# Conjure is ready once its own code is done. An error is fine. +[[checks.steps]] +refs = ["client/clojure/nrepl/server.fnl#L289-L302", "client/clojure/nrepl/server.fnl#L394-L402"] + +[checks.steps.send] +op = "eval" +session = "$conjure" +code = ''' +(create-ns 'conjure.internal) +(intern 'conjure.internal 'initial-ns (symbol (str *ns*))) + +(ns conjure.internal + (:require [clojure.pprint :as pp] + [clojure.test] + [clojure.data] + [clojure.string])) + +;; This is a shim that inserts a pprint fn in the place that CIDER would create it if it's not found. +;; We shim instead of creating our own distinct function because babashka requires us +;; to refer to `cider.nrepl.pprint/pprint` if we want to use pretty printing. +;; https://github.com/Olical/conjure/issues/406 +(when-not (find-ns 'cider.nrepl.pprint) + (create-ns 'cider.nrepl.pprint) + (intern 'cider.nrepl.pprint 'pprint + (fn pprint [val w opts] + (apply pp/write val + (mapcat identity (assoc opts :stream w)))))) + +(defn bounded-conj [queue x limit] + (->> x (conj queue) (take limit))) + +(def tap-queue-size 16) +(defonce tap-queue! (atom (list))) + +;; Must be a defonce so that we always have the same function +;; reference to remove-tap and add-tap. If we make a new +;; function each time we'll end up adding more and more tap +;; functions. +(defonce enqueue-tap! + (fn [x] (swap! tap-queue! bounded-conj x tap-queue-size))) + +;; No setup for older Clojure versions. +(when (resolve 'add-tap) + (remove-tap enqueue-tap!) + (add-tap enqueue-tap!)) + +(defn dump-tap-queue! [] + (reverse (first (reset-vals! tap-queue! (list))))) + +(when true + (defmethod clojure.test/report :fail [m] + (clojure.test/with-test-out + (clojure.test/inc-report-counter :fail) + (println "\nFAIL in" (clojure.test/testing-vars-str m)) + (when (seq clojure.test/*testing-contexts*) (println (clojure.test/testing-contexts-str))) + (when-let [message (:message m)] (println message)) + (print "expected:" (with-out-str (prn (:expected m)))) + (print " actual:" (with-out-str (prn (:actual m)))) + (when (and (seq? (:actual m)) + (= #'clojure.core/not (resolve (first (:actual m)))) + (seq? (second (:actual m))) + (= #'clojure.core/= (resolve (first (second (:actual m))))) + (= 3 (count (second (:actual m))))) + (let [[missing extra _] (clojure.data/diff (second (second (:actual m))) (last (second (:actual m)))) + missing-str (with-out-str (pp/pprint missing)) + missing-lines (clojure.string/split-lines missing-str) + extra-str (with-out-str (pp/pprint extra)) + extra-lines (clojure.string/split-lines extra-str)] + (when (some? missing) (doseq [m missing-lines] (println "- " m))) + (when (some? extra) (doseq [e extra-lines] (println "+ " e)))))))) + +(in-ns initial-ns) +''' + +[[checks]] +id = "conjure.eval" +title = "Conjure can evaluate code from a source buffer" +why = "Evaluating code from a source buffer sends an ns form for the buffer's namespace first, and then the code with its file, line and column and options for printing the value, so a server that can't handle either breaks evaluation in Conjure." +needs = ["clojure"] + +[[checks.steps]] +send = { op = "clone", client-name = "Conjure" } +new-session = "conjure" +why = "Conjure gives up waiting after 10 seconds and evaluates code without a session, so nothing (e.g. *1 or *e) carries over from one evaluation to the next" +refs = ["client/clojure/nrepl/server.fnl#L259-L272", "client/clojure/nrepl/server.fnl#L379-L392", "remote/nrepl.fnl#L48-L57"] + +# The first eval in a buffer makes sure its namespace exists. Its reply +# isn't checked. +[[checks.steps]] +refs = ["client/clojure/nrepl/action.fnl#L95-L110"] + +[checks.steps.send] +op = "eval" +session = "$conjure" +code = "(ns proof.conjure)" +"nrepl.middleware.print/print" = "cider.nrepl.pprint/pprint" +"nrepl.middleware.print/options" = { associative = 1, length = 500, level = 50, right-margin = 72 } + +[[checks.steps]] +snippet = "value" +why = "users don't see the result of their code" +refs = ["client/clojure/nrepl/server.fnl#L139-L152", "client/clojure/nrepl/server.fnl#L108-L137", "client/clojure/nrepl/ui.fnl#L24-L50"] + +# The options are a dict even when Conjure has none, which is what +# associative is for. +[checks.steps.send] +op = "eval" +session = "$conjure" +ns = "proof.conjure" +file = "/home/user/project/src/proof/conjure.clj" +line = 3 +column = 1 +"nrepl.middleware.print/print" = "cider.nrepl.pprint/pprint" +"nrepl.middleware.print/options" = { associative = 1, length = 500, level = 50, right-margin = 72 } diff --git a/internal/checks/clients/fireplace.toml b/internal/checks/clients/fireplace.toml new file mode 100644 index 0000000..1548c70 --- /dev/null +++ b/internal/checks/clients/fireplace.toml @@ -0,0 +1,60 @@ +# What vim-fireplace sends while connecting to a server without +# cider-nrepl, and when it evaluates code, with its default settings. The +# links point at the code that needs what the steps check. +name = "vim-fireplace" +code = "https://github.com/tpope/vim-fireplace/blob/5e66509599de92550762cf2681338fc4cd1e71cf/autoload/" + +# fireplace finds the classpath with Java interop, so it can connect only +# to servers that have it. +[[checks]] +id = "fireplace.connect" +title = "vim-fireplace can connect" +why = "vim-fireplace asks for describe, evaluates code that tells it the classpath and clones a session before it can evaluate anything, so a server that gets any of it wrong can't be used from vim-fireplace at all." +needs = ["java"] + +[[checks.steps]] +send = { op = "describe", "verbose?" = 1 } +dicts = [["ops"]] +why = "vim-fireplace can't connect, as Vim stops with an error when it looks up an op" +refs = ["fireplace/transport.vim#L205-L206", "fireplace/transport.vim#L258-L260"] + +# fireplace tells the values apart by the ns that comes with each one, and +# reads the first three. +[[checks.steps]] +send = { op = "eval", code = "(System/getProperty \"path.separator\") (or (System/getProperty \"fake.class.path\") (System/getProperty \"java.class.path\") \"\") (System/getProperty \"user.dir\") (require 'clojure.repl 'clojure.java.javadoc)" } +values = 3 +why = "vim-fireplace can't connect, as Vim stops with an error when it looks for the classpath" +refs = ["fireplace/transport.vim#L212-L226", "fireplace/transport.vim#L24-L65"] + +[[checks.steps]] +send = { op = "clone" } +new-session = "fireplace" +why = "vim-fireplace can't connect" +refs = ["fireplace/session.vim#L19", "fireplace.vim#L658"] + +[[checks]] +id = "fireplace.eval" +title = "vim-fireplace can evaluate code" +why = "vim-fireplace sends the code with its file, line and column and asks for the value to be streamed, so a server that can't handle them breaks evaluation in vim-fireplace." +needs = ["java"] + +[[checks.steps]] +send = { op = "clone" } +new-session = "fireplace" +why = "vim-fireplace can't connect" +refs = ["fireplace/session.vim#L19", "fireplace.vim#L658"] + +# Evaluating a form from src/user.clj. A namespace other than user would +# have to be loaded first, from the classpath. +[[checks.steps]] +snippet = "value" +why = "users don't see the result of their code" +refs = ["fireplace.vim#L1612-L1671", "fireplace.vim#L1584-L1594", "fireplace.vim#L1230-L1295"] + +[checks.steps.send] +op = "eval" +session = "$fireplace" +file = "user.clj" +line = 3 +column = 1 +"nrepl.middleware.print/stream?" = 1 diff --git a/internal/checks/fake_test.go b/internal/checks/fake_test.go index d5ecc72..c944a55 100644 --- a/internal/checks/fake_test.go +++ b/internal/checks/fake_test.go @@ -318,7 +318,7 @@ func (s *fakeServer) eval(c net.Conn, req nrepl.Message, local map[string]bool) } } var values []string - for _, form := range strings.Fields(code) { + for _, form := range forms(code) { value, out := "nil", "" switch form { case "value": diff --git a/internal/checks/wire.go b/internal/checks/wire.go index e5966f0..e43dc84 100644 --- a/internal/checks/wire.go +++ b/internal/checks/wire.go @@ -3,7 +3,10 @@ package checks import ( "errors" "fmt" + "slices" "strconv" + "strings" + "unicode" "unicode/utf8" "github.com/nrepl/proof/bencode" @@ -183,7 +186,10 @@ func WireRules() []*check.Rule { if !r.AfterError { return } - if k := firstKeyNotIn(r.Msg, afterErrorKeys); k != "" { + // nREPL goes on with the next form after one throws, and + // REPLy sends one form at a time. Only Clojure code can be + // told apart into forms, though. + if k := firstKeyNotIn(r.Msg, afterErrorKeys); k != "" && !severalForms(r.Req.Str("code")) { report(k+" arrived after an error status", r.Msg.String()) } }), @@ -372,3 +378,62 @@ func excerpt(raw []byte) string { } return strconv.Quote(string(raw)) } + +// severalForms says whether code is more than one Clojure form, each in +// brackets, like the code clients send when they connect. +func severalForms(code string) bool { + fs := forms(code) + return len(fs) > 1 && !slices.ContainsFunc(fs, func(f string) bool { return !strings.ContainsAny(f[:1], "([{") }) +} + +// forms splits Clojure code into its top-level forms (e.g. +// `(System/getProperty "user.dir")`), leaving out comments. +func forms(code string) []string { + var forms []string + var form strings.Builder + end := func() { + if form.Len() > 0 { + forms = append(forms, form.String()) + form.Reset() + } + } + depth, inString, escaped, inComment := 0, false, false, false + for _, r := range code { + switch { + case inComment: + inComment = r != '\n' + continue + case escaped: + escaped = false + case inString: + escaped = r == '\\' + inString = r != '"' + case r == '\\': + // A character, e.g. \( + escaped = true + case r == '"': + inString = true + case r == ';': + inComment = true + if depth == 0 { + end() + } + continue + case strings.ContainsRune("([{", r): + depth++ + case strings.ContainsRune(")]}", r): + depth-- + if depth == 0 { + form.WriteRune(r) + end() + continue + } + case (unicode.IsSpace(r) || r == ',') && depth == 0: + end() + continue + } + form.WriteRune(r) + } + end() + return forms +} diff --git a/internal/serve/eval.go b/internal/serve/eval.go index daec347..88da94f 100644 --- a/internal/serve/eval.go +++ b/internal/serve/eval.go @@ -7,6 +7,7 @@ import ( "maps" "math" "math/big" + "os" "strings" "sync" "time" @@ -352,6 +353,16 @@ func init() { }, "let": binder("let"), "when-let": binder("when-let"), + "or": func(e *evaluation, args []any) (any, error) { + var v any + for _, arg := range args { + var err error + if v, err = e.eval(arg); err != nil || truthy(v) { + return v, err + } + } + return v, nil + }, "if": func(e *evaluation, args []any) (any, error) { if len(args) < 2 || len(args) > 3 { return nil, arity("if", len(args)) @@ -513,6 +524,29 @@ func init() { } return nil, castError(args[0], "java.util.concurrent.Future") }, + // What vim-fireplace asks for to find the classpath. + "System/getProperty": func(e *evaluation, args []any) (any, error) { + if len(args) != 1 { + return nil, arity("System/getProperty", len(args)) + } + key, ok := args[0].(string) + if !ok { + return nil, castError(args[0], "java.lang.String") + } + switch key { + case "path.separator": + return string(os.PathListSeparator), nil + case "java.class.path": + return "src", nil + case "user.dir": + dir, err := os.Getwd() + if err != nil { + return nil, nil + } + return dir, nil + } + return nil, nil + }, "Thread/sleep": func(e *evaluation, args []any) (any, error) { if len(args) != 1 { return nil, arity("Thread/sleep", len(args)) diff --git a/internal/serve/eval_test.go b/internal/serve/eval_test.go index e429add..f241ea6 100644 --- a/internal/serve/eval_test.go +++ b/internal/serve/eval_test.go @@ -13,6 +13,7 @@ func FuzzEval(f *testing.F) { `(println "a" :k) (binding [*out* *err*] (prn 'x)) (flush)`, "(let [x 1] (when-let [y x] (if y x 2)))", "(def x 1) #'x @(resolve 'x) (in-ns 'foo) (ns bar) *ns* *1 *e", `(throw (ex-info "x" {:a 1})) (ex-data *e)`, "(read-line) (Thread/sleep 10) (future (println 1)) (require 'x)", "(let) (when) (1 2) '(1 \"a\") (quote)", + `(or nil 1) (or) (System/getProperty "user.dir") (System/getProperty 1)`, } { f.Add(code) } diff --git a/internal/serve/matrix_test.go b/internal/serve/matrix_test.go index 025ced3..1840259 100644 --- a/internal/serve/matrix_test.go +++ b/internal/serve/matrix_test.go @@ -26,14 +26,28 @@ func serveFor(t *testing.T, scenarios ...string) *Server { return s } -// runChecks runs proof's server checks against proof serve, with the -// snippets of the profile for nREPL itself. -func runChecks(t *testing.T, scenarios ...string) map[string]check.Result { +func loadProfile(t *testing.T, name string) *profile.Profile { t.Helper() - p, err := profile.Load("../../profiles/clojure.toml") + p, err := profile.Load("../../profiles/" + name + ".toml") if err != nil { t.Fatal(err) } + return p +} + +// runChecks runs proof's server checks against proof serve, with the +// profile for nREPL itself. +func runChecks(t *testing.T, scenarios ...string) map[string]check.Result { + t.Helper() + return runChecksAs(t, "clojure", scenarios...) +} + +// 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() + 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())) @@ -54,7 +68,9 @@ func TestScenariosGetTheVerdictsOfTheirServers(t *testing.T) { }{ {[]string{"split-output"}, nil}, {[]string{"empty-messages"}, nil}, - {[]string{"last-value"}, map[string]check.Verdict{"eval.multiple-forms": F}}, + // None of these servers has Java interop, but vim-fireplace would + // run into it. + {[]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-op-echo"}, map[string]check.Verdict{"op.unknown-echo": W}}, @@ -84,24 +100,29 @@ func TestScenariosGetTheVerdictsOfTheirServers(t *testing.T) { // 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, and the checks the profiles of dialtone and repartee skip -// for their languages. +// without code. 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} + noLanguage := map[string]check.Verdict{"eval.ns": S, "eval.unknown-ns": S, "cider.eval": S, "calva.eval": S, + "conjure.eval": S} columns := map[string]map[string]check.Verdict{ "clojure": {"stdin.eof": W}, "babashka": nil, - "clojure-clr": {"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": {"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": {"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, "stdin.need-input": S, "stdin.roundtrip": S, - "stdin.eof": S, "wire.canonical": W}, - "dialtone": {"op.unknown-echo": W, "session.close": F, "eval.stderr": F, "eval.multiple-forms": F}, - "repartee": {"op.unknown-echo": W, "session.close": F, "eval.stderr": F}, + "clojure-clr": checktest.Merged(noJava, 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, 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, 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, "stdin.need-input": S, + "stdin.roundtrip": S, "stdin.eof": S, "wire.canonical": W}), + "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, + "eval.stderr": F}), } for name, want := range columns { t.Run(name, func(t *testing.T) { @@ -110,7 +131,7 @@ func TestPresetsGetTheColumnsOfTheirServers(t *testing.T) { if err != nil { t.Fatal(err) } - checktest.Verdicts(t, runChecks(t, scenarios...), want) + checktest.Verdicts(t, runChecksAs(t, name, scenarios...), want) }) } for _, p := range Presets() { diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index df050ff..dd0e4c2 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -4,6 +4,7 @@ import ( "io" "maps" "net" + "os" "reflect" "slices" "strings" @@ -50,6 +51,10 @@ func eval(t *testing.T, c *nrepl.Conn, session, code string) nrepl.Response { func TestEvaluatesLikeClojure(t *testing.T) { s := serveFor(t) + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } cases := []struct { code string values []string @@ -71,6 +76,11 @@ func TestEvaluatesLikeClojure(t *testing.T) { {code: "(def x 1) x user/x", values: []string{"#'user/x", "1", "1"}}, {code: "(let [x 1 y (+ x 1)] y) (when 1 2) (when nil 2) (when-let [x nil] 1) (when-let [x 3] x)", values: []string{"2", "2", "nil", "nil", "3"}}, + {code: "(or nil false) (or nil 2 (/ 1 0)) (or)", values: []string{"false", "2", "nil"}}, + // What vim-fireplace evaluates when it connects. + {code: `(System/getProperty "path.separator") (or (System/getProperty "fake.class.path") (System/getProperty "java.class.path") "") ` + + `(System/getProperty "user.dir") (require 'clojure.repl 'clojure.java.javadoc)`, + values: []string{quote(string(os.PathListSeparator)), `"src"`, quote(dir), "nil"}}, {code: "(require 'clojure.stacktrace) (def y 1) (resolve 'y) (resolve 'nope) @(resolve 'y) @#'y", values: []string{"nil", "#'user/y", "#'user/y", "nil", "1", "1"}}, {code: `(ex-data (ex-info "x" {:a 1}))`, values: []string{"{:a 1}"}}, diff --git a/profiles/babashka.toml b/profiles/babashka.toml index eaea45e..318faeb 100644 --- a/profiles/babashka.toml +++ b/profiles/babashka.toml @@ -10,6 +10,7 @@ startup-timeout = "30s" [capabilities] namespaces = true clojure = true +java = true [snippets.value] code = "(+ 1 2)" diff --git a/profiles/clojure.toml b/profiles/clojure.toml index 6559075..b92173b 100644 --- a/profiles/clojure.toml +++ b/profiles/clojure.toml @@ -10,6 +10,7 @@ startup-timeout = "120s" [capabilities] namespaces = true clojure = true +java = true [snippets.value] code = "(+ 1 2)"