Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import scala.meta.internal.metals.MetalsEnrichments._
import scala.meta.internal.metals.clients.language.ConfiguredLanguageClient
import scala.meta.internal.metals.doctor.HeadDoctor
import scala.meta.internal.metals.doctor.MetalsServiceInfo
import scala.meta.internal.metals.scalacli.ScalaCliAutoStart
import scala.meta.internal.metals.watcher.FileWatcher
import scala.meta.internal.metals.watcher.NoopFileWatcher
import scala.meta.internal.mtags.Semanticdbs
Expand Down Expand Up @@ -41,6 +42,7 @@ class FallbackMetalsLspService(
override val workDoneProgress: WorkDoneProgress,
bspStatus: BspStatus,
moduleStatus: ModuleStatus,
workspaceFolders: () => Seq[AbsolutePath],
) extends MetalsLspService(
ec,
sh,
Expand Down Expand Up @@ -106,8 +108,14 @@ class FallbackMetalsLspService(
): Future[Unit] =
for {
_ <-
if (!path.isScala) Future.unit
else {
if (!ScalaCliAutoStart.shouldAutoStart(path, workspaceFolders())) {
if (path.isScala) {
scribe.info(
s"Skipping Scala CLI auto-start for out-of-workspace file: $path"
)
}
Future.unit
} else {
val prev = files.getAndUpdate(_ + path)
if (prev.contains(path)) Future.unit
else scalaCli.start(path)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ class WorkspaceLspService(
workDoneProgress,
bspStatus,
moduleStatus,
workspaceFolders = () =>
(folderServices.map(_.path) ++ nonScalaProjects.map(_.path)).distinct,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package scala.meta.internal.metals.scalacli

import scala.meta.internal.metals.MetalsEnrichments._
import scala.meta.io.AbsolutePath

/**
* Policy for when the fallback Metals service may auto-start Scala CLI for an
* orphan document.
*
* Editor agents (and some LSP clients) can send `textDocument/didOpen` for
* files outside the configured workspace folder(s). Auto-starting Scala CLI for
* those paths floods Problems with fake diagnostics and spawns many BSP
* processes. See https://github.com/scalameta/metals/issues/8736.
*/
object ScalaCliAutoStart {

/**
* Whether FallbackMetalsLspService should automatically start Scala CLI when
* `path` is opened.
*
* @param path
* document path that would be imported via Scala CLI
* @param workspaceFolders
* roots of the LSP workspace folders (Scala and non-Scala). When empty,
* keep historical behavior and allow auto-start (standalone session).
*/
def shouldAutoStart(
path: AbsolutePath,
workspaceFolders: Seq[AbsolutePath],
): Boolean = {
if (!path.isScala) false
else if (workspaceFolders.isEmpty) true
else workspaceFolders.exists(folder => path.startWith(folder))
}
}
118 changes: 118 additions & 0 deletions tests/unit/src/test/scala/tests/OutOfWorkspaceScalaCliLspSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package tests

import scala.meta.internal.metals.{BuildInfo => V}

/**
* Regression tests for https://github.com/scalameta/metals/issues/8736
*
* When the client opens Scala files outside the LSP workspace folder(s), the
* fallback service must not auto-start Scala CLI. Manual
* `metals.scala-cli-start` remains unchanged (covered by ScalaCliSuite).
*
* `TestingServer.didOpen` awaits the fallback `maybeImportFileAndLoad` future,
* so assertions after `didOpen` already run after auto-start has been decided
* (and either skipped or completed).
*/
class OutOfWorkspaceScalaCliLspSuite
extends BaseLspSuite("out-of-workspace-scala-cli") {

test("didOpen-outside-workspace-does-not-start-scala-cli") {
cleanWorkspace()
for {
_ <- initialize(
Map(
"project" ->
s"""|/metals.json
|{
| "a": { "scalaVersion": "${V.scala213}" }
|}
|/a/src/main/scala/a/A.scala
|package a
|object A
|""".stripMargin
),
expectError = false,
)
// Sibling of the workspace folder, still under the test root on disk.
_ = writeLayout(
"""|/outsider/Foo.scala
|object Foo {
| val x = 1
|}
|""".stripMargin
)
_ <- server.didOpen("outsider/Foo.scala")
_ = assertEquals(
server.fullServer.fallbackService.scalaCli.servers.size,
0,
"Scala CLI must not auto-start for files outside workspace folders",
)
_ = assertEquals(
server.fullServer.fallbackService.scalaCli.paths.toList,
Nil,
)
} yield ()
}

test("sibling-under-common-parent-still-outside-workspace-folder") {
// Mirrors multi-repo layouts (e.g. ~/projects/fun/{zipx,anode}): opening a
// file in a sibling directory must not count as "in workspace".
cleanWorkspace()
for {
_ <- initialize(
Map(
"zipx" ->
s"""|/metals.json
|{
| "a": { "scalaVersion": "${V.scala213}" }
|}
|/a/src/main/scala/a/A.scala
|package a
|object A
|""".stripMargin
),
expectError = false,
)
_ = writeLayout(
"""|/anode/src/Main.scala
|object Main
|""".stripMargin
)
_ <- server.didOpen("anode/src/Main.scala")
_ = assertEquals(
server.fullServer.fallbackService.scalaCli.servers.size,
0,
"Sibling repo under a common parent is still outside the workspace folder",
)
} yield ()
}

test("in-workspace-orphan-under-non-scala-folder-still-eligible") {
// WorkspaceLspService must pass nonScalaProjects into the auto-start check
// so orphans under a non-Scala workspace folder remain eligible.
cleanWorkspace()
for {
_ <- initialize(
Map(
"docs" ->
"""|/README.md
|Not a metals project yet.
|""".stripMargin
),
expectError = false,
)
_ = writeLayout(
"""|/Snippet.scala
|object Snippet
|""".stripMargin,
"docs",
)
eligible = scala.meta.internal.metals.scalacli.ScalaCliAutoStart
.shouldAutoStart(
workspace.resolve("docs").resolve("Snippet.scala"),
Seq(workspace.resolve("docs")),
)
_ = assertEquals(eligible, true)
} yield ()
}
}
111 changes: 111 additions & 0 deletions tests/unit/src/test/scala/tests/ScalaCliAutoStartSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package tests

import java.nio.file.Files

import scala.meta.internal.metals.MetalsEnrichments._
import scala.meta.internal.metals.scalacli.ScalaCliAutoStart
import scala.meta.io.AbsolutePath

class ScalaCliAutoStartSuite extends BaseSuite {

private def tempDir(name: String): AbsolutePath = {
val dir = AbsolutePath(Files.createTempDirectory(name))
dir.toFile.deleteOnExit()
dir
}

test("outside-workspace-folder-skips-auto-start") {
val workspace = tempDir("metals-ws")
val outsider = tempDir("metals-outsider")
val scalaFile = outsider.resolve("Foo.scala")
Files.write(scalaFile.toNIO, "object Foo".getBytes)
scalaFile.toFile.deleteOnExit()

assertEquals(
ScalaCliAutoStart.shouldAutoStart(scalaFile, Seq(workspace)),
false,
)
}

test("inside-workspace-folder-allows-auto-start") {
val workspace = tempDir("metals-ws-in")
val scalaFile = workspace.resolve("src").resolve("Foo.scala")
Files.createDirectories(scalaFile.parent.toNIO)
Files.write(scalaFile.toNIO, "object Foo".getBytes)
scalaFile.toFile.deleteOnExit()

assertEquals(
ScalaCliAutoStart.shouldAutoStart(scalaFile, Seq(workspace)),
true,
)
}

test("nested-workspace-folder-match-uses-deepest-prefix") {
val parent = tempDir("metals-parent")
val child = parent.resolve("child")
Files.createDirectories(child.toNIO)
child.toFile.deleteOnExit()
val scalaFile = child.resolve("Foo.scala")
Files.write(scalaFile.toNIO, "object Foo".getBytes)
scalaFile.toFile.deleteOnExit()

assertEquals(
ScalaCliAutoStart.shouldAutoStart(scalaFile, Seq(parent, child)),
true,
)
}

test("sibling-directory-is-outside-workspace") {
val root = tempDir("metals-root")
val project = root.resolve("project")
val sibling = root.resolve("sibling")
Files.createDirectories(project.toNIO)
Files.createDirectories(sibling.toNIO)
project.toFile.deleteOnExit()
sibling.toFile.deleteOnExit()
val scalaFile = sibling.resolve("Foo.scala")
Files.write(scalaFile.toNIO, "object Foo".getBytes)
scalaFile.toFile.deleteOnExit()

assertEquals(
ScalaCliAutoStart.shouldAutoStart(scalaFile, Seq(project)),
false,
)
}

test("empty-workspace-folders-keeps-legacy-auto-start") {
val outsider = tempDir("metals-empty-folders")
val scalaFile = outsider.resolve("Foo.scala")
Files.write(scalaFile.toNIO, "object Foo".getBytes)
scalaFile.toFile.deleteOnExit()

assertEquals(
ScalaCliAutoStart.shouldAutoStart(scalaFile, Seq.empty),
true,
)
}

test("non-scala-files-never-auto-start") {
val workspace = tempDir("metals-non-scala")
val md = workspace.resolve("README.md")
Files.write(md.toNIO, "# hi".getBytes)
md.toFile.deleteOnExit()

assertEquals(
ScalaCliAutoStart.shouldAutoStart(md, Seq(workspace)),
false,
)
}

test("scala-script-inside-workspace-allows-auto-start") {
val workspace = tempDir("metals-script")
val script = workspace.resolve("script.sc")
Files.write(script.toNIO, "println(1)".getBytes)
script.toFile.deleteOnExit()

assertEquals(
ScalaCliAutoStart.shouldAutoStart(script, Seq(workspace)),
true,
)
}
}
Loading