View Javadoc
1   /*
2    * Copyright (C) 2012 Christian Halstrick and others
3    *
4    * This program and the accompanying materials are made available under the
5    * terms of the Eclipse Distribution License v. 1.0 which is available at
6    * https://www.eclipse.org/org/documents/edl-v10.php.
7    *
8    * SPDX-License-Identifier: BSD-3-Clause
9    */
10  package org.eclipse.jgit.util;
11  
12  import java.text.MessageFormat;
13  import java.text.ParseException;
14  import java.text.SimpleDateFormat;
15  import java.util.Calendar;
16  import java.util.Date;
17  import java.util.GregorianCalendar;
18  import java.util.HashMap;
19  import java.util.Locale;
20  import java.util.Map;
21  
22  import org.eclipse.jgit.internal.JGitText;
23  
24  /**
25   * Parses strings with time and date specifications into {@link java.util.Date}.
26   *
27   * When git needs to parse strings specified by the user this parser can be
28   * used. One example is the parsing of the config parameter gc.pruneexpire. The
29   * parser can handle only subset of what native gits approxidate parser
30   * understands.
31   */
32  public class GitDateParser {
33  	/**
34  	 * The Date representing never. Though this is a concrete value, most
35  	 * callers are adviced to avoid depending on the actual value.
36  	 */
37  	public static final Date NEVER = new Date(Long.MAX_VALUE);
38  
39  	// Since SimpleDateFormat instances are expensive to instantiate they should
40  	// be cached. Since they are also not threadsafe they are cached using
41  	// ThreadLocal.
42  	private static ThreadLocal<Map<Locale, Map<ParseableSimpleDateFormat, SimpleDateFormat>>> formatCache =
43  			new ThreadLocal<>() {
44  
45  		@Override
46  		protected Map<Locale, Map<ParseableSimpleDateFormat, SimpleDateFormat>> initialValue() {
47  			return new HashMap<>();
48  		}
49  	};
50  
51  	// Gets an instance of a SimpleDateFormat for the specified locale. If there
52  	// is not already an appropriate instance in the (ThreadLocal) cache then
53  	// create one and put it into the cache.
54  	private static SimpleDateFormat getDateFormat(ParseableSimpleDateFormat f,
55  			Locale locale) {
56  		Map<Locale, Map<ParseableSimpleDateFormat, SimpleDateFormat>> cache = formatCache
57  				.get();
58  		Map<ParseableSimpleDateFormat, SimpleDateFormat> map = cache
59  				.get(locale);
60  		if (map == null) {
61  			map = new HashMap<>();
62  			cache.put(locale, map);
63  			return getNewSimpleDateFormat(f, locale, map);
64  		}
65  		SimpleDateFormat dateFormat = map.get(f);
66  		if (dateFormat != null)
67  			return dateFormat;
68  		SimpleDateFormat df = getNewSimpleDateFormat(f, locale, map);
69  		return df;
70  	}
71  
72  	private static SimpleDateFormat getNewSimpleDateFormat(
73  			ParseableSimpleDateFormat f, Locale locale,
74  			Map<ParseableSimpleDateFormat, SimpleDateFormat> map) {
75  		SimpleDateFormat df = SystemReader.getInstance().getSimpleDateFormat(
76  				f.formatStr, locale);
77  		map.put(f, df);
78  		return df;
79  	}
80  
81  	// An enum of all those formats which this parser can parse with the help of
82  	// a SimpleDateFormat. There are other formats (e.g. the relative formats
83  	// like "yesterday" or "1 week ago") which this parser can parse but which
84  	// are not listed here because they are parsed without the help of a
85  	// SimpleDateFormat.
86  	enum ParseableSimpleDateFormat {
87  		ISO("yyyy-MM-dd HH:mm:ss Z"), // //$NON-NLS-1$
88  		RFC("EEE, dd MMM yyyy HH:mm:ss Z"), // //$NON-NLS-1$
89  		SHORT("yyyy-MM-dd"), // //$NON-NLS-1$
90  		SHORT_WITH_DOTS_REVERSE("dd.MM.yyyy"), // //$NON-NLS-1$
91  		SHORT_WITH_DOTS("yyyy.MM.dd"), // //$NON-NLS-1$
92  		SHORT_WITH_SLASH("MM/dd/yyyy"), // //$NON-NLS-1$
93  		DEFAULT("EEE MMM dd HH:mm:ss yyyy Z"), // //$NON-NLS-1$
94  		LOCAL("EEE MMM dd HH:mm:ss yyyy"); //$NON-NLS-1$
95  
96  		private final String formatStr;
97  
98  		private ParseableSimpleDateFormat(String formatStr) {
99  			this.formatStr = formatStr;
100 		}
101 	}
102 
103 	/**
104 	 * Parses a string into a {@link java.util.Date} using the default locale.
105 	 * Since this parser also supports relative formats (e.g. "yesterday") the
106 	 * caller can specify the reference date. These types of strings can be
107 	 * parsed:
108 	 * <ul>
109 	 * <li>"never"</li>
110 	 * <li>"now"</li>
111 	 * <li>"yesterday"</li>
112 	 * <li>"(x) years|months|weeks|days|hours|minutes|seconds ago"<br>
113 	 * Multiple specs can be combined like in "2 weeks 3 days ago". Instead of '
114 	 * ' one can use '.' to separate the words</li>
115 	 * <li>"yyyy-MM-dd HH:mm:ss Z" (ISO)</li>
116 	 * <li>"EEE, dd MMM yyyy HH:mm:ss Z" (RFC)</li>
117 	 * <li>"yyyy-MM-dd"</li>
118 	 * <li>"yyyy.MM.dd"</li>
119 	 * <li>"MM/dd/yyyy",</li>
120 	 * <li>"dd.MM.yyyy"</li>
121 	 * <li>"EEE MMM dd HH:mm:ss yyyy Z" (DEFAULT)</li>
122 	 * <li>"EEE MMM dd HH:mm:ss yyyy" (LOCAL)</li>
123 	 * </ul>
124 	 *
125 	 * @param dateStr
126 	 *            the string to be parsed
127 	 * @param now
128 	 *            the base date which is used for the calculation of relative
129 	 *            formats. E.g. if baseDate is "25.8.2012" then parsing of the
130 	 *            string "1 week ago" would result in a date corresponding to
131 	 *            "18.8.2012". This is used when a JGit command calls this
132 	 *            parser often but wants a consistent starting point for
133 	 *            calls.<br>
134 	 *            If set to <code>null</code> then the current time will be used
135 	 *            instead.
136 	 * @return the parsed {@link java.util.Date}
137 	 * @throws java.text.ParseException
138 	 *             if the given dateStr was not recognized
139 	 */
140 	public static Date parse(String dateStr, Calendar now)
141 			throws ParseException {
142 		return parse(dateStr, now, Locale.getDefault());
143 	}
144 
145 	/**
146 	 * Parses a string into a {@link java.util.Date} using the given locale.
147 	 * Since this parser also supports relative formats (e.g. "yesterday") the
148 	 * caller can specify the reference date. These types of strings can be
149 	 * parsed:
150 	 * <ul>
151 	 * <li>"never"</li>
152 	 * <li>"now"</li>
153 	 * <li>"yesterday"</li>
154 	 * <li>"(x) years|months|weeks|days|hours|minutes|seconds ago"<br>
155 	 * Multiple specs can be combined like in "2 weeks 3 days ago". Instead of '
156 	 * ' one can use '.' to separate the words</li>
157 	 * <li>"yyyy-MM-dd HH:mm:ss Z" (ISO)</li>
158 	 * <li>"EEE, dd MMM yyyy HH:mm:ss Z" (RFC)</li>
159 	 * <li>"yyyy-MM-dd"</li>
160 	 * <li>"yyyy.MM.dd"</li>
161 	 * <li>"MM/dd/yyyy",</li>
162 	 * <li>"dd.MM.yyyy"</li>
163 	 * <li>"EEE MMM dd HH:mm:ss yyyy Z" (DEFAULT)</li>
164 	 * <li>"EEE MMM dd HH:mm:ss yyyy" (LOCAL)</li>
165 	 * </ul>
166 	 *
167 	 * @param dateStr
168 	 *            the string to be parsed
169 	 * @param now
170 	 *            the base date which is used for the calculation of relative
171 	 *            formats. E.g. if baseDate is "25.8.2012" then parsing of the
172 	 *            string "1 week ago" would result in a date corresponding to
173 	 *            "18.8.2012". This is used when a JGit command calls this
174 	 *            parser often but wants a consistent starting point for
175 	 *            calls.<br>
176 	 *            If set to <code>null</code> then the current time will be used
177 	 *            instead.
178 	 * @param locale
179 	 *            locale to be used to parse the date string
180 	 * @return the parsed {@link java.util.Date}
181 	 * @throws java.text.ParseException
182 	 *             if the given dateStr was not recognized
183 	 * @since 3.2
184 	 */
185 	public static Date parse(String dateStr, Calendar now, Locale locale)
186 			throws ParseException {
187 		dateStr = dateStr.trim();
188 		Date ret;
189 
190 		if ("never".equalsIgnoreCase(dateStr)) //$NON-NLS-1$
191 			return NEVER;
192 		ret = parse_relative(dateStr, now);
193 		if (ret != null)
194 			return ret;
195 		for (ParseableSimpleDateFormat f : ParseableSimpleDateFormat.values()) {
196 			try {
197 				return parse_simple(dateStr, f, locale);
198 			} catch (ParseException e) {
199 				// simply proceed with the next parser
200 			}
201 		}
202 		ParseableSimpleDateFormat[] values = ParseableSimpleDateFormat.values();
203 		StringBuilder allFormats = new StringBuilder("\"") //$NON-NLS-1$
204 				.append(values[0].formatStr);
205 		for (int i = 1; i < values.length; i++)
206 			allFormats.append("\", \"").append(values[i].formatStr); //$NON-NLS-1$
207 		allFormats.append("\""); //$NON-NLS-1$
208 		throw new ParseException(MessageFormat.format(
209 				JGitText.get().cannotParseDate, dateStr, allFormats.toString()), 0);
210 	}
211 
212 	// tries to parse a string with the formats supported by SimpleDateFormat
213 	private static Date parse_simple(String dateStr,
214 			ParseableSimpleDateFormat f, Locale locale)
215 			throws ParseException {
216 		SimpleDateFormat dateFormat = getDateFormat(f, locale);
217 		dateFormat.setLenient(false);
218 		return dateFormat.parse(dateStr);
219 	}
220 
221 	// tries to parse a string with a relative time specification
222 	@SuppressWarnings("nls")
223 	private static Date parse_relative(String dateStr, Calendar now) {
224 		Calendar cal;
225 		SystemReader sysRead = SystemReader.getInstance();
226 
227 		// check for the static words "yesterday" or "now"
228 		if ("now".equals(dateStr)) {
229 			return ((now == null) ? new Date(sysRead.getCurrentTime()) : now
230 					.getTime());
231 		}
232 
233 		if (now == null) {
234 			cal = new GregorianCalendar(sysRead.getTimeZone(),
235 					sysRead.getLocale());
236 			cal.setTimeInMillis(sysRead.getCurrentTime());
237 		} else
238 			cal = (Calendar) now.clone();
239 
240 		if ("yesterday".equals(dateStr)) {
241 			cal.add(Calendar.DATE, -1);
242 			cal.set(Calendar.HOUR_OF_DAY, 0);
243 			cal.set(Calendar.MINUTE, 0);
244 			cal.set(Calendar.SECOND, 0);
245 			cal.set(Calendar.MILLISECOND, 0);
246 			cal.set(Calendar.MILLISECOND, 0);
247 			return cal.getTime();
248 		}
249 
250 		// parse constructs like "3 days ago", "5.week.2.day.ago"
251 		String[] parts = dateStr.split("\\.| ");
252 		int partsLength = parts.length;
253 		// check we have an odd number of parts (at least 3) and that the last
254 		// part is "ago"
255 		if (partsLength < 3 || (partsLength & 1) == 0
256 				|| !"ago".equals(parts[parts.length - 1]))
257 			return null;
258 		int number;
259 		for (int i = 0; i < parts.length - 2; i += 2) {
260 			try {
261 				number = Integer.parseInt(parts[i]);
262 			} catch (NumberFormatException e) {
263 				return null;
264 			}
265 			if (parts[i + 1] == null){
266 				return null;
267 			}
268 			switch (parts[i + 1]) {
269 			case "year":
270 			case "years":
271 				cal.add(Calendar.YEAR, -number);
272 				break;
273 			case "month":
274 			case "months":
275 				cal.add(Calendar.MONTH, -number);
276 				break;
277 			case "week":
278 			case "weeks":
279 				cal.add(Calendar.WEEK_OF_YEAR, -number);
280 				break;
281 			case "day":
282 			case "days":
283 				cal.add(Calendar.DATE, -number);
284 				break;
285 			case "hour":
286 			case "hours":
287 				cal.add(Calendar.HOUR_OF_DAY, -number);
288 				break;
289 			case "minute":
290 			case "minutes":
291 				cal.add(Calendar.MINUTE, -number);
292 				break;
293 			case "second":
294 			case "seconds":
295 				cal.add(Calendar.SECOND, -number);
296 				break;
297 			default:
298 				return null;
299 			}
300 		}
301 		return cal.getTime();
302 	}
303 }