This is a port of Li Haoyi's excellent Scala pretty-printing library into Kotlin PPrint. (As well as Li Haoyi's excellent Ansi-Formatting library Fansi!)
PPrint for Kotlin is available in both JVM and Kotlin Multiplatform flavors. The JVM flavor uses kotlin-reflect
, the KMP flavor uses kotlinx-serialization
.
Add the following to your build.gradle.kts:
implementation("io.exoquery:pprint-kotlin:2.0.2")
// For Kotlin Multiplatform add serialization to your plugins:
// plugins {
// kotlin("plugin.serialization") version "1.9.22"
// }
// Then add the following to your dependencies
// implementation("io.exoquery:pprint-kotlin-kmp:2.0.2")
// implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.2")
Then use the library like this:
import io.exoquery.pprint
// For kotlin multiplatform use: import io.exoquery.kmp.pprint
data class Name(val first: String, val last: String)
data class Person(val name: Name, val age: Int)
val p = Person(Name("Joe", "Bloggs"), 42)
println(pprint(p))
It will print the following beautiful output:
PPrint-Kotlin supports most of the same features and options as the Scala version. I will document them here over time however for now please refer to the Scala documentation
- For PPrint here - https://github.com/com-lihaoyi/PPrint
- For Fansi here - https://github.com/com-lihaoyi/fansi
PPrint excels at printing nested data structures and complex collections. For example:
data class Address(val street: String, val zip: Int)
data class Customer(val name: Name, val addresses: List<Address>)
val p = Customer(Name("Joe", "Bloggs"), listOf(Address("foo", 123), Address("bar", 456), Address("baz", 789)))
println(pprint(p))
data class Alias(val value: String)
data class ComplexCustomer(val name: Name, val addressAliases: Map<Alias, Address>)
val p =
ComplexCustomer(
Name("Joe", "Bloggs"),
mapOf(Alias("Primary") to Address("foo", 123), Alias("Secondary") to Address("bar", 456), Alias("Tertiary") to Address("baz", 789))
)
println(pprint(p))
val p =
VeryComplexCustomer(
Name("Joe", "Bloggs"),
mapOf(
Alias("Primary") to
listOf(Address("foo", 123), Address("foo1", 123), Address("foo2", 123)),
Alias("Secondary") to
listOf(Address("bar", 456), Address("bar1", 456), Address("bar2", 456)),
Alias("Tertiary") to
listOf(Address("baz", 789), Address("baz1", 789), Address("baz2", 789))
)
)
println(pprint(p))
By default pprint will print the field names of data classes. You can remove these by using showFieldNames = false
:
val p = Person(Name("Joe", "Bloggs"), 42)
println(pprint(p, showFieldNames = false))
For larger ADTs this dramatically reduces the amount of output and often improves the readability.
Another nice feature of PPrint is that it can print data classes with a user-controlled width.
println(pprint(p, showFieldNames = false, defaultWidth = 30)) // Narrow
println(pprint(p, showFieldNames = false, defaultWidth = 100)) // Wide
Another very impressive ability of PPrint is that it can print infinite sequences, even if they are embedded other objects for example:
data class SequenceHolder(val seq: Sequence<String>)
var i = 0
val p = SequenceHolder(generateSequence { "foo-${i++}" })
println(pprint(p, defaultHeight = 10))
Note that in order to use Infinite sequences is Kotlin Multiplatform, you need to annotate the sequence-field using
@Serializable(with = PPrintSequenceSerializer::class)
for example:@Serializable data class SequenceHolder(@Serializable(with = PPrintSequenceSerializer::class) val seq: Sequence<String>) var i = 0 val p = SequenceHolder(generateSequence { "foo-${i++}" }) println(pprint(p, defaultHeight = 10))You should also be able to use the
@file:UseSerializers(PPrintSequenceSerializer::class)
to deliniate this for a entire file but this does not always work in practice. See the kotlinx-serialization documentation for Serializing 3rd Party Classes for more detail.
PPrint is able to print this infinite sequence without stack-overflowing or running out of memory
because it is highly lazy. It only evaluates the sequence as it is printing it,
and the printing is always constrained by the height and width of the output. You can
control these with the defaultHeight
and defaultWidth
parameters to the pprint
function.
Similar to infinite sequences, PPrint will print circular references up to the specified defaultHeight after which the output will be truncated.
data class Parent(var child: Child?)
data class Child(var parent: Parent?)
val child = Child(parent = null)
val parent = Parent(child = null)
child.parent = parent
parent.child = child
println(pprint(parent, defaultHeight = 10))
The output of the pprint function is not actually a java.lang.String, but a fansi.Str. This means you can control how it is printed. For example, to print it in black and white simple do:
import io.exoquery.pprint
val p = Person(Name("Joe", "Bloggs"), 42)
// Use Black & White Printing
println(pprint(p).plainText)
In order to extend pprint, subclass the PPrinter class and override the treeify
function.
For example:
class CustomPPrinter1(val config: PPrinterConfig) : PPrinter(config) {
override fun treeify(x: Any?, escapeUnicode: Boolean, showFieldNames: Boolean): Tree =
when (x) {
is java.time.LocalDate -> Tree.Literal(x.format(DateTimeFormatter.ofPattern("MM/dd/YYYY")))
else -> super.treeify(x, escapeUnicode, showFieldNames)
}
}
data class Person(val name: String, val born: LocalDate)
val pp = CustomPPrinter1(PPrinterConfig())
val joe = Person("Joe", LocalDate.of(1981, 1, 1))
println(pp.invoke(joe))
//> Person(name = "Joe", born = 01/01/1981)
This printer can then be used as the basis of a custom pprint
-like user defined function.
In Kotlin Multiplatform, the PPrinter is parametrized and takes an additional
SerializationStrategy<T>
parameter. You can extend it like this:class CustomPPrinter1<T>(override val serializer: SerializationStrategy<T>, override val config: PPrinterConfig) : PPrinter<T>(serializer, config) { // Overwrite `treeifyWith` instead of treeify override fun <R> treeifyWith(treeifyable: PPrinter.Treeifyable<R>, escapeUnicode: Boolean, showFieldNames: Boolean): Tree = when (val v = treeifyable.value) { is LocalDate -> Tree.Literal(v.format(DateTimeFormatter.ofPattern("MM/dd/YYYY"))) else -> super.treeifyWith(treeifyable, escapeUnicode, showFieldNames) } } // Define the class to serialize, it will not compile unless you add a @Contextual for the custom property @Serializeable data class Person(val name: String, @Contextual val born: LocalDate) val pp = CustomPPrinter1(Person.serializer(), PPrinterConfig()) val joe = Person("Joe", LocalDate.of(1981, 1, 1)) println(pp.invoke(joe))You can write a custom pprint-function based on this class like this:
inline fun <reified T> myPPrint(value: T) = CustomPPrinter1(serializer<T>(), PPrinterConfig()).invoke(value)
For nested objects use Tree.Apply and recursively call the treeify method.
// A class that wouldn't normally print the right thing with pprint...
class MyJavaBean(val a: String, val b: Int) {
fun getValueA() = a
fun getValueB() = b
}
// Create the custom printer
class CustomPPrinter2(val config: PPrinterConfig) : PPrinter(config) {
override fun treeify(x: Any?, esc: Boolean, names: Boolean): Tree =
when (x) {
// List through the properties of 'MyJavaBean' and recursively call treeify on them.
// (Note that Tree.Apply takes an iterator of properties so that the interface is lazy)
is MyJavaBean -> Tree.Apply("MyJavaBean", listOf(x.getValueA(), x.getValueB()).map { treeify(it, esc, names) }.iterator())
else -> super.treeify(x, esc, names)
}
}
val bean = MyJavaBean("abc", 123)
val pp = CustomPPrinter2(PPrinterConfig())
println(pp.invoke(bean))
//> MyJavaBean("abc", 123)
To print field-names you use Tree.KeyValue:
class CustomPPrinter3(val config: PPrinterConfig) : PPrinter(config) {
override fun treeify(x: Any?, escapeUnicode: Boolean, showFieldNames: Boolean): Tree {
// function to make recursive calls shorter
fun rec(x: Any?) = treeify(x, escapeUnicode, showFieldNames)
return when (x) {
// Recurse on the values, pass result into Tree.KeyValue.
is MyJavaBean ->
Tree.Apply(
"MyJavaBean",
listOf(Tree.KeyValue("a", rec(x.getValueA())), Tree.KeyValue("b", rec(x.getValueB()))).iterator()
)
else ->
super.treeify(x, esc, names)
}
}
}
val bean = MyJavaBean("abc", 123)
val pp = CustomPPrinter2(PPrinterConfig())
println(pp.invoke(bean))
//> MyJavaBean(a = "abc", b = 123)
Often it is a good idea to honor the showFieldNames
parameter only display key-values if it is enabled:
class CustomPPrinter4(val config: PPrinterConfig) : PPrinter(config) {
override fun treeify(x: Any?, escapeUnicode: Boolean, showFieldNames: Boolean): Tree {
// function to make recursive calls shorter
fun rec(x: Any?) = treeify(x, escapeUnicode, showFieldNames)
fun field(fieldName: String, value: Any?) =
if (showFieldNames) Tree.KeyValue(fieldName, rec(value)) else rec(value)
return when (x) {
// Recurse on the values, pass result into Tree.KeyValue.
is MyJavaBean ->
Tree.Apply("MyJavaBean", listOf(field("a", x.getValueA()), field("b", x.getValueB())).iterator())
else ->
super.treeify(x, escapeUnicode, showFieldNames)
}
}
}
val bean = MyJavaBean("abc", 123)
println(CustomPPrinter4(PPrinterConfig()).invoke(bean))
//> MyJavaBean(a = "abc", b = 123)
println(CustomPPrinter4(PPrinterConfig(defaultShowFieldNames = false)).invoke(bean))
//> MyJavaBean("abc", 123)
The JVM-based PPrint relies on the kotlin-reflect
library in order to recurse on the fields in a data class.
For PPrint-KMP, this is done by the kotlinx-serialization
library. Therefore you need the kotlinx-serialization
runtime as well as the compiler-plugin in order to use PPrint Multiplatform. The former should be pulled in
automatically when you import pprint-kotlin-kmp
:
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization") version "1.9.22"
}
...
kotlin {
sourceSets {
commonMain {
dependencies {
implementation("io.exoquery:pprint-kotlin-kmp:2.0.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.2")
}
}
}
...
}
Since Kotlin Multiplatform relies on the @Serialization
(and related) annotations in order to deliniate a
class as serializable, you will need to use the @Serializable
annotation on your data classes. For example:
@Serializable
data class Name(val first: String, val last: String)
@Serializable
data class Person(val name: Name, val age: Int)
val p = Person(Name("Joe", "Bloggs"), 42)
pprint(p)
//> Person(name = Name(first = "Joe", last = "Bloggs"), age = 123)
In some cases (i.e. custom fields) you will need to use the @Contextual annotation to deliniate a field as custom. See the note about LocalDate in the Extending PPrint in Kotlin Multiplatform section for more detail.
When using sequences, you will need to annotate the
sequence-field using @Serializable(with = PPrintSequenceSerializer::class)
.
See the note in the Infinite Sequences in Kotlin Multiplatform section for more detail.
According to the kotlinx-serialization
documentation, every member of a sealed hierarchy must be annotated with @Serializable
.
For example, in the following hierarchy:
@Serializable
sealed interface Colors {
@Serializable object Red : Colors
@Serializable object Green : Colors
@Serializable object Blue : Colors
@Serializable data class Custom(val value: String) : Colors
}
Every member is annotated with @Serializable
.
This requirement extends to PPrint-Multiplatform as well since it relies on kotlinx-serialization
to traverse the hierarchy.
In general whenever you have a atom-property i.e. something not generic you can just mark the field as @Contextual
so long as there is a specific case defined for it in treeifyWith
. However if you are using a type such as
a collection that has a generic element requring its own serializer, you will need to use the
@Serializable(with = CustomSerializer::class)
syntax and define a CustomSerializer
for the type.
What is important to note is that CustomSerializer
does not actually need a serialization implementation,
you it is just needed in order to be able to carry around the serializer for the generic type. For example,
the serializer for Sequence
is defined as:
class PPrintSequenceSerializer<T>(val element: KSerializer<T>) : KSerializer<Sequence<T>> {
override val descriptor: SerialDescriptor = element.descriptor
override fun serialize(encoder: Encoder, value: Sequence<T>) = throw IllegalStateException("...")
override fun deserialize(decoder: Decoder) = throw IllegalStateException("...")
}
(Note that a real user-defined serialzier for Sequence
will work as well.)
The actual handling of sequence printing is done in the treeifyWith
method (roughly) like this:
open fun <R> treeifyWith(treeifyable: Treeifyable<R>, escapeUnicode: Boolean, showFieldNames: Boolean): Tree =
when {
treeifyable is Sequence<*> && treeifyable is Treeifyable.Elem && treeifyable.serializer is PPrintSequenceSerializer<*> -> {
@Suppress("UNCHECKED_CAST")
val elementSerializer = treeifyable.serializer.element as KSerializer<Any?>
Tree.Apply("Sequence", value.map { treeifyWith(Treeifyable.Elem(it, elementSerializer), escapeUnicode, showFieldNames) }.iterator())
}
else -> super.treeifyWith(treeifyable, escapeUnicode, showFieldNames)
}
You can follow this pattern to define PPrintable serializers for other generic types.
Due to issues in kotlinx-serialization like #1341 there are cases where kotlinx-serialization will not be able to serialize a generic ADT (GADT). This is inherently a problem for PPrint-KMP since it relies on kotlinx-serialization to traverse the ADT. In general, if you are having trouble with a GADT, may need to define a custom serializer.
For example if you attempt to fully-type a partially-typed GADT element with a collection-type and then widen it to the GADT-root type you'll get some serious problems:
@Serializable
sealed interface Root<A, B>
@Serializable
data class Parent<A, B>(val child: Root<A, B>): Root<A, B>
@Serializable
data class PartiallyTyped<A>(val value: A): Root<A, String>
fun gadt() {
val value = Parent(PartiallyTyped(listOf(1,2,3)))
println(pprint(value))
// ========= Boom! =========
// Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for subclass 'ArrayList' is not found in the polymorphic scope of 'Any'.
}
I've made some comments on this issue here.
PPrint is powered by Fansi. It relies on this amazing library in order to be able to print out ansi-colored strings.
NOTE. Most of this is taken from the original Fansi documentation here
Fansi is a Kotlin library (ported from Scala) that was designed make it easy to deal with fancy colored Ansi strings within your command-line programs.
While "normal" use of Ansi escapes with java.lang.String
, you find yourself
concatenating colors:
val colored = Console.RED + "Hello World Ansi!" + Console.RESET
To build your colored string. This works the first time, but is error prone
on larger strings: e.g. did you remember to put a Console.RESET
where it's
necessary? Do you need to end with one to avoid leaking the color to the entire
console after printing it?
Furthermore, some operations are fundamentally difficult or error-prone with this approach. For example,
val colored: String = Console.RED + "Hello World Ansi!" + Console.RESET
// How to efficiently get the length of this string on-screen? We could try
// using regexes to remove and Ansi codes, but that's slow and inefficient.
// And it's easy to accidentally call `colored.length` and get a invalid length
val length = ???
// How to make the word `World` blue, while preserving the coloring of the
// `Ansi!` text after? What if the string came from somewhere else and you
// don't know what color that text was originally?
val blueWorld = ???
// What if I want to underline "World" instead of changing it's color, while
// still preserving the original color?
val underlinedWorld = ???
// What if I want to apply underlines to "World" and the two characters on
// either side, after I had already turned "World" blue?
val underlinedBlue = ???
While simple to describe, these tasks are all error-prone and difficult to
do using normal java.lang.String
s containing Ansi color codes. This is
especially so if, unlike the toy example above, colored
is coming from some
other part of your program and you're not sure what or how-many Ansi color
codes it already contains.
With Fansi, doing all these tasks is simple, error-proof and efficient:
val colored: fansi.Str = fansi.Color.Red("Hello World Ansi!")
// Or fansi.Str("Hello World Ansi!").overlay(fansi.Color.Red)
val length = colored.length // Fast and returns the non-colored length of string
val blueWorld = colored.overlay(fansi.Color.Blue, 6, 11)
val underlinedWorld = colored.overlay(fansi.Underlined.On, 6, 11)
val underlinedBlue = blueWorld.overlay(fansi.Underlined.On, 4, 13)
And it just works:
Unlike normal java.lang.String
s with Ansi escapes embedded inside,
fansi.Str
allows you to perform a range of operations in an efficient
manner:
-
Extracting the non-Ansi
plainText
version of the string -
Get the non-Ansi
length
-
Concatenate colored Ansi strings without worrying about leaking colors between them
-
Applying colors to certain portions of an existing
fansi.Str
, and ensuring that the newly-applied colors get properly terminated while existing colors are unchanged -
Splitting colored Ansi strings at a
plainText
index -
Rendering to colored
java.lang.String
s with Ansi escapes embedded, which can be passed around or concatenated without worrying about leaking colors.
These are tasks which are possible to do with normal java.lang.String
,
but are tedious, error-prone and typically inefficient. Often, you can get
by with adding copious amounts of Console.RESET
s when working with colored
java.lang.String
s, but even that easily results in errors when you RESET
too much and stomp over colors that already exist:
fansi.Str
allows you to perform these tasks safely and easily:
Fansi is also very efficient: fansi.Str
uses just 3x as much memory as
java.lang.String
to hold all the additional formatting information.
Note this was the case in Scala, I am not certain if the same is true in Kotlin.
Its operations are probably about the same factor slower, as they are all
implemented using fast arraycopy
s and while-loops similar to
java.lang.String
. That means that - unlike fiddling with Ansi-codes using
regexes - you generally do not need to worry about performance when dealing with
fansi.Str
s. Just treat them as you would java.lang.String
s: splitting them,
substring
ing them, and applying or removing colors or other styles at-will.
The main operations you need to know are:
-
Str(raw: CharSequence): fansi.String
, to construct colored Ansi strings from a
java.lang.String`, with or without existing Ansi color codes inside it. -
Str
, the primary data-type that you will use to pass-around colored Ansi strings and manipulate them: concatenating, splitting, applying or removing colors, etc.
fansi.Attr
s are the individual modifications you can make to anfansi.Str
's formatting. Examples are:fansi.Bold.{On, Off}
fansi.Reversed.{On, Off}
fansi.Underlined.{On, Off}
fansi.Color.*
fansi.Back.*
fansi.Attr.Reset
fansi.Attrs
represents a group of zero or morefansi.Attr
s. These that can be passed around together, combined via++
or applied tofansi.Str
s all at once. Any individualfansi.Attr
can be used whenfansi.Attrs
is required, as canfansi.Attrs.empty
.
-
Using any of the
fansi.Attr
orfansi.Attrs
mentioned above, e.g.fansi.Color.Red
, usingfansi.Color.Red("hello world ansi!")
to create afansi.Str
with that text and color, orfansi.Str("hello world ansi!").overlay(fansi.Color.Blue, 6, 11)
-
.render
to convert afansi.Str
back into ajava.lang.String
with all necessary Ansi color codes within it
Fansi also supports 8-bit 256-colors through fansi.Color.Full
and
fansi.Back.Full
, as well as 24-bit 16-million-colors through
fansi.Color.True
and fansi.Back.True
:
Note that Fansi only performs the rendering of the colors to an ANSI-encoded string. Final rendering will depend on whichever terminal you print the string to, whether it is able to display these sets of colors or not.
Thanks so much to Li Haoyi for building Fansi and PPrint!!