Skip to content

Commit

Permalink
adding the AtLeast2 data type / abstraction
Browse files Browse the repository at this point in the history
  • Loading branch information
vreuter committed Oct 25, 2024
1 parent 4bfda29 commit 3312d84
Show file tree
Hide file tree
Showing 4 changed files with 122 additions and 26 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed
* Scala is now version 3.5.2.

### Added
* The `AtLeast2[C[*], E]` abstraction for a collection/container `C[E]` which must contain at least two elements.

## [v0.2.0] - 2024-10-22

### Changed
Expand Down
99 changes: 75 additions & 24 deletions modules/pan/src/main/scala/collections.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package at.ac.oeaw.imba.gerlich.gerlib

import cats.Functor
import cats.*
import cats.data.{NonEmptyList, NonEmptySet}
import cats.syntax.all.*
import io.github.iltotore.iron.{:|, Constraint, refineEither, refineUnsafe}
import io.github.iltotore.iron.constraint.collection.MinLength

Expand All @@ -16,7 +15,10 @@ object collections:
* @tparam E
* The element type
*/
type AtLeast2[C[*], E] = C[E] :| MinLength[2]
opaque type AtLeast2[C[*], E] = C[E] :| MinLength[2]

/** A one-argument (element type) type constructor, fixing the container type */
private[gerlib] type AtLeast2FixedC[C[*]] = [X] =>> AtLeast2[C, X]

/** Typeclass instances and convenience syntax for working with containers of at least two
* elements
Expand Down Expand Up @@ -68,27 +70,76 @@ object collections:
): Either[String, AtLeast2[C, X]] =
xs.refineEither[MinLength[2]]

extension [X](xs: AtLeast2[Set, X])
/** Add an element to the given collection, usign the same definition of this operator as for
* the underlying collection.
*
* @param x
* The element to add to the collection
* @return
* The collection with the given element added
*/
infix def +(x: X): AtLeast2[Set, X] =
(xs + x).refineUnsafe[MinLength[2]]

// A one-argument (element type) type constructor, fixing the container type
private type AtLeast2FixedC[C[*]] = [X] =>> AtLeast2[C, X]

/** Provide a [[cats.Functor]] instance using the natural definition following from an available
* instance for the underlying container type.
*/
given functorForAtLeast2[C[*]: Functor]: Functor[AtLeast2FixedC[C]] with
override def map[A, B](fa: AtLeast2FixedC[C][A])(f: A => B): AtLeast2FixedC[C][B] =
fa.map(f)
inline def unsafe[C[*], X](xs: C[X])(using
inline ev: Constraint[C[X], MinLength[2]]
): AtLeast2[C, X] =
xs.refineUnsafe

/** Define equality the same way as for the underlying, unrefined value. */
given eqForAtLeast2[C[*], E](using Eq[C[E]]): Eq[AtLeast2[C, E]] = Eq.by(es => es: C[E])

/** Syntax enrichment for certain type members of at AtLeast2 family */
object syntax:
extension [X](xs: AtLeast2[Set, X])
/** Add an element to the given collection, usign the same definition of this operator as
* for the underlying collection.
*
* @param x
* The element to add to the collection
* @return
* The collection with the given element added
*/
infix def +(x: X): AtLeast2[Set, X] =
(xs + x).refineUnsafe[MinLength[2]]

extension [X](xs: AtLeast2[List, X])
/** With knowledge that the given container type is an set, we can use the underlying
* collection's {@code .contains} member.
*
* @return
* Whether the underlying collection contains the given element
*/
def contains(x: X): Boolean = (xs: List[X]).contains(x)

extension [C[*] <: Set[*], X](xs: AtLeast2[C, X])
/** With knowledge that the given container type is an set, we can use the underlying
* collection's {@code .contains} member.
*
* @return
* Whether the underlying collection contains the given element
*/
def contains(x: X): Boolean = (xs: C[X]).contains(x)

extension [C[*], X](xs: AtLeast2[C, X])
/** When the underlying container type has a functor, use it to {@code .map} over the
* refined collection.
*
* @tparam Y
* The codomain
* @param f
* The function to apply to each value of the collection
* @param F
* The [[cats.Functor]] instance for the underlying container type
* @param constraint
* Proof of the requisite constraint for the output collection
* @return
* A length-refined collection with each value mapped according to the given
* transformation
*/
inline def map[Y](
f: X => Y
)(using F: Functor[C], inline constraint: Constraint[C[Y], MinLength[2]]): AtLeast2[C, Y] =
unsafe(F.map(xs: C[X])(f))

