Skip to content

Commit

Permalink
Add Expr.RegexMatches, DSL method, and unit tests (#98)
Browse files Browse the repository at this point in the history
- Add support for .matchesRegex, .findFirstMatch, and .findAllMatches
- Add unit tests for one, multiple, and zero RegexMatches
- Add documentation
  • Loading branch information
jeffmay authored Mar 2, 2022
1 parent 194bf0f commit cbbb343
Show file tree
Hide file tree
Showing 15 changed files with 506 additions and 8 deletions.
32 changes: 30 additions & 2 deletions core-v1/src/main/scala/algebra/Expr.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import cats.{Applicative, FlatMap, Foldable, Functor, SemigroupK, Traverse}
import shapeless.{::, HList, HNil, Typeable}

import scala.annotation.nowarn
import scala.reflect.ClassTag
import scala.util.matching.Regex

/**
* The root trait of all expression nodes.
Expand Down Expand Up @@ -346,6 +346,8 @@ object Expr {
opO: OP[W[B]],
): I ~:> W[B]

def visitRegexMatches[I, S, O : OP](expr: RegexMatches[I, S, O, OP]): I ~:> O

def visitRepeat[I, O](expr: Repeat[I, O, OP])(implicit opO: OP[IterableOnce[O]]): I ~:> IterableOnce[O]

def visitSelect[I, A, B, O : OP](expr: Select[I, A, B, O, OP]): I ~:> O
Expand Down Expand Up @@ -534,6 +536,9 @@ object Expr {
opO: OP[W[B]],
): H[I, W[B]] = proxy(underlying.visitOr(expr))

override def visitRegexMatches[I, S, O : OP](expr: RegexMatches[I, S, O, OP]): H[I, O] =
proxy(underlying.visitRegexMatches(expr))

override def visitRepeat[I, O](expr: Repeat[I, O, OP])(implicit opO: OP[IterableOnce[O]]): H[I, IterableOnce[O]] =
proxy(underlying.visitRepeat(expr))

Expand Down Expand Up @@ -1321,12 +1326,35 @@ object Expr {
)(implicit
ev: S <:< I,
opO: OP[Option[O]],
) extends Expr[I, Option[O], OP]("matching") {
) extends Expr[I, Option[O], OP]("match") {
override def visit[G[-_, +_]](v: Visitor[G, OP]): G[I, Option[O]] = v.visitMatch(this)
override private[v1] def withDebugging(debugging: Debugging[Nothing, Nothing]): Match[I, S, B, O, OP] =
copy(debugging = debugging)
}

/**
* Attempts to match the input [[asString]] result against a [[Regex]] and applies the [[asOutput]] function.
*
* @note the matches are received lazily, so if you only need the first match, you can efficiently grab the first
* match and discard the rest.
*
* @param inputExpr an expression producing a stringify-able value
* @param asString a function to convert the input to a string
* @param regex a pre-compiled regular expression
* @param asOutput a function to convert the matched groups to an expected output type (as determined by the DSL
*/
final case class RegexMatches[-I, S : OP, +O : OP, OP[_]](
inputExpr: Expr[I, S, OP],
asString: S => String,
regex: Regex,
asOutput: (S, LazyList[RegexMatch]) => O,
override private[v1] val debugging: Debugging[Nothing, Nothing] = NoDebugging,
) extends Expr[I, O, OP]("matchesRegex") {
override def visit[G[-_, +_]](v: Visitor[G, OP]): G[I, O] = v.visitRegexMatches(this)
override private[v1] def withDebugging(debugging: Debugging[Nothing, Nothing]): RegexMatches[I, S, O, OP] =
copy(debugging = debugging)
}

/**
* A container for a condition and an expression to compute if the condition is met.
*
Expand Down
24 changes: 23 additions & 1 deletion core-v1/src/main/scala/data/Justified.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.rallyhealth.vapors.v1
package data

import algebra.{EqualComparable, SizeComparable, SizeComparison}
import dsl.{WrapConst, WrapContained, WrapFact, WrapQuantifier, WrapSelected}
import dsl.{WrapConst, WrapContained, WrapFact, WrapQuantifier, WrapRegexMatches, WrapSelected}
import lens.{DataPath, VariantLens}
import logic.Logic
import math._
Expand All @@ -15,6 +15,7 @@ import cats.{Applicative, Eq, Eval, Foldable, Functor, Order, Semigroupal, Trave

import scala.annotation.nowarn
import scala.collection.Factory
import scala.util.matching.Regex

/**
* Represents a result that contains a tree of justified inputs and operations, as well as the value
Expand Down Expand Up @@ -306,6 +307,27 @@ object Justified extends LowPriorityJustifiedImplicits {
}
}

implicit def wrapRegexMatches[OP[_]]: WrapRegexMatches[Justified, OP] =
WrapRegexMatchesJustified.asInstanceOf[WrapRegexMatches[Justified, OP]]

private final object WrapRegexMatchesJustified extends WrapRegexMatches[Justified, Any] {

override def wrapMatched[O](
in: Justified[String],
out: O,
pattern: Regex,
matches: LazyList[RegexMatch],
): Justified[O] =
Justified.byInference(
"regex_matches",
out,
NonEmptySeq.of(
in,
Justified.byConst(pattern),
),
)
}

implicit def wrapSelected[OP[_]]: WrapSelected[Justified, OP] =
WrapSelectedJustified.asInstanceOf[WrapSelected[Justified, OP]]

Expand Down
35 changes: 35 additions & 0 deletions core-v1/src/main/scala/data/RegexMatch.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.rallyhealth.vapors.v1

package data

import scala.util.matching.Regex

/**
* Represents the data provided from a regular expression matched within some input string.
*
* @see [[Regex.Match]]
*
* @param matched the string that was matched in the original input
* @param slice the slice range of the matched string
* @param groups a map of group names AND group indexes to their matched strings
*/
final case class RegexMatch(
matched: String,
slice: SliceRange.Absolute,
groups: Map[String, String],
)

object RegexMatch {

def unapply(m: RegexMatch): Some[(String, Map[String, String])] = Some((m.matched, m.groups))
def unapply(m: Regex.Match): Some[(String, Map[String, String])] = unapply(from(m))

def from(m: Regex.Match): RegexMatch =
RegexMatch(
m.matched,
SliceRange.Absolute(m.start, m.end),
(1 to m.groupCount).map(_.toString).zip(m.subgroups).toMap.withDefault { k =>
Option(m.group(k)).getOrElse("")
},
)
}
17 changes: 15 additions & 2 deletions core-v1/src/main/scala/data/SliceRange.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ object SliceRange {
relativeStart => if (relativeStart < 0) size + relativeStart else relativeStart,
relativeEnd => if (relativeEnd < 0) size + relativeEnd else relativeEnd,
)
Absolute(this, relative.left.getOrElse(0), relative.right.getOrElse(size) - 1)
Absolute(relative.left.getOrElse(0), relative.right.getOrElse(size) - 1, this)
}
}

