mirror of
https://github.com/enso-org/enso.git
synced 2024-11-26 17:06:48 +03:00
text/openFile method (#575)
This commit is contained in:
parent
9913915fd9
commit
e5530045bf
@ -311,10 +311,38 @@ Detailed information on the flags it supports can be obtained by executing `run
|
||||
project.
|
||||
|
||||
#### Language Server Mode
|
||||
Though operating the Enso binary as a language server is functionality planned
|
||||
for the 2.0 release, it is not currently implemented. For more information on
|
||||
the planned functionality and its progress, please see the
|
||||
[Issue Tracker](https://github.com/luna/enso/issues).
|
||||
The Language Server can be run using the `--server` option. It requires also a
|
||||
content root to be provided (`--root-id` and `--path` options). Command-line
|
||||
interface of the runner prints all server options when you execute it with
|
||||
`--help` option.
|
||||
|
||||
Below are options uses by the Language Server:
|
||||
- `--server`: Runs the Language Server
|
||||
- `--root-id <uuid>`: Content root id.
|
||||
- `--path <path>`: Path to the content root.
|
||||
- `--interface <interface>`: Interface for processing all incoming connections.
|
||||
Default value is 127.0.0.1
|
||||
- `--port <port>`: Port for processing all incoming connections. Default value
|
||||
is 8080.
|
||||
|
||||
To run the Language Server on 127.0.0.1:8080 type:
|
||||
```bash
|
||||
java -jar enso.jar \
|
||||
--server \
|
||||
--root-id 3256d10d-45be-45b1-9ea4-7912ef4226b1 \
|
||||
--path /tmp/content-root
|
||||
```
|
||||
|
||||
If you want to provide a socket that the server should listen to, type:
|
||||
|
||||
```bash
|
||||
java -jar enso.jar \
|
||||
--server \
|
||||
--root-id 3256d10d-45be-45b1-9ea4-7912ef4226b1 \
|
||||
--path /tmp/content-root \
|
||||
--interface 0.0.0.0 \
|
||||
--port 80
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
Pull Requests are the primary method for making changes to Enso. GitHub has
|
||||
|
15
build.sbt
15
build.sbt
@ -429,6 +429,7 @@ lazy val language_server = (project in file("engine/language-server"))
|
||||
"io.circe" %% "circe-literal" % circeVersion,
|
||||
"org.typelevel" %% "cats-core" % "2.0.0",
|
||||
"org.typelevel" %% "cats-effect" % "2.0.0",
|
||||
"org.bouncycastle" % "bcpkix-jdk15on" % "1.64",
|
||||
"commons-io" % "commons-io" % "2.6",
|
||||
akkaTestkit % Test,
|
||||
"org.scalatest" %% "scalatest" % "3.2.0-M2" % Test,
|
||||
@ -544,6 +545,20 @@ lazy val runner = project
|
||||
assemblyJarName in assembly := "enso.jar",
|
||||
test in assembly := {},
|
||||
assemblyOutputPath in assembly := file("enso.jar"),
|
||||
assemblyMergeStrategy in assembly := {
|
||||
case PathList("META-INF", file, xs @ _*) if file.endsWith(".DSA") =>
|
||||
MergeStrategy.discard
|
||||
case PathList("META-INF", file, xs @ _*) if file.endsWith(".SF") =>
|
||||
MergeStrategy.discard
|
||||
case PathList("META-INF", "MANIFEST.MF", xs @ _*) =>
|
||||
MergeStrategy.discard
|
||||
case "application.conf" =>
|
||||
MergeStrategy.concat
|
||||
case "reference.conf" =>
|
||||
MergeStrategy.concat
|
||||
case x =>
|
||||
MergeStrategy.first
|
||||
},
|
||||
assemblyOption in assembly := (assemblyOption in assembly).value
|
||||
.copy(
|
||||
prependShellScript = Some(
|
||||
|
@ -13,8 +13,7 @@ import java.util.UUID
|
||||
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.enso.FileManager
|
||||
import org.scalatest.BeforeAndAfterAll
|
||||
import org.scalatest.Outcome
|
||||
import org.scalatest.{BeforeAndAfterAll, Ignore, Outcome}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@ -25,6 +24,7 @@ import scala.reflect.ClassTag
|
||||
import scala.util.Try
|
||||
|
||||
// needs to be separate because watcher message are asynchronous
|
||||
@Ignore
|
||||
class WatchTests
|
||||
extends AnyFunSuite
|
||||
with BeforeAndAfterAll
|
||||
|
@ -927,8 +927,8 @@ A representation of a batch of edits to a file, versioned.
|
||||
interface FileEdit {
|
||||
path: Path;
|
||||
edits: [TextEdit];
|
||||
oldVersion: UUID;
|
||||
newVersion: UUID;
|
||||
oldVersion: SHA3-224;
|
||||
newVersion: SHA3-224;
|
||||
}
|
||||
```
|
||||
|
||||
@ -995,7 +995,6 @@ client.
|
||||
}
|
||||
|
||||
interface CapabilityRegistration {
|
||||
id: UUID; // The registration ID
|
||||
method: String;
|
||||
registerOptions?: any;
|
||||
}
|
||||
@ -1024,7 +1023,7 @@ capability.
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: UUID; // The ID used to register the capability
|
||||
registration: CapabilityRegistration;
|
||||
}
|
||||
```
|
||||
|
||||
@ -1066,7 +1065,7 @@ capability set.
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: UUID; // The ID used to register the capability
|
||||
registration: CapabilityRegistration;
|
||||
}
|
||||
```
|
||||
|
||||
@ -1567,7 +1566,7 @@ the client that sent the `text/openFile` message.
|
||||
{
|
||||
writeCapability?: CapabilityRegistration;
|
||||
content: String;
|
||||
currentVersion: UUID;
|
||||
currentVersion: SHA3-224;
|
||||
}
|
||||
```
|
||||
|
||||
@ -1612,7 +1611,7 @@ that file, or if the client is requesting a save of an outdated version.
|
||||
```typescript
|
||||
{
|
||||
path: Path;
|
||||
currentVersion: UUID;
|
||||
currentVersion: SHA3-224;
|
||||
}
|
||||
```
|
||||
|
||||
@ -2022,6 +2021,16 @@ It signals that file already exists.
|
||||
}
|
||||
```
|
||||
|
||||
##### `OperationTimeoutError`
|
||||
It signals that IO operation timed out.
|
||||
|
||||
```typescript
|
||||
"error" : {
|
||||
"code" : 1005,
|
||||
"message" : "IO operation timeout"
|
||||
}
|
||||
```
|
||||
|
||||
##### `StackItemNotFoundError`
|
||||
```typescript
|
||||
"error" : {
|
||||
|
@ -1,4 +1,4 @@
|
||||
package org.enso.languageserver.buffer
|
||||
package org.enso.languageserver.text
|
||||
import org.enso.languageserver.data.buffer.Rope
|
||||
import org.scalacheck.Gen.Parameters
|
||||
import org.scalameter.{Bench, Gen}
|
@ -1,19 +1,34 @@
|
||||
package org.enso.languageserver
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
import akka.actor.{Actor, ActorLogging, ActorRef, Stash}
|
||||
import akka.actor.{Actor, ActorLogging, ActorRef, Props, Stash}
|
||||
import akka.pattern.ask
|
||||
import akka.util.Timeout
|
||||
import org.enso.languageserver.ClientApi._
|
||||
import org.enso.languageserver.data.{CapabilityRegistration, Client}
|
||||
import org.enso.languageserver.capability.CapabilityApi.{
|
||||
AcquireCapability,
|
||||
ForceReleaseCapability,
|
||||
GrantCapability,
|
||||
ReleaseCapability
|
||||
}
|
||||
import org.enso.languageserver.capability.CapabilityProtocol
|
||||
import org.enso.languageserver.data.Client
|
||||
import org.enso.languageserver.event.{ClientConnected, ClientDisconnected}
|
||||
import org.enso.languageserver.filemanager.FileManagerApi._
|
||||
import org.enso.languageserver.filemanager.FileManagerProtocol.{
|
||||
CreateFileResult,
|
||||
WriteFileResult
|
||||
}
|
||||
import org.enso.languageserver.filemanager.{
|
||||
FileManagerProtocol,
|
||||
FileSystemFailureMapper
|
||||
}
|
||||
import org.enso.languageserver.jsonrpc.Errors.ServiceError
|
||||
import org.enso.languageserver.jsonrpc._
|
||||
import org.enso.languageserver.requesthandler.{
|
||||
AcquireCapabilityHandler,
|
||||
OpenFileHandler,
|
||||
ReleaseCapabilityHandler
|
||||
}
|
||||
import org.enso.languageserver.text.TextApi.OpenFile
|
||||
|
||||
import scala.concurrent.duration._
|
||||
import scala.util.{Failure, Success}
|
||||
@ -26,45 +41,13 @@ import scala.util.{Failure, Success}
|
||||
object ClientApi {
|
||||
import io.circe.generic.auto._
|
||||
|
||||
case object AcquireCapability extends Method("capability/acquire") {
|
||||
implicit val hasParams = new HasParams[this.type] {
|
||||
type Params = CapabilityRegistration
|
||||
}
|
||||
implicit val hasResult = new HasResult[this.type] {
|
||||
type Result = Unused.type
|
||||
}
|
||||
}
|
||||
|
||||
case class ReleaseCapabilityParams(id: UUID)
|
||||
|
||||
case object ReleaseCapability extends Method("capability/release") {
|
||||
implicit val hasParams = new HasParams[this.type] {
|
||||
type Params = ReleaseCapabilityParams
|
||||
}
|
||||
implicit val hasResult = new HasResult[this.type] {
|
||||
type Result = Unused.type
|
||||
}
|
||||
}
|
||||
|
||||
case object ForceReleaseCapability
|
||||
extends Method("capability/forceReleased") {
|
||||
implicit val hasParams = new HasParams[this.type] {
|
||||
type Params = ReleaseCapabilityParams
|
||||
}
|
||||
}
|
||||
|
||||
case object GrantCapability extends Method("capability/granted") {
|
||||
implicit val hasParams = new HasParams[this.type] {
|
||||
type Params = CapabilityRegistration
|
||||
}
|
||||
}
|
||||
|
||||
val protocol: Protocol = Protocol.empty
|
||||
.registerRequest(AcquireCapability)
|
||||
.registerRequest(ReleaseCapability)
|
||||
.registerRequest(WriteFile)
|
||||
.registerRequest(ReadFile)
|
||||
.registerRequest(CreateFile)
|
||||
.registerRequest(OpenFile)
|
||||
.registerRequest(DeleteFile)
|
||||
.registerRequest(CopyFile)
|
||||
.registerNotification(ForceReleaseCapability)
|
||||
@ -83,6 +66,8 @@ object ClientApi {
|
||||
class ClientController(
|
||||
val clientId: Client.Id,
|
||||
val server: ActorRef,
|
||||
val bufferRegistry: ActorRef,
|
||||
val capabilityRouter: ActorRef,
|
||||
requestTimeout: FiniteDuration = 10.seconds
|
||||
) extends Actor
|
||||
with Stash
|
||||
@ -92,8 +77,21 @@ class ClientController(
|
||||
|
||||
implicit val timeout = Timeout(requestTimeout)
|
||||
|
||||
private val client = Client(clientId, self)
|
||||
|
||||
private val requestHandlers: Map[Method, Props] =
|
||||
Map(
|
||||
AcquireCapability -> AcquireCapabilityHandler
|
||||
.props(capabilityRouter, requestTimeout, client),
|
||||
ReleaseCapability -> ReleaseCapabilityHandler
|
||||
.props(capabilityRouter, requestTimeout, client),
|
||||
OpenFile -> OpenFileHandler.props(bufferRegistry, requestTimeout, client)
|
||||
)
|
||||
|
||||
override def receive: Receive = {
|
||||
case ClientApi.WebConnect(webActor) =>
|
||||
context.system.eventStream
|
||||
.publish(ClientConnected(Client(clientId, self)))
|
||||
unstashAll()
|
||||
context.become(connected(webActor))
|
||||
case _ => stash()
|
||||
@ -101,25 +99,18 @@ class ClientController(
|
||||
|
||||
def connected(webActor: ActorRef): Receive = {
|
||||
case MessageHandler.Disconnected =>
|
||||
server ! LanguageProtocol.Disconnect(clientId)
|
||||
context.system.eventStream.publish(ClientDisconnected(clientId))
|
||||
context.stop(self)
|
||||
|
||||
case LanguageProtocol.CapabilityForceReleased(id) =>
|
||||
webActor ! Notification(
|
||||
ForceReleaseCapability,
|
||||
ReleaseCapabilityParams(id)
|
||||
)
|
||||
case CapabilityProtocol.CapabilityForceReleased(registration) =>
|
||||
webActor ! Notification(ForceReleaseCapability, registration)
|
||||
|
||||
case LanguageProtocol.CapabilityGranted(registration) =>
|
||||
case CapabilityProtocol.CapabilityGranted(registration) =>
|
||||
webActor ! Notification(GrantCapability, registration)
|
||||
|
||||
case Request(AcquireCapability, id, registration: CapabilityRegistration) =>
|
||||
server ! LanguageProtocol.AcquireCapability(clientId, registration)
|
||||
sender ! ResponseResult(AcquireCapability, id, Unused)
|
||||
|
||||
case Request(ReleaseCapability, id, params: ReleaseCapabilityParams) =>
|
||||
server ! LanguageProtocol.ReleaseCapability(clientId, params.id)
|
||||
sender ! ResponseResult(ReleaseCapability, id, Unused)
|
||||
case r @ Request(method, _, _) if (requestHandlers.contains(method)) =>
|
||||
val handler = context.actorOf(requestHandlers(method))
|
||||
handler.forward(r)
|
||||
|
||||
case Request(WriteFile, id, params: WriteFile.Params) =>
|
||||
writeFile(webActor, id, params)
|
||||
@ -167,10 +158,10 @@ class ClientController(
|
||||
): Unit = {
|
||||
(server ? FileManagerProtocol.WriteFile(params.path, params.contents))
|
||||
.onComplete {
|
||||
case Success(FileManagerProtocol.WriteFileResult(Right(()))) =>
|
||||
case Success(WriteFileResult(Right(()))) =>
|
||||
webActor ! ResponseResult(WriteFile, id, Unused)
|
||||
|
||||
case Success(FileManagerProtocol.WriteFileResult(Left(failure))) =>
|
||||
case Success(WriteFileResult(Left(failure))) =>
|
||||
webActor ! ResponseError(
|
||||
Some(id),
|
||||
FileSystemFailureMapper.mapFailure(failure)
|
||||
@ -189,10 +180,10 @@ class ClientController(
|
||||
): Unit = {
|
||||
(server ? FileManagerProtocol.CreateFile(params.`object`))
|
||||
.onComplete {
|
||||
case Success(FileManagerProtocol.CreateFileResult(Right(()))) =>
|
||||
case Success(CreateFileResult(Right(()))) =>
|
||||
webActor ! ResponseResult(CreateFile, id, Unused)
|
||||
|
||||
case Success(FileManagerProtocol.CreateFileResult(Left(failure))) =>
|
||||
case Success(CreateFileResult(Left(failure))) =>
|
||||
webActor ! ResponseError(
|
||||
Some(id),
|
||||
FileSystemFailureMapper.mapFailure(failure)
|
||||
|
@ -1,8 +1,13 @@
|
||||
package org.enso.languageserver
|
||||
|
||||
import akka.actor.{Actor, ActorLogging, ActorRef, Stash}
|
||||
import akka.actor.{Actor, ActorLogging, Stash}
|
||||
import cats.effect.IO
|
||||
import org.enso.languageserver.data._
|
||||
import org.enso.languageserver.event.{
|
||||
ClientConnected,
|
||||
ClientDisconnected,
|
||||
ClientEvent
|
||||
}
|
||||
import org.enso.languageserver.filemanager.FileManagerProtocol._
|
||||
import org.enso.languageserver.filemanager.{FileSystemApi, FileSystemObject}
|
||||
|
||||
@ -11,59 +16,6 @@ object LanguageProtocol {
|
||||
/** Initializes the Language Server. */
|
||||
case object Initialize
|
||||
|
||||
/**
|
||||
* Notifies the Language Server about a new client connecting.
|
||||
*
|
||||
* @param clientId the internal client id.
|
||||
* @param clientActor the actor this client is represented by.
|
||||
*/
|
||||
case class Connect(clientId: Client.Id, clientActor: ActorRef)
|
||||
|
||||
/**
|
||||
* Notifies the Language Server about a client disconnecting.
|
||||
* The client may not send any further messages after this one.
|
||||
*
|
||||
* @param clientId the id of the disconnecting client.
|
||||
*/
|
||||
case class Disconnect(clientId: Client.Id)
|
||||
|
||||
/**
|
||||
* Requests the Language Server grant a new capability to a client.
|
||||
*
|
||||
* @param clientId the client to grant the capability to.
|
||||
* @param registration the capability to grant.
|
||||
*/
|
||||
case class AcquireCapability(
|
||||
clientId: Client.Id,
|
||||
registration: CapabilityRegistration
|
||||
)
|
||||
|
||||
/**
|
||||
* Notifies the Language Server about a client releasing a capability.
|
||||
*
|
||||
* @param clientId the client releasing the capability.
|
||||
* @param capabilityId the capability being released.
|
||||
*/
|
||||
case class ReleaseCapability(
|
||||
clientId: Client.Id,
|
||||
capabilityId: CapabilityRegistration.Id
|
||||
)
|
||||
|
||||
/**
|
||||
* A notification sent by the Language Server, notifying a client about
|
||||
* a capability being taken away from them.
|
||||
*
|
||||
* @param capabilityId the capability being released.
|
||||
*/
|
||||
case class CapabilityForceReleased(capabilityId: CapabilityRegistration.Id)
|
||||
|
||||
/**
|
||||
* A notification sent by the Language Server, notifying a client about a new
|
||||
* capability being granted to them.
|
||||
*
|
||||
* @param registration the capability being granted.
|
||||
*/
|
||||
case class CapabilityGranted(registration: CapabilityRegistration)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -77,6 +29,10 @@ class LanguageServer(config: Config, fs: FileSystemApi[IO])
|
||||
with ActorLogging {
|
||||
import LanguageProtocol._
|
||||
|
||||
override def preStart(): Unit = {
|
||||
context.system.eventStream.subscribe(self, classOf[ClientEvent])
|
||||
}
|
||||
|
||||
override def receive: Receive = {
|
||||
case Initialize =>
|
||||
log.debug("Language Server initialized.")
|
||||
@ -89,38 +45,16 @@ class LanguageServer(config: Config, fs: FileSystemApi[IO])
|
||||
config: Config,
|
||||
env: Environment = Environment.empty
|
||||
): Receive = {
|
||||
case Connect(clientId, actor) =>
|
||||
log.debug("Client connected [{}].", clientId)
|
||||
case ClientConnected(client) =>
|
||||
log.info("Client connected [{}].", client.id)
|
||||
context.become(
|
||||
initialized(config, env.addClient(Client(clientId, actor)))
|
||||
initialized(config, env.addClient(client))
|
||||
)
|
||||
|
||||
case Disconnect(clientId) =>
|
||||
log.debug("Client disconnected [{}].", clientId)
|
||||
case ClientDisconnected(clientId) =>
|
||||
log.info("Client disconnected [{}].", clientId)
|
||||
context.become(initialized(config, env.removeClient(clientId)))
|
||||
|
||||
case AcquireCapability(
|
||||
clientId,
|
||||
reg @ CapabilityRegistration(_, capability: CanEdit)
|
||||
) =>
|
||||
val (envWithoutCapability, releasingClients) = env.removeCapabilitiesBy {
|
||||
case CapabilityRegistration(_, CanEdit(file)) => file == capability.path
|
||||
case _ => false
|
||||
}
|
||||
releasingClients.foreach {
|
||||
case (client: Client, capabilities) =>
|
||||
capabilities.foreach { registration =>
|
||||
client.actor ! CapabilityForceReleased(registration.id)
|
||||
}
|
||||
}
|
||||
val newEnv = envWithoutCapability.grantCapability(clientId, reg)
|
||||
context.become(initialized(config, newEnv))
|
||||
|
||||
case ReleaseCapability(clientId, capabilityId) =>
|
||||
context.become(
|
||||
initialized(config, env.releaseCapability(clientId, capabilityId))
|
||||
)
|
||||
|
||||
case WriteFile(path, content) =>
|
||||
val result =
|
||||
for {
|
||||
|
@ -0,0 +1,18 @@
|
||||
package org.enso.languageserver
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The config of the running Language Server instance.
|
||||
*
|
||||
* @param interface a interface that the server listen to
|
||||
* @param port a port that the server listen to
|
||||
* @param contentRootUuid an id of content root
|
||||
* @param contentRootPath a path to the content root
|
||||
*/
|
||||
case class LanguageServerConfig(
|
||||
interface: String,
|
||||
port: Int,
|
||||
contentRootUuid: UUID,
|
||||
contentRootPath: String
|
||||
)
|
@ -0,0 +1,52 @@
|
||||
package org.enso.languageserver
|
||||
|
||||
import java.io.File
|
||||
|
||||
import akka.actor.{ActorSystem, Props}
|
||||
import akka.stream.SystemMaterializer
|
||||
import cats.effect.IO
|
||||
import org.enso.languageserver.capability.CapabilityRouter
|
||||
import org.enso.languageserver.data.{
|
||||
Config,
|
||||
ContentBasedVersioning,
|
||||
Sha3_224VersionCalculator
|
||||
}
|
||||
import org.enso.languageserver.filemanager.{FileSystem, FileSystemApi}
|
||||
import org.enso.languageserver.text.BufferRegistry
|
||||
|
||||
/**
|
||||
* A main module containing all components of th server.
|
||||
*
|
||||
* @param serverConfig a server config
|
||||
*/
|
||||
class MainModule(serverConfig: LanguageServerConfig) {
|
||||
|
||||
lazy val languageServerConfig = Config(
|
||||
Map(serverConfig.contentRootUuid -> new File(serverConfig.contentRootPath))
|
||||
)
|
||||
|
||||
lazy val fileSystem: FileSystemApi[IO] = new FileSystem[IO]
|
||||
|
||||
implicit val versionCalculator: ContentBasedVersioning =
|
||||
Sha3_224VersionCalculator
|
||||
|
||||
implicit val system = ActorSystem()
|
||||
|
||||
implicit val materializer = SystemMaterializer.get(system)
|
||||
|
||||
lazy val languageServer =
|
||||
system.actorOf(
|
||||
Props(new LanguageServer(languageServerConfig, fileSystem)),
|
||||
"server"
|
||||
)
|
||||
|
||||
lazy val bufferRegistry =
|
||||
system.actorOf(BufferRegistry.props(languageServer), "buffer-registry")
|
||||
|
||||
lazy val capabilityRouter =
|
||||
system.actorOf(CapabilityRouter.props(bufferRegistry), "capability-router")
|
||||
|
||||
lazy val server =
|
||||
new WebSocketServer(languageServer, bufferRegistry, capabilityRouter)
|
||||
|
||||
}
|
@ -12,9 +12,8 @@ import akka.stream.scaladsl.{Flow, Sink, Source}
|
||||
import akka.stream.{Materializer, OverflowStrategy}
|
||||
import org.enso.languageserver.jsonrpc.MessageHandler
|
||||
|
||||
import scala.concurrent.duration.{FiniteDuration, _}
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
import scala.concurrent.duration.FiniteDuration
|
||||
import scala.concurrent.duration._
|
||||
|
||||
object WebSocketServer {
|
||||
|
||||
@ -47,6 +46,8 @@ object WebSocketServer {
|
||||
*/
|
||||
class WebSocketServer(
|
||||
languageServer: ActorRef,
|
||||
bufferRegistry: ActorRef,
|
||||
capabilityRouter: ActorRef,
|
||||
config: WebSocketServer.Config = WebSocketServer.Config.default
|
||||
)(
|
||||
implicit val system: ActorSystem,
|
||||
@ -60,7 +61,16 @@ class WebSocketServer(
|
||||
private def newUser(): Flow[Message, Message, NotUsed] = {
|
||||
val clientId = UUID.randomUUID()
|
||||
val clientActor =
|
||||
system.actorOf(Props(new ClientController(clientId, languageServer)))
|
||||
system.actorOf(
|
||||
Props(
|
||||
new ClientController(
|
||||
clientId,
|
||||
languageServer,
|
||||
bufferRegistry,
|
||||
capabilityRouter
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val messageHandler =
|
||||
system.actorOf(
|
||||
@ -68,8 +78,6 @@ class WebSocketServer(
|
||||
)
|
||||
clientActor ! ClientApi.WebConnect(messageHandler)
|
||||
|
||||
languageServer ! LanguageProtocol.Connect(clientId, clientActor)
|
||||
|
||||
val incomingMessages: Sink[Message, NotUsed] =
|
||||
Flow[Message]
|
||||
.mapConcat({
|
||||
|
@ -0,0 +1,44 @@
|
||||
package org.enso.languageserver.capability
|
||||
|
||||
import org.enso.languageserver.data.CapabilityRegistration
|
||||
import org.enso.languageserver.jsonrpc.{HasParams, HasResult, Method, Unused}
|
||||
|
||||
/**
|
||||
* The capability JSON RPC API provided by the language server.
|
||||
* See [[https://github.com/luna/enso/blob/master/doc/design/engine/engine-services.md]]
|
||||
* for message specifications.
|
||||
*/
|
||||
object CapabilityApi {
|
||||
|
||||
case object AcquireCapability extends Method("capability/acquire") {
|
||||
implicit val hasParams = new HasParams[this.type] {
|
||||
type Params = CapabilityRegistration
|
||||
}
|
||||
implicit val hasResult = new HasResult[this.type] {
|
||||
type Result = Unused.type
|
||||
}
|
||||
}
|
||||
|
||||
case object ReleaseCapability extends Method("capability/release") {
|
||||
implicit val hasParams = new HasParams[this.type] {
|
||||
type Params = CapabilityRegistration
|
||||
}
|
||||
implicit val hasResult = new HasResult[this.type] {
|
||||
type Result = Unused.type
|
||||
}
|
||||
}
|
||||
|
||||
case object ForceReleaseCapability
|
||||
extends Method("capability/forceReleased") {
|
||||
implicit val hasParams = new HasParams[this.type] {
|
||||
type Params = CapabilityRegistration
|
||||
}
|
||||
}
|
||||
|
||||
case object GrantCapability extends Method("capability/granted") {
|
||||
implicit val hasParams = new HasParams[this.type] {
|
||||
type Params = CapabilityRegistration
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
package org.enso.languageserver.capability
|
||||
|
||||
import org.enso.languageserver.data.{CapabilityRegistration, Client}
|
||||
|
||||
object CapabilityProtocol {
|
||||
|
||||
/**
|
||||
* Requests the Language Server grant a new capability to a client.
|
||||
*
|
||||
* @param client the client to grant the capability to.
|
||||
* @param registration the capability to grant.
|
||||
*/
|
||||
case class AcquireCapability(
|
||||
client: Client,
|
||||
registration: CapabilityRegistration
|
||||
)
|
||||
|
||||
/**
|
||||
* Signals capability acquisition status.
|
||||
*/
|
||||
sealed trait AcquireCapabilityResponse
|
||||
|
||||
/**
|
||||
* Confirms client that capability has been acquired.
|
||||
*/
|
||||
case object CapabilityAcquired extends AcquireCapabilityResponse
|
||||
|
||||
/**
|
||||
* Signals that capability acquisition request cannot be processed.
|
||||
*/
|
||||
case object CapabilityAcquisitionBadRequest extends AcquireCapabilityResponse
|
||||
|
||||
/**
|
||||
* Notifies the Language Server about a client releasing a capability.
|
||||
*
|
||||
* @param clientId the client releasing the capability.
|
||||
* @param capability the capability being released.
|
||||
*/
|
||||
case class ReleaseCapability(
|
||||
clientId: Client.Id,
|
||||
capability: CapabilityRegistration
|
||||
)
|
||||
|
||||
/**
|
||||
* Signals capability release status.
|
||||
*/
|
||||
sealed trait ReleaseCapabilityResponse
|
||||
|
||||
/**
|
||||
* Confirms client that capability has been released.
|
||||
*/
|
||||
case object CapabilityReleased extends ReleaseCapabilityResponse
|
||||
|
||||
/**
|
||||
* Signals that capability release request cannot be processed.
|
||||
*/
|
||||
case object CapabilityReleaseBadRequest extends ReleaseCapabilityResponse
|
||||
|
||||
/**
|
||||
* A notification sent by the Language Server, notifying a client about
|
||||
* a capability being taken away from them.
|
||||
*
|
||||
* @param capability the capability being released.
|
||||
*/
|
||||
case class CapabilityForceReleased(capability: CapabilityRegistration)
|
||||
|
||||
/**
|
||||
* A notification sent by the Language Server, notifying a client about a new
|
||||
* capability being granted to them.
|
||||
*
|
||||
* @param registration the capability being granted.
|
||||
*/
|
||||
case class CapabilityGranted(registration: CapabilityRegistration)
|
||||
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package org.enso.languageserver.capability
|
||||
|
||||
import akka.actor.{Actor, ActorRef, Props}
|
||||
import org.enso.languageserver.capability.CapabilityProtocol.{
|
||||
AcquireCapability,
|
||||
ReleaseCapability
|
||||
}
|
||||
import org.enso.languageserver.data.{CanEdit, CapabilityRegistration}
|
||||
|
||||
/**
|
||||
* A content based router that routes each capability request to the
|
||||
* correct recipient based on the capability object.
|
||||
*
|
||||
* @param bufferRegistry the recipient of buffer capability requests
|
||||
*/
|
||||
class CapabilityRouter(bufferRegistry: ActorRef) extends Actor {
|
||||
|
||||
override def receive: Receive = {
|
||||
case msg @ AcquireCapability(_, CapabilityRegistration(CanEdit(_))) =>
|
||||
bufferRegistry.forward(msg)
|
||||
|
||||
case msg @ ReleaseCapability(_, CapabilityRegistration(CanEdit(_))) =>
|
||||
bufferRegistry.forward(msg)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
object CapabilityRouter {
|
||||
|
||||
/**
|
||||
* Creates a configuration object used to create a [[CapabilityRouter]]
|
||||
*
|
||||
* @param bufferRegistry a buffer registry ref
|
||||
* @return a configuration object
|
||||
*/
|
||||
def props(bufferRegistry: ActorRef): Props =
|
||||
Props(new CapabilityRouter(bufferRegistry))
|
||||
|
||||
}
|
@ -2,6 +2,7 @@ package org.enso.languageserver.data
|
||||
import java.util.UUID
|
||||
|
||||
import io.circe._
|
||||
import org.enso.languageserver.filemanager.Path
|
||||
|
||||
/**
|
||||
* A superclass for all capabilities in the system.
|
||||
@ -12,9 +13,9 @@ sealed abstract class Capability(val method: String)
|
||||
//TODO[MK]: Migrate to actual Path, once it is implemented.
|
||||
/**
|
||||
* A capability allowing the user to modify a given file.
|
||||
* @param path
|
||||
* @param path the file path this capability is granted for.
|
||||
*/
|
||||
case class CanEdit(path: String) extends Capability(CanEdit.methodName)
|
||||
case class CanEdit(path: Path) extends Capability(CanEdit.methodName)
|
||||
|
||||
object CanEdit {
|
||||
val methodName = "canEdit"
|
||||
@ -35,11 +36,9 @@ object Capability {
|
||||
/**
|
||||
* A capability registration object, used to identify acquired capabilities.
|
||||
*
|
||||
* @param id the registration id.
|
||||
* @param capability the registered capability.
|
||||
*/
|
||||
case class CapabilityRegistration(
|
||||
id: CapabilityRegistration.Id,
|
||||
capability: Capability
|
||||
)
|
||||
|
||||
@ -49,13 +48,11 @@ object CapabilityRegistration {
|
||||
|
||||
type Id = UUID
|
||||
|
||||
private val idField = "id"
|
||||
private val methodField = "method"
|
||||
private val optionsField = "registerOptions"
|
||||
|
||||
implicit val encoder: Encoder[CapabilityRegistration] = registration =>
|
||||
Json.obj(
|
||||
idField -> registration.id.asJson,
|
||||
methodField -> registration.capability.method.asJson,
|
||||
optionsField -> registration.capability.asJson
|
||||
)
|
||||
@ -71,12 +68,11 @@ object CapabilityRegistration {
|
||||
}
|
||||
|
||||
for {
|
||||
id <- json.downField(idField).as[Id]
|
||||
method <- json.downField(methodField).as[String]
|
||||
capability <- resolveOptions(
|
||||
method,
|
||||
json.downField(optionsField).focus.getOrElse(Json.Null)
|
||||
)
|
||||
} yield CapabilityRegistration(id, capability)
|
||||
} yield CapabilityRegistration(capability)
|
||||
}
|
||||
}
|
||||
|
@ -8,16 +8,14 @@ import akka.actor.ActorRef
|
||||
* @param id the internal id of this client
|
||||
* @param actor the actor handling remote client communications, used to push
|
||||
* requests and notifications.
|
||||
* @param capabilities the capabilities this client has available.
|
||||
*/
|
||||
case class Client(
|
||||
id: Client.Id,
|
||||
actor: ActorRef,
|
||||
capabilities: List[CapabilityRegistration]
|
||||
actor: ActorRef
|
||||
)
|
||||
|
||||
object Client {
|
||||
|
||||
type Id = UUID
|
||||
|
||||
def apply(id: Id, actor: ActorRef): Client = Client(id, actor, List())
|
||||
}
|
||||
|
@ -0,0 +1,16 @@
|
||||
package org.enso.languageserver.data
|
||||
|
||||
/**
|
||||
* A content-based versioning calculator.
|
||||
*/
|
||||
trait ContentBasedVersioning {
|
||||
|
||||
/**
|
||||
* Evaluates content-based version of document.
|
||||
*
|
||||
* @param content a textual content
|
||||
* @return a digest
|
||||
*/
|
||||
def evalVersion(content: String): String
|
||||
|
||||
}
|
@ -48,79 +48,6 @@ case class Environment(clients: List[Client]) {
|
||||
def removeClient(clientId: Client.Id): Environment =
|
||||
copy(clients = clients.filter(_.id != clientId))
|
||||
|
||||
/**
|
||||
* Removes all registered capabilities matching a given predicate.
|
||||
*
|
||||
* @param predicate the predicate to match capabilities against.
|
||||
* @return a new version of `Env` without the capabilities matching the
|
||||
* predicate and a list of all clients, together with capabilities
|
||||
* that got removed for them.
|
||||
*/
|
||||
def removeCapabilitiesBy(
|
||||
predicate: CapabilityRegistration => Boolean
|
||||
): (Environment, List[(Client, List[CapabilityRegistration])]) = {
|
||||
val newClients = clients.map { client =>
|
||||
val (removedCapabilities, retainedCapabilities) =
|
||||
client.capabilities.partition(predicate)
|
||||
val newClient = client.copy(capabilities = retainedCapabilities)
|
||||
(newClient, removedCapabilities)
|
||||
}
|
||||
(copy(clients = newClients.map(_._1)), newClients)
|
||||
}
|
||||
|
||||
/**
|
||||
* Modified a client at a given id.
|
||||
*
|
||||
* @param clientId the id of the client to modify.
|
||||
* @param modification the function used to modify the client.
|
||||
* @return a new version of this env, with the selected client modified by
|
||||
* `modification`
|
||||
*/
|
||||
def modifyClient(
|
||||
clientId: Client.Id,
|
||||
modification: Client => Client
|
||||
): Environment = {
|
||||
val newClients = clients.map { client =>
|
||||
if (client.id == clientId) {
|
||||
modification(client)
|
||||
} else {
|
||||
client
|
||||
}
|
||||
}
|
||||
copy(clients = newClients)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants a given client a provided capability.
|
||||
*
|
||||
* @param clientId the id of the client to grant the capability.
|
||||
* @param registration the capability to grant.
|
||||
* @return a new version of this env, with the capability granted.
|
||||
*/
|
||||
def grantCapability(
|
||||
clientId: Client.Id,
|
||||
registration: CapabilityRegistration
|
||||
): Environment =
|
||||
modifyClient(clientId, { client =>
|
||||
client.copy(capabilities = registration :: client.capabilities)
|
||||
})
|
||||
|
||||
/**
|
||||
* Releases a capability from a given client.
|
||||
*
|
||||
* @param clientId the id of the client that releases the capability.
|
||||
* @param capabilityId the id of the capability registration to release.
|
||||
* @return a new version of this env, with the selected capability released.
|
||||
*/
|
||||
def releaseCapability(
|
||||
clientId: Client.Id,
|
||||
capabilityId: CapabilityRegistration.Id
|
||||
): Environment =
|
||||
modifyClient(clientId, { client =>
|
||||
client.copy(
|
||||
capabilities = client.capabilities.filter(_.id != capabilityId)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
object Environment {
|
||||
|
@ -0,0 +1,23 @@
|
||||
package org.enso.languageserver.data
|
||||
|
||||
import org.bouncycastle.jcajce.provider.digest.SHA3
|
||||
import org.bouncycastle.util.encoders.Hex
|
||||
|
||||
/**
|
||||
* SHA3-224 digest calculator.
|
||||
*/
|
||||
object Sha3_224VersionCalculator extends ContentBasedVersioning {
|
||||
|
||||
/**
|
||||
* Digests textual content.
|
||||
*
|
||||
* @param content a textual content
|
||||
* @return a digest
|
||||
*/
|
||||
override def evalVersion(content: String): String = {
|
||||
val digestSHA3 = new SHA3.Digest224()
|
||||
val hash = digestSHA3.digest(content.getBytes("UTF-8"))
|
||||
Hex.toHexString(hash)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
package org.enso.languageserver.event
|
||||
|
||||
import org.enso.languageserver.filemanager.Path
|
||||
|
||||
/**
|
||||
* Base trait for all buffer events.
|
||||
*/
|
||||
sealed trait BufferEvent extends Event
|
||||
|
||||
/**
|
||||
* Notifies the Language Server when new file is opened for editing.
|
||||
*
|
||||
* @param path the path to a file
|
||||
*/
|
||||
case class FileOpened(path: Path) extends BufferEvent
|
||||
|
||||
/**
|
||||
* Notifies the Language Server when a file is closed for editing.
|
||||
*
|
||||
* @param path the path to a file
|
||||
*/
|
||||
case class FileClosed(path: Path) extends BufferEvent
|
@ -0,0 +1,23 @@
|
||||
package org.enso.languageserver.event
|
||||
|
||||
import org.enso.languageserver.data.Client
|
||||
|
||||
/**
|
||||
* Base trait for all client events.
|
||||
*/
|
||||
sealed trait ClientEvent extends Event
|
||||
|
||||
/**
|
||||
* Notifies the Language Server about a new client connecting.
|
||||
*
|
||||
* @param client an object representing a client
|
||||
*/
|
||||
case class ClientConnected(client: Client) extends ClientEvent
|
||||
|
||||
/**
|
||||
* Notifies the Language Server about a client disconnecting.
|
||||
* The client may not send any further messages after this one.
|
||||
*
|
||||
* @param clientId the internal id of this client
|
||||
*/
|
||||
case class ClientDisconnected(clientId: Client.Id) extends ClientEvent
|
@ -0,0 +1,6 @@
|
||||
package org.enso.languageserver.event
|
||||
|
||||
/**
|
||||
* Base trait for all server events.
|
||||
*/
|
||||
trait Event
|
@ -91,4 +91,6 @@ object FileManagerApi {
|
||||
|
||||
case object FileExistsError extends Error(1004, "File already exists")
|
||||
|
||||
case object OperationTimeoutError extends Error(1005, "IO operation timeout")
|
||||
|
||||
}
|
||||
|
@ -25,6 +25,11 @@ case object FileNotFound extends FileSystemFailure
|
||||
*/
|
||||
case object FileExists extends FileSystemFailure
|
||||
|
||||
/**
|
||||
* Signal that the operation timed out.
|
||||
*/
|
||||
case object OperationTimeout extends FileSystemFailure
|
||||
|
||||
/**
|
||||
* Signals file system specific errors.
|
||||
*
|
||||
|
@ -5,7 +5,8 @@ import org.enso.languageserver.filemanager.FileManagerApi.{
|
||||
ContentRootNotFoundError,
|
||||
FileExistsError,
|
||||
FileNotFoundError,
|
||||
FileSystemError
|
||||
FileSystemError,
|
||||
OperationTimeoutError
|
||||
}
|
||||
import org.enso.languageserver.jsonrpc.Error
|
||||
|
||||
@ -23,6 +24,7 @@ object FileSystemFailureMapper {
|
||||
case AccessDenied => AccessDeniedError
|
||||
case FileNotFound => FileNotFoundError
|
||||
case FileExists => FileExistsError
|
||||
case OperationTimeout => OperationTimeoutError
|
||||
case GenericFileSystemFailure(reason) => FileSystemError(reason)
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,83 @@
|
||||
package org.enso.languageserver.requesthandler
|
||||
|
||||
import akka.actor.{Actor, ActorLogging, ActorRef, Props}
|
||||
import org.enso.languageserver.capability.CapabilityApi.AcquireCapability
|
||||
import org.enso.languageserver.capability.CapabilityProtocol
|
||||
import org.enso.languageserver.capability.CapabilityProtocol.{
|
||||
CapabilityAcquired,
|
||||
CapabilityAcquisitionBadRequest
|
||||
}
|
||||
import org.enso.languageserver.data.{CapabilityRegistration, Client}
|
||||
import org.enso.languageserver.jsonrpc.Errors.ServiceError
|
||||
import org.enso.languageserver.jsonrpc._
|
||||
|
||||
import scala.concurrent.duration.FiniteDuration
|
||||
|
||||
/**
|
||||
* A request handler for `capability/acquire` commands.
|
||||
*
|
||||
* @param capabilityRouter a router that dispatches capability requests
|
||||
* @param timeout a request timeout
|
||||
* @param client an object representing a client connected to the language server
|
||||
*/
|
||||
class AcquireCapabilityHandler(
|
||||
capabilityRouter: ActorRef,
|
||||
timeout: FiniteDuration,
|
||||
client: Client
|
||||
) extends Actor
|
||||
with ActorLogging {
|
||||
|
||||
import context.dispatcher
|
||||
|
||||
override def receive: Receive = requestStage
|
||||
|
||||
private def requestStage: Receive = {
|
||||
case Request(AcquireCapability, id, registration: CapabilityRegistration) =>
|
||||
capabilityRouter ! CapabilityProtocol.AcquireCapability(
|
||||
client,
|
||||
registration
|
||||
)
|
||||
context.system.scheduler.scheduleOnce(timeout, self, RequestTimeout)
|
||||
context.become(responseStage(id, sender()))
|
||||
}
|
||||
|
||||
private def responseStage(id: Id, replyTo: ActorRef): Receive = {
|
||||
case RequestTimeout =>
|
||||
log.error(s"Acquiring capability for ${client.id} timed out")
|
||||
replyTo ! ResponseError(Some(id), ServiceError)
|
||||
context.stop(self)
|
||||
|
||||
case CapabilityAcquired =>
|
||||
replyTo ! ResponseResult(AcquireCapability, id, Unused)
|
||||
context.stop(self)
|
||||
|
||||
case CapabilityAcquisitionBadRequest =>
|
||||
replyTo ! ResponseError(Some(id), ServiceError)
|
||||
context.stop(self)
|
||||
}
|
||||
|
||||
override def unhandled(message: Any): Unit =
|
||||
log.warning("Received unknown message: {}", message)
|
||||
|
||||
}
|
||||
|
||||
object AcquireCapabilityHandler {
|
||||
|
||||
/**
|
||||
* Creates a configuration object used to create a [[AcquireCapabilityHandler]]
|
||||
*
|
||||
* @param capabilityRouter a router that dispatches capability requests
|
||||
* @param requestTimeout a request timeout
|
||||
* @param client an object representing a client connected to the language server
|
||||
* @return a configuration object
|
||||
*/
|
||||
def props(
|
||||
capabilityRouter: ActorRef,
|
||||
requestTimeout: FiniteDuration,
|
||||
client: Client
|
||||
): Props =
|
||||
Props(
|
||||
new AcquireCapabilityHandler(capabilityRouter, requestTimeout, client)
|
||||
)
|
||||
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
package org.enso.languageserver.requesthandler
|
||||
|
||||
import akka.actor.{Actor, ActorLogging, ActorRef, Props}
|
||||
import org.enso.languageserver.data.Client
|
||||
import org.enso.languageserver.filemanager.FileSystemFailureMapper
|
||||
import org.enso.languageserver.jsonrpc.Errors.ServiceError
|
||||
import org.enso.languageserver.jsonrpc.{
|
||||
Id,
|
||||
Request,
|
||||
ResponseError,
|
||||
ResponseResult
|
||||
}
|
||||
import org.enso.languageserver.text.TextApi.OpenFile
|
||||
import org.enso.languageserver.text.TextProtocol
|
||||
import org.enso.languageserver.text.TextProtocol.{
|
||||
OpenFileResponse,
|
||||
OpenFileResult
|
||||
}
|
||||
|
||||
import scala.concurrent.duration.FiniteDuration
|
||||
|
||||
/**
|
||||
* A request handler for `text/openFile` commands.
|
||||
*
|
||||
* @param bufferRegistry a router that dispatches text editing requests
|
||||
* @param timeout a request timeout
|
||||
* @param client an object representing a client connected to the language server
|
||||
*/
|
||||
class OpenFileHandler(
|
||||
bufferRegistry: ActorRef,
|
||||
timeout: FiniteDuration,
|
||||
client: Client
|
||||
) extends Actor
|
||||
with ActorLogging {
|
||||
|
||||
import context.dispatcher
|
||||
|
||||
override def receive: Receive = requestStage
|
||||
|
||||
private def requestStage: Receive = {
|
||||
case Request(OpenFile, id, params: OpenFile.Params) =>
|
||||
bufferRegistry ! TextProtocol.OpenFile(client, params.path)
|
||||
context.system.scheduler.scheduleOnce(timeout, self, RequestTimeout)
|
||||
context.become(responseStage(id, sender()))
|
||||
}
|
||||
|
||||
private def responseStage(id: Id, replyTo: ActorRef): Receive = {
|
||||
case RequestTimeout =>
|
||||
log.error(s"Opening file for ${client.id} timed out")
|
||||
replyTo ! ResponseError(Some(id), ServiceError)
|
||||
context.stop(self)
|
||||
|
||||
case OpenFileResponse(Right(OpenFileResult(buffer, capability))) =>
|
||||
replyTo ! ResponseResult(
|
||||
OpenFile,
|
||||
id,
|
||||
OpenFile
|
||||
.Result(capability, buffer.contents.toString, buffer.version)
|
||||
)
|
||||
context.stop(self)
|
||||
|
||||
case OpenFileResponse(Left(failure)) =>
|
||||
replyTo ! ResponseError(
|
||||
Some(id),
|
||||
FileSystemFailureMapper.mapFailure(failure)
|
||||
)
|
||||
context.stop(self)
|
||||
}
|
||||
|
||||
override def unhandled(message: Any): Unit =
|
||||
log.warning("Received unknown message: {}", message)
|
||||
|
||||
}
|
||||
|
||||
object OpenFileHandler {
|
||||
|
||||
/**
|
||||
* Creates a configuration object used to create a [[OpenFileHandler]]
|
||||
*
|
||||
* @param bufferRegistry a router that dispatches text editing requests
|
||||
* @param requestTimeout a request timeout
|
||||
* @param client an object representing a client connected to the language server
|
||||
* @return a configuration object
|
||||
*/
|
||||
def props(
|
||||
bufferRegistry: ActorRef,
|
||||
requestTimeout: FiniteDuration,
|
||||
client: Client
|
||||
): Props = Props(new OpenFileHandler(bufferRegistry, requestTimeout, client))
|
||||
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
package org.enso.languageserver.requesthandler
|
||||
|
||||
import akka.actor.{Actor, ActorLogging, ActorRef, Props}
|
||||
import org.enso.languageserver.capability.CapabilityApi.ReleaseCapability
|
||||
import org.enso.languageserver.capability.CapabilityProtocol
|
||||
import org.enso.languageserver.capability.CapabilityProtocol.{
|
||||
CapabilityReleaseBadRequest,
|
||||
CapabilityReleased
|
||||
}
|
||||
import org.enso.languageserver.data.{CapabilityRegistration, Client}
|
||||
import org.enso.languageserver.jsonrpc.Errors.ServiceError
|
||||
import org.enso.languageserver.jsonrpc._
|
||||
|
||||
import scala.concurrent.duration.FiniteDuration
|
||||
|
||||
/**
|
||||
* A request handler for `capability/release` commands.
|
||||
*
|
||||
* @param capabilityRouter a router that dispatches capability requests
|
||||
* @param timeout a request timeout
|
||||
* @param client an object representing a client connected to the language server
|
||||
*/
|
||||
class ReleaseCapabilityHandler(
|
||||
capabilityRouter: ActorRef,
|
||||
timeout: FiniteDuration,
|
||||
client: Client
|
||||
) extends Actor
|
||||
with ActorLogging {
|
||||
override def receive: Receive = requestStage
|
||||
|
||||
import context.dispatcher
|
||||
|
||||
private def requestStage: Receive = {
|
||||
case Request(ReleaseCapability, id, params: CapabilityRegistration) =>
|
||||
capabilityRouter ! CapabilityProtocol.ReleaseCapability(client.id, params)
|
||||
context.system.scheduler.scheduleOnce(timeout, self, RequestTimeout)
|
||||
context.become(responseStage(id, sender()))
|
||||
}
|
||||
private def responseStage(id: Id, replyTo: ActorRef): Receive = {
|
||||
case RequestTimeout =>
|
||||
log.error(s"Releasing capability for ${client.id} timed out")
|
||||
replyTo ! ResponseError(Some(id), ServiceError)
|
||||
context.stop(self)
|
||||
|
||||
case CapabilityReleased =>
|
||||
replyTo ! ResponseResult(ReleaseCapability, id, Unused)
|
||||
context.stop(self)
|
||||
|
||||
case CapabilityReleaseBadRequest =>
|
||||
replyTo ! ResponseError(Some(id), ServiceError)
|
||||
context.stop(self)
|
||||
}
|
||||
|
||||
override def unhandled(message: Any): Unit =
|
||||
log.warning("Received unknown message: {}", message)
|
||||
|
||||
}
|
||||
|
||||
object ReleaseCapabilityHandler {
|
||||
|
||||
/**
|
||||
* Creates a configuration object used to create a [[ReleaseCapabilityHandler]]
|
||||
*
|
||||
* @param capabilityRouter a router that dispatches capability requests
|
||||
* @param requestTimeout a request timeout
|
||||
* @param client an object representing a client connected to the language server
|
||||
* @return a configuration object
|
||||
*/
|
||||
def props(
|
||||
capabilityRouter: ActorRef,
|
||||
requestTimeout: FiniteDuration,
|
||||
client: Client
|
||||
): Props =
|
||||
Props(
|
||||
new ReleaseCapabilityHandler(capabilityRouter, requestTimeout, client)
|
||||
)
|
||||
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package org.enso.languageserver.requesthandler
|
||||
|
||||
/**
|
||||
* Signals that operation has timed out.
|
||||
*/
|
||||
case object RequestTimeout
|
@ -0,0 +1,40 @@
|
||||
package org.enso.languageserver.text
|
||||
|
||||
import org.enso.languageserver.data.ContentBasedVersioning
|
||||
import org.enso.languageserver.data.buffer.Rope
|
||||
|
||||
/**
|
||||
* A buffer state representation.
|
||||
*
|
||||
* @param contents the contents of the buffer.
|
||||
* @param version the current version of the buffer contents.
|
||||
*/
|
||||
case class Buffer(contents: Rope, version: Buffer.Version)
|
||||
|
||||
object Buffer {
|
||||
type Version = String
|
||||
|
||||
/**
|
||||
* Creates a new buffer with a freshly generated version.
|
||||
*
|
||||
* @param contents the contents of this buffer.
|
||||
* @param versionCalculator a digest calculator for content based versioning.
|
||||
* @return a new buffer instance.
|
||||
*/
|
||||
def apply(
|
||||
contents: Rope
|
||||
)(implicit versionCalculator: ContentBasedVersioning): Buffer =
|
||||
Buffer(contents, versionCalculator.evalVersion(contents.toString))
|
||||
|
||||
/**
|
||||
* Creates a new buffer with a freshly generated version.
|
||||
*
|
||||
* @param contents the contents of this buffer.
|
||||
* @param versionCalculator a digest calculator for content based versioning.
|
||||
* @return a new buffer instance.
|
||||
*/
|
||||
def apply(
|
||||
contents: String
|
||||
)(implicit versionCalculator: ContentBasedVersioning): Buffer =
|
||||
Buffer(Rope(contents))
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
package org.enso.languageserver.text
|
||||
|
||||
import akka.actor.{Actor, ActorRef, Props, Terminated}
|
||||
import org.enso.languageserver.capability.CapabilityProtocol.{
|
||||
AcquireCapability,
|
||||
CapabilityAcquisitionBadRequest,
|
||||
CapabilityReleaseBadRequest,
|
||||
ReleaseCapability
|
||||
}
|
||||
import org.enso.languageserver.data.{
|
||||
CanEdit,
|
||||
CapabilityRegistration,
|
||||
ContentBasedVersioning
|
||||
}
|
||||
import org.enso.languageserver.filemanager.Path
|
||||
import org.enso.languageserver.text.TextProtocol.OpenFile
|
||||
|
||||
/**
|
||||
* An actor that routes request regarding text editing to the right buffer.
|
||||
* It creates a buffer actor, if a buffer doesn't exists.
|
||||
*
|
||||
* @param fileManager a file manager
|
||||
* @param versionCalculator a content based version calculator
|
||||
*/
|
||||
class BufferRegistry(fileManager: ActorRef)(
|
||||
implicit versionCalculator: ContentBasedVersioning
|
||||
) extends Actor {
|
||||
|
||||
override def receive: Receive = running(Map.empty)
|
||||
|
||||
private def running(registry: Map[Path, ActorRef]): Receive = {
|
||||
case msg @ OpenFile(_, path) =>
|
||||
if (registry.contains(path)) {
|
||||
registry(path).forward(msg)
|
||||
} else {
|
||||
val bufferRef =
|
||||
context.actorOf(CollaborativeBuffer.props(path, fileManager))
|
||||
context.watch(bufferRef)
|
||||
bufferRef.forward(msg)
|
||||
context.become(running(registry + (path -> bufferRef)))
|
||||
}
|
||||
|
||||
case Terminated(bufferRef) =>
|
||||
context.become(running(registry.filter(_._2 != bufferRef)))
|
||||
|
||||
case msg @ AcquireCapability(_, CapabilityRegistration(CanEdit(path))) =>
|
||||
if (registry.contains(path)) {
|
||||
registry(path).forward(msg)
|
||||
} else {
|
||||
sender() ! CapabilityAcquisitionBadRequest
|
||||
}
|
||||
|
||||
case msg @ ReleaseCapability(_, CapabilityRegistration(CanEdit(path))) =>
|
||||
if (registry.contains(path)) {
|
||||
registry(path).forward(msg)
|
||||
} else {
|
||||
sender() ! CapabilityReleaseBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
object BufferRegistry {
|
||||
|
||||
/**
|
||||
* Creates a configuration object used to create a [[BufferRegistry]]
|
||||
*
|
||||
* @param fileManager a file manager actor
|
||||
* @param versionCalculator a content based version calculator
|
||||
* @return a configuration object
|
||||
*/
|
||||
def props(
|
||||
fileManager: ActorRef
|
||||
)(implicit versionCalculator: ContentBasedVersioning): Props =
|
||||
Props(new BufferRegistry(fileManager))
|
||||
|
||||
}
|
@ -0,0 +1,237 @@
|
||||
package org.enso.languageserver.text
|
||||
|
||||
import akka.actor.{Actor, ActorLogging, ActorRef, Props, Stash}
|
||||
import org.enso.languageserver.capability.CapabilityProtocol._
|
||||
import org.enso.languageserver.data.Client.Id
|
||||
import org.enso.languageserver.data.{
|
||||
CanEdit,
|
||||
CapabilityRegistration,
|
||||
Client,
|
||||
ContentBasedVersioning
|
||||
}
|
||||
import org.enso.languageserver.event.{
|
||||
ClientDisconnected,
|
||||
FileClosed,
|
||||
FileOpened
|
||||
}
|
||||
import org.enso.languageserver.filemanager.FileManagerProtocol.ReadFileResult
|
||||
import org.enso.languageserver.filemanager.{
|
||||
FileManagerProtocol,
|
||||
OperationTimeout,
|
||||
Path
|
||||
}
|
||||
import org.enso.languageserver.text.CollaborativeBuffer.FileReadingTimeout
|
||||
import org.enso.languageserver.text.TextProtocol.{
|
||||
OpenFile,
|
||||
OpenFileResponse,
|
||||
OpenFileResult
|
||||
}
|
||||
|
||||
import scala.concurrent.duration._
|
||||
import scala.language.postfixOps
|
||||
|
||||
/**
|
||||
* An actor enabling multiple users edit collaboratively a file.
|
||||
*
|
||||
* @param bufferPath a path to a file
|
||||
* @param fileManager a file manger actor
|
||||
* @param timeout a request timeout
|
||||
* @param versionCalculator a content based version calculator
|
||||
*/
|
||||
class CollaborativeBuffer(
|
||||
bufferPath: Path,
|
||||
fileManager: ActorRef,
|
||||
timeout: FiniteDuration
|
||||
)(
|
||||
implicit versionCalculator: ContentBasedVersioning
|
||||
) extends Actor
|
||||
with Stash
|
||||
with ActorLogging {
|
||||
|
||||
import context.dispatcher
|
||||
|
||||
override def preStart(): Unit = {
|
||||
context.system.eventStream.subscribe(self, classOf[ClientDisconnected])
|
||||
}
|
||||
|
||||
override def receive: Receive = uninitialized
|
||||
|
||||
private def uninitialized: Receive = {
|
||||
case OpenFile(client, path) =>
|
||||
context.system.eventStream.publish(FileOpened(path))
|
||||
log.info(s"Buffer opened for $path [client:${client.id}]")
|
||||
readFile(client, path)
|
||||
}
|
||||
|
||||
private def waitingForFileContent(
|
||||
client: Client,
|
||||
replyTo: ActorRef
|
||||
): Receive = {
|
||||
case ReadFileResult(Right(content)) =>
|
||||
handleFileContent(client, replyTo, content)
|
||||
unstashAll()
|
||||
|
||||
case ReadFileResult(Left(failure)) =>
|
||||
replyTo ! OpenFileResponse(Left(failure))
|
||||
stop()
|
||||
|
||||
case FileReadingTimeout =>
|
||||
replyTo ! OpenFileResponse(Left(OperationTimeout))
|
||||
stop()
|
||||
|
||||
case _ => stash()
|
||||
}
|
||||
|
||||
private def collaborativeEditing(
|
||||
buffer: Buffer,
|
||||
clients: Map[Client.Id, Client],
|
||||
lockHolder: Option[Client]
|
||||
): Receive = {
|
||||
case OpenFile(client, _) =>
|
||||
openFile(buffer, clients, lockHolder, client)
|
||||
|
||||
case AcquireCapability(clientId, CapabilityRegistration(CanEdit(path))) =>
|
||||
acquireWriteLock(buffer, clients, lockHolder, clientId, path)
|
||||
|
||||
case ReleaseCapability(clientId, CapabilityRegistration(CanEdit(_))) =>
|
||||
releaseWriteLock(buffer, clients, lockHolder, clientId)
|
||||
|
||||
case ClientDisconnected(clientId) =>
|
||||
if (clients.contains(clientId)) {
|
||||
removeClient(buffer, clients, lockHolder, clientId)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private def readFile(client: Client, path: Path): Unit = {
|
||||
fileManager ! FileManagerProtocol.ReadFile(path)
|
||||
context.system.scheduler
|
||||
.scheduleOnce(timeout, self, FileReadingTimeout)
|
||||
context.become(waitingForFileContent(client, sender()))
|
||||
}
|
||||
|
||||
private def handleFileContent(
|
||||
client: Client,
|
||||
originalSender: ActorRef,
|
||||
content: String
|
||||
): Unit = {
|
||||
val buffer = Buffer(content)
|
||||
val cap = CapabilityRegistration(CanEdit(bufferPath))
|
||||
originalSender ! OpenFileResponse(
|
||||
Right(OpenFileResult(buffer, Some(cap)))
|
||||
)
|
||||
context.become(
|
||||
collaborativeEditing(buffer, Map(client.id -> client), Some(client))
|
||||
)
|
||||
}
|
||||
|
||||
private def openFile(
|
||||
buffer: Buffer,
|
||||
clients: Map[Id, Client],
|
||||
lockHolder: Option[Client],
|
||||
client: Client
|
||||
): Unit = {
|
||||
val writeCapability =
|
||||
if (lockHolder.isEmpty)
|
||||
Some(CapabilityRegistration(CanEdit(bufferPath)))
|
||||
else
|
||||
None
|
||||
sender ! OpenFileResponse(Right(OpenFileResult(buffer, writeCapability)))
|
||||
context.become(
|
||||
collaborativeEditing(buffer, clients + (client.id -> client), lockHolder)
|
||||
)
|
||||
}
|
||||
|
||||
private def removeClient(
|
||||
buffer: Buffer,
|
||||
clients: Map[Id, Client],
|
||||
lockHolder: Option[Client],
|
||||
clientId: Id
|
||||
): Unit = {
|
||||
val newLock =
|
||||
lockHolder.flatMap {
|
||||
case holder if (holder.id == clientId) => None
|
||||
case holder => Some(holder)
|
||||
}
|
||||
val newClientMap = clients - clientId
|
||||
if (newClientMap.isEmpty) {
|
||||
stop()
|
||||
} else {
|
||||
context.become(collaborativeEditing(buffer, newClientMap, newLock))
|
||||
}
|
||||
}
|
||||
|
||||
private def releaseWriteLock(
|
||||
buffer: Buffer,
|
||||
clients: Map[Client.Id, Client],
|
||||
lockHolder: Option[Client],
|
||||
clientId: Id
|
||||
): Unit = {
|
||||
lockHolder match {
|
||||
case None =>
|
||||
sender() ! CapabilityReleaseBadRequest
|
||||
context.become(collaborativeEditing(buffer, clients, lockHolder))
|
||||
|
||||
case Some(holder) if holder.id != clientId =>
|
||||
sender() ! CapabilityReleaseBadRequest
|
||||
context.become(collaborativeEditing(buffer, clients, lockHolder))
|
||||
|
||||
case Some(holder) if holder.id == clientId =>
|
||||
sender() ! CapabilityReleased
|
||||
context.become(collaborativeEditing(buffer, clients, None))
|
||||
}
|
||||
}
|
||||
|
||||
private def acquireWriteLock(
|
||||
buffer: Buffer,
|
||||
clients: Map[Client.Id, Client],
|
||||
lockHolder: Option[Client],
|
||||
clientId: Client,
|
||||
path: Path
|
||||
): Unit = {
|
||||
lockHolder match {
|
||||
case None =>
|
||||
sender() ! CapabilityAcquired
|
||||
context.become(collaborativeEditing(buffer, clients, Some(clientId)))
|
||||
|
||||
case Some(holder) if holder == clientId =>
|
||||
sender() ! CapabilityAcquisitionBadRequest
|
||||
context.become(collaborativeEditing(buffer, clients, lockHolder))
|
||||
|
||||
case Some(holder) if holder != clientId =>
|
||||
sender() ! CapabilityAcquired
|
||||
holder.actor ! CapabilityForceReleased(
|
||||
CapabilityRegistration(CanEdit(path))
|
||||
)
|
||||
context.become(collaborativeEditing(buffer, clients, Some(clientId)))
|
||||
}
|
||||
}
|
||||
|
||||
def stop(): Unit = {
|
||||
context.system.eventStream.publish(FileClosed(bufferPath))
|
||||
context.stop(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
object CollaborativeBuffer {
|
||||
|
||||
case object FileReadingTimeout
|
||||
|
||||
/**
|
||||
* Creates a configuration object used to create a [[CollaborativeBuffer]]
|
||||
*
|
||||
* @param bufferPath a path to a file
|
||||
* @param fileManager a file manager actor
|
||||
* @param timeout a request timeout
|
||||
* @param versionCalculator a content based version calculator
|
||||
* @return a configuration object
|
||||
*/
|
||||
def props(
|
||||
bufferPath: Path,
|
||||
fileManager: ActorRef,
|
||||
timeout: FiniteDuration = 10 seconds
|
||||
)(implicit versionCalculator: ContentBasedVersioning): Props =
|
||||
Props(new CollaborativeBuffer(bufferPath, fileManager, timeout))
|
||||
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
package org.enso.languageserver.text
|
||||
|
||||
import org.enso.languageserver.data.CapabilityRegistration
|
||||
import org.enso.languageserver.filemanager.Path
|
||||
import org.enso.languageserver.jsonrpc.{HasParams, HasResult, Method}
|
||||
|
||||
/**
|
||||
* The text editing JSON RPC API provided by the language server.
|
||||
* See [[https://github.com/luna/enso/blob/master/doc/design/engine/engine-services.md]]
|
||||
* for message specifications.
|
||||
*/
|
||||
object TextApi {
|
||||
|
||||
case object OpenFile extends Method("text/openFile") {
|
||||
case class Params(path: Path)
|
||||
case class Result(
|
||||
writeCapability: Option[CapabilityRegistration],
|
||||
content: String,
|
||||
currentVersion: String
|
||||
)
|
||||
implicit val hasParams = new HasParams[this.type] {
|
||||
type Params = OpenFile.Params
|
||||
}
|
||||
implicit val hasResult = new HasResult[this.type] {
|
||||
type Result = OpenFile.Result
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package org.enso.languageserver.text
|
||||
|
||||
import org.enso.languageserver.data.{CapabilityRegistration, Client}
|
||||
import org.enso.languageserver.filemanager.{FileSystemFailure, Path}
|
||||
|
||||
object TextProtocol {
|
||||
|
||||
/** Requests the language server to open a file on behalf of a given user.
|
||||
*
|
||||
* @param client the client opening the file.
|
||||
* @param path the file path.
|
||||
*/
|
||||
case class OpenFile(client: Client, path: Path)
|
||||
|
||||
/** Sent by the server in response to [[OpenFile]]
|
||||
*
|
||||
* @param result either a file system failure, or successful opening data.
|
||||
*/
|
||||
case class OpenFileResponse(result: Either[FileSystemFailure, OpenFileResult])
|
||||
|
||||
/** The data carried by a successful file open operation.
|
||||
*
|
||||
* @param buffer file contents and current version.
|
||||
* @param writeCapability a write capability that could have been
|
||||
* automatically granted.
|
||||
*/
|
||||
case class OpenFileResult(
|
||||
buffer: Buffer,
|
||||
writeCapability: Option[CapabilityRegistration]
|
||||
)
|
||||
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package org.enso.languageserver.data
|
||||
|
||||
import org.scalatest.flatspec.AnyFlatSpec
|
||||
import org.scalatest.matchers.must.Matchers
|
||||
|
||||
class Sha3224VersionCalculatorSpec extends AnyFlatSpec with Matchers {
|
||||
|
||||
"A Sha3Digest" should "produce SHA3-224 digest" in {
|
||||
Sha3_224VersionCalculator.evalVersion(" ") mustBe "4cb5f87b01b38adc0e6f13f915668c2394cb1fb7a2795635b894dda1"
|
||||
Sha3_224VersionCalculator.evalVersion(
|
||||
"The quick brown fox jumps over the lazy dog"
|
||||
) mustBe "d15dadceaa4d5d7bb3b48f446421d542e08ad8887305e28d58335795"
|
||||
}
|
||||
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package org.enso.languageserver.buffer
|
||||
package org.enso.languageserver.text
|
||||
import org.scalacheck.Arbitrary.arbitrary
|
||||
import org.scalacheck.Gen
|
||||
|
@ -1,4 +1,4 @@
|
||||
package org.enso.languageserver.buffer
|
||||
package org.enso.languageserver.text
|
||||
import org.enso.languageserver.data.buffer.StringUtils
|
||||
|
||||
case class MockBuffer(lines: List[String]) {
|
@ -1,4 +1,4 @@
|
||||
package org.enso.languageserver.buffer
|
||||
package org.enso.languageserver.text
|
||||
import org.enso.languageserver.data.buffer.Rope
|
||||
import org.scalacheck.Prop.forAll
|
||||
import org.scalacheck.Arbitrary._
|
@ -1,4 +1,4 @@
|
||||
package org.enso.languageserver.buffer
|
||||
package org.enso.languageserver.text
|
||||
import org.enso.languageserver.data.buffer.StringUtils
|
||||
import org.scalacheck.Properties
|
||||
import org.scalacheck.Prop.forAll
|
@ -1,174 +0,0 @@
|
||||
package org.enso.languageserver.websocket
|
||||
|
||||
import java.util.UUID
|
||||
import io.circe.literal._
|
||||
|
||||
class CapabilitiesTest extends WebSocketServerTest {
|
||||
"Language Server" must {
|
||||
"be able to grant and release capabilities" in {
|
||||
val probe = new WsTestClient(address)
|
||||
val capabilityId = UUID.randomUUID()
|
||||
probe.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/acquire",
|
||||
"id": 1,
|
||||
"params": {
|
||||
"id": $capabilityId,
|
||||
"method": "canEdit",
|
||||
"registerOptions": { "path": "Foo/bar" }
|
||||
}
|
||||
}
|
||||
""")
|
||||
probe.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
probe.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/release",
|
||||
"id": 2,
|
||||
"params": {
|
||||
"id": $capabilityId
|
||||
}
|
||||
}
|
||||
""")
|
||||
probe.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
}
|
||||
|
||||
"take canEdit capability away from clients when another client registers for it" in {
|
||||
val client1 = new WsTestClient(address)
|
||||
val client2 = new WsTestClient(address)
|
||||
val client3 = new WsTestClient(address)
|
||||
val capability1Id = UUID.randomUUID()
|
||||
val capability2Id = UUID.randomUUID()
|
||||
val capability3Id = UUID.randomUUID()
|
||||
|
||||
client1.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/acquire",
|
||||
"id": 1,
|
||||
"params": {
|
||||
"id": $capability1Id,
|
||||
"method": "canEdit",
|
||||
"registerOptions": { "path": "Foo/bar" }
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
client1.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
client2.expectNoMessage()
|
||||
client3.expectNoMessage()
|
||||
|
||||
client2.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/acquire",
|
||||
"id": 2,
|
||||
"params": {
|
||||
"id": $capability2Id,
|
||||
"method": "canEdit",
|
||||
"registerOptions": { "path": "Foo/bar" }
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
client1.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/forceReleased",
|
||||
"params": {"id": $capability1Id}
|
||||
}
|
||||
""")
|
||||
client2.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
client3.expectNoMessage()
|
||||
|
||||
client3.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/acquire",
|
||||
"id": 3,
|
||||
"params": {
|
||||
"id": $capability3Id,
|
||||
"method": "canEdit",
|
||||
"registerOptions": { "path": "Foo/bar" }
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
client1.expectNoMessage()
|
||||
client2.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/forceReleased",
|
||||
"params": {"id": $capability2Id}
|
||||
}
|
||||
""")
|
||||
client3.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
}
|
||||
|
||||
"implement the canEdit capability on a per-file basis" in {
|
||||
val client1 = new WsTestClient(address)
|
||||
val client2 = new WsTestClient(address)
|
||||
val capability1Id = UUID.randomUUID()
|
||||
val capability2Id = UUID.randomUUID()
|
||||
|
||||
client1.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/acquire",
|
||||
"id": 1,
|
||||
"params": {
|
||||
"id": $capability1Id,
|
||||
"method": "canEdit",
|
||||
"registerOptions": { "path": "Foo/bar" }
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
client1.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
client2.expectNoMessage()
|
||||
|
||||
client2.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/acquire",
|
||||
"id": 2,
|
||||
"params": {
|
||||
"id": $capability2Id,
|
||||
"method": "canEdit",
|
||||
"registerOptions": { "path": "Foo/baz" }
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
client1.expectNoMessage()
|
||||
client2.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,459 @@
|
||||
package org.enso.languageserver.websocket
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
import io.circe.literal._
|
||||
|
||||
class TextOperationsTest extends WebSocketServerTest {
|
||||
|
||||
"text/openFile" must {
|
||||
"fail opening a file if it does not exist" in {
|
||||
// Interaction:
|
||||
// 1. Client tries to open a non-existent file.
|
||||
// 2. Client receives an error message.
|
||||
val client = new WsTestClient(address)
|
||||
|
||||
// 1
|
||||
client.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "text/openFile",
|
||||
"id": 1,
|
||||
"params": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
// 2
|
||||
client.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": { "code": 1003, "message": "File not found" }
|
||||
}
|
||||
""")
|
||||
}
|
||||
|
||||
"allow opening files for editing" in {
|
||||
// Interaction:
|
||||
// 1. Client creates a file.
|
||||
// 2. Client receives confirmation.
|
||||
// 3. Client opens the created file.
|
||||
// 4. Client receives the file contents and a canEdit capability.
|
||||
val client = new WsTestClient(address)
|
||||
|
||||
// 1
|
||||
client.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "file/write",
|
||||
"id": 0,
|
||||
"params": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
},
|
||||
"contents": "123456789"
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
// 2
|
||||
client.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 0,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
|
||||
// 3
|
||||
client.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "text/openFile",
|
||||
"id": 1,
|
||||
"params": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
// 4
|
||||
client.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"writeCapability": {
|
||||
"method": "canEdit",
|
||||
"registerOptions": { "path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": ["foo.txt"]
|
||||
} }
|
||||
},
|
||||
"content": "123456789",
|
||||
"currentVersion": "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522"
|
||||
}
|
||||
}
|
||||
""")
|
||||
}
|
||||
|
||||
"allow opening files for reading for another client" in {
|
||||
// Interaction:
|
||||
// 1. Client 1 creates a file.
|
||||
// 2. Client 1 receives confirmation.
|
||||
// 3. Client 1 opens the created file.
|
||||
// 4. Client 1 receives the file contents and a canEdit capability.
|
||||
// 5. Client 2 opens the file.
|
||||
// 6. Client 2 receives the file contents without a canEdit capability.
|
||||
val client1 = new WsTestClient(address)
|
||||
val client2 = new WsTestClient(address)
|
||||
|
||||
// 1
|
||||
client1.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "file/write",
|
||||
"id": 0,
|
||||
"params": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
},
|
||||
"contents": "123456789"
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
// 2
|
||||
client1.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 0,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
|
||||
// 3
|
||||
client1.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "text/openFile",
|
||||
"id": 1,
|
||||
"params": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
// 4
|
||||
client1.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"writeCapability": {
|
||||
"method": "canEdit",
|
||||
"registerOptions": { "path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": ["foo.txt"]
|
||||
} }
|
||||
},
|
||||
"content": "123456789",
|
||||
"currentVersion": "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522"
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
// 5
|
||||
client2.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "text/openFile",
|
||||
"id": 2,
|
||||
"params": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
// 6
|
||||
client2.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": {
|
||||
"writeCapability": null,
|
||||
"content": "123456789",
|
||||
"currentVersion": "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522"
|
||||
}
|
||||
}
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
"grant the canEdit capability if no one else holds it" in {
|
||||
// Interaction:
|
||||
// 1. Client 1 creates a file.
|
||||
// 2. Client 1 receives confirmation.
|
||||
// 3. Client 1 opens the created file.
|
||||
// 4. Client 1 receives the file contents and a canEdit capability.
|
||||
// 5. Client 1 releases the canEdit capability.
|
||||
// 6. Client 1 receives a confirmation.
|
||||
// 7. Client 2 opens the file.
|
||||
// 8. Client 2 receives the file contents and a canEdit capability.
|
||||
val client1 = new WsTestClient(address)
|
||||
val client2 = new WsTestClient(address)
|
||||
|
||||
// 1
|
||||
client1.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "file/write",
|
||||
"id": 0,
|
||||
"params": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
},
|
||||
"contents": "123456789"
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
// 2
|
||||
client1.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 0,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
|
||||
// 3
|
||||
client1.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "text/openFile",
|
||||
"id": 1,
|
||||
"params": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
// 4
|
||||
client1.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"writeCapability": {
|
||||
"method": "canEdit",
|
||||
"registerOptions": { "path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": ["foo.txt"]
|
||||
} }
|
||||
},
|
||||
"content": "123456789",
|
||||
"currentVersion": "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522"
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
// 5
|
||||
client1.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/release",
|
||||
"id": 2,
|
||||
"params": {
|
||||
"method": "canEdit",
|
||||
"registerOptions": { "path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": ["foo.txt"]
|
||||
} }
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
// 6
|
||||
client1.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
|
||||
// 7
|
||||
client2.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "text/openFile",
|
||||
"id": 3,
|
||||
"params": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
// 8
|
||||
client2.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"result": {
|
||||
"writeCapability": {
|
||||
"method": "canEdit",
|
||||
"registerOptions": { "path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": ["foo.txt"]
|
||||
} }
|
||||
},
|
||||
"content": "123456789",
|
||||
"currentVersion": "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522"
|
||||
}
|
||||
}
|
||||
""")
|
||||
}
|
||||
|
||||
"take canEdit capability away from clients when another client registers for it" in {
|
||||
val client1 = new WsTestClient(address)
|
||||
val client2 = new WsTestClient(address)
|
||||
val client3 = new WsTestClient(address)
|
||||
val capability1Id = UUID.randomUUID()
|
||||
val capability2Id = UUID.randomUUID()
|
||||
val capability3Id = UUID.randomUUID()
|
||||
|
||||
client1.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "file/write",
|
||||
"id": 0,
|
||||
"params": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
},
|
||||
"contents": "123456789"
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
client1.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 0,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
|
||||
client1.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "text/openFile",
|
||||
"id": 1,
|
||||
"params": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
client1.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"writeCapability": {
|
||||
"method": "canEdit",
|
||||
"registerOptions": { "path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": ["foo.txt"]
|
||||
} }
|
||||
},
|
||||
"content": "123456789",
|
||||
"currentVersion": "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522"
|
||||
}
|
||||
}
|
||||
""")
|
||||
client2.expectNoMessage()
|
||||
client3.expectNoMessage()
|
||||
|
||||
client2.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/acquire",
|
||||
"id": 2,
|
||||
"params": {
|
||||
"method": "canEdit",
|
||||
"registerOptions": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
client1.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/forceReleased",
|
||||
"params": {
|
||||
"method": "canEdit",
|
||||
"registerOptions": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
client2.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
client3.expectNoMessage()
|
||||
|
||||
client3.send(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/acquire",
|
||||
"id": 3,
|
||||
"params": {
|
||||
"method": "canEdit",
|
||||
"registerOptions": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
client1.expectNoMessage()
|
||||
client2.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"method": "capability/forceReleased",
|
||||
"params": {
|
||||
"method": "canEdit",
|
||||
"registerOptions": {
|
||||
"path": {
|
||||
"rootId": $testContentRootId,
|
||||
"segments": [ "foo.txt" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
client3.expectJson(json"""
|
||||
{ "jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"result": null
|
||||
}
|
||||
""")
|
||||
}
|
||||
|
||||
}
|
@ -12,14 +12,15 @@ import akka.testkit.{ImplicitSender, TestKit, TestProbe}
|
||||
import cats.effect.IO
|
||||
import io.circe.Json
|
||||
import io.circe.parser.parse
|
||||
import org.enso.languageserver.data.Config
|
||||
|
||||
import org.enso.languageserver.capability.CapabilityRouter
|
||||
import org.enso.languageserver.data.{Config, Sha3_224VersionCalculator}
|
||||
import org.enso.languageserver.{
|
||||
LanguageProtocol,
|
||||
LanguageServer,
|
||||
WebSocketServer
|
||||
}
|
||||
import org.enso.languageserver.filemanager.FileSystem
|
||||
import org.enso.languageserver.text.BufferRegistry
|
||||
import org.scalatest.{Assertion, BeforeAndAfterAll, BeforeAndAfterEach}
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
@ -57,7 +58,16 @@ abstract class WebSocketServerTest
|
||||
Props(new LanguageServer(config, new FileSystem[IO]))
|
||||
)
|
||||
languageServer ! LanguageProtocol.Initialize
|
||||
server = new WebSocketServer(languageServer)
|
||||
val bufferRegistry =
|
||||
system.actorOf(
|
||||
BufferRegistry.props(languageServer)(Sha3_224VersionCalculator)
|
||||
)
|
||||
|
||||
lazy val capabilityRouter =
|
||||
system.actorOf(CapabilityRouter.props(bufferRegistry))
|
||||
|
||||
server =
|
||||
new WebSocketServer(languageServer, bufferRegistry, capabilityRouter)
|
||||
binding = Await.result(server.bind(interface, port = 0), 3.seconds)
|
||||
address = s"ws://$interface:${binding.localAddress.getPort}"
|
||||
}
|
||||
|
@ -10,6 +10,8 @@ import org.enso.languageserver.filemanager.FileSystem
|
||||
import org.enso.languageserver.{
|
||||
LanguageProtocol,
|
||||
LanguageServer,
|
||||
LanguageServerConfig,
|
||||
MainModule,
|
||||
WebSocketServer
|
||||
}
|
||||
|
||||
@ -29,22 +31,16 @@ object LanguageServerApp {
|
||||
*/
|
||||
def run(config: LanguageServerConfig): Unit = {
|
||||
println("Starting Language Server...")
|
||||
implicit val system = ActorSystem()
|
||||
implicit val materializer = SystemMaterializer.get(system)
|
||||
val languageServerConfig = Config(
|
||||
Map(config.contentRootUuid -> new File(config.contentRootPath))
|
||||
)
|
||||
val languageServer =
|
||||
system.actorOf(
|
||||
Props(new LanguageServer(languageServerConfig, new FileSystem[IO]))
|
||||
)
|
||||
val mainModule = new MainModule(config)
|
||||
|
||||
languageServer ! LanguageProtocol.Initialize
|
||||
|
||||
val server = new WebSocketServer(languageServer)
|
||||
mainModule.languageServer ! LanguageProtocol.Initialize
|
||||
|
||||
val binding =
|
||||
Await.result(server.bind(config.interface, config.port), 3.seconds)
|
||||
Await.result(
|
||||
mainModule.server.bind(config.interface, config.port),
|
||||
3.seconds
|
||||
)
|
||||
|
||||
println(
|
||||
s"Started server at ${config.interface}:${config.port}, press enter to kill server"
|
||||
)
|
||||
|
@ -1,10 +0,0 @@
|
||||
package org.enso.runner
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
case class LanguageServerConfig(
|
||||
interface: String,
|
||||
port: Int,
|
||||
contentRootUuid: UUID,
|
||||
contentRootPath: String
|
||||
)
|
@ -5,6 +5,8 @@ import java.util.UUID
|
||||
|
||||
import cats.implicits._
|
||||
import org.apache.commons.cli.{Option => CliOption, _}
|
||||
import org.enso.languageserver
|
||||
import org.enso.languageserver.LanguageServerConfig
|
||||
import org.enso.pkg.Package
|
||||
import org.enso.polyglot.{ExecutionContext, LanguageInfo, Module}
|
||||
import org.graalvm.polyglot.Value
|
||||
@ -244,7 +246,7 @@ object Main {
|
||||
port <- Either
|
||||
.catchNonFatal(portString.toInt)
|
||||
.leftMap(_ => "Port must be integer")
|
||||
} yield LanguageServerConfig(interface, port, rootId, rootPath)
|
||||
} yield languageserver.LanguageServerConfig(interface, port, rootId, rootPath)
|
||||
// format: on
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user