Use sourcePositionMappers in BuildServerReporter

This commit is contained in:
unknown 2023-04-27 17:35:44 +02:00 committed by Adrien Piquerez
parent f641208341
commit 3d1319efea
6 changed files with 214 additions and 91 deletions

View File

@ -33,7 +33,6 @@ import sjsonnew.shaded.scalajson.ast.unsafe.{ JNull, JValue }
import sjsonnew.support.scalajson.unsafe.{ CompactPrinter, Converter, Parser => JsonParser }
import xsbti.CompileFailed
import java.nio.file.Path
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import scala.collection.mutable
@ -44,6 +43,8 @@ import scala.util.{ Failure, Success, Try }
import scala.annotation.nowarn
import sbt.testing.Framework
import scala.collection.immutable.ListSet
import xsbti.VirtualFileRef
import java.util.concurrent.atomic.AtomicReference
object BuildServerProtocol {
import sbt.internal.bsp.codec.JsonProtocol._
@ -329,11 +330,13 @@ object BuildServerProtocol {
val underlying = (Keys.compile / compilerReporter).value
val logger = streams.value.log
val meta = isMetaBuild.value
val spms = sourcePositionMappers.value
if (bspEnabled.value) {
new BuildServerReporterImpl(
targetId,
bspCompileStateInstance,
converter,
Defaults.foldMappers(spms, reportAbsolutePath.value, fileConverter.value),
meta,
logger,
underlying
@ -1064,11 +1067,17 @@ object BuildServerProtocol {
private[server] final class BspCompileState {
/**
* keeps track of problems in given file so BSP reporter
* can omit unnecessary diagnostics updates
* @param problemsBySourceFiles keeps track of problems in a given file by mapping its VirtualFileRef to all the TextDocumentIdentifier affected by this problem.
* e.g., Suppose we have a source file at "target/../twirl/file.template.scala" which is generated by a Twirl file "src/main/twirl/file.scala.txt",the VirtualRef will be the ref of the Scala file,
* where the TextDocumentIdentifier will be pointing to the Twirl file .In case of a non-generated Scala source file , the textDocumentIdentifier will point to the same document .
* @param compiledAtLeastOnce keeps track of those projects that were compiled at
* least once so that we can decide to enable fresh reporting for projects that
* are compiled for the first time.
* see: https://github.com/scalacenter/bloop/issues/726
*/
val hasAnyProblems: java.util.Set[Path] =
java.util.concurrent.ConcurrentHashMap.newKeySet[Path]
val problemsBySourceFiles
: AtomicReference[Map[VirtualFileRef, Vector[TextDocumentIdentifier]]] =
new AtomicReference(Map.empty)
/**
* keeps track of those projects that were compiled at

View File

@ -28,6 +28,9 @@ import xsbti.{
import scala.collection.JavaConverters._
import scala.collection.mutable
/**
Provides methods for sending success and failure reports and publishing diagnostics.
*/
sealed trait BuildServerReporter extends Reporter {
private final val sigFilesWritten = "[sig files written]"
private final val pureExpression = "a pure expression does nothing in statement position"
@ -71,10 +74,23 @@ sealed trait BuildServerReporter extends Reporter {
override def comment(pos: XPosition, msg: String): Unit = underlying.comment(pos, msg)
}
/**
An implementation of the BuildServerReporter for communicating with the Build Server.
Sends diagnostic messages to the client, handling success and failure cases.
@param buildTarget the identifier of the build target
@param bspCompileState state representing what has already been reported in previous compilation.
@param converter a file converter for converting between VirtualFileRef and Path
@param sourcePositionMapper a function to map an xsbti.Position from the generated file (the Scala file) to the source file of the generator (e.g. Twirl file)
@param isMetaBuild a flag indicating if this is a meta build
@param logger a ManagedLogger for logging messages
@param underlying the underlying reporter instance which reports to the sbt shell or native clients
*/
final class BuildServerReporterImpl(
buildTarget: BuildTargetIdentifier,
bspCompileState: BspCompileState,
converter: FileConverter,
sourcePositionMapper: xsbti.Position => xsbti.Position,
protected override val isMetaBuild: Boolean,
protected override val logger: ManagedLogger,
protected override val underlying: Reporter
@ -83,7 +99,9 @@ final class BuildServerReporterImpl(
import sbt.internal.inc.JavaInterfaceUtil._
private lazy val exchange = StandardMain.exchange
private val problemsByFile = mutable.Map[Path, Vector[Diagnostic]]()
//keeps track of problems in a given file by mapping its VirtualFileRef to a Vector of problems.
//N.B : In case of a source generator file (Twirl), the given file is the generated one (Scala).
private val problemsByFile = mutable.Map[VirtualFileRef, Vector[Problem]]()
// sometimes the compiler returns a fake position such as <macro>
// on Windows, this causes InvalidPathException (see #5994 and #6720)
@ -103,62 +121,98 @@ final class BuildServerReporterImpl(
val shouldReportAllProblems = !bspCompileState.compiledAtLeastOnce.getAndSet(true)
for {
(source, infos) <- analysis.readSourceInfos.getAllSourceInfos.asScala
filePath <- toSafePath(source)
sourcePath <- toSafePath(source)
} {
// clear problems for current file
val hadProblems = bspCompileState.hasAnyProblems.remove(filePath)
val oldDocuments =
bspCompileState.problemsBySourceFiles.getAndUpdate(_ - source).getOrElse(source, Seq.empty)
val reportedProblems = infos.getReportedProblems.toVector
val diagnostics = reportedProblems.map(toDiagnostic)
val problems = infos.getReportedProblems.toVector
// publish diagnostics if:
// 1. file had any problems previously - we might want to update them with new ones
// 2. file has fresh problems - we might want to update old ones
// 3. build project is compiled first time - shouldReportAllProblems is set
val shouldPublish = hadProblems || diagnostics.nonEmpty || shouldReportAllProblems
// file can have some warnings
if (diagnostics.nonEmpty) {
bspCompileState.hasAnyProblems.add(filePath)
}
val shouldPublish = oldDocuments.nonEmpty || problems.nonEmpty || shouldReportAllProblems
if (shouldPublish) {
val params = PublishDiagnosticsParams(
textDocument = TextDocumentIdentifier(filePath.toUri),
buildTarget,
originId = None,
diagnostics.toVector,
reset = true
)
exchange.notifyEvent("build/publishDiagnostics", params)
// Group diagnostics by document
val diagsByDocuments = problems
.flatMap(mapProblemToDiagnostic)
.groupBy { case (document, _) => document }
.mapValues(_.map { case (_, diag) => diag })
//Get a set of these diagnostics to remove duplicates
val newDocuments = diagsByDocuments.keySet
bspCompileState.problemsBySourceFiles.updateAndGet(_ + (source -> newDocuments.toVector))
val sourceDocument = TextDocumentIdentifier(sourcePath.toUri)
val allDocuments = (newDocuments ++ oldDocuments + sourceDocument)
// Iterate through both new and old documents, sending diagnostics for each
allDocuments.foreach { document =>
val diags: Vector[Diagnostic] = diagsByDocuments
.getOrElse(document, Vector.empty)
val params = PublishDiagnosticsParams(
document,
buildTarget,
originId = None,
diags,
reset = true
)
// Notify the client with the diagnostics
exchange.notifyEvent("build/publishDiagnostics", params)
}
}
}
}
/**
*This method sends a failure report to the client when the compilation fails. It takes an array of virtual files
*as the input parameter and processes the reported problems for each source.
@param sources an array of virtual files representing the source files
*/
override def sendFailureReport(sources: Array[VirtualFile]): Unit = {
val shouldReportAllProblems = !bspCompileState.compiledAtLeastOnce.get
// Iterate through all source files
for {
source <- sources
filePath <- toSafePath(source)
} {
val diagnostics = problemsByFile.getOrElse(filePath, Vector.empty)
// Get the problems associated with the current source file
val problems = problemsByFile.getOrElse(source, Vector.empty)
val hadProblems = bspCompileState.hasAnyProblems.remove(filePath)
val shouldPublish = hadProblems || diagnostics.nonEmpty || shouldReportAllProblems
// mark file as file with problems
if (diagnostics.nonEmpty) {
bspCompileState.hasAnyProblems.add(filePath)
}
val oldDocuments =
bspCompileState.problemsBySourceFiles.getAndUpdate(_ - source).getOrElse(source, Seq.empty)
// Determine if diagnostics should be published
// 1. The file had problems previously - we might want to update them with new ones
// 2. The file has fresh problems - we might want to update old ones
// 3. The build project is compiled for the first time - shouldReportAllProblems is set
val shouldPublish = oldDocuments.nonEmpty || problems.nonEmpty || shouldReportAllProblems
if (shouldPublish) {
val params = PublishDiagnosticsParams(
textDocument = TextDocumentIdentifier(filePath.toUri),
buildTarget,
originId = None,
diagnostics,
reset = true
)
exchange.notifyEvent("build/publishDiagnostics", params)
// Group diagnostics by document
val diagsByDocuments = problems
.flatMap(mapProblemToDiagnostic)
.groupBy { case (document, _) => document }
.mapValues(_.map { case (_, diag) => diag })
val newDocuments = diagsByDocuments.keySet
bspCompileState.problemsBySourceFiles.updateAndGet(_ + (source -> newDocuments.toVector))
// Iterate through both new and old documents, sending diagnostics for each
(newDocuments ++ oldDocuments).foreach { document =>
val diags: Vector[Diagnostic] = diagsByDocuments
.getOrElse(document, Vector.empty)
val params = PublishDiagnosticsParams(
document,
buildTarget,
originId = None,
diags,
reset = true
)
exchange.notifyEvent("build/publishDiagnostics", params)
}
}
}
}
@ -166,12 +220,14 @@ final class BuildServerReporterImpl(
protected override def publishDiagnostic(problem: Problem): Unit = {
for {
id <- problem.position.sourcePath.toOption
filePath <- toSafePath(VirtualFileRef.of(id))
// mapProblemToDiagnostic maps the position in the Scala source file back to the source of the generator that generated this Scala file.
(document, diagnostic) <- mapProblemToDiagnostic(problem)
} {
val diagnostic = toDiagnostic(problem)
problemsByFile(filePath) = problemsByFile.getOrElse(filePath, Vector.empty) :+ diagnostic
val fileRef = VirtualFileRef.of(id)
problemsByFile(fileRef) = problemsByFile.getOrElse(fileRef, Vector.empty) :+ problem
val params = PublishDiagnosticsParams(
TextDocumentIdentifier(filePath.toUri),
document,
buildTarget,
originId = None,
Vector(diagnostic),
@ -181,13 +237,39 @@ final class BuildServerReporterImpl(
}
}
private def toRange(pos: XPosition): Range = {
val startLineOpt = pos.startLine.toOption.map(_.toLong - 1)
val startColumnOpt = pos.startColumn.toOption.map(_.toLong)
val endLineOpt = pos.endLine.toOption.map(_.toLong - 1)
val endColumnOpt = pos.endColumn.toOption.map(_.toLong)
val lineOpt = pos.line.toOption.map(_.toLong - 1)
val columnOpt = pos.pointer.toOption.map(_.toLong)
/**
* This function maps a given problem in a Scala source file to a diagnostic in the source of the generator that generated this Scala
* or the the file itself in case it was not generated.
* @param problem the problem to be converted into a diagnostic
*/
private def mapProblemToDiagnostic(
problem: Problem
): Option[(TextDocumentIdentifier, Diagnostic)] = {
// Map the position of the problem from the generated file to the origin , this way we send the original position of the problem instead of the generated one
val mappedPosition = sourcePositionMapper(problem.position)
for {
id <- mappedPosition.sourcePath.toOption
path <- toSafePath(VirtualFileRef.of(id))
} yield {
val diagnostic = Diagnostic(
toRange(mappedPosition),
Option(toDiagnosticSeverity(problem.severity)),
problem.diagnosticCode().toOption.map(_.code),
Option("sbt"),
problem.message
)
(TextDocumentIdentifier(path.toUri), diagnostic)
}
}
private def toRange(position: xsbti.Position): Range = {
val startLineOpt = position.startLine.toOption.map(_.toLong - 1)
val startColumnOpt = position.startColumn.toOption.map(_.toLong)
val endLineOpt = position.endLine.toOption.map(_.toLong - 1)
val endColumnOpt = position.endColumn.toOption.map(_.toLong)
val lineOpt = position.line.toOption.map(_.toLong - 1)
val columnOpt = position.pointer.toOption.map(_.toLong)
def toPosition(lineOpt: Option[Long], columnOpt: Option[Long]): Option[Position] =
lineOpt.map(line => Position(line, columnOpt.getOrElse(0L)))
@ -199,45 +281,6 @@ final class BuildServerReporterImpl(
Range(startPos, endPosOpt.getOrElse(startPos))
}
private def toDiagnostic(problem: Problem): Diagnostic = {
val actions0 = problem.actions().asScala.toVector
val data =
if (actions0.isEmpty) None
else
Some(
ScalaDiagnostic(
actions = actions0.map { a =>
ScalaAction(
title = a.title,
description = a.description.toOption,
edit = Some(
ScalaWorkspaceEdit(
changes = a.edit.changes().asScala.toVector.map { edit =>
ScalaTextEdit(
range = toRange(edit.position),
newText = edit.newText,
)
}
)
),
)
}
)
)
Diagnostic(
range = toRange(problem.position),
severity = Option(toDiagnosticSeverity(problem.severity)),
code = problem.diagnosticCode().toOption.map(_.code),
source = Option("sbt"),
message = problem.message,
relatedInformation = Vector.empty,
dataKind = data.map { _ =>
"scala"
},
data = data,
)
}
private def toDiagnosticSeverity(severity: Severity): Long = severity match {
case Severity.Info => DiagnosticSeverity.Information
case Severity.Warn => DiagnosticSeverity.Warning

View File

@ -42,6 +42,10 @@ lazy val javaProj = project
javacOptions += "-Xlint:all"
)
lazy val twirlProj = project
.in(file("twirlProj"))
.enablePlugins(SbtTwirl)
def somethingBad = throw new MessageOnlyException("I am a bad build target")
// other build targets should not be affected by this bad build target
lazy val badBuildTarget = project.in(file("bad-build-target"))

View File

@ -0,0 +1 @@
addSbtPlugin("com.typesafe.play" % "sbt-twirl" % "1.5.2")

View File

@ -0,0 +1,16 @@
@(subDomain: String, mainDomain: String, redirectStatusCode: Int)
@fullDomain=@{
if(mainDomaiiiiin) {
mainDomain
} else {
s"$subDomain.$mainDomain"
}
}
<VirtualHost *:80>
ServerName @fullDomain
RewriteEngine On
RewriteRule (.*) "https://%{HTTP_HOST}%{REQUEST_URI}" [R=@redirectStatusCode,L]
</VirtualHost>

View File

@ -596,6 +596,56 @@ object BuildServerTest extends AbstractServerTest {
assert(actualResult == expectedResult)
}
test("buildTarget/compile twirl diagnostics (sourcePositionMapping)") { _ =>
val buildTarget = buildTargetUri("twirlProj", "Compile")
val testFile =
new File(svr.baseDirectory, s"twirlProj/src/main/twirl/vHostHttpToHttps.scala.txt")
compile(buildTarget)
assert(
svr.waitForString(10.seconds) { s =>
s.contains("build/publishDiagnostics") &&
s.contains("vHostHttpToHttps.scala.txt") &&
s.contains(""""severity":1""") &&
s.contains("""not found: value mainDomaiiiiin""")
},
"should send publishDiagnostics with serverity 1 for vHostHttpToHttps.scala.txt "
)
IO.write(
testFile,
"""|@(subDomain: String, mainDomain: String, redirectStatusCode: Int)
|@fullDomain=@{
| if(mainDomain) {
| mainDomain
| } else {
| s"$subDomain.$mainDomain"
| }
|}
|
|
|<VirtualHost *:80>
| ServerName @fullDomain
| RewriteEngine On
| RewriteRule (.*) "https://%{HTTP_HOST}%{REQUEST_URI}" [R=@redirectStatusCode,L]
|
|</VirtualHost>
|""".stripMargin
)
compile(buildTarget)
assert(
svr.waitForString(30.seconds) { s =>
s.contains("build/publishDiagnostics") &&
s.contains("vHostHttpToHttps.scala.txt") &&
s.contains(""""diagnostics":[]""") &&
s.contains(""""reset":true""")
},
"should send publishDiagnostics with empty diagnostics"
)
}
private def initializeRequest(): Int = {
val params = InitializeBuildParams(
"test client",