diff --git a/xml/src/main/scala/fs2/data/xml/Attr.scala b/xml/src/main/scala/fs2/data/xml/Attr.scala index 1c151aa7..f1848cd5 100644 --- a/xml/src/main/scala/fs2/data/xml/Attr.scala +++ b/xml/src/main/scala/fs2/data/xml/Attr.scala @@ -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)}"""" + } + +} diff --git a/xml/src/main/scala/fs2/data/xml/internals/Renderer.scala b/xml/src/main/scala/fs2/data/xml/internals/Renderer.scala new file mode 100644 index 00000000..25f4a826 --- /dev/null +++ b/xml/src/main/scala/fs2/data/xml/internals/Renderer.scala @@ -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 ++= "" + + 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"" + } + skipClose = false + newline = true + + case XmlEvent.XmlString(content, true) => + indentation() + show"" + 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) + } + + } + +} diff --git a/xml/src/main/scala/fs2/data/xml/package.scala b/xml/src/main/scala/fs2/data/xml/package.scala index 21920d49..6a71d675 100644 --- a/xml/src/main/scala/fs2/data/xml/package.scala +++ b/xml/src/main/scala/fs2/data/xml/package.scala @@ -80,12 +80,39 @@ package object xml { * without additional (or original) whitespace and with empty tags being collapsed to the short self-closed form * 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 + * 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'),