One possibility is for you to write a small parser that translates those snippets into an expression that you can evaluate at runtime.
First you would need to define the AST.
sealed trait AST
final case class Identifier(name: String) extends AST
final case class Number(value: Double) extends AST
final case class Bool(value: Boolean) extends AST
final case class Comparison(left: AST, op: String, right: AST) extends AST
final case class Logical(left: AST, op: String, right: AST) extends AST
Then you can use fastparse to define the parsing logic
def number[$: P]: P[Number] = P(CharsWhileIn("0-9.").!).map(s => Number(s.toDouble))
def boolean[$: P]: P[Bool] = P("true" | "false").!.map(s => Bool(s.toBoolean))
def identifier[$: P]: P[Identifier] = P(CharsWhileIn("a-zA-Z_").!).map(Identifier.apply)
def factor[$: P]: P[AST] = P(number | boolean | identifier)
def comparisonOp[$: P]: P[String] = P(">" | "<" | ">=" | "<=" | "==" | "!=").!
def comparison[$: P]: P[Comparison] =
P(factor ~ " ".rep(1) ~ comparisonOp ~ " ".rep(1) ~ factor).map {
case (left, op, right) => Comparison(left, op, right)
}
def logicalOp[$: P]: P[String] = P("and" | "or").!
def logicalExpr[$: P]: P[AST] =
P(comparison ~ (" ".rep(1) ~ logicalOp ~ " ".rep(1) ~ logicalExpr).?).map {
case (left, None) => left
case (left, Some((op, right))) => Logical(left, op, right)
}
def parseExpr(input: String): Parsed[AST] = {
parse(input, logicalExpr(_))
}
You can use the output of the parser to evaluate the expression, passing your Example as the environment in which it operates.
def eval(ast: AST, context: Example): AST = ast match {
case Identifier(name) =>
name match {
case "a" => Number(context.a)
case "b" => Number(context.b)
case _ => throw new IllegalArgumentException(s"Undefined variable: $name")
}
case Comparison(left, op, right) =>
lazy val lhs = eval(left, context).asInstanceOf[Number].value
lazy val rhs = eval(right, context).asInstanceOf[Number].value
val result = op match {
case ">" => lhs > rhs
case "<" => lhs < rhs
case ">=" => lhs >= rhs
case "<=" => lhs <= rhs
case "==" => lhs == rhs
case "!=" => lhs != rhs
case _ => throw new IllegalArgumentException(s"Unknown operator: $op")
}
Bool(result)
case Logical(left, op, right) =>
lazy val lhs = eval(left, context).asInstanceOf[Bool].value
lazy val rhs = eval(right, context).asInstanceOf[Bool].value
val result = op match {
case "and" => lhs && rhs
case "or" => lhs || rhs
case _ => throw new IllegalArgumentException(s"Unknown operator: $op")
}
Bool(result)
case literal => literal // literal, doesn't need further evaluation
}
Finally, you can put everything together by parsing the expression and giving it to the evaluation function:
parseExpr("a > 3.7 and b < 67.2") match {
case Parsed.Success(ast, _) =>
try {
assert(eval(ast, Example(a = 5.0, b = 60.0)) == Bool(true))
assert(eval(ast, Example(a = 5.0, b = 68.0)) == Bool(false))
} catch {
case e =>
println(s"Evaluation error: ${e.getMessage}")
}
case f: Parsed.Failure =>
println("Parsing failed.")
println(f.longMsg)
}
This is a very barebones implementation which skips some important bits, like properly handling runtime errors (such as type errors, which with this toy implementation will pop up as ClassCastExceptions). Furthermore there's plenty of improvements that can be made to the overall implementation, this is mostly meant as a starting point.
You can play around with this code here on Scastie.
For more info on fastparse, here are the docs.
scala-parser-combinatorsyou might want to have a look at this tutorial for some inspiration: enear.github.io/2016/03/31/parser-combinators Your case looks like a subset of what's in that tutorial.