├── .gitignore ├── README.md ├── build.sbt ├── project ├── build.properties └── plugins.sbt └── src ├── main └── scala │ └── com │ └── github │ └── pathikrit │ └── sauron │ └── package.scala └── test └── scala └── com └── github └── pathikrit └── sauron └── suites └── SauronSuite.scala /.gitignore: -------------------------------------------------------------------------------- 1 | # Git Ignore compiled from https://github.com/github/gitignore 2 | 3 | # OSX # 4 | .DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | .Spotlight-V100 8 | .Trashes 9 | *~ 10 | 11 | # Windows # 12 | Thumbs.db 13 | ehthumbs.db 14 | Desktop.ini 15 | $RECYCLE.BIN/ 16 | *.cab 17 | *.msi 18 | *.msm 19 | *.msp 20 | 21 | # IntelliJ # 22 | *.iml 23 | *.ipr 24 | *.iws 25 | out/ 26 | .idea/ 27 | .idea_modules/ 28 | atlassian-ide-plugin.xml 29 | 30 | # Eclipse # 31 | *.pydevproject 32 | .metadata 33 | .gradle 34 | bin/ 35 | tmp/ 36 | *.tmp 37 | *.bak 38 | *.swp 39 | *~.nib 40 | local.properties 41 | .settings/ 42 | .loadpath 43 | .externalToolBuilders/ 44 | *.launch 45 | .cproject 46 | .buildpath 47 | .target 48 | .texlipse 49 | 50 | # Java # 51 | *.class 52 | *.log 53 | .mtj.tmp/ 54 | *.jar 55 | *.war 56 | *.ear 57 | 58 | # sbt # 59 | cache/ 60 | .history/ 61 | .lib/ 62 | dist/ 63 | target/ 64 | lib_managed/ 65 | src_managed/ 66 | project/boot/ 67 | project/plugins/project/ 68 | 69 | # Scala # 70 | .scala_dependencies 71 | .worksheet 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Sauron [![Circle CI](https://img.shields.io/circleci/project/pathikrit/sauron.svg)](https://circleci.com/gh/pathikrit/sauron) [![Download](https://api.bintray.com/packages/pathikrit/maven/sauron/images/download.svg)](https://bintray.com/pathikrit/maven/sauron/_latestVersion) 2 | -------- 3 | 4 | Lightweight [lens library](http://stackoverflow.com/questions/3900307/cleaner-way-to-update-nested-structures) in less than [50-lines of Scala](src/main/scala/com/github/pathikrit/sauron/package.scala): 5 | 6 | ```scala 7 | case class Person(address: Address) 8 | case class Address(street: Street) 9 | case class Street(name: String) 10 | val person = Person(Address(Street("1 Functional Rd."))) 11 | 12 | import com.github.pathikrit.sauron._ 13 | 14 | lens(person)(_.address.street.name)(_.toUpperCase) 15 | ``` 16 | 17 | There is zero overhead; the `lens` macro simply expands to this during compilation: 18 | ```scala 19 | person.copy(address = person.address.copy( 20 | street = person.address.street.copy( 21 | name = (person.address.street.name).toUpperCase) 22 | ) 23 | ) 24 | ``` 25 | 26 | **Simple setters**: 27 | ```scala 28 | lens(person)(_.address.street.name).setTo("1 Objective Rd.") 29 | ``` 30 | 31 | **Reusable lenses**: 32 | ```scala 33 | val f1 = lens(person)(_.address.street.name) 34 | 35 | val p1: Person = f1(_.toLowerCase) 36 | val p2: Person = f1(_.toUpperCase) 37 | ``` 38 | 39 | **Lens factories**: The above lens only updates a particular person. You can make even more generic lenses that can update any `Person`: 40 | ```scala 41 | val f = lens(_: Person)(_.address.street.name) 42 | 43 | val p3: Person = f(p1)(_.toUpperCase) 44 | val p4: Person = f(p2)(_.toLowerCase) 45 | ``` 46 | 47 | **Lens composition**: 48 | ```scala 49 | val lens1: Person ~~> Address = lens(_: Person)(_.address) 50 | val lens2: Address ~~> String = lens(_: Address)(_.street.name) 51 | 52 | val lens3: Person ~~> String = lens1 andThenLens lens2 // or lens2 composeLens lens1 53 | val p5: Person = lens3(person)(_.toLowerCase) 54 | ``` 55 | 56 | **sbt**: In your `build.sbt`, add the following entries: 57 | 58 | ```scala 59 | resolvers += Resolver.bintrayRepo("pathikrit", "maven") 60 | 61 | libraryDependencies += "com.github.pathikrit" %% "sauron" % "1.1.0" 62 | 63 | addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0-M5" cross CrossVersion.full) 64 | ``` 65 | 66 | This library is inspired by the clever work done by @adamw in his [quicklens](https://github.com/adamw/quicklens) library. 67 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := "sauron" 2 | 3 | version := "1.2.0-SNAPSHOT" 4 | 5 | description := "Yet another Scala lens macro" 6 | 7 | licenses += ("MIT", url("http://opensource.org/licenses/MIT")) 8 | 9 | organization := "com.github.pathikrit" 10 | 11 | scalaVersion := "2.11.5" 12 | 13 | crossScalaVersions := Seq("2.11.1", "2.11.2", "2.11.4", "2.11.5") 14 | 15 | scalacOptions ++= Seq("-unchecked", "-deprecation", "-feature", "-language:experimental.macros") 16 | 17 | resolvers += Resolver.typesafeRepo("releases") 18 | 19 | libraryDependencies <+= scalaVersion("org.scala-lang" % "scala-reflect" % _) 20 | 21 | libraryDependencies += "org.scalatest" %% "scalatest" % "2.2.4" % Test 22 | 23 | addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0-M5" cross CrossVersion.full) 24 | 25 | seq(bintraySettings:_*) 26 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.7 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | resolvers += Resolver.bintrayRepo("sbt", "sbt-plugin-releases") 2 | 3 | addSbtPlugin("me.lessis" % "bintray-sbt" % "0.1.2") 4 | 5 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.1.8") 6 | -------------------------------------------------------------------------------- /src/main/scala/com/github/pathikrit/sauron/package.scala: -------------------------------------------------------------------------------- 1 | package com.github.pathikrit 2 | 3 | import scala.reflect.macros.blackbox 4 | 5 | package object sauron { 6 | 7 | type Setter[A] = A => A // a function that updates a field 8 | type Updater[A, B] = Setter[B] => A // given a setter to update a nested field, return back the updated parent 9 | type Lens[A, B] = A => Updater[A, B] // lens from A to B means given an A, return an updater on A,B (see above) 10 | type ~~>[A, B] = Lens[A, B] // for those who prefer symbols 11 | 12 | def lens[A, B](obj: A)(path: A => B): Updater[A, B] = macro lensImpl[A, B] 13 | 14 | def lensImpl[A, B](c: blackbox.Context)(obj: c.Expr[A])(path: c.Expr[A => B]): c.Tree = { 15 | import c.universe._ 16 | 17 | def split(accessor: c.Tree): List[c.TermName] = accessor match { // (_.p.q.r) -> List(p, q, r) 18 | case q"$pq.$r" => split(pq) :+ r 19 | case _: Ident => Nil 20 | case _ => c.abort(c.enclosingPosition, s"Unsupported path element: $accessor") 21 | } 22 | 23 | def nest(prefix: c.Tree, f: TermName, suffix: List[TermName]): c.Tree = suffix match { 24 | case p :: ps => q"$prefix.copy($p = ${nest(q"$prefix.$p", f, ps)})" // Recursively nest the f 25 | case Nil => q"$f($prefix)" // Reached the end, apply f 26 | } 27 | 28 | path.tree match { 29 | case q"($_) => $accessor" => 30 | val f = TermName(c.freshName()) 31 | val fParamTree = q"val $f = ${q""}" 32 | q"{$fParamTree => ${nest(obj.tree, f, split(accessor))}}" 33 | case _ => c.abort(c.enclosingPosition, s"Path must have shape: _.a.b.c.(...); got: ${path.tree}") 34 | } 35 | } 36 | 37 | implicit class LensOps[A, B](val f: A ~~> B) extends AnyVal { 38 | def andThenLens[C](g: B ~~> C): A ~~> C = x => y => f(x)(g(_)(y)) 39 | def composeLens[C](g: C ~~> A): C ~~> B = g andThenLens f 40 | } 41 | 42 | implicit class UpdaterOps[A, B](val f: Updater[A, B]) extends AnyVal { 43 | def setTo(v: B): A = f(_ => v) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/test/scala/com/github/pathikrit/sauron/suites/SauronSuite.scala: -------------------------------------------------------------------------------- 1 | package com.github.pathikrit.sauron.suites 2 | 3 | import org.scalatest._, Matchers._ 4 | 5 | class SauronSuite extends FunSuite { 6 | test("lensing") { 7 | import com.github.pathikrit.sauron._ 8 | 9 | case class Person(name: String, address: Address) 10 | case class Address(street: Street, street2: Option[Street], city: String, state: String, zip: String, country: String) 11 | case class Street(name: String) 12 | 13 | val p1 = Person("Rick", Address(Street("Rock St"), None, "MtV", "CA", "94041", "USA")) 14 | def addHouseNumber(number: Int)(st: String) = s"$number $st" 15 | 16 | val p2 = lens(p1)(_.address.street.name)(addHouseNumber(1901)) 17 | p2 shouldEqual p1.copy(address = p1.address.copy(street = p1.address.street.copy(name = addHouseNumber(1901)(p1.address.street.name)))) 18 | 19 | val streetUpdater = lens(p1)(_.address.street.name) 20 | val p3 = streetUpdater(_.toLowerCase) 21 | p3.address.street.name shouldEqual "rock st" 22 | streetUpdater(_.toUpperCase).address.street.name shouldEqual "ROCK ST" 23 | 24 | val personToCity: Person ~~> String = lens(_: Person)(_.address.city) 25 | val p4 = personToCity(p1)(_.toLowerCase) 26 | p4.address.city shouldEqual "mtv" 27 | 28 | val lens1: Person ~~> Address = lens(_: Person)(_.address) 29 | val lens2: Address ~~> Street = lens(_: Address)(_.street) 30 | val lens3: Street ~~> String = lens(_: Street)(_.name) 31 | 32 | val lens4: Person ~~> String = lens1 andThenLens lens2 andThenLens lens3 33 | lens4(p1)(_.toLowerCase) shouldEqual p3 34 | 35 | val lens5: Person ~~> String = lens3 composeLens lens2 composeLens lens1 36 | lens5(p1)(_.toLowerCase) shouldEqual p3 37 | 38 | val p5: Person = lens(p1)(_.address.street.name).setTo("Rick St") 39 | p5.address.street.name shouldEqual "Rick St" 40 | 41 | "lens(p1)(_.address.zip)(_.toUpperCase)" should compile 42 | "lens(p1)(_.address.zip.length)(_ + 1)" shouldNot compile 43 | "lens(p1)(_.toString)(_.toUpperCase)" shouldNot compile 44 | } 45 | } 46 | --------------------------------------------------------------------------------