From 749a7245a2c3118108dc463009cd6ddf338cae94 Mon Sep 17 00:00:00 2001 From: Ferdinand Beyer Date: Thu, 8 Aug 2024 12:45:59 +0200 Subject: [PATCH 1/3] Support custom handler fns - allows multiple server instances that don't compete on implementing the multi-methods - allows ring-style middleware --- src/lsp4clj/server.clj | 68 ++++++++++++++++++++++++++++++------ test/lsp4clj/server_test.clj | 35 +++++++++++++++++++ 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/lsp4clj/server.clj b/src/lsp4clj/server.clj index 843b712..384bd20 100644 --- a/src/lsp4clj/server.clj +++ b/src/lsp4clj/server.clj @@ -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?}))) @@ -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* @@ -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 @@ -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] @@ -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}] @@ -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 @@ -422,8 +424,52 @@ (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: + + - `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" @@ -435,6 +481,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) diff --git a/test/lsp4clj/server_test.clj b/test/lsp4clj/server_test.clj index 3b3a3f0..8564c91 100644 --- a/test/lsp4clj/server_test.clj +++ b/test/lsp4clj/server_test.clj @@ -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) From 237a57fb37202c00c277aaa235fccc8000526e1e Mon Sep 17 00:00:00 2001 From: Ferdinand Beyer Date: Fri, 9 Aug 2024 11:31:21 +0200 Subject: [PATCH 2/3] Update README and CHANGELOG --- CHANGELOG.md | 3 +++ README.md | 30 ++++++++++++++++++++---------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f02af1..813742a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/README.md b/README.md index d440db7..f59167d 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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) @@ -51,9 +61,9 @@ The return value of requests will be converted to camelCase json and returned to ### 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 @@ -64,7 +74,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 @@ -191,7 +201,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 From 0cdb5b07de436aa4cb1de25459a092fdc298557b Mon Sep 17 00:00:00 2001 From: Ferdinand Beyer Date: Mon, 12 Aug 2024 13:39:52 +0200 Subject: [PATCH 3/3] Update docs --- README.md | 18 ++++++++++++++++++ src/lsp4clj/server.clj | 3 +++ 2 files changed, 21 insertions(+) diff --git a/README.md b/README.md index f59167d..522c0df 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,24 @@ Note that the use of these multimethods is deprecated and they will be removed i 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 message handlers. Therefore, by default, requests and notifications are processed in series. diff --git a/src/lsp4clj/server.clj b/src/lsp4clj/server.clj index 384bd20..d173606 100644 --- a/src/lsp4clj/server.clj +++ b/src/lsp4clj/server.clj @@ -459,6 +459,9 @@ 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."