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