Skip to content

Commit

Permalink
Add support for pretty printing XML
Browse files Browse the repository at this point in the history
  • Loading branch information
satabin committed Jan 22, 2024
1 parent a2b202f commit 13e9a78
Show file tree
Hide file tree
Showing 3 changed files with 183 additions and 5 deletions.
11 changes: 11 additions & 0 deletions xml/src/main/scala/fs2/data/xml/Attr.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,15 @@ package fs2
package data
package xml

import cats.Show
import cats.syntax.all._

case class Attr(name: QName, value: List[XmlEvent.XmlTexty])

object Attr {

implicit val show: Show[Attr] = Show.show { case Attr(name, value) =>
show"""$name="${value.foldMap(_.render)}""""
}

}
140 changes: 140 additions & 0 deletions xml/src/main/scala/fs2/data/xml/internals/Renderer.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* 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
package data
package xml
package internals

import cats.syntax.all._

private[xml] class Renderer(collapseEmpty: Boolean, resetOnChunk: Boolean, indent: String, attributeThreshold: Int)
extends Collector.Builder[XmlEvent, String] {

private val builder = new StringBuilder

private var level = 0

private var newline = false

private var skipClose = false

private def indentation(): Unit =
if (newline) {
builder.append('\n')
builder.append(indent * level)
}

override def +=(chunk: Chunk[XmlEvent]): Unit = {
if (resetOnChunk)
builder.setLength(0)
chunk.foreach {
case e @ (XmlEvent.XmlDecl(_, _, _) | XmlEvent.XmlPI(_, _)) =>
indentation()
builder ++= e.show
newline = true

case XmlEvent.Comment(content) =>
newline = true
indentation()
builder ++= "<!--"
content.linesIterator.foreach { line =>
indentation()
builder ++= line.trim()
}
indentation()
builder ++= "-->"

case XmlEvent.StartTag(name, attributes, isEmpty) =>
indentation()
val renderedName = name.show
builder ++= show"<$renderedName"

attributes match {
case a :: as =>
val exceedThreshold = as.size > attributeThreshold - 1
builder ++= show"<$renderedName $a"
as.foreach { a =>
if (exceedThreshold) {
builder += '\n'
builder ++= " " * (renderedName.length() + 2)
} else {
builder += ' '
}
builder ++= a.show
}
case Nil => // do nothing
}

if (isEmpty && collapseEmpty) {
builder ++= " />"
skipClose = true
} else {
builder += '>'
level += 1
}
newline = true

case XmlEvent.EndTag(name) =>
level -= 1
if (!skipClose) {
indentation()
builder ++= show"</$name>"
}
skipClose = false
newline = true

case XmlEvent.XmlString(content, true) =>
indentation()
show"<![CDATA[$content]]>"
newline = true

case XmlEvent.XmlString(content, false) =>
content.linesWithSeparators
content.linesIterator.foreach { line =>
indentation()
if (newline)
builder ++= line.stripLeading()
else
builder ++= line
newline = true
}
newline = content.matches("^.*\n\\s*$")

case e =>
indentation()
builder ++ e.show
newline = false
}
}

override def result: String = builder.result()

}

private[xml] object Renderer {

def pipe[F[_]](collapseEmpty: Boolean, indent: String, attributeThreshold: Int): Pipe[F, XmlEvent, String] =
in =>
Stream.suspend(Stream.emit(new Renderer(collapseEmpty, true, indent, attributeThreshold))).flatMap { builder =>
in.mapChunks { chunk =>
builder += chunk
Chunk.singleton(builder.result)
}

}

}
37 changes: 32 additions & 5 deletions xml/src/main/scala/fs2/data/xml/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,39 @@ package object xml {
* without additional (or original) whitespace and with empty tags being collapsed to the short self-closed form <x/>
* if collapseEmpty is true. Preserves chunking, each String in the output will correspond to one event in the input.
*/
@deprecated(message = "Use `fs2.data.xml.render.raw() instead.`", since = "fs2-data 1.11.0")
def render[F[_]](collapseEmpty: Boolean = true): Pipe[F, XmlEvent, String] =
_.zipWithPrevious.map {
case (_, st: XmlEvent.StartTag) => st.render(collapseEmpty)
case (Some(XmlEvent.StartTag(_, _, true)), XmlEvent.EndTag(_)) if collapseEmpty => ""
case (_, event) => event.show
}
render.raw(collapseEmpty)

object render {

/**
* Render the incoming xml events to their string representation. The output will be concise,
* without additional (or original) whitespace and with empty tags being collapsed to the short self-closed form <x/>
* if collapseEmpty is true. Preserves chunking, each String in the output will correspond to one event in the input.
*/
def raw[F[_]](collapseEmpty: Boolean = true): Pipe[F, XmlEvent, String] =
_.zipWithPrevious.map {
case (_, st: XmlEvent.StartTag) => st.render(collapseEmpty)
case (Some(XmlEvent.StartTag(_, _, true)), XmlEvent.EndTag(_)) if collapseEmpty => ""
case (_, event) => event.show
}

/**
* Render the incoming xml events intot a prettified string representation.
* _Prettified_ means that nested tags will be indented as per `indent` parameter
* and text data (except for `CDATA`, which remains untouched) is indented to the current
* indentation level after each new line.
*
* This pipe can be used when whitespace characters are not relevant to the application
* and to make it more readable to human beings.
*/
def pretty[F[_]](collapseEmpty: Boolean = true,
indent: String = " ",
attributeThreshold: Int = 3): Pipe[F, XmlEvent, String] =
Renderer.pipe(collapseEmpty, indent, attributeThreshold)

}

val ncNameStart = CharRanges.fromRanges(
('A', 'Z'),
Expand Down

0 comments on commit 13e9a78

Please sign in to comment.