1 /*
2 * Licensed to the Apache Software Foundation (ASF) under one or more
3 * contributor license agreements. See the NOTICE file distributed with
4 * this work for additional information regarding copyright ownership.
5 * The ASF licenses this file to You under the Apache License, Version 2.0
6 * (the "License"); you may not use this file except in compliance with
7 * the License. You may obtain a copy of the License at
8 *
9 * https://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17 package org.apache.commons.lang3.time;
18
19 import java.text.SimpleDateFormat;
20 import java.util.ArrayList;
21 import java.util.Calendar;
22 import java.util.Date;
23 import java.util.GregorianCalendar;
24 import java.util.Objects;
25 import java.util.TimeZone;
26 import java.util.stream.Stream;
27
28 import org.apache.commons.lang3.StringUtils;
29 import org.apache.commons.lang3.Strings;
30 import org.apache.commons.lang3.Validate;
31
32 /**
33 * Duration formatting utilities and constants. The following table describes the tokens
34 * used in the pattern language for formatting.
35 * <table border="1">
36 * <caption>Pattern Tokens</caption>
37 * <tr><th>character</th><th>duration element</th></tr>
38 * <tr><td>y</td><td>years</td></tr>
39 * <tr><td>M</td><td>months</td></tr>
40 * <tr><td>d</td><td>days</td></tr>
41 * <tr><td>H</td><td>hours</td></tr>
42 * <tr><td>m</td><td>minutes</td></tr>
43 * <tr><td>s</td><td>seconds</td></tr>
44 * <tr><td>S</td><td>milliseconds</td></tr>
45 * <tr><td>'text'</td><td>arbitrary text content</td></tr>
46 * </table>
47 *
48 * <strong>Note: It's not currently possible to include a single-quote in a format.</strong>
49 * <br>
50 * Token values are printed using decimal digits.
51 * A token character can be repeated to ensure that the field occupies a certain minimum
52 * size. Values will be left-padded with 0 unless padding is disabled in the method invocation.
53 * <br>
54 * Tokens can be marked as optional by surrounding them with brackets [ ]. These tokens will
55 * only be printed if the token value is non-zero. Literals within optional blocks will only be
56 * printed if the preceding non-literal token is non-zero. Leading optional literals will only
57 * be printed if the following non-literal is non-zero.
58 * Multiple optional blocks can be used to group literals with the desired token.
59 * <p>
60 * Notes on Optional Tokens:<br>
61 * <strong>Multiple optional tokens without literals can result in impossible to understand output.</strong><br>
62 * <strong>Patterns where all tokens are optional can produce empty strings.</strong><br>
63 * (See examples below)
64 * </p>
65 * <br>
66 * <table border="1">
67 * <caption>Example Output</caption>
68 * <tr><th>pattern</th><th>Duration.ofDays(1)</th><th>Duration.ofHours(1)</th><th>Duration.ofMinutes(1)</th><th>Duration.ZERO</th></tr>
69 * <tr><td>d'd'H'h'm'm's's'</td><td>1d0h0m0s</td><td>0d1h0m0s</td><td>0d0h1m0s</td><td>0d0h0m0s</td></tr>
70 * <tr><td>d'd'[H'h'm'm']s's'</td><td>1d0s</td><td>0d1h0s</td><td>0d1m0s</td><td>0d0s</td></tr>
71 * <tr><td>[d'd'H'h'm'm']s's'</td><td>1d0s</td><td>1h0s</td><td>1m0s</td><td>0s</td></tr>
72 * <tr><td>[d'd'H'h'm'm's's']</td><td>1d</td><td>1h</td><td>1m</td><td></td></tr>
73 * <tr><td>['{'d'}']HH':'mm</td><td>{1}00:00</td><td>01:00</td><td>00:01</td><td>00:00</td></tr>
74 * <tr><td>['{'dd'}']['<'HH'>']['('mm')']</td><td>{01}</td><td><01></td><td>(00)</td><td></td></tr>
75 * <tr><td>[dHms]</td><td>1</td><td>1</td><td>1</td><td></td></tr>
76 * </table>
77 * <strong>Note: Optional blocks cannot be nested.</strong>
78 *
79 * @since 2.1
80 */
81 public class DurationFormatUtils {
82
83 /**
84 * Element that is parsed from the format pattern.
85 */
86 static final class Token {
87
88 /** Empty array. */
89 private static final Token[] EMPTY_ARRAY = {};
90
91 /**
92 * Helper method to determine if a set of tokens contain a value
93 *
94 * @param tokens set to look in
95 * @param value to look for
96 * @return boolean {@code true} if contained
97 */
98 static boolean containsTokenWithValue(final Token[] tokens, final Object value) {
99 return Stream.of(tokens).anyMatch(token -> token.getValue() == value);
100 }
101
102 private final CharSequence value;
103 private int count;
104 private int optionalIndex = -1;
105
106 /**
107 * Wraps a token around a value. A value would be something like a 'Y'.
108 *
109 * @param value value to wrap, non-null.
110 * @param optional whether the token is optional
111 * @param optionalIndex the index of the optional token within the pattern
112 */
113 Token(final CharSequence value, final boolean optional, final int optionalIndex) {
114 this.value = Objects.requireNonNull(value, "value");
115 this.count = 1;
116 if (optional) {
117 this.optionalIndex = optionalIndex;
118 }
119 }
120
121 /**
122 * Supports equality of this Token to another Token.
123 *
124 * @param obj2 Object to consider equality of
125 * @return boolean {@code true} if equal
126 */
127 @Override
128 public boolean equals(final Object obj2) {
129 if (obj2 instanceof Token) {
130 final Token tok2 = (Token) obj2;
131 if (this.value.getClass() != tok2.value.getClass()) {
132 return false;
133 }
134 if (this.count != tok2.count) {
135 return false;
136 }
137 if (this.value instanceof StringBuilder) {
138 return this.value.toString().equals(tok2.value.toString());
139 }
140 if (this.value instanceof Number) {
141 return this.value.equals(tok2.value);
142 }
143 return this.value == tok2.value;
144 }
145 return false;
146 }
147
148 /**
149 * Gets the current number of values represented
150 *
151 * @return int number of values represented
152 */
153 int getCount() {
154 return count;
155 }
156
157 /**
158 * Gets the particular value this token represents.
159 *
160 * @return Object value, non-null.
161 */
162 Object getValue() {
163 return value;
164 }
165
166 /**
167 * Returns a hash code for the token equal to the
168 * hash code for the token's value. Thus 'TT' and 'TTTT'
169 * will have the same hash code.
170 *
171 * @return The hash code for the token
172 */
173 @Override
174 public int hashCode() {
175 return this.value.hashCode();
176 }
177
178 /**
179 * Adds another one of the value
180 */
181 void increment() {
182 count++;
183 }
184
185 /**
186 * Represents this token as a String.
187 *
188 * @return String representation of the token
189 */
190 @Override
191 public String toString() {
192 return StringUtils.repeat(this.value.toString(), this.count);
193 }
194 }
195
196 private static final int MINUTES_PER_HOUR = 60;
197
198 private static final int SECONDS_PER_MINUTES = 60;
199
200 private static final int HOURS_PER_DAY = 24;
201
202 /**
203 * Pattern used with {@link FastDateFormat} and {@link SimpleDateFormat}
204 * for the ISO 8601 period format used in durations.
205 *
206 * @see org.apache.commons.lang3.time.FastDateFormat
207 * @see java.text.SimpleDateFormat
208 */
209 public static final String ISO_EXTENDED_FORMAT_PATTERN = "'P'yyyy'Y'M'M'd'DT'H'H'm'M's.SSS'S'";
210
211 static final String y = "y";
212
213 static final String M = "M";
214
215 static final String d = "d";
216
217 static final String H = "H";
218
219 static final String m = "m";
220
221 static final String s = "s";
222
223 static final String S = "S";
224
225 /**
226 * The internal method to do the formatting.
227 *
228 * @param tokens the tokens
229 * @param years the number of years
230 * @param months the number of months
231 * @param days the number of days
232 * @param hours the number of hours
233 * @param minutes the number of minutes
234 * @param seconds the number of seconds
235 * @param milliseconds the number of millis
236 * @param padWithZeros whether to pad
237 * @return the formatted string
238 */
239 static String format(final Token[] tokens, final long years, final long months, final long days, final long hours, final long minutes,
240 final long seconds,
241 final long milliseconds, final boolean padWithZeros) {
242 final StringBuilder buffer = new StringBuilder();
243 boolean lastOutputSeconds = false;
244 boolean lastOutputZero = false;
245 int optionalStart = -1;
246 boolean firstOptionalNonLiteral = false;
247 int optionalIndex = -1;
248 boolean inOptional = false;
249 for (final Token token : tokens) {
250 final Object value = token.getValue();
251 final boolean isLiteral = value instanceof StringBuilder;
252 final int count = token.getCount();
253 if (optionalIndex != token.optionalIndex) {
254 optionalIndex = token.optionalIndex;
255 if (optionalIndex > -1) {
256 //entering new optional block
257 optionalStart = buffer.length();
258 lastOutputZero = false;
259 inOptional = true;
260 firstOptionalNonLiteral = false;
261 } else {
262 //leaving optional block
263 inOptional = false;
264 }
265 }
266 if (isLiteral) {
267 if (!inOptional || !lastOutputZero) {
268 buffer.append(value.toString());
269 }
270 } else if (value.equals(y)) {
271 lastOutputSeconds = false;
272 lastOutputZero = years == 0;
273 if (!inOptional || !lastOutputZero) {
274 buffer.append(paddedValue(years, padWithZeros, count));
275 }
276 } else if (value.equals(M)) {
277 lastOutputSeconds = false;
278 lastOutputZero = months == 0;
279 if (!inOptional || !lastOutputZero) {
280 buffer.append(paddedValue(months, padWithZeros, count));
281 }
282 } else if (value.equals(d)) {
283 lastOutputSeconds = false;
284 lastOutputZero = days == 0;
285 if (!inOptional || !lastOutputZero) {
286 buffer.append(paddedValue(days, padWithZeros, count));
287 }
288 } else if (value.equals(H)) {
289 lastOutputSeconds = false;
290 lastOutputZero = hours == 0;
291 if (!inOptional || !lastOutputZero) {
292 buffer.append(paddedValue(hours, padWithZeros, count));
293 }
294 } else if (value.equals(m)) {
295 lastOutputSeconds = false;
296 lastOutputZero = minutes == 0;
297 if (!inOptional || !lastOutputZero) {
298 buffer.append(paddedValue(minutes, padWithZeros, count));
299 }
300 } else if (value.equals(s)) {
301 lastOutputSeconds = true;
302 lastOutputZero = seconds == 0;
303 if (!inOptional || !lastOutputZero) {
304 buffer.append(paddedValue(seconds, padWithZeros, count));
305 }
306 } else if (value.equals(S)) {
307 lastOutputZero = milliseconds == 0;
308 if (!inOptional || !lastOutputZero) {
309 if (lastOutputSeconds) {
310 // ensure at least 3 digits are displayed even if padding is not selected
311 final int width = padWithZeros ? Math.max(3, count) : 3;
312 buffer.append(paddedValue(milliseconds, true, width));
313 } else {
314 buffer.append(paddedValue(milliseconds, padWithZeros, count));
315 }
316 }
317 lastOutputSeconds = false;
318 }
319 //as soon as we hit first nonliteral in optional, check for literal prefix
320 if (inOptional && !isLiteral && !firstOptionalNonLiteral) {
321 firstOptionalNonLiteral = true;
322 if (lastOutputZero) {
323 buffer.delete(optionalStart, buffer.length());
324 }
325 }
326 }
327 return buffer.toString();
328 }
329
330 /**
331 * Formats the time gap as a string, using the specified format, and padding with zeros.
332 *
333 * <p>This method formats durations using the days and lower fields of the
334 * format pattern. Months and larger are not used.</p>
335 *
336 * @param durationMillis the duration to format
337 * @param format the way in which to format the duration, not null
338 * @return the formatted duration, not null
339 * @throws IllegalArgumentException if durationMillis is negative
340 */
341 public static String formatDuration(final long durationMillis, final String format) {
342 return formatDuration(durationMillis, format, true);
343 }
344
345 /**
346 * Formats the time gap as a string, using the specified format.
347 * Padding the left-hand side side of numbers with zeroes is optional.
348 *
349 * <p>This method formats durations using the days and lower fields of the
350 * format pattern. Months and larger are not used.</p>
351 *
352 * @param durationMillis the duration to format
353 * @param format the way in which to format the duration, not null
354 * @param padWithZeros whether to pad the left-hand side side of numbers with 0's
355 * @return the formatted duration, not null
356 * @throws IllegalArgumentException if durationMillis is negative
357 */
358 public static String formatDuration(final long durationMillis, final String format, final boolean padWithZeros) {
359 Validate.inclusiveBetween(0, Long.MAX_VALUE, durationMillis, "durationMillis must not be negative");
360
361 final Token[] tokens = lexx(format);
362
363 long days = 0;
364 long hours = 0;
365 long minutes = 0;
366 long seconds = 0;
367 long milliseconds = durationMillis;
368
369 if (Token.containsTokenWithValue(tokens, d)) {
370 days = milliseconds / DateUtils.MILLIS_PER_DAY;
371 milliseconds -= days * DateUtils.MILLIS_PER_DAY;
372 }
373 if (Token.containsTokenWithValue(tokens, H)) {
374 hours = milliseconds / DateUtils.MILLIS_PER_HOUR;
375 milliseconds -= hours * DateUtils.MILLIS_PER_HOUR;
376 }
377 if (Token.containsTokenWithValue(tokens, m)) {
378 minutes = milliseconds / DateUtils.MILLIS_PER_MINUTE;
379 milliseconds -= minutes * DateUtils.MILLIS_PER_MINUTE;
380 }
381 if (Token.containsTokenWithValue(tokens, s)) {
382 seconds = milliseconds / DateUtils.MILLIS_PER_SECOND;
383 milliseconds -= seconds * DateUtils.MILLIS_PER_SECOND;
384 }
385
386 return format(tokens, 0, 0, days, hours, minutes, seconds, milliseconds, padWithZeros);
387 }
388
389 /**
390 * Formats the time gap as a string.
391 *
392 * <p>The format used is ISO 8601-like: {@code HH:mm:ss.SSS}.</p>
393 *
394 * @param durationMillis the duration to format
395 * @return the formatted duration, not null
396 * @throws IllegalArgumentException if durationMillis is negative
397 */
398 public static String formatDurationHMS(final long durationMillis) {
399 return formatDuration(durationMillis, "HH:mm:ss.SSS");
400 }
401
402 /**
403 * Formats the time gap as a string.
404 *
405 * <p>The format used is the ISO 8601 period format.</p>
406 *
407 * <p>This method formats durations using the days and lower fields of the
408 * ISO format pattern, such as P7D6TH5M4.321S.</p>
409 *
410 * @param durationMillis the duration to format
411 * @return the formatted duration, not null
412 * @throws IllegalArgumentException if durationMillis is negative
413 */
414 public static String formatDurationISO(final long durationMillis) {
415 return formatDuration(durationMillis, ISO_EXTENDED_FORMAT_PATTERN, false);
416 }
417
418 /**
419 * Formats an elapsed time into a pluralization correct string.
420 *
421 * <p>This method formats durations using the days and lower fields of the
422 * format pattern. Months and larger are not used.</p>
423 *
424 * @param durationMillis the elapsed time to report in milliseconds
425 * @param suppressLeadingZeroElements suppresses leading 0 elements
426 * @param suppressTrailingZeroElements suppresses trailing 0 elements
427 * @return the formatted text in days/hours/minutes/seconds, not null
428 * @throws IllegalArgumentException if durationMillis is negative
429 */
430 public static String formatDurationWords(
431 final long durationMillis,
432 final boolean suppressLeadingZeroElements,
433 final boolean suppressTrailingZeroElements) {
434
435 // This method is generally replaceable by the format method, but
436 // there are a series of tweaks and special cases that require
437 // trickery to replicate.
438 String duration = formatDuration(durationMillis, "d' days 'H' hours 'm' minutes 's' seconds'");
439 if (suppressLeadingZeroElements) {
440 // this is a temporary marker on the front. Like ^ in regexp.
441 duration = " " + duration;
442 final String text = duration;
443 String tmp = Strings.CS.replaceOnce(text, " 0 days", StringUtils.EMPTY);
444 if (tmp.length() != duration.length()) {
445 duration = tmp;
446 final String text1 = duration;
447 tmp = Strings.CS.replaceOnce(text1, " 0 hours", StringUtils.EMPTY);
448 if (tmp.length() != duration.length()) {
449 duration = tmp;
450 final String text2 = duration;
451 tmp = Strings.CS.replaceOnce(text2, " 0 minutes", StringUtils.EMPTY);
452 duration = tmp;
453 }
454 }
455 if (!duration.isEmpty()) {
456 // strip the space off again
457 duration = duration.substring(1);
458 }
459 }
460 if (suppressTrailingZeroElements) {
461 final String text = duration;
462 String tmp = Strings.CS.replaceOnce(text, " 0 seconds", StringUtils.EMPTY);
463 if (tmp.length() != duration.length()) {
464 duration = tmp;
465 final String text1 = duration;
466 tmp = Strings.CS.replaceOnce(text1, " 0 minutes", StringUtils.EMPTY);
467 if (tmp.length() != duration.length()) {
468 duration = tmp;
469 final String text2 = duration;
470 tmp = Strings.CS.replaceOnce(text2, " 0 hours", StringUtils.EMPTY);
471 if (tmp.length() != duration.length()) {
472 final String text3 = tmp;
473 duration = Strings.CS.replaceOnce(text3, " 0 days", StringUtils.EMPTY);
474 }
475 }
476 }
477 }
478 // handle plurals
479 duration = " " + duration;
480 final String text = duration;
481 duration = Strings.CS.replaceOnce(text, " 1 seconds", " 1 second");
482 final String text1 = duration;
483 duration = Strings.CS.replaceOnce(text1, " 1 minutes", " 1 minute");
484 final String text2 = duration;
485 duration = Strings.CS.replaceOnce(text2, " 1 hours", " 1 hour");
486 final String text3 = duration;
487 duration = Strings.CS.replaceOnce(text3, " 1 days", " 1 day");
488 return duration.trim();
489 }
490
491 /**
492 * Formats the time gap as a string, using the specified format.
493 * Padding the left-hand side side of numbers with zeroes is optional.
494 *
495 * @param startMillis the start of the duration
496 * @param endMillis the end of the duration
497 * @param format the way in which to format the duration, not null
498 * @return the formatted duration, not null
499 * @throws IllegalArgumentException if startMillis is greater than endMillis
500 */
501 public static String formatPeriod(final long startMillis, final long endMillis, final String format) {
502 return formatPeriod(startMillis, endMillis, format, true, TimeZone.getDefault());
503 }
504
505 /**
506 * <p>Formats the time gap as a string, using the specified format.
507 * Padding the left-hand side side of numbers with zeroes is optional and
508 * the time zone may be specified.
509 *
510 * <p>When calculating the difference between months/days, it chooses to
511 * calculate months first. So when working out the number of months and
512 * days between January 15th and March 10th, it choose 1 month and
513 * 23 days gained by choosing January->February = 1 month and then
514 * calculating days forwards, and not the 1 month and 26 days gained by
515 * choosing March -> February = 1 month and then calculating days
516 * backwards.</p>
517 *
518 * <p>For more control, the <a href="https://www.joda.org/joda-time/">Joda-Time</a>
519 * library is recommended.</p>
520 *
521 * @param startMillis the start of the duration
522 * @param endMillis the end of the duration
523 * @param format the way in which to format the duration, not null
524 * @param padWithZeros whether to pad the left-hand side side of numbers with 0's
525 * @param timezone the millis are defined in
526 * @return the formatted duration, not null
527 * @throws IllegalArgumentException if startMillis is greater than endMillis
528 */
529 public static String formatPeriod(final long startMillis, final long endMillis, final String format, final boolean padWithZeros,
530 final TimeZone timezone) {
531 Validate.isTrue(startMillis <= endMillis, "startMillis must not be greater than endMillis");
532
533 // Used to optimize for differences under 28 days and
534 // called formatDuration(millis, format); however this did not work
535 // over leap years.
536 // TODO: Compare performance to see if anything was lost by
537 // losing this optimization.
538
539 final Token[] tokens = lexx(format);
540
541 // time zones get funky around 0, so normalizing everything to GMT
542 // stops the hours being off
543 final Calendar start = Calendar.getInstance(timezone);
544 start.setTime(new Date(startMillis));
545 final Calendar end = Calendar.getInstance(timezone);
546 end.setTime(new Date(endMillis));
547
548 // initial estimates
549 long milliseconds = end.get(Calendar.MILLISECOND) - start.get(Calendar.MILLISECOND);
550 int seconds = end.get(Calendar.SECOND) - start.get(Calendar.SECOND);
551 int minutes = end.get(Calendar.MINUTE) - start.get(Calendar.MINUTE);
552 int hours = end.get(Calendar.HOUR_OF_DAY) - start.get(Calendar.HOUR_OF_DAY);
553 int days = end.get(Calendar.DAY_OF_MONTH) - start.get(Calendar.DAY_OF_MONTH);
554 int months = end.get(Calendar.MONTH) - start.get(Calendar.MONTH);
555 int years = end.get(Calendar.YEAR) - start.get(Calendar.YEAR);
556
557 // each initial estimate is adjusted in case it is under 0
558 while (milliseconds < 0) {
559 milliseconds += DateUtils.MILLIS_PER_SECOND;
560 seconds -= 1;
561 }
562 while (seconds < 0) {
563 seconds += SECONDS_PER_MINUTES;
564 minutes -= 1;
565 }
566 while (minutes < 0) {
567 minutes += MINUTES_PER_HOUR;
568 hours -= 1;
569 }
570 while (hours < 0) {
571 hours += HOURS_PER_DAY;
572 days -= 1;
573 }
574
575 if (Token.containsTokenWithValue(tokens, M)) {
576 while (days < 0) {
577 days += start.getActualMaximum(Calendar.DAY_OF_MONTH);
578 months -= 1;
579 start.add(Calendar.MONTH, 1);
580 }
581
582 while (months < 0) {
583 months += 12;
584 years -= 1;
585 }
586
587 if (!Token.containsTokenWithValue(tokens, y) && years != 0) {
588 while (years != 0) {
589 months += 12 * years;
590 years = 0;
591 }
592 }
593 } else {
594 // there are no M's in the format string
595
596 if (!Token.containsTokenWithValue(tokens, y)) {
597 int target = end.get(Calendar.YEAR);
598 if (months < 0) {
599 // target is end-year -1
600 target -= 1;
601 }
602
603 while (start.get(Calendar.YEAR) != target) {
604 days += start.getActualMaximum(Calendar.DAY_OF_YEAR) - start.get(Calendar.DAY_OF_YEAR);
605
606 // Not sure I grok why this is needed, but the brutal tests show it is
607 if (start instanceof GregorianCalendar &&
608 start.get(Calendar.MONTH) == Calendar.FEBRUARY &&
609 start.get(Calendar.DAY_OF_MONTH) == 29) {
610 days += 1;
611 }
612
613 start.add(Calendar.YEAR, 1);
614
615 days += start.get(Calendar.DAY_OF_YEAR);
616 }
617
618 years = 0;
619 }
620
621 while (start.get(Calendar.MONTH) != end.get(Calendar.MONTH)) {
622 days += start.getActualMaximum(Calendar.DAY_OF_MONTH);
623 start.add(Calendar.MONTH, 1);
624 }
625
626 months = 0;
627
628 while (days < 0) {
629 days += start.getActualMaximum(Calendar.DAY_OF_MONTH);
630 months -= 1;
631 start.add(Calendar.MONTH, 1);
632 }
633
634 }
635
636 // The rest of this code adds in values that
637 // aren't requested. This allows the user to ask for the
638 // number of months and get the real count and not just 0->11.
639
640 if (!Token.containsTokenWithValue(tokens, d)) {
641 hours += HOURS_PER_DAY * days;
642 days = 0;
643 }
644 if (!Token.containsTokenWithValue(tokens, H)) {
645 minutes += MINUTES_PER_HOUR * hours;
646 hours = 0;
647 }
648 if (!Token.containsTokenWithValue(tokens, m)) {
649 seconds += SECONDS_PER_MINUTES * minutes;
650 minutes = 0;
651 }
652 if (!Token.containsTokenWithValue(tokens, s)) {
653 milliseconds += DateUtils.MILLIS_PER_SECOND * seconds;
654 seconds = 0;
655 }
656
657 return format(tokens, years, months, days, hours, minutes, seconds, milliseconds, padWithZeros);
658 }
659
660 /**
661 * Formats the time gap as a string.
662 *
663 * <p>The format used is the ISO 8601 period format.</p>
664 *
665 * @param startMillis the start of the duration to format
666 * @param endMillis the end of the duration to format
667 * @return the formatted duration, not null
668 * @throws IllegalArgumentException if startMillis is greater than endMillis
669 */
670 public static String formatPeriodISO(final long startMillis, final long endMillis) {
671 return formatPeriod(startMillis, endMillis, ISO_EXTENDED_FORMAT_PATTERN, false, TimeZone.getDefault());
672 }
673
674 /**
675 * Parses a classic date format string into Tokens
676 *
677 * @param format the format to parse, not null
678 * @return array of Token[]
679 */
680 static Token[] lexx(final String format) {
681 final ArrayList<Token> list = new ArrayList<>(format.length());
682
683 boolean inLiteral = false;
684 // Although the buffer is stored in a Token, the Tokens are only
685 // used internally, so cannot be accessed by other threads
686 StringBuilder buffer = null;
687 Token previous = null;
688 boolean inOptional = false;
689 int optionalIndex = -1;
690 for (int i = 0; i < format.length(); i++) {
691 final char ch = format.charAt(i);
692 if (inLiteral && ch != '\'') {
693 buffer.append(ch); // buffer can't be null if inLiteral is true
694 continue;
695 }
696 String value = null;
697 switch (ch) {
698 // TODO: Need to handle escaping of '
699 case '[':
700 if (inOptional) {
701 throw new IllegalArgumentException("Nested optional block at index: " + i);
702 }
703 optionalIndex++;
704 inOptional = true;
705 break;
706 case ']':
707 if (!inOptional) {
708 throw new IllegalArgumentException("Attempting to close unopened optional block at index: " + i);
709 }
710 inOptional = false;
711 break;
712 case '\'':
713 if (inLiteral) {
714 buffer = null;
715 inLiteral = false;
716 } else {
717 buffer = new StringBuilder();
718 list.add(new Token(buffer, inOptional, optionalIndex));
719 inLiteral = true;
720 }
721 break;
722 case 'y':
723 value = y;
724 break;
725 case 'M':
726 value = M;
727 break;
728 case 'd':
729 value = d;
730 break;
731 case 'H':
732 value = H;
733 break;
734 case 'm':
735 value = m;
736 break;
737 case 's':
738 value = s;
739 break;
740 case 'S':
741 value = S;
742 break;
743 default:
744 if (buffer == null) {
745 buffer = new StringBuilder();
746 list.add(new Token(buffer, inOptional, optionalIndex));
747 }
748 buffer.append(ch);
749 }
750
751 if (value != null) {
752 if (previous != null && previous.getValue().equals(value)) {
753 previous.increment();
754 } else {
755 final Token token = new Token(value, inOptional, optionalIndex);
756 list.add(token);
757 previous = token;
758 }
759 buffer = null;
760 }
761 }
762 if (inLiteral) { // i.e. we have not found the end of the literal
763 throw new IllegalArgumentException("Unmatched quote in format: " + format);
764 }
765 if (inOptional) { // i.e. we have not found the end of the literal
766 throw new IllegalArgumentException("Unmatched optional in format: " + format);
767 }
768 return list.toArray(Token.EMPTY_ARRAY);
769 }
770
771 /**
772 * Converts a {@code long} to a {@link String} with optional
773 * zero padding.
774 *
775 * @param value the value to convert
776 * @param padWithZeros whether to pad with zeroes
777 * @param count the size to pad to (ignored if {@code padWithZeros} is false)
778 * @return the string result
779 */
780 private static String paddedValue(final long value, final boolean padWithZeros, final int count) {
781 final String longString = Long.toString(value);
782 return padWithZeros ? StringUtils.leftPad(longString, count, '0') : longString;
783 }
784
785 /**
786 * DurationFormatUtils instances should NOT be constructed in standard programming.
787 *
788 * <p>This constructor is public to permit tools that require a JavaBean instance
789 * to operate.</p>
790 *
791 * @deprecated TODO Make private in 4.0.
792 */
793 @Deprecated
794 public DurationFormatUtils() {
795 // empty
796 }
797
798 }