Skip to content

Commit

Permalink
Bsp errors in status (#5726)
Browse files Browse the repository at this point in the history
  • Loading branch information
kasiaMarek authored Nov 21, 2023
1 parent 819462a commit 57eb284
Show file tree
Hide file tree
Showing 14 changed files with 386 additions and 326 deletions.
180 changes: 164 additions & 16 deletions metals/src/main/scala/scala/meta/internal/bsp/ConnectionBspStatus.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package scala.meta.internal.bsp

import java.util.concurrent.atomic.AtomicBoolean
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicReference

import scala.meta.internal.metals.BspStatus
import scala.meta.internal.metals.Icons
import scala.meta.internal.metals.MetalsEnrichments._
import scala.meta.internal.metals.ReportContext
import scala.meta.internal.metals.ServerCommands
import scala.meta.internal.metals.clients.language.MetalsStatusParams
import scala.meta.internal.metals.clients.language.StatusType
Expand All @@ -13,25 +16,94 @@ class ConnectionBspStatus(
bspStatus: BspStatus,
folderPath: AbsolutePath,
icons: Icons,
) {
private val isServerResponsive = new AtomicBoolean(false)
val status: MetalsStatusParams => Unit = bspStatus.status(folderPath, _)

def connected(serverName: String): Unit =
if (isServerResponsive.compareAndSet(false, true))
status(ConnectionBspStatus.connectedParams(serverName, icons))
def noResponse(serverName: String): Unit =
if (isServerResponsive.compareAndSet(true, false)) {
scribe.debug("server liveness monitor detected no response")
status(ConnectionBspStatus.noResponseParams(serverName, icons))
}
)(implicit rc: ReportContext) {
private val status = new AtomicReference[BspStatusState](
BspStatusState(Disconnected, None, None, shouldShow = false)
)

/** Paths to all bsp error reports from this session (bsp connection). */
private val currentSessionErrors = new AtomicReference[Set[String]](Set())

def connected(serverName: String): Unit = changeState(Connected(serverName))

def noResponse(): Unit = {
scribe.debug("server liveness monitor detected no response")
changeState(NoResponse)
}

def disconnected(): Unit = {
isServerResponsive.set(false)
status(ConnectionBspStatus.disconnectedParams)
currentSessionErrors.set(Set.empty)
changeState(Disconnected)
}

def showError(message: String, pathToReport: Path): Unit = {
val updatedSet =
currentSessionErrors.updateAndGet(_ + pathToReport.toUri().toString())
changeState(ErrorMessage(message), updatedSet)
}

def onReportsUpdate(): Unit = {
status.get().currentState match {
case ErrorMessage(_) => showState(status.get())
case _ =>
}
}

def isBuildServerResponsive: Option[Boolean] =
status.get().currentState match {
case NoResponse => Some(false)
case Disconnected => None
case _ => Some(true)
}

private def changeState(
newState: BspServerState,
errorReports: Set[String] = currentSessionErrors.get(),
) = {
val newServerState = status.updateAndGet(_.changeState(newState))
if (newServerState.shouldShow) {
showState(newServerState, errorReports)
}
}

def isBuildServerResponsive: Boolean = isServerResponsive.get()
private def showState(
statusState: BspStatusState,
errorReports: Set[String] = currentSessionErrors.get(),
) = {
val showParams =
statusState.currentState match {
case Disconnected => ConnectionBspStatus.disconnectedParams
case NoResponse =>
ConnectionBspStatus.noResponseParams(statusState.serverName, icons)
case Connected(serverName) =>
ConnectionBspStatus.connectedParams(serverName, icons)
case ErrorMessage(message) =>
val currentSessionReports = syncWithReportContext(errorReports)
if (currentSessionReports.isEmpty)
ConnectionBspStatus.connectedParams(statusState.serverName, icons)
else
ConnectionBspStatus.bspErrorParams(
statusState.serverName,
icons,
message,
currentSessionReports.size,
)
}
bspStatus.status(folderPath, showParams)
}

/**
* To get the actual number of error reports we take an intersection
* of this session's error reports with the reports in `.metals/.reports/bloop`,
* this allows for two things:
* 1. When user deletes the report from file system the warning will disappear.
* 2. Error deduplication. When for a perticular error a report already exists, we remove the old report.
* For reports management details look [[scala.meta.internal.metals.StdReporter]]
*/
private def syncWithReportContext(errorReports: Set[String]) =
errorReports.intersect(
rc.bloop.getReports().map(_.toPath.toUri().toString()).toSet
)
}

object ConnectionBspStatus {
Expand All @@ -55,4 +127,80 @@ object ConnectionBspStatus {
command = ServerCommands.ConnectBuildServer.id,
commandTooltip = "Reconnect.",
).withStatusType(StatusType.bsp)

def bspErrorParams(
serverName: String,
icons: Icons,
message: String,
errorsNumber: Int,
): MetalsStatusParams =
MetalsStatusParams(
s"$serverName $errorsNumber ${icons.alert}",
"warn",
show = true,
tooltip = message.trimTo(TOOLTIP_MAX_LENGTH),
command = ServerCommands.RunDoctor.id,
commandTooltip = "Open doctor.",
).withStatusType(StatusType.bsp)

private val TOOLTIP_MAX_LENGTH = 150
}

trait BspServerState
case object Disconnected extends BspServerState
case class Connected(serverName: String) extends BspServerState
case class ErrorMessage(message: String) extends BspServerState
case object NoResponse extends BspServerState

case class BspStatusState(
currentState: BspServerState,
lastError: Option[ErrorMessage],
optServerName: Option[String],
shouldShow: Boolean,
) {
val serverName: String = optServerName.getOrElse("bsp")
def changeState(
newState: BspServerState
): BspStatusState = {
newState match {
case Disconnected if currentState != Disconnected => moveTo(Disconnected)
case NoResponse if currentState != NoResponse => moveTo(NoResponse)
case ErrorMessage(msg) =>
currentState match {
case NoResponse =>
BspStatusState(
NoResponse,
Some(ErrorMessage(msg)),
optServerName,
shouldShow = false,
)
case _ => moveTo(ErrorMessage(msg))
}
case Connected(serverName) =>
currentState match {
case Disconnected => moveTo(Connected(serverName))
case NoResponse =>
lastError match {
case Some(error) => moveTo(error)
case _ => moveTo(Connected(serverName))
}
case _ => this.copy(shouldShow = false)
}
case _ => this.copy(shouldShow = false)
}
}

def moveTo(
newState: BspServerState
): BspStatusState = {
val newServerName =
newState match {
case Connected(serverName) => Some(serverName)
case _ => optServerName
}
val lastError = Some(currentState).collect { case error: ErrorMessage =>
error
}
BspStatusState(newState, lastError, newServerName, shouldShow = true)
}
}
154 changes: 29 additions & 125 deletions metals/src/main/scala/scala/meta/internal/builds/BSPErrorHandler.scala
Original file line number Diff line number Diff line change
@@ -1,49 +1,26 @@
package scala.meta.internal.builds

import java.nio.file.Files
import java.util.concurrent.atomic.AtomicReference

import scala.concurrent.ExecutionContext
import scala.concurrent.Future
import scala.util.control.NonFatal
import java.security.MessageDigest

import scala.meta.internal.bsp.BspSession
import scala.meta.internal.metals.ClientCommands
import scala.meta.internal.metals.ConcurrentHashSet
import scala.meta.internal.metals.Directories
import scala.meta.internal.bsp.ConnectionBspStatus
import scala.meta.internal.metals.MetalsEnrichments._
import scala.meta.internal.metals.Report
import scala.meta.internal.metals.ReportContext
import scala.meta.internal.metals.Tables
import scala.meta.internal.metals.clients.language.MetalsLanguageClient
import scala.meta.io.AbsolutePath

import org.eclipse.{lsp4j => l}

class BspErrorHandler(
languageClient: MetalsLanguageClient,
workspaceFolder: AbsolutePath,
currentSession: () => Option[BspSession],
tables: Tables,
)(implicit context: ExecutionContext) {

protected def logsPath: AbsolutePath =
workspaceFolder.resolve(Directories.log)
private val lastError = new AtomicReference[String]("")
private val dismissedErrors = ConcurrentHashSet.empty[String]

def onError(message: String): Future[Unit] = {
if (
!tables.dismissedNotifications.BspErrors.isDismissed &&
shouldShowBspError &&
!dismissedErrors.contains(message)
) {
val previousError = lastError.getAndSet(message)
if (message != previousError) {
showError(message)
} else Future.successful(())
} else {
logError(message)
Future.successful(())
}
bspStatus: ConnectionBspStatus,
)(implicit reportContext: ReportContext) {
def onError(message: String): Unit = {
if (shouldShowBspError) {
for {
report <- createReport(message)
if !tables.dismissedNotifications.BspErrors.isDismissed
} bspStatus.showError(message, report)
} else logError(message)
}

def shouldShowBspError: Boolean = currentSession().exists(session =>
Expand All @@ -52,96 +29,23 @@ class BspErrorHandler(

protected def logError(message: String): Unit = scribe.error(message)

private def showError(message: String): Future[Unit] = {
val bspError = s"${BspErrorHandler.errorInBsp} $message"
logError(bspError)
val params = BspErrorHandler.makeShowMessage(message)
languageClient.showMessageRequest(params).asScala.flatMap {
case BspErrorHandler.goToLogs =>
val errorMsgStartLine =
bspError.linesIterator.headOption
.flatMap(findLine(_))
.getOrElse(0)
Future.successful(gotoLogs(errorMsgStartLine))
case BspErrorHandler.dismiss =>
Future.successful(dismissedErrors.add(message)).ignoreValue
case BspErrorHandler.doNotShowErrors =>
Future.successful {
tables.dismissedNotifications.BspErrors.dismissForever
}.ignoreValue
case _ => Future.successful(())
}
}

private def findLine(line: String): Option[Int] =
try {
val lineNumber =
Files
.readAllLines(logsPath.toNIO)
.asScala
.lastIndexWhere(_.contains(line))
if (lineNumber >= 0) Some(lineNumber) else None
} catch {
case NonFatal(_) => None
}

private def gotoLogs(line: Int) = {
val pos = new l.Position(line, 0)
val location = new l.Location(
logsPath.toURI.toString(),
new l.Range(pos, pos),
)
languageClient.metalsExecuteClientCommand(
ClientCommands.GotoLocation.toExecuteCommandParams(
ClientCommands.WindowLocation(
location.getUri(),
location.getRange(),
)
private def createReport(message: String) = {
val id = MessageDigest
.getInstance("MD5")
.digest(message.getBytes)
.map(_.toChar)
.mkString
val sanitized = reportContext.bloop.sanitize(message)
reportContext.bloop.create(
Report(
sanitized.trimTo(20),
s"""|### Bloop error:
|
|$message""".stripMargin,
shortSummary = sanitized.trimTo(100),
path = None,
id = Some(id),
)
)
}
}

object BspErrorHandler {
def makeShowMessage(
message: String
): l.ShowMessageRequestParams = {
val (msg, actions) =
if (message.length() <= MESSAGE_MAX_LENGTH) {
(
makeShortMessage(message),
List(dismiss, doNotShowErrors),
)
} else {
(
makeLongMessage(message),
List(goToLogs, dismiss, doNotShowErrors),
)
}
val params = new l.ShowMessageRequestParams()
params.setType(l.MessageType.Error)
params.setMessage(msg)
params.setActions(actions.asJava)
params
}

def makeShortMessage(message: String): String =
s"""|$errorHeader
|$message""".stripMargin

def makeLongMessage(message: String): String =
s"""|${makeShortMessage(s"${message.take(MESSAGE_MAX_LENGTH)}...")}
|$gotoLogsToSeeFull""".stripMargin

val goToLogs = new l.MessageActionItem("Go to logs.")
val dismiss = new l.MessageActionItem("Dismiss.")
val doNotShowErrors = new l.MessageActionItem("Stop showing bsp errors.")

val errorHeader = "Encountered an error in the build server:"
private val gotoLogsToSeeFull = "Go to logs to see the full error"

val errorInBsp = "Build server error:"

private val MESSAGE_MAX_LENGTH = 150

}
Loading

0 comments on commit 57eb284

Please sign in to comment.