Skip to content

Commit

Permalink
feat: Add common collection constraints (#91)
Browse files Browse the repository at this point in the history
  • Loading branch information
Iltotore authored Dec 29, 2022
1 parent 3644e0a commit b5aed9d
Show file tree
Hide file tree
Showing 4 changed files with 337 additions and 36 deletions.
10 changes: 9 additions & 1 deletion main/src/io/github/iltotore/iron/compileTime.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package io.github.iltotore.iron

import scala.compiletime.constValue
import scala.compiletime.ops.*, any.ToString
import scala.compiletime.ops.*
import scala.compiletime.ops.any.ToString
import scala.quoted.*

/**
* Methods and types to ease compile-time operations.
Expand Down Expand Up @@ -149,3 +151,9 @@ object compileTime:
* @return the String representation of the given type. Equivalent to `constValue[ToString[A]]`.
*/
inline def stringValue[A]: String = constValue[ToString[A]]

def applyConstraint[A, C, Impl <: Constraint[A, C]](expr: Expr[A], constraintExpr: Expr[Impl])(using Quotes): Expr[Boolean] = // Using quotes directly causes a "deferred inline error"

import quotes.reflect.*

Apply(Select.unique(constraintExpr.asTerm, "test"), List(expr.asTerm)).asExprOf[Boolean]
257 changes: 227 additions & 30 deletions main/src/io/github/iltotore/iron/constraint/collection.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package io.github.iltotore.iron.constraint

import io.github.iltotore.iron.{:|, ==>, Constraint, Implication}
import io.github.iltotore.iron.compileTime.*
import io.github.iltotore.iron.constraint.any.DescribedAs
import io.github.iltotore.iron.constraint.numeric.{GreaterEqual, LessEqual}

import scala.compiletime.{constValue, summonInline}
import scala.compiletime.ops.string.Length
Expand All @@ -14,19 +16,25 @@ import scala.quoted.*
*/
object collection:

/**
* Tests if the length of the passed input satisfies the given constraint.
* @tparam C the constraint to test on the given input.
*/
final class Length[C]

/**
* Tests minimum length. Supports [[Iterable]] and [[String]] by default.
*
* @tparam V the minimum length of the tested input
*/
final class MinLength[V <: Int]
type MinLength[V <: Int] = Length[GreaterEqual[V]] DescribedAs "Should have a minimum length of " + V

/**
* Tests maximum length. Supports [[Iterable]] and [[String]] by default.
*
* @tparam V the maximum length of the tested input
*/
final class MaxLength[V <: Int]
type MaxLength[V <: Int] = Length[LessEqual[V]] DescribedAs "Should have a maximum length of" + V

/**
* Tests if the given collection contains a specific value.
Expand All @@ -41,41 +49,65 @@ object collection:
*/
final class ForAll[C]

object MinLength:
inline given [V <: Int, I <: Iterable[?]]: Constraint[I, MinLength[V]] with
/**
* Tests if each element except the last one satisfies the given constraint.
*
* @tparam C the constraint to test against the init.
*/
final class Init[C]

override inline def test(value: I): Boolean = value.sizeCompare(constValue[V]) >= 0
/**
* Tests if each element except the first one satisfies the given constraint.
* @tparam C the constraint to test against the tail.
*/
final class Tail[C]

override inline def message: String = "Should contain atleast " + stringValue[V] + " elements"
/**
* Tests if at least one element satisfies the given constraint.
*
* @tparam C the constraint to test against each element.
*/
final class Exists[C]

inline given [V <: Int]: Constraint[String, MinLength[V]] with
/**
* Tests if the head of the passed input satisfies the given constraint.
* @tparam C the constraint to test against the first element (head).
*/
final class Head[C]

override inline def test(value: String): Boolean = ${ check('value, '{ constValue[V] }) }
/**
* Tests if the last element of the passed input satisfies the given constraint.
* @tparam C the constraint to test against the last element.
*/
final class Last[C]

override inline def message: String = "Should have a min length of " + stringValue[V]
object Length:

private def check(expr: Expr[String], lengthExpr: Expr[Int])(using Quotes): Expr[Boolean] =
(expr.value, lengthExpr.value) match
case (Some(value), Some(minLength)) => Expr(value.length >= minLength)
case _ => '{ ${ expr }.length >= $lengthExpr }
class LengthIterable[I <: Iterable[?], C, Impl <: Constraint[Int, C]](using Impl) extends Constraint[I, Length[C]]:

object MaxLength:
inline given [V <: Int, I <: Iterable[?]]: Constraint[I, MaxLength[V]] with
override inline def test(value: I): Boolean = summonInline[Impl].test(value.size)

override inline def test(value: I): Boolean = value.sizeCompare(constValue[V]) <= 0
override inline def message: String = "Length: (" + summonInline[Impl].message + ")"

override inline def message: String = "Should contain at most " + stringValue[V] + " elements"
inline given[I <: Iterable[?], C, Impl <: Constraint[Int, C]](using inline impl: Impl): LengthIterable[I, C, Impl] =
new LengthIterable

class LengthString[C, Impl <: Constraint[Int, C]](using Impl) extends Constraint[String, Length[C]]:

override inline def test(value: String): Boolean = ${ checkString('value, '{ summonInline[Impl] }) }

inline given [V <: Int]: Constraint[String, MaxLength[V]] with
override inline def message: String = "Length: (" + summonInline[Impl].message + ")"

override inline def test(value: String): Boolean = ${ checkMaxLength('value, '{ constValue[V] }) }
inline given lengthString[C, Impl <: Constraint[Int, C]](using inline impl: Impl): LengthString[C, Impl] = new LengthString

override inline def message: String = "Should have a max length of " + stringValue[V]
private def checkString[C, Impl <: Constraint[Int, C]](expr: Expr[String], constraintExpr: Expr[Impl])(using Quotes): Expr[Boolean] =

import quotes.reflect.*

expr.value match
case Some(value) => applyConstraint(Expr(value.length), constraintExpr)

private def checkMaxLength(expr: Expr[String], lengthExpr: Expr[Int])(using Quotes): Expr[Boolean] =
(expr.value, lengthExpr.value) match
case (Some(value), Some(maxLength)) => Expr(value.length <= maxLength)
case _ => '{ ${ expr }.length <= $lengthExpr }
case None => applyConstraint('{$expr.length}, constraintExpr)

object Contain:
inline given [A, V <: A, I <: Iterable[A]]: Constraint[I, Contain[V]] with
Expand All @@ -86,11 +118,11 @@ object collection:

inline given [V <: String]: Constraint[String, Contain[V]] with

override inline def test(value: String): Boolean = ${ check('value, '{ constValue[V] }) }
override inline def test(value: String): Boolean = ${ checkString('value, '{ constValue[V] }) }

override inline def message: String = "Should contain the string " + constValue[V]

private def check(expr: Expr[String], partExpr: Expr[String])(using Quotes): Expr[Boolean] =
private def checkString(expr: Expr[String], partExpr: Expr[String])(using Quotes): Expr[Boolean] =
(expr.value, partExpr.value) match
case (Some(value), Some(part)) => Expr(value.contains(part))
case _ => '{ ${ expr }.contains($partExpr) }
Expand Down Expand Up @@ -118,14 +150,179 @@ object collection:

import quotes.reflect.*

def testChar(char: Expr[Char]): Expr[Boolean] = // Using quotes directly causes a "deferred inline error"
Apply(Select.unique(constraintExpr.asTerm, "test"), List(char.asTerm)).asExprOf[Boolean]
expr.value match
case Some(value) =>
value
.map(Expr.apply)
.map(applyConstraint(_, constraintExpr))
.foldLeft(Expr(true))((e, t) => '{ $e && $t })

case None => '{ $expr.forall(c => ${ applyConstraint('c, constraintExpr) }) }

