diff --git a/build.sbt b/build.sbt index 14b7a46..a4c7d7e 100644 --- a/build.sbt +++ b/build.sbt @@ -8,6 +8,8 @@ lazy val cats = "org.typelevel" %% "cats-core" % "1.1.0" // test dependencies lazy val scalatest = "org.scalatest" %% "scalatest" % "3.0.1" % Test lazy val scalacheck = "org.scalacheck" %% "scalacheck" % "1.13.4" % Test +lazy val discipline = "org.typelevel" %% "discipline" % "0.9.0" % Test +lazy val catsLaws = "org.typelevel" %% "cats-laws" % "1.1.0" % Test lazy val main = (project in file(".")) .settings( @@ -22,7 +24,9 @@ lazy val main = (project in file(".")) shapeless, cats, scalatest, - scalacheck + scalacheck, + discipline, + catsLaws ), libraryDependencies ++= (if (scalaBinaryVersion.value startsWith "2.10") Seq(shapelessMacros) else Nil), diff --git a/src/main/scala/com/github/atais/util/Read.scala b/src/main/scala/com/github/atais/util/Read.scala index d74f991..afa2b0a 100644 --- a/src/main/scala/com/github/atais/util/Read.scala +++ b/src/main/scala/com/github/atais/util/Read.scala @@ -2,12 +2,46 @@ package com.github.atais.util import cats.implicits.catsSyntaxEitherObject import cats.implicits.catsSyntaxEither +import cats.MonadError +import scala.annotation.tailrec - -trait Read[A] extends Serializable { +trait Read[A] extends Serializable { self => def read(str: String): Either[Throwable, A] + /** + * lift function A => B to Read[A] => Read[B] + */ + def map[B](f: A => B): Read[B] = new Read[B] { + def read(str: String): Either[Throwable, B] = self.read(str).map(f) + } + + /** + * Monadically bind function f over Read + */ + def flatMap[B](f: A => Read[B]): Read[B] = new Read[B] { + def read(str: String): Either[Throwable, B] = self.read(str) match { + case Right(v) => f(v).read(str) + case Left(err) => Left(err) + } + } + + /** + * Create a new instances that can recover errors (and potentially fail again) + */ + def handleWithError(f: Throwable => Read[A]): Read[A] = new Read[A] { + def read(str: String): Either[Throwable, A] = self.read(str) match { + case Right(v) => Right(v) + case Left(err) => f(err).read(str) + } + } + + /** + * Create a new instance that recovers from all potential errors + */ + def handleWithAllError(f: Throwable => A): Read[A] = + handleWithError(f andThen Read.const) + } object Read { @@ -16,6 +50,18 @@ object Read { override def read(str: String): Either[Throwable, A] = f(str) } + /** + * Creates a constant Read instance. Useful together with flatMap + */ + def const[A](a: A): Read[A] = create(_ => Right(a)) + + /** + * Creates a Read instance that always fails + */ + def failed[A](err: Throwable): Read[A] = new Read[A] { + def read(str: String): Either[Throwable, A] = Left(err) + } + // -------------------- // implicits @@ -54,5 +100,26 @@ object Read { implicit val stringRead: Read[String] = create[String] { s => Right(s) } + + implicit val unitRead: Read[Unit] = create[Unit] { + _ => Right(()) + } + + implicit final val monadReadInstance = new MonadError[Read, Throwable] { + def raiseError[A](e: Throwable): Read[A] = failed(e) + def handleErrorWith[A](fa: Read[A])(f: Throwable => Read[A]): Read[A] = fa.handleWithError(f) + def flatMap[A, B](fa: Read[A])(f: A => Read[B]): Read[B] = fa.flatMap(f) + def tailRecM[A, B](a: A)(f: A => Read[Either[A, B]]): Read[B] = new Read[B] { + @tailrec + def step(str: String, a: A): Either[Throwable, B] = f(a).read(str) match { + case Left(l) => Left(l) + case Right(Left(a1)) => step(str, a1) + case Right(Right(a1)) => Right(a1) + } + + def read(str: String): Either[Throwable, B] = step(str, a) + } + def pure[A](a: A): Read[A] = const(a) + } } diff --git a/src/test/scala/com/github/atais/read/ReadLawTest.scala b/src/test/scala/com/github/atais/read/ReadLawTest.scala new file mode 100644 index 0000000..4bc3424 --- /dev/null +++ b/src/test/scala/com/github/atais/read/ReadLawTest.scala @@ -0,0 +1,41 @@ +package com.github.atais.read + +import cats._ +import cats.implicits._ +import cats.laws.discipline.MonadErrorTests +import com.github.atais.util.Read +import org.scalacheck.Gen +import org.scalacheck.Arbitrary._ +import org.scalacheck.Arbitrary +import org.scalacheck.Arbitrary.{arbitrary => arb} +import org.scalatest.FunSuite +import org.typelevel.discipline.scalatest + +class ReadLawTest extends FunSuite with scalatest.Discipline { + + // Input generator to to the Read[Long].read + // It will be sucessfull ~66% of the time (int and long cases) + val genReadInput = Gen.frequency( + (1, arbLong.arbitrary.map(_.toString)), + (1, arbInt.arbitrary.map(_.toString)), + (1, arbString.arbitrary)) + + val readEqTestSize = 20 + val inputStream = Stream.continually(genReadInput.sample).flatten + + implicit val arbReadInt = Arbitrary(Read.longRead) + implicit val arbReadUnit = Arbitrary(Read.unitRead) + implicit val arbReadFunction = Arbitrary(arb[Long => Long].map(Applicative[Read].pure)) + implicit val eqThrowable = new Eq[Throwable] { + // All exceptions are non-terminating and given exceptions + // aren't values (being mutable, they implement reference + // equality), then we can't really test them reliably, + // especially due to race conditions or outside logic + // that wraps them (e.g. ExecutionException) + def eqv(x: Throwable, y: Throwable): Boolean = (x ne null) == (y ne null) + } + implicit def eqRead[A: Eq]: Eq[Read[A]] = + Eq.instance((a, b) => inputStream.take(readEqTestSize).forall(x => a.read(x) === b.read(x))) + + checkAll("Read[Long]", MonadErrorTests[Read, Throwable].monadError[Long, Long, Long]) +} diff --git a/src/test/scala/com/github/atais/read/ReadersTest.scala b/src/test/scala/com/github/atais/read/ReadersTest.scala index c45364e..51c99fa 100644 --- a/src/test/scala/com/github/atais/read/ReadersTest.scala +++ b/src/test/scala/com/github/atais/read/ReadersTest.scala @@ -3,6 +3,7 @@ package com.github.atais.read import com.github.atais.util.Read._ import org.scalatest.prop.PropertyChecks import org.scalatest.{FlatSpec, Matchers} +import cats.implicits._ class ReadersTest extends FlatSpec with Matchers with PropertyChecks { @@ -40,6 +41,46 @@ class ReadersTest extends FlatSpec with Matchers with PropertyChecks { forAll { m: String => test(stringRead.read(m.toString), m) } } + "Unit" should "be parsed properly" in { + forAll { m: String => test(unitRead.read(m), ()) } + } + + "Const" should "create an instance with a constant value" in { + forAll { (m1: String, m2: String) => test(const(m1).read(m2), m1) } + } + + "Read" should "be transformable" in { + forAll { m: Int => test(stringRead.read(m.toString).map(_.toInt), m) } + } + + "Read" should "should behave as an applicative" in { + forAll { m: Int => + case class Product(i: Int, l: Long, s: String) + + val productRead = (intRead, longRead, stringRead).mapN(Product.apply) + + test(productRead.read(m.toString), Product(m, m.toLong, m.toString)) + } + } + + "Read" should "behave as a monad" in { + forAll { m: Int => + val xor = (key: Int) => intRead.map(_ ^ key) + val reader = intRead.flatMap(xor).flatMap(xor) + + test(reader.read(m.toString), m) + } + } + + "Read" should "be able to recover from errors" in { + forAll { (a: Int, b: Int) => + val error = new IllegalStateException("Error") + val reader = create(_ => Left(error)).handleWithAllError(_ => b) + + test(reader.read(a.toString), b) + } + } + private def test[A](returned: Either[Throwable, A], checkValue: A) = { returned match { case Right(v) => v shouldEqual checkValue