Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support custom request and notification handlers #44

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Add `:request-handler` and `:notification-handler` options, as a preferred
alternative to the `receive-request` and `receive-notification`
multi-methods.
- Add a `:response-executor` option to control on which thread responses to
server-initiated requests are run, defaulting to Promesa's `:default`
executor, i.e. `ForkJoinPool/commonPool`.
Expand Down
48 changes: 38 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ lsp4clj reads and writes from io streams, parsing JSON-RPC according to the LSP
To initialize a server that will read from stdin and write to stdout:

```clojure
(lsp4clj.io-server/stdio-server)
(lsp4clj.io-server/stdio-server
{:request-handler receive-request
:notification-handler receive-notification})
```

The returned server will have a core.async `:log-ch`, from which you can read server logs (vectors beginning with a log level).
Expand All @@ -27,20 +29,28 @@ The returned server will have a core.async `:log-ch`, from which you can read se

### Receive messages

To receive messages from a client, lsp4clj defines a pair of multimethods, `lsp4clj.server/receive-notification` and `lsp4clj.server/receive-request` that dispatch on the method name (as defined by the LSP spec) of an incoming JSON-RPC message.
To receive messages from a client, lsp4clj defines a pair of handlers: the `:request-handler`
and the `:notification-handler`, which should be passed to the server constructor.

