diff --git a/CHANGELOG.md b/CHANGELOG.md index b4ac4d776b0..79c3f4fd2e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/build.sbt b/build.sbt index ef48f291925..fbe6c2c8e69 100644 --- a/build.sbt +++ b/build.sbt @@ -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" ) diff --git a/docs/language-server/protocol-language-server.md b/docs/language-server/protocol-language-server.md index 360501a6969..9b6806bbddd 100644 --- a/docs/language-server/protocol-language-server.md +++ b/docs/language-server/protocol-language-server.md @@ -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) @@ -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 []" +} +``` + +### `FailedToApplyEdits` + +Signals that the refactoring operation was not able to apply generated edits. + +```typescript +"error" : { + "code" : 9002, + "message" : "Failed to apply edits to module []" +} +``` + +### `RefactoringNotSupported` + +Signals that the refactoring of the given expression is not supported. + +```typescript +"error" : { + "code" : 9003, + "message" : "Refactoring not supported for expression []" +} +``` diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala b/engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala index 4791f698560..4e0820451f2 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala @@ -226,6 +226,7 @@ class MainModule(serverConfig: LanguageServerConfig, logLevel: LogLevel) { fileManager, vcsManager, runtimeConnector, + contentRootManagerWrapper, TimingsConfig.default().withAutoSave(6.seconds) ), "buffer-registry" diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala index 2351e38c3ab..33654cde0af 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala @@ -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 ) ) } diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonRpc.scala b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonRpc.scala index feadedae3db..29ed73bd8f4 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonRpc.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonRpc.scala @@ -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) diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/refactoring/RefactoringApi.scala b/engine/language-server/src/main/scala/org/enso/languageserver/refactoring/RefactoringApi.scala index 7a577a578c1..fab2844f130 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/refactoring/RefactoringApi.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/refactoring/RefactoringApi.scala @@ -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]" + ) + } diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/refactoring/RenameFailureMapper.scala b/engine/language-server/src/main/scala/org/enso/languageserver/refactoring/RenameFailureMapper.scala new file mode 100644 index 00000000000..e6e2ec46078 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/refactoring/RenameFailureMapper.scala @@ -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) + } + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/refactoring/RenameSymbolHandler.scala b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/refactoring/RenameSymbolHandler.scala new file mode 100644 index 00000000000..64afa6bc789 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/refactoring/RenameSymbolHandler.scala @@ -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) + ) + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/text/ApplyEditHandler.scala b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/text/ApplyEditHandler.scala index 304953ea47d..a8e68dcec72 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/text/ApplyEditHandler.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/text/ApplyEditHandler.scala @@ -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) ) diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/runtime/ExecutionApi.scala b/engine/language-server/src/main/scala/org/enso/languageserver/runtime/ExecutionApi.scala index 640917c9f63..e9751a04fd5 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/runtime/ExecutionApi.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/runtime/ExecutionApi.scala @@ -277,5 +277,4 @@ object ExecutionApi { Encoder[ContextRegistryProtocol.ExecutionDiagnostic].apply(_) ) } - } diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/runtime/RuntimeFailureMapper.scala b/engine/language-server/src/main/scala/org/enso/languageserver/runtime/RuntimeFailureMapper.scala index 64c4d9c9fd2..8ac81f40e48 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/runtime/RuntimeFailureMapper.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/runtime/RuntimeFailureMapper.scala @@ -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( diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/text/BufferRegistry.scala b/engine/language-server/src/main/scala/org/enso/languageserver/text/BufferRegistry.scala index d4b2122407a..fa8cb9ea353 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/text/BufferRegistry.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/text/BufferRegistry.scala @@ -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 ) ) diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/text/CollaborativeBuffer.scala b/engine/language-server/src/main/scala/org/enso/languageserver/text/CollaborativeBuffer.scala index ba7c009e8b7..e8b76b0e3be 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/text/CollaborativeBuffer.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/text/CollaborativeBuffer.scala @@ -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 { diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/text/TextProtocol.scala b/engine/language-server/src/main/scala/org/enso/languageserver/text/TextProtocol.scala index 4681ca22a6e..08696ae5730 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/text/TextProtocol.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/text/TextProtocol.scala @@ -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. */ diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/BaseServerTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/BaseServerTest.scala index 38d39471052..99e5208d718 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/BaseServerTest.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/BaseServerTest.scala @@ -198,6 +198,7 @@ class BaseServerTest fileManager, vcsManager, runtimeConnectorProbe.ref, + contentRootManagerWrapper, timingsConfig )( Sha3_224VersionCalculator diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/RefactoringTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/RefactoringTest.scala new file mode 100644 index 00000000000..3b7f5129b31 --- /dev/null +++ b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/RefactoringTest.scala @@ -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]"} + } + } + """) + } + } + +} diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/TextOperationsTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/TextOperationsTest.scala index 2aacd7271f2..939bd8cbdc4 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/TextOperationsTest.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/TextOperationsTest.scala @@ -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 { diff --git a/engine/polyglot-api/src/main/scala/org/enso/polyglot/runtime/Runtime.scala b/engine/polyglot-api/src/main/scala/org/enso/polyglot/runtime/Runtime.scala index d64ca613baf..f9c87430707 100644 --- a/engine/polyglot-api/src/main/scala/org/enso/polyglot/runtime/Runtime.scala +++ b/engine/polyglot-api/src/main/scala/org/enso/polyglot/runtime/Runtime.scala @@ -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 diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/command/Command.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/command/Command.scala index 02d41be5651..6d557024f70 100644 --- a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/command/Command.scala +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/command/Command.scala @@ -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)) + } } diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/command/CommandFactory.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/command/CommandFactory.scala index 6d2b5ead6f3..2c345887ac4 100644 --- a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/command/CommandFactory.scala +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/command/CommandFactory.scala @@ -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) diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/command/RenameSymbolCmd.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/command/RenameSymbolCmd.scala new file mode 100644 index 00000000000..e1561f8c517 --- /dev/null +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/command/RenameSymbolCmd.scala @@ -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 () +} diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/CommandExecutionEngine.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/CommandExecutionEngine.scala index 74d71be6c20..8c4d7163069 100644 --- a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/CommandExecutionEngine.scala +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/CommandExecutionEngine.scala @@ -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 */ diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/JobExecutionEngine.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/JobExecutionEngine.scala index 487c174f766..8113cc8d794 100644 --- a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/JobExecutionEngine.scala +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/JobExecutionEngine.scala @@ -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 */ diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/PendingEdits.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/PendingEdits.scala index 5e5407b8344..3b46582720d 100644 --- a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/PendingEdits.scala +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/PendingEdits.scala @@ -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] } diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/PendingFileEdits.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/PendingFileEdits.scala index f493019ad8c..d28ef04538c 100644 --- a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/PendingFileEdits.scala +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/PendingFileEdits.scala @@ -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 } diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/RuntimeContext.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/RuntimeContext.scala index d88cd6826da..b05c2bd8432 100644 --- a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/RuntimeContext.scala +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/RuntimeContext.scala @@ -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 ) diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/EnsureCompiledJob.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/EnsureCompiledJob.scala index 785ecbfca95..95cf32c8e23 100644 --- a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/EnsureCompiledJob.scala +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/EnsureCompiledJob.scala @@ -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 diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/RefactoringRenameJob.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/RefactoringRenameJob.scala new file mode 100644 index 00000000000..a2e1564ca84 --- /dev/null +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/RefactoringRenameJob.scala @@ -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]" + ) +} diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/UpsertVisualizationJob.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/UpsertVisualizationJob.scala index 1d87cdc93e2..384f05764a0 100644 --- a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/UpsertVisualizationJob.scala +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/UpsertVisualizationJob.scala @@ -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 diff --git a/engine/runtime-with-instruments/src/test/scala/org/enso/interpreter/test/instrument/RuntimeRefactoringTest.scala b/engine/runtime-with-instruments/src/test/scala/org/enso/interpreter/test/instrument/RuntimeRefactoringTest.scala new file mode 100644 index 00000000000..7966e9c288c --- /dev/null +++ b/engine/runtime-with-instruments/src/test/scala/org/enso/interpreter/test/instrument/RuntimeRefactoringTest.scala @@ -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() + } +} diff --git a/engine/runtime-with-instruments/src/test/scala/org/enso/interpreter/test/instrument/RuntimeServerTest.scala b/engine/runtime-with-instruments/src/test/scala/org/enso/interpreter/test/instrument/RuntimeServerTest.scala index 8f2e5a3157d..4f59ca086ab 100644 --- a/engine/runtime-with-instruments/src/test/scala/org/enso/interpreter/test/instrument/RuntimeServerTest.scala +++ b/engine/runtime-with-instruments/src/test/scala/org/enso/interpreter/test/instrument/RuntimeServerTest.scala @@ -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()) + ) ) } diff --git a/engine/runtime/src/main/scala/org/enso/compiler/refactoring/IRUtils.scala b/engine/runtime/src/main/scala/org/enso/compiler/refactoring/IRUtils.scala new file mode 100644 index 00000000000..66f0b778711 --- /dev/null +++ b/engine/runtime/src/main/scala/org/enso/compiler/refactoring/IRUtils.scala @@ -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 diff --git a/engine/runtime/src/test/scala/org/enso/compiler/refactoring/IRUtilsTest.scala b/engine/runtime/src/test/scala/org/enso/compiler/refactoring/IRUtilsTest.scala new file mode 100644 index 00000000000..768caeca3d0 --- /dev/null +++ b/engine/runtime/src/test/scala/org/enso/compiler/refactoring/IRUtilsTest.scala @@ -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") + } + } + + } +} diff --git a/engine/runtime/src/test/scala/org/enso/compiler/test/context/SuggestionBuilderTest.scala b/engine/runtime/src/test/scala/org/enso/compiler/test/context/SuggestionBuilderTest.scala index 48ca1ed4e82..3286b46b896 100644 --- a/engine/runtime/src/test/scala/org/enso/compiler/test/context/SuggestionBuilderTest.scala +++ b/engine/runtime/src/test/scala/org/enso/compiler/test/context/SuggestionBuilderTest.scala @@ -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 { diff --git a/lib/scala/refactoring-utils/src/main/java/org/enso/refactoring/validation/MethodNameValidation.java b/lib/scala/refactoring-utils/src/main/java/org/enso/refactoring/validation/MethodNameValidation.java new file mode 100644 index 00000000000..1d2872eb581 --- /dev/null +++ b/lib/scala/refactoring-utils/src/main/java/org/enso/refactoring/validation/MethodNameValidation.java @@ -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; + } +} diff --git a/lib/scala/refactoring-utils/src/main/scala/org/enso/refactoring/RenameUtils.scala b/lib/scala/refactoring-utils/src/main/scala/org/enso/refactoring/RenameUtils.scala new file mode 100644 index 00000000000..d9279ec5d5c --- /dev/null +++ b/lib/scala/refactoring-utils/src/main/scala/org/enso/refactoring/RenameUtils.scala @@ -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() + } +} diff --git a/lib/scala/refactoring-utils/src/test/scala/org/enso/refactoring/RenameUtilsTest.java b/lib/scala/refactoring-utils/src/test/scala/org/enso/refactoring/RenameUtilsTest.java new file mode 100644 index 00000000000..f17dca10ab7 --- /dev/null +++ b/lib/scala/refactoring-utils/src/test/scala/org/enso/refactoring/RenameUtilsTest.java @@ -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 indexedSource; + private final TextEditor 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 occurrences = vec(new Location(0, 3), new Location(6, 9)); + Seq edits = + RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor); + + Assert.assertEquals(2, edits.length()); + + Either 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 occurrences = vec(new Location(0, 3), new Location(10, 13)); + Seq edits = + RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor); + + Assert.assertEquals(2, edits.length()); + + Either 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 occurrences = vec(new Location(8, 11), new Location(0, 3)); + Seq edits = + RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor); + + Assert.assertEquals(2, edits.length()); + + Either 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 occurrences = vec(new Location(10, 13), new Location(0, 3)); + Seq edits = + RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor); + + Assert.assertEquals(2, edits.length()); + + Either 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 occurrences = vec(); + Seq edits = + RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor); + + Assert.assertEquals(0, edits.length()); + + Either 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 occurrences = vec(new Location(0, 3), new Location(8, 11)); + Seq edits = + RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor); + + Assert.assertEquals(2, edits.length()); + + Either 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 occurrences = vec(new Location(0, 3), new Location(7, 10)); + Seq edits = + RenameUtils$.MODULE$.buildEdits(source, occurrences, newName, indexedSource, textEditor); + + Assert.assertEquals(2, edits.length()); + + Either 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 Seq vec(A... elems) { + VectorBuilder builder = new VectorBuilder<>(); + + for (A elem : elems) { + builder.addOne(elem); + } + + return builder.result(); + } +} diff --git a/lib/scala/refactoring-utils/src/test/scala/org/enso/refactoring/validation/MethodNameValidationTest.java b/lib/scala/refactoring-utils/src/test/scala/org/enso/refactoring/validation/MethodNameValidationTest.java new file mode 100644 index 00000000000..5ae3de41bf7 --- /dev/null +++ b/lib/scala/refactoring-utils/src/test/scala/org/enso/refactoring/validation/MethodNameValidationTest.java @@ -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")); + } +}