File write feature. (#557)

Provides writing to a file capability. It writes a textual content to an arbitrary file.
This commit is contained in:
Łukasz Olczak 2020-02-25 14:38:48 +01:00 committed by GitHub
parent 083fa0e4a5
commit 016602972f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 421 additions and 14 deletions

View File

@ -427,6 +427,9 @@ lazy val language_server = (project in file("engine/language-server"))
"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
@ -530,14 +533,15 @@ lazy val runner = project
assemblyJarName in assembly := "enso.jar",
test in assembly := {},
assemblyOutputPath in assembly := file("enso.jar"),
assemblyOption in assembly := (assemblyOption in assembly).value.copy(
prependShellScript = Some(
defaultUniversalScript(
shebang = false,
javaOpts = truffleRunOptions
assemblyOption in assembly := (assemblyOption in assembly).value
.copy(
prependShellScript = Some(
defaultUniversalScript(
shebang = false,
javaOpts = truffleRunOptions
)
)
)
),
),
inConfig(Compile)(truffleRunOptionsSettings),
libraryDependencies ++= Seq(
"org.graalvm.sdk" % "polyglot-tck" % graalVersion % "provided",

View File

@ -1110,7 +1110,10 @@ null
```
##### Errors
TBC
- **FileSystemError(errorCode=1000)** This error signals generic file system errors.
- **ContentRootNotFoundError(errorCode=1001)** The error informs that the requested content root cannot be found.
- **AccessDeniedError(errorCode=1002)** It signals that a user doesn't have access to a resource.
#### `file/read`
This requests that the file manager component reads the contents of a specified

View File

@ -3,10 +3,27 @@ package org.enso.languageserver
import java.util.UUID
import akka.actor.{Actor, ActorLogging, ActorRef, 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.filemanager.FileManagerApi.{
FileSystemError,
FileWrite,
FileWriteParams
}
import org.enso.languageserver.filemanager.FileManagerProtocol.FileWriteResult
import org.enso.languageserver.filemanager.{
FileManagerProtocol,
FileSystemFailure,
FileSystemFailureMapper
}
import org.enso.languageserver.jsonrpc.Errors.ServiceError
import org.enso.languageserver.jsonrpc._
import scala.concurrent.duration._
import scala.util.{Failure, Success}
/**
* The JSON RPC API provided by the language server.
* See [[https://github.com/luna/enso/blob/master/doc/design/engine/engine-services.md]]
@ -51,6 +68,7 @@ object ClientApi {
val protocol: Protocol = Protocol.empty
.registerRequest(AcquireCapability)
.registerRequest(ReleaseCapability)
.registerRequest(FileWrite)
.registerNotification(ForceReleaseCapability)
.registerNotification(GrantCapability)
@ -64,11 +82,18 @@ object ClientApi {
* @param clientId the internal client id.
* @param server the language server actor.
*/
class ClientController(val clientId: Client.Id, val server: ActorRef)
extends Actor
class ClientController(
val clientId: Client.Id,
val server: ActorRef,
requestTimeout: FiniteDuration = 10.seconds
) extends Actor
with Stash
with ActorLogging {
import context.dispatcher
implicit val timeout = Timeout(requestTimeout)
override def receive: Receive = {
case ClientApi.WebConnect(webActor) =>
unstashAll()
@ -97,5 +122,23 @@ class ClientController(val clientId: Client.Id, val server: ActorRef)
case Request(ReleaseCapability, id, params: ReleaseCapabilityParams) =>
server ! LanguageProtocol.ReleaseCapability(clientId, params.id)
sender ! ResponseResult(ReleaseCapability, id, Unused)
case Request(FileWrite, id, params: FileWriteParams) =>
(server ? FileManagerProtocol.FileWrite(params.path, params.content))
.onComplete {
case Success(FileWriteResult(Right(()))) =>
webActor ! ResponseResult(FileWrite, id, Unused)
case Success(FileWriteResult(Left(failure))) =>
webActor ! ResponseError(
Some(id),
FileSystemFailureMapper.mapFailure(failure)
)
case Failure(th) =>
log.error("An exception occurred during writing to a file", th)
webActor ! ResponseError(Some(id), ServiceError)
}
}
}

View File

@ -1,6 +1,15 @@
package org.enso.languageserver
import java.io.File
import akka.actor.{Actor, ActorLogging, ActorRef, Stash}
import cats.effect.IO
import org.enso.languageserver.data._
import org.enso.languageserver.filemanager.FileManagerProtocol.{
FileWrite,
FileWriteResult
}
import org.enso.languageserver.filemanager.{FileSystemApi, FileSystemFailure}
object LanguageProtocol {
@ -67,7 +76,7 @@ object LanguageProtocol {
*
* @param config the configuration used by this Language Server.
*/
class LanguageServer(config: Config)
class LanguageServer(config: Config, fs: FileSystemApi[IO])
extends Actor
with Stash
with ActorLogging {
@ -116,5 +125,14 @@ class LanguageServer(config: Config)
context.become(
initialized(config, env.releaseCapability(clientId, capabilityId))
)
case FileWrite(path, content) =>
val result =
for {
rootPath <- config.findContentRoot(path.rootId)
_ <- fs.write(path.toFile(rootPath), content).unsafeRunSync()
} yield ()
sender ! FileWriteResult(result)
}
}

View File

@ -1,11 +1,27 @@
package org.enso.languageserver.data
import java.io.File
import java.util.UUID
import org.enso.languageserver.filemanager.{
ContentRootNotFound,
FileSystemFailure
}
/**
* The config of the running Language Server instance.
*
* Currently empty, to be filled in with content roots etc.
* @param contentRoots a mapping between content root id and absolute path to
* the content root
*/
case class Config()
case class Config(contentRoots: Map[UUID, File] = Map.empty) {
def findContentRoot(rootId: UUID): Either[FileSystemFailure, File] =
contentRoots
.get(rootId)
.toRight(ContentRootNotFound)
}
/**
* The state of the running Language Server instance.

View File

@ -0,0 +1,37 @@
package org.enso.languageserver.filemanager
import org.enso.languageserver.jsonrpc.{
Error,
HasParams,
HasResult,
Method,
Unused
}
/**
* The file manager 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 FileManagerApi {
case object FileWrite extends Method("file/write") {
implicit val hasParams = new HasParams[this.type] {
type Params = FileWriteParams
}
implicit val hasResult = new HasResult[this.type] {
type Result = Unused.type
}
}
case class FileWriteParams(path: Path, content: String)
case class FileSystemError(override val message: String)
extends Error(1000, message)
case object ContentRootNotFoundError
extends Error(1001, "Content root not found")
case object AccessDeniedError extends Error(1002, "Access denied")
}

View File

@ -0,0 +1,20 @@
package org.enso.languageserver.filemanager
object FileManagerProtocol {
/**
* Requests the Language Server write textual content to an arbitrary file.
*
* @param path a path to a file
* @param content a textual content
*/
case class FileWrite(path: Path, content: String)
/**
* Signals file manipulation status.
*
* @param result either file system failure or unit representing success
*/
case class FileWriteResult(result: Either[FileSystemFailure, Unit])
}

View File

@ -0,0 +1,44 @@
package org.enso.languageserver.filemanager
import java.io.{File, IOException}
import java.nio.file._
import cats.effect.Sync
import cats.implicits._
import org.apache.commons.io.FileUtils
/**
* File manipulation facility.
*
* @tparam F represents target monad
*/
class FileSystem[F[_]: Sync] extends FileSystemApi[F] {
/**
* Writes textual content to a file.
*
* @param file path to the file
* @param content a textual content of the file
* @return either FileSystemFailure or Unit
*/
override def write(
file: File,
content: String
): F[Either[FileSystemFailure, Unit]] =
Sync[F].delay { writeStringToFile(file, content) }
private def writeStringToFile(
file: File,
content: String
): Either[FileSystemFailure, Unit] =
Either
.catchOnly[IOException](
FileUtils.write(file, content, "UTF-8")
)
.leftMap {
case _: AccessDeniedException => AccessDenied
case ex => GenericFileSystemFailure(ex.getMessage)
}
.map(_ => ())
}

View File

@ -0,0 +1,24 @@
package org.enso.languageserver.filemanager
import java.io.File
/**
* File manipulation API.
*
* @tparam F represents target monad
*/
trait FileSystemApi[F[_]] {
/**
* Writes textual content to a file.
*
* @param file path to the file
* @param content a textual content of the file
* @return either FileSystemFailure or Unit
*/
def write(
file: File,
content: String
): F[Either[FileSystemFailure, Unit]]
}

View File

@ -0,0 +1,23 @@
package org.enso.languageserver.filemanager
/**
* Represents file system failures.
*/
sealed trait FileSystemFailure
/**
* Informs that the requested content root cannot be found.
*/
case object ContentRootNotFound extends FileSystemFailure
/**
* Signals that a user doesn't have access to a file.
*/
case object AccessDenied extends FileSystemFailure
/**
* Signals file system specific errors.
*
* @param reason a reason of failure
*/
case class GenericFileSystemFailure(reason: String) extends FileSystemFailure

View File

@ -0,0 +1,19 @@
package org.enso.languageserver.filemanager
import org.enso.languageserver.filemanager.FileManagerApi.{
AccessDeniedError,
ContentRootNotFoundError,
FileSystemError
}
import org.enso.languageserver.jsonrpc.Error
object FileSystemFailureMapper {
def mapFailure(fileSystemFailure: FileSystemFailure): Error =
fileSystemFailure match {
case ContentRootNotFound => ContentRootNotFoundError
case AccessDenied => AccessDeniedError
case GenericFileSystemFailure(reason) => FileSystemError(reason)
}
}

View File

@ -0,0 +1,19 @@
package org.enso.languageserver.filemanager
import java.io.File
import java.util.UUID
/**
* A representation of a path relative to a specified content root.
*
* @param rootId a content root id that the path is relative to
* @param segments path segments
*/
case class Path(rootId: UUID, segments: List[String]) {
def toFile(rootPath: File): File =
segments.foldLeft(rootPath) {
case (parent, child) => new File(parent, child)
}
}

View File

@ -113,6 +113,7 @@ object Errors {
case object InvalidRequest extends Error(-32600, "Invalid Request")
case object MethodNotFound extends Error(-32601, "Method not found")
case object InvalidParams extends Error(-32602, "Invalid params")
case object ServiceError extends Error(1, "Service error")
case class UnknownError(override val code: Int, override val message: String)
extends Error(code, message)
}

View File

@ -1,5 +1,6 @@
package org.enso.languageserver
import java.nio.file.{Files, Paths}
import java.util.UUID
import akka.NotUsed
@ -9,16 +10,19 @@ import akka.http.scaladsl.model.ws.{Message, TextMessage, WebSocketRequest}
import akka.stream.OverflowStrategy
import akka.stream.scaladsl.{Flow, Sink, Source}
import akka.testkit.{ImplicitSender, TestKit, TestProbe}
import cats.effect.IO
import io.circe.Json
import io.circe.literal._
import io.circe.parser._
import org.enso.languageserver.data.Config
import org.enso.languageserver.filemanager.FileSystem
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
import org.scalatest.{Assertion, BeforeAndAfterAll, BeforeAndAfterEach}
import scala.concurrent.Await
import scala.concurrent.duration._
import scala.io.{Source => IoSource}
class WebSocketServerTest
extends TestKit(ActorSystem("TestSystem"))
@ -36,11 +40,18 @@ class WebSocketServerTest
val port = 54321
val address = s"ws://$interface:$port"
val testContentRoot = Files.createTempDirectory(null)
val testContentRootId = UUID.randomUUID()
testContentRoot.toFile.deleteOnExit()
var server: WebSocketServer = _
var binding: Http.ServerBinding = _
override def beforeEach(): Unit = {
val languageServer = system.actorOf(Props(new LanguageServer(Config())))
val config = Config(Map(testContentRootId -> testContentRoot.toFile))
val languageServer =
system.actorOf(Props(new LanguageServer(config, new FileSystem[IO])))
languageServer ! LanguageProtocol.Initialize
server = new WebSocketServer(languageServer)
binding = Await.result(server.bind(interface, port), 3.seconds)
@ -216,6 +227,62 @@ class WebSocketServerTest
}
""")
}
"write textual content to a file" in {
val client = new WsTestClient(address)
client.send(json"""
{ "jsonrpc": "2.0",
"method": "file/write",
"id": 3,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo", "bar", "baz.txt" ]
},
"content": "123456789"
}
}
""")
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 3,
"result": null
}
""")
client.expectNoMessage()
val path = Paths.get(testContentRoot.toString, "foo", "bar", "baz.txt")
IoSource.fromFile(path.toFile).getLines().mkString shouldBe "123456789"
}
"return failure when a content root cannot be found" in {
val client = new WsTestClient(address)
client.send(json"""
{ "jsonrpc": "2.0",
"method": "file/write",
"id": 3,
"params": {
"path": {
"rootId": ${UUID.randomUUID()},
"segments": [ "foo", "bar", "baz.txt" ]
},
"content": "123456789"
}
}
""")
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 3,
"error" : {
"code" : 1001,
"message" : "Content root not found"
}
}
""")
client.expectNoMessage()
}
}
class WsTestClient(address: String) {

View File

@ -0,0 +1,69 @@
package org.enso.languageserver.filemanager
import java.nio.file.{Files, Paths}
import cats.effect.IO
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import scala.io.Source
import java.nio.file.Path
class FileSystemSpec extends AnyFlatSpec with Matchers {
"A file system interpreter" should "write textual content to file" in new TestCtx {
//given
val path = Paths.get(testDirPath.toString, "foo.txt")
val content = "123456789"
//when
val result =
objectUnderTest.write(path.toFile, content).unsafeRunSync()
//then
result shouldBe Right(())
readTxtFile(path) shouldBe content
}
it should "overwrite existing files" in new TestCtx {
//given
val path = Paths.get(testDirPath.toString, "foo.txt")
val existingContent = "123456789"
val newContent = "abcdef"
//when
objectUnderTest.write(path.toFile, existingContent).unsafeRunSync()
objectUnderTest.write(path.toFile, newContent).unsafeRunSync()
//then
readTxtFile(path) shouldBe newContent
}
it should "create the parent directory if it doesn't exist" in new TestCtx {
//given
val path = Paths.get(testDirPath.toString, "foo.txt")
val content = "123456789"
testDir.delete()
//when
val result =
objectUnderTest.write(path.toFile, content).unsafeRunSync()
//then
result shouldBe Right(())
readTxtFile(path) shouldBe content
}
def readTxtFile(path: Path): String = {
val buffer = Source.fromFile(path.toFile)
val content = buffer.getLines().mkString
buffer.close()
content
}
trait TestCtx {
val testDirPath = Files.createTempDirectory(null)
val testDir = testDirPath.toFile
testDir.deleteOnExit()
val objectUnderTest = new FileSystem[IO]
}
}