From c8fe6d407f82cd4c9d86fe69f89eba7bd1da583d Mon Sep 17 00:00:00 2001 From: Michael Pollmeier Date: Tue, 30 May 2023 10:10:39 +0200 Subject: [PATCH 1/5] bring pprint into dependency tree * pprint dependencyTree: 3 jars weighing 360k in total * pprint 0.8.1: 150k * fansi 0.4.0: 102k * sourcecode 0.3.0: 108k * if it's too large, workaround could be to allow injecting something by configuration - it's using reflection anyway... * might make sense to factor repl out into a separate subproject... jline is probably also only needed for the repl, no? --- dist/bin/common | 8 ++++++++ project/Build.scala | 1 + 2 files changed, 9 insertions(+) diff --git a/dist/bin/common b/dist/bin/common index e3e4253938fb..60e681efe213 100755 --- a/dist/bin/common +++ b/dist/bin/common @@ -159,6 +159,9 @@ SBT_INTF=$(find_lib "*compiler-interface*") JLINE_READER=$(find_lib "*jline-reader-3*") JLINE_TERMINAL=$(find_lib "*jline-terminal-3*") JLINE_TERMINAL_JNA=$(find_lib "*jline-terminal-jna-3*") +PPRINT=$(find_lib "*pprint*") +FANSI=$(find_lib "*fansi*") +SOURCECODE=$(find_lib "*sourcecode*") # jna-5 only appropriate for some combinations [[ ${conemu-} && ${msys-} ]] || JNA=$(find_lib "*jna-5*") @@ -189,6 +192,11 @@ compilerJavaClasspathArgs () { toolchain+="$JLINE_TERMINAL_JNA$PSEP" [ -n "${JNA-}" ] && toolchain+="$JNA$PSEP" + # pprint + toolchain+="$PPRINT$PSEP" + toolchain+="$FANSI$PSEP" + toolchain+="$SOURCECODE$PSEP" + if [ -n "${jvm_cp_args-}" ]; then jvm_cp_args="$toolchain$jvm_cp_args" else diff --git a/project/Build.scala b/project/Build.scala index 892a98b2852f..39e59044a5f4 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -552,6 +552,7 @@ object Build { "org.jline" % "jline-reader" % "3.19.0", // used by the REPL "org.jline" % "jline-terminal" % "3.19.0", "org.jline" % "jline-terminal-jna" % "3.19.0", // needed for Windows + "com.lihaoyi" %% "pprint" % "0.8.1", // pretty printing in REPL ("io.get-coursier" %% "coursier" % "2.0.16" % Test).cross(CrossVersion.for3Use2_13), ), From 5eaa4ef79d61d0b5f01d5e7fe606252c0fa0b18a Mon Sep 17 00:00:00 2001 From: Michael Pollmeier Date: Tue, 30 May 2023 17:33:49 +0200 Subject: [PATCH 2/5] bring over and wire up pprinter for output rendering --- compiler/src/dotty/tools/repl/PPrinter.scala | 79 +++++++++++++++++++ compiler/src/dotty/tools/repl/Rendering.scala | 45 +++-------- .../src/dotty/tools/repl/ReplDriver.scala | 3 +- 3 files changed, 89 insertions(+), 38 deletions(-) create mode 100644 compiler/src/dotty/tools/repl/PPrinter.scala diff --git a/compiler/src/dotty/tools/repl/PPrinter.scala b/compiler/src/dotty/tools/repl/PPrinter.scala new file mode 100644 index 000000000000..b498c0938b60 --- /dev/null +++ b/compiler/src/dotty/tools/repl/PPrinter.scala @@ -0,0 +1,79 @@ +package dotty.tools.repl + +import pprint.{Renderer, Result, Tree, Truncated} +import scala.util.matching.Regex + +/** Wraps pprint with a minor fix for fansi encodings - TODO report upstream to get those fixed. + * https://github.com/com-lihaoyi/PPrint + */ +object PPrinter { + // cached instance to avoid reinstantiation for each invocation + private var pprinter: pprint.PPrinter | Null = null + private var maxHeight: Int = Int.MaxValue + private var nocolors: Boolean = false + + def apply(objectToRender: Object, maxHeight: Int = Int.MaxValue, nocolors: Boolean = false): String = { + val _pprinter = this.synchronized { + // initialise on first use and whenever the maxHeight setting changed + if (pprinter == null || this.maxHeight != maxHeight || this.nocolors != nocolors) { + this.pprinter = create(maxHeight, nocolors) + this.maxHeight = maxHeight + this.nocolors = nocolors + } + this.pprinter.nn + } + _pprinter.apply(objectToRender).render + } + + private def create(maxHeight: Int, nocolors: Boolean): pprint.PPrinter = { + val (colorLiteral, colorApplyPrefix) = + if (nocolors) (fansi.Attrs.Empty, fansi.Attrs.Empty) + else (fansi.Color.Green, fansi.Color.Yellow) + + new pprint.PPrinter( + defaultHeight = maxHeight, + colorLiteral = colorLiteral, + colorApplyPrefix = colorApplyPrefix) { + + override def tokenize(x: Any, + width: Int = defaultWidth, + height: Int = defaultHeight, + indent: Int = defaultIndent, + initialOffset: Int = 0, + escapeUnicode: Boolean, + showFieldNames: Boolean): Iterator[fansi.Str] = { + val tree = this.treeify(x, escapeUnicode = escapeUnicode, showFieldNames = showFieldNames) + val renderer = new Renderer(width, this.colorApplyPrefix, this.colorLiteral, indent) { + override def rec(x: Tree, leftOffset: Int, indentCount: Int): Result = x match { + case Tree.Literal(body) if isAnsiEncoded(body) => + // this is the part we're overriding, everything else is just boilerplate + Result.fromString(fixForFansi(body)) + case _ => super.rec(x, leftOffset, indentCount) + } + } + val rendered = renderer.rec(tree, initialOffset, 0).iter + new Truncated(rendered, width, height) + } + } + } + + def isAnsiEncoded(string: String): Boolean = + string.exists(c => c == '\u001b' || c == '\u009b') + + /** We use source-highlight to encode source as ansi strings, e.g. the .dump step Ammonite uses fansi for it's + * colour-coding, and while both pledge to follow the ansi codec, they aren't compatible TODO: PR for fansi to + * support these standard encodings out of the box + */ + def fixForFansi(ansiEncoded: String): String = { + import scala.language.unsafeNulls + ansiEncoded + .replaceAll("\u001b\\[m", "\u001b[39m") // encoding ends with [39m for fansi instead of [m + .replaceAll("\u001b\\[0(\\d)m", "\u001b[$1m") // `[01m` is encoded as `[1m` in fansi for all single digit numbers + .replaceAll("\u001b\\[0?(\\d+);0?(\\d+)m", "\u001b[$1m\u001b[$2m") // `[01;34m` is encoded as `[1m[34m` in fansi + .replaceAll( + "\u001b\\[[00]+;0?(\\d+);0?(\\d+);0?(\\d+)m", + "\u001b[$1;$2;$3m" + ) // `[00;38;05;70m` is encoded as `[38;5;70m` in fansi - 8bit color encoding + } + +} diff --git a/compiler/src/dotty/tools/repl/Rendering.scala b/compiler/src/dotty/tools/repl/Rendering.scala index c647ef302bb9..1b52349b5e8d 100644 --- a/compiler/src/dotty/tools/repl/Rendering.scala +++ b/compiler/src/dotty/tools/repl/Rendering.scala @@ -20,7 +20,9 @@ import scala.util.control.NonFatal * `ReplDriver#resetToInitial` is called, the accompanying instance of * `Rendering` is no longer valid. */ -private[repl] class Rendering(parentClassLoader: Option[ClassLoader] = None): +private[repl] class Rendering(parentClassLoader: Option[ClassLoader] = None, + maxHeight: Option[Int] = None, + nocolors: Boolean = false): import Rendering._ @@ -47,48 +49,19 @@ private[repl] class Rendering(parentClassLoader: Option[ClassLoader] = None): myClassLoader = new AbstractFileClassLoader(ctx.settings.outputDir.value, parent) myReplStringOf = { - // We need to use the ScalaRunTime class coming from the scala-library + // We need to use the PPrinter class coming from the scala-library // on the user classpath, and not the one available in the current // classloader, so we use reflection instead of simply calling - // `ScalaRunTime.replStringOf`. Probe for new API without extraneous newlines. - // For old API, try to clean up extraneous newlines by stripping suffix and maybe prefix newline. - val scalaRuntime = Class.forName("scala.runtime.ScalaRunTime", true, myClassLoader) - val renderer = "stringOf" - def stringOfMaybeTruncated(value: Object, maxElements: Int): String = { - try { - val meth = scalaRuntime.getMethod(renderer, classOf[Object], classOf[Int], classOf[Boolean]) - val truly = java.lang.Boolean.TRUE - meth.invoke(null, value, maxElements, truly).asInstanceOf[String] - } catch { - case _: NoSuchMethodException => - val meth = scalaRuntime.getMethod(renderer, classOf[Object], classOf[Int]) - meth.invoke(null, value, maxElements).asInstanceOf[String] - } + // `dotty.tools.repl.PPrinter:apply`. + val pprinter = Class.forName("dotty.tools.repl.PPrinter", true, myClassLoader) + val renderingMethod = pprinter.getMethod("apply", classOf[Object], classOf[Int], classOf[Boolean]) + (objectToRender: Object, maxElements: Int, maxCharacters: Int) => { + renderingMethod.invoke(null, objectToRender, maxHeight.getOrElse(Int.MaxValue), nocolors).asInstanceOf[String] } - - (value: Object, maxElements: Int, maxCharacters: Int) => { - // `ScalaRuntime.stringOf` may truncate the output, in which case we want to indicate that fact to the user - // In order to figure out if it did get truncated, we invoke it twice - once with the `maxElements` that we - // want to print, and once without a limit. If the first is shorter, truncation did occur. - val notTruncated = stringOfMaybeTruncated(value, Int.MaxValue) - val maybeTruncatedByElementCount = stringOfMaybeTruncated(value, maxElements) - val maybeTruncated = truncate(maybeTruncatedByElementCount, maxCharacters) - - // our string representation may have been truncated by element and/or character count - // if so, append an info string - but only once - if (notTruncated.length == maybeTruncated.length) maybeTruncated - else s"$maybeTruncated ... large output truncated, print value to show all" - } - } myClassLoader } - private[repl] def truncate(str: String, maxPrintCharacters: Int)(using ctx: Context): String = - val ncp = str.codePointCount(0, str.length) // to not cut inside code point - if ncp <= maxPrintCharacters then str - else str.substring(0, str.offsetByCodePoints(0, maxPrintCharacters - 1)) - /** Return a String representation of a value we got from `classLoader()`. */ private[repl] def replStringOf(value: Object)(using Context): String = assert(myReplStringOf != null, diff --git a/compiler/src/dotty/tools/repl/ReplDriver.scala b/compiler/src/dotty/tools/repl/ReplDriver.scala index 905f4f06de08..2c4d27d909b4 100644 --- a/compiler/src/dotty/tools/repl/ReplDriver.scala +++ b/compiler/src/dotty/tools/repl/ReplDriver.scala @@ -427,8 +427,7 @@ class ReplDriver(settings: Array[String], val formattedTypeDefs = // don't render type defs if wrapper initialization failed if newState.invalidObjectIndexes.contains(state.objectIndex) then Seq.empty else typeDefs(wrapperModule.symbol) - val highlighted = (formattedTypeDefs ++ formattedMembers) - .map(d => new Diagnostic(d.msg.mapMsg(SyntaxHighlighting.highlight), d.pos, d.level)) + val highlighted = (formattedTypeDefs ++ formattedMembers).map(d => new Diagnostic(d.msg, d.pos, d.level)) (newState, highlighted) } .getOrElse { From 47107c48940fad826c90d43fd458466cbcbfbca2 Mon Sep 17 00:00:00 2001 From: Michael Pollmeier Date: Tue, 30 May 2023 17:35:44 +0200 Subject: [PATCH 3/5] color highlight the identifier part of the output e.g. `val res0: Int = 23` --- compiler/src/dotty/tools/repl/Rendering.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compiler/src/dotty/tools/repl/Rendering.scala b/compiler/src/dotty/tools/repl/Rendering.scala index 1b52349b5e8d..6acafadccf0b 100644 --- a/compiler/src/dotty/tools/repl/Rendering.scala +++ b/compiler/src/dotty/tools/repl/Rendering.scala @@ -4,6 +4,7 @@ package repl import scala.language.unsafeNulls import dotc.*, core.* +import printing.SyntaxHighlighting import Contexts.*, Denotations.*, Flags.*, NameOps.*, StdNames.*, Symbols.* import printing.ReplPrinter import reporting.Diagnostic @@ -117,7 +118,8 @@ private[repl] class Rendering(parentClassLoader: Option[ClassLoader] = None, /** Render value definition result */ def renderVal(d: Denotation)(using Context): Either[ReflectiveOperationException, Option[Diagnostic]] = - val dcl = d.symbol.showUser + val dcl = SyntaxHighlighting.highlight(d.symbol.showUser) + def msg(s: String) = infoDiagnostic(s, d) try Right( From 501d9d2f8fe267d8da0b6bca70fe1b94c3497da0 Mon Sep 17 00:00:00 2001 From: Michael Pollmeier Date: Tue, 30 May 2023 17:40:01 +0200 Subject: [PATCH 4/5] change prompt color to magenta, just like ammonite --- compiler/src/dotty/tools/repl/JLineTerminal.scala | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/compiler/src/dotty/tools/repl/JLineTerminal.scala b/compiler/src/dotty/tools/repl/JLineTerminal.scala index 8e048d786ae1..6cd22bf1012a 100644 --- a/compiler/src/dotty/tools/repl/JLineTerminal.scala +++ b/compiler/src/dotty/tools/repl/JLineTerminal.scala @@ -27,12 +27,12 @@ class JLineTerminal extends java.io.Closeable { private val history = new DefaultHistory def dumbTerminal = Option(System.getenv("TERM")) == Some("dumb") - private def blue(str: String)(using Context) = - if (ctx.settings.color.value != "never") Console.BLUE + str + Console.RESET + private def promptColor(str: String)(using Context) = + if (ctx.settings.color.value != "never") Console.MAGENTA + str + Console.RESET else str protected def promptStr = "scala" - private def prompt(using Context) = blue(s"\n$promptStr> ") - private def newLinePrompt(using Context) = blue(" | ") + private def prompt(using Context) = promptColor(s"\n$promptStr> ") + private def newLinePrompt(using Context) = promptColor(" | ") /** Blockingly read line from `System.in` * From 21dd38b4dcf9100bae93accc980d776671accc79 Mon Sep 17 00:00:00 2001 From: Michael Pollmeier Date: Tue, 30 May 2023 17:42:24 +0200 Subject: [PATCH 5/5] input highlighting: use same colors as in ammonite --- .../src/dotty/tools/dotc/printing/SyntaxHighlighting.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/printing/SyntaxHighlighting.scala b/compiler/src/dotty/tools/dotc/printing/SyntaxHighlighting.scala index 53e6b9472f5e..03df9f787c6d 100644 --- a/compiler/src/dotty/tools/dotc/printing/SyntaxHighlighting.scala +++ b/compiler/src/dotty/tools/dotc/printing/SyntaxHighlighting.scala @@ -26,9 +26,9 @@ object SyntaxHighlighting { val CommentColor: String = Console.BLUE val KeywordColor: String = Console.YELLOW val ValDefColor: String = Console.CYAN - val LiteralColor: String = Console.RED + val LiteralColor: String = Console.GREEN val StringColor: String = Console.GREEN - val TypeColor: String = Console.MAGENTA + val TypeColor: String = Console.GREEN val AnnotationColor: String = Console.MAGENTA def highlight(in: String)(using Context): String = {