├── project ├── build.properties └── Dependencies.scala ├── .gitignore └── src ├── test └── scala │ └── net │ └── michalsitko │ ├── utils │ ├── CogenInstances.scala │ ├── XmlFragments.scala │ └── ArbitraryInstances.scala │ ├── laws │ └── LawsSpec.scala │ └── XmlOpticsSpec.scala └── main └── scala └── net └── michalsitko ├── naive └── XmlSupport.scala └── optics └── Optics.scala /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.15 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /RUNNING_PID 2 | /logs/ 3 | /project/*-shim.sbt 4 | /project/project/ 5 | /project/target/ 6 | /target/ 7 | /.idea -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object Dependencies { 4 | lazy val scalaTest = "org.scalatest" %% "scalatest" % "3.0.1" 5 | } 6 | -------------------------------------------------------------------------------- /src/test/scala/net/michalsitko/utils/CogenInstances.scala: -------------------------------------------------------------------------------- 1 | package net.michalsitko.utils 2 | 3 | import cats.data.NonEmptyList 4 | import org.scalacheck.Cogen 5 | 6 | import scala.xml.{Elem, NodeSeq} 7 | 8 | trait CogenInstances { 9 | implicit val nodeSeqCogen = Cogen[NodeSeq]((_ : NodeSeq).hashCode().toLong) 10 | implicit val nonEmptyListOfElemCogen = Cogen[NonEmptyList[Elem]]((_: NonEmptyList[Elem]).hashCode().toLong) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/scala/net/michalsitko/naive/XmlSupport.scala: -------------------------------------------------------------------------------- 1 | package net.michalsitko.naive 2 | 3 | import scala.xml.{Elem, Node, NodeSeq} 4 | 5 | // whole "naive" packages should be eventually abandoned 6 | // it may be useful just for documentary purposes - to show motivation behind the project 7 | // it contains some naive helper function that try to overcome some scala-xml API quirks 8 | trait XmlSupport { 9 | def deeper(toReplace: String)(fn: PartialFunction[Node, NodeSeq]): PartialFunction[Node, NodeSeq] = { 10 | case elem: Elem if (elem.label == toReplace) => 11 | elem.copy(child = elem.child.flatMap(fn)) 12 | case e => 13 | e 14 | } 15 | 16 | def update(toReplace: String)(fn: PartialFunction[Node, Node]): PartialFunction[Node, NodeSeq] = { 17 | case elem: Elem if (elem.label == toReplace) => 18 | elem.map(fn) 19 | case e => e 20 | } 21 | 22 | def extract(parent: NodeSeq, fieldName: String): Option[String] = { 23 | (parent \ fieldName).headOption.map(_.text) 24 | } 25 | 26 | def getAttr(parent: NodeSeq, attrName: String): Option[String] = { 27 | parent.headOption.flatMap(_.headOption).flatMap(_.attribute(attrName)).map(_.text) 28 | } 29 | } 30 | 31 | object XmlSupport extends XmlSupport 32 | -------------------------------------------------------------------------------- /src/test/scala/net/michalsitko/laws/LawsSpec.scala: -------------------------------------------------------------------------------- 1 | package net.michalsitko.laws 2 | 3 | import cats.data.NonEmptyList 4 | import monocle.internal.IsEq 5 | import monocle.law.discipline.{LensTests, OptionalTests} 6 | import monocle.law.{LensLaws, PrismLaws} 7 | import net.michalsitko.optics.Optics 8 | import net.michalsitko.utils.{ArbitraryInstances, CogenInstances, XmlFragments} 9 | import org.scalacheck.Arbitrary 10 | import org.scalactic.anyvals.PosZInt 11 | import org.scalatest.matchers.{MatchResult, Matcher} 12 | import org.scalatest.prop.Checkers 13 | import org.scalatest.{FlatSpec, Matchers, WordSpec} 14 | import org.typelevel.discipline.Laws 15 | 16 | import scala.xml._ 17 | import scala.xml.Utility 18 | import scalaz.Equal 19 | 20 | class LawsSpec extends OpticsSpec with Matchers with ArbitraryInstances with CogenInstances { 21 | // implicit val nodeEqualInstance: Equal[Node] = (n1: Node, n2: Node) => n1 == n2 22 | // implicit val nodeSeqEqualInstance: Equal[NodeSeq] = (n1: NodeSeq, n2: NodeSeq) => n1 == n2 23 | 24 | implicit val elemEqual: Equal[Elem] = (e1: Elem, e2: Elem) => { 25 | // TODO: not sure it's proper. Better than `e1 == e2` which has a problem with whitespaces 26 | Utility.trim(e1) == Utility.trim(e2) 27 | } 28 | implicit val nonEmptyListOfElemsEqual: Equal[NonEmptyList[Elem]] = { 29 | (l1: NonEmptyList[Elem], l2: NonEmptyList[Elem]) => 30 | val elemEq = implicitly[Equal[Elem]] 31 | val (list1, list2) = (l1.toList, l2.toList) 32 | list1.size == list2.size && list1.zip(list2).forall(t => elemEq.equal(t._1, t._2)) 33 | } 34 | 35 | 36 | val elem: Elem = XML.loadString(XmlFragments.simpleAsString) 37 | 38 | // implicit val arbNode = Arbitrary[Node](elemWithLabelOccurance(4, "abc")) 39 | // implicit val arbNodeSeq = Arbitrary(nodeSeq(2)) 40 | implicit val arbElem = Arbitrary[Elem](elemWithLabelOccurance(2, "abc")) 41 | implicit val arbElems = arbNonEmptyListOfElems(arbElem) 42 | 43 | // val nodeLens = LensTests(Optics.nodeLens("abc")) 44 | val elemTest = OptionalTests(Optics.elem("abc")) 45 | 46 | checkLaws("elem Optional", elemTest) 47 | 48 | } 49 | 50 | trait OpticsSpec extends FlatSpec { 51 | def checkLaws(name: String, ruleSet: Laws#RuleSet, maxSize: Int = 100): Unit = { 52 | val configParams = List(Checkers.MinSuccessful(15), Checkers.SizeRange(PosZInt.from(maxSize).get)) 53 | 54 | ruleSet.all.properties.zipWithIndex.foreach { 55 | case ((id, prop), 0) => name should s"obey $id" in Checkers.check(prop, configParams:_*) 56 | case ((id, prop), _) => it should s"obey $id" in Checkers.check(prop, configParams:_*) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/test/scala/net/michalsitko/utils/XmlFragments.scala: -------------------------------------------------------------------------------- 1 | package net.michalsitko.utils 2 | 3 | trait XmlFragments { 4 | val xmlAsString = 5 | """ 6 | | 7 | | 8 | | 9 | | 10 | | 11 | | item1 12 | | item2 13 | | 14 | | 15 | | item1 16 | | item2 17 | | item3 18 | | item1 19 | | 20 | | 21 | | item1 22 | | item2 23 | | item3 24 | | item1 25 | | 26 | | 27 | | summary 28 | | 29 | | 30 | | 31 | """.stripMargin 32 | 33 | val simpleAsString = 34 | """ 35 | | 36 | | 37 | | item1 38 | | item2 39 | | 40 | | 41 | | item1 42 | | item2 43 | | 44 | | 45 | | item1 46 | | item2 47 | | item3 48 | | 49 | | summary 50 | | 51 | """.stripMargin 52 | 53 | val verySimpleString = 54 | """ 55 | | 56 | | 57 | | item1 58 | | item2 59 | | 60 | | 61 | | item1 62 | | item2 63 | | item3 64 | | 65 | | summary 66 | | 67 | """.stripMargin 68 | 69 | // TODO: to remove 70 | val xmlString1 = 71 | """ 72 | | 73 | | 74 | | item1 75 | | 76 | | 77 | """.stripMargin 78 | 79 | val xmlString2 = 80 | """ 81 | | 82 | | 83 | | item1 84 | | 85 | | 86 | """.stripMargin 87 | 88 | val asLiteral = 89 | 90 | 91 | 92 | 93 | 94 | item1 95 | item2 96 | 97 | 98 | item1 99 | item2 100 | item3 101 | 102 | 103 | summary 104 | 105 | 106 | 107 | } 108 | 109 | object XmlFragments extends XmlFragments 110 | -------------------------------------------------------------------------------- /src/main/scala/net/michalsitko/optics/Optics.scala: -------------------------------------------------------------------------------- 1 | package net.michalsitko.optics 2 | 3 | import cats.data.NonEmptyList 4 | import monocle.{Lens, Optional, Prism, Traversal} 5 | 6 | import scala.xml.{Elem, Node, NodeSeq} 7 | import scalaz.Applicative 8 | import scalaz.std.list._ 9 | 10 | // a messy attempt to implement Optics over scala-xml 11 | trait Optics { 12 | 13 | def elem(label: String): Optional[Elem, NonEmptyList[Elem]] = Optional[Elem, NonEmptyList[Elem]]{ 14 | parent => 15 | val nodes = parent \ label 16 | val elems = nodes.foldRight(List.empty[Elem]) { (current, acc) => 17 | current match { 18 | case elem: Elem => elem :: acc 19 | } 20 | } 21 | NonEmptyList.fromList(elems) 22 | }{ newElems => parent => 23 | val it = newElems.toList.toIterator 24 | val children = parent.child.flatMap { 25 | case el: Elem if el.label == label => 26 | if(it.hasNext){ 27 | Some(it.next()) 28 | } else { 29 | None 30 | } 31 | case el => 32 | Some(el) 33 | } 34 | parent.copy(child = children) 35 | } 36 | 37 | def nodeLens(fieldName: String): Lens[Node, NodeSeq] = Lens.apply[Node, NodeSeq]( 38 | elem => elem \ fieldName 39 | ){newNodeSeq => rootNode => 40 | val it = newNodeSeq.toIterator 41 | val children = rootNode.child.flatMap { 42 | case el: Elem if el.label == fieldName => 43 | if(it.hasNext){ 44 | Some(it.next()) 45 | } else { 46 | None 47 | } 48 | case el => 49 | Some(el) 50 | } 51 | rootNode.asInstanceOf[Elem].copy(child = children) 52 | } 53 | 54 | def nodeLens2(fieldName: String): Lens[NodeSeq, NodeSeq] = Lens.apply[NodeSeq, NodeSeq]( 55 | elem => elem \ fieldName 56 | ){newNodeSeq => rootNode => 57 | val it = newNodeSeq.toIterator 58 | val r = rootNode.map { 59 | case e: Elem => 60 | val children = e.child.flatMap { 61 | case el: Elem if el.label == fieldName => 62 | if(it.hasNext){ 63 | Some(it.next) 64 | } else { 65 | None 66 | } 67 | case el => 68 | Some(el) 69 | } 70 | e.copy(child = children) 71 | case e => e 72 | } 73 | NodeSeq.fromSeq(r) 74 | } 75 | 76 | val elemPrism: Prism[Node, Elem] = Prism[Node, Elem](node => node match { 77 | case e: Elem => Some(e) 78 | case _ => None 79 | })(el => el) 80 | 81 | val each = new Traversal[NodeSeq, Node]{ 82 | final def modifyF[F[_]](f: Node => F[Node])(from: NodeSeq)(implicit F: Applicative[F]): F[NodeSeq] = { 83 | val mapped = from.theSeq.map(f).toList 84 | F.map(F.sequence(mapped))(nodes => NodeSeq.fromSeq(nodes)) 85 | } 86 | } 87 | 88 | } 89 | 90 | object Optics extends Optics 91 | -------------------------------------------------------------------------------- /src/test/scala/net/michalsitko/utils/ArbitraryInstances.scala: -------------------------------------------------------------------------------- 1 | package net.michalsitko.utils 2 | 3 | import cats.data.NonEmptyList 4 | import org.scalacheck.{Arbitrary, Gen} 5 | 6 | import scala.xml._ 7 | import scala.collection.JavaConverters._ 8 | 9 | trait ArbitraryInstances { 10 | def arbitraryNode(depth: Int): Arbitrary[Node] = ??? 11 | 12 | def elemWithLabelOccurance(depth: Int, label: String): Gen[Elem] = { 13 | elemOfDepth(depth, Some(label)) 14 | } 15 | 16 | def nodeSeq(depth: Int): Gen[NodeSeq] = { 17 | Gen.choose(0, 2).flatMap(n => Gen.listOfN(n, elemOfDepth(depth, None))).map(NodeSeq.fromSeq(_)) 18 | } 19 | 20 | def arbNonEmptyListOfElems(arbElem: Arbitrary[Elem]): Arbitrary[NonEmptyList[Elem]] = 21 | Arbitrary(arbElem.arbitrary.flatMap(Gen.nonEmptyListOf(_).map(NonEmptyList.fromListUnsafe(_)))) 22 | 23 | private val maxAttrsLength = 2 24 | private val maxDirectChilden = 3 25 | 26 | // TODO: Not sure if retryUntil is the way to go 27 | private val alphanumGen: Gen[String] = Gen.alphaStr.map(_.take(9) + "a") 28 | 29 | private val attrGen = for { 30 | attrName <- alphanumGen 31 | attrVal <- alphanumGen 32 | } yield new UnprefixedAttribute(attrName, new Text(attrVal), Null) 33 | 34 | private val attrsGen = for { 35 | length <- Gen.choose(0, maxAttrsLength) 36 | attributes <- Gen.listOfN(length, attrGen) 37 | } yield attributes 38 | 39 | private val linkedAttrs: Gen[MetaData] = attrsGen.map { listOfAttrs => 40 | listOfAttrs.foldLeft[MetaData](Null){ (prev, current) => 41 | new UnprefixedAttribute(current.key, current.value, prev) 42 | } 43 | } 44 | 45 | private def elemOfDepth(desiredDepth: Int, label: Option[String]): Gen[Elem] = { 46 | // TODO: non-tail recursion 47 | def loop(depth: Int): Gen[List[Elem]] = { 48 | if(depth == 0) { 49 | Gen.choose(1, maxDirectChilden).flatMap(n => Gen.listOfN(n, leafElem(List(Text("Hello XML")), label))) 50 | } else { 51 | val usedLabel = if(depth - 1 == desiredDepth) None else label 52 | for { 53 | newChildrenNumber <- Gen.choose(1, maxDirectChilden) 54 | children <- Gen.sequence(List.fill(newChildrenNumber)(loop(depth - 1))) 55 | currentNodes <- Gen.sequence(children.asScala.toList.map(ch => leafElem(ch, usedLabel))) 56 | currentNodesList <- currentNodes.asScala.toList 57 | } yield currentNodesList 58 | } 59 | } 60 | 61 | loop(desiredDepth - 1).map(_.head) 62 | } 63 | 64 | private def leafElem(children: List[Node], label: Option[String]): Gen[Elem] = { 65 | val labelGen = label match { 66 | case Some(desiredLabel) => 67 | Gen.frequency[String]((9, alphanumGen), (1, Gen.const(desiredLabel))) 68 | case None => 69 | alphanumGen 70 | } 71 | 72 | for { 73 | label <- labelGen 74 | attrs <- linkedAttrs 75 | } yield newElem(label, attrs, children) 76 | } 77 | 78 | private def newElem(label: String, attributes: MetaData, children: List[Node]) = 79 | Elem(prefix = null, label = label, attributes = attributes, scope = TopScope, 80 | minimizeEmpty = false, child = children:_*) 81 | } 82 | 83 | object ArbitraryInstances extends ArbitraryInstances 84 | -------------------------------------------------------------------------------- /src/test/scala/net/michalsitko/XmlOpticsSpec.scala: -------------------------------------------------------------------------------- 1 | package net.michalsitko 2 | 3 | import monocle.{PTraversal, Traversal} 4 | import net.michalsitko.utils.XmlFragments 5 | import org.scalatest._ 6 | 7 | import scala.xml._ 8 | 9 | class XmlOpticsSpec extends WordSpec with Matchers with XmlFragments with Solutions { 10 | "naive solution" should { 11 | "work for text replacement" in { 12 | val simpleXml = XML.loadString(simpleAsString) 13 | 14 | val res = naive(simpleXml) 15 | 16 | val expectedXml = XML.loadString(ExpectedValues.simpleAsStringAfterTextReplacement) 17 | res.head should equal(expectedXml) 18 | } 19 | } 20 | 21 | "naiveXmlSupport" should { 22 | "work for text replacement" in { 23 | val simpleXml = XML.loadString(simpleAsString) 24 | 25 | val res = naiveXmlSupport(simpleXml) 26 | 27 | val expectedXml = XML.loadString(ExpectedValues.simpleAsStringAfterTextReplacement) 28 | res.head should equal(expectedXml) 29 | } 30 | 31 | "work for more complicated text replacement" in { 32 | val simpleXml = XML.loadString(xmlAsString) 33 | 34 | val res = naiveXmlSupport2(simpleXml) 35 | 36 | val expectedXml = XML.loadString(ExpectedValues.xmlAsStringAfterTextReplacement) 37 | res.head should equal(expectedXml) 38 | } 39 | } 40 | 41 | "Optics" should { 42 | "work for text replacement" in { 43 | val simpleXml = XML.loadString(simpleAsString) 44 | 45 | val res = withOptics(simpleXml) 46 | 47 | val expectedXml = XML.loadString(ExpectedValues.simpleAsStringAfterTextReplacement) 48 | res should equal(expectedXml) 49 | } 50 | 51 | "work for more complicated text replacement" in { 52 | val simpleXml = XML.loadString(xmlAsString) 53 | 54 | val res = withOptics2(simpleXml) 55 | 56 | val expectedXml = XML.loadString(ExpectedValues.xmlAsStringAfterTextReplacement) 57 | res should equal(expectedXml) 58 | } 59 | 60 | // TODO: to remove 61 | "tmp1" in { 62 | import net.michalsitko.optics.Optics._ 63 | 64 | val simpleXml = XML.loadString(xmlString1) 65 | 66 | val focused = nodeLens("c1").composeLens(nodeLens2("f")).set(NodeSeq.fromSeq(Seq.empty)) 67 | val res = focused(simpleXml) 68 | 69 | val expectedXml = XML.loadString(""" 70 | | 71 | | 72 | | 73 | | 74 | | 75 | """.stripMargin) 76 | 77 | val trimmedEqual = scala.xml.Utility.trim(res) == scala.xml.Utility.trim(expectedXml) 78 | 79 | trimmedEqual should equal(true) 80 | } 81 | 82 | "tmp2" in { 83 | import net.michalsitko.optics.Optics._ 84 | 85 | val simpleXml = XML.loadString(xmlString1) 86 | 87 | val newElem = txt 88 | val focused = nodeLens("c1").composeLens(nodeLens2("f")).set(NodeSeq.fromSeq(List(newElem))) 89 | val res = focused(simpleXml) 90 | 91 | val expectedXml = XML.loadString(""" 92 | | 93 | | 94 | | txt 95 | | 96 | | 97 | """.stripMargin) 98 | 99 | val trimmedEqual = scala.xml.Utility.trim(res) == scala.xml.Utility.trim(expectedXml) 100 | 101 | trimmedEqual should equal(true) 102 | } 103 | 104 | "tmp3" in { 105 | import net.michalsitko.optics.Optics._ 106 | 107 | val simpleXml = XML.loadString(xmlString2) 108 | 109 | val newElem = NodeSeq.fromSeq(List(txt)) 110 | val focused = nodeLens("c1").composeLens(nodeLens2("f")) 111 | val res = focused.set(newElem)(simpleXml) 112 | 113 | val newF = focused.get(simpleXml) 114 | 115 | newF should equal(newElem) 116 | } 117 | } 118 | 119 | } 120 | 121 | trait Solutions { 122 | def naive(elem: Elem): NodeSeq = { 123 | elem.map { 124 | case aElem: Elem if (aElem.label == "a") => 125 | aElem.copy(child = aElem.child.flatMap { 126 | case c1Elem: Elem if (c1Elem.label == "c1") => 127 | c1Elem.copy(child = c1Elem.child.flatMap { 128 | case fElem: Elem if (fElem.label == "f") => 129 | fElem.copy(child = List(Text("f replaced"))) 130 | case el => el 131 | }) 132 | case el => el 133 | }) 134 | case el => el 135 | } 136 | } 137 | 138 | def naiveXmlSupport(elem: Elem): NodeSeq = { 139 | import net.michalsitko.naive.XmlSupport._ 140 | elem.map { 141 | deeper("a")(deeper("c1")(update("f"){ 142 | case elem: Elem => elem.copy(child = List(Text("f replaced"))) 143 | })) 144 | }.head 145 | } 146 | 147 | def naiveXmlSupport2(elem: Elem): NodeSeq = { 148 | import net.michalsitko.naive.XmlSupport._ 149 | elem.map { 150 | deeper("a")(deeper("b")(deeper("c")(deeper("d")(deeper("e2")(update("f"){ 151 | case elem: Elem => elem.copy(child = List(Text("f replaced"))) 152 | }))))) 153 | }.head 154 | } 155 | 156 | def withOptics(element: Elem): NodeSeq = { 157 | import net.michalsitko.optics.Optics._ 158 | 159 | val focused = (nodeLens("c1").composeLens(nodeLens2("f"))).composeTraversal(each.composePrism(elemPrism)) 160 | focused.modify(_.copy(child = List(Text("f replaced"))))(element) 161 | } 162 | 163 | def withOptics2(element: Elem): NodeSeq = { 164 | import net.michalsitko.optics.Optics._ 165 | 166 | val composed = 167 | nodeLens("b").composeLens(nodeLens2("c")).composeLens(nodeLens2("d")).composeLens(nodeLens2("e2")).composeLens(nodeLens2("f")) 168 | val focused = composed.composeTraversal(each.composePrism(elemPrism)) 169 | focused.modify(_.copy(child = List(Text("f replaced"))))(element) 170 | } 171 | } 172 | 173 | object ExpectedValues { 174 | val simpleAsStringAfterTextReplacement = 175 | """ 176 | | 177 | | 178 | | f replaced 179 | | item2 180 | | 181 | | 182 | | f replaced 183 | | item2 184 | | 185 | | 186 | | item1 187 | | item2 188 | | item3 189 | | 190 | | summary 191 | | 192 | """.stripMargin 193 | 194 | val xmlAsStringAfterTextReplacement = 195 | """ 196 | | 197 | | 198 | | 199 | | 200 | | 201 | | item1 202 | | item2 203 | | 204 | | 205 | | f replaced 206 | | item2 207 | | item3 208 | | f replaced 209 | | 210 | | 211 | | f replaced 212 | | item2 213 | | item3 214 | | f replaced 215 | | 216 | | 217 | | summary 218 | | 219 | | 220 | | 221 | """.stripMargin 222 | } 223 | --------------------------------------------------------------------------------