diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f650aa..1dbc8563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed +* Only allow escape character (\) in front of (, ), or \. Throw error otherwise. ([#17](https://github.com/cucumber/tag-expressions/pull/17)) + ### Deprecated ### Removed ### Fixed +* Document escaping. ([#16](https://github.com/cucumber/tag-expressions/issues/16), [#17](https://github.com/cucumber/tag-expressions/pull/17)) +* [Ruby] Empty expression evaluates to true + ## [4.1.0] - 2021-10-08 ### Added diff --git a/README.md b/README.md index 6bf01997..ff2ea9a3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,16 @@ For more complex Tag Expressions you can use parenthesis for clarity, or to chan (@smoke or @ui) and (not @slow) +## Escaping + +If you need to use one of the reserved characters `(`, `)`, `\` or ` ` (whitespace) in a tag, +you can escape it with a `\`. Examples: + +| Gherkin Tag | Escaped Tag Expression | +| ------------- | ---------------------- | +| @x(y) | @x\(y\) | +| @x\y | @x\\y | + ## Migrating from old style tags Older versions of Cucumber used a different syntax for tags. The list below diff --git a/go/.gitignore b/go/.gitignore index 7b0ee7ae..df241b81 100644 --- a/go/.gitignore +++ b/go/.gitignore @@ -15,3 +15,4 @@ dist_compressed/ *.iml # upx dist/cucumber-gherkin-openbsd-386 fails with a core dump core.*.!usr!bin!upx-ucl +.idea diff --git a/go/Makefile b/go/Makefile index f1e28128..c0b802b5 100644 --- a/go/Makefile +++ b/go/Makefile @@ -1,4 +1,5 @@ GO_SOURCE_FILES := $(wildcard *.go) +TEST_FILES := $(wildcard ../testdata/*.yml) default: .linted .tested .PHONY: default @@ -7,7 +8,7 @@ default: .linted .tested gofmt -w $^ touch $@ -.tested: $(GO_SOURCE_FILES) +.tested: $(GO_SOURCE_FILES) $(TEST_FILES) go test ./... touch $@ diff --git a/go/errors_test.go b/go/errors_test.go new file mode 100644 index 00000000..de7134fa --- /dev/null +++ b/go/errors_test.go @@ -0,0 +1,32 @@ +package tagexpressions + +import ( + "fmt" + "gopkg.in/yaml.v3" + "io/ioutil" + "testing" + + "github.com/stretchr/testify/require" +) + +type ErrorTest struct { + Expression string `yaml:"expression"` + Error string `yaml:"error"` +} + +func TestErrors(t *testing.T) { + contents, err := ioutil.ReadFile("../testdata/errors.yml") + require.NoError(t, err) + var tests []ErrorTest + err = yaml.Unmarshal(contents, &tests) + require.NoError(t, err) + + for _, test := range tests { + name := fmt.Sprintf("fails to parse \"%s\" with \"%s\"", test.Expression, test.Error) + t.Run(name, func(t *testing.T) { + _, err := Parse(test.Expression) + require.Error(t, err) + require.Equal(t, test.Error, err.Error()) + }) + } +} diff --git a/go/evaluations_test.go b/go/evaluations_test.go new file mode 100644 index 00000000..c098925a --- /dev/null +++ b/go/evaluations_test.go @@ -0,0 +1,43 @@ +package tagexpressions + +import ( + "fmt" + "gopkg.in/yaml.v3" + "io/ioutil" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type Evaluation struct { + Expression string `yaml:"expression"` + Tests []Test `yaml:"tests"` +} + +type Test struct { + Variables []string `yaml:"variables"` + Result bool `yaml:"result"` +} + +func TestEvaluations(t *testing.T) { + contents, err := ioutil.ReadFile("../testdata/evaluations.yml") + require.NoError(t, err) + var evaluations []Evaluation + err = yaml.Unmarshal(contents, &evaluations) + require.NoError(t, err) + + for _, evaluation := range evaluations { + for _, test := range evaluation.Tests { + variables := strings.Join(test.Variables, ", ") + name := fmt.Sprintf("evaluates [%s] to %t", variables, test.Result) + t.Run(name, func(t *testing.T) { + expression, err := Parse(evaluation.Expression) + require.NoError(t, err) + + result := expression.Evaluate(test.Variables) + require.Equal(t, test.Result, result) + }) + } + } +} diff --git a/go/parser.go b/go/parser.go index 8759690a..ebac8fcf 100644 --- a/go/parser.go +++ b/go/parser.go @@ -2,7 +2,6 @@ package tagexpressions import ( "bytes" - "errors" "fmt" "strings" "unicode" @@ -17,7 +16,10 @@ type Evaluatable interface { } func Parse(infix string) (Evaluatable, error) { - tokens := tokenize(infix) + tokens, err := tokenize(infix) + if err != nil { + return nil, err + } if len(tokens) == 0 { return &trueExpr{}, nil } @@ -27,13 +29,13 @@ func Parse(infix string) (Evaluatable, error) { for _, token := range tokens { if isUnary(token) { - if err := check(expectedTokenType, OPERAND); err != nil { + if err := check(infix, expectedTokenType, OPERAND); err != nil { return nil, err } operators.Push(token) expectedTokenType = OPERAND } else if isBinary(token) { - if err := check(expectedTokenType, OPERATOR); err != nil { + if err := check(infix, expectedTokenType, OPERATOR); err != nil { return nil, err } for operators.Len() > 0 && @@ -45,27 +47,27 @@ func Parse(infix string) (Evaluatable, error) { operators.Push(token) expectedTokenType = OPERAND } else if "(" == token { - if err := check(expectedTokenType, OPERAND); err != nil { + if err := check(infix, expectedTokenType, OPERAND); err != nil { return nil, err } operators.Push(token) expectedTokenType = OPERAND } else if ")" == token { - if err := check(expectedTokenType, OPERATOR); err != nil { + if err := check(infix, expectedTokenType, OPERATOR); err != nil { return nil, err } for operators.Len() > 0 && operators.Peek() != "(" { pushExpr(operators.Pop(), expressions) } if operators.Len() == 0 { - return nil, errors.New("Syntax error. Unmatched )") + return nil, fmt.Errorf("Tag expression \"%s\" could not be parsed because of syntax error: Unmatched ).", infix) } if operators.Peek() == "(" { operators.Pop() } expectedTokenType = OPERATOR } else { - if err := check(expectedTokenType, OPERAND); err != nil { + if err := check(infix, expectedTokenType, OPERAND); err != nil { return nil, err } pushExpr(token, expressions) @@ -75,7 +77,7 @@ func Parse(infix string) (Evaluatable, error) { for operators.Len() > 0 { if operators.Peek() == "(" { - return nil, errors.New("Syntax error. Unmatched (") + return nil, fmt.Errorf("Tag expression \"%s\" could not be parsed because of syntax error: Unmatched (.", infix) } pushExpr(operators.Pop(), expressions) } @@ -97,51 +99,38 @@ var PREC = map[string]int{ "not": 2, } -func tokenize(expr string) []string { +func tokenize(expr string) ([]string, error) { var tokens []string var token bytes.Buffer - collectToken := func() { - if token.Len() > 0 { - tokens = append(tokens, token.String()) - token.Reset() - } - } - escaped := false for _, c := range expr { - if unicode.IsSpace(c) { - collectToken() - escaped = false - continue - } - - ch := string(c) - - switch ch { - case "\\": - if escaped { - token.WriteString(ch) + if escaped { + if c == '(' || c == ')' || c == '\\' || unicode.IsSpace(c) { + token.WriteRune(c) escaped = false } else { - escaped = true + return nil, fmt.Errorf("Tag expression \"%s\" could not be parsed because of syntax error: Illegal escape before \"%s\".", expr, string(c)) } - case "(", ")": - if escaped { - token.WriteString(ch) - escaped = false - } else { - collectToken() - tokens = append(tokens, ch) + } else if c == '\\' { + escaped = true + } else if c == '(' || c == ')' || unicode.IsSpace(c) { + if token.Len() > 0 { + tokens = append(tokens, token.String()) + token.Reset() } - default: - token.WriteString(ch) - escaped = false + if !unicode.IsSpace(c) { + tokens = append(tokens, string(c)) + } + } else { + token.WriteRune(c) } } + if token.Len() > 0 { + tokens = append(tokens, token.String()) + } - collectToken() - return tokens + return tokens, nil } func isUnary(token string) bool { @@ -157,9 +146,9 @@ func isOp(token string) bool { return ok } -func check(expectedTokenType, tokenType string) error { +func check(infix, expectedTokenType, tokenType string) error { if expectedTokenType != tokenType { - return fmt.Errorf("Syntax error. Expected %s", expectedTokenType) + return fmt.Errorf("Tag expression \"%s\" could not be parsed because of syntax error: Expected %s.", infix, expectedTokenType) } return nil } @@ -198,17 +187,11 @@ func (l *literalExpr) Evaluate(variables []string) bool { } func (l *literalExpr) ToString() string { - return strings.Replace( - strings.Replace( - strings.Replace(l.value, "\\", "\\\\", -1), - "(", - "\\(", - -1, - ), - ")", - "\\)", - -1, - ) + s1 := l.value + s2 := strings.Replace(s1, "\\", "\\\\", -1) + s3 := strings.Replace(s2, "(", "\\(", -1) + s4 := strings.Replace(s3, ")", "\\)", -1) + return strings.Replace(s4, " ", "\\ ", -1) } type orExpr struct { diff --git a/go/parser_test.go b/go/parser_test.go deleted file mode 100644 index 3833919a..00000000 --- a/go/parser_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package tagexpressions - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestParseForValidCases(t *testing.T) { - cases := []struct { - name string - given string - expected string - }{ - { - name: "test and", - given: "a and b", - expected: "( a and b )", - }, - { - name: "test or", - given: "a or b", - expected: "( a or b )", - }, - { - name: "test unary not", - given: "not a", - expected: "not ( a )", - }, - { - name: "test and & or", - given: "( a and b ) or ( c and d )", - expected: "( ( a and b ) or ( c and d ) )", - }, - { - name: "test and, or, not", - given: "not a or b and not c or not d or e and f", - expected: "( ( ( not ( a ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )", - }, - { - name: "test escaping", - given: "not a\\(\\) or b and not c or not d or e and f", - expected: "( ( ( not ( a\\(\\) ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )", - }, - { - name: "test escaping backslash", - given: "(a\\\\ and b\\\\\\( and c\\\\\\) and d\\\\)", - expected: "( ( ( a\\\\ and b\\\\\\( ) and c\\\\\\) ) and d\\\\ )", - }, - { - name: "test backslash", - given: "(a\\ and \\b) and c\\", - expected: "( ( a and b ) and c )", - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - actual, err := Parse(tc.given) - require.NoError(t, err) - - actualStr := actual.ToString() - require.Equal(t, tc.expected, actualStr) - - roundTripActual, err := Parse(actualStr) - require.NoError(t, err) - - roundTripActualStr := roundTripActual.ToString() - require.Equal(t, tc.expected, roundTripActualStr) - }) - } -} - -func TestParseForSyntaxErrors(t *testing.T) { - cases := []struct { - name string - given string - expected string - }{ - { - name: "no operators", - given: "a b", - expected: "Syntax error. Expected operator", - }, - { - name: "missing operator in binary expression", - given: "@a @b or", - expected: "Syntax error. Expected operator", - }, - { - name: "missing operator in unary expression", - given: "@a and (@b not)", - expected: "Syntax error. Expected operator", - }, - { - name: "missing operator between operands", - given: "@a and (@b @c) or", - expected: "Syntax error. Expected operator", - }, - { - name: "no operands", - given: "or or", - expected: "Syntax error. Expected operand", - }, - { - name: "missing operand", - given: "@a and or", - expected: "Syntax error. Expected operand", - }, - { - name: "unmatched closing parenthesis", - given: "( a and b ) )", - expected: "Syntax error. Unmatched )", - }, - { - name: "unmatched opening parenthesis", - given: "( ( a and b )", - expected: "Syntax error. Unmatched (", - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - _, err := Parse(tc.given) - require.Error(t, err) - require.Equal(t, tc.expected, err.Error()) - }) - } -} - -func TestParseForEvaluationErrors(t *testing.T) { - cases := []struct { - name string - given string - expectations func(*testing.T, Evaluatable) - }{ - { - name: "evaluate not", - given: "not x", - expectations: func(t *testing.T, expr Evaluatable) { - require.False(t, expr.Evaluate([]string{"x"})) - require.True(t, expr.Evaluate([]string{"y"})) - }, - }, - { - name: "evaluate and", - given: "x and y", - expectations: func(t *testing.T, expr Evaluatable) { - require.True(t, expr.Evaluate([]string{"x", "y"})) - require.False(t, expr.Evaluate([]string{"y"})) - require.False(t, expr.Evaluate([]string{"x"})) - }, - }, - { - name: "evaluate or", - given: " x or(y) ", - expectations: func(t *testing.T, expr Evaluatable) { - require.False(t, expr.Evaluate([]string{})) - require.True(t, expr.Evaluate([]string{"y"})) - require.True(t, expr.Evaluate([]string{"x"})) - }, - }, - { - name: "evaluate expressions with escaped chars", - given: " x\\(1\\) or(y\\(2\\)) ", - expectations: func(t *testing.T, expr Evaluatable) { - require.False(t, expr.Evaluate([]string{})) - require.True(t, expr.Evaluate([]string{"y(2)"})) - require.True(t, expr.Evaluate([]string{"x(1)"})) - require.False(t, expr.Evaluate([]string{"y"})) - require.False(t, expr.Evaluate([]string{"x"})) - }, - }, - { - name: "evaluate empty expressions to true", - given: "", - expectations: func(t *testing.T, expr Evaluatable) { - require.True(t, expr.Evaluate([]string{})) - require.True(t, expr.Evaluate([]string{"y"})) - require.True(t, expr.Evaluate([]string{"x"})) - }, - }, - { - name: "evaluate expressions with escaped backslash", - given: "x\\\\ or(y\\\\\\)) or(z\\\\)", - expectations: func(t *testing.T, expr Evaluatable) { - require.False(t, expr.Evaluate([]string{})) - require.True(t, expr.Evaluate([]string{"x\\"})) - require.True(t, expr.Evaluate([]string{"y\\)"})) - require.True(t, expr.Evaluate([]string{"z\\"})) - require.False(t, expr.Evaluate([]string{"x"})) - require.False(t, expr.Evaluate([]string{"y)"})) - require.False(t, expr.Evaluate([]string{"z"})) - }, - }, - { - name: "evaluate expressions with backslash", - given: "\\x or y\\ or z\\", - expectations: func(t *testing.T, expr Evaluatable) { - require.False(t, expr.Evaluate([]string{})) - require.True(t, expr.Evaluate([]string{"x"})) - require.True(t, expr.Evaluate([]string{"y"})) - require.True(t, expr.Evaluate([]string{"z"})) - require.False(t, expr.Evaluate([]string{"\\x"})) - require.False(t, expr.Evaluate([]string{"y\\"})) - require.False(t, expr.Evaluate([]string{"z\\"})) - }, - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - expr, err := Parse(tc.given) - require.NoError(t, err) - tc.expectations(t, expr) - }) - } -} diff --git a/go/parsing_test.go b/go/parsing_test.go new file mode 100644 index 00000000..d0bcba32 --- /dev/null +++ b/go/parsing_test.go @@ -0,0 +1,38 @@ +package tagexpressions + +import ( + "fmt" + "gopkg.in/yaml.v3" + "io/ioutil" + "testing" + + "github.com/stretchr/testify/require" +) + +type ParsingTest struct { + Expression string `yaml:"expression"` + Formatted string `yaml:"formatted"` +} + +func TestParsing(t *testing.T) { + contents, err := ioutil.ReadFile("../testdata/parsing.yml") + require.NoError(t, err) + var tests []ParsingTest + err = yaml.Unmarshal(contents, &tests) + require.NoError(t, err) + + for _, test := range tests { + name := fmt.Sprintf("parses \"%s\" into \"%s\"", test.Expression, test.Formatted) + t.Run(name, func(t *testing.T) { + expression, err := Parse(test.Expression) + require.NoError(t, err) + + require.Equal(t, test.Formatted, expression.ToString()) + + expressionAgain, err := Parse(expression.ToString()) + require.NoError(t, err) + + require.Equal(t, test.Formatted, expressionAgain.ToString()) + }) + } +} diff --git a/java/pom.xml b/java/pom.xml index 16f84e42..d7dd633f 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -55,6 +55,12 @@ junit-jupiter-params test + + org.yaml + snakeyaml + 1.29 + test + diff --git a/java/src/main/java/io/cucumber/tagexpressions/TagExpressionParser.java b/java/src/main/java/io/cucumber/tagexpressions/TagExpressionParser.java index 6b62a225..d1e7da6f 100644 --- a/java/src/main/java/io/cucumber/tagexpressions/TagExpressionParser.java +++ b/java/src/main/java/io/cucumber/tagexpressions/TagExpressionParser.java @@ -6,6 +6,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public final class TagExpressionParser { private static final Map ASSOC = new HashMap() {{ @@ -24,16 +26,16 @@ public final class TagExpressionParser { private final String infix; public static Expression parse(String infix) { - return new TagExpressionParser(infix).parse(); + return new TagExpressionParser(infix).parse(); } private TagExpressionParser(String infix) { - this.infix = infix; + this.infix = infix; } - - private Expression parse() { + + private Expression parse() { List tokens = tokenize(infix); - if(tokens.isEmpty()) return new True(); + if (tokens.isEmpty()) return new True(); Deque operators = new ArrayDeque<>(); Deque expressions = new ArrayDeque<>(); @@ -49,7 +51,7 @@ private Expression parse() { (ASSOC.get(token) == Assoc.LEFT && PREC.get(token) <= PREC.get(operators.peek())) || (ASSOC.get(token) == Assoc.RIGHT && PREC.get(token) < PREC.get(operators.peek()))) - ) { + ) { pushExpr(pop(operators), expressions); } operators.push(token); @@ -64,7 +66,7 @@ private Expression parse() { 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); + throw new TagExpressionException("Tag expression \"%s\" could not be parsed because of syntax error: Unmatched ).", this.infix); } if ("(".equals(operators.peek())) { pop(operators); @@ -79,7 +81,7 @@ private Expression parse() { while (operators.size() > 0) { 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 (.", infix); } pushExpr(pop(operators), expressions); } @@ -89,43 +91,32 @@ private Expression parse() { private static List tokenize(String expr) { List tokens = new ArrayList<>(); - boolean isEscaped = false; - StringBuilder token = null; + StringBuilder token = new StringBuilder(); for (int i = 0; i < expr.length(); i++) { char c = expr.charAt(i); - if (ESCAPING_CHAR == c && !isEscaped) { - isEscaped = true; - } else { - if (Character.isWhitespace(c)) { // skip - if (null != token) { // end of token - tokens.add(token.toString()); - token = null; - } + if (isEscaped) { + if (c == '(' || c == ')' || c == '\\' || Character.isWhitespace(c)) { + token.append(c); + isEscaped = false; } else { - switch (c) { - case '(': - case ')': - if (!isEscaped) { - if (null != token) { // end of token - tokens.add(token.toString()); - token = null; - } - tokens.add(String.valueOf(c)); - break; - } - default: - if (null == token) { // start of token - token = new StringBuilder(); - } - token.append(c); - break; - } + throw new TagExpressionException("Tag expression \"%s\" could not be parsed because of syntax error: Illegal escape before \"%s\".", expr, c); + } + } else if (c == ESCAPING_CHAR) { + isEscaped = true; + } else if (c == '(' || c == ')' || Character.isWhitespace(c)) { + if (token.length() > 0) { + tokens.add(token.toString()); + token = new StringBuilder(); } - isEscaped = false; + if (!Character.isWhitespace(c)) { + tokens.add(String.valueOf(c)); + } + } else { + token.append(c); } } - if (null != token) { // end of token + if (token.length() > 0) { tokens.add(token.toString()); } return tokens; @@ -133,12 +124,13 @@ private static List tokenize(String expr) { 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.", infix, expectedTokenType.toString().toLowerCase()); } } private T pop(Deque stack) { - if (stack.isEmpty()) throw new TagExpressionException("Tag expression '%s' could not be parsed because of an empty stack", infix); + if (stack.isEmpty()) + throw new TagExpressionException("Tag expression \"%s\" could not be parsed because of an empty stack", infix); return stack.pop(); } @@ -183,7 +175,7 @@ private enum Assoc { RIGHT } - private class Literal implements Expression { + private static class Literal implements Expression { private final String value; Literal(String value) { @@ -197,11 +189,15 @@ public boolean evaluate(List variables) { @Override public String toString() { - return value.replaceAll("\\\\", "\\\\\\\\").replaceAll("\\(", "\\\\(").replaceAll("\\)", "\\\\)"); + return value + .replaceAll(Pattern.quote("\\"), Matcher.quoteReplacement("\\\\")) + .replaceAll(Pattern.quote("("), Matcher.quoteReplacement("\\(")) + .replaceAll(Pattern.quote(")"), Matcher.quoteReplacement("\\)")) + .replaceAll("\\s", "\\\\ "); } } - private class Or implements Expression { + private static class Or implements Expression { private final Expression left; private final Expression right; @@ -221,7 +217,7 @@ public String toString() { } } - private class And implements Expression { + private static class And implements Expression { private final Expression left; private final Expression right; @@ -241,7 +237,7 @@ public String toString() { } } - private class Not implements Expression { + private static class Not implements Expression { private final Expression expr; Not(Expression expr) { @@ -259,10 +255,15 @@ public String toString() { } } - private class True implements Expression { + private static class True implements Expression { @Override public boolean evaluate(List variables) { return true; } + + @Override + public String toString() { + return "true"; + } } } diff --git a/java/src/test/java/io/cucumber/tagexpressions/ErrorsTest.java b/java/src/test/java/io/cucumber/tagexpressions/ErrorsTest.java new file mode 100644 index 00000000..f641d431 --- /dev/null +++ b/java/src/test/java/io/cucumber/tagexpressions/ErrorsTest.java @@ -0,0 +1,30 @@ +package io.cucumber.tagexpressions; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; + +import static java.nio.file.Files.newInputStream; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ErrorsTest { + + private static List> acceptance_tests_pass() throws IOException { + return new Yaml().loadAs(newInputStream(Paths.get("..", "testdata", "errors.yml")), List.class); + } + + @ParameterizedTest + @MethodSource + void acceptance_tests_pass(Map expectation) { + TagExpressionException e = assertThrows(TagExpressionException.class, + () -> TagExpressionParser.parse(expectation.get("expression"))); + + assertEquals(expectation.get("error"), e.getMessage()); + } +} diff --git a/java/src/test/java/io/cucumber/tagexpressions/EvaluationsTest.java b/java/src/test/java/io/cucumber/tagexpressions/EvaluationsTest.java new file mode 100644 index 00000000..4c69b740 --- /dev/null +++ b/java/src/test/java/io/cucumber/tagexpressions/EvaluationsTest.java @@ -0,0 +1,49 @@ +package io.cucumber.tagexpressions; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static java.nio.file.Files.newInputStream; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class EvaluationsTest { + static class Expectation { + final String expression; + final List variables; + final boolean result; + + public Expectation(String expression, List variables, boolean result) { + this.expression = expression; + this.variables = variables; + this.result = result; + } + } + + private static List acceptance_tests_pass() throws IOException { + List> evaluations = new Yaml().loadAs(newInputStream(Paths.get("..", "testdata", "evaluations.yml")), List.class); + return evaluations.stream().flatMap(map -> { + String expression = (String) map.get("expression"); + List> tests = (List>) map.get("tests"); + return tests.stream().map(test -> { + List variables = (List) test.get("variables"); + boolean result = (boolean) test.get("result"); + return new Expectation(expression, variables, result); + }); + }).collect(Collectors.toList()); + } + + @ParameterizedTest + @MethodSource + void acceptance_tests_pass(Expectation expectation) { + Expression expr = TagExpressionParser.parse(expectation.expression); + expr.evaluate(expectation.variables); + assertEquals(expectation.result, expr.evaluate(expectation.variables)); + } +} diff --git a/java/src/test/java/io/cucumber/tagexpressions/ParsingTest.java b/java/src/test/java/io/cucumber/tagexpressions/ParsingTest.java new file mode 100644 index 00000000..ffa08964 --- /dev/null +++ b/java/src/test/java/io/cucumber/tagexpressions/ParsingTest.java @@ -0,0 +1,31 @@ +package io.cucumber.tagexpressions; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; + +import static java.nio.file.Files.newInputStream; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ParsingTest { + + private static List> acceptance_tests_pass() throws IOException { + return new Yaml().loadAs(newInputStream(Paths.get("..", "testdata", "parsing.yml")), List.class); + } + + @ParameterizedTest + @MethodSource + void acceptance_tests_pass(Map expectation) { + Expression expr = TagExpressionParser.parse(expectation.get("expression")); + String formatted = expectation.get("formatted"); + assertEquals(formatted, expr.toString()); + + Expression expr2 = TagExpressionParser.parse(formatted); + assertEquals(formatted, expr2.toString()); + } +} diff --git a/java/src/test/java/io/cucumber/tagexpressions/TagExpressionParserParameterizedTest.java b/java/src/test/java/io/cucumber/tagexpressions/TagExpressionParserParameterizedTest.java deleted file mode 100644 index 6fa10782..00000000 --- a/java/src/test/java/io/cucumber/tagexpressions/TagExpressionParserParameterizedTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package io.cucumber.tagexpressions; - -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.params.provider.Arguments.arguments; - -class TagExpressionParserParameterizedTest { - - static Stream data() { - return Stream.of( - arguments("a and b", "( a and b )"), - arguments("a or b", "( a or b )"), - arguments("not a", "not ( a )"), - arguments("( a and b ) or ( c and d )", "( ( a and b ) or ( c and d ) )"), - arguments("not a or b and not c or not d or e and f", "( ( ( not ( a ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )"), - arguments("not a\\(1\\) or b and not c or not d or e and f", "( ( ( not ( a\\(1\\) ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )"), - arguments("a\\\\ and b", "( a\\\\ and b )"), - arguments("\\a and b\\ and c\\", "( ( a and b ) and c )"), - arguments("a\\\\\\( and b\\\\\\)", "( a\\\\\\( and b\\\\\\) )"), - arguments("(a and \\b)", "( a and b )") - ); - } - - @ParameterizedTest - @MethodSource("data") - public void parser_expression(String infix, String expected) { - Expression expr = TagExpressionParser.parse(infix); - assertEquals(expected, expr.toString()); - } - -} diff --git a/java/src/test/java/io/cucumber/tagexpressions/TagExpressionParserSyntaxErrorTest.java b/java/src/test/java/io/cucumber/tagexpressions/TagExpressionParserSyntaxErrorTest.java deleted file mode 100644 index 56b2519a..00000000 --- a/java/src/test/java/io/cucumber/tagexpressions/TagExpressionParserSyntaxErrorTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package io.cucumber.tagexpressions; - -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.params.provider.Arguments.arguments; - -class TagExpressionParserSyntaxErrorTest { - - static Stream data() { - return Stream.of( - arguments("@a @b or", "Tag expression '@a @b or' could not be parsed because of syntax error: expected operator"), - arguments("@a and (@b not)", "Tag expression '@a and (@b not)' could not be parsed because of syntax error: expected operator"), - arguments("@a and (@b @c) or", "Tag expression '@a and (@b @c) or' could not be parsed because of syntax error: expected operator"), - arguments("@a and or", "Tag expression '@a and or' could not be parsed because of syntax error: expected operand"), - arguments("or or", "Tag expression 'or or' could not be parsed because of syntax error: expected operand"), - arguments("a b", "Tag expression 'a b' could not be parsed because of syntax error: expected operator"), - arguments("( a and b ) )", "Tag expression '( a and b ) )' could not be parsed because of syntax error: unmatched )"), - arguments("( ( a and b )", "Tag expression '( ( a and b )' could not be parsed because of syntax error: unmatched (") - ); - } - - @ParameterizedTest - @MethodSource("data") - public void parser_expression(String infix, String expectedError) { - TagExpressionException e = assertThrows(TagExpressionException.class, - () -> TagExpressionParser.parse(infix)); - - assertEquals(expectedError, e.getMessage()); - } - -} diff --git a/java/src/test/java/io/cucumber/tagexpressions/TagExpressionParserTest.java b/java/src/test/java/io/cucumber/tagexpressions/TagExpressionParserTest.java deleted file mode 100644 index 3396d126..00000000 --- a/java/src/test/java/io/cucumber/tagexpressions/TagExpressionParserTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package io.cucumber.tagexpressions; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.function.Executable; - -import static java.util.Arrays.asList; -import static java.util.Collections.singletonList; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.core.Is.is; -import static org.hamcrest.core.IsEqual.equalTo; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class TagExpressionParserTest { - - @Test - void evaluates_empty_expression_to_true() { - Expression expr = TagExpressionParser.parse(""); - assertTrue(expr.evaluate(asList("@a @c @d".split(" ")))); - } - - @Test - void evaluates_not() { - Expression expr = TagExpressionParser.parse("not x"); - assertEquals(false, expr.evaluate(singletonList("x"))); - assertEquals(true, expr.evaluate(singletonList("y"))); - } - - @Test - void evaluates_example() { - Expression expr = TagExpressionParser.parse("not @a or @b and not @c or not @d or @e and @f"); - assertFalse(expr.evaluate(asList("@a @c @d".split(" ")))); - } - - @Test - void evaluates_expr_with_escaped_chars() { - Expression expr = TagExpressionParser.parse("((not @a\\(1\\) or @b\\(2\\)) and not @c\\(3\\) or not @d\\(4\\) or @e\\(5\\) and @f\\(6\\))"); - assertFalse(expr.evaluate(asList("@a(1) @c(3) @d(4)".split(" ")))); - assertTrue(expr.evaluate(asList("@b(2) @e(5) @f(6)".split(" ")))); - } - - @Test - void errors_when_there_are_only_operators() { - Executable testMethod = () -> TagExpressionParser.parse("or or"); - TagExpressionException thrownException = assertThrows(TagExpressionException.class, testMethod); - assertThat("Unexpected message", thrownException.getMessage(), is(equalTo("Tag expression 'or or' could not be parsed because of syntax error: expected operand"))); - } - - @Test - void evaluates_expr_with_escaped_backslash() { - Expression expr = TagExpressionParser.parse("@a\\\\ or (@b\\\\\\)) or (@c\\\\)"); - assertFalse(expr.evaluate(asList("@a @b) @c".split(" ")))); - assertTrue(expr.evaluate(asList("@a\\".split(" ")))); - assertTrue(expr.evaluate(asList("@b\\)".split(" ")))); - assertTrue(expr.evaluate(asList("@c\\".split(" ")))); - } - - @Test - void evaluates_expr_with_backslash() { - Expression expr = TagExpressionParser.parse("@\\a or @b\\ or @c\\"); - assertFalse(expr.evaluate(asList("@\\a @b\\ @c\\".split(" ")))); - assertTrue(expr.evaluate(asList("@a".split(" ")))); - assertTrue(expr.evaluate(asList("@b".split(" ")))); - assertTrue(expr.evaluate(asList("@c".split(" ")))); - } - -} diff --git a/javascript/package-lock.json b/javascript/package-lock.json index 2ab4d3c6..4f74316d 100644 --- a/javascript/package-lock.json +++ b/javascript/package-lock.json @@ -12,6 +12,7 @@ "@stryker-mutator/core": "5.4.1", "@stryker-mutator/mocha-runner": "5.4.1", "@stryker-mutator/typescript-checker": "^5.4.1", + "@types/js-yaml": "^4.0.3", "@types/mocha": "9.0.0", "@types/node": "16.10.3", "@typescript-eslint/eslint-plugin": "5.0.0", @@ -22,6 +23,7 @@ "eslint-plugin-node": "11.1.0", "eslint-plugin-prettier": "4.0.0", "eslint-plugin-simple-import-sort": "7.0.0", + "js-yaml": "^4.1.0", "mocha": "9.1.3", "npm-check-updates": "11.8.5", "prettier": "2.4.1", @@ -1179,6 +1181,12 @@ "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", "dev": true }, + "node_modules/@types/js-yaml": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.3.tgz", + "integrity": "sha512-5t9BhoORasuF5uCPr+d5/hdB++zRFUTMIZOzbNkr+jZh3yQht4HYbRDyj9fY8n2TZT30iW9huzav73x4NikqWg==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", @@ -8457,6 +8465,12 @@ "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", "dev": true }, + "@types/js-yaml": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.3.tgz", + "integrity": "sha512-5t9BhoORasuF5uCPr+d5/hdB++zRFUTMIZOzbNkr+jZh3yQht4HYbRDyj9fY8n2TZT30iW9huzav73x4NikqWg==", + "dev": true + }, "@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", diff --git a/javascript/package.json b/javascript/package.json index 622c0657..841eb933 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -23,7 +23,7 @@ "build": "npm run build:cjs && npm run build:esm", "test": "mocha && npm run test:cjs", "test:cjs": "npm run build:cjs && mocha --no-config dist/cjs/test", - "stryker": "stryker run", + "stryker": "TAG_EXPRESSIONS_TEST_DATA_DIR=$(pwd)/../testdata stryker run", "prepublishOnly": "npm run build", "eslint-fix": "eslint --ext ts,tsx --max-warnings 0 --fix src test", "eslint": "eslint --ext ts,tsx --max-warnings 0 src test", @@ -46,6 +46,7 @@ "@stryker-mutator/core": "5.4.1", "@stryker-mutator/mocha-runner": "5.4.1", "@stryker-mutator/typescript-checker": "^5.4.1", + "@types/js-yaml": "^4.0.3", "@types/mocha": "9.0.0", "@types/node": "16.10.3", "@typescript-eslint/eslint-plugin": "5.0.0", @@ -56,6 +57,7 @@ "eslint-plugin-node": "11.1.0", "eslint-plugin-prettier": "4.0.0", "eslint-plugin-simple-import-sort": "7.0.0", + "js-yaml": "^4.1.0", "mocha": "9.1.3", "npm-check-updates": "11.8.5", "prettier": "2.4.1", diff --git a/javascript/src/index.ts b/javascript/src/index.ts index e9d8b32b..49ced6af 100644 --- a/javascript/src/index.ts +++ b/javascript/src/index.ts @@ -55,7 +55,9 @@ export default function parse(infix: string): Node { pushExpr(pop(operators), expressions) } if (operators.length === 0) { - throw Error('Syntax error. Unmatched )') + throw new Error( + `Tag expression "${infix}" could not be parsed because of syntax error: Unmatched ).` + ) } if (peek(operators) === '(') { pop(operators) @@ -70,47 +72,54 @@ export default function parse(infix: string): Node { while (operators.length > 0) { if (peek(operators) === '(') { - throw Error('Syntax error. Unmatched (') + throw new Error( + `Tag expression "${infix}" could not be parsed because of syntax error: Unmatched (.` + ) } pushExpr(pop(operators), expressions) } return pop(expressions) + + function check(expectedTokenType: string, tokenType: string) { + if (expectedTokenType !== tokenType) { + throw new Error( + `Tag expression "${infix}" could not be parsed because of syntax error: Expected ${expectedTokenType}.` + ) + } + } } function tokenize(expr: string): string[] { const tokens = [] let isEscaped = false - let token: string[] | undefined + let token: string[] = [] for (let i = 0; i < expr.length; i++) { const c = expr.charAt(i) - if ('\\' === c && !isEscaped) { - isEscaped = true - } else { - if (/\s/.test(c)) { - // skip - if (token) { - // end of token - tokens.push(token.join('')) - token = undefined - } - } else { - if ((c === '(' || c === ')') && !isEscaped) { - if (token) { - // end of token - tokens.push(token.join('')) - token = undefined - } - tokens.push(c) - continue - } - token = token ? token : [] // start of token + if (isEscaped) { + if (c === '(' || c === ')' || c === '\\' || /\s/.test(c)) { token.push(c) + isEscaped = false + } else { + throw new Error( + `Tag expression "${expr}" could not be parsed because of syntax error: Illegal escape before "${c}".` + ) } - isEscaped = false + } else if (c === '\\') { + isEscaped = true + } else if (c === '(' || c === ')' || /\s/.test(c)) { + if (token.length > 0) { + tokens.push(token.join('')) + token = [] + } + if (!/\s/.test(c)) { + tokens.push(c) + } + } else { + token.push(c) } } - if (token) { + if (token.length > 0) { tokens.push(token.join('')) } return tokens @@ -128,12 +137,6 @@ function isOp(token: string) { return ASSOC[token] !== undefined } -function check(expectedTokenType: string, tokenType: string) { - if (expectedTokenType !== tokenType) { - throw new Error('Syntax error. Expected ' + expectedTokenType) - } -} - function peek(stack: string[]) { return stack[stack.length - 1] } @@ -171,7 +174,11 @@ class Literal implements Node { } public toString() { - return this.value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)') + return this.value + .replace(/\\/g, '\\\\') + .replace(/\(/g, '\\(') + .replace(/\)/g, '\\)') + .replace(/\s/g, '\\ ') } } diff --git a/javascript/test/errors.test.ts b/javascript/test/errors.test.ts new file mode 100644 index 00000000..f3ebb133 --- /dev/null +++ b/javascript/test/errors.test.ts @@ -0,0 +1,21 @@ +import assert from 'assert' +import fs from 'fs' +import yaml from 'js-yaml' + +import parse from '../src/index.js' +import { testDataDir } from './testDataDir.js' + +type ErrorTest = { + expression: string + error: string +} + +const tests = yaml.load(fs.readFileSync(`${testDataDir}/errors.yml`, 'utf-8')) as ErrorTest[] + +describe('Errors', () => { + for (const test of tests) { + it(`fails to parse "${test.expression}" with "${test.error}"`, () => { + assert.throws(() => parse(test.expression), { message: test.error }) + }) + } +}) diff --git a/javascript/test/evaluations.test.ts b/javascript/test/evaluations.test.ts new file mode 100644 index 00000000..85898b51 --- /dev/null +++ b/javascript/test/evaluations.test.ts @@ -0,0 +1,31 @@ +import assert from 'assert' +import fs from 'fs' +import yaml from 'js-yaml' + +import parse from '../src/index.js' +import { testDataDir } from './testDataDir.js' + +type Evaluation = { + expression: string + tests: { + variables: string[] + result: boolean + }[] +} + +const evaluationsTest = yaml.load( + fs.readFileSync(`${testDataDir}/evaluations.yml`, 'utf-8') +) as Evaluation[] + +describe('Evaluations', () => { + for (const evaluation of evaluationsTest) { + describe(evaluation.expression, () => { + for (const test of evaluation.tests) { + it(`evaluates [${test.variables.join(', ')}] to ${test.result}`, () => { + const node = parse(evaluation.expression) + assert.strictEqual(node.evaluate(test.variables), test.result) + }) + } + }) + } +}) diff --git a/javascript/test/parsing.test.ts b/javascript/test/parsing.test.ts new file mode 100644 index 00000000..0dd38ed1 --- /dev/null +++ b/javascript/test/parsing.test.ts @@ -0,0 +1,25 @@ +import assert from 'assert' +import fs from 'fs' +import yaml from 'js-yaml' + +import parse from '../src/index.js' +import { testDataDir } from './testDataDir.js' + +type ParsingTest = { + expression: string + formatted: string +} + +const tests = yaml.load(fs.readFileSync(`${testDataDir}/parsing.yml`, 'utf-8')) as ParsingTest[] + +describe('Parsing', () => { + for (const test of tests) { + it(`parses "${test.expression}" into "${test.formatted}"`, () => { + const expression = parse(test.expression) + assert.strictEqual(expression.toString(), test.formatted) + + const expressionAgain = parse(expression.toString()) + assert.strictEqual(expressionAgain.toString(), test.formatted) + }) + } +}) diff --git a/javascript/test/tag_expression_parser_test.ts b/javascript/test/tag_expression_parser_test.ts deleted file mode 100644 index a9d9c8a6..00000000 --- a/javascript/test/tag_expression_parser_test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import assert from 'assert' - -import parse from '../src/index.js' - -describe('TagExpressionParser', () => { - describe('#parse', () => { - const tests = [ - ['', 'true'], - ['a and b', '( a and b )'], - ['a or b', '( a or b )'], - ['not a', 'not ( a )'], - ['( a and b ) or ( c and d )', '( ( a and b ) or ( c and d ) )'], - [ - 'not a or b and not c or not d or e and f', - '( ( ( not ( a ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )', - ], - [ - 'not a\\(\\) or b and not c or not d or e and f', - '( ( ( not ( a\\(\\) ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )', - ], - ['a\\\\ and b', '( a\\\\ and b )'], - ['\\a and b', '( a and b )'], - ['a\\ and b', '( a and b )'], - ['a and b\\', '( a and b )'], - ['( a and b\\\\)', '( a and b\\\\ )'], - ['a\\\\\\( and b\\\\\\)', '( a\\\\\\( and b\\\\\\) )'], - ['(a and \\b)', '( a and b )'], - // a or not b - ] - tests.forEach(function (inOut) { - it(inOut[0], function () { - const infix = inOut[0] - const expr = parse(infix) - assert.strictEqual(expr.toString(), inOut[1]) - - const roundtripTokens = expr.toString() - const roundtripExpr = parse(roundtripTokens) - assert.strictEqual(roundtripExpr.toString(), inOut[1]) - }) - }) - ;[ - ['@a @b or', 'Syntax error. Expected operator'], - ['@a and (@b not)', 'Syntax error. Expected operator'], - ['@a and (@b @c) or', 'Syntax error. Expected operator'], - ['@a and or', 'Syntax error. Expected operand'], - ['or or', 'Syntax error. Expected operand'], - ['a b', 'Syntax error. Expected operator'], - ['( a and b ) )', 'Syntax error. Unmatched )'], - ['( ( a and b )', 'Syntax error. Unmatched ('], - // a or not b - ].forEach(function (inOut) { - it(inOut[0] + ' fails', function () { - const infix = inOut[0] - try { - parse(infix) - throw new Error('Expected syntax error') - } catch (expected) { - assert.strictEqual(expected.message, inOut[1]) - } - }) - }) - - // evaluation - - it('evaluates not', function () { - const expr = parse('not x') - assert.strictEqual(expr.evaluate(['x']), false) - assert.strictEqual(expr.evaluate(['y']), true) - }) - - it('evaluates and', function () { - const expr = parse('x and y') - assert.strictEqual(expr.evaluate(['x', 'y']), true) - assert.strictEqual(expr.evaluate(['y']), false) - assert.strictEqual(expr.evaluate(['x']), false) - }) - - it('evaluates or', function () { - const expr = parse(' x or(y) ') - assert.strictEqual(expr.evaluate([]), false) - assert.strictEqual(expr.evaluate(['y']), true) - assert.strictEqual(expr.evaluate(['x']), true) - }) - - it('evaluates expressions with escaped chars', function () { - const expr = parse(' x\\(1\\) or(y\\(2\\)) ') - assert.strictEqual(expr.evaluate([]), false) - assert.strictEqual(expr.evaluate(['y(2)']), true) - assert.strictEqual(expr.evaluate(['x(1)']), true) - assert.strictEqual(expr.evaluate(['y']), false) - assert.strictEqual(expr.evaluate(['x']), false) - }) - - it('evaluates empty expressions to true', function () { - const expr = parse('') - assert.strictEqual(expr.evaluate([]), true) - assert.strictEqual(expr.evaluate(['y']), true) - assert.strictEqual(expr.evaluate(['x']), true) - }) - - it('evaluates expressions with escaped backslash', function () { - const expr = parse('x\\\\ or(y\\\\\\)) or(z\\\\)') - assert.strictEqual(expr.evaluate([]), false) - assert.strictEqual(expr.evaluate(['x\\']), true) - assert.strictEqual(expr.evaluate(['y\\)']), true) - assert.strictEqual(expr.evaluate(['z\\']), true) - assert.strictEqual(expr.evaluate(['x']), false) - assert.strictEqual(expr.evaluate(['y)']), false) - assert.strictEqual(expr.evaluate(['z']), false) - }) - - it('evaluates expressions with backslash', function () { - const expr = parse('\\x or y\\ or z\\') - assert.strictEqual(expr.evaluate([]), false) - assert.strictEqual(expr.evaluate(['x']), true) - assert.strictEqual(expr.evaluate(['y']), true) - assert.strictEqual(expr.evaluate(['z']), true) - assert.strictEqual(expr.evaluate(['\\x']), false) - assert.strictEqual(expr.evaluate(['y\\']), false) - assert.strictEqual(expr.evaluate(['z\\']), false) - }) - }) -}) diff --git a/javascript/test/testDataDir.ts b/javascript/test/testDataDir.ts new file mode 100644 index 00000000..68b60fed --- /dev/null +++ b/javascript/test/testDataDir.ts @@ -0,0 +1 @@ +export const testDataDir = process.env.TAG_EXPRESSIONS_TEST_DATA_DIR || '../testdata' diff --git a/ruby/lib/cucumber/tag_expressions/expressions.rb b/ruby/lib/cucumber/tag_expressions/expressions.rb index 9e3c0d67..2157b924 100644 --- a/ruby/lib/cucumber/tag_expressions/expressions.rb +++ b/ruby/lib/cucumber/tag_expressions/expressions.rb @@ -11,7 +11,11 @@ def evaluate(variables) end def to_s - @value.gsub(/\\/, "\\\\\\\\").gsub(/\(/, "\\(").gsub(/\)/, "\\)") + @value + .gsub(/\\/, "\\\\\\\\") + .gsub(/\(/, "\\(") + .gsub(/\)/, "\\)") + .gsub(/\s/, "\\ ") end end @@ -61,5 +65,15 @@ def to_s "( #{@left} and #{@right} )" end end + + class True + def evaluate(variables) + true + end + + def to_s + "true" + end + end end end diff --git a/ruby/lib/cucumber/tag_expressions/parser.rb b/ruby/lib/cucumber/tag_expressions/parser.rb index a9f855c8..1c09450b 100644 --- a/ruby/lib/cucumber/tag_expressions/parser.rb +++ b/ruby/lib/cucumber/tag_expressions/parser.rb @@ -18,9 +18,21 @@ def initialize end def parse(infix_expression) - process_tokens!(infix_expression) + expected_token_type = :operand + + tokens = tokenize(infix_expression) + return True.new if tokens.empty? + + tokens.each do |token| + if @operator_types[token] + expected_token_type = send("handle_#{@operator_types[token][:type]}", infix_expression, token, expected_token_type) + else + expected_token_type = handle_literal(infix_expression, token, expected_token_type) + end + end + while @operators.any? - raise 'Syntax error. Unmatched (' if @operators.last == '(' + raise %Q{Tag expression "#{infix_expression}" could not be parsed because of syntax error: Unmatched (.} if @operators.last == '(' push_expression(pop(@operators)) end expression = pop(@expressions) @@ -52,48 +64,36 @@ def precedence(token) @operator_types[token][:precedence] end - def tokens(infix_expression) + def tokenize(infix_expression) + tokens = [] escaped = false token = "" - result = [] infix_expression.chars.each do | ch | - if ch == '\\' && !escaped - escaped = true - else - if ch.match(/\s/) - if token.length > 0 - result.push(token) - token = "" - end + if escaped + if ch == '(' || ch == ')' || ch == '\\' || ch.match(/\s/) + token += ch + escaped = false else - if (ch == '(' || ch == ')') && !escaped - if token.length > 0 - result.push(token) - token = "" - end - result.push(ch) - else - token = token + ch - end + raise %Q{Tag expression "#{infix_expression}" could not be parsed because of syntax error: Illegal escape before "#{ch}".} end - escaped = false + elsif ch == '\\' + escaped = true + elsif ch == '(' || ch == ')' || ch.match(/\s/) + if token.length > 0 + tokens.push(token) + token = "" + end + if !ch.match(/\s/) + tokens.push(ch) + end + else + token += ch end end if token.length > 0 - result.push(token) - end - result - end - - def process_tokens!(infix_expression) - expected_token_type = :operand - tokens(infix_expression).each do |token| - if @operator_types[token] - expected_token_type = send("handle_#{@operator_types[token][:type]}", token, expected_token_type) - else - expected_token_type = handle_literal(token, expected_token_type) - end + tokens.push(token) end + tokens end def push_expression(token) @@ -112,14 +112,14 @@ def push_expression(token) ############################################################################ # Handlers # - def handle_unary_operator(token, expected_token_type) - check(expected_token_type, :operand) + def handle_unary_operator(infix_expression, token, expected_token_type) + check(infix_expression, expected_token_type, :operand) @operators.push(token) :operand end - def handle_binary_operator(token, expected_token_type) - check(expected_token_type, :operator) + def handle_binary_operator(infix_expression, token, expected_token_type) + check(infix_expression, expected_token_type, :operator) while @operators.any? && operator?(@operators.last) && lower_precedence?(token) push_expression(pop(@operators)) @@ -128,31 +128,31 @@ def handle_binary_operator(token, expected_token_type) :operand end - def handle_open_paren(token, expected_token_type) - check(expected_token_type, :operand) + def handle_open_paren(infix_expression, token, expected_token_type) + check(infix_expression, expected_token_type, :operand) @operators.push(token) :operand end - def handle_close_paren(_token, expected_token_type) - check(expected_token_type, :operator) + def handle_close_paren(infix_expression, _token, expected_token_type) + check(infix_expression, expected_token_type, :operator) while @operators.any? && @operators.last != '(' push_expression(pop(@operators)) end - raise 'Syntax error. Unmatched )' if @operators.empty? + raise %Q{Tag expression "#{infix_expression}" could not be parsed because of syntax error: Unmatched ).} if @operators.empty? pop(@operators) if @operators.last == '(' :operator end - def handle_literal(token, expected_token_type) - check(expected_token_type, :operand) + def handle_literal(infix_expression, token, expected_token_type) + check(infix_expression, expected_token_type, :operand) push_expression(token) :operator end - def check(expected_token_type, token_type) + def check(infix_expression, expected_token_type, token_type) if expected_token_type != token_type - raise "Syntax error. Expected #{expected_token_type}" + raise %Q{Tag expression "#{infix_expression}" could not be parsed because of syntax error: Expected #{expected_token_type}.} end end diff --git a/ruby/spec/errors_spec.rb b/ruby/spec/errors_spec.rb new file mode 100644 index 00000000..3af908d3 --- /dev/null +++ b/ruby/spec/errors_spec.rb @@ -0,0 +1,13 @@ +require 'cucumber/tag_expressions/parser' +require 'yaml' + +tests = YAML.load_file('../testdata/errors.yml') + +describe 'Errors' do + tests.each do |test| + it %Q{fails to parse "#{test['expression']}" with "#{test['error']}"} do + parser = Cucumber::TagExpressions::Parser.new + expect { parser.parse(test['expression']) }.to raise_error(test['error']) + end + end +end diff --git a/ruby/spec/evaluations_spec.rb b/ruby/spec/evaluations_spec.rb new file mode 100644 index 00000000..a7f8a46f --- /dev/null +++ b/ruby/spec/evaluations_spec.rb @@ -0,0 +1,17 @@ +require 'cucumber/tag_expressions/parser' +require 'yaml' + +evaluations = YAML.load_file('../testdata/evaluations.yml') + +describe 'Evaluations' do + evaluations.each do |evaluation| + context evaluation['expression'] do + evaluation['tests'].each do |test| + it "evaluates [#{test['variables'].join(', ')}] to #{test['result']}" do + parser = Cucumber::TagExpressions::Parser.new + expect(parser.parse(evaluation['expression']).evaluate(test['variables'])).to eq(test['result']) + end + end + end + end +end diff --git a/ruby/spec/expressions_spec.rb b/ruby/spec/expressions_spec.rb deleted file mode 100644 index 8fa8a8dd..00000000 --- a/ruby/spec/expressions_spec.rb +++ /dev/null @@ -1,84 +0,0 @@ -require 'cucumber/tag_expressions/parser' - -describe 'Expression node' do - shared_examples 'expression node' do |infix_expression, data| - data.each do |tags, result| - it "#{infix_expression.inspect} with variables: #{tags.inspect}'" do - expression = Cucumber::TagExpressions::Parser.new.parse(infix_expression) - expect(expression.evaluate(tags)).to eq(result) - end - end - end - - describe Cucumber::TagExpressions::Not do - context '#evaluate' do - infix_expression = 'not x' - data = [[%w(x), false], - [%w(y), true]] - include_examples 'expression node', infix_expression, data - end - end - - describe Cucumber::TagExpressions::And do - context '#evaluate' do - infix_expression = 'x and y' - data = [[%w(x y), true], - [%w(x), false], - [%w(y), false]] - include_examples 'expression node', infix_expression, data - end - end - - describe Cucumber::TagExpressions::Or do - context '#evaluate' do - infix_expression = 'x or y' - data = [[%w(), false], - [%w(x), true], - [%w(y), true], - [%w(x q), true], - [%w(x y), true]] - include_examples 'expression node', infix_expression, data - end - end - - describe Cucumber::TagExpressions::Or do - context '#evaluate' do - infix_expression = 'x\\(1\\) or (y\\(2\\))' - data = [[%w(), false], - [%w(x), false], - [%w(y), false], - [%w(x(1)), true], - [%w(y(2)), true]] - include_examples 'expression node', infix_expression, data - end - end - - describe Cucumber::TagExpressions::Or do - context '#evaluate' do - infix_expression = 'x\\\\ or (y\\\\\\)) or (z\\\\)' - data = [[%w(), false], - [%w(x), false], - [%w(y\)), false], - [%w(z), false], - [%w(x\\), true], - [%w(y\\\)), true], - [%w(z\\), true]] - include_examples 'expression node', infix_expression, data - end - end - - describe Cucumber::TagExpressions::Or do - context '#evaluate' do - infix_expression = '\\x or y\\ or z\\' - data = [[%w(), false], - [%w(\\x), false], - [%w(y\\), false], - [%w(z\\), false], - [%w(x), true], - [%w(y), true], - [%w(z), true]] - include_examples 'expression node', infix_expression, data - end - end - -end diff --git a/ruby/spec/parser_spec.rb b/ruby/spec/parser_spec.rb deleted file mode 100644 index c77cf694..00000000 --- a/ruby/spec/parser_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -require 'cucumber/tag_expressions/parser' - -describe Cucumber::TagExpressions::Parser do - correct_test_data = [ - ['a and b', '( a and b )'], - ['a or (b)', '( a or b )'], - ['not a', 'not ( a )'], - ['( a and b ) or ( c and d )', '( ( a and b ) or ( c and d ) )'], - ['not a or b and not c or not d or e and f', - '( ( ( not ( a ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )'], - ['not a\\(\\) or b and not c or not d or e and f', - '( ( ( not ( a\\(\\) ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )'], - ['a\\\\ and b', '( a\\\\ and b )'], - ['\\a and b\\ and c\\', '( ( a and b ) and c )'], - ['(a and b\\\\)', '( a and b\\\\ )'], - ['a\\\\\\( and b\\\\\\)', '( a\\\\\\( and b\\\\\\) )'] - ] - - error_test_data = [ - ['@a @b or', 'Syntax error. Expected operator'], - ['@a and (@b not)', 'Syntax error. Expected operator'], - ['@a and (@b @c) or', 'Syntax error. Expected operator'], - ['@a and or', 'Syntax error. Expected operand'], - ['or or', 'Syntax error. Expected operand'], - ['a b', 'Syntax error. Expected operator'], - ['( a and b ) )', 'Syntax error. Unmatched )'], - ['( ( a and b )', 'Syntax error. Unmatched ('], - ] - - context '#parse' do - context 'with correct test data' do - correct_test_data.each do |infix_expression, to_string| - parser = Cucumber::TagExpressions::Parser.new - it "parses correctly #{infix_expression.inspect}" do - expect(parser.parse(infix_expression).to_s).to eq(to_string) - end - end - end - - context 'with error test data' do - error_test_data.each do |infix_expression, message| - parser = Cucumber::TagExpressions::Parser.new - it "raises an error parsing #{infix_expression.inspect}" do - expect { parser.parse(infix_expression) } - .to raise_error(RuntimeError, message) - end - end - end - end -end diff --git a/ruby/spec/parsing_spec.rb b/ruby/spec/parsing_spec.rb new file mode 100644 index 00000000..57329658 --- /dev/null +++ b/ruby/spec/parsing_spec.rb @@ -0,0 +1,14 @@ +require 'cucumber/tag_expressions/parser' +require 'yaml' + +tests = YAML.load_file('../testdata/parsing.yml') + +describe 'Parsing' do + tests.each do |test| + it %Q{parses "#{test['expression']}" into "#{test['formatted']}"} do + parser = Cucumber::TagExpressions::Parser.new + expression = parser.parse(test['expression']) + expect(expression.to_s).to eq(test['formatted']) + end + end +end diff --git a/testdata/errors.yml b/testdata/errors.yml new file mode 100644 index 00000000..389e782d --- /dev/null +++ b/testdata/errors.yml @@ -0,0 +1,22 @@ +- expression: '@a @b or' + error: 'Tag expression "@a @b or" could not be parsed because of syntax error: Expected operator.' +- expression: '@a and (@b not)' + error: 'Tag expression "@a and (@b not)" could not be parsed because of syntax error: Expected operator.' +- expression: '@a and (@b @c) or' + error: 'Tag expression "@a and (@b @c) or" could not be parsed because of syntax error: Expected operator.' +- expression: '@a and or' + error: 'Tag expression "@a and or" could not be parsed because of syntax error: Expected operand.' +- expression: 'or or' + error: 'Tag expression "or or" could not be parsed because of syntax error: Expected operand.' +- expression: 'a and or' + error: 'Tag expression "a and or" could not be parsed because of syntax error: Expected operand.' +- expression: 'a b' + error: 'Tag expression "a b" could not be parsed because of syntax error: Expected operator.' +- expression: '( a and b ) )' + error: 'Tag expression "( a and b ) )" could not be parsed because of syntax error: Unmatched ).' +- expression: '( ( a and b )' + error: 'Tag expression "( ( a and b )" could not be parsed because of syntax error: Unmatched (.' +- expression: 'x or \y or z' + error: 'Tag expression "x or \y or z" could not be parsed because of syntax error: Illegal escape before "y".' +- expression: 'x\ or y' + error: 'Tag expression "x\ or y" could not be parsed because of syntax error: Expected operator.' diff --git a/testdata/evaluations.yml b/testdata/evaluations.yml new file mode 100644 index 00000000..a84fb3ca --- /dev/null +++ b/testdata/evaluations.yml @@ -0,0 +1,59 @@ +- expression: 'not x' + tests: + - variables: ['x'] + result: false + - variables: ['y'] + result: true +- expression: 'x and y' + tests: + - variables: ['x', 'y'] + result: true + - variables: ['x'] + result: false + - variables: ['y'] + result: false + +- expression: 'x or y' + tests: + - variables: [] + result: false + - variables: ['x', 'y'] + result: true + - variables: ['x'] + result: true + - variables: ['y'] + result: true +- expression: 'x\(1\) or y\(2\)' + tests: + - variables: ['x(1)'] + result: true + - variables: ['y(2)'] + result: true +- expression: 'x\\ or y\\\) or z\\' + tests: + - variables: ['x\'] + result: true + - variables: ['y\)'] + result: true + - variables: ['z\'] + result: true + - variables: ['x'] + result: false + - variables: ['y)'] + result: false + - variables: ['z'] + result: false +- expression: '\\x or y\\ or z\\' + tests: + - variables: ['\x'] + result: true + - variables: ['y\'] + result: true + - variables: ['z\'] + result: true + - variables: ['x'] + result: false + - variables: ['y'] + result: false + - variables: ['z'] + result: false diff --git a/testdata/parsing.yml b/testdata/parsing.yml new file mode 100644 index 00000000..3014f5b3 --- /dev/null +++ b/testdata/parsing.yml @@ -0,0 +1,40 @@ +- expression: '' + formatted: 'true' +- expression: 'a and b' + formatted: '( a and b )' +- expression: 'a or b' + formatted: '( a or b )' +- expression: 'not a' + formatted: 'not ( a )' +- expression: 'a and b and c' + formatted: '( ( a and b ) and c )' +- expression: '( a and b ) or ( c and d )' + formatted: '( ( a and b ) or ( c and d ) )' +- expression: 'not a or b and not c or not d or e and f' + formatted: '( ( ( not ( a ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )' +- expression: 'not a\(\) or b and not c or not d or e and f' + formatted: '( ( ( not ( a\(\) ) or ( b and not ( c ) ) ) or not ( d ) ) or ( e and f ) )' +- expression: 'a\\ and b' + formatted: '( a\\ and b )' +- expression: '\\a and b' + formatted: '( \\a and b )' +- expression: 'a\\ and b' + formatted: '( a\\ and b )' +- expression: 'a and b\\' + formatted: '( a and b\\ )' +- expression: '( a and b\\\\)' + formatted: '( a and b\\\\ )' +- expression: 'a\\\( and b\\\)' + formatted: '( a\\\( and b\\\) )' +- expression: '(a and \\b)' + formatted: '( a and \\b )' +- expression: 'x or(y) ' + formatted: '( x or y )' +- expression: 'x\(1\) or(y\(2\))' + formatted: '( x\(1\) or y\(2\) )' +- expression: '\\x or y\\ or z\\' + formatted: '( ( \\x or y\\ ) or z\\ )' +- expression: 'x\\ or(y\\\)) or(z\\)' + formatted: '( ( x\\ or y\\\) ) or z\\ )' +- expression: 'x\ or y' + formatted: '( x\ or y )'