-
Notifications
You must be signed in to change notification settings - Fork 354
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
Implement JSON-RPC and LSP with Monix #164
Conversation
- fix $/cancelParams - remove scala-json-rpc - remove dead code
PS. I apologize for the terrible commit log, the best way to review this PR is most likely to look at the entire final diff. There were many failed experiments and I was not diligent at documenting intermediary steps. |
That would be great, especially if it could also be used to write language clients. |
|
||
def showMessage(params: ShowMessageParams): Unit | ||
final def showMessage( | ||
tpe: MessageType, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like an inconsistency with the parameter name of logMessage
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
capabilities: ClientCapabilities) | ||
object InitializeParams { | ||
implicit val format: OFormat[InitializeParams] = Json.format[InitializeParams] | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it possible to auto-derive this boilerplate? The necessity to explicitly spell out the serializers for every request/response was one of the biggest annoyances personally to me when I was writing my own simple language server.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, it's going away soon. The next thing I want to work on (unless someone beats me to it) is to replace play-json with circe and use the @JsonCodec
macro annotation to make this boilerplate (and other, see Configuration) go away.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❤️
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in #165 🎉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't want to change the json library in this PR as it was doing enough already
sealed trait Response extends Message | ||
sealed trait ResultResponse extends Response | ||
|
||
sealed trait Notification extends Message |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
object DocumentSymbolParams { | ||
implicit val format: OFormat[DocumentSymbolParams] = Json.format[DocumentSymbolParams] | ||
} | ||
case class TextDocumentRenameRequest(params: RenameParams) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How do you figure out which classes to call XxxRequest
and which classes to call XxxParams
? This is the problem that I faced myself when writing a language server, and I haven't figured out a consistent naming strategy yet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My understanding is that each Request has (optionally) Params.
The names of the Params classes are taken as-is from the names of the TypeScript interfaces in https://microsoft.github.io/language-server-protocol/specification.
The names of the Requests are essentially transformed from <scope>/<requestType>
to <Scope><RequestType>Request
, e.g. textDocument/rename
-> TextDocumentRenameRequest
If something doesn't follow this rules, I think we should change it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ditto, the names should match the interfaces in the lsp spec.
@@ -21,5 +21,5 @@ class LSPLogger(@BeanProperty var encoder: PatternLayoutEncoder) | |||
} | |||
|
|||
object LSPLogger { | |||
var connection: Option[Connection] = None | |||
var connection: Option[Notifications] = None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This probably shouldn't be called connection
anymore? Same question for other things in the code that are also called connection
.
shutdown() | ||
JsNull | ||
} | ||
.notification[JsValue]("exit") { _ => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How hard would it be to implement the spec that says "The server should exit with success code 0 if the shutdown request has been received before; otherwise with error code 1" (https://microsoft.github.io/language-server-protocol/specification#exit).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Easy, we can use an Atomic boolean to determine if shutdown has been received. Done.
logger.info("exit(0)") | ||
sys.exit(0) | ||
} | ||
.requestAsync[TextDocumentPositionParams, CompletionList]( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the difference between request
and requestAsync
? Looks like the former method simply delegates to the latter?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, the former delegates, I made the distinction to reduce ceremony in adding new handlers. All handlers are currently synchronous except the codeActions handler that is truly asynchronous since it does a workspaceEdit roundtrip.
val sb = new java.lang.StringBuilder() | ||
header.foreach { | ||
case (key, value) => | ||
sb.append(key).append(": ").append(value).append("\n") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"\r\n"
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
@@ -15,13 +15,13 @@ object DefinitionProvider extends LazyLogging { | |||
uri: Uri, | |||
position: l.Position, | |||
tempSourcesDir: AbsolutePath | |||
): DefinitionResult = { | |||
): List[Location] = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How do you determine what data structures to return from the language server - raw payload, XxxResult
or XxxResponse
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Those as specified in https://microsoft.github.io/language-server-protocol/specification
s"Unable to parse TextDocumentIdentifier from $arguments" | ||
) | ||
) | ||
Task { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why does this have to be a task if the result is returned immediately? Also, if this has to be a task, then why not wrap the computation of result
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, I would either wrap the whole thing in Task
or use Task.now
if this is synchronous.
@Sciss lsp4s would be for language servers, but I suppose models could be shared and we could join efforts with @vovapolu, who's already worked on scala-based language clients in https://github.com/ensime/ensime-lsp-clients |
@gabro Yes, I think a lot of the types and API, you know from error codes to traits and protocol classes, the rpc implementation etc. could be shared between client and server. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Impressive work 😮
I've left a few non-blocking comments
libraryDependencies ++= Seq( | ||
"com.dhpcs" %% "scala-json-rpc" % "2.0.1", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎉
@@ -79,14 +78,11 @@ class DocumentFormattingProvider( | |||
} | |||
} | |||
formatResult match { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😇
import cats.syntax.bifunctor._
import cats.instances.either._
formatResult.bimap(
Response.invalidParams,
formatted => List(TextEdit(fullDocumentRange, formatted))
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TIL! Done.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a shame the stdlib Either
does not have a signature like that, it's so close to fold
but different enough
) | ||
) | ||
Task { | ||
result match { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😇
import cats.syntax.either._
Either.fromOption(
result,
Left(
Response.invalidParams(
s"Unable to parse TextDocumentIdentifier from $arguments"
)
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice! Only gripe with these APIs is finding the right imports, I just want to type Either.fromOpt<TAB>
and have the editor insert the import. Only if we had a language server to do this for us 🤔
We've started using cats, but so far it was a sub-dependency of circe and monix.
- Report message in document formatting provider since invalid params are not reported in the vscode ui. - Use cats syntax for either - Use move executeCommand to separate method
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks everyone for the swift review on this huge PR. Very nice to get this in asap to avoid conflicts.
I took the liberty to merge #165 into this PR before addressing comments to prevent conflicts. Looks like this PR is ready 🚢
|
||
def showMessage(params: ShowMessageParams): Unit | ||
final def showMessage( | ||
tpe: MessageType, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
capabilities: ClientCapabilities) | ||
object InitializeParams { | ||
implicit val format: OFormat[InitializeParams] = Json.format[InitializeParams] | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in #165 🎉
capabilities: ClientCapabilities) | ||
object InitializeParams { | ||
implicit val format: OFormat[InitializeParams] = Json.format[InitializeParams] | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't want to change the json library in this PR as it was doing enough already
object DocumentSymbolParams { | ||
implicit val format: OFormat[DocumentSymbolParams] = Json.format[DocumentSymbolParams] | ||
} | ||
case class TextDocumentRenameRequest(params: RenameParams) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ditto, the names should match the interfaces in the lsp spec.
@@ -29,11 +32,17 @@ object Main extends LazyLogging { | |||
// messes up with the client, since stdout is used for the language server protocol | |||
System.setOut(out) | |||
System.setErr(err) | |||
val client = new LanguageClient(stdout) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I kept it identical to the lsp4j name, which is LanguageClient
.
It's a true "client" in the sense that you can send any generic json rpc requests to a server and get responses back. I don't think a proxy or clientConnection improve readability.
logger.info("exit(0)") | ||
sys.exit(0) | ||
} | ||
.requestAsync[TextDocumentPositionParams, CompletionList]( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, the former delegates, I made the distinction to reduce ceremony in adding new handlers. All handlers are currently synchronous except the codeActions handler that is truly asynchronous since it does a workspaceEdit roundtrip.
val sb = new java.lang.StringBuilder() | ||
header.foreach { | ||
case (key, value) => | ||
sb.append(key).append(": ").append(value).append("\n") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
@@ -79,14 +78,11 @@ class DocumentFormattingProvider( | |||
} | |||
} | |||
formatResult match { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TIL! Done.
@@ -79,14 +78,11 @@ class DocumentFormattingProvider( | |||
} | |||
} | |||
formatResult match { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a shame the stdlib Either
does not have a signature like that, it's so close to fold
but different enough
) | ||
) | ||
Task { | ||
result match { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice! Only gripe with these APIs is finding the right imports, I just want to type Either.fromOpt<TAB>
and have the editor insert the import. Only if we had a language server to do this for us 🤔
FYI ensime's protocol (including LSP) is drop-in ADT and it shows up automatically. |
This PR gets rid of the dependency on scala-json-rpc and removes languageserver
LanguageServer/Connection/MessageReader/...
. Only simple LSP case classes remain from the languageserver project in addition to the MessageWriter.The motivation for this PR is that I have always found it too difficult to introduce support for new message types. Adding support for a feature like workspace/symbol usually took 20 minutes of adding classes to several places, guessing who to serialize/deserialize them, manually test in vscode at least 5 times until it worked (with typically 1-2 sessions ending in silent failure). Another common problem was figuring out how to send error responses instead of
showMessage(); DummyResponse()
. My patience for this complexity ran out after I spent 50% of the time for #154 figuring out how to initiate a request to the client from the server.With this PR, adding support for a new message is now as simple as
Responding with an error works like this
Sending a request to the client works like this
The ease of sending requests to the client and first-class support to respond with standard JSON-RPC error codes (without hacks) should simplify the last remaining issue for the v0.1 milestone #161 .
A nice benefit of this refactoring is that we no longer need dummy wrapper types when the response is
List[T]
. For example, rename, formatting, document highlight. We can dorequest[Request, List[Response]]
since there is nosealed trait LspMessage
.I tried to build as much as possible with monix, using for example
Observable[Array[Byte]]
instead ofInputStream
andTask.create
to build responses in the LanguageClient.It's worth noting that after refactoring ScalametaLanguageServer to this new API, almost everything worked on the first run! Completions, scalafmt, scalafix, definition, references, squigglies, workspace symbols, document highlight. Types give you refactoring superpowers. Once we have more experience with this new API, we can consider moving it into a separate lsp4s project, if there is interest.
I left out some changes that can be done in future PRs
@JsonCodec
(done in Replace play-json with circe #165, merged into this PR )