forked from clojure-lsp/clojure-lsp
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Re-model LSP JSON-RPC with core.async channels (clojure-lsp#1110)
This gives clients a uniform interface to conform to. They model messages as Clojure hashmaps, and put those messages to and take them from a pair of channels. This cleans up the tests slightly, but is actually prep for clojure-lsp/lsp4clj#8. We'll be able to use this code for both client and server communication. With tools to convert stdio to channels, we get a few benefits. 1. It will be easier to implement socket communication clojure-lsp/lsp4clj#1 as a complement to stdio communication. We'll need to write the code that converts socket i/o to channels, but the client (or actually server) won't change. 2. It will be easier to write mocks for tests. The tests (even unit tests) can read from and write to a server's channels to make assertions about how it responds without having to understand the wire level of the LSP JSON-RPC protocol. 3. There will be a natural mechanism for concurrency. Servers can choose to read several messages from a channel, distributing each to a pool of workers.
- Loading branch information
Showing
3 changed files
with
139 additions
and
86 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
(ns integration.lsp-json-rpc | ||
"Models LSP JSON-RPC as core.async channels of messages (Clojure hashmaps). | ||
https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#baseProtocol" | ||
(:require | ||
[cheshire.core :as json] | ||
[clojure.core.async :as async] | ||
[clojure.string :as string])) | ||
|
||
(set! *warn-on-reflection* true) | ||
|
||
(defn ^:private read-n-chars [^java.io.Reader reader content-length] | ||
(let [cs (char-array content-length)] | ||
(loop [total-read 0] | ||
(when (< total-read content-length) | ||
;; FIXME: this is buggy. It reads `content-length` chars, but | ||
;; `content-length` is specified in bytes. It works as long as the | ||
;; integration tests are all in ASCII, but would break if they weren't. | ||
;; See https://github.com/mainej/lsp4clj/blob/lsp2clj/server/src/lsp4clj/json_rpc.clj | ||
;; for a correct implementation. The best way to fix this is probably to | ||
;; wait for that to be merged and then use those helpers. | ||
(let [new-read (.read reader cs total-read (- content-length total-read))] | ||
(when (< new-read 0) | ||
;; TODO: return nil instead? | ||
(throw (java.io.EOFException.))) | ||
(recur (+ total-read new-read))))) | ||
(String. cs))) | ||
|
||
(defn ^:private parse-header [line headers] | ||
(let [[h v] (string/split line #":\s*" 2)] | ||
(when-not (contains? #{"Content-Length" "Content-Type"} h) | ||
(throw (ex-info "unexpected header" {:line line}))) | ||
(assoc headers h v))) | ||
|
||
(defn ^:private read-message [reader headers] | ||
(let [content-length (parse-long (get headers "Content-Length")) | ||
;; TODO: handle content-type | ||
content (read-n-chars reader content-length)] | ||
(json/parse-string content true))) | ||
|
||
(defn ^:private write-message [msg] | ||
(let [content (json/generate-string msg)] | ||
;; FIXME: this is buggy. It sets `Content-Length` to the number of chars in | ||
;; the content, but `Content-Length` should be the number of bytes (encoded | ||
;; as UTF-8). The fix is the same as for `read-n-chars`: we should use the | ||
;; helpers provided by lsp4clj. | ||
(print (str "Content-Length: " (.length content) "\r\n" | ||
"\r\n" | ||
content)) | ||
(flush))) | ||
|
||
(defn ^:private read-line-async | ||
"Reads a line of input asynchronously. Returns a channel which will yield the | ||
line when it is ready, or nil if the input has closed. Returns immediately. | ||
Avoids blocking by reading in a separate thread." | ||
[^java.io.BufferedReader input] | ||
;; we are agnostic about \r\n or \n because readLine is too | ||
(async/thread (.readLine input))) | ||
|
||
(defn buffered-reader->receiver-chan | ||
"Returns a channel which will yield parsed messages that have been read off | ||
the reader. When the reader is closed, closes the channel." | ||
[^java.io.BufferedReader reader] | ||
(let [msgs (async/chan 1)] | ||
(async/go-loop [headers {}] | ||
(if-let [line (async/<! (read-line-async reader))] | ||
(if (string/blank? line) ;; a blank line after the headers indicate start of message | ||
(do (async/>! msgs (read-message reader headers)) | ||
(recur {})) | ||
(recur (parse-header line headers))) | ||
;; input closed; also close channel | ||
(async/close! msgs))) | ||
msgs)) | ||
|
||
(defn buffered-writer->sender-chan | ||
"Returns a channel which expects to have messages put on it. nil values are | ||
not allowed. Serializes and writes the messages to the writer. When the | ||
channel is closed, closes the writer." | ||
[^java.io.BufferedWriter writer] | ||
(let [messages (async/chan 1)] | ||
(binding [*out* writer] | ||
(async/go-loop [] | ||
(if-let [msg (async/<! messages)] | ||
(do | ||
(write-message msg) | ||
(recur)) | ||
;; channel closed; also close writer | ||
(.close writer)))) | ||
messages)) | ||
|
||
(defn json-rpc-message | ||
([method params] ;; notification | ||
{:jsonrpc "2.0" | ||
:method method | ||
:params params}) | ||
([id method params] ;; request | ||
(assoc (json-rpc-message method params) :id id))) |