diff --git a/core/src/main/scala/cats/ApplicativeError.scala b/core/src/main/scala/cats/ApplicativeError.scala index 7582fca4ed..5163974581 100644 --- a/core/src/main/scala/cats/ApplicativeError.scala +++ b/core/src/main/scala/cats/ApplicativeError.scala @@ -21,9 +21,9 @@ package cats -import cats.ApplicativeError.CatchOnlyPartiallyApplied -import cats.data.{EitherT, Validated} +import cats.ApplicativeError.{CatchOnlyAsUnsafePartiallyApplied, CatchOnlyPartiallyApplied} import cats.data.Validated.{Invalid, Valid} +import cats.data.{EitherT, Validated} import scala.reflect.ClassTag import scala.util.control.NonFatal @@ -264,6 +264,8 @@ trait ApplicativeError[F[_], E] extends Applicative[F] { /** * Often E is Throwable. Here we try to call pure or catch * and raise. + * + * Note: fatal exceptions (as defined by `scala.util.control.NonFatal`) will be propagated */ def catchNonFatal[A](a: => A)(implicit ev: Throwable <:< E): F[A] = try pure(a) @@ -274,6 +276,8 @@ trait ApplicativeError[F[_], E] extends Applicative[F] { /** * Often E is Throwable. Here we try to call pure or catch * and raise + * + * Note: fatal exceptions (as defined by `scala.util.control.NonFatal`) will be propagated */ def catchNonFatalEval[A](a: Eval[A])(implicit ev: Throwable <:< E): F[A] = try pure(a.value) @@ -282,11 +286,41 @@ trait ApplicativeError[F[_], E] extends Applicative[F] { } /** - * Evaluates the specified block, catching exceptions of the specified type. Uncaught exceptions are propagated. + * Evaluates the specified block, catching exceptions of the specified type. + * + * This method is considered unsafe because uncaught exceptions are propagated + * outside of `F`. While the name does not currently reflect this, this may change + * at a future date. */ def catchOnly[T >: Null <: Throwable]: CatchOnlyPartiallyApplied[T, F, E] = new CatchOnlyPartiallyApplied[T, F, E](this) + /** + * Often E can be created from Throwable. Here we try to call pure or + * catch, adapt into E, and raise. + * + * Note: fatal exceptions (as defined by `scala.util.control.NonFatal`) will be propagated + */ + def catchNonFatalAs[A](adapt: Throwable => E)(a: => A): F[A] = + try pure(a) + catch { + case NonFatal(t) => raiseError[A](adapt(t)) + } + + /** + * Evaluates the specified block, catching exceptions of the specified type. + * + * Caught exceptions are mapped to `E` and raised in `F`. + * + * This method is considered unsafe because uncaught exceptions will be propagated outside of `F` + * + * Note: `catchOnlyAsUnsafe` assumes that, if a specific exception type `T` is expected, there + * exists a mapping from `T` to `E`. If this is not the case, consider either manually + * rethrowing inside the mapping function, or using `catchNonFatalAs` + */ + def catchOnlyAs[T >: Null <: Throwable]: CatchOnlyAsUnsafePartiallyApplied[T, F, E] = + new CatchOnlyAsUnsafePartiallyApplied[T, F, E](this) + /** * If the error type is Throwable, we can convert from a scala.util.Try */ @@ -383,6 +417,16 @@ object ApplicativeError { } } + final private[cats] class CatchOnlyAsUnsafePartiallyApplied[T >: Null <: Throwable, F[_], E]( + private val F: ApplicativeError[F, E] + ) extends AnyVal { + def apply[A](adapt: T => E)(f: => A)(implicit CT: ClassTag[T], NT: NotNull[T]): F[A] = + try F.pure(f) + catch { + case CT(t) => F.raiseError(adapt(t)) + } + } + /** * lift from scala.Option[A] to a F[A] * diff --git a/tests/shared/src/test/scala/cats/tests/EitherSuite.scala b/tests/shared/src/test/scala/cats/tests/EitherSuite.scala index eca7b226e7..68aa85667d 100644 --- a/tests/shared/src/test/scala/cats/tests/EitherSuite.scala +++ b/tests/shared/src/test/scala/cats/tests/EitherSuite.scala @@ -21,18 +21,19 @@ package cats.tests -import cats._ -import cats.data.{EitherT, NonEmptyChain, NonEmptyList, NonEmptySet, NonEmptyVector, Validated} -import cats.syntax.bifunctor._ +import cats.* +import cats.data.* import cats.kernel.laws.discipline.{EqTests, MonoidTests, OrderTests, PartialOrderTests, SemigroupTests} -import cats.laws.discipline._ -import cats.laws.discipline.arbitrary._ +import cats.laws.discipline.* import cats.laws.discipline.SemigroupalTests.Isomorphisms -import cats.syntax.either._ +import cats.laws.discipline.arbitrary.* +import cats.syntax.bifunctor.* +import cats.syntax.either.* +import cats.syntax.eq.* +import cats.syntax.option.* +import org.scalacheck.Prop.* import scala.util.Try -import cats.syntax.eq._ -import org.scalacheck.Prop._ class EitherSuite extends CatsSuite { implicit val iso: Isomorphisms[Either[Int, *]] = Isomorphisms.invariant[Either[Int, *]] @@ -136,6 +137,39 @@ class EitherSuite extends CatsSuite { assert(Either.catchNonFatal(throw new Throwable("blargh")).isLeft) } + test("ApplicativeError instance catchNonFatalAs maps exceptions to E") { + val res = + ApplicativeError[Either[String, *], String] + .catchNonFatalAs(_.getMessage)("foo".toInt) + assert(res === Left("For input string: \"foo\"")) + } + + test("ApplicativeError instance catchNonFatalAs maps exceptions to E") { + val res = ApplicativeError[Either[String, *], String].catchNonFatalAs(_.getMessage)("foo".toInt) + assert(res === Left("For input string: \"foo\"")) + } + + test("ApplicativeError instance catchOnlyAs maps exceptions of the specified type to E") { + val res = + ApplicativeError[Either[String, *], String] + .catchOnlyAs[NumberFormatException](_.getMessage)("foo".toInt) + assert(res === Left("For input string: \"foo\"")) + } + + test("ApplicativeError instance catchOnlyAsUnsafe maps exceptions of the specified type to E") { + val res = + ApplicativeError[Either[String, *], String] + .catchOnlyAs[NumberFormatException](_.getMessage)("foo".toInt) + assert(res === Left("For input string: \"foo\"")) + } + + test("ApplicativeError instance catchOnlyAsUnsafe propagates non-matching exceptions") { + val _ = intercept[NumberFormatException] { + ApplicativeError[Either[String, *], String] + .catchOnlyAs[IndexOutOfBoundsException](_.getMessage)("foo".toInt) + } + } + test("fromTry is left for failed Try") { forAll { (t: Try[Int]) => assert(t.isFailure === (Either.fromTry(t).isLeft)) @@ -429,8 +463,8 @@ class EitherSuite extends CatsSuite { final class EitherInstancesSuite extends munit.FunSuite { test("parallel instance in cats.instances.either") { - import cats.instances.either._ - import cats.syntax.parallel._ + import cats.instances.either.* + import cats.syntax.parallel.* def either: Either[String, Int] = Left("Test") (either, either).parTupled diff --git a/tests/shared/src/test/scala/cats/tests/TrySuite.scala b/tests/shared/src/test/scala/cats/tests/TrySuite.scala index 8208cfad11..343b3dbcea 100644 --- a/tests/shared/src/test/scala/cats/tests/TrySuite.scala +++ b/tests/shared/src/test/scala/cats/tests/TrySuite.scala @@ -25,13 +25,16 @@ import cats.{CoflatMap, Eval, Later, Monad, MonadThrow, Semigroupal, Traverse} import cats.kernel.{Eq, Monoid, Semigroup} import cats.kernel.laws.discipline.{MonoidTests, SemigroupTests} import cats.laws.{ApplicativeLaws, CoflatMapLaws, FlatMapLaws, MonadLaws} -import cats.laws.discipline._ -import cats.laws.discipline.arbitrary._ -import cats.syntax.apply._ -import cats.syntax.show._ +import cats.laws.discipline.* +import cats.laws.discipline.arbitrary.* +import cats.syntax.either.* +import cats.syntax.apply.* +import cats.syntax.show.* + import scala.util.{Success, Try} -import cats.syntax.eq._ -import org.scalacheck.Prop._ +import cats.syntax.eq.* +import org.scalacheck.{Arbitrary, Gen} +import org.scalacheck.Prop.* class TrySuite extends CatsSuite { implicit val eqThrow: Eq[Throwable] = Eq.allEqual @@ -103,6 +106,42 @@ class TrySuite extends CatsSuite { } } + test("catchOnlyAs works") { + forAll(Gen.either(Gen.alphaStr, Arbitrary.arbitrary[Int])) { (e: Either[String, Int]) => + val str = e.fold(identity, _.toString) + val res = + MonadThrow[Try] + .catchOnlyAs[NumberFormatException](new IllegalArgumentException("Bad Number", _))(str.toInt) + .toEither + + assertEquals( + res.leftMap { t => + // Shenanigans because Throwable doesn't have a well-behaved equals + (t.getClass.getCanonicalName, t.getMessage, Option(t.getCause).map(_.getClass.getCanonicalName)) + }, + e.leftMap { _ => + ( + "java.lang.IllegalArgumentException", + "Bad Number", + Some("java.lang.NumberFormatException") + ) + }, + clues(res) + ) + } + } + + test("catchOnlyAs catches only a specified type") { + val res = + Either + .catchNonFatal { + MonadThrow[Try] + .catchOnlyAs[UnsupportedOperationException](new IllegalArgumentException("Bad Number", _))("str".toInt) + .toEither + } + assertEquals(res.leftMap(_.getClass.getCanonicalName), Left("java.lang.NumberFormatException"), clues(res)) + } + test("fromTry works") { forAll { (t: Try[Int]) => assert((MonadThrow[Try].fromTry(t)) === t)