'text/save' method (#601)

This commit is contained in:
Łukasz Olczak 2020-03-12 16:27:47 +01:00 committed by GitHub
parent c2df4e7957
commit 7a1b333f2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 677 additions and 42 deletions

View File

@ -1631,7 +1631,14 @@ null
```
##### Errors
TBC
- [`FileNotOpenedError`](#filenotopenederror) to signal that the file isn't
open.
- [`InvalidVersionError`](#invalidversionerror) to signal that the version provided by the client doesn't match the version
computed by the server.
- [`WriteDeniedError`](#writedeniederror) to signal that the client doesn't hold write lock for the buffer.
- [`FileSystemError`](#filesystemerror) to signal a generic, unrecoverable file-system error.
- [`ContentRootNotFoundError`](#contentrootnotfounderror) to signal that the requested content root cannot be found.
- [`AccessDeniedError`](#accessdeniederror) to signal that the user doesn't have access to a resource.
#### `text/applyEdit`
This requests that the server apply a series of edits to the project. These
@ -1659,12 +1666,12 @@ null
```
##### Errors
- [`FileNotOpenedError`](#filenotopenederror) to signal that a file wasn't
opened.
- [`FileNotOpenedError`](#filenotopenederror) to signal that the file isn't
open.
- [`TextEditValidationError`](#texteditvalidationerror) to signal that validation has failed for a series of edits.
- [`InvalidVersionError`](#invalidversionerror) to signal that version provided by a client doesn't match to the version
- [`InvalidVersionError`](#invalidversionerror) to signal that the version provided by the client doesn't match the version
computed by the server.
- [`WriteDeniedError`](#writedeniederror) to signal that the client doesn't hold write lock to the buffer.
- [`WriteDeniedError`](#writedeniederror) to signal that the client doesn't hold write lock for the buffer.
#### `text/didChange`
This is a notification sent from the server to the clients to inform them of any

View File

@ -28,12 +28,14 @@ import org.enso.languageserver.requesthandler.{
ApplyEditHandler,
CloseFileHandler,
OpenFileHandler,
ReleaseCapabilityHandler
ReleaseCapabilityHandler,
SaveFileHandler
}
import org.enso.languageserver.text.TextApi.{
ApplyEdit,
CloseFile,
OpenFile,
SaveFile,
TextDidChange
}
import org.enso.languageserver.text.TextProtocol
@ -57,6 +59,7 @@ object ClientApi {
.registerRequest(CreateFile)
.registerRequest(OpenFile)
.registerRequest(CloseFile)
.registerRequest(SaveFile)
.registerRequest(ApplyEdit)
.registerRequest(DeleteFile)
.registerRequest(CopyFile)
@ -102,7 +105,8 @@ class ClientController(
CloseFile -> CloseFileHandler
.props(bufferRegistry, requestTimeout, client),
ApplyEdit -> ApplyEditHandler
.props(bufferRegistry, requestTimeout, client)
.props(bufferRegistry, requestTimeout, client),
SaveFile -> SaveFileHandler.props(bufferRegistry, requestTimeout, client)
)
override def unhandled(message: Any): Unit =

View File

@ -15,7 +15,7 @@ import org.enso.languageserver.text.TextProtocol
import org.enso.languageserver.text.TextProtocol.{
ApplyEditSuccess,
FileNotOpened,
InvalidVersion,
TextEditInvalidVersion,
TextEditValidationFailed,
WriteDenied
}
@ -61,7 +61,7 @@ class ApplyEditHandler(
replyTo ! ResponseError(Some(id), TextEditValidationError(msg))
context.stop(self)
case InvalidVersion(clientVersion, serverVersion) =>
case TextEditInvalidVersion(clientVersion, serverVersion) =>
replyTo ! ResponseError(
Some(id),
InvalidVersionError(clientVersion, serverVersion)

View File

@ -0,0 +1,108 @@
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._
import org.enso.languageserver.text.TextApi.{
FileNotOpenedError,
InvalidVersionError,
SaveFile,
WriteDeniedError
}
import org.enso.languageserver.text.TextProtocol
import org.enso.languageserver.text.TextProtocol.{
FileNotOpened,
FileSaved,
SaveDenied,
SaveFailed,
SaveFileInvalidVersion
}
import scala.concurrent.duration.FiniteDuration
/**
* A request handler for `text/save` 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 SaveFileHandler(
bufferRegistry: ActorRef,
timeout: FiniteDuration,
client: Client
) extends Actor
with ActorLogging {
import context.dispatcher
override def receive: Receive = requestStage
private def requestStage: Receive = {
case Request(SaveFile, id, params: SaveFile.Params) =>
bufferRegistry ! TextProtocol.SaveFile(
client.id,
params.path,
params.currentVersion
)
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"Saving file for ${client.id} timed out")
replyTo ! ResponseError(Some(id), ServiceError)
context.stop(self)
case FileSaved =>
replyTo ! ResponseResult(SaveFile, id, Unused)
context.stop(self)
case SaveFailed(fsFailure) =>
replyTo ! ResponseError(
Some(id),
FileSystemFailureMapper.mapFailure(fsFailure)
)
context.stop(self)
case SaveDenied =>
replyTo ! ResponseError(Some(id), WriteDeniedError)
context.stop(self)
case SaveFileInvalidVersion(clientVersion, serverVersion) =>
replyTo ! ResponseError(
Some(id),
InvalidVersionError(clientVersion, serverVersion)
)
context.stop(self)
case FileNotOpened =>
replyTo ! ResponseError(Some(id), FileNotOpenedError)
context.stop(self)
}
override def unhandled(message: Any): Unit =
log.warning("Received unknown message: {}", message)
}
object SaveFileHandler {
/**
* Creates a configuration object used to create a [[SaveFileHandler]].
*
* @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 SaveFileHandler(bufferRegistry, requestTimeout, client))
}

View File

@ -14,9 +14,11 @@ import org.enso.languageserver.data.{
}
import org.enso.languageserver.filemanager.Path
import org.enso.languageserver.text.TextProtocol.{
ApplyEdit,
CloseFile,
FileNotOpened,
OpenFile
OpenFile,
SaveFile
}
import org.enso.languageserver.text.editing.model.FileEdit
@ -69,7 +71,14 @@ class BufferRegistry(fileManager: ActorRef)(
sender() ! CapabilityReleaseBadRequest
}
case msg @ TextProtocol.ApplyEdit(_, FileEdit(path, _, _, _)) =>
case msg @ ApplyEdit(_, FileEdit(path, _, _, _)) =>
if (registry.contains(path)) {
registry(path).forward(msg)
} else {
sender() ! FileNotOpened
}
case msg @ SaveFile(_, path, _) =>
if (registry.contains(path)) {
registry(path).forward(msg)
} else {

View File

@ -1,9 +1,9 @@
package org.enso.languageserver.text
import akka.actor.{Actor, ActorLogging, ActorRef, Props, Stash}
import akka.actor.{Actor, ActorLogging, ActorRef, Cancellable, Props, Stash}
import cats.implicits._
import org.enso.languageserver.capability.CapabilityProtocol._
import org.enso.languageserver.data.Client.Id
import org.enso.languageserver.data.buffer.Rope
import org.enso.languageserver.data.{
CanEdit,
CapabilityRegistration,
@ -15,23 +15,20 @@ import org.enso.languageserver.event.{
BufferOpened,
ClientDisconnected
}
import org.enso.languageserver.filemanager.FileManagerProtocol.ReadFileResult
import org.enso.languageserver.filemanager.FileManagerProtocol.{
ReadFileResult,
WriteFileResult
}
import org.enso.languageserver.filemanager.{
FileManagerProtocol,
OperationTimeout,
Path
}
import org.enso.languageserver.text.CollaborativeBuffer.FileReadingTimeout
import org.enso.languageserver.text.Buffer.Version
import org.enso.languageserver.text.CollaborativeBuffer.IOTimeout
import org.enso.languageserver.text.TextProtocol._
import org.enso.languageserver.text.editing.{
EditorOps,
EndPositionBeforeStartPosition,
InvalidPosition,
NegativeCoordinateInPosition,
TextEditValidationFailure
}
import org.enso.languageserver.text.editing.model.{FileEdit, Position, TextEdit}
import cats.implicits._
import org.enso.languageserver.text.editing._
import org.enso.languageserver.text.editing.model.{FileEdit, TextEdit}
import scala.concurrent.duration._
import scala.language.postfixOps
@ -62,6 +59,9 @@ class CollaborativeBuffer(
override def receive: Receive = uninitialized
override def unhandled(message: Any): Unit =
log.warning("Received unknown message: {}", message)
private def uninitialized: Receive = {
case OpenFile(client, path) =>
context.system.eventStream.publish(BufferOpened(path))
@ -71,17 +71,20 @@ class CollaborativeBuffer(
private def waitingForFileContent(
client: Client,
replyTo: ActorRef
replyTo: ActorRef,
timeoutCancellable: Cancellable
): Receive = {
case ReadFileResult(Right(content)) =>
handleFileContent(client, replyTo, content)
unstashAll()
timeoutCancellable.cancel()
case ReadFileResult(Left(failure)) =>
replyTo ! OpenFileResponse(Left(failure))
timeoutCancellable.cancel()
stop()
case FileReadingTimeout =>
case IOTimeout =>
replyTo ! OpenFileResponse(Left(OperationTimeout))
stop()
@ -116,19 +119,86 @@ class CollaborativeBuffer(
}
case ApplyEdit(clientId, change) =>
applyEdits(buffer, lockHolder, clientId, change) match {
case Left(failure) =>
sender() ! failure
edit(buffer, clients, lockHolder, clientId, change)
case Right(modifiedBuffer) =>
sender() ! ApplyEditSuccess
val subscribers = clients.filterNot(_._1 == clientId).values
subscribers foreach { _.actor ! TextDidChange(List(change)) }
context.become(
collaborativeEditing(modifiedBuffer, clients, lockHolder)
)
case SaveFile(clientId, _, clientVersion) =>
saveFile(buffer, clients, lockHolder, clientId, clientVersion)
}
private def saving(
buffer: Buffer,
clients: Map[Client.Id, Client],
lockHolder: Option[Client],
replyTo: ActorRef,
timeoutCancellable: Cancellable
): Receive = {
case IOTimeout =>
replyTo ! SaveFailed(OperationTimeout)
unstashAll()
context.become(collaborativeEditing(buffer, clients, lockHolder))
case WriteFileResult(Left(failure)) =>
replyTo ! SaveFailed(failure)
unstashAll()
timeoutCancellable.cancel()
context.become(collaborativeEditing(buffer, clients, lockHolder))
case WriteFileResult(Right(())) =>
replyTo ! FileSaved
unstashAll()
timeoutCancellable.cancel()
context.become(collaborativeEditing(buffer, clients, lockHolder))
case _ => stash()
}
private def saveFile(
buffer: Buffer,
clients: Map[Id, Client],
lockHolder: Option[Client],
clientId: Id,
clientVersion: Version
): Unit = {
val hasLock = lockHolder.exists(_.id == clientId)
if (hasLock) {
if (clientVersion == buffer.version) {
fileManager ! FileManagerProtocol.WriteFile(
bufferPath,
buffer.contents.toString
)
val timeoutCancellable = context.system.scheduler
.scheduleOnce(timeout, self, IOTimeout)
context.become(
saving(buffer, clients, lockHolder, sender(), timeoutCancellable)
)
} else {
sender() ! SaveFileInvalidVersion(clientVersion, buffer.version)
}
} else {
sender() ! SaveDenied
}
}
private def edit(
buffer: Buffer,
clients: Map[Id, Client],
lockHolder: Option[Client],
clientId: Id,
change: FileEdit
): Unit = {
applyEdits(buffer, lockHolder, clientId, change) match {
case Left(failure) =>
sender() ! failure
case Right(modifiedBuffer) =>
sender() ! ApplyEditSuccess
val subscribers = clients.filterNot(_._1 == clientId).values
subscribers foreach { _.actor ! TextDidChange(List(change)) }
context.become(
collaborativeEditing(modifiedBuffer, clients, lockHolder)
)
}
}
private def applyEdits(
@ -151,7 +221,7 @@ class CollaborativeBuffer(
if (clientVersion == serverVersion) {
Right(())
} else {
Left(InvalidVersion(clientVersion, serverVersion))
Left(TextEditInvalidVersion(clientVersion, serverVersion))
}
}
@ -188,9 +258,9 @@ class CollaborativeBuffer(
private def readFile(client: Client, path: Path): Unit = {
fileManager ! FileManagerProtocol.ReadFile(path)
context.system.scheduler
.scheduleOnce(timeout, self, FileReadingTimeout)
context.become(waitingForFileContent(client, sender()))
val timeoutCancellable = context.system.scheduler
.scheduleOnce(timeout, self, IOTimeout)
context.become(waitingForFileContent(client, sender(), timeoutCancellable))
}
private def handleFileContent(
@ -299,7 +369,7 @@ class CollaborativeBuffer(
object CollaborativeBuffer {
case object FileReadingTimeout
case object IOTimeout
/**
* Creates a configuration object used to create a [[CollaborativeBuffer]]

View File

@ -72,4 +72,14 @@ object TextApi {
)
case object WriteDeniedError extends Error(3004, "Write denied")
case object SaveFile extends Method("text/save") {
case class Params(path: Path, currentVersion: Buffer.Version)
implicit val hasParams = new HasParams[this.type] {
type Params = SaveFile.Params
}
implicit val hasResult = new HasResult[this.type] {
type Result = Unused.type
}
}
}

View File

@ -94,7 +94,7 @@ object TextProtocol {
* @param clientVersion a version send by the client
* @param serverVersion a version computed by the server
*/
case class InvalidVersion(
case class TextEditInvalidVersion(
clientVersion: Buffer.Version,
serverVersion: Buffer.Version
) extends ApplyEditFailure
@ -107,4 +107,50 @@ object TextProtocol {
*/
case class TextDidChange(changes: List[FileEdit])
/** Requests the language server to save a file on behalf of a given user.
*
* @param clientId the client closing the file.
* @param path the file path.
* @param currentVersion the current version evaluated on the client side.
*/
case class SaveFile(
clientId: Client.Id,
path: Path,
currentVersion: Buffer.Version
)
/**
* Signals the result of saving a file.
*/
sealed trait SaveFileResult
/**
* Signals that saving a file was executed successfully.
*/
case object FileSaved extends SaveFileResult
/**
* Signals that the client doesn't hold write lock to the buffer.
*/
case object SaveDenied extends SaveFileResult
/**
* Signals that version provided by a client doesn't match to the version
* computed by the server.
*
* @param clientVersion a version send by the client
* @param serverVersion a version computed by the server
*/
case class SaveFileInvalidVersion(
clientVersion: Buffer.Version,
serverVersion: Buffer.Version
) extends SaveFileResult
/**
* Signals that saving a file failed due to IO error.
*
* @param fsFailure a filesystem failure
*/
case class SaveFailed(fsFailure: FileSystemFailure) extends SaveFileResult
}

View File

@ -1485,4 +1485,385 @@ class TextOperationsTest extends WebSocketServerTest {
}
"text/save" must {
"fail when a client didn't open it" in {
val client = new WsTestClient(address)
client.send(json"""
{ "jsonrpc": "2.0",
"method": "file/write",
"id": 0,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
},
"contents": "123456789"
}
}
""")
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 0,
"result": null
}
""")
client.send(json"""
{ "jsonrpc": "2.0",
"method": "text/applyEdit",
"id": 2,
"params": {
"edit": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
},
"oldVersion": "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522",
"newVersion": "ebe55342f9c8b86857402797dd723fb4a2174e0b56d6ace0a6929ec3",
"edits": [
{
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 0 }
},
"text": "bar"
},
{
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 12 }
},
"text": "foo"
}
]
}
}
}
""")
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 2,
"error": { "code": 3001, "message": "File not opened" }
}
""")
}
"fail when a client's version doesn't match a server version" in {
val client = new WsTestClient(address)
client.send(json"""
{ "jsonrpc": "2.0",
"method": "file/write",
"id": 0,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
},
"contents": "123456789"
}
}
""")
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 0,
"result": null
}
""")
client.send(json"""
{ "jsonrpc": "2.0",
"method": "text/openFile",
"id": 1,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
}
}
}
""")
client.expectJson(json"""
{
"jsonrpc" : "2.0",
"id" : 1,
"result" : {
"writeCapability" : {
"method" : "canEdit",
"registerOptions" : {
"path" : {
"rootId" : $testContentRootId,
"segments" : [
"foo.txt"
]
}
}
},
"content" : "123456789",
"currentVersion" : "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522"
}
}
""")
client.send(json"""
{ "jsonrpc": "2.0",
"method": "text/save",
"id": 3,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
},
"currentVersion": "ebe55342f9c8b86857402797dd723fb4a2174e0b56d6ace0a6929ec3"
}
}
""")
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 3,
"error": {
"code": 3003,
"message": "Invalid version [client version: ebe55342f9c8b86857402797dd723fb4a2174e0b56d6ace0a6929ec3, server version: 5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522]"
}
}
""")
}
"fail when a client doesn't hold a write lock" in {
val client1 = new WsTestClient(address)
val client2 = new WsTestClient(address)
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.send(json"""
{ "jsonrpc": "2.0",
"method": "text/openFile",
"id": 1,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
}
}
}
""")
client2.expectJson(json"""
{
"jsonrpc" : "2.0",
"id" : 1,
"result" : {
"writeCapability" : null,
"content" : "123456789",
"currentVersion" : "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522"
}
}
""")
client2.send(json"""
{ "jsonrpc": "2.0",
"method": "text/save",
"id": 3,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
},
"currentVersion": "ebe55342f9c8b86857402797dd723fb4a2174e0b56d6ace0a6929ec3"
}
}
""")
client2.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 3,
"error": {
"code": 3004,
"message": "Write denied"
}
}
""")
}
"persist changes from a buffer to durable storage" in {
val client = new WsTestClient(address)
client.send(json"""
{ "jsonrpc": "2.0",
"method": "file/write",
"id": 0,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
},
"contents": "123456789"
}
}
""")
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 0,
"result": null
}
""")
client.send(json"""
{ "jsonrpc": "2.0",
"method": "text/openFile",
"id": 1,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
}
}
}
""")
client.expectJson(json"""
{
"jsonrpc" : "2.0",
"id" : 1,
"result" : {
"writeCapability" : {
"method" : "canEdit",
"registerOptions" : {
"path" : {
"rootId" : $testContentRootId,
"segments" : [
"foo.txt"
]
}
}
},
"content" : "123456789",
"currentVersion" : "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522"
}
}
""")
client.send(json"""
{ "jsonrpc": "2.0",
"method": "text/applyEdit",
"id": 2,
"params": {
"edit": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
},
"oldVersion": "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522",
"newVersion": "ebe55342f9c8b86857402797dd723fb4a2174e0b56d6ace0a6929ec3",
"edits": [
{
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 0 }
},
"text": "bar"
},
{
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 12 }
},
"text": "foo"
}
]
}
}
}
""")
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 2,
"result": null
}
""")
client.send(json"""
{ "jsonrpc": "2.0",
"method": "text/save",
"id": 3,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
},
"currentVersion": "ebe55342f9c8b86857402797dd723fb4a2174e0b56d6ace0a6929ec3"
}
}
""")
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 3,
"result": null
}
""")
client.send(json"""
{ "jsonrpc": "2.0",
"method": "file/read",
"id": 4,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
}
}
}
""")
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 4,
"result": { "contents": "bar123456789foo" }
}
""")
}
}
}