Skip to content

RFC: structured rendering and switching to ammonite's color palette. #17624

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
8 changes: 4 additions & 4 deletions compiler/src/dotty/tools/repl/JLineTerminal.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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`
*
Expand Down
79 changes: 79 additions & 0 deletions compiler/src/dotty/tools/repl/PPrinter.scala
Original file line number Diff line number Diff line change
@@ -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
}

}
49 changes: 12 additions & 37 deletions compiler/src/dotty/tools/repl/Rendering.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,7 +21,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._

Expand All @@ -47,48 +50,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,
Expand Down Expand Up @@ -144,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(
Expand Down
3 changes: 1 addition & 2 deletions compiler/src/dotty/tools/repl/ReplDriver.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions dist/bin/common
Original file line number Diff line number Diff line change
Expand Up @@ -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*")
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions project/Build.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),

Expand Down