diff --git a/build.sbt b/build.sbt index 6aa4efce..9a34fff5 100644 --- a/build.sbt +++ b/build.sbt @@ -5,6 +5,9 @@ val `scala 2.12` = "2.12.10" val `scala 2.13` = "2.13.1" val V = new { + val cats = "2.1.0" + val `cats-discipline` = "1.0.0" + val `discipline-scalatest` = "1.0.0-RC1" val circe = "0.12.3" val fs2 = "2.1.0" val `kind-projector` = "0.11.0" @@ -20,6 +23,8 @@ val V = new { val shapeless = "2.3.3" } +val `cats-core` = Def.setting("org.typelevel" %% "cats-core" % V.cats) +val `cats-laws` = Def.setting("org.typelevel" %% "cats-laws" % V.cats) val `circe-core` = Def.setting("io.circe" %%% "circe-core" % V.circe) val `circe-parser` = Def.setting("io.circe" %%% "circe-parser" % V.circe) val `fs2-core` = Def.setting("co.fs2" %%% "fs2-core" % V.fs2) @@ -32,10 +37,13 @@ val `scodec-core` = Def.setting("org.scodec" %%% "scodec-core" % V.`sco val `scodec-stream` = Def.setting("org.scodec" %%% "scodec-stream" % V.`scodec-stream`) val shapeless = Def.setting("com.chuusai" %%% "shapeless" % V.shapeless) -val `circe-generic` = Def.setting("io.circe" %%% "circe-generic" % V.circe % Test) -val `refined-scalacheck` = Def.setting("eu.timepit" %%% "refined-scalacheck" % V.refined % Test) -val scalacheck = Def.setting("org.scalacheck" %%% "scalacheck" % V.scalacheck % Test) -val scalatest = Def.setting("org.scalatest" %%% "scalatest" % V.scalatest % Test) +val `cats-discipline` = Def.setting("org.typelevel" %% "discipline-core" % V.`cats-discipline` % Test) +val `discipline-scalatest` = Def.setting("org.typelevel" %% "discipline-scalatest" % V.`discipline-scalatest` % Test) +val `circe-generic` = Def.setting("io.circe" %%% "circe-generic" % V.circe % Test) +val `refined-scalacheck` = Def.setting("eu.timepit" %%% "refined-scalacheck" % V.refined % Test) +val scalacheck = Def.setting("org.scalacheck" %%% "scalacheck" % V.scalacheck % Test) +val scalatest = Def.setting("org.scalatest" %%% "scalatest" % V.scalatest % Test) + val `scala-parallel-collections` = Def.setting { CrossVersion.partialVersion(scalaVersion.value) match { case Some((2, major)) if major >= 13 => @@ -62,6 +70,15 @@ val coreDeps = Def.Initialize.join { ) } +val coreLawsDeps = Def.Initialize.join { + Seq( + `cats-core`, + `cats-laws`, + `cats-discipline`, + `discipline-scalatest` + ) +} + val fs2Deps = Def.Initialize .join { Seq( @@ -268,6 +285,16 @@ lazy val core = crossProject(JSPlatform, JVMPlatform) .jsConfigure(_.enablePlugins(BoilerplatePlugin)) .jvmConfigure(_.enablePlugins(BoilerplatePlugin)) +lazy val `core-laws` = project + .in(file("core-laws")) + .dependsOn(core.jvm) + .settings(commonSettings ++ testSettings) + .settings( + name := "laserdisc-core-laws", + libraryDependencies ++= coreDeps.value ++ coreLawsDeps.value, + publishArtifact := false + ) + lazy val fs2 = project .in(file("fs2")) .dependsOn(core.jvm) @@ -311,7 +338,7 @@ lazy val circe = crossProject(JSPlatform, JVMPlatform) lazy val laserdisc = project .in(file(".")) - .aggregate(core.jvm, core.js, fs2, cli, circe.jvm, circe.js) + .aggregate(core.jvm, core.js, `core-laws`, fs2, cli, circe.jvm, circe.js) .settings(publishSettings) .settings( publishArtifact := false, diff --git a/core-laws/src/main/scala/laserdisc/protocol/ReadInstances.scala b/core-laws/src/main/scala/laserdisc/protocol/ReadInstances.scala new file mode 100644 index 00000000..2c81fb67 --- /dev/null +++ b/core-laws/src/main/scala/laserdisc/protocol/ReadInstances.scala @@ -0,0 +1,39 @@ +package laserdisc +package protocol + +import cats.{Contravariant, Functor, Invariant, Monad} + +import scala.util.{Left, Right} + +private[protocol] object ReadInstances { + implicit def readFunctor[X]: Functor[X ==> *] = + new Functor[X ==> *] { + override def map[A, B](fa: X ==> A)(f: A => B): X ==> B = fa.map(f) + } + + implicit def readContravariant[X]: Contravariant[* ==> X] = + new Contravariant[* ==> X] { + override def contramap[A, B](fa: A ==> X)(f: B => A): B ==> X = fa.contramap(f) + } + + implicit def readInvariantFirst[X]: Invariant[* ==> X] = + new Invariant[* ==> X] { + override def imap[A, B](fa: A ==> X)(f: A => B)(g: B => A): B ==> X = fa.contramap(g) + } + + implicit def readInvariantSecond[X]: Invariant[X ==> *] = + new Invariant[X ==> *] { + override def imap[A, B](fa: X ==> A)(f: A => B)(g: B => A): X ==> B = fa.map(f) + } + + implicit def readMonad[X]: Monad[X ==> *] = + new Monad[X ==> *] { + override def pure[A](x: A): X ==> A = Read.const(x) + override def flatMap[A, B](fa: X ==> A)(f: A => X ==> B): X ==> B = fa.flatMap(f) + override def tailRecM[A, B](a: A)(f: A => X ==> Either[A, B]): X ==> B = + flatMap(f(a)) { + case Left(a) => tailRecM(a)(f) + case Right(b) => pure(b) + } + } +} diff --git a/core-laws/src/test/scala/laserdisc/protocol/ReadLawsCheck.scala b/core-laws/src/test/scala/laserdisc/protocol/ReadLawsCheck.scala new file mode 100644 index 00000000..b56bb854 --- /dev/null +++ b/core-laws/src/test/scala/laserdisc/protocol/ReadLawsCheck.scala @@ -0,0 +1,92 @@ +package laserdisc +package protocol + +import cats.instances.int._ +import cats.instances.long._ +import cats.instances.string._ +import cats.laws.discipline.{ContravariantTests, InvariantTests, MonadTests, SerializableTests} +import cats.{Contravariant, Eq, Invariant, Monad} +import org.scalacheck.Gen.chooseNum +import org.scalacheck.{Arbitrary, Cogen, Gen} +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.Configuration +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks +import org.typelevel.discipline.scalatest.Discipline + +final class ReadLawsCheck + extends AnyFunSuiteLike + with Matchers + with ScalaCheckDrivenPropertyChecks + with Discipline + with Configuration + with Implicits { + + import ReadInstances._ + + checkAll("Read[Num, *]", MonadTests[Read[Num, *]].stackUnsafeMonad[Long, String, Long]) + checkAll("Monad[Read[Num, *]]", SerializableTests.serializable(Monad[Read[Num, *]])) + + checkAll("Read[*, Long]", ContravariantTests[Read[*, Long]].contravariant[Str, Num, Str]) + checkAll("Contravariant[Read[*, Long]]", SerializableTests.serializable(Contravariant[Read[*, Long]])) + + checkAll("Read[*, Int]", InvariantTests[Read[*, Long]].invariant[Str, Num, Str]) + checkAll("Invariant[Read[*, Long]]", SerializableTests.serializable(Invariant[Read[*, Long]])) + + checkAll("Read[Num, *]", InvariantTests[Read[Num, *]].invariant[Long, String, Long]) + checkAll("Invariant[Read[Num, *]]", SerializableTests.serializable(Invariant[Read[Num, *]])) +} + +private[protocol] sealed trait Implicits { + implicit val genNum: Gen[Num] = chooseNum(0L, Long.MaxValue) map Num.apply + implicit val genStr: Gen[Str] = Gen.alphaNumStr map Str.apply + + implicit val genListOfNum: Gen[List[Num]] = Gen.listOfN(500, genNum) map (_.distinct) + implicit val genListOfStr: Gen[List[Str]] = Gen.listOfN(500, genStr) map (_.distinct) + + implicit def arbNum(implicit ev: Gen[Num]): Arbitrary[Num] = Arbitrary(ev) + implicit def arbStr(implicit ev: Gen[Str]): Arbitrary[Str] = Arbitrary(ev) + + implicit val cogenNum: Cogen[Num] = Cogen(_.value) + implicit val cogenStr: Cogen[Str] = Cogen(_.value.length.toLong) + + implicit def arbReadNum(implicit ev: Arbitrary[Long]): Arbitrary[Read[Num, Long]] = Arbitrary(ev.arbitrary map Read.const) + implicit def arbReadStr(implicit ev: Arbitrary[Long]): Arbitrary[Read[Str, Long]] = Arbitrary(ev.arbitrary map Read.const) + + implicit def arbReadNumString(implicit ev: Arbitrary[Num]): Arbitrary[Read[Num, String]] = + Arbitrary(ev.arbitrary map (n => Read.const(n.value.toString))) + + implicit def arbReadNumStringFun(implicit ev: Arbitrary[Num]): Arbitrary[Read[Num, Long => String]] = + Arbitrary(ev.arbitrary map (l => Read.const(_ => l.value.toString))) + + implicit def arbReadNumNumFun(implicit ev: Arbitrary[String]): Arbitrary[Read[Num, String => Long]] = + Arbitrary(ev.arbitrary map (s => Read.const(_ => s.length.toLong))) + + implicit def eqReadTup[A, B, C, D](implicit ga: Gen[List[A]], eb: Eq[B], ec: Eq[C], ed: Eq[D]): Eq[Read[A, (B, C, D)]] = + new Eq[Read[A, (B, C, D)]] { + override def eqv(x: ==>[A, (B, C, D)], y: ==>[A, (B, C, D)]): Boolean = { + val as = ga.sample.get + as.forall { a => + (x.read(a), y.read(a)) match { + case (Right((b1, c1, d1)), Right((b2, c2, d2))) => eb.eqv(b1, b2) && ec.eqv(c1, c2) && ed.eqv(d1, d2) + case (Left(e1), Left(e2)) => e1.message == e2.message + case _ => false + } + } + } + } + + implicit def eqRead[A, B](implicit ga: Gen[List[A]], eb: Eq[B]): Eq[Read[A, B]] = + new Eq[Read[A, B]] { + override def eqv(x: A ==> B, y: A ==> B): Boolean = { + val as = ga.sample.get + as.forall { a => + (x.read(a), y.read(a)) match { + case (Right(b1), Right(b2)) => eb.eqv(b1, b2) + case (Left(e1), Left(e2)) => e1.message == e2.message + case _ => false + } + } + } + } +} diff --git a/core/src/main/scala/laserdisc/protocol/Read.scala b/core/src/main/scala/laserdisc/protocol/Read.scala index 20576ff7..a64617eb 100644 --- a/core/src/main/scala/laserdisc/protocol/Read.scala +++ b/core/src/main/scala/laserdisc/protocol/Read.scala @@ -24,10 +24,10 @@ Note 2: make sure to inspect the combinators as you may be able to leverage some final def map[C](f: B => C): Read[A, C] = Read.instance(read(_).map(f)) - final def flatMap[C](f: B => Read[A, C]): Read[A, C] = Read.instance(a => read(a).flatMap(f(_).read(a))) - final def contramap[C](f: C => A): Read[C, B] = Read.instance(read _ compose f) + final def flatMap[C](f: B => Read[A, C]): Read[A, C] = Read.instance(a => read(a).flatMap(f(_).read(a))) + private[this] final val _extract: Any => Read.Extract[Any] = new Read.Extract[Any](_) final def unapply(a: A): Read.Extract[RESPDecErr | B] = _extract(read(a)).asInstanceOf[Read.Extract[RESPDecErr | B]] @@ -45,6 +45,8 @@ object Read extends ReadInstances0 { @inline final def infallible[A, B](f: A => B): Read[A, B] = (a: A) => Right(f(a)) + @inline final def const[A, B](b: B): Read[A, B] = Read.infallible(_ => b) + @inline final def instancePF[A, B](expectation: String)(pf: PartialFunction[A, B]): Read[A, B] = a => if (pf.isDefinedAt(a)) Right(pf(a)) else Left(RESPDecErr(s"Read Error: expected $expectation but was $a"))