Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions java/.mvn/jvm.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED
44 changes: 37 additions & 7 deletions java/pom.xml
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-parent</artifactId>
<version>4.5.0</version>
<version>5.0.0-SNAPSHOT</version>
</parent>

<artifactId>tag-expressions</artifactId>
<version>8.0.1-SNAPSHOT</version>
<version>9.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Cucumber Tag Expressions</name>
<description>Parses boolean infix expressions</description>
Expand All @@ -27,7 +28,6 @@
<tag>HEAD</tag>
</scm>


<dependencyManagement>
<dependencies>
<dependency>
Expand All @@ -37,14 +37,27 @@
<type>pom</type>
<scope>import</scope>
</dependency>

<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-bom</artifactId>
<version>3.27.6</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest</artifactId>
<version>3.0</version>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>1.0.0</version>
</dependency>

<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
Expand All @@ -60,4 +73,21 @@
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<executions>
<execution>
<id>validate</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package io.cucumber.tagexpressions;

public final class TagExpressionException extends RuntimeException {
TagExpressionException(String message, Object... args) {
super(String.format(message, args));
TagExpressionException(String message) {
super(message);
}
}
113 changes: 63 additions & 50 deletions java/src/main/java/io/cucumber/tagexpressions/TagExpressionParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,33 @@
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static java.util.Objects.requireNonNull;

public final class TagExpressionParser {
private static final Map<String, Assoc> ASSOC = new HashMap<String, Assoc>() {{
put("or", Assoc.LEFT);
put("and", Assoc.LEFT);
put("not", Assoc.RIGHT);
}};
private static final Map<String, Integer> PREC = new HashMap<String, Integer>() {{
put("(", -2);
put(")", -1);
put("or", 0);
put("and", 1);
put("not", 2);
}};
private static boolean assoc(String token, Assoc assoc) {
return switch (token) {
case "or", "and" -> assoc == Assoc.LEFT;
case "not" -> assoc == Assoc.RIGHT;
default -> throw new IllegalArgumentException(token);
};
}

private static int prec(String token) {
return switch (token) {
case "(" -> -2;
case ")" -> -1;
case "or" -> 0;
case "and" -> 1;
case "not" -> 2;
default -> throw new IllegalArgumentException(token);
};
}

private static final char ESCAPING_CHAR = '\\';
private final String infix;

Expand All @@ -30,7 +38,7 @@ public static Expression parse(String infix) {
}

private TagExpressionParser(String infix) {
this.infix = infix;
this.infix = requireNonNull(infix);
}

private Expression parse() {
Expand All @@ -47,10 +55,7 @@ private Expression parse() {
expectedTokenType = TokenType.OPERAND;
} else if (isBinary(token)) {
check(expectedTokenType, TokenType.OPERATOR);
while (operators.size() > 0 && isOperator(operators.peek()) && (
(ASSOC.get(token) == Assoc.LEFT && PREC.get(token) <= PREC.get(operators.peek()))
||
(ASSOC.get(token) == Assoc.RIGHT && PREC.get(token) < PREC.get(operators.peek())))
while (!operators.isEmpty() && isTokenForOperator(token, operators.peek())
) {
pushExpr(pop(operators), expressions);
}
Expand All @@ -62,11 +67,11 @@ private Expression parse() {
expectedTokenType = TokenType.OPERAND;
} else if (")".equals(token)) {
check(expectedTokenType, TokenType.OPERATOR);
while (operators.size() > 0 && !"(".equals(operators.peek())) {
while (!operators.isEmpty() && !"(".equals(operators.peek())) {
pushExpr(pop(operators), expressions);
}
if (operators.size() == 0) {
throw new TagExpressionException("Tag expression \"%s\" could not be parsed because of syntax error: Unmatched ).", this.infix);
if (operators.isEmpty()) {
throw new TagExpressionException("Tag expression \"%s\" could not be parsed because of syntax error: Unmatched ).".formatted(this.infix));
}
if ("(".equals(operators.peek())) {
pop(operators);
Expand All @@ -79,16 +84,25 @@ private Expression parse() {
}
}

while (operators.size() > 0) {
while (!operators.isEmpty()) {
if ("(".equals(operators.peek())) {
throw new TagExpressionException("Tag expression \"%s\" could not be parsed because of syntax error: Unmatched (.", infix);
throw new TagExpressionException("Tag expression \"%s\" could not be parsed because of syntax error: Unmatched (.".formatted(infix));
}
pushExpr(pop(operators), expressions);
}

return expressions.pop();
}

@SuppressWarnings("UnnecessaryParentheses")
private boolean isTokenForOperator(String token, String operator) {
if (!isOperator(operator)) {
return false;
}
return (assoc(token, Assoc.LEFT) && prec(token) <= prec(operator)) ||
(assoc(token, Assoc.RIGHT) && prec(token) < prec(operator));
}

private static List<String> tokenize(String expr) {
List<String> tokens = new ArrayList<>();
boolean isEscaped = false;
Expand All @@ -100,12 +114,12 @@ private static List<String> tokenize(String expr) {
token.append(c);
isEscaped = false;
} else {
throw new TagExpressionException("Tag expression \"%s\" could not be parsed because of syntax error: Illegal escape before \"%s\".", expr, c);
throw new TagExpressionException("Tag expression \"%s\" could not be parsed because of syntax error: Illegal escape before \"%s\".".formatted(expr, c));
}
} else if (c == ESCAPING_CHAR) {
isEscaped = true;
} else if (c == '(' || c == ')' || Character.isWhitespace(c)) {
if (token.length() > 0) {
if (!token.isEmpty()) {
tokens.add(token.toString());
token = new StringBuilder();
}
Expand All @@ -116,41 +130,40 @@ private static List<String> tokenize(String expr) {
token.append(c);
}
}
if (token.length() > 0) {
if (!token.isEmpty()) {
tokens.add(token.toString());
}
return tokens;
}

private void check(TokenType expectedTokenType, TokenType tokenType) {
if (expectedTokenType != tokenType) {
throw new TagExpressionException("Tag expression \"%s\" could not be parsed because of syntax error: Expected %s.", infix, expectedTokenType.toString().toLowerCase());
throw new TagExpressionException("Tag expression \"%s\" could not be parsed because of syntax error: Expected %s.".formatted(infix, expectedTokenType.toString().toLowerCase(Locale.US)));
}
}

private <T> T pop(Deque<T> stack) {
if (stack.isEmpty())
throw new TagExpressionException("Tag expression \"%s\" could not be parsed because of an empty stack", infix);
throw new TagExpressionException("Tag expression \"%s\" could not be parsed because of an empty stack".formatted(infix));
return stack.pop();
}

private void pushExpr(String token, Deque<Expression> stack) {
switch (token) {
case "and":
Expression expression = switch (token) {
case "and" -> {
Expression rightAndExpr = pop(stack);
stack.push(new And(pop(stack), rightAndExpr));
break;
case "or":
Expression leftAndExpr = pop(stack);
yield new And(leftAndExpr, rightAndExpr);
}
case "or" -> {
Expression rightOrExpr = pop(stack);
stack.push(new Or(pop(stack), rightOrExpr));
break;
case "not":
stack.push(new Not(pop(stack)));
break;
default:
stack.push(new Literal(token));
break;
}
Expression leftOrExpr = pop(stack);
yield new Or(leftOrExpr, rightOrExpr);
}
case "not" -> new Not(pop(stack));
default -> new Literal(token);
};
stack.push(expression);
}

private boolean isUnary(String token) {
Expand All @@ -162,7 +175,7 @@ private boolean isBinary(String token) {
}

private boolean isOperator(String token) {
return ASSOC.get(token) != null;
return isBinary(token) || isUnary(token);
}

private enum TokenType {
Expand Down Expand Up @@ -213,7 +226,7 @@ public boolean evaluate(List<String> variables) {

@Override
public String toString() {
return "( " + left.toString() + " or " + right.toString() + " )";
return "( " + left + " or " + right + " )";
}
}

Expand All @@ -233,7 +246,7 @@ public boolean evaluate(List<String> variables) {

@Override
public String toString() {
return "( " + left.toString() + " and " + right.toString() + " )";
return "( " + left + " and " + right + " )";
}
}

Expand All @@ -251,15 +264,15 @@ public boolean evaluate(List<String> variables) {

@Override
public String toString() {
if (And.class.isInstance(expr) || Or.class.isInstance(expr)) {
if (expr instanceof And || expr instanceof Or) {
// -- HINT: Binary Operators already have already ' ( ... ) '.
return "not " + expr.toString();
return "not " + expr;
}
return "not ( " + expr.toString() + " )";
return "not ( " + expr + " )";
}
}

private static class True implements Expression {
private static final class True implements Expression {
@Override
public boolean evaluate(List<String> variables) {
return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@NullMarked
package io.cucumber.tagexpressions;

import org.jspecify.annotations.NullMarked;
6 changes: 4 additions & 2 deletions java/src/test/java/io/cucumber/tagexpressions/ErrorsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,22 @@
import java.util.Map;

import static java.nio.file.Files.newInputStream;
import static java.util.Objects.requireNonNull;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class ErrorsTest {

private static List<Map<String, String>> acceptance_tests_pass() throws IOException {
static List<Map<String, String>> acceptance_tests_pass() throws IOException {
return new Yaml().loadAs(newInputStream(Paths.get("..", "testdata", "errors.yml")), List.class);
}

@ParameterizedTest
@MethodSource
void acceptance_tests_pass(Map<String, String> expectation) {
String expression = requireNonNull(expectation.get("expression"));
TagExpressionException e = assertThrows(TagExpressionException.class,
() -> TagExpressionParser.parse(expectation.get("expression")));
() -> TagExpressionParser.parse(expression));

assertEquals(expectation.get("error"), e.getMessage());
}
Expand Down
Loading