diff --git a/src/main/java/org/threeten/extra/Interval.java b/src/main/java/org/threeten/extra/Interval.java index f7b2a8cf..09afb2f5 100644 --- a/src/main/java/org/threeten/extra/Interval.java +++ b/src/main/java/org/threeten/extra/Interval.java @@ -40,7 +40,8 @@ import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; -import java.time.temporal.TemporalAccessor; +import java.time.temporal.Temporal; +import java.time.temporal.TemporalAmount; import java.util.Objects; import org.joda.convert.FromString; @@ -78,6 +79,11 @@ public final class Interval */ private static final long serialVersionUID = 8375285238652L; + /** + * Leap year cycle length. + */ + private static final Duration leapYearCycleLength = Duration.ofHours(3506328L); + /** * The start instant (inclusive). */ @@ -180,80 +186,94 @@ public static Interval parse(CharSequence text) { Objects.requireNonNull(text, "text"); for (int i = 0; i < text.length(); i++) { if (text.charAt(i) == '/') { - return parseSplit(text.subSequence(0, i), text.subSequence(i + 1, text.length())); + CharSequence startStr = text.subSequence(0, i); + CharSequence endStr = text.subSequence(i + 1, text.length()); + if (startStr.charAt(0) == 'P' || startStr.charAt(0) == 'p') { + // duration followed by temporal + TemporalAmount duration = parseDuration(startStr); + Temporal temporal = parseTemporal(endStr); + return Interval.of(Instant.from(minus(temporal, duration)), Instant.from(temporal)); + } else if (endStr.charAt(0) == 'P' || endStr.charAt(0) == 'p') { + // temporal followed by duration + Temporal temporal = parseTemporal(startStr); + TemporalAmount duration = parseDuration(endStr); + return Interval.of(Instant.from(temporal), Instant.from(plus(temporal, duration))); + } else { + // temporal followed by temporal + Temporal start = parseTemporal(startStr); + Temporal end = parseTemporal(endStr); + if (start instanceof LocalDateTime) { + // can *not* use zone offset from end instant (see ISO 8601-1:2019) + throw new DateTimeParseException("Interval cannot be parsed, start instant must contain zone offset", text, 0); + } + if (end instanceof LocalDateTime) { + // can use zone offset from start instant (see ISO 8601-1:2019) + return Interval.of(Instant.from(start), ((LocalDateTime) end).atOffset(ZoneOffset.from(start)).toInstant()); + } + return Interval.of(Instant.from(start), Instant.from(end)); + } } } throw new DateTimeParseException("Interval cannot be parsed, no forward slash found", text, 0); } - private static Interval parseSplit(CharSequence startStr, CharSequence endStr) { - char firstChar = startStr.charAt(0); - if (firstChar == 'P' || firstChar == 'p') { - // duration followed by instant - PeriodDuration amount = PeriodDuration.parse(startStr); - try { - OffsetDateTime end = OffsetDateTime.parse(endStr); - return Interval.of(end.minus(amount).toInstant(), end.toInstant()); - } catch (DateTimeParseException ex) { - // handle case where Instant is outside the bounds of OffsetDateTime - Instant end = Instant.parse(endStr); - // addition of PeriodDuration only supported by OffsetDateTime, - // but to make that work need to move point being subtracted from closer to EPOCH - long move = end.isBefore(Instant.EPOCH) ? 1000 * 86400 : -1000 * 86400; - Instant start = end.plusSeconds(move).atOffset(ZoneOffset.UTC).minus(amount).toInstant().minusSeconds(move); - return Interval.of(start, end); - } - } - // instant followed by instant or duration - OffsetDateTime start; + /** + * Obtains an instance of {@code Temporal} from a text string. + * + * @param text the text to parse, validated not null + * @return the parsed temporal, not null + */ + private static Temporal parseTemporal(CharSequence text) { try { - start = OffsetDateTime.parse(startStr); - } catch (DateTimeParseException ex) { - return parseStartExtended(startStr, endStr); - } - if (endStr.length() > 0) { - char c = endStr.charAt(0); - if (c == 'P' || c == 'p') { - PeriodDuration amount = PeriodDuration.parse(endStr); - return Interval.of(start.toInstant(), start.plus(amount).toInstant()); - } + // temporal within date-time bounds + return (Temporal) DateTimeFormatter.ISO_DATE_TIME.parseBest(text, OffsetDateTime::from, LocalDateTime::from); + } catch (DateTimeParseException exception) { + // temporal outside date-time bounds + return Instant.parse(text); } - return parseEndDateTime(start.toInstant(), start.getOffset(), endStr); - } - - // handle case where Instant is outside the bounds of OffsetDateTime - private static Interval parseStartExtended(CharSequence startStr, CharSequence endStr) { - Instant start = Instant.parse(startStr); - if (endStr.length() > 0) { - char c = endStr.charAt(0); - if (c == 'P' || c == 'p') { - PeriodDuration amount = PeriodDuration.parse(endStr); - // addition of PeriodDuration only supported by OffsetDateTime, - // but to make that work need to move point being added to closer to EPOCH - long move = start.isBefore(Instant.EPOCH) ? 1000 * 86400 : -1000 * 86400; - Instant end = start.plusSeconds(move).atOffset(ZoneOffset.UTC).plus(amount).toInstant().minusSeconds(move); - return Interval.of(start, end); - } + } + + /** + * Obtains an instance of {@code TemporalAmount} from a text string. + * + * @param text the text to parse, validated not null + * @return the parsed temporal amount, not null + */ + private static TemporalAmount parseDuration(CharSequence text) { + return PeriodDuration.parse(text); + } + + /** + * Returns a copy of given temporal with the specified amount added. + * + * @param temporal the temporal, validated not null + * @param amount the amount to add, validated not null + * @return a {@code Temporal} based on given temporal with the addition made, not null + */ + private static Temporal plus(Temporal temporal, TemporalAmount amount) { + if (temporal instanceof Instant) { + Instant instant = (Instant) temporal; + TemporalAmount shift = instant.isBefore(Instant.EPOCH) ? leapYearCycleLength : leapYearCycleLength.negated(); + return instant.plus(shift).atOffset(ZoneOffset.UTC).plus(amount).toInstant().minus(shift); + } else { + return temporal.plus(amount); } - // infer offset from start if not specified by end - return parseEndDateTime(start, ZoneOffset.UTC, endStr); } - // parse when there are two date-times - private static Interval parseEndDateTime(Instant start, ZoneOffset offset, CharSequence endStr) { - try { - TemporalAccessor temporal = DateTimeFormatter.ISO_DATE_TIME.parseBest(endStr, OffsetDateTime::from, LocalDateTime::from); - if (temporal instanceof OffsetDateTime) { - OffsetDateTime odt = (OffsetDateTime) temporal; - return Interval.of(start, odt.toInstant()); - } else { - // infer offset from start if not specified by end - LocalDateTime ldt = (LocalDateTime) temporal; - return Interval.of(start, ldt.toInstant(offset)); - } - } catch (DateTimeParseException ex) { - Instant end = Instant.parse(endStr); - return Interval.of(start, end); + /** + * Returns a copy of given temporal with the specified amount subtracted. + * + * @param temporal the temporal, validated not null + * @param amount the amount to subtract, validated not null + * @return a {@code Temporal} based on given temporal with the subtraction made, not null + */ + private static Temporal minus(Temporal temporal, TemporalAmount amount) { + if (temporal instanceof Instant) { + Instant instant = (Instant) temporal; + TemporalAmount shift = instant.isBefore(Instant.EPOCH) ? leapYearCycleLength : leapYearCycleLength.negated(); + return instant.plus(shift).atOffset(ZoneOffset.UTC).minus(amount).toInstant().minus(shift); + } else { + return temporal.minus(amount); } } diff --git a/src/test/java/org/threeten/extra/TestInterval.java b/src/test/java/org/threeten/extra/TestInterval.java index d7b698cd..6b0dcc54 100644 --- a/src/test/java/org/threeten/extra/TestInterval.java +++ b/src/test/java/org/threeten/extra/TestInterval.java @@ -51,8 +51,10 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeParseException; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; /** @@ -230,8 +232,8 @@ public void test_endingAt_null() { } /* Lower and upper bound for Intervals */ - private static final Instant MIN_OFFSET_DATE_TIME = OffsetDateTime.MIN.plusDays(1L).toInstant(); - private static final Instant MAX_OFFSET_DATE_TIME = OffsetDateTime.MAX.minusDays(1L).toInstant(); + private static final Instant MIN_OFFSET_DATE_TIME = OffsetDateTime.MIN.toInstant(); + private static final Instant MAX_OFFSET_DATE_TIME = OffsetDateTime.MAX.toInstant(); //----------------------------------------------------------------------- public static Object[][] data_parseValid() { @@ -267,6 +269,39 @@ public void test_parse_CharSequence(String input, Instant start, Instant end) { assertEquals(end, test.getEnd()); } + @ParameterizedTest + @CsvSource({ + "-1000000000-01-31T00:00:00Z/P1M, -1000000000-01-31T00:00:00Z, -1000000000-02-29T00:00:00Z", + "-1000000000-01-31T00:00:00Z/P2M, -1000000000-01-31T00:00:00Z, -1000000000-03-31T00:00:00Z", + "P5M/+1000000000-12-31T23:59:59.999999999Z, +1000000000-07-31T23:59:59.999999999Z, +1000000000-12-31T23:59:59.999999999Z", + "P10M/+1000000000-12-31T23:59:59.999999999Z, +1000000000-02-29T23:59:59.999999999Z, +1000000000-12-31T23:59:59.999999999Z", + }) + public void data_parse_outside_bounds(String interval, String start, String end) { + assertEquals(Interval.of(Instant.parse(start), Instant.parse(end)), Interval.parse(interval)); + } + + @Disabled("not implemented") + @ParameterizedTest + @CsvSource({ + "P1Y/-999999999-01-01T00:00:00+00:00, -1000000000-01-01T00:00:00Z, -999999999-01-01T00:00:00Z", + "-999999999-01-01T00:00:00+00:00/P-1Y, -1000000000-01-01T00:00:00Z, -999999999-01-01T00:00:00Z", + "+999999999-01-01T00:00:00+00:00/P1Y, +999999999-01-01T00:00:00Z, +1000000000-01-01T00:00:00Z", + "P-1Y/+999999999-01-01T00:00:00+00:00, +999999999-01-01T00:00:00Z, +1000000000-01-01T00:00:00Z", + }) + public void data_parse_crossing_bounds(String interval, String start, String end) { + assertEquals(Interval.of(Instant.parse(start), Instant.parse(end)), Interval.parse(interval)); + } + + @Disabled("not implemented") + @ParameterizedTest + @CsvSource({ + "-1000000000-01-01T00:00:00Z/P2000000000Y11M30DT23H59M59.999999999S, -1000000000-01-01T00:00:00Z, +1000000000-12-31T23:59:59.999999999Z", + "P2000000000Y11M30DT23H59M59.999999999S/+1000000000-12-31T23:59:59.999999999Z, -1000000000-01-01T00:00:00Z, +1000000000-12-31T23:59:59.999999999Z", + }) + public void data_parse_crossing_bounds_twice(String interval, String start, String end) { + assertEquals(Interval.of(Instant.parse(start), Instant.parse(end)), Interval.parse(interval)); + } + @Test public void test_parse_CharSequence_badOrder() { assertThrows(DateTimeException.class, () -> Interval.parse(NOW2 + "/" + NOW1));