001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      https://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.commons.lang3.time;
018
019import java.text.SimpleDateFormat;
020import java.util.ArrayList;
021import java.util.Calendar;
022import java.util.Date;
023import java.util.GregorianCalendar;
024import java.util.Objects;
025import java.util.TimeZone;
026import java.util.stream.Stream;
027
028import org.apache.commons.lang3.StringUtils;
029import org.apache.commons.lang3.Strings;
030import org.apache.commons.lang3.Validate;
031
032/**
033 * Duration formatting utilities and constants. The following table describes the tokens
034 * used in the pattern language for formatting.
035 * <table border="1">
036 *  <caption>Pattern Tokens</caption>
037 *  <tr><th>character</th><th>duration element</th></tr>
038 *  <tr><td>y</td><td>years</td></tr>
039 *  <tr><td>M</td><td>months</td></tr>
040 *  <tr><td>d</td><td>days</td></tr>
041 *  <tr><td>H</td><td>hours</td></tr>
042 *  <tr><td>m</td><td>minutes</td></tr>
043 *  <tr><td>s</td><td>seconds</td></tr>
044 *  <tr><td>S</td><td>milliseconds</td></tr>
045 *  <tr><td>'text'</td><td>arbitrary text content</td></tr>
046 * </table>
047 *
048 * <strong>Note: It's not currently possible to include a single-quote in a format.</strong>
049 * <br>
050 * Token values are printed using decimal digits.
051 * A token character can be repeated to ensure that the field occupies a certain minimum
052 * size. Values will be left-padded with 0 unless padding is disabled in the method invocation.
053 * <br>
054 * Tokens can be marked as optional by surrounding them with brackets [ ]. These tokens will
055 * only be printed if the token value is non-zero. Literals within optional blocks will only be
056 * printed if the preceding non-literal token is non-zero. Leading optional literals will only
057 * be printed if the following non-literal is non-zero.
058 * Multiple optional blocks can be used to group literals with the desired token.
059 * <p>
060 * Notes on Optional Tokens:<br>
061 * <strong>Multiple optional tokens without literals can result in impossible to understand output.</strong><br>
062 * <strong>Patterns where all tokens are optional can produce empty strings.</strong><br>
063 * (See examples below)
064 * </p>
065 * <br>
066 * <table border="1">
067 * <caption>Example Output</caption>
068 * <tr><th>pattern</th><th>Duration.ofDays(1)</th><th>Duration.ofHours(1)</th><th>Duration.ofMinutes(1)</th><th>Duration.ZERO</th></tr>
069 * <tr><td>d'd'H'h'm'm's's'</td><td>1d0h0m0s</td><td>0d1h0m0s</td><td>0d0h1m0s</td><td>0d0h0m0s</td></tr>
070 * <tr><td>d'd'[H'h'm'm']s's'</td><td>1d0s</td><td>0d1h0s</td><td>0d1m0s</td><td>0d0s</td></tr>
071 * <tr><td>[d'd'H'h'm'm']s's'</td><td>1d0s</td><td>1h0s</td><td>1m0s</td><td>0s</td></tr>
072 * <tr><td>[d'd'H'h'm'm's's']</td><td>1d</td><td>1h</td><td>1m</td><td></td></tr>
073 * <tr><td>['{'d'}']HH':'mm</td><td>{1}00:00</td><td>01:00</td><td>00:01</td><td>00:00</td></tr>
074 * <tr><td>['{'dd'}']['&lt;'HH'&gt;']['('mm')']</td><td>{01}</td><td>&lt;01&gt;</td><td>(00)</td><td></td></tr>
075 * <tr><td>[dHms]</td><td>1</td><td>1</td><td>1</td><td></td></tr>
076 * </table>
077 * <strong>Note: Optional blocks cannot be nested.</strong>
078 *
079 * @since 2.1
080 */
081public class DurationFormatUtils {
082
083    /**
084     * Element that is parsed from the format pattern.
085     */
086    static final class Token {
087
088        /** Empty array. */
089        private static final Token[] EMPTY_ARRAY = {};
090
091        /**
092         * Helper method to determine if a set of tokens contain a value
093         *
094         * @param tokens set to look in
095         * @param value to look for
096         * @return boolean {@code true} if contained
097         */
098        static boolean containsTokenWithValue(final Token[] tokens, final Object value) {
099            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}