From 819999363514b44c7070fcac7cd11b427889aeca Mon Sep 17 00:00:00 2001 From: Katarzyna Marek Date: Tue, 14 Feb 2023 16:49:16 +0100 Subject: [PATCH] feature: reports A framework for creating error reports in metals. --- .../internal/metals/MetalsEnrichments.scala | 7 + .../internal/metals/MetalsLspService.scala | 14 +- .../internal/metals/ReportsProvider.scala | 123 ++++++++++++++++++ .../scala/meta/internal/parsing/Trees.scala | 17 +-- .../meta/metals/MetalsLanguageServer.scala | 3 + .../src/main/scala/tests/TestingServer.scala | 5 +- .../unit/src/main/scala/tests/TreeUtils.scala | 4 +- .../src/test/scala/tests/ReportsSuite.scala | 60 +++++++++ 8 files changed, 214 insertions(+), 19 deletions(-) create mode 100644 metals/src/main/scala/scala/meta/internal/metals/ReportsProvider.scala create mode 100644 tests/unit/src/test/scala/tests/ReportsSuite.scala diff --git a/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala b/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala index 14885b55ba0..7a2ec2455c9 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala @@ -1086,4 +1086,11 @@ object MetalsEnrichments } } + implicit class XtensionAny[T](v: T) { + def withExec[R](toExec: T => R): T = { + toExec(v) + v + } + } + } diff --git a/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala b/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala index 497367a0e37..ee3970e7dde 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala @@ -189,6 +189,8 @@ class MetalsLspService( val tables: Tables = register(new Tables(workspace, time)) + private val reports = new Reports(workspace) + private val buildTools: BuildTools = new BuildTools( workspace, bspGlobalDirectories, @@ -288,7 +290,7 @@ class MetalsLspService( ) private val timerProvider: TimerProvider = new TimerProvider(time) - private val trees = new Trees(buffers, scalaVersionSelector, workspace) + private val trees = new Trees(buffers, scalaVersionSelector, reports) private val documentSymbolProvider = new DocumentSymbolProvider( trees, @@ -1050,7 +1052,7 @@ class MetalsLspService( tables.fingerprints.save(fingerprints.getAllFingerprints()) cancel() } catch { - case NonFatal(e) => + case NonFatal(e) => // report? scribe.error("cancellation error", e) } finally { promise.success(()) @@ -2209,7 +2211,7 @@ class MetalsLspService( case Failed(exit) => exit match { case Left(exitCode) => - scribe.error( + scribe.error( // report s"Create of .bsp failed with exit code: $exitCode" ) languageClient.showMessage( @@ -2383,7 +2385,7 @@ class MetalsLspService( if (ammoniteChanges.nonEmpty) ammonite.importBuild().onComplete { case Success(()) => - case Failure(exception) => + case Failure(exception) => // report? scribe.error("Error re-importing Ammonite build", exception) } @@ -2448,7 +2450,7 @@ class MetalsLspService( val details = " See logs for more details." languageClient.showMessage( new MessageParams(MessageType.Error, message + details) - ) + ) // report scribe.error(message, e) BuildChange.Failed } @@ -2726,7 +2728,7 @@ class MetalsLspService( private def newSymbolIndex(): OnDemandSymbolIndex = { OnDemandSymbolIndex.empty( - onError = { + onError = { // report? case e @ (_: ParseException | _: TokenizeException) => scribe.error(e.toString) case e: IndexingExceptions.InvalidJarException => diff --git a/metals/src/main/scala/scala/meta/internal/metals/ReportsProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/ReportsProvider.scala new file mode 100644 index 00000000000..dedb653067f --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/ReportsProvider.scala @@ -0,0 +1,123 @@ +package scala.meta.internal.metals + +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.io.AbsolutePath +import scala.meta.io.RelativePath + +class Reports(workspace: AbsolutePath) { + private lazy val reportsDir = + workspace.resolve(Directories.reports).withExec { d => + Files.createDirectories(d.toNIO) + } + val unsanitized = + new ReportsProvider(workspace, Directories.reports.resolve("metals-full")) + val incognito = + new ReportsProvider(workspace, Directories.reports.resolve("metals")) + val bloop = + new ReportsProvider(workspace, Directories.reports.resolve("bloop")) + + def all: List[ReportsProvider] = List(unsanitized, incognito, bloop) + def allToZip: List[ReportsProvider] = List(incognito, bloop) + + def zipReports(): Path = { + val path = reportsDir.resolve(Reports.ZIP_FILE_NAME).toNIO + val zipOut = new ZipOutputStream(Files.newOutputStream(path)) + + for { + reportsProvider <- allToZip + report <- reportsProvider.getReports + } { + val zipEntry = new ZipEntry(report.name) + zipOut.putNextEntry(zipEntry) + zipOut.write(Files.readAllBytes(report.toPath)) + } + zipOut.close() + + path + } + + def cleanUpOldReports( + maxReportsNumber: Int = Reports.MAX_NUMBER_OF_REPORTS + ): Unit = { + all.foreach(_.cleanUpOldReports(maxReportsNumber)) + } + + def deleteAll(): Unit = { + all.foreach(_.deleteAll()) + Files.delete(reportsDir.resolve(Reports.ZIP_FILE_NAME).toNIO) + } +} + +class ReportsProvider(workspace: AbsolutePath, pathToReports: RelativePath) { + private lazy val reportsDir = + workspace.resolve(pathToReports).withExec { d => + Files.createDirectories(d.toNIO) + } + + private lazy val userHome = Option(System.getProperty("user.home")) + + def createReport(name: String, text: String): AbsolutePath = + reportsDir + .resolve(s"r_${name}_${System.currentTimeMillis()}") + .withExec(_.writeText(sanitize(text))) + + private def sanitize(text: String) = { + val textAfterWokspaceReplace = + text.replaceAll(workspace.toString(), Reports.WORKSPACE_STR) + userHome + .map(textAfterWokspaceReplace.replaceAll(_, Reports.HOME_STR)) + .getOrElse(textAfterWokspaceReplace) + } + + def cleanUpOldReports( + maxReportsNumber: Int = Reports.MAX_NUMBER_OF_REPORTS + ): List[Report] = { + val reports = getReports + if (reports.length > maxReportsNumber) { + val filesToDelete = reports + .sortBy(_.timestamp) + .slice(0, reports.length - maxReportsNumber) + filesToDelete.foreach { f => Files.delete(f.toPath) } + filesToDelete + } else List() + } + + def getReports: List[Report] = { + val reportsDir = workspace.resolve(pathToReports) + if (reportsDir.exists && reportsDir.isDirectory) { + reportsDir.toFile.listFiles().toList.map(Report.fromFile(_)).collect { + case Some(l) => l + } + } else List() + } + + def deleteAll(): Unit = getReports.foreach(r => Files.delete(r.toPath)) +} + +object Reports { + val MAX_NUMBER_OF_REPORTS = 30 + val WORKSPACE_STR = "" + val HOME_STR = "" + val ZIP_FILE_NAME = "reports.zip" +} + +case class Report(file: File, timestamp: Long) { + def toPath: Path = file.toPath() + def name: String = file.getName() +} + +object Report { + def fromFile(file: File): Option[Report] = { + val reportRegex = "r_.*_([-+]?[0-9]+)".r + file.getName() match { + case reportRegex(time) => Some(Report(file, time.toLong)) + case _: String => None + } + } +} diff --git a/metals/src/main/scala/scala/meta/internal/parsing/Trees.scala b/metals/src/main/scala/scala/meta/internal/parsing/Trees.scala index 0a0544e3099..f6e2ea47189 100644 --- a/metals/src/main/scala/scala/meta/internal/parsing/Trees.scala +++ b/metals/src/main/scala/scala/meta/internal/parsing/Trees.scala @@ -1,15 +1,13 @@ package scala.meta.internal.parsing -import java.nio.file.Files - import scala.collection.concurrent.TrieMap import scala.reflect.ClassTag import scala.meta._ import scala.meta.inputs.Position import scala.meta.internal.metals.Buffers -import scala.meta.internal.metals.Directories import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.metals.Reports import scala.meta.internal.metals.ScalaVersionSelector import scala.meta.io.AbsolutePath import scala.meta.parsers.Parse @@ -30,7 +28,7 @@ import org.eclipse.{lsp4j => l} final class Trees( buffers: Buffers, scalaVersionSelector: ScalaVersionSelector, - workspace: AbsolutePath, + reports: Reports, ) { private val trees = TrieMap.empty[AbsolutePath, Tree] @@ -141,13 +139,10 @@ final class Trees( } catch { // if the parsers breaks we should not throw the exception further case _: StackOverflowError => - val reportsDir = workspace.resolve(Directories.reports) - val newPathCopy = - reportsDir.resolve( - "stackoverflow_" + System.currentTimeMillis() + "_" + path.filename - ) - Files.createDirectories(reportsDir.toNIO) - newPathCopy.writeText(text) + val newPathCopy = reports.unsanitized.createReport( + s"stackoverflow_${path.filename}", + text, + ) val message = s"Could not parse $path, saved the current snapshot to ${newPathCopy}" scribe.warn(message) diff --git a/metals/src/main/scala/scala/meta/metals/MetalsLanguageServer.scala b/metals/src/main/scala/scala/meta/metals/MetalsLanguageServer.scala index 9f0a7df15f2..96f63e2ae98 100644 --- a/metals/src/main/scala/scala/meta/metals/MetalsLanguageServer.scala +++ b/metals/src/main/scala/scala/meta/metals/MetalsLanguageServer.scala @@ -16,6 +16,7 @@ import scala.meta.internal.metals.MetalsEnrichments._ import scala.meta.internal.metals.MetalsLspService import scala.meta.internal.metals.MetalsServerInputs import scala.meta.internal.metals.MutableCancelable +import scala.meta.internal.metals.Reports import scala.meta.internal.metals.ThreadPools import scala.meta.internal.metals.clients.language.MetalsLanguageClient import scala.meta.internal.metals.clients.language.NoopLanguageClient @@ -166,6 +167,8 @@ class MetalsLanguageServer( serverState.set(ServerState.Initialized(service)) metalsService.underlying = service + new Reports(workspace).cleanUpOldReports() + service.initialize() } } diff --git a/tests/unit/src/main/scala/tests/TestingServer.scala b/tests/unit/src/main/scala/tests/TestingServer.scala index 0f027814bf4..f499c130ed2 100644 --- a/tests/unit/src/main/scala/tests/TestingServer.scala +++ b/tests/unit/src/main/scala/tests/TestingServer.scala @@ -48,6 +48,7 @@ import scala.meta.internal.metals.MtagsResolver import scala.meta.internal.metals.ParametrizedCommand import scala.meta.internal.metals.PositionSyntax._ import scala.meta.internal.metals.ProgressTicks +import scala.meta.internal.metals.Reports import scala.meta.internal.metals.ScalaVersionSelector import scala.meta.internal.metals.ServerCommands import scala.meta.internal.metals.TextEdits @@ -162,13 +163,15 @@ final case class TestingServer( lazy val server = languageServer.getOldMetalsLanguageServer + val reports = new Reports(workspace) + private lazy val trees = new Trees( buffers, new ScalaVersionSelector( () => initialUserConfig, server.buildTargets, ), - workspace, + reports, ) private val virtualDocSources = TrieMap.empty[String, AbsolutePath] diff --git a/tests/unit/src/main/scala/tests/TreeUtils.scala b/tests/unit/src/main/scala/tests/TreeUtils.scala index ea28c4705a0..cef27384890 100644 --- a/tests/unit/src/main/scala/tests/TreeUtils.scala +++ b/tests/unit/src/main/scala/tests/TreeUtils.scala @@ -4,6 +4,7 @@ import java.nio.file.Paths import scala.meta.internal.metals.Buffers import scala.meta.internal.metals.BuildTargets +import scala.meta.internal.metals.Reports import scala.meta.internal.metals.ScalaVersionSelector import scala.meta.internal.metals.UserConfiguration import scala.meta.internal.parsing.Trees @@ -21,7 +22,8 @@ object TreeUtils { () => UserConfiguration(fallbackScalaVersion = scalaVersion), buildTargets, ) - val trees = new Trees(buffers, selector, AbsolutePath(Paths.get("."))) + val trees = + new Trees(buffers, selector, new Reports(AbsolutePath(Paths.get(".")))) (buffers, trees) } } diff --git a/tests/unit/src/test/scala/tests/ReportsSuite.scala b/tests/unit/src/test/scala/tests/ReportsSuite.scala new file mode 100644 index 00000000000..e51f399e915 --- /dev/null +++ b/tests/unit/src/test/scala/tests/ReportsSuite.scala @@ -0,0 +1,60 @@ +package tests + +import java.nio.file.Files +import java.nio.file.Paths + +import scala.meta.internal.metals.Report +import scala.meta.internal.metals.Reports +import scala.meta.io.AbsolutePath + +class ReportsSuite extends BaseSuite { + val workspace: AbsolutePath = AbsolutePath(Paths.get(".")) + val reportsProvider = new Reports(workspace) + + def exampleText(workspaceStr: String = workspace.toString()): String = + s"""|An error happend in the file: + |${workspaceStr}/WrongFile.scala + |""".stripMargin + + override def afterEach(context: AfterEach): Unit = { + reportsProvider.deleteAll() + super.afterEach(context) + } + + test("create-report") { + val path = + reportsProvider.incognito.createReport("test_error", exampleText()) + val obtained = Files.readString(path.toNIO) + assertEquals(exampleText(Reports.WORKSPACE_STR), obtained) + assert(Report.fromFile(path.toFile).nonEmpty) + } + + test("delete-old-reports") { + reportsProvider.incognito.createReport("some_test_error_old", exampleText()) + reportsProvider.incognito.createReport( + "some_different_test_error_old", + exampleText(), + ) + reportsProvider.incognito.createReport("some_test_error_new", exampleText()) + reportsProvider.incognito.createReport( + "some_different_test_error_new", + exampleText(), + ) + val deleted = reportsProvider.incognito.cleanUpOldReports(2) + assertEquals(deleted.length, 2) + deleted.foreach(f => assert(f.name.contains("old"))) + val reports = reportsProvider.incognito.getReports + assertEquals(reports.length, 2) + reports.foreach(f => assert(f.name.contains("new"))) + } + + test("zip-reports") { + reportsProvider.incognito.createReport("test_error", exampleText()) + reportsProvider.incognito.createReport( + "different_test_error", + exampleText(), + ) + val pathToZip = reportsProvider.zipReports() + assertEquals(pathToZip.toFile.getName(), Reports.ZIP_FILE_NAME) + } +}