diff --git a/metals/src/main/scala/scala/meta/internal/implementation/Supermethods.scala b/metals/src/main/scala/scala/meta/internal/implementation/Supermethods.scala index dece920d1a2..94d1dce1154 100644 --- a/metals/src/main/scala/scala/meta/internal/implementation/Supermethods.scala +++ b/metals/src/main/scala/scala/meta/internal/implementation/Supermethods.scala @@ -7,6 +7,7 @@ import scala.meta.internal.implementation.Supermethods.formatMethodSymbolForQuic import scala.meta.internal.metals.ClientCommands import scala.meta.internal.metals.DefinitionProvider import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.metals.ReportContext import scala.meta.internal.metals.clients.language.MetalsLanguageClient import scala.meta.internal.metals.clients.language.MetalsQuickPickItem import scala.meta.internal.metals.clients.language.MetalsQuickPickParams @@ -23,7 +24,8 @@ class Supermethods( definitionProvider: DefinitionProvider, implementationProvider: ImplementationProvider, )(implicit - ec: ExecutionContext + ec: ExecutionContext, + reports: ReportContext, ) { def getGoToSuperMethodCommand( diff --git a/metals/src/main/scala/scala/meta/internal/metals/Directories.scala b/metals/src/main/scala/scala/meta/internal/metals/Directories.scala index ce7ac8a7ca3..1af941f5424 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/Directories.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/Directories.scala @@ -8,8 +8,6 @@ object Directories { RelativePath(".metals").resolve("readonly") def tmp: RelativePath = RelativePath(".metals").resolve(".tmp") - def reports: RelativePath = - RelativePath(".metals").resolve(".reports") def dependencies: RelativePath = readonly.resolve(dependenciesName) def log: RelativePath = 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 731a8857d1d..7a9761662b2 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsEnrichments.scala @@ -488,9 +488,6 @@ object MetalsEnrichments else Files.move(path.toNIO, newPath.toNIO) } - def createDirectories(): AbsolutePath = - AbsolutePath(Files.createDirectories(path.dealias.toNIO)) - def createAndGetDirectories(): Seq[AbsolutePath] = { def createDirectoriesRec( absolutePath: AbsolutePath, @@ -513,31 +510,6 @@ object MetalsEnrichments path.listRecursive.toList.reverse.foreach(_.delete()) } - def writeText(text: String): Unit = { - path.parent.createDirectories() - val tmp = Files.createTempFile("metals", path.filename) - // Write contents first to a temporary file and then try to - // atomically move the file to the destination. The atomic move - // reduces the risk that another tool will concurrently read the - // file contents during a half-complete file write. - Files.write( - tmp, - text.getBytes(StandardCharsets.UTF_8), - StandardOpenOption.TRUNCATE_EXISTING, - ) - try { - Files.move( - tmp, - path.toNIO, - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE, - ) - } catch { - case NonFatal(_) => - Files.move(tmp, path.toNIO, StandardCopyOption.REPLACE_EXISTING) - } - } - def appendText(text: String): Unit = { path.parent.createDirectories() Files.write( @@ -601,7 +573,21 @@ object MetalsEnrichments case _ => None } - def toAbsolutePathSafe: Option[AbsolutePath] = Try(toAbsolutePath).toOption + def toAbsolutePathSafe(implicit + reports: ReportContext + ): Option[AbsolutePath] = + try { + Some(toAbsolutePath) + } catch { + case NonFatal(e) => + reports.incognito.createReport( + "absolute-path", + s"""|Uri: $value + |""".stripMargin, + e, + ) + None + } def toAbsolutePath: AbsolutePath = toAbsolutePath(followSymlink = true) 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 62a2e62f47c..0f335d48c16 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala @@ -43,6 +43,7 @@ import scala.meta.internal.metals.BuildInfo import scala.meta.internal.metals.Messages.AmmoniteJvmParametersChange import scala.meta.internal.metals.Messages.IncompatibleBloopVersion import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.metals.StdReportContext import scala.meta.internal.metals.ammonite.Ammonite import scala.meta.internal.metals.callHierarchy.CallHierarchyProvider import scala.meta.internal.metals.clients.language.ConfiguredLanguageClient @@ -189,6 +190,11 @@ class MetalsLspService( val tables: Tables = register(new Tables(workspace, time)) + implicit val reports: StdReportContext = new StdReportContext(workspace) + + val zipReportsProvider: ZipReportsProvider = + new ZipReportsProvider(doctor.getTargetsInfoForReports, reports) + private val buildTools: BuildTools = new BuildTools( workspace, bspGlobalDirectories, @@ -288,7 +294,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) private val documentSymbolProvider = new DocumentSymbolProvider( trees, @@ -1168,9 +1174,10 @@ class MetalsLspService( None } - uriOpt match { - case Some(uri) => { - val path = uri.toAbsolutePath + val pathOpt = uriOpt.flatMap(_.toAbsolutePathSafe) + + pathOpt match { + case Some(path) => { focusedDocument = Some(path) buildTargets .inverseSources(path) @@ -1868,6 +1875,23 @@ class MetalsLspService( doctor.onVisibilityDidChange(true) doctor.executeRunDoctor() }.asJavaObject + case ServerCommands.ZipReports() => + Future { + val zip = zipReportsProvider.zip() + val pos = new l.Position(0, 0) + val location = new Location( + zip.toURI.toString(), + new l.Range(pos, pos), + ) + languageClient.metalsExecuteClientCommand( + ClientCommands.GotoLocation.toExecuteCommandParams( + ClientCommands.WindowLocation( + location.getUri(), + location.getRange(), + ) + ) + ) + }.asJavaObject case ServerCommands.ListBuildTargets() => Future { buildTargets.all.toList @@ -2735,6 +2759,11 @@ class MetalsLspService( case e: IndexingExceptions.PathIndexingException => scribe.error(s"issues while parsing: ${e.path}", e.underlying) case e: IndexingExceptions.InvalidSymbolException => + reports.incognito.createReport( + "invalid-symbol", + s"""Symbol: ${e.symbol}""".stripMargin, + e, + ) scribe.error(s"searching for `${e.symbol}` failed", e.underlying) case _: NoSuchFileException => // only comes for badly configured jar with `/Users` path added. diff --git a/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala b/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala index 886e26fc876..7034a499d2d 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala @@ -192,6 +192,13 @@ object ServerCommands { |""".stripMargin, ) + val ZipReports = new Command( + "zip-reports", + "Create a zip with error reports", + """|Creates a zip from incognito and bloop reports with additional information about build targets. + |""".stripMargin, + ) + val RunScalafix = new ParametrizedCommand[TextDocumentPositionParams]( "scalafix-run", "Run all Scalafix Rules", @@ -741,6 +748,7 @@ object ServerCommands { StopScalaCliServer, OpenIssue, OpenFeatureRequest, + ZipReports, ) val allIds: Set[String] = all.map(_.id).toSet diff --git a/metals/src/main/scala/scala/meta/internal/metals/ZipReportsProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/ZipReportsProvider.scala new file mode 100644 index 00000000000..c65d00d83b0 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/ZipReportsProvider.scala @@ -0,0 +1,73 @@ +package scala.meta.internal.metals + +import java.nio.file.Files +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +import scala.meta.internal.mtags.MtagsEnrichments._ +import scala.meta.io.AbsolutePath + +class ZipReportsProvider( + doctorTargetsInfo: () => List[ + Map[String, String] + ], // we pass the function instead of a whole doctor for the simplicity of testing + reportContext: StdReportContext, +) { + + def zip(): AbsolutePath = { + val buildTargersFile = storeBuildTargetsInfo() + zipReports(List(buildTargersFile)) + crateReportReadme() + } + + private def crateReportReadme(): AbsolutePath = { + val path = reportContext.reportsDir.resolve("READ_ME.md") + if (Files.notExists(path.toNIO)) { + path.writeText( + s"""|Please attach `${StdReportContext.ZIP_FILE_NAME}` to your GitHub issue. + |Reports zip URI: ${reportContext.reportsDir.resolve(StdReportContext.ZIP_FILE_NAME).toURI(false)} + |""".stripMargin + ) + } + path + } + + private def storeBuildTargetsInfo(): FileToZip = { + val text = doctorTargetsInfo().zipWithIndex + .map { case (info, ind) => + s"""|#### $ind + |${info.toList.map { case (key, value) => s"$key: $value" }.mkString("\n")} + |""".stripMargin + } + .mkString("\n") + FileToZip("build-targets-info.md", text.getBytes()) + } + + private def zipReports(additionalToZip: List[FileToZip]): AbsolutePath = { + val path = reportContext.reportsDir.resolve(StdReportContext.ZIP_FILE_NAME) + val zipOut = new ZipOutputStream(Files.newOutputStream(path.toNIO)) + + for { + reportsProvider <- reportContext.allToZip + report <- reportsProvider.getReports() + } { + val zipEntry = new ZipEntry(report.name) + zipOut.putNextEntry(zipEntry) + zipOut.write(Files.readAllBytes(report.toPath)) + } + + for { + toZip <- additionalToZip + } { + val zipEntry = new ZipEntry(toZip.name) + zipOut.putNextEntry(zipEntry) + zipOut.write(toZip.text) + } + + zipOut.close() + + path + } +} + +case class FileToZip(name: String, text: Array[Byte]) diff --git a/metals/src/main/scala/scala/meta/internal/metals/doctor/Doctor.scala b/metals/src/main/scala/scala/meta/internal/metals/doctor/Doctor.scala index c8cc2def047..e428209a1f4 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/doctor/Doctor.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/doctor/Doctor.scala @@ -475,6 +475,12 @@ final class Doctor( } } + def getTargetsInfoForReports(): List[Map[String, String]] = + allTargetIds() + .flatMap(extractTargetInfo(_)) + .map(_.toMap(exclude = List("gotoCommand", "buildTarget"))) + .toList + private def extractTargetInfo( targetId: BuildTargetIdentifier ): List[DoctorTargetInfo] = { diff --git a/metals/src/main/scala/scala/meta/internal/metals/doctor/DoctorResults.scala b/metals/src/main/scala/scala/meta/internal/metals/doctor/DoctorResults.scala index 19ee4678371..ba23bd98459 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/doctor/DoctorResults.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/doctor/DoctorResults.scala @@ -75,6 +75,20 @@ final case class DoctorTargetInfo( "recommendation" -> recommenedFix, ) + def toMap(exclude: List[String] = List()): Map[String, String] = + Map( + "buildTarget" -> name, + "gotoCommand" -> gotoCommand, + "compilationStatus" -> compilationStatus.explanation, + "targetType" -> targetType, + "diagnostics" -> diagnosticsStatus.explanation, + "interactive" -> interactiveStatus.explanation, + "semanticdb" -> indexesStatus.explanation, + "debugging" -> debuggingStatus.explanation, + "java" -> javaStatus.explanation, + "recommendation" -> recommenedFix, + ) -- exclude + } /** diff --git a/metals/src/main/scala/scala/meta/internal/parsing/JavaFoldingRangeExtractor.scala b/metals/src/main/scala/scala/meta/internal/parsing/JavaFoldingRangeExtractor.scala index ec1f7d77a58..9cee3a6a1ae 100644 --- a/metals/src/main/scala/scala/meta/internal/parsing/JavaFoldingRangeExtractor.scala +++ b/metals/src/main/scala/scala/meta/internal/parsing/JavaFoldingRangeExtractor.scala @@ -46,14 +46,16 @@ final class JavaFoldingRangeExtractor( def extract(): List[FoldingRange] = { val scanner = ToolFactory.createScanner(true, true, false, true) scanner.setSource(text.toCharArray()) - @tailrec def swallowUntilSemicolon(token: Int, line: Int): Int = { - val addLine = scanner.getCurrentTokenSource.count(_ == '\n') - if (token != ITerminalSymbols.TokenNameSEMICOLON) { - swallowUntilSemicolon(scanner.getNextToken(), line + addLine) - } else { - line + addLine + if (token == ITerminalSymbols.TokenNameEOF) line + else { + val addLine = scanner.getCurrentTokenSource.count(_ == '\n') + if (token != ITerminalSymbols.TokenNameSEMICOLON) { + swallowUntilSemicolon(scanner.getNextToken(), line + addLine) + } else { + line + addLine + } } } 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..a9109398170 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.ReportContext import scala.meta.internal.metals.ScalaVersionSelector import scala.meta.io.AbsolutePath import scala.meta.parsers.Parse @@ -30,8 +28,7 @@ import org.eclipse.{lsp4j => l} final class Trees( buffers: Buffers, scalaVersionSelector: ScalaVersionSelector, - workspace: AbsolutePath, -) { +)(implicit reports: ReportContext) { private val trees = TrieMap.empty[AbsolutePath, Tree] @@ -141,13 +138,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..47c684e53cf 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.StdReportContext 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 StdReportContext(workspace).cleanUpOldReports() + service.initialize() } } diff --git a/mtags/src/main/scala-2/scala/meta/internal/pc/PcCollector.scala b/mtags/src/main/scala-2/scala/meta/internal/pc/PcCollector.scala index 5d3d9826122..0b803878e86 100644 --- a/mtags/src/main/scala-2/scala/meta/internal/pc/PcCollector.scala +++ b/mtags/src/main/scala-2/scala/meta/internal/pc/PcCollector.scala @@ -228,7 +228,7 @@ abstract class PcCollector[T]( def isForComprehensionOwner(named: NameTree) = soughtNames(named.name) && named.symbol.owner.isAnonymousFunction && owners.exists( - _.pos.point == named.symbol.owner.pos.point + pos.isDefined && _.pos.point == named.symbol.owner.pos.point ) def soughtOrOverride(sym: Symbol) = diff --git a/mtags/src/main/scala-3/scala/meta/internal/pc/AutoImportsProvider.scala b/mtags/src/main/scala-3/scala/meta/internal/pc/AutoImportsProvider.scala index 6a0ac25774b..a4fa947a739 100644 --- a/mtags/src/main/scala-3/scala/meta/internal/pc/AutoImportsProvider.scala +++ b/mtags/src/main/scala-3/scala/meta/internal/pc/AutoImportsProvider.scala @@ -6,6 +6,7 @@ import java.{util as ju} import scala.collection.mutable import scala.jdk.CollectionConverters.* +import scala.meta.internal.metals.ReportContext import scala.meta.internal.mtags.MtagsEnrichments.* import scala.meta.internal.pc.AutoImports.* import scala.meta.internal.pc.completions.CompletionPos @@ -31,7 +32,7 @@ final class AutoImportsProvider( params: OffsetParams, config: PresentationCompilerConfig, buildTargetIdentifier: String, -): +)(using ReportContext): def autoImports(isExtension: Boolean): List[AutoImportsResult] = val uri = params.uri diff --git a/mtags/src/main/scala-3/scala/meta/internal/pc/CompilerSearchVisitor.scala b/mtags/src/main/scala-3/scala/meta/internal/pc/CompilerSearchVisitor.scala index cbc6408f629..8dddf6ddba0 100644 --- a/mtags/src/main/scala-3/scala/meta/internal/pc/CompilerSearchVisitor.scala +++ b/mtags/src/main/scala-3/scala/meta/internal/pc/CompilerSearchVisitor.scala @@ -5,6 +5,7 @@ import java.util.logging.Logger import scala.util.control.NonFatal +import scala.meta.internal.metals.ReportContext import scala.meta.pc.* import dotty.tools.dotc.core.Contexts.* @@ -16,7 +17,7 @@ import dotty.tools.dotc.core.Symbols.* class CompilerSearchVisitor( query: String, visitSymbol: Symbol => Boolean, -)(using ctx: Context) +)(using ctx: Context, reports: ReportContext) extends SymbolSearchVisitor: val logger: Logger = Logger.getLogger(classOf[CompilerSearchVisitor].getName) @@ -25,6 +26,11 @@ class CompilerSearchVisitor( sym != NoSymbol && sym.isPublic catch case NonFatal(e) => + reports.incognito.createReport( + "is_public", + s"""Symbol: $sym""".stripMargin, + e, + ) logger.log(Level.SEVERE, e.getMessage(), e) false diff --git a/mtags/src/main/scala-3/scala/meta/internal/pc/ScalaPresentationCompiler.scala b/mtags/src/main/scala-3/scala/meta/internal/pc/ScalaPresentationCompiler.scala index 730c9fac18d..4344b9037dd 100644 --- a/mtags/src/main/scala-3/scala/meta/internal/pc/ScalaPresentationCompiler.scala +++ b/mtags/src/main/scala-3/scala/meta/internal/pc/ScalaPresentationCompiler.scala @@ -15,6 +15,9 @@ import scala.concurrent.ExecutionContext import scala.concurrent.ExecutionContextExecutor import scala.meta.internal.metals.EmptyCancelToken +import scala.meta.internal.metals.EmptyReportContext +import scala.meta.internal.metals.ReportContext +import scala.meta.internal.metals.StdReportContext import scala.meta.internal.mtags.BuildInfo import scala.meta.internal.mtags.MtagsEnrichments.* import scala.meta.internal.pc.AutoImports.* @@ -57,6 +60,8 @@ case class ScalaPresentationCompiler( private val forbiddenOptions = Set("-print-lines", "-print-tasty") private val forbiddenDoubleOptions = Set("-release") + given ReportContext = + workspace.map(StdReportContext(_)).getOrElse(EmptyReportContext) val compilerAccess: CompilerAccess[StoreReporter, MetalsDriver] = Scala3CompilerAccess( diff --git a/mtags/src/main/scala-3/scala/meta/internal/pc/completions/CompletionProvider.scala b/mtags/src/main/scala-3/scala/meta/internal/pc/completions/CompletionProvider.scala index 9da0c20e332..4f054aa45d3 100644 --- a/mtags/src/main/scala-3/scala/meta/internal/pc/completions/CompletionProvider.scala +++ b/mtags/src/main/scala-3/scala/meta/internal/pc/completions/CompletionProvider.scala @@ -6,6 +6,7 @@ import java.nio.file.Path import scala.annotation.tailrec import scala.collection.JavaConverters.* +import scala.meta.internal.metals.ReportContext import scala.meta.internal.mtags.MtagsEnrichments.* import scala.meta.internal.pc.AutoImports.AutoImportEdits import scala.meta.internal.pc.AutoImports.AutoImportsGenerator @@ -38,7 +39,7 @@ class CompletionProvider( config: PresentationCompilerConfig, buildTargetIdentifier: String, workspace: Option[Path], -): +)(using reports: ReportContext): def completions(): CompletionList = val uri = params.uri diff --git a/mtags/src/main/scala-3/scala/meta/internal/pc/completions/Completions.scala b/mtags/src/main/scala-3/scala/meta/internal/pc/completions/Completions.scala index 69854ff61ff..10240634818 100644 --- a/mtags/src/main/scala-3/scala/meta/internal/pc/completions/Completions.scala +++ b/mtags/src/main/scala-3/scala/meta/internal/pc/completions/Completions.scala @@ -7,6 +7,7 @@ import java.nio.file.Paths import scala.collection.mutable import scala.meta.internal.metals.Fuzzy +import scala.meta.internal.metals.ReportContext import scala.meta.internal.mtags.BuildInfo import scala.meta.internal.mtags.MtagsEnrichments.* import scala.meta.internal.pc.AutoImports.AutoImportsGenerator @@ -51,7 +52,7 @@ class Completions( workspace: Option[Path], autoImports: AutoImportsGenerator, options: List[String], -): +)(using ReportContext): implicit val context: Context = ctx diff --git a/mtags/src/main/scala-3/scala/meta/internal/pc/completions/InterpolatorCompletions.scala b/mtags/src/main/scala-3/scala/meta/internal/pc/completions/InterpolatorCompletions.scala index 1d4c23fe2bf..0fb68b8973e 100644 --- a/mtags/src/main/scala-3/scala/meta/internal/pc/completions/InterpolatorCompletions.scala +++ b/mtags/src/main/scala-3/scala/meta/internal/pc/completions/InterpolatorCompletions.scala @@ -3,6 +3,7 @@ package scala.meta.internal.pc.completions import scala.collection.mutable.ListBuffer import scala.meta.internal.metals.Fuzzy +import scala.meta.internal.metals.ReportContext import scala.meta.internal.mtags.MtagsEnrichments.* import scala.meta.internal.mtags.MtagsEnrichments.given import scala.meta.internal.pc.AutoImports.AutoImport @@ -41,7 +42,7 @@ object InterpolatorCompletions: search: SymbolSearch, config: PresentationCompilerConfig, buildTargetIdentifier: String, - )(using Context) = + )(using Context, ReportContext) = InterpolationSplice(pos.span.point, text.toCharArray(), text) match case Some(interpolator) => InterpolatorCompletions.contributeScope( @@ -122,7 +123,7 @@ object InterpolatorCompletions: areSnippetsSupported: Boolean, search: SymbolSearch, buildTargetIdentifier: String, - )(using Context): List[CompletionValue] = + )(using Context, ReportContext): List[CompletionValue] = def newText( name: String, suffix: Option[String], @@ -241,7 +242,7 @@ object InterpolatorCompletions: search: SymbolSearch, config: PresentationCompilerConfig, buildTargetIdentifier: String, - )(using ctx: Context): List[CompletionValue] = + )(using ctx: Context, reportsContext: ReportContext): List[CompletionValue] = val litStartPos = lit.span.start val litEndPos = lit.span.end - Cursor.value.length() val span = position.span diff --git a/mtags/src/main/scala/scala/meta/internal/metals/ReportContext.scala b/mtags/src/main/scala/scala/meta/internal/metals/ReportContext.scala new file mode 100644 index 00000000000..73e2299740b --- /dev/null +++ b/mtags/src/main/scala/scala/meta/internal/metals/ReportContext.scala @@ -0,0 +1,178 @@ +package scala.meta.internal.metals + +import java.io.File +import java.nio.file.Files +import java.nio.file.Path + +import scala.meta.internal.mtags.MtagsEnrichments._ +import scala.meta.io.AbsolutePath +import scala.meta.io.RelativePath + +trait ReportContext { + def unsanitized: Reporter + def incognito: Reporter + def bloop: Reporter + def all: List[Reporter] = List(unsanitized, incognito, bloop) + def allToZip: List[Reporter] = List(incognito, bloop) + def cleanUpOldReports( + maxReportsNumber: Int = StdReportContext.MAX_NUMBER_OF_REPORTS + ): Unit = all.foreach(_.cleanUpOldReports(maxReportsNumber)) + def deleteAll(): Unit = all.foreach(_.deleteAll()) +} + +trait Reporter { + def createReport(name: String, text: String): Option[AbsolutePath] + def createReport( + name: String, + text: String, + e: Throwable + ): Option[AbsolutePath] + def cleanUpOldReports( + maxReportsNumber: Int = StdReportContext.MAX_NUMBER_OF_REPORTS + ): List[Report] + def getReports(): List[Report] + def deleteAll(): Unit +} + +class StdReportContext(workspace: AbsolutePath) extends ReportContext { + lazy val reportsDir: AbsolutePath = + workspace.resolve(StdReportContext.reportsDir).createDirectories() + + val unsanitized = + new StdReporter( + workspace, + StdReportContext.reportsDir.resolve("metals-full") + ) + val incognito = + new StdReporter(workspace, StdReportContext.reportsDir.resolve("metals")) + val bloop = + new StdReporter(workspace, StdReportContext.reportsDir.resolve("bloop")) + + override def cleanUpOldReports( + maxReportsNumber: Int = StdReportContext.MAX_NUMBER_OF_REPORTS + ): Unit = { + all.foreach(_.cleanUpOldReports(maxReportsNumber)) + } + + override def deleteAll(): Unit = { + all.foreach(_.deleteAll()) + Files.delete(reportsDir.resolve(StdReportContext.ZIP_FILE_NAME).toNIO) + } +} + +class StdReporter(workspace: AbsolutePath, pathToReports: RelativePath) + extends Reporter { + private lazy val reportsDir = + workspace.resolve(pathToReports).createDirectories() + + private lazy val userHome = Option(System.getProperty("user.home")) + + override def createReport( + name: String, + text: String + ): Option[AbsolutePath] = { + val path = reportsDir.resolve(s"r_${name}_${System.currentTimeMillis()}") + path.writeText(sanitize(text)) + Some(path) + } + + override def createReport( + name: String, + text: String, + e: Throwable + ): Option[AbsolutePath] = + createReport( + name, + s"""|$text + |Error message: ${e.getMessage()} + |Error: $e + |""".stripMargin + ) + + private def sanitize(text: String) = { + val textAfterWokspaceReplace = + text.replace(workspace.toString(), StdReportContext.WORKSPACE_STR) + userHome + .map(textAfterWokspaceReplace.replace(_, StdReportContext.HOME_STR)) + .getOrElse(textAfterWokspaceReplace) + } + + override def cleanUpOldReports( + maxReportsNumber: Int = StdReportContext.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() + } + + override 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() + } + + override def deleteAll(): Unit = + getReports().foreach(r => Files.delete(r.toPath)) + +} + +object StdReportContext { + val MAX_NUMBER_OF_REPORTS = 30 + val WORKSPACE_STR = "" + val HOME_STR = "" + val ZIP_FILE_NAME = "reports.zip" + + def reportsDir: RelativePath = + RelativePath(".metals").resolve(".reports") + def apply(path: Path) = new StdReportContext(AbsolutePath(path)) +} + +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 + } + } +} + +object EmptyReporter extends Reporter { + + override def createReport(name: String, text: String): Option[AbsolutePath] = + None + + override def createReport( + name: String, + text: String, + e: Throwable + ): Option[AbsolutePath] = None + + override def cleanUpOldReports(maxReportsNumber: Int): List[Report] = List() + + override def getReports(): List[Report] = List() + + override def deleteAll(): Unit = {} +} + +object EmptyReportContext extends ReportContext { + + override def unsanitized: Reporter = EmptyReporter + + override def incognito: Reporter = EmptyReporter + + override def bloop: Reporter = EmptyReporter +} diff --git a/mtags/src/main/scala/scala/meta/internal/mtags/CommonMtagsEnrichments.scala b/mtags/src/main/scala/scala/meta/internal/mtags/CommonMtagsEnrichments.scala index 3b31e3abdc5..26672a7e866 100644 --- a/mtags/src/main/scala/scala/meta/internal/mtags/CommonMtagsEnrichments.scala +++ b/mtags/src/main/scala/scala/meta/internal/mtags/CommonMtagsEnrichments.scala @@ -5,6 +5,8 @@ import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption import java.util import java.util.logging.Level import java.util.logging.Logger @@ -585,6 +587,34 @@ trait CommonMtagsEnrichments { def startWith(other: AbsolutePath): Boolean = path.toNIO.startsWith(other.toNIO) + + def createDirectories(): AbsolutePath = + AbsolutePath(Files.createDirectories(path.dealias.toNIO)) + + def writeText(text: String): Unit = { + path.parent.createDirectories() + val tmp = Files.createTempFile("metals", path.filename) + // Write contents first to a temporary file and then try to + // atomically move the file to the destination. The atomic move + // reduces the risk that another tool will concurrently read the + // file contents during a half-complete file write. + Files.write( + tmp, + text.getBytes(StandardCharsets.UTF_8), + StandardOpenOption.TRUNCATE_EXISTING + ) + try { + Files.move( + tmp, + path.toNIO, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch { + case NonFatal(_) => + Files.move(tmp, path.toNIO, StandardCopyOption.REPLACE_EXISTING) + } + } } implicit class XtensionJavaPriorityQueue[A](q: util.PriorityQueue[A]) { diff --git a/tests/unit/src/main/scala/tests/TestingServer.scala b/tests/unit/src/main/scala/tests/TestingServer.scala index 0f027814bf4..08ba0c7a913 100644 --- a/tests/unit/src/main/scala/tests/TestingServer.scala +++ b/tests/unit/src/main/scala/tests/TestingServer.scala @@ -48,8 +48,10 @@ 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.ReportContext import scala.meta.internal.metals.ScalaVersionSelector import scala.meta.internal.metals.ServerCommands +import scala.meta.internal.metals.StdReportContext import scala.meta.internal.metals.TextEdits import scala.meta.internal.metals.Time import scala.meta.internal.metals.UserConfiguration @@ -162,13 +164,14 @@ final case class TestingServer( lazy val server = languageServer.getOldMetalsLanguageServer + implicit val reports: ReportContext = new StdReportContext(workspace) + private lazy val trees = new Trees( buffers, new ScalaVersionSelector( () => initialUserConfig, server.buildTargets, ), - workspace, ) 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..d3f0de0a2ab 100644 --- a/tests/unit/src/main/scala/tests/TreeUtils.scala +++ b/tests/unit/src/main/scala/tests/TreeUtils.scala @@ -5,6 +5,7 @@ import java.nio.file.Paths import scala.meta.internal.metals.Buffers import scala.meta.internal.metals.BuildTargets import scala.meta.internal.metals.ScalaVersionSelector +import scala.meta.internal.metals.StdReportContext import scala.meta.internal.metals.UserConfiguration import scala.meta.internal.parsing.Trees import scala.meta.io.AbsolutePath @@ -21,7 +22,9 @@ object TreeUtils { () => UserConfiguration(fallbackScalaVersion = scalaVersion), buildTargets, ) - val trees = new Trees(buffers, selector, AbsolutePath(Paths.get("."))) + implicit val reports = new StdReportContext(AbsolutePath(Paths.get("."))) + val trees = + new Trees(buffers, selector) (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..f58b6eb42cd --- /dev/null +++ b/tests/unit/src/test/scala/tests/ReportsSuite.scala @@ -0,0 +1,81 @@ +package tests + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Paths + +import scala.meta.internal.metals.Icons +import scala.meta.internal.metals.Report +import scala.meta.internal.metals.StdReportContext +import scala.meta.internal.metals.ZipReportsProvider +import scala.meta.io.AbsolutePath + +class ReportsSuite extends BaseSuite { + val workspace: AbsolutePath = AbsolutePath(Paths.get(".")) + val reportsProvider = new StdReportContext(workspace) + val zipReportsProvider = + new ZipReportsProvider(exampleBuildTargetsInfo, reportsProvider) + + def exampleBuildTargetsInfo(): List[Map[String, String]] = + List( + Map("type" -> "scala 3", "semanticdb" -> Icons.unicode.check), + Map("type" -> "scala 2", "semanticdb" -> Icons.unicode.check), + ) + + 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 = + new String(Files.readAllBytes(path.get.toNIO), StandardCharsets.UTF_8) + assertEquals(exampleText(StdReportContext.WORKSPACE_STR), obtained) + assert(Report.fromFile(path.get.toFile).nonEmpty) + } + + test("delete-old-reports") { + reportsProvider.incognito.createReport("some_test_error_old", exampleText()) + reportsProvider.incognito.createReport( + "some_different_test_error_old", + exampleText(), + ) + Thread.sleep(2) // to make sure, that the new tests have a later timestamp + reportsProvider.incognito.createReport("some_test_error_new", exampleText()) + reportsProvider.incognito.createReport( + "some_different_test_error_new", + exampleText(), + ) + val deleted = reportsProvider.incognito.cleanUpOldReports(2) + deleted match { + case (_ :: _ :: Nil) if deleted.forall(_.name.contains("old")) => + case _ => fail(s"deleted: ${deleted.map(_.name)}") + } + val reports = reportsProvider.incognito.getReports() + reports match { + case (_ :: _ :: Nil) if reports.forall(_.name.contains("new")) => + case _ => fail(s"reports: ${reports.map(_.name)}") + } + } + + test("zip-reports") { + reportsProvider.incognito.createReport("test_error", exampleText()) + reportsProvider.incognito.createReport( + "different_test_error", + exampleText(), + ) + val pathToReadMe = zipReportsProvider.zip() + val zipPath = + reportsProvider.reportsDir.resolve(StdReportContext.ZIP_FILE_NAME).toNIO + assert(Files.exists(zipPath)) + assert(Files.exists(pathToReadMe.toNIO)) + Files.delete(pathToReadMe.toNIO) + } +}