mirror of
https://github.com/enso-org/enso.git
synced 2024-12-27 22:18:43 +03:00
File write feature. (#557)
Provides writing to a file capability. It writes a textual content to an arbitrary file.
This commit is contained in:
parent
083fa0e4a5
commit
016602972f
18
build.sbt
18
build.sbt
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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")
|
||||
|
||||
}
|
@ -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])
|
||||
|
||||
}
|
@ -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(_ => ())
|
||||
|
||||
}
|
@ -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]]
|
||||
|
||||
}
|
@ -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
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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]
|
||||
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue
Block a user