Renaming Variable or Function Support (#7515)

close #7389



Changelog:
- add: `refactoring/renameSymbol` request to rename locals or module methods
This commit is contained in:
Dmitry Bushev 2023-08-10 22:16:33 +01:00 committed by GitHub
parent f1c224e62e
commit b7ab6911ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 3092 additions and 329 deletions

View File

@ -910,6 +910,7 @@
- [Update to GraalVM 23.0.0][7176]
- [Using official BigInteger support][7420]
- [Allow users to give a project other than Upper_Snake_Case name][7397]
- [Support renaming variable or function][7515]
[3227]: https://github.com/enso-org/enso/pull/3227
[3248]: https://github.com/enso-org/enso/pull/3248
@ -1040,6 +1041,7 @@
[7291]: https://github.com/enso-org/enso/pull/7291
[7420]: https://github.com/enso-org/enso/pull/7420
[7397]: https://github.com/enso-org/enso/pull/7397
[7515]: https://github.com/enso-org/enso/pull/7515
# Enso 2.0.0-alpha.18 (2021-10-12)

View File

@ -272,6 +272,7 @@ lazy val enso = (project in file("."))
`locking-test-helper`,
`akka-native`,
`version-output`,
`refactoring-utils`,
`engine-runner`,
runtime,
searcher,
@ -787,6 +788,22 @@ lazy val `version-output` = (project in file("lib/scala/version-output"))
}.taskValue
)
lazy val `refactoring-utils` = project
.in(file("lib/scala/refactoring-utils"))
.configs(Test)
.settings(
frgaalJavaCompilerSetting,
commands += WithDebugCommand.withDebug,
version := "0.1",
libraryDependencies ++= Seq(
"junit" % "junit" % junitVersion % Test,
"com.github.sbt" % "junit-interface" % junitIfVersion % Test
)
)
.dependsOn(`runtime-parser`)
.dependsOn(`text-buffer`)
.dependsOn(testkit % Test)
lazy val `project-manager` = (project in file("lib/scala/project-manager"))
.settings(
(Compile / mainClass) := Some("org.enso.projectmanager.boot.ProjectManager")
@ -1434,6 +1451,7 @@ lazy val `runtime-instrument-common` =
"ENSO_TEST_DISABLE_IR_CACHE" -> "false"
)
)
.dependsOn(`refactoring-utils`)
.dependsOn(
runtime % "compile->compile;test->test;runtime->runtime;bench->bench"
)

View File

