diff --git a/README.md b/README.md index e7b718522..4eb925b66 100644 --- a/README.md +++ b/README.md @@ -2978,6 +2978,57 @@ val d: Task[Int] = > Note: Support for ZIO environments (`R` in `ZIO[R, E, A]`) is currently in development. Once implemented, it will be possible to use ZIO effects with environments directly within Kyo computations. +### Cats: Integration with Cats Effect + +The `Cats` effect provides seamless integration between Kyo and the Cats Effect library. This integration is designed to enable gradual adoption of Kyo within a Cats Effect codebase. The integration properly suspends side effects and propagates fiber cancellations/interrupts between both libraries. + +```scala +import kyo.* +import cats.effect.IO as CatsIO + +// Use the 'get' method to extract a 'IO' effect from Cats Effect: +val a: Int < Cats = + Cats.get(CatsIO.pure(42)) + +// Handle the 'Cats' effect to obtain a 'CatsIO' effect: +val b: CatsIO[Int] = + Cats.run(a) +``` + +Kyo and Cats effects can be seamlessly mixed and matched within computations, allowing developers to leverage the power of both libraries. Here are a few examples showcasing this integration: + +```scala +import kyo.* +import cats.effect.IO as CatsIO +import cats.effect.kernel.Outcome.Succeeded + +// Note how Cats includes the IO, Async, and Abort[Throwable] effects: +val a: Int < Cats = + for + v1 <- Cats.get(CatsIO.pure(21)) + v2 <- IO(21) + _ <- Abort.when(v1 > 10)(new Exception) + v3 <- Async.run(-42).map(_.get) + yield v1 + v2 + v3 + +// Using fibers from both libraries: +val b: Int < Cats = + for + f1 <- Cats.get(CatsIO.pure(21).start) + f2 <- Async.run(IO(21)) + v1 <- Cats.get(f1.joinWith(CatsIO(99))) + v2 <- f2.get + yield v1 + v2 + +// Transforming Cats Effect IO within Kyo computations: +val c: Int < Cats = + Cats.get(CatsIO.pure(21)).map(_ * 2) + +// Transforming Kyo effects within Cats Effect IO: +val d: CatsIO[Int] = + Cats.run(IO(21).map(_ * 2)) +``` + ### Resolvers: GraphQL Server via Caliban `Resolvers` integrates with the [Caliban](https://github.com/ghostdogpr/caliban) library to help setup GraphQL servers. diff --git a/build.sbt b/build.sbt index 949b3b902..0abfc56f8 100644 --- a/build.sbt +++ b/build.sbt @@ -9,6 +9,7 @@ val scala212Version = "2.12.20" val scala213Version = "2.13.14" val zioVersion = "2.1.9" +val catsVersion = "3.5.4" val scalaTestVersion = "3.2.19" val compilerOptions = Set( @@ -92,6 +93,7 @@ lazy val kyoJVM = project `kyo-bench`.jvm, `kyo-test`.jvm, `kyo-zio`.jvm, + `kyo-cats`.jvm, `kyo-combinators`.jvm, `kyo-examples`.jvm ) @@ -113,6 +115,7 @@ lazy val kyoJS = project `kyo-sttp`.js, `kyo-test`.js, `kyo-zio`.js, + `kyo-cats`.js, `kyo-combinators`.js ) @@ -344,6 +347,19 @@ lazy val `kyo-zio` = `js-settings` ) +lazy val `kyo-cats` = + crossProject(JSPlatform, JVMPlatform) + .withoutSuffixFor(JVMPlatform) + .crossType(CrossType.Full) + .in(file("kyo-cats")) + .dependsOn(`kyo-core`) + .settings( + `kyo-settings`, + libraryDependencies += "org.typelevel" %%% "cats-effect" % catsVersion + ).jsSettings( + `js-settings` + ) + lazy val `kyo-combinators` = crossProject(JSPlatform, JVMPlatform) .withoutSuffixFor(JVMPlatform) @@ -411,7 +427,7 @@ lazy val `kyo-bench` = } }, libraryDependencies += "dev.zio" %% "izumi-reflect" % "2.3.10", - libraryDependencies += "org.typelevel" %% "cats-effect" % "3.5.4", + libraryDependencies += "org.typelevel" %% "cats-effect" % catsVersion, libraryDependencies += "org.typelevel" %% "log4cats-core" % "2.7.0", libraryDependencies += "org.typelevel" %% "log4cats-slf4j" % "2.7.0", libraryDependencies += "org.typelevel" %% "cats-mtl" % "1.5.0", @@ -460,6 +476,7 @@ lazy val readme = `kyo-tapir`, `kyo-bench`, `kyo-zio`, + `kyo-cats`, `kyo-caliban`, `kyo-combinators` ) diff --git a/kyo-cats/shared/src/main/scala/kyo/Cats.scala b/kyo-cats/shared/src/main/scala/kyo/Cats.scala new file mode 100644 index 000000000..3d90e175e --- /dev/null +++ b/kyo-cats/shared/src/main/scala/kyo/Cats.scala @@ -0,0 +1,46 @@ +package kyo + +import Cats.GetCatsIO +import cats.effect.IO as CatsIO +import kyo.kernel.* +import scala.util.control.NonFatal + +opaque type Cats <: (Async & Abort[Throwable]) = GetCatsIO & Async & Abort[Throwable] + +object Cats: + + /** Lifts a cats.effect.IO into a Kyo effect. + * + * @param io + * The cats.effect.IO to lift + * @return + * A Kyo effect that, when run, will execute the cats.effect.IO + */ + def get[A](io: CatsIO[A])(using Frame): A < Cats = + ArrowEffect.suspendMap(Tag[GetCatsIO], io)(Abort.get(_)) + + /** Runs a Kyo effect that uses Cats and converts it to a cats.effect.IO. + * + * @param v + * The Kyo effect to run + * @return + * A cats.effect.IO that, when run, will execute the Kyo effect + */ + def run[A](v: => A < Cats)(using frame: Frame): CatsIO[A] = + CatsIO.defer { + ArrowEffect.handle(Tag[GetCatsIO], v.map(CatsIO.pure))( + [C] => (input, cont) => input.attempt.flatMap(r => run(cont(r)).flatten) + ).pipe(Async.run) + .map { fiber => + CatsIO.async[CatsIO[A]] { cb => + CatsIO { + fiber.unsafe.onComplete(r => cb(r.toEither)) + Some(CatsIO(fiber.unsafe.interrupt(Result.Panic(Fiber.Interrupted(frame)))).void) + } + } + }.pipe(IO.run).eval.flatten + } + end run + + sealed private[kyo] trait GetCatsIO extends ArrowEffect[CatsIO, Either[Throwable, *]] +end Cats diff --git a/kyo-cats/shared/src/test/scala/kyo/CatsTest.scala b/kyo-cats/shared/src/test/scala/kyo/CatsTest.scala new file mode 100644 index 000000000..1c3eb6b21 --- /dev/null +++ b/kyo-cats/shared/src/test/scala/kyo/CatsTest.scala @@ -0,0 +1,132 @@ +package kyo + +import cats.effect.IO as CatsIO +import cats.effect.kernel.Fiber as CatsFiber +import cats.effect.kernel.Outcome +import cats.effect.unsafe.implicits.global +import kyo.* +import kyo.kernel.Platform +import org.scalatest.compatible.Assertion +import org.scalatest.concurrent.Eventually.* +import scala.concurrent.Future +import scala.concurrent.duration.* + +class CatsTest extends Test: + + def runCatsIO[T](v: CatsIO[T]): Future[T] = + v.unsafeToFuture() + + def runKyo(v: => Assertion < (Abort[Throwable] & Cats)): Future[Assertion] = + Cats.run(v).unsafeToFuture() + + "Abort ordering" - { + "kyo then cats" in runKyo { + object catsFailure extends RuntimeException + object kyoFailure extends RuntimeException + val a = Abort.fail(kyoFailure) + val b = Cats.get(CatsIO.raiseError(catsFailure)) + Abort.run[Throwable](a.map(_ => b)).map { + case Result.Fail(ex) => + assert(ex == kyoFailure) + case _ => + fail() + } + } + "cats then kyo" in runKyo { + object catsFailure extends RuntimeException + object kyoFailure extends RuntimeException + val a = Cats.get(CatsIO.raiseError(catsFailure)) + val b = Abort.fail(kyoFailure) + Abort.run[Throwable](a.map(_ => b)).map { + case Result.Fail(ex) => + assert(ex == catsFailure) + case ex => + fail() + } + } + } + + "A < Cats" in runKyo { + val a = Cats.get(CatsIO.pure(10)) + val b = a.map(_ * 2) + b.map(i => assert(i == 20)) + } + + "nested" in runKyo { + val a = Cats.get(CatsIO.pure(Cats.get(CatsIO.pure("Nested")))).flatten + a.map(s => assert(s == "Nested")) + } + + "fibers" in runKyo { + for + v1 <- Cats.get(CatsIO.pure(1)) + v2 <- Async.run(2).map(_.get) + v3 <- Cats.get(CatsIO.pure(3)) + yield assert(v1 == 1 && v2 == 2 && v3 == 3) + } + + "interrupts" - { + + import java.util.concurrent.atomic.LongAdder + + def kyoLoop(a: LongAdder = new LongAdder): Unit < IO = + IO(a.increment()).map(_ => kyoLoop(a)) + + def catsLoop(a: LongAdder = new LongAdder): CatsIO[Unit] = + CatsIO.delay(a.increment()).flatMap(_ => catsLoop(a)) + + if Platform.isJVM then + + "cats to kyo" in runCatsIO { + for + f <- Cats.run(kyoLoop()).start + _ <- f.cancel + r <- f.join + yield assert(r.isCanceled) + end for + } + "kyo to cats" in runKyo { + for + f <- Async.run(Cats.run(catsLoop())) + _ <- f.interrupt(Result.Panic(new Exception)) + r <- f.getResult + yield assert(r.isPanic) + end for + } + "both" in runCatsIO { + val v = + for + _ <- Cats.get(catsLoop()) + _ <- Async.run(kyoLoop()) + yield () + for + f <- Cats.run(v).start + _ <- f.cancel + r <- f.join + yield assert(r.isCanceled) + end for + } + end if + } + + "Error handling" - { + "Kyo Abort to Cats IO error" in runKyo { + val kyoAbort = Abort.fail(new Exception("Kyo error")) + val converted = Cats.get(CatsIO.fromEither(Abort.run(kyoAbort).eval.toEither)) + Abort.run[Throwable](converted).map { + case Result.Fail(ex) => assert(ex.getMessage() == "Kyo error") + case _ => fail("Expected a String error") + } + } + + "Cats IO error to Kyo Abort" in runKyo { + val catsError = CatsIO.raiseError[Int](new Exception("Cats error")) + val converted = Cats.get(catsError) + Abort.run[Throwable](converted).map { + case Result.Fail(error: Exception) => assert(error.getMessage == "Cats error") + case _ => fail("Expected an Exception") + } + } + } + +end CatsTest diff --git a/kyo-cats/shared/src/test/scala/kyo/Test.scala b/kyo-cats/shared/src/test/scala/kyo/Test.scala new file mode 100644 index 000000000..571d2efa2 --- /dev/null +++ b/kyo-cats/shared/src/test/scala/kyo/Test.scala @@ -0,0 +1,18 @@ +package kyo + +import kyo.internal.BaseKyoTest +import kyo.kernel.Platform +import org.scalatest.NonImplicitAssertions +import org.scalatest.freespec.AsyncFreeSpec +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +abstract class Test extends AsyncFreeSpec with BaseKyoTest[Any] with NonImplicitAssertions: + + def run(v: Future[Assertion] < Any): Future[Assertion] = v.eval + + type Assertion = org.scalatest.compatible.Assertion + def success = succeed + + override given executionContext: ExecutionContext = Platform.executionContext +end Test