text/openFile method (#575)

This commit is contained in:
Łukasz Olczak 2020-03-06 15:17:46 +01:00 committed by GitHub
parent 9913915fd9
commit e5530045bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1660 additions and 456 deletions

View File

@ -311,10 +311,38 @@ Detailed information on the flags it supports can be obtained by executing `run
project. project.
#### Language Server Mode #### Language Server Mode
Though operating the Enso binary as a language server is functionality planned The Language Server can be run using the `--server` option. It requires also a
for the 2.0 release, it is not currently implemented. For more information on content root to be provided (`--root-id` and `--path` options). Command-line
the planned functionality and its progress, please see the interface of the runner prints all server options when you execute it with
[Issue Tracker](https://github.com/luna/enso/issues). `--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
Pull Requests are the primary method for making changes to Enso. GitHub has Pull Requests are the primary method for making changes to Enso. GitHub has

View File

@ -429,6 +429,7 @@ lazy val language_server = (project in file("engine/language-server"))
"io.circe" %% "circe-literal" % circeVersion, "io.circe" %% "circe-literal" % circeVersion,
"org.typelevel" %% "cats-core" % "2.0.0", "org.typelevel" %% "cats-core" % "2.0.0",
"org.typelevel" %% "cats-effect" % "2.0.0", "org.typelevel" %% "cats-effect" % "2.0.0",
"org.bouncycastle" % "bcpkix-jdk15on" % "1.64",
"commons-io" % "commons-io" % "2.6", "commons-io" % "commons-io" % "2.6",
akkaTestkit % Test, akkaTestkit % Test,
"org.scalatest" %% "scalatest" % "3.2.0-M2" % Test, "org.scalatest" %% "scalatest" % "3.2.0-M2" % Test,
@ -544,6 +545,20 @@ lazy val runner = project
assemblyJarName in assembly := "enso.jar", assemblyJarName in assembly := "enso.jar",
test in assembly := {}, test in assembly := {},
assemblyOutputPath in assembly := file("enso.jar"), 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 assemblyOption in assembly := (assemblyOption in assembly).value
.copy( .copy(
prependShellScript = Some( prependShellScript = Some(

View File

@ -13,8 +13,7 @@ import java.util.UUID
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
import org.enso.FileManager import org.enso.FileManager
import org.scalatest.BeforeAndAfterAll import org.scalatest.{BeforeAndAfterAll, Ignore, Outcome}
import org.scalatest.Outcome
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@ -25,6 +24,7 @@ import scala.reflect.ClassTag
import scala.util.Try import scala.util.Try
// needs to be separate because watcher message are asynchronous // needs to be separate because watcher message are asynchronous
@Ignore
class WatchTests class WatchTests
extends AnyFunSuite extends AnyFunSuite
with BeforeAndAfterAll with BeforeAndAfterAll

View File

@ -927,8 +927,8 @@ A representation of a batch of edits to a file, versioned.
interface FileEdit { interface FileEdit {
path: Path; path: Path;
edits: [TextEdit]; edits: [TextEdit];
oldVersion: UUID; oldVersion: SHA3-224;
newVersion: UUID; newVersion: SHA3-224;
} }
``` ```
@ -995,7 +995,6 @@ client.
} }
interface CapabilityRegistration { interface CapabilityRegistration {
id: UUID; // The registration ID
method: String; method: String;
registerOptions?: any; registerOptions?: any;
} }
@ -1024,7 +1023,7 @@ capability.
```typescript ```typescript
{ {
id: UUID; // The ID used to register the capability registration: CapabilityRegistration;
} }
``` ```
@ -1066,7 +1065,7 @@ capability set.
```typescript ```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; writeCapability?: CapabilityRegistration;
content: String; 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 ```typescript
{ {
path: Path; 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` ##### `StackItemNotFoundError`
```typescript ```typescript
"error" : { "error" : {

View File

@ -1,4 +1,4 @@
package org.enso.languageserver.buffer package org.enso.languageserver.text
import org.enso.languageserver.data.buffer.Rope import org.enso.languageserver.data.buffer.Rope
import org.scalacheck.Gen.Parameters import org.scalacheck.Gen.Parameters
import org.scalameter.{Bench, Gen} import org.scalameter.{Bench, Gen}

View File

@ -1,19 +1,34 @@
package org.enso.languageserver package org.enso.languageserver
import java.util.UUID import akka.actor.{Actor, ActorLogging, ActorRef, Props, Stash}
import akka.actor.{Actor, ActorLogging, ActorRef, Stash}
import akka.pattern.ask import akka.pattern.ask
import akka.util.Timeout import akka.util.Timeout
import org.enso.languageserver.ClientApi._ import org.enso.languageserver.capability.CapabilityApi.{
import org.enso.languageserver.data.{CapabilityRegistration, Client} 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.FileManagerApi._
import org.enso.languageserver.filemanager.FileManagerProtocol.{
CreateFileResult,
WriteFileResult
}
import org.enso.languageserver.filemanager.{ import org.enso.languageserver.filemanager.{
FileManagerProtocol, FileManagerProtocol,
FileSystemFailureMapper FileSystemFailureMapper
} }
import org.enso.languageserver.jsonrpc.Errors.ServiceError import org.enso.languageserver.jsonrpc.Errors.ServiceError
import org.enso.languageserver.jsonrpc._ 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.concurrent.duration._
import scala.util.{Failure, Success} import scala.util.{Failure, Success}
@ -26,45 +41,13 @@ import scala.util.{Failure, Success}
object ClientApi { object ClientApi {
import io.circe.generic.auto._ 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 val protocol: Protocol = Protocol.empty
.registerRequest(AcquireCapability) .registerRequest(AcquireCapability)
.registerRequest(ReleaseCapability) .registerRequest(ReleaseCapability)
.registerRequest(WriteFile) .registerRequest(WriteFile)
.registerRequest(ReadFile) .registerRequest(ReadFile)
.registerRequest(CreateFile) .registerRequest(CreateFile)
.registerRequest(OpenFile)
.registerRequest(DeleteFile) .registerRequest(DeleteFile)
.registerRequest(CopyFile) .registerRequest(CopyFile)
.registerNotification(ForceReleaseCapability) .registerNotification(ForceReleaseCapability)
@ -83,6 +66,8 @@ object ClientApi {
class ClientController( class ClientController(
val clientId: Client.Id, val clientId: Client.Id,
val server: ActorRef, val server: ActorRef,
val bufferRegistry: ActorRef,
val capabilityRouter: ActorRef,
requestTimeout: FiniteDuration = 10.seconds requestTimeout: FiniteDuration = 10.seconds
) extends Actor ) extends Actor
with Stash with Stash
@ -92,8 +77,21 @@ class ClientController(
implicit val timeout = Timeout(requestTimeout) 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 = { override def receive: Receive = {
case ClientApi.WebConnect(webActor) => case ClientApi.WebConnect(webActor) =>
context.system.eventStream
.publish(ClientConnected(Client(clientId, self)))
unstashAll() unstashAll()
context.become(connected(webActor)) context.become(connected(webActor))
case _ => stash() case _ => stash()
@ -101,25 +99,18 @@ class ClientController(
def connected(webActor: ActorRef): Receive = { def connected(webActor: ActorRef): Receive = {
case MessageHandler.Disconnected => case MessageHandler.Disconnected =>
server ! LanguageProtocol.Disconnect(clientId) context.system.eventStream.publish(ClientDisconnected(clientId))
context.stop(self) context.stop(self)
case LanguageProtocol.CapabilityForceReleased(id) => case CapabilityProtocol.CapabilityForceReleased(registration) =>
webActor ! Notification( webActor ! Notification(ForceReleaseCapability, registration)
ForceReleaseCapability,
ReleaseCapabilityParams(id)
)
case LanguageProtocol.CapabilityGranted(registration) => case CapabilityProtocol.CapabilityGranted(registration) =>
webActor ! Notification(GrantCapability, registration) webActor ! Notification(GrantCapability, registration)
case Request(AcquireCapability, id, registration: CapabilityRegistration) => case r @ Request(method, _, _) if (requestHandlers.contains(method)) =>
server ! LanguageProtocol.AcquireCapability(clientId, registration) val handler = context.actorOf(requestHandlers(method))
sender ! ResponseResult(AcquireCapability, id, Unused) handler.forward(r)
case Request(ReleaseCapability, id, params: ReleaseCapabilityParams) =>
server ! LanguageProtocol.ReleaseCapability(clientId, params.id)
sender ! ResponseResult(ReleaseCapability, id, Unused)
case Request(WriteFile, id, params: WriteFile.Params) => case Request(WriteFile, id, params: WriteFile.Params) =>
writeFile(webActor, id, params) writeFile(webActor, id, params)
@ -167,10 +158,10 @@ class ClientController(
): Unit = { ): Unit = {
(server ? FileManagerProtocol.WriteFile(params.path, params.contents)) (server ? FileManagerProtocol.WriteFile(params.path, params.contents))
.onComplete { .onComplete {
case Success(FileManagerProtocol.WriteFileResult(Right(()))) => case Success(WriteFileResult(Right(()))) =>
webActor ! ResponseResult(WriteFile, id, Unused) webActor ! ResponseResult(WriteFile, id, Unused)
case Success(FileManagerProtocol.WriteFileResult(Left(failure))) => case Success(WriteFileResult(Left(failure))) =>
webActor ! ResponseError( webActor ! ResponseError(
Some(id), Some(id),
FileSystemFailureMapper.mapFailure(failure) FileSystemFailureMapper.mapFailure(failure)
@ -189,10 +180,10 @@ class ClientController(
): Unit = { ): Unit = {
(server ? FileManagerProtocol.CreateFile(params.`object`)) (server ? FileManagerProtocol.CreateFile(params.`object`))
.onComplete { .onComplete {
case Success(FileManagerProtocol.CreateFileResult(Right(()))) => case Success(CreateFileResult(Right(()))) =>
webActor ! ResponseResult(CreateFile, id, Unused) webActor ! ResponseResult(CreateFile, id, Unused)
case Success(FileManagerProtocol.CreateFileResult(Left(failure))) => case Success(CreateFileResult(Left(failure))) =>
webActor ! ResponseError( webActor ! ResponseError(
Some(id), Some(id),
FileSystemFailureMapper.mapFailure(failure) FileSystemFailureMapper.mapFailure(failure)

View File

@ -1,8 +1,13 @@
package org.enso.languageserver package org.enso.languageserver
import akka.actor.{Actor, ActorLogging, ActorRef, Stash} import akka.actor.{Actor, ActorLogging, Stash}
import cats.effect.IO import cats.effect.IO
import org.enso.languageserver.data._ import org.enso.languageserver.data._
import org.enso.languageserver.event.{
ClientConnected,
ClientDisconnected,
ClientEvent
}
import org.enso.languageserver.filemanager.FileManagerProtocol._ import org.enso.languageserver.filemanager.FileManagerProtocol._
import org.enso.languageserver.filemanager.{FileSystemApi, FileSystemObject} import org.enso.languageserver.filemanager.{FileSystemApi, FileSystemObject}
@ -11,59 +16,6 @@ object LanguageProtocol {
/** Initializes the Language Server. */ /** Initializes the Language Server. */
case object Initialize 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 { with ActorLogging {
import LanguageProtocol._ import LanguageProtocol._
override def preStart(): Unit = {
context.system.eventStream.subscribe(self, classOf[ClientEvent])
}
override def receive: Receive = { override def receive: Receive = {
case Initialize => case Initialize =>
log.debug("Language Server initialized.") log.debug("Language Server initialized.")
@ -89,38 +45,16 @@ class LanguageServer(config: Config, fs: FileSystemApi[IO])
config: Config, config: Config,
env: Environment = Environment.empty env: Environment = Environment.empty
): Receive = { ): Receive = {
case Connect(clientId, actor) => case ClientConnected(client) =>
log.debug("Client connected [{}].", clientId) log.info("Client connected [{}].", client.id)
context.become( context.become(
initialized(config, env.addClient(Client(clientId, actor))) initialized(config, env.addClient(client))
) )
case Disconnect(clientId) => case ClientDisconnected(clientId) =>
log.debug("Client disconnected [{}].", clientId) log.info("Client disconnected [{}].", clientId)
context.become(initialized(config, env.removeClient(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) => case WriteFile(path, content) =>
val result = val result =
for { for {

View File

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

View File

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

View File

@ -12,9 +12,8 @@ import akka.stream.scaladsl.{Flow, Sink, Source}
import akka.stream.{Materializer, OverflowStrategy} import akka.stream.{Materializer, OverflowStrategy}
import org.enso.languageserver.jsonrpc.MessageHandler import org.enso.languageserver.jsonrpc.MessageHandler
import scala.concurrent.duration.{FiniteDuration, _}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.duration._
object WebSocketServer { object WebSocketServer {
@ -47,6 +46,8 @@ object WebSocketServer {
*/ */
class WebSocketServer( class WebSocketServer(
languageServer: ActorRef, languageServer: ActorRef,
bufferRegistry: ActorRef,
capabilityRouter: ActorRef,
config: WebSocketServer.Config = WebSocketServer.Config.default config: WebSocketServer.Config = WebSocketServer.Config.default
)( )(
implicit val system: ActorSystem, implicit val system: ActorSystem,
@ -60,7 +61,16 @@ class WebSocketServer(
private def newUser(): Flow[Message, Message, NotUsed] = { private def newUser(): Flow[Message, Message, NotUsed] = {
val clientId = UUID.randomUUID() val clientId = UUID.randomUUID()
val clientActor = val clientActor =
system.actorOf(Props(new ClientController(clientId, languageServer))) system.actorOf(
Props(
new ClientController(
clientId,
languageServer,
bufferRegistry,
capabilityRouter
)
)
)
val messageHandler = val messageHandler =
system.actorOf( system.actorOf(
@ -68,8 +78,6 @@ class WebSocketServer(
) )
clientActor ! ClientApi.WebConnect(messageHandler) clientActor ! ClientApi.WebConnect(messageHandler)
languageServer ! LanguageProtocol.Connect(clientId, clientActor)
val incomingMessages: Sink[Message, NotUsed] = val incomingMessages: Sink[Message, NotUsed] =
Flow[Message] Flow[Message]
.mapConcat({ .mapConcat({

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ package org.enso.languageserver.data
import java.util.UUID import java.util.UUID
import io.circe._ import io.circe._
import org.enso.languageserver.filemanager.Path
/** /**
* A superclass for all capabilities in the system. * 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. //TODO[MK]: Migrate to actual Path, once it is implemented.
/** /**
* A capability allowing the user to modify a given file. * 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 { object CanEdit {
val methodName = "canEdit" val methodName = "canEdit"
@ -35,11 +36,9 @@ object Capability {
/** /**
* A capability registration object, used to identify acquired capabilities. * A capability registration object, used to identify acquired capabilities.
* *
* @param id the registration id.
* @param capability the registered capability. * @param capability the registered capability.
*/ */
case class CapabilityRegistration( case class CapabilityRegistration(
id: CapabilityRegistration.Id,
capability: Capability capability: Capability
) )
@ -49,13 +48,11 @@ object CapabilityRegistration {
type Id = UUID type Id = UUID
private val idField = "id"
private val methodField = "method" private val methodField = "method"
private val optionsField = "registerOptions" private val optionsField = "registerOptions"
implicit val encoder: Encoder[CapabilityRegistration] = registration => implicit val encoder: Encoder[CapabilityRegistration] = registration =>
Json.obj( Json.obj(
idField -> registration.id.asJson,
methodField -> registration.capability.method.asJson, methodField -> registration.capability.method.asJson,
optionsField -> registration.capability.asJson optionsField -> registration.capability.asJson
) )
@ -71,12 +68,11 @@ object CapabilityRegistration {
} }
for { for {
id <- json.downField(idField).as[Id]
method <- json.downField(methodField).as[String] method <- json.downField(methodField).as[String]
capability <- resolveOptions( capability <- resolveOptions(
method, method,
json.downField(optionsField).focus.getOrElse(Json.Null) json.downField(optionsField).focus.getOrElse(Json.Null)
) )
} yield CapabilityRegistration(id, capability) } yield CapabilityRegistration(capability)
} }
} }

View File

@ -8,16 +8,14 @@ import akka.actor.ActorRef
* @param id the internal id of this client * @param id the internal id of this client
* @param actor the actor handling remote client communications, used to push * @param actor the actor handling remote client communications, used to push
* requests and notifications. * requests and notifications.
* @param capabilities the capabilities this client has available.
*/ */
case class Client( case class Client(
id: Client.Id, id: Client.Id,
actor: ActorRef, actor: ActorRef
capabilities: List[CapabilityRegistration]
) )
object Client { object Client {
type Id = UUID type Id = UUID
def apply(id: Id, actor: ActorRef): Client = Client(id, actor, List())
} }

View File

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

View File

@ -48,79 +48,6 @@ case class Environment(clients: List[Client]) {
def removeClient(clientId: Client.Id): Environment = def removeClient(clientId: Client.Id): Environment =
copy(clients = clients.filter(_.id != clientId)) 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 { object Environment {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
package org.enso.languageserver.event
/**
* Base trait for all server events.
*/
trait Event

View File

@ -91,4 +91,6 @@ object FileManagerApi {
case object FileExistsError extends Error(1004, "File already exists") case object FileExistsError extends Error(1004, "File already exists")
case object OperationTimeoutError extends Error(1005, "IO operation timeout")
} }

View File

@ -25,6 +25,11 @@ case object FileNotFound extends FileSystemFailure
*/ */
case object FileExists extends FileSystemFailure case object FileExists extends FileSystemFailure
/**
* Signal that the operation timed out.
*/
case object OperationTimeout extends FileSystemFailure
/** /**
* Signals file system specific errors. * Signals file system specific errors.
* *

View File

@ -5,7 +5,8 @@ import org.enso.languageserver.filemanager.FileManagerApi.{
ContentRootNotFoundError, ContentRootNotFoundError,
FileExistsError, FileExistsError,
FileNotFoundError, FileNotFoundError,
FileSystemError FileSystemError,
OperationTimeoutError
} }
import org.enso.languageserver.jsonrpc.Error import org.enso.languageserver.jsonrpc.Error
@ -23,6 +24,7 @@ object FileSystemFailureMapper {
case AccessDenied => AccessDeniedError case AccessDenied => AccessDeniedError
case FileNotFound => FileNotFoundError case FileNotFound => FileNotFoundError
case FileExists => FileExistsError case FileExists => FileExistsError
case OperationTimeout => OperationTimeoutError
case GenericFileSystemFailure(reason) => FileSystemError(reason) case GenericFileSystemFailure(reason) => FileSystemError(reason)
} }

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
package org.enso.languageserver.requesthandler
/**
* Signals that operation has timed out.
*/
case object RequestTimeout

View File

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

View File

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

View File

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

View File

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

View File

@ -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]
)
}

View File

@ -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"
}
}

View File

@ -1,4 +1,4 @@
package org.enso.languageserver.buffer package org.enso.languageserver.text
import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.Arbitrary.arbitrary
import org.scalacheck.Gen import org.scalacheck.Gen

View File

@ -1,4 +1,4 @@
package org.enso.languageserver.buffer package org.enso.languageserver.text
import org.enso.languageserver.data.buffer.StringUtils import org.enso.languageserver.data.buffer.StringUtils
case class MockBuffer(lines: List[String]) { case class MockBuffer(lines: List[String]) {

View File

@ -1,4 +1,4 @@
package org.enso.languageserver.buffer package org.enso.languageserver.text
import org.enso.languageserver.data.buffer.Rope import org.enso.languageserver.data.buffer.Rope
import org.scalacheck.Prop.forAll import org.scalacheck.Prop.forAll
import org.scalacheck.Arbitrary._ import org.scalacheck.Arbitrary._

View File

@ -1,4 +1,4 @@
package org.enso.languageserver.buffer package org.enso.languageserver.text
import org.enso.languageserver.data.buffer.StringUtils import org.enso.languageserver.data.buffer.StringUtils
import org.scalacheck.Properties import org.scalacheck.Properties
import org.scalacheck.Prop.forAll import org.scalacheck.Prop.forAll

View File

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

View File

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

View File

@ -12,14 +12,15 @@ import akka.testkit.{ImplicitSender, TestKit, TestProbe}
import cats.effect.IO import cats.effect.IO
import io.circe.Json import io.circe.Json
import io.circe.parser.parse 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.{ import org.enso.languageserver.{
LanguageProtocol, LanguageProtocol,
LanguageServer, LanguageServer,
WebSocketServer WebSocketServer
} }
import org.enso.languageserver.filemanager.FileSystem import org.enso.languageserver.filemanager.FileSystem
import org.enso.languageserver.text.BufferRegistry
import org.scalatest.{Assertion, BeforeAndAfterAll, BeforeAndAfterEach} import org.scalatest.{Assertion, BeforeAndAfterAll, BeforeAndAfterEach}
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike import org.scalatest.wordspec.AnyWordSpecLike
@ -57,7 +58,16 @@ abstract class WebSocketServerTest
Props(new LanguageServer(config, new FileSystem[IO])) Props(new LanguageServer(config, new FileSystem[IO]))
) )
languageServer ! LanguageProtocol.Initialize 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) binding = Await.result(server.bind(interface, port = 0), 3.seconds)
address = s"ws://$interface:${binding.localAddress.getPort}" address = s"ws://$interface:${binding.localAddress.getPort}"
} }

View File

@ -10,6 +10,8 @@ import org.enso.languageserver.filemanager.FileSystem
import org.enso.languageserver.{ import org.enso.languageserver.{
LanguageProtocol, LanguageProtocol,
LanguageServer, LanguageServer,
LanguageServerConfig,
MainModule,
WebSocketServer WebSocketServer
} }
@ -29,22 +31,16 @@ object LanguageServerApp {
*/ */
def run(config: LanguageServerConfig): Unit = { def run(config: LanguageServerConfig): Unit = {
println("Starting Language Server...") println("Starting Language Server...")
implicit val system = ActorSystem() val mainModule = new MainModule(config)
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]))
)
languageServer ! LanguageProtocol.Initialize mainModule.languageServer ! LanguageProtocol.Initialize
val server = new WebSocketServer(languageServer)
val binding = val binding =
Await.result(server.bind(config.interface, config.port), 3.seconds) Await.result(
mainModule.server.bind(config.interface, config.port),
3.seconds
)
println( println(
s"Started server at ${config.interface}:${config.port}, press enter to kill server" s"Started server at ${config.interface}:${config.port}, press enter to kill server"
) )

View File

@ -1,10 +0,0 @@
package org.enso.runner
import java.util.UUID
case class LanguageServerConfig(
interface: String,
port: Int,
contentRootUuid: UUID,
contentRootPath: String
)

View File

@ -5,6 +5,8 @@ import java.util.UUID
import cats.implicits._ import cats.implicits._
import org.apache.commons.cli.{Option => CliOption, _} import org.apache.commons.cli.{Option => CliOption, _}
import org.enso.languageserver
import org.enso.languageserver.LanguageServerConfig
import org.enso.pkg.Package import org.enso.pkg.Package
import org.enso.polyglot.{ExecutionContext, LanguageInfo, Module} import org.enso.polyglot.{ExecutionContext, LanguageInfo, Module}
import org.graalvm.polyglot.Value import org.graalvm.polyglot.Value
@ -244,7 +246,7 @@ object Main {
port <- Either port <- Either
.catchNonFatal(portString.toInt) .catchNonFatal(portString.toInt)
.leftMap(_ => "Port must be integer") .leftMap(_ => "Port must be integer")
} yield LanguageServerConfig(interface, port, rootId, rootPath) } yield languageserver.LanguageServerConfig(interface, port, rootId, rootPath)
// format: on // format: on
/** /**