├── .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 |
--------------------------------------------------------------------------------