extension [C[*] <: Iterable[*], X](xs: AtLeast2[C, X])
/** With knowledge that the given container type is an iterable, we can use the underlying
* collection's {@code .length} member.
*
* @return
* The size of the underlying collection
*/
def size: Int = (xs: C[X]).size
end syntax
end AtLeast2

extension [A](bag: Set[A])
Expand Down
39 changes: 38 additions & 1 deletion modules/pan/src/test/scala/TestAtLeast2.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package at.ac.oeaw.imba.gerlich.gerlib

import cats.data.*
import cats.laws.discipline.arbitrary.given // for Arbitrary[NonEmptySet[*]], Arbitrary[NonEmptyList[*]], etc.
import io.github.iltotore.iron.Constraint
import io.github.iltotore.iron.constraint.collection.MinLength
import org.scalacheck.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should
Expand All @@ -12,6 +14,26 @@ import at.ac.oeaw.imba.gerlich.gerlib.collections.AtLeast2
*/
class TestAtLeast2 extends AnyFunSuite, ScalaCheckPropertyChecks, should.Matchers:

inline given arbitraryForAtLeast2[C[*], E](using
Arbitrary[E],
org.scalacheck.util.Buildable[E, C[E]],
C[E] => Iterable[E],
Constraint[C[E], MinLength[2]]
): Arbitrary[AtLeast2[C, E]] = Arbitrary {
for
n <- Gen.choose(2, 5)
unrefined <- Gen.containerOfN[C, E](n, Arbitrary.arbitrary[E])
yield AtLeast2
.either(unrefined)
.fold(
msg =>
throw new Exception(
s"Error generating collection of at least 2 elements! Collection: ${unrefined}. Message: $msg"
),
identity
)
}

test("For lists, AtLeast2.apply has cons-like argument order"):
assertTypeError:
"AtLeast2(NonEmptyList.one(1), 0)"
Expand All @@ -25,15 +47,30 @@ class TestAtLeast2 extends AnyFunSuite, ScalaCheckPropertyChecks, should.Matcher
"AtLeast2(NonEmptySet.one(0), 1)"

test("For lists, AtLeat2 is correct with apply-syntax"):
import at.ac.oeaw.imba.gerlich.gerlib.collections.AtLeast2.syntax.*
forAll: (xs: NonEmptyList[Int], x: Int) =>
val atLeast2 = AtLeast2(x, xs)
atLeast2 `contains` x shouldBe true
atLeast2.length shouldEqual 1 + xs.length
atLeast2.size shouldEqual 1 + xs.size

test("For sets, AtLeast2 is correct with apply-syntax."):
import at.ac.oeaw.imba.gerlich.gerlib.collections.AtLeast2.syntax.*
forAll: (xs: NonEmptySet[Int], x: Int) =>
val atLeast2 = AtLeast2(xs, x)
atLeast2 `contains` x shouldBe true
atLeast2.size shouldEqual (xs.length + (if xs `contains` x then 0 else 1))

test(
".map on a AtLeast2[C, *] value returns a refined value IF AND ONLY IF a functor is available for the underlying container type and the AtLeast2 syntax is imported"
):
assertCompiles("summon[cats.Functor[List]]") // Functor is available for List.
assertTypeError("summon[cats.Functor[Set]]") // No functor for Set
assertCompiles(
"AtLeast2.unsafe(List(0, 1, 2))"
) // necessary precondition to render subsequent checks meaningful
assertTypeError("AtLeast2.unsafe(List(0, 1, 2)).map(_ + 1)") // Haven't imported the .map syntax
assertCompiles(
"import AtLeast2.syntax.map; AtLeast2.unsafe(List(0, 1, 2)).map(_ + 1): AtLeast2[List, Int]"
) // With the proper syntax import, the .map operation works.

end TestAtLeast2
2 changes: 1 addition & 1 deletion project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ object Dependencies {

/* Core dependencies */
lazy val catsCore = Cats.getModuleId("core")
lazy val catsLaws = Cats.getModuleId("laws")
lazy val kittens = "org.typelevel" %% "kittens" % "3.3.0"
lazy val mouse = "org.typelevel" %% "mouse" % "1.3.2"
lazy val uJson = HaoyiJson.getModuleId("ujson")
Expand All @@ -46,6 +45,7 @@ object Dependencies {
lazy val jzarr = "dev.zarr" % "jzarr" % "0.4.2"

/* Test dependencies */
lazy val catsLaws = Cats.getModuleId("laws")
lazy val scalacheck = "org.scalacheck" %% "scalacheck" % "1.18.0"
lazy val scalactic = "org.scalactic" %% "scalactic" % scalatestVersion
lazy val scalatest = "org.scalatest" %% "scalatest" % scalatestVersion
Expand Down

0 comments on commit 3312d84

Please sign in to comment.