Expand All @@ -54,16 +54,29 @@ object SliceRange {
* This could contain the total size as well, but I didn't see a need for it yet.
*/
final case class Absolute private[SliceRange] (
relative: Relative,
start: Int,
end: Int,
relative: Relative,
) {

def this(
start: Int,
end: Int,
) = this(start, end, Relative(Ior.Both(start, end)))

val toRange: Range = Range.inclusive(start, end)

def contains(index: Int): Boolean = toRange.contains(index)
}

object Absolute {

def apply(
start: Int,
end: Int,
): Absolute = new Absolute(start, end)
}

final class Syntax(private val num: Int) extends AnyVal {

def fromEnd: Relative = Relative.fromEnd(num)
Expand Down
15 changes: 13 additions & 2 deletions core-v1/src/main/scala/debug/DebugArgs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import lens.VariantLens

import cats.Eval
import cats.data.{NonEmptySeq, NonEmptyVector}
import com.rallyhealth.vapors.v1.algebra.Expr.MatchCase
import izumi.reflect.Tag
import shapeless.{unexpected, HList}
import shapeless.HList

import scala.reflect.ClassTag
import scala.util.matching.Regex

/**
* A type-level programming trick that enlists the compiler to prove that the [[Debugging]] hook function
Expand Down Expand Up @@ -312,6 +312,17 @@ object DebugArgs {
override type Out = Option[O]
}

implicit def debugRegexMatches[
I,
S,
O,
OP[_],
]: Aux[Expr.RegexMatches[I, S, O, OP], OP, (I, S, Regex, LazyList[RegexMatch]), O] =
new DebugArgs[Expr.RegexMatches[I, S, O, OP], OP] {
override type In = (I, S, Regex, LazyList[RegexMatch])
override type Out = O
}

implicit def debugWhen[I, B, O, OP[_]]: Aux[Expr.When[I, B, O, OP], OP, (I, Int), O] =
new DebugArgs[Expr.When[I, B, O, OP], OP] {
override type In = (I, Int)
Expand Down
63 changes: 62 additions & 1 deletion core-v1/src/main/scala/dsl/BuildExprDsl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ package dsl
import algebra.Expr.MatchCase
import algebra._
import data._
import lens.{CollectInto, IterableInto, VariantLens}
import lens.{CollectInto, DataPath, IterableInto, VariantLens}
import logic.{Conjunction, Disjunction, Logic, Negation}
import math.{Add, Power}

Expand All @@ -14,6 +14,8 @@ import cats.{Applicative, FlatMap, Foldable, Functor, Id, Order, Reducible, Semi
import izumi.reflect.Tag
import shapeless.{Generic, HList, Typeable}

import scala.util.matching.Regex

trait BuildExprDsl
extends DebugExprDsl
with SliceRangeSyntax
Expand All @@ -37,6 +39,8 @@ trait BuildExprDsl

protected implicit def wrapContained: WrapContained[W, OP]

protected implicit def wrapRegexMatches: WrapRegexMatches[W, OP]

protected implicit def wrapSelected: WrapSelected[W, OP]

def ident[I](implicit opI: OP[W[I]]): Expr.Identity[W[I], OP]
Expand Down Expand Up @@ -175,6 +179,63 @@ You should prefer put your declaration of dependency on definitions close to whe
): Expr.Sequence[C, I, O, OP] =
Expr.Sequence(expressions)

implicit def str[I](expr: I ~:> W[String]): StringExprOps[I]

abstract class StringExprOps[-I](inputExpr: I ~:> W[String]) {

def matchesRegex(
re: Regex,
)(implicit
opS: OP[W[String]],
opB: OP[W[Boolean]],
): Expr.RegexMatches[I, W[String], W[Boolean], OP] =
Expr.RegexMatches(
inputExpr,
extract.extract(_),
re,
(in, ms) => wrapRegexMatches.wrapMatched(in, ms.nonEmpty, re, ms),
)

def findFirstMatch(
re: Regex,
)(implicit
opS: OP[W[String]],
opM: OP[RegexMatch],
opO: OP[Option[W[RegexMatch]]],
): Expr.RegexMatches[I, W[String], Option[W[RegexMatch]], OP] =
Expr.RegexMatches[I, W[String], Option[W[RegexMatch]], OP](
inputExpr,
extract.extract(_),
re,
(in, ms) => {
val wrappedMatches = wrapRegexMatches.wrapMatched(in, ms, re, ms)
ms.headOption.map { m =>
wrapSelected.wrapSelected(wrappedMatches, DataPath.empty.atIndex(0), m)
}
},
)

def findAllMatches(
re: Regex,
)(implicit
opS: OP[W[String]],
opM: OP[RegexMatch],
opO: OP[Seq[W[RegexMatch]]],
): Expr.RegexMatches[I, W[String], Seq[W[RegexMatch]], OP] =
Expr.RegexMatches[I, W[String], Seq[W[RegexMatch]], OP](
inputExpr,
extract.extract(_),
re,
(in, ms) => {
val wrappedMatches = wrapRegexMatches.wrapMatched(in, ms, re, ms)
ms.zipWithIndex.map {
case (m, idx) =>
wrapSelected.wrapSelected(wrappedMatches, DataPath.empty.atIndex(idx), m)
}
},
)
}

def when[I](condExpr: I ~:> W[Boolean]): WhenBuilder[I, W[Boolean]]

abstract class WhenBuilder[-I, B : ExtractValue.AsBoolean](firstCondExpr: I ~:> B) {
Expand Down
2 changes: 2 additions & 0 deletions core-v1/src/main/scala/dsl/JustifiedBuildExprDsl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ trait JustifiedBuildExprDsl

override protected implicit final def wrapFact: WrapFact[Justified, OP] = Justified.wrapFact

override protected implicit final def wrapRegexMatches: WrapRegexMatches[Justified, OP] = Justified.wrapRegexMatches

override protected implicit final def wrapSelected: WrapSelected[Justified, OP] = Justified.wrapSelected

override protected implicit final def wrapQuantifier: WrapQuantifier[Justified, OP] = Justified.wrapQuantifier
Expand Down
33 changes: 33 additions & 0 deletions core-v1/src/main/scala/dsl/UnwrappedBuildExprDsl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import cats.{FlatMap, Foldable, Functor, Id, Order, Reducible, Traverse}
import izumi.reflect.Tag
import shapeless.{Generic, HList, Nat, Typeable}

import scala.util.matching.Regex

trait UnwrappedBuildExprDsl
extends BuildExprDsl
with UnwrappedUsingDefinitionArityMethods
Expand All @@ -31,6 +33,8 @@ trait UnwrappedBuildExprDsl

override protected implicit final def wrapContained: WrapContained[W, OP] = WrapContained.unwrapped

override protected implicit final def wrapRegexMatches: WrapRegexMatches[W, OP] = WrapRegexMatches.unwrapped

override protected implicit final def wrapSelected: WrapSelected[W, OP] = WrapSelected.unwrapped

// TODO: Should this be visible outside this trait?
Expand Down Expand Up @@ -127,6 +131,35 @@ trait UnwrappedBuildExprDsl
): CombineHolder[I, L, L, R, R, pow.Out, OP] =
(leftExpr ^ rightExpr)(opR, pow)

override implicit final def str[I](expr: I ~:> String): UnwrappedStringExprOps[I] = new UnwrappedStringExprOps(expr)

final class UnwrappedStringExprOps[-I](inputExpr: I ~:> String) extends StringExprOps(inputExpr) {

override def matchesRegex(
re: Regex,
)(implicit
opS: OP[String],
opB: OP[Boolean],
): Expr.RegexMatches[I, String, Boolean, OP] =
super.matchesRegex(re)

override def findFirstMatch(
re: Regex,
)(implicit
opS: OP[String],
opM: OP[RegexMatch],
opO: OP[Option[RegexMatch]],
): Expr.RegexMatches[I, String, Option[RegexMatch], OP] = super.findFirstMatch(re)

override def findAllMatches(
re: Regex,
)(implicit
opS: OP[String],
opM: OP[RegexMatch],
opO: OP[Seq[RegexMatch]],
): Expr.RegexMatches[I, String, Seq[RegexMatch], OP] = super.findAllMatches(re)
}

override final def when[I](condExpr: I ~:> Boolean): UnwrappedWhenBuilder[I] = new UnwrappedWhenBuilder(condExpr)

final class UnwrappedWhenBuilder[-I](condExpr: I ~:> Boolean) extends WhenBuilder[I, Boolean](condExpr) {
Expand Down
Loading

0 comments on commit cbbb343

Please sign in to comment.