From a70cbacecfe6b451fbb189f0fb3b8ae21cef5996 Mon Sep 17 00:00:00 2001 From: Pavel Marek Date: Fri, 2 Feb 2024 12:45:19 +0100 Subject: [PATCH] Remove fansi dependency from runtime-compiler (#8847) Moves `fansi` dependency from `runtime-compiler` into `runtime`. # Important Notes I have not refactored [DiagnosticFormatter.scala](https://github.com/enso-org/enso/pull/8847/files#diff-8e73cf562742d6b0510acfe30af940fb9252e32be27a023f9705908a464e08ed) into Java just yet - I don't know what should be the replacement for now. I have just moved that source from `runtime-compiler` to `runtime`. --- build.sbt | 4 +- .../compiler/context/CompilerContext.java | 11 + .../scala/org/enso/compiler/Compiler.scala | 221 ++---------------- .../org/enso/interpreter/EnsoLanguage.java | 2 - .../node/expression/debug/EvalNode.java | 3 +- .../enso/interpreter/runtime/EnsoContext.java | 20 ++ .../runtime/TruffleCompilerContext.java | 42 ++++ .../runtime/util/DiagnosticFormatter.scala | 216 +++++++++++++++++ .../interpreter/test/DebuggingEnsoTest.java | 7 +- .../test/DiagnosticFormatterTest.java | 102 ++++++++ 10 files changed, 414 insertions(+), 214 deletions(-) create mode 100644 engine/runtime/src/main/scala/org/enso/interpreter/runtime/util/DiagnosticFormatter.scala create mode 100644 engine/runtime/src/test/java/org/enso/interpreter/test/DiagnosticFormatterTest.java diff --git a/build.sbt b/build.sbt index 0bc337c5e49..dad23a48d07 100644 --- a/build.sbt +++ b/build.sbt @@ -1619,6 +1619,7 @@ lazy val runtime = (project in file("engine/runtime")) libraryDependencies ++= jmh ++ jaxb ++ GraalVM.langsPkgs ++ Seq( "org.apache.commons" % "commons-lang3" % commonsLangVersion, "org.apache.tika" % "tika-core" % tikaVersion, + "com.lihaoyi" %% "fansi" % fansiVersion, "org.graalvm.polyglot" % "polyglot" % graalMavenPackagesVersion % "provided", "org.graalvm.sdk" % "polyglot-tck" % graalMavenPackagesVersion % "provided", "org.graalvm.truffle" % "truffle-api" % graalMavenPackagesVersion % "provided", @@ -1871,8 +1872,7 @@ lazy val `runtime-compiler` = "junit" % "junit" % junitVersion % Test, "com.github.sbt" % "junit-interface" % junitIfVersion % Test, "org.scalatest" %% "scalatest" % scalatestVersion % Test, - "org.netbeans.api" % "org-openide-util-lookup" % netbeansApiVersion % "provided", - "com.lihaoyi" %% "fansi" % fansiVersion + "org.netbeans.api" % "org-openide-util-lookup" % netbeansApiVersion % "provided" ) ) .dependsOn(`runtime-parser`) diff --git a/engine/runtime-compiler/src/main/java/org/enso/compiler/context/CompilerContext.java b/engine/runtime-compiler/src/main/java/org/enso/compiler/context/CompilerContext.java index 938da72e3af..f6526c00829 100644 --- a/engine/runtime-compiler/src/main/java/org/enso/compiler/context/CompilerContext.java +++ b/engine/runtime-compiler/src/main/java/org/enso/compiler/context/CompilerContext.java @@ -12,6 +12,7 @@ import org.enso.compiler.Compiler; import org.enso.compiler.PackageRepository; import org.enso.compiler.Passes; import org.enso.compiler.core.CompilerStub; +import org.enso.compiler.core.ir.Diagnostic; import org.enso.compiler.data.BindingsMap; import org.enso.compiler.data.CompilerConfig; import org.enso.editions.LibraryName; @@ -51,6 +52,16 @@ public interface CompilerContext extends CompilerStub { Module findTopScopeModule(String name); + /** + * Format the given diagnostic into a string. The returned string might have ANSI colors. + * + * @param module May be null if inline diagnostics is required. + * @param diagnostic + * @param isOutputRedirected True if the output is not system's out. If true, no ANSI color escape + * characters will be inside the returned string. + */ + String formatDiagnostic(Module module, Diagnostic diagnostic, boolean isOutputRedirected); + // threads boolean isCreateThreadAllowed(); diff --git a/engine/runtime-compiler/src/main/scala/org/enso/compiler/Compiler.scala b/engine/runtime-compiler/src/main/scala/org/enso/compiler/Compiler.scala index 2c60cab504a..565a58292b3 100644 --- a/engine/runtime-compiler/src/main/scala/org/enso/compiler/Compiler.scala +++ b/engine/runtime-compiler/src/main/scala/org/enso/compiler/Compiler.scala @@ -1,6 +1,6 @@ package org.enso.compiler -import com.oracle.truffle.api.source.{Source, SourceSection} +import com.oracle.truffle.api.source.{Source} import org.enso.compiler.context.{ CompilerContext, FreshNameSupply, @@ -22,7 +22,7 @@ import org.enso.compiler.core.ir.MetadataStorage.MetadataPair import org.enso.compiler.core.ir.expression.Error import org.enso.compiler.core.ir.module.scope.Export import org.enso.compiler.core.ir.module.scope.Import -import org.enso.compiler.core.ir.module.scope.imports; +import org.enso.compiler.core.ir.module.scope.imports import org.enso.compiler.core.EnsoParser import org.enso.compiler.data.{BindingsMap, CompilerConfig} import org.enso.compiler.exception.CompilationAbortedException @@ -60,7 +60,7 @@ import scala.jdk.OptionConverters._ class Compiler( val context: CompilerContext, val packageRepository: PackageRepository, - config: CompilerConfig + private val config: CompilerConfig ) { private val freshNameSupply: FreshNameSupply = new FreshNameSupply private val passes: Passes = new Passes(config) @@ -75,6 +75,9 @@ class Compiler( else context.getOut private lazy val ensoCompiler: EnsoParser = new EnsoParser() + /** Java accessor */ + def getConfig(): CompilerConfig = config + /** The thread pool that handles parsing of modules. */ private val pool: ExecutorService = if (config.parallelParsing) { new ThreadPoolExecutor( @@ -678,7 +681,7 @@ class Compiler( ensoCompiler.generateIRInline(tree).map { ir => val compilerOutput = runCompilerPhasesInline(ir, newContext) - runErrorHandlingInline(compilerOutput, source, newContext) + runErrorHandlingInline(compilerOutput, newContext) (newContext, compilerOutput, source) } } @@ -843,12 +846,10 @@ class Compiler( * context) for the inline compiler flow. * * @param ir the IR after compilation passes. - * @param source the original source code. * @param inlineContext the inline compilation context. */ private def runErrorHandlingInline( ir: Expression, - source: Source, inlineContext: InlineContext ): Unit = { val errors = GatherDiagnostics @@ -858,7 +859,7 @@ class Compiler( "No diagnostics metadata right after the gathering pass." ) .diagnostics - val hasErrors = reportDiagnostics(errors, source) + val hasErrors = reportDiagnostics(errors, null) if (hasErrors && inlineContext.compilerConfig.isStrictErrors) { throw new CompilationAbortedException } @@ -979,7 +980,7 @@ class Compiler( diagnostics .foldLeft(false) { case (result, (mod, diags)) => if (diags.nonEmpty) { - reportDiagnostics(diags, mod.getSource) || result + reportDiagnostics(diags, mod) || result } else { result } @@ -990,210 +991,22 @@ class Compiler( * exception breaking the execution flow if there are errors. * * @param diagnostics all the diagnostics found in the program IR. - * @param source the original source code. + * @param compilerModule The module in which the diagnostics should be reported. Or null if run inline. * @return whether any errors were encountered. */ private def reportDiagnostics( diagnostics: List[Diagnostic], - source: Source + compilerModule: CompilerContext.Module ): Boolean = { - diagnostics.foreach(diag => - printDiagnostic(new DiagnosticFormatter(diag, source).format()) - ) + val isOutputRedirected = config.outputRedirect.isDefined + diagnostics.foreach { diag => + val formattedDiag = + context.formatDiagnostic(compilerModule, diag, isOutputRedirected) + printDiagnostic(formattedDiag) + } diagnostics.exists(_.isInstanceOf[Error]) } - /** Formatter of IR diagnostics. Heavily inspired by GCC. Can format one-line as well as multiline - * diagnostics. The output is colorized if the output stream supports ANSI colors. - * Also prints the offending lines from the source along with line number - the same way as - * GCC does. - * @param diagnostic the diagnostic to pretty print - * @param source the original source code - */ - private class DiagnosticFormatter( - private val diagnostic: Diagnostic, - private val source: Source - ) { - private val maxLineNum = 99999 - private val blankLinePrefix = " | " - private val maxSourceLinesToPrint = 3 - private val linePrefixSize = blankLinePrefix.length - private val outSupportsAnsiColors: Boolean = outSupportsColors - private val (textAttrs: fansi.Attrs, subject: String) = diagnostic match { - case _: Error => (fansi.Color.Red ++ fansi.Bold.On, "error: ") - case _: Warning => (fansi.Color.Yellow ++ fansi.Bold.On, "warning: ") - case _ => throw new IllegalStateException("Unexpected diagnostic type") - } - - def fileLocationFromSection(loc: IdentifiedLocation) = { - val section = - source.createSection(loc.location().start(), loc.location().length()); - val locStr = "" + section.getStartLine() + ":" + section - .getStartColumn() + "-" + section.getEndLine() + ":" + section - .getEndColumn() - source.getName() + "[" + locStr + "]"; - } - - private val sourceSection: Option[SourceSection] = - diagnostic.location match { - case Some(location) => - Some(source.createSection(location.start, location.length)) - case None => None - } - private val shouldPrintLineNumber = sourceSection match { - case Some(section) => - section.getStartLine <= maxLineNum && section.getEndLine <= maxLineNum - case None => false - } - - def format(): String = { - sourceSection match { - case Some(section) => - val isOneLine = section.getStartLine == section.getEndLine - val srcPath: String = - if (source.getPath == null && source.getName == null) { - "" - } else if (source.getPath != null) { - source.getPath - } else { - source.getName - } - if (isOneLine) { - val lineNumber = section.getStartLine - val startColumn = section.getStartColumn - val endColumn = section.getEndColumn - var str = fansi.Str() - str ++= fansi - .Str(srcPath + ":" + lineNumber + ":" + startColumn + ": ") - .overlay(fansi.Bold.On) - str ++= fansi.Str(subject).overlay(textAttrs) - str ++= diagnostic.formattedMessage(fileLocationFromSection) - str ++= "\n" - str ++= oneLineFromSourceColored(lineNumber, startColumn, endColumn) - str ++= "\n" - str ++= underline(startColumn, endColumn) - if (outSupportsAnsiColors) { - str.render.stripLineEnd - } else { - str.plainText.stripLineEnd - } - } else { - var str = fansi.Str() - str ++= fansi - .Str( - srcPath + ":[" + section.getStartLine + ":" + section.getStartColumn + "-" + section.getEndLine + ":" + section.getEndColumn + "]: " - ) - .overlay(fansi.Bold.On) - str ++= fansi.Str(subject).overlay(textAttrs) - str ++= diagnostic.formattedMessage(fileLocationFromSection) - str ++= "\n" - val printAllSourceLines = - section.getEndLine - section.getStartLine <= maxSourceLinesToPrint - val endLine = - if (printAllSourceLines) section.getEndLine - else section.getStartLine + maxSourceLinesToPrint - for (lineNum <- section.getStartLine to endLine) { - str ++= oneLineFromSource(lineNum) - str ++= "\n" - } - if (!printAllSourceLines) { - val restLineCount = - section.getEndLine - section.getStartLine - maxSourceLinesToPrint - str ++= blankLinePrefix + "... and " + restLineCount + " more lines ..." - str ++= "\n" - } - if (outSupportsAnsiColors) { - str.render.stripLineEnd - } else { - str.plainText.stripLineEnd - } - } - case None => - // There is no source section associated with the diagnostics - var str = fansi.Str() - val fileLocation = diagnostic.location match { - case Some(_) => - fileLocationFromSectionOption(diagnostic.location, source) - case None => - Option(source.getPath).getOrElse("") - } - - str ++= fansi - .Str(fileLocation) - .overlay(fansi.Bold.On) - str ++= ": " - str ++= fansi.Str(subject).overlay(textAttrs) - str ++= diagnostic.formattedMessage(fileLocationFromSection) - if (outSupportsAnsiColors) { - str.render.stripLineEnd - } else { - str.plainText.stripLineEnd - } - } - } - - /** @see https://github.com/termstandard/colors/ - * @see https://no-color.org/ - * @return - */ - private def outSupportsColors: Boolean = { - if (System.console() == null) { - // Non-interactive output is always without color support - return false - } - if (System.getenv("NO_COLOR") != null) { - return false - } - if (config.outputRedirect.isDefined) { - return false - } - if (System.getenv("COLORTERM") != null) { - return true - } - if (System.getenv("TERM") != null) { - val termEnv = System.getenv("TERM").toLowerCase - return termEnv.split("-").contains("color") || termEnv - .split("-") - .contains("256color") - } - return false - } - - private def oneLineFromSource(lineNum: Int): String = { - val line = source.createSection(lineNum).getCharacters.toString - linePrefix(lineNum) + line - } - - private def oneLineFromSourceColored( - lineNum: Int, - startCol: Int, - endCol: Int - ): String = { - val line = source.createSection(lineNum).getCharacters.toString - linePrefix(lineNum) + fansi - .Str(line) - .overlay(textAttrs, startCol - 1, endCol) - } - - private def linePrefix(lineNum: Int): String = { - if (shouldPrintLineNumber) { - val pipeSymbol = " | " - val prefixWhitespaces = - linePrefixSize - lineNum.toString.length - pipeSymbol.length - " " * prefixWhitespaces + lineNum + pipeSymbol - } else { - blankLinePrefix - } - } - - private def underline(startColumn: Int, endColumn: Int): String = { - val sectionLen = endColumn - startColumn - blankLinePrefix + - " " * (startColumn - 1) + - fansi.Str("^" + ("~" * sectionLen)).overlay(textAttrs) - } - } - private def fileLocationFromSectionOption( loc: Option[IdentifiedLocation], source: Source diff --git a/engine/runtime/src/main/java/org/enso/interpreter/EnsoLanguage.java b/engine/runtime/src/main/java/org/enso/interpreter/EnsoLanguage.java index 49086155602..f5ab472f4f1 100644 --- a/engine/runtime/src/main/java/org/enso/interpreter/EnsoLanguage.java +++ b/engine/runtime/src/main/java/org/enso/interpreter/EnsoLanguage.java @@ -274,8 +274,6 @@ public final class EnsoLanguage extends TruffleLanguage { } catch (UnhandledEntity e) { throw new InlineParsingException("Unhandled entity: " + e.entity(), e); } catch (CompilationAbortedException e) { - assert outputRedirect.toString().lines().count() > 1 - : "Expected a header line from the compiler"; String compilerErrOutput = outputRedirect.toString(); throw new InlineParsingException(compilerErrOutput, e); } finally { diff --git a/engine/runtime/src/main/java/org/enso/interpreter/node/expression/debug/EvalNode.java b/engine/runtime/src/main/java/org/enso/interpreter/node/expression/debug/EvalNode.java index a255df6b011..b34838464eb 100644 --- a/engine/runtime/src/main/java/org/enso/interpreter/node/expression/debug/EvalNode.java +++ b/engine/runtime/src/main/java/org/enso/interpreter/node/expression/debug/EvalNode.java @@ -85,8 +85,7 @@ public abstract class EvalNode extends BaseNode { var mod = newInlineContext.module$access$0().module$access$0(); var m = org.enso.interpreter.runtime.Module.fromCompilerModule(mod); - var toTruffle = - new IrToTruffle(context, src, m.getScope(), compiler.org$enso$compiler$Compiler$$config); + var toTruffle = new IrToTruffle(context, src, m.getScope(), compiler.getConfig()); var expr = toTruffle.runInline(ir, sco, ""); if (shouldCaptureResultScope) { diff --git a/engine/runtime/src/main/java/org/enso/interpreter/runtime/EnsoContext.java b/engine/runtime/src/main/java/org/enso/interpreter/runtime/EnsoContext.java index 41288a23f48..43928e71118 100644 --- a/engine/runtime/src/main/java/org/enso/interpreter/runtime/EnsoContext.java +++ b/engine/runtime/src/main/java/org/enso/interpreter/runtime/EnsoContext.java @@ -484,6 +484,26 @@ public final class EnsoContext { return environment.asGuestValue(obj); } + /** + * Returns true if the output is a terminal that supports ANSI colors. {@see + * https://github.com/termstandard/colors/} {@see https://no-color.org/} + */ + public boolean isColorTerminalOutput() { + var envVars = environment.getEnvironment(); + if (envVars.get("NO_COLOR") != null) { + return false; + } + if (envVars.get("COLORTERM") != null) { + return true; + } + if (envVars.get("TERM") != null) { + var termEnv = envVars.get("TERM").toLowerCase(); + return Arrays.stream(termEnv.split("-")) + .anyMatch(str -> str.equals("color") || str.equals("256color")); + } + return false; + } + /** * Tries to lookup a Java class (host symbol in Truffle terminology) by its fully qualified name. * This method also tries to lookup inner classes. More specifically, if the provided name diff --git a/engine/runtime/src/main/java/org/enso/interpreter/runtime/TruffleCompilerContext.java b/engine/runtime/src/main/java/org/enso/interpreter/runtime/TruffleCompilerContext.java index dd63ab124a1..4e79d6c8183 100644 --- a/engine/runtime/src/main/java/org/enso/interpreter/runtime/TruffleCompilerContext.java +++ b/engine/runtime/src/main/java/org/enso/interpreter/runtime/TruffleCompilerContext.java @@ -16,6 +16,8 @@ import org.enso.compiler.PackageRepository; import org.enso.compiler.Passes; import org.enso.compiler.context.CompilerContext; import org.enso.compiler.context.FreshNameSupply; +import org.enso.compiler.core.ir.Diagnostic; +import org.enso.compiler.core.ir.IdentifiedLocation; import org.enso.compiler.data.BindingsMap; import org.enso.compiler.data.CompilerConfig; import org.enso.compiler.pass.analyse.BindingAnalysis$; @@ -23,6 +25,7 @@ import org.enso.editions.LibraryName; import org.enso.interpreter.caches.Cache; import org.enso.interpreter.caches.ModuleCache; import org.enso.interpreter.runtime.type.Types; +import org.enso.interpreter.runtime.util.DiagnosticFormatter; import org.enso.pkg.Package; import org.enso.pkg.QualifiedName; import org.enso.polyglot.CompilationStage; @@ -232,6 +235,45 @@ final class TruffleCompilerContext implements CompilerContext { return option.isEmpty() ? null : option.get().asCompilerModule(); } + /** + * Return true if the given location is inside module. More specifically, if the location's bounds + * point inside the character bounds of the module. + * + *

Note that it is possible that a {@link Diagnostic}'s location has a bigger size than the + * size of the module. + */ + private static boolean isLocationInsideModule( + CompilerContext.Module module, IdentifiedLocation location) { + try { + return location.end() <= module.getSource().getLength(); + } catch (IOException e) { + throw new AssertionError("Unreachable", e); + } + } + + @Override + public String formatDiagnostic( + CompilerContext.Module module, Diagnostic diagnostic, boolean isOutputRedirected) { + DiagnosticFormatter diagnosticFormatter; + if (module != null && diagnostic.location().isDefined()) { + var location = diagnostic.location().get(); + if (isLocationInsideModule(module, location)) { + Source source; + try { + source = module.getSource(); + } catch (IOException e) { + throw new AssertionError(e); + } + assert source != null; + diagnosticFormatter = new DiagnosticFormatter(diagnostic, source, isOutputRedirected); + return diagnosticFormatter.format(); + } + } + var emptySource = Source.newBuilder(LanguageInfo.ID, "", null).build(); + diagnosticFormatter = new DiagnosticFormatter(diagnostic, emptySource, isOutputRedirected); + return diagnosticFormatter.format(); + } + @SuppressWarnings("unchecked") @Override public Future serializeLibrary( diff --git a/engine/runtime/src/main/scala/org/enso/interpreter/runtime/util/DiagnosticFormatter.scala b/engine/runtime/src/main/scala/org/enso/interpreter/runtime/util/DiagnosticFormatter.scala new file mode 100644 index 00000000000..b524eca52f9 --- /dev/null +++ b/engine/runtime/src/main/scala/org/enso/interpreter/runtime/util/DiagnosticFormatter.scala @@ -0,0 +1,216 @@ +package org.enso.interpreter.runtime.util + +import com.oracle.truffle.api.source.{Source, SourceSection} +import org.enso.compiler.core.ir.expression.Error +import org.enso.compiler.core.ir.{Diagnostic, IdentifiedLocation, Warning} +import org.enso.interpreter.runtime.EnsoContext + +/** Formatter of IR diagnostics. Heavily inspired by GCC. Can format one-line as well as multiline + * diagnostics. The output is colorized if the output stream supports ANSI colors. + * Also prints the offending lines from the source along with line number - the same way as + * GCC does. + * + * @param diagnostic the diagnostic to pretty print + * @param source the original source code + */ +class DiagnosticFormatter( + private val diagnostic: Diagnostic, + private val source: Source, + private val isOutputRedirected: Boolean +) { + private val maxLineNum = 99999 + private val blankLinePrefix = " | " + private val maxSourceLinesToPrint = 3 + private val linePrefixSize = blankLinePrefix.length + private val outSupportsAnsiColors: Boolean = outSupportsColors + private val (textAttrs: fansi.Attrs, subject: String) = diagnostic match { + case _: Error => (fansi.Color.Red ++ fansi.Bold.On, "error: ") + case _: Warning => (fansi.Color.Yellow ++ fansi.Bold.On, "warning: ") + case _ => throw new IllegalStateException("Unexpected diagnostic type") + } + + def fileLocationFromSection(loc: IdentifiedLocation) = { + val section = + source.createSection(loc.location().start(), loc.location().length()); + val locStr = "" + section.getStartLine() + ":" + section + .getStartColumn() + "-" + section.getEndLine() + ":" + section + .getEndColumn() + source.getName() + "[" + locStr + "]"; + } + + private val sourceSection: Option[SourceSection] = + diagnostic.location match { + case Some(location) => + if (location.length > source.getLength) { + None + } else { + Some(source.createSection(location.start, location.length)) + } + case None => None + } + private val shouldPrintLineNumber = sourceSection match { + case Some(section) => + section.getStartLine <= maxLineNum && section.getEndLine <= maxLineNum + case None => false + } + + def format(): String = { + sourceSection match { + case Some(section) => + val isOneLine = section.getStartLine == section.getEndLine + val srcPath: String = + if (source.getPath == null && source.getName == null) { + "" + } else if (source.getPath != null) { + source.getPath + } else { + source.getName + } + var str = fansi.Str() + if (isOneLine) { + val lineNumber = section.getStartLine + val startColumn = section.getStartColumn + val endColumn = section.getEndColumn + val isLocationEmpty = startColumn == endColumn + str ++= fansi + .Str(srcPath + ":" + lineNumber + ":" + startColumn + ": ") + .overlay(fansi.Bold.On) + str ++= fansi.Str(subject).overlay(textAttrs) + str ++= diagnostic.formattedMessage(fileLocationFromSection) + if (!isLocationEmpty) { + str ++= "\n" + str ++= oneLineFromSourceColored(lineNumber, startColumn, endColumn) + str ++= "\n" + str ++= underline(startColumn, endColumn) + } + } else { + str ++= fansi + .Str( + srcPath + ":[" + section.getStartLine + ":" + section.getStartColumn + "-" + section.getEndLine + ":" + section.getEndColumn + "]: " + ) + .overlay(fansi.Bold.On) + str ++= fansi.Str(subject).overlay(textAttrs) + str ++= diagnostic.formattedMessage(fileLocationFromSection) + str ++= "\n" + val printAllSourceLines = + section.getEndLine - section.getStartLine <= maxSourceLinesToPrint + val endLine = + if (printAllSourceLines) section.getEndLine + else section.getStartLine + maxSourceLinesToPrint + for (lineNum <- section.getStartLine to endLine) { + str ++= oneLineFromSource(lineNum) + str ++= "\n" + } + if (!printAllSourceLines) { + val restLineCount = + section.getEndLine - section.getStartLine - maxSourceLinesToPrint + str ++= blankLinePrefix + "... and " + restLineCount + " more lines ..." + str ++= "\n" + } + } + if (outSupportsAnsiColors) { + str.render.stripLineEnd + } else { + str.plainText.stripLineEnd + } + case None => + // There is no source section associated with the diagnostics + var str = fansi.Str() + val fileLocation = diagnostic.location match { + case Some(_) => + fileLocationFromSectionOption(diagnostic.location, source) + case None => + Option(source.getPath).getOrElse("") + } + + str ++= fansi + .Str(fileLocation) + .overlay(fansi.Bold.On) + str ++= ": " + str ++= fansi.Str(subject).overlay(textAttrs) + str ++= diagnostic.formattedMessage(fileLocationFromSection) + if (outSupportsAnsiColors) { + str.render.stripLineEnd + } else { + str.plainText.stripLineEnd + } + } + } + + /** @see https://github.com/termstandard/colors/ + * @see https://no-color.org/ + * @return + */ + private def outSupportsColors: Boolean = { + if (System.console() == null) { + // Non-interactive output is always without color support + return false + } + if (isOutputRedirected) { + return false + } + return EnsoContext.get(null).isColorTerminalOutput; + } + + private def oneLineFromSource(lineNum: Int): String = { + val line = source.createSection(lineNum).getCharacters.toString + linePrefix(lineNum) + line + } + + private def oneLineFromSourceColored( + lineNum: Int, + startCol: Int, + endCol: Int + ): String = { + val line = source.createSection(lineNum).getCharacters.toString + linePrefix(lineNum) + fansi + .Str(line) + .overlay(textAttrs, startCol - 1, endCol) + } + + private def linePrefix(lineNum: Int): String = { + if (shouldPrintLineNumber) { + val pipeSymbol = " | " + val prefixWhitespaces = + linePrefixSize - lineNum.toString.length - pipeSymbol.length + " " * prefixWhitespaces + lineNum + pipeSymbol + } else { + blankLinePrefix + } + } + + private def underline(startColumn: Int, endColumn: Int): String = { + val sectionLen = endColumn - startColumn + blankLinePrefix + + " " * (startColumn - 1) + + fansi.Str("^" + ("~" * sectionLen)).overlay(textAttrs) + } + + private def fileLocationFromSectionOption( + loc: Option[IdentifiedLocation], + source: Source + ): String = { + val srcLocation = loc match { + case Some(identifiedLoc) + if isLocationInSourceBounds(identifiedLoc, source) => + val section = + source.createSection(identifiedLoc.start, identifiedLoc.length) + val locStr = + "" + section.getStartLine + ":" + + section.getStartColumn + "-" + + section.getEndLine + ":" + + section.getEndColumn + "[" + locStr + "]" + case _ => "" + } + + source.getPath + ":" + srcLocation + } + + private def isLocationInSourceBounds( + loc: IdentifiedLocation, + source: Source + ): Boolean = { + loc.end() <= source.getLength + } +} diff --git a/engine/runtime/src/test/java/org/enso/interpreter/test/DebuggingEnsoTest.java b/engine/runtime/src/test/java/org/enso/interpreter/test/DebuggingEnsoTest.java index 0db91622762..538de3c492f 100644 --- a/engine/runtime/src/test/java/org/enso/interpreter/test/DebuggingEnsoTest.java +++ b/engine/runtime/src/test/java/org/enso/interpreter/test/DebuggingEnsoTest.java @@ -404,10 +404,9 @@ public class DebuggingEnsoTest { + " DebugException", DebugException.class, () -> event.getTopStackFrame().eval("non_existing_identifier")); - assertTrue( - exception - .getMessage() - .contains("The name `non_existing_identifier` could not be found")); + assertThat( + exception.getMessage(), + containsString("The name `non_existing_identifier` could not be found")); assertThrows( DebugException.class, diff --git a/engine/runtime/src/test/java/org/enso/interpreter/test/DiagnosticFormatterTest.java b/engine/runtime/src/test/java/org/enso/interpreter/test/DiagnosticFormatterTest.java new file mode 100644 index 00000000000..06524f6c5f7 --- /dev/null +++ b/engine/runtime/src/test/java/org/enso/interpreter/test/DiagnosticFormatterTest.java @@ -0,0 +1,102 @@ +package org.enso.interpreter.test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import com.oracle.truffle.api.source.Source; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import org.enso.compiler.core.ir.Diagnostic; +import org.enso.interpreter.runtime.EnsoContext; +import org.enso.interpreter.runtime.util.DiagnosticFormatter; +import org.enso.polyglot.LanguageInfo; +import org.enso.polyglot.MethodNames.Module; +import org.enso.polyglot.MethodNames.TopScope; +import org.enso.polyglot.RuntimeOptions; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.io.IOAccess; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class DiagnosticFormatterTest { + private Context ctx; + private OutputStream output; + private EnsoContext ensoCtx; + + @Before + public void initCtx() { + output = new ByteArrayOutputStream(); + ctx = + Context.newBuilder() + .allowExperimentalOptions(true) + .allowIO(IOAccess.ALL) + .allowAllAccess(true) + .option(RuntimeOptions.LOG_LEVEL, Level.WARNING.getName()) + .logHandler(System.err) + .option(RuntimeOptions.STRICT_ERRORS, "true") + .option( + RuntimeOptions.LANGUAGE_HOME_OVERRIDE, + Paths.get("../../distribution/component").toFile().getAbsolutePath()) + .out(output) + .err(output) + .environment("NO_COLOR", "true") + .build(); + ensoCtx = ctx.getBindings(LanguageInfo.ID).invokeMember(TopScope.LEAK_CONTEXT).asHostObject(); + } + + @After + public void closeCtx() throws IOException { + ctx.close(); + output.close(); + } + + @Test + public void testOneLineDiagnostics() throws IOException { + var code = "main = foo"; + var polyglotSrc = + org.graalvm.polyglot.Source.newBuilder(LanguageInfo.ID, code, "tmp_test.enso").build(); + var expectedDiagnostics = + """ +tmp_test:1:8: error: The name `foo` could not be found. + 1 | main = foo + | ^~~"""; + var module = ctx.eval(polyglotSrc); + try { + module.invokeMember(Module.EVAL_EXPRESSION, "main"); + } catch (PolyglotException e) { + assertThat(output.toString(), containsString(expectedDiagnostics)); + } + var moduleOpt = ensoCtx.getTopScope().getModule("tmp_test"); + assertThat(moduleOpt.isPresent(), is(true)); + var moduleIr = moduleOpt.get().getIr(); + var diags = gatherDiagnostics(moduleIr); + assertThat("There should be just one Diagnostic in main method", diags.size(), is(1)); + + var src = Source.newBuilder(LanguageInfo.ID, code, "tmp_test").build(); + var diag = diags.get(0); + var diagFormatter = new DiagnosticFormatter(diag, src, true); + var formattedDiag = diagFormatter.format(); + assertThat(formattedDiag, containsString(expectedDiagnostics)); + } + + private static List gatherDiagnostics(org.enso.compiler.core.ir.Module moduleIr) { + List diags = new ArrayList<>(); + moduleIr + .preorder() + .foreach( + ir -> { + if (ir instanceof Diagnostic diag) { + diags.add(diag); + } + return null; + }); + return diags; + } +}