diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a477d5f3c..dbd5d023f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 `: Content root id. +- `--path `: Path to the content root. +- `--interface `: Interface for processing all incoming connections. +Default value is 127.0.0.1 +- `--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 diff --git a/build.sbt b/build.sbt index 25969c8100..0e68b58902 100644 --- a/build.sbt +++ b/build.sbt @@ -424,15 +424,16 @@ lazy val polyglot_api = project lazy val language_server = (project in file("engine/language-server")) .settings( libraryDependencies ++= akka ++ circe ++ Seq( - "ch.qos.logback" % "logback-classic" % "1.2.3", - "io.circe" %% "circe-generic-extras" % "0.12.2", - "io.circe" %% "circe-literal" % circeVersion, - "org.typelevel" %% "cats-core" % "2.0.0", - "org.typelevel" %% "cats-effect" % "2.0.0", - "commons-io" % "commons-io" % "2.6", - akkaTestkit % Test, - "org.scalatest" %% "scalatest" % "3.2.0-M2" % Test, - "org.scalacheck" %% "scalacheck" % "1.14.0" % Test + "ch.qos.logback" % "logback-classic" % "1.2.3", + "io.circe" %% "circe-generic-extras" % "0.12.2", + "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, + "org.scalacheck" %% "scalacheck" % "1.14.0" % Test ), testOptions in Test += Tests .Argument(TestFrameworks.ScalaCheck, "-minSuccessfulTests", "1000") @@ -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( diff --git a/common/file-manager/src/test/scala/org/enso/filemanager/WatchTests.scala b/common/file-manager/src/test/scala/org/enso/filemanager/WatchTests.scala index dfcd4c053e..e0167803b4 100644 --- a/common/file-manager/src/test/scala/org/enso/filemanager/WatchTests.scala +++ b/common/file-manager/src/test/scala/org/enso/filemanager/WatchTests.scala @@ -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 diff --git a/doc/design/engine/engine-services.md b/doc/design/engine/engine-services.md index 4a9ec6e8c0..81de7bd49b 100644 --- a/doc/design/engine/engine-services.md +++ b/doc/design/engine/engine-services.md @@ -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" : { diff --git a/engine/language-server/src/bench/scala/org/enso/languageserver/buffer/RopeBench.scala b/engine/language-server/src/bench/scala/org/enso/languageserver/text/RopeBench.scala similarity index 97% rename from engine/language-server/src/bench/scala/org/enso/languageserver/buffer/RopeBench.scala rename to engine/language-server/src/bench/scala/org/enso/languageserver/text/RopeBench.scala index 537905e949..a7ff4a628c 100644 --- a/engine/language-server/src/bench/scala/org/enso/languageserver/buffer/RopeBench.scala +++ b/engine/language-server/src/bench/scala/org/enso/languageserver/text/RopeBench.scala @@ -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} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/ClientController.scala b/engine/language-server/src/main/scala/org/enso/languageserver/ClientController.scala index 971d6519e9..8e8f810c5b 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/ClientController.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/ClientController.scala @@ -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) diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/LanguageServer.scala b/engine/language-server/src/main/scala/org/enso/languageserver/LanguageServer.scala index 75d528073e..c470a9e499 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/LanguageServer.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/LanguageServer.scala @@ -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 { diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/LanguageServerConfig.scala b/engine/language-server/src/main/scala/org/enso/languageserver/LanguageServerConfig.scala new file mode 100644 index 0000000000..e52f9b42f1 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/LanguageServerConfig.scala @@ -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 +) diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/MainModule.scala b/engine/language-server/src/main/scala/org/enso/languageserver/MainModule.scala new file mode 100644 index 0000000000..5e0424dcee --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/MainModule.scala @@ -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) + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/WebSocketServer.scala b/engine/language-server/src/main/scala/org/enso/languageserver/WebSocketServer.scala index a73bf8eef0..6900ffae8a 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/WebSocketServer.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/WebSocketServer.scala @@ -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({ diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/capability/CapabilityApi.scala b/engine/language-server/src/main/scala/org/enso/languageserver/capability/CapabilityApi.scala new file mode 100644 index 0000000000..cf98bf2fc8 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/capability/CapabilityApi.scala @@ -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 + } + } + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/capability/CapabilityProtocol.scala b/engine/language-server/src/main/scala/org/enso/languageserver/capability/CapabilityProtocol.scala new file mode 100644 index 0000000000..ff205a8ec9 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/capability/CapabilityProtocol.scala @@ -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) + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/capability/CapabilityRouter.scala b/engine/language-server/src/main/scala/org/enso/languageserver/capability/CapabilityRouter.scala new file mode 100644 index 0000000000..74b0e1d372 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/capability/CapabilityRouter.scala @@ -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)) + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/data/Capability.scala b/engine/language-server/src/main/scala/org/enso/languageserver/data/Capability.scala index 83539b9a68..6a963e2dd8 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/data/Capability.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/data/Capability.scala @@ -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) } } diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/data/Client.scala b/engine/language-server/src/main/scala/org/enso/languageserver/data/Client.scala index 6a07eff3ff..29afd94395 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/data/Client.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/data/Client.scala @@ -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()) } diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/data/ContentBasedVersioning.scala b/engine/language-server/src/main/scala/org/enso/languageserver/data/ContentBasedVersioning.scala new file mode 100644 index 0000000000..7fc653abac --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/data/ContentBasedVersioning.scala @@ -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 + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/data/Environment.scala b/engine/language-server/src/main/scala/org/enso/languageserver/data/Environment.scala index 39249fa744..a5415ed386 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/data/Environment.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/data/Environment.scala @@ -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 { diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/data/Sha3_224VersionCalculator.scala b/engine/language-server/src/main/scala/org/enso/languageserver/data/Sha3_224VersionCalculator.scala new file mode 100644 index 0000000000..194bb23dc5 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/data/Sha3_224VersionCalculator.scala @@ -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) + } + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/event/BufferEvent.scala b/engine/language-server/src/main/scala/org/enso/languageserver/event/BufferEvent.scala new file mode 100644 index 0000000000..8dd04e0297 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/event/BufferEvent.scala @@ -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 diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/event/ClientEvent.scala b/engine/language-server/src/main/scala/org/enso/languageserver/event/ClientEvent.scala new file mode 100644 index 0000000000..f202124b08 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/event/ClientEvent.scala @@ -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 diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/event/Event.scala b/engine/language-server/src/main/scala/org/enso/languageserver/event/Event.scala new file mode 100644 index 0000000000..f3752b50fe --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/event/Event.scala @@ -0,0 +1,6 @@ +package org.enso.languageserver.event + +/** + * Base trait for all server events. + */ +trait Event diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/filemanager/FileManagerApi.scala b/engine/language-server/src/main/scala/org/enso/languageserver/filemanager/FileManagerApi.scala index 379181511d..ce273eedd0 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/filemanager/FileManagerApi.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/filemanager/FileManagerApi.scala @@ -91,4 +91,6 @@ object FileManagerApi { case object FileExistsError extends Error(1004, "File already exists") + case object OperationTimeoutError extends Error(1005, "IO operation timeout") + } diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/filemanager/FileSystemFailure.scala b/engine/language-server/src/main/scala/org/enso/languageserver/filemanager/FileSystemFailure.scala index aa434637ef..b11fe49607 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/filemanager/FileSystemFailure.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/filemanager/FileSystemFailure.scala @@ -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. * diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/filemanager/FileSystemFailureMapper.scala b/engine/language-server/src/main/scala/org/enso/languageserver/filemanager/FileSystemFailureMapper.scala index a52a70f056..7f2f01db3f 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/filemanager/FileSystemFailureMapper.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/filemanager/FileSystemFailureMapper.scala @@ -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) } diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/AcquireCapabilityHandler.scala b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/AcquireCapabilityHandler.scala new file mode 100644 index 0000000000..3dfe282bd5 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/AcquireCapabilityHandler.scala @@ -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) + ) + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/OpenFileHandler.scala b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/OpenFileHandler.scala new file mode 100644 index 0000000000..9b5f109f72 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/OpenFileHandler.scala @@ -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)) + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/ReleaseCapabilityHandler.scala b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/ReleaseCapabilityHandler.scala new file mode 100644 index 0000000000..5c0e6a0ef8 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/ReleaseCapabilityHandler.scala @@ -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) + ) + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/RequestTimeout.scala b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/RequestTimeout.scala new file mode 100644 index 0000000000..39cd9f4e7c --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/RequestTimeout.scala @@ -0,0 +1,6 @@ +package org.enso.languageserver.requesthandler + +/** + * Signals that operation has timed out. + */ +case object RequestTimeout diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/text/Buffer.scala b/engine/language-server/src/main/scala/org/enso/languageserver/text/Buffer.scala new file mode 100644 index 0000000000..d8799993c7 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/text/Buffer.scala @@ -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)) +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/text/BufferRegistry.scala b/engine/language-server/src/main/scala/org/enso/languageserver/text/BufferRegistry.scala new file mode 100644 index 0000000000..6ebb7a0b4d --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/text/BufferRegistry.scala @@ -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)) + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/text/CollaborativeBuffer.scala b/engine/language-server/src/main/scala/org/enso/languageserver/text/CollaborativeBuffer.scala new file mode 100644 index 0000000000..2ba1799a3d --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/text/CollaborativeBuffer.scala @@ -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)) + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/text/TextApi.scala b/engine/language-server/src/main/scala/org/enso/languageserver/text/TextApi.scala new file mode 100644 index 0000000000..30eb1e6d8b --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/text/TextApi.scala @@ -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 + } + } + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/text/TextProtocol.scala b/engine/language-server/src/main/scala/org/enso/languageserver/text/TextProtocol.scala new file mode 100644 index 0000000000..d662176347 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/text/TextProtocol.scala @@ -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] + ) + +} diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/data/Sha3224VersionCalculatorSpec.scala b/engine/language-server/src/test/scala/org/enso/languageserver/data/Sha3224VersionCalculatorSpec.scala new file mode 100644 index 0000000000..2154ba047d --- /dev/null +++ b/engine/language-server/src/test/scala/org/enso/languageserver/data/Sha3224VersionCalculatorSpec.scala @@ -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" + } + +} diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/buffer/Generators.scala b/engine/language-server/src/test/scala/org/enso/languageserver/text/Generators.scala similarity index 95% rename from engine/language-server/src/test/scala/org/enso/languageserver/buffer/Generators.scala rename to engine/language-server/src/test/scala/org/enso/languageserver/text/Generators.scala index fb07eeda07..14fc2cb362 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/buffer/Generators.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/text/Generators.scala @@ -1,4 +1,4 @@ -package org.enso.languageserver.buffer +package org.enso.languageserver.text import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.Gen diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/buffer/MockBuffer.scala b/engine/language-server/src/test/scala/org/enso/languageserver/text/MockBuffer.scala similarity index 91% rename from engine/language-server/src/test/scala/org/enso/languageserver/buffer/MockBuffer.scala rename to engine/language-server/src/test/scala/org/enso/languageserver/text/MockBuffer.scala index 35d13c1ad9..ea9fb55b22 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/buffer/MockBuffer.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/text/MockBuffer.scala @@ -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]) { diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/buffer/RopeSpecification.scala b/engine/language-server/src/test/scala/org/enso/languageserver/text/RopeSpecification.scala similarity index 99% rename from engine/language-server/src/test/scala/org/enso/languageserver/buffer/RopeSpecification.scala rename to engine/language-server/src/test/scala/org/enso/languageserver/text/RopeSpecification.scala index f669b0c606..2276b43dd0 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/buffer/RopeSpecification.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/text/RopeSpecification.scala @@ -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._ diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/buffer/StringUtilsSpecification.scala b/engine/language-server/src/test/scala/org/enso/languageserver/text/StringUtilsSpecification.scala similarity index 96% rename from engine/language-server/src/test/scala/org/enso/languageserver/buffer/StringUtilsSpecification.scala rename to engine/language-server/src/test/scala/org/enso/languageserver/text/StringUtilsSpecification.scala index 7cecfa2ee0..e376751d74 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/buffer/StringUtilsSpecification.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/text/StringUtilsSpecification.scala @@ -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 diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/CapabilitiesTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/CapabilitiesTest.scala deleted file mode 100644 index 8ed622249b..0000000000 --- a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/CapabilitiesTest.scala +++ /dev/null @@ -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 - } - """) - } - } -} diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/TextOperationsTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/TextOperationsTest.scala new file mode 100644 index 0000000000..4aa5b01814 --- /dev/null +++ b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/TextOperationsTest.scala @@ -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 + } + """) + } + +} diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/WebSocketServerTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/WebSocketServerTest.scala index 8861aea69e..423f1b2c67 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/WebSocketServerTest.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/WebSocketServerTest.scala @@ -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}" } diff --git a/engine/runner/src/main/scala/org/enso/runner/LanguageServerApp.scala b/engine/runner/src/main/scala/org/enso/runner/LanguageServerApp.scala index 3e4500cb78..b159dc11d1 100644 --- a/engine/runner/src/main/scala/org/enso/runner/LanguageServerApp.scala +++ b/engine/runner/src/main/scala/org/enso/runner/LanguageServerApp.scala @@ -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" ) diff --git a/engine/runner/src/main/scala/org/enso/runner/LanguageServerConfig.scala b/engine/runner/src/main/scala/org/enso/runner/LanguageServerConfig.scala deleted file mode 100644 index fe09a04973..0000000000 --- a/engine/runner/src/main/scala/org/enso/runner/LanguageServerConfig.scala +++ /dev/null @@ -1,10 +0,0 @@ -package org.enso.runner - -import java.util.UUID - -case class LanguageServerConfig( - interface: String, - port: Int, - contentRootUuid: UUID, - contentRootPath: String -) diff --git a/engine/runner/src/main/scala/org/enso/runner/Main.scala b/engine/runner/src/main/scala/org/enso/runner/Main.scala index c917e08670..d5732a5328 100644 --- a/engine/runner/src/main/scala/org/enso/runner/Main.scala +++ b/engine/runner/src/main/scala/org/enso/runner/Main.scala @@ -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 /**