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 de55b943d..b9c3b2254 100644 --- a/src/main/scala/org/camunda/feel/impl/parser/FeelParser.scala +++ b/src/main/scala/org/camunda/feel/impl/parser/FeelParser.scala @@ -16,7 +16,7 @@ */ package org.camunda.feel.impl.parser -import fastparse.JavaWhitespace.whitespace +import org.camunda.feel.impl.parser.FeelWhitespace.whitespace import fastparse.{ AnyChar, ByNameOps, @@ -167,7 +167,7 @@ object FeelParser { private def namePart[_: P]: P[String] = P( - CharsWhile(Character.isJavaIdentifierPart, 1) + CharsWhile(c => Character.isJavaIdentifierPart(c) && !FeelWhitespace.isWhitespace(c), 1) ).! // an identifier wrapped in backticks. it can contain any char (e.g. `a b`, `a+b`). diff --git a/src/main/scala/org/camunda/feel/impl/parser/FeelWhitespace.scala b/src/main/scala/org/camunda/feel/impl/parser/FeelWhitespace.scala new file mode 100644 index 000000000..52269fb5f --- /dev/null +++ b/src/main/scala/org/camunda/feel/impl/parser/FeelWhitespace.scala @@ -0,0 +1,92 @@ +/* + * 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.parser + +import fastparse.ParsingRun +import fastparse.internal.Util + +import scala.annotation.{switch, tailrec} + +/** Whitespace syntax for FEEL. + * + *
This is a copy of [[fastparse.JavaWhitespace]] with adjustments for additional space + * characters that are listed in the DMN 1.5 standard (chapter 10.3.1.2, page 103, grammar rules + * 61+62). + */ +object FeelWhitespace { + + /** Checks if the given character is a whitespace according to FEEL. + * + * @param char + * the character to check + * @return + * true if the given character is a FEEL whitespace + */ + def isWhitespace(char: Char): Boolean = char match { + case '\u0009' | '\u0020' | '\u0085' | '\u00A0' | '\u1680' | '\u180E' | '\u2000' | '\u2001' | + '\u2002' | '\u2003' | '\u2004' | '\u2005' | '\u2006' | '\u2007' | '\u2008' | '\u2009' | + '\u200A' | '\u200B' | '\u2028' | '\u2029' | '\u202F' | '\u205F' | '\u3000' | '\uFEFF' | + '\u000A' | '\u000B' | '\u000C' | '\u000D' => + true + case _ => false + } + + implicit val whitespace = { implicit ctx: ParsingRun[_] => + val input = ctx.input + val startIndex = ctx.index + @tailrec def rec(current: Int, state: Int): ParsingRun[Unit] = { + if (!input.isReachable(current)) { + if (state == 0 || state == 1) ctx.freshSuccessUnit(current) + else if (state == 2) ctx.freshSuccessUnit(current - 1) + else { + ctx.cut = true + val res = ctx.freshFailure(current) + if (ctx.verboseFailures) ctx.setMsg(startIndex, () => Util.literalize("*/")) + res + } + } else { + val currentChar = input(current) + (state: @switch) match { + case 0 => + (currentChar: @switch) match { + case ' ' | '\t' | '\n' | '\r' | _ if isWhitespace(currentChar) => + rec(current + 1, state) + case '/' => rec(current + 1, state = 2) + case _ => ctx.freshSuccessUnit(current) + } + case 1 => rec(current + 1, state = if (currentChar == '\n') 0 else state) + case 2 => + (currentChar: @switch) match { + case '/' => rec(current + 1, state = 1) + case '*' => rec(current + 1, state = 3) + case _ => ctx.freshSuccessUnit(current - 1) + } + case 3 => rec(current + 1, state = if (currentChar == '*') 4 else state) + case 4 => + (currentChar: @switch) match { + case '/' => rec(current + 1, state = 0) + case '*' => rec(current + 1, state = 4) + case _ => rec(current + 1, state = 3) + } + // rec(current + 1, state = if (currentChar == '/') 0 else 3) + } + } + } + rec(current = ctx.index, state = 0) + } + +} diff --git a/src/test/scala/org/camunda/feel/impl/interpreter/InterpreterExpressionTest.scala b/src/test/scala/org/camunda/feel/impl/interpreter/InterpreterExpressionTest.scala index 383753854..eb24baf70 100644 --- a/src/test/scala/org/camunda/feel/impl/interpreter/InterpreterExpressionTest.scala +++ b/src/test/scala/org/camunda/feel/impl/interpreter/InterpreterExpressionTest.scala @@ -21,6 +21,7 @@ import org.camunda.feel.impl.{EvaluationResultMatchers, FeelEngineTest} import org.camunda.feel.syntaxtree._ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks /** @author * Philipp Ossler @@ -29,7 +30,8 @@ class InterpreterExpressionTest extends AnyFlatSpec with Matchers with FeelEngineTest - with EvaluationResultMatchers { + with EvaluationResultMatchers + with TableDrivenPropertyChecks { "An expression" should "be an if-then-else (with parentheses)" in { val exp = """ if (x < 5) then "low" else "high" """ @@ -366,4 +368,122 @@ class InterpreterExpressionTest ) } + // FEEL-specific whitespace characters from the DMN specification + private val whitespaceChars = Table( + ("Character", "Char"), + ('\u0009', "\\u0009"), + ('\u0020', "\\u0020"), + ('\u0085', "\\u0085"), + ('\u00A0', "\\u00A0"), + ('\u1680', "\\u1680"), + ('\u180E', "\\u180E"), + ('\u2000', "\\u2000"), + ('\u2001', "\\u2001"), + ('\u2002', "\\u2002"), + ('\u2003', "\\u2003"), + ('\u2004', "\\u2004"), + ('\u2005', "\\u2005"), + ('\u2006', "\\u2006"), + ('\u2007', "\\u2007"), + ('\u2008', "\\u2008"), + ('\u2009', "\\u2009"), + ('\u200A', "\\u200A"), + ('\u200B', "\\u200B"), + ('\u2028', "\\u2028"), + ('\u2029', "\\u2029"), + ('\u202F', "\\u202F"), + ('\u205F', "\\u205F"), + ('\u3000', "\\u3000"), + ('\uFEFF', "\\uFEFF"), + ('\u000A', "\\u000A"), + ('\u000B', "\\u000B"), + ('\u000C', "\\u000C"), + ('\u000D', "\\u000D") + ) + + "A space character" should "be ignored before of a literal" in { + forEvery(whitespaceChars) { (whitespaceChar, _) => + evaluateExpression(s"${whitespaceChar}1") should returnResult(1) + } + } + + it should "be ignored after of a literal" in { + forEvery(whitespaceChars) { (whitespaceChar, _) => + evaluateExpression(s"1${whitespaceChar}") should returnResult(1) + } + } + + it should "be ignored between a math operation" in { + forEvery(whitespaceChars) { (whitespaceChar, _) => + evaluateExpression(s"${whitespaceChar}1+2") should returnResult(3) + evaluateExpression(s"1${whitespaceChar}+2") should returnResult(3) + evaluateExpression(s"1+${whitespaceChar}2") should returnResult(3) + evaluateExpression(s"1+2${whitespaceChar}") should returnResult(3) + } + } + + it should "be ignored between a comparison" in { + forEvery(whitespaceChars) { (whitespaceChar, _) => + evaluateExpression(s"${whitespaceChar}1<2") should returnResult(true) + evaluateExpression(s"1${whitespaceChar}<2") should returnResult(true) + evaluateExpression(s"1<${whitespaceChar}2") should returnResult(true) + evaluateExpression(s"1<2${whitespaceChar}") should returnResult(true) + } + } + + it should "be ignored inside a context literal" in { + forEvery(whitespaceChars) { (whitespaceChar, _) => + evaluateExpression(s"{${whitespaceChar}x:1}") should returnResult(Map("x" -> 1)) + evaluateExpression(s"{x${whitespaceChar}:1}") should returnResult(Map("x" -> 1)) + evaluateExpression(s"{x:${whitespaceChar}1}") should returnResult(Map("x" -> 1)) + evaluateExpression(s"{x:1${whitespaceChar}}") should returnResult(Map("x" -> 1)) + } + } + + it should "be ignored inside a for loop" in { + forEvery(whitespaceChars) { (whitespaceChar, _) => + evaluateExpression(s"for${whitespaceChar}x in [1] return x") should returnResult(List(1)) + evaluateExpression(s"for x${whitespaceChar}in [1] return x") should returnResult(List(1)) + evaluateExpression(s"for x in${whitespaceChar}[1] return x") should returnResult(List(1)) + evaluateExpression(s"for x in [1]${whitespaceChar}return x") should returnResult(List(1)) + evaluateExpression(s"for x in [1] return${whitespaceChar}x") should returnResult(List(1)) + } + } + + it should "be ignored inside a some/every operation" in { + forEvery(whitespaceChars) { (whitespaceChar, _) => + evaluateExpression(s"some${whitespaceChar}x in [1] satisfies odd(x)") should returnResult( + true + ) + evaluateExpression(s"some x${whitespaceChar}in [1] satisfies odd(x)") should returnResult( + true + ) + evaluateExpression(s"some x in${whitespaceChar}[1] satisfies odd(x)") should returnResult( + true + ) + evaluateExpression(s"some x in [1]${whitespaceChar}satisfies odd(x)") should returnResult( + true + ) + evaluateExpression(s"some x in [1] satisfies${whitespaceChar}odd(x)") should returnResult( + true + ) + + evaluateExpression(s"every${whitespaceChar}x in [1] satisfies odd(x)") should returnResult( + true + ) + evaluateExpression(s"every x${whitespaceChar}in [1] satisfies odd(x)") should returnResult( + true + ) + evaluateExpression(s"every x in${whitespaceChar}[1] satisfies odd(x)") should returnResult( + true + ) + evaluateExpression(s"every x in [1]${whitespaceChar}satisfies odd(x)") should returnResult( + true + ) + evaluateExpression(s"every x in [1] satisfies${whitespaceChar}odd(x)") should returnResult( + true + ) + } + } + }