Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix XML rendering #385

Merged
merged 4 commits into from
Oct 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions xml/src/main/scala/fs2/data/xml/XmlEvent.scala
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ object XmlEvent {

case class XmlDecl(version: String, encoding: Option[String], standalone: Option[Boolean]) extends XmlEvent

case class StartTag(name: QName, attributes: List[Attr], isEmpty: Boolean) extends XmlEvent
case class StartTag(name: QName, attributes: List[Attr], isEmpty: Boolean) extends XmlEvent {
def render(collapseEmpty: Boolean): String = {
val end = if (collapseEmpty && isEmpty) "/>" else ">"
val attrs = attributes.foldMap[String] { case Attr(n, v) => show""" $n="${v.foldMap[String](_.render)}"""" }
show"""<$name$attrs$end"""
}
}

case class XmlCharRef(value: Int) extends XmlTexty {
def render = f"&#x$value%04x;"
Expand All @@ -44,7 +50,7 @@ object XmlEvent {
}

case class XmlString(s: String, isCDATA: Boolean) extends XmlTexty {
def render = s
def render = if (isCDATA) show"<![CDATA[$s]]>" else s
}

case class XmlPI(target: String, content: String) extends XmlEvent
Expand All @@ -58,15 +64,12 @@ object XmlEvent {
case class Comment(comment: String) extends XmlEvent

implicit val show: Show[XmlEvent] = Show.show {
case XmlString(s, false) => s
case XmlString(s, true) => show"<![CDATA[$s]]>"
case t: XmlTexty => t.render
case StartTag(n, attrs, isEmpty) =>
show"<${n} ${attrs.map { case Attr(n, v) => show"$n='${v.map(_.render).mkString_("")}'" }.mkString_("")}>"
case t: XmlTexty => t.render
case s: StartTag => s.render(false)
case EndTag(n) => show"</$n>"
case Comment(content) => s"<!--$content-->"
case XmlDecl(version, encoding, standalone) =>
s"""<?xml version="$version"${encoding.map(e => s""" encoding="$e"""").getOrElse("")}${standalone.map {
s"""<?xml version="$version"${encoding.foldMap(e => s""" encoding="$e"""")}${standalone.foldMap {
case true => s""" standalone="yes""""
case false => s""" standalone="no""""
}}?>"""
Expand Down
12 changes: 12 additions & 0 deletions xml/src/main/scala/fs2/data/xml/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ package object xml {
def normalize[F[_]]: Pipe[F, XmlEvent, XmlEvent] =
Normalizer.pipe[F]

/**
* 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 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 => ""
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@satabin Just confirming: It is correct that we emit an EndTag after a StartTag with isEmpty=true, right?

So <doc/> would lead to Lis(StartTag("doc", Nil, true), EndTag("doc")).

case (_, event) => event.show
}

val ncNameStart = CharRanges.fromRanges(
('A', 'Z'),
('_', '_'),
Expand Down
50 changes: 50 additions & 0 deletions xml/src/test/scala/fs2/data/xml/XmlEventShowTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package fs2.data.xml

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

object XmlEventShowTest extends SimpleIOSuite {

implicit def showX[X <: XmlEvent]: Show[X] = XmlEvent.show.narrow

import XmlEvent._

pureTest("texts") {
expect.eql("foo", XmlString("foo", false).show) &&
expect.eql("<![CDATA[foo]]>", XmlString("foo", true).show) &&
expect.eql("&#x04d2;", XmlCharRef(0x04d2).show) &&
expect.eql("&amp;", XmlEntityRef("amp").show)
}

pureTest("tags") {
val hello = QName("hello")
val preHello = QName(Some("pre"), "hello")
val attrA = Attr(QName("a"), List(XmlString("1", false)))
val attrB = Attr(QName("b"), List(XmlString("2", false)))

expect.eql("<hello>", StartTag(hello, Nil, false).show) &&
expect.eql("<hello>", StartTag(hello, Nil, true).show) && // ideally, this would use />
expect.eql("<pre:hello>", StartTag(preHello, Nil, false).show) &&
expect.eql("<hello a=\"1\">", StartTag(hello, List(attrA), false).show) &&
expect.eql("<hello a=\"1\" b=\"2\">", StartTag(hello, List(attrA, attrB), false).show) &&
expect.eql("</hello>", EndTag(hello).show)
}

pureTest("comments") {
expect.eql("<!--something-->", Comment("something").show)
}

pureTest("declarations") {
expect.eql("<?xml version=\"1.0\"?>", XmlDecl("1.0", None, None).show) &&
expect.eql("<?xml version=\"1.0\" encoding=\"utf-8\"?>", XmlDecl("1.0", Some("utf-8"), None).show) &&
expect.eql("<?xml version=\"1.0\" standalone=\"yes\"?>", XmlDecl("1.0", None, Some(true)).show) &&
expect.eql("<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>",
XmlDecl("1.0", Some("utf-8"), Some(true)).show)
}

pureTest("pi") {
expect.eql("<?target content?>", XmlPI("target", "content").show)
}

}
25 changes: 25 additions & 0 deletions xml/src/test/scala/fs2/data/xml/XmlRenderTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package fs2.data.xml

import cats.effect.IO
import cats.syntax.all._
import weaver._

object XmlRenderTest extends SimpleIOSuite {

test("renders xml with self-closing tags") {
val result =
xml"""<?xml version="1.0" encoding="utf-8"?><doc><no-content/></doc>""".through(render()).compile.string
result.liftTo[IO].map { result =>
expect.eql("""<?xml version="1.0" encoding="utf-8"?><doc><no-content/></doc>""", result)
}
}

test("renders xml without self-closing tags if disabled") {
val result =
xml"""<?xml version="1.0" encoding="utf-8"?><doc><no-content/></doc>""".through(render(false)).compile.string
result.liftTo[IO].map { result =>
expect.eql("""<?xml version="1.0" encoding="utf-8"?><doc><no-content></no-content></doc>""", result)
}
}

}