diff --git a/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/BazelMbtBuildSupport.scala b/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/BazelMbtBuildSupport.scala index 0b9d63ec63f..0a8cc4299dd 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/BazelMbtBuildSupport.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/mbt/importer/BazelMbtBuildSupport.scala @@ -40,7 +40,7 @@ object BazelMbtBuildSupport { runTargets: Set[String], classDirectoriesByTarget: Map[String, String], dependencyModules: Seq[MbtDependencyModule], - scalaVersion: Option[String], + scalaVersionByNamespace: Map[String, Option[String]], genSrcOutputsByTarget: Map[String, List[String]] = Map.empty, ): MbtBuild = { val depModules = new ju.ArrayList[MbtDependencyModule]() @@ -49,7 +49,11 @@ object BazelMbtBuildSupport { if (granularity == BazelMbtNamespaceMode.Workspace) { MbtBuild( depModules, - singleNamespace(workspaceNamespaceName, Set.empty, scalaVersion), + singleNamespace( + workspaceNamespaceName, + Set.empty, + scalaVersionByNamespace.getOrElse(workspaceNamespaceName, None), + ), uncheckedSources = ju.Collections.emptyList(), ) } else { @@ -148,7 +152,7 @@ object BazelMbtBuildSupport { externalDepsByNs.getOrElse(namespace, Set.empty), runTargetsByNs.getOrElse(namespace, Set.empty), classDirectoriesByNs.get(namespace), - scalaVersion, + scalaVersionByNamespace.getOrElse(namespace, None), genSrcOutputsByNamespaces .getOrElse(namespace, mutable.Buffer.empty) .toSeq, @@ -169,7 +173,7 @@ object BazelMbtBuildSupport { allExtDeps, runTargetsByNs.getOrElse(workspaceNamespaceName, Set.empty), classDirectoriesByNs.get(workspaceNamespaceName), - scalaVersion, + scalaVersionByNamespace.getOrElse(workspaceNamespaceName, None), allGenSrcOutputs, ) } 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 e63466edf54..55d2a2f8851 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 @@ -16,6 +16,7 @@ import scala.meta.internal.metals.Tables import scala.meta.internal.metals.UserConfiguration import scala.meta.internal.metals.clients.language.MetalsLanguageClient import scala.meta.internal.metals.mbt.MbtBuild +import scala.meta.internal.metals.mbt.MbtDependencyModule import scala.meta.internal.process.ExitCodes import scala.meta.io.AbsolutePath @@ -108,11 +109,13 @@ abstract class BazelMbtImporter( dependencyModules, mavenHubs, ) - scalaVersionFromDeps <- queryScalaVersionFromDeps() - effectiveScalaVersion <- scalaVersionFromDeps match { - case Some(value) => Future.successful(Some(value)) - case None => queryScalaVersion(targets) - } + scalaVersionByNamespace <- resolveScalaVersionByNamespace( + namespaceMode, + targets, + targetsXmlDump, + externalDeps, + outputBase, + ) build = BazelMbtBuildSupport.fromDiscovery( namespaceMode, targets, @@ -124,7 +127,7 @@ abstract class BazelMbtImporter( runTargets, classDirectories, dependencyModules, - effectiveScalaVersion, + scalaVersionByNamespace, genSrcOutputsByTarget, ) _ <- Future(Files.writeString(out.toNIO, MbtBuild.toJson(build))) @@ -210,15 +213,94 @@ abstract class BazelMbtImporter( }.toMap } - private def queryScalaVersionFromDeps(): Future[Option[String]] = for { - queryOutput <- BazelQuery.allScalaLibrariesQuery.run(queryEnv) - lines = asLines(queryOutput) - } yield lines.flatMap(extractScalaVersionFromLabel).headOption + private def bazelLabelFromModuleId( + moduleId: String, + repositoryName: String, + ): Option[String] = { + val parts = moduleId.split(":") + if (parts.length >= 2) { + val groupId = parts(0) + val artifactId = parts(1) + val sanitizedGroup = groupId.replace('.', '_').replace('-', '_') + val sanitizedArtifact = artifactId.replace('.', '_').replace('-', '_') + Some(s"@$repositoryName//:${sanitizedGroup}_$sanitizedArtifact") + } else None + } + + private def normalizeBazelLabel(label: String): String = { + val withoutDoubleAt = + if (label.startsWith("@@")) label.substring(1) else label + withoutDoubleAt.replaceAll("~[^/]+", "") + } + + private def resolveScalaVersionByNamespace( + namespaceMode: BazelMbtNamespaceMode, + targets: List[String], + targetsXmlDump: BazelTargetsXmlDump, + externalDeps: Map[String, List[String]], + outputBase: Option[Path], + ): Future[Map[String, Option[String]]] = { + val scalaVersionAttributes = targetsXmlDump.getStrings("scala_version") + val namespaceToTargets = targets.groupBy( + BazelMbtBuildSupport.namespaceKey(namespaceMode, _) + ) + val versionsByNamespace = + namespaceToTargets.map { case (ns, nsTargets) => + val explicitVersions = + nsTargets.flatMap(t => scalaVersionAttributes.getOrElse(t, Nil)) + val version = pickMaxScalaVersion( + if (explicitVersions.nonEmpty) explicitVersions + else + nsTargets + .flatMap(t => externalDeps.getOrElse(t, Nil)) + .flatMap(extractScalaVersionFromLabel) + ) + ns -> version + } + val namespacesNeedingFallback = + versionsByNamespace.collect { case (ns, None) => ns } + val globalFallback = + if (namespacesNeedingFallback.isEmpty) Future.successful(None) + else + parseScalaVersionFromRulesScalaConfig(outputBase) + .orElse(parseScalaVersionFromBuildFiles()) + .map(v => Future.successful(Some(v))) + .getOrElse(queryScalaVersionFromDeps()) + + globalFallback.map { fallbackVersion => + versionsByNamespace.map { case (ns, v) => + ns -> v.orElse(fallbackVersion) + } + } + } + + private def parseScalaVersionFromRulesScalaConfig( + outputBase: Option[Path] + ): Option[String] = { + val versionPattern = """SCALA_VERSION\s*=\s*["'](\d+\.\d+\.\d+)["']""".r + outputBase.flatMap { base => + val externalDir = base.resolve("external").toFile + val entries = externalDir.listFiles() + val configDir = Option(entries).flatMap( + _.find(f => f.isDirectory && f.getName.contains("rules_scala_config")) + .map(_.toPath) + ) + configDir.flatMap { dir => + val configFile = dir.resolve("config.bzl") + if (Files.exists(configFile)) { + val content = new String(Files.readAllBytes(configFile)) + versionPattern.findFirstMatchIn(content).map(_.group(1)) + } else None + } + } + } - private def queryScalaVersion( - @annotation.nowarn("msg=never used") targets: List[String] - ): Future[Option[String]] = - Future.successful(parseScalaVersionFromBuildFiles()) + private def queryScalaVersionFromDeps(): Future[Option[String]] = + BazelQuery.allScalaLibrariesQuery.run(queryEnv).map { queryOutput => + pickMaxScalaVersion( + asLines(queryOutput).flatMap(extractScalaVersionFromLabel) + ) + } private def parseScalaVersionFromBuildFiles(): Option[String] = { val versionPattern = """scala_version\s*=\s*["'](\d+\.\d+\.\d+)["']""".r @@ -234,8 +316,19 @@ abstract class BazelMbtImporter( extractFromFile(moduleFile).orElse(extractFromFile(workspaceFile)) } + private def pickMaxScalaVersion(versions: List[String]): Option[String] = + versions.maxByOption { v => + val p = v.split('.') + ( + p.lift(0).flatMap(_.toIntOption).getOrElse(0), + p.lift(1).flatMap(_.toIntOption).getOrElse(0), + p.lift(2).flatMap(_.toIntOption).getOrElse(0), + ) + } + private def extractScalaVersionFromLabel(label: String): Option[String] = { - val versionPattern = """scala[_-]library[_-](\d+\.\d+\.\d+)""".r + val versionPattern = + """scala(?:3-library_3|[_-]library)[_-](\d+\.\d+\.\d+)""".r versionPattern.findFirstMatchIn(label).map(_.group(1)) } diff --git a/tests/slow/src/test/scala/tests/bazel/BazelMbtLspSuite.scala b/tests/slow/src/test/scala/tests/bazel/BazelMbtLspSuite.scala index fb9ddf28343..65f42bfa89d 100644 --- a/tests/slow/src/test/scala/tests/bazel/BazelMbtLspSuite.scala +++ b/tests/slow/src/test/scala/tests/bazel/BazelMbtLspSuite.scala @@ -51,6 +51,56 @@ class BazelMbtLspSuite private val catsVersion = "2.13.0" private val jsoupVersion = "1.21.1" + private def mixedScalaVersionsLayout: String = + s"""|/.bazelproject + |targets: + | //... + | + |/MODULE.bazel + |bazel_dep(name = "rules_scala", version = "7.2.4") + | + |scala_config = use_extension("@rules_scala//scala/extensions:config.bzl", "scala_config") + |scala_config.settings( + | scala_version = "${V.scala213}", + | scala_versions = ["${V.scala213}", "${V.scala3}"], + |) + | + |scala_deps = use_extension("@rules_scala//scala/extensions:deps.bzl", "scala_deps") + |scala_deps.settings(fetch_sources = True) + |scala_deps.scala() + |scala_deps.scala(scala_version = "${V.scala3}") + |use_repo(scala_deps, "rules_scala_toolchains") + | + |register_toolchains("@rules_scala_toolchains//...:all") + | + |/lib2/BUILD + |load("@rules_scala//scala:scala.bzl", "scala_library") + | + |scala_library( + | name = "lib2", + | srcs = ["Lib2.scala"], + |) + | + |/lib2/Lib2.scala + |package lib2 + | + |class Lib2 + | + |/lib3/BUILD + |load("@rules_scala//scala:scala.bzl", "scala_library") + | + |scala_library( + | name = "lib3", + | srcs = ["Lib3.scala"], + | scala_version = "${V.scala3}", + |) + | + |/lib3/Lib3.scala + |package lib3 + | + |class Lib3 + |""".stripMargin + /** Same targets as [[BazelLspSuite]], plus a project view so MBT import scopes `bazel query`. */ private def bazelWorkspaceLayout: String = { val projectView = @@ -434,7 +484,7 @@ class BazelMbtLspSuite | "org.typelevel:cats-core_2.13:2.13.0", | "org.typelevel:cats-kernel_2.13:2.13.0" | ], - | "scalaVersion": "2.13.14", + | "scalaVersion": "2.13.16", | "dependsOn": [], | "classDirectories": [] | } @@ -866,4 +916,46 @@ class BazelMbtLspSuite } yield () } + test("bazel-import-mbt-mixed-scala-versions") { + cleanWorkspace() + for { + _ <- initialize( + BazelBuildLayout(mixedScalaVersionsLayout, V.scala213, bazelVersion) + ) + _ <- server.headServer.connectionProvider.buildServerPromise.future + mbtFile = workspace.resolve(".metals/mbt.json").readText + _ = assertNoDiff( + escapeMbtFile(mbtFile), + s"""|{ + | "dependencyModules": [], + | "namespaces": { + | "//lib2": { + | "sources": [ + | "lib2/Lib2.scala" + | ], + | "scalacOptions": [], + | "javacOptions": [], + | "dependencyModules": [], + | "scalaVersion": "${V.scala213}", + | "dependsOn": [], + | "classDirectories": [] + | }, + | "//lib3": { + | "sources": [ + | "lib3/Lib3.scala" + | ], + | "scalacOptions": [], + | "javacOptions": [], + | "dependencyModules": [], + | "scalaVersion": "${V.scala3}", + | "dependsOn": [], + | "classDirectories": [] + | } + | }, + | "uncheckedSources": [] + |}""".stripMargin, + ) + } yield () + } + }