View Javadoc
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'}']['&lt;'HH'&gt;']['('mm')']</td><td>{01}</td><td>&lt;01&gt;</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-&gt;February = 1 month and then
514      * calculating days forwards, and not the 1 month and 26 days gained by
515      * choosing March -&gt; 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 }