From 24a4b51e8b4977f796f823c20ce8b9aa2c59f1e7 Mon Sep 17 00:00:00 2001 From: Yannick Heiber Date: Tue, 4 Oct 2022 10:28:20 +0200 Subject: [PATCH 1/4] Fix XML rendering Fix buggy Show instance for XmlEvent and provide a pipe to render events with collapsed empty tags. --- .../main/scala/fs2/data/xml/XmlEvent.scala | 18 ++++--- xml/src/main/scala/fs2/data/xml/package.scala | 12 +++++ .../scala/fs2/data/xml/XmlEventShowTest.scala | 50 +++++++++++++++++++ .../scala/fs2/data/xml/XmlRenderTest.scala | 25 ++++++++++ 4 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 xml/src/test/scala/fs2/data/xml/XmlEventShowTest.scala create mode 100644 xml/src/test/scala/fs2/data/xml/XmlRenderTest.scala diff --git a/xml/src/main/scala/fs2/data/xml/XmlEvent.scala b/xml/src/main/scala/fs2/data/xml/XmlEvent.scala index 3fc363935..05108c7f3 100644 --- a/xml/src/main/scala/fs2/data/xml/XmlEvent.scala +++ b/xml/src/main/scala/fs2/data/xml/XmlEvent.scala @@ -33,7 +33,12 @@ 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 ">" + show"<$name${attributes.foldMap { case Attr(n, v) => show" $n=\"${v.foldMap(_.render)}\"" }}$end" + } + } case class XmlCharRef(value: Int) extends XmlTexty { def render = f"&#x$value%04x;" @@ -44,7 +49,7 @@ object XmlEvent { } case class XmlString(s: String, isCDATA: Boolean) extends XmlTexty { - def render = s + def render = if (isCDATA) show"" else s } case class XmlPI(target: String, content: String) extends XmlEvent @@ -58,15 +63,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"" - 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"" case Comment(content) => s"" case XmlDecl(version, encoding, standalone) => - s""" s""" encoding="$e"""").getOrElse("")}${standalone.map { + s""" s""" encoding="$e"""")}${standalone.foldMap { case true => s""" standalone="yes"""" case false => s""" standalone="no"""" }}?>""" diff --git a/xml/src/main/scala/fs2/data/xml/package.scala b/xml/src/main/scala/fs2/data/xml/package.scala index 10da7948c..bc7a58d7c 100644 --- a/xml/src/main/scala/fs2/data/xml/package.scala +++ b/xml/src/main/scala/fs2/data/xml/package.scala @@ -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 + * 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 => "" + case (_, event) => event.show + } + val ncNameStart = CharRanges.fromRanges( ('A', 'Z'), ('_', '_'), diff --git a/xml/src/test/scala/fs2/data/xml/XmlEventShowTest.scala b/xml/src/test/scala/fs2/data/xml/XmlEventShowTest.scala new file mode 100644 index 000000000..f44e27523 --- /dev/null +++ b/xml/src/test/scala/fs2/data/xml/XmlEventShowTest.scala @@ -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("", XmlString("foo", true).show) && + expect.eql("Ӓ", XmlCharRef(0x04d2).show) && + expect.eql("&", 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("", StartTag(hello, Nil, false).show) && + expect.eql("", StartTag(hello, Nil, true).show) && // ideally, this would use /> + expect.eql("", StartTag(preHello, Nil, false).show) && + expect.eql("", StartTag(hello, List(attrA), false).show) && + expect.eql("", StartTag(hello, List(attrA, attrB), false).show) && + expect.eql("", EndTag(hello).show) + } + + pureTest("comments") { + expect.eql("", Comment("something").show) + } + + pureTest("declarations") { + expect.eql("", XmlDecl("1.0", None, None).show) && + expect.eql("", XmlDecl("1.0", Some("utf-8"), None).show) && + expect.eql("", XmlDecl("1.0", None, Some(true)).show) && + expect.eql("", + XmlDecl("1.0", Some("utf-8"), Some(true)).show) + } + + pureTest("pi") { + expect.eql("", XmlPI("target", "content").show) + } + +} diff --git a/xml/src/test/scala/fs2/data/xml/XmlRenderTest.scala b/xml/src/test/scala/fs2/data/xml/XmlRenderTest.scala new file mode 100644 index 000000000..9f34274fa --- /dev/null +++ b/xml/src/test/scala/fs2/data/xml/XmlRenderTest.scala @@ -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"""""".through(render()).compile.string + result.liftTo[IO].map { result => + expect.eql("""""", result) + } + } + + test("renders xml without self-closing tags if disabled") { + val result = + xml"""""".through(render(false)).compile.string + result.liftTo[IO].map { result => + expect.eql("""""", result) + } + } + +} From 1bbd3b2b9622ea2084d8f22deb4ae98bfef791ae Mon Sep 17 00:00:00 2001 From: Yannick Heiber Date: Tue, 4 Oct 2022 10:39:21 +0200 Subject: [PATCH 2/4] Fix 2.12 compilation of string interpolation --- xml/src/main/scala/fs2/data/xml/XmlEvent.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xml/src/main/scala/fs2/data/xml/XmlEvent.scala b/xml/src/main/scala/fs2/data/xml/XmlEvent.scala index 05108c7f3..728b4cad7 100644 --- a/xml/src/main/scala/fs2/data/xml/XmlEvent.scala +++ b/xml/src/main/scala/fs2/data/xml/XmlEvent.scala @@ -36,7 +36,7 @@ object XmlEvent { case class StartTag(name: QName, attributes: List[Attr], isEmpty: Boolean) extends XmlEvent { def render(collapseEmpty: Boolean): String = { val end = if (collapseEmpty && isEmpty) "/>" else ">" - show"<$name${attributes.foldMap { case Attr(n, v) => show" $n=\"${v.foldMap(_.render)}\"" }}$end" + show"""<$name${attributes.foldMap { case Attr(n, v) => show""" $n="${v.foldMap(_.render)}"""" }}$end""" } } From e20741bbd58fa6e6353060b908e9bf05594ab43a Mon Sep 17 00:00:00 2001 From: Yannick Heiber Date: Tue, 4 Oct 2022 10:52:32 +0200 Subject: [PATCH 3/4] Fix Scala 3 compilation of string interpolation --- xml/src/main/scala/fs2/data/xml/XmlEvent.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/xml/src/main/scala/fs2/data/xml/XmlEvent.scala b/xml/src/main/scala/fs2/data/xml/XmlEvent.scala index 728b4cad7..2564461d3 100644 --- a/xml/src/main/scala/fs2/data/xml/XmlEvent.scala +++ b/xml/src/main/scala/fs2/data/xml/XmlEvent.scala @@ -36,7 +36,9 @@ object XmlEvent { case class StartTag(name: QName, attributes: List[Attr], isEmpty: Boolean) extends XmlEvent { def render(collapseEmpty: Boolean): String = { val end = if (collapseEmpty && isEmpty) "/>" else ">" - show"""<$name${attributes.foldMap { case Attr(n, v) => show""" $n="${v.foldMap(_.render)}"""" }}$end""" + show"""<$name${attributes.foldMap[String] { case Attr(n, v) => + show""" $n="${v.foldMap[String](_.render)}"""" + }}$end""" } } From 10d2f56a673c7fa9adc6686ef9bf51bdbd39d643 Mon Sep 17 00:00:00 2001 From: Yannick Heiber Date: Tue, 4 Oct 2022 10:56:15 +0200 Subject: [PATCH 4/4] Refactor show code slightly for readability --- xml/src/main/scala/fs2/data/xml/XmlEvent.scala | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/xml/src/main/scala/fs2/data/xml/XmlEvent.scala b/xml/src/main/scala/fs2/data/xml/XmlEvent.scala index 2564461d3..620e2e703 100644 --- a/xml/src/main/scala/fs2/data/xml/XmlEvent.scala +++ b/xml/src/main/scala/fs2/data/xml/XmlEvent.scala @@ -36,9 +36,8 @@ object XmlEvent { case class StartTag(name: QName, attributes: List[Attr], isEmpty: Boolean) extends XmlEvent { def render(collapseEmpty: Boolean): String = { val end = if (collapseEmpty && isEmpty) "/>" else ">" - show"""<$name${attributes.foldMap[String] { case Attr(n, v) => - show""" $n="${v.foldMap[String](_.render)}"""" - }}$end""" + val attrs = attributes.foldMap[String] { case Attr(n, v) => show""" $n="${v.foldMap[String](_.render)}"""" } + show"""<$name$attrs$end""" } }