From 307ef6b4b51f5f9266f6212015a9b5a1f1e75df4 Mon Sep 17 00:00:00 2001 From: Ivan Frade Date: Mon, 11 Nov 2024 13:13:59 -0800 Subject: [PATCH] GitTimeParser: A date parser using the java.time API Replacement of GitDateParser that uses java.time classes instead of the obsolete Date. Updating GitDateParser would have been a mess of deprecation and methods with confusing names, so I opted for writing a parallel class with the new types. Some differences: * The new DateTimeFormatter is thread-safe, so we don't need the LocalThread cache * No code seems to use other locale than the default, we don't need to cache per locale either Change-Id: If24610a055a47702fb5b7be2fc35a7c722480ee3 --- .../util/GitTimeParserBadlyFormattedTest.java | 62 +++++ .../eclipse/jgit/util/GitTimeParserTest.java | 247 ++++++++++++++++++ .../org/eclipse/jgit/util/GitDateParser.java | 3 + .../org/eclipse/jgit/util/GitTimeParser.java | 202 ++++++++++++++ 4 files changed, 514 insertions(+) create mode 100644 org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserBadlyFormattedTest.java create mode 100644 org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserTest.java create mode 100644 org.eclipse.jgit/src/org/eclipse/jgit/util/GitTimeParser.java diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserBadlyFormattedTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserBadlyFormattedTest.java new file mode 100644 index 0000000000..e5f162d11a --- /dev/null +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserBadlyFormattedTest.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2012, Christian Halstrick and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.util; + +import static org.junit.Assert.assertThrows; + +import java.text.ParseException; + +import org.eclipse.jgit.junit.MockSystemReader; +import org.junit.After; +import org.junit.Before; +import org.junit.experimental.theories.DataPoints; +import org.junit.experimental.theories.Theories; +import org.junit.experimental.theories.Theory; +import org.junit.runner.RunWith; + +/** + * Tests which assert that unparseable Strings lead to ParseExceptions + */ +@RunWith(Theories.class) +public class GitTimeParserBadlyFormattedTest { + private String dateStr; + + @Before + public void setUp() { + MockSystemReader mockSystemReader = new MockSystemReader(); + SystemReader.setInstance(mockSystemReader); + } + + @After + public void tearDown() { + SystemReader.setInstance(null); + } + + public GitTimeParserBadlyFormattedTest(String dateStr) { + this.dateStr = dateStr; + } + + @DataPoints + public static String[] getDataPoints() { + return new String[] { "", "1970", "3000.3000.3000", "3 yesterday ago", + "now yesterday ago", "yesterdays", "3.day. 2.week.ago", + "day ago", "Gra Feb 21 15:35:00 2007 +0100", + "Sun Feb 21 15:35:00 2007 +0100", + "Wed Feb 21 15:35:00 Grand +0100" }; + } + + @Theory + public void badlyFormattedWithoutRef() { + assertThrows( + "The expected ParseException while parsing '" + dateStr + + "' did not occur.", + ParseException.class, () -> GitTimeParser.parse(dateStr)); + } +} diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserTest.java new file mode 100644 index 0000000000..0e5eb283a4 --- /dev/null +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserTest.java @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2024, Christian Halstrick and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +package org.eclipse.jgit.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.text.ParseException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Period; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoField; +import java.time.temporal.TemporalAccessor; + +import org.eclipse.jgit.junit.MockSystemReader; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class GitTimeParserTest { + MockSystemReader mockSystemReader; + + @Before + public void setUp() { + mockSystemReader = new MockSystemReader(); + SystemReader.setInstance(mockSystemReader); + } + + @After + public void tearDown() { + SystemReader.setInstance(null); + } + + @Test + public void yesterday() throws ParseException { + LocalDateTime parse = GitTimeParser.parse("yesterday"); + + LocalDateTime now = SystemReader.getInstance().civilNow(); + assertEquals(Period.between(parse.toLocalDate(), now.toLocalDate()), + Period.ofDays(1)); + } + + @Test + public void never() throws ParseException { + LocalDateTime parse = GitTimeParser.parse("never"); + assertEquals(LocalDateTime.MAX, parse); + } + + @Test + public void now_pointInTime() throws ParseException { + LocalDateTime aTime = asLocalDateTime("2007-02-21 15:35:00 +0100"); + + LocalDateTime parsedNow = GitTimeParser.parse("now", aTime); + + assertEquals(aTime, parsedNow); + } + + @Test + public void now_systemTime() throws ParseException { + LocalDateTime firstNow = GitTimeParser.parse("now"); + assertEquals(SystemReader.getInstance().civilNow(), firstNow); + mockSystemReader.tick(10); + LocalDateTime secondNow = GitTimeParser.parse("now"); + assertTrue(secondNow.isAfter(firstNow)); + } + + @Test + public void weeksAgo() throws ParseException { + LocalDateTime aTime = asLocalDateTime("2007-02-21 15:35:00 +0100"); + + LocalDateTime parse = GitTimeParser.parse("2 weeks ago", aTime); + assertEquals(asLocalDateTime("2007-02-07 15:35:00 +0100"), parse); + } + + @Test + public void daysAndWeeksAgo() throws ParseException { + LocalDateTime aTime = asLocalDateTime("2007-02-21 15:35:00 +0100"); + + LocalDateTime twoWeeksAgoActual = GitTimeParser.parse("2 weeks ago", + aTime); + + LocalDateTime twoWeeksAgoExpected = asLocalDateTime( + "2007-02-07 15:35:00 +0100"); + assertEquals(twoWeeksAgoExpected, twoWeeksAgoActual); + + LocalDateTime combinedWhitespace = GitTimeParser + .parse("3 days 2 weeks ago", aTime); + LocalDateTime combinedWhitespaceExpected = asLocalDateTime( + "2007-02-04 15:35:00 +0100"); + assertEquals(combinedWhitespaceExpected, combinedWhitespace); + + LocalDateTime combinedDots = GitTimeParser.parse("3.day.2.week.ago", + aTime); + LocalDateTime combinedDotsExpected = asLocalDateTime( + "2007-02-04 15:35:00 +0100"); + assertEquals(combinedDotsExpected, combinedDots); + } + + @Test + public void hoursAgo() throws ParseException { + LocalDateTime aTime = asLocalDateTime("2007-02-21 17:35:00 +0100"); + + LocalDateTime twoHoursAgoActual = GitTimeParser.parse("2 hours ago", + aTime); + + LocalDateTime twoHoursAgoExpected = asLocalDateTime( + "2007-02-21 15:35:00 +0100"); + assertEquals(twoHoursAgoExpected, twoHoursAgoActual); + } + + @Test + public void hoursAgo_acrossDay() throws ParseException { + LocalDateTime aTime = asLocalDateTime("2007-02-21 00:35:00 +0100"); + + LocalDateTime twoHoursAgoActual = GitTimeParser.parse("2 hours ago", + aTime); + + LocalDateTime twoHoursAgoExpected = asLocalDateTime( + "2007-02-20 22:35:00 +0100"); + assertEquals(twoHoursAgoExpected, twoHoursAgoActual); + } + + @Test + public void minutesHoursAgoCombined() throws ParseException { + LocalDateTime aTime = asLocalDateTime("2007-02-04 15:35:00 +0100"); + + LocalDateTime combinedWhitespace = GitTimeParser + .parse("3 hours 2 minutes ago", aTime); + LocalDateTime combinedWhitespaceExpected = asLocalDateTime( + "2007-02-04 12:33:00 +0100"); + assertEquals(combinedWhitespaceExpected, combinedWhitespace); + + LocalDateTime combinedDots = GitTimeParser + .parse("3.hours.2.minutes.ago", aTime); + LocalDateTime combinedDotsExpected = asLocalDateTime( + "2007-02-04 12:33:00 +0100"); + assertEquals(combinedDotsExpected, combinedDots); + } + + @Test + public void minutesAgo() throws ParseException { + LocalDateTime aTime = asLocalDateTime("2007-02-21 17:35:10 +0100"); + + LocalDateTime twoMinutesAgo = GitTimeParser.parse("2 minutes ago", + aTime); + + LocalDateTime twoMinutesAgoExpected = asLocalDateTime( + "2007-02-21 17:33:10 +0100"); + assertEquals(twoMinutesAgoExpected, twoMinutesAgo); + } + + @Test + public void minutesAgo_acrossDay() throws ParseException { + LocalDateTime aTime = asLocalDateTime("2007-02-21 00:35:10 +0100"); + + LocalDateTime minutesAgoActual = GitTimeParser.parse("40 minutes ago", + aTime); + + LocalDateTime minutesAgoExpected = asLocalDateTime( + "2007-02-20 23:55:10 +0100"); + assertEquals(minutesAgoExpected, minutesAgoActual); + } + + @Test + public void iso() throws ParseException { + String dateStr = "2007-02-21 15:35:00 +0100"; + + LocalDateTime actual = GitTimeParser.parse(dateStr); + + LocalDateTime expected = asLocalDateTime(dateStr); + assertEquals(expected, actual); + } + + @Test + public void rfc() throws ParseException { + String dateStr = "Wed, 21 Feb 2007 15:35:00 +0100"; + + LocalDateTime actual = GitTimeParser.parse(dateStr); + + LocalDateTime expected = asLocalDateTime(dateStr, + "EEE, dd MMM yyyy HH:mm:ss Z"); + assertEquals(expected, actual); + } + + @Test + public void shortFmt() throws ParseException { + assertParsing("2007-02-21", "yyyy-MM-dd"); + } + + @Test + public void shortWithDots() throws ParseException { + assertParsing("2007.02.21", "yyyy.MM.dd"); + } + + @Test + public void shortWithSlash() throws ParseException { + assertParsing("02/21/2007", "MM/dd/yyyy"); + } + + @Test + public void shortWithDotsReverse() throws ParseException { + assertParsing("21.02.2007", "dd.MM.yyyy"); + } + + @Test + public void defaultFmt() throws ParseException { + assertParsing("Wed Feb 21 15:35:00 2007 +0100", + "EEE MMM dd HH:mm:ss yyyy Z"); + } + + @Test + public void local() throws ParseException { + assertParsing("Wed Feb 21 15:35:00 2007", "EEE MMM dd HH:mm:ss yyyy"); + } + + private static void assertParsing(String dateStr, String format) + throws ParseException { + LocalDateTime actual = GitTimeParser.parse(dateStr); + + LocalDateTime expected = asLocalDateTime(dateStr, format); + assertEquals(expected, actual); + } + + private static LocalDateTime asLocalDateTime(String dateStr) { + return asLocalDateTime(dateStr, "yyyy-MM-dd HH:mm:ss Z"); + } + + private static LocalDateTime asLocalDateTime(String dateStr, + String pattern) { + DateTimeFormatter fmt = DateTimeFormatter.ofPattern(pattern); + TemporalAccessor ta = fmt + .withZone(SystemReader.getInstance().getTimeZoneId()) + .withLocale(SystemReader.getInstance().getLocale()) + .parse(dateStr); + return ta.isSupported(ChronoField.HOUR_OF_DAY) ? LocalDateTime.from(ta) + : LocalDate.from(ta).atStartOfDay(); + } +} diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/util/GitDateParser.java b/org.eclipse.jgit/src/org/eclipse/jgit/util/GitDateParser.java index 6a4b39652a..f080056546 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/util/GitDateParser.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/util/GitDateParser.java @@ -28,7 +28,10 @@ import org.eclipse.jgit.internal.JGitText; * used. One example is the parsing of the config parameter gc.pruneexpire. The * parser can handle only subset of what native gits approxidate parser * understands. + * + * @deprecated Use {@link GitTimeParser} instead. */ +@Deprecated(since = "7.1") public class GitDateParser { /** * The Date representing never. Though this is a concrete value, most diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/util/GitTimeParser.java b/org.eclipse.jgit/src/org/eclipse/jgit/util/GitTimeParser.java new file mode 100644 index 0000000000..e238e3e92e --- /dev/null +++ b/org.eclipse.jgit/src/org/eclipse/jgit/util/GitTimeParser.java @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2024 Christian Halstrick and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.util; + +import java.text.MessageFormat; +import java.text.ParseException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoField; +import java.time.temporal.TemporalAccessor; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jgit.internal.JGitText; + +/** + * Parses strings with time and date specifications into + * {@link java.time.Instant}. + * + * When git needs to parse strings specified by the user this parser can be + * used. One example is the parsing of the config parameter gc.pruneexpire. The + * parser can handle only subset of what native gits approxidate parser + * understands. + * + * @since 7.1 + */ +public class GitTimeParser { + + private static final Map formatCache = new HashMap<>(); + + // An enum of all those formats which this parser can parse with the help of + // a DateTimeFormatter. There are other formats (e.g. the relative formats + // like "yesterday" or "1 week ago") which this parser can parse but which + // are not listed here because they are parsed without the help of a + // DateTimeFormatter. + enum ParseableSimpleDateFormat { + ISO("yyyy-MM-dd HH:mm:ss Z"), // //$NON-NLS-1$ + RFC("EEE, dd MMM yyyy HH:mm:ss Z"), // //$NON-NLS-1$ + SHORT("yyyy-MM-dd"), // //$NON-NLS-1$ + SHORT_WITH_DOTS_REVERSE("dd.MM.yyyy"), // //$NON-NLS-1$ + SHORT_WITH_DOTS("yyyy.MM.dd"), // //$NON-NLS-1$ + SHORT_WITH_SLASH("MM/dd/yyyy"), // //$NON-NLS-1$ + DEFAULT("EEE MMM dd HH:mm:ss yyyy Z"), // //$NON-NLS-1$ + LOCAL("EEE MMM dd HH:mm:ss yyyy"); //$NON-NLS-1$ + + private final String formatStr; + + ParseableSimpleDateFormat(String formatStr) { + this.formatStr = formatStr; + } + } + + /** + * Parses a string into a {@link java.time.LocalDateTime} using the default + * locale. Since this parser also supports relative formats (e.g. + * "yesterday") the caller can specify the reference date. These types of + * strings can be parsed: + * + * + * @param dateStr + * the string to be parsed + * @return the parsed {@link java.time.LocalDateTime} + * @throws java.text.ParseException + * if the given dateStr was not recognized + */ + public static LocalDateTime parse(String dateStr) throws ParseException { + return parse(dateStr, SystemReader.getInstance().civilNow()); + } + + // Only tests seem to use this method + static LocalDateTime parse(String dateStr, LocalDateTime now) + throws ParseException { + dateStr = dateStr.trim(); + LocalDateTime ret; + + if ("never".equalsIgnoreCase(dateStr)) //$NON-NLS-1$ + return LocalDateTime.MAX; + ret = parse_relative(dateStr, now); + if (ret != null) + return ret; + for (ParseableSimpleDateFormat f : ParseableSimpleDateFormat.values()) { + try { + return parse_simple(dateStr, f); + } catch (DateTimeParseException e) { + // simply proceed with the next parser + } + } + ParseableSimpleDateFormat[] values = ParseableSimpleDateFormat.values(); + StringBuilder allFormats = new StringBuilder("\"") //$NON-NLS-1$ + .append(values[0].formatStr); + for (int i = 1; i < values.length; i++) + allFormats.append("\", \"").append(values[i].formatStr); //$NON-NLS-1$ + allFormats.append("\""); //$NON-NLS-1$ + throw new ParseException( + MessageFormat.format(JGitText.get().cannotParseDate, dateStr, + allFormats.toString()), + 0); + } + + // tries to parse a string with the formats supported by DateTimeFormatter + private static LocalDateTime parse_simple(String dateStr, + ParseableSimpleDateFormat f) throws DateTimeParseException { + DateTimeFormatter dateFormat = formatCache.computeIfAbsent(f, + format -> DateTimeFormatter.ofPattern(f.formatStr) + .withLocale(SystemReader.getInstance().getLocale())); + TemporalAccessor parsed = dateFormat.parse(dateStr); + return parsed.isSupported(ChronoField.HOUR_OF_DAY) + ? LocalDateTime.from(parsed) + : LocalDate.from(parsed).atStartOfDay(); + } + + // tries to parse a string with a relative time specification + @SuppressWarnings("nls") + private static LocalDateTime parse_relative(String dateStr, + LocalDateTime now) { + // check for the static words "yesterday" or "now" + if ("now".equals(dateStr)) { + return now; + } + + if ("yesterday".equals(dateStr)) { + return now.minusDays(1); + } + + // parse constructs like "3 days ago", "5.week.2.day.ago" + String[] parts = dateStr.split("\\.| "); + int partsLength = parts.length; + // check we have an odd number of parts (at least 3) and that the last + // part is "ago" + if (partsLength < 3 || (partsLength & 1) == 0 + || !"ago".equals(parts[parts.length - 1])) + return null; + int number; + for (int i = 0; i < parts.length - 2; i += 2) { + try { + number = Integer.parseInt(parts[i]); + } catch (NumberFormatException e) { + return null; + } + if (parts[i + 1] == null) { + return null; + } + switch (parts[i + 1]) { + case "year": + case "years": + now = now.minusYears(number); + break; + case "month": + case "months": + now = now.minusMonths(number); + break; + case "week": + case "weeks": + now = now.minusWeeks(number); + break; + case "day": + case "days": + now = now.minusDays(number); + break; + case "hour": + case "hours": + now = now.minusHours(number); + break; + case "minute": + case "minutes": + now = now.minusMinutes(number); + break; + case "second": + case "seconds": + now = now.minusSeconds(number); + break; + default: + return null; + } + } + return now; + } +} -- 2.39.5