From c2b3145d4d09ff4c61f3e95219b0867fe9bc222a Mon Sep 17 00:00:00 2001 From: Tadhg Boyle Date: Tue, 4 Jan 2022 21:46:53 -0700 Subject: [PATCH] Add `DurationArgument` for parsing `java.time.Duration` (#330) Co-authored-by: Frank van der Heijden --- .../arguments/standard/DurationArgument.java | 287 ++++++++++++++++++ .../captions/SimpleCaptionRegistry.java | 8 + .../captions/StandardCaptionKeys.java | 4 + .../DurationArgumentSuggestionsTest.java | 91 ++++++ .../standard/DurationArgumentTest.java | 96 ++++++ 5 files changed, 486 insertions(+) create mode 100644 cloud-core/src/main/java/cloud/commandframework/arguments/standard/DurationArgument.java create mode 100644 cloud-core/src/test/java/cloud/commandframework/arguments/standard/DurationArgumentSuggestionsTest.java create mode 100644 cloud-core/src/test/java/cloud/commandframework/arguments/standard/DurationArgumentTest.java diff --git a/cloud-core/src/main/java/cloud/commandframework/arguments/standard/DurationArgument.java b/cloud-core/src/main/java/cloud/commandframework/arguments/standard/DurationArgument.java new file mode 100644 index 00000000..674fb1b6 --- /dev/null +++ b/cloud-core/src/main/java/cloud/commandframework/arguments/standard/DurationArgument.java @@ -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 java.time.Duration from a 1d2h3m4s format. + * @param Command sender type + * @since 1.7.0 + */ +@SuppressWarnings("unused") +public final class DurationArgument extends CommandArgument { + + /** + * Matches durations in the format of: 2d15h7m12s + */ + 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, @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 Command sender type + * @return Created builder + * @since 1.7.0 + */ + public static @NonNull Builder newBuilder(final @NonNull String name) { + return new Builder<>(name); + } + + /** + * Create a new required command component + * + * @param name Component name + * @param Command sender type + * @return Created component + * @since 1.7.0 + */ + public static @NonNull CommandArgument of(final @NonNull String name) { + return DurationArgument.newBuilder(name).asRequired().build(); + } + + /** + * Create a new optional command component + * + * @param name Component name + * @param Command sender type + * @return Created component + * @since 1.7.0 + */ + public static @NonNull CommandArgument optional(final @NonNull String name) { + return DurationArgument.newBuilder(name).asOptional().build(); + } + + /** + * Create a new required command component with a default value + * + * @param name Component name + * @param defaultDuration Default duration + * @param Command sender type + * @return Created component + * @since 1.7.0 + */ + public static @NonNull CommandArgument optional( + final @NonNull String name, + final @NonNull String defaultDuration + ) { + return DurationArgument.newBuilder(name).asOptionalWithDefault(defaultDuration).build(); + } + + + public static final class Builder extends CommandArgument.Builder { + + 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 build() { + return new DurationArgument<>(this.isRequired(), this.getName(), this.getDefaultValue(), + this.getSuggestionsProvider(), this.getDefaultDescription() + ); + } + + } + + + /** + * Represents a duration parser. + * @since 1.7.0 + * @param Command sender type + */ + public static final class DurationParser implements ArgumentParser { + + @Override + public @NonNull ArgumentParseResult parse( + final @NonNull CommandContext commandContext, + final @NonNull Queue 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 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; + } + + } + +} diff --git a/cloud-core/src/main/java/cloud/commandframework/captions/SimpleCaptionRegistry.java b/cloud-core/src/main/java/cloud/commandframework/captions/SimpleCaptionRegistry.java index 317ae385..b209fd8a 100644 --- a/cloud-core/src/main/java/cloud/commandframework/captions/SimpleCaptionRegistry.java +++ b/cloud-core/src/main/java/cloud/commandframework/captions/SimpleCaptionRegistry.java @@ -87,6 +87,10 @@ public class SimpleCaptionRegistry implements FactoryDelegatingCaptionRegistr * Default caption for {@link StandardCaptionKeys#ARGUMENT_PARSE_FAILURE_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> messageFactories = new HashMap<>(); @@ -143,6 +147,10 @@ public class SimpleCaptionRegistry implements FactoryDelegatingCaptionRegistr StandardCaptionKeys.ARGUMENT_PARSE_FAILURE_COLOR, (caption, sender) -> ARGUMENT_PARSE_FAILURE_COLOR ); + this.registerMessageFactory( + StandardCaptionKeys.ARGUMENT_PARSE_FAILURE_DURATION, + (caption, sender) -> ARGUMENT_PARSE_FAILURE_DURATION + ); } @Override diff --git a/cloud-core/src/main/java/cloud/commandframework/captions/StandardCaptionKeys.java b/cloud-core/src/main/java/cloud/commandframework/captions/StandardCaptionKeys.java index f3ee9774..2227c9f6 100644 --- a/cloud-core/src/main/java/cloud/commandframework/captions/StandardCaptionKeys.java +++ b/cloud-core/src/main/java/cloud/commandframework/captions/StandardCaptionKeys.java @@ -91,6 +91,10 @@ public final class StandardCaptionKeys { * Variables: {input} */ 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() { } diff --git a/cloud-core/src/test/java/cloud/commandframework/arguments/standard/DurationArgumentSuggestionsTest.java b/cloud-core/src/test/java/cloud/commandframework/arguments/standard/DurationArgumentSuggestionsTest.java new file mode 100644 index 00000000..3abea0aa --- /dev/null +++ b/cloud-core/src/test/java/cloud/commandframework/arguments/standard/DurationArgumentSuggestionsTest.java @@ -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 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 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 suggestions2 = manager.suggest(new TestCommandSender(), input2); + Assertions.assertEquals(Arrays.asList("1d", "1h", "1m", "1s"), suggestions2); + + final String input3 = "duration 1d"; + final List suggestions3 = manager.suggest(new TestCommandSender(), input3); + Assertions.assertEquals(Collections.emptyList(), suggestions3); + + final String input4 = "duration 1d2"; + final List 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 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 suggestions5 = manager.suggest(new TestCommandSender(), input5); + Assertions.assertEquals(Collections.emptyList(), suggestions5); + + final String input6 = "duration 1d2d"; + final List suggestions6 = manager.suggest(new TestCommandSender(), input6); + Assertions.assertEquals(Collections.emptyList(), suggestions6); + + final String input7 = "duration 1d2h3m4s"; + final List suggestions7 = manager.suggest(new TestCommandSender(), input7); + Assertions.assertEquals(Collections.emptyList(), suggestions7); + + final String input8 = "duration dd"; + final List suggestions8 = manager.suggest(new TestCommandSender(), input8); + Assertions.assertEquals(Collections.emptyList(), suggestions8); + } + + +} diff --git a/cloud-core/src/test/java/cloud/commandframework/arguments/standard/DurationArgumentTest.java b/cloud-core/src/test/java/cloud/commandframework/arguments/standard/DurationArgumentTest.java new file mode 100644 index 00000000..da21320d --- /dev/null +++ b/cloud-core/src/test/java/cloud/commandframework/arguments/standard/DurationArgumentTest.java @@ -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 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()); + } + +}