├── .github └── workflows │ ├── ci.yml │ ├── docs.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── build.sbt ├── docs-gen └── src │ └── main │ └── scala │ └── zipper │ └── Docs.scala ├── docs ├── _config.yml ├── images │ ├── both.png │ ├── modified.png │ └── tree.png └── index.md ├── project ├── build.properties └── plugins.sbt └── shared └── src ├── main ├── scala-2.12 │ └── zipper │ │ └── ForImplScalaVersionSpecific.scala ├── scala-2.13 │ └── zipper │ │ └── ForImplScalaVersionSpecific.scala ├── scala-2 │ └── zipper │ │ └── ForImpl.scala ├── scala-3 │ └── zipper │ │ ├── ForImpl.scala │ │ └── contrib │ │ └── shapeless3 │ │ ├── Replacer.scala │ │ └── Selector.scala └── scala │ └── zipper │ ├── GenericUnzipInstances.scala │ └── Zipper.scala └── test └── scala └── zipper ├── UnzipDerivationSpec.scala └── ZipperSpec.scala /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | tests: 5 | name: Run tests 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: actions/setup-java@v4 10 | with: 11 | distribution: 'temurin' 12 | java-version: '17' 13 | - uses: coursier/cache-action@v6 14 | - name: Run tests 15 | run: | 16 | sbt "+Test/compile; +test" 17 | docs: 18 | name: Build docs 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-java@v4 23 | with: 24 | distribution: 'temurin' 25 | java-version: '17' 26 | - uses: coursier/cache-action@v6 27 | - run: sbt docs/run 28 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: [push] 3 | jobs: 4 | docs: 5 | name: Build docs 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: actions/setup-java@v4 10 | with: 11 | distribution: 'temurin' 12 | java-version: '17' 13 | - uses: coursier/cache-action@v6 14 | - run: sbt docs/run 15 | - uses: peaceiris/actions-gh-pages@v4 16 | with: 17 | github_token: ${{ secrets.GITHUB_TOKEN }} 18 | enable_jekyll: true 19 | publish_dir: ./docs-gen/target/mdoc/. 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: ["*"] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | with: 11 | fetch-depth: 0 12 | - uses: actions/setup-java@v4 13 | with: 14 | distribution: 'temurin' 15 | java-version: '17' 16 | - uses: coursier/cache-action@v6 17 | - run: sbt ci-release 18 | env: 19 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 20 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 21 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 22 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .idea 3 | .bsp 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Nick Stanchenko 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Zipper — an implementation of Huet’s Zipper 2 | 3 | A Zipper is a tool that allows to navigate and modify immutable recursive data structures. 4 | This implementation is inspired by 5 | [the original paper by Huet](https://www.st.cs.uni-saarland.de/edu/seminare/2005/advanced-fp/docs/huet-zipper.pdf), 6 | as well as the [Argonaut’s JSON Zipper](http://argonaut.io/doc/zipper/). 7 | 8 | See https://stanch.github.io/zipper for more details. 9 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | val Scala212 = "2.12.19" 2 | val Scala213 = "2.13.14" 3 | val Scala3 = "3.3.3" 4 | 5 | val commonSettings = Seq( 6 | scalaVersion := Scala213, 7 | crossScalaVersions := Seq(Scala212, Scala213, Scala3), 8 | scalacOptions ++= { 9 | val commonScalacOptions = 10 | Seq( 11 | "-feature", 12 | "-deprecation", 13 | "-Xfatal-warnings" 14 | ) 15 | val scala2Options = 16 | Seq( 17 | "-Xlint" 18 | ) 19 | 20 | scalaVersion.value match { 21 | case v if v.startsWith("2.12") => 22 | Seq( 23 | "-Ypartial-unification", 24 | "-Ywarn-unused-import" 25 | ) ++ commonScalacOptions ++ scala2Options 26 | case v if v.startsWith("2.13") => 27 | commonScalacOptions ++ scala2Options 28 | case _ => 29 | commonScalacOptions 30 | } 31 | } 32 | ) ++ metadata 33 | 34 | lazy val metadata = Seq( 35 | organization := "io.github.stanch", 36 | homepage := Some(url("https://stanch.github.io/zipper/")), 37 | scmInfo := Some(ScmInfo( 38 | url("https://github.com/stanch/zipper"), 39 | "scm:git@github.com:stanch/zipper.git" 40 | )), 41 | developers := List(Developer( 42 | id="stanch", 43 | name="Nick Stanchenko", 44 | email="nick.stanch@gmail.com", 45 | url=url("https://github.com/stanch") 46 | )), 47 | licenses += ("MIT", url("http://opensource.org/licenses/MIT")) 48 | ) 49 | 50 | lazy val zipper = crossProject(JSPlatform, JVMPlatform).in(file(".")) 51 | .settings(commonSettings) 52 | .settings( 53 | name := "zipper", 54 | libraryDependencies += { 55 | scalaVersion.value match { 56 | case v if v.startsWith("2") => 57 | "com.chuusai" %%% "shapeless" % "2.3.10" 58 | case _ => 59 | "org.typelevel" %%% "shapeless3-deriving" % "3.4.1" 60 | } 61 | }, 62 | libraryDependencies ++= Seq( 63 | "org.scalatest" %%% "scalatest-flatspec" % "3.2.18" % Test, 64 | "org.scalatest" %%% "scalatest-shouldmatchers" % "3.2.18" % Test 65 | ) 66 | ) 67 | 68 | lazy val zipperJVM = zipper.jvm 69 | lazy val zipperJS = zipper.js 70 | 71 | lazy val docs = project 72 | .in(file("docs-gen")) 73 | .enablePlugins(MdocPlugin, BuildInfoPlugin) 74 | .dependsOn(zipperJVM) 75 | .settings(commonSettings) 76 | .settings( 77 | name := "zipper-docs", 78 | moduleName := "zipper-docs", 79 | (publish / skip) := true, 80 | mdoc := (Compile / run).evaluated, 81 | (Compile / mainClass) := Some("zipper.Docs"), 82 | (Compile / resources) ++= { 83 | List((ThisBuild / baseDirectory).value / "docs") 84 | }, 85 | buildInfoKeys := Seq[BuildInfoKey](version), 86 | buildInfoPackage := "zipper.build" 87 | ) 88 | 89 | lazy val root = project.in(file(".")) 90 | .aggregate(zipperJVM, zipperJS, docs) 91 | .settings(commonSettings) 92 | .settings( 93 | name := "zipper-root", 94 | publish := {}, 95 | publishLocal := {}, 96 | publishArtifact := false 97 | ) 98 | 99 | addCommandAlias("cov", ";coverage; test; coverageOff; coverageReport") 100 | -------------------------------------------------------------------------------- /docs-gen/src/main/scala/zipper/Docs.scala: -------------------------------------------------------------------------------- 1 | package zipper 2 | 3 | import zipper.build.BuildInfo.version 4 | 5 | object Docs { 6 | def main(args: Array[String]): Unit = { 7 | val settings = mdoc.MainSettings() 8 | .withSiteVariables(Map("VERSION" -> version)) 9 | .withArgs(args.toList) 10 | 11 | val exitCode = mdoc.Main.process(settings) 12 | if (exitCode != 0) sys.exit(exitCode) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /docs/images/both.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stanch/zipper/302ab4d82d9a1aa8bc2b10594f32f23715ac84ed/docs/images/both.png -------------------------------------------------------------------------------- /docs/images/modified.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stanch/zipper/302ab4d82d9a1aa8bc2b10594f32f23715ac84ed/docs/images/modified.png -------------------------------------------------------------------------------- /docs/images/tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stanch/zipper/302ab4d82d9a1aa8bc2b10594f32f23715ac84ed/docs/images/tree.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ## Zipper — an implementation of Huet’s Zipper 2 | 3 | A Zipper is a tool that allows to navigate and modify immutable recursive data structures. 4 | This implementation is inspired by 5 | [the original paper by Huet](https://www.st.cs.uni-saarland.de/edu/seminare/2005/advanced-fp/docs/huet-zipper.pdf), 6 | as well as the [Argonaut’s JSON Zipper](http://argonaut.io/doc/zipper/). 7 | 8 | Consider the following example: 9 | 10 | ```scala mdoc:silent 11 | // Define a tree data structure 12 | case class Tree(x: Int, c: List[Tree] = List.empty) 13 | 14 | // Create a tree 15 | val tree = Tree( 16 | 1, List( 17 | Tree( 18 | 11, List( 19 | Tree(111), 20 | Tree(112) 21 | ) 22 | ), 23 | Tree( 24 | 12, List( 25 | Tree(121), 26 | Tree( 27 | 122, List( 28 | Tree(1221), 29 | Tree(1222) 30 | ) 31 | ), 32 | Tree(123) 33 | ) 34 | ), 35 | Tree(13) 36 | ) 37 | ) 38 | ``` 39 | 40 | 41 | 42 | Since the tree is immutable, modifying it can be a pain, 43 | but it’s easily solved with a Zipper: 44 | 45 | ```scala mdoc:silent 46 | import zipper._ 47 | 48 | // Use a Zipper to move around and change data 49 | val modified = { 50 | Zipper(tree) 51 | .moveDownAt(1) // 12 52 | .moveDownRight // 123 53 | .deleteAndMoveLeft // 122 54 | .moveDownLeft // 1221 55 | .update(_.copy(x = -1)) 56 | .moveRight // 1222 57 | .set(Tree(-2)) 58 | .moveUp // 122 59 | .moveUp // 12 60 | .rewindLeft // 11 61 | .moveDownRight // 112 62 | .moveLeftBy(1) // 111 63 | .deleteAndMoveUp // 11 64 | .commit // commit the changes and return the result 65 | } 66 | ``` 67 | 68 | Here’s what the modified tree looks like: 69 | 70 | 71 | 72 | If we draw both trees side by side, we’ll see that 73 | the unchanged parts are shared: 74 | 75 | 76 | 77 | ### Usage 78 | 79 | `zipper` is available for Scala 2.12, 2.13 and 3.3+. Include these lines in your `build.sbt`: 80 | 81 | ```scala 82 | // for JVM 83 | libraryDependencies += "io.github.stanch" %% "zipper" % "@VERSION@" 84 | 85 | // for Scala.js 86 | libraryDependencies += "io.github.stanch" %%% "zipper" % "@VERSION@" 87 | ``` 88 | 89 | #### Unzip 90 | 91 | In order for the Zipper to work on your data structure `Tree`, you need an implicit instance of `Unzip[Tree]`. 92 | `Unzip` is defined as follows: 93 | 94 | ```scala 95 | trait Unzip[A] { 96 | def unzip(node: A): List[A] 97 | def zip(node: A, children: List[A]): A 98 | } 99 | ``` 100 | 101 | As we saw before, the library can automatically derive `Unzip[Tree]` 102 | if the `Tree` is a case class that has a single field of type `List[Tree]`. 103 | It is also possible to derive an `Unzip[Tree]` for similar cases, but with other collections: 104 | 105 | ```scala mdoc:reset 106 | import zipper._ 107 | 108 | case class Tree(x: Int, c: Vector[Tree] = Vector.empty) 109 | 110 | implicit val unzip: Unzip[Tree] = Unzip.For[Tree, Vector].derive 111 | ``` 112 | 113 | The automatic derivation is powered by [shapeless](https://github.com/milessabin/shapeless). 114 | 115 | #### Moves, failures and recovery 116 | 117 | There are many operations defined on a `Zipper`. 118 | Some of them are not safe, e.g. `moveLeft` will fail with an exception 119 | if there are no elements on the left. 120 | For all unsafe operations a safe version is provided, which is prefixed with `try`. 121 | These operations return a `Zipper.MoveResult`, which allows to recover from the failure or return to the original state: 122 | 123 | ```scala mdoc 124 | val newTree = Tree(1, Vector(Tree(3), Tree(4))) 125 | 126 | val newModified = 127 | Zipper(newTree) 128 | .moveDownLeft 129 | .tryMoveLeft.getOrElse(_.insertLeft(Tree(2)).moveLeft) 130 | .commit 131 | ``` 132 | 133 | #### Loops 134 | 135 | `Zipper` provides a looping functionality, which can be useful with recursive data: 136 | 137 | ```scala mdoc 138 | import zipper._ 139 | 140 | val anotherTree = Tree(1, Vector(Tree(2), Tree(3), Tree(5))) 141 | 142 | val anotherModified = 143 | Zipper(anotherTree) 144 | .moveDownLeft 145 | .repeatWhile(_.x < 5, _.tryMoveRight) 146 | .insertRight(Tree(4)) 147 | .commit 148 | ``` 149 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.10.1 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | //addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.3.5") 2 | 3 | addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.5.2") 4 | 5 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") 6 | addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2") 7 | 8 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.12.0") 9 | 10 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.12") 11 | -------------------------------------------------------------------------------- /shared/src/main/scala-2.12/zipper/ForImplScalaVersionSpecific.scala: -------------------------------------------------------------------------------- 1 | package zipper 2 | 3 | import shapeless.{HList, Generic} 4 | import shapeless.ops.hlist.{Selector, Replacer} 5 | 6 | import scala.collection.generic.CanBuildFrom 7 | import scala.language.higherKinds 8 | 9 | private[zipper] trait ForImplScalaVersionSpecific { 10 | class For[A, Coll[X] <: Seq[X]] { 11 | /** Derive an instance of `Unzip[A]` */ 12 | def derive[L <: HList]( 13 | implicit generic: Generic.Aux[A, L], 14 | select: Selector[L, Coll[A]], 15 | replace: Replacer.Aux[L, Coll[A], Coll[A], (Coll[A], L)], 16 | cbf: CanBuildFrom[Coll[A], A, Coll[A]] 17 | ): Unzip[A] = new Unzip[A] { 18 | def unzip(node: A): List[A] = select(generic.to(node)).toList 19 | 20 | def zip(node: A, children: List[A]): A = 21 | generic.from(replace(generic.to(node), (cbf() ++= children).result())._2) 22 | } 23 | } 24 | 25 | /** A helper for deriving [[zipper.Unzip]] instances for collections other than List */ 26 | object For { 27 | /** 28 | * @tparam A The type of the tree-like data structure 29 | * @tparam Coll The type of the collection used for recursion (e.g. Vector) 30 | */ 31 | def apply[A, Coll[X] <: Seq[X]] = new For[A, Coll] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /shared/src/main/scala-2.13/zipper/ForImplScalaVersionSpecific.scala: -------------------------------------------------------------------------------- 1 | package zipper 2 | 3 | import shapeless.{Generic, HList} 4 | import shapeless.ops.hlist.{Replacer, Selector} 5 | 6 | import scala.collection.Factory 7 | 8 | private[zipper] trait ForImplScalaVersionSpecific { 9 | class For[A, Coll[X] <: Seq[X]] { 10 | /** Derive an instance of `Unzip[A]` */ 11 | def derive[L <: HList]( 12 | implicit generic: Generic.Aux[A, L], 13 | select: Selector[L, Coll[A]], 14 | replace: Replacer.Aux[L, Coll[A], Coll[A], (Coll[A], L)], 15 | factory: Factory[A, Coll[A]] 16 | ): Unzip[A] = new Unzip[A] { 17 | def unzip(node: A): List[A] = select(generic.to(node)).toList 18 | 19 | def zip(node: A, children: List[A]): A = 20 | generic.from(replace(generic.to(node), children.to(factory))._2) 21 | } 22 | } 23 | 24 | /** A helper for deriving [[zipper.Unzip]] instances for collections other than List */ 25 | object For { 26 | /** 27 | * @tparam A The type of the tree-like data structure 28 | * @tparam Coll The type of the collection used for recursion (e.g. Vector) 29 | */ 30 | def apply[A, Coll[X] <: Seq[X]] = new For[A, Coll] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /shared/src/main/scala-2/zipper/ForImpl.scala: -------------------------------------------------------------------------------- 1 | package zipper 2 | 3 | import shapeless.{Generic, HList} 4 | import shapeless.ops.hlist.{Replacer, Selector} 5 | 6 | private[zipper] trait ForImpl extends ForImplScalaVersionSpecific { 7 | implicit def `Unzip List-based`[A, L <: HList]( 8 | implicit generic: Generic.Aux[A, L], 9 | select: Selector[L, List[A]], 10 | replace: Replacer.Aux[L, List[A], List[A], (List[A], L)] 11 | ): Unzip[A] = new Unzip[A] { 12 | def unzip(node: A): List[A] = select(generic.to(node)) 13 | 14 | def zip(node: A, children: List[A]): A = generic.from(replace(generic.to(node), children)._2) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /shared/src/main/scala-3/zipper/ForImpl.scala: -------------------------------------------------------------------------------- 1 | package zipper 2 | 3 | import contrib.shapeless3.{Replacer, Selector} 4 | import shapeless3.deriving.K0 5 | import shapeless3.deriving.K0.* 6 | 7 | import scala.collection.Factory 8 | 9 | private[zipper] trait ForImpl { 10 | given unzipListBased[A, L <: Tuple](using 11 | generic: K0.ProductGeneric[A] { type MirroredElemTypes = L }, 12 | selector: Selector[L, List[A]], 13 | replacer: Replacer.Aux[L, List[A], List[A], (List[A], L)] 14 | ): Unzip[A] with { 15 | def unzip(node: A): List[A] = selector(generic.toRepr(node)) 16 | def zip(node: A, children: List[A]): A = { 17 | val repr = replacer(generic.toRepr(node), children) 18 | generic.fromRepr(repr._2) 19 | } 20 | } 21 | 22 | class For[A, Coll[X] <: Seq[X]]: 23 | /** Derive an instance of `Unzip[A]` */ 24 | inline given derive[L <: Tuple](using 25 | generic: K0.ProductGeneric[A] { type MirroredElemTypes = L }, 26 | selector: Selector[L, Coll[A]], 27 | replacer: Replacer.Aux[L, Coll[A], Coll[A], (Coll[A], L)], 28 | factory: Factory[A, Coll[A]] 29 | ): Unzip[A] with { 30 | def unzip(node: A): List[A] = selector(generic.toRepr(node)).toList 31 | def zip(node: A, children: List[A]): A = { 32 | val repr = replacer(generic.toRepr(node), children.to(factory)) 33 | generic.fromRepr(repr._2) 34 | } 35 | } 36 | 37 | object For: 38 | /** 39 | * @tparam A The type of the tree-like data structure 40 | * @tparam Coll The type of the collection used for recursion (e.g. Vector) 41 | */ 42 | def apply[A, Coll[X] <: Seq[X]]: For[A, Coll] = new For[A, Coll] 43 | } 44 | -------------------------------------------------------------------------------- /shared/src/main/scala-3/zipper/contrib/shapeless3/Replacer.scala: -------------------------------------------------------------------------------- 1 | package contrib.shapeless3 2 | 3 | /** 4 | * This is ported from [[shapeless.ops.hlist.Replacer Replacer]] from shapeless-2. 5 | * At the moment of implementation, there is no direct support in shapeless-3. 6 | * We should give up on it once it arrives in the library. 7 | */ 8 | trait Replacer[L <: Tuple, U, V]: 9 | type Out <: Tuple 10 | def apply(t: L, v: V): Out 11 | 12 | object Replacer: 13 | def apply[L <: Tuple, U, V](using r: Replacer[L, U, V]): Aux[L, U, V, r.Out] = r 14 | 15 | type Aux[L <: Tuple, U, V, Out0] = Replacer[L, U, V] { type Out = Out0 } 16 | 17 | given tupleReplacer1[T <: Tuple, U, V]: Aux[U *: T, U, V, (U, V *: T)] = 18 | new Replacer[U *: T, U, V] { 19 | type Out = (U, V *: T) 20 | 21 | def apply(l: U *: T, v: V): Out = (l.head, v *: l.tail) 22 | } 23 | 24 | given tupleReplacer2[H, T <: Tuple, U, V, OutT <: Tuple](using 25 | ut: Aux[T, U, V, (U, OutT)]): Aux[H *: T, U, V, (U, H *: OutT)] = 26 | new Replacer[H *: T, U, V] { 27 | type Out = (U, H *: OutT) 28 | 29 | def apply(l: H *: T, v: V): Out = { 30 | val (u, outT) = ut(l.tail, v) 31 | (u, l.head *: outT) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /shared/src/main/scala-3/zipper/contrib/shapeless3/Selector.scala: -------------------------------------------------------------------------------- 1 | package contrib.shapeless3 2 | 3 | /** 4 | * This is ported from [[shapeless.ops.hlist.Selector Selector]] from shapeless-2. 5 | * At the moment of implementation, there is no direct support in shapeless-3. 6 | * We should give up on it once it arrives in the library. 7 | */ 8 | trait Selector[L <: Tuple, U]: 9 | def apply(t: L): U 10 | 11 | object Selector: 12 | given[H, T <: Tuple]: Selector[H *: T, H] with { 13 | def apply(t: H *: T): H = t.head 14 | } 15 | 16 | given[H, T <: Tuple, U] (using s: Selector[T, U]): Selector[H *: T, U] with { 17 | def apply(t: H *: T): U = s (t.tail) 18 | } 19 | -------------------------------------------------------------------------------- /shared/src/main/scala/zipper/GenericUnzipInstances.scala: -------------------------------------------------------------------------------- 1 | package zipper 2 | 3 | private[zipper] trait GenericUnzipInstances extends ForImpl 4 | -------------------------------------------------------------------------------- /shared/src/main/scala/zipper/Zipper.scala: -------------------------------------------------------------------------------- 1 | package zipper 2 | 3 | import scala.annotation.{implicitNotFound, tailrec} 4 | 5 | /** 6 | * A typeclass that defines how a certain data structure can be unzipped and zipped back. 7 | * 8 | * An instance of Unzip can be automatically derived for a case class C with a single field 9 | * of type List[C]. In a similar situation, but with a different collection class used, say, Vector, 10 | * an instance can still be derived like so: 11 | * {{{ 12 | * implicit val instance = Unzip.For[C, Vector].derive 13 | * }}} 14 | */ 15 | @implicitNotFound("Count not find a way to unzip ${A}.") 16 | trait Unzip[A] { 17 | def unzip(node: A): List[A] 18 | def zip(node: A, children: List[A]): A 19 | } 20 | 21 | object Unzip extends GenericUnzipInstances 22 | 23 | /** 24 | * A Zipper allows to move around a recursive immutable data structure and perform updates. 25 | * 26 | * Example: 27 | * {{{ 28 | * case class Tree(x: Int, c: List[Tree] = List.empty) 29 | * 30 | * val before = Tree(1, List(Tree(2))) 31 | * val after = Tree(1, List(Tree(2), Tree(3))) 32 | * 33 | * Zipper(before).moveDownRight.insertRight(Tree(3, Nil)).commit shouldEqual after 34 | * }}} 35 | * 36 | * See https://www.st.cs.uni-saarland.de/edu/seminare/2005/advanced-fp/docs/huet-zipper.pdf. 37 | */ 38 | case class Zipper[A]( 39 | left: List[A], 40 | focus: A, 41 | right: List[A], 42 | top: Option[Zipper[A]] 43 | )(implicit val unzip: Unzip[A]) { 44 | import Zipper._ 45 | 46 | def stay: MoveResult[A] = MoveResult.Success(this, this) 47 | def moveTo(z: Zipper[A]): MoveResult[A] = MoveResult.Success(z, this) 48 | def fail: MoveResult[A] = MoveResult.Failure(this) 49 | 50 | // Sideways 51 | 52 | /** Move left */ 53 | def tryMoveLeft = left match { 54 | case head :: tail => 55 | moveTo(copy(left = tail, focus = head, right = focus :: right)) 56 | case Nil => fail 57 | } 58 | 59 | /** Move left or throw if impossible */ 60 | def moveLeft = tryMoveLeft.get 61 | 62 | /** Move right */ 63 | def tryMoveRight = right match { 64 | case head :: tail => 65 | moveTo(copy(right = tail, focus = head, left = focus :: left)) 66 | case Nil => fail 67 | } 68 | 69 | /** Move right or throw if impossible */ 70 | def moveRight = tryMoveRight.get 71 | 72 | /** Move to the leftmost position */ 73 | def rewindLeft = cycle(_.tryMoveLeft) 74 | 75 | /** Move left by n */ 76 | def tryMoveLeftBy(n: Int) = tryRepeat(n, _.tryMoveLeft) 77 | 78 | /** Move left by n or throw if impossible */ 79 | def moveLeftBy(n: Int) = tryMoveLeftBy(n).get 80 | 81 | /** Move to the rightmost position */ 82 | def rewindRight = cycle(_.tryMoveRight) 83 | 84 | /** Move right by n */ 85 | def tryMoveRightBy(n: Int) = tryRepeat(n, _.tryMoveRight) 86 | 87 | /** Move right by n or throw if impossible */ 88 | def moveRightBy(n: Int) = tryMoveRightBy(n).get 89 | 90 | // Unzip-zip 91 | 92 | /** Unzip the current node and focus on the left child */ 93 | def tryMoveDownLeft = unzip.unzip(focus) match { 94 | case head :: tail => 95 | moveTo(copy(left = Nil, focus = head, right = tail, top = Some(this))) 96 | case Nil => fail 97 | } 98 | 99 | /** Unzip the current node and focus on the left child, or throw if impossible */ 100 | def moveDownLeft = tryMoveDownLeft.get 101 | 102 | /** Unzip the current node and focus on the right child */ 103 | def tryMoveDownRight = tryMoveDownLeft.map(_.rewindRight) 104 | 105 | /** Unzip the current node and focus on the right child, or throw if impossible */ 106 | def moveDownRight = tryMoveDownRight.get 107 | 108 | /** Unzip the current node and focus on the nth child */ 109 | def tryMoveDownAt(index: Int) = tryMoveDownLeft.map(_.moveRightBy(index)) 110 | 111 | /** Unzip the current node and focus on the nth child, or throw if impossible */ 112 | def moveDownAt(index: Int) = tryMoveDownAt(index).get 113 | 114 | /** Zip the current layer and move up */ 115 | def tryMoveUp = top.fold(fail) { z => 116 | moveTo { 117 | z.copy(focus = { 118 | val children = (focus :: left) reverse_::: right 119 | unzip.zip(z.focus, children) 120 | }) 121 | } 122 | } 123 | 124 | /** Zip the current layer and move up, or throw if impossible */ 125 | def moveUp = tryMoveUp.get 126 | 127 | /** Zip to the top and return the resulting value */ 128 | def commit = cycle(_.tryMoveUp).focus 129 | 130 | // Updates and focus manipulation 131 | 132 | /** Perform a side-effect on the focus */ 133 | def tapFocus(f: A => Unit) = { f(focus); this } 134 | 135 | /** Replace the focus with a different value */ 136 | def set(value: A) = copy(focus = value) 137 | 138 | /** Update the focus by applying a function */ 139 | def update(f: A => A) = copy(focus = f(focus)) 140 | 141 | /** Insert a new value to the left of focus */ 142 | def insertLeft(value: A) = copy(left = value :: left) 143 | 144 | /** Insert a new value to the right of focus */ 145 | def insertRight(value: A) = copy(right = value :: right) 146 | 147 | /** Move down left and insert a list of values on the left, focusing on the first one */ 148 | def tryInsertDownLeft(values: List[A]) = { 149 | values ::: unzip.unzip(focus) match { 150 | case head :: tail => 151 | moveTo(copy(left = Nil, focus = head, right = tail, top = Some(this))) 152 | case _ => fail 153 | } 154 | } 155 | 156 | /** Move down left and insert a list of values on the left, focusing on the first one */ 157 | def insertDownLeft(values: List[A]) = tryInsertDownLeft(values).get 158 | 159 | /** Move down right and insert a list of values on the right, focusing on the last one */ 160 | def tryInsertDownRight(values: List[A]) = { 161 | unzip.unzip(focus) ::: values match { 162 | case head :: tail => 163 | moveTo(copy(left = Nil, focus = head, right = tail, top = Some(this)).rewindRight) 164 | case _ => fail 165 | } 166 | } 167 | 168 | /** Move down right and insert a list of values on the right, focusing on the last one */ 169 | def insertDownRight(values: List[A]) = tryInsertDownRight(values).get 170 | 171 | /** Delete the value in focus and move left */ 172 | def tryDeleteAndMoveLeft = left match { 173 | case head :: tail => moveTo(copy(left = tail, focus = head)) 174 | case Nil => fail 175 | } 176 | 177 | /** Delete the value in focus and move left, or throw if impossible */ 178 | def deleteAndMoveLeft = tryDeleteAndMoveLeft.get 179 | 180 | /** Delete the value in focus and move right */ 181 | def tryDeleteAndMoveRight = right match { 182 | case head :: tail => moveTo(copy(right = tail, focus = head)) 183 | case Nil => fail 184 | } 185 | 186 | /** Delete the value in focus and move right, or throw if impossible */ 187 | def deleteAndMoveRight = tryDeleteAndMoveRight.get 188 | 189 | /** Delete the value in focus and move up */ 190 | def tryDeleteAndMoveUp = top.fold(fail) { z => 191 | moveTo { 192 | z.copy(focus = { 193 | val children = left reverse_::: right 194 | unzip.zip(z.focus, children) 195 | }) 196 | } 197 | } 198 | 199 | /** Delete the value in focus and move up, or throw if impossible */ 200 | def deleteAndMoveUp = tryDeleteAndMoveUp.get 201 | 202 | // Depth-first traversal 203 | 204 | /** Move to the next position in depth-first left-to-right order */ 205 | def tryAdvanceLeftDepthFirst = tryMoveDownRight 206 | .orElse(_.tryMoveLeft) 207 | .orElse(_.tryMoveUp.flatMap(_.tryMoveLeft)) 208 | 209 | /** Move to the next position in depth-first left-to-right order, or throw if impossible */ 210 | def advanceLeftDepthFirst = tryAdvanceLeftDepthFirst.get 211 | 212 | /** Move to the next position in depth-first right-to-left order */ 213 | def tryAdvanceRightDepthFirst = tryMoveDownLeft 214 | .orElse(_.tryMoveRight) 215 | .orElse(_.tryMoveUp.flatMap(_.tryMoveRight)) 216 | 217 | /** Move to the next position in depth-first right-to-left order, or throw if impossible */ 218 | def advanceRightDepthFirst = tryAdvanceRightDepthFirst.get 219 | 220 | /** Delete the value in focus and move to the next position in depth-first left-to-right order */ 221 | def tryDeleteAndAdvanceRightDepthFirst = tryDeleteAndMoveRight 222 | .orElse(_.tryDeleteAndMoveUp.flatMap(_.tryMoveRight)) 223 | 224 | /** Delete the value in focus and move to the next position in depth-first left-to-right order, or throw if impossible */ 225 | def deleteAndAdvanceRightDepthFirst = tryDeleteAndAdvanceRightDepthFirst.get 226 | 227 | /** Delete the value in focus and move to the next position in depth-first right-to-left order */ 228 | def tryDeleteAndAdvanceLeftDepthFirst = tryDeleteAndMoveLeft 229 | .orElse(_.tryDeleteAndMoveUp.flatMap(_.tryMoveLeft)) 230 | 231 | /** Delete the value in focus and move to the next position in depth-first right-to-left order, or throw if impossible */ 232 | def deleteAndAdvanceLeftDepthFirst = tryDeleteAndAdvanceLeftDepthFirst.get 233 | 234 | // Loops 235 | 236 | /** Cycle through the moves until a failure is produced and return the last success */ 237 | def cycle(move0: Move[A], moves: Move[A]*): Zipper[A] = { 238 | val moveList = move0 :: moves.toList 239 | @tailrec def inner(s: List[Move[A]], acc: Zipper[A]): Zipper[A] = 240 | s.head(acc) match { 241 | case MoveResult.Success(z, _) => inner(if (s.tail.isEmpty) moveList else s.tail, z) 242 | case _ => acc 243 | } 244 | inner(moveList, this) 245 | } 246 | 247 | /** Repeat a move `n` times */ 248 | def tryRepeat(n: Int, move: Move[A]): Zipper.MoveResult[A] = { 249 | @tailrec def inner(n: Int, acc: Zipper[A]): Zipper.MoveResult[A] = { 250 | if (n < 1) acc.stay else move(acc) match { 251 | case MoveResult.Success(z, _) => inner(n - 1, z) 252 | case failure => failure 253 | } 254 | } 255 | inner(n, this).withOrigin(this) 256 | } 257 | 258 | /** Repeat a move `n` times or throw if impossible */ 259 | def repeat(n: Int, move: Move[A]): Zipper[A] = tryRepeat(n, move).get 260 | 261 | /** 262 | * Repeat a move while the condition is satisfied 263 | * 264 | * @return the first zipper that does not satisfy the condition, or failure 265 | */ 266 | def tryRepeatWhile(condition: A => Boolean, move: Move[A]): Zipper.MoveResult[A] = { 267 | @tailrec def inner(acc: Zipper[A]): Zipper.MoveResult[A] = { 268 | if (!condition(acc.focus)) acc.stay else move(acc) match { 269 | case MoveResult.Success(z, _) => inner(z) 270 | case failure => failure 271 | } 272 | } 273 | inner(this).withOrigin(this) 274 | } 275 | 276 | /** 277 | * Repeat a move while the condition is satisfied or throw if impossible 278 | * 279 | * @return the first zipper that does not satisfy the condition 280 | */ 281 | def repeatWhile(condition: A => Boolean, move: Move[A]): Zipper[A] = 282 | tryRepeatWhile(condition, move).get 283 | 284 | /** 285 | * Repeat a move while the condition is not satisfied 286 | * 287 | * @return the first zipper that satisfies the condition, or failure 288 | */ 289 | def tryRepeatWhileNot(condition: A => Boolean, move: Move[A]): Zipper.MoveResult[A] = 290 | tryRepeatWhile(focus => !condition(focus), move) 291 | 292 | /** 293 | * Repeat a move while the condition is not satisfied or throw if impossible 294 | * 295 | * @return the first zipper that satisfies the condition 296 | */ 297 | def repeatWhileNot(condition: A => Boolean, move: Move[A]): Zipper[A] = 298 | tryRepeatWhileNot(condition, move).get 299 | 300 | /** Loop and accumulate state until a failure is produced */ 301 | @tailrec final def loopAccum[B](acc: B)(f: (Zipper[A], B) => (Zipper.MoveResult[A], B)): (Zipper[A], B) = { 302 | f(this, acc) match { 303 | case (Zipper.MoveResult.Success(moved, _), accumulated) => moved.loopAccum(accumulated)(f) 304 | case (Zipper.MoveResult.Failure(origin), accumulated) => (origin, accumulated) 305 | } 306 | } 307 | } 308 | 309 | object Zipper { 310 | type Move[A] = Zipper[A] => MoveResult[A] 311 | 312 | /** A result of moving a zipper, which can be either successful or not */ 313 | sealed trait MoveResult[A] { 314 | import MoveResult._ 315 | 316 | /** The starting point of the move */ 317 | def origin: Zipper[A] 318 | 319 | /** Change the starting point of the move */ 320 | def withOrigin(origin: Zipper[A]) = this match { 321 | case Success(zipper, _) => Success(zipper, origin) 322 | case Failure(_) => Failure(origin) 323 | } 324 | 325 | /** Obtain the resulting zipper or throw an exception if the move failed */ 326 | def get = this match { 327 | case Success(zipper, _) => zipper 328 | case Failure(_) => throw new UnsupportedOperationException("failed to move the zipper") 329 | } 330 | 331 | /** Obtain the resulting zipper or None if the move failed */ 332 | def toOption = this match { 333 | case Success(zipper, _) => Some(zipper) 334 | case Failure(_) => None 335 | } 336 | 337 | /** Obtain the resulting zipper or the original zipper in case the move failed */ 338 | def orStay = this match { 339 | case Success(zipper, _) => zipper 340 | case Failure(origin) => origin 341 | } 342 | 343 | /** Try another move if the current move failed */ 344 | def orElse(other: Move[A]): MoveResult[A] = this match { 345 | case Failure(origin) => other(origin) 346 | case success => success 347 | } 348 | 349 | /** Try a result of another move if the current move failed */ 350 | def orElse(other: => MoveResult[A]): MoveResult[A] = this match { 351 | case Failure(_) => other 352 | case success => success 353 | } 354 | 355 | /** Try another safe move if the current move failed */ 356 | def getOrElse(other: Zipper[A] => Zipper[A]): Zipper[A] = this match { 357 | case Success(zipper, _) => zipper 358 | case Failure(origin) => other(origin) 359 | } 360 | 361 | /** Try another zipper if the current move failed */ 362 | def getOrElse(other: Zipper[A]): Zipper[A] = this match { 363 | case Success(zipper, _) => zipper 364 | case Failure(_) => other 365 | } 366 | 367 | /** Safely move the resulting zipper, if the current move did not fail */ 368 | def map(f: Zipper[A] => Zipper[A]) = this match { 369 | case Success(zipper, origin) => Success(f(zipper), origin) 370 | case failure => failure 371 | } 372 | 373 | /** Try another move on the resulting zipper, if the current move did not fail */ 374 | def flatMap(move: Move[A]) = this match { 375 | case Success(zipper, origin) => move(zipper).withOrigin(origin) 376 | case failure => failure 377 | } 378 | } 379 | 380 | object MoveResult { 381 | case class Success[A](zipper: Zipper[A], origin: Zipper[A]) extends MoveResult[A] 382 | case class Failure[A](origin: Zipper[A]) extends MoveResult[A] 383 | } 384 | 385 | /** Create a zipper from a tree-like data structure */ 386 | def apply[A: Unzip](node: A): Zipper[A] = new Zipper(Nil, node, Nil, None) 387 | } 388 | -------------------------------------------------------------------------------- /shared/src/test/scala/zipper/UnzipDerivationSpec.scala: -------------------------------------------------------------------------------- 1 | package zipper 2 | 3 | import org.scalatest.flatspec.AnyFlatSpec 4 | import org.scalatest.matchers.should._ 5 | 6 | class UnzipDerivationSpec extends AnyFlatSpec with Matchers { 7 | it should "derive Unzip for list-based trees" in { 8 | case class Tree(x: Int, c: List[Tree] = List.empty) 9 | 10 | val before = Tree(1, List(Tree(2))) 11 | val after = Tree(1, List(Tree(2), Tree(3))) 12 | 13 | Zipper(before).moveDownRight.insertRight(Tree(3, Nil)).commit shouldEqual after 14 | } 15 | 16 | it should "support other collections with a bit of boilerplate" in { 17 | case class Tree(x: Int, c: Vector[Tree] = Vector.empty) 18 | 19 | val before = Tree(1, Vector(Tree(2))) 20 | val after = Tree(1, Vector(Tree(2), Tree(3))) 21 | 22 | implicit val unzip: Unzip[Tree] = Unzip.For[Tree, Vector].derive 23 | 24 | Zipper(before).moveDownRight.insertRight(Tree(3)).commit shouldEqual after 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /shared/src/test/scala/zipper/ZipperSpec.scala: -------------------------------------------------------------------------------- 1 | package zipper 2 | 3 | import org.scalatest.flatspec.AnyFlatSpec 4 | import org.scalatest.matchers.should._ 5 | 6 | class ZipperSpec extends AnyFlatSpec with Matchers { 7 | case class Tree(x: Int, c: List[Tree] = List.empty) 8 | 9 | val tree = Tree( 10 | 1, List( 11 | Tree(11, List( 12 | Tree(111), 13 | Tree(112) 14 | )), 15 | Tree(12, List( 16 | Tree(121), 17 | Tree(122, List( 18 | Tree(1221), 19 | Tree(1222) 20 | )), 21 | Tree(123) 22 | )), 23 | Tree(13) 24 | ) 25 | ) 26 | 27 | it should "perform basic operations correctly" in { 28 | val modified = Zipper(tree) 29 | .moveDownAt(1) .tapFocus(_.x shouldEqual 12) 30 | .moveDownRight .tapFocus(_.x shouldEqual 123) 31 | .deleteAndMoveLeft .tapFocus(_.x shouldEqual 122) 32 | .moveDownLeft .tapFocus(_.x shouldEqual 1221) 33 | .update(_.copy(x = -1)) .tapFocus(_.x shouldEqual -1) 34 | .moveRight .tapFocus(_.x shouldEqual 1222) 35 | .set(Tree(-2)) .tapFocus(_.x shouldEqual -2) 36 | .moveUp .tapFocus(_.x shouldEqual 122) 37 | .moveUp .tapFocus(_.x shouldEqual 12) 38 | .rewindLeft .tapFocus(_.x shouldEqual 11) 39 | .moveDownRight .tapFocus(_.x shouldEqual 112) 40 | .moveLeftBy(1) .tapFocus(_.x shouldEqual 111) 41 | .deleteAndMoveUp .tapFocus(_.x shouldEqual 11) 42 | .insertDownRight(List(Tree(113), Tree(114))).tapFocus(_.x shouldEqual 114) 43 | .moveUp .tapFocus(_.x shouldEqual 11) 44 | .rewindRight .tapFocus(_.x shouldEqual 13) 45 | .insertDownLeft(List(Tree(131), Tree(132))) .tapFocus(_.x shouldEqual 131) 46 | .commit 47 | 48 | modified shouldEqual Tree( 49 | 1, List( 50 | Tree(11, List( 51 | Tree(112), 52 | Tree(113), 53 | Tree(114) 54 | )), 55 | Tree(12, List( 56 | Tree(121), 57 | Tree(122, List( 58 | Tree(-1), 59 | Tree(-2) 60 | )) 61 | )), 62 | Tree(13, List( 63 | Tree(131), 64 | Tree(132) 65 | )) 66 | ) 67 | ) 68 | } 69 | 70 | it should "support depth-first traversal" in { 71 | Zipper(tree) 72 | .advanceRightDepthFirst.tapFocus(_.x shouldEqual 11) 73 | .advanceRightDepthFirst.tapFocus(_.x shouldEqual 111) 74 | .advanceRightDepthFirst.tapFocus(_.x shouldEqual 112) 75 | .advanceRightDepthFirst.tapFocus(_.x shouldEqual 12) 76 | 77 | Zipper(tree) 78 | .advanceLeftDepthFirst.tapFocus(_.x shouldEqual 13) 79 | .advanceLeftDepthFirst.tapFocus(_.x shouldEqual 12) 80 | .advanceLeftDepthFirst.tapFocus(_.x shouldEqual 123) 81 | .advanceLeftDepthFirst.tapFocus(_.x shouldEqual 122) 82 | .advanceLeftDepthFirst.tapFocus(_.x shouldEqual 1222) 83 | .advanceLeftDepthFirst.tapFocus(_.x shouldEqual 1221) 84 | .advanceLeftDepthFirst.tapFocus(_.x shouldEqual 121) 85 | 86 | val trimmed = Zipper(tree) 87 | .repeat(4, _.tryAdvanceLeftDepthFirst).tapFocus(_.x shouldEqual 122) 88 | .deleteAndAdvanceRightDepthFirst.tapFocus(_.x shouldEqual 123) 89 | .deleteAndAdvanceRightDepthFirst.tapFocus(_.x shouldEqual 13) 90 | .cycle(_.tryMoveUp) 91 | .repeat(3, _.tryAdvanceRightDepthFirst).tapFocus(_.x shouldEqual 112) 92 | .deleteAndAdvanceLeftDepthFirst.tapFocus(_.x shouldEqual 111) 93 | .tryDeleteAndAdvanceLeftDepthFirst.orStay.tapFocus(_.x shouldEqual 111) 94 | .commit 95 | 96 | trimmed shouldEqual Tree( 97 | 1, List( 98 | Tree(11, List( 99 | Tree(111) 100 | )), 101 | Tree(12, List( 102 | Tree(121) 103 | )), 104 | Tree(13) 105 | ) 106 | ) 107 | } 108 | 109 | it should "allow to express loops in a simple way" in { 110 | Zipper(tree) 111 | .cycle(_.tryMoveDownRight, _.tryMoveLeft, _.tryMoveLeft) 112 | .tapFocus(_.x shouldEqual 111) 113 | .repeat(2, _.tryMoveUp) 114 | .tapFocus(_.x shouldEqual 1) 115 | 116 | Zipper(tree) 117 | .repeatWhile(_.x < 100, _.tryMoveDownLeft.flatMap(_.tryMoveRight)) 118 | .tapFocus(_.x shouldEqual 122) 119 | } 120 | 121 | it should "allow to accumulate state while looping" in { 122 | def next: Zipper.Move[Tree] = 123 | _.tryMoveDownLeft 124 | .orElse(_.tryMoveRight) 125 | .orElse(_.tryMoveUp.flatMap(_.tryMoveRight)) 126 | 127 | val (zipper, sum) = Zipper(tree) 128 | .repeatWhileNot(_.x > 10, next) 129 | .tapFocus(_.x shouldEqual 11) 130 | .loopAccum(0) { (z, a) => 131 | if (a > 1000) (z.fail, a) 132 | else (next(z), a + z.focus.x) 133 | } 134 | 135 | zipper.focus.x shouldEqual 1222 136 | sum shouldEqual 1710 137 | } 138 | 139 | it should "throw when the move is impossible" in { 140 | intercept[UnsupportedOperationException] { 141 | Zipper(tree).moveUp 142 | } 143 | 144 | intercept[UnsupportedOperationException] { 145 | Zipper(tree).deleteAndMoveUp 146 | } 147 | 148 | intercept[UnsupportedOperationException] { 149 | Zipper(tree).moveDownRight.moveRight 150 | } 151 | 152 | intercept[UnsupportedOperationException] { 153 | Zipper(tree).moveDownRight.deleteAndMoveRight 154 | } 155 | 156 | intercept[UnsupportedOperationException] { 157 | Zipper(tree).moveDownLeft.moveLeft 158 | } 159 | 160 | intercept[UnsupportedOperationException] { 161 | Zipper(tree).moveDownLeft.deleteAndMoveLeft 162 | } 163 | 164 | intercept[UnsupportedOperationException] { 165 | Zipper(tree).moveDownRight.moveDownLeft 166 | } 167 | 168 | intercept[UnsupportedOperationException] { 169 | Zipper(tree).moveDownRight.insertDownLeft(List.empty) 170 | } 171 | 172 | intercept[UnsupportedOperationException] { 173 | Zipper(tree).moveDownRight.insertDownRight(List.empty) 174 | } 175 | } 176 | 177 | it should "allow to recover impossible moves" in { 178 | Zipper(tree).tryMoveUp.toOption shouldEqual None 179 | Zipper(tree).tryMoveDownLeft.toOption.isDefined shouldEqual true 180 | 181 | val modified1 = Zipper(tree) 182 | .moveDownLeft 183 | .tryMoveLeft.getOrElse(_.insertLeft(Tree(-1)).moveLeft) 184 | .commit 185 | 186 | modified1 shouldEqual Tree( 187 | 1, List( 188 | Tree(-1), 189 | Tree(11, List( 190 | Tree(111), 191 | Tree(112) 192 | )), 193 | Tree(12, List( 194 | Tree(121), 195 | Tree(122, List( 196 | Tree(1221), 197 | Tree(1222) 198 | )), 199 | Tree(123) 200 | )), 201 | Tree(13) 202 | ) 203 | ) 204 | 205 | val modified2 = Zipper(tree) 206 | .moveDownLeft 207 | .tryMoveLeft.orStay 208 | .set(Tree(-1)) 209 | .commit 210 | 211 | modified2 shouldEqual Tree( 212 | 1, List( 213 | Tree(-1), 214 | Tree(12, List( 215 | Tree(121), 216 | Tree(122, List( 217 | Tree(1221), 218 | Tree(1222) 219 | )), 220 | Tree(123) 221 | )), 222 | Tree(13) 223 | ) 224 | ) 225 | 226 | val modified3 = Zipper(tree) 227 | .moveDownLeft 228 | .cycle(_.tryDeleteAndMoveRight) 229 | .tryDeleteAndMoveLeft.orElse(_.tryDeleteAndMoveRight).getOrElse(_.deleteAndMoveUp) 230 | .commit 231 | 232 | modified3 shouldEqual Tree(1) 233 | 234 | val modified4 = Zipper(tree).tryMoveDownLeft.flatMap(_.tryMoveUp).orStay.set(Tree(-1)).commit 235 | 236 | modified4 shouldEqual Tree(-1) 237 | } 238 | } 239 | --------------------------------------------------------------------------------