diff --git a/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala b/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala index f9002946aa1..f75e12a325a 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsLanguageServer.scala @@ -105,6 +105,7 @@ class MetalsLanguageServer( var buildServer = Option.empty[BuildServerConnection] private val buildTargetClasses = new BuildTargetClasses(() => buildServer) private val openTextDocument = new AtomicReference[AbsolutePath]() + private val createdFiles = ConcurrentHashSet.empty[AbsolutePath] private val savedFiles = new ActiveFiles(time) private val openedFiles = new ActiveFiles(time) private val messages = new Messages(config.icons) @@ -157,6 +158,8 @@ class MetalsLanguageServer( private var referencesProvider: ReferenceProvider = _ private var workspaceSymbols: WorkspaceSymbolProvider = _ private var foldingRangeProvider: FoldingRangeProvider = _ + private val packageProvider: PackageProvider = + new PackageProvider(buildTargets) private var compilers: Compilers = _ var tables: Tables = _ var statusBar: StatusBar = _ @@ -588,6 +591,15 @@ class MetalsLanguageServer( // Update in-memory buffer contents from LSP client buffers.put(path, params.getTextDocument.getText) trees.didChange(path) + + val wasCreated = createdFiles.remove(path) + if (wasCreated) { + packageProvider + .addPackageWorkspaceEdit(path) + .map(new ApplyWorkspaceEditParams(_)) + .foreach(languageClient.applyEdit) + } + if (path.isDependencySource(workspace)) { CancelTokens { _ => // trigger compilation in preparation for definition requests @@ -728,6 +740,12 @@ class MetalsLanguageServer( event.eventType() match { case EventType.CREATE => buildTargets.onCreate(path) + + // We cannot apply the workspace edit before the file is open, + // so we need to wait until we receive a proper request from the client. + if (path.isScala) { + createdFiles.add(path) + } case _ => } onChange(List(path)).asJava diff --git a/metals/src/main/scala/scala/meta/internal/metals/PackageProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/PackageProvider.scala new file mode 100644 index 00000000000..ee199c97496 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/PackageProvider.scala @@ -0,0 +1,41 @@ +package scala.meta.internal.metals + +import org.eclipse.lsp4j.Position +import org.eclipse.lsp4j.Range +import org.eclipse.lsp4j.TextEdit +import org.eclipse.lsp4j.WorkspaceEdit + +import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.io.AbsolutePath + +class PackageProvider(private val buildTargets: BuildTargets) { + + def addPackageWorkspaceEdit(path: AbsolutePath): Option[WorkspaceEdit] = { + def createWorkspaceEdit(packageName: String): WorkspaceEdit = { + val textEdit = new TextEdit( + new Range(new Position(0, 0), new Position(0, 0)), + s"package $packageName\n\n" + ) + + val textEdits = List(textEdit).asJava + val changes = Map(path.toString -> textEdits).asJava + new WorkspaceEdit(changes) + } + + createPackageName(path).map(createWorkspaceEdit) + } + + private def createPackageName(path: AbsolutePath): Option[String] = { + if (path.isScala && path.toFile.length() == 0) { + val sourceItem = buildTargets.inverseSourceItem(path) + val relativeDirectory = sourceItem + .map(path.toRelative) + .flatMap(relativePath => Option(relativePath.toNIO.getParent)) + .map(_.toString) + + relativeDirectory.map(_.replace("/", ".")) + } else { + None + } + } +} diff --git a/mtags/src/main/scala/scala/meta/internal/mtags/MtagsEnrichments.scala b/mtags/src/main/scala/scala/meta/internal/mtags/MtagsEnrichments.scala index fa55dac4077..2d0bbaaee8a 100644 --- a/mtags/src/main/scala/scala/meta/internal/mtags/MtagsEnrichments.scala +++ b/mtags/src/main/scala/scala/meta/internal/mtags/MtagsEnrichments.scala @@ -91,6 +91,12 @@ trait MtagsEnrichments { case _ => false } } + def isScala: Boolean = { + toLanguage match { + case Language.SCALA => true + case _ => false + } + } def isSemanticdb: Boolean = { file.toNIO.getFileName.toString.endsWith(".semanticdb") } diff --git a/tests/unit/src/main/scala/tests/TestingClient.scala b/tests/unit/src/main/scala/tests/TestingClient.scala index f344c9b4573..084c0fd3999 100644 --- a/tests/unit/src/main/scala/tests/TestingClient.scala +++ b/tests/unit/src/main/scala/tests/TestingClient.scala @@ -5,6 +5,8 @@ import java.util.concurrent.ConcurrentLinkedDeque import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicInteger +import org.eclipse.lsp4j.ApplyWorkspaceEditParams +import org.eclipse.lsp4j.ApplyWorkspaceEditResponse import org.eclipse.lsp4j.Diagnostic import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions import org.eclipse.lsp4j.ExecuteCommandParams @@ -14,6 +16,7 @@ import org.eclipse.lsp4j.MessageType import org.eclipse.lsp4j.PublishDiagnosticsParams import org.eclipse.lsp4j.RegistrationParams import org.eclipse.lsp4j.ShowMessageRequestParams +import org.eclipse.lsp4j.TextEdit import org.eclipse.lsp4j.jsonrpc.CompletableFutures import scala.collection.concurrent.TrieMap import scala.meta.internal.metals.Buffers @@ -66,6 +69,24 @@ final class TestingClient(workspace: AbsolutePath, buffers: Buffers) ): Unit = { clientCommands.addLast(params) } + + override def applyEdit( + params: ApplyWorkspaceEditParams + ): CompletableFuture[ApplyWorkspaceEditResponse] = { + def applyEdits(uri: String, textEdits: java.util.List[TextEdit]): Unit = { + val path = AbsolutePath(uri) + + val content = path.readText + val editedContent = + TextEdits.applyEdits(content, textEdits.asScala.toList) + + path.writeText(editedContent) + } + + params.getEdit.getChanges.forEach(applyEdits) + CompletableFuture.completedFuture(new ApplyWorkspaceEditResponse(true)) + } + def workspaceClientCommands: String = { clientCommands.asScala.map(_.getCommand).mkString("\n") } diff --git a/tests/unit/src/test/scala/tests/AddPackageSlowSuite.scala b/tests/unit/src/test/scala/tests/AddPackageSlowSuite.scala new file mode 100644 index 00000000000..97733bfe563 --- /dev/null +++ b/tests/unit/src/test/scala/tests/AddPackageSlowSuite.scala @@ -0,0 +1,113 @@ +package tests + +import java.nio.file.Files + +import scala.meta.internal.metals.RecursivelyDelete +import scala.meta.internal.metals.MetalsEnrichments._ + +object AddPackageSlowSuite extends BaseSlowSuite("add-package") { + + testAsync("single-level") { + cleanCompileCache("a") + RecursivelyDelete(workspace.resolve("a")) + Files.createDirectories( + workspace.resolve("a/src/main/scala/a").toNIO + ) + for { + _ <- server.initialize(""" + |/metals.json + |{ + | "a": { } + |} + """.stripMargin) + _ = workspace + .resolve("a/src/main/scala/a/Main.scala") + .toFile + .createNewFile() + _ <- server.didOpen("a/src/main/scala/a/Main.scala") + _ = assertNoDiff( + workspace.resolve("a/src/main/scala/a/Main.scala").readText, + """ + |package a + """.stripMargin + ) + } yield () + } + + testAsync("multilevel") { + cleanCompileCache("a") + RecursivelyDelete(workspace.resolve("a")) + Files.createDirectories( + workspace.resolve("a/src/main/scala/a/b/c").toNIO + ) + for { + _ <- server.initialize(""" + |/metals.json + |{ + | "a": { } + |} + """.stripMargin) + _ = workspace + .resolve("a/src/main/scala/a/b/c/Main.scala") + .toFile + .createNewFile() + _ <- server.didOpen("a/src/main/scala/a/b/c/Main.scala") + _ = assertNoDiff( + workspace.resolve("a/src/main/scala/a/b/c/Main.scala").readText, + """ + |package a.b.c + """.stripMargin + ) + } yield () + } + + testAsync("no-package") { + cleanCompileCache("a") + RecursivelyDelete(workspace.resolve("a")) + Files.createDirectories( + workspace.resolve("a/src/main/scala").toNIO + ) + for { + _ <- server.initialize(""" + |/metals.json + |{ + | "a": { } + |} + """.stripMargin) + _ = workspace + .resolve("a/src/main/scala/Main.scala") + .toFile + .createNewFile() + _ <- server.didOpen("a/src/main/scala/Main.scala") + _ = assertNoDiff( + workspace.resolve("a/src/main/scala/Main.scala").readText, + "" + ) + } yield () + } + + testAsync("java-file") { + cleanCompileCache("a") + RecursivelyDelete(workspace.resolve("a")) + Files.createDirectories( + workspace.resolve("a/src/main/java/a").toNIO + ) + for { + _ <- server.initialize(""" + |/metals.json + |{ + | "a": { } + |} + """.stripMargin) + _ = workspace + .resolve("a/src/main/java/a/Main.java") + .toFile + .createNewFile() + _ <- server.didOpen("a/src/main/java/a/Main.java") + _ = assertNoDiff( + workspace.resolve("a/src/main/java/a/Main.java").readText, + "" + ) + } yield () + } +}