diff --git a/metals/src/main/scala/scala/meta/internal/builds/MavenBuildTool.scala b/metals/src/main/scala/scala/meta/internal/builds/MavenBuildTool.scala index 5defa6b5fbf..1c1e5b9a8df 100644 --- a/metals/src/main/scala/scala/meta/internal/builds/MavenBuildTool.scala +++ b/metals/src/main/scala/scala/meta/internal/builds/MavenBuildTool.scala @@ -55,7 +55,7 @@ case class MavenBuildTool( override def mavenBaseCommand(): List[String] = mbtMavenBaseCommand(projectRoot) - override def isBuildRelated(path: AbsolutePath): Boolean = + override def isWatchedFile(path: AbsolutePath): Boolean = MavenBuildTool.isMavenRelatedPath(projectRoot, path) override def digest(workspace: AbsolutePath): Option[String] = diff --git a/metals/src/main/scala/scala/meta/internal/metals/ConnectionProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/ConnectionProvider.scala index 7cb7ffb1ea2..9b81d624c63 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ConnectionProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ConnectionProvider.scala @@ -45,8 +45,15 @@ import scala.meta.internal.metals.scalacli.ScalaCliServers import scala.meta.io.AbsolutePath import org.eclipse.lsp4j +import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions +import org.eclipse.lsp4j.FileSystemWatcher import org.eclipse.lsp4j.MessageParams import org.eclipse.lsp4j.MessageType +import org.eclipse.lsp4j.Registration +import org.eclipse.lsp4j.RegistrationParams +import org.eclipse.lsp4j.Unregistration +import org.eclipse.lsp4j.UnregistrationParams +import org.eclipse.lsp4j.jsonrpc.messages.{Either => JEither} class ConnectionProvider( buildToolProvider: BuildToolProvider, @@ -141,6 +148,9 @@ class ConnectionProvider( ) private val isMbtImportInProcess: AtomicBoolean = new AtomicBoolean(false) + private val mbtWatchedFilesRegistered: AtomicBoolean = new AtomicBoolean( + false + ) private val buildServerPromptShown: AtomicBoolean = new AtomicBoolean(false) val cancelables = new MutableCancelable @@ -223,6 +233,7 @@ class ConnectionProvider( .runUnconditionally(mbtImporters, isMbtImportInProcess) .flatMap { importStatus => if (importStatus.isInstalled) { + updateMbtWatchedFiles(mbtImporters) tables.buildServers.chooseServer(MbtBuildServer.name) connect(CreateSession(), progress).ignoreValue } else Future.unit @@ -242,11 +253,96 @@ class ConnectionProvider( userConfig.preferredBuildServer.contains(MbtBuildServer.name) || tables.buildServers.selectedServer().contains(MbtBuildServer.name) + private def unregisterMbtWatchedFiles(): Unit = + if (mbtWatchedFilesRegistered.getAndSet(false)) { + languageClient.unregisterCapability( + new UnregistrationParams( + List( + new Unregistration( + "mbt-watched-files", + "workspace/didChangeWatchedFiles", + ) + ).asJava + ) + ) + } + + private def registerMbtWatchedFiles( + watchers: List[FileSystemWatcher] + ): Unit = { + if (watchers.nonEmpty) { + languageClient.registerCapability( + new RegistrationParams( + List( + new Registration( + "mbt-watched-files", + "workspace/didChangeWatchedFiles", + new DidChangeWatchedFilesRegistrationOptions(watchers.asJava), + ) + ).asJava + ) + ) + mbtWatchedFilesRegistered.set(true) + } + } + + private def refreshMbtWatchedFiles(build: MbtBuild): Unit = { + unregisterMbtWatchedFiles() + val explicitPaths = build.getWatchedFiles.asScala.toList + .filterNot(mbt.MbtGlobMatcher.isPatternGlob) + if (explicitPaths.nonEmpty) { + val root = folder.toString().replace('\\', '/') + val watchers = explicitPaths.map { p => + val normalized = mbt.MbtGlobMatcher.normalizeSlashes(p) + val withoutLeading = + if (normalized.startsWith("./")) normalized.substring(2) + else normalized + new FileSystemWatcher(JEither.forLeft(s"$root/$withoutLeading")) + } + registerMbtWatchedFiles(watchers) + } + } + + private def updateMbtWatchedFiles( + importers: List[MbtImportProvider] + ): Unit = { + val build = mbtBuild() + importers.foreach { + case script: mbt.importer.ScriptMbtImporter => + mbt.importer.ScriptMbtImporter + .updateWatchedFiles(script.scriptPath, build) + case _ => + } + refreshMbtWatchedFiles(build) + } + + private def withWatchedFilesUpdate( + run: Future[WorkspaceLoadedStatus], + importers: List[MbtImportProvider], + ): Future[Unit] = + run.map { status => + if (status.isInstalled) updateMbtWatchedFiles(importers) + }.ignoreValue + def runMbtReimport(importers: List[MbtImportProvider]): Future[Unit] = - mbtImport.runIfApproved(importers, isMbtImportInProcess).ignoreValue + withWatchedFilesUpdate( + mbtImport.runIfApproved(importers, isMbtImportInProcess), + importers, + ) + + def runMbtReimportIgnoringDigest( + importers: List[MbtImportProvider] + ): Future[Unit] = + withWatchedFilesUpdate( + mbtImport.runIgnoringDigest(importers, isMbtImportInProcess), + importers, + ) def forceMbtReimport(importers: List[MbtImportProvider]): Future[Unit] = - mbtImport.runUnconditionally(importers, isMbtImportInProcess).ignoreValue + withWatchedFilesUpdate( + mbtImport.runUnconditionally(importers, isMbtImportInProcess), + importers, + ) def reloadCurrentSession(): Future[Unit] = bspSession match { @@ -1049,17 +1145,22 @@ class ConnectionProvider( for { importStatus <- if (isMbtPreferred) { - mbtImport - .runUnconditionally( - buildTools - .mbtImporters( - shellRunner, - () => userConfig, - Some(languageClient), - Some(tables), - ), - isMbtImportInProcess, + val importers = buildTools + .mbtImporters( + shellRunner, + () => userConfig, + Some(languageClient), + Some(tables), ) + mbtImport + .runUnconditionally(importers, isMbtImportInProcess) + .map { status => + if (status.isInstalled) + updateMbtWatchedFiles( + importers + ) + status + } } else Future.successful(WorkspaceLoadedStatus.Installed) change <- if (importStatus.isInstalled) connect(request, progress) diff --git a/metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala b/metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala index 59e756adcfc..9f89fc67209 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala @@ -463,8 +463,8 @@ class ProjectMetalsLspService( Some(languageClient), Some(tables), ) - if (paths.exists(path => mbtImporters.exists(_.isBuildRelated(path)))) - connectionProvider.runMbtReimport(mbtImporters) + if (paths.exists(path => mbtImporters.exists(_.isWatchedFile(path)))) + connectionProvider.runMbtReimportIgnoringDigest(mbtImporters) else Future.unit } else if (userConfig.buildChangedAction.isNone) { diff --git a/metals/src/main/scala/scala/meta/internal/metals/mbt/MbtBuild.scala b/metals/src/main/scala/scala/meta/internal/metals/mbt/MbtBuild.scala index 9863eee88b5..c257c7c9948 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/mbt/MbtBuild.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/mbt/MbtBuild.scala @@ -16,6 +16,7 @@ case class MbtBuild( @Nullable dependencyModules: ju.List[MbtDependencyModule], @Nullable namespaces: ju.Map[String, MbtNamespace], @Nullable uncheckedSources: ju.List[String], + @Nullable watchedFiles: ju.List[String] = null, ) { def getDependencyModules(): ju.List[MbtDependencyModule] = @@ -34,10 +35,14 @@ case class MbtBuild( (topLevel ++ fromNamespaces).distinct.asJava } + def getWatchedFiles: ju.List[String] = + Option(this.watchedFiles).getOrElse(ju.Collections.emptyList()) + def isEmpty: Boolean = Option(this.dependencyModules).forall(_.isEmpty) && Option(this.namespaces).forall(_.isEmpty) && - Option(this.uncheckedSources).forall(_.isEmpty) + Option(this.uncheckedSources).forall(_.isEmpty) && + Option(this.watchedFiles).forall(_.isEmpty) def asBspModules: bsp4j.DependencyModulesResult = new bsp4j.DependencyModulesResult( @@ -86,7 +91,8 @@ case class MbtBuild( None } } - val globPatterns = namespace.getSources.asScala.toSeq.filter(isGlob) + val globPatterns = namespace.getSources.asScala.toSeq + .filter(MbtGlobMatcher.isPatternGlob) val nsModules = for { moduleId <- namespace.getDependencyModuleIds.asScala.toSeq module <- modulesById.get(moduleId).orElse { @@ -101,7 +107,7 @@ case class MbtBuild( id = new bsp4j.BuildTargetIdentifier(MbtBuild.namespaceTargetId(name)), sources = namespace.getSources.asScala.toSeq - .filterNot(isGlob), + .filterNot(MbtGlobMatcher.isPatternGlob), globMatchers = globPatterns.map(pattern => MbtGlobMatcher( pattern = pattern, @@ -127,17 +133,9 @@ case class MbtBuild( } } - private def isGlob(pattern: String): Boolean = { - val n = normalizeSlashes(pattern) - n.exists(c => c == '*' || c == '?' || c == '[' || c == '{') - } - - private def normalizeSlashes(s: String): String = - s.trim.replace('\\', '/') - /** Leading `./` is stripped so matchers align with workspace-relative paths. */ private def globPatternForMatcher(pattern: String): String = { - val n = normalizeSlashes(pattern) + val n = MbtGlobMatcher.normalizeSlashes(pattern) if (n.startsWith("./")) n.substring(2) else n } @@ -146,7 +144,7 @@ case class MbtBuild( .split('/') .toSeq .filter(_.nonEmpty) - .takeWhile(segment => !isGlob(segment)) + .takeWhile(segment => !MbtGlobMatcher.isPatternGlob(segment)) literalSegments match { case head +: tail => Some(Paths.get(head, tail: _*)) case _ => None @@ -230,7 +228,18 @@ object MbtBuild { .getOrElse(ju.Collections.emptyList()) .asScala).distinct.asJava - MbtBuild(mergedModules, mergedNamespaces, mergedUncheckedSources) + val mergedWatchedFiles = { + val combined = + (a.getWatchedFiles.asScala ++ b.getWatchedFiles.asScala).distinct + if (combined.isEmpty) null else combined.asJava + } + + MbtBuild( + mergedModules, + mergedNamespaces, + mergedUncheckedSources, + mergedWatchedFiles, + ) } } diff --git a/metals/src/main/scala/scala/meta/internal/metals/mbt/MbtGlobMatcher.scala b/metals/src/main/scala/scala/meta/internal/metals/mbt/MbtGlobMatcher.scala index 6370f1238a8..a0b186cd606 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/mbt/MbtGlobMatcher.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/mbt/MbtGlobMatcher.scala @@ -17,3 +17,12 @@ case class MbtGlobMatcher( value.startsWith(relativeDirectory) } } + +object MbtGlobMatcher { + def normalizeSlashes(s: String): String = s.trim.replace('\\', '/') + + def isPatternGlob(pattern: String): Boolean = + normalizeSlashes(pattern).exists(c => + c == '*' || c == '?' || c == '[' || c == '{' + ) +} diff --git a/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/BazelMbtImporter.scala b/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/BazelMbtImporter.scala index 37c83c8527c..18c3cfe6005 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/BazelMbtImporter.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/BazelMbtImporter.scala @@ -224,7 +224,7 @@ abstract class BazelMbtImporter( tables.bazelMbtNamespaceModes.chooseMode(projectRoot, mode.name) } - override def isBuildRelated(path: AbsolutePath): Boolean = + override def isWatchedFile(path: AbsolutePath): Boolean = BazelBuildTool.isBazelRelatedPath(projectRoot, path) override def digest(workspace: AbsolutePath): Option[String] = diff --git a/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/GradleMbtImporter.scala b/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/GradleMbtImporter.scala index bfe0b848156..b6df637d83c 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/GradleMbtImporter.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/GradleMbtImporter.scala @@ -45,7 +45,7 @@ class GradleMbtImporter( scribe.info(s"time: gradle-extractor extract in $timer") } - override def isBuildRelated(path: AbsolutePath): Boolean = + override def isWatchedFile(path: AbsolutePath): Boolean = GradleBuildTool.isGradleRelatedPath(projectRoot, path) override def digest(workspace: AbsolutePath): Option[String] = diff --git a/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/MbtImport.scala b/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/MbtImport.scala index 21e67cb990a..044a667a52e 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/MbtImport.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/MbtImport.scala @@ -95,6 +95,40 @@ final class MbtImport( scribe.info("mbt-import: wrote .metals/mbt.json") } + /** + * Like [[runUnconditionally]] but ignores the digest check and prompts the + * user for confirmation when auto-import is disabled. Used for watched files. + */ + def runIgnoringDigest( + providers: List[MbtImportProvider], + isImportInProcess: AtomicBoolean, + ): Future[WorkspaceLoadedStatus] = { + val digest = computeDigest(providers).getOrElse( + s"watched-file-changed:${providers.map(_.name).mkString(",")}" + ) + val run = + if (userConfig().shouldAutoImportNewProject) { + runUnconditionally(providers, isImportInProcess) + } else { + scribe.debug("mbt-import: awaiting user response for watched file…") + for { + response <- requestImport(providers, digest) + result <- + if (response.isYes) + runUnconditionally(providers, isImportInProcess) + else { + notification.dismiss(2, TimeUnit.MINUTES) + Future.successful(WorkspaceLoadedStatus.Rejected) + } + } yield result + } + run.andThen { case Success(status) => + status.toChecksumStatus.foreach( + tables.digests.setStatus(digest, _) + ) + } + } + /** * Like [[runUnconditionally]] but first checks whether the build digest has * changed and prompts the user for confirmation when auto-import is disabled. diff --git a/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/MbtImportProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/MbtImportProvider.scala index c9ebc2e1139..c1516f5f386 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/MbtImportProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/MbtImportProvider.scala @@ -35,10 +35,9 @@ trait MbtImportProvider { workspace.resolve(s".metals/mbt-$name.json") /** - * Returns `true` when the given path is a build file that, if modified, - * should trigger a re-import (e.g. `pom.xml` for Maven). + * Returns `true` when the given path, if modified, should trigger a re-import. */ - def isBuildRelated(path: AbsolutePath): Boolean + def isWatchedFile(path: AbsolutePath): Boolean /** * Stable digest of all build files owned by this importer. diff --git a/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/ScriptMbtImporter.scala b/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/ScriptMbtImporter.scala index df1cb7b3991..c6577c3e342 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/ScriptMbtImporter.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/ScriptMbtImporter.scala @@ -3,11 +3,15 @@ package scala.meta.internal.metals.mbt.importer import java.nio.file.Files import java.util.concurrent.CancellationException +import scala.collection.concurrent.TrieMap import scala.concurrent.ExecutionContext import scala.concurrent.Future import scala.meta.internal.builds.ShellRunner +import scala.meta.internal.metals.MetalsEnrichments._ import scala.meta.internal.metals.UserConfiguration +import scala.meta.internal.metals.mbt.MbtBuild +import scala.meta.internal.metals.mbt.MbtGlobMatcher import scala.meta.internal.mtags.MD5 import scala.meta.internal.process.ExitCodes import scala.meta.io.AbsolutePath @@ -26,7 +30,7 @@ import scala.meta.io.AbsolutePath * - `MBT_WORKSPACE` env var: workspace root path */ final class ScriptMbtImporter( - scriptPath: AbsolutePath, + val scriptPath: AbsolutePath, shellRunner: ShellRunner, userConfig: () => UserConfiguration, )(implicit ec: ExecutionContext) @@ -73,7 +77,15 @@ final class ScriptMbtImporter( } } - override def isBuildRelated(path: AbsolutePath): Boolean = path == scriptPath + override def isWatchedFile(path: AbsolutePath): Boolean = + path == scriptPath || { + val patterns = + ScriptMbtImporter.watchedFilesCache.getOrElse(scriptPath, Nil) + patterns.nonEmpty && path.toRelativeInside(projectRoot).exists { + relative => + patterns.contains(MbtGlobMatcher.normalizeSlashes(relative.toString)) + } + } override def digest(workspace: AbsolutePath): Option[String] = scala.util.Try(MD5.compute(scriptPath.toNIO)).toOption @@ -107,4 +119,23 @@ final class ScriptMbtImporter( object ScriptMbtImporter { val scriptExtensions: List[String] = List(".mbt.scala", ".mbt.java", ".mbt.sh", ".mbt.bat") + + private val watchedFilesCache = + TrieMap.empty[AbsolutePath, List[String]] + + def updateWatchedFiles( + scriptPath: AbsolutePath, + mbtBuild: MbtBuild, + ): Unit = { + val explicitPaths = mbtBuild.getWatchedFiles.asScala.toList + .filterNot(MbtGlobMatcher.isPatternGlob) + .map { pattern => + val normalized = MbtGlobMatcher.normalizeSlashes(pattern) + if (normalized.startsWith("./")) normalized.substring(2) else normalized + } + watchedFilesCache(scriptPath) = explicitPaths + } + + private[importer] def clearWatchedFiles(scriptPath: AbsolutePath): Unit = + watchedFilesCache.remove(scriptPath) } diff --git a/tests/unit/src/test/scala/scala/meta/internal/metals/mbt/importer/ScriptMbtImporterSuite.scala b/tests/unit/src/test/scala/scala/meta/internal/metals/mbt/importer/ScriptMbtImporterSuite.scala index a8a5bd2a9c9..bdc280302e8 100644 --- a/tests/unit/src/test/scala/scala/meta/internal/metals/mbt/importer/ScriptMbtImporterSuite.scala +++ b/tests/unit/src/test/scala/scala/meta/internal/metals/mbt/importer/ScriptMbtImporterSuite.scala @@ -67,17 +67,17 @@ class ScriptMbtImporterSuite extends FunSuite { ) } - test("isBuildRelated-true-for-own-script") { + test("isWatchedFile-true-for-own-script") { val dir = AbsolutePath(Files.createTempDirectory("mbt-related")) val script = makeScript(dir, "export.mbt.sh") - assert(importer(script).isBuildRelated(script)) + assert(importer(script).isWatchedFile(script)) } - test("isBuildRelated-false-for-other-file") { + test("isWatchedFile-false-for-other-file-with-no-cache") { val dir = AbsolutePath(Files.createTempDirectory("mbt-related")) val script = makeScript(dir, "export.mbt.sh") val other = makeScript(dir, "other.mbt.sh") - assert(!importer(script).isBuildRelated(other)) + assert(!importer(script).isWatchedFile(other)) } test("buildCommand-sh-uses-sh") {