given [C1, C2](using C1 ==> C2): (ForAll[C1] ==> Exists[C2]) = Implication()
given [C1, C2](using C1 ==> C2): (ForAll[C1] ==> Last[C2]) = Implication()
given [C1, C2](using C1 ==> C2): (ForAll[C1] ==> Init[C2]) = Implication()
given [C1, C2](using C1 ==> C2): (ForAll[C1] ==> Tail[C2]) = Implication()

object Init:

class InitIterable[A, I <: Iterable[A], C, Impl <: Constraint[A, C]](using Impl) extends Constraint[I, Init[C]]:

override inline def test(value: I): Boolean = value.isEmpty || value.init.forall(summonInline[Impl].test(_))

override inline def message: String = "For each element except head: (" + summonInline[Impl].message + ")"

inline given[A, I <: Iterable[A], C, Impl <: Constraint[A, C]](using inline impl: Impl): InitIterable[A, I, C, Impl] =
new InitIterable

class InitString[C, Impl <: Constraint[Char, C]](using Impl) extends Constraint[String, Init[C]]:

override inline def test(value: String): Boolean = ${ checkString('value, '{ summonInline[Impl] }) }

override inline def message: String = "For each element except last: (" + summonInline[Impl].message + ")"

inline given initString[C, Impl <: Constraint[Char, C]](using inline impl: Impl): InitString[C, Impl] = new InitString

