├── README.md ├── build.sbt ├── project └── build.properties └── src ├── main └── scala │ └── classy │ ├── ClassyPlugin.scala │ └── Generator.scala ├── sbt-test └── basic │ └── example │ ├── build.sbt │ ├── project │ └── plugins.sbt │ ├── src │ └── main │ │ └── scala │ │ └── foo │ │ ├── Config.scala │ │ └── Program.scala │ └── test └── test └── scala └── classy └── GeneratorSpec.scala /README.md: -------------------------------------------------------------------------------- 1 | # sbt-classy 2 | 3 | Generates classy lenses using scala.meta. 4 | 5 | ## How to install 6 | 7 | The plugin is not published yet. 8 | 9 | ## How to use 10 | 11 | Enable the plugin. In build.sbt: 12 | 13 | ``` 14 | enablePlugins(ClassyPlugin) 15 | ``` 16 | 17 | Annotate your case classes with a comment containing `generate-classy-lenses`, 18 | e.g.: 19 | 20 | ```scala 21 | /* 22 | * generate-classy-lenses 23 | */ 24 | case class DbConfig(host: String, port: Int) 25 | ``` 26 | 27 | or 28 | 29 | ```scala 30 | // generate-classy-lenses 31 | case class DbConfig(host: String, port: Int) 32 | ``` 33 | 34 | The plugin will generate the following source file: 35 | 36 | ```scala 37 | package foo 38 | 39 | import monocle.Lens 40 | 41 | trait HasDbConfig[T] { 42 | def dbConfig: Lens[T, DbConfig] 43 | def dbConfigHost: Lens[T, String] = 44 | dbConfig composeLens Lens[DbConfig, String](_.host)(x => a => a.copy(host = x)) 45 | def dbConfigPort: Lens[T, Int] = 46 | dbConfig composeLens Lens[DbConfig, Int](_.port)(x => a => a.copy(port = x)) 47 | } 48 | 49 | object HasDbConfig { 50 | def apply[T](implicit instance: HasDbConfig[T]): HasDbConfig[T] = instance 51 | 52 | implicit val id: HasDbConfig[DbConfig] = new HasDbConfig[DbConfig]() { 53 | def dbConfig: Lens[DbConfig, DbConfig] = Lens.id[DbConfig] 54 | } 55 | } 56 | ``` 57 | 58 | ## Notes 59 | 60 | ### Monocle 61 | 62 | The generated lenses are [Monocle](http://julien-truffaut.github.io/Monocle/) 63 | lenses. However, the plugin does *not* add any Monocle dependencies to your 64 | project. You need to do this yourself, which means you are free to choose things 65 | like the Monocle version and whether you want to use macros or not. 66 | 67 | The generated code only depends on `monocle.Lens`. 68 | 69 | ### Classy what? 70 | 71 | "Classy lenses" basically means generating a type class and an instance of that 72 | type class, along with some lenses. 73 | 74 | Classy lenses often come in handy when you are writing code in so-called "MTL 75 | style". Here is a great talk that explains in more detail (in Haskell): [Next 76 | Level MTL - George Wilson](https://www.youtube.com/watch?v=GZPup5Iuaqw). 77 | 78 | For related work in Scala, see [meow-mtl](https://github.com/oleg-py/meow-mtl). 79 | 80 | ## TODO 81 | 82 | * Generate `HasFoo` type class instances for case classes with parameters of type 83 | `Foo` 84 | * Generate classy prisms 85 | * Write some proper tests 86 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | enablePlugins(SbtPlugin) 2 | 3 | scalaVersion := "2.12.7" 4 | organization := "com.github.cb372" 5 | 6 | libraryDependencies ++= Seq( 7 | "org.scalameta" %% "scalameta" % "4.0.0", 8 | "org.scalameta" %% "contrib" % "4.0.0", 9 | "org.scalatest" %% "scalatest" % "3.0.5" % Test 10 | ) 11 | 12 | scriptedLaunchOpts := scriptedLaunchOpts.value ++ Seq("-Xmx1024M", "-Dplugin.version=" + version.value) 13 | scriptedBufferLog := false 14 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.2.3 2 | -------------------------------------------------------------------------------- /src/main/scala/classy/ClassyPlugin.scala: -------------------------------------------------------------------------------- 1 | package classy 2 | 3 | import sbt._ 4 | import sbt.Keys._ 5 | import sbt.util.Tracked 6 | import sjsonnew.BasicJsonProtocol._ 7 | import java.io.File 8 | import java.nio.file._ 9 | import scala.meta._ 10 | 11 | object ClassyPlugin extends AutoPlugin { 12 | 13 | object autoImport { 14 | 15 | } 16 | 17 | override lazy val projectSettings = Seq( 18 | sourceGenerators in Compile += sourceGenTask.taskValue 19 | ) 20 | 21 | def sourceGenTask = Def.task { 22 | val strs = streams.value 23 | val cacheDir = strs.cacheDirectory 24 | val log = strs.log 25 | val outputDirectory = (sourceManaged in Compile).value / "classy-lenses" 26 | 27 | /* 28 | * Caching to avoid unnecessary work: 29 | * 30 | * if the maximum last-modified timestamp of the unmanaged source files has changed: 31 | * run the source generator and cache its output (i.e. the list of generated files) 32 | * else: 33 | * do nothing except return the cached output of the previous run 34 | */ 35 | def gen(unmanagedSrcs: List[File]): List[File] = { 36 | def execute = generate(unmanagedSrcs, outputDirectory, baseDirectory.value, log) 37 | 38 | val maxLastModified: () => Long = 39 | () => unmanagedSrcs.foldRight[Long](0) { case (file, maxSoFar) => file.lastModified() max maxSoFar } 40 | 41 | val execIfLastModifiedChanged: (() => Long) => List[File] = 42 | Tracked.outputChanged(cacheDir / "classy-unmanaged-sources-last-modified") { (lastModifiedChanged: Boolean, lastModified: Long) => 43 | val execOrReturnCachedOutput: Unit => List[File] = 44 | Tracked.lastOutput(cacheDir / "classy-generated-sources") { (_: Unit, prevOutput: Option[List[File]]) => 45 | if (lastModifiedChanged) { 46 | execute 47 | } else { 48 | prevOutput.getOrElse(execute) 49 | } 50 | } 51 | 52 | execOrReturnCachedOutput(()) 53 | } 54 | 55 | execIfLastModifiedChanged(maxLastModified) 56 | } 57 | 58 | gen((unmanagedSources in Compile).value.toList) 59 | } 60 | 61 | def generate(sources: List[File], outputDirectory: File, baseDirectory: File, log: Logger): List[File] = { 62 | sources.flatMap { sourceFile => 63 | val path = sourceFile.toPath 64 | val bytes = Files.readAllBytes(path) 65 | val text = new String(bytes, "UTF-8") 66 | val input = Input.VirtualFile(path.toString, text) 67 | 68 | input.parse[Source] match { 69 | case Parsed.Success(source) => 70 | val generatedFiles = Generator.generateOptics(msg => log.warn(msg))(source) 71 | generatedFiles.map { gen => 72 | log.info(s"sbt-classy: Writing generated file ${gen.path}") 73 | val outputFile = new File(outputDirectory, gen.path) 74 | Files.createDirectories(outputFile.toPath.getParent) 75 | IO.write(outputFile, gen.source.toString) 76 | outputFile 77 | } 78 | case Parsed.Error(pos, msg, details) => 79 | log.warn(s"sbt-classy: skipping file because failed to parse as Scala (${path.relativize(baseDirectory.toPath)})") 80 | Nil 81 | } 82 | } 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/main/scala/classy/Generator.scala: -------------------------------------------------------------------------------- 1 | package classy 2 | 3 | import scala.meta._ 4 | import scala.meta.contrib.AssociatedComments 5 | 6 | object Generator { 7 | 8 | case class GeneratedFile(path: String, source: Source) 9 | 10 | def generateOptics(warn: String => Unit)(inputSource: Source): List[GeneratedFile] = { 11 | val comments = AssociatedComments(inputSource) 12 | 13 | val pkg = inputSource.collect { case p @ Pkg(ref, stats) => ref } 14 | .headOption 15 | .getOrElse(Term.Name("classy")) 16 | 17 | inputSource.collect { 18 | case c @ Defn.Class(mods, name, tparams, ctor, templ) if mods.exists(isCase) && hasDirective(comments.leading(c)) && tparams.nonEmpty => 19 | warn(s"Cannot generate classy lenses for case class ${c.name.value} because it is generic and that makes my head hurt") 20 | None 21 | case c @ Defn.Class(mods, name, tparams, ctor, templ) if mods.exists(isCase) && hasDirective(comments.leading(c)) => 22 | val typeclassName = Type.Name(s"Has${name.value}") 23 | val mainLensMethodName = Term.Name(name.value.head.toLower + name.value.tail) // lowercase the first char of the case class name 24 | val mainLensReturnType = t"Lens[T, $name]" 25 | 26 | val fieldLenses = ctor.paramss.flatten.collect { 27 | case param if !param.mods.exists(isPrivate) => 28 | val fieldName = Term.Name(param.name.value) 29 | val fieldType = param.decltpe.get 30 | val methodName = Term.Name(s"$mainLensMethodName${fieldName.value.head.toUpper + fieldName.value.tail}") 31 | val returnType = t"Lens[T, $fieldType]" 32 | val fieldLens = q"Lens[$name, $fieldType](_.$fieldName)(x => a => a.copy($fieldName = x))" 33 | q"def $methodName: $returnType = $mainLensMethodName composeLens $fieldLens" 34 | } 35 | 36 | val typeclassInstanceType = t"$typeclassName[$name]" 37 | val typeclassInstanceInit = init"$typeclassInstanceType()" 38 | 39 | // TODO also add instances for any other case classes in this file that have this type as a field 40 | 41 | val generatedSource = source""" 42 | package $pkg { 43 | 44 | import monocle.Lens 45 | 46 | trait $typeclassName[T] { 47 | def $mainLensMethodName: $mainLensReturnType 48 | ..$fieldLenses 49 | } 50 | 51 | object ${Term.Name(typeclassName.value)} { 52 | def apply[T](implicit instance: $typeclassName[T]): $typeclassName[T] = instance 53 | 54 | implicit val id: $typeclassInstanceType = new $typeclassInstanceInit { 55 | def $mainLensMethodName: Lens[$name, $name] = Lens.id[$name] 56 | } 57 | } 58 | 59 | } 60 | """ 61 | 62 | val path = pkg.toString.replaceAllLiterally(".", "/") + "/" + s"${typeclassName.value}.scala" 63 | 64 | Some(GeneratedFile(path, generatedSource)) 65 | }.flatten 66 | } 67 | 68 | private def isCase(mod: Mod): Boolean = mod match { 69 | case mod"case" => true 70 | case _ => false 71 | } 72 | 73 | private def isPrivate(mod: Mod): Boolean = mod match { 74 | case mod"private" => true 75 | case _ => false 76 | } 77 | 78 | private def hasDirective(leadingComments: Set[Token.Comment]): Boolean = 79 | leadingComments.exists(_.value.contains("generate-classy-lenses")) 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/sbt-test/basic/example/build.sbt: -------------------------------------------------------------------------------- 1 | scalaVersion := "2.12.7" 2 | enablePlugins(ClassyPlugin) 3 | 4 | val monocleVersion = "1.5.1-cats" 5 | libraryDependencies ++= Seq( 6 | "com.github.julien-truffaut" %% "monocle-core" % monocleVersion, 7 | "com.github.julien-truffaut" %% "monocle-macro" % monocleVersion 8 | ) 9 | -------------------------------------------------------------------------------- /src/sbt-test/basic/example/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.github.cb372" % "sbt-classy" % sys.props("plugin.version")) 2 | -------------------------------------------------------------------------------- /src/sbt-test/basic/example/src/main/scala/foo/Config.scala: -------------------------------------------------------------------------------- 1 | package foo 2 | 3 | import monocle.Lens 4 | import monocle.macros.GenLens 5 | 6 | /* 7 | * generate-classy-lenses 8 | */ 9 | case class DbConfig(host: String, port: Int) 10 | 11 | // generate-classy-lenses 12 | case class ApiConfig(url: String, apikey: String) 13 | 14 | case class Config( 15 | dbConfig: DbConfig, 16 | apiConfig: ApiConfig 17 | ) 18 | 19 | object Config { 20 | // TODO with some more scalameta magic we'll be able to auto-generate these as well 21 | implicit val hasDbConfig: HasDbConfig[Config] = new HasDbConfig[Config] { 22 | def dbConfig: Lens[Config, DbConfig] = GenLens[Config](_.dbConfig) 23 | } 24 | implicit val hasApiConfig: HasApiConfig[Config] = new HasApiConfig[Config] { 25 | def apiConfig: Lens[Config, ApiConfig] = GenLens[Config](_.apiConfig) 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /src/sbt-test/basic/example/src/main/scala/foo/Program.scala: -------------------------------------------------------------------------------- 1 | package foo 2 | 3 | object Main extends App { 4 | 5 | def needsDbConfig[A: HasDbConfig](a: A): Unit = { 6 | val port = HasDbConfig[A].dbConfigPort.get(a) 7 | println(s"The DB port is $port") 8 | } 9 | 10 | val config = Config( 11 | DbConfig("host", 1234), 12 | ApiConfig("url", "api key") 13 | ) 14 | 15 | needsDbConfig(config) 16 | 17 | needsDbConfig(config.dbConfig) 18 | 19 | } -------------------------------------------------------------------------------- /src/sbt-test/basic/example/test: -------------------------------------------------------------------------------- 1 | # Should generate the files before compilation 2 | > run 3 | # Should not re-generate them because no unmanaged sources have changed 4 | > run 5 | > clean 6 | # Should re-generate them now 7 | > run 8 | # Should not re-generate them because no unmanaged sources have changed 9 | > run 10 | $ touch src/main/scala/foo/Program.scala 11 | # Should regenerate them now because the last modified timestamp has changed 12 | > run 13 | -------------------------------------------------------------------------------- /src/test/scala/classy/GeneratorSpec.scala: -------------------------------------------------------------------------------- 1 | package classy 2 | 3 | import org.scalatest._ 4 | import scala.meta._ 5 | import scala.meta.contrib._ 6 | 7 | class GeneratorSpec extends FlatSpec { 8 | 9 | private val warn: String => Unit = _ => () 10 | 11 | it should "generate classy lenses for case classes" in { 12 | val input = """package foo 13 | | 14 | |case class Config( 15 | | dbConfig: DbConfig, 16 | | apiConfig: ApiConfig 17 | |) 18 | | 19 | |/* 20 | | * generate-classy-lenses 21 | | */ 22 | |case class DbConfig(host: String, port: Int) 23 | | 24 | |// generate-classy-lenses 25 | |case class ApiConfig(url: String, apikey: String) 26 | |""".stripMargin.parse[Source].get 27 | 28 | val generatedFiles = Generator.generateOptics(warn)(input).sortBy(_.path) 29 | generatedFiles.foreach(println) 30 | 31 | assert(generatedFiles(0).path == "foo/HasApiConfig.scala") 32 | assert(generatedFiles(0).source.isEqual( 33 | source""" 34 | package foo 35 | 36 | import monocle.Lens 37 | 38 | trait HasApiConfig[T] { 39 | def apiConfig: Lens[T, ApiConfig] 40 | def apiConfigUrl: Lens[T, String] = 41 | apiConfig composeLens Lens[ApiConfig, String](_.url)(x => a => a.copy(url = x)) 42 | def apiConfigApikey: Lens[T, String] = 43 | apiConfig composeLens Lens[ApiConfig, String](_.apikey)(x => a => a.copy(apikey = x)) 44 | } 45 | 46 | object HasApiConfig { 47 | def apply[T](implicit instance: HasApiConfig[T]): HasApiConfig[T] = instance 48 | 49 | implicit val id: HasApiConfig[ApiConfig] = new HasApiConfig[ApiConfig]() { 50 | def apiConfig: Lens[ApiConfig, ApiConfig] = Lens.id[ApiConfig] 51 | } 52 | 53 | //implicit val configHasApiConfig: HasApiConfig[Config] = new HasApiConfig[Config]() { 54 | // def apiConfig: Lens[Config, ApiConfig] = Lens[Config, ApiConfig](_.apiConfig)(x => a => a.copy(apiConfig = x)) 55 | //} 56 | 57 | } 58 | """)) 59 | 60 | assert(generatedFiles(1).path == "foo/HasDbConfig.scala") 61 | assert(generatedFiles(1).source.isEqual( 62 | source""" 63 | package foo 64 | 65 | import monocle.Lens 66 | 67 | trait HasDbConfig[T] { 68 | def dbConfig: Lens[T, DbConfig] 69 | def dbConfigHost: Lens[T, String] = 70 | dbConfig composeLens Lens[DbConfig, String](_.host)(x => a => a.copy(host = x)) 71 | def dbConfigPort: Lens[T, Int] = 72 | dbConfig composeLens Lens[DbConfig, Int](_.port)(x => a => a.copy(port = x)) 73 | } 74 | 75 | object HasDbConfig { 76 | def apply[T](implicit instance: HasDbConfig[T]): HasDbConfig[T] = instance 77 | 78 | implicit val id: HasDbConfig[DbConfig] = new HasDbConfig[DbConfig]() { 79 | def dbConfig: Lens[DbConfig, DbConfig] = Lens.id[DbConfig] 80 | } 81 | 82 | //implicit val configHasDbConfig: HasDbConfig[Config] = new HasDbConfig[Config]() { 83 | // def dbConfig: Lens[Config, DbConfig] = Lens[Config, DbConfig](_.dbConfig)(x => a => a.copy(dbConfig = x)) 84 | //} 85 | 86 | } 87 | """)) 88 | } 89 | 90 | it should "not generate lenses for a case class's private fields" in { 91 | val input = """package foo.bar 92 | | 93 | |// generate-classy-lenses 94 | |case class Hello(world: String, private val moon: String) 95 | |""".stripMargin.parse[Source].get 96 | 97 | val generatedFiles = Generator.generateOptics(warn)(input).sortBy(_.path) 98 | 99 | assert(generatedFiles(0).path == "foo/bar/HasHello.scala") 100 | assert(generatedFiles(0).source.isEqual( 101 | source""" 102 | package foo.bar 103 | 104 | import monocle.Lens 105 | 106 | trait HasHello[T] { 107 | def hello: Lens[T, Hello] 108 | def helloWorld: Lens[T, String] = 109 | hello composeLens Lens[Hello, String](_.world)(x => a => a.copy(world = x)) 110 | } 111 | 112 | object HasHello { 113 | def apply[T](implicit instance: HasHello[T]): HasHello[T] = instance 114 | 115 | implicit val id: HasHello[Hello] = new HasHello[Hello]() { 116 | def hello: Lens[Hello, Hello] = Lens.id[Hello] 117 | } 118 | } 119 | """)) 120 | } 121 | 122 | } 123 | 124 | --------------------------------------------------------------------------------