diff --git a/README.md b/README.md index 5d332de..8291b8e 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,6 @@ An extremely fast parser and formatter of specific ISO-8601 format date and date This project's goal is to do one thing: Make it easy to handle [RFC-3339 Timestamps](https://www.ietf.org/rfc/rfc3339.txt) and W3C [Date and Time Formats](https://www.w3.org/TR/NOTE-datetime) in Java. -⚠️ Important note: Version 1.7.4 to 1.7.7 have a known issue parsing very specific, errounous date-time strings. _Please upgrade to version [1.8.0](https://github.com/ethlo/itu/releases/tag/v1.8.0) or later!_ - ## Features * Very easy to use. * Aim for 100% specification compliance. @@ -52,99 +50,73 @@ import java.time.OffsetDateTime; import com.ethlo.time.DateTime; import com.ethlo.time.ITU; -class Test { - void smokeTest() { - // Parse a string - final OffsetDateTime dateTime = ITU.parseDateTime("2012-12-27T19:07:22.123456789-03:00"); +// Parse a string +final OffsetDateTime dateTime = ITU.parseDateTime("2012-12-27T19:07:22.123456789-03:00"); - // Format with seconds (no fraction digits) - final String formatted = ITU.formatUtc(dateTime); // 2012-12-27T22:07:22Z +// Format with seconds (no fraction digits) +final String formatted = ITU.formatUtc(dateTime); // 2012-12-27T22:07:22Z - // Format with microsecond precision - final String formattedMicro = ITU.formatUtcMicro(dateTime); // 2012-12-27T22:07:22.123457Z +// Format with microsecond precision +final String formattedMicro = ITU.formatUtcMicro(dateTime); // 2012-12-27T22:07:22.123457Z - // Parse lenient, raw data - final DateTime dateTime = ITU.parseLenient("2012-12-27T19:07Z"); - } -} +// Parse lenient +final DateTime dateTime = ITU.parseLenient("2012-12-27T19:07Z"); ``` ### Handle leap-seconds ```java -import com.ethlo.time.ITU; -import com.ethlo.time.LeapSecondException; -import java.time.OffsetDateTime; - -class Test { - void testParseDateTime() { - try { - final OffsetDateTime dateTime = ITU.parseDateTime("1990-12-31T15:59:60-08:00"); - } catch (LeapSecondException exc) { - // The following helper methods are available let you decide how to progress - exc.getSecondsInMinute(); // 60 - exc.getNearestDateTime(); // 1991-01-01T00:00:00Z - exc.isVerifiedValidLeapYearMonth(); // true - } - } +try { + final OffsetDateTime dateTime = ITU.parseDateTime("1990-12-31T15:59:60-08:00"); +} catch (LeapSecondException exc) { + // The following helper methods are available let you decide how to progress + exc.getSecondsInMinute(); // 60 + exc.getNearestDateTime(); // 1991-01-01T00:00:00Z + exc.isVerifiedValidLeapYearMonth(); // true } ``` +### Parse lenient, configurable separators +```java +final ParseConfig config = ParseConfig.DEFAULT + .withFractionSeparators('.', ',') + .withDateTimeSeparators('T', '|'); +ITU.parseLenient("1999-11-22|11:22:17,191", config); +``` + +### Parse with ParsePosition + +```java +final ParsePosition pos = new ParsePosition(0); +ITU.parseLenient("1999-11-22T11:22", pos); +``` + ### Handle different granularity (ISO format) #### Validate with specified granularity ```java -import com.ethlo.time.ITU; -import com.ethlo.time.TemporalType; - -class Test { - void test() { - ITU.isValid("2017-12-06", TemporalType.LOCAL_DATE_TIME); - } -} +ITU.isValid("2017-12-06", TemporalType.LOCAL_DATE_TIME); ``` #### Handling different levels of granularity explicitly ```java -import com.ethlo.time.ITU; -import com.ethlo.time.TemporalHandler; +ITU.parse("2017-12-06", new TemporalHandler<>() { + @Override + public OffsetDateTime handle(final LocalDate localDate) { + return localDate.atTime(OffsetTime.of(LocalTime.of(0, 0), ZoneOffset.UTC)); + } -import java.time.LocalDate; -import java.time.LocalTime; -import java.time.OffsetDateTime; -import java.time.OffsetTime; -import java.time.ZoneOffset; -import java.time.temporal.TemporalAccessor; - -class Test { - TemporalAccessor extract() { - return ITU.parse("2017-12-06", new TemporalHandler<>() { - @Override - public OffsetDateTime handle(final LocalDate localDate) { - return localDate.atTime(OffsetTime.of(LocalTime.of(0, 0), ZoneOffset.UTC)); - } - - @Override - public OffsetDateTime handle(final OffsetDateTime offsetDateTime) { - return offsetDateTime; - } - }); + @Override + public OffsetDateTime handle(final OffsetDateTime offsetDateTime) { + return offsetDateTime; } -} +}); ``` #### Parsing leniently to a timestamp -In some real world scenarios, it is useful to parse a best-effort timestamp. To ease usage, converting a raw `com.ethlo.time.DateTime` instance into `java.time.Instant`. Note the limitations and the assumption of UTC time-zone, as mentioned in the javadoc. +In some real world scenarios, it is useful to parse a best-effort timestamp. To ease usage, we can easily convert a raw `com.ethlo.time.DateTime` instance into `java.time.Instant`. Note the limitations and the assumption of UTC time-zone, as mentioned in the javadoc. We can use `ITU.parseLenient()` with `DateTime.toInstant()` like this: ```java -import com.ethlo.time.ITU; -import com.ethlo.time.TemporalHandler; -import java.time.temporal.TemporalAccessor; - -class Test { - void parseTest() { - final Instant instant = ITU.parseLenient("2017-12-06").toInstant(); - } -} +final Instant instant = ITU.parseLenient("2017-12-06").toInstant(); ``` ## Q & A diff --git a/src/main/java/com/ethlo/time/ITU.java b/src/main/java/com/ethlo/time/ITU.java index 32d661e..1d888f4 100644 --- a/src/main/java/com/ethlo/time/ITU.java +++ b/src/main/java/com/ethlo/time/ITU.java @@ -20,12 +20,14 @@ * #L% */ +import java.text.ParsePosition; import java.time.DateTimeException; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.Year; import java.time.YearMonth; +import java.time.format.DateTimeParseException; import com.ethlo.time.internal.EthloITU; @@ -51,6 +53,22 @@ public static OffsetDateTime parseDateTime(String text) return delegate.parseDateTime(text); } + public static OffsetDateTime parseDateTime(String text, ParsePosition position) + { + try + { + final OffsetDateTime result = delegate.parseDateTime(text); + position.setIndex(text.length()); + return result; + } + catch (DateTimeParseException exc) + { + position.setErrorIndex(exc.getErrorIndex()); + position.setIndex(position.getErrorIndex()); + throw exc; + } + } + /** * Parse an ISO formatted date and optionally time to a {@link DateTime}. The result has * rudimentary checks for correctness, but will not be aware of number of days per specific month or leap-years. @@ -63,6 +81,32 @@ public static DateTime parseLenient(String text) return delegate.parse(text); } + public static DateTime parseLenient(String text, ParsePosition position) + { + return parseLenient(text, ParseConfig.DEFAULT, position); + } + + public static DateTime parseLenient(String text, ParseConfig parseConfig) + { + return delegate.parse(text, parseConfig); + } + + public static DateTime parseLenient(String text, ParseConfig parseConfig, ParsePosition position) + { + try + { + final DateTime result = delegate.parse(text, parseConfig); + position.setIndex(text.length()); + return result; + } + catch (DateTimeParseException exc) + { + position.setIndex(exc.getErrorIndex()); + position.setErrorIndex(exc.getErrorIndex()); + throw exc; + } + } + /** * Check if the dateTime is valid according to the RFC-3339 specification * diff --git a/src/main/java/com/ethlo/time/ParseConfig.java b/src/main/java/com/ethlo/time/ParseConfig.java new file mode 100644 index 0000000..e5ccd65 --- /dev/null +++ b/src/main/java/com/ethlo/time/ParseConfig.java @@ -0,0 +1,88 @@ +package com.ethlo.time; + +/*- + * #%L + * Internet Time Utility + * %% + * Copyright (C) 2017 - 2024 Morten Haraldsen (ethlo) + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +public class ParseConfig +{ + public static final ParseConfig DEFAULT = new ParseConfig(new char[]{'T', 't', ' '}, new char[]{'.'}); + + private final char[] allowedDateTimeSeparators; + private final char[] allowedFractionSeparators; + + private ParseConfig(char[] allowedDateTimeSeparators, char[] allowedFractionSeparators) + { + this.allowedDateTimeSeparators = allowedDateTimeSeparators; + this.allowedFractionSeparators = allowedFractionSeparators; + } + + public ParseConfig withDateTimeSeparators(char... allowed) + { + assertChars(allowed); + return new ParseConfig(allowed, allowedFractionSeparators); + } + + private void assertChars(char[] allowed) + { + if (allowed == null) + { + throw new IllegalArgumentException("Cannot have null array of characters"); + } + if (allowed.length == 0) + { + throw new IllegalArgumentException("Must have at least one character in allowed list"); + } + } + + public ParseConfig withFractionSeparators(char... allowed) + { + assertChars(allowed); + return new ParseConfig(allowedDateTimeSeparators, allowed); + } + + public char[] getDateTimeSeparators() + { + return allowedDateTimeSeparators; + } + + public boolean isDateTimeSeparator(char needle) + { + for (char c : allowedDateTimeSeparators) + { + if (c == needle) + { + return true; + } + } + return false; + } + + public boolean isFractionSeparator(char needle) + { + for (char c : allowedFractionSeparators) + { + if (c == needle) + { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/ethlo/time/internal/EthloITU.java b/src/main/java/com/ethlo/time/internal/EthloITU.java index bcb8d7a..8435491 100644 --- a/src/main/java/com/ethlo/time/internal/EthloITU.java +++ b/src/main/java/com/ethlo/time/internal/EthloITU.java @@ -39,18 +39,18 @@ import com.ethlo.time.DateTime; import com.ethlo.time.Field; import com.ethlo.time.LeapSecondException; +import com.ethlo.time.ParseConfig; import com.ethlo.time.TimezoneOffset; public class EthloITU extends AbstractRfc3339 implements W3cDateTimeUtil { + private static final EthloITU instance = new EthloITU(); + public static final char DATE_SEPARATOR = '-'; public static final char TIME_SEPARATOR = ':'; public static final char SEPARATOR_UPPER = 'T'; - private static final EthloITU instance = new EthloITU(); private static final char PLUS = '+'; private static final char MINUS = '-'; - private static final char SEPARATOR_LOWER = 't'; - private static final char SEPARATOR_SPACE = ' '; private static final char FRACTION_SEPARATOR = '.'; private static final char ZULU_UPPER = 'Z'; private static final char ZULU_LOWER = 'z'; @@ -121,13 +121,13 @@ private static int scale(int fractions, int len, String parsedData, final int in } } - private static Object handleTime(String chars, int year, int month, int day, int hour, int minute, final boolean raw) + private static Object handleTime(ParseConfig parseConfig, String chars, int year, int month, int day, int hour, int minute, final boolean raw) { switch (chars.charAt(16)) { // We have more granularity, keep going case TIME_SEPARATOR: - return handleTimeResolution(year, month, day, hour, minute, chars, raw); + return handleTimeResolution(parseConfig, year, month, day, hour, minute, chars, raw); case PLUS: case MINUS: @@ -157,19 +157,13 @@ private static void assertPositionContains(String chars, int offset, char expect } } - private static void assertAllowedDateTimeSeparator(String chars) + private static void assertAllowedDateTimeSeparator(String chars, final ParseConfig config) { final char needle = chars.charAt(10); - switch (needle) + if (!config.isDateTimeSeparator(needle)) { - case SEPARATOR_UPPER: - case SEPARATOR_LOWER: - case SEPARATOR_SPACE: - return; - - default: - throw new DateTimeParseException("Expected character " + Arrays.toString(new char[]{SEPARATOR_UPPER, SEPARATOR_LOWER, SEPARATOR_SPACE}) - + " at position " + (10 + 1) + ": " + chars, chars, 10); + throw new DateTimeParseException("Expected character " + Arrays.toString(config.getDateTimeSeparators()) + + " at position " + (10 + 1) + ": " + chars, chars, 10); } } @@ -223,7 +217,7 @@ private static void assertNoMoreChars(String chars, int lastUsed) } } - private static Object parse(String chars, boolean raw) + private static Object parse(String chars, ParseConfig parseConfig, boolean raw) { if (chars == null) { @@ -270,7 +264,7 @@ private static Object parse(String chars, boolean raw) } // HOURS - assertAllowedDateTimeSeparator(chars); + assertAllowedDateTimeSeparator(chars, parseConfig); final int hours = parsePositiveInt(chars, 11, 13); // MINUTES @@ -279,7 +273,7 @@ private static Object parse(String chars, boolean raw) if (len > 16) { // SECONDS or TIMEZONE - return handleTime(chars, years, months, days, hours, minutes, raw); + return handleTime(parseConfig, chars, years, months, days, hours, minutes, raw); } // Have only minutes @@ -290,7 +284,7 @@ private static Object parse(String chars, boolean raw) throw raiseMissingGranularity(Field.SECOND, chars, 16); } - private static Object handleTimeResolution(int year, int month, int day, int hour, int minute, String chars, boolean raw) + private static Object handleTimeResolution(ParseConfig config, int year, int month, int day, int hour, int minute, String chars, boolean raw) { // From here the specification is more lenient final int length = chars.length(); @@ -300,7 +294,7 @@ private static Object handleTimeResolution(int year, int month, int day, int hou int fractions = 0; int fractionDigits = 0; char c = chars.charAt(19); - if (c == FRACTION_SEPARATOR) + if (config.isFractionSeparator(c)) { final int firstFraction = 20; if (chars.length() < 21) @@ -498,7 +492,7 @@ private void addFractions(char[] buf, int fractionDigits, int nano) @Override public OffsetDateTime parseDateTime(final String dateTime) { - return (OffsetDateTime) parse(dateTime, false); + return (OffsetDateTime) parse(dateTime, ParseConfig.DEFAULT, false); } @Override @@ -528,6 +522,12 @@ public String formatUtc(OffsetDateTime date) @Override public DateTime parse(String chars) { - return (DateTime) parse(chars, true); + return (DateTime) parse(chars, ParseConfig.DEFAULT, true); + } + + @Override + public DateTime parse(String chars, ParseConfig config) + { + return (DateTime) parse(chars, config, true); } } diff --git a/src/main/java/com/ethlo/time/internal/W3cDateTimeUtil.java b/src/main/java/com/ethlo/time/internal/W3cDateTimeUtil.java index 7159fe0..a423176 100644 --- a/src/main/java/com/ethlo/time/internal/W3cDateTimeUtil.java +++ b/src/main/java/com/ethlo/time/internal/W3cDateTimeUtil.java @@ -26,9 +26,10 @@ import com.ethlo.time.DateTime; import com.ethlo.time.Field; +import com.ethlo.time.ParseConfig; /** - * This class deals with the formats mentioned in W3C - NOTE-datetime: https://www.w3.org/TR/NOTE-datetime + * This class deals with the formats mentioned in W3C - NOTE-datetime: <a href="https://www.w3.org/TR/NOTE-datetime">...</a> * * <ul> * <li>Year:<br> @@ -90,4 +91,6 @@ public interface W3cDateTimeUtil DateTime parse(String s); String formatUtc(OffsetDateTime parse, Field lastIncluded); + + DateTime parse(String chars, ParseConfig config); } diff --git a/src/test/java/com/ethlo/time/ITUTest.java b/src/test/java/com/ethlo/time/ITUTest.java index 8b543f0..3854cdf 100644 --- a/src/test/java/com/ethlo/time/ITUTest.java +++ b/src/test/java/com/ethlo/time/ITUTest.java @@ -29,13 +29,16 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.fail; +import java.text.ParsePosition; import java.time.DateTimeException; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.Year; import java.time.YearMonth; import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; import java.time.temporal.Temporal; import org.junit.jupiter.api.Tag; @@ -321,5 +324,50 @@ void testRfcExample() assertThat(ITU.formatUtc(dA)).isEqualTo(ITU.formatUtc(dB)); } + @Test + void testParseCommaFractionSeparator() + { + final ParseConfig config = ParseConfig.DEFAULT + .withFractionSeparators('.', ',') + .withDateTimeSeparators('T', '|'); + final ParsePosition pos = new ParsePosition(0); + assertThat(ITU.parseLenient("1999-11-22|11:22:17,191", config, pos).toInstant()).isEqualTo(Instant.parse("1999-11-22T11:22:17.191Z")); + assertThat(pos.getErrorIndex()).isEqualTo(-1); + assertThat(pos.getIndex()).isEqualTo(23); + } + + @Test + void testParseUnparseable() + { + final ParsePosition pos = new ParsePosition(0); + assertThrows(DateTimeParseException.class, () -> ITU.parseLenient("1999-11-22|11:22:1", ParseConfig.DEFAULT, pos)); + assertThat(pos.getErrorIndex()).isEqualTo(10); + assertThat(pos.getIndex()).isEqualTo(10); + } + + @Test + void testParsePosition() + { + final ParsePosition pos = new ParsePosition(0); + ITU.parseLenient("1999-11-22T11:22:17.191", ParseConfig.DEFAULT, pos); + assertThat(pos.getIndex()).isEqualTo(23); + } + @Test + void testParsePositionDateTime() + { + final ParsePosition pos = new ParsePosition(0); + ITU.parseDateTime("1999-11-22T11:22:17.191Z", pos); + assertThat(pos.getIndex()).isEqualTo(24); + assertThat(pos.getErrorIndex()).isEqualTo(-1); + } + + @Test + void testParsePositionDateTimeInvalid() + { + final ParsePosition pos = new ParsePosition(0); + assertThrows(DateTimeException.class, () -> ITU.parseDateTime("1999-11-22X11:22:17.191Z", pos)); + assertThat(pos.getIndex()).isEqualTo(10); + assertThat(pos.getErrorIndex()).isEqualTo(10); + } } diff --git a/src/test/java/com/ethlo/time/ParseConfigTest.java b/src/test/java/com/ethlo/time/ParseConfigTest.java new file mode 100644 index 0000000..ac253bf --- /dev/null +++ b/src/test/java/com/ethlo/time/ParseConfigTest.java @@ -0,0 +1,35 @@ +package com.ethlo.time; + +/*- + * #%L + * Internet Time Utility + * %% + * Copyright (C) 2017 - 2024 Morten Haraldsen (ethlo) + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class ParseConfigTest +{ + @Test + void testParseConfigInvalid1() + { + assertThrows(IllegalArgumentException.class, () -> ParseConfig.DEFAULT.withDateTimeSeparators(null)); + assertThrows(IllegalArgumentException.class, () -> ParseConfig.DEFAULT.withDateTimeSeparators()); + } +} diff --git a/src/test/java/com/ethlo/time/fuzzer/ParseDateTimeFuzzTest.java b/src/test/java/com/ethlo/time/fuzzer/ParseDateTimeFuzzTest.java index 832feb7..c8728a4 100644 --- a/src/test/java/com/ethlo/time/fuzzer/ParseDateTimeFuzzTest.java +++ b/src/test/java/com/ethlo/time/fuzzer/ParseDateTimeFuzzTest.java @@ -9,9 +9,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/src/test/java/com/ethlo/time/fuzzer/ParseLenientFuzzTest.java b/src/test/java/com/ethlo/time/fuzzer/ParseLenientFuzzTest.java index 16d4629..9caf8b0 100644 --- a/src/test/java/com/ethlo/time/fuzzer/ParseLenientFuzzTest.java +++ b/src/test/java/com/ethlo/time/fuzzer/ParseLenientFuzzTest.java @@ -9,9 +9,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -20,8 +20,6 @@ * #L% */ -import java.time.DateTimeException; - /* import com.code_intelligence.jazzer.api.FuzzedDataProvider; import com.ethlo.time.DateTime;