@ -125,6 +125,7 @@ transport formats, please look [here](./protocol-architecture).
- [`heartbeat/init`](#heartbeatinit)
- [Refactoring](#refactoring)
- [`refactoring/renameProject`](#refactoringrenameproject)
- [`refactoring/renameSymbol`](#refactoringrenamesymbol)
- [Execution Management Operations](#execution-management-operations)
- [Execution Management Example](#execution-management-example)
- [Create Execution Context](#create-execution-context)
@ -224,6 +225,9 @@ transport formats, please look [here](./protocol-architecture).
- [`InvalidLibraryName`](#invalidlibraryname)
- [`DependencyDiscoveryError`](#dependencydiscoveryerror)
- [`InvalidSemverVersion`](#invalidsemverversion)
- [`ExpressionNotFoundError`](#expressionnotfounderror)
- [`FailedToApplyEdits`](#failedtoapplyedits)
- [`RefactoringNotSupported`](#refactoringnotsupported)
<!-- /MarkdownTOC -->
@ -3311,6 +3315,101 @@ null;
None
### `refactoring/renameSymbol`
Sent from the client to the server to rename a symbol in the program. The text
edits required to perform the refactoring will be returned as a
[`text/didChange`](#textdidchange) notification.
- **Type:** Request
- **Direction:** Project Manager -> Server
- **Connection:** Protocol
- **Visibility:** Private
#### Supported refactorings
Refactorins supports only limited cases listed below.
##### Local definition
```text
main =
operator1 = 42
^^^^^^^^^
```
Expression id in the request should point to the left hand side symbol of the
assignment.
##### Module method
```text
function1 x = x
^^^^^^^^^
main =
operator1 = Main.function1 42
```
Expression id in the request should point to the symbol defining the function.
Current limitations of the method renaming are:
- Methods defined on types are not supported, i.e.
```text
Main.function1 x = x
```
- Method calls where the self type is not specified will not be renamed, i.e.
```text
function1 x = x
main =
operator1 = function1 42
```
#### Parameters
```typescript
{
/**
* The qualified module name.
*/
module: string;
/**
* The symbol to rename.
*/
expressionId: ExpressionId;
/**
* The new name of the symbol. If the provided name is not a valid Enso
* identifier (contains unsupported symbols, spaces, etc.), it will be normalized.
* The final name will be returned in the response.
*/
newName: string;
}
```
#### Result
```typescript
{
newName: string;
}
```
#### Errors
- [`ModuleNotFoundError`](#modulenotfounderror) to signal that the requested
module cannot be found.
- [`ExpressionNotFoundError`](#expressionnotfounderror) to signal that the given
expression cannot be found.
- [`FailedToApplyEdits`](#failedtoapplyedits) to signal that the refactoring
operation was not able to apply generated edits.
- [`RefactoringNotSupported`](#refactoringnotsupported) to signal that the
refactoring of the given expression is not supported.
## Execution Management Operations
The execution management portion of the language server API deals with exposing
@ -5787,3 +5886,36 @@ message contains the invalid version in the payload.
}
}
```
### `ExpressionNotFoundError`
Signals that the expression cannot be found by the provided id.
```typescript
"error" : {
"code" : 9001,
"message" : "Expression not found by id [<expression-id>]"
}
```
### `FailedToApplyEdits`
Signals that the refactoring operation was not able to apply generated edits.
```typescript
"error" : {
"code" : 9002,
"message" : "Failed to apply edits to module [<module-name>]"
}
```
### `RefactoringNotSupported`
Signals that the refactoring of the given expression is not supported.
```typescript
"error" : {
"code" : 9003,
"message" : "Refactoring not supported for expression [<expression-id>]"
}
```

View File

@ -226,6 +226,7 @@ class MainModule(serverConfig: LanguageServerConfig, logLevel: LogLevel) {
fileManager,
vcsManager,
runtimeConnector,
contentRootManagerWrapper,
TimingsConfig.default().withAutoSave(6.seconds)
),
"buffer-registry"

View File

@ -32,7 +32,10 @@ import org.enso.languageserver.libraries.LibraryConfig
import org.enso.languageserver.libraries.handler._
import org.enso.languageserver.monitoring.MonitoringApi.{InitialPing, Ping}
import org.enso.languageserver.monitoring.MonitoringProtocol
import org.enso.languageserver.refactoring.RefactoringApi.RenameProject
import org.enso.languageserver.refactoring.RefactoringApi.{
RenameProject,
RenameSymbol
}
import org.enso.languageserver.requesthandler._
import org.enso.languageserver.requesthandler.capability._
import org.enso.languageserver.requesthandler.io._
@ -40,7 +43,10 @@ import org.enso.languageserver.requesthandler.monitoring.{
InitialPingHandler,
PingHandler
}
import org.enso.languageserver.requesthandler.refactoring.RenameProjectHandler
import org.enso.languageserver.requesthandler.refactoring.{
RenameProjectHandler,
RenameSymbolHandler
}
import org.enso.languageserver.requesthandler.text._
import org.enso.languageserver.requesthandler.visualization.{
AttachVisualizationHandler,
@ -76,6 +82,7 @@ import org.enso.polyglot.runtime.Runtime.Api
import org.enso.polyglot.runtime.Runtime.Api.ProgressNotification
import java.util.UUID
import scala.concurrent.duration._
/** An actor handling communications between a single client and the language
@ -573,6 +580,10 @@ class JsonConnectionController(
requestTimeout,
libraryConfig.localLibraryManager,
libraryConfig.publishedLibraryCache
),
RenameSymbol -> RenameSymbolHandler.props(
requestTimeout,
runtimeConnector
)
)
}

View File

@ -17,7 +17,7 @@ import org.enso.languageserver.capability.CapabilityApi.{
import org.enso.languageserver.filemanager.FileManagerApi._
import org.enso.languageserver.io.InputOutputApi._
import org.enso.languageserver.monitoring.MonitoringApi.{InitialPing, Ping}
import org.enso.languageserver.refactoring.RefactoringApi.RenameProject
import org.enso.languageserver.refactoring.RefactoringApi._
import org.enso.languageserver.runtime.ExecutionApi._
import org.enso.languageserver.search.SearchApi._
import org.enso.languageserver.runtime.VisualizationApi._
@ -84,6 +84,7 @@ object JsonRpc {
.registerRequest(Completion)
.registerRequest(AICompletion)
.registerRequest(RenameProject)
.registerRequest(RenameSymbol)
.registerRequest(ProjectInfo)
.registerRequest(EditionsListAvailable)
.registerRequest(EditionsResolve)

View File

@ -1,6 +1,8 @@
package org.enso.languageserver.refactoring
import org.enso.jsonrpc.{HasParams, HasResult, Method, Unused}
import org.enso.jsonrpc.{Error, HasParams, HasResult, Method, Unused}
import java.util.UUID
/** The refactoring JSON RPC API provided by the language server.
* See [[https://github.com/luna/enso/blob/develop/docs/language-server/README.md]]
@ -8,6 +10,8 @@ import org.enso.jsonrpc.{HasParams, HasResult, Method, Unused}
*/
object RefactoringApi {
type ExpressionId = UUID
case object RenameProject extends Method("refactoring/renameProject") {
case class Params(namespace: String, oldName: String, newName: String)
@ -21,7 +25,39 @@ object RefactoringApi {
new HasResult[this.type] {
type Result = Unused.type
}
}
case object RenameSymbol extends Method("refactoring/renameSymbol") {
case class Params(
module: String,
expressionId: ExpressionId,
newName: String
)
case class Result(newName: String)
implicit val hasParams: HasParams.Aux[this.type, RenameSymbol.Params] =
new HasParams[this.type] {
type Params = RenameSymbol.Params
}
implicit val hasResult: HasResult.Aux[this.type, RenameSymbol.Result] =
new HasResult[this.type] {
type Result = RenameSymbol.Result
}
}
case class ExpressionNotFoundError(expressionId: UUID)
extends Error(9001, s"Expression not found by id [$expressionId]")
case class FailedToApplyEdits(module: String)
extends Error(9002, s"Failed to apply edits to module [$module]")
case class RefactoringNotSupported(expressionId: UUID)
extends Error(
9003,
s"Refactoring not supported for expression [$expressionId]"
)
}

View File

@ -0,0 +1,25 @@
package org.enso.languageserver.refactoring
import org.enso.jsonrpc.Error
import org.enso.polyglot.runtime.Runtime.Api
object RenameFailureMapper {
/** Maps refactoring error into JSON RPC error.
*
* @param error refactoring error
* @return JSON RPC error
*/
def mapFailure(error: Api.SymbolRenameFailed.Error): Error =
error match {
case error: Api.SymbolRenameFailed.ExpressionNotFound =>
RefactoringApi.ExpressionNotFoundError(error.expressionId)
case error: Api.SymbolRenameFailed.FailedToApplyEdits =>
RefactoringApi.FailedToApplyEdits(error.module)
case error: Api.SymbolRenameFailed.OperationNotSupported =>
RefactoringApi.RefactoringNotSupported(error.expressionId)
}
}

View File

@ -0,0 +1,99 @@
package org.enso.languageserver.requesthandler.refactoring
import akka.actor.{Actor, ActorRef, Cancellable, Props}
import com.typesafe.scalalogging.LazyLogging
import org.enso.jsonrpc._
import org.enso.languageserver.refactoring.RefactoringApi.RenameSymbol
import org.enso.languageserver.refactoring.RenameFailureMapper
import org.enso.languageserver.requesthandler.RequestTimeout
import org.enso.languageserver.runtime.ExecutionApi
import org.enso.languageserver.util.UnhandledLogging
import org.enso.polyglot.runtime.Runtime.Api
import java.util.UUID
import scala.concurrent.duration.FiniteDuration
/** A request handler for `refactoring/renameSymbol` commands.
*
* @param timeout a request timeout
* @param runtimeConnector a reference to the runtime connector
*/
class RenameSymbolHandler(
timeout: FiniteDuration,
runtimeConnector: ActorRef
) extends Actor
with LazyLogging
with UnhandledLogging {
import context.dispatcher
override def receive: Receive = requestStage
private def requestStage: Receive = {
case Request(RenameSymbol, id, params: RenameSymbol.Params) =>
val payload =
Api.RenameSymbol(params.module, params.expressionId, params.newName)
runtimeConnector ! Api.Request(UUID.randomUUID(), payload)
val cancellable =
context.system.scheduler.scheduleOnce(timeout, self, RequestTimeout)
context.become(
responseStage(
id,
sender(),
cancellable
)
)
}
private def responseStage(
id: Id,
replyTo: ActorRef,
cancellable: Cancellable
): Receive = {
case RequestTimeout =>
logger.error("Request [{}] timed out.", id)
replyTo ! ResponseError(Some(id), Errors.RequestTimeout)
context.stop(self)
case Api.Response(_, Api.SymbolRenamed(newName)) =>
replyTo ! ResponseResult(
RenameSymbol,
id,
RenameSymbol.Result(newName)
)
cancellable.cancel()
context.stop(self)
case Api.Response(_, Api.ModuleNotFound(moduleName)) =>
replyTo ! ResponseError(
Some(id),
ExecutionApi.ModuleNotFoundError(moduleName)
)
cancellable.cancel()
context.stop(self)
case Api.Response(_, Api.SymbolRenameFailed(error)) =>
replyTo ! ResponseError(Some(id), RenameFailureMapper.mapFailure(error))
cancellable.cancel()
context.stop(self)
}
}
object RenameSymbolHandler {
/** Creates configuration object used to create a [[RenameSymbolHandler]].
*
* @param timeout request timeout
* @param runtimeConnector reference to the runtime connector
*/
def props(
timeout: FiniteDuration,
runtimeConnector: ActorRef
): Props =
Props(
new RenameSymbolHandler(timeout, runtimeConnector)
)
}

View File

@ -33,7 +33,7 @@ class ApplyEditHandler(
private def requestStage: Receive = {
case Request(ApplyEdit, id, params: ApplyEdit.Params) =>
bufferRegistry ! TextProtocol.ApplyEdit(
rpcSession.clientId,
Some(rpcSession.clientId),
params.edit,
params.execute.getOrElse(true)
)

View File

@ -277,5 +277,4 @@ object ExecutionApi {
Encoder[ContextRegistryProtocol.ExecutionDiagnostic].apply(_)
)
}
}

View File

@ -53,7 +53,7 @@ final class RuntimeFailureMapper(contentRootManager: ContentRootManager) {
/** Convert the runtime failure message to the context registry protocol
* representation.
*
* @param error the error message
* @param result the api execution result
* @return the registry protocol representation fo the diagnostic message
*/
def toProtocolFailure(

View File

@ -10,7 +10,12 @@ import org.enso.languageserver.capability.CapabilityProtocol.{
ReleaseCapability
}
import org.enso.languageserver.data.{CanEdit, CapabilityRegistration, ClientId}
import org.enso.languageserver.filemanager.{FileEvent, FileEventKind, Path}
import org.enso.languageserver.filemanager.{
ContentRootManager,
FileEvent,
FileEventKind,
Path
}
import org.enso.languageserver.monitoring.MonitoringProtocol.{Ping, Pong}
import org.enso.languageserver.session.JsonSession
import org.enso.languageserver.text.BufferRegistry.{
@ -43,6 +48,8 @@ import org.enso.languageserver.vcsmanager.VcsProtocol.{
RestoreRepoResponse,
SaveRepo
}
import org.enso.logger.masking.MaskedPath
import org.enso.polyglot.runtime.Runtime.Api
import org.enso.text.ContentBasedVersioning
import java.util.UUID
@ -81,13 +88,14 @@ import java.util.UUID
* @param fileManager a file manager
* @param vcsManager a VCS manager
* @param runtimeConnector a gateway to the runtime
* @param versionCalculator a content based version calculator
* @param contentRootManager the content root manager
* @param timingsConfig a config with timeout/delay values
*/
class BufferRegistry(
fileManager: ActorRef,
vcsManager: ActorRef,
runtimeConnector: ActorRef,
contentRootManager: ContentRootManager,
timingsConfig: TimingsConfig
)(implicit
versionCalculator: ContentBasedVersioning
@ -102,6 +110,7 @@ class BufferRegistry(
super.preStart()
context.system.eventStream.subscribe(self, classOf[FileEvent])
context.system.eventStream.subscribe(self, classOf[Api.FileEdit])
}
override def receive: Receive = running(Map.empty)
@ -212,6 +221,30 @@ class BufferRegistry(
buffer ! msg
}
}
case msg: Api.FileEdit =>
contentRootManager
.findRelativePath(msg.path)
.foreach {
case Some(path) =>
registry.get(path).foreach { buffer =>
buffer ! ApplyEdit(
None,
FileEdit(
path,
msg.edits.toList,
msg.oldVersion,
msg.newVersion
),
execute = true
)
}
case None =>
logger.error(
"Failed to resolve path [{}].",
MaskedPath(msg.path.toPath)
)
}
}
private def forwardMessageToVCS(
@ -475,7 +508,7 @@ object BufferRegistry {
* @param fileManager a file manager actor
* @param vcsManager a VCS manager actor
* @param runtimeConnector a gateway to the runtime
* @param versionCalculator a content based version calculator
* @param contentRootManager the content root manager
* @param timingsConfig a config with timout/delay values
* @return a configuration object
*/
@ -483,6 +516,7 @@ object BufferRegistry {
fileManager: ActorRef,
vcsManager: ActorRef,
runtimeConnector: ActorRef,
contentRootManager: ContentRootManager,
timingsConfig: TimingsConfig
)(implicit
versionCalculator: ContentBasedVersioning
@ -492,6 +526,7 @@ object BufferRegistry {
fileManager,
vcsManager,
runtimeConnector,
contentRootManager,
timingsConfig
)
)

View File

@ -676,7 +676,7 @@ class CollaborativeBuffer(
expressionValue: String,
autoSave: Map[ClientId, (ContentVersion, Cancellable)]
): Unit = {
applyEdits(buffer, lockHolder, clientId, change) match {
applyEdits(buffer, lockHolder, Some(clientId), change) match {
case Left(failure) =>
sender() ! failure
@ -705,7 +705,7 @@ class CollaborativeBuffer(
buffer: Buffer,
clients: Map[ClientId, JsonSession],
lockHolder: Option[JsonSession],
clientId: ClientId,
clientId: Option[ClientId],
change: FileEdit,
execute: Boolean,
autoSave: Map[ClientId, (ContentVersion, Cancellable)]
@ -716,20 +716,21 @@ class CollaborativeBuffer(
case Right(modifiedBuffer) =>
sender() ! ApplyEditSuccess
val subscribers = clients.filterNot(_._1 == clientId).values
val subscribers =
clients.filterNot(kv => clientId.contains(kv._1)).values
subscribers foreach { _.rpcController ! TextDidChange(List(change)) }
runtimeConnector ! Api.Request(
Api.EditFileNotification(
buffer.fileWithMetadata.file,
change.edits,
execute
clientId.foreach { _ =>
runtimeConnector ! Api.Request(
Api.EditFileNotification(
buffer.fileWithMetadata.file,
change.edits,
execute
)
)
)
}
val newAutoSave: Map[ClientId, (ContentVersion, Cancellable)] =
upsertAutoSaveTimer(
autoSave,
clientId,
modifiedBuffer.version
clientId.fold(autoSave)(
upsertAutoSaveTimer(autoSave, _, modifiedBuffer.version)
)
context.become(
collaborativeEditing(modifiedBuffer, clients, lockHolder, newAutoSave)
@ -740,7 +741,7 @@ class CollaborativeBuffer(
private def applyEdits(
buffer: Buffer,
lockHolder: Option[JsonSession],
clientId: ClientId,
clientId: Option[ClientId],
change: FileEdit
): Either[ApplyEditFailure, Buffer] = {
for {
@ -772,9 +773,10 @@ class CollaborativeBuffer(
private def validateAccess(
lockHolder: Option[JsonSession],
clientId: ClientId
clientId: Option[ClientId]
): Either[ApplyEditFailure, Unit] = {
val hasLock = lockHolder.exists(_.clientId == clientId)
val hasLock =
lockHolder.exists(session => clientId.forall(_ == session.clientId))
if (hasLock) {
Right(())
} else {

View File

@ -69,7 +69,11 @@ object TextProtocol {
* @param edit a diff describing changes made to a file
* @param execute whether to execute the program after applying the edits
*/
case class ApplyEdit(clientId: ClientId, edit: FileEdit, execute: Boolean)
case class ApplyEdit(
clientId: Option[ClientId],
edit: FileEdit,
execute: Boolean
)
/** Signals the result of applying a series of edits.
*/

View File

@ -198,6 +198,7 @@ class BaseServerTest
fileManager,
vcsManager,
runtimeConnectorProbe.ref,
contentRootManagerWrapper,
timingsConfig
)(
Sha3_224VersionCalculator

View File

@ -0,0 +1,238 @@
package org.enso.languageserver.websocket.json
import io.circe.literal._
import org.enso.polyglot.runtime.Runtime.Api
import java.util.UUID
class RefactoringTest extends BaseServerTest {
"refactoring/renameSymbol" should {
"return ok response after a successful renaming" in {
val client = getInitialisedWsClient()
val moduleName = "local.Unnamed.Main"
val expressionId = new UUID(0, 1)
val newName = "bar"
client.send(json"""
{ "jsonrpc": "2.0",
"method": "refactoring/renameSymbol",
"id": 1,
"params": {
"module": $moduleName,
"expressionId": $expressionId,
"newName": $newName
}
}
""")
val requestId = runtimeConnectorProbe.receiveN(1).head match {
case Api.Request(requestId, payload: Api.RenameSymbol) =>
payload.module shouldEqual moduleName
payload.expressionId shouldEqual expressionId
payload.newName shouldEqual newName
requestId
case msg =>
fail(s"Runtime connector received unexpected message: $msg")
}
runtimeConnectorProbe.lastSender ! Api.Response(
requestId,
Api.SymbolRenamed(newName)
)
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 1,
"result": {
"newName": $newName
}
}
""")
}
"reply with ModuleNotFound error when the requested module not found" in {
val client = getInitialisedWsClient()
val moduleName = "local.Unnamed.Foo"
val expressionId = new UUID(0, 1)
val newName = "bar"
client.send(json"""
{ "jsonrpc": "2.0",
"method": "refactoring/renameSymbol",
"id": 1,
"params": {
"module": $moduleName,
"expressionId": $expressionId,
"newName": $newName
}
}
""")
val requestId = runtimeConnectorProbe.receiveN(1).head match {
case Api.Request(requestId, payload: Api.RenameSymbol) =>
payload.module shouldEqual moduleName
payload.expressionId shouldEqual expressionId
payload.newName shouldEqual newName
requestId
case msg =>
fail(s"Runtime connector received unexpected message: $msg")
}
runtimeConnectorProbe.lastSender ! Api.Response(
requestId,
Api.ModuleNotFound(moduleName)
)
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 1,
"error": {
"code": 2005,
"message": ${s"Module not found [$moduleName]"}
}
}
""")
}
"reply with ExpressionNotFound error when the requested expression not found" in {
val client = getInitialisedWsClient()
val moduleName = "local.Unnamed.Main"
val expressionId = new UUID(0, 1)
val newName = "bar"
client.send(json"""
{ "jsonrpc": "2.0",
"method": "refactoring/renameSymbol",
"id": 1,
"params": {
"module": $moduleName,
"expressionId": $expressionId,
"newName": $newName
}
}
""")
val requestId = runtimeConnectorProbe.receiveN(1).head match {
case Api.Request(requestId, payload: Api.RenameSymbol) =>
payload.module shouldEqual moduleName
payload.expressionId shouldEqual expressionId
payload.newName shouldEqual newName
requestId
case msg =>
fail(s"Runtime connector received unexpected message: $msg")
}
runtimeConnectorProbe.lastSender ! Api.Response(
requestId,
Api.SymbolRenameFailed(
Api.SymbolRenameFailed.ExpressionNotFound(expressionId)
)
)
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 1,
"error": {
"code": 9001,
"message": ${s"Expression not found by id [$expressionId]"}
}
}
""")
}
"reply with FailedToApplyEdits error when failed to apply edits" in {
val client = getInitialisedWsClient()
val moduleName = "local.Unnamed.Main"
val expressionId = new UUID(0, 1)
val newName = "bar"
client.send(json"""
{ "jsonrpc": "2.0",
"method": "refactoring/renameSymbol",
"id": 1,
"params": {
"module": $moduleName,
"expressionId": $expressionId,
"newName": $newName
}
}
""")
val requestId = runtimeConnectorProbe.receiveN(1).head match {
case Api.Request(requestId, payload: Api.RenameSymbol) =>
payload.module shouldEqual moduleName
payload.expressionId shouldEqual expressionId
payload.newName shouldEqual newName
requestId
case msg =>
fail(s"Runtime connector received unexpected message: $msg")
}
runtimeConnectorProbe.lastSender ! Api.Response(
requestId,
Api.SymbolRenameFailed(
Api.SymbolRenameFailed.FailedToApplyEdits(moduleName)
)
)
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 1,
"error": {
"code": 9002,
"message": ${s"Failed to apply edits to module [$moduleName]"}
}
}
""")
}
"reply with RefactoringNotSupported error when renaming unsupported expression" in {
val client = getInitialisedWsClient()
val moduleName = "local.Unnamed.Main"
val expressionId = new UUID(0, 1)
val newName = "bar"
client.send(json"""
{ "jsonrpc": "2.0",
"method": "refactoring/renameSymbol",
"id": 1,
"params": {
"module": $moduleName,
"expressionId": $expressionId,
"newName": $newName
}
}
""")
val requestId = runtimeConnectorProbe.receiveN(1).head match {
case Api.Request(requestId, payload: Api.RenameSymbol) =>
payload.module shouldEqual moduleName
payload.expressionId shouldEqual expressionId
payload.newName shouldEqual newName
requestId
case msg =>
fail(s"Runtime connector received unexpected message: $msg")
}
runtimeConnectorProbe.lastSender ! Api.Response(
requestId,
Api.SymbolRenameFailed(
Api.SymbolRenameFailed.OperationNotSupported(expressionId)
)
)
client.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 1,
"error": {
"code": 9003,
"message": ${s"Refactoring not supported for expression [$expressionId]"}
}
}
""")
}
}
}

View File

@ -5,8 +5,11 @@ import io.circe.literal._
import org.enso.languageserver.event.{BufferClosed, JsonSessionTerminated}
import org.enso.languageserver.filemanager.Path
import org.enso.languageserver.session.JsonSession
import org.enso.polyglot.runtime.Runtime.Api
import org.enso.testkit.FlakySpec
import org.enso.text.editing.model
import java.io.File
import java.nio.charset.StandardCharsets
import java.nio.file.Files
@ -16,6 +19,387 @@ class TextOperationsTest extends BaseServerTest with FlakySpec {
override def isFileWatcherEnabled = true
"BufferRegistry" must {
"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 = getInitialisedWsClient()
val client2 = getInitialisedWsClient()
// 1
client1.send(json"""
{ "jsonrpc": "2.0",
"method": "file/write",
"id": 0,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "grant_can_edit.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": [ "grant_can_edit.txt" ]
}
}
}
""")
// 4
client1.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 1,
"result": {
"writeCapability": {
"method": "text/canEdit",
"registerOptions": { "path": {
"rootId": $testContentRootId,
"segments": ["grant_can_edit.txt"]
} }
},
"content": "123456789",
"currentVersion": "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522"
}
}
""")
// 5
client1.send(json"""
{ "jsonrpc": "2.0",
"method": "capability/release",
"id": 2,
"params": {
"method": "text/canEdit",
"registerOptions": { "path": {
"rootId": $testContentRootId,
"segments": ["grant_can_edit.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": [ "grant_can_edit.txt" ]
}
}
}
""")
// 8
client2.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 3,
"result": {
"writeCapability": {
"method": "text/canEdit",
"registerOptions": { "path": {
"rootId": $testContentRootId,
"segments": ["grant_can_edit.txt"]
} }
},
"content": "123456789",
"currentVersion": "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522"
}
}
""")
}
"take canEdit capability away from clients when another client registers for it" in {
val client1 = getInitialisedWsClient()
val client2 = getInitialisedWsClient()
val client3 = getInitialisedWsClient()
client1.send(json"""
{ "jsonrpc": "2.0",
"method": "file/write",
"id": 0,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "take_can_edit.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": [ "take_can_edit.txt" ]
}
}
}
""")
client1.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 1,
"result": {
"writeCapability": {
"method": "text/canEdit",
"registerOptions": { "path": {
"rootId": $testContentRootId,
"segments": ["take_can_edit.txt"]
} }
},
"content": "123456789",
"currentVersion": "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522"
}
}
""")
client2.expectNoMessage()
client3.expectNoMessage()
client2.send(json"""
{ "jsonrpc": "2.0",
"method": "capability/acquire",
"id": 2,
"params": {
"method": "text/canEdit",
"registerOptions": {
"path": {
"rootId": $testContentRootId,
"segments": [ "take_can_edit.txt" ]
}
}
}
}
""")
client1.expectJson(json"""
{ "jsonrpc": "2.0",
"method": "capability/forceReleased",
"params": {
"method": "text/canEdit",
"registerOptions": {
"path": {
"rootId": $testContentRootId,
"segments": [ "take_can_edit.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": "text/canEdit",
"registerOptions": {
"path": {
"rootId": $testContentRootId,
"segments": [ "take_can_edit.txt" ]
}
}
}
}
""")
client1.expectNoMessage()
client2.expectJson(json"""
{ "jsonrpc": "2.0",
"method": "capability/forceReleased",
"params": {
"method": "text/canEdit",
"registerOptions": {
"path": {
"rootId": $testContentRootId,
"segments": [ "take_can_edit.txt" ]
}
}
}
}
""")
client3.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 3,
"result": null
}
""")
}
"apply refactoring changes" in {
val client = getInitialisedWsClient()
client.send(json"""
{ "jsonrpc": "2.0",
"method": "file/write",
"id": 0,
"params": {
"path": {
"rootId": $testContentRootId,
"segments": [ "to_refactor.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": [ "to_refactor.txt" ]
}
}
}
""")
client.expectJson(json"""
{
"jsonrpc" : "2.0",
"id" : 1,
"result" : {
"writeCapability" : {
"method" : "text/canEdit",
"registerOptions" : {
"path" : {
"rootId" : $testContentRootId,
"segments" : [
"to_refactor.txt"
]
}
}
},
"content" : "123456789",
"currentVersion" : "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522"
}
}
""")
val textEdits = Vector(
model.TextEdit(
model.Range(model.Position(0, 0), model.Position(0, 0)),
"bar"
),
model.TextEdit(
model.Range(model.Position(0, 12), model.Position(0, 12)),
"foo"
)
)
val fileEdit = Api.FileEdit(
new File(testContentRoot.file, "to_refactor.txt"),
textEdits,
oldVersion = "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522",
newVersion = "ebe55342f9c8b86857402797dd723fb4a2174e0b56d6ace0a6929ec3"
)
system.eventStream.publish(fileEdit)
client.expectJson(json"""
{ "jsonrpc" : "2.0",
"method" : "text/didChange",
"params" : {
"edits" : [
{
"path" : {
"rootId" : $testContentRootId,
"segments" : [
"to_refactor.txt"
]
},
"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"
}
],
"oldVersion" : "5795c3d628fd638c9835a4c79a55809f265068c88729a1a3fcdf8522",
"newVersion" : "ebe55342f9c8b86857402797dd723fb4a2174e0b56d6ace0a6929ec3"
}
]
}
}""")
}
}
"text/openFile" must {
"fail opening a file if it does not exist" taggedAs Flaky in {
// Interaction:
@ -294,263 +678,6 @@ class TextOperationsTest extends BaseServerTest with FlakySpec {
}
}
"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 = getInitialisedWsClient()
val client2 = getInitialisedWsClient()
// 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": "text/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": "text/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": "text/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 = getInitialisedWsClient()
val client2 = getInitialisedWsClient()
val client3 = getInitialisedWsClient()
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": "text/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": "text/canEdit",
"registerOptions": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
}
}
}
}
""")
client1.expectJson(json"""
{ "jsonrpc": "2.0",
"method": "capability/forceReleased",
"params": {
"method": "text/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": "text/canEdit",
"registerOptions": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
}
}
}
}
""")
client1.expectNoMessage()
client2.expectJson(json"""
{ "jsonrpc": "2.0",
"method": "capability/forceReleased",
"params": {
"method": "text/canEdit",
"registerOptions": {
"path": {
"rootId": $testContentRootId,
"segments": [ "foo.txt" ]
}
}
}
}
""")
client3.expectJson(json"""
{ "jsonrpc": "2.0",
"id": 3,
"result": null
}
""")
}
"text/closeFile" must {
"fail when a client didn't open it before" in {

View File

@ -116,6 +116,10 @@ object Runtime {
value = classOf[Api.VisualizationUpdate],
name = "visualizationUpdate"
),
new JsonSubTypes.Type(
value = classOf[Api.FileEdit],
name = "fileEdit"
),
new JsonSubTypes.Type(
value = classOf[Api.AttachVisualization],
name = "attachVisualization"
@ -152,6 +156,18 @@ object Runtime {
value = classOf[Api.ProjectRenamed],
name = "projectRenamed"
),
new JsonSubTypes.Type(
value = classOf[Api.RenameSymbol],
name = "renameSymbol"
),
new JsonSubTypes.Type(
value = classOf[Api.SymbolRenamed],
name = "symbolRenamed"
),
new JsonSubTypes.Type(
value = classOf[Api.SymbolRenameFailed],
name = "symbolRenameFailed"
),
new JsonSubTypes.Type(
value = classOf[Api.ContextNotExistError],
name = "contextNotExistError"
@ -1162,6 +1178,30 @@ object Runtime {
}
}
/** A list of edits applied to a file.
*
* @param path the module file path
* @param edits the list of text edits
* @param oldVersion the current version of a buffer
* @param newVersion the version of a buffer after applying all edits
*/
final case class FileEdit(
path: File,
edits: Vector[TextEdit],
oldVersion: String,
newVersion: String
) extends ApiNotification
with ToLogString {
override def toLogString(shouldMask: Boolean): String =
"FileEdit(" +
s"path=${MaskedPath(path.toPath).toLogString(shouldMask)}," +
s"edits=${edits.mkString("[", ",", "]")}" +
s"oldVersion=$oldVersion" +
s"newVersion=$newVersion" +
")"
}
/** Envelope for an Api request.
*
* @param requestId the request identifier.
@ -1604,6 +1644,74 @@ object Runtime {
final case class ProjectRenamed(namespace: String, newName: String)
extends ApiResponse
/** A request for symbol renaming.
*
* @param module the qualified module name
* @param expressionId the symbol to rename
* @param newName the new name of the symbol
*/
final case class RenameSymbol(
module: String,
expressionId: ExpressionId,
newName: String
) extends ApiRequest
/** Signals that the symbol has been renamed.
*
* @param newName the new name of the symbol
*/
final case class SymbolRenamed(newName: String) extends ApiResponse
/** Signals that the symbol rename has failed.
*
* @param error the error that happened
*/
final case class SymbolRenameFailed(error: SymbolRenameFailed.Error)
extends ApiResponse
object SymbolRenameFailed {
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes(
Array(
new JsonSubTypes.Type(
value = classOf[SymbolRenameFailed.ExpressionNotFound],
name = "symbolRenameFailedExpressionNotFound"
),
new JsonSubTypes.Type(
value = classOf[SymbolRenameFailed.FailedToApplyEdits],
name = "symbolRenameFailedFailedToApplyEdits"
),
new JsonSubTypes.Type(
value = classOf[SymbolRenameFailed.OperationNotSupported],
name = "symbolRenameFailedOperationNotSupported"
)
)
) sealed trait Error
/** Signals that an expression cannot be found by provided id.
*
* @param expressionId the id of expression
*/
final case class ExpressionNotFound(expressionId: ExpressionId)
extends SymbolRenameFailed.Error
/** Signals that it was unable to apply edits to the current module contents.
*
* @param module the module name
*/
final case class FailedToApplyEdits(module: String)
extends SymbolRenameFailed.Error
/** Signals that the renaming operation is not supported for the
* provided expression.
*
* @param expressionId the id of expression
*/
final case class OperationNotSupported(expressionId: ExpressionId)
extends SymbolRenameFailed.Error
}
/** A notification about the changes in the suggestions database.
*
* @param module the module name

View File

@ -1,10 +1,10 @@
package org.enso.interpreter.instrument.command
import org.enso.interpreter.instrument.execution.{Completion, RuntimeContext}
import org.enso.polyglot.runtime.Runtime.{Api, ApiResponse}
import org.enso.polyglot.runtime.Runtime.{Api, ApiNotification, ApiResponse}
import org.enso.polyglot.runtime.Runtime.Api.RequestId
import scala.concurrent.{ExecutionContext}
import scala.concurrent.ExecutionContext
/** Base command trait that encapsulates a function request. Uses
* [[RuntimeContext]] to perform a request.
@ -30,4 +30,9 @@ abstract class Command(maybeRequestId: Option[RequestId]) {
ctx.endpoint.sendToClient(Api.Response(maybeRequestId, payload))
}
protected def notify(
payload: ApiNotification
)(implicit ctx: RuntimeContext): Unit = {
ctx.endpoint.sendToClient(Api.Response(None, payload))
}
}

View File

@ -46,6 +46,9 @@ object CommandFactory {
case payload: Api.RenameProject =>
new RenameProjectCmd(request.requestId, payload)
case payload: Api.RenameSymbol =>
new RenameSymbolCmd(request.requestId, payload)
case payload: Api.OpenFileNotification =>
new OpenFileCmd(payload)
case payload: Api.CloseFileNotification => new CloseFileCmd(payload)

View File

@ -0,0 +1,72 @@
package org.enso.interpreter.instrument.command
import org.enso.interpreter.instrument.execution.RuntimeContext
import org.enso.interpreter.instrument.job.{
EnsureCompiledJob,
ExecuteJob,
RefactoringRenameJob
}
import org.enso.polyglot.runtime.Runtime.Api
import java.io.File
import scala.concurrent.{ExecutionContext, Future}
/** A command that orchestrates renaming of a symbol.
*
* @param maybeRequestId an option with request id
* @param request a request for a service
*/
class RenameSymbolCmd(
maybeRequestId: Option[Api.RequestId],
request: Api.RenameSymbol
) extends AsynchronousCommand(maybeRequestId) {
/** @inheritdoc */
override def executeAsynchronously(implicit
ctx: RuntimeContext,
ec: ExecutionContext
): Future[Unit] = {
val moduleFile = ctx.executionService.getContext
.findModule(request.module)
.map(module => Seq(new File(module.getPath)))
.orElseGet(() => Seq())
val ensureCompiledJob = ctx.jobProcessor.run(
new EnsureCompiledJob(
(ctx.state.pendingEdits.files ++ moduleFile).distinct,
isCancellable = false
)
)
val refactoringRenameJob = ctx.jobProcessor.run(
new RefactoringRenameJob(
maybeRequestId,
request.module,
request.expressionId,
request.newName
)
)
for {
_ <- ensureCompiledJob
refactoredFiles <- refactoringRenameJob
_ <-
if (refactoredFiles.isEmpty) Future.successful(())
else reExecute(refactoredFiles)
} yield ()
}
private def reExecute(files: Seq[File])(implicit
ctx: RuntimeContext,
ec: ExecutionContext
): Future[Unit] =
for {
_ <- ctx.jobProcessor.run(new EnsureCompiledJob(files))
_ <- Future.sequence {
ctx.contextManager.getAllContexts
.collect {
case (contextId, stack) if stack.nonEmpty =>
ctx.jobProcessor.run(ExecuteJob(contextId, stack.toList))
}
}
} yield ()
}

View File

@ -3,6 +3,7 @@ package org.enso.interpreter.instrument.execution
import org.enso.interpreter.instrument.InterpreterContext
import org.enso.interpreter.instrument.command.Command
import org.enso.polyglot.RuntimeOptions
import org.enso.text.Sha3_224VersionCalculator
import scala.concurrent.{ExecutionContext, ExecutionContextExecutor}
@ -48,14 +49,15 @@ class CommandExecutionEngine(interpreterContext: InterpreterContext)
private val runtimeContext =
RuntimeContext(
executionService = interpreterContext.executionService,
contextManager = interpreterContext.contextManager,
endpoint = interpreterContext.endpoint,
truffleContext = interpreterContext.truffleContext,
jobProcessor = jobExecutionEngine,
jobControlPlane = jobExecutionEngine,
locking = locking,
state = executionState
executionService = interpreterContext.executionService,
contextManager = interpreterContext.contextManager,
endpoint = interpreterContext.endpoint,
truffleContext = interpreterContext.truffleContext,
jobProcessor = jobExecutionEngine,
jobControlPlane = jobExecutionEngine,
locking = locking,
state = executionState,
versionCalculator = Sha3_224VersionCalculator
)
/** @inheritdoc */

View File

@ -2,6 +2,7 @@ package org.enso.interpreter.instrument.execution
import org.enso.interpreter.instrument.InterpreterContext
import org.enso.interpreter.instrument.job.{BackgroundJob, Job, UniqueJob}
import org.enso.text.Sha3_224VersionCalculator
import java.util
import java.util.{Collections, UUID}
@ -50,14 +51,15 @@ final class JobExecutionEngine(
private val runtimeContext =
RuntimeContext(
executionService = interpreterContext.executionService,
contextManager = interpreterContext.contextManager,
endpoint = interpreterContext.endpoint,
truffleContext = interpreterContext.truffleContext,
jobProcessor = this,
jobControlPlane = this,
locking = locking,
state = executionState
executionService = interpreterContext.executionService,
contextManager = interpreterContext.contextManager,
endpoint = interpreterContext.endpoint,
truffleContext = interpreterContext.truffleContext,
jobProcessor = this,
jobControlPlane = this,
locking = locking,
state = executionState,
versionCalculator = Sha3_224VersionCalculator
)
/** @inheritdoc */

View File

@ -19,4 +19,10 @@ trait PendingEdits {
* @return the list of pending edits
*/
def dequeue(file: File): Seq[PendingEdit]
/** List files with pending edits.
*
* @return the list of files with pending edits
*/
def files: Seq[File]
}

View File

@ -20,4 +20,8 @@ final class PendingFileEdits(
/** @inheritdoc */
override def dequeue(file: File): Seq[PendingEdit] =
pending.remove(file).getOrElse(Seq())
/** @inheritdoc */
override def files: Seq[File] =
pending.keys.toSeq
}

View File

@ -3,6 +3,7 @@ package org.enso.interpreter.instrument.execution
import com.oracle.truffle.api.TruffleContext
import org.enso.interpreter.instrument.{Endpoint, ExecutionContextManager}
import org.enso.interpreter.service.ExecutionService
import org.enso.text.ContentBasedVersioning
/** Contains suppliers of services that provide application specific
* functionality.
@ -16,6 +17,7 @@ import org.enso.interpreter.service.ExecutionService
* @param jobControlPlane a job control plane
* @param locking a locking service
* @param state a state of the runtime
* @param versionCalculator a content based version calculator
*/
case class RuntimeContext(
executionService: ExecutionService,
@ -25,5 +27,6 @@ case class RuntimeContext(
jobProcessor: JobProcessor,
jobControlPlane: JobControlPlane,
locking: Locking,
state: ExecutionState
state: ExecutionState,
versionCalculator: ContentBasedVersioning
)

View File

@ -34,9 +34,16 @@ import scala.jdk.OptionConverters._
/** A job that ensures that specified files are compiled.
*
* @param files a files to compile
* @param isCancellable a flag indicating if the job is cancellable
*/
final class EnsureCompiledJob(protected val files: Iterable[File])
extends Job[EnsureCompiledJob.CompilationStatus](List.empty, true, false) {
final class EnsureCompiledJob(
protected val files: Iterable[File],
isCancellable: Boolean = true
) extends Job[EnsureCompiledJob.CompilationStatus](
List.empty,
isCancellable,
false
) {
import EnsureCompiledJob.CompilationStatus
@ -282,7 +289,6 @@ final class EnsureCompiledJob(protected val files: Iterable[File])
*
* @param changeset the [[Changeset]] object capturing the previous
* version of IR
* @param ctx the runtime context
* @return the list of cache invalidation commands
*/
private def buildCacheInvalidationCommands(
@ -291,7 +297,7 @@ final class EnsureCompiledJob(protected val files: Iterable[File])
): Seq[CacheInvalidation] = {
val invalidateExpressionsCommand =
CacheInvalidation.Command.InvalidateKeys(changeset.invalidated)
val scopeIds = splitMeta(source.toString())._2.map(_._2)
val scopeIds = splitMeta(source.toString)._2.map(_._2)
val invalidateStaleCommand =
CacheInvalidation.Command.InvalidateStale(scopeIds)
Seq(
@ -518,7 +524,7 @@ object EnsureCompiledJob {
/** The outcome of a compilation. */
sealed trait CompilationStatus
case object CompilationStatus {
private case object CompilationStatus {
/** Compilation completed. */
case object Success extends CompilationStatus

View File

@ -0,0 +1,204 @@
package org.enso.interpreter.instrument.job
import org.enso.compiler.core.IR
import org.enso.compiler.refactoring.IRUtils
import org.enso.interpreter.instrument.execution.RuntimeContext
import org.enso.interpreter.instrument.execution.model.PendingEdit
import org.enso.interpreter.service.error.ModuleNotFoundException
import org.enso.polyglot.runtime.Runtime.{Api, ApiNotification, ApiResponse}
import org.enso.refactoring.RenameUtils
import org.enso.refactoring.validation.MethodNameValidation
import org.enso.text.editing.EditorOps
import java.io.File
import java.util.UUID
import java.util.logging.Level
/** A job responsible for refactoring renaming operation.
*
* @param maybeRequestId the original request id
* @param moduleName the qualified module name
* @param expressionId the symbol to rename
* @param newName the new name of the symbol
*/
final class RefactoringRenameJob(
maybeRequestId: Option[Api.RequestId],
moduleName: String,
expressionId: UUID,
newName: String
) extends Job[Seq[File]](
List(),
isCancellable = false,
mayInterruptIfRunning = false
) {
/** @inheritdoc */
override def run(implicit ctx: RuntimeContext): Seq[File] = {
val logger = ctx.executionService.getLogger
val compilationLockTimestamp = ctx.locking.acquireReadCompilationLock()
try {
logger.log(
Level.FINE,
s"Renaming symbol [{0}]...",
expressionId
)
val refactoredFile = applyRefactoringEdits()
Seq(refactoredFile)
} catch {
case _: ModuleNotFoundException =>
reply(Api.ModuleNotFound(moduleName))
Seq()
case ex: RefactoringRenameJob.ExpressionNotFound =>
reply(
Api.SymbolRenameFailed(
Api.SymbolRenameFailed.ExpressionNotFound(ex.expressionId)
)
)
Seq()
case ex: RefactoringRenameJob.FailedToApplyEdits =>
reply(
Api.SymbolRenameFailed(
Api.SymbolRenameFailed.FailedToApplyEdits(ex.module)
)
)
Seq()
case ex: RefactoringRenameJob.OperationNotSupported =>
reply(
Api.SymbolRenameFailed(
Api.SymbolRenameFailed.OperationNotSupported(ex.expressionId)
)
)
Seq()
} finally {
ctx.locking.releaseReadCompilationLock()
logger.log(
Level.FINEST,
s"Kept read compilation lock [{0}] for {1} milliseconds.",
Array(
getClass.getSimpleName,
System.currentTimeMillis() - compilationLockTimestamp
)
)
}
}
private def applyRefactoringEdits()(implicit ctx: RuntimeContext): File = {
val module = ctx.executionService.getContext
.findModule(moduleName)
.orElseThrow(() => new ModuleNotFoundException(moduleName))
val newSymbolName = MethodNameValidation.normalize(newName)
val expression = IRUtils
.findByExternalId(module.getIr, expressionId)
.getOrElse(
throw new RefactoringRenameJob.ExpressionNotFound(expressionId)
)
val local = getLiteral(expression)
val methodDefinition = getMethodDefinition(expression)
val symbol = local
.orElse(methodDefinition)
.getOrElse(
throw new RefactoringRenameJob.OperationNotSupported(expressionId)
)
def localUsages = local.flatMap(IRUtils.findLocalUsages(module.getIr, _))
def methodDefinitionUsages = methodDefinition.flatMap(
IRUtils.findModuleMethodUsages(module.getName, module.getIr, _)
)
val usages = localUsages
.orElse(methodDefinitionUsages)
.getOrElse(Set())
.concat(Set(symbol))
.flatMap(_.location)
.map(_.location)
.toSeq
val edits =
RenameUtils.buildEdits(module.getLiteralSource, usages, newSymbolName)
val oldVersion =
ctx.versionCalculator.evalVersion(module.getLiteralSource.toString)
val newContents =
EditorOps
.applyEdits(module.getLiteralSource, edits)
.getOrElse(
throw new RefactoringRenameJob.FailedToApplyEdits(moduleName)
)
val newVersion = ctx.versionCalculator.evalVersion(newContents.toString)
val fileEdit = Api.FileEdit(
new File(module.getPath),
edits.toVector,
oldVersion.toHexString,
newVersion.toHexString
)
enqueuePendingEdits(fileEdit)
notify(fileEdit)
reply(Api.SymbolRenamed(newSymbolName))
fileEdit.path
}
private def enqueuePendingEdits(fileEdit: Api.FileEdit)(implicit
ctx: RuntimeContext
): Unit = {
val pendingEditsLockTimestamp = ctx.locking.acquirePendingEditsLock()
try {
val pendingEdits =
fileEdit.edits.map(PendingEdit.ApplyEdit(_, execute = true))
ctx.state.pendingEdits.enqueue(fileEdit.path, pendingEdits)
} finally {
ctx.locking.releasePendingEditsLock()
ctx.executionService.getLogger.log(
Level.FINEST,
s"Kept pending edits lock [{0}] for {1} milliseconds.",
Array(
getClass.getSimpleName,
System.currentTimeMillis() - pendingEditsLockTimestamp
)
)
}
}
private def getLiteral(ir: IR): Option[IR.Name.Literal] =
ir match {
case literal: IR.Name.Literal => Some(literal)
case _ => None
}
private def getMethodDefinition(ir: IR): Option[IR.Name] =
ir match {
case methodRef: IR.Name.MethodReference
if methodRef.typePointer.isEmpty =>
Some(methodRef.methodName)
case _ =>
None
}
private def reply(
payload: ApiResponse
)(implicit ctx: RuntimeContext): Unit = {
ctx.endpoint.sendToClient(Api.Response(maybeRequestId, payload))
}
private def notify(
payload: ApiNotification
)(implicit ctx: RuntimeContext): Unit = {
ctx.endpoint.sendToClient(Api.Response(None, payload))
}
}
object RefactoringRenameJob {
final private class ExpressionNotFound(val expressionId: IR.ExternalId)
extends Exception(s"Expression was not found by id [$expressionId].")
final private class FailedToApplyEdits(val module: String)
extends Exception(s"Failed to apply edits to module [$module]")
final private class OperationNotSupported(val expressionId: IR.ExternalId)
extends Exception(
s"Operation not supported for expression [$expressionId]"
)
}

View File

@ -22,7 +22,6 @@ import org.enso.interpreter.instrument.{
import org.enso.interpreter.runtime.Module
import org.enso.interpreter.runtime.control.ThreadInterruptedException
import org.enso.pkg.QualifiedName
//import org.enso.polyglot.runtime.Runtime.Api.
import org.enso.polyglot.runtime.Runtime.Api
import java.util.logging.Level

View File

@ -0,0 +1,865 @@
package org.enso.interpreter.test.instrument
import org.apache.commons.io.output.TeeOutputStream
import org.enso.distribution.FileSystem
import org.enso.distribution.locking.ThreadSafeFileLockManager
import org.enso.interpreter.runtime.`type`.ConstantsGen
import org.enso.interpreter.test.Metadata
import org.enso.pkg.{Package, PackageManager}
import org.enso.polyglot._
import org.enso.polyglot.runtime.Runtime.Api
import org.enso.text.{ContentBasedVersioning, Sha3_224VersionCalculator}
import org.enso.text.editing.model
import org.enso.text.editing.model.TextEdit
import org.graalvm.polyglot.Context
import org.scalatest.BeforeAndAfterEach
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import java.io.{ByteArrayOutputStream, File}
import java.nio.file.{Files, Path, Paths}
import java.util.UUID
@scala.annotation.nowarn("msg=multiarg infix syntax")
class RuntimeRefactoringTest
extends AnyFlatSpec
with Matchers
with BeforeAndAfterEach {
// === Test Utilities =======================================================
var context: TestContext = _
class TestContext(packageName: String) extends InstrumentTestContext {
val tmpDir: Path = Files.createTempDirectory("enso-test-packages")
sys.addShutdownHook(FileSystem.removeDirectoryIfExists(tmpDir))
val lockManager = new ThreadSafeFileLockManager(tmpDir.resolve("locks"))
val runtimeServerEmulator =
new RuntimeServerEmulator(messageQueue, lockManager)
val pkg: Package[File] =
PackageManager.Default.create(tmpDir.toFile, packageName, "Enso_Test")
val out: ByteArrayOutputStream = new ByteArrayOutputStream()
val logOut: ByteArrayOutputStream = new ByteArrayOutputStream()
val executionContext = new PolyglotContext(
Context
.newBuilder(LanguageInfo.ID)
.allowExperimentalOptions(true)
.allowAllAccess(true)
.option(RuntimeOptions.PROJECT_ROOT, pkg.root.getAbsolutePath)
.option(RuntimeOptions.LOG_LEVEL, "WARNING")
.option(RuntimeOptions.INTERPRETER_SEQUENTIAL_COMMAND_EXECUTION, "true")
.option(RuntimeOptions.ENABLE_PROJECT_SUGGESTIONS, "false")
.option(RuntimeOptions.ENABLE_GLOBAL_SUGGESTIONS, "false")
.option(RuntimeOptions.ENABLE_EXECUTION_TIMER, "false")
.option(
RuntimeOptions.DISABLE_IR_CACHES,
InstrumentTestContext.DISABLE_IR_CACHE
)
.option(RuntimeServerInfo.ENABLE_OPTION, "true")
.option(RuntimeOptions.INTERACTIVE_MODE, "true")
.option(
RuntimeOptions.LANGUAGE_HOME_OVERRIDE,
Paths
.get("../../test/micro-distribution/component")
.toFile
.getAbsolutePath
)
.option(RuntimeOptions.EDITION_OVERRIDE, "0.0.0-dev")
.logHandler(new TeeOutputStream(logOut, System.err))
.out(new TeeOutputStream(out, System.err))
.serverTransport(runtimeServerEmulator.makeServerTransport)
.build()
)
executionContext.context.initialize(LanguageInfo.ID)
def writeMain(contents: String): File =
Files.write(pkg.mainFile.toPath, contents.getBytes).toFile
def readMain: String =
Files.readString(pkg.mainFile.toPath)
def send(msg: Api.Request): Unit = runtimeServerEmulator.sendToRuntime(msg)
def consumeOut: List[String] = {
val result = out.toString
out.reset()
result.linesIterator.toList
}
def executionComplete(contextId: UUID): Api.Response =
Api.Response(Api.ExecutionComplete(contextId))
}
val versionCalculator: ContentBasedVersioning = Sha3_224VersionCalculator
override protected def beforeEach(): Unit = {
context = new TestContext("Test")
val Some(Api.Response(_, Api.InitializedNotification())) = context.receive
}
override protected def afterEach(): Unit = {
context.executionContext.context.close()
context.runtimeServerEmulator.terminate()
}
"RuntimeServer" should "rename operator in main body" in {
val contextId = UUID.randomUUID()
val requestId = UUID.randomUUID()
val moduleName = "Enso_Test.Test.Main"
val metadata = new Metadata
val idOperator1 = metadata.addItem(42, 9)
val code =
"""from Standard.Base import all
|
|main =
| operator1 = 41
| operator2 = operator1 + 1
| IO.println operator2
|""".stripMargin.linesIterator.mkString("\n")
val contents = metadata.appendToCode(code)
val mainFile = context.writeMain(contents)
// create context
context.send(Api.Request(requestId, Api.CreateContextRequest(contextId)))
context.receive shouldEqual Some(
Api.Response(requestId, Api.CreateContextResponse(contextId))
)
// open file
context.send(
Api.Request(Api.OpenFileNotification(mainFile, contents))
)
context.receiveNone shouldEqual None
// push main
context.send(
Api.Request(
requestId,
Api.PushContextRequest(
contextId,
Api.StackItem.ExplicitCall(
Api.MethodPointer(moduleName, moduleName, "main"),
None,
Vector()
)
)
)
)
context.receiveNIgnoreStdLib(3) should contain theSameElementsAs Seq(
Api.Response(Api.BackgroundJobsStartedNotification()),
Api.Response(requestId, Api.PushContextResponse(contextId)),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
// rename operator1
val newName = "foobarbaz"
val expectedEdits = Vector(
TextEdit(
model.Range(model.Position(3, 4), model.Position(3, 13)),
newName
),
TextEdit(
model.Range(model.Position(4, 16), model.Position(4, 25)),
newName
)
)
val expectedFileEdit = Api.FileEdit(
context.pkg.mainFile,
expectedEdits,
versionCalculator.evalVersion(contents).toHexString,
versionCalculator
.evalVersion(contents.replaceAll("operator1", newName))
.toHexString
)
context.send(
Api.Request(requestId, Api.RenameSymbol(moduleName, idOperator1, newName))
)
context.receiveNIgnoreStdLib(4) should contain theSameElementsAs Seq(
Api.Response(requestId, Api.SymbolRenamed(newName)),
Api.Response(None, expectedFileEdit),
TestMessages.pending(contextId, idOperator1),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
}
it should "rename operator in lambda expression" in {
val contextId = UUID.randomUUID()
val requestId = UUID.randomUUID()
val moduleName = "Enso_Test.Test.Main"
val metadata = new Metadata
val idOperator1 = metadata.addItem(42, 9)
val code =
"""from Standard.Base import all
|
|main =
| operator1 = 41
| operator2 = x-> operator1 + x
| IO.println (operator2 1)
|""".stripMargin.linesIterator.mkString("\n")
val contents = metadata.appendToCode(code)
val mainFile = context.writeMain(contents)
// create context
context.send(Api.Request(requestId, Api.CreateContextRequest(contextId)))
context.receive shouldEqual Some(
Api.Response(requestId, Api.CreateContextResponse(contextId))
)
// open file
context.send(
Api.Request(Api.OpenFileNotification(mainFile, contents))
)
context.receiveNone shouldEqual None
// push main
context.send(
Api.Request(
requestId,
Api.PushContextRequest(
contextId,
Api.StackItem.ExplicitCall(
Api.MethodPointer(moduleName, moduleName, "main"),
None,
Vector()
)
)
)
)
context.receiveNIgnoreStdLib(3) should contain theSameElementsAs Seq(
Api.Response(Api.BackgroundJobsStartedNotification()),
Api.Response(requestId, Api.PushContextResponse(contextId)),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
// rename operator1
val newName = "foobarbaz"
val expectedEdits = Vector(
TextEdit(
model.Range(model.Position(3, 4), model.Position(3, 13)),
newName
),
TextEdit(
model.Range(model.Position(4, 20), model.Position(4, 29)),
newName
)
)
val expectedFileEdit = Api.FileEdit(
context.pkg.mainFile,
expectedEdits,
versionCalculator.evalVersion(contents).toHexString,
versionCalculator
.evalVersion(contents.replaceAll("operator1", newName))
.toHexString
)
context.send(
Api.Request(requestId, Api.RenameSymbol(moduleName, idOperator1, newName))
)
context.receiveNIgnoreStdLib(4) should contain theSameElementsAs Seq(
Api.Response(requestId, Api.SymbolRenamed(newName)),
Api.Response(None, expectedFileEdit),
TestMessages.pending(contextId, idOperator1),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
}
it should "edit file after renaming local" in {
val contextId = UUID.randomUUID()
val requestId = UUID.randomUUID()
val moduleName = "Enso_Test.Test.Main"
val metadata = new Metadata
val symbolOperator1 = metadata.addItem(42, 9, "aa")
val exprOperator1 = metadata.addItem(54, 2, "ab")
val exprOperator2 = metadata.addItem(73, 13, "ac")
val code =
"""from Standard.Base import all
|
|main =
| operator1 = 41
| operator2 = operator1 + 1
| IO.println operator2
|""".stripMargin.linesIterator.mkString("\n")
val contents = metadata.appendToCode(code)
val mainFile = context.writeMain(contents)
// create context
context.send(Api.Request(requestId, Api.CreateContextRequest(contextId)))
context.receive shouldEqual Some(
Api.Response(requestId, Api.CreateContextResponse(contextId))
)
// open file
context.send(
Api.Request(Api.OpenFileNotification(mainFile, contents))
)
context.receiveNone shouldEqual None
// push main
context.send(
Api.Request(
requestId,
Api.PushContextRequest(
contextId,
Api.StackItem.ExplicitCall(
Api.MethodPointer(moduleName, moduleName, "main"),
None,
Vector()
)
)
)
)
context.receiveNIgnoreStdLib(5) should contain theSameElementsAs Seq(
Api.Response(Api.BackgroundJobsStartedNotification()),
Api.Response(requestId, Api.PushContextResponse(contextId)),
TestMessages.update(contextId, exprOperator1, ConstantsGen.INTEGER),
TestMessages.update(contextId, exprOperator2, ConstantsGen.INTEGER),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
// rename operator1
val newName = "foobarbaz"
val expectedEdits = Vector(
TextEdit(
model.Range(model.Position(3, 4), model.Position(3, 13)),
newName
),
TextEdit(
model.Range(model.Position(4, 16), model.Position(4, 25)),
newName
)
)
val expectedFileEdit = Api.FileEdit(
context.pkg.mainFile,
expectedEdits,
versionCalculator.evalVersion(contents).toHexString,
versionCalculator
.evalVersion(contents.replaceAll("operator1", newName))
.toHexString
)
context.send(
Api.Request(
requestId,
Api.RenameSymbol(moduleName, symbolOperator1, newName)
)
)
context.receiveNIgnoreStdLib(5) should contain theSameElementsAs Seq(
Api.Response(requestId, Api.SymbolRenamed(newName)),
Api.Response(None, expectedFileEdit),
TestMessages.pending(contextId, symbolOperator1, exprOperator2),
TestMessages.update(
contextId,
exprOperator2,
ConstantsGen.INTEGER,
typeChanged = false
),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
// modify main
context.send(
Api.Request(
Api.EditFileNotification(
mainFile,
Seq(
TextEdit(
model.Range(model.Position(3, 16), model.Position(3, 18)),
"42"
)
),
execute = true
)
)
)
context.receiveN(4) should contain theSameElementsAs Seq(
TestMessages.pending(contextId, exprOperator1, exprOperator2),
TestMessages
.update(
contextId,
exprOperator1,
ConstantsGen.INTEGER,
typeChanged = false
),
TestMessages
.update(
contextId,
exprOperator2,
ConstantsGen.INTEGER,
typeChanged = false
),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("43")
}
it should "rename module method in main body" in {
val contextId = UUID.randomUUID()
val requestId = UUID.randomUUID()
val moduleName = "Enso_Test.Test.Main"
val metadata = new Metadata
val idFunction1 = metadata.addItem(31, 9)
val code =
"""from Standard.Base import all
|
|function1 x = x + 1
|
|main =
| operator1 = 41
| operator2 = Main.function1 operator1
| IO.println operator2
|""".stripMargin.linesIterator.mkString("\n")
val contents = metadata.appendToCode(code)
val mainFile = context.writeMain(contents)
// create context
context.send(Api.Request(requestId, Api.CreateContextRequest(contextId)))
context.receive shouldEqual Some(
Api.Response(requestId, Api.CreateContextResponse(contextId))
)
// open file
context.send(
Api.Request(Api.OpenFileNotification(mainFile, contents))
)
context.receiveNone shouldEqual None
// push main
context.send(
Api.Request(
requestId,
Api.PushContextRequest(
contextId,
Api.StackItem.ExplicitCall(
Api.MethodPointer(moduleName, moduleName, "main"),
None,
Vector()
)
)
)
)
context.receiveNIgnoreStdLib(3) should contain theSameElementsAs Seq(
Api.Response(Api.BackgroundJobsStartedNotification()),
Api.Response(requestId, Api.PushContextResponse(contextId)),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
// rename operator1
val newName = "function2"
val expectedEdits = Vector(
TextEdit(
model.Range(model.Position(2, 0), model.Position(2, 9)),
newName
),
TextEdit(
model.Range(model.Position(6, 21), model.Position(6, 30)),
newName
)
)
val expectedFileEdit = Api.FileEdit(
context.pkg.mainFile,
expectedEdits,
versionCalculator.evalVersion(contents).toHexString,
versionCalculator
.evalVersion(contents.replaceAll("function1", newName))
.toHexString
)
context.send(
Api.Request(requestId, Api.RenameSymbol(moduleName, idFunction1, newName))
)
context.receiveNIgnoreStdLib(4, 5) should contain theSameElementsAs Seq(
Api.Response(requestId, Api.SymbolRenamed(newName)),
Api.Response(None, expectedFileEdit),
TestMessages.pending(contextId, idFunction1),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
}
it should "rename module method in lambda expression" in {
val contextId = UUID.randomUUID()
val requestId = UUID.randomUUID()
val moduleName = "Enso_Test.Test.Main"
val metadata = new Metadata
val idFunction1 = metadata.addItem(31, 9)
val code =
"""from Standard.Base import all
|
|function1 x = x + 1
|
|main =
| operator1 = 41
| operator2 = x -> Main.function1 x
| IO.println (operator2 operator1)
|""".stripMargin.linesIterator.mkString("\n")
val contents = metadata.appendToCode(code)
val mainFile = context.writeMain(contents)
// create context
context.send(Api.Request(requestId, Api.CreateContextRequest(contextId)))
context.receive shouldEqual Some(
Api.Response(requestId, Api.CreateContextResponse(contextId))
)
// open file
context.send(
Api.Request(Api.OpenFileNotification(mainFile, contents))
)
context.receiveNone shouldEqual None
// push main
context.send(
Api.Request(
requestId,
Api.PushContextRequest(
contextId,
Api.StackItem.ExplicitCall(
Api.MethodPointer(moduleName, moduleName, "main"),
None,
Vector()
)
)
)
)
context.receiveNIgnoreStdLib(3) should contain theSameElementsAs Seq(
Api.Response(Api.BackgroundJobsStartedNotification()),
Api.Response(requestId, Api.PushContextResponse(contextId)),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
// rename operator1
val newName = "function2"
val expectedEdits = Vector(
TextEdit(
model.Range(model.Position(2, 0), model.Position(2, 9)),
newName
),
TextEdit(
model.Range(model.Position(6, 26), model.Position(6, 35)),
newName
)
)
val expectedFileEdit = Api.FileEdit(
context.pkg.mainFile,
expectedEdits,
versionCalculator.evalVersion(contents).toHexString,
versionCalculator
.evalVersion(contents.replaceAll("function1", newName))
.toHexString
)
context.send(
Api.Request(requestId, Api.RenameSymbol(moduleName, idFunction1, newName))
)
context.receiveNIgnoreStdLib(4, 5) should contain theSameElementsAs Seq(
Api.Response(requestId, Api.SymbolRenamed(newName)),
Api.Response(None, expectedFileEdit),
TestMessages.pending(contextId, idFunction1),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
}
it should "edit file after renaming module method" in {
val contextId = UUID.randomUUID()
val requestId = UUID.randomUUID()
val moduleName = "Enso_Test.Test.Main"
val metadata = new Metadata
val symbolFunction1 = metadata.addItem(31, 9, "aa")
val exprOperator1 = metadata.addItem(75, 2, "ab")
val exprOperator2 = metadata.addItem(94, 24, "ac")
val code =
"""from Standard.Base import all
|
|function1 x = x + 1
|
|main =
| operator1 = 41
| operator2 = Main.function1 operator1
| IO.println operator2
|""".stripMargin.linesIterator.mkString("\n")
val contents = metadata.appendToCode(code)
val mainFile = context.writeMain(contents)
// create context
context.send(Api.Request(requestId, Api.CreateContextRequest(contextId)))
context.receive shouldEqual Some(
Api.Response(requestId, Api.CreateContextResponse(contextId))
)
// open file
context.send(
Api.Request(Api.OpenFileNotification(mainFile, contents))
)
context.receiveNone shouldEqual None
// push main
context.send(
Api.Request(
requestId,
Api.PushContextRequest(
contextId,
Api.StackItem.ExplicitCall(
Api.MethodPointer(moduleName, moduleName, "main"),
None,
Vector()
)
)
)
)
context.receiveNIgnoreStdLib(5) should contain theSameElementsAs Seq(
Api.Response(Api.BackgroundJobsStartedNotification()),
Api.Response(requestId, Api.PushContextResponse(contextId)),
TestMessages.update(contextId, exprOperator1, ConstantsGen.INTEGER),
TestMessages.update(
contextId,
exprOperator2,
ConstantsGen.INTEGER,
Api.MethodCall(Api.MethodPointer(moduleName, moduleName, "function1"))
),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
// rename operator1
val newName = "function2"
val expectedEdits = Vector(
TextEdit(
model.Range(model.Position(2, 0), model.Position(2, 9)),
newName
),
TextEdit(
model.Range(model.Position(6, 21), model.Position(6, 30)),
newName
)
)
val expectedFileEdit = Api.FileEdit(
context.pkg.mainFile,
expectedEdits,
versionCalculator.evalVersion(contents).toHexString,
versionCalculator
.evalVersion(contents.replaceAll("function1", newName))
.toHexString
)
context.send(
Api.Request(
requestId,
Api.RenameSymbol(moduleName, symbolFunction1, newName)
)
)
context.receiveNIgnoreStdLib(5) should contain theSameElementsAs Seq(
Api.Response(requestId, Api.SymbolRenamed(newName)),
Api.Response(None, expectedFileEdit),
TestMessages.pending(contextId, symbolFunction1, exprOperator2),
TestMessages.update(
contextId,
exprOperator2,
ConstantsGen.INTEGER,
Api.MethodCall(Api.MethodPointer(moduleName, moduleName, "function2"))
),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
// modify main
context.send(
Api.Request(
Api.EditFileNotification(
mainFile,
Seq(
TextEdit(
model.Range(model.Position(5, 16), model.Position(5, 18)),
"42"
)
),
execute = true
)
)
)
context.receiveN(4) should contain theSameElementsAs Seq(
TestMessages.pending(contextId, exprOperator1, exprOperator2),
TestMessages
.update(
contextId,
exprOperator1,
ConstantsGen.INTEGER,
typeChanged = false
),
TestMessages
.update(
contextId,
exprOperator2,
ConstantsGen.INTEGER,
typeChanged = false,
methodCall = Some(
Api.MethodCall(
Api.MethodPointer(moduleName, moduleName, "function2")
)
)
),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("43")
}
it should "fail with ExpressionNotFound when renaming non-existent symbol" in {
val contextId = UUID.randomUUID()
val requestId = UUID.randomUUID()
val moduleName = "Enso_Test.Test.Main"
val metadata = new Metadata
val code =
"""from Standard.Base import all
|
|main =
| operator1 = 41
| operator2 = operator1 + 1
| IO.println operator2
|""".stripMargin.linesIterator.mkString("\n")
val contents = metadata.appendToCode(code)
val mainFile = context.writeMain(contents)
// create context
context.send(Api.Request(requestId, Api.CreateContextRequest(contextId)))
context.receive shouldEqual Some(
Api.Response(requestId, Api.CreateContextResponse(contextId))
)
// open file
context.send(
Api.Request(Api.OpenFileNotification(mainFile, contents))
)
context.receiveNone shouldEqual None
// push main
context.send(
Api.Request(
requestId,
Api.PushContextRequest(
contextId,
Api.StackItem.ExplicitCall(
Api.MethodPointer(moduleName, moduleName, "main"),
None,
Vector()
)
)
)
)
context.receiveNIgnoreStdLib(3) should contain theSameElementsAs Seq(
Api.Response(Api.BackgroundJobsStartedNotification()),
Api.Response(requestId, Api.PushContextResponse(contextId)),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
// rename operator1
val randomExpressionId = UUID.randomUUID()
val newName = "foobarbaz"
context.send(
Api.Request(
requestId,
Api.RenameSymbol(moduleName, randomExpressionId, newName)
)
)
context.receiveNIgnoreStdLib(1) should contain theSameElementsAs Seq(
Api.Response(
requestId,
Api.SymbolRenameFailed(
Api.SymbolRenameFailed.ExpressionNotFound(randomExpressionId)
)
)
)
context.consumeOut shouldEqual List()
}
it should "fail with OperationNotSupported when renaming an expression" in {
val contextId = UUID.randomUUID()
val requestId = UUID.randomUUID()
val moduleName = "Enso_Test.Test.Main"
val metadata = new Metadata
val exprOperator1 = metadata.addItem(54, 2)
val code =
"""from Standard.Base import all
|
|main =
| operator1 = 41
| operator2 = operator1 + 1
| IO.println operator2
|""".stripMargin.linesIterator.mkString("\n")
val contents = metadata.appendToCode(code)
val mainFile = context.writeMain(contents)
// create context
context.send(Api.Request(requestId, Api.CreateContextRequest(contextId)))
context.receive shouldEqual Some(
Api.Response(requestId, Api.CreateContextResponse(contextId))
)
// open file
context.send(
Api.Request(Api.OpenFileNotification(mainFile, contents))
)
context.receiveNone shouldEqual None
// push main
context.send(
Api.Request(
requestId,
Api.PushContextRequest(
contextId,
Api.StackItem.ExplicitCall(
Api.MethodPointer(moduleName, moduleName, "main"),
None,
Vector()
)
)
)
)
context.receiveNIgnoreStdLib(4) should contain theSameElementsAs Seq(
Api.Response(Api.BackgroundJobsStartedNotification()),
Api.Response(requestId, Api.PushContextResponse(contextId)),
TestMessages.update(contextId, exprOperator1, ConstantsGen.INTEGER),
context.executionComplete(contextId)
)
context.consumeOut shouldEqual List("42")
// rename operator1
val newName = "foobarbaz"
context.send(
Api.Request(
requestId,
Api.RenameSymbol(moduleName, exprOperator1, newName)
)
)
context.receiveNIgnoreStdLib(1) should contain theSameElementsAs Seq(
Api.Response(
requestId,
Api.SymbolRenameFailed(
Api.SymbolRenameFailed.OperationNotSupported(exprOperator1)
)
)
)
context.consumeOut shouldEqual List()
}
}

View File

@ -4256,7 +4256,7 @@ class RuntimeServerTest
)
)
)
context.receiveNIgnoreStdLib(4) should contain theSameElementsAs Seq(
context.receiveNIgnoreStdLib(3) should contain theSameElementsAs Seq(
Api.Response(Api.BackgroundJobsStartedNotification()),
Api.Response(requestId, Api.PushContextResponse(contextId)),
Api.Response(
@ -4264,8 +4264,7 @@ class RuntimeServerTest
contextId,
Api.ExecutionResult.Failure("Module Unnamed.Main not found.", None)
)
),
Api.Response(Api.BackgroundJobsStartedNotification())
)
)
}
@ -4305,7 +4304,7 @@ class RuntimeServerTest
)
)
)
context.receiveNIgnoreStdLib(4) should contain theSameElementsAs Seq(
context.receiveNIgnoreStdLib(3) should contain theSameElementsAs Seq(
Api.Response(Api.BackgroundJobsStartedNotification()),
Api.Response(requestId, Api.PushContextResponse(contextId)),
Api.Response(
@ -4316,8 +4315,7 @@ class RuntimeServerTest
Some(mainFile)
)
)
),
Api.Response(Api.BackgroundJobsStartedNotification())
)
)
}
@ -4357,7 +4355,7 @@ class RuntimeServerTest
)
)
)
context.receiveNIgnoreStdLib(4) should contain theSameElementsAs Seq(
context.receiveNIgnoreStdLib(3) should contain theSameElementsAs Seq(
Api.Response(Api.BackgroundJobsStartedNotification()),
Api.Response(requestId, Api.PushContextResponse(contextId)),
Api.Response(
@ -4368,8 +4366,7 @@ class RuntimeServerTest
Some(mainFile)
)
)
),
Api.Response(Api.BackgroundJobsStartedNotification())
)
)
}
@ -4479,7 +4476,7 @@ class RuntimeServerTest
)
)
)
context.receiveNIgnoreStdLib(4) should contain theSameElementsAs Seq(
context.receiveNIgnoreStdLib(3) should contain theSameElementsAs Seq(
Api.Response(Api.BackgroundJobsStartedNotification()),
Api.Response(requestId, Api.PushContextResponse(contextId)),
Api.Response(
@ -4502,8 +4499,7 @@ class RuntimeServerTest
)
)
)
),
Api.Response(Api.BackgroundJobsStartedNotification())
)
)
}
@ -4621,7 +4617,7 @@ class RuntimeServerTest
)
)
)
context.receiveNIgnoreStdLib(4) should contain theSameElementsAs Seq(
context.receiveNIgnoreStdLib(3) should contain theSameElementsAs Seq(
Api.Response(Api.BackgroundJobsStartedNotification()),
Api.Response(requestId, Api.PushContextResponse(contextId)),
Api.Response(
@ -4652,8 +4648,7 @@ class RuntimeServerTest
)
)
)
),
Api.Response(Api.BackgroundJobsStartedNotification())
)
)
}

View File

@ -0,0 +1,136 @@
package org.enso.compiler.refactoring
import org.enso.compiler.core.IR
import org.enso.compiler.data.BindingsMap
import org.enso.compiler.pass.analyse.DataflowAnalysis
import org.enso.compiler.pass.resolve.MethodCalls
import org.enso.pkg.QualifiedName
trait IRUtils {
/** Find the node by external id.
*
* @param ir the syntax tree
* @param externalId the external id to look for
* @return the first node with the given external id in `ir`
*/
def findByExternalId(ir: IR, externalId: IR.ExternalId): Option[IR] = {
ir.preorder.find(_.getExternalId.contains(externalId))
}
/** Find usages of a local defined in the body block.
*
* @param ir the syntax tree
* @param literal the literal name of the local
* @return the list of usages of the given literal in the `ir`
*/
def findLocalUsages(
ir: IR,
literal: IR.Name.Literal
): Option[Set[IR.Name.Literal]] = {
for {
usages <- findStaticUsages(ir, literal)
} yield {
usages.collect {
case usage: IR.Name.Literal if usage.name == literal.name => usage
}
}
}
/** Find usages of a method defined on module.
*
* @param moduleName the qualified module name
* @param ir the syntax tree
* @param node the name of the method
* @return the list of usages of the given method in the `ir`
*/
def findModuleMethodUsages(
moduleName: QualifiedName,
ir: IR,
node: IR.Name
): Option[Set[IR.Name.Literal]] =
for {
usages <- findDynamicUsages(ir, node)
} yield {
usages
.collect {
case usage: IR.Name.Literal
if usage.isMethod && usage.name == node.name =>
usage
}
.flatMap { symbol =>
symbol.getMetadata(MethodCalls).flatMap { resolution =>
resolution.target match {
case BindingsMap.ResolvedMethod(module, _)
if module.getName == moduleName =>
Some(symbol)
case _ =>
None
}
}
}
}
/** Find usages of a static dependency in the [[DataflowAnalysis]] metadata.
*
* @param ir the syntax tree
* @param literal the name to look for
* @return the list of usages of the given name in the `ir`
*/
private def findStaticUsages(
ir: IR,
literal: IR.Name.Literal
): Option[Set[IR]] = {
for {
metadata <- ir.getMetadata(DataflowAnalysis)
key = DataflowAnalysis.DependencyInfo.Type
.Static(literal.getId, literal.getExternalId)
dependents <- metadata.dependents.get(key)
} yield {
dependents
.flatMap {
case _: DataflowAnalysis.DependencyInfo.Type.Dynamic =>
None
case DataflowAnalysis.DependencyInfo.Type.Static(id, _) =>
findById(ir, id)
}
}
}
/** Find usages of a dynamic dependency in the [[DataflowAnalysis]] metadata.
*
* @param ir the syntax tree
* @param node the name to look for
* @return the list of usages of the given name in the `ir`
*/
private def findDynamicUsages(
ir: IR,
node: IR.Name
): Option[Set[IR]] = {
for {
metadata <- ir.getMetadata(DataflowAnalysis)
key = DataflowAnalysis.DependencyInfo.Type.Dynamic(node.name, None)
dependents <- metadata.dependents.get(key)
} yield {
dependents
.flatMap {
case _: DataflowAnalysis.DependencyInfo.Type.Dynamic =>
None
case DataflowAnalysis.DependencyInfo.Type.Static(id, _) =>
findById(ir, id)
}
}
}
/** Find node by id.
*
* @param ir the syntax tree
* @param id the identifier to look for
* @return the `ir` node with the given identifier
*/
private def findById(ir: IR, id: IR.Identifier): Option[IR] = {
ir.preorder.find(_.getId == id)
}
}
object IRUtils extends IRUtils

View File

@ -0,0 +1,264 @@
package org.enso.compiler.refactoring
import org.enso.compiler.core.IR
import org.enso.interpreter.runtime
import org.enso.interpreter.runtime.EnsoContext
import org.enso.interpreter.test.InterpreterContext
import org.enso.pkg.QualifiedName
import org.enso.polyglot.{LanguageInfo, MethodNames}
import org.scalatest.OptionValues
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
import java.util.UUID
class IRUtilsTest extends AnyWordSpecLike with Matchers with OptionValues {
private val ctx = new InterpreterContext()
private val langCtx = ctx.ctx
.getBindings(LanguageInfo.ID)
.invokeMember(MethodNames.TopScope.LEAK_CONTEXT)
.asHostObject[EnsoContext]()
implicit private class PreprocessModule(code: String) {
private val Module = QualifiedName(List("Unnamed"), "Test")
def preprocessModule(name: QualifiedName): IR.Module = {
val module = new runtime.Module(
name,
null,
code.stripMargin.linesIterator.mkString("\n")
)
langCtx.getCompiler.run(module)
module.getIr
}
def preprocessModule: IR.Module =
preprocessModule(Module)
}
private def findUsagesOfLiteral(
module: IR.Module,
ir: IR
): Option[Set[IR.Name.Literal]] = {
ir match {
case literal: IR.Name.Literal =>
IRUtils.findLocalUsages(module, literal)
case _ =>
fail(s"Trying to find literal usages of [${ir.getClass}]: [$ir]")
}
}
private def findUsagesOfStaticMethod(
moduleName: QualifiedName,
module: IR.Module,
ir: IR
): Option[Set[IR.Name.Literal]] = {
ir match {
case methodRef: IR.Name.MethodReference
if methodRef.typePointer.isEmpty =>
IRUtils.findModuleMethodUsages(
moduleName,
module,
methodRef.methodName
)
case _ =>
fail(s"Trying to find method usages of [${ir.getClass}]: [$ir]")
}
}
"IRUtils" should {
"find usages of a literal in expression" in {
val uuid1 = new UUID(0, 1)
val code =
s"""main =
| operator1 = 41
| operator2 = operator1 + 1
| operator2
|
|
|#### METADATA ####
|[[{"index": {"value": 11}, "size": {"value": 9}}, "$uuid1"]]
|[]
|""".stripMargin
val module = code.preprocessModule
val operator1 = IRUtils.findByExternalId(module, uuid1).get
val usages = findUsagesOfLiteral(module, operator1)
usages.value.size shouldEqual 1
usages.value.foreach {
case _: IR.Name.Literal => succeed
case ir => fail(s"Not a literal: $ir")
}
}
"find usages of a literal in a complex expression" in {
val uuid1 = new UUID(0, 1)
val code =
s"""main =
| operator1 = 41
| operator2 = operator1 + operator1 + 1
| operator2
|
|
|#### METADATA ####
|[[{"index": {"value": 11}, "size": {"value": 9}}, "$uuid1"]]
|[]
|""".stripMargin
val module = code.preprocessModule
val operator1 = IRUtils.findByExternalId(module, uuid1).get
val usages = findUsagesOfLiteral(module, operator1)
usages.value.size shouldEqual 2
usages.value.foreach {
case _: IR.Name.Literal => succeed
case ir => fail(s"Not a literal: $ir")
}
}
"find usages of a literal in a lambda" in {
val uuid1 = new UUID(0, 1)
val code =
s"""main =
| operator1 = 41
| operator2 = "".map (x -> x + operator1)
| operator2
|
|
|#### METADATA ####
|[[{"index": {"value": 11}, "size": {"value": 9}}, "$uuid1"]]
|[]
|""".stripMargin
val module = code.preprocessModule
val operator1 = IRUtils.findByExternalId(module, uuid1).get
val usages = findUsagesOfLiteral(module, operator1)
usages.value.size shouldEqual 1
usages.value.foreach {
case _: IR.Name.Literal => succeed
case ir => fail(s"Not a literal: $ir")
}
}
"find usages of a static method call in main body" in {
val uuid1 = new UUID(0, 1)
val moduleName = QualifiedName(List("Unnamed"), "Test")
val code =
s"""function1 x = x + 1
|
|main =
| operator1 = 41
| operator2 = Test.function1 operator1
| operator2
|
|
|#### METADATA ####
|[[{"index": {"value": 0}, "size": {"value": 9}}, "$uuid1"]]
|[]
|""".stripMargin
val module = code.preprocessModule(moduleName)
val operator1 = IRUtils.findByExternalId(module, uuid1).get
val usages = findUsagesOfStaticMethod(moduleName, module, operator1)
usages.value.size shouldEqual 1
usages.value.foreach {
case _: IR.Name.Literal => succeed
case ir => fail(s"Not a literal: $ir")
}
}
"find usages of a static call in lambda" in {
val uuid1 = new UUID(0, 1)
val moduleName = QualifiedName(List("Unnamed"), "Test")
val code =
s"""function1 x = x
|
|main =
| operator1 = 41
| operator2 = Test.function1 (x -> Test.function1 x)
| operator2
|
|
|#### METADATA ####
|[[{"index": {"value": 0}, "size": {"value": 9}}, "$uuid1"]]
|[]
|""".stripMargin
val module = code.preprocessModule(moduleName)
val operator1 = IRUtils.findByExternalId(module, uuid1).get
val usages = findUsagesOfStaticMethod(moduleName, module, operator1)
usages.value.size shouldEqual 2
usages.value.foreach {
case _: IR.Name.Literal => succeed
case ir => fail(s"Not a literal: $ir")
}
}
"find usages of a static method call in presence of an instance method" in {
val uuid1 = new UUID(0, 1)
val moduleName = QualifiedName(List("Unnamed"), "Test")
val code =
s"""function1 x = x
|
|main =
| operator1 = 41
| operator2 = Test.function1 operator1
| operator3 = operator2.function1
|
|
|#### METADATA ####
|[[{"index": {"value": 0}, "size": {"value": 9}}, "$uuid1"]]
|[]
|""".stripMargin
val module = code.preprocessModule(moduleName)
val operator1 = IRUtils.findByExternalId(module, uuid1).get
val usages = findUsagesOfStaticMethod(moduleName, module, operator1)
usages.value.size shouldEqual 1
usages.value.foreach {
case _: IR.Name.Literal => succeed
case ir => fail(s"Not a literal: $ir")
}
}
"find usages of a static method call in presence of a type method" in {
val uuid1 = new UUID(0, 1)
val moduleName = QualifiedName(List("Unnamed"), "Test")
val code =
s"""function1 x = x
|
|type A
| function1 x = x
|
|main =
| operator1 = 41
| operator2 = Test.function1 operator1
| operator3 = A.function1
|
|
|#### METADATA ####
|[[{"index": {"value": 0}, "size": {"value": 9}}, "$uuid1"]]
|[]
|""".stripMargin
val module = code.preprocessModule(moduleName)
val operator1 = IRUtils.findByExternalId(module, uuid1).get
val usages = findUsagesOfStaticMethod(moduleName, module, operator1)
usages.value.size shouldEqual 1
usages.value.foreach {
case _: IR.Name.Literal => succeed
case ir => fail(s"Not a literal: $ir")
}
}
}
}

View File

@ -54,10 +54,6 @@ class SuggestionBuilderTest extends AnyWordSpecLike with Matchers {
Vector()
)
@annotation.nowarn
def endOfLine(line: Int, character: Int): Suggestion.Position =
Suggestion.Position(line + 1, 0)
"SuggestionBuilder" should {
"build method without explicit arguments" in {

View File

@ -0,0 +1,103 @@
package org.enso.refactoring.validation;
public final class MethodNameValidation {
public static final String DEFAULT_NAME = "operator";
private static final char CHAR_UNDERSCORE = '_';
private static final char CHAR_LOWERCASE_A = 'a';
private static final char CHAR_LOWERCASE_Z = 'z';
private static final char CHAR_UPPERCASE_A = 'A';
private static final char CHAR_UPPERCASE_Z = 'Z';
/**
* Normalize the name to make it a valid identifier of a method.
*
* @param name the name to normalize.
* @return the normalized name.
*/
public static String normalize(String name) {
if (name.isEmpty()) {
return DEFAULT_NAME;
}
if (isAllowedFirstCharacter(Character.toLowerCase(name.charAt(0)))) {
return toLowerSnakeCase(name);
}
return toLowerSnakeCase(DEFAULT_NAME + "_" + name);
}
/**
* @return {@code true} if the provided name is a valid identifier of a method and {@code false}
* otherwise.
*/
public static boolean isAllowedName(String name) {
return !name.isEmpty()
&& isAllowedFirstCharacter(name.charAt(0))
&& name.chars().allMatch(MethodNameValidation::isAllowedNameCharacter);
}
private static String toLowerSnakeCase(String name) {
if (name.isEmpty()) {
return name;
}
StringBuilder result = new StringBuilder(name.length());
char[] chars = name.toCharArray();
char previous = name.charAt(0);
for (int i = 0; i < chars.length; i++) {
char current = name.charAt(i);
if (current == CHAR_UNDERSCORE && previous == CHAR_UNDERSCORE) {
continue;
}
if (isLetterAscii(current) || Character.isDigit(current) || current == CHAR_UNDERSCORE) {
if (Character.isUpperCase(current)
&& (Character.isLowerCase(previous) || Character.isDigit(previous))) {
result.append(CHAR_UNDERSCORE);
}
if (Character.isLowerCase(current) && Character.isDigit(previous)) {
result.append(CHAR_UNDERSCORE);
}
result.append(Character.toLowerCase(current));
previous = current;
}
if (Character.isWhitespace(current) && previous != CHAR_UNDERSCORE) {
result.append(CHAR_UNDERSCORE);
previous = CHAR_UNDERSCORE;
}
}
char lastChar = result.charAt(result.length() - 1);
if (lastChar == CHAR_UNDERSCORE) {
result.setLength(result.length() - 1);
}
return result.toString();
}
private static boolean isAllowedFirstCharacter(int c) {
return isLowerCaseAscii(c);
}
private static boolean isAllowedNameCharacter(int c) {
return isAlphanumericAscii(c) || c == CHAR_UNDERSCORE;
}
private static boolean isAlphanumericAscii(int c) {
return isLowerCaseAscii(c) || Character.isDigit(c);
}
private static boolean isLetterAscii(int c) {
return isLowerCaseAscii(c) || isUpperCaseAscii(c);
}
private static boolean isLowerCaseAscii(int c) {
return c >= CHAR_LOWERCASE_A && c <= CHAR_LOWERCASE_Z;
}
private static boolean isUpperCaseAscii(int c) {
return c >= CHAR_UPPERCASE_A && c <= CHAR_UPPERCASE_Z;
}
}

View File

@ -0,0 +1,44 @@
package org.enso.refactoring
import org.enso.syntax.text.Location
import org.enso.text.editing.model
import org.enso.text.editing.{IndexedSource, TextEditor}
import org.enso.text.editing.model.TextEdit
object RenameUtils {
/** Create a list of edits that should be made to rename the provided
* occurrences of text.
*
* @param source the original source
* @param occurrences the occurrences in the source that should be replaced
* @param newText the text to replace in the provided locations
* @return a list of text edits that should be applied to the original source
* in order to replace the provided text occurrences with the new text.
*/
def buildEdits[A: IndexedSource: TextEditor](
source: A,
occurrences: Seq[Location],
newText: String
): Seq[TextEdit] = {
val (_, _, builder) = occurrences
.sortBy(_.start)
.foldLeft((0, source, Vector.newBuilder[TextEdit])) {
case ((offset, source, builder), location) =>
val start =
implicitly[IndexedSource[A]]
.toPosition(location.start + offset, source)
val end =
implicitly[IndexedSource[A]]
.toPosition(location.end + offset, source)
val range = model.Range(start, end)
val newOffset = offset - location.length + newText.length
val textEdit = TextEdit(range, newText)
val newSource = implicitly[TextEditor[A]].edit(source, textEdit)
(newOffset, newSource, builder += textEdit)
}
builder.result()
}
}

View File

@ -0,0 +1,162 @@
package org.enso.refactoring;
import org.enso.syntax.text.Location;
import org.enso.text.buffer.Rope;
import org.enso.text.editing.IndexedSource;
import org.enso.text.editing.IndexedSource$;
import org.enso.text.editing.JavaEditorAdapter;
import org.enso.text.editing.RopeTextEditor$;
import org.enso.text.editing.TextEditValidationFailure;
import org.enso.text.editing.TextEditor;
import org.enso.text.editing.model;
import org.junit.Assert;
import org.junit.Test;
import scala.collection.immutable.Seq;
import scala.collection.immutable.VectorBuilder;
import scala.util.Either;
public class RenameUtilsTest {
private final IndexedSource<Rope> indexedSource;
private final TextEditor<Rope> textEditor;
public RenameUtilsTest() {
indexedSource = IndexedSource$.MODULE$.RopeIndexedSource();
textEditor = RopeTextEditor$.MODULE$;
}
@Test
public void buildEditsSingleLine() {
Rope source = Rope.apply("foo a foo baz");
String newName = "quux";
Seq<Location> occurrences = vec(new Location(0, 3), new Location(6, 9));
Seq<model.TextEdit> edits =
RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor);
Assert.assertEquals(2, edits.length());
Either<TextEditValidationFailure, Rope> result = JavaEditorAdapter.applyEdits(source, edits);
Assert.assertTrue(result.isRight());
Rope expected = Rope.apply("quux a quux baz");
Assert.assertEquals(expected.toString(), result.toOption().get().toString());
}
@Test
public void buildEditsMultiLine() {
Rope source = Rope.apply("foo a\nbaz foo");
String newName = "quux";
Seq<Location> occurrences = vec(new Location(0, 3), new Location(10, 13));
Seq<model.TextEdit> edits =
RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor);
Assert.assertEquals(2, edits.length());
Either<TextEditValidationFailure, Rope> result = JavaEditorAdapter.applyEdits(source, edits);
Assert.assertTrue(result.isRight());
Rope expected = Rope.apply("quux a\nbaz quux");
Assert.assertEquals(expected.toString(), result.toOption().get().toString());
}
@Test
public void buildEditsUnordered() {
Rope source = Rope.apply("foo bar foo baz");
String newName = "quux";
Seq<Location> occurrences = vec(new Location(8, 11), new Location(0, 3));
Seq<model.TextEdit> edits =
RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor);
Assert.assertEquals(2, edits.length());
Either<TextEditValidationFailure, Rope> result = JavaEditorAdapter.applyEdits(source, edits);
Assert.assertTrue(result.isRight());
Rope expected = Rope.apply("quux bar quux baz");
Assert.assertEquals(expected.toString(), result.toOption().get().toString());
}
@Test
public void buildEditsUnorderedMultiLine() {
Rope source = Rope.apply("foo a\nbaz foo");
String newName = "quux";
Seq<Location> occurrences = vec(new Location(10, 13), new Location(0, 3));
Seq<model.TextEdit> edits =
RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor);
Assert.assertEquals(2, edits.length());
Either<TextEditValidationFailure, Rope> result = JavaEditorAdapter.applyEdits(source, edits);
Assert.assertTrue(result.isRight());
Rope expected = Rope.apply("quux a\nbaz quux");
Assert.assertEquals(expected.toString(), result.toOption().get().toString());
}
@Test
public void buildEditsEmptyOccurrences() {
Rope source = Rope.apply("foo bar foo baz");
String newName = "quux";
Seq<Location> occurrences = vec();
Seq<model.TextEdit> edits =
RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor);
Assert.assertEquals(0, edits.length());
Either<TextEditValidationFailure, Rope> result = JavaEditorAdapter.applyEdits(source, edits);
Assert.assertTrue(result.isRight());
Assert.assertEquals(source.toString(), result.toOption().get().toString());
}
@Test
public void buildEditsRemovingOccurrences() {
Rope source = Rope.apply("foo bar foo baz");
String newName = "";
Seq<Location> occurrences = vec(new Location(0, 3), new Location(8, 11));
Seq<model.TextEdit> edits =
RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor);
Assert.assertEquals(2, edits.length());
Either<TextEditValidationFailure, Rope> result = JavaEditorAdapter.applyEdits(source, edits);
Assert.assertTrue(result.isRight());
Rope expected = Rope.apply(" bar baz");
Assert.assertEquals(expected.toString(), result.toOption().get().toString());
}
@Test
public void buildEditsRemovingOccurrencesMultiline() {
Rope source = Rope.apply("foo xs\nfoo baz");
String newName = "";
Seq<Location> occurrences = vec(new Location(0, 3), new Location(7, 10));
Seq<model.TextEdit> edits =
RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor);
Assert.assertEquals(2, edits.length());
Either<TextEditValidationFailure, Rope> result = JavaEditorAdapter.applyEdits(source, edits);
Assert.assertTrue(result.isRight());
Rope expected = Rope.apply(" xs\n baz");
Assert.assertEquals(expected.toString(), result.toOption().get().toString());
}
@SafeVarargs
private static <A> Seq<A> vec(A... elems) {
VectorBuilder<A> builder = new VectorBuilder<>();
for (A elem : elems) {
builder.addOne(elem);
}
return builder.result();
}
}

View File

@ -0,0 +1,53 @@
package org.enso.refactoring.validation;
import org.junit.Assert;
import org.junit.Test;
public class MethodNameValidationTest {
public MethodNameValidationTest() {}
@Test
public void isAllowedName() {
Assert.assertFalse(MethodNameValidation.isAllowedName(""));
Assert.assertFalse(MethodNameValidation.isAllowedName("@#$"));
Assert.assertFalse(MethodNameValidation.isAllowedName("_foo"));
Assert.assertFalse(MethodNameValidation.isAllowedName("42"));
Assert.assertFalse(MethodNameValidation.isAllowedName("42_foo"));
Assert.assertFalse(MethodNameValidation.isAllowedName("Foo"));
Assert.assertFalse(MethodNameValidation.isAllowedName("foo_Bar"));
Assert.assertFalse(MethodNameValidation.isAllowedName("foo bar"));
Assert.assertTrue(MethodNameValidation.isAllowedName("foo"));
Assert.assertTrue(MethodNameValidation.isAllowedName("foo_bar"));
Assert.assertTrue(MethodNameValidation.isAllowedName("foo_42"));
Assert.assertTrue(MethodNameValidation.isAllowedName("foo42"));
}
@Test
public void normalizeName() {
Assert.assertEquals("foo", MethodNameValidation.normalize("FOO"));
Assert.assertEquals("foo_bar", MethodNameValidation.normalize("FOO_BAR"));
Assert.assertEquals("foo42_bar", MethodNameValidation.normalize("FOO42BAR"));
Assert.assertEquals("foo_bar", MethodNameValidation.normalize("FOO bar"));
Assert.assertEquals("f_oo_bar", MethodNameValidation.normalize("fOoBar"));
Assert.assertEquals("foo_42", MethodNameValidation.normalize("foo_42"));
Assert.assertEquals("foo_bar", MethodNameValidation.normalize("foo_bar"));
Assert.assertEquals("foo_bar", MethodNameValidation.normalize("foo__bar"));
Assert.assertEquals("foo_bar", MethodNameValidation.normalize("foo$_$bar"));
Assert.assertEquals(MethodNameValidation.DEFAULT_NAME, MethodNameValidation.normalize("!$%"));
Assert.assertEquals(MethodNameValidation.DEFAULT_NAME, MethodNameValidation.normalize("!_%"));
Assert.assertEquals(MethodNameValidation.DEFAULT_NAME + "_foo", MethodNameValidation.normalize("_foo"));
Assert.assertEquals(MethodNameValidation.DEFAULT_NAME + "_foo", MethodNameValidation.normalize("__foo"));
Assert.assertEquals(MethodNameValidation.DEFAULT_NAME + "_foo", MethodNameValidation.normalize("__foo__"));
Assert.assertEquals(MethodNameValidation.DEFAULT_NAME + "_foo", MethodNameValidation.normalize(" foo "));
Assert.assertEquals("foo_bar", MethodNameValidation.normalize("foo bar"));
Assert.assertEquals("foo42", MethodNameValidation.normalize("foo42"));
Assert.assertEquals("foo42_bar", MethodNameValidation.normalize("foo42bar"));
Assert.assertEquals("foo_42_bar", MethodNameValidation.normalize("foo$ 42$bar"));
Assert.assertEquals("foo_bar", MethodNameValidation.normalize("fooBar"));
Assert.assertEquals("foo_bar", MethodNameValidation.normalize("FooBar"));
Assert.assertEquals("foo42_bar", MethodNameValidation.normalize("Foo42Bar"));
}
}