Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions bin/regenerate-bazel-proto-fixture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAybe it makes sense to put it into test resources?

# Regenerates the `bazel query --output=streamed_proto` fixture consumed by
# `tests.bazel.BazelTargetsProtoDumpRealOutputSuite`:
#
# tests/unit/src/test/resources/bazel/rules-scala-fullinfo-fragments.pb
#
# Run it from INSIDE a bazelbuild/rules_scala checkout (commit 156e65c /
# v7.2.5-5-g156e65c, Bazel 7.7.1). It does no validation — it just runs the
# importer's exact full-information query over the nine pinned targets and
# writes the raw binary stream.
#
# Usage (from within the rules_scala repo):
# /path/to/metals/bin/regenerate-bazel-proto-fixture.sh > rules-scala-fullinfo-fragments.pb
# /path/to/metals/bin/regenerate-bazel-proto-fixture.sh /path/to/metals/tests/unit/src/test/resources/bazel/rules-scala-fullinfo-fragments.pb
set -euo pipefail

# The nine hand-picked targets: six rules plus a SOURCE_FILE, a GENERATED_FILE
# and a PACKAGE_GROUP, chosen to cover the decoder's edge cases with no overlap.
TARGETS="\
//third_party/dependency_analyzer/src/main:scala_version \
//src/java/io/bazel/rulesscala/scalac/reporter:reporter \
//third_party/dependency_analyzer/src/main/io/bazel/rulesscala/dependencyanalyzer/compiler:dep_reporting_compiler \
//src/java/io/bazel/rulesscala/scalac:scalac_files \
//src/java/io/bazel/rulesscala/scalac:scalac \
//third_party/dependency_analyzer/src/test:scalac_dependency_test \
//java_stub_template/file:file.txt \
//scala:libPlaceHolderClassToCreateEmptyJarForScalaImport.jar \
@bazel_tools//src/main/cpp/util:ijar"

# The importer's production query flags (see BazelQuery.scala) plus
# `--proto:locations=false` to keep the fixture compact.
# `--keep_going` exits 3 on partial success, which is expected here,
# so don't let `set -e` trip on it.
# stdout is the raw binary; a path arg (if given) receives it, else it
# goes to this script's stdout for the caller to redirect.
run() {
bazel query \
--output=streamed_proto \
--proto:flatten_selects=false \
--proto:output_rule_attrs=srcs,scalacopts,javacopts,scala_version,jars,srcjar \
--proto:locations=false \
--keep_going \
"set(${TARGETS})"
}

