Skip to content

Commit

Permalink
Close #281 - [refined4s-core] Add NonBlankString which can be neither…
Browse files Browse the repository at this point in the history
… all whitespace chars nor an empty String
  • Loading branch information
kevin-lee committed Apr 6, 2024
1 parent 82c8e54 commit 3c9cd3d
Show file tree
Hide file tree
Showing 3 changed files with 297 additions and 3 deletions.
7 changes: 5 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ lazy val props =
val RepoName = GitHubRepo.fold("refined4s")(_.nameToString)

val Scala3Version = "3.1.3"
// val Scala3Version = "3.3.1"
// val Scala3Version = "3.3.3"

val ProjectScalaVersion = Scala3Version

Expand All @@ -280,6 +280,7 @@ lazy val props =
val IncludeTest = "compile->compile;test->test"

val HedgehogVersion = "0.10.1"
val HedgehogExtraVersion = "0.8.0"

val ExtrasVersion = "0.44.0"

Expand Down Expand Up @@ -348,6 +349,8 @@ lazy val libs = new {
hedgehogRunner,
hedgehogSbt,
).map(_ % Test)

lazy val hedgehogExtraCore = "io.kevinlee" %% "hedgehog-extra-core" % props.HedgehogExtraVersion % Test
}
}

Expand Down Expand Up @@ -381,7 +384,7 @@ def module(projectName: String, crossProject: CrossProject.Builder): CrossProjec
),
scalacOptions ++= (if (isScala3(scalaVersion.value)) List("-no-indent") else List("-Xsource:3")),
// scalacOptions ~= (ops => ops.filter(_ != "UTF-8")),
libraryDependencies ++= libs.tests.hedgehog,
libraryDependencies ++= libs.tests.hedgehog ++ List(libs.tests.hedgehogExtraCore),
wartremoverErrors ++= Warts.allBut(Wart.Any, Wart.Nothing, Wart.ImplicitConversion, Wart.ImplicitParameter),
Compile / console / scalacOptions :=
(console / scalacOptions)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ trait strings {
final type NonEmptyString = strings.NonEmptyString
final val NonEmptyString = strings.NonEmptyString

final type NonBlankString = strings.NonBlankString
final val NonBlankString = strings.NonBlankString

final type Uuid = strings.Uuid
final val Uuid = strings.Uuid

Expand All @@ -40,6 +43,42 @@ object strings {
}
}

private[types] val WhitespaceCharRange: List[(Int, Int)] =
List(
9 -> 13,
28 -> 32,
5760 -> 5760,
8192 -> 8198,
8200 -> 8202,
8232 -> 8233,
8287 -> 8287,
12288 -> 12288,
)

