From 456d47b174c5d5a826ec59a68a78936e8fdd0488 Mon Sep 17 00:00:00 2001 From: Maksym Ochenashko Date: Fri, 7 Nov 2025 18:21:18 +0200 Subject: [PATCH] Add Handle#allow#attempt --- .../scala-3/cats/mtl/HandleCrossCompat.scala | 11 +++++ core/src/main/scala/cats/mtl/Handle.scala | 43 +++++++++++++------ .../scala-3/cats/mtl/tests/Handle3Tests.scala | 30 +++++++++++++ .../scala/cats/mtl/tests/HandleTests.scala | 30 +++++++++++++ 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/core/src/main/scala-3/cats/mtl/HandleCrossCompat.scala b/core/src/main/scala-3/cats/mtl/HandleCrossCompat.scala index c3fefbc4..9f8557a2 100644 --- a/core/src/main/scala-3/cats/mtl/HandleCrossCompat.scala +++ b/core/src/main/scala-3/cats/mtl/HandleCrossCompat.scala @@ -17,6 +17,10 @@ package cats package mtl +import cats.syntax.applicative.* +import cats.syntax.either.* +import cats.syntax.functor.* + private[mtl] trait HandleCrossCompat: inline def allow[E]: AdHocSyntaxWired[E] = @@ -35,6 +39,13 @@ private final class InnerWired[F[_], E, A](body: Handle[F, E] => F[A]) extends A inner(body(InnerHandle(Marker)), f, Marker) + inline def attempt(using ApplicativeThrow[F]): F[Either[E, A]] = + val Marker = new AnyRef + inner[F, E, Either[E, A]]( + body(InnerHandle(Marker)).map(_.asRight), + _.asLeft.pure[F], + Marker) + private inline def inner[F[_], E, A](inline fb: F[A], inline f: E => F[A], Marker: AnyRef)( using ApplicativeThrow[F]): F[A] = ApplicativeThrow[F].handleErrorWith(fb): diff --git a/core/src/main/scala/cats/mtl/Handle.scala b/core/src/main/scala/cats/mtl/Handle.scala index 16f6f7b4..1aa41e16 100644 --- a/core/src/main/scala/cats/mtl/Handle.scala +++ b/core/src/main/scala/cats/mtl/Handle.scala @@ -18,6 +18,9 @@ package cats package mtl import cats.data._ +import cats.syntax.applicative._ +import cats.syntax.either._ +import cats.syntax.functor._ import scala.annotation.implicitNotFound import scala.util.control.NoStackTrace @@ -235,21 +238,37 @@ object Handle extends HandleInstances with HandleCrossCompat { def rescue(h: E => F[A])(implicit F: ApplicativeThrow[F]): F[A] = { val Marker = new AnyRef - def inner[B](fb: F[B])(f: E => F[B]): F[B] = - ApplicativeThrow[F].handleErrorWith(fb) { - case Submarine(e, Marker) => f(e.asInstanceOf[E]) - case t => ApplicativeThrow[F].raiseError(t) - } + val fa = body(new InnerHandle(Marker)) + + inner(fa, h, Marker) + } + + def attempt(implicit F: ApplicativeThrow[F]): F[Either[E, A]] = { + val Marker = new AnyRef - val fa = body(new Handle[F, E] { - def applicative = Applicative[F] - def raise[E2 <: E, B](e: E2): F[B] = - ApplicativeThrow[F].raiseError(Submarine(e, Marker)) - def handleWith[B](fb: F[B])(f: E => F[B]): F[B] = inner(fb)(f) - }) + val fa = body(new InnerHandle(Marker)) - inner(fa)(h) + inner(fa.map(_.asRight), _.asLeft.pure[F], Marker) } + + private def inner[B](fb: F[B], f: E => F[B], Marker: AnyRef)( + implicit F: ApplicativeThrow[F]): F[B] = + ApplicativeThrow[F].handleErrorWith(fb) { + case Submarine(e, Marker) => f(e.asInstanceOf[E]) + case t => ApplicativeThrow[F].raiseError(t) + } + + } + + private final class InnerHandle[F[_]: ApplicativeThrow, E](Marker: AnyRef) + extends Handle[F, E] { + def applicative = Applicative[F] + def raise[E2 <: E, B](e: E2): F[B] = ApplicativeThrow[F].raiseError(Submarine(e, Marker)) + def handleWith[B](fb: F[B])(f: E => F[B]): F[B] = + ApplicativeThrow[F].handleErrorWith(fb) { + case Submarine(e, Marker) => f(e.asInstanceOf[E]) + case t => ApplicativeThrow[F].raiseError(t) + } } private[mtl] final case class Submarine[E](e: E, marker: AnyRef) diff --git a/tests/shared/src/test/scala-3/cats/mtl/tests/Handle3Tests.scala b/tests/shared/src/test/scala-3/cats/mtl/tests/Handle3Tests.scala index f3f20d99..c68c1377 100644 --- a/tests/shared/src/test/scala-3/cats/mtl/tests/Handle3Tests.scala +++ b/tests/shared/src/test/scala-3/cats/mtl/tests/Handle3Tests.scala @@ -72,3 +72,33 @@ class Handle3Tests extends munit.FunSuite: case Error1.Third => "third1".pure[F] case Error2.Fourth => "fourth1".pure[F] assert.equals(test.value.value.toOption, Some("third1")) + + test("attempt - return Either[E, A]"): + enum Error: + case First, Second, Third + + val success: F[Either[Error, String]] = + allow[Error]: + EitherT.rightT[Eval, Throwable]("all good") + .attempt + + val failure = + allow[Error]: + Error.Second.raise[F, String].as("nope") + .attempt + + assert(success.value.value == Right(Right("all good"))) + assert(failure.value.value == Right(Left(Error.Second))) + + test("attempt - propagate unhandled exceptions"): + enum Error: + case First, Second, Third + + val exception = new RuntimeException("oops") + + val test = + allow[Error]: + EitherT.leftT[Eval, Unit](exception) + .attempt + + assert(test.value.value == Left(exception)) diff --git a/tests/shared/src/test/scala/cats/mtl/tests/HandleTests.scala b/tests/shared/src/test/scala/cats/mtl/tests/HandleTests.scala index 1454914e..90d33625 100644 --- a/tests/shared/src/test/scala/cats/mtl/tests/HandleTests.scala +++ b/tests/shared/src/test/scala/cats/mtl/tests/HandleTests.scala @@ -92,6 +92,36 @@ class HandleTests extends BaseSuite { assert(test.value.value.toOption == Some("third1")) } + test("attempt - return Either[E, A]") { + sealed trait Error extends Product with Serializable + + object Error { + case object First extends Error + case object Second extends Error + case object Third extends Error + } + + val success = + Handle.allowF[F, Error](_ => EitherT.pure("all good")).attempt + + val failure = + Handle.allowF[F, Error](implicit h => Error.Second.raise.as("nope")).attempt + + assert(success.value.value == Right(Right("all good"))) + assert(failure.value.value == Right(Left(Error.Second))) + } + + test("attempt - propagate unhandled exceptions") { + sealed trait Error extends Product with Serializable + + val exception = new RuntimeException("oops") + + val test = + Handle.allowF[F, Error](_ => EitherT.leftT[Eval, Unit](exception)).attempt + + assert(test.value.value == Left(exception)) + } + { final case class Error(value: Int)