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());
+ }
+
+}