From a76e209dde0b4d4aa5a26aa96bd1f44b19bc4e1d Mon Sep 17 00:00:00 2001 From: Adrien Piquerez Date: Fri, 25 Jun 2021 18:14:59 +0200 Subject: [PATCH] [BSP] send diagnostics when evaluating build.sbt Since build.sbt is compiled/evaluated in `sbt.compiler.Eval`, this commit introduces a `BuildServerEvalReporter` to redirect the compiler errors to the BSP clients. A new `finalReport` method is added in the new `EvalReporter` base class to reset the old diagnostics. --- .../src/main/scala/sbt/compiler/Eval.scala | 66 +++++++------ .../scala/sbt/compiler/EvalReporter.scala | 54 +++++++++++ .../test/scala/sbt/compiler/EvalTest.scala | 4 +- main/src/main/scala/sbt/EvaluateTask.scala | 6 +- main/src/main/scala/sbt/internal/Load.scala | 42 ++++++--- .../server/BuildServerEvalReporter.scala | 93 +++++++++++++++++++ main/src/test/scala/PluginCommandTest.scala | 2 +- 7 files changed, 223 insertions(+), 44 deletions(-) create mode 100644 main-actions/src/main/scala/sbt/compiler/EvalReporter.scala create mode 100644 main/src/main/scala/sbt/internal/server/BuildServerEvalReporter.scala diff --git a/main-actions/src/main/scala/sbt/compiler/Eval.scala b/main-actions/src/main/scala/sbt/compiler/Eval.scala index 461a8c727..ea841dbd2 100644 --- a/main-actions/src/main/scala/sbt/compiler/Eval.scala +++ b/main-actions/src/main/scala/sbt/compiler/Eval.scala @@ -12,7 +12,7 @@ import scala.collection.mutable.ListBuffer import scala.tools.nsc.{ ast, io, reporters, CompilerCommand, Global, Phase, Settings } import io.{ AbstractFile, PlainFile, VirtualDirectory } import ast.parser.Tokens -import reporters.{ ConsoleReporter, Reporter } +import reporters.Reporter import scala.reflect.internal.util.{ AbstractFileClassLoader, BatchSourceFile } import Tokens.{ EOF, NEWLINE, NEWLINES, SEMI } import java.io.{ File, FileNotFoundException } @@ -65,12 +65,12 @@ final class EvalException(msg: String) extends RuntimeException(msg) final class Eval( optionsNoncp: Seq[String], classpath: Seq[File], - mkReporter: Settings => Reporter, + mkReporter: Settings => EvalReporter, backing: Option[File] ) { - def this(mkReporter: Settings => Reporter, backing: Option[File]) = + def this(mkReporter: Settings => EvalReporter, backing: Option[File]) = this(Nil, IO.classLocationPath[Product].toFile :: Nil, mkReporter, backing) - def this() = this(s => new ConsoleReporter(s), None) + def this() = this(EvalReporter.console, None) backing.foreach(IO.createDirectory) val classpathString = Path.makeString(classpath ++ backing.toList) @@ -114,6 +114,7 @@ final class Eval( line: Int = DefaultStartLine ): EvalResult = { val ev = new EvalType[String] { + def sourceName: String = srcName def makeUnit = mkUnit(srcName, line, expression) def unlink = true def unitBody(unit: CompilationUnit, importTrees: Seq[Tree], moduleName: String): Tree = { @@ -142,6 +143,7 @@ final class Eval( require(definitions.nonEmpty, "Definitions to evaluate cannot be empty.") val ev = new EvalType[Seq[String]] { lazy val (fullUnit, defUnits) = mkDefsUnit(srcName, definitions) + def sourceName: String = srcName def makeUnit = fullUnit def unlink = false def unitBody(unit: CompilationUnit, importTrees: Seq[Tree], moduleName: String): Tree = { @@ -202,28 +204,19 @@ final class Eval( val hash = Hash.toHex(d) val moduleName = makeModuleName(hash) - lazy val unit = { - reporter.reset - ev.makeUnit - } - lazy val run = new Run { - override def units = (unit :: Nil).iterator - } - def unlinkAll(): Unit = - for ((sym, _) <- run.symSource) if (ev.unlink) unlink(sym) else toUnlinkLater ::= sym - - val (extra, loader) = backing match { - case Some(back) if classExists(back, moduleName) => - val loader = (parent: ClassLoader) => - (new URLClassLoader(Array(back.toURI.toURL), parent): ClassLoader) - val extra = ev.read(cacheFile(back, moduleName)) - (extra, loader) - case _ => - try { - compileAndLoad(run, unit, imports, backing, moduleName, ev) - } finally { - unlinkAll() - } + val (extra, loader) = try { + backing match { + case Some(back) if classExists(back, moduleName) => + val loader = (parent: ClassLoader) => + (new URLClassLoader(Array(back.toURI.toURL), parent): ClassLoader) + val extra = ev.read(cacheFile(back, moduleName)) + (extra, loader) + case _ => + compileAndLoad(imports, backing, moduleName, ev) + } + } finally { + // send a final report even if the class file was backed to reset preceding diagnostics + reporter.finalReport(ev.sourceName) } val generatedFiles = getGeneratedFiles(backing, moduleName) @@ -232,6 +225,25 @@ final class Eval( // location of the cached type or definition information private[this] def cacheFile(base: File, moduleName: String): File = new File(base, moduleName + ".cache") + + private def compileAndLoad[T]( + imports: EvalImports, + backing: Option[File], + moduleName: String, + ev: EvalType[T] + ): (T, ClassLoader => ClassLoader) = { + reporter.reset() + val unit = ev.makeUnit + val run = new Run { + override def units = (unit :: Nil).iterator + } + try { + compileAndLoad(run, unit, imports, backing, moduleName, ev) + } finally { + // unlink all + for ((sym, _) <- run.symSource) if (ev.unlink) unlink(sym) else toUnlinkLater ::= sym + } + } private[this] def compileAndLoad[T]( run: Run, unit: CompilationUnit, @@ -457,6 +469,8 @@ final class Eval( /** Serializes the extra information to a cache file, where it can be `read` back if inputs haven't changed.*/ def write(value: T, file: File): Unit + def sourceName: String + /** * Constructs the full compilation unit for this evaluation. * This is used for error reporting during compilation. diff --git a/main-actions/src/main/scala/sbt/compiler/EvalReporter.scala b/main-actions/src/main/scala/sbt/compiler/EvalReporter.scala new file mode 100644 index 000000000..485e8c7c2 --- /dev/null +++ b/main-actions/src/main/scala/sbt/compiler/EvalReporter.scala @@ -0,0 +1,54 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.compiler + +import scala.reflect.internal.settings.MutableSettings +import scala.reflect.internal.util.Position +import scala.tools.nsc.Settings +import scala.tools.nsc.reporters.{ ConsoleReporter, FilteringReporter } + +abstract class EvalReporter extends FilteringReporter { + def finalReport(sourceName: String): Unit +} + +object EvalReporter { + def console(s: Settings): EvalReporter = new ForwardingReporter(new ConsoleReporter(s)) +} + +class ForwardingReporter(delegate: FilteringReporter) extends EvalReporter { + def settings: Settings = delegate.settings + + def doReport(pos: Position, msg: String, severity: Severity): Unit = + delegate.doReport(pos, msg, severity) + + override def filter(pos: Position, msg: String, severity: Severity): Int = + delegate.filter(pos, msg, severity) + + override def increment(severity: Severity): Unit = delegate.increment(severity) + + override def errorCount: Int = delegate.errorCount + override def warningCount: Int = delegate.warningCount + + override def hasErrors: Boolean = delegate.hasErrors + override def hasWarnings: Boolean = delegate.hasWarnings + + override def comment(pos: Position, msg: String): Unit = delegate.comment(pos, msg) + + override def cancelled: Boolean = delegate.cancelled + override def cancelled_=(b: Boolean): Unit = delegate.cancelled_=(b) + + override def flush(): Unit = delegate.flush() + override def finish(): Unit = delegate.finish() + override def reset(): Unit = + delegate.reset() // super.reset not necessary, own state is never modified + + override def rerunWithDetails(setting: MutableSettings#Setting, name: String): String = + delegate.rerunWithDetails(setting, name) + + override def finalReport(sourceName: String): Unit = () +} diff --git a/main-actions/src/test/scala/sbt/compiler/EvalTest.scala b/main-actions/src/test/scala/sbt/compiler/EvalTest.scala index 21ffe61b9..4bea445b9 100644 --- a/main-actions/src/test/scala/sbt/compiler/EvalTest.scala +++ b/main-actions/src/test/scala/sbt/compiler/EvalTest.scala @@ -19,7 +19,7 @@ import sbt.io.IO class EvalTest extends Properties("eval") { private[this] lazy val reporter = new StoreReporter(new Settings()) import reporter.ERROR - private[this] lazy val eval = new Eval(_ => reporter, None) + private[this] lazy val eval = new Eval(_ => new ForwardingReporter(reporter), None) property("inferred integer") = forAll { (i: Int) => val result = eval.eval(i.toString) @@ -46,7 +46,7 @@ class EvalTest extends Properties("eval") { property("backed local class") = forAll { (i: Int) => IO.withTemporaryDirectory { dir => - val eval = new Eval(_ => reporter, backing = Some(dir)) + val eval = new Eval(_ => new ForwardingReporter(reporter), backing = Some(dir)) val result = eval.eval(local(i)) val v = value(result).asInstanceOf[{ def i: Int }].i (label("Value", v) |: (v == i)) && diff --git a/main/src/main/scala/sbt/EvaluateTask.scala b/main/src/main/scala/sbt/EvaluateTask.scala index 0235e31d8..55da21c94 100644 --- a/main/src/main/scala/sbt/EvaluateTask.scala +++ b/main/src/main/scala/sbt/EvaluateTask.scala @@ -23,6 +23,7 @@ import sbt.librarymanagement.{ Resolver, UpdateReport } import sbt.std.Transform.DummyTaskMap import sbt.util.{ Logger, Show } import sbt.BuildSyntax._ +import sbt.internal.bsp.BuildTargetIdentifier import scala.annotation.nowarn import scala.Console.RED @@ -147,14 +148,15 @@ final case class PluginData( unmanagedSourceDirectories: Seq[File], unmanagedSources: Seq[File], managedSourceDirectories: Seq[File], - managedSources: Seq[File] + managedSources: Seq[File], + buildTarget: Option[BuildTargetIdentifier] ) { val classpath: Seq[Attributed[File]] = definitionClasspath ++ dependencyClasspath } object PluginData { private[sbt] def apply(dependencyClasspath: Def.Classpath): PluginData = - PluginData(dependencyClasspath, Nil, None, None, Nil, Nil, Nil, Nil, Nil) + PluginData(dependencyClasspath, Nil, None, None, Nil, Nil, Nil, Nil, Nil, None) } object EvaluateTask { diff --git a/main/src/main/scala/sbt/internal/Load.scala b/main/src/main/scala/sbt/internal/Load.scala index 684586f5a..08965b0ce 100755 --- a/main/src/main/scala/sbt/internal/Load.scala +++ b/main/src/main/scala/sbt/internal/Load.scala @@ -8,19 +8,17 @@ package sbt package internal -import java.io.File -import java.net.URI - import sbt.BuildPaths._ import sbt.Def.{ ScopeLocal, ScopedKey, Setting, isDummy } import sbt.Keys._ import sbt.Project.inScope import sbt.Scope.GlobalScope import sbt.SlashSyntax0._ -import sbt.compiler.Eval +import sbt.compiler.{ Eval, EvalReporter } import sbt.internal.BuildStreams._ import sbt.internal.inc.classpath.ClasspathUtil import sbt.internal.inc.{ ScalaInstance, ZincLmUtil, ZincUtil } +import sbt.internal.server.BuildServerEvalReporter import sbt.internal.util.Attributed.data import sbt.internal.util.Types.const import sbt.internal.util.{ Attributed, Settings, ~> } @@ -31,6 +29,8 @@ import sbt.nio.Settings import sbt.util.{ Logger, Show } import xsbti.compile.{ ClasspathOptionsUtil, Compilers } +import java.io.File +import java.net.URI import scala.annotation.{ nowarn, tailrec } import scala.collection.mutable import scala.tools.nsc.reporters.ConsoleReporter @@ -426,14 +426,21 @@ private[sbt] object Load { () => eval } - def mkEval(unit: BuildUnit): Eval = - mkEval(unit.definitions, unit.plugins, unit.plugins.pluginData.scalacOptions) - - def mkEval(defs: LoadedDefinitions, plugs: LoadedPlugins, options: Seq[String]): Eval = - mkEval(defs.target ++ plugs.classpath, defs.base, options) + def mkEval(unit: BuildUnit): Eval = { + val defs = unit.definitions + mkEval(defs.target ++ unit.plugins.classpath, defs.base, unit.plugins.pluginData.scalacOptions) + } def mkEval(classpath: Seq[File], base: File, options: Seq[String]): Eval = - new Eval(options, classpath, s => new ConsoleReporter(s), Some(evalOutputDirectory(base))) + mkEval(classpath, base, options, EvalReporter.console) + + def mkEval( + classpath: Seq[File], + base: File, + options: Seq[String], + mkReporter: scala.tools.nsc.Settings => EvalReporter + ): Eval = + new Eval(options, classpath, mkReporter, Some(evalOutputDirectory(base))) /** * This will clean up left-over files in the config-classes directory if they are no longer used. @@ -703,7 +710,13 @@ private[sbt] object Load { // NOTE - because we create an eval here, we need a clean-eval later for this URI. lazy val eval = timed("Load.loadUnit: mkEval", log) { - mkEval(plugs.classpath, defDir, plugs.pluginData.scalacOptions) + def mkReporter(settings: scala.tools.nsc.Settings): EvalReporter = + plugs.pluginData.buildTarget match { + case None => EvalReporter.console(settings) + case Some(buildTarget) => + new BuildServerEvalReporter(buildTarget, new ConsoleReporter(settings)) + } + mkEval(plugs.classpath, defDir, plugs.pluginData.scalacOptions, mkReporter) } val initialProjects = defsScala.flatMap(b => projectsFromBuild(b, normBase)) ++ buildLevelExtraProjects @@ -1168,6 +1181,7 @@ private[sbt] object Load { val unmanagedSrcs = (Configurations.Compile / unmanagedSources).value val managedSrcDirs = (Configurations.Compile / managedSourceDirectories).value val managedSrcs = (Configurations.Compile / managedSources).value + val buildTarget = (Configurations.Compile / bspTargetIdentifier).value PluginData( removeEntries(cp, prod), prod, @@ -1178,6 +1192,7 @@ private[sbt] object Load { unmanagedSrcs, managedSrcDirs, managedSrcs, + Some(buildTarget) ) }, scalacOptions += "-Wconf:cat=unused-nowarn:s", @@ -1233,7 +1248,7 @@ private[sbt] object Load { loadPluginDefinition( dir, config, - PluginData(config.globalPluginClasspath, Nil, None, None, Nil, Nil, Nil, Nil, Nil) + PluginData(config.globalPluginClasspath, Nil, None, None, Nil, Nil, Nil, Nil, Nil, None) ) def buildPlugins(dir: File, s: State, config: LoadBuildConfiguration): LoadedPlugins = @@ -1429,7 +1444,8 @@ final case class LoadBuildConfiguration( Nil, Nil, Nil, - Nil + Nil, + None ) case None => PluginData(globalPluginClasspath) } diff --git a/main/src/main/scala/sbt/internal/server/BuildServerEvalReporter.scala b/main/src/main/scala/sbt/internal/server/BuildServerEvalReporter.scala new file mode 100644 index 000000000..cc4475e75 --- /dev/null +++ b/main/src/main/scala/sbt/internal/server/BuildServerEvalReporter.scala @@ -0,0 +1,93 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal.server + +import sbt.StandardMain.exchange +import sbt.compiler.ForwardingReporter +import sbt.internal.bsp +import sbt.internal.bsp.{ + BuildTargetIdentifier, + Diagnostic, + DiagnosticSeverity, + PublishDiagnosticsParams, + Range, + TextDocumentIdentifier +} + +import java.nio.file.{ Files, Path, Paths } +import scala.collection.mutable +import scala.reflect.internal.Reporter +import scala.reflect.internal.util.{ DefinedPosition, Position } +import scala.tools.nsc.reporters.FilteringReporter +import sbt.internal.bsp.codec.JsonProtocol._ + +class BuildServerEvalReporter(buildTarget: BuildTargetIdentifier, delegate: FilteringReporter) + extends ForwardingReporter(delegate) { + private val problemsByFile = mutable.Map[Path, Vector[Diagnostic]]() + + override def doReport(pos: Position, msg: String, severity: Severity): Unit = { + for { + filePath <- if (pos.source.file.exists) Some(Paths.get(pos.source.file.path)) else None + range <- convertToRange(pos) + } { + val bspSeverity = convertToBsp(severity) + val diagnostic = Diagnostic(range, bspSeverity, None, Option("sbt"), msg) + problemsByFile(filePath) = problemsByFile.getOrElse(filePath, Vector()) :+ diagnostic + val params = PublishDiagnosticsParams( + TextDocumentIdentifier(filePath.toUri), + buildTarget, + originId = None, + Vector(diagnostic), + reset = false + ) + exchange.notifyEvent("build/publishDiagnostics", params) + } + super.doReport(pos, msg, severity) + } + + override def finalReport(sourceName: String): Unit = { + val filePath = Paths.get(sourceName) + if (Files.exists(filePath)) { + val diagnostics = problemsByFile.getOrElse(filePath, Vector()) + val params = PublishDiagnosticsParams( + textDocument = TextDocumentIdentifier(filePath.toUri), + buildTarget, + originId = None, + diagnostics, + reset = true + ) + exchange.notifyEvent("build/publishDiagnostics", params) + } + } + + private def convertToBsp(severity: Severity): Option[Long] = { + val result = severity match { + case Reporter.INFO => DiagnosticSeverity.Information + case Reporter.WARNING => DiagnosticSeverity.Warning + case Reporter.ERROR => DiagnosticSeverity.Error + } + Some(result) + } + + private def convertToRange(pos: Position): Option[Range] = { + pos match { + case _: DefinedPosition => + val startLine = pos.source.offsetToLine(pos.start) + val startChar = pos.start - pos.source.lineToOffset(startLine) + val endLine = pos.source.offsetToLine(pos.end) + val endChar = pos.end - pos.source.lineToOffset(endLine) + Some( + Range( + bsp.Position(startLine.toLong, startChar.toLong), + bsp.Position(endLine.toLong, endChar.toLong) + ) + ) + case _ => None + } + } +} diff --git a/main/src/test/scala/PluginCommandTest.scala b/main/src/test/scala/PluginCommandTest.scala index b8cf07768..eb8d9d593 100644 --- a/main/src/test/scala/PluginCommandTest.scala +++ b/main/src/test/scala/PluginCommandTest.scala @@ -114,7 +114,7 @@ object FakeState { Nil ) - val pluginData = PluginData(Nil, Nil, None, None, Nil, Nil, Nil, Nil, Nil) + val pluginData = PluginData(Nil, Nil, None, None, Nil, Nil, Nil, Nil, Nil, None) val builds: DetectedModules[BuildDef] = new DetectedModules[BuildDef](Nil) val detectedAutoPlugins: Seq[DetectedAutoPlugin] =