private def checkString[C, Impl <: Constraint[Char, C]](expr: Expr[String], constraintExpr: Expr[Impl])(using Quotes): Expr[Boolean] =

import quotes.reflect.*

expr.value match
case Some(value) =>
value
.init
.map(Expr.apply)
.map(testChar)
.map(applyConstraint(_, constraintExpr))
.foldLeft(Expr(true))((e, t) => '{ $e && $t })

case None => '{ $expr.forall(c => ${ testChar('c) }) }
case None => '{ $expr.init.forall(c => ${ applyConstraint('c, constraintExpr) }) }

given[C1, C2](using C1 ==> C2): (Init[C1] ==> Exists[C2]) = Implication()

given[C1, C2](using C1 ==> C2): (Init[C1] ==> Head[C2]) = Implication()

object Tail:

class TailIterable[A, I <: Iterable[A], C, Impl <: Constraint[A, C]](using Impl) extends Constraint[I, Tail[C]]:

override inline def test(value: I): Boolean = value.isEmpty || value.tail.forall(summonInline[Impl].test(_))

override inline def message: String = "For each element: (" + summonInline[Impl].message + ")"

inline given[A, I <: Iterable[A], C, Impl <: Constraint[A, C]](using inline impl: Impl): TailIterable[A, I, C, Impl] =
new TailIterable

class TailString[C, Impl <: Constraint[Char, C]](using Impl) extends Constraint[String, Tail[C]]:

override inline def test(value: String): Boolean = ${ checkString('value, '{ summonInline[Impl] }) }

override inline def message: String = "For each element: (" + summonInline[Impl].message + ")"

inline given tailString[C, Impl <: Constraint[Char, C]](using inline impl: Impl): TailString[C, Impl] = new TailString

private def checkString[C, Impl <: Constraint[Char, C]](expr: Expr[String], constraintExpr: Expr[Impl])(using Quotes): Expr[Boolean] =

import quotes.reflect.*

expr.value match
case Some(value) =>
value
.tail
.map(Expr.apply)
.map(applyConstraint(_, constraintExpr))
.foldLeft(Expr(true))((e, t) => '{ $e && $t })

case None => '{ $expr.tail.forall(c => ${ applyConstraint('c, constraintExpr) }) }

given[C1, C2](using C1 ==> C2): (Tail[C1] ==> Exists[C2]) = Implication()
given[C1, C2](using C1 ==> C2): (Tail[C1] ==> Last[C2]) = Implication()

object Exists:

class ExistsIterable[A, I <: Iterable[A], C, Impl <: Constraint[A, C]](using Impl) extends Constraint[I, Exists[C]]:

override inline def test(value: I): Boolean = value.exists(summonInline[Impl].test(_))

override inline def message: String = "At least one: (" + summonInline[Impl].message + ")"

inline given [A, I <: Iterable[A], C, Impl <: Constraint[A, C]](using inline impl: Impl): ExistsIterable[A, I, C, Impl] =
new ExistsIterable

class ExistsString[C, Impl <: Constraint[Char, C]](using Impl) extends Constraint[String, Exists[C]]:

override inline def test(value: String): Boolean = ${ checkString('value, '{ summonInline[Impl] }) }

override inline def message: String = "At least one element: (" + summonInline[Impl].message + ")"

inline given existsString[C, Impl <: Constraint[Char, C]](using inline impl: Impl): ExistsString[C, Impl] = new ExistsString

private def checkString[C, Impl <: Constraint[Char, C]](expr: Expr[String], constraintExpr: Expr[Impl])(using Quotes): Expr[Boolean] =

import quotes.reflect.*

expr.value match
case Some(value) =>
value
.map(Expr.apply)
.map(applyConstraint(_, constraintExpr))
.foldLeft(Expr(false))((e, t) => '{ $e || $t })

case None => '{ $expr.exists(c => ${ applyConstraint('c, constraintExpr) }) }

object Head:

class HeadIterable[A, I <: Iterable[A], C, Impl <: Constraint[A, C]](using Impl) extends Constraint[I, Head[C]]:

override inline def test(value: I): Boolean = value.headOption.exists(summonInline[Impl].test(_))

override inline def message: String = "Head: (" + summonInline[Impl].message + ")"

inline given [A, I <: Iterable[A], C, Impl <: Constraint[A, C]](using inline impl: Impl): HeadIterable[A, I, C, Impl] =
new HeadIterable

class HeadString[C, Impl <: Constraint[Char, C]](using Impl) extends Constraint[String, Head[C]]:

override inline def test(value: String): Boolean = ${ checkString('value, '{ summonInline[Impl] }) }

override inline def message: String = "Head: (" + summonInline[Impl].message + ")"

inline given headString[C, Impl <: Constraint[Char, C]](using inline impl: Impl): HeadString[C, Impl] = new HeadString

private def checkString[C, Impl <: Constraint[Char, C]](expr: Expr[String], constraintExpr: Expr[Impl])(using Quotes): Expr[Boolean] =
expr.value match
case Some(value) =>
value.headOption match
case Some(head) => applyConstraint(Expr(head), constraintExpr)
case None => Expr(false)
case None => '{ $expr.headOption.exists(head => ${ applyConstraint('{ head }, constraintExpr) }) }

given [C1, C2](using C1 ==> C2): (Head[C1] ==> Exists[C2]) = Implication()

object Last:

class LastIterable[A, I <: Iterable[A], C, Impl <: Constraint[A, C]](using Impl) extends Constraint[I, Last[C]]:

override inline def test(value: I): Boolean = value.lastOption.exists(summonInline[Impl].test(_))

override inline def message: String = "Last: (" + summonInline[Impl].message + ")"

inline given[A, I <: Iterable[A], C, Impl <: Constraint[A, C]](using inline impl: Impl): LastIterable[A, I, C, Impl] =
new LastIterable

class LastString[C, Impl <: Constraint[Char, C]](using Impl) extends Constraint[String, Last[C]]:

override inline def test(value: String): Boolean = ${ checkString('value, '{ summonInline[Impl] }) }

override inline def message: String = "Last: (" + summonInline[Impl].message + ")"

inline given lastString[C, Impl <: Constraint[Char, C]](using inline impl: Impl): LastString[C, Impl] = new LastString

private def checkString[C, Impl <: Constraint[Char, C]](expr: Expr[String], constraintExpr: Expr[Impl])(using Quotes): Expr[Boolean] =
expr.value match
case Some(value) =>
value.lastOption match
case Some(last) => applyConstraint(Expr(last), constraintExpr)
case None => Expr(false)
case None => '{ $expr.lastOption.exists(last => ${ applyConstraint('{ last }, constraintExpr) }) }

given[C1, C2](using C1 ==> C2): (Last[C1] ==> Exists[C2]) = Implication()
6 changes: 3 additions & 3 deletions main/src/io/github/iltotore/iron/conversion.scala
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ implicit inline def autoCastIron[A, C1, C2](inline value: A :| C1)(using C1 ==>
* @return the given value as instance of `I[A] :| ForAll[C2]`.
* @see [[autoDistribute]]
*/
implicit inline def autoFactorize[A, I[_] <: Iterable[_], C1, C2](inline iterable: I[A :| C1])(using C1 ==> C2): I[A] :| ForAll[C2] =
implicit inline def autoFactorize[A, I[_] <: Iterable[?], C1, C2](inline iterable: I[A :| C1])(using C1 ==> C2): I[A] :| ForAll[C2] =
iterable.asInstanceOf

/**
Expand All @@ -57,5 +57,5 @@ implicit inline def autoFactorize[A, I[_] <: Iterable[_], C1, C2](inline iterabl
* @return the given value as instance of `I[A :| C2]`.
* @see [[autoFactorize]]
*/
implicit inline def autoDistribute[A, I[_] <: Iterable[_], C1, C2](inline iterable: I[A] :| ForAll[C1])(using C1 ==> C2): I[A :| C2] =
iterable.asInstanceOf
implicit inline def autoDistribute[A, I[_] <: Iterable[?], C1, C2](inline iterable: I[A] :| ForAll[C1])(using C1 ==> C2): I[A :| C2] =
iterable.asInstanceOf
Loading

0 comments on commit b5aed9d

Please sign in to comment.