Skip to content

Latest commit

 

History

History
601 lines (469 loc) · 23.8 KB

README.md

File metadata and controls

601 lines (469 loc) · 23.8 KB

PPrint for Kotlin

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!)

Usage

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

Nested Data and Complex Collections

PPrint excels at printing nested data structures and complex collections. For example:

Lists embedded in objects:

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))

Maps embedded in objects:

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))

Lists embedded in maps embedded in objects:

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))

Removing Field Names

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.

User-controlled Width

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

Infinite Sequences

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))

Infinite Sequences in Kotlin Multiplatform

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.

Circular References

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))

Black & White Printing

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)

Extending PPrint

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.

Extending PPrint in Kotlin Multiplatform

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)

PPrint with Kotlin Multiplatform (KMP)

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.

Sealed Hierarchies in KMP

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.

How do deal with Custom Fields in KMP

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.

General Note on Generic ADTs and KMP

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.

Fansi for Kotlin

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.Strings 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:

image

Why Fansi?

Unlike normal java.lang.Strings 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.Strings 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.RESETs when working with colored java.lang.Strings, but even that easily results in errors when you RESET too much and stomp over colors that already exist:

image

fansi.Str allows you to perform these tasks safely and easily:

image

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 arraycopys 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.Strs. Just treat them as you would java.lang.Strings: splitting them, substringing them, and applying or removing colors or other styles at-will.

Using Fansi

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.

image

  • fansi.Attrs are the individual modifications you can make to an fansi.Str's formatting. Examples are:
    • fansi.Bold.{On, Off}
    • fansi.Reversed.{On, Off}
    • fansi.Underlined.{On, Off}
    • fansi.Color.*
    • fansi.Back.*
    • fansi.Attr.Reset

image

  • fansi.Attrs represents a group of zero or more fansi.Attrs. These that can be passed around together, combined via ++ or applied to fansi.Strs all at once. Any individual fansi.Attr can be used when fansi.Attrs is required, as can fansi.Attrs.empty.

image

  • Using any of the fansi.Attr or fansi.Attrs mentioned above, e.g. fansi.Color.Red, using fansi.Color.Red("hello world ansi!") to create a fansi.Str with that text and color, or fansi.Str("hello world ansi!").overlay(fansi.Color.Blue, 6, 11)

  • .render to convert a fansi.Str back into a java.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:

image

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!!