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

Implement JSON-RPC and LSP with Monix #164

Merged
merged 19 commits into from
Jan 4, 2018
Merged

Conversation

olafurpg
Copy link
Member

@olafurpg olafurpg commented Jan 3, 2018

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

case class WorkspaceSymbolParams(query: String)
// ...
val workspaceSymbol =
  request[WorkspaceSymbolParams, List[SymbolInformation]]("workspace/symbol")(params => ???)

Responding with an error works like this

val workspaceSymbol =
  requestAsync[WorkspaceSymbolParams, List[SymbolInformation]](
    "workspace/symbol")(params => Task(Left(Response.invalidParams("can't read params"))))

Sending a request to the client works like this

val client: LanguageClient = ???
client.request[MyRequestType, MyResponseType]("method", request): Task[MyResponseType]

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 do request[Request, List[Response]] since there is no sealed trait LspMessage.

I tried to build as much as possible with monix, using for example Observable[Array[Byte]] instead of InputStream and Task.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

@olafurpg
Copy link
Member Author

olafurpg commented Jan 3, 2018

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.

@Sciss
Copy link

Sciss commented Jan 4, 2018

Once we have more experience with this new API, we can consider moving it into a separate lsp4s project, if there is interest.

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,
Copy link
Member

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.

Copy link
Member Author

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]
}
Copy link
Member

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.

Copy link
Member

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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #165 🎉

Copy link
Member Author

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
Copy link
Member

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)
Copy link
Member

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.

Copy link
Member

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.

Copy link
Member Author

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
Copy link
Member

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") { _ =>
Copy link
Member

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).

Copy link
Member Author

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](
Copy link
Member

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?

Copy link
Member Author

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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"\r\n"?

Copy link
Member Author

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] = {
Copy link
Member

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?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s"Unable to parse TextDocumentIdentifier from $arguments"
)
)
Task {
Copy link
Member

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?

Copy link
Member

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.

@gabro
Copy link
Member

gabro commented Jan 4, 2018

That would be great, especially if it could also be used to write language clients.

@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

@Sciss
Copy link

Sciss commented Jan 4, 2018

@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.

Copy link
Member

@gabro gabro left a 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",
Copy link
Member

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 {
Copy link
Member

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))
)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL! Done.

Copy link
Member Author

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 {
Copy link
Member

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"
  )
)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Member Author

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 🤔

gabro and others added 6 commits January 4, 2018 17:19
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
Copy link
Member Author

@olafurpg olafurpg left a 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,
Copy link
Member Author

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]
}
Copy link
Member Author

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]
}
Copy link
Member Author

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)
Copy link
Member Author

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)
Copy link
Member Author

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](
Copy link
Member Author

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")
Copy link
Member Author

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 {
Copy link
Member Author

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 {
Copy link
Member Author

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 {
Copy link
Member Author

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 🤔

@olafurpg
Copy link
Member Author

olafurpg commented Jan 4, 2018

CI is green, let's do this :shipit:

screen shot 2018-01-04 at 22 45 46

@olafurpg olafurpg merged commit 6999bc0 into scalameta:master Jan 4, 2018
@fommil
Copy link
Contributor

fommil commented Jan 4, 2018

FYI ensime's protocol (including LSP) is drop-in ADT and it shows up automatically.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants