diff --git a/cloud-core/src/main/java/org/incendo/cloud/parser/standard/DurationParser.java b/cloud-core/src/main/java/org/incendo/cloud/parser/standard/DurationParser.java index cfa955955..727bb0bad 100644 --- a/cloud-core/src/main/java/org/incendo/cloud/parser/standard/DurationParser.java +++ b/cloud-core/src/main/java/org/incendo/cloud/parser/standard/DurationParser.java @@ -25,13 +25,12 @@ import java.time.Duration; import java.util.Collections; -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.apiguardian.api.API; import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; import org.incendo.cloud.caption.CaptionVariable; import org.incendo.cloud.caption.StandardCaptionKeys; import org.incendo.cloud.component.CommandComponent; @@ -46,6 +45,8 @@ /** * Parser for {@link Duration}. * + *

Matches durations in the format of: 2d15h7m12s.

+ * * @param command sender type */ @API(status = API.Status.STABLE) @@ -73,11 +74,6 @@ public final class DurationParser implements ArgumentParser, Blo return CommandComponent.builder().parser(durationParser()); } - /** - * Matches durations in the format of: 2d15h7m12s - */ - private static final Pattern DURATION_PATTERN = Pattern.compile("(([1-9][0-9]+|[1-9])[dhms])"); - @Override public @NonNull ArgumentParseResult parse( final @NonNull CommandContext commandContext, @@ -85,30 +81,55 @@ public final class DurationParser implements ArgumentParser, Blo ) { final String input = commandInput.readString(); - 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 DurationParseException(input, commandContext)); + // substring range enclosing digits and unit (single char) + int rangeStart = 0; + int cursor = 0; + + while (cursor < input.length()) { + // advance cursor until time unit or we reach end of input (in which case it's invalid anyway) + while (cursor < input.length() && Character.isDigit(input.charAt(cursor))) { + cursor += 1; + } + + // reached end of input with no time unit + if (cursor == input.length()) { + return ArgumentParseResult.failure(new DurationParseException(input, commandContext)); } + + final long timeValue; + try { + timeValue = Long.parseLong(input.substring(rangeStart, cursor)); + } catch (final NumberFormatException ex) { + return ArgumentParseResult.failure(new DurationParseException(ex, input, commandContext)); + } + + final char timeUnit = input.charAt(cursor); + try { + 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 DurationParseException(input, commandContext)); + } + } catch (final ArithmeticException ex) { + return ArgumentParseResult.failure(new DurationParseException(ex, input, commandContext)); + } + + // skip unit, reset rangeStart to start of next segment + cursor += 1; + rangeStart = cursor; } if (duration.isZero()) { @@ -172,6 +193,28 @@ public DurationParseException( this.input = input; } + /** + * Construct a new {@link DurationParseException} with a causing exception. + * + * @param cause cause of exception + * @param input input string + * @param context command context + */ + public DurationParseException( + final @Nullable Throwable cause, + final @NonNull String input, + final @NonNull CommandContext context + ) { + super( + cause, + DurationParser.class, + context, + StandardCaptionKeys.ARGUMENT_PARSE_FAILURE_DURATION, + CaptionVariable.of("input", input) + ); + this.input = input; + } + /** * Returns the supplied input string. * diff --git a/cloud-core/src/test/java/org/incendo/cloud/parser/standard/DurationParserTest.java b/cloud-core/src/test/java/org/incendo/cloud/parser/standard/DurationParserTest.java index 6e6defbe4..610d7aeb3 100644 --- a/cloud-core/src/test/java/org/incendo/cloud/parser/standard/DurationParserTest.java +++ b/cloud-core/src/test/java/org/incendo/cloud/parser/standard/DurationParserTest.java @@ -75,15 +75,50 @@ void single_multiple_units() { } @Test - void invalid_format_failing() { + void invalid_format_no_time_value() { Assertions.assertThrows( CompletionException.class, () -> manager.commandExecutor().executeCommand(new TestCommandSender(), "duration d").join() ); + } + @Test + void invalid_format_no_time_unit() { + Assertions.assertThrows( + CompletionException.class, + () -> manager.commandExecutor().executeCommand(new TestCommandSender(), "duration 1").join() + ); + } + + @Test + void invalid_format_invalid_unit() { Assertions.assertThrows( CompletionException.class, () -> manager.commandExecutor().executeCommand(new TestCommandSender(), "duration 1x").join() ); } + + @Test + void invalid_format_leading_garbage() { + Assertions.assertThrows( + CompletionException.class, + () -> manager.commandExecutor().executeCommand(new TestCommandSender(), "duration foo1d").join() + ); + } + + @Test + void invalid_format_garbage() { + Assertions.assertThrows( + CompletionException.class, + () -> manager.commandExecutor().executeCommand(new TestCommandSender(), "duration 1dfoo2h").join() + ); + } + + @Test + void invalid_format_trailing_garbage() { + Assertions.assertThrows( + CompletionException.class, + () -> manager.commandExecutor().executeCommand(new TestCommandSender(), "duration 1dfoo").join() + ); + } }