Skip to content

Commit

Permalink
Add renderer for JSON
Browse files Browse the repository at this point in the history
  • Loading branch information
satabin committed Jan 30, 2024
1 parent ca7fb10 commit 49da144
Show file tree
Hide file tree
Showing 5 changed files with 383 additions and 24 deletions.
24 changes: 22 additions & 2 deletions json/src/main/scala/fs2/data/json/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import json.ast._
import json.internals._

import cats._
import fs2.data.text.render.Renderable

/** Handles stream parsing and traversing of json documents.
*/
Expand Down Expand Up @@ -153,7 +154,7 @@ package object json {
* You can use this to write the Json stream to a file.
*/
def compact[F[_]]: Pipe[F, Token, String] =
Renderer.pipe[F](false, "")
_.through(fs2.data.text.render.pretty(width = Int.MaxValue)(Token.compact))

/** Renders a pretty-printed representation of the token stream with the given
* indentation size.
Expand All @@ -163,8 +164,25 @@ package object json {
*
* You can use this to write the Json stream to a file.
*/
@deprecated(since = "fs2-data 1.11.0", message = "Consider using `fs2.data.json.render.prettyPrint` instead.")
def pretty[F[_]](indent: String = " "): Pipe[F, Token, String] =
Renderer.pipe[F](true, indent)
_.through(fs2.data.text.render.pretty(width = 0)(Token.compact))

/** Renders a pretty-printed representation of the token stream with the given
* indentation size and page width.
*
* Chunks can be concatenated to render all values in the stream,
* separated by new lines.
*
* You can use this to write the Json stream to a file.
*
* You can configure how the stream is rendered by providing an instance of
* [[fs2.data.text.render.Renderable Renderable]] in scope. A default one is automatically
* provided if you do not need specific formatting.
*/
def prettyPrint[F[_]](width: Int = 100, indent: Int = 2)(implicit
renderable: Renderable[Token]): Pipe[F, Token, String] =
_.through(fs2.data.text.render.pretty(width = width, indent = indent))

}

Expand All @@ -187,6 +205,8 @@ package object json {
*
* Top-level values are separated by new lines.
*/
@deprecated(since = "fs2-data 1.11.0",
message = "Consider using `.through(fs2.data.json.render.prettyPrint()).compile.string` instead.")
def pretty(indent: String = " "): Collector.Aux[Token, String] =
new Collector[Token] {
type Out = String
Expand Down
300 changes: 299 additions & 1 deletion json/src/main/scala/fs2/data/json/tokens.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,16 @@ package fs2
package data
package json

import fs2.Pure
import fs2.data.text.render.{DocEvent, Renderable, Renderer}

import scala.annotation.switch
import scala.annotation.tailrec

sealed abstract class Token(val kind: String) {
def jsonRepr: String
}

object Token {

case object StartObject extends Token("object") {
Expand Down Expand Up @@ -55,7 +62,298 @@ object Token {
def jsonRepr: String = value
}
case class StringValue(value: String) extends Token("string") {
def jsonRepr: String = s""""$value""""
def jsonRepr: String = {
val rendered = new StringBuilder
rendered.append('"')
renderString(value, 0, rendered)
rendered.append('"')
rendered.result()
}
}

private final val hex = "0123456789abcdef"

@tailrec
def renderString(s: String, idx: Int, builder: StringBuilder): Unit =
if (idx < s.length) {
val nextEscape = s.indexWhere(c => c > 127 || Character.isISOControl(c) || "\\/\b\f\n\r\t\"".contains(c), idx)
if (nextEscape >= 0) {
if (nextEscape > 0) {
builder.append(s.substring(idx, nextEscape))
}
val c = s(nextEscape)
(c: @switch) match {
case '\\' =>
builder.append("\\\\")
case '/' =>
builder.append("\\/")
case '\b' =>
builder.append("\\b")
case '\f' =>
builder.append("\\f")
case '\n' =>
builder.append("\\n")
case '\r' =>
builder.append("\\r")
case '\t' =>
builder.append("\\t")
case '"' =>
builder.append("\\\"")
case _ =>
// escape non ascii or control characters
builder
.append("\\u")
.append(hex((c >> 12) & 0x0f))
.append(hex((c >> 8) & 0x0f))
.append(hex((c >> 4) & 0x0f))
.append(hex(c & 0x0f))
}
renderString(s, nextEscape + 1, builder)
} else {
// append the rest of the string and we are done
builder.append(s.substring(idx))
}
}

private val nullValue =
Stream.emit(DocEvent.Text("null"))

private val trueValue =
Stream.emit(DocEvent.Text("true"))

private val falseValue =
Stream.emit(DocEvent.Text("false"))

private final val FirstObjectKey = 0
private final val ObjectKey = 1
private final val ObjectValue = 2
private final val FirstArrayValue = 3
private final val ArrayValue = 4

implicit object renderable extends Renderable[Token] {

private val startObject =
Stream.emits(DocEvent.GroupBegin :: DocEvent.Text("{") :: DocEvent.IndentBegin :: DocEvent.LineBreak :: Nil)

private val endEmptyObject =
Stream.emits(DocEvent.IndentEnd :: DocEvent.LineBreak :: DocEvent.Text("}") :: DocEvent.GroupEnd :: Nil)

private val endObject =
Stream.emits(DocEvent.GroupEnd :: Nil) ++ endEmptyObject

private val startArray =
Stream.emits(DocEvent.GroupBegin :: DocEvent.Text("[") :: DocEvent.IndentBegin :: DocEvent.LineBreak :: Nil)

private val endEmptyArray =
Stream.emits(DocEvent.IndentEnd :: DocEvent.LineBreak :: DocEvent.Text("]") :: DocEvent.GroupEnd :: Nil)

private val endArray =
Stream.emit(DocEvent.GroupEnd) ++ endEmptyArray

private val objectSep =
Stream.emits(DocEvent.Text(",") :: DocEvent.GroupEnd :: DocEvent.Line :: Nil)

private val arraySep =
Stream.emits(DocEvent.Text(",") :: DocEvent.GroupEnd :: DocEvent.Line :: DocEvent.GroupBegin :: Nil)

override def newRenderer(): Renderer[Token] = new Renderer[Token] {

// the current stack of states, helping to deal with comma and indentation
// states are described right above
private[this] var states = List.empty[Int]

private def separator(): Stream[Pure, DocEvent] =
states match {
case Nil =>
Stream.empty
case state :: rest =>
(state: @switch) match {
case FirstObjectKey =>
states = ObjectValue :: rest
Stream.empty
case ObjectKey =>
states = ObjectValue :: rest
objectSep
case ObjectValue =>
states = ObjectKey :: rest
Stream.emit(DocEvent.GroupBegin)
case FirstArrayValue =>
states = ArrayValue :: rest
Stream.emit(DocEvent.GroupBegin)
case ArrayValue =>
states = ArrayValue :: rest
arraySep
}
}

private def closeObject(): Stream[Pure, DocEvent] =
states match {
case Nil => endEmptyObject
case state :: rest =>
states = rest
(state: @switch) match {
case FirstObjectKey => endEmptyObject
case ObjectKey => endObject
}
}

private def closeArray(): Stream[Pure, DocEvent] =
states match {
case Nil => endEmptyArray
case state :: rest =>
states = rest
(state: @switch) match {
case FirstArrayValue => endEmptyArray
case ArrayValue => endArray
}
}

override def doc(token: Token): Stream[Pure, DocEvent] =
token match {
case StartObject =>
val res = separator() ++ startObject
states = FirstObjectKey :: states
res
case EndObject =>
closeObject()
case StartArray =>
val res = separator() ++ startArray
states = FirstArrayValue :: states
res
case EndArray =>
closeArray()
case Key(value) =>
val rendered = new StringBuilder
rendered.append('"')
renderString(value, 0, rendered)
rendered.append("\": ")
separator() ++ Stream.emit(DocEvent.Text(rendered.result()))
case NullValue =>
separator() ++ nullValue
case TrueValue =>
separator() ++ trueValue
case FalseValue =>
separator() ++ falseValue
case NumberValue(value) =>
separator() ++ Stream.emit(DocEvent.Text(value))
case StringValue(value) =>
val rendered = new StringBuilder
rendered.append('"')
renderString(value, 0, rendered)
rendered.append('"')
separator() ++ Stream.emit(DocEvent.Text(rendered.result()))
}
}

}

/** A compact version of the JSON rendering with no space or new lines. */
object compact extends Renderable[Token] {

private val startObject =
Stream.emit(DocEvent.Text("{"))

private val endObject =
Stream.emit(DocEvent.Text("}"))

private val startArray =
Stream.emit(DocEvent.Text("["))

private val endArray =
Stream.emit(DocEvent.Text("]"))

private val sep =
Stream.emit(DocEvent.Text(","))

override def newRenderer(): Renderer[Token] = new Renderer[Token] {

// the current stack of states, helping to deal with comma and indentation
// states are described right above
private[this] var states = List.empty[Int]

private def separator(): Stream[Pure, DocEvent] =
states match {
case Nil =>
Stream.empty
case state :: rest =>
(state: @switch) match {
case FirstObjectKey =>
states = ObjectValue :: rest
Stream.empty
case ObjectKey =>
states = ObjectValue :: rest
sep
case ObjectValue =>
states = ObjectKey :: rest
Stream.empty
case FirstArrayValue =>
states = ArrayValue :: rest
Stream.empty
case ArrayValue =>
states = ArrayValue :: rest
sep
}
}

private def closeObject(): Stream[Pure, DocEvent] =
states match {
case Nil => endObject
case state :: rest =>
states = rest
(state: @switch) match {
case FirstObjectKey => endObject
case ObjectKey => endObject
}
}

private def closeArray(): Stream[Pure, DocEvent] =
states match {
case Nil => endArray
case state :: rest =>
states = rest
(state: @switch) match {
case FirstArrayValue => endArray
case ArrayValue => endArray
}
}

override def doc(token: Token): Stream[Pure, DocEvent] =
token match {
case StartObject =>
val res = separator() ++ startObject
states = FirstObjectKey :: states
res
case EndObject =>
closeObject()
case StartArray =>
val res = separator() ++ startArray
states = FirstArrayValue :: states
res
case EndArray =>
closeArray()
case Key(value) =>
val rendered = new StringBuilder
rendered.append('"')
renderString(value, 0, rendered)
rendered.append("\":")
separator() ++ Stream.emit(DocEvent.Text(rendered.result()))
case NullValue =>
separator() ++ nullValue
case TrueValue =>
separator() ++ trueValue
case FalseValue =>
separator() ++ falseValue
case NumberValue(value) =>
separator() ++ Stream.emit(DocEvent.Text(value))
case StringValue(value) =>
val rendered = new StringBuilder
rendered.append('"')
renderString(value, 0, rendered)
rendered.append('"')
separator() ++ Stream.emit(DocEvent.Text(rendered.result()))
}
}

}

}
Loading

0 comments on commit 49da144

Please sign in to comment.