if [[ $# -ge 1 ]]; then
run > "$1" || { rc=$?; [[ $rc -eq 3 ]] || exit $rc; }
echo "Wrote $(wc -c < "$1") bytes to $1" >&2
else
run || { rc=$?; [[ $rc -eq 3 ]] || exit $rc; }
fi
44 changes: 44 additions & 0 deletions jsemanticdb/src/main/protobuf/bazel_query.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// A trimmed subset of Bazel's `src/main/protobuf/build.proto` for the MBT
// importer. Field NUMBERS must stay identical to build.proto — that is what the
// wire format depends on.
// Based on
// https://github.com/bazelbuild/bazel/blob/master/src/main/protobuf/build.proto
syntax = "proto2";

package metals_bazel_query;

option java_package = "scala.meta.internal.metals.mbt.importer.bazelproto";
option java_outer_classname = "BazelQueryProto";
Comment thread
maksymilianrozanski marked this conversation as resolved.

message Attribute {
message SelectorEntry {
optional string label = 1;
optional string string_value = 3;
repeated string string_list_value = 6;
}

message Selector {
repeated SelectorEntry entries = 1;
}

message SelectorList {
repeated Selector elements = 2;
}

optional string name = 1;
optional string string_value = 5;
repeated string string_list_value = 6;
optional SelectorList selector_list = 21;
}

message Rule {
optional string name = 1;
optional string rule_class = 2;
repeated Attribute attribute = 4;
repeated string rule_input = 5;
repeated string rule_output = 6;
}

message Target {
optional Rule rule = 2;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package scala.meta.internal.builds
import scala.concurrent.ExecutionContext
import scala.concurrent.Future

import scala.meta.internal.builds.BazelBuildTool.minBazelVersion
import scala.meta.internal.metals.Embedded
import scala.meta.internal.metals.JavaBinary
import scala.meta.internal.metals.MetalsEnrichments._
Expand Down Expand Up @@ -60,7 +61,7 @@ case class BazelBuildTool(
) ++ BazelBuildTool.projectViewArgs(projectRoot)
}

override def minimumVersion: String = "5.0.0"
override def minimumVersion: String = minBazelVersion

override def recommendedVersion: String = version

Expand Down Expand Up @@ -270,6 +271,7 @@ object BazelBuildTool {
val name: String = "bazel"
val bspName: String = "bazelbsp"
val bspVersion: String = "4.0.3"
val minBazelVersion = "5.0.0"
val defaultBazelVersion = "8.2.1"

def resolveBazelVersion(projectRoot: AbsolutePath): String = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package scala.meta.internal.metals.mbt.importer

import scala.collection.mutable

// Per-target Scala-version recovery from unflattened `select()` srcs.
object BazelBuildSrcs {

case class TargetSrcs(
unconditional: Set[String],
byVersion: Map[String, Set[String]],
) {
def activeFor(scalaVersion: Option[String]): Set[String] =
unconditional ++ scalaVersion.flatMap(byVersion.get).getOrElse(Set.empty)
}
Comment on lines +8 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case class TargetSrcs(
unconditional: Set[String],
byVersion: Map[String, Set[String]],
) {
def activeFor(scalaVersion: Option[String]): Set[String] =
unconditional ++ scalaVersion.flatMap(byVersion.get).getOrElse(Set.empty)
}
case class TargetSrcs(
alwaysIncludedFiles: Set[String],
versionSpecificFiles: Map[String, Set[String]],
) {
def activeFilesForVersion(scalaVersion: Option[String]): Set[String] =
alwaysIncluded ++ scalaVersion.flatMap(versionSpecificFiles.get).getOrElse(Set.empty)
}

it took me a while to understand, so this is maybe a bit better?


case class InactiveSource(version: String, originTarget: String)

// Highest inactive-branch version wins; ties broken by smallest origin label.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a bit more why use it, why do we have inactive sources in bazel.

def inactiveSources(
srcsByTarget: Map[String, TargetSrcs],
scalaVersionByTarget: Map[String, Option[String]],
): Map[String, InactiveSource] = {
val byTarget = srcsByTarget.filter { case (target, _) =>
scalaVersionByTarget.contains(target)
}
val active = byTarget.flatMap { case (target, srcs) =>
srcs.activeFor(scalaVersionByTarget.getOrElse(target, None))
}.toSet
val candidatesByLabel =
mutable.Map.empty[String, mutable.Set[(String, String)]]
for {
(target, srcs) <- byTarget
(version, srcLabels) <- srcs.byVersion
srcLabel <- srcLabels
if !active.contains(srcLabel)
} candidatesByLabel.getOrElseUpdate(srcLabel, mutable.Set.empty) +=
(version -> target)
candidatesByLabel.flatMap { case (srcLabel, candidates) =>
BazelScalaVersions
.maxVersion(candidates.map { case (version, _) => version })
.map { version =>
val origin = candidates.collect { case (`version`, target) =>
target
}.min
srcLabel -> InactiveSource(version, origin)
}
}.toMap
}

private val scalaVersionKey = """:scala_version_(\d+_\d+_\d+)""".r

// Non-`scala_version` branches (literals, defaults) count as always compiled.
def parseSrcs(rule: BazelRule): TargetSrcs = {
val unconditional = mutable.Set.empty[String]
val byVersion = mutable.Map.empty[String, mutable.Set[String]]
for (branch <- rule.branches("srcs")) {
branchVersion(branch.label) match {
case Some(version) =>
byVersion.getOrElseUpdate(
version,
mutable.Set.empty,
) ++= branch.values
case None => unconditional ++= branch.values
}
}
TargetSrcs(
unconditional.toSet,
byVersion.map { case (k, v) => k -> v.toSet }.toMap,
)
}

private def branchVersion(label: Option[String]): Option[String] =
label.flatMap { l =>
scalaVersionKey.findFirstMatchIn(l).map(_.group(1).replace('_', '.'))
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package scala.meta.internal.metals.mbt.importer

import java.io.ByteArrayOutputStream
import java.nio.file.Files
import java.nio.file.Path

Expand Down Expand Up @@ -30,15 +31,24 @@ object BazelQuery {
javaHome: Option[String],
)

sealed abstract class OutputMode(name: String) {
sealed abstract class OutputMode(name: String, val extraArgs: List[String]) {
override def toString(): String = name
}
object OutputMode {
case object Label extends OutputMode("label")
case object Xml extends OutputMode("xml")
case object Label extends OutputMode("label", Nil)
case object Xml extends OutputMode("xml", Nil)

// valid only for "bazel cquery"
case object Starlark extends OutputMode("starlark")
case object Starlark extends OutputMode("starlark", Nil)

private val targetStreamArgs = List(
"--proto:flatten_selects=false",
"--proto:output_rule_attrs=srcs,scalacopts,javacopts,scala_version,jars,srcjar",
)

// Binary length-delimited `Target` stream; must be captured as raw bytes.
case object StreamedProto
extends OutputMode("streamed_proto", targetStreamArgs)
}
sealed abstract class QueryType(name: String) {
override def toString(): String = name
Expand Down Expand Up @@ -123,54 +133,78 @@ case class BazelQuery(
def run(
env: Env
)(implicit ec: ExecutionContext): Future[String] = {
import env._
val buf = new StringBuilder()
execute(
env,
ProcessOutput.Lines { line =>
buf.append(line)
buf.append(System.lineSeparator())
},
)(() => buf.toString)
}

/**
* Runs the query and parses its binary `streamed_proto` `Target` stream
* (see [[fullInformationQuery]]) into a [[BazelTargetsProtoDump]].
* Stream captured as raw bytes — the line-based [[run]] path would
* mangle non-text bytes — and parsed by [[BazelStreamedProto.parseRules]].
*/
def runProtoDump(
env: Env
)(implicit ec: ExecutionContext): Future[BazelTargetsProtoDump] =
runRaw(env)
.map(BazelStreamedProto.parseRules)
.map(new BazelTargetsProtoDump(_))

private def runRaw(
env: Env
)(implicit ec: ExecutionContext): Future[Array[Byte]] = {
val buf = new ByteArrayOutputStream()
execute(env, ProcessOutput.RawBytes(buf))(() => buf.toByteArray)
}

private def execute[A](
env: Env,
processOut: ProcessOutput,
)(result: () => A)(implicit ec: ExecutionContext): Future[A] = {
import env._
val (queryArgs, queryFile) = prepareQueryArgs(query)
shellRunner
.run(
s"bazel-mbt-$queryType",
List(
"bazel",
queryType.toString,
s"--output=$outputMode",
"--keep_going",
) ++ queryArgs ++ extraArgs,
bazelQueryArgs(queryArgs),
projectRoot,
redirectErrorOutput = false,
javaHome,
processOut = ProcessOutput.Lines { line =>
buf.append(line)
buf.append(System.lineSeparator())
},
processOut = processOut,
processErr = scribe.warn(_),
)
.future
.andThen {
case result => {
Try {
// Just ignore the possible error; we will have a second attempt to delete the file upon the VM exit
queryFile.foreach(Files.delete)
}
result
}
}
// Ignore a failed delete; the VM-exit hook retries it.
.andThen { case _ => Try(queryFile.foreach(Files.delete)) }
.flatMap {
case ExitCodes.Success =>
Future.successful(buf.toString)
case ExitCodes.Cancel =>
Future.failed(
new java.util.concurrent.CancellationException(
"bazel-mbt: query cancelled"
)
)
case code =>
scribe.warn(
s"bazel-mbt: bazel query failed with exit code $code, but might be unreleated."
)
Future.successful(buf.toString)
if (code != ExitCodes.Success)
scribe.warn(
s"bazel-mbt: bazel query failed with exit code $code, but might be unrelated."
)
Future.successful(result())
}
}

private def bazelQueryArgs(queryArgs: List[String]): List[String] =
List(
"bazel",
queryType.toString,
s"--output=$outputMode",
) ++ outputMode.extraArgs ++ List("--keep_going") ++ queryArgs ++ extraArgs

private def prepareQueryArgs(query: String): (List[String], Option[Path]) = {
if (query.length() <= queryStringMaxLength) (List(query), None)
else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package scala.meta.internal.metals.mbt.importer

// A `bazel query` rule reduced to the fields the importer reads. `attributes`
// keeps each `select()` unflattened as a list of branches.
case class BazelRule(
name: String,
ruleClass: Option[String],
ruleInputs: List[String],
ruleOutputs: List[String],
attributes: Map[String, List[BazelSelectBranch]],
) {

def branches(attribute: String): List[BazelSelectBranch] =
attributes.getOrElse(attribute, Nil)

def flattenedStrings(attribute: String): List[String] =
branches(attribute).flatMap(_.values).filter(_.nonEmpty)
}

// `label` is the `config_setting` guarding a `select()` branch, or `None` for a
// plain value; `values` holds a scalar as a single-element list.
case class BazelSelectBranch(label: Option[String], values: List[String])
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package scala.meta.internal.metals.mbt.importer

import scala.util.Failure
import scala.util.Success
import scala.util.Try

import scala.meta.internal.semver.SemVer

object BazelScalaVersions {

// Highest SemVer-parseable version; `scala_version` is a free-form STRING, so
// unparseable values are logged and skipped.
def maxVersion(versions: Iterable[String]): Option[String] =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probable move it to ScalaVersions class, which handles this already.

versions.toSeq.distinct
.flatMap { version =>
Try(SemVer.Version.fromString(version)) match {
case Success(parsed) => Some(version -> parsed)
case Failure(_) =>
scribe.warn(
s"bazel-mbt: could not parse Scala version '$version'; ignoring it"
)
None
}
}
.maxByOption { case (_, parsed) => parsed }
.map { case (version, _) => version }

}
Loading
Loading