Server implementors should create `defmethod`s for the messages they want to process. (Other methods will be logged and responded to with a generic "Method not found" response.)
These handlers receive 3 arguments, the method name, a "context", and the `params` of the [JSON-RPC request or notification object](https://www.jsonrpc.org/specification#request_object). The keys of the params will have been converted (recursively) to kebab-case keywords. Read on for an explanation of what a "context" is and how to set it.

These `defmethod`s receive 3 arguments, the method name, a "context", and the `params` of the [JSON-RPC request or notification object](https://www.jsonrpc.org/specification#request_object). The keys of the params will have been converted (recursively) to kebab-case keywords. Read on for an explanation of what a "context" is and how to set it.
When a message is not understood by the server, these handlers should return the value `:lsp4clj.server/method-not-found`. For requests, lsp4clj will then report an appropriate error message to the client.

lsp4clj provides two multimethods as default handlers, `lsp4clj.server/receive-notification` and `lsp4clj.server/receive-request`, that dispatch on the method name (as defined by the LSP spec) of an incoming JSON-RPC message. Instead of passing custom `:request-handler` and `:notification-handler` options when creating the server, implementors can create `defmethod`s for the messages they want to process. (Other methods will be logged and responded to with a generic "Method not found" response.)

Note that the use of these multimethods is deprecated and they will be removed in a future release of lsp4clj. New code should pass their own handlers instead, potentially defining their own multimethod.

```clojure
(defmulti receive-request (fn [method _context _params] method))
(defmulti receive-notification (fn [method _context _params] method))

;; a notification; return value is ignored
(defmethod lsp4clj.server/receive-notification "textDocument/didOpen"
(defmethod receive-notification "textDocument/didOpen"
[_ context {:keys [text-document]}]
(handler/did-open context (:uri text-document) (:text text-document))

;; a request; return value is converted to a response
(defmethod lsp4clj.server/receive-request "textDocument/definition"
(defmethod receive-request "textDocument/definition"
[_ context params]
(->> params
(handler/definition context)
Expand All @@ -49,11 +59,29 @@ These `defmethod`s receive 3 arguments, the method name, a "context", and the `p

The return value of requests will be converted to camelCase json and returned to the client. If the return value looks like `{:error ...}`, it is assumed to indicate an error response, and the `...` part will be set as the `error` of a [JSON-RPC error object](https://www.jsonrpc.org/specification#error_object). It is up to you to conform the `...` object (by giving it a `code`, `message`, and `data`.) Otherwise, the entire return value will be set as the `result` of a [JSON-RPC response object](https://www.jsonrpc.org/specification#response_object). (Message ids are handled internally by lsp4clj.)

### Middleware

For cross-cutting concerns of your request and notification handlers, consider middleware functions:

```clojure
(defn wrap-vthread
"Middleware that executes requests in a virtual thread."
[handler]
(fn [method context params]
(promesa.core/vthread (handler method context params))))

;; ...

(defmulti receive-request (fn [method _ _] method))

(def server (server/chan-server {:request-handler (wrap-vthread #'receive-request)}))
```

### Async requests

lsp4clj passes the language server the client's messages one at a time. It won't provide another message until it receives a result from the multimethods. Therefore, by default, requests and notifications are processed in series.
lsp4clj passes the language server the client's messages one at a time. It won't provide another message until it receives a result from the message handlers. Therefore, by default, requests and notifications are processed in series.

However, it's possible to calculate requests in parallel (though not notifications). If the language server wants a request to be calculated in parallel with others, it should return a `java.util.concurrent.CompletableFuture`, possibly created with `promesa.core/future`, from `lsp4clj.server/receive-request`. lsp4clj will arrange for the result of this future to be returned to the client when it resolves. In the meantime, lsp4clj will continue passing the client's messages to the language server. The language server can control the number of simultaneous messages by setting the parallelism of the CompletableFutures' executor.
However, it's possible to calculate requests in parallel (though not notifications). If the language server wants a request to be calculated in parallel with others, it should return a `java.util.concurrent.CompletableFuture`, possibly created with `promesa.core/future`, from the request handler. lsp4clj will arrange for the result of this future to be returned to the client when it resolves. In the meantime, lsp4clj will continue passing the client's messages to the language server. The language server can control the number of simultaneous messages by setting the parallelism of the CompletableFutures' executor.

### Cancelled inbound requests

Expand All @@ -64,7 +92,7 @@ But clients can cancel requests that are processed in parallel. In these cases l
Nevertheless, lsp4clj gives language servers a tool to abort cancelled requests. In the request's `context`, there will be a key `:lsp4clj.server/req-cancelled?` that can be dereffed to check if the request has been cancelled. If it has, then the language server can abort whatever it is doing. If it fails to abort, there are no consequences except that it will do more work than necessary.

```clojure
(defmethod lsp4clj.server/receive-request "textDocument/semanticTokens/full"
(defmethod receive-request "textDocument/semanticTokens/full"
[_ {:keys [:lsp4clj.server/req-cancelled?] :as context} params]
(promesa.core/future
;; client may cancel request while we are waiting for analysis
Expand Down Expand Up @@ -191,7 +219,7 @@ You must not print to stdout while a `stdio-server` is running. This will corrup

From experience, it's dismayingly easy to leave in an errant `prn` or `time` and end up with a non-responsive client. For this reason, we highly recommend supporting communication over sockets (see [other types of servers](#other-types-of-servers)) which are immune to this problem. However, since the choice of whether to use sockets or stdio is ultimately up to the client, you may have no choice but to support both.

lsp4clj provides one tool to avoid accidental writes to stdout (or rather to `*out*`, which is usually the same as `System.out`). To protect a block of code from writing to `*out*`, wrap it with `lsp4clj.server/discarding-stdout`. The `receive-notification` and `receive-request` multimethods are already protected this way, but tasks started outside of these multimethods or that run in separate threads need this protection added.
lsp4clj provides one tool to avoid accidental writes to stdout (or rather to `*out*`, which is usually the same as `System.out`). To protect a block of code from writing to `*out*`, wrap it with `lsp4clj.server/discarding-stdout`. The request and notification handlers are already protected this way, but tasks started outside of these handlers or that run in separate threads need this protection added.

## Known lsp4clj users

Expand Down
71 changes: 61 additions & 10 deletions src/lsp4clj/server.clj
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,13 @@
(-cancelled? [_]
@cancelled?))

(defn pending-received-request [method context params]
(defn ^:private pending-received-request [handler method context params]
(let [cancelled? (atom false)
;; coerce result/error to promise
result-promise (p/promise
(receive-request method
(assoc context ::req-cancelled? cancelled?)
params))]
(handler method
(assoc context ::req-cancelled? cancelled?)
params))]
(map->PendingReceivedRequest
{:result-promise result-promise
:cancelled? cancelled?})))
Expand All @@ -193,6 +193,8 @@
;; * send-notification should do nothing until initialize response is sent, with the exception of window/showMessage, window/logMessage, telemetry/event, and $/progress
(defrecord ChanServer [input-ch
output-ch
request-handler
notification-handler
log-ch
trace-ch
tracer*
Expand Down Expand Up @@ -374,7 +376,7 @@
resp (lsp.responses/response id)]
(try
(trace this trace/received-request req started)
(let [pending-req (pending-received-request method context params)]
(let [pending-req (pending-received-request request-handler method context params)]
(swap! pending-received-requests* assoc id pending-req)
(-> pending-req
:result-promise
Expand All @@ -387,7 +389,7 @@
(lsp.responses/error resp (lsp.errors/not-found method)))
(lsp.responses/infer resp result))))
;; Handle
;; 1. Exceptions thrown within p/future created by receive-request.
;; 1. Exceptions thrown within promise returned by request-handler.
;; 2. Cancelled requests.
(p/catch
(fn [e]
Expand All @@ -401,7 +403,7 @@
(swap! pending-received-requests* dissoc id)
(trace this trace/sending-response req resp started (.instant clock))
(async/>!! output-ch resp)))))
(catch Throwable e ;; exceptions thrown by receive-request
(catch Throwable e ;; exceptions thrown by request-handler
(log-error-receiving this e req)
(async/>!! output-ch (internal-error-response resp req))))))
(receive-notification [this context {:keys [method params] :as notif}]
Expand All @@ -412,7 +414,7 @@
(if-let [pending-req (get @pending-received-requests* (:id params))]
(p/cancel! pending-req)
(trace this trace/received-unmatched-cancellation-notification notif now))
(let [result (receive-notification method context params)]
(let [result (notification-handler method context params)]
(when (identical? ::method-not-found result)
(protocols.endpoint/log this :warn "received unexpected notification" method)))))
(catch Throwable e
Expand All @@ -422,8 +424,55 @@
(update server :tracer* reset! (trace/tracer-for-level trace-level)))

(defn chan-server
[{:keys [output-ch input-ch log-ch trace? trace-level trace-ch clock on-close response-executor]
:or {clock (java.time.Clock/systemDefaultZone)
"Creates a channel-based Language Server.

The returned server will be in unstarted state. Pass it to `start` to
start it.

Required options:

- `output-ch` is a core.async channel that the server puts messages to the
client onto.
- `input-ch` is a core.async channel that the server takes messages from the
client from.

Handler functions:

- `request-handler` is a 3-arg fn `[message context params] => response`
to handle incoming client requests. The response can be a response map
or a promise resolving to a response map. Defaults to the `receive-request`
multi-method.
- `notification-handler` is a 3-arg fn `[message context params]` to handle
incoming client notifications. Its return value is ignored. Defaults to
the `receive-notification` multi-method.

Options for logging and tracing:

- `log-ch` is an optional core.async channel that the server will put log
messages onto. If none is specified, a default one will be created.
- `trace-ch` is an optional core.async channel that the server will put
trace events onto.
- `trace-level` is a string that determines the verbosity of trace messages,
can be \"verbose\", \"messages\", or \"off\".
- `trace?` is a short-hand for `:trace-level \"verbose\"` and the default
when a `trace-ch` is specified.

Other options:

- `response-executor` is value supported by Promesa to specify an executor
to handle client responses to server-initiated requests. When none is
specified, uses `:default`, which is mapped to `(ForkJoinPool/commonPool)`.
- `clock` is a `java.time.Clock` that provides the current time for trace
messages.
- `on-close` is a 0-arg fn that the server will call after it has shut down."
[{:keys [output-ch input-ch
request-handler notification-handler
log-ch
trace? trace-level trace-ch
clock on-close response-executor]
:or {request-handler #'receive-request
notification-handler #'receive-notification
clock (java.time.Clock/systemDefaultZone)
on-close (constantly nil)
response-executor :default}}]
(let [;; before defaulting trace-ch, so that default is "off"
Expand All @@ -435,6 +484,8 @@
(map->ChanServer
{:output-ch output-ch
:input-ch input-ch
:request-handler request-handler
:notification-handler notification-handler
:log-ch log-ch
:trace-ch trace-ch
:tracer* (atom tracer)
Expand Down
35 changes: 35 additions & 0 deletions test/lsp4clj/server_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,41 @@
[lsp4clj.test-helper :as h]
[promesa.core :as p]))

(deftest should-pass-requests-to-handler
(let [input-ch (async/chan 3)
output-ch (async/chan 3)
requests* (atom [])
server (server/chan-server {:output-ch output-ch
:input-ch input-ch
:request-handler (fn [& args]
(swap! requests* conj args)
::server/method-not-found)})]
(server/start server {:context :some-value})
(async/put! input-ch (lsp.requests/request 1 "foo" {:param 42}))
(h/assert-take output-ch)
(is (= 1 (count @requests*)))
(let [args (first @requests*)]
(is (= "foo" (first args)))
(is (= :some-value (:context (second args))))
(is (= 42 (:param (nth args 2)))))
(server/shutdown server)))

(deftest should-pass-notifications-to-handler
(let [input-ch (async/chan 3)
output-ch (async/chan 3)
notification (promise)
server (server/chan-server {:output-ch output-ch
:input-ch input-ch
:notification-handler (fn [& args]
(deliver notification args))})]
(server/start server {:context :some-value})
(async/put! input-ch (lsp.requests/notification "foo" {:param 42}))
(let [args (deref notification 100 nil)]
(is (= "foo" (first args)))
(is (= :some-value (:context (second args))))
(is (= 42 (:param (nth args 2)))))
(server/shutdown server)))

(deftest should-process-messages-received-before-start
(let [input-ch (async/chan 3)
output-ch (async/chan 3)
Expand Down