type NonBlankString = NonBlankString.Type
@SuppressWarnings(Array("org.wartremover.warts.Equals"))
object NonBlankString extends InlinedRefined[String], CanBeOrdered[String] {

override inline val inlinedExpectedValue = "not all whitespace non-empty String"

override inline def inlinedPredicate(inline a: String): Boolean = ${ isValidNotAllWhitespaceNonEmptyString('a) }

override def invalidReason(a: String): String =
expectedMessage("not all whitespace non-empty String")

override def predicate(a: String): Boolean = a != "" && !a.forall(c => WhitespaceCharRange.exists((min, max) => c >= min && c <= max))

extension (inline thisNbs: Type) {
@targetName("plusPlus")
inline def ++(thatNbs: Type): Type = NonBlankString.unsafeFrom(thisNbs.value + thatNbs.value)

inline def prependString(that: String): Type = NonBlankString.unsafeFrom(that + thisNbs.value)

inline def appendString(that: String): Type = NonBlankString.unsafeFrom(thisNbs.value + that)
}

}

type Uuid = Uuid.Type
object Uuid extends InlinedRefined[String], CanBeOrdered[String] {
override inline val inlinedExpectedValue = "UUID"
Expand All @@ -66,6 +105,31 @@ object strings {

}

@SuppressWarnings(Array("org.wartremover.warts.Equals"))
def isValidNotAllWhitespaceNonEmptyString(stringExpr: Expr[String])(using Quotes): Expr[Boolean] = {
import quotes.reflect.*
stringExpr.asTerm match {
case Inlined(_, _, Literal(StringConstant(str))) =>
try {
// Expr(str != "" && !str.forall(c => WhitespaceCharRange.exists((min, max) => min <= c && c <= max)))
Expr(NonBlankString.predicate(str))
} catch {
case ex: Throwable =>
report.error(

Check warning on line 118 in modules/refined4s-core/shared/src/main/scala/refined4s/types/strings.scala

View check run for this annotation

Codecov / codecov/patch

modules/refined4s-core/shared/src/main/scala/refined4s/types/strings.scala#L116-L118

Added lines #L116 - L118 were not covered by tests
"Error when checking validity of NotAllWhitespaceNonEmptyString. Error: " + ex.getMessage,
stringExpr,
)

Check warning on line 121 in modules/refined4s-core/shared/src/main/scala/refined4s/types/strings.scala

View check run for this annotation

Codecov / codecov/patch

modules/refined4s-core/shared/src/main/scala/refined4s/types/strings.scala#L121

Added line #L121 was not covered by tests
Expr(false)
}
case _ =>

Check warning on line 124 in modules/refined4s-core/shared/src/main/scala/refined4s/types/strings.scala

View check run for this annotation

Codecov / codecov/patch

modules/refined4s-core/shared/src/main/scala/refined4s/types/strings.scala#L123-L124

Added lines #L123 - L124 were not covered by tests
report.error(
"The argument passed to NotAllWhitespaceNonEmptyString.apply must be a string literal.",
stringExpr,
)

Check warning on line 128 in modules/refined4s-core/shared/src/main/scala/refined4s/types/strings.scala

View check run for this annotation

Codecov / codecov/patch

modules/refined4s-core/shared/src/main/scala/refined4s/types/strings.scala#L128

Added line #L128 was not covered by tests
Expr(false)
}
}

val UnexpectedLiteralErrorMessage: String =
"""Uri must be a string literal.
|If it's unknown in compile-time, use `Uuid.from` or `Uuid.unsafeFrom` instead.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import java.util.UUID
* @since 2023-04-25
*/
object stringsSpec extends Properties {
override def tests: List[Test] = NonEmptyStringSpec.tests ++ UuidSpec.tests
override def tests: List[Test] = NonEmptyStringSpec.tests ++ NonBlankStringSpec.tests ++ UuidSpec.tests

object NonEmptyStringSpec {
import all.NonEmptyString
Expand Down Expand Up @@ -131,6 +131,233 @@ object stringsSpec extends Properties {

}

object NonBlankStringSpec {
import all.NonBlankString

def tests: List[Test] = List(
example("test NonBlankString.apply", testApply),
example("test NonBlankString.apply (2)", testApply),
example("test NonBlankString.apply with invalid", testApply),
property("test NonBlankString.from(valid)", testFromValid),
property("test NonBlankString.from(invalid)", testFromInvalid),
property("test NonBlankString.unsafeFrom(valid)", testUnsafeFromValid),
property("test NonBlankString.unsafeFrom(invalid)", testUnsafeFromInvalid),
property("test NonBlankString.value", testValue),
property("test NonBlankString.unapply", testUnapplyWithPatternMatching),
property("test NonBlankString ++ NonBlankString", testNonBlankStringPlusNonBlankString),
property("test NonBlankString.prependString(String)", testNonBlankStringPrependStringString),
property("test NonBlankString.appendString(String)", testNonBlankStringAppendStringString),
property("test Ordering[NonBlankString]", testOrdering),
property("test Ordered[NonBlankString]", testOrdered),
)

def testApply: Result = {
/* The actual test is whether this compiles or not actual ==== expected is meaningless here */
val expected = NonBlankString("blah")
val actual = NonBlankString("blah")
actual ==== expected
}

def testApply2: Result = {

import scala.compiletime.testing.typeChecks

val shouldCompile1 = typeChecks(
"""
import strings.*
NonBlankString("blah")
"""
)

Result.assert(shouldCompile1)
}

def testApplyWithInvalid: Result = {

import scala.compiletime.testing.typeChecks

val shouldFail1 = typeChecks(
"""
import strings.*
NonBlankString("")
"""
)

val shouldFail2 = typeChecks(
"""
import strings.*
NonBlankString(" ")
"""
)

val shouldFail3 = typeChecks(
"""
import strings.*
NonBlankString("\n")
"""
)

val shouldFail4 = typeChecks(
"""
import strings.*
NonBlankString("\t")
"""
)

Result.all(
List(
(shouldFail1 ==== false).log("Compilation should have been failed but it didn't for NonBlankString(\"\")"),
(shouldFail2 ==== false).log("Compilation should have been failed but it didn't for NonBlankString(\" \")"),
(shouldFail3 ==== false).log("Compilation should have been failed but it didn't for NonBlankString(\"\\n\")"),
(shouldFail4 ==== false).log("Compilation should have been failed but it didn't for NonBlankString(\"\\t\")"),
)
)
}

def testFromValid: Property =
for {
nonWhitespaceString <- Gen.string(hedgehog.extra.Gens.genNonWhitespaceChar, Range.linear(1, 10)).log("nonWhitespaceString")
whitespaceString <-
Gen.string(hedgehog.extra.Gens.genCharByRange(strings.WhitespaceCharRange), Range.linear(1, 10)).log("whitespaceString")
s <- Gen.constant(scala.util.Random.shuffle((nonWhitespaceString + whitespaceString).toList).mkString).log("s")
} yield {
val expected = NonBlankString.unsafeFrom(s)
val actual = NonBlankString.from(s)
actual ==== Right(expected)
}

def testFromInvalid: Property =
for {
s <-
Gen
.frequency1(
5 -> Gen.constant(""),
95 -> Gen.string(hedgehog.extra.Gens.genCharByRange(strings.WhitespaceCharRange), Range.linear(1, 10)),
)
.log("s")
} yield {
val expected = s"Invalid value: [$s]. It must be not all whitespace non-empty String"
val actual = NonBlankString.from(s)
actual ==== Left(expected)
}

def testUnsafeFromValid: Property =
for {
nonWhitespaceString <- Gen.string(hedgehog.extra.Gens.genNonWhitespaceChar, Range.linear(1, 10)).log("nonWhitespaceString")
whitespaceString <-
Gen.string(hedgehog.extra.Gens.genCharByRange(strings.WhitespaceCharRange), Range.linear(1, 10)).log("whitespaceString")
s <- Gen.constant(scala.util.Random.shuffle((nonWhitespaceString + whitespaceString).toList).mkString).log("s")
} yield {
val expected = NonBlankString.unsafeFrom(s)
val actual = NonBlankString.unsafeFrom(s)
actual ==== expected
}

def testUnsafeFromInvalid: Property =
for {
s <-
Gen
.frequency1(
5 -> Gen.constant(""),
95 -> Gen.string(hedgehog.extra.Gens.genCharByRange(strings.WhitespaceCharRange), Range.linear(1, 10)),
)
.log("s")
} yield {
val expected = s"Invalid value: [$s]. It must be not all whitespace non-empty String"
try {
NonBlankString.unsafeFrom(s)
Result
.failure
.log("""IllegalArgumentException was expected from NonBlankString.unsafeFrom(""), but it was not thrown.""")
} catch {
case ex: IllegalArgumentException =>
ex.getMessage ==== expected

}
}

def testValue: Property =
for {
s <- Gen.string(Gen.unicode, Range.linear(1, 10)).log("s")
} yield {
val expected = s
val actual = NonBlankString.unsafeFrom(s)
actual.value ==== expected
}

def testUnapplyWithPatternMatching: Property =
for {
s <- Gen.string(Gen.unicode, Range.linear(1, 10)).log("s")
} yield {
val expected = s
val nes = NonBlankString.unsafeFrom(s)
nes match {
case NonBlankString(actual) =>
actual ==== expected
}
}

def testNonBlankStringPlusNonBlankString: Property =
for {
s1 <- Gen.string(hedgehog.extra.Gens.genNonWhitespaceChar, Range.linear(1, 10)).log("s1")
s2 <- Gen.string(hedgehog.extra.Gens.genNonWhitespaceChar, Range.linear(1, 10)).log("s2")

expected <- Gen.constant(s1 + s2).map(NonBlankString.unsafeFrom).log("expected")
} yield {
val nbs1 = NonBlankString.unsafeFrom(s1)
val nbs2 = NonBlankString.unsafeFrom(s2)
val actual = nbs1 ++ nbs2
actual ==== expected
}

def testNonBlankStringPrependStringString: Property =
for {
s1 <- Gen.string(Gen.unicode, Range.linear(1, 3)).log("s1")
s2 <- Gen.string(hedgehog.extra.Gens.genNonWhitespaceChar, Range.linear(1, 10)).log("s2")

expected <- Gen.constant(s1 + s2).map(NonBlankString.unsafeFrom).log("expected")
} yield {
val nbs = NonBlankString.unsafeFrom(s2)
val actual = nbs.prependString(s1)
actual ==== expected
}

def testNonBlankStringAppendStringString: Property =
for {
s1 <- Gen.string(hedgehog.extra.Gens.genNonWhitespaceChar, Range.linear(1, 10)).log("s1")
s2 <- Gen.string(Gen.unicode, Range.linear(1, 3)).log("s2")

expected <- Gen.constant(s1 + s2).map(NonBlankString.unsafeFrom).log("expected")
} yield {
val nbs1 = NonBlankString.unsafeFrom(s1)
val actual = nbs1.appendString(s2)
actual ==== expected
}

def testOrdering: Property =
for {
s1 <- Gen.string(Gen.alphaNum, Range.linear(1, 10)).log("s1")
s2 <- Gen.string(Gen.alphaNum, Range.linear(1, 10)).log("s2")
} yield {
val input1 = NonBlankString.unsafeFrom(s1)
val input2 = NonBlankString.unsafeFrom(s2)
val expected = s1.compare(s2)
Result.diff(input1, input2)(Ordering[NonBlankString].compare(_, _) == expected)
}

def testOrdered: Property =
for {
s1 <- Gen.string(Gen.alphaNum, Range.linear(1, 10)).log("s1")
s2 <- Gen.string(Gen.alphaNum, Range.linear(1, 10)).log("s2")
} yield {
val input1 = NonBlankString.unsafeFrom(s1)
val input2 = NonBlankString.unsafeFrom(s2)
val expected = s1.compare(s2)
Result.diff(input1: Ordered[NonBlankString], input2: NonBlankString)(_.compare(_) == expected)
}

}

@SuppressWarnings(Array("org.wartremover.warts.ToString"))
object UuidSpec {

Expand Down

0 comments on commit 3c9cd3d

Please sign in to comment.