Skip to content

Commit 3efc606

Browse files
authored
[Core] Generate valid parameter names in snippets (#2029)
Parameter names were taken directly form the parameter names used in the cucumber expression. By passing these through the identifier generator we can ensure that these are valid identifiers. To reflect this new usage the `FunctionNameGenerator` was renamed to `IdentifierGenerator`. The identifier generator uses a strategy pattern to join words into method and parameter names. Parameter names are always generated using the camel case strategy. While long sentence like method names may be more readable in snake case, it is expected that people will still want to follow the java conventions for parameters. To avoid issues where a parameter name were already in camel case or underscore case sentences are also split on camel case and underscores (in addition to whitespace). This also required using all lower case in the `UnderscoreJoiner` which has since been renamed to `SnakeCaseJoiner`. Fixes: #2028
1 parent 1fdf68f commit 3efc606

File tree

10 files changed

+195
-100
lines changed

10 files changed

+195
-100
lines changed

core/src/main/java/io/cucumber/core/snippets/CamelCaseJoiner.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package io.cucumber.core.snippets;
22

3+
import java.util.List;
4+
35
final class CamelCaseJoiner implements Joiner {
46

57
@Override
6-
public String concatenate(String[] words) {
8+
public String concatenate(List<String> words) {
79
StringBuilder functionName = new StringBuilder();
810
boolean firstWord = true;
911
for (String word : words) {

core/src/main/java/io/cucumber/core/snippets/FunctionNameGenerator.java

Lines changed: 0 additions & 37 deletions
This file was deleted.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package io.cucumber.core.snippets;
2+
3+
import java.util.List;
4+
import java.util.regex.Pattern;
5+
import java.util.stream.Collectors;
6+
import java.util.stream.Stream;
7+
8+
import static java.lang.Character.isJavaIdentifierStart;
9+
10+
final class IdentifierGenerator {
11+
12+
private static final String BETWEEN_LOWER_AND_UPPER = "(?<=\\p{Ll})(?=\\p{Lu})";
13+
private static final String BEFORE_UPPER_AND_LOWER = "(?<=\\p{L})(?=\\p{Lu}\\p{Ll})";
14+
private static final Pattern SPLIT_CAMEL_CASE = Pattern
15+
.compile(BETWEEN_LOWER_AND_UPPER + "|" + BEFORE_UPPER_AND_LOWER);
16+
private static final Pattern SPLIT_WHITESPACE = Pattern.compile("\\s");
17+
private static final Pattern SPLIT_UNDERSCORE = Pattern.compile("_");
18+
19+
private static final char SUBST = ' ';
20+
private final Joiner joiner;
21+
22+
IdentifierGenerator(Joiner joiner) {
23+
this.joiner = joiner;
24+
}
25+
26+
String generate(String sentence) {
27+
if (sentence.isEmpty()) {
28+
throw new IllegalArgumentException("Cannot create function name from empty sentence");
29+
}
30+
31+
List<String> words = Stream.of(sentence)
32+
.map(this::replaceIllegalCharacters)
33+
.map(String::trim)
34+
.flatMap(SPLIT_WHITESPACE::splitAsStream)
35+
.flatMap(SPLIT_CAMEL_CASE::splitAsStream)
36+
.flatMap(SPLIT_UNDERSCORE::splitAsStream)
37+
.collect(Collectors.toList());
38+
39+
return joiner.concatenate(words);
40+
}
41+
42+
private String replaceIllegalCharacters(String sentence) {
43+
StringBuilder sanitized = new StringBuilder();
44+
sanitized.append(isJavaIdentifierStart(sentence.charAt(0)) ? sentence.charAt(0) : SUBST);
45+
for (int i = 1; i < sentence.length(); i++) {
46+
if (Character.isJavaIdentifierPart(sentence.charAt(i))) {
47+
sanitized.append(sentence.charAt(i));
48+
} else if (sanitized.charAt(sanitized.length() - 1) != SUBST && i != sentence.length() - 1) {
49+
sanitized.append(SUBST);
50+
}
51+
}
52+
return sanitized.toString();
53+
}
54+
55+
}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package io.cucumber.core.snippets;
22

3+
import java.util.List;
4+
35
interface Joiner {
46

5-
String concatenate(String[] words);
7+
String concatenate(List<String> words);
68

79
}

core/src/main/java/io/cucumber/core/snippets/UnderscoreJoiner.java renamed to core/src/main/java/io/cucumber/core/snippets/SnakeCaseJoiner.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
package io.cucumber.core.snippets;
22

3-
class UnderscoreJoiner implements Joiner {
3+
import java.util.List;
4+
5+
class SnakeCaseJoiner implements Joiner {
46

57
@Override
6-
public String concatenate(String[] words) {
8+
public String concatenate(List<String> words) {
79
StringBuilder functionName = new StringBuilder();
810
boolean firstWord = true;
911
for (String word : words) {
1012
if (firstWord) {
11-
word = word.toLowerCase();
13+
firstWord = false;
1214
} else {
1315
functionName.append('_');
1416
}
15-
functionName.append(word);
16-
firstWord = false;
17+
functionName.append(word.toLowerCase());
1718
}
1819
return functionName.toString();
1920
}

core/src/main/java/io/cucumber/core/snippets/SnippetGenerator.java

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,20 @@
1212
import io.cucumber.plugin.event.StepArgument;
1313

1414
import java.lang.reflect.Type;
15-
import java.util.ArrayList;
1615
import java.util.LinkedHashMap;
1716
import java.util.List;
1817
import java.util.Map;
1918
import java.util.regex.Pattern;
19+
import java.util.stream.Collectors;
20+
import java.util.stream.Stream;
21+
22+
import static io.cucumber.core.snippets.SnippetType.CAMELCASE;
2023

2124
public final class SnippetGenerator {
2225

23-
@SuppressWarnings("RegExpRedundantEscape") // Android can't parse unescaped
24-
// braces.
25-
private static final ArgumentPattern[] DEFAULT_ARGUMENT_PATTERNS = new ArgumentPattern[] {
26-
new ArgumentPattern(Pattern.compile("\\{.*?\\}"))
27-
};
26+
// Android can't parse unescaped braces.
27+
@SuppressWarnings("RegExpRedundantEscape")
28+
private static final ArgumentPattern DEFAULT_ARGUMENT_PATTERN = new ArgumentPattern(Pattern.compile("\\{.*?\\}"));
2829

2930
private static final String REGEXP_HINT = "Write code here that turns the phrase above into concrete actions";
3031

@@ -38,35 +39,50 @@ public SnippetGenerator(Snippet snippet, ParameterTypeRegistry parameterTypeRegi
3839

3940
public List<String> getSnippet(Step step, SnippetType snippetType) {
4041
List<GeneratedExpression> generatedExpressions = generator.generateExpressions(step.getText());
41-
List<String> snippets = new ArrayList<>(generatedExpressions.size());
42-
FunctionNameGenerator functionNameGenerator = new FunctionNameGenerator(snippetType.joiner());
43-
for (GeneratedExpression expression : generatedExpressions) {
44-
snippets.add(snippet.template().format(new String[] {
45-
sanitize(
46-
step.getType().isGivenWhenThen() ? step.getKeyword() : step.getPreviousGivenWhenThenKeyword()),
47-
snippet.escapePattern(expression.getSource()),
48-
functionName(expression.getSource(), functionNameGenerator),
49-
snippet.arguments(arguments(step, expression.getParameterNames(), expression.getParameterTypes())),
50-
REGEXP_HINT,
51-
tableHint(step)
52-
}));
53-
}
42+
IdentifierGenerator functionNameGenerator = new IdentifierGenerator(snippetType.joiner());
43+
IdentifierGenerator parameterNameGenerator = new IdentifierGenerator(CAMELCASE.joiner());
44+
return generatedExpressions.stream()
45+
.map(expression -> createSnippet(step, functionNameGenerator, parameterNameGenerator, expression))
46+
.collect(Collectors.toList());
47+
}
48+
49+
private String createSnippet(
50+
Step step, IdentifierGenerator functionNameGenerator,
51+
IdentifierGenerator parameterNameGenerator, GeneratedExpression expression
52+
) {
53+
String keyword = step.getType().isGivenWhenThen() ? step.getKeyword() : step.getPreviousGivenWhenThenKeyword();
54+
String source = expression.getSource();
55+
String functionName = functionName(source, functionNameGenerator);
56+
List<String> parameterNames = toParameterNames(expression, parameterNameGenerator);
57+
Map<String, Type> arguments = arguments(step, parameterNames, expression.getParameterTypes());
58+
return snippet.template().format(new String[] {
59+
sanitize(keyword),
60+
snippet.escapePattern(source),
61+
functionName,
62+
snippet.arguments(arguments),
63+
REGEXP_HINT,
64+
tableHint(step)
65+
});
66+
}
5467

55-
return snippets;
68+
private List<String> toParameterNames(GeneratedExpression expression, IdentifierGenerator parameterNameGenerator) {
69+
List<String> parameterNames = expression.getParameterNames();
70+
return parameterNames.stream()
71+
.map(parameterNameGenerator::generate)
72+
.collect(Collectors.toList());
5673
}
5774

5875
private static String sanitize(String keyWord) {
5976
return keyWord.replaceAll("[\\s',!]", "");
6077
}
6178

62-
private String functionName(String sentence, FunctionNameGenerator functionNameGenerator) {
63-
if (functionNameGenerator == null) {
64-
return null;
65-
}
66-
for (ArgumentPattern argumentPattern : argumentPatterns()) {
67-
sentence = argumentPattern.replaceMatchesWithSpace(sentence);
68-
}
69-
return functionNameGenerator.generateFunctionName(sentence);
79+
private String functionName(String sentence, IdentifierGenerator functionNameGenerator) {
80+
return Stream.of(sentence)
81+
.map(DEFAULT_ARGUMENT_PATTERN::replaceMatchesWithSpace)
82+
.map(functionNameGenerator::generate)
83+
.filter(s -> !s.isEmpty())
84+
.findFirst()
85+
.orElseGet(() -> functionNameGenerator.generate(sentence));
7086
}
7187

7288
private Map<String, Type> arguments(Step step, List<String> parameterNames, List<ParameterType<?>> parameterTypes) {
@@ -102,10 +118,6 @@ private String tableHint(Step step) {
102118
return "";
103119
}
104120

105-
private ArgumentPattern[] argumentPatterns() {
106-
return DEFAULT_ARGUMENT_PATTERNS;
107-
}
108-
109121
private String parameterName(String name, List<String> parameterNames) {
110122
if (!parameterNames.contains(name)) {
111123
return name;

core/src/main/java/io/cucumber/core/snippets/SnippetType.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package io.cucumber.core.snippets;
22

33
public enum SnippetType {
4-
UNDERSCORE(new UnderscoreJoiner()),
4+
UNDERSCORE(new SnakeCaseJoiner()),
55
CAMELCASE(new CamelCaseJoiner());
66

77
private final Joiner joiner;

core/src/test/java/io/cucumber/core/snippets/FunctionNameGeneratorTest.java renamed to core/src/test/java/io/cucumber/core/snippets/IdentifierGeneratorTest.java

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,76 +9,100 @@
99
import static org.junit.jupiter.api.Assertions.assertAll;
1010
import static org.junit.jupiter.api.Assertions.assertThrows;
1111

12-
class FunctionNameGeneratorTest {
12+
class IdentifierGeneratorTest {
1313

14-
private final FunctionNameGenerator underscore = new FunctionNameGenerator(SnippetType.UNDERSCORE.joiner());
15-
private final FunctionNameGenerator camelCase = new FunctionNameGenerator(SnippetType.CAMELCASE.joiner());
14+
private final IdentifierGenerator snakeCase = new IdentifierGenerator(SnippetType.UNDERSCORE.joiner());
15+
private final IdentifierGenerator camelCase = new IdentifierGenerator(SnippetType.CAMELCASE.joiner());
1616

1717
@Test
1818
void testSanitizeEmptyFunctionName() {
19-
Executable testMethod = () -> underscore.generateFunctionName("");
19+
Executable testMethod = () -> snakeCase.generate("");
2020
IllegalArgumentException expectedThrown = assertThrows(IllegalArgumentException.class, testMethod);
2121
assertThat(expectedThrown.getMessage(), is(equalTo("Cannot create function name from empty sentence")));
2222
}
2323

2424
@Test
2525
void testSanitizeFunctionName() {
26-
assertFunctionNames(
26+
assertIdentifiers(
2727
"test_function_123",
2828
"testFunction123",
2929
".test function 123 ");
3030
}
3131

32-
private void assertFunctionNames(String expectedUnderscore, String expectedCamelCase, String sentence) {
32+
@Test
33+
void testSanitizeParameterName() {
34+
assertIdentifiers(
35+
"country_code",
36+
"countryCode",
37+
"country-code");
38+
}
39+
40+
@Test
41+
void preservesCamelCase() {
42+
assertIdentifiers(
43+
"country_code",
44+
"countryCode",
45+
"countryCode");
46+
}
47+
48+
@Test
49+
void preservesSnakeCase() {
50+
assertIdentifiers(
51+
"country_code",
52+
"countryCode",
53+
"country_code");
54+
}
55+
56+
private void assertIdentifiers(String expectedSnakeCase, String expectedCamelCase, String sentence) {
3357
assertAll(
34-
() -> assertThat(underscore.generateFunctionName(sentence), is(equalTo(expectedUnderscore))),
35-
() -> assertThat(camelCase.generateFunctionName(sentence), is(equalTo(expectedCamelCase))));
58+
() -> assertThat(snakeCase.generate(sentence), is(equalTo(expectedSnakeCase))),
59+
() -> assertThat(camelCase.generate(sentence), is(equalTo(expectedCamelCase))));
3660
}
3761

3862
@Test
3963
void sanitizes_simple_sentence() {
40-
assertFunctionNames(
64+
assertIdentifiers(
4165
"i_am_a_function_name",
4266
"iAmAFunctionName",
4367
"I am a function name");
4468
}
4569

4670
@Test
4771
void sanitizes_sentence_with_multiple_spaces() {
48-
assertFunctionNames(
72+
assertIdentifiers(
4973
"i_am_a_function_name",
5074
"iAmAFunctionName",
5175
"I am a function name");
5276
}
5377

5478
@Test
5579
void sanitizes_pascal_case_word() {
56-
assertFunctionNames(
57-
"function_name_with_pascalCase_word",
80+
assertIdentifiers(
81+
"function_name_with_pascal_case_word",
5882
"functionNameWithPascalCaseWord",
5983
"Function name with pascalCase word");
6084
}
6185

6286
@Test
6387
void sanitizes_camel_case_word() {
64-
assertFunctionNames(
65-
"function_name_with_CamelCase_word",
88+
assertIdentifiers(
89+
"function_name_with_camel_case_word",
6690
"functionNameWithCamelCaseWord",
6791
"Function name with CamelCase word");
6892
}
6993

7094
@Test
7195
void sanitizes_acronyms() {
72-
assertFunctionNames(
73-
"function_name_with_multi_char_acronym_HTTP_Server",
96+
assertIdentifiers(
97+
"function_name_with_multi_char_acronym_http_server",
7498
"functionNameWithMultiCharAcronymHTTPServer",
7599
"Function name with multi char acronym HTTP Server");
76100
}
77101

78102
@Test
79103
void sanitizes_two_char_acronym() {
80-
assertFunctionNames(
81-
"function_name_with_two_char_acronym_US",
104+
assertIdentifiers(
105+
"function_name_with_two_char_acronym_us",
82106
"functionNameWithTwoCharAcronymUS",
83107
"Function name with two char acronym US");
84108
}

0 commit comments

Comments
 (0)