diff --git a/README.md b/README.md index 6efdf37ae..dd0440646 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,7 @@ The library comes with these predefined predicates: * `XPath`: checks if a `String` is a valid XPath expression * `Trimmed`: checks if a `String` has no leading or trailing whitespace * `HexStringSpec`: checks if a `String` represents a hexadecimal number +* `SplitAt`: split a string at an Index `N`, and then check the conjunction of the predicates `A` on the first part of the string and `B` on its second part ## Contributors and participation diff --git a/modules/core/shared/src/main/scala-3.0+/eu/timepit/refined/string.scala b/modules/core/shared/src/main/scala-3.0+/eu/timepit/refined/string.scala index dfb33a4b0..2c8e36460 100644 --- a/modules/core/shared/src/main/scala-3.0+/eu/timepit/refined/string.scala +++ b/modules/core/shared/src/main/scala-3.0+/eu/timepit/refined/string.scala @@ -72,6 +72,9 @@ object string extends StringInference { /** Predicate that checks if a `String` represents a hexadecimal number. */ type HexStringSpec = MatchesRegex["""^(([0-9a-f]+)|([0-9A-F]+))$"""] + /** Predicate that split a `String` and check the conjunction of the predicates `A` and `B` */ + final case class SplitAt[N, A, B](n: N, a: A, b: B) + object EndsWith { implicit def endsWithValidate[S <: String](implicit ws: ValueOf[S] @@ -240,6 +243,54 @@ object string extends StringInference { implicit def trimmedValidate: Validate.Plain[String, Trimmed] = Validate.fromPredicate(s => s.trim == s, t => s"$t is trimmed", Trimmed()) } + + object SplitAt { + implicit def splitAtValidate[N <: Int, A, RA, B, RB]( + implicit + wn: Witness.Aux[N], + va: Validate.Aux[String, A, RA], + vb: Validate.Aux[String, B, RB] + ) + + : Validate.Aux[String, SplitAt[N, A, B], SplitAt[N, Option[va.Res], Option[vb.Res]]] = new Validate[String, SplitAt[N, A, B]] { + + override type R = SplitAt[N, Option[va.Res], Option[vb.Res]] + + override def validate(s: String): Res = { + try { + val (ra, rb) = (va.validate(s.substring(0, wn.value.toInt)), vb.validate(s.substring(wn.value.toInt))) + Result.fromBoolean(ra.isPassed && rb.isPassed, SplitAt(wn.value, Some(ra), Some(rb))) + } catch { + case _: StringIndexOutOfBoundsException => Failed(SplitAt(wn.value, None, None)) + case NonFatal(_) => Failed(SplitAt(wn.value, None, None)) + } + } + + override def showExpr(s: String): String = + s"splitAt(${wn.value.toInt}, ${va.showExpr(s)} && ${vb.showExpr(s)})" + + override def showResult(s: String, r: Res): String = { + val expr = showExpr(s) + val (ra, rb) = (r.detail.a, r.detail.b) + (ra, rb) match { + case (None, None) => + Resources.showResultInputFailed(expr, s"${wn.value.toInt} should be between zero and this string length") + case (Some(_), None) => + Resources.showResultInputFailed(expr, s"${wn.value.toInt} should be between zero and this string length") + case (None, Some(_)) => + Resources.showResultInputFailed(expr, s"${wn.value.toInt} should be between zero and this string length") + case (Some(Passed(_)), Some(Passed(_))) => + Resources.showResultAndBothPassed(expr) + case (Some(Passed(_)), Some(Failed(_))) => + Resources.showResultAndRightFailed(expr, vb.showResult(s, rb.get)) + case (Some(Failed(_)), Some(Passed(_))) => + Resources.showResultAndLeftFailed(expr, va.showResult(s, ra.get)) + case (Some(Failed(_)), Some(Failed(_))) => + Resources.showResultAndBothFailed(expr, va.showResult(s, ra.get), vb.showResult(s, rb.get)) + } + } + } + } } private[refined] trait StringInference { diff --git a/modules/core/shared/src/main/scala-3.0-/eu/timepit/refined/string.scala b/modules/core/shared/src/main/scala-3.0-/eu/timepit/refined/string.scala index 892dfe99d..0ac49c4b8 100644 --- a/modules/core/shared/src/main/scala-3.0-/eu/timepit/refined/string.scala +++ b/modules/core/shared/src/main/scala-3.0-/eu/timepit/refined/string.scala @@ -1,10 +1,12 @@ package eu.timepit.refined -import eu.timepit.refined.api.{Inference, Validate} +import eu.timepit.refined.api.{Failed, Inference, Passed, Result, Validate} import eu.timepit.refined.api.Inference.==> -import eu.timepit.refined.collection.NonEmpty +import eu.timepit.refined.collection.{NonEmpty} +import eu.timepit.refined.internal.Resources import eu.timepit.refined.string._ import shapeless.Witness +import scala.util.control.NonFatal /** * Module for `String` related predicates. Note that most of the predicates @@ -73,6 +75,9 @@ object string extends StringInference { /** Predicate that checks if a `String` represents a hexadecimal number. */ type HexStringSpec = MatchesRegex[W.`"""^(([0-9a-f]+)|([0-9A-F]+))$"""`.T] + /** Predicate that split a `String` and check the conjunction of the predicates `A` and `B` */ + final case class SplitAt[N, A, B](n: N, a: A, b: B) + object EndsWith { implicit def endsWithValidate[S <: String](implicit ws: Witness.Aux[S] @@ -241,6 +246,55 @@ object string extends StringInference { implicit def trimmedValidate: Validate.Plain[String, Trimmed] = Validate.fromPredicate(s => s.trim == s, t => s"$t is trimmed", Trimmed()) } + + object SplitAt { + implicit def splitAtValidate[N <: Int, A, RA, B, RB]( + implicit + wn: Witness.Aux[N], + va: Validate.Aux[String, A, RA], + vb: Validate.Aux[String, B, RB] + ) + + : Validate.Aux[String, SplitAt[N, A, B], SplitAt[N, Option[va.Res], Option[vb.Res]]] = new Validate[String, SplitAt[N, A, B]] { + + override type R = SplitAt[N, Option[va.Res], Option[vb.Res]] + + override def validate(s: String): Res = { + try { + val (ra, rb) = (va.validate(s.substring(0, wn.value.toInt)), vb.validate(s.substring(wn.value.toInt))) + Result.fromBoolean(ra.isPassed && rb.isPassed, SplitAt(wn.value, Some(ra), Some(rb))) + } catch { + case NonFatal(_) => Failed(SplitAt(wn.value, None, None)) + case _: Throwable => + Failed(SplitAt(wn.value, None, None)) + } + } + + override def showExpr(s: String): String = + s"splitAt(${wn.value.toInt}, ${va.showExpr(s)} && ${vb.showExpr(s)})" + + override def showResult(s: String, r: Res): String = { + val expr = showExpr(s) + val (ra, rb) = (r.detail.a, r.detail.b) + (ra, rb) match { + case (None, None) => + Resources.showResultInputFailed(expr, s"${wn.value.toInt} should be between zero and this string length") + case (Some(_), None) => + Resources.showResultInputFailed(expr, s"${wn.value.toInt} should be between zero and this string length") + case (None, Some(_)) => + Resources.showResultInputFailed(expr, s"${wn.value.toInt} should be between zero and this string length") + case (Some(Passed(_)), Some(Passed(_))) => + Resources.showResultAndBothPassed(expr) + case (Some(Passed(_)), Some(Failed(_))) => + Resources.showResultAndRightFailed(expr, vb.showResult(s, rb.get)) + case (Some(Failed(_)), Some(Passed(_))) => + Resources.showResultAndLeftFailed(expr, va.showResult(s, ra.get)) + case (Some(Failed(_)), Some(Failed(_))) => + Resources.showResultAndBothFailed(expr, va.showResult(s, ra.get), vb.showResult(s, rb.get)) + } + } + } + } } private[refined] trait StringInference { @@ -294,4 +348,10 @@ private[refined] trait StringInference { implicit def xPathNonEmptyInference: XPath ==> NonEmpty = Inference.alwaysValid("xPathNonEmptyInference") + + implicit def splitAtNonEmptyInference[N <: Int, A, B](implicit + wn: Witness.Aux[N] + ): SplitAt[N, A, B] ==> NonEmpty = + Inference(wn.value.toInt > 0, s"splitAtNonEmptyInference(${wn.value})") + } diff --git a/modules/core/shared/src/main/scala/eu/timepit/refined/internal/Resources.scala b/modules/core/shared/src/main/scala/eu/timepit/refined/internal/Resources.scala index ba895d379..b97c2398f 100644 --- a/modules/core/shared/src/main/scala/eu/timepit/refined/internal/Resources.scala +++ b/modules/core/shared/src/main/scala/eu/timepit/refined/internal/Resources.scala @@ -86,6 +86,9 @@ object Resources { def showResultOrBothFailed(expr: String, left: String, right: String): String = s"$Both $predicates of $expr $failed. $Left: $left $Right: $right" + def showResultInputFailed(expr: String, input: String): String = + s"$expr $failed: input $input" + // val refineNonCompileTimeConstant = diff --git a/modules/core/shared/src/test/scala-3.0-/eu/timepit/refined/StringInferenceSpec.scala b/modules/core/shared/src/test/scala-3.0-/eu/timepit/refined/StringInferenceSpec.scala index 3f85d2295..f4ea79931 100644 --- a/modules/core/shared/src/test/scala-3.0-/eu/timepit/refined/StringInferenceSpec.scala +++ b/modules/core/shared/src/test/scala-3.0-/eu/timepit/refined/StringInferenceSpec.scala @@ -71,4 +71,12 @@ class StringInferenceSpec extends Properties("StringInference") { Inference[ValidBigDecimal, NonEmpty].isValid } + property("SplitAt =!> NonEmpty") = secure { + Inference[SplitAt[W.`1`.T, IPv4, Uuid], NonEmpty].isValid + } + + property("SplitAt =!> NonEmpty") = secure { + Inference[SplitAt[W.`-1`.T, IPv4, Uuid], NonEmpty].notValid + } + } diff --git a/modules/core/shared/src/test/scala-3.0-/eu/timepit/refined/StringValidateSpec.scala b/modules/core/shared/src/test/scala-3.0-/eu/timepit/refined/StringValidateSpec.scala index d533a6c20..15ec57ee1 100644 --- a/modules/core/shared/src/test/scala-3.0-/eu/timepit/refined/StringValidateSpec.scala +++ b/modules/core/shared/src/test/scala-3.0-/eu/timepit/refined/StringValidateSpec.scala @@ -7,6 +7,8 @@ import org.scalacheck.{Arbitrary, Properties} import org.scalacheck.Prop._ import shapeless.Witness + + class StringValidateSpec extends Properties("StringValidate") { property("EndsWith.isValid") = secure { @@ -143,4 +145,39 @@ class StringValidateSpec extends Properties("StringValidate") { validNumber[Double, ValidDouble]("ValidDouble", "a") validNumber[BigInt, ValidBigInt]("ValidBigInt", "1.0") validNumber[BigDecimal, ValidBigDecimal]("ValidBigDecimal", "a") + + property("SplitAt.isValid") = secure { + val ipv4 = "10.0.0.1" + val uuid = "9ecce884-47fe-4ba4-a1bb-1a3d71ed6530" + val length = Witness(8) + val s = s"$ipv4$uuid" + isValid[SplitAt[length.T, IPv4, Uuid]](s) ?= s.startsWith(ipv4) && s.endsWith(uuid) + } + + property("SplitAt.showResult.example.Failed") = secure { + val ipv4 = "whops" + val uuid = "9ecce884-47fe-4ba4-a1bb-1a3d71ed6530" + val length = Witness(5) + val s = s"$ipv4$uuid" + showResult[SplitAt[length.T, IPv4, Uuid]](s) ?= + s"""Left predicate of splitAt(${length.value.toInt}, $s is a valid IPv4 && isValidUuid(\"$s\")) failed: Predicate failed: $s is a valid IPv4.""" + } + + property("SplitAt.showResult.length.negative.Failed") = secure { + val ipv4 = "whops" + val uuid = "9ecce884-47fe-4ba4-a1bb-1a3d71ed6530" + val length = Witness(-1) + val s = s"$ipv4$uuid" + showResult[SplitAt[length.T, IPv4, Uuid]](s) ?= + s"""splitAt(${length.value.toInt}, $s is a valid IPv4 && isValidUuid(\"$s\")) failed: input ${length.value.toInt} should be between zero and this string length""" + } + + property("SplitAt.showResult.length.big.Failed") = secure { + val ipv4 = "whops" + val uuid = "9ecce884-47fe-4ba4-a1bb-1a3d71ed6530" + val length = Witness(1000) + val s = s"$ipv4$uuid" + showResult[SplitAt[length.T, IPv4, Uuid]](s) ?= + s"""splitAt(${length.value.toInt}, $s is a valid IPv4 && isValidUuid(\"$s\")) failed: input ${length.value.toInt} should be between zero and this string length""" + } }