├── .gitignore ├── .scalafmt.conf ├── LICENSE ├── README.md ├── build.sbt ├── examples ├── cli-args │ ├── build.sbt │ ├── project │ │ └── build.properties │ └── src │ │ └── main │ │ └── scala │ │ └── example │ │ └── Main.scala ├── command │ ├── build.sbt │ ├── project │ │ └── build.properties │ └── src │ │ └── main │ │ └── scala │ │ └── example │ │ └── Main.scala ├── counter │ ├── build.sbt │ ├── project │ │ └── build.properties │ └── src │ │ └── main │ │ └── scala │ │ └── example │ │ └── Main.scala ├── env │ ├── build.sbt │ ├── project │ │ └── build.properties │ └── src │ │ └── main │ │ └── scala │ │ └── example │ │ └── Main.scala ├── task │ ├── build.sbt │ ├── project │ │ └── build.properties │ └── src │ │ └── main │ │ └── scala │ │ └── example │ │ └── Main.scala └── todolist │ ├── build.sbt │ ├── project │ └── build.properties │ └── src │ └── main │ └── scala │ └── example │ └── Main.scala ├── project ├── build.properties └── plugins.sbt └── src ├── main ├── scala │ └── com │ │ └── github │ │ └── battermann │ │ └── pureapp │ │ ├── PureApp.scala │ │ └── interpreters │ │ ├── FileSystem.scala │ │ └── Terminal.scala └── tut │ └── README.md └── test └── scala └── com └── github └── battermann └── pureapp └── PureAppTests.scala /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | style = defaultWithAlign 2 | project.excludeFilters = [ 3 | target/ 4 | ] 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Leif Battermann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PureApp 2 | 3 | A principled and opinionated library for writing purely functional, easy to reason about, and stack-safe sequential programs partly inspired by [Elm](http://elm-lang.org/), [scalm](https://github.com/julienrf/scalm), and scalaz's [SafeApp](https://github.com/scalaz/scalaz/blob/bffbbcf366ca3a33dad6b3c10683228b20812bcf/effect/src/main/scala/scalaz/effect/SafeApp.scala) 4 | 5 | ## installtion 6 | 7 | libraryDependencies += "com.github.battermann" %% "pureapp" % "0.6.0" 8 | 9 | ## overview 10 | 11 | The architecture for PureApp applications is mainly inspired by the [Elm Architecture](https://guide.elm-lang.org/architecture/). 12 | 13 | An Idiomatic PureApp program is completely pure and referentially transparent. 14 | 15 | It can be either implemented as the main application or it can be composed of other PureApp programs (see below). 16 | 17 | A program consists of three components: 18 | 19 | ### model 20 | 21 | The model represents the immutable application state. 22 | 23 | ### update 24 | 25 | A way to update the application's state. `update` is a function that takes a `Model` (the current application state) and a `Msg` and returns a new `Model` (a new application state). 26 | 27 | ### io 28 | 29 | `io` is a function that describes all side effects of an application. 30 | 31 | Unlike Elm and scalm, PureApp applications do not have a `view` function. Instead `io` is responsible for printing and reading from the standard input/output as well as for other side effects. 32 | 33 | `io` takes a `Model` and returns an `F[Msg]`. Where `F[_]` has an instance of [`Effect[F]`](https://typelevel.org/cats-effect/typeclasses/effect.html). Additionally you can pass immutable, pure values of type `Cmd` that represent commands to perform other side effects than just printing and reading. 34 | 35 | Internally the `Msg` that is returned from `io` and wrapped inside an `F[_]` together with the current `Model` is fed back into the `update` function. However, this is hidden from the user and we do not have to worry about this. 36 | 37 | ## termination 38 | 39 | To control when to terminate a PureApp application we define `def quit(msg: Msg): Boolean`. If `quit` returns `true` when applied to a `Msg` coming from the `io` function the program will terminate. 40 | 41 | ## example 42 | 43 | How to use PureApp can best be demonstrated with an example. Here is the PureApp version of the [Elm counter example](http://elm-lang.org/examples/buttons): 44 | 45 | ```scala 46 | import com.github.battermann.pureapp._ 47 | // import com.github.battermann.pureapp._ 48 | 49 | import com.github.battermann.pureapp.interpreters.Terminal._ 50 | // import com.github.battermann.pureapp.interpreters.Terminal._ 51 | 52 | import cats.effect.IO 53 | // import cats.effect.IO 54 | 55 | object Main extends SimplePureApp[IO] { 56 | 57 | // MODEL 58 | 59 | type Model = Int 60 | 61 | sealed trait Msg 62 | case object Increment extends Msg 63 | case object Decrement extends Msg 64 | case object InvalidInput extends Msg 65 | case object Quit extends Msg 66 | 67 | def init: Model = 42 68 | 69 | def quit(msg: Msg): Boolean = msg == Quit 70 | 71 | // UPDATE 72 | 73 | def update(msg: Msg, model: Model): Model = 74 | msg match { 75 | case Increment => model + 1 76 | case Decrement => model - 1 77 | case Quit => model 78 | case InvalidInput => model 79 | } 80 | 81 | // IO 82 | 83 | def io(model: Model): IO[Msg] = 84 | for { 85 | _ <- putStrLn(model.toString) 86 | _ <- putStr("enter: +, -, or q> ") 87 | input <- readLine 88 | } yield { 89 | input match { 90 | case "+" => Increment 91 | case "-" => Decrement 92 | case "q" => Quit 93 | case _ => InvalidInput 94 | } 95 | } 96 | } 97 | // defined object Main 98 | ``` 99 | 100 | ## three different patterns 101 | 102 | PureApp supports three different patterns: 103 | 104 | ### SimplePureApp 105 | 106 | A simple program (like the counter example from above) knows only models and messages. We can create a simple program by extending from the `SimplePureApp[F_]]` class. 107 | 108 | ### StandardPureApp 109 | 110 | A *standard* program which extends `StandardPureApp[F[_]]` also supports commands. Normally printing to and reading from the console can be done based on the `Model` (the application state). If we want to perform other side effecting actions, we often can't or don't want to do this based on the application state. Instead we can use commands that represent requests for performing such tasks. The `io` function then becomes the interpreter for our commands as [this example](examples/command/src/main/scala/example/Main.scala) demonstrates. 111 | 112 | ### PureApp 113 | 114 | A program that can create and dispose resources in a referentially transparent way has to extend the `PureApp[F[_]]` class. The type `Resource` represents an environment containing disposable resources and other things that do not belong into the domain model (like e.g. a configuration). We have to provide an implementation for `def acquire: F[Resource]` and we can override `def dispose(resource: Resource): F[Unit]` to dispose resources. 115 | 116 | The `io` function of an `PureApp ` provides an additional parameter of type `PureApp ` that we can now use while interpreting our commands. [Here is an example](examples/env/src/main/scala/Main.scala) uses an HTTP client as a resource. 117 | 118 | 119 | ## minimal working skeleton 120 | 121 | To create a minimal working skeleton the main object of an application has to extend one of the three abstract classes mentioned above: 122 | 123 | - `SimplePureApp[F[_]]` 124 | - `StandardPureApp[F[_]]` 125 | - or `PureApp[F[_]]` 126 | 127 | Then the types `Model` and `Msg` have to be defined. Depending on which pattern we use we might have to define `Cmd` and `Resource` as well. 128 | 129 | Usually `Msg` and `Cmd` will be implemented as sum types. 130 | 131 | Finally all abstract methods have to be implemented: 132 | 133 | - `init` 134 | - `update` 135 | - `io` 136 | - `quit` (if we want the program to terminate) 137 | 138 | And optionally: 139 | 140 | - `acquire` 141 | - `dispose` 142 | 143 | Here is a minimal working skeleton to get started: 144 | 145 | ```scala 146 | object Main extends StandardPureApp[IO] { 147 | 148 | // MODEL 149 | 150 | type Model = String 151 | 152 | type Msg = Unit 153 | 154 | type Cmd = Unit 155 | 156 | def init: (Model, Cmd) = ("Hello PureApp!", ()) 157 | 158 | def quit(msg: Msg): Boolean = true 159 | 160 | // UPDATE 161 | 162 | def update(msg: Msg, model: Model): (Model, Cmd) = (model, ()) 163 | 164 | // IO 165 | 166 | def io(model: Model, cmd: Cmd): IO[Msg] = 167 | putStrLn(model) 168 | } 169 | // defined object Main 170 | 171 | Main.main(Array()) 172 | // Hello PureApp! 173 | ``` 174 | 175 | An example that is a little more involved can be found here: [TodoList](https://github.com/battermann/pureapp/blob/master/examples/todolist/src/main/scala/example/Main.scala). 176 | 177 | ## command line args 178 | 179 | To use command line arguments we have to override the `runl(args: List[String])` method. And the call `run(_init: (Model, Cmd))` manually. Now we can use `args` for creating the initial `Model` and `Cmd` e.g. like this: 180 | 181 | ```scala 182 | object Main extends StandardPureApp[IO] { 183 | 184 | override def runl(args: List[String]) = 185 | run((Model(args = args), Cmd.Empty)) 186 | 187 | // ... 188 | } 189 | ``` 190 | 191 | ## composability 192 | 193 | PureApp programs are pure, immutable values represented by the case class `Program[F[_]: Effect, Model, Msg, Cmd, Resource, A]`. 194 | 195 | There are different constructors for the three different flavours described above: 196 | 197 | - `Program.simple(...)` 198 | - `Program.standard(...)` 199 | - or `Program.apply(...)` 200 | 201 | By default, the final result of a program is `F[Model]`, the final application state. If we need our program to return something else we can map over it with `map` and pass a function `f: A => B`. 202 | 203 | To finally create a composable program, we have to transform it to it's representation in the context of it's effect type `F[_]` by calling `build()`. Note that this will not run the program. 204 | 205 | Now we have all the compositional capabilities at hand that the type `F[_]` offers. 206 | 207 | Here is a (not very meaningful) example of showing the technique of composing programs: 208 | 209 | ```scala 210 | import cats.implicits._ 211 | // import cats.implicits._ 212 | 213 | val p1 = Program.simple( 214 | "Hello PureApp 1!", 215 | (_: Unit, model: String) => model, 216 | (_: String) => IO.unit, 217 | (_: Unit) => true 218 | ).map(List(_)).build() 219 | // p1: cats.effect.IO[List[String]] = 220 | 221 | val p2 = Program.simple( 222 | "Hello PureApp 2!", 223 | (_: Unit, model: String) => model, 224 | (_: String) => IO.unit, 225 | (_: Unit) => true 226 | ).map(List(_)).build() 227 | // p2: cats.effect.IO[List[String]] = 228 | 229 | val program = p1 |+| p2 230 | // program: cats.effect.IO[List[String]] = IO$378649170 231 | 232 | program.unsafeRunSync() 233 | // res1: List[String] = List(Hello PureApp 1!, Hello PureApp 2!) 234 | ``` 235 | 236 | Alternatively and for convenience, instead of using the constructors we can implement one of the three abstract classes: 237 | 238 | - `SimplePureProgram[F_]` 239 | - `StandardPureProgram[F_]` 240 | - or `PureProgram[F_]` 241 | 242 | Here is how to apply this approach to the example from above: 243 | 244 | ```scala 245 | object Hello1 extends SimplePureProgram[IO] { 246 | type Model = String 247 | type Msg = Unit 248 | def init: Model = "Hello PureApp 1!" 249 | def quit(msg: Msg): Boolean = true 250 | def update(msg: Msg, model: Model): Model = model 251 | def io(model: Model): IO[Msg] = IO.unit 252 | } 253 | // defined object Hello1 254 | 255 | object Hello2 extends SimplePureProgram[IO] { 256 | type Model = String 257 | type Msg = Unit 258 | def init: Model = "Hello PureApp 2!" 259 | def quit(msg: Msg): Boolean = true 260 | def update(msg: Msg, model: Model): Model = model 261 | def io(model: Model): IO[Msg] = IO.unit 262 | } 263 | // defined object Hello2 264 | ``` 265 | 266 | Similar to scalaz, PureApp offers an abstract class `SafeApp[F[_]]` that provides an implementation of the `main` method by running a specified `Effect[F]`. We can use this to embed the composition of the two programs: 267 | 268 | ```scala 269 | object Main extends SafeApp[IO] { 270 | 271 | val program = 272 | Hello1.program.map(List(_)).build() |+| 273 | Hello2.program.map(List(_)).build() 274 | 275 | override def run: IO[Unit] = 276 | program.flatMap(v => putStrLn(v.toString)) 277 | } 278 | // defined object Main 279 | 280 | Main.main(Array()) 281 | // List(Hello PureApp 1!, Hello PureApp 2!) 282 | ``` 283 | 284 | 285 | ## internals 286 | 287 | Internally PureApp uses an instance of `StateT[F, (Model, Cmd, Resource), Msg]`. The program loop is implemented with `iterateUntil` which is stack safe. And the state is run with the initial `Model` and `Cmd`. 288 | 289 | Also we do not have to run our program. This is handled internally. The given effect is evaluated in the context of `F[_]` to an `IO[Unit]`. Which is then run with `unsafeRunSync` similar to scalaz's SafeApp. 290 | 291 | ## contributions 292 | 293 | I'm happy for any kind of contributions whatsoever, be it comments, issues, or pull requests. 294 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | organization := "com.github.battermann" 2 | scalaVersion := "2.12.4" 3 | name := "pureapp" 4 | 5 | enablePlugins(TutPlugin) 6 | tutTargetDirectory := baseDirectory.value 7 | scalacOptions in Tut --= Seq("-Ywarn-unused:imports", "-Xfatal-warnings") 8 | 9 | libraryDependencies += "org.typelevel" %% "cats-effect" % "1.0.0-RC" 10 | libraryDependencies += "org.scalactic" %% "scalactic" % "3.0.5" 11 | libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.5" % "test" 12 | 13 | resolvers += Resolver.sonatypeRepo("releases") 14 | addCompilerPlugin("org.spire-math" %% "kind-projector" % "0.9.4") 15 | addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.1.0") 16 | 17 | compile := (compile in Compile dependsOn tut).value 18 | 19 | scalacOptions ++= Seq( 20 | "-deprecation", // Emit warning and location for usages of deprecated APIs. 21 | "-encoding", 22 | "utf-8", // Specify character encoding used by source files. 23 | "-explaintypes", // Explain type errors in more detail. 24 | "-feature", // Emit warning and location for usages of features that should be imported explicitly. 25 | "-language:existentials", // Existential types (besides wildcard types) can be written and inferred 26 | "-language:experimental.macros", // Allow macro definition (besides implementation and application) 27 | "-language:higherKinds", // Allow higher-kinded types 28 | "-language:implicitConversions", // Allow definition of implicit functions called views 29 | "-unchecked", // Enable additional warnings where generated code depends on assumptions. 30 | "-Xcheckinit", // Wrap field accessors to throw an exception on uninitialized access. 31 | "-Xfatal-warnings", // Fail the compilation if there are any warnings. 32 | "-Xfuture", // Turn on future language features. 33 | "-Xlint:adapted-args", // Warn if an argument list is modified to match the receiver. 34 | "-Xlint:by-name-right-associative", // By-name parameter of right associative operator. 35 | "-Xlint:constant", // Evaluation of a constant arithmetic expression results in an error. 36 | "-Xlint:delayedinit-select", // Selecting member of DelayedInit. 37 | "-Xlint:doc-detached", // A Scaladoc comment appears to be detached from its element. 38 | "-Xlint:inaccessible", // Warn about inaccessible types in method signatures. 39 | "-Xlint:infer-any", // Warn when a type argument is inferred to be `Any`. 40 | "-Xlint:missing-interpolator", // A string literal appears to be missing an interpolator id. 41 | "-Xlint:nullary-override", // Warn when non-nullary `def f()' overrides nullary `def f'. 42 | "-Xlint:nullary-unit", // Warn when nullary methods return Unit. 43 | "-Xlint:option-implicit", // Option.apply used implicit view. 44 | "-Xlint:package-object-classes", // Class or object defined in package object. 45 | "-Xlint:poly-implicit-overload", // Parameterized overloaded implicit methods are not visible as view bounds. 46 | "-Xlint:private-shadow", // A private field (or class parameter) shadows a superclass field. 47 | "-Xlint:stars-align", // Pattern sequence wildcard must align with sequence component. 48 | "-Xlint:type-parameter-shadow", // A local type parameter shadows a type already in scope. 49 | "-Xlint:unsound-match", // Pattern match may not be typesafe. 50 | "-Yno-adapted-args", // Do not adapt an argument list (either by inserting () or creating a tuple) to match the receiver. 51 | "-Ypartial-unification", // Enable partial unification in type constructor inference 52 | "-Ywarn-dead-code", // Warn when dead code is identified. 53 | "-Ywarn-extra-implicit", // Warn when more than one implicit parameter section is defined. 54 | "-Ywarn-inaccessible", // Warn about inaccessible types in method signatures. 55 | "-Ywarn-infer-any", // Warn when a type argument is inferred to be `Any`. 56 | "-Ywarn-nullary-override", // Warn when non-nullary `def f()' overrides nullary `def f'. 57 | "-Ywarn-nullary-unit", // Warn when nullary methods return Unit. 58 | "-Ywarn-numeric-widen", // Warn when numerics are widened. 59 | // "-Ywarn-unused:implicits", // Warn if an implicit parameter is unused. // 60 | "-Ywarn-unused:imports", // Warn if an import selector is not referenced. 61 | "-Ywarn-unused:locals", // Warn if a local definition is unused. 62 | // "-Ywarn-unused:params", // Warn if a value parameter is unused. // turned off because of the args parameter of the abstract runl method 63 | "-Ywarn-unused:patvars", // Warn if a variable bound in a pattern is unused. 64 | "-Ywarn-unused:privates", // Warn if a private member is unused. 65 | "-Ywarn-value-discard" // Warn when non-Unit expression results are unused. 66 | ) 67 | 68 | scalacOptions in (Compile, console) --= Seq("-Ywarn-unused:imports", 69 | "-Xfatal-warnings") 70 | 71 | publishTo := sonatypePublishTo.value 72 | 73 | inThisBuild( 74 | List( 75 | licenses += ("MIT License", url( 76 | "https://github.com/battermann/pureapp/blob/master/LICENSE")), 77 | homepage := Some(url("https://github.com/battermann/pureapp")), 78 | developers := List( 79 | Developer(id = "battermann", 80 | name = "Leif Battermann", 81 | email = "leifbattermann@gmail.com", 82 | url = url("http://github.com/battermann")) 83 | ), 84 | scmInfo := Some(ScmInfo(url("https://github.com/battermann/pureapp"), 85 | "scm:git@github.com:battermann/pureapp.git")), 86 | credentials ++= ( 87 | for { 88 | username <- sys.env.get("SONATYPE_USER") 89 | password <- sys.env.get("SONATYPE_PASSWORD") 90 | } yield 91 | Credentials("Sonatype Nexus Repository Manager", 92 | "oss.sonatype.org", 93 | username, 94 | password) 95 | ).toList 96 | )) 97 | -------------------------------------------------------------------------------- /examples/cli-args/build.sbt: -------------------------------------------------------------------------------- 1 | lazy val root = (project in file(".")) 2 | .dependsOn(pureApp) 3 | .settings( 4 | scalaVersion := "2.12.4", 5 | name := "pureapp-cli-args-example" 6 | ) 7 | 8 | lazy val pureApp = ProjectRef(file("../.."), "pureapp") 9 | -------------------------------------------------------------------------------- /examples/cli-args/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.1.1 2 | -------------------------------------------------------------------------------- /examples/cli-args/src/main/scala/example/Main.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import cats.effect.IO 4 | import cats.implicits._ 5 | import com.github.battermann.pureapp._ 6 | 7 | object Main extends SimplePureApp[IO] { 8 | 9 | // MODEL 10 | 11 | type Model = String 12 | 13 | type Msg = Unit 14 | 15 | def init: Model = "hello pureapp" 16 | 17 | def quit(msg: Msg): Boolean = true 18 | 19 | override def runl(args: List[String]) = 20 | run(s"args: [${args.mkString(", ")}]") 21 | 22 | // UPDATE 23 | 24 | def update(msg: Msg, model: Model): Model = model 25 | 26 | // IO 27 | 28 | def io(model: Model): IO[Msg] = 29 | IO { println(model) }.void 30 | } 31 | -------------------------------------------------------------------------------- /examples/command/build.sbt: -------------------------------------------------------------------------------- 1 | lazy val root = (project in file(".")) 2 | .dependsOn(pureApp) 3 | .settings( 4 | scalaVersion := "2.12.4", 5 | name := "pureapp-command-example" 6 | ) 7 | 8 | lazy val pureApp = ProjectRef(file("../.."), "pureapp") 9 | -------------------------------------------------------------------------------- /examples/command/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.1.1 2 | -------------------------------------------------------------------------------- /examples/command/src/main/scala/example/Main.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import cats.effect.IO 4 | import com.github.battermann.pureapp._ 5 | import cats.implicits._ 6 | import com.github.battermann.pureapp.interpreters.{FileSystem, Terminal} 7 | 8 | object Main extends StandardPureApp[IO] { 9 | 10 | // MODEL 11 | 12 | type Model = Option[Either[String, String]] 13 | 14 | sealed trait Msg 15 | final case class ReadFromFile(fileName: String) extends Msg 16 | final case class FileContentResult(result: Either[Throwable, String]) 17 | extends Msg 18 | case object Quit extends Msg 19 | 20 | sealed trait Cmd 21 | object Cmd { 22 | case object Empty extends Cmd 23 | final case class ReadFromFile(fileName: String) extends Cmd 24 | } 25 | 26 | def init: (Model, Cmd) = (None, Cmd.Empty) 27 | 28 | def quit(msg: Msg): Boolean = msg == Quit 29 | 30 | // UPDATE 31 | 32 | def update(msg: Msg, model: Model): (Model, Cmd) = 33 | msg match { 34 | case Quit => (model, Cmd.Empty) 35 | 36 | case FileContentResult(Right(content)) => 37 | (content.asRight.some, Cmd.Empty) 38 | 39 | case FileContentResult(Left(err)) => 40 | (err.getMessage.asLeft.some, Cmd.Empty) 41 | 42 | case ReadFromFile(fileName) => (None, Cmd.ReadFromFile(fileName)) 43 | } 44 | 45 | // IO 46 | 47 | def io(model: Model, cmd: Cmd): IO[Msg] = 48 | cmd match { 49 | case Cmd.Empty => 50 | model match { 51 | case None => 52 | Terminal.putStr("enter a file name> ") *> Terminal.readLine map ReadFromFile 53 | 54 | case Some(Right(content)) => 55 | Terminal.putStrLn(content) map (_ => Quit) 56 | 57 | case Some(Left(err)) => 58 | Terminal.putStrLn(s"error: $err") map (_ => Quit) 59 | } 60 | 61 | case Cmd.ReadFromFile(fileName) => 62 | FileSystem.readLines(fileName) map (_.map(_.mkString("\n"))) map FileContentResult 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /examples/counter/build.sbt: -------------------------------------------------------------------------------- 1 | lazy val root = (project in file(".")) 2 | .dependsOn(pureApp) 3 | .settings( 4 | scalaVersion := "2.12.4", 5 | name := "pureapp-counter-example" 6 | ) 7 | 8 | lazy val pureApp = ProjectRef(file("../.."), "pureapp") 9 | -------------------------------------------------------------------------------- /examples/counter/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.1.1 2 | -------------------------------------------------------------------------------- /examples/counter/src/main/scala/example/Main.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import com.github.battermann.pureapp._ 4 | import cats.effect.IO 5 | import com.github.battermann.pureapp.interpreters.Terminal 6 | 7 | object Main extends SimplePureApp[IO] { 8 | 9 | // MODEL 10 | 11 | type Model = Int 12 | 13 | sealed trait Msg 14 | case object Increment extends Msg 15 | case object Decrement extends Msg 16 | case object InvalidInput extends Msg 17 | case object Quit extends Msg 18 | 19 | def init: Model = 42 20 | 21 | def quit(msg: Msg): Boolean = msg == Quit 22 | 23 | // UPDATE 24 | 25 | def update(msg: Msg, model: Model): Model = 26 | msg match { 27 | case Increment => model + 1 28 | case Decrement => model - 1 29 | case Quit => model 30 | case InvalidInput => model 31 | } 32 | 33 | // IO 34 | 35 | def io(model: Model): IO[Msg] = 36 | for { 37 | _ <- Terminal.putStrLn(model.toString) 38 | _ <- Terminal.putStr("enter: +, -, or q> ") 39 | input <- Terminal.readLine 40 | } yield { 41 | input match { 42 | case "+" => Increment 43 | case "-" => Decrement 44 | case "q" => Quit 45 | case _ => InvalidInput 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /examples/env/build.sbt: -------------------------------------------------------------------------------- 1 | lazy val root = (project in file(".")) 2 | .dependsOn(pureApp) 3 | .settings( 4 | scalaVersion := "2.12.4", 5 | name := "pureapp-env-example", 6 | libraryDependencies += "com.typesafe.play" %% "play-ahc-ws-standalone" % "2.0.0-M1" 7 | ) 8 | 9 | lazy val pureApp = ProjectRef(file("../.."), "pureapp") 10 | -------------------------------------------------------------------------------- /examples/env/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.1.1 2 | -------------------------------------------------------------------------------- /examples/env/src/main/scala/example/Main.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import akka.actor.ActorSystem 4 | import akka.stream.ActorMaterializer 5 | import cats.effect.IO 6 | import play.api.libs.ws.StandaloneWSClient 7 | import play.api.libs.ws.ahc.StandaloneAhcWSClient 8 | import play.api.libs.ws.DefaultBodyReadables._ 9 | import scala.concurrent.ExecutionContext.Implicits._ 10 | import cats.implicits._ 11 | 12 | import com.github.battermann.pureapp._ 13 | import com.github.battermann.pureapp.interpreters._ 14 | 15 | object Main extends PureApp[IO] { 16 | 17 | // MODEL 18 | 19 | type Model = String 20 | 21 | final case class Resource(wsClient: StandaloneAhcWSClient, system: ActorSystem) 22 | 23 | sealed trait Msg 24 | case object Quit extends Msg 25 | 26 | sealed trait Cmd 27 | object Cmd { 28 | case object Empty extends Cmd 29 | case object GetRequest extends Cmd 30 | } 31 | 32 | def init: (Model, Cmd) = ("http://www.google.com", Cmd.GetRequest) 33 | 34 | def quit(msg: Msg): Boolean = msg == Quit 35 | 36 | // UPDATE 37 | 38 | def update(msg: Msg, model: Model): (Model, Cmd) = 39 | (model, Cmd.Empty) 40 | 41 | // IO 42 | 43 | def acquire: IO[Resource] = IO { 44 | implicit val sys = ActorSystem() 45 | implicit val mat = ActorMaterializer() 46 | Resource(StandaloneAhcWSClient(), sys) 47 | } 48 | 49 | def dispose(env: Resource): IO[Unit] = for { 50 | _ <- Terminal.putStrLn("Disposing resources...") 51 | _ <- IO(env.wsClient.close()) 52 | _ <- IO(env.system.terminate()) 53 | _ <- Terminal.putStrLn("Disposed resources.") 54 | } yield () 55 | 56 | def call(wsClient: StandaloneWSClient, 57 | url: String): IO[Either[Throwable, String]] = 58 | IO.fromFuture { 59 | IO { 60 | wsClient.url(url).get().map { response ⇒ 61 | response.body[String] 62 | } 63 | } 64 | }.attempt 65 | 66 | def io(model: Model, cmd: Cmd, env: Resource): IO[Msg] = 67 | cmd match { 68 | case Cmd.GetRequest => 69 | call(env.wsClient, model) 70 | .map(_.leftMap(_.getMessage).merge) 71 | .flatMap(Terminal.putStrLn) map (_ => Quit) 72 | 73 | case Cmd.Empty => Quit.pure[IO] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /examples/task/build.sbt: -------------------------------------------------------------------------------- 1 | lazy val root = (project in file(".")) 2 | .dependsOn(pureApp) 3 | .settings( 4 | scalaVersion := "2.12.4", 5 | name := "pureapp-monix-task-example", 6 | libraryDependencies += "io.monix" %% "monix" % "3.0.0-8084549" 7 | ) 8 | 9 | lazy val pureApp = ProjectRef(file("../.."), "pureapp") 10 | -------------------------------------------------------------------------------- /examples/task/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.1.1 2 | -------------------------------------------------------------------------------- /examples/task/src/main/scala/example/Main.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import com.github.battermann.pureapp._ 4 | import cats.implicits._ 5 | import monix.execution.Scheduler.Implicits.global 6 | import monix.eval.Task 7 | 8 | /** 9 | * Currently not working, no Bracket instance for Task 10 | */ 11 | object Main extends SimplePureApp[Task] { 12 | 13 | // MODEL 14 | 15 | type Model = String 16 | 17 | type Msg = Unit 18 | 19 | def init: Model = "hello monix task" 20 | 21 | def quit(msg: Msg): Boolean = true 22 | 23 | // UPDATE 24 | 25 | def update(msg: Msg, model: Model): Model = model 26 | 27 | // IO 28 | 29 | def io(model: Model): Task[Msg] = 30 | Task { println(model) }.void 31 | } 32 | -------------------------------------------------------------------------------- /examples/todolist/build.sbt: -------------------------------------------------------------------------------- 1 | lazy val root = (project in file(".")) 2 | .dependsOn(pureApp) 3 | .settings( 4 | scalaVersion := "2.12.4", 5 | name := "pureapp-todolist-example" 6 | ) 7 | 8 | lazy val pureApp = ProjectRef(file("../.."), "pureapp") 9 | -------------------------------------------------------------------------------- /examples/todolist/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.1.1 2 | -------------------------------------------------------------------------------- /examples/todolist/src/main/scala/example/Main.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import cats.effect.IO 4 | import cats.implicits._ 5 | import com.github.battermann.pureapp._ 6 | import com.github.battermann.pureapp.interpreters.{FileSystem, Terminal} 7 | 8 | import scala.util.Try 9 | 10 | object Main extends StandardPureApp[IO] { 11 | 12 | // MODEL 13 | 14 | private val fileName = "todos.csv" 15 | 16 | sealed trait Todo 17 | final case class Active(name: String) extends Todo 18 | final case class Completed(name: String) extends Todo 19 | 20 | sealed trait Status 21 | final case class Error(msg: String) extends Status 22 | final case class Info(msg: String) extends Status 23 | 24 | final case class Model( 25 | todos: List[Todo], 26 | status: Option[Status] = None 27 | ) 28 | 29 | sealed trait Msg 30 | final case class LoadResult(result: Either[String, List[Todo]]) extends Msg 31 | final case class Add(name: String) extends Msg 32 | final case class Delete(id: Int) extends Msg 33 | final case class MarkCompleted(id: Int) extends Msg 34 | case object InvalidInput extends Msg 35 | case object Save extends Msg 36 | final case class SaveResult(result: Either[String, Unit]) extends Msg 37 | case object Quit extends Msg 38 | 39 | sealed trait Cmd 40 | object Cmd { 41 | case object Empty extends Cmd 42 | final case class Save(fileName: String, list: List[Todo]) extends Cmd 43 | final case class Load(fileName: String) extends Cmd 44 | } 45 | 46 | def init: (Model, Cmd) = (Model(Nil), Cmd.Load(fileName)) 47 | 48 | def quit(msg: Msg): Boolean = msg == Quit 49 | 50 | // UPDATE 51 | 52 | def update(msg: Msg, model: Model): (Model, Cmd) = 53 | msg match { 54 | 55 | case LoadResult(Right(list)) => 56 | (Model(list, Some(Info("successfully loaded todos from file"))), 57 | Cmd.Empty) 58 | 59 | case LoadResult(Left(err)) => 60 | (model.copy( 61 | status = Some(Info(s"could not load todos from file. $err"))), 62 | Cmd.Empty) 63 | 64 | case Add(name) => 65 | (Model(model.todos :+ Active(name), Some(Info("item added"))), 66 | Cmd.Empty) 67 | 68 | case Delete(id) => 69 | (Model( 70 | model.todos.zipWithIndex.filter { case (_, i) => i != id }.map(_._1), 71 | Some(Info("item deleted"))), 72 | Cmd.Empty) 73 | 74 | case MarkCompleted(id) => 75 | val updatedList = model.todos.zipWithIndex 76 | .map { 77 | case (Active(name), i) if i == id => 78 | (Completed(name), i) 79 | case todo => todo 80 | } 81 | .map(_._1) 82 | (Model(updatedList, Some(Info("marked as completed"))), Cmd.Empty) 83 | 84 | case InvalidInput => 85 | (model.copy(status = Some(Error("invalid input"))), Cmd.Empty) 86 | 87 | case Save => 88 | (model, Cmd.Save(fileName, model.todos)) 89 | 90 | case SaveResult(Right(())) => 91 | (model.copy(status = Some(Info("saved successfully"))), Cmd.Empty) 92 | 93 | case SaveResult(Left(err)) => 94 | (model.copy(status = Some(Error(err))), Cmd.Empty) 95 | 96 | case Quit => (model, Cmd.Empty) 97 | } 98 | 99 | // IO 100 | 101 | def parse(input: String): Msg = { 102 | if (input == "q") Quit 103 | else if (input == "s") Save 104 | else { 105 | Try { 106 | val cmd = input.substring(0, input.indexOf(' ')).trim 107 | val value = input.substring(input.indexOf(' ')).trim 108 | cmd match { 109 | case "a" => Add(value) 110 | case "d" => Delete(value.toInt - 1) 111 | case "c" => MarkCompleted(value.toInt - 1) 112 | } 113 | }.getOrElse(InvalidInput) 114 | } 115 | } 116 | 117 | def printUsage: IO[Unit] = 118 | Terminal.putStrLn("""usage: 119 | | 'a ' adds a new todo 120 | | 'd ' deletes a todo 121 | | 'c ' marks todo as completed 122 | | 's' to save 123 | | 'q' to quit 124 | | """.stripMargin) 125 | 126 | def getAndParseInput: IO[Msg] = 127 | Terminal.putStr(">>> ").flatMap(_ => Terminal.readLine.map(parse)) 128 | 129 | def formatList(list: List[Todo]): String = 130 | if (list.isEmpty) "no todos" 131 | else 132 | list.zipWithIndex 133 | .map { 134 | case (Active(name), i) => s"${i + 1}. [active] $name" 135 | case (Completed(name), i) => s"${i + 1}. [completed] $name" 136 | } 137 | .mkString("\n") 138 | 139 | def decode(lines: List[String]): Either[String, List[Todo]] = 140 | Try( 141 | lines.map(_.split(",").map(_.trim)).map(arr => (arr(0), arr(1))).flatMap { 142 | case ("a", name) => Some(Active(name)) 143 | case ("c", name) => Some(Completed(name)) 144 | case _ => None 145 | }).toEither.leftMap(_.getMessage) 146 | 147 | def encode(list: List[Todo]): String = 148 | list 149 | .map { 150 | case Active(name) => s"a, $name" 151 | case Completed(name) => s"c, $name" 152 | } 153 | .mkString("\n") 154 | 155 | def io(model: Model, cmd: Cmd): IO[Msg] = 156 | cmd match { 157 | case Cmd.Empty => 158 | model.status match { 159 | case Some(Info(_)) | None => 160 | for { 161 | _ <- Terminal.putStrLn( 162 | s"\n## TODOS\n\n${formatList(model.todos)}\n") 163 | _ <- printUsage 164 | _ <- model.status 165 | .map(s => s"[$s]") 166 | .map(Terminal.putStrLn) 167 | .getOrElse(IO.unit) 168 | msg <- getAndParseInput 169 | } yield msg 170 | 171 | case Some(Error(err)) => 172 | Terminal.putStrLn(s"\n[$err]").flatMap(_ => getAndParseInput) 173 | } 174 | 175 | case Cmd.Save(fn, content) => 176 | FileSystem 177 | .save(fn, encode(content)) 178 | .map(_.leftMap(_.getMessage)) 179 | .map(SaveResult) 180 | 181 | case Cmd.Load(fn) => 182 | FileSystem 183 | .readLines(fn) 184 | .map(_.leftMap(_.getMessage).flatMap(decode)) 185 | .map(LoadResult) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.1.1 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.dwijnand" % "sbt-dynver" % "3.0.0") 2 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "2.3") 3 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.1.1") 4 | addSbtPlugin("org.tpolecat" % "tut-plugin" % "0.6.4") 5 | -------------------------------------------------------------------------------- /src/main/scala/com/github/battermann/pureapp/PureApp.scala: -------------------------------------------------------------------------------- 1 | package com.github.battermann.pureapp 2 | 3 | import cats.data.StateT 4 | import cats.effect._ 5 | import cats.implicits._ 6 | import com.github.battermann.pureapp.Program.{SimpleProgram, StandardProgram} 7 | 8 | final case class Program[F[_]: Effect, Model, Msg, Cmd, Resource, A]( 9 | acquire: F[Resource], 10 | init: (Model, Cmd), 11 | update: (Msg, Model) => (Model, Cmd), 12 | io: (Model, Cmd, Resource) => F[Msg], 13 | quit: Msg => Boolean, 14 | dispose: Resource => F[Unit], 15 | mkResult: Model => A 16 | ) { 17 | 18 | /** Transforms a program to it's representation in the context of it's effect type `F[_]` and maps the function `f` over the final value produced by the program. The program will not be run. */ 19 | def buildMap[B](f: A => B): F[B] = 20 | this.map(f).build() 21 | 22 | /** Transforms a program to it's representation in the context of it's effect type `F[_]`. The program will not be run. */ 23 | def build(): F[A] = { 24 | val app = StateT[F, (Model, Cmd, Resource), Msg] { 25 | case (model, cmd, resource) => 26 | io(model, cmd, resource).map { msg => 27 | val (updatedModel, newCmd) = update(msg, model) 28 | ((updatedModel, newCmd, resource), msg) 29 | } 30 | } 31 | 32 | val (initialModel, initialCmd) = init 33 | 34 | val finalModel = for { 35 | ((model, _, _), msg) <- Bracket[F, Throwable].bracket(acquire) { 36 | resource => 37 | app 38 | .iterateUntil(quit) 39 | .run((initialModel, initialCmd, resource)) 40 | } { resource => 41 | dispose(resource) 42 | } 43 | } yield update(msg, model)._1 44 | 45 | finalModel.map(mkResult) 46 | } 47 | 48 | /** Maps the function `f` over the final value produced by the program. */ 49 | def map[B](f: A => B): Program[F, Model, Msg, Cmd, Resource, B] = 50 | Program(acquire, init, update, io, quit, dispose, mkResult andThen f) 51 | 52 | /** Creates a new program with the target type `G[_]` by replacing its `acquire`, `io`, and `dispose` function. */ 53 | def withIo[G[_]: Effect, R]( 54 | acquire: G[R], 55 | io: (Model, Cmd, R) => G[Msg], 56 | dispose: R => G[Unit]): Program[G, Model, Msg, Cmd, R, A] = 57 | Program(acquire, init, update, io, quit, dispose, mkResult) 58 | } 59 | 60 | object Program { 61 | 62 | type StandardProgram[F[_], Model, Msg, Cmd, A] = 63 | Program[F, Model, Msg, Cmd, Unit, A] 64 | 65 | type SimpleProgram[F[_], Model, Msg, A] = 66 | Program[F, Model, Msg, Unit, Unit, A] 67 | 68 | /** Constructor for a simple program.*/ 69 | def simple[F[_]: Effect, Model, Msg]( 70 | init: Model, 71 | update: (Msg, Model) => Model, 72 | io: Model => F[Msg], 73 | quit: Msg => Boolean 74 | ): SimpleProgram[F, Model, Msg, Model] = 75 | Program(Effect[F].unit, 76 | (init, ()), 77 | (msg, model) => (update(msg, model), ()), 78 | (model, _, _) => io(model), 79 | quit, 80 | _ => Effect[F].unit, 81 | identity) 82 | 83 | /** Constructor for a standard program.*/ 84 | def standard[F[_]: Effect, Model, Msg, Cmd]( 85 | init: (Model, Cmd), 86 | update: (Msg, Model) => (Model, Cmd), 87 | io: (Model, Cmd) => F[Msg], 88 | quit: Msg => Boolean): StandardProgram[F, Model, Msg, Cmd, Model] = 89 | Program(Effect[F].unit, 90 | init, 91 | update, 92 | (model, cmd, _) => io(model, cmd), 93 | quit, 94 | _ => Effect[F].unit, 95 | identity) 96 | 97 | /** Creates a new standard program with the target type `G[_]` with the given `io` function. */ 98 | implicit class WithIoStandard[F[_]: Effect, Model, Msg, Cmd, A]( 99 | val p: StandardProgram[F, Model, Msg, Cmd, A]) { 100 | def withIoStandard[G[_]: Effect]( 101 | io: (Model, Cmd) => G[Msg]): StandardProgram[G, Model, Msg, Cmd, A] = 102 | p.withIo(Effect[G].unit, 103 | (model, cmd, _) => io(model, cmd), 104 | _ => Effect[G].unit) 105 | } 106 | 107 | /** Creates a new simple program with the target type `G[_]` with the given `io` function. */ 108 | implicit class WithIoSimple[F[_]: Effect, Model, Msg, A]( 109 | val p: SimpleProgram[F, Model, Msg, A]) { 110 | def withIoSimple[G[_]: Effect]( 111 | io: Model => G[Msg]): SimpleProgram[G, Model, Msg, A] = 112 | p.withIo(Effect[G].unit, (model, _, _) => io(model), _ => Effect[G].unit) 113 | } 114 | } 115 | 116 | abstract class PureProgram[F[_]: Effect] { 117 | type Resource 118 | 119 | type Model 120 | 121 | type Msg 122 | 123 | type Cmd 124 | 125 | def acquire: F[Resource] 126 | 127 | def init: (Model, Cmd) 128 | 129 | def update(msg: Msg, model: Model): (Model, Cmd) 130 | 131 | def io(model: Model, cmd: Cmd, env: Resource): F[Msg] 132 | 133 | def quit(msg: Msg): Boolean 134 | 135 | def dispose(env: Resource): F[Unit] 136 | 137 | val program: Program[F, Model, Msg, Cmd, Resource, Model] = 138 | Program(acquire, init, update, io, quit, dispose, identity) 139 | } 140 | 141 | abstract class StandardPureProgram[F[_]: Effect] { 142 | type Model 143 | 144 | type Msg 145 | 146 | type Cmd 147 | 148 | def init: (Model, Cmd) 149 | 150 | def update(msg: Msg, model: Model): (Model, Cmd) 151 | 152 | def io(model: Model, cmd: Cmd): F[Msg] 153 | 154 | def quit(msg: Msg): Boolean 155 | 156 | val program: StandardProgram[F, Model, Msg, Cmd, Model] = 157 | Program.standard(init, update, io, quit) 158 | } 159 | 160 | abstract class SimplePureProgram[F[_]: Effect] { 161 | type Model 162 | 163 | type Msg 164 | 165 | def init: Model 166 | 167 | def update(msg: Msg, model: Model): Model 168 | 169 | def io(model: Model): F[Msg] 170 | 171 | def quit(msg: Msg): Boolean 172 | 173 | val program: SimpleProgram[F, Model, Msg, Model] = 174 | Program.simple(init, update, io, quit) 175 | } 176 | 177 | abstract class PureApp[F[_]: Effect] extends PureProgram[F] { 178 | 179 | def runl(args: List[String]): F[Unit] = run() 180 | 181 | def run(_init: (Model, Cmd) = init): F[Unit] = 182 | program.copy(init = _init).build().void 183 | 184 | final def main(args: Array[String]): Unit = 185 | Effect[F] 186 | .runAsync(runl(args.toList)) { 187 | case Left(err) => 188 | IO { err.printStackTrace() } 189 | case Right(_) => IO.unit 190 | } 191 | .unsafeRunSync() 192 | } 193 | 194 | abstract class StandardPureApp[F[_]: Effect] extends StandardPureProgram[F] { 195 | def runl(args: List[String]): F[Unit] = run() 196 | 197 | def run(_init: (Model, Cmd) = init): F[Unit] = 198 | program.copy(init = _init).build().void 199 | 200 | final def main(args: Array[String]): Unit = 201 | Effect[F] 202 | .runAsync(runl(args.toList)) { 203 | case Left(err) => 204 | IO { err.printStackTrace() } 205 | case Right(_) => IO.unit 206 | } 207 | .unsafeRunSync() 208 | } 209 | 210 | abstract class SimplePureApp[F[_]: Effect] extends SimplePureProgram[F] { 211 | def runl(args: List[String]): F[Unit] = run() 212 | 213 | def run(_init: Model = init): F[Unit] = 214 | program.copy(init = (_init, ())).build().void 215 | 216 | final def main(args: Array[String]): Unit = 217 | Effect[F] 218 | .runAsync(runl(args.toList)) { 219 | case Left(err) => 220 | IO { err.printStackTrace() } 221 | case Right(_) => IO.unit 222 | } 223 | .unsafeRunSync() 224 | } 225 | 226 | abstract class SafeApp[F[_]: Effect] { 227 | def runl(args: List[String]): F[Unit] = run 228 | 229 | def run: F[Unit] = Effect[F].unit 230 | 231 | final def main(args: Array[String]): Unit = 232 | Effect[F] 233 | .runAsync(runl(args.toList)) { 234 | case Left(err) => 235 | IO { err.printStackTrace() } 236 | case Right(_) => IO.unit 237 | } 238 | .unsafeRunSync() 239 | } 240 | -------------------------------------------------------------------------------- /src/main/scala/com/github/battermann/pureapp/interpreters/FileSystem.scala: -------------------------------------------------------------------------------- 1 | package com.github.battermann.pureapp.interpreters 2 | 3 | import java.io.{File, PrintWriter} 4 | 5 | import cats.effect.IO 6 | 7 | object FileSystem { 8 | def readLines(name: String): IO[Either[Throwable, List[String]]] = 9 | IO { 10 | io.Source.fromFile(name).getLines.toList 11 | }.attempt 12 | 13 | def save(name: String, content: String): IO[Either[Throwable, Unit]] = 14 | IO { 15 | val writer = new PrintWriter(new File(name)) 16 | writer.write(content) 17 | writer.close() 18 | }.attempt 19 | } 20 | -------------------------------------------------------------------------------- /src/main/scala/com/github/battermann/pureapp/interpreters/Terminal.scala: -------------------------------------------------------------------------------- 1 | package com.github.battermann.pureapp.interpreters 2 | 3 | import cats.effect.IO 4 | 5 | object Terminal { 6 | def putStrLn(line: String): IO[Unit] = 7 | IO { println(line) } 8 | 9 | def putStr(str: String): IO[Unit] = 10 | IO { print(str) } 11 | 12 | def readLine: IO[String] = 13 | IO { io.StdIn.readLine() } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/tut/README.md: -------------------------------------------------------------------------------- 1 | # PureApp 2 | 3 | A principled and opinionated library for writing purely functional, easy to reason about, and stack-safe sequential programs partly inspired by [Elm](http://elm-lang.org/), [scalm](https://github.com/julienrf/scalm), and scalaz's [SafeApp](https://github.com/scalaz/scalaz/blob/bffbbcf366ca3a33dad6b3c10683228b20812bcf/effect/src/main/scala/scalaz/effect/SafeApp.scala) 4 | 5 | ## installtion 6 | 7 | libraryDependencies += "com.github.battermann" %% "pureapp" % "0.6.0" 8 | 9 | ## overview 10 | 11 | The architecture for PureApp applications is mainly inspired by the [Elm Architecture](https://guide.elm-lang.org/architecture/). 12 | 13 | An Idiomatic PureApp program is completely pure and referentially transparent. 14 | 15 | It can be either implemented as the main application or it can be composed of other PureApp programs (see below). 16 | 17 | A program consists of three components: 18 | 19 | ### model 20 | 21 | The model represents the immutable application state. 22 | 23 | ### update 24 | 25 | A way to update the application's state. `update` is a function that takes a `Model` (the current application state) and a `Msg` and returns a new `Model` (a new application state). 26 | 27 | ### io 28 | 29 | `io` is a function that describes all side effects of an application. 30 | 31 | Unlike Elm and scalm, PureApp applications do not have a `view` function. Instead `io` is responsible for printing and reading from the standard input/output as well as for other side effects. 32 | 33 | `io` takes a `Model` and returns an `F[Msg]`. Where `F[_]` has an instance of [`Effect[F]`](https://typelevel.org/cats-effect/typeclasses/effect.html). Additionally you can pass immutable, pure values of type `Cmd` that represent commands to perform other side effects than just printing and reading. 34 | 35 | Internally the `Msg` that is returned from `io` and wrapped inside an `F[_]` together with the current `Model` is fed back into the `update` function. However, this is hidden from the user and we do not have to worry about this. 36 | 37 | ## termination 38 | 39 | To control when to terminate a PureApp application we define `def quit(msg: Msg): Boolean`. If `quit` returns `true` when applied to a `Msg` coming from the `io` function the program will terminate. 40 | 41 | ## example 42 | 43 | How to use PureApp can best be demonstrated with an example. Here is the PureApp version of the [Elm counter example](http://elm-lang.org/examples/buttons): 44 | 45 | ```tut:book 46 | import com.github.battermann.pureapp._ 47 | import com.github.battermann.pureapp.interpreters.Terminal._ 48 | import cats.effect.IO 49 | 50 | object Main extends SimplePureApp[IO] { 51 | 52 | // MODEL 53 | 54 | type Model = Int 55 | 56 | sealed trait Msg 57 | case object Increment extends Msg 58 | case object Decrement extends Msg 59 | case object InvalidInput extends Msg 60 | case object Quit extends Msg 61 | 62 | def init: Model = 42 63 | 64 | def quit(msg: Msg): Boolean = msg == Quit 65 | 66 | // UPDATE 67 | 68 | def update(msg: Msg, model: Model): Model = 69 | msg match { 70 | case Increment => model + 1 71 | case Decrement => model - 1 72 | case Quit => model 73 | case InvalidInput => model 74 | } 75 | 76 | // IO 77 | 78 | def io(model: Model): IO[Msg] = 79 | for { 80 | _ <- putStrLn(model.toString) 81 | _ <- putStr("enter: +, -, or q> ") 82 | input <- readLine 83 | } yield { 84 | input match { 85 | case "+" => Increment 86 | case "-" => Decrement 87 | case "q" => Quit 88 | case _ => InvalidInput 89 | } 90 | } 91 | } 92 | ``` 93 | 94 | ## three different patterns 95 | 96 | PureApp supports three different patterns: 97 | 98 | ### SimplePureApp 99 | 100 | A simple program (like the counter example from above) knows only models and messages. We can create a simple program by extending from the `SimplePureApp[F_]]` class. 101 | 102 | ### StandardPureApp 103 | 104 | A *standard* program which extends `StandardPureApp[F[_]]` also supports commands. Normally printing to and reading from the console can be done based on the `Model` (the application state). If we want to perform other side effecting actions, we often can't or don't want to do this based on the application state. Instead we can use commands that represent requests for performing such tasks. The `io` function then becomes the interpreter for our commands as [this example](examples/command/src/main/scala/example/Main.scala) demonstrates. 105 | 106 | ### PureApp 107 | 108 | A program that can create and dispose resources in a referentially transparent way has to extend the `PureApp[F[_]]` class. The type `Resource` represents an environment containing disposable resources and other things that do not belong into the domain model (like e.g. a configuration). We have to provide an implementation for `def acquire: F[Resource]` and we can override `def dispose(resource: Resource): F[Unit]` to dispose resources. 109 | 110 | The `io` function of an `PureApp ` provides an additional parameter of type `PureApp ` that we can now use while interpreting our commands. [Here is an example](examples/env/src/main/scala/Main.scala) uses an HTTP client as a resource. 111 | 112 | 113 | ## minimal working skeleton 114 | 115 | To create a minimal working skeleton the main object of an application has to extend one of the three abstract classes mentioned above: 116 | 117 | - `SimplePureApp[F[_]]` 118 | - `StandardPureApp[F[_]]` 119 | - or `PureApp[F[_]]` 120 | 121 | Then the types `Model` and `Msg` have to be defined. Depending on which pattern we use we might have to define `Cmd` and `Resource` as well. 122 | 123 | Usually `Msg` and `Cmd` will be implemented as sum types. 124 | 125 | Finally all abstract methods have to be implemented: 126 | 127 | - `init` 128 | - `update` 129 | - `io` 130 | - `quit` (if we want the program to terminate) 131 | 132 | And optionally: 133 | 134 | - `acquire` 135 | - `dispose` 136 | 137 | Here is a minimal working skeleton to get started: 138 | 139 | ```tut:book 140 | object Main extends StandardPureApp[IO] { 141 | 142 | // MODEL 143 | 144 | type Model = String 145 | 146 | type Msg = Unit 147 | 148 | type Cmd = Unit 149 | 150 | def init: (Model, Cmd) = ("Hello PureApp!", ()) 151 | 152 | def quit(msg: Msg): Boolean = true 153 | 154 | // UPDATE 155 | 156 | def update(msg: Msg, model: Model): (Model, Cmd) = (model, ()) 157 | 158 | // IO 159 | 160 | def io(model: Model, cmd: Cmd): IO[Msg] = 161 | putStrLn(model) 162 | } 163 | 164 | Main.main(Array()) 165 | ``` 166 | 167 | An example that is a little more involved can be found here: [TodoList](https://github.com/battermann/pureapp/blob/master/examples/todolist/src/main/scala/example/Main.scala). 168 | 169 | ## command line args 170 | 171 | To use command line arguments we have to override the `runl(args: List[String])` method. And the call `run(_init: (Model, Cmd))` manually. Now we can use `args` for creating the initial `Model` and `Cmd` e.g. like this: 172 | 173 | ```tut:book:silent:fail 174 | object Main extends StandardPureApp[IO] { 175 | 176 | override def runl(args: List[String]) = 177 | run((Model(args = args), Cmd.Empty)) 178 | 179 | // ... 180 | } 181 | ``` 182 | 183 | ## composability 184 | 185 | PureApp programs are pure, immutable values represented by the case class `Program[F[_]: Effect, Model, Msg, Cmd, Resource, A]`. 186 | 187 | There are different constructors for the three different flavours described above: 188 | 189 | - `Program.simple(...)` 190 | - `Program.standard(...)` 191 | - or `Program.apply(...)` 192 | 193 | By default, the final result of a program is `F[Model]`, the final application state. If we need our program to return something else we can map over it with `map` and pass a function `f: A => B`. 194 | 195 | To finally create a composable program, we have to transform it to it's representation in the context of it's effect type `F[_]` by calling `build()`. Note that this will not run the program. 196 | 197 | Now we have all the compositional capabilities at hand that the type `F[_]` offers. 198 | 199 | Here is a (not very meaningful) example of showing the technique of composing programs: 200 | 201 | ```tut:book 202 | import cats.implicits._ 203 | 204 | val p1 = Program.simple( 205 | "Hello PureApp 1!", 206 | (_: Unit, model: String) => model, 207 | (_: String) => IO.unit, 208 | (_: Unit) => true 209 | ).map(List(_)).build() 210 | 211 | val p2 = Program.simple( 212 | "Hello PureApp 2!", 213 | (_: Unit, model: String) => model, 214 | (_: String) => IO.unit, 215 | (_: Unit) => true 216 | ).map(List(_)).build() 217 | 218 | val program = p1 |+| p2 219 | 220 | program.unsafeRunSync() 221 | ``` 222 | 223 | Alternatively and for convenience, instead of using the constructors we can implement one of the three abstract classes: 224 | 225 | - `SimplePureProgram[F_]` 226 | - `StandardPureProgram[F_]` 227 | - or `PureProgram[F_]` 228 | 229 | Here is how to apply this approach to the example from above: 230 | 231 | ```tut:book 232 | object Hello1 extends SimplePureProgram[IO] { 233 | type Model = String 234 | type Msg = Unit 235 | def init: Model = "Hello PureApp 1!" 236 | def quit(msg: Msg): Boolean = true 237 | def update(msg: Msg, model: Model): Model = model 238 | def io(model: Model): IO[Msg] = IO.unit 239 | } 240 | 241 | object Hello2 extends SimplePureProgram[IO] { 242 | type Model = String 243 | type Msg = Unit 244 | def init: Model = "Hello PureApp 2!" 245 | def quit(msg: Msg): Boolean = true 246 | def update(msg: Msg, model: Model): Model = model 247 | def io(model: Model): IO[Msg] = IO.unit 248 | } 249 | ``` 250 | 251 | Similar to scalaz, PureApp offers an abstract class `SafeApp[F[_]]` that provides an implementation of the `main` method by running a specified `Effect[F]`. We can use this to embed the composition of the two programs: 252 | 253 | ```tut:book 254 | object Main extends SafeApp[IO] { 255 | 256 | val program = 257 | Hello1.program.map(List(_)).build() |+| 258 | Hello2.program.map(List(_)).build() 259 | 260 | override def run: IO[Unit] = 261 | program.flatMap(v => putStrLn(v.toString)) 262 | } 263 | 264 | Main.main(Array()) 265 | ``` 266 | 267 | 268 | ## internals 269 | 270 | Internally PureApp uses an instance of `StateT[F, (Model, Cmd, Resource), Msg]`. The program loop is implemented with `iterateUntil` which is stack safe. And the state is run with the initial `Model` and `Cmd`. 271 | 272 | Also we do not have to run our program. This is handled internally. The given effect is evaluated in the context of `F[_]` to an `IO[Unit]`. Which is then run with `unsafeRunSync` similar to scalaz's SafeApp. 273 | 274 | ## contributions 275 | 276 | I'm happy for any kind of contributions whatsoever, be it comments, issues, or pull requests. 277 | -------------------------------------------------------------------------------- /src/test/scala/com/github/battermann/pureapp/PureAppTests.scala: -------------------------------------------------------------------------------- 1 | package com.github.battermann.pureapp 2 | 3 | import cats.data.StateT 4 | import cats.effect.IO 5 | import cats.implicits._ 6 | import org.scalatest.{FunSuite, Matchers} 7 | 8 | class PureAppTests extends FunSuite with Matchers { 9 | test("create a simple program and replace the interpreter") { 10 | val program = 11 | SimpleHello.program 12 | .withIoSimple(_ => StateT.pure[IO, String, Unit](())) 13 | .build() 14 | 15 | val expected = "Hello SimplePureProgram!" 16 | val actual = program.runA("").unsafeRunSync() 17 | actual shouldEqual expected 18 | } 19 | 20 | test("create a standard program and replace the interpreter") { 21 | val program = 22 | StandardHello.program 23 | .withIoStandard((_, _) => StateT.pure[IO, String, Unit](())) 24 | .build() 25 | 26 | val expected = "Hello StandardPureProgram!" 27 | val actual = program.runA("").unsafeRunSync() 28 | actual shouldEqual expected 29 | } 30 | 31 | object SimpleHello extends SimplePureProgram[IO] { 32 | type Model = String 33 | type Msg = Unit 34 | def init: Model = "Hello SimplePureProgram!" 35 | def quit(msg: Msg): Boolean = true 36 | def update(msg: Msg, model: Model): Model = model 37 | def io(model: Model): IO[Msg] = IO.unit 38 | } 39 | 40 | object StandardHello extends StandardPureProgram[IO] { 41 | type Model = String 42 | type Msg = Unit 43 | type Cmd = Unit 44 | def init: (Model, Cmd) = ("Hello StandardPureProgram!", ()) 45 | def quit(msg: Msg): Boolean = true 46 | def update(msg: Msg, model: Model): (Model, Cmd) = (model, ()) 47 | def io(model: Model, cmd: Cmd): IO[Msg] = IO.unit 48 | } 49 | } 50 | --------------------------------------------------------------------------------