From f298623f0341eab5a128034a8dd51b5c6cdacd37 Mon Sep 17 00:00:00 2001 From: Philipp Ossler Date: Thu, 14 Aug 2025 15:45:26 +0200 Subject: [PATCH] feat: String templates Add support for string templates. Align with the Feelers JS library. --- .../impl/interpreter/FeelInterpreter.scala | 12 ++ .../camunda/feel/impl/parser/FeelParser.scala | 65 +++++++++- .../org/camunda/feel/syntaxtree/Exp.scala | 2 + .../InterpreterListExpressionTest.scala | 2 +- .../interpreter/TemplateExpressionTest.scala | 114 ++++++++++++++++++ 5 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 src/test/scala/org/camunda/feel/impl/interpreter/TemplateExpressionTest.scala diff --git a/src/main/scala/org/camunda/feel/impl/interpreter/FeelInterpreter.scala b/src/main/scala/org/camunda/feel/impl/interpreter/FeelInterpreter.scala index 155b41f3a..4a64a0044 100644 --- a/src/main/scala/org/camunda/feel/impl/interpreter/FeelInterpreter.scala +++ b/src/main/scala/org/camunda/feel/impl/interpreter/FeelInterpreter.scala @@ -80,6 +80,18 @@ class FeelInterpreter(private val valueMapper: ValueMapper) { case range: ConstRange => toRange(range) + case Template(parts) => + ValString( + // evaluate all parts and concatenate them + parts + .map(eval) + .map { + case ValString(value) => value + case other => other.toString + } + .mkString + ) + // simple unary tests case InputEqualTo(x) => withVal(getImplicitInputValue, i => checkEquality(i, eval(x))) diff --git a/src/main/scala/org/camunda/feel/impl/parser/FeelParser.scala b/src/main/scala/org/camunda/feel/impl/parser/FeelParser.scala index 4086fa208..7ffd38d7c 100644 --- a/src/main/scala/org/camunda/feel/impl/parser/FeelParser.scala +++ b/src/main/scala/org/camunda/feel/impl/parser/FeelParser.scala @@ -52,6 +52,7 @@ import org.camunda.feel.syntaxtree.{ ConstNull, ConstNumber, ConstRange, + ConstRangeBoundary, ConstString, ConstTime, ConstYearMonthDuration, @@ -88,20 +89,20 @@ import org.camunda.feel.syntaxtree.{ PathExpression, PositionalFunctionParameters, QualifiedFunctionInvocation, - ConstRangeBoundary, Ref, SomeItem, Subtraction, + Template, UnaryTestExpression } import org.camunda.feel.{ Date, + isDayTimeDuration, + isLocalDateTime, isOffsetDateTime, isOffsetTime, isValidDate, isYearMonthDuration, - isLocalDateTime, - isDayTimeDuration, stringToDate, stringToDateTime, stringToDayTimeDuration, @@ -375,7 +376,7 @@ object FeelParser { terminalValue.flatMap(optional(chainedValueOp(_))) private def terminalValue[_: P]: P[Exp] = - temporal | functionInvocation | variableRef | literal | inputValue | functionDefinition | "(" ~ expression ~ ")" + temporal | functionInvocation | variableRef | literal | inputValue | functionDefinition | "(" ~ expression ~ ")" | template private def literal[_: P]: P[Exp] = nullLiteral | boolean | string | number | temporal | list | context @@ -561,6 +562,62 @@ object FeelParser { ("[" ~ expression ~ "]").rep(1) ).map(ops => ops.foldLeft(base)(Filter)) + // --------------- template expressions --------------- + + private def template[_: P]: P[Exp] = P( + "```" ~~/ templateContent ~~/ "```" + ) + + private def templateContent[_: P]: P[Exp] = P( + templateStringPart.? ~~ ( + (templatePlaceholder | templateCondition | templateLoop) ~~ templateStringPart.? + ).repX(0) + ).map { case (prefix, sequence) => + val start: Seq[Exp] = prefix.map(Seq(_)).getOrElse(Seq.empty) + val parts = sequence.foldLeft(start) { + case (acc, (placeholder, None)) => acc :+ placeholder + case (acc, (placeholder, Some(suffix))) => acc :++ Seq(placeholder, suffix) + } + Template(parts) + } + + private def templateStringPart[_: P]: P[Exp] = P( + (!("{{" | "```") ~~ AnyChar).repX(1).! + ).map(translateEscapes).map(ConstString) + + private def templatePlaceholder[_: P]: P[Exp] = P( + "{{" ~~ !("#" | "/") ~/ expression ~ "}}" + ).map(exp => + FunctionInvocation( + function = "string", + params = PositionalFunctionParameters(List(exp)) + ) + ) + + private def templateCondition[_: P]: P[Exp] = P( + "{{#if" ~/ expression ~ "}}" ~~/ templateContent ~~ "{{/if}}" + ).map { case (condition, content) => + If( + condition = condition, + statement = content, + elseStatement = ConstString("") + ) + } + + private def templateLoop[_: P]: P[Exp] = P( + "{{#loop" ~/ expression ~ "}}" ~~/ templateContent ~~ "{{/loop}}" + ).map { case (iterationContext, content) => + val loop = For( + iterators = List(("this", iterationContext)), + exp = content + ) + + FunctionInvocation( + function = "string join", + params = PositionalFunctionParameters(List(loop)) + ) + } + // --------------- unary-tests expressions --------------- private def unaryTests[_: P]: P[Exp] = diff --git a/src/main/scala/org/camunda/feel/syntaxtree/Exp.scala b/src/main/scala/org/camunda/feel/syntaxtree/Exp.scala index 7cc7cff88..f2a9cd5a2 100644 --- a/src/main/scala/org/camunda/feel/syntaxtree/Exp.scala +++ b/src/main/scala/org/camunda/feel/syntaxtree/Exp.scala @@ -144,3 +144,5 @@ case class Filter(list: Exp, filter: Exp) extends Exp case class IterationContext(start: Exp, end: Exp) extends Exp case class UnaryTestExpression(exp: Exp) extends Exp + +case class Template(parts: Seq[Exp]) extends Exp diff --git a/src/test/scala/org/camunda/feel/impl/interpreter/InterpreterListExpressionTest.scala b/src/test/scala/org/camunda/feel/impl/interpreter/InterpreterListExpressionTest.scala index 092227c66..e461f7af5 100644 --- a/src/test/scala/org/camunda/feel/impl/interpreter/InterpreterListExpressionTest.scala +++ b/src/test/scala/org/camunda/feel/impl/interpreter/InterpreterListExpressionTest.scala @@ -514,7 +514,7 @@ class InterpreterListExpressionTest it should "compute a long list" in { evaluateExpression( - """count(for x in 0..1000000 return "Hi there")""", + """count(for x in 0..1000000 return "Hi there")""" ) should returnResult(1000001) } } diff --git a/src/test/scala/org/camunda/feel/impl/interpreter/TemplateExpressionTest.scala b/src/test/scala/org/camunda/feel/impl/interpreter/TemplateExpressionTest.scala new file mode 100644 index 000000000..523a387ec --- /dev/null +++ b/src/test/scala/org/camunda/feel/impl/interpreter/TemplateExpressionTest.scala @@ -0,0 +1,114 @@ +/* + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. Camunda licenses this file to you under the Apache License, + * Version 2.0; you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.camunda.feel.impl.interpreter + +import org.camunda.feel.impl.{EvaluationResultMatchers, FeelEngineTest} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks + +class TemplateExpressionTest + extends AnyFlatSpec + with Matchers + with FeelEngineTest + with EvaluationResultMatchers { + + "A template" should "be a simple string" in { + + evaluateExpression("```Hello friend!```") should returnResult("Hello friend!") + } + + it should "contain a variable reference" in { + + evaluateExpression( + expression = "```Hello {{name}}```", + variables = Map("name" -> "Zee") + ) should returnResult("Hello Zee") + + evaluateExpression( + expression = "```Hello {{name}}, nice to meet you.```", + variables = Map("name" -> "Zee") + ) should returnResult("Hello Zee, nice to meet you.") + } + + it should "contain an expression" in { + + evaluateExpression( + expression = """```Good {{if hour < 12 then "morning" else "afternoon"}}```""", + variables = Map("hour" -> 9) + ) should returnResult("Good morning") + + evaluateExpression( + expression = """```Good {{if hour < 12 then "morning" else "afternoon"}}```""", + variables = Map("hour" -> 15) + ) should returnResult("Good afternoon") + } + + it should "insert the value as a string" in { + + evaluateExpression( + expression = """```Value: {{value}}```""", + variables = Map("value" -> "FEEL") + ) should returnResult("Value: FEEL") + + evaluateExpression( + expression = """```Value: {{value}}```""", + variables = Map("value" -> 123) + ) should returnResult("Value: 123") + + evaluateExpression( + expression = """```Value: {{value}}```""", + variables = Map("value" -> true) + ) should returnResult("Value: true") + + evaluateExpression( + expression = """```Value: {{value}}```""", + variables = Map("value" -> List(1, 2, 3)) + ) should returnResult("Value: [1, 2, 3]") + + evaluateExpression( + expression = """```Value: {{value}}```""", + variables = Map("value" -> Map("a" -> 1, "b" -> 2)) + ) should returnResult("Value: {a:1, b:2}") + } + + it should "contain an conditional section" in { + + evaluateExpression( + expression = """```Hello{{#if name = "Zee"}} my friend{{/if}}!```""", + variables = Map("name" -> "Zee") + ) should returnResult("Hello my friend!") + + evaluateExpression( + expression = """```Hello{{#if name = "Zee"}} my friend{{/if}}!```""", + variables = Map("name" -> "Joe") + ) should returnResult("Hello!") + } + + it should "contain a loop section" in { + + evaluateExpression( + expression = """```Items:\n{{#loop items}}- {{this}}\n{{/loop}}```""", + variables = Map("items" -> List("a", "b", "c")) + ) should returnResult("""Items: + |- a + |- b + |- c + |""".stripMargin) + } + +}