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