diff --git a/core/src/main/scala/cats/NonEmptyAlternative.scala b/core/src/main/scala/cats/NonEmptyAlternative.scala index a5d846c30f..91503bfd64 100644 --- a/core/src/main/scala/cats/NonEmptyAlternative.scala +++ b/core/src/main/scala/cats/NonEmptyAlternative.scala @@ -45,6 +45,36 @@ trait NonEmptyAlternative[F[_]] extends Applicative[F] with SemigroupK[F] { self */ def appendK[A](fa: F[A], a: A): F[A] = combineK(fa, pure(a)) + /** + * Lift `fa` from `F[A]` into `F[Option[A]]` by surfacing every value `fa` + * produces as `Some(a)` and combining (via `combineK`) with `pure(None)`, + * so the result always succeeds at least once. The additional `None` + * witnesses the possibility that `fa` produced no values. + * + * This is the standard `optional` combinator from parser-combinator + * libraries and matches Haskell's `Control.Applicative.optional`: + * `Just <$> fa <|> pure Nothing`. For non-deterministic instances such + * as `List`, `attemptOption` always appends an extra `None`, which is + * consistent with the laws even if it can look surprising at first. + * + * Example: + * {{{ + * scala> NonEmptyAlternative[Option].attemptOption(Option(5)) + * res0: Option[Option[Int]] = Some(Some(5)) + * + * scala> NonEmptyAlternative[Option].attemptOption(Option.empty[Int]) + * res1: Option[Option[Int]] = Some(None) + * + * scala> NonEmptyAlternative[List].attemptOption(List(1, 2, 3)) + * res2: List[Option[Int]] = List(Some(1), Some(2), Some(3), None) + * + * scala> NonEmptyAlternative[List].attemptOption(List.empty[Int]) + * res3: List[Option[Int]] = List(None) + * }}} + */ + def attemptOption[A](fa: F[A]): F[Option[A]] = + combineK(map(fa)((a: A) => Some(a): Option[A]), pure(Option.empty[A])) + override def compose[G[_]: Applicative]: NonEmptyAlternative[λ[α => F[G[α]]]] = new ComposedNonEmptyAlternative[F, G] { val F = self @@ -75,6 +105,7 @@ object NonEmptyAlternative { val typeClassInstance: TypeClassType def prependK(a: A): F[A] = typeClassInstance.prependK[A](a, self) def appendK(a: A): F[A] = typeClassInstance.appendK[A](self, a) + def attemptOption: F[Option[A]] = typeClassInstance.attemptOption[A](self) } trait AllOps[F[_], A] extends Ops[F, A] with Applicative.AllOps[F, A] with SemigroupK.AllOps[F, A] { type TypeClassType <: NonEmptyAlternative[F] diff --git a/laws/src/main/scala/cats/laws/NonEmptyAlternativeLaws.scala b/laws/src/main/scala/cats/laws/NonEmptyAlternativeLaws.scala index 8dcd785fbe..aece8866c0 100644 --- a/laws/src/main/scala/cats/laws/NonEmptyAlternativeLaws.scala +++ b/laws/src/main/scala/cats/laws/NonEmptyAlternativeLaws.scala @@ -40,6 +40,9 @@ trait NonEmptyAlternativeLaws[F[_]] extends ApplicativeLaws[F] with SemigroupKLa def nonEmptyAlternativeAppendKConsistentWithPureAndCombineK[A](fa: F[A], a: A): IsEq[F[A]] = fa.appendK(a) <-> (fa <+> a.pure[F]) + def nonEmptyAlternativeAttemptOptionConsistentWithCombineKAndPure[A](fa: F[A]): IsEq[F[Option[A]]] = + F.attemptOption(fa) <-> (fa.map(Some(_): Option[A]) <+> Option.empty[A].pure[F]) + @deprecated("typo in the name, use nonEmptyAlternativePrependKConsistentWithPureAndCombineK instead", "2.14.0") private[laws] def nonEmptyAlternativePrependKConsitentWithPureAndCombineK[A](fa: F[A], a: A): IsEq[F[A]] = nonEmptyAlternativePrependKConsistentWithPureAndCombineK(fa, a) diff --git a/laws/src/main/scala/cats/laws/discipline/AlternativeTests.scala b/laws/src/main/scala/cats/laws/discipline/AlternativeTests.scala index 38feae4565..3af7a63621 100644 --- a/laws/src/main/scala/cats/laws/discipline/AlternativeTests.scala +++ b/laws/src/main/scala/cats/laws/discipline/AlternativeTests.scala @@ -42,6 +42,7 @@ trait AlternativeTests[F[_]] extends NonEmptyAlternativeTests[F] with MonoidKTes EqFA: Eq[F[A]], EqFB: Eq[F[B]], EqFC: Eq[F[C]], + EqFOA: Eq[F[Option[A]]], EqFABC: Eq[F[(A, B, C)]], iso: Isomorphisms[F] ): RuleSet = diff --git a/laws/src/main/scala/cats/laws/discipline/NonEmptyAlternativeTests.scala b/laws/src/main/scala/cats/laws/discipline/NonEmptyAlternativeTests.scala index b6342ff994..504579a347 100644 --- a/laws/src/main/scala/cats/laws/discipline/NonEmptyAlternativeTests.scala +++ b/laws/src/main/scala/cats/laws/discipline/NonEmptyAlternativeTests.scala @@ -42,6 +42,7 @@ trait NonEmptyAlternativeTests[F[_]] extends ApplicativeTests[F] with SemigroupK EqFA: Eq[F[A]], EqFB: Eq[F[B]], EqFC: Eq[F[C]], + EqFOA: Eq[F[Option[A]]], EqFABC: Eq[F[(A, B, C)]], iso: Isomorphisms[F] ): RuleSet = @@ -55,7 +56,9 @@ trait NonEmptyAlternativeTests[F[_]] extends ApplicativeTests[F] with SemigroupK "prependK consistent with pure and combineK" -> forAll(laws.nonEmptyAlternativePrependKConsistentWithPureAndCombineK[A] _), "appendK consistent with pure and combineK" -> - forAll(laws.nonEmptyAlternativeAppendKConsistentWithPureAndCombineK[A] _) + forAll(laws.nonEmptyAlternativeAppendKConsistentWithPureAndCombineK[A] _), + "attemptOption consistent with combineK and pure" -> + forAll(laws.nonEmptyAlternativeAttemptOptionConsistentWithCombineKAndPure[A] _) ) } } diff --git a/mima.sbt b/mima.sbt index af38fe3d6e..6558c5b450 100644 --- a/mima.sbt +++ b/mima.sbt @@ -167,5 +167,13 @@ ThisBuild / mimaBinaryIssueFilters ++= Seq.concat( ProblemFilters.exclude[MissingTypesProblem]("cats.free.FreeFoldable"), ProblemFilters.exclude[IncompatibleTemplateDefProblem]("cats.RepresentableBimonad"), ProblemFilters.exclude[IncompatibleTemplateDefProblem]("cats.RepresentableMonad") + ), + Seq( // PR#4862 (issue #2936): added EqFOA: Eq[F[Option[A]]] implicit param to + // (NonEmpty)AlternativeTests rule sets so the new attemptOption law can run. + // Test-helper signatures only; source-compatible since callers pass implicits. + ProblemFilters.exclude[DirectMissingMethodProblem]("cats.laws.discipline.AlternativeTests.alternative"), + ProblemFilters.exclude[DirectMissingMethodProblem]( + "cats.laws.discipline.NonEmptyAlternativeTests.nonEmptyAlternative" + ) ) ) diff --git a/tests/shared/src/test/scala/cats/tests/NonEmptyAlternativeSuite.scala b/tests/shared/src/test/scala/cats/tests/NonEmptyAlternativeSuite.scala index d86c0b23b7..dd4b3124aa 100644 --- a/tests/shared/src/test/scala/cats/tests/NonEmptyAlternativeSuite.scala +++ b/tests/shared/src/test/scala/cats/tests/NonEmptyAlternativeSuite.scala @@ -23,6 +23,8 @@ package cats.tests import cats.NonEmptyAlternative import cats.laws.discipline.NonEmptyAlternativeTests +import cats.syntax.eq.* +import org.scalacheck.Prop.* class NonEmptyAlternativeSuite extends CatsSuite { implicit val listWrapperNeAlternative: NonEmptyAlternative[ListWrapper] = ListWrapper.nonEmptyAlternative @@ -42,4 +44,18 @@ class NonEmptyAlternativeSuite extends CatsSuite { "compose ListWrapper[ListWrapper[Int]]", NonEmptyAlternativeTests.composed[ListWrapper, ListWrapper].nonEmptyAlternative[Int, Int, Int] ) + + property("attemptOption on List concatenates map(Some) with pure(None)") { + forAll { (xs: List[Int]) => + val expected: List[Option[Int]] = xs.map(Some(_)) :+ None + assert(NonEmptyAlternative[List].attemptOption(xs) === expected) + } + } + + property("attemptOption on Option preserves Some, surfaces empty as Some(None)") { + forAll { (o: Option[Int]) => + val expected: Option[Option[Int]] = o.fold[Option[Option[Int]]](Some(None))(a => Some(Some(a))) + assert(NonEmptyAlternative[Option].attemptOption(o) === expected) + } + } }