Add DurationArgument for parsing java.time.Duration (#330)

Co-authored-by: Frank van der Heijden <frank.boekanier@gmail.com>
This commit is contained in:
Tadhg Boyle 2022-01-04 21:46:53 -07:00 committed by Jason
parent 52f3e2c679
commit c2b3145d4d
5 changed files with 486 additions and 0 deletions

View file

@ -0,0 +1,287 @@
//
// MIT License
//
// Copyright (c) 2021 Alexander Söderberg & Contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
package cloud.commandframework.arguments.standard;
import cloud.commandframework.ArgumentDescription;
import cloud.commandframework.arguments.CommandArgument;
import cloud.commandframework.arguments.parser.ArgumentParseResult;
import cloud.commandframework.arguments.parser.ArgumentParser;
import cloud.commandframework.captions.CaptionVariable;
import cloud.commandframework.captions.StandardCaptionKeys;
import cloud.commandframework.context.CommandContext;
import cloud.commandframework.exceptions.parsing.NoInputProvidedException;
import cloud.commandframework.exceptions.parsing.ParserException;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Queue;
import java.util.function.BiFunction;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
/**
* Parses <code>java.time.Duration</code> from a <code>1d2h3m4s</code> format.
* @param <C> Command sender type
* @since 1.7.0
*/
@SuppressWarnings("unused")
public final class DurationArgument<C> extends CommandArgument<C, Duration> {
/**
* Matches durations in the format of: <code>2d15h7m12s</code>
*/
private static final Pattern DURATION_PATTERN = Pattern.compile("(([1-9][0-9]+|[1-9])[dhms])");
private DurationArgument(
final boolean required,
final @NonNull String name,
final @NonNull String defaultValue,
final @Nullable BiFunction<@NonNull CommandContext<C>, @NonNull String,
@NonNull List<@NonNull String>> suggestionsProvider,
final @NonNull ArgumentDescription defaultDescription
) {
super(
required,
name,
new DurationArgument.DurationParser<>(),
defaultValue,
Duration.class,
suggestionsProvider,
defaultDescription
);
}
/**
* Create a new builder
*
* @param name Name of the component
* @param <C> Command sender type
* @return Created builder
* @since 1.7.0
*/
public static <C> @NonNull Builder<C> newBuilder(final @NonNull String name) {
return new Builder<>(name);
}
/**
* Create a new required command component
*
* @param name Component name
* @param <C> Command sender type
* @return Created component
* @since 1.7.0
*/
public static <C> @NonNull CommandArgument<C, Duration> of(final @NonNull String name) {
return DurationArgument.<C>newBuilder(name).asRequired().build();
}
/**
* Create a new optional command component
*
* @param name Component name
* @param <C> Command sender type
* @return Created component
* @since 1.7.0
*/
public static <C> @NonNull CommandArgument<C, Duration> optional(final @NonNull String name) {
return DurationArgument.<C>newBuilder(name).asOptional().build();
}
/**
* Create a new required command component with a default value
*
* @param name Component name
* @param defaultDuration Default duration
* @param <C> Command sender type
* @return Created component
* @since 1.7.0
*/
public static <C> @NonNull CommandArgument<C, Duration> optional(
final @NonNull String name,
final @NonNull String defaultDuration
) {
return DurationArgument.<C>newBuilder(name).asOptionalWithDefault(defaultDuration).build();
}
public static final class Builder<C> extends CommandArgument.Builder<C, Duration> {
private Builder(final @NonNull String name) {
super(Duration.class, name);
}
/**
* Builder a new boolean component
*
* @return Constructed component
* @since 1.7.0
*/
@Override
public @NonNull DurationArgument<C> build() {
return new DurationArgument<>(this.isRequired(), this.getName(), this.getDefaultValue(),
this.getSuggestionsProvider(), this.getDefaultDescription()
);
}
}
/**
* Represents a duration parser.
* @since 1.7.0
* @param <C> Command sender type
*/
public static final class DurationParser<C> implements ArgumentParser<C, Duration> {
@Override
public @NonNull ArgumentParseResult<Duration> parse(
final @NonNull CommandContext<C> commandContext,
final @NonNull Queue<String> inputQueue
) {
final String input = inputQueue.peek();
if (input == null) {
return ArgumentParseResult.failure(new NoInputProvidedException(
DurationArgument.DurationParseException.class,
commandContext
));
}
final Matcher matcher = DURATION_PATTERN.matcher(input);
Duration duration = Duration.ofNanos(0);
while (matcher.find()) {
String group = matcher.group();
String timeUnit = String.valueOf(group.charAt(group.length() - 1));
int timeValue = Integer.parseInt(group.substring(0, group.length() - 1));
switch (timeUnit) {
case "d":
duration = duration.plusDays(timeValue);
break;
case "h":
duration = duration.plusHours(timeValue);
break;
case "m":
duration = duration.plusMinutes(timeValue);
break;
case "s":
duration = duration.plusSeconds(timeValue);
break;
default:
return ArgumentParseResult.failure(new DurationArgument.DurationParseException(input, commandContext));
}
}
if (duration.isZero()) {
return ArgumentParseResult.failure(new DurationArgument.DurationParseException(input, commandContext));
}
inputQueue.remove();
return ArgumentParseResult.success(duration);
}
/**
* Provides suggestions for Durations.
* @since 1.7.0
*/
@Override
@SuppressWarnings("MixedMutabilityReturnType")
public @NonNull List<@NonNull String> suggestions(
final @NonNull CommandContext<C> commandContext,
final @NonNull String input
) {
char[] chars = input.toLowerCase(Locale.ROOT).toCharArray();
if (chars.length == 0) {
return IntStream.range(1, 10).boxed()
.sorted()
.map(String::valueOf)
.collect(Collectors.toList());
}
char last = chars[chars.length - 1];
// 1d_, 5d4m_, etc
if (Character.isLetter(last)) {
return Collections.emptyList();
}
// 1d5_, 5d4m2_, etc
return Stream.of("d", "h", "m", "s")
.filter(unit -> !input.contains(unit))
.map(unit -> input + unit)
.collect(Collectors.toList());
}
}
/**
* Represents a duration parse exception.
* @since 1.7.0
*/
public static final class DurationParseException extends ParserException {
private static final long serialVersionUID = 7632293268451349508L;
private final String input;
/**
* Construct a new Duration parse exception
*
* @param input String input
* @param context Command context
* @since 1.7.0
*/
public DurationParseException(
final @NonNull String input,
final @NonNull CommandContext<?> context
) {
super(
DurationArgument.DurationParser.class,
context,
StandardCaptionKeys.ARGUMENT_PARSE_FAILURE_DURATION,
CaptionVariable.of("input", input)
);
this.input = input;
}
/**
* Get the supplied input
*
* @return String value
* @since 1.7.0
*/
public @NonNull String getInput() {
return this.input;
}
}
}

View file

@ -87,6 +87,10 @@ public class SimpleCaptionRegistry<C> implements FactoryDelegatingCaptionRegistr
* Default caption for {@link StandardCaptionKeys#ARGUMENT_PARSE_FAILURE_COLOR} * Default caption for {@link StandardCaptionKeys#ARGUMENT_PARSE_FAILURE_COLOR}
*/ */
public static final String ARGUMENT_PARSE_FAILURE_COLOR = "'{input}' is not a valid color"; public static final String ARGUMENT_PARSE_FAILURE_COLOR = "'{input}' is not a valid color";
/**
* Default caption for {@link StandardCaptionKeys#ARGUMENT_PARSE_FAILURE_DURATION}
*/
public static final String ARGUMENT_PARSE_FAILURE_DURATION = "'{input}' is not a duration format";
private final Map<Caption, BiFunction<Caption, C, String>> messageFactories = new HashMap<>(); private final Map<Caption, BiFunction<Caption, C, String>> messageFactories = new HashMap<>();
@ -143,6 +147,10 @@ public class SimpleCaptionRegistry<C> implements FactoryDelegatingCaptionRegistr
StandardCaptionKeys.ARGUMENT_PARSE_FAILURE_COLOR, StandardCaptionKeys.ARGUMENT_PARSE_FAILURE_COLOR,
(caption, sender) -> ARGUMENT_PARSE_FAILURE_COLOR (caption, sender) -> ARGUMENT_PARSE_FAILURE_COLOR
); );
this.registerMessageFactory(
StandardCaptionKeys.ARGUMENT_PARSE_FAILURE_DURATION,
(caption, sender) -> ARGUMENT_PARSE_FAILURE_DURATION
);
} }
@Override @Override

View file

@ -91,6 +91,10 @@ public final class StandardCaptionKeys {
* Variables: {input} * Variables: {input}
*/ */
public static final Caption ARGUMENT_PARSE_FAILURE_COLOR = of("argument.parse.failure.color"); public static final Caption ARGUMENT_PARSE_FAILURE_COLOR = of("argument.parse.failure.color");
/**
* Variables: {input}
*/
public static final Caption ARGUMENT_PARSE_FAILURE_DURATION = of("argument.parse.failure.duration");
private StandardCaptionKeys() { private StandardCaptionKeys() {
} }

View file

@ -0,0 +1,91 @@
//
// MIT License
//
// Copyright (c) 2021 Alexander Söderberg & Contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
package cloud.commandframework.arguments.standard;
import cloud.commandframework.CommandManager;
import cloud.commandframework.TestCommandSender;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static cloud.commandframework.util.TestUtils.createManager;
public class DurationArgumentSuggestionsTest {
private static CommandManager<TestCommandSender> manager;
@BeforeAll
static void setupManager() {
manager = createManager();
manager.command(manager.commandBuilder("duration")
.argument(DurationArgument.of("duration")));
}
@Test
void testDurationSuggestions() {
final String input = "duration ";
final List<String> suggestions = manager.suggest(new TestCommandSender(), input);
Assertions.assertEquals(Arrays.asList("1", "2", "3", "4", "5", "6", "7", "8", "9"), suggestions);
final String input2 = "duration 1";
final List<String> suggestions2 = manager.suggest(new TestCommandSender(), input2);
Assertions.assertEquals(Arrays.asList("1d", "1h", "1m", "1s"), suggestions2);
final String input3 = "duration 1d";
final List<String> suggestions3 = manager.suggest(new TestCommandSender(), input3);
Assertions.assertEquals(Collections.emptyList(), suggestions3);
final String input4 = "duration 1d2";
final List<String> suggestions4 = manager.suggest(new TestCommandSender(), input4);
Assertions.assertTrue(suggestions4.containsAll(Arrays.asList("1d2h", "1d2m", "1d2s")));
Assertions.assertFalse(suggestions4.contains("1d2d"));
final String input9 = "duration 1d22";
final List<String> suggestions9 = manager.suggest(new TestCommandSender(), input9);
Assertions.assertTrue(suggestions9.containsAll(Arrays.asList("1d22h", "1d22m", "1d22s")));
Assertions.assertFalse(suggestions9.contains("1d22d"));
final String input5 = "duration d";
final List<String> suggestions5 = manager.suggest(new TestCommandSender(), input5);
Assertions.assertEquals(Collections.emptyList(), suggestions5);
final String input6 = "duration 1d2d";
final List<String> suggestions6 = manager.suggest(new TestCommandSender(), input6);
Assertions.assertEquals(Collections.emptyList(), suggestions6);
final String input7 = "duration 1d2h3m4s";
final List<String> suggestions7 = manager.suggest(new TestCommandSender(), input7);
Assertions.assertEquals(Collections.emptyList(), suggestions7);
final String input8 = "duration dd";
final List<String> suggestions8 = manager.suggest(new TestCommandSender(), input8);
Assertions.assertEquals(Collections.emptyList(), suggestions8);
}
}

View file

@ -0,0 +1,96 @@
//
// MIT License
//
// Copyright (c) 2021 Alexander Söderberg & Contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
package cloud.commandframework.arguments.standard;
import cloud.commandframework.CommandManager;
import cloud.commandframework.TestCommandSender;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.concurrent.CompletionException;
import static cloud.commandframework.util.TestUtils.createManager;
import static com.google.common.truth.Truth.assertThat;
public class DurationArgumentTest {
private static final Duration[] storage = new Duration[1];
private static CommandManager<TestCommandSender> manager;
@BeforeAll
static void setup() {
manager = createManager();
manager.command(manager.commandBuilder("duration")
.argument(DurationArgument.of("duration"))
.handler(c -> {
final Duration duration = c.get("duration");
storage[0] = duration;
})
.build());
}
@AfterEach
void reset() {
storage[0] = null;
}
@Test
void single_single_unit() {
manager.executeCommand(new TestCommandSender(), "duration 2d").join();
assertThat(storage[0]).isEqualTo(Duration.ofDays(2));
manager.executeCommand(new TestCommandSender(), "duration 999s").join();
assertThat(storage[0]).isEqualTo(Duration.ofSeconds(999));
}
@Test
void single_multiple_units() {
manager.executeCommand(new TestCommandSender(), "duration 2d12h7m34s").join();
assertThat(storage[0]).isEqualTo(Duration.ofDays(2).plusHours(12).plusMinutes(7).plusSeconds(34));
manager.executeCommand(new TestCommandSender(), "duration 700h75m1d999s").join();
assertThat(storage[0]).isEqualTo(Duration.ofDays(1).plusHours(700).plusMinutes(75).plusSeconds(999));
}
@Test
void invalid_format_failing() {
Assertions.assertThrows(CompletionException.class, () -> manager.executeCommand(
new TestCommandSender(),
"duration d"
).join());
Assertions.assertThrows(CompletionException.class, () -> manager.executeCommand(
new TestCommandSender(),
"duration 1x"
).join());
}
}