Support modify option names

- New OptionNameModifier which is just a Function<String,String> to
  modify a name.
- Can be defined per option in CommandRegistration.
- Can be defined as global default as bean.
- Default implementation for common case types is enabled via boot's
  config props under spring.shell.option.naming.case-type
- Support facilities for camel, kebab, snake and pascal conversions.
- Fixes #621
This commit is contained in:
Janne Valkealahti
2023-01-15 10:02:55 +00:00
parent 448c507ce9
commit a04091c08f
14 changed files with 747 additions and 5 deletions

View File

@@ -145,6 +145,14 @@ public interface CommandRegistration {
public interface BuilderSupplier extends Supplier<Builder> {
}
/**
* Interface used to modify option long name. Usual use case is i.e. making
* conversion from a {@code camelCase} to {@code snake-case}.
*/
@FunctionalInterface
public interface OptionNameModifier extends Function<String, String> {
}
/**
* Spec defining an option.
*/
@@ -247,6 +255,14 @@ public interface CommandRegistration {
*/
OptionSpec completion(CompletionResolver completion);
/**
* Define an option name modifier.
*
* @param modifier the option name modifier function
* @return option spec for chaining
*/
OptionSpec nameModifier(Function<String, String> modifier);
/**
* Return a builder for chaining.
*
@@ -673,6 +689,16 @@ public interface CommandRegistration {
*/
Builder hidden(boolean hidden);
/**
* Provides a global option name modifier. Will be used with all options to
* modify long names. Usual use case is to enforce naming convention i.e. to
* have {@code snake-case} for all names.
*
* @param modifier to modifier to change option name
* @return builder for chaining
*/
Builder defaultOptionNameModifier(Function<String, String> modifier);
/**
* Define an option what this command should user for. Can be used multiple
* times.
@@ -738,6 +764,7 @@ public interface CommandRegistration {
private Integer arityMax;
private String label;
private CompletionResolver completion;
private Function<String, String> optionNameModifier;
DefaultOptionSpec(BaseBuilder builder) {
this.builder = builder;
@@ -842,6 +869,12 @@ public interface CommandRegistration {
return this;
}
@Override
public OptionSpec nameModifier(Function<String, String> modifier) {
this.optionNameModifier = modifier;
return this;
}
@Override
public Builder and() {
return builder;
@@ -890,6 +923,17 @@ public interface CommandRegistration {
public CompletionResolver getCompletion() {
return completion;
}
@Nullable
public Function<String, String> getOptionNameModifier() {
if (optionNameModifier != null) {
return optionNameModifier;
}
if (builder.defaultOptionNameModifier != null) {
return builder.defaultOptionNameModifier;
}
return null;
}
}
static class DefaultTargetSpec implements TargetSpec {
@@ -1142,9 +1186,16 @@ public interface CommandRegistration {
@Override
public List<CommandOption> getOptions() {
List<CommandOption> options = optionSpecs.stream()
.map(o -> CommandOption.of(o.getLongNames(), o.getShortNames(), o.getDescription(), o.getType(),
o.isRequired(), o.getDefaultValue(), o.getPosition(), o.getArityMin(), o.getArityMax(),
o.getLabel(), o.getCompletion()))
.map(o -> {
String[] longNames = o.getLongNames();
Function<String, String> modifier = o.getOptionNameModifier();
if (modifier != null) {
longNames = Arrays.stream(longNames).map(modifier).toArray(String[]::new);
}
return CommandOption.of(longNames, o.getShortNames(), o.getDescription(), o.getType(),
o.isRequired(), o.getDefaultValue(), o.getPosition(), o.getArityMin(), o.getArityMax(),
o.getLabel(), o.getCompletion());
})
.collect(Collectors.toList());
if (helpOptionsSpec != null) {
String[] longNames = helpOptionsSpec.longNames != null ? helpOptionsSpec.longNames : null;
@@ -1235,6 +1286,7 @@ public interface CommandRegistration {
private DefaultExitCodeSpec exitCodeSpec;
private DefaultErrorHandlingSpec errorHandlingSpec;
private DefaultHelpOptionsSpec helpOptionsSpec;
private Function<String, String> defaultOptionNameModifier;
@Override
public Builder command(String... commands) {
@@ -1283,6 +1335,12 @@ public interface CommandRegistration {
return this;
}
@Override
public Builder defaultOptionNameModifier(Function<String,String> modifier) {
this.defaultOptionNameModifier = modifier;
return this;
}
@Override
public OptionSpec withOption() {
DefaultOptionSpec spec = new DefaultOptionSpec(this);

View File

@@ -0,0 +1,163 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://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.springframework.shell.command.support;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.springframework.shell.command.CommandRegistration.OptionNameModifier;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
/**
* Support facilities for {@link OptionNameModifier} providing common naming
* types.
*
* @author Janne Valkealahti
*/
public abstract class OptionNameModifierSupport {
public static final OptionNameModifier NOOP = name -> name;
public static final OptionNameModifier CAMELCASE = name -> toCamelCase(name);
public static final OptionNameModifier SNAKECASE = name -> toSnakeCase(name);
public static final OptionNameModifier KEBABCASE = name -> toKebabCase(name);
public static final OptionNameModifier PASCALCASE = name -> toPascalCase(name);
private static final Pattern PATTERN = Pattern
.compile("[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+");
/**
* Convert given name to {@code camelCase}.
*
* @param name the name to modify
* @return a modified name as camel case
*/
public static String toCamelCase(String name) {
return toCapitalizeCase(name, false, ' ', '-', '_');
}
/**
* Convert given name to {@code snake_case}.
*
* @param name the name to modify
* @return a modified name as snake case
*/
public static String toSnakeCase(String name) {
return matchJoin(name, "_");
}
/**
* Convert given name to {@code kebab-case}.
*
* @param name the name to modify
* @return a modified name as kebab case
*/
public static String toKebabCase(String name) {
return matchJoin(name, "-");
}
/**
* Convert given name to {@code PascalCase}.
*
* @param name the name to modify
* @return a modified name as pascal case
*/
public static String toPascalCase(String name) {
return toCapitalizeCase(name, true, ' ', '-', '_');
}
private static String matchJoin(String name, String delimiter) {
Matcher matcher = PATTERN.matcher(name);
List<String> matches = new ArrayList<>();
while (matcher.find()) {
String group = matcher.group();
matches.add(group);
}
return matches.stream().map(x -> x.toLowerCase()).collect(Collectors.joining(delimiter));
}
private static String toCapitalizeCase(String name, final boolean capitalizeFirstLetter, final char... delimiters) {
if (!StringUtils.hasText(name)) {
return name;
}
String nameL = name.toLowerCase();
final int strLen = nameL.length();
final int[] newCodePoints = new int[strLen];
final Set<Integer> delimiterSet = toDelimiterSet(delimiters);
int outOffset = 0;
boolean capitalizeNext = capitalizeFirstLetter;
boolean delimiterFound = false;
for (int index = 0; index < strLen;) {
final int codePoint = nameL.codePointAt(index);
if (delimiterSet.contains(codePoint)) {
capitalizeNext = outOffset != 0;
index += Character.charCount(codePoint);
delimiterFound = true;
} else if (capitalizeNext || outOffset == 0 && capitalizeFirstLetter) {
final int titleCaseCodePoint = Character.toTitleCase(codePoint);
newCodePoints[outOffset++] = titleCaseCodePoint;
index += Character.charCount(titleCaseCodePoint);
capitalizeNext = false;
} else {
newCodePoints[outOffset++] = codePoint;
index += Character.charCount(codePoint);
}
}
if (!delimiterFound) {
if (capitalizeFirstLetter) {
return name.substring(0, 1).toUpperCase() + name.substring(1, name.length());
}
else {
return name.substring(0, 1).toLowerCase() + name.substring(1, name.length());
}
}
return new String(newCodePoints, 0, outOffset);
}
/**
* Converts an array of delimiters to a hash set of code points. Code point of
* space(32) is added as the default value. The generated hash set provides O(1)
* lookup time.
*
* @param delimiters set of characters to determine capitalization, null means
* whitespace
* @return Integers of code points
*/
private static Set<Integer> toDelimiterSet(final char[] delimiters) {
final Set<Integer> delimiterHashSet = new HashSet<>();
delimiterHashSet.add(Character.codePointAt(new char[]{' '}, 0));
if (ObjectUtils.isEmpty(delimiters)) {
return delimiterHashSet;
}
for (int index = 0; index < delimiters.length; index++) {
delimiterHashSet.add(Character.codePointAt(delimiters, index));
}
return delimiterHashSet;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 the original author or authors.
* Copyright 2022-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -543,4 +543,44 @@ public class CommandRegistrationTests extends AbstractCommandTests {
assertThat(registration.getOptions().get(0).getLongNames()).containsExactly("help");
assertThat(registration.getOptions().get(0).getShortNames()).containsExactly('h');
}
@Test
void testOptionNameModifierInOption() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.withOption()
.longNames("arg1")
.nameModifier(name -> "x" + name)
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0)).satisfies(option -> {
assertThat(option).isNotNull();
assertThat(option.getLongNames()).isEqualTo(new String[] { "xarg1" });
});
}
@Test
void testOptionNameModifierFromDefault() {
CommandRegistration registration = CommandRegistration.builder()
.defaultOptionNameModifier(name -> "x" + name)
.command("command1")
.withOption()
.longNames("arg1")
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0)).satisfies(option -> {
assertThat(option).isNotNull();
assertThat(option.getLongNames()).isEqualTo(new String[] { "xarg1" });
});
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://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.springframework.shell.command.support;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.assertj.core.api.Assertions.assertThat;
class OptionNameModifierSupportTests {
@ParameterizedTest
@CsvSource({
"camel case,camelCase",
"camel_case,camelCase",
"camel-case,camelCase",
"camelCase,camelCase",
"CamelCase,camelCase"
})
void testCamel(String name, String expected) {
assertThat(camel(name)).isEqualTo(expected);
}
@ParameterizedTest
@CsvSource({
"pascal-case,PascalCase",
"pascal_case,PascalCase",
"pascalCase,PascalCase",
"PascalCase,PascalCase"
})
void testPascal(String name, String expected) {
assertThat(pascal(name)).isEqualTo(expected);
}
@ParameterizedTest
@CsvSource({
"kebabCase,kebab-case",
"kebab_case,kebab-case",
"kebab_Case,kebab-case",
"Kebab_case,kebab-case"
})
void testKebab(String name, String expected) {
assertThat(kebab(name)).isEqualTo(expected);
}
@ParameterizedTest
@CsvSource({
"snakeCase,snake_case",
"snake_case,snake_case",
"snake_Case,snake_case",
"Snake_case,snake_case"
})
void testSnake(String name, String expected) {
assertThat(snake(name)).isEqualTo(expected);
}
private String camel(String name) {
return OptionNameModifierSupport.toCamelCase(name);
}
private String kebab(String name) {
return OptionNameModifierSupport.toKebabCase(name);
}
private String snake(String name) {
return OptionNameModifierSupport.toSnakeCase(name);
}
private String pascal(String name) {
return OptionNameModifierSupport.toPascalCase(name);
}
}