diff --git a/compiler/src/dotty/tools/dotc/util/StackTraceOps.scala b/compiler/src/dotty/tools/dotc/util/StackTraceOps.scala new file mode 100644 index 000000000000..071fc22afa3d --- /dev/null +++ b/compiler/src/dotty/tools/dotc/util/StackTraceOps.scala @@ -0,0 +1,76 @@ +/* + * Scala (https://www.scala-lang.org) + * + * Copyright EPFL and Lightbend, Inc. + * + * Licensed under Apache License 2.0 + * (http://www.apache.org/licenses/LICENSE-2.0). + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package dotty.tools.dotc.util + +import collection.mutable, mutable.ListBuffer +import scala.util.chaining.given +import java.lang.System.lineSeparator + +object StackTraceOps: + + extension (t: Throwable) + + /** Format a stack trace, taking the prefix span satisfying a predicate. + * + * The format is similar to the typical case described in the Javadoc + * for [[java.lang.Throwable#printStackTrace()*]]. + * If a stack trace is truncated, it will be followed by a line of the form + * `... 3 elided`, by analogy to the lines `... 3 more` which indicate + * shared stack trace segments. + * @param e the exception + * @param p the predicate to select the prefix + */ + def formatStackTracePrefix(p: StackTraceElement => Boolean): String = + + type TraceRelation = String + val Self = new TraceRelation("") + val CausedBy = new TraceRelation("Caused by: ") + val Suppressed = new TraceRelation("Suppressed: ") + + def header(e: Throwable): String = + def because = e.getCause match { case null => null ; case c => header(c) } + def msg = e.getMessage match { case null => because ; case s => s } + def txt = msg match { case null => "" ; case s => s": $s" } + s"${e.getClass.getName}$txt" + + val seen = mutable.Set.empty[Throwable] + def unseen(e: Throwable): Boolean = (e != null && !seen(e)).tap(if _ then seen += e) + + val lines = ListBuffer.empty[String] + + // format the stack trace, skipping the shared trace + def print(e: Throwable, r: TraceRelation, share: Array[StackTraceElement], indents: Int): Unit = if unseen(e) then + val trace = e.getStackTrace + val frames = if share.isEmpty then trace else + val spare = share.reverseIterator + val trimmed = trace.reverse.dropWhile(spare.hasNext && spare.next() == _) + trimmed.reverse + val prefix = frames.takeWhile(p) + val margin = " " * indents + lines += s"${margin}${r}${header(e)}" + prefix.foreach(frame => lines += s"$margin at $frame") + + val traceFramesLenDiff = trace.length - frames.length + val framesPrefixLenDiff = frames.length - prefix.length + if traceFramesLenDiff > 0 then + if framesPrefixLenDiff > 0 then lines += s"$margin ... $framesPrefixLenDiff elided and $traceFramesLenDiff more" + else lines += s"$margin ... $traceFramesLenDiff more" + else if framesPrefixLenDiff > 0 then lines += s"$margin ... $framesPrefixLenDiff elided" + + print(e.getCause, CausedBy, trace, indents) + e.getSuppressed.foreach(print(_, Suppressed, frames, indents + 1)) + end print + + print(t, Self, share = Array.empty, indents = 0) + lines.mkString(lineSeparator) + end formatStackTracePrefix diff --git a/compiler/src/dotty/tools/repl/Rendering.scala b/compiler/src/dotty/tools/repl/Rendering.scala index 1d6c21553b85..e968356079e1 100644 --- a/compiler/src/dotty/tools/repl/Rendering.scala +++ b/compiler/src/dotty/tools/repl/Rendering.scala @@ -11,7 +11,7 @@ import dotc.core.Denotations.Denotation import dotc.core.Flags import dotc.core.Flags._ import dotc.core.Symbols.{Symbol, defn} -import dotc.core.StdNames.str +import dotc.core.StdNames.{nme, str} import dotc.core.NameOps._ import dotc.printing.ReplPrinter import dotc.reporting.{MessageRendering, Message, Diagnostic} @@ -115,32 +115,33 @@ private[repl] class Rendering(parentClassLoader: Option[ClassLoader] = None) { /** Render value definition result */ def renderVal(d: Denotation)(using Context): Option[Diagnostic] = val dcl = d.symbol.showUser - + def msg(s: String) = infoDiagnostic(s, d) try - if (d.symbol.is(Flags.Lazy)) Some(infoDiagnostic(dcl, d)) - else valueOf(d.symbol).map(value => infoDiagnostic(s"$dcl = $value", d)) - catch case ex: InvocationTargetException => Some(infoDiagnostic(renderError(ex), d)) + if (d.symbol.is(Flags.Lazy)) Some(msg(dcl)) + else valueOf(d.symbol).map(value => msg(s"$dcl = $value")) + catch case e: InvocationTargetException => Some(msg(renderError(e, d))) end renderVal /** Force module initialization in the absence of members. */ def forceModule(sym: Symbol)(using Context): Seq[Diagnostic] = def load() = val objectName = sym.fullName.encode.toString - val resObj: Class[?] = Class.forName(objectName, true, classLoader()) + Class.forName(objectName, true, classLoader()) Nil - try load() catch case e: ExceptionInInitializerError => List(infoDiagnostic(renderError(e), sym.denot)) + try load() catch case e: ExceptionInInitializerError => List(infoDiagnostic(renderError(e, sym.denot), sym.denot)) /** Render the stack trace of the underlying exception. */ - private def renderError(ex: InvocationTargetException | ExceptionInInitializerError): String = { - val cause = ex.getCause match { - case ex: ExceptionInInitializerError => ex.getCause - case ex => ex - } - val sw = new StringWriter() - val pw = new PrintWriter(sw) - cause.printStackTrace(pw) - sw.toString - } + private def renderError(ite: InvocationTargetException | ExceptionInInitializerError, d: Denotation)(using Context): String = + import dotty.tools.dotc.util.StackTraceOps._ + val cause = ite.getCause match + case e: ExceptionInInitializerError => e.getCause + case e => e + def isWrapperCode(ste: StackTraceElement) = + ste.getClassName == d.symbol.owner.name.show + && (ste.getMethodName == nme.STATIC_CONSTRUCTOR.show || ste.getMethodName == nme.CONSTRUCTOR.show) + + cause.formatStackTracePrefix(!isWrapperCode(_)) + end renderError private def infoDiagnostic(msg: String, d: Denotation)(using Context): Diagnostic = new Diagnostic.Info(msg, d.symbol.sourcePos) diff --git a/compiler/test/dotty/tools/dotc/util/StackTraceTest.scala b/compiler/test/dotty/tools/dotc/util/StackTraceTest.scala new file mode 100644 index 000000000000..ef5f1b813030 --- /dev/null +++ b/compiler/test/dotty/tools/dotc/util/StackTraceTest.scala @@ -0,0 +1,76 @@ + +package dotty.tools.dotc.util + +import scala.util.{Failure, Success, Try} +import scala.util.chaining.given + +import org.junit.Assert.{assertEquals, assertTrue} +import org.junit.Test + +class StackTraceTest: + val CausedBy = "Caused by: " + val Suppressed = "Suppressed: " + + // throws + def sample = throw new RuntimeException("Point of failure") + def sampler: String = sample + + // repackage with message + def resample: String = try sample catch case e: Throwable => throw new RuntimeException("resample", e) + def resampler: String = resample + + // simple wrapper + def wrapper: String = try sample catch case e: Throwable => throw new RuntimeException(e) + // another onion skin + def rewrapper: String = try wrapper catch case e: Throwable => throw new RuntimeException(e) + def rewrapperer: String = rewrapper + + // circular cause + def insane: String = try sample catch case e: Throwable => throw new RuntimeException(e).tap(e.initCause) + def insaner: String = insane + + def repressed: String = try sample catch case e: Throwable => throw new RuntimeException("My problem").tap(_.addSuppressed(e)) + def represser: String = repressed + + // evaluating s should throw, p trims stack trace, t is the test of resulting trace string + def probe(s: => String)(p: StackTraceElement => Boolean)(t: String => Unit): Unit = + import StackTraceOps.formatStackTracePrefix + Try(s).recover { case e => e.formatStackTracePrefix(p) } match + case Success(s) => t(s) + case Failure(e) => throw e + + @Test def showsAllTrace() = + probe(sampler)(_ => true)(s => assertTrue(s.linesIterator.length > 5)) + + // summary + one frame + elision + @Test def showsOnlyPrefix() = + probe(sample)(_.getMethodName == "sample")(s => assertEquals(3, s.linesIterator.length)) + + // summary + one frame + elision, caused by + one frame + elision + @Test def showsCause() = probe(resampler)(_.getMethodName != "resampler") { s => + val res = s.linesIterator.toList + assertEquals(6, res.length) + assertTrue(res.exists(_.startsWith(CausedBy))) + } + + // summary + one frame + elision times three + @Test def showsWrappedExceptions() = probe(rewrapperer)(_.getMethodName != "rewrapperer") { s => + val res = s.linesIterator.toList + assertEquals(9, res.length) + assertTrue(res.exists(_.startsWith(CausedBy))) + assertEquals(2, res.collect { case s if s.startsWith(CausedBy) => s }.size) + } + + // summary + one frame + elision times two with extra frame + @Test def dontBlowOnCycle() = probe(insaner)(_.getMethodName != "insaner") { s => + val res = s.linesIterator.toList + assertEquals(6, res.length) + assertTrue(res.exists(_.startsWith(CausedBy))) + } + + @Test def showsSuppressed() = probe(represser)(_.getMethodName != "represser") { s => + val res = s.linesIterator.toList + assertEquals(6, res.length) + assertTrue(res.exists(_.trim.startsWith(Suppressed))) + } +end StackTraceTest