From 1033c4bb2a3ac25e0d54e5091b7241b553bc1b67 Mon Sep 17 00:00:00 2001 From: Ben Yu Date: Sat, 16 Dec 2023 13:07:35 -0800 Subject: [PATCH] DateTimeFormats class to generate DateTimeFormatter based on example date/time strings --- .../com/google/mu/collect/InternalUtils.java | 9 + .../google/mu/collect/PrefixSearchTable.java | 172 +++++ .../com/google/mu/time/DateTimeFormats.java | 415 ++++++++++++ .../com/google/mu/util/CharPredicate.java | 32 +- .../com/google/mu/util/stream/BiStream.java | 3 +- .../mu/collect/PrefixSearchTableTest.java | 145 ++++ .../google/mu/time/DateTimeFormatsTest.java | 631 ++++++++++++++++++ .../com/google/mu/util/CharPredicateTest.java | 39 ++ 8 files changed, 1443 insertions(+), 3 deletions(-) create mode 100644 mug/src/main/java/com/google/mu/collect/InternalUtils.java create mode 100644 mug/src/main/java/com/google/mu/collect/PrefixSearchTable.java create mode 100644 mug/src/main/java/com/google/mu/time/DateTimeFormats.java create mode 100644 mug/src/test/java/com/google/mu/collect/PrefixSearchTableTest.java create mode 100644 mug/src/test/java/com/google/mu/time/DateTimeFormatsTest.java diff --git a/mug/src/main/java/com/google/mu/collect/InternalUtils.java b/mug/src/main/java/com/google/mu/collect/InternalUtils.java new file mode 100644 index 0000000000..dc60979c32 --- /dev/null +++ b/mug/src/main/java/com/google/mu/collect/InternalUtils.java @@ -0,0 +1,9 @@ +package com.google.mu.collect; + +final class InternalUtils { + static void checkArgument(boolean condition, String message, Object... args) { + if (!condition) { + throw new IllegalArgumentException(String.format(message, args)); + } + } +} diff --git a/mug/src/main/java/com/google/mu/collect/PrefixSearchTable.java b/mug/src/main/java/com/google/mu/collect/PrefixSearchTable.java new file mode 100644 index 0000000000..8f023f351b --- /dev/null +++ b/mug/src/main/java/com/google/mu/collect/PrefixSearchTable.java @@ -0,0 +1,172 @@ +/***************************************************************************** + * ------------------------------------------------------------------------- * + * 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. * + *****************************************************************************/ +package com.google.mu.collect; + +import static com.google.mu.collect.InternalUtils.checkArgument; +import static java.util.Collections.unmodifiableList; +import static java.util.Objects.requireNonNull; + +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; + +import com.google.mu.util.stream.BiStream; +import com.google.mu.util.stream.MoreStreams; + +/** + * A lookup table that stores prefix (a list of keys of type {@code K}) -> value mappings. + * + *

For example, if the table maps {@code [a, b]} prefix to a value "foo", when you search by + * {@code [a, b, c]}, it will find the {@code [a, b] -> foo} mapping. + * + *

Conceptually it's a "Trie" except it searches by a list of key prefixes instead of string + * prefixes. + * + * @since 7.1 + */ +public final class PrefixSearchTable { + private final Map> nodes; + + private PrefixSearchTable(Map> nodes) { + this.nodes = nodes; + } + + /** + * Searches the table for the longest prefix match of {@code compoundKey}. + * + * @return the value mapped to the longest prefix of {@code compoundKey} if present + * @throws IllegalArgumentException if {@code compoundKey} is empty + * @throws NullPointerException if {@code compoundKey} is null or any key element is null + */ + public Optional get(List compoundKey) { + return search(compoundKey).values().reduce((shorter, longer) -> longer); + } + + /** + * Searches the table for prefixes of {@code compoundKey}. If there are multiple prefixes, all of + * the matches will be returned, in ascending order of match length. + * + *

If no non-empty prefix exists in the table, an empty BiStream is returned. + * + *

To get the longest matched prefix, use {@link #get} instead. + * + * @return BiStream of the matched prefixes of {@code compoundKey} and the mapped values + * @throws IllegalArgumentException if {@code compoundKey} is empty + * @throws NullPointerException if {@code compoundKey} is null or any key element is null + */ + public BiStream, V> search(List compoundKey) { + checkArgument(compoundKey.size() > 0, "cannot search by empty key"); + return BiStream.fromEntries( + MoreStreams.whileNotNull( + new Supplier, V>>() { + private int index = 0; + private Map> remaining = nodes; + + @Override + public Map.Entry, V> get() { + while (remaining != null && index < compoundKey.size()) { + Node node = remaining.get(requireNonNull(compoundKey.get(index))); + if (node == null) { + return null; + } + index++; + remaining = node.children; + if (node.value != null) { + return new AbstractMap.SimpleImmutableEntry, V>( + unmodifiableList(compoundKey.subList(0, index)), node.value); + } + } + return null; + } + })); + } + + /** Returns a new builder. */ + public static Builder builder() { + return new Builder<>(); + } + + /** Builder of {@link PrefixSearchTable}. */ + public static final class Builder { + private final Map> nodes = new HashMap<>(); + + /** + * Adds the mapping from {@code compoundKey} to {@code value}. + * + * @return this builder + * @throws IllegalArgument if {@code compoundKey} is empty, or it has already been mapped to a + * value that's not equal to {@code value}. + * @throws NullPointerException if {@code compoundKey} is null, any of the key element is null + * or {@code value} is null + */ + public Builder add(List compoundKey, V value) { + int size = compoundKey.size(); + requireNonNull(value); + checkArgument(size > 0, "empty key not allowed"); + Node.Builder node = nodes.computeIfAbsent(compoundKey.get(0), k -> new Node.Builder<>()); + for (int i = 1; i < size; i++) { + node = node.child(compoundKey.get(i)); + } + checkArgument(node.set(value), "conflicting key: %s", compoundKey); + return this; + } + + public PrefixSearchTable build() { + return new PrefixSearchTable<>(BiStream.from(nodes).mapValues(Node.Builder::build).toMap()); + } + + Builder() {} + } + + private static class Node { + private final V value; + private final Map> children; + + private Node(V value, Map> children) { + this.value = value; + this.children = children; + } + + static class Builder { + private V value; + private final Map> children = new HashMap<>(); + + Builder child(K key) { + requireNonNull(key); + return children.computeIfAbsent(key, k -> new Builder()); + } + + /** + * Sets the value carried by this node to {@code value} if it's not already set. Return false + * if the value has already been set to a different value. + */ + boolean set(V value) { + if (this.value == null) { + this.value = value; + return true; + } else { + return value.equals(this.value); + } + } + + Node build() { + return new Node<>(value, BiStream.from(children).mapValues(Builder::build).toMap()); + } + } + } +} diff --git a/mug/src/main/java/com/google/mu/time/DateTimeFormats.java b/mug/src/main/java/com/google/mu/time/DateTimeFormats.java new file mode 100644 index 0000000000..0518c5ab8f --- /dev/null +++ b/mug/src/main/java/com/google/mu/time/DateTimeFormats.java @@ -0,0 +1,415 @@ +/***************************************************************************** + * ------------------------------------------------------------------------- * + * 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. * + *****************************************************************************/ +package com.google.mu.time; + +import static com.google.mu.util.CharPredicate.anyOf; +import static com.google.mu.util.CharPredicate.is; +import static com.google.mu.util.Substring.consecutive; +import static com.google.mu.util.Substring.first; +import static com.google.mu.util.Substring.firstOccurrence; +import static com.google.mu.util.Substring.leading; +import static com.google.mu.util.Substring.BoundStyle.INCLUSIVE; +import static com.google.mu.util.stream.BiCollectors.maxByKey; +import static com.google.mu.util.stream.BiStream.biStream; +import static java.util.Arrays.asList; +import static java.util.Comparator.comparingInt; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toMap; + +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import com.google.mu.collect.PrefixSearchTable; +import com.google.mu.util.CharPredicate; +import com.google.mu.util.Substring; +import com.google.mu.util.stream.BiStream; + +/** + * A facade class providing convenient {@link DateTimeFormatter} instances by inferring from an + * example date/time/datetime string in the expected format. + * + *

For example: + * + *

{@code
+ * private static final DateTimeFormatter DATE_TIME_FORMATTER =
+ *     DateTimeFormats.formatOf("2023-12-09 10:00:00.12345 America/Los_Angeles");
+ * private static final DateTimeFormatter USING_ZONE_OFFSET =
+ *     DateTimeFormats.formatOf("2023-12-09 10:00:00+08:00");
+ * private static final DateTimeFormatter ISO_FORMATTER =
+ *     DateTimeFormats.formatOf("2023-12-09T10:00:00.12345[Europe/Paris]");
+ * private static final DateTimeFormatter WITH_DAY_OF_WEEK =
+ *     DateTimeFormats.formatOf("2023/12/09 Sat 10:00+08:00");
+ * }
+ * + *

Most ISO 8601 formats are supported, except ISO_WEEK_DATE ('2012-W48-6') and ISO_ORDINAL_DATE + * ('2012-337'). + * + *

For the date part of custom patterns, only the variants with the same order as {@code + * yyyy/MM/dd} are supported, no {@code MM/dd/yyyy} or {@code dd/MM/yyyy}. + * + *

For the time part of custom patterns, only {@code HH:mm}, {@code HH:mm:ss} and {@code + * HH:mm:ss.S} variants are supported (the S can be 1 to 9 digits). AM/PM and 12-hour numbers are + * not supported. + * + *

Besides RFC_1123_DATE_TIME, day-of-week texts (such as Fri, Friday) and month-of-year texts + * (such as Jan, January) are not supported. It's assumed that you either use yyyy/MM/dd or + * yyyy-MM-dd. + * + *

If the variant of the date time pattern exceeds the out-of-box support, you can directly + * specify the {@link DateTimeFormatter} specifiers you care about, together with example + * placeholders (between a pair of curly braces) to be translated. + * + *

For example the following code uses the {@code dd}, {@code MM} and {@code yyyy} specifiers as + * is but translates the {@code Mon} and {@code America/New_York} example snippets into {@code E} + * and {@code VV} specifiers respectively. It will then parse and format to datetime strings like + * "Fri, 20 Oct 2023 10:30:59.123 Europe/Paris". + * + *

{@code
+ * private static final DateTimeFormatter FORMATTER =
+ *     formatOf("{Mon}, dd MM yyyy HH:mm:ss.SSS {America/New_York}");
+ * }
+ * + * @since 7.1 + */ +public final class DateTimeFormats { + private static final CharPredicate DIGIT = CharPredicate.range('0', '9'); + private static final CharPredicate ALPHA = + CharPredicate.range('a', 'z').or(CharPredicate.range('A', 'Z').or(is('_'))); + + /** delimiters don't have semantics and are ignored during parsing. */ + private static final CharPredicate DELIMITER = anyOf(" ,;").or(Character::isWhitespace); + + /** Punctuation chars, such as '/', ':', '-' are essential part of the pattern syntax. */ + private static final CharPredicate PUNCTUATION = DIGIT.or(ALPHA).or(DELIMITER).not(); + + private static final Substring.RepeatingPattern PLACEHOLDERS = + Substring.consecutive(CharPredicate.noneOf("{}")) + .immediatelyBetween("{", Substring.BoundStyle.INCLUSIVE, "}", INCLUSIVE) + .repeatedly(); + private static final Map, DateTimeFormatter> ISO_DATE_FORMATTERS = + BiStream.of( + forExample("20111203"), DateTimeFormatter.BASIC_ISO_DATE, + forExample("2011-12-03"), DateTimeFormatter.ISO_LOCAL_DATE, + forExample("2011-12-03+08:00"), DateTimeFormatter.ISO_DATE).toMap(); + + /** These ISO formats all support optional nanoseconds in the format of ".nnnnnnnnn". */ + private static final Map, DateTimeFormatter> ISO_DATE_TIME_FORMATTERS = + BiStream.of( + forExample("10:00:00"), DateTimeFormatter.ISO_LOCAL_TIME, + forExample("10:00:00+00:00"), DateTimeFormatter.ISO_TIME, + forExample("2011-12-03T10:15:30"), DateTimeFormatter.ISO_LOCAL_DATE_TIME, + forExample("2011-12-03T10:15:30+01:00"), DateTimeFormatter.ISO_DATE_TIME, + forExample("2011-12-03T10:15:30+01:00[Europe/Paris]"), DateTimeFormatter.ISO_DATE_TIME, + forExample("2011-12-03T10:15:30Z"), DateTimeFormatter.ISO_INSTANT).toMap(); + + /** The day-of-week part is optional; the day-of-month can be 1 or 2 digits. */ + private static final Map, DateTimeFormatter> RFC_1123_FORMATTERS = + Stream.of( + "Tue, 1 Jun 2008 11:05:30 GMT", + "Tue, 10 Jun 2008 11:05:30 GMT", + "1 Jun 2008 11:05:30 GMT", + "10 Jun 2008 11:05:30 GMT", + "Tue, 1 Jun 2008 11:05:30 +0800", + "Tue, 10 Jun 2008 11:05:30 +0800", + "1 Jun 2008 11:05:30 +0800", + "10 Jun 2008 11:05:30 +0800") + .collect( + toMap( + DateTimeFormats::forExample, ex -> DateTimeFormatter.RFC_1123_DATE_TIME)); + + private static final PrefixSearchTable PREFIX_TABLE = + PrefixSearchTable.builder() + .add(forExample("2011-12-03"), "yyyy-MM-dd") + .add(forExample("2011-12-3"), "yyyy-MM-d") + .add(forExample("2011/12/03"), "yyyy/MM/dd") + .add(forExample("2011/12/3"), "yyyy/MM/d") + .add(forExample("20111201"), "yyyyMMdd") + .add(forExample("Jan 11 2011"), "LLL dd yyyy") + .add(forExample("Jan 1 2011"), "LLL d yyyy") + .add(forExample("11 Jan 2011"), "dd LLL yyyy") + .add(forExample("1 Jan 2011"), "d LLL yyyy") + .add(forExample("2011 Jan 1"), "yyyy LLL d") + .add(forExample("2011 Jan 11"), "yyyy LLL dd") + .add(forExample("January 11 2011"), "LLLL dd yyyy") + .add(forExample("January 1 2011"), "LLLL d yyyy") + .add(forExample("11 January 2011"), "dd LLLL yyyy") + .add(forExample("1 January 2011"), "d LLLL yyyy") + .add(forExample("2011 January 1"), "yyyy LLLL d") + .add(forExample("2011 January 11"), "yyyy LLLL dd") + .add(forExample("10:15"), "HH:mm") + .add(forExample("10:15:30"), "HH:mm:ss") + .add(forExample("10:15:30.1"), "HH:mm:ss.S") + .add(forExample("10:15:30.12"), "HH:mm:ss.SS") + .add(forExample("10:15:30.123"), "HH:mm:ss.SSS") + .add(forExample("10:15:30.1234"), "HH:mm:ss.SSSS") + .add(forExample("10:15:30.12345"), "HH:mm:ss.SSSSS") + .add(forExample("10:15:30.123456"), "HH:mm:ss.SSSSSS") + .add(forExample("10:15:30.1234567"), "HH:mm:ss.SSSSSSS") + .add(forExample("10:15:30.12345678"), "HH:mm:ss.SSSSSSSS") + .add(forExample("10:15:30.123456789"), "HH:mm:ss.SSSSSSSSS") + .add(forExample("America/Los_Angeles"), "VV") + .add(forExample("PST"), "zzz") + .add(forExample("Central Time"), "zzzz") + .add(forExample("Alma-Ata Time"), "zzzz") + .add(forExample("Hawaii-Aleutian Daylight Time"), "zzzz") + .add(forExample("Dumont-d'Urville Time"), "zzzz") + .add(forExample("Pacific Standard Time"), "zzzz") + .add(forExample("Easter Island Standard Time"), "zzzz") + .add(forExample("Australian Central Western Standard Time"), "zzzz") + .add(forExample("Pierre & Miquelon Daylight Time"), "zzzz") + .add(forExample("Z"), "X") + .add(forExample("-08"), "x") + .add(forExample("+0800"), "ZZ") + .add(forExample("+08:00"), "ZZZZZ") + .add(forExample("GMT+8"), "O") + .add(forExample("GMT+12"), "O") + .add(forExample("GMT+08:00"), "OOOO") + .add(forExample("Fri"), "E") + .add(forExample("Friday"), "EEEE") + .add(forExample("Jan"), "LLL") + .add(forExample("January"), "LLLL") + .add(forExample("PM"), "a") + .add(forExample("AD"), "G") + .build(); + + /** + * Infers and returns the {@link DateTimeFormatter} based on {@code example}. + * + * @throws IllegalArgumentException if {@code example} is invalid or the pattern isn't supported. + */ + public static DateTimeFormatter formatOf(String example) { + return inferFromExample(example); + } + + /** + * Just so that the parameterized tests can call it with test parameter strings that are + * not @CompileTimeConstant. + */ + static DateTimeFormatter inferFromExample(String example) { + List signature = forExample(example); + DateTimeFormatter rfc = lookup(RFC_1123_FORMATTERS, signature).orElse(null); + if (rfc != null) return rfc; + DateTimeFormatter iso = lookup(ISO_DATE_FORMATTERS, signature).orElse(null); + if (iso != null) return iso; + // Ignore the ".nanosecond" part of the time in ISO examples because all ISO + // time formats allow the nanosecond part optionally, with 1 to 9 digits. + return lookup(ISO_DATE_TIME_FORMATTERS, forExample(removeNanosecondsPart(example))) + .map( + fmt -> { + try { + fmt.parse(example); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("invalid date time example: " + example, e); + } + return fmt; + }) + .orElseGet( + () -> { + AtomicInteger placeholderCount = new AtomicInteger(); + String pattern = + PLACEHOLDERS.replaceAllFrom( + example, + placeholder -> { + placeholderCount.incrementAndGet(); + System.out.println("translating placeholder " + placeholder); + return inferDateTimePattern(placeholder.skip(1, 1).toString()); + }); + try { + if (placeholderCount.get() > 0) { + // There is at least 1 placeholder. The input isn't a pure datetime "example". + // So we can't validate using parse(). + System.out.println("after placeholder translated: " + pattern); + return DateTimeFormatter.ofPattern(pattern); + } + pattern = inferDateTimePattern(example, signature); + DateTimeFormatter fmt = DateTimeFormatter.ofPattern(pattern); + fmt.parse(example); + return fmt; + } catch (DateTimeParseException | IllegalArgumentException e) { + throw new IllegalArgumentException( + "invalid date time example: " + example + " (" + pattern + ")", e); + } + }); + } + + static String inferDateTimePattern(String example) { + return inferDateTimePattern(example, forExample(example)); + } + + private static String inferDateTimePattern(String example, List signature) { + int matched = 0; + StringBuilder builder = new StringBuilder(); + for (List remaining = signature; + remaining.size() > 0; + remaining = signature.subList(matched, signature.size())) { + Object head = remaining.get(0); + if (head instanceof String && DELIMITER.matchesAllOf((String) head)) { + builder.append(head); + matched++; + continue; + } + + int consumed = + PREFIX_TABLE + .search(remaining) + .collect(maxByKey(comparingInt(List::size))) + .map( + (prefix, fmt) -> { + builder.append(fmt); + return prefix.size(); + }) + .orElse(0); + if (consumed <= 0) { + throw new IllegalArgumentException("unsupported example: " + example); + } + matched += consumed; + } + return builder.toString(); + } + + /** + * Tokenizes {@code example} into a token list such that two examples of equal signatures are + * considered equivalent. + * + *

An example string like {@code 2001-10-01 10:00:00 America/New_York} is considered to have + * the same signature as {@code Tue 2023-01-20 05:09:00 Europe/Paris} because they have the same + * number of digits for each part and the punctuations match, and their timezones are in the same + * format. Both their signature lists will be: {@code [4, -, 2, -, 2, space, 2, : 2, :, 2, space, + * word, /, word]}. + * + *

On the other hand {@code 10:01} and {@code 10:01:00} are considered to be different with + * signature lists being: {@code [2, :, 2]} and {@code [2, :, 2, :, 2]} respectively. + */ + private static List forExample(String example) { + return Stream.of(consecutive(DIGIT), consecutive(ALPHA), first(PUNCTUATION)) + .collect(firstOccurrence()) + .repeatedly() + .cut(example) + .filter(Substring.Match::isNotEmpty) + .map( + match -> { + if (DIGIT.matchesAnyOf(match)) { + System.out.println("got number token " + match); + return match.length(); // the number of digits in the example matter + } + String name = match.toString(); + if (PUNCTUATION.matchesAnyOf(name)) { + System.out.println("got punctuation token " + match); + // A punctuation that must be matched literally. + // But + and - are interchangeable (as example) in timezone spec. + return name.replace('+', '-'); + } + if (DELIMITER.matchesAnyOf(name)) { + System.out.println("got delimiter " + match); + // whitespaces are treated as is and will be ignored from prefix pattern matching + return name; + } + // Keyword equivalences are grouped by their pre-defined mappings. + // Differentiating known keywords to avoid matching apples as oranges. + // Single-letter words are reserved as format specifiers. + // Multi-letter unreserved words can be timezone names. + Token token = Token.ALL.get(name); + if (token != null) { + return token; + } + return name.length() == 1 ? name : Token.WORD; + }) + .collect(toList()); + } + + private static String removeNanosecondsPart(String example) { + return consecutive(DIGIT) + .immediatelyBetween(":", INCLUSIVE, ".", INCLUSIVE) // the "":ss."" in "HH:mm:ss.nnnnn" + .then(leading(DIGIT)) // the digits immediately after the ":ss." are the nanos + .in(example) + .map(nanos -> example.substring(0, nanos.index() - 1) + nanos.after()) + .orElse(example); + } + + private enum Token { + WEEKDAY_ABBREVIATION("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"), + WEEKDAY("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"), + WEEKDAY_CODES("E", "EEEE"), + MONTH_ABBREVIATION("Jan", "Feb", "Mar", "Apr", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"), + MONTH( + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December"), + MONTH_CODES("L", "LLL", "LLLL"), + YEAR_CODES("yyyy", "YYYY"), + DAY_CODES("dd", "d"), + HOUR_CODES("HH", "hh"), + MINUTE_CODES("mm"), + SECOND_CODES("ss"), + AM_OR_PM("am", "pm", "AM", "PM"), + AM_PM_CODES("a"), + AD_OR_BC("ad", "bc", "AD", "BC"), + AD_BC_CODES("G"), + ZONE_ABBREVIATION( + "ACDT", "ACST", "ACT", "ADT", "AEDT", "AEST", "AET", "AFT", "AKDT", "AKST", "AKT", "AMST", + "AST", "AT", "AWDT", "AWST", "AWT", "AZOST", "AZT", "BDT", "BET", "BIOT", "BRT", "BST", + "BT", "BTT", "CAST", "CAT", "CCT", "CDT", "CEDT", "CEST", "CET", "CHADT", "CHAST", "CHOST", + "CHOT", "CHUT", "CIST", "CIT", "CKT", "CLST", "CLT", "CST", "CT", "CVT", "CWST", "CXT", + "ChST", "DAVT", "DDUT", "DFT", "DUT", "EASST", "EAT", "ECT", "EDT", "EEDT", "EEST", "EET", + "EGST", "EGT", "EIT", "EST", "ET", "FET", "FJT", "FKST", "FKT", "FNT", "GALT", "GAMT", + "GFT", "GMT", "GST", "GT", "GYT", "HADT", "HAEC", "HAST", "HDT", "HKT", "HMT", "HOVT", + "HST", "HT", "ICT", "IDT", "IOT", "IRDT", "IRKT", "IRST", "IST", "IT", "JST", "JT", "KGT", + "KOST", "KRAT", "KST", "KT", "LHST", "LINT", "MAGT", "MAWT", "MDT", "MEST", "MET", "MHT", + "MMT", "MSK", "MST", "MT", "MUT", "MVT", "MYT", "NCT", "NDT", "NFT", "NPT", "NST", "NT", + "NUT", "NZDT", "NZST", "NZT", "OMST", "ORAT", "PDT", "PETT", "PGT", "PHOT", "PHT", "PKT", + "PMDT", "PMST", "PONT", "PST", "PT", "RET", "ROTT", "SAKT", "SAMT", "SAST", "SBT", "SCT", + "SGT", "SLT", "SRT", "SST", "ST", "SYOT", "TAHT", "TFT", "THA", "TJT", "TKT", "TLT", "TMT", + "TVT", "UCT", "ULAT", "UT", "UTC", "UYST", "UYT", "UZT", "VLAT", "VOLT", "VOST", "VUT", + "WAKT", "WAST", "WAT", "WEDT", "WEST", "WET", "WIB", "WIT", "WITA", "WST", "WT", "YAKT", + "YEKT", "YET", "YKT", "YST"), + ZONE_CODES("VV", "z", "zz", "zzz", "zzzz", "ZZ", "ZZZ", "ZZZZ", "ZZZZZ", "x", "X", "O", "OOOO"), + ZERO_OFFSET("Z"), + WORD; + + static final Map ALL = + biStream(Arrays.stream(Token.values())) + .flatMapKeys(token -> token.names.stream()) + .toMap(); + + private final Set names; + + Token(String... names) { + this.names = new HashSet(asList(names)); + } + } + + private static Optional lookup(Map map, Object key) { + return Optional.ofNullable(map.get(key)); + } + + private DateTimeFormats() {} +} diff --git a/mug/src/main/java/com/google/mu/util/CharPredicate.java b/mug/src/main/java/com/google/mu/util/CharPredicate.java index 28e38f289e..032922599b 100644 --- a/mug/src/main/java/com/google/mu/util/CharPredicate.java +++ b/mug/src/main/java/com/google/mu/util/CharPredicate.java @@ -89,6 +89,34 @@ static CharPredicate range(char from, char to) { }; } + /** Returns a CharPredicate that matches any of {@code chars}. */ + static CharPredicate anyOf(String chars) { + requireNonNull(chars); + return new CharPredicate() { + @Override public boolean test(char c) { + return chars.indexOf(c) >= 0; + } + + @Override public String toString() { + return "anyOf('" + chars + "')"; + } + }; + } + + /** Returns a CharPredicate that matches any of {@code chars}. */ + static CharPredicate noneOf(String chars) { + requireNonNull(chars); + return new CharPredicate() { + @Override public boolean test(char c) { + return chars.indexOf(c) < 0; + } + + @Override public String toString() { + return "noneOf('" + chars + "')"; + } + }; + } + /** Returns true if {@code ch} satisfies this predicate. */ boolean test(char ch); @@ -191,9 +219,9 @@ default boolean matchesAllOf(CharSequence sequence) { default boolean matchesNoneOf(CharSequence sequence) { for (int i = sequence.length() - 1; i >= 0; i--) { if (test(sequence.charAt(i))) { - return true; + return false; } } - return false; + return true; } } diff --git a/mug/src/main/java/com/google/mu/util/stream/BiStream.java b/mug/src/main/java/com/google/mu/util/stream/BiStream.java index 68dd504d12..285660a1a2 100644 --- a/mug/src/main/java/com/google/mu/util/stream/BiStream.java +++ b/mug/src/main/java/com/google/mu/util/stream/BiStream.java @@ -987,7 +987,8 @@ public interface Partitioner { boolean belong(A a1, B b1, A a2, B b2); } - static > BiStream fromEntries( + /** @since 7.1 */ + public static > BiStream fromEntries( Stream entryStream) { return new GenericEntryStream(entryStream, Map.Entry::getKey, Map.Entry::getValue) { @Override public BiStream map( diff --git a/mug/src/test/java/com/google/mu/collect/PrefixSearchTableTest.java b/mug/src/test/java/com/google/mu/collect/PrefixSearchTableTest.java new file mode 100644 index 0000000000..e1854abd69 --- /dev/null +++ b/mug/src/test/java/com/google/mu/collect/PrefixSearchTableTest.java @@ -0,0 +1,145 @@ +/***************************************************************************** + * ------------------------------------------------------------------------- * + * 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. * + *****************************************************************************/ +package com.google.mu.collect; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth8.assertThat; +import static java.util.Arrays.asList; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class PrefixSearchTableTest { + + @Test + public void emptyTable() { + PrefixSearchTable table = PrefixSearchTable.builder().build(); + assertThat(table.search(asList(1)).toMap()).isEmpty(); + assertThat(table.get(asList(1))).isEmpty(); + } + + @Test + public void emptyKeyCannotBeSearched() { + PrefixSearchTable table = PrefixSearchTable.builder().build(); + assertThrows(IllegalArgumentException.class, () -> table.search(asList())); + assertThrows(IllegalArgumentException.class, () -> table.get(asList())); + } + + @Test + public void emptyKeyCannotBeAdded() { + PrefixSearchTable.Builder builder = PrefixSearchTable.builder(); + assertThrows(IllegalArgumentException.class, () -> builder.add(asList(), "foo")); + } + + @Test + public void nullKeyElementCannotBeAdded() { + PrefixSearchTable.Builder builder = PrefixSearchTable.builder(); + assertThrows(NullPointerException.class, () -> builder.add(asList(1, null), "foo")); + } + + @Test + public void nullKeyCannotBeSearched() { + PrefixSearchTable table = + PrefixSearchTable.builder().add(asList(1), "foo").build(); + assertThrows(NullPointerException.class, () -> table.get(asList(1, null))); + } + + @Test + public void singleKeyMatched() { + PrefixSearchTable table = + PrefixSearchTable.builder().add(asList(1), "foo").build(); + assertThat(table.search(asList(1)).toMap()).containsExactly(asList(1), "foo"); + assertThat(table.get(asList(1))).hasValue("foo"); + } + + @Test + public void singleKeyNotMatched() { + PrefixSearchTable table = + PrefixSearchTable.builder().add(asList(1), "foo").build(); + assertThat(table.search(asList(2)).toMap()).isEmpty(); + assertThat(table.get(asList(2))).isEmpty(); + } + + @Test + public void singleKeyMatchesPrefix() { + PrefixSearchTable table = + PrefixSearchTable.builder().add(asList(1), "foo").build(); + assertThat(table.search(asList(1, 2, 3)).toMap()).containsExactly(asList(1), "foo"); + assertThat(table.get(asList(1, 2, 3))).hasValue("foo"); + } + + @Test + public void multipleKeysExactMatch() { + PrefixSearchTable table = + PrefixSearchTable.builder().add(asList(1, 2, 3), "foo").build(); + assertThat(table.search(asList(1, 2, 3)).toMap()).containsExactly(asList(1, 2, 3), "foo"); + assertThat(table.get(asList(1, 2, 3))).hasValue("foo"); + } + + @Test + public void multipleKeysPrefixMatched() { + PrefixSearchTable table = + PrefixSearchTable.builder().add(asList(1, 2, 3), "foo").build(); + assertThat(table.search(asList(1, 2, 3, 4, 5)).toMap()).containsExactly(asList(1, 2, 3), "foo"); + assertThat(table.get(asList(1, 2, 3, 4, 5))).hasValue("foo"); + } + + @Test + public void multipleKeysLongerThanSearchKeySize() { + PrefixSearchTable table = + PrefixSearchTable.builder().add(asList(1, 2, 3), "foo").build(); + assertThat(table.search(asList(1, 2)).toMap()).isEmpty(); + assertThat(table.get(asList(1, 2))).isEmpty(); + } + + @Test + public void multipleCandidates() { + PrefixSearchTable table = + PrefixSearchTable.builder() + .add(asList(1, 2, 3), "foo") + .add(asList(1, 2), "bar") + .add(asList(1, 2, 4), "baz") + .add(asList(2, 1, 3), "zoo") + .build(); + assertThat(table.search(asList(1, 2, 3)).toMap()) + .containsExactly(asList(1, 2), "bar", asList(1, 2, 3), "foo") + .inOrder(); + assertThat(table.get(asList(1, 2, 3))).hasValue("foo"); + } + + @Test + public void conflictingMappingDisallowed() { + PrefixSearchTable.Builder builder = PrefixSearchTable.builder(); + builder.add(asList(1, 2, 3), "foo"); + assertThrows(IllegalArgumentException.class, () -> builder.add(asList(1, 2, 3), "bar")); + } + + @Test + public void redundantMappingAllowed() { + PrefixSearchTable table = + PrefixSearchTable.builder() + .add(asList(1, 2, 3), "foo") + .add(asList(1), "bar") + .add(asList(1, 2, 3), "foo") + .build(); + assertThat(table.search(asList(1, 2, 3)).toMap()) + .containsExactly(asList(1), "bar", asList(1, 2, 3), "foo") + .inOrder(); + assertThat(table.get(asList(1, 2, 3))).hasValue("foo"); + } +} diff --git a/mug/src/test/java/com/google/mu/time/DateTimeFormatsTest.java b/mug/src/test/java/com/google/mu/time/DateTimeFormatsTest.java new file mode 100644 index 0000000000..9071908cc6 --- /dev/null +++ b/mug/src/test/java/com/google/mu/time/DateTimeFormatsTest.java @@ -0,0 +1,631 @@ +/***************************************************************************** + * ------------------------------------------------------------------------- * + * 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. * + *****************************************************************************/ +package com.google.mu.time; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.mu.time.DateTimeFormats.formatOf; +import static org.junit.Assert.assertThrows; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import com.google.common.truth.ComparableSubject; +import com.google.errorprone.annotations.CompileTimeConstant; +import com.google.testing.junit.testparameterinjector.TestParameter; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; + +@RunWith(TestParameterInjector.class) +public final class DateTimeFormatsTest { + @Test + public void dateOnlyExamples() { + assertLocalDate("2023-10-20", "yyyy-MM-dd").isEqualTo(LocalDate.of(2023, 10, 20)); + assertLocalDate("1986/01/01", "yyyy/MM/dd").isEqualTo(LocalDate.of(1986, 1, 1)); + } + + @Test + public void timeOnlyExamples() { + assertLocalTime("10:30:00", "HH:mm:ss").isEqualTo(LocalTime.of(10, 30, 0)); + assertLocalTime("10:30", "HH:mm").isEqualTo(LocalTime.of(10, 30, 0)); + assertLocalTime("10:30:00.001234", "HH:mm:ss.SSSSSS") + .isEqualTo(LocalTime.of(10, 30, 0, 1234000)); + assertLocalTime("10:30:00.123456789", "HH:mm:ss.SSSSSSSSS") + .isEqualTo(LocalTime.of(10, 30, 0, 123456789)); + } + + @Test + public void dateAndTimeExamples() { + assertLocalDateTime("2023-10-20 15:30:05", "yyyy-MM-dd HH:mm:ss") + .isEqualTo(LocalDateTime.of(2023, 10, 20, 15, 30, 5)); + assertLocalDateTime("2023/10/05 15:30:05", "yyyy/MM/dd HH:mm:ss") + .isEqualTo(LocalDateTime.of(2023, 10, 5, 15, 30, 5)); + } + + @Test + public void instantExample() { + Instant instant = Instant.parse("2023-10-05T15:30:05Z"); + assertThat(formatOf("2023-10-05T15:30:05Z").parse(Instant.now().toString())).isNotNull(); + } + + @Test + public void isoLocalDateTimeExample() { + assertThat(LocalDateTime.parse("2023-10-05T15:03:05", formatOf("2023-10-05T15:30:05"))) + .isEqualTo(LocalDateTime.of(2023, 10, 5, 15, 3, 5)); + } + + @Test + public void isoLocalDateExample() { + assertThat(LocalDate.parse("2022-10-05", formatOf("2023-10-05"))) + .isEqualTo(LocalDate.of(2022, 10, 5)); + } + + @Test + public void isoOffsetDateTimeExample() { + assertThat( + ZonedDateTime.parse("2022-10-05T00:10:00-08:00", formatOf("2023-10-05T11:12:13-05:00"))) + .isEqualTo( + ZonedDateTime.of(LocalDateTime.of(2022, 10, 5, 0, 10, 0, 0), ZoneId.of("-08:00"))); + assertThat( + ZonedDateTime.parse( + "2022-10-05T00:10:00.123456789-08:00", formatOf("2023-10-05T11:12:13-05:00"))) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2022, 10, 5, 0, 10, 0, 123456789), ZoneId.of("-08:00"))); + } + + @Test + public void isoZonedDateTimeExample() { + assertThat( + ZonedDateTime.parse( + "2022-10-05T00:10:00-07:00[America/Los_Angeles]", + formatOf("2023-10-09T11:12:13+01:00[Europe/Paris]"))) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2022, 10, 5, 0, 10, 0, 0), ZoneId.of("America/Los_Angeles"))); + assertThat( + ZonedDateTime.parse( + "2022-10-05T00:10:00.123456789-07:00[America/Los_Angeles]", + formatOf("2023-10-09T11:12:13+01:00[Europe/Paris]"))) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2022, 10, 5, 0, 10, 0, 123456789), + ZoneId.of("America/Los_Angeles"))); + } + + @Test + public void isoZonedDateTime_withNanosExample() { + assertThat( + ZonedDateTime.parse( + "2022-10-05T00:10:00.123456789-07:00[America/Los_Angeles]", + formatOf("2023-10-09T11:12:13.1+01:00[Europe/Paris]"))) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2022, 10, 5, 0, 10, 0, 123456789), + ZoneId.of("America/Los_Angeles"))); + assertThat( + ZonedDateTime.parse( + "2022-10-05T00:10:00.123456789-07:00[America/Los_Angeles]", + formatOf("2023-10-09T11:12:13.123456+01:00[Europe/Paris]"))) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2022, 10, 5, 0, 10, 0, 123456789), + ZoneId.of("America/Los_Angeles"))); + } + + @Test + public void isoLocalTimeExample() { + assertThat(LocalTime.parse("10:20:10", formatOf("10:30:12"))) + .isEqualTo(LocalTime.of(10, 20, 10)); + } + + @Test + public void basicIsoDateExample() { + assertThat(LocalDate.parse("19881010", formatOf("20231020"))) + .isEqualTo(LocalDate.of(1988, 10, 10)); + } + + @Test + public void rfc1123Example() { + assertThat( + ZonedDateTime.parse( + "Fri, 6 Jun 2008 03:10:10 GMT", formatOf("Tue, 10 Jun 2008 11:05:30 GMT"))) + .isEqualTo(ZonedDateTime.of(LocalDateTime.of(2008, 6, 6, 3, 10, 10, 0), ZoneOffset.UTC)); + assertThat( + ZonedDateTime.parse( + "6 Jun 2008 03:10:10 GMT", formatOf("Tue, 3 Jun 2008 11:05:30 GMT"))) + .isEqualTo(ZonedDateTime.of(LocalDateTime.of(2008, 6, 6, 3, 10, 10, 0), ZoneOffset.UTC)); + assertThat( + ZonedDateTime.parse( + "Fri, 20 Jun 2008 03:10:10 GMT", formatOf("13 Jun 2008 11:05:30 GMT"))) + .isEqualTo(ZonedDateTime.of(LocalDateTime.of(2008, 6, 20, 3, 10, 10, 0), ZoneOffset.UTC)); + assertThat(ZonedDateTime.parse("13 Jun 2008 03:10:10 GMT", formatOf("3 Jun 2008 11:05:30 GMT"))) + .isEqualTo(ZonedDateTime.of(LocalDateTime.of(2008, 6, 13, 3, 10, 10, 0), ZoneOffset.UTC)); + } + + @Test + public void monthOfYear_notSupported() { + assertThrows( + IllegalArgumentException.class, () -> formatOf("Dec 31, 2023 12:00:00 America/New_York")); + } + + @Test + public void mmddyyyy_notSupported() { + assertThrows(IllegalArgumentException.class, () -> formatOf("10/20/2023 10:10:10")); + } + + @Test + public void formatOf_mmddyyMixedIn() { + DateTimeFormatter formatter = formatOf("MM/dd/yyyy {12:10:00} {America/New_York}"); + ZonedDateTime zonedTime = + ZonedDateTime.of(LocalDateTime.of(2023, 10, 20, 1, 2, 3), ZoneId.of("America/Los_Angeles")); + assertThat(zonedTime.format(formatter)).isEqualTo("10/20/2023 01:02:03 America/Los_Angeles"); + } + + @Test + public void formatOf_ddmmyyMixedIn() { + DateTimeFormatter formatter = formatOf("dd MM yyyy {12:10:00 America/New_York}"); + ZonedDateTime zonedTime = + ZonedDateTime.of(LocalDateTime.of(2023, 10, 20, 1, 2, 3), ZoneId.of("America/Los_Angeles")); + assertThat(zonedTime.format(formatter)).isEqualTo("20 10 2023 01:02:03 America/Los_Angeles"); + } + + @Test + public void formatOf_monthOfYearMixedIn() { + DateTimeFormatter formatter = formatOf("E, LLL dd yyyy {12:10:00 America/New_York}"); + ZonedDateTime zonedTime = + ZonedDateTime.of(LocalDateTime.of(2023, 10, 20, 1, 2, 3), ZoneId.of("America/Los_Angeles")); + assertEquivalent(formatter, zonedTime, "E, LLL dd yyyy HH:mm:ss VV"); + } + + @Test + public void formatOf_fullWeekdayAndMonthNamePlaceholder() { + ZonedDateTime zonedTime = + ZonedDateTime.of(LocalDateTime.of(2023, 10, 20, 1, 2, 3), ZoneId.of("America/Los_Angeles")); + DateTimeFormatter formatter = formatOf("{Tuesday}, {May} dd yyyy {12:10:00} {+08:00} {America/New_York}"); + assertEquivalent(formatter, zonedTime, "EEEE, LLLL dd yyyy HH:mm:ss ZZZZZ VV"); + } + + @Test + public void formatOf_12HourFormat() { + ZonedDateTime zonedTime = + ZonedDateTime.of(LocalDateTime.of(2023, 10, 20, 1, 2, 3), ZoneId.of("America/Los_Angeles")); + DateTimeFormatter formatter = formatOf("dd MM yyyy {ad} hh:mm {pm} {+08:00}"); + assertThat(zonedTime.format(formatter)).isEqualTo("20 10 2023 AD 01:02 AM -07:00"); + } + + @Test + public void formatOf_zoneNameNotRetranslated() { + DateTimeFormatter formatter = formatOf("{Mon}, {Jan} dd yyyy {12:10:00} VV"); + ZonedDateTime zonedTime = + ZonedDateTime.of(LocalDateTime.of(2023, 10, 20, 1, 2, 3), ZoneId.of("America/Los_Angeles")); + assertEquivalent(formatter, zonedTime, "E, LLL dd yyyy HH:mm:ss VV"); + } + + @Test + public void formatOf_zoneOffsetNotRetranslated() { + DateTimeFormatter formatter = formatOf("E, LLL dd yyyy {12:10:00} zzzz"); + ZonedDateTime zonedTime = + ZonedDateTime.of(LocalDateTime.of(2023, 10, 20, 1, 2, 3), ZoneId.of("America/Los_Angeles")); + assertEquivalent(formatter, zonedTime, "E, LLL dd yyyy HH:mm:ss zzzz"); + } + + @Test + public void formatOf_monthOfYearMixedIn_withDayOfWeek() { + DateTimeFormatter formatter = formatOf("E, LLL dd yyyy {12:10:00} {America/New_York}"); + ZonedDateTime zonedTime = + ZonedDateTime.of(LocalDateTime.of(2023, 10, 20, 1, 2, 3), ZoneId.of("America/Los_Angeles")); + assertEquivalent(formatter, zonedTime, "E, LLL dd yyyy HH:mm:ss VV"); + } + + @Test + public void localTimeExamples( + @TestParameter({ + "00:00", + "12:00", + "00:00:00", + "00:00:00.000000001", + "00:01:02.3", + "00:01:02.34", + "23:59:59.999999999" + }) + String example) { + DateTimeFormatter formatter = DateTimeFormats.inferFromExample(example); + LocalTime time = LocalTime.parse(example, formatter); + assertThat(time.format(formatter)).isEqualTo(example); + } + + @Test + public void localDateExamplesFromDifferentFormatters( + @TestParameter({"BASIC_ISO_DATE", "ISO_LOCAL_DATE", "yyyy/MM/dd", "yyyyMMdd"}) + String formatterName, + @TestParameter({"2020-01-01", "1979-01-01", "2035-12-31"}) String date) + throws Exception { + LocalDate day = LocalDate.parse(date); + String example = day.format(getFormatterByName(formatterName)); + assertThat(LocalDate.parse(example, DateTimeFormats.inferFromExample(example))).isEqualTo(day); + } + + @Test + public void zonedDateTimeExamplesFromDifferentFormatters( + @TestParameter({ + "ISO_OFFSET_DATE_TIME", + "ISO_DATE_TIME", + "ISO_ZONED_DATE_TIME", + "RFC_1123_DATE_TIME", + "yyyy/MM/dd HH:mm:ss.SSSSSSX", + "yyyy/MM/dd HH:mm:ss.SSSSSSx", + "yyyyMMdd HH:mm:ssZ", + "yyyy/MM/dd HH:mm:ssZ", + "yyyy/MM/dd HH:mm:ss.nnnZZ", + "yyyy-MM-dd HH:mm:ss.nnnZZZ", + "yyyy-MM-dd HH:mm:ssZZZZZ", + "yyyy-MM-dd HH:mm:ssz", + "yyyy-MM-dd HH:mm:sszz", + "yyyy-MM-dd HH:mm:sszzz", + "yyyy-MM-d HH:mm:ssZ", + "yyyy-MM-dd HH:mm:ssZZZZZ", + "yyyy-MM-dd HH:mm:ss VV", + }) + String formatterName, + @TestParameter({ + "2020-01-01T00:00:00+08:00", + "1979-01-01T00:00:00+01:00", + "2035-12-31T00:00:01-12:00" + }) + String datetime) + throws Exception { + ZonedDateTime zonedTime = OffsetDateTime.parse(datetime).toZonedDateTime(); + String example = zonedTime.format(getFormatterByName(formatterName)); + assertThat(ZonedDateTime.parse(example, DateTimeFormats.inferFromExample(example))) + .isEqualTo(zonedTime); + } + + @Test + public void withZoneIdExamplesFromDifferentFormatters( + @TestParameter({ + "ISO_OFFSET_DATE_TIME", + "ISO_DATE_TIME", + "ISO_ZONED_DATE_TIME", + "RFC_1123_DATE_TIME", + "yyyyMMdd HH:mm:ssa VV", + "yyyy/MM/dd HH:mm:ss VV", + "yyyy/MM/dd HH:mm:ss.nnn VV", + "yyyy/MM/dd HH:mm:ss.nnn VV", + "yyyy/MM/dd HH:mm:ss.SSSSSS VV", + "yyyy-MM-dd HH:mm:ss.SSSSSS VV", + "yyyy/MM/dd HH:mm:ss.SSSSSSx", + "yyyy/MM/dd HH:mm:ss.SSSSSSX", + "yyyy/MM/dd HH:mm:ssZ", + "yyyy/MM/dd HH:mm:ssZZ", + "yyyy/MM/dd HH:mm:ssZZZ", + "yyyy/MM/dd HH:mm:ssZZZZZ", + "yyyy/MM/dd HH:mm:ssz", + "yyyy/MM/dd HH:mm:sszz", + "yyyy/MM/dd HH:mm:sszzz", + "yyyy/MM/dd HH:mm:ssz", + "yyyy/MM/dd HH:mm:sszzzz", + }) + String formatterName, + @TestParameter({ + "2020-01-01T00:00:01-07:00[America/New_York]", + "1979-01-01T00:00:00+01:00[Europe/Paris]", + }) + String datetime) + throws Exception { + ZonedDateTime zonedTime = ZonedDateTime.parse(datetime, DateTimeFormatter.ISO_DATE_TIME); + String example = zonedTime.format(getFormatterByName(formatterName)); + assertThat( + ZonedDateTime.parse(example, DateTimeFormats.inferFromExample(example)) + .withFixedOffsetZone()) + .isEqualTo(zonedTime.withFixedOffsetZone()); + } + + @Test + public void zoneIdRetainedExamples( + @TestParameter({ + "ISO_DATE_TIME", + "ISO_ZONED_DATE_TIME", + "yyyyMMdd HH:mm:ss VV", + "yyyy/MM/dd HH:mm:ss VV", + "yyyy/MM/dd HH:mm:ss.nnn VV", + "yyyy/MM/dd HH:mm:ss.nnn VV", + "yyyy/MM/dd HH:mm:ss.SSSSSS VV", + "yyyy-MM-dd HH:mm:ss.SSSSSS VV", + "yyyy-MM-dd HH:mm:ss.SSSSSS z", + "yyyy-MM-dd HH:mm:ss.SSSSSS zz", + "yyyy-MM-dd HH:mm:ss.SSSSSS zzz", + }) + String formatterName, + @TestParameter({ + "2020-01-01T00:00:01-07:00[America/New_York]", + "1979-01-01T00:00:00+01:00[Europe/Paris]", + }) + String datetime) + throws Exception { + ZonedDateTime zonedTime = ZonedDateTime.parse(datetime, DateTimeFormatter.ISO_DATE_TIME); + String example = zonedTime.format(getFormatterByName(formatterName)); + assertThat(ZonedDateTime.parse(example, DateTimeFormats.inferFromExample(example))) + .isEqualTo(zonedTime); + } + + @Test + public void zonedDateTimeWithNanosExamples( + @TestParameter({ + "ISO_OFFSET_DATE_TIME", + "ISO_DATE_TIME", + "ISO_ZONED_DATE_TIME", + "yyyy/MM/dd HH:mm:ss.SSSSSSX", + "yyyy/MM/dd HH:mm:ss.SSSSSSVV", + "yyyyMMdd HH:mm:ss.SSSSSSZ", + "yyyy/MM/dd HH:mm:ss.SSSSSSZ", + "yyyy/MM/dd HH:mm:ss.SSSSSSVV", + "yyyy/MM/dd HH:mm:ss.SSSSSSz", + "yyyy/MM/dd HH:mm:ss.SSSSSSzz", + "yyyy/MM/dd HH:mm:ss.SSSSSSzzz", + "yyyy/MM/dd HH:mm:ss.SSSSSSzzzz", + "yyyy/MM/dd HH:mm:ss.SSSSSSSSSVV", + }) + String formatterName, + @TestParameter({ + "2020-01-01T00:00:00.123+08:00", + "1979-01-01T00:00:00.1+01:00", + "2035-12-31T00:00:01.123456-12:00" + }) + String datetime) + throws Exception { + ZonedDateTime zonedTime = OffsetDateTime.parse(datetime).toZonedDateTime(); + String example = zonedTime.format(getFormatterByName(formatterName)); + assertThat(ZonedDateTime.parse(example, DateTimeFormats.inferFromExample(example))) + .isEqualTo(zonedTime); + } + + @Test + public void offsetTimeExamples( + @TestParameter({"00:00:00+18:00", "12:00-08:00", "23:59:59.999999999-18:00"}) + String example) { + DateTimeFormatter formatter = DateTimeFormats.inferFromExample(example); + OffsetTime time = OffsetTime.parse(example, formatter); + assertThat(time.format(formatter)).isEqualTo(example); + } + + @Test + public void timeZoneMixedIn_zeroOffset() { + DateTimeFormatter formatter = DateTimeFormats.inferFromExample("M dd yyyy HH:mm:ss{Z}"); + ZonedDateTime dateTime = ZonedDateTime.parse("1 10 2023 10:20:30Z", formatter); + assertThat(dateTime) + .isEqualTo(ZonedDateTime.of(LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneOffset.UTC)); + } + + @Test + public void timeZoneMixedIn_offsetWithoutColon() { + DateTimeFormatter formatter = DateTimeFormats.inferFromExample("MM dd yyyy HH:mm:ss{+0100}"); + ZonedDateTime dateTime = ZonedDateTime.parse("01 10 2023 10:20:30-0800", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of(LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneOffset.ofHours(-8))); + } + + @Test + public void timeZoneMixedIn_hourOffset() { + DateTimeFormatter formatter = DateTimeFormats.inferFromExample("M dd yyyy HH:mm:ss{+01}"); + ZonedDateTime dateTime = ZonedDateTime.parse("1 10 2023 10:20:30-08", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of(LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneOffset.ofHours(-8))); + } + + @Test + public void timeZoneMixedIn_offsetWithColon() { + DateTimeFormatter formatter = DateTimeFormats.inferFromExample("M dd yyyy HH:mm:ss{-01:00}"); + ZonedDateTime dateTime = ZonedDateTime.parse("1 10 2023 10:20:30-08:00", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of(LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneOffset.ofHours(-8))); + } + + @Test + public void timeZoneMixedIn_zoneNameWithEuropeDateStyle() { + DateTimeFormatter formatter = + DateTimeFormats.inferFromExample("dd MM yyyy HH:mm:ss.SSS {America/New_York}"); + ZonedDateTime dateTime = ZonedDateTime.parse("30 10 2023 10:20:30.123 Europe/Paris", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2023, 10, 30, 10, 20, 30, 123000000), ZoneId.of("Europe/Paris"))); + } + + @Test + public void timeZoneMixedIn_offsetWithAmericanDateStyle() { + DateTimeFormatter formatter = DateTimeFormats.inferFromExample("M dd yyyy HH:mm:ss{+01:00}"); + ZonedDateTime dateTime = ZonedDateTime.parse("1 10 2023 10:20:30-07:00", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of(LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneId.of("-07:00"))); + } + + @Test + public void timeZoneMixedIn_twoLetterZoneNameAbbreviation() { + DateTimeFormatter formatter = DateTimeFormats.inferFromExample("M dd yyyy HH:mm:ss{PT}"); + ZonedDateTime dateTime = ZonedDateTime.parse("1 10 2023 10:20:30PT", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneId.of("America/Los_Angeles"))); + } + + @Test + public void timeZoneMixedIn_fourLetterZoneNameAbbreviation() { + DateTimeFormatter formatter = DateTimeFormats.inferFromExample("M dd yyyy HH:mm:ss{CAST}"); + ZonedDateTime dateTime = ZonedDateTime.parse("1 10 2023 10:20:30CAST", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneId.of("Africa/Maputo"))); + } + + @Test + public void timeZoneMixedIn_abbreviatedZoneName() { + DateTimeFormatter formatter = DateTimeFormats.inferFromExample("MM dd yyyy HH:mm:ss{GMT}"); + ZonedDateTime dateTime = ZonedDateTime.parse("01 10 2023 10:20:30PST", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneId.of("America/Los_Angeles"))); + } + + @Test + public void timeZoneMixedIn_twoWordsZoneName() { + DateTimeFormatter formatter = + DateTimeFormats.inferFromExample("M dd yyyy HH:mm:ss {Eastern Time}"); + ZonedDateTime dateTime = + ZonedDateTime.parse("1 10 2023 10:20:30 Eastern Standard Time", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneId.of("America/New_York"))); + } + + @Test + public void timeZoneMixedIn_threeWordsZoneName() { + DateTimeFormatter formatter = + DateTimeFormats.inferFromExample("M dd yyyy HH:mm:ss {Zulu Time Zone}"); + ZonedDateTime dateTime = + ZonedDateTime.parse("1 10 2023 10:20:30 Eastern Standard Time", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneId.of("America/New_York"))); + } + + @Test + public void timeZoneMixedIn_fourWordsZoneName() { + DateTimeFormatter formatter = + DateTimeFormats.inferFromExample("M dd yyyy HH:mm:ss {Eastern European Summer Time}"); + ZonedDateTime dateTime = + ZonedDateTime.parse("1 10 2023 10:20:30 Eastern Standard Time", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneId.of("America/New_York"))); + } + + @Test + public void timeZoneMixedIn_fiveWordsZoneName() { + DateTimeFormatter formatter = + DateTimeFormats.inferFromExample( + "M dd yyyy HH:mm:ss {French Southern and Antarctic Time}"); + ZonedDateTime dateTime = + ZonedDateTime.parse("1 10 2023 10:20:30 Eastern Standard Time", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneId.of("America/New_York"))); + } + + @Test + public void timeZoneMixedIn_zoneNameWithDash() { + DateTimeFormatter formatter = + DateTimeFormats.inferFromExample("M dd yyyy HH:mm:ss {Further-Eastern European Time}"); + ZonedDateTime dateTime = + ZonedDateTime.parse("1 10 2023 10:20:30 Eastern Standard Time", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneId.of("America/New_York"))); + } + + @Test + public void timeZoneMixedIn_zoneNameWithApostrophe() { + DateTimeFormatter formatter = + DateTimeFormats.inferFromExample("M dd yyyy HH:mm:ss {Dumont-d'Urville Time}"); + ZonedDateTime dateTime = ZonedDateTime.parse("1 10 2023 10:20:30 Eastern Time", formatter); + assertThat(dateTime) + .isEqualTo( + ZonedDateTime.of( + LocalDateTime.of(2023, 1, 10, 10, 20, 30, 0), ZoneId.of("America/New_York"))); + } + + @Test + public void timeZoneMixedIn_unsupportedZoneSpec() { + assertThrows( + IllegalArgumentException.class, () -> DateTimeFormats.inferDateTimePattern("1234")); + assertThrows( + IllegalArgumentException.class, () -> DateTimeFormats.inferDateTimePattern("12:34:5")); + } + + private static ComparableSubject assertLocalDateTime( + @CompileTimeConstant String example, String equivalentPattern) { + String pattern = DateTimeFormats.inferDateTimePattern(example); + assertThat(pattern).isEqualTo(equivalentPattern); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern); + LocalDateTime dateTime = LocalDateTime.parse(example, formatter); + assertThat(dateTime.format(formatter)).isEqualTo(example); + return assertThat(dateTime); + } + + private static ComparableSubject assertLocalDate( + @CompileTimeConstant String example, String equivalentPattern) { + String pattern = DateTimeFormats.inferDateTimePattern(example); + assertThat(pattern).isEqualTo(equivalentPattern); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern); + LocalDate date = LocalDate.parse(example, formatter); + assertThat(date.format(formatter)).isEqualTo(example); + return assertThat(date); + } + + private static ComparableSubject assertLocalTime( + @CompileTimeConstant String example, String equivalentPattern) { + String pattern = DateTimeFormats.inferDateTimePattern(example); + assertThat(pattern).isEqualTo(equivalentPattern); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern); + LocalTime time = LocalTime.parse(example, formatter); + assertThat(time.format(formatter)).isEqualTo(example); + return assertThat(time); + } + + private static ComparableSubject assertZonedDateTime( + @CompileTimeConstant String example, String equivalentPattern) { + String pattern = DateTimeFormats.inferDateTimePattern(example); + assertThat(pattern).isEqualTo(equivalentPattern); + DateTimeFormatter formatter = formatOf(example); + ZonedDateTime datetime = ZonedDateTime.parse(example, formatter); + assertThat(datetime.format(formatter)).isEqualTo(example); + return assertThat(datetime); + } + + private static DateTimeFormatter getFormatterByName(String formatterName) throws Exception { + try { + return (DateTimeFormatter) DateTimeFormatter.class.getDeclaredField(formatterName).get(null); + } catch (NoSuchFieldException e) { + return DateTimeFormatter.ofPattern(formatterName); + } + } + + private static void assertEquivalent(DateTimeFormatter formatter, ZonedDateTime time, String pattern) { + assertThat(time.format(formatter)).isEqualTo(time.format(DateTimeFormatter.ofPattern(pattern))); + assertThat(ZonedDateTime.parse(time.format(DateTimeFormatter.ofPattern(pattern)), formatter)) + .isEqualTo(time); + } +} diff --git a/mug/src/test/java/com/google/mu/util/CharPredicateTest.java b/mug/src/test/java/com/google/mu/util/CharPredicateTest.java index faa4940212..097218f879 100644 --- a/mug/src/test/java/com/google/mu/util/CharPredicateTest.java +++ b/mug/src/test/java/com/google/mu/util/CharPredicateTest.java @@ -69,6 +69,45 @@ public class CharPredicateTest { assertThat(CharPredicate.range('A', 'Z').or('c').toString()).isEqualTo("['A', 'Z'] | 'c'"); } + @Test public void testAnyOf() { + assertThat(CharPredicate.anyOf("ab").test('a')).isTrue(); + assertThat(CharPredicate.anyOf("ab").test('b')).isTrue(); + assertThat(CharPredicate.anyOf("ab").test('c')).isFalse(); + } + + @Test public void testAnyOf_toString() { + assertThat(CharPredicate.anyOf("ab").toString()).isEqualTo("anyOf('ab')"); + } + + @Test public void testNoneOf() { + assertThat(CharPredicate.noneOf("ab").test('a')).isFalse(); + assertThat(CharPredicate.noneOf("ab").test('b')).isFalse(); + assertThat(CharPredicate.noneOf("ab").test('c')).isTrue(); + } + + @Test public void testNoneOf_toString() { + assertThat(CharPredicate.noneOf("ab").toString()).isEqualTo("noneOf('ab')"); + } + + @Test public void testMatchesAnyOf() { + assertThat(CharPredicate.range('0', '9').matchesAnyOf("-")).isFalse(); + assertThat(CharPredicate.range('0', '9').matchesAnyOf("0")).isTrue(); + } + + @Test public void testMatchesNoneOf() { + assertThat(CharPredicate.anyOf("ab").matchesNoneOf("a")).isFalse(); + assertThat(CharPredicate.anyOf("ab").matchesNoneOf("b")).isFalse(); + assertThat(CharPredicate.anyOf("ab").matchesNoneOf("c")).isTrue(); + } + + @Test public void testMatchesAllOf() { + assertThat(CharPredicate.anyOf("ab").matchesAllOf("a")).isTrue(); + assertThat(CharPredicate.anyOf("ab").matchesAllOf("b")).isTrue(); + assertThat(CharPredicate.anyOf("ab").matchesAllOf("ba")).isTrue(); + assertThat(CharPredicate.anyOf("ab").matchesAllOf("abc")).isFalse(); + assertThat(CharPredicate.anyOf("ab").matchesAllOf("c")).isFalse(); + } + @Test public void testNulls() throws Throwable { CharPredicate p = CharPredicate.is('a'); new NullPointerTester().testAllPublicInstanceMethods(p);