diff --git a/benchmarks/src/main/scala/fs2/data/benchmarks/PrinterBenchmarks.scala b/benchmarks/src/main/scala/fs2/data/benchmarks/PrinterBenchmarks.scala new file mode 100644 index 000000000..67be0df5b --- /dev/null +++ b/benchmarks/src/main/scala/fs2/data/benchmarks/PrinterBenchmarks.scala @@ -0,0 +1,85 @@ +package fs2.data.benchmarks + +import cats.effect.SyncIO +import cats.effect.IO +import fs2.data.json.Token +import fs2.data.json.circe.* +import fs2.{Fallible, Stream} +import io.circe.Json +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole +import cats.effect.unsafe.implicits.global + +import java.util.concurrent.TimeUnit + +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@BenchmarkMode(Array(Mode.AverageTime)) +@State(org.openjdk.jmh.annotations.Scope.Benchmark) +@Fork(value = 1) +@Warmup(iterations = 15, time = 5) +@Measurement(iterations = 10, time = 5) +class PrinterBenchmarks { + + val intArrayStream = + Stream.emits( + Token.StartArray :: + (List + .range(0, 1000000) + .map(i => Token.NumberValue(i.toString())) :+ Token.EndArray)) + + val objectStream = + Stream.emits( + Token.StartObject :: + (List + .range(0, 1000000) + .flatMap(i => List(Token.Key(s"key:$i"), Token.NumberValue(i.toString()))) :+ Token.EndObject)) + + @Benchmark + def intArrayCompact(bh: Blackhole) = + bh.consume( + intArrayStream + .through(fs2.data.json.render.compact) + .compile + .drain) + + @Benchmark + def objectCompact(bh: Blackhole) = + bh.consume( + objectStream + .through(fs2.data.json.render.compact) + .compile + .drain) + + @Benchmark + def intArrayPretty(bh: Blackhole) = + bh.consume( + intArrayStream + .through(fs2.data.json.render.prettyPrint()) + .compile + .drain) + + @Benchmark + def objectPretty(bh: Blackhole) = + bh.consume( + objectStream + .through(fs2.data.json.render.prettyPrint()) + .compile + .drain) + + @Benchmark + def intArrayPrettyLegacy(bh: Blackhole) = + bh.consume( + intArrayStream + .through(fs2.data.json.render.pretty()) + .compile + .drain) + + @Benchmark + def objectPrettyLegacy(bh: Blackhole) = + bh.consume( + objectStream + .through(fs2.data.json.render.pretty()) + .compile + .drain) + +} diff --git a/build.sbt b/build.sbt index 212b46741..25ee3d72d 100644 --- a/build.sbt +++ b/build.sbt @@ -142,7 +142,46 @@ lazy val text = crossProject(JVMPlatform, JSPlatform, NativePlatform) ProblemFilters.exclude[IncompatibleMethTypeProblem]("fs2.data.text.CharLikeStringChunks.pullNext"), ProblemFilters.exclude[IncompatibleMethTypeProblem]("fs2.data.text.CharLikeStringChunks.advance"), ProblemFilters.exclude[IncompatibleMethTypeProblem]("fs2.data.text.CharLikeStringChunks.current"), - ProblemFilters.exclude[MissingClassProblem]("fs2.data.text.CharLikeStringChunks$StringContext") + ProblemFilters.exclude[MissingClassProblem]("fs2.data.text.CharLikeStringChunks$StringContext"), + ProblemFilters.exclude[MissingClassProblem]("fs2.data.text.render.internal.Annotated$AlignBegin"), + ProblemFilters.exclude[MissingTypesProblem]("fs2.data.text.render.internal.Annotated$AlignBegin$"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#AlignBegin.apply"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#AlignBegin.unapply"), + ProblemFilters.exclude[IncompatibleResultTypeProblem]( + "fs2.data.text.render.internal.Annotated#AlignBegin.fromProduct"), + ProblemFilters.exclude[MissingClassProblem]("fs2.data.text.render.internal.Annotated$AlignEnd"), + ProblemFilters.exclude[MissingTypesProblem]("fs2.data.text.render.internal.Annotated$AlignEnd$"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#AlignEnd.apply"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#AlignEnd.unapply"), + ProblemFilters.exclude[IncompatibleResultTypeProblem]( + "fs2.data.text.render.internal.Annotated#AlignEnd.fromProduct"), + ProblemFilters.exclude[MissingClassProblem]("fs2.data.text.render.internal.Annotated$GroupEnd"), + ProblemFilters.exclude[MissingTypesProblem]("fs2.data.text.render.internal.Annotated$GroupEnd$"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#GroupEnd.apply"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#GroupEnd.unapply"), + ProblemFilters.exclude[IncompatibleResultTypeProblem]( + "fs2.data.text.render.internal.Annotated#GroupEnd.fromProduct"), + ProblemFilters.exclude[MissingClassProblem]("fs2.data.text.render.internal.Annotated$IndentBegin"), + ProblemFilters.exclude[MissingTypesProblem]("fs2.data.text.render.internal.Annotated$IndentBegin$"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#IndentBegin.apply"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#IndentBegin.unapply"), + ProblemFilters.exclude[IncompatibleResultTypeProblem]( + "fs2.data.text.render.internal.Annotated#IndentBegin.fromProduct"), + ProblemFilters.exclude[MissingClassProblem]("fs2.data.text.render.internal.Annotated$IndentEnd"), + ProblemFilters.exclude[MissingTypesProblem]("fs2.data.text.render.internal.Annotated$IndentEnd$"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#IndentEnd.apply"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#IndentEnd.unapply"), + ProblemFilters.exclude[IncompatibleResultTypeProblem]( + "fs2.data.text.render.internal.Annotated#IndentEnd.fromProduct"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#Line.hp"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#LineBreak.hp"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#Text.hp"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#Text.copy"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#Text.copy$default$2"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#Text.this"), + ProblemFilters.exclude[MissingTypesProblem]("fs2.data.text.render.internal.Annotated$Text$"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#Text.apply"), + ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.data.text.render.internal.Annotated#Text._2") ) ) .nativeSettings( diff --git a/json/src/main/scala/fs2/data/json/package.scala b/json/src/main/scala/fs2/data/json/package.scala index 9a74b83d5..1e5c359af 100644 --- a/json/src/main/scala/fs2/data/json/package.scala +++ b/json/src/main/scala/fs2/data/json/package.scala @@ -154,7 +154,71 @@ package object json { * You can use this to write the Json stream to a file. */ def compact[F[_]]: Pipe[F, Token, String] = - _.through(fs2.data.text.render.pretty(width = Int.MaxValue)(Token.compact)) + _.scanChunks((0, false)) { case (state, chunk) => + val builder = new StringBuilder + val state1 = + chunk.foldLeft(state) { + case ((level, comma), Token.StartObject) => + if (comma) { + builder.append(',') + } + builder.append(('{')) + (level + 1, false) + case ((level, _), Token.EndObject) => + builder.append('}') + (level - 1, level > 1) + case ((level, comma), Token.StartArray) => + if (comma) { + builder.append(',') + } + builder.append(('[')) + (level + 1, false) + case ((level, _), Token.EndArray) => + builder.append(']') + (level - 1, level > 1) + case ((level, comma), Token.Key(key)) => + if (comma) { + builder.append(',') + } + builder.append('"') + Token.renderString(key, 0, builder) + builder.append("\":") + (level, false) + case ((level, comma), Token.StringValue(key)) => + if (comma) { + builder.append(',') + } + builder.append('"') + Token.renderString(key, 0, builder) + builder.append('"') + (level, level > 0) + case ((level, comma), Token.NumberValue(n)) => + if (comma) { + builder.append(',') + } + builder.append(n) + (level, level > 0) + case ((level, comma), Token.TrueValue) => + if (comma) { + builder.append(',') + } + builder.append("true") + (level, level > 0) + case ((level, comma), Token.FalseValue) => + if (comma) { + builder.append(',') + } + builder.append("false") + (level, level > 0) + case ((level, comma), Token.NullValue) => + if (comma) { + builder.append(',') + } + builder.append("null") + (level, level > 0) + } + (state1, Chunk.singleton(builder.result())) + } /** Renders a pretty-printed representation of the token stream with the given * indentation size. diff --git a/text/shared/src/main/scala/fs2/data/text/render/internal/Annotated.scala b/text/shared/src/main/scala/fs2/data/text/render/internal/Annotated.scala index 5a81353c5..2fb5ee250 100644 --- a/text/shared/src/main/scala/fs2/data/text/render/internal/Annotated.scala +++ b/text/shared/src/main/scala/fs2/data/text/render/internal/Annotated.scala @@ -18,13 +18,13 @@ package fs2.data.text.render.internal private sealed trait Annotated private object Annotated { - case class Text(text: String, hp: Int) extends Annotated - case class Line(hp: Int) extends Annotated - case class LineBreak(hp: Int) extends Annotated + case class Text(text: String) extends Annotated + case class Line(pos: Int) extends Annotated + case class LineBreak(pos: Int) extends Annotated case class GroupBegin(hpl: Position) extends Annotated - case class GroupEnd(hp: Int) extends Annotated - case class IndentBegin(hp: Int) extends Annotated - case class IndentEnd(hp: Int) extends Annotated - case class AlignBegin(hp: Int) extends Annotated - case class AlignEnd(hp: Int) extends Annotated + case object GroupEnd extends Annotated + case object IndentBegin extends Annotated + case object IndentEnd extends Annotated + case object AlignBegin extends Annotated + case object AlignEnd extends Annotated } diff --git a/text/shared/src/main/scala/fs2/data/text/render/internal/NonEmptyIntList.scala b/text/shared/src/main/scala/fs2/data/text/render/internal/NonEmptyIntList.scala new file mode 100644 index 000000000..9e72748b5 --- /dev/null +++ b/text/shared/src/main/scala/fs2/data/text/render/internal/NonEmptyIntList.scala @@ -0,0 +1,36 @@ +/* + * Copyright 2024 fs2-data Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fs2.data.text.render.internal + +private sealed trait NonEmptyIntList { + def head: Int + def ::(i: Int): NonEmptyIntList = + More(i, this) + def incHead: NonEmptyIntList + def decHead: NonEmptyIntList + def pop: NonEmptyIntList +} +private final case class One(head: Int) extends NonEmptyIntList { + override def incHead: NonEmptyIntList = One(head + 1) + override def decHead: NonEmptyIntList = One(head - 1) + override lazy val pop: NonEmptyIntList = One(0) +} +private final case class More(head: Int, tail: NonEmptyIntList) extends NonEmptyIntList { + override def incHead: NonEmptyIntList = More(head + 1, tail) + override def decHead: NonEmptyIntList = More(head - 1, tail) + override def pop: NonEmptyIntList = tail +} diff --git a/text/shared/src/main/scala/fs2/data/text/render/internal/StreamPrinter.scala b/text/shared/src/main/scala/fs2/data/text/render/internal/StreamPrinter.scala index 22d01ee92..0d6aff388 100644 --- a/text/shared/src/main/scala/fs2/data/text/render/internal/StreamPrinter.scala +++ b/text/shared/src/main/scala/fs2/data/text/render/internal/StreamPrinter.scala @@ -21,246 +21,275 @@ import cats.collections.Dequeue import cats.data.{Chain, NonEmptyList} import fs2.{Chunk, Pipe, Pull, Stream} +private case class OpenGroup(hpl: Int, indent: Int, group: Chain[Annotated]) +private class AnnotationContext(var pos: Int, + var aligns: NonEmptyIntList, + var hpl: Int, + var indent: Int, + var groups: Dequeue[OpenGroup]) +private class RenderingContext(var fit: Int, var hpl: Int, var lines: NonEmptyList[String], var col: Int) + private[render] class StreamPrinter[F[_], Event](width: Int, indentSize: Int)(implicit render: Renderable[Event]) extends Pipe[F, Event, String] { - private val emptyGroups = (0, 0, Dequeue.empty[(Int, Int, Chain[Annotated])]) - - private def push(groups: Dequeue[(Int, Int, Chain[Annotated])], - evt: Annotated): Dequeue[(Int, Int, Chain[Annotated])] = - groups.unsnoc match { - case Some(((ghpl, gindent, group), groups)) => groups.snoc((ghpl, gindent, group.append(evt))) - case None => Dequeue.empty // should never happen + private def push(annctx: AnnotationContext, evt: Annotated): Unit = + annctx.groups.unsnoc match { + case Some((OpenGroup(ghpl, gindent, group), groups)) => + annctx.groups = groups.snoc(OpenGroup(ghpl, gindent, group.append(evt))) + case None => // should never happen } - private def pop(groups: Dequeue[(Int, Int, Chain[Annotated])], - buffer: Chain[Annotated]): Pull[F, Annotated, (Dequeue[(Int, Int, Chain[Annotated])])] = - groups.unsnoc match { - case Some(((ghpl, gindent, group), groups)) => - Pull.pure((groups.snoc((ghpl, gindent, group.concat(buffer))))) + private def pop(buffer: Chain[Annotated], + annctx: AnnotationContext, + rctx: RenderingContext, + chunkAcc: StringBuilder): Unit = + annctx.groups.unsnoc match { + case Some((OpenGroup(ghpl, gindent, group), groups)) => + annctx.groups = groups.snoc(OpenGroup(ghpl, gindent, group.concat(buffer))) case None => - Pull.output(Chunk.chain(buffer)).as(Dequeue.empty) + annctx.groups = Dequeue.empty + buffer.iterator.foreach(renderAnnotated(_, rctx, chunkAcc)) } - private def check(hpl: Int, - indent: Int, - groups: Dequeue[(Int, Int, Chain[Annotated])], - ghpl: Int): Pull[F, Annotated, (Int, Int, Dequeue[(Int, Int, Chain[Annotated])])] = - if (ghpl <= hpl - (indent * indentSize) && groups.size <= width - (indent * indentSize)) { + private def check(annctx: AnnotationContext, rctx: RenderingContext, chunkAcc: StringBuilder): Unit = + if (annctx.pos <= annctx.hpl - (annctx.indent * indentSize) && annctx.groups.size <= width - (annctx.indent * indentSize)) { // groups still fits - Pull.pure((hpl, indent, groups)) } else { // group does not fit, uncons first buffer - groups.uncons match { - case Some(((_, _, buffer), groups)) => - Pull.output(Chunk.chain(buffer.prepend(Annotated.GroupBegin(Position.TooFar)))) >> (groups.uncons match { - case Some(((hpl, indent, _), _)) => check(hpl, indent, groups, ghpl) // check inner groups recursively - case None => Pull.pure(emptyGroups) - }) + annctx.groups.uncons match { + case Some((OpenGroup(_, _, buffer), groups)) => + renderGroupBegin(Position.TooFar, rctx) + buffer.iterator.foreach(renderAnnotated(_, rctx, chunkAcc)) + groups.uncons match { + case Some((OpenGroup(newhpl, newindent, _), _)) => + annctx.hpl = newhpl + annctx.indent = newindent + annctx.groups = groups + check(annctx, rctx, chunkAcc) // check inner groups recursively + case None => + annctx.hpl = 0 + annctx.indent = 0 + annctx.groups = Dequeue.empty + } case None => - Pull.pure(emptyGroups) // should never happen + // should never happen } } - private def annotate(chunk: Chunk[DocEvent], - idx: Int, - rest: Stream[F, DocEvent], - pos: Int, - aligns: NonEmptyList[Int], - hpl: Int, - indent: Int, - groups: Dequeue[(Int, Int, Chain[Annotated])]): Pull[F, Annotated, Unit] = - if (idx >= chunk.size) { - rest.pull.uncons.flatMap { - case Some((hd, tl)) => annotate(hd, 0, tl, pos, aligns, hpl, indent, groups) - case None => Pull.done - } + private def process(chunk: Chunk[DocEvent], + chunkSize: Int, + idx: Int, + rest: Stream[F, DocEvent], + annctx: AnnotationContext, + rctx: RenderingContext, + chunkAcc: StringBuilder): Pull[F, String, Unit] = + if (idx >= chunkSize) { + Pull.output1(chunkAcc.result()) >> + rest.pull.uncons.flatMap { + case Some((hd, tl)) => + chunkAcc.setLength(0) + process(hd, hd.size, 0, tl, annctx, rctx, chunkAcc) + case None => Pull.done + } } else { val evt = chunk(idx) evt match { case DocEvent.Text(text) => val size = text.size - val pos1 = pos + size - if (groups.isEmpty) { + annctx.pos += size + if (annctx.groups.isEmpty) { // no open group we can emit immediately - Pull - .output1(Annotated.Text(text, pos1)) >> annotate(chunk, idx + 1, rest, pos1, aligns, hpl, indent, groups) + renderText(text, rctx, chunkAcc) } else { // there is an open group, append the event to the current group - check(hpl, indent, push(groups, Annotated.Text(text, pos1)), pos1).flatMap { case (hpl, gindent, groups) => - annotate(chunk, idx + 1, rest, pos1, aligns, hpl, gindent, groups) - } + push(annctx, Annotated.Text(text)) + check(annctx, rctx, chunkAcc) } case DocEvent.Line => - if (groups.isEmpty) { + annctx.pos += 1 + if (annctx.groups.isEmpty) { // no open group we can emit immediately a new line - Pull - .output1(Annotated.Line(pos + 1)) >> annotate(chunk, idx + 1, rest, pos + 1, aligns, hpl, indent, groups) + renderLine(annctx.pos, rctx, chunkAcc) } else { // there is an open group, append the event to the current group - check(hpl, indent, push(groups, Annotated.Line(pos + 1)), pos + 1).flatMap { case (hpl, indent, groups) => - annotate(chunk, idx + 1, rest, pos + 1, aligns, hpl, indent, groups) - } + push(annctx, Annotated.Line(annctx.pos)) + check(annctx, rctx, chunkAcc) } case DocEvent.LineBreak => - if (groups.isEmpty) { + if (annctx.groups.isEmpty) { // no open group we can emit immediately a new line - Pull.output1(Annotated.LineBreak(pos)) >> annotate(chunk, idx + 1, rest, pos, aligns, hpl, indent, groups) + renderLineBreak(annctx.pos, rctx, chunkAcc) } else { // there is an open group, append the event to the current group - check(hpl, indent, push(groups, Annotated.LineBreak(pos)), pos).flatMap { case (hpl, indent, groups) => - annotate(chunk, idx + 1, rest, pos, aligns, hpl, indent, groups) - } + push(annctx, Annotated.LineBreak(annctx.pos)) + check(annctx, rctx, chunkAcc) } case DocEvent.GroupBegin => - val hpl1 = pos + width - aligns.head - if (groups.isEmpty) + val hpl1 = annctx.pos + width - annctx.aligns.head + if (annctx.groups.isEmpty) { // this is the top-level group, turn on the buffer mechanism - annotate(chunk, - idx + 1, - rest, - pos, - aligns, - hpl1, - aligns.head, - groups.snoc((hpl1, aligns.head, Chain.empty))) - else + annctx.hpl = hpl1 + annctx.indent = annctx.aligns.head + annctx.groups = annctx.groups.snoc(OpenGroup(hpl1, annctx.indent, Chain.empty)) + } else { // starting a new group, puts a new empty buffer in the group dequeue, and check for overflow - check(hpl, indent, groups.snoc((hpl1, aligns.head, Chain.empty)), pos).flatMap { - case (hpl, indent, groups) => - annotate(chunk, idx + 1, rest, pos, aligns, hpl, indent, groups) - } + annctx.groups = annctx.groups.snoc(OpenGroup(hpl1, annctx.aligns.head, Chain.empty)) + check(annctx, rctx, chunkAcc) + } case DocEvent.GroupEnd => - groups.unsnoc match { + annctx.groups.unsnoc match { case None => - // closing unknown group, just ignore it - annotate(chunk, idx + 1, rest, pos, aligns, hpl, indent, groups) + // closing unknown group, just ignore it - case Some(((hpl, indent, group), groups)) => + case Some((OpenGroup(newhpl, newindent, group), groups)) => // closing a group, pop it from the buffer dequeue, and continue - pop(groups, group.prepend(Annotated.GroupBegin(Position.Small(pos))).append(Annotated.GroupEnd(pos))) - .flatMap { groups => - annotate(chunk, idx + 1, rest, pos, aligns, hpl, indent, groups) - } + annctx.groups = groups + pop(group.prepend(Annotated.GroupBegin(Position.Small(annctx.pos))).append(Annotated.GroupEnd), + annctx, + rctx, + chunkAcc) + annctx.hpl = newhpl + annctx.indent = newindent } case DocEvent.IndentBegin => // increment the current indentation level - if (groups.isEmpty) { + annctx.aligns = annctx.aligns.incHead + if (annctx.groups.isEmpty) { // no open group we can emit immediately a new line - Pull.output1(Annotated.IndentBegin(pos)) >> annotate(chunk, - idx + 1, - rest, - pos, - NonEmptyList(aligns.head + 1, aligns.tail), - hpl, - indent + 1, - groups) + renderIndentBegin(rctx) + annctx.indent += 1 } else { // there is an open group, append the event to the current group - check(hpl, indent, push(groups, Annotated.IndentBegin(pos)), pos).flatMap { case (hpl, indent, groups) => - annotate(chunk, idx + 1, rest, pos, NonEmptyList(aligns.head + 1, aligns.tail), hpl, indent, groups) - } + push(annctx, Annotated.IndentBegin) + check(annctx, rctx, chunkAcc) } case DocEvent.IndentEnd => // decrement the current indentation level - if (groups.isEmpty) { + annctx.aligns = annctx.aligns.decHead + if (annctx.groups.isEmpty) { // no open group we can emit immediately a new line - Pull.output1(Annotated.IndentEnd(pos)) >> annotate(chunk, - idx + 1, - rest, - pos, - NonEmptyList(aligns.head - 1, aligns.tail), - hpl, - indent, - groups) + renderIndentEnd(rctx) + annctx.indent -= 1 } else { // there is an open group, append the event to the current group - check(hpl, indent, push(groups, Annotated.IndentEnd(pos)), pos).flatMap { case (hpl, indent, groups) => - annotate(chunk, idx + 1, rest, pos, NonEmptyList(aligns.head - 1, aligns.tail), hpl, indent, groups) - } + push(annctx, Annotated.IndentEnd) + check(annctx, rctx, chunkAcc) } case DocEvent.AlignBegin => // push new indentation level - if (groups.isEmpty) { + annctx.aligns = annctx.pos :: annctx.aligns + if (annctx.groups.isEmpty) { // no open group we can emit immediately a new line - Pull.output1(Annotated.AlignBegin(pos)) >> annotate(chunk, - idx + 1, - rest, - pos, - pos :: aligns, - hpl, - indent, - groups) + renderAlignBegin(rctx) } else { // there is an open group, append the event to the current group - check(hpl, indent, push(groups, Annotated.AlignBegin(pos)), pos).flatMap { case (hpl, indent, groups) => - annotate(chunk, idx + 1, rest, pos, pos :: aligns, hpl, indent, groups) - } + push(annctx, Annotated.AlignBegin) + check(annctx, rctx, chunkAcc) } case DocEvent.AlignEnd => // restore to previous indentation level - val aligns1 = - aligns match { - case NonEmptyList(_, i :: is) => NonEmptyList(i, is) - case NonEmptyList(_, Nil) => NonEmptyList.one(0) - } - if (groups.isEmpty) { + annctx.aligns = annctx.aligns.pop + if (annctx.groups.isEmpty) { // no open group we can emit immediately a new line - Pull.output1(Annotated.AlignEnd(pos)) >> annotate(chunk, idx + 1, rest, pos, aligns1, hpl, indent, groups) + renderAlignEnd(rctx) } else { // there is an open group, append the event to the current group - check(hpl, indent, push(groups, Annotated.AlignEnd(pos)), pos).flatMap { case (hpl, indent, groups) => - annotate(chunk, idx + 1, rest, pos, aligns1, hpl, indent, groups) - } + push(annctx, Annotated.AlignEnd) + check(annctx, rctx, chunkAcc) } } + process(chunk, chunkSize, idx + 1, rest, annctx, rctx, chunkAcc) } - def apply(events: Stream[F, Event]): Stream[F, String] = - annotate( - Chunk.empty, - 0, - Stream.suspend(Stream.emit(render.newRenderer())).flatMap(renderer => events.flatMap(renderer.doc(_))), - 0, - NonEmptyList.one(0), - 0, - 0, - Dequeue.empty - ).stream - .mapAccumulate((0, width, NonEmptyList.one(""), 0)) { case (acc @ (fit, hpl, lines, col), evt) => - evt match { - case Annotated.Text(text, _) => ((fit, hpl, lines, col + text.size), Some(text)) - case Annotated.Line(pos) if fit == 0 => ((fit, pos + width, lines, lines.head.size), Some("\n" + lines.head)) - case Annotated.Line(_) => ((fit, hpl, lines, col + 1), Some(" ")) - case Annotated.LineBreak(pos) if fit == 0 => - ((fit, pos + width, lines, lines.head.size), Some("\n" + lines.head)) - case Annotated.LineBreak(_) => (acc, None) - case Annotated.GroupBegin(Position.TooFar) if fit == 0 => ((0, hpl, lines, col), None) - case Annotated.GroupBegin(Position.Small(pos)) if fit == 0 => - ((if (pos <= hpl) 1 else 0, hpl, lines, col), None) - case Annotated.GroupBegin(_) => ((fit + 1, hpl, lines, col), None) - case Annotated.GroupEnd(_) if fit == 0 => (acc, None) - case Annotated.GroupEnd(_) => ((fit - 1, hpl, lines, col), None) - case Annotated.IndentBegin(_) => - ((fit, hpl, NonEmptyList(lines.head + (" " * indentSize), lines.tail), col), None) - case Annotated.IndentEnd(_) => - ((fit, hpl, NonEmptyList(lines.head.drop(indentSize), lines.tail), col), None) - case Annotated.AlignBegin(_) => - ((fit, hpl, (" " * col) :: lines, col), None) - case Annotated.AlignEnd(_) => - ((fit, hpl, NonEmptyList.fromList(lines.tail).getOrElse(NonEmptyList.one("")), col), None) - } + // rendering + + private def renderText(text: String, ctx: RenderingContext, chunkAcc: StringBuilder): Unit = { + ctx.col += text.size + chunkAcc.append(text) + } + + private def renderLine(pos: Int, ctx: RenderingContext, chunkAcc: StringBuilder): Unit = if (ctx.fit == 0) { + ctx.hpl = pos + width + ctx.col = ctx.lines.head.size + val _ = chunkAcc.append('\n').append(ctx.lines.head) + } else { + ctx.col += 1 + chunkAcc.append(' ') + } + + private def renderLineBreak(pos: Int, ctx: RenderingContext, chunkAcc: StringBuilder): Unit = + if (ctx.fit == 0) { + ctx.hpl = pos + width + ctx.col = ctx.lines.head.size + val _ = chunkAcc.append('\n').append(ctx.lines.head) + } + private def renderGroupBegin(pos: Position, ctx: RenderingContext): Unit = + if (ctx.fit == 0) { + pos match { + case Position.TooFar => + // too far, do nothing + case Position.Small(pos) => + ctx.fit = if (pos <= ctx.hpl) 1 else 0 } - .map(_._2) - .unNone + } else { + ctx.fit += 1 + } + + private def renderGroupEnd(ctx: RenderingContext): Unit = + if (ctx.fit > 0) { + ctx.fit -= 1 + } + + private def renderIndentBegin(ctx: RenderingContext): Unit = { + ctx.lines = NonEmptyList(ctx.lines.head + (" " * indentSize), ctx.lines.tail) + } + + private def renderIndentEnd(ctx: RenderingContext): Unit = { + ctx.lines = NonEmptyList(ctx.lines.head.drop(indentSize), ctx.lines.tail) + } + + private def renderAlignBegin(ctx: RenderingContext): Unit = { + ctx.lines = (" " * ctx.col) :: ctx.lines + } + + private def renderAlignEnd(ctx: RenderingContext): Unit = { + ctx.lines = NonEmptyList.fromList(ctx.lines.tail).getOrElse(NonEmptyList.one("")) + } + + private def renderAnnotated(annotated: Annotated, ctx: RenderingContext, chunkAcc: StringBuilder): Unit = + annotated match { + case Annotated.Text(text) => renderText(text, ctx, chunkAcc) + case Annotated.Line(pos) => renderLine(pos, ctx, chunkAcc) + case Annotated.LineBreak(pos) => renderLineBreak(pos, ctx, chunkAcc) + case Annotated.GroupBegin(pos) => renderGroupBegin(pos, ctx) + case Annotated.GroupEnd => renderGroupEnd(ctx) + case Annotated.IndentBegin => renderIndentBegin(ctx) + case Annotated.IndentEnd => renderIndentEnd(ctx) + case Annotated.AlignBegin => renderAlignBegin(ctx) + case Annotated.AlignEnd => renderAlignEnd(ctx) + } + + def apply(events: Stream[F, Event]): Stream[F, String] = + Stream.suspend(Stream.emit(render.newRenderer())).flatMap { renderer => + process( + Chunk.empty, + 0, + 0, + events.flatMap(renderer.doc(_)), + new AnnotationContext(0, One(0), 0, 0, Dequeue.empty), + new RenderingContext(0, width, NonEmptyList.one(""), 0), + new StringBuilder + ).stream + + } }