Skip to content

Commit

Permalink
feature: Support Bazel as a build tool (#3233)
Browse files Browse the repository at this point in the history
* Support Bazel as a build tool - wip

* Auto-install bazel-bsp

If `WORKSPACE` is found, and `.bsp/bazelbsp.json` is not found,
install bazel-bsp 2.7.1 and write the config file to `.bsp/bazelbsp.json`.

* Refresh workspace when build changed

we should update metals-vscode so it fires textDocument/didSave

* fix: Fix PathTrie with empty paths

When aths where empty, FileWatcher was watching the entire file system

* feat: Detect changes in Bazel-related files

Adds two improvements:
1. After changing BUILD, WORKSPACE or *.bzl file, user will be prompted to import build.
2. If .bazelbsp is not present, user will be asked if they want to create it (the same way as with .bloop)

* improvement: Persist checksum on connecting to new build server

Previously cheksum was not persisted there, so first save of build file would result in unnecessary reload

* improvement: Add basic BazelBsp test

* improvement: Strip ANSI coloring from BazelBsp logs

* improvement: Add Bazel slow tests to CI

* improvement: Add more test for BazelBsp

Adds tests for connecting to build server using GenerateBspConfig and ImportBuild commands

* improvement: Apply review suggestions

* fix: Fix memory leak in supportedBuildTool

MetalsLspService.supportedBuildTool was starting new FileWatcher if there was no build tool present,
which was never canceled if indexing didn't happen (eg. build tool was never found).
It caused memory leak in unit tests, because we were never killing FileWatcher threads.
Now we watch .bsp folder by default

* improv: Apply review suggestions

* bump bazel-bsp to 3.1.0

* improvement: Bump bazelbsp to 3.1.0

Since 3.0.0 build targets are empty by default. This can be unintuitive for users,
so if there is no .bazelproject file we default to all build targets.
Also fixes issue with running build server twice on Metals initialize

* bugfix: Ignore non scala or java build targets

* improvement: Look for projectview in ijwb directory

* improvement: Add diagnostics check to bazel tests

* improvement: Warning message for no semanticdb in bazel projects

* improvement: Download bazelbsp dependency

---------

Co-authored-by: Kamil Podsiadlo <[email protected]>
Co-authored-by: Rikito Taniguchi <[email protected]>
Co-authored-by: Jakub Ciesluk <[email protected]>
Co-authored-by: Tomasz Godzik <[email protected]>
  • Loading branch information
5 people authored Jan 16, 2024
1 parent 30f8ab5 commit 76caf3b
Show file tree
Hide file tree
Showing 24 changed files with 686 additions and 40 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
gradle,
scalacli,
mill,
bazel,
feature,
cross,
scalafmt,
Expand Down Expand Up @@ -92,6 +93,11 @@ jobs:
name: Mill integration
os: ubuntu-latest
java: "17"
- type: bazel
command: bin/test.sh 'slow/testOnly -- tests.bazel.*'
name: Bazel integration
os: ubuntu-latest
java: "17"
- type: scalacli
command: bin/test.sh 'slow/testOnly -- tests.scalacli.*'
name: Scala CLI integration
Expand Down
184 changes: 184 additions & 0 deletions metals/src/main/scala/scala/meta/internal/builds/BazelBuildTool.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package scala.meta.internal.builds

import java.util.concurrent.TimeUnit

import scala.concurrent.ExecutionContext
import scala.concurrent.Future

import scala.meta.internal.metals.JavaBinary
import scala.meta.internal.metals.Messages
import scala.meta.internal.metals.Messages.ImportBuild
import scala.meta.internal.metals.MetalsEnrichments._
import scala.meta.internal.metals.Tables
import scala.meta.internal.metals.UserConfiguration
import scala.meta.internal.process.ExitCodes
import scala.meta.io.AbsolutePath

import coursierapi.Dependency
import coursierapi.Fetch
import org.eclipse.lsp4j.services.LanguageClient

case class BazelBuildTool(
userConfig: () => UserConfiguration,
projectRoot: AbsolutePath,
) extends BuildTool
with BuildServerProvider
with VersionRecommendation {

override def digest(workspace: AbsolutePath): Option[String] = {
BazelDigest.current(projectRoot)
}

def createBspFileArgs(workspace: AbsolutePath): Option[List[String]] =
Option.when(workspaceSupportsBsp)(composeArgs())

def workspaceSupportsBsp: Boolean = {
projectRoot.list.exists {
case file if file.filename == "WORKSPACE" => true
case _ => false
}
}

private def composeArgs(): List[String] = {
val classpathSeparator = java.io.File.pathSeparator
val classpath = Fetch
.create()
.withDependencies(BazelBuildTool.dependency)
.fetch()
.asScala
.mkString(classpathSeparator)
List(
JavaBinary(userConfig().javaHome),
"-classpath",
classpath,
BazelBuildTool.mainClass,
) ++ BazelBuildTool.projectViewArgs(projectRoot)
}

override def minimumVersion: String = "3.0.0"

override def recommendedVersion: String = version

override def version: String = BazelBuildTool.version

override def toString: String = "Bazel"

override def executableName = BazelBuildTool.name

override val forcesBuildServer = true

override def buildServerName: String = BazelBuildTool.bspName

}

object BazelBuildTool {
val name: String = "bazel"
val bspName: String = "bazelbsp"
val version: String = "3.1.0"

val mainClass = "org.jetbrains.bsp.bazel.install.Install"

val dependency: Dependency = Dependency.of(
"org.jetbrains.bsp",
"bazel-bsp",
version,
)

private def hasProjectView(dir: AbsolutePath): Option[AbsolutePath] =
dir.list.find(_.filename.endsWith(".bazelproject"))

private def existingProjectView(
projectRoot: AbsolutePath
): Option[AbsolutePath] =
List(projectRoot, projectRoot.resolve("ijwb"), projectRoot.resolve(".ijwb"))
.filter(_.isDirectory)
.flatMap(hasProjectView)
.headOption

private def projectViewArgs(projectRoot: AbsolutePath): List[String] = {
existingProjectView(projectRoot) match {
case Some(projectView) =>
List("-p", projectView.toRelative(projectRoot).toString())
case None =>
List(
"-t",
"//...",
)
}
}

def writeBazelConfig(
shellRunner: ShellRunner,
projectDirectory: AbsolutePath,
javaHome: Option[String],
)(implicit
ec: ExecutionContext
): Future[WorkspaceLoadedStatus] = {
def run() =
shellRunner.runJava(
dependency,
mainClass,
projectDirectory,
projectViewArgs(projectDirectory),
javaHome,
false,
)
run()
.flatMap { code =>
scribe.info(s"Generate Bazel-BSP process returned code $code")
if (code != 0) run()
else Future.successful(0)
}
.map {
case ExitCodes.Success => WorkspaceLoadedStatus.Installed
case ExitCodes.Cancel => WorkspaceLoadedStatus.Cancelled
case result =>
scribe.error("Failed to write Bazel-BSP config to .bsp")
WorkspaceLoadedStatus.Failed(result)
}
}

def maybeWriteBazelConfig(
shellRunner: ShellRunner,
projectDirectory: AbsolutePath,
languageClient: LanguageClient,
tables: Tables,
javaHome: Option[String],
forceImport: Boolean = false,
)(implicit
ec: ExecutionContext
): Future[WorkspaceLoadedStatus] = {
val notification = tables.dismissedNotifications.ImportChanges
if (forceImport) {
writeBazelConfig(shellRunner, projectDirectory, javaHome)
} else if (!notification.isDismissed) {
languageClient
.showMessageRequest(ImportBuild.params("Bazel"))
.asScala
.flatMap {
case item if item == Messages.dontShowAgain =>
notification.dismissForever()
Future.successful(WorkspaceLoadedStatus.Rejected)
case item if item == ImportBuild.yes =>
writeBazelConfig(shellRunner, projectDirectory, javaHome)
case _ =>
notification.dismiss(2, TimeUnit.MINUTES)
Future.successful(WorkspaceLoadedStatus.Rejected)

}
} else {
scribe.info(
s"skipping build import with status ${WorkspaceLoadedStatus.Dismissed}"
)
Future.successful(WorkspaceLoadedStatus.Dismissed)
}
}

def isBazelRelatedPath(
workspace: AbsolutePath,
path: AbsolutePath,
): Boolean =
path.toNIO.startsWith(workspace.toNIO) &&
path.isBazelRelatedPath &&
!path.isInBazelBspDirectory(workspace)
}
23 changes: 23 additions & 0 deletions metals/src/main/scala/scala/meta/internal/builds/BazelDigest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package scala.meta.internal.builds

import java.security.MessageDigest

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

object BazelDigest extends Digestable {
override protected def digestWorkspace(
workspace: AbsolutePath,
digest: MessageDigest,
): Boolean = {
workspace.listRecursive.forall {
case file
if file.isBazelRelatedPath && !file.isInBazelBspDirectory(
workspace
) =>
Digest.digestFile(file, digest)
case _ =>
true
}
}
}
20 changes: 17 additions & 3 deletions metals/src/main/scala/scala/meta/internal/builds/BuildTools.scala
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ final class BuildTools(
.map(isBloop)
.getOrElse(
isBloop
) || (isBsp && all.isEmpty) || (isBsp && explicitChoiceMade()) || (isBsp && isBazel)
) || (isBsp && all.isEmpty) || (isBsp && explicitChoiceMade()) || isBazelBsp
}
def isBloop(root: AbsolutePath): Boolean = hasJsonFile(root.resolve(".bloop"))
def bloopProject: Option[AbsolutePath] = searchForBuildTool(isBloop)
Expand All @@ -61,7 +61,9 @@ final class BuildTools(
private def hasJsonFile(dir: AbsolutePath): Boolean = {
dir.list.exists(_.extension == "json")
}

def isBazelBsp: Boolean = {
workspace.resolve(".bazelbsp").isDirectory
}
// Returns true if there's a build.sbt file or project/build.properties with sbt.version
def sbtProject: Option[AbsolutePath] = searchForBuildTool { root =>
root.resolve("build.sbt").isFile || {
Expand Down Expand Up @@ -152,7 +154,11 @@ final class BuildTools(
}

private def knownBsps =
Set(SbtBuildTool.name, MillBuildTool.bspName) ++ ScalaCli.names
Set(
SbtBuildTool.name,
MillBuildTool.bspName,
BazelBuildTool.bspName,
) ++ ScalaCli.names

private def customProjectRoot = userConfig().getCustomProjectRoot(workspace)

Expand Down Expand Up @@ -184,6 +190,7 @@ final class BuildTools(
MavenBuildTool(userConfig, workspace),
MillBuildTool(userConfig, workspace),
ScalaCliBuildTool(workspace, workspace, userConfig),
BazelBuildTool(userConfig, workspace),
)
}

Expand All @@ -210,6 +217,7 @@ final class BuildTools(
mavenProject.foreach(buf += MavenBuildTool(userConfig, _))
millProject.foreach(buf += MillBuildTool(userConfig, _))
scalaCliProject.foreach(buf += ScalaCliBuildTool(workspace, _, userConfig))
bazelProject.foreach(buf += BazelBuildTool(userConfig, _))
buf.addAll(customBsps)

buf.result()
Expand All @@ -232,6 +240,12 @@ final class BuildTools(
Some(MavenBuildTool.name)
else if (isMill && MillBuildTool.isMillRelatedPath(path) || isMillBsp(path))
Some(MillBuildTool.name)
else if (
bazelProject.exists(
BazelBuildTool.isBazelRelatedPath(_, path)
) || isInBsp(path) && path.filename == "bazelbsp.json"
)
Some(BazelBuildTool.name)
else if (isInBsp(path))
Some(path.filename.stripSuffix(".json"))
else None
Expand Down
21 changes: 21 additions & 0 deletions metals/src/main/scala/scala/meta/internal/builds/Digest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,32 @@ object Digest {
digestGeneralJvm(path, digest)
} else if (isXml) {
digestXml(path, digest)
} else if (path.isBazelRelatedPath) {
digestBazel(path, digest)
} else {
true
}
}

def digestBazel(
file: AbsolutePath,
digest: MessageDigest,
): Boolean = {
try {
Files
.readAllLines(file.toNIO)
.asScala
.filterNot(_.trim().startsWith("#"))
.mkString("\n")
.split("\\s+")
.foreach { word => digest.update(word.getBytes()) }
true
} catch {
case NonFatal(_) =>
false
}
}

def digestXml(
file: AbsolutePath,
digest: MessageDigest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import scala.reflect.ClassTag
import scala.util.Success

import scala.meta.internal.bsp.ConnectionBspStatus
import scala.meta.internal.builds.BazelBuildTool
import scala.meta.internal.builds.MillBuildTool
import scala.meta.internal.builds.SbtBuildTool
import scala.meta.internal.metals.MetalsEnrichments._
Expand Down Expand Up @@ -101,6 +102,8 @@ class BuildServerConnection private (

def isMill: Boolean = name == MillBuildTool.bspName

def isBazel: Boolean = name == BazelBuildTool.bspName

def isScalaCLI: Boolean = ScalaCli.names(name)

def isAmmonite: Boolean = name == Ammonite.name
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package scala.meta.internal.metals

import java.util.UUID
import java.util.concurrent.TimeUnit

import scala.collection.concurrent.TrieMap
Expand Down Expand Up @@ -246,7 +247,9 @@ final class Compilations(
targets: Seq[b.BuildTargetIdentifier],
timeout: Option[Timeout],
): CancelableFuture[b.CompileResult] = {
val originId = "METALS-$" + UUID.randomUUID().toString
val params = new b.CompileParams(targets.asJava)
params.setOriginId(originId)
if (
userConfiguration().verboseCompilation && (connection.isBloop || connection.isScalaCLI)
) {
Expand Down
14 changes: 12 additions & 2 deletions metals/src/main/scala/scala/meta/internal/metals/Configs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ object Configs {
if (isVscode) workspace.toString()
else workspace.toURI.toString.stripSuffix("/")
new DidChangeWatchedFilesRegistrationOptions(
List(
(List(
new FileSystemWatcher(Either.forLeft(s"$root/*.sbt")),
new FileSystemWatcher(Either.forLeft(s"$root/pom.xml")),
new FileSystemWatcher(Either.forLeft(s"$root/*.sc")),
Expand All @@ -41,9 +41,19 @@ object Configs {
Either.forLeft(s"$root/.metals/.reports/bloop/*/*")
),
new FileSystemWatcher(Either.forLeft(s"$root/**/.bsp/*.json")),
).asJava
) ++ bazelPaths(root)).asJava
)
}

def bazelPaths(root: String): List[FileSystemWatcher] =
List(
new FileSystemWatcher(Either.forLeft(s"$root/**/BUILD")),
new FileSystemWatcher(Either.forLeft(s"$root/**/BUILD.bazel")),
new FileSystemWatcher(Either.forLeft(s"$root/WORKSPACE")),
new FileSystemWatcher(Either.forLeft(s"$root/WORKSPACE.bazel")),
new FileSystemWatcher(Either.forLeft(s"$root/**/*.bzl")),
new FileSystemWatcher(Either.forLeft(s"$root/*.bazelproject")),
)
}

object GlobSyntaxConfig {
Expand Down
Loading

0 comments on commit 76caf3b

Please sign in to comment.