Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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),

Expand Down
71 changes: 69 additions & 2 deletions src/main/scala/com/github/atais/util/Read.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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

Expand Down Expand Up @@ -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)
}
}

41 changes: 41 additions & 0 deletions src/test/scala/com/github/atais/read/ReadLawTest.scala
Original file line number Diff line number Diff line change
@@ -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])
}
41 changes: 41 additions & 0 deletions src/test/scala/com/github/atais/read/ReadersTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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
Expand Down