Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions core/src/main/scala/cats/Foldable.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ package cats

import scala.collection.mutable
import cats.kernel.CommutativeMonoid
import cats.data.NonEmptyList

import Foldable.{sentinel, Source}

Expand Down Expand Up @@ -960,6 +961,60 @@ trait Foldable[F[_]] extends UnorderedFoldable[F] with FoldableNFunctions[F] { s
import cats.instances.either.*
partitionBifoldM[G, Either, A, B, C](fa)(f)(A, M, Bifoldable[Either])
}

/**
* Split this Foldable into a NonEmptyList of Lists based on a predicate.
* The behaviour is aimed to be identical to that of haskell's `splitWhen`
*
* {{{
* scala> import cats.syntax.all._, cats.Foldable, cats.data.NonEmptyList
* scala> Foldable[List].splitWhen(List(1,1))(_ == 1)
* res0: NonEmptyList[List[Int]] = NonEmptyList(List(), List(), List())
* scala> Foldable[List].splitWhen(Nil)(_ == 1)
* res1: NonEmptyList[List[Nothing]] = NonEmptyList(List())
* scala> Foldable[List].splitWhen(List(1, 2, 3, 1, 4, 5))(_ == 1)
* res2: NonEmptyList[List[Int]] = NonEmptyList(List(), List(2, 3), List(4, 5))
* }}}
*/

def splitWhen[A](fa: F[A])(f: A => Boolean)(implicit
Comment thread
TheBugYouCantFix marked this conversation as resolved.
FA: Alternative[F]
): NonEmptyList[F[A]] = {
foldRight(fa, Eval.now(NonEmptyList.one(FA.empty[A]))) {
case (a, acc) if f(a) => acc.map(FA.empty[A] :: _)
case (a, acc) => acc.map(nel => NonEmptyList(FA.prependK(a, nel.head), nel.tail))
}.value
}

/**
* Split this Foldable into a NonEmptyList of Lists based on the effectufl predicate. Monadic version of `splitWhen`
*
* {{{
* scala> import cats.syntax.all._, cats.Foldable, cats.Eval, cats.data.NonEmptyList
* scala> Foldable[List].splitWhenM(List(1,1))(x => Eval.now(x == 1)).value
* res0: NonEmptyList[List[Int]] = NonEmptyList(List(), List(), List())
* scala> Foldable[List].splitWhenM(List.empty[Int])(x => Eval.now(x == 1)).value
* res1: NonEmptyList[List[Int]] = NonEmptyList(List())
* scala> Foldable[List].splitWhenM(List(1, 2, 3, 1, 4, 5))(x => Eval.now(x == 1)).value
* val res2: NonEmptyList[List[Int]] = NonEmptyList(List(), List(2, 3), List(4, 5))
* }}}
*/

def splitWhenM[G[_], A](fa: F[A])(f: A => G[Boolean])(implicit
M: Monad[G],
FA: Alternative[F]
): G[NonEmptyList[F[A]]] = {
foldRight(fa, Eval.now(M.pure(NonEmptyList.one(FA.empty[A])))) { (a, evalGnel) =>
evalGnel.map { gnel =>
M.flatMap(f(a)) { isDelimiter =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the loss of tailRecM here means this isn't stack safe and will likely cause pain for users when they try to use it with Reader/Writer/State/Kleisli/etc...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if a truly stack-safe implementation is possible here inside Foldable yet I may be wrong. But if it turns out to be true then we'd probably have to either remove that method or bring it all back to list...
@satorg what do you think?

Copy link
Copy Markdown
Contributor

@satorg satorg Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be getting it wrong, but I couldn't come up with an example where we'd blow the stack here.

  1. The method uses Foldable.foldRight on F[_], which engages Eval, which, in turn, is a StackSafeMonad itself:
    implicit val catsBimonadForEval: Bimonad[Eval] & CommutativeMonad[Eval] =
    new FlatMap.AbstractFlatMap[Eval] with Bimonad[Eval] with StackSafeMonad[Eval] with CommutativeMonad[Eval] {
    According to the docs, if it uses map or flatMap over Eval only, then it's probably safe.
  2. When it comes to type G[_], then it doesn't have to be a Monad at all – Applicative should be enough here:
    def splitWhenM[G[_], A](fa: F[A])(f: A => G[Boolean])(implicit
      FA: Alternative[F],
      G: Applicative[G]
    ): G[NonEmptyList[F[A]]] = {
      foldRight(fa, Eval.now(M.pure(NonEmptyList.one(FA.empty[A])))) { (a, evalGnel) =>
        evalGnel.map { gnel =>
          G.map2(f(a), gnel) { case (isDelimiter, nel) =>
            if (isDelimiter) FA.empty[A] :: nel
            else NonEmptyList(FA.prependK(a, nel.head), nel.tail)
          }
        }
      }.value
    }
    Therefore, the transformation is flat – it should be safe as long as the user-provided f is safe.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @johnynek mentioned, it'd blow the stack if G is Kleisli, Writer or Reader. I tried running it with Kleisli on my machine and just 10K element list was enough to get a StackOverflowError

M.map(gnel) { nel =>
if (isDelimiter) FA.empty[A] :: nel
else NonEmptyList(FA.prependK(a, nel.head), nel.tail)
}
}
}
}.value
}
}

object Foldable {
Expand Down
12 changes: 12 additions & 0 deletions core/src/main/scala/cats/syntax/foldable.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
package cats
package syntax

import cats.data.NonEmptyList

trait FoldableSyntax extends Foldable.ToFoldableOps with UnorderedFoldable.ToUnorderedFoldableOps {

implicit final def catsSyntaxNestedFoldable[F[_]: Foldable, G[_], A](fga: F[G[A]]): NestedFoldableOps[F, G, A] =
Expand Down Expand Up @@ -304,6 +306,16 @@ final class FoldableOps0[F[_], A](private val fa: F[A]) extends AnyVal {
)(implicit A: Alternative[F], F: Foldable[F], M: Monad[G]): G[(F[B], F[C])] =
F.partitionEitherM[G, A, B, C](fa)(f)(A, M)

def splitWhen(f: A => Boolean)(implicit FF: Foldable[F], FA: Alternative[F]): NonEmptyList[F[A]] = {
FF.splitWhen[A](fa)(f)(FA)
}

def splitWhenM[G[_]](
f: A => G[Boolean]
)(implicit FF: Foldable[F], FA: Alternative[F], G: Monad[G]): G[NonEmptyList[F[A]]] = {
FF.splitWhenM[G, A](fa)(f)(G, FA)
}

def sliding2(implicit F: Foldable[F]): List[(A, A)] =
F.sliding2(fa)
def sliding3(implicit F: Foldable[F]): List[(A, A, A)] =
Expand Down
27 changes: 27 additions & 0 deletions tests/shared/src/test/scala/cats/tests/FoldableSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,33 @@ abstract class FoldableSuite[F[_]: Foldable](name: String)(implicit
}
}

test(s"Foldable[$name].splitWhen") {
forAll { (fi: F[Int], x: Int) =>
val pred = (y: Int) => x == y
val li = fi.toList
val res = li.splitWhen(pred)
val expectedFiltered = li.filterNot(pred)
val expectedSize = li.size - expectedFiltered.size + 1
assert(res.size === expectedSize)
assert(res.reduce === expectedFiltered)
assert(Reducible[NonEmptyList].nonEmptyIntercalate(res, List(x)) == li)
}
}

test(s"Foldable[$name].splitWhenM") {
forAll { (fi: F[Int], x: Int) =>
val pred = (y: Int) => x == y
val predM = (y: Int) => Eval.now(pred(y))
val li = fi.toList
val res = li.splitWhenM(predM).value
val expectedFiltered = li.filterNot(pred)
val expectedSize = li.size - expectedFiltered.size + 1
assert(res.size === expectedSize)
assert(res.reduce === expectedFiltered)
assert(Reducible[NonEmptyList].nonEmptyIntercalate(res, List(x)) == li)
}
}

test(s"Foldable[$name].sliding2 consistent with List#sliding(2)") {
forAll { (fi: F[Int]) =>
val n = 2
Expand Down
Loading