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 *
036 * <table>
037 *  <caption>Pattern Tokens</caption>
038 *  <tr><th>character</th><th>duration element</th></tr>
039 *  <tr><td>y</td><td>years</td></tr>
040 *  <tr><td>M</td><td>months</td></tr>
041 *  <tr><td>d</td><td>days</td></tr>
042 *  <tr><td>H</td><td>hours</td></tr>
043 *  <tr><td>m</td><td>minutes</td></tr>
044 *  <tr><td>s</td><td>seconds</td></tr>
045 *  <tr><td>S</td><td>milliseconds</td></tr>
046 *  <tr><td>'text'</td><td>arbitrary text content</td></tr>
047 * </table>
048 *
049 * <strong>Note: It's not currently possible to include a single-quote in a format.</strong>
050 * <p>
051 * Token values are printed using decimal digits.
052 * A token character can be repeated to ensure that the field occupies a certain minimum
053 * size. Values will be left-padded with 0 unless padding is disabled in the method invocation.
054 * </p>
055 * <p>
056 * Tokens can be marked as optional by surrounding them with brackets [ ]. These tokens will
057 * only be printed if the token value is non-zero. Literals within optional blocks will only be
058 * printed if the preceding non-literal token is non-zero. Leading optional literals will only
059 * be printed if the following non-literal is non-zero.
060 * Multiple optional blocks can be used to group literals with the desired token.
061 * </p>
062 * <p>
063 * Notes on Optional Tokens:
064 * </p>
065 * <p>
066 * <strong>Multiple optional tokens without literals can result in impossible to understand output.</strong>
067 * </p>
068 * <p>
069 * <strong>Patterns where all tokens are optional can produce empty strings.</strong>
070 * </p>
071 * <p>
072 * See the following examples.
073 * </p>
074 *
075 * <table>
076 * <caption>Example Output</caption>
077 * <tr><th>pattern</th><th>Duration.ofDays(1)</th><th>Duration.ofHours(1)</th><th>Duration.ofMinutes(1)</th><th>Duration.ZERO</th></tr>
078 * <tr><td>d'd'H'h'm'm's's'</td><td>1d0h0m0s</td><td>0d1h0m0s</td><td>0d0h1m0s</td><td>0d0h0m0s</td></tr>
079 * <tr><td>d'd'[H'h'm'm']s's'</td><td>1d0s</td><td>0d1h0s</td><td>0d1m0s</td><td>0d0s</td></tr>
080 * <tr><td>[d'd'H'h'm'm']s's'</td><td>1d0s</td><td>1h0s</td><td>1m0s</td><td>0s</td></tr>
081 * <tr><td>[d'd'H'h'm'm's's']</td><td>1d</td><td>1h</td><td>1m</td><td></td></tr>
082 * <tr><td>['{'d'}']HH':'mm</td><td>{1}00:00</td><td>01:00</td><td>00:01</td><td>00:00</td></tr>
083 * <tr><td>['{'dd'}']['&lt;'HH'&gt;']['('mm')']</td><td>{01}</td><td>&lt;01&gt;</td><td>(00)</td><td></td></tr>
084 * <tr><td>[dHms]</td><td>1</td><td>1</td><td>1</td><td></td></tr>
085 * </table>
086 * <p>
087 * <strong>Note: Optional blocks cannot be nested.</strong>
088 * </p>
089 *
090 * @since 2.1
091 */
092public class DurationFormatUtils {
093
094    /**
095     * Element that is parsed from the format pattern.
096     */
097    static final class Token {
098
099        /** 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}