From cfc9dac0616d1a910f34c3a05526f7ad1fc50709 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 14 May 2026 15:11:41 +0200 Subject: [PATCH 1/2] Restore BSP base directory to the original workspace regardless of permissions/ownership --- .../scala/scala/build/bsp/BspServer.scala | 26 ++++--- .../main/scala/scala/build/input/Inputs.scala | 14 ++-- .../scala/cli/integration/BspSuite.scala | 20 ++++-- .../cli/integration/BspTestDefinitions.scala | 67 +++++++++++++++++++ 4 files changed, 107 insertions(+), 20 deletions(-) diff --git a/modules/build/src/main/scala/scala/build/bsp/BspServer.scala b/modules/build/src/main/scala/scala/build/bsp/BspServer.scala index 1b4bfdc2af..32b58d0a59 100644 --- a/modules/build/src/main/scala/scala/build/bsp/BspServer.scala +++ b/modules/build/src/main/scala/scala/build/bsp/BspServer.scala @@ -9,6 +9,7 @@ import java.util as ju import java.util.concurrent.{CompletableFuture, TimeUnit} import scala.build.Logger +import scala.build.input.Inputs import scala.build.internal.Constants import scala.build.options.Scope import scala.concurrent.{Future, Promise} @@ -31,6 +32,13 @@ class BspServer( @volatile private var intelliJ: Boolean = presetIntelliJ def isIntelliJ: Boolean = intelliJ + @volatile private var bspBaseDirectoryOverride: Option[os.Path] = None + + override def newInputs(inputs: Inputs): Unit = { + super.newInputs(inputs) + bspBaseDirectoryOverride = inputs.originalWorkspaceOpt + } + def clientOpt: Option[BuildClient] = client @volatile private var extraDependencySources: Seq[os.Path] = Nil @@ -276,16 +284,16 @@ class BspServer( val res0 = res.duplicate() stripInvalidTargets(res0) for (target <- res0.getTargets.asScala) { - val capabilities = target.getCapabilities - capabilities.setCanDebug(true) + target.getCapabilities.setCanDebug(true) val baseDirectory = new File(new URI(target.getBaseDirectory)) - if ( - isIntelliJ && baseDirectory.getName == Constants.workspaceDirName && - baseDirectory - .getParentFile != null - ) { - val newBaseDirectory = baseDirectory.getParentFile.toPath.toUri.toASCIIString - target.setBaseDirectory(newBaseDirectory) + bspBaseDirectoryOverride match { + case Some(originalWs) => + target.setBaseDirectory(originalWs.toNIO.toUri.toASCIIString) + case None + if isIntelliJ && baseDirectory.getName == Constants.workspaceDirName + && baseDirectory.getParentFile != null => + target.setBaseDirectory(baseDirectory.getParentFile.toPath.toUri.toASCIIString) + case _ => // leave Bloop's value untouched } } res0 diff --git a/modules/build/src/main/scala/scala/build/input/Inputs.scala b/modules/build/src/main/scala/scala/build/input/Inputs.scala index 2e608b28db..610be997e2 100644 --- a/modules/build/src/main/scala/scala/build/input/Inputs.scala +++ b/modules/build/src/main/scala/scala/build/input/Inputs.scala @@ -24,7 +24,8 @@ final case class Inputs( mayAppendHash: Boolean, workspaceOrigin: Option[WorkspaceOrigin], enableMarkdown: Boolean, - allowRestrictedFeatures: Boolean + allowRestrictedFeatures: Boolean, + originalWorkspaceOpt: Option[os.Path] ) { def isEmpty: Boolean = elements.isEmpty @@ -75,7 +76,8 @@ final case class Inputs( copy( workspace = elements.homeWorkspace(directories), mayAppendHash = false, - workspaceOrigin = Some(WorkspaceOrigin.HomeDir) + workspaceOrigin = Some(WorkspaceOrigin.HomeDir), + originalWorkspaceOpt = originalWorkspaceOpt.orElse(Some(workspace)) ) def avoid(forbidden: Seq[os.Path], directories: Directories): Inputs = if forbidden.exists(workspace.startsWith) then inHomeDir(directories) else this @@ -159,7 +161,8 @@ object Inputs { mayAppendHash = needsHash, workspaceOrigin = Some(workspaceOrigin), enableMarkdown = enableMarkdown, - allowRestrictedFeatures = allowRestrictedFeatures + allowRestrictedFeatures = allowRestrictedFeatures, + originalWorkspaceOpt = None ) } @@ -476,11 +479,12 @@ object Inputs { mayAppendHash = true, workspaceOrigin = None, enableMarkdown = enableMarkdown, - allowRestrictedFeatures = false + allowRestrictedFeatures = false, + originalWorkspaceOpt = None ) def empty(projectName: String): Inputs = - Inputs(Nil, None, os.pwd, projectName, false, None, true, false) + Inputs(Nil, None, os.pwd, projectName, false, None, true, false, None) def baseName(p: os.Path) = if (p == os.root || p.lastOpt.isEmpty) "" else p.baseName diff --git a/modules/integration/src/test/scala/scala/cli/integration/BspSuite.scala b/modules/integration/src/test/scala/scala/cli/integration/BspSuite.scala index 1c97d73ff1..a521ae22b1 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/BspSuite.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/BspSuite.scala @@ -22,9 +22,12 @@ import scala.util.{Failure, Success, Try} trait BspSuite { this: ScalaCliSuite => protected def extraOptions: Seq[String] - def initParams(root: os.Path): b.InitializeBuildParams = + def initParams( + root: os.Path, + clientName: String = "Scala CLI ITs" + ): b.InitializeBuildParams = new b.InitializeBuildParams( - "Scala CLI ITs", + clientName, "0", Constants.bspVersion, root.toNIO.toUri.toASCIIString, @@ -74,7 +77,8 @@ trait BspSuite { this: ScalaCliSuite => bspEnvs: Map[String, String] = Map.empty, reuseRoot: Option[os.Path] = None, stdErrOpt: Option[os.RelPath] = None, - extraOptionsOverride: Seq[String] = extraOptions + extraOptionsOverride: Seq[String] = extraOptions, + bspClientName: String = "Scala CLI ITs" )( f: ( os.Path, @@ -91,7 +95,8 @@ trait BspSuite { this: ScalaCliSuite => bspEnvs, reuseRoot, stdErrOpt, - extraOptionsOverride + extraOptionsOverride, + bspClientName )((root, client, server, _: b.InitializeBuildResult) => f(root, client, server)) def withBspInitResults[T]( @@ -103,7 +108,8 @@ trait BspSuite { this: ScalaCliSuite => bspEnvs: Map[String, String] = Map.empty, reuseRoot: Option[os.Path] = None, stdErrOpt: Option[os.RelPath] = None, - extraOptionsOverride: Seq[String] = extraOptions + extraOptionsOverride: Seq[String] = extraOptions, + bspClientName: String = "Scala CLI ITs" )( f: ( os.Path, @@ -151,7 +157,9 @@ trait BspSuite { this: ScalaCliSuite => TestBspClient.connect(proc.stdout, proc.stdin, pool) remoteServer = remoteServer0 val initRes: b.InitializeBuildResult = Await.result( - whileBspServerIsRunning(remoteServer.buildInitialize(initParams(root)).asScala), + whileBspServerIsRunning( + remoteServer.buildInitialize(initParams(root, bspClientName)).asScala + ), Duration.Inf ) Await.result( diff --git a/modules/integration/src/test/scala/scala/cli/integration/BspTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/BspTestDefinitions.scala index 210b91bd47..1222b0044d 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/BspTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/BspTestDefinitions.scala @@ -2445,4 +2445,71 @@ abstract class BspTestDefinitions extends ScalaCliSuite } } } + + for { + isIntelliJ <- Seq(false, true) + clientDescription = if isIntelliJ then "IntelliJ" else "any client" + bspClientName = if isIntelliJ then "IntelliJ" else "test" + } { + def baseDirectoryOfMainTarget( + remoteServer: b.BuildServer & b.ScalaBuildServer & b.JavaBuildServer & b.JvmBuildServer & + TestBspClient.WrappedSourcesBuildServer + ): os.Path = { + val buildTargetsResp = remoteServer.workspaceBuildTargets().asScala.await + val targets = buildTargetsResp.getTargets.asScala.toSeq + val mainTarget = targets.find(t => !t.getId.getUri.contains("-test")).getOrElse { + sys.error(s"No main build target found in ${targets.map(_.getId.getUri)}") + } + os.Path(Paths.get(new URI(mainTarget.getBaseDirectory))) + } + + test(s"workspaceBuildTargets baseDirectory: default workspace ($clientDescription)") { + withBsp( + inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def main(args: Array[String]): Unit = println("Hello") + |} + |""".stripMargin + ), + args = Seq("."), + bspClientName = bspClientName + ) { (root, _, remoteServer) => + Future { + val baseDir = baseDirectoryOfMainTarget(remoteServer) + if isIntelliJ then expect(baseDir == root) + else expect(baseDir == root / Constants.workspaceDirName) + } + } + } + + if !Properties.isWin then + test(s"workspaceBuildTargets baseDirectory: non-writable workspace ($clientDescription)") { + val simpleHelloInputsInDir = TestInputs( + os.rel / "dir" / "Hello.scala" -> + """object Hello { + | def main(args: Array[String]): Unit = println("Hello") + |} + |""".stripMargin + ) + simpleHelloInputsInDir.fromRoot { root => + os.perms.set(root / "dir", "r-xr-xr-x") + try + withBsp( + simpleHelloInputsInDir, + Seq("dir"), + reuseRoot = Some(root), + bspClientName = bspClientName + ) { + (_, _, remoteServer) => + Future { + val baseDir = baseDirectoryOfMainTarget(remoteServer) + val expectedBaseDir = root / "dir" + expect(baseDir == expectedBaseDir) + } + } + finally os.perms.set(root / "dir", "rwxr-xr-x") + } + } + } } From 6060cd6954496504dfd2696c3f4c856bf0739096 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 15 May 2026 19:05:02 +0200 Subject: [PATCH 2/2] Ensure `*.semanticdb` files still land along with the compiled classes when a virtual directory is used by BSP --- .../src/main/scala/scala/build/Build.scala | 8 +++- .../main/scala/scala/build/bsp/BspImpl.scala | 5 ++- .../cli/integration/BspTestDefinitions.scala | 42 ++++++++++++++++--- .../scala/build/options/BuildOptions.scala | 16 +++++++ 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/modules/build/src/main/scala/scala/build/Build.scala b/modules/build/src/main/scala/scala/build/Build.scala index 1a52b9bc6d..f868b60dd6 100644 --- a/modules/build/src/main/scala/scala/build/Build.scala +++ b/modules/build/src/main/scala/scala/build/Build.scala @@ -303,7 +303,10 @@ object Build { workspace = inputs.workspace, updateSemanticDbs = true, scalaVersion = sv, - buildOptions = build.options + buildOptions = + inputs.originalWorkspaceOpt.fold(build.options)( + build.options.withResolvedSemanticDbSourceRoot + ) ).left.foreach(_.foreach(logger.message(_))) case _ => } @@ -942,7 +945,8 @@ object Build { options.scalaOptions.semanticDbOptions.generateSemanticDbs.getOrElse(false) val semanticDbTargetRoot = options.scalaOptions.semanticDbOptions.semanticDbTargetRoot val semanticDbSourceRoot = - options.scalaOptions.semanticDbOptions.semanticDbSourceRoot.getOrElse(inputs.workspace) + options.scalaOptions.semanticDbOptions.semanticDbSourceRoot + .getOrElse(inputs.originalWorkspaceOpt.getOrElse(inputs.workspace)) val scalaCompilerParamsOpt = artifacts.scalaOpt match { case Some(scalaArtifacts) => diff --git a/modules/build/src/main/scala/scala/build/bsp/BspImpl.scala b/modules/build/src/main/scala/scala/build/bsp/BspImpl.scala index d3b23c9cb5..1c6cc128f5 100644 --- a/modules/build/src/main/scala/scala/build/bsp/BspImpl.scala +++ b/modules/build/src/main/scala/scala/build/bsp/BspImpl.scala @@ -363,7 +363,10 @@ final class BspImpl( currentBloopSession.inputs.workspace, updateSemanticDbs = true, scalaVersion = sv, - buildOptions = data.buildOptions + buildOptions = + currentBloopSession.inputs.originalWorkspaceOpt.fold(data.buildOptions)( + data.buildOptions.withResolvedSemanticDbSourceRoot + ) ).left.foreach(_.foreach(showGlobalWarningOnce)) if (res.getStatusCode == b.StatusCode.OK) diff --git a/modules/integration/src/test/scala/scala/cli/integration/BspTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/BspTestDefinitions.scala index 1222b0044d..cb61415ee2 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/BspTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/BspTestDefinitions.scala @@ -2451,18 +2451,25 @@ abstract class BspTestDefinitions extends ScalaCliSuite clientDescription = if isIntelliJ then "IntelliJ" else "any client" bspClientName = if isIntelliJ then "IntelliJ" else "test" } { - def baseDirectoryOfMainTarget( + def mainTargetOf( remoteServer: b.BuildServer & b.ScalaBuildServer & b.JavaBuildServer & b.JvmBuildServer & TestBspClient.WrappedSourcesBuildServer - ): os.Path = { + ): b.BuildTarget = { val buildTargetsResp = remoteServer.workspaceBuildTargets().asScala.await val targets = buildTargetsResp.getTargets.asScala.toSeq - val mainTarget = targets.find(t => !t.getId.getUri.contains("-test")).getOrElse { + targets.find(t => !t.getId.getUri.contains("-test")).getOrElse { sys.error(s"No main build target found in ${targets.map(_.getId.getUri)}") } - os.Path(Paths.get(new URI(mainTarget.getBaseDirectory))) } + def baseDirectoryOf(target: b.BuildTarget): os.Path = + os.Path(Paths.get(new URI(target.getBaseDirectory))) + + def collectSemDbFiles(root: os.Path): Seq[os.Path] = + os.walk(root) + .filter(_.last.endsWith(".semanticdb")) + .filter(p => !p.segments.exists(_ == "bloop-internal-classes")) + test(s"workspaceBuildTargets baseDirectory: default workspace ($clientDescription)") { withBsp( inputs = TestInputs( @@ -2476,9 +2483,21 @@ abstract class BspTestDefinitions extends ScalaCliSuite bspClientName = bspClientName ) { (root, _, remoteServer) => Future { - val baseDir = baseDirectoryOfMainTarget(remoteServer) + val mainTarget = mainTargetOf(remoteServer) + val baseDir = baseDirectoryOf(mainTarget) if isIntelliJ then expect(baseDir == root) else expect(baseDir == root / Constants.workspaceDirName) + + val compileResp = + remoteServer + .buildTargetCompile(new b.CompileParams(List(mainTarget.getId).asJava)) + .asScala + .await + expect(compileResp.getStatusCode == b.StatusCode.OK) + + val semDbFiles = collectSemDbFiles(root) + expect(semDbFiles.nonEmpty) + expect(semDbFiles.forall(_.segments.contains(Constants.workspaceDirName))) } } } @@ -2503,9 +2522,20 @@ abstract class BspTestDefinitions extends ScalaCliSuite ) { (_, _, remoteServer) => Future { - val baseDir = baseDirectoryOfMainTarget(remoteServer) + val mainTarget = mainTargetOf(remoteServer) + val baseDir = baseDirectoryOf(mainTarget) val expectedBaseDir = root / "dir" expect(baseDir == expectedBaseDir) + + val compileResp = + remoteServer + .buildTargetCompile(new b.CompileParams(List(mainTarget.getId).asJava)) + .asScala + .await + expect(compileResp.getStatusCode == b.StatusCode.OK) + + val semDbFilesUnderRoot = collectSemDbFiles(root) + expect(semDbFilesUnderRoot.isEmpty) } } finally os.perms.set(root / "dir", "rwxr-xr-x") diff --git a/modules/options/src/main/scala/scala/build/options/BuildOptions.scala b/modules/options/src/main/scala/scala/build/options/BuildOptions.scala index aa96da1d41..26fa89c7c0 100644 --- a/modules/options/src/main/scala/scala/build/options/BuildOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/BuildOptions.scala @@ -53,6 +53,22 @@ final case class BuildOptions( import BuildOptions.JavaHomeInfo + /** When the build workspace was moved to a virtual directory (e.g. after + * [[scala.build.input.Inputs.checkAttributes]] fallback) and the user did not set an explicit + * semanticdb source root, use the given original workspace so semanticdb paths stay relative to + * the user's project root. + */ + def withResolvedSemanticDbSourceRoot(originalWorkspace: os.Path): BuildOptions = + if scalaOptions.semanticDbOptions.semanticDbSourceRoot.isEmpty then + copy(scalaOptions = + scalaOptions.copy( + semanticDbOptions = scalaOptions.semanticDbOptions.copy( + semanticDbSourceRoot = Some(originalWorkspace) + ) + ) + ) + else this + lazy val platform: Positioned[Platform] = scalaOptions.platform.getOrElse(Positioned(List(Position.Custom("DEFAULT")), Platform.JVM))