AmountFormats.java
/*
* Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos
*
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of JSR-310 nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.threeten.extra;
import java.time.Duration;
import java.time.Period;
import java.time.format.DateTimeParseException;
import java.time.temporal.TemporalAmount;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.function.Function;
import java.util.function.IntPredicate;
import java.util.regex.Pattern;
import java.util.stream.Stream;
/**
* Provides the ability to format a temporal amount.
* <p>
* This allows a {@link TemporalAmount}, such as {@link Duration} or {@link Period},
* to be formatted. Only selected formatting options are provided.
*
* <h3>Implementation Requirements:</h3>
* This class is immutable and thread-safe.
*/
public final class AmountFormats {
/**
* The number of days per week.
*/
private static final int DAYS_PER_WEEK = 7;
/**
* The number of hours per day.
*/
private static final int HOURS_PER_DAY = 24;
/**
* The number of minutes per hour.
*/
private static final int MINUTES_PER_HOUR = 60;
/**
* The number of seconds per minute.
*/
private static final int SECONDS_PER_MINUTE = 60;
/**
* The number of nanosecond per millisecond.
*/
private static final int NANOS_PER_MILLIS = 1000_000;
/**
* The resource bundle name.
*/
private static final String BUNDLE_NAME = "org.threeten.extra.wordbased";
/**
* The pattern to split lists with.
*/
private static final Pattern SPLITTER = Pattern.compile("[|][|][|]");
/**
* The property file key for the separator ", ".
*/
private static final String WORDBASED_COMMASPACE = "WordBased.commaspace";
/**
* The property file key for the separator " and ".
*/
private static final String WORDBASED_SPACEANDSPACE = "WordBased.spaceandspace";
/**
* The property file key for the word "year".
*/
private static final String WORDBASED_YEAR = "WordBased.year";
/**
* The property file key for the word "month".
*/
private static final String WORDBASED_MONTH = "WordBased.month";
/**
* The property file key for the word "week".
*/
private static final String WORDBASED_WEEK = "WordBased.week";
/**
* The property file key for the word "day".
*/
private static final String WORDBASED_DAY = "WordBased.day";
/**
* The property file key for the word "hour".
*/
private static final String WORDBASED_HOUR = "WordBased.hour";
/**
* The property file key for the word "minute".
*/
private static final String WORDBASED_MINUTE = "WordBased.minute";
/**
* The property file key for the word "second".
*/
private static final String WORDBASED_SECOND = "WordBased.second";
/**
* The property file key for the word "millisecond".
*/
private static final String WORDBASED_MILLISECOND = "WordBased.millisecond";
/**
* The predicate that matches 1 or -1.
*/
private static final IntPredicate PREDICATE_1 = value -> value == 1 || value == -1;
/**
* The predicate that matches numbers ending 1 but not ending 11.
*/
private static final IntPredicate PREDICATE_END1_NOT11 = value -> {
int abs = Math.abs(value);
int last = abs % 10;
int secondLast = (abs % 100) / 10;
return (last == 1 && secondLast != 1);
};
/**
* The predicate that matches numbers ending 2, 3 or 4, but not ending 12, 13 or 14.
*/
private static final IntPredicate PREDICATE_END234_NOTTEENS = value -> {
int abs = Math.abs(value);
int last = abs % 10;
int secondLast = (abs % 100) / 10;
return (last >= 2 && last <= 4 && secondLast != 1);
};
/**
* List of DurationUnit values ordered by longest suffix first.
*/
private static final List<DurationUnit> DURATION_UNITS =
Arrays.asList(new DurationUnit("ns", Duration.ofNanos(1)),
new DurationUnit("µs", Duration.ofNanos(1000)), // U+00B5 = micro symbol
new DurationUnit("μs", Duration.ofNanos(1000)), // U+03BC = Greek letter mu
new DurationUnit("us", Duration.ofNanos(1000)),
new DurationUnit("ms", Duration.ofMillis(1)),
new DurationUnit("s", Duration.ofSeconds(1)),
new DurationUnit("m", Duration.ofMinutes(1)),
new DurationUnit("h", Duration.ofHours(1)));
/**
* Zero value for an absent fractional component of a numeric duration string.
*/
private static final FractionScalarPart EMPTY_FRACTION = new FractionScalarPart(0, 0);
//-----------------------------------------------------------------------
/**
* Formats a period and duration to a string in ISO-8601 format.
* <p>
* To obtain the ISO-8601 format of a {@code Period} or {@code Duration}
* individually, simply call {@code toString()}.
* See also {@link PeriodDuration}.
*
* @param period the period to format
* @param duration the duration to format
* @return the ISO-8601 format for the period and duration
*/
public static String iso8601(Period period, Duration duration) {
Objects.requireNonNull(period, "period must not be null");
Objects.requireNonNull(duration, "duration must not be null");
if (period.isZero()) {
return duration.toString();
}
if (duration.isZero()) {
return period.toString();
}
return period.toString() + duration.toString().substring(1);
}
//-------------------------------------------------------------------------
/**
* Formats a period to a string in a localized word-based format.
* <p>
* This returns a word-based format for the period.
* The year and month are printed as supplied unless the signs differ, in which case they are normalized.
* The words are configured in a resource bundle text file -
* {@code org.threeten.extra.wordbased.properties} - with overrides per language.
*
* @param period the period to format
* @param locale the locale to use
* @return the localized word-based format for the period
*/
public static String wordBased(Period period, Locale locale) {
Objects.requireNonNull(period, "period must not be null");
Objects.requireNonNull(locale, "locale must not be null");
ResourceBundle bundle = ResourceBundle.getBundle(BUNDLE_NAME, locale);
UnitFormat[] formats = {
UnitFormat.of(bundle, WORDBASED_YEAR),
UnitFormat.of(bundle, WORDBASED_MONTH),
UnitFormat.of(bundle, WORDBASED_WEEK),
UnitFormat.of(bundle, WORDBASED_DAY)};
WordBased wb = new WordBased(formats, bundle.getString(WORDBASED_COMMASPACE), bundle.getString(WORDBASED_SPACEANDSPACE));
Period normPeriod = oppositeSigns(period.getMonths(), period.getYears()) ? period.normalized() : period;
int weeks = 0;
int days = 0;
if (normPeriod.getDays() % DAYS_PER_WEEK == 0) {
weeks = normPeriod.getDays() / DAYS_PER_WEEK;
} else {
days = normPeriod.getDays();
}
int[] values = {normPeriod.getYears(), normPeriod.getMonths(), weeks, days};
return wb.format(values);
}
/**
* Formats a duration to a string in a localized word-based format.
* <p>
* This returns a word-based format for the duration.
* The words are configured in a resource bundle text file -
* {@code org.threeten.extra.wordbased.properties} - with overrides per language.
*
* @param duration the duration to format
* @param locale the locale to use
* @return the localized word-based format for the duration
*/
public static String wordBased(Duration duration, Locale locale) {
Objects.requireNonNull(duration, "duration must not be null");
Objects.requireNonNull(locale, "locale must not be null");
ResourceBundle bundle = ResourceBundle.getBundle(BUNDLE_NAME, locale);
UnitFormat[] formats = {
UnitFormat.of(bundle, WORDBASED_HOUR),
UnitFormat.of(bundle, WORDBASED_MINUTE),
UnitFormat.of(bundle, WORDBASED_SECOND),
UnitFormat.of(bundle, WORDBASED_MILLISECOND)};
WordBased wb = new WordBased(formats, bundle.getString(WORDBASED_COMMASPACE), bundle.getString(WORDBASED_SPACEANDSPACE));
long hours = duration.toHours();
long mins = duration.toMinutes() % MINUTES_PER_HOUR;
long secs = duration.getSeconds() % SECONDS_PER_MINUTE;
int millis = duration.getNano() / NANOS_PER_MILLIS;
int[] values = {(int) hours, (int) mins, (int) secs, millis};
return wb.format(values);
}
/**
* Formats a period and duration to a string in a localized word-based format.
* <p>
* This returns a word-based format for the period.
* The year and month are printed as supplied unless the signs differ, in which case they are normalized.
* The words are configured in a resource bundle text file -
* {@code org.threeten.extra.wordbased.properties} - with overrides per language.
*
* @param period the period to format
* @param duration the duration to format
* @param locale the locale to use
* @return the localized word-based format for the period and duration
*/
public static String wordBased(Period period, Duration duration, Locale locale) {
Objects.requireNonNull(period, "period must not be null");
Objects.requireNonNull(duration, "duration must not be null");
Objects.requireNonNull(locale, "locale must not be null");
ResourceBundle bundle = ResourceBundle.getBundle(BUNDLE_NAME, locale);
UnitFormat[] formats = {
UnitFormat.of(bundle, WORDBASED_YEAR),
UnitFormat.of(bundle, WORDBASED_MONTH),
UnitFormat.of(bundle, WORDBASED_WEEK),
UnitFormat.of(bundle, WORDBASED_DAY),
UnitFormat.of(bundle, WORDBASED_HOUR),
UnitFormat.of(bundle, WORDBASED_MINUTE),
UnitFormat.of(bundle, WORDBASED_SECOND),
UnitFormat.of(bundle, WORDBASED_MILLISECOND)};
WordBased wb = new WordBased(formats, bundle.getString(WORDBASED_COMMASPACE), bundle.getString(WORDBASED_SPACEANDSPACE));
Period normPeriod = oppositeSigns(period.getMonths(), period.getYears()) ? period.normalized() : period;
int weeks = 0;
int days = 0;
if (normPeriod.getDays() % DAYS_PER_WEEK == 0) {
weeks = normPeriod.getDays() / DAYS_PER_WEEK;
} else {
days = normPeriod.getDays();
}
long totalHours = duration.toHours();
days += (int) (totalHours / HOURS_PER_DAY);
int hours = (int) (totalHours % HOURS_PER_DAY);
int mins = (int) (duration.toMinutes() % MINUTES_PER_HOUR);
int secs = (int) (duration.getSeconds() % SECONDS_PER_MINUTE);
int millis = duration.getNano() / NANOS_PER_MILLIS;
int[] values = {
normPeriod.getYears(), normPeriod.getMonths(), weeks, days,
(int) hours, mins, secs, millis};
return wb.format(values);
}
// are the signs opposite
private static boolean oppositeSigns(int a, int b) {
return a < 0 ? (b >= 0) : (b < 0);
}
// -------------------------------------------------------------------------
/**
* Parses formatted durations based on units.
* <p>
* The behaviour matches the <a href="https://golang.org/pkg/time/#ParseDuration">Golang</a>
* duration parser, however, infinite durations are not supported.
* <p>
* The duration format is a possibly signed sequence of decimal numbers, each with optional
* fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are
* "ns", "us" (or "µs"), "ms", "s", "m", "h".
* <p>
* Note, the value "0" is specially supported as {@code Duration.ZERO}.
*
* @param durationText the formatted unit-based duration string.
* @return the {@code Duration} value represented by the string, if possible.
*/
public static Duration parseUnitBasedDuration(CharSequence durationText) {
Objects.requireNonNull(durationText, "durationText must not be null");
// variables for tracking error positions during parsing.
int offset = 0;
CharSequence original = durationText;
// consume the leading sign - or + if one is present.
int sign = 1;
Optional<CharSequence> updatedText = consumePrefix(durationText, '-');
if (updatedText.isPresent()) {
sign = -1;
offset += 1;
durationText = updatedText.get();
} else {
updatedText = consumePrefix(durationText, '+');
if (updatedText.isPresent()) {
offset += 1;
}
durationText = updatedText.orElse(durationText);
}
// special case for a string of "0"
if (durationText.equals("0")) {
return Duration.ZERO;
}
// special case, empty string as an invalid duration.
if (durationText.length() == 0) {
throw new DateTimeParseException("Not a numeric value", original, 0);
}
Duration value = Duration.ZERO;
int durationTextLength = durationText.length();
while (durationTextLength > 0) {
ParsedUnitPart integerPart =
consumeDurationLeadingInt(durationText, original, offset);
offset += (durationText.length() - integerPart.remainingText().length());
durationText = integerPart.remainingText();
DurationScalar leadingInt = integerPart;
DurationScalar fraction = EMPTY_FRACTION;
Optional<CharSequence> dot = consumePrefix(durationText, '.');
if (dot.isPresent()) {
offset += 1;
durationText = dot.get();
ParsedUnitPart fractionPart =
consumeDurationFraction(durationText, original, offset);
// update the remaining string and fraction.
offset += (durationText.length() - fractionPart.remainingText().length());
durationText = fractionPart.remainingText();
fraction = fractionPart;
}
Optional<DurationUnit> optUnit = findUnit(durationText);
if (!optUnit.isPresent()) {
throw new DateTimeParseException(
"Invalid duration unit", original, offset);
}
DurationUnit unit = optUnit.get();
try {
Duration unitValue = leadingInt.applyTo(unit);
Duration fractionValue = fraction.applyTo(unit);
unitValue = unitValue.plus(fractionValue);
value = value.plus(unitValue);
} catch (ArithmeticException e) {
throw new DateTimeParseException(
"Duration string exceeds valid numeric range",
original, offset, e);
}
// update the remaining text and text length.
CharSequence remainingText = unit.consumeDurationUnit(durationText);
offset += (durationText.length() - remainingText.length());
durationText = remainingText;
durationTextLength = durationText.length();
}
return sign < 0 ? value.negated() : value;
}
// consume the fractional part of a unit-based duration, e.g.
// <int>.<fraction><unit>.
private static ParsedUnitPart consumeDurationLeadingInt(CharSequence text,
CharSequence original, int offset) {
long integerPart = 0;
int i = 0;
int valueLength = text.length();
for ( ; i < valueLength; i++) {
char c = text.charAt(i);
if (c < '0' || c > '9') {
break;
}
// overflow of a single numeric specifier for a duration.
if (integerPart > Long.MAX_VALUE / 10) {
throw new DateTimeParseException(
"Duration string exceeds valid numeric range",
original, i + offset);
}
integerPart *= 10;
integerPart += (long) (c - '0');
// overflow of a single numeric specifier for a duration.
if (integerPart < 0) {
throw new DateTimeParseException(
"Duration string exceeds valid numeric range",
original, i + offset);
}
}
// if no text was consumed, return empty.
if (i == 0) {
throw new DateTimeParseException("Missing leading integer", original, offset);
}
return new ParsedUnitPart(text.subSequence(i, text.length()),
new IntegerScalarPart(integerPart));
}
// consume the fractional part of a unit-based duration, e.g.
// <int>.<fraction><unit>.
private static ParsedUnitPart consumeDurationFraction(CharSequence text,
CharSequence original, int offset) {
int i = 0;
long fraction = 0;
long scale = 1;
boolean overflow = false;
for ( ; i < text.length(); i++) {
char c = text.charAt(i);
if (c < '0' || c > '9') {
break;
}
// for the fractional part, it's possible to overflow; however,
// this does not invalidate the duration, but rather it means that
// the precision of the fractional part is truncated to 999,999,999.
if (overflow || fraction > Long.MAX_VALUE / 10) {
continue;
}
long tmp = fraction * 10 + (long) (c - '0');
if (tmp < 0) {
overflow = true;
continue;
}
fraction = tmp;
scale *= 10;
}
if (i == 0) {
throw new DateTimeParseException(
"Missing numeric fraction after '.'", original, offset);
}
return new ParsedUnitPart(text.subSequence(i, text.length()),
new FractionScalarPart(fraction, scale));
}
// find the duration unit at the beginning of the input text, if present.
private static Optional<DurationUnit> findUnit(CharSequence text) {
return DURATION_UNITS.stream()
.sequential()
.filter(du -> du.prefixMatchesUnit(text))
.findFirst();
}
// consume the indicated {@code prefix} if it exists at the beginning of the
// text, returning the
// remaining string if the prefix was consumed.
private static Optional<CharSequence> consumePrefix(CharSequence text, char prefix) {
if (text.length() > 0 && text.charAt(0) == prefix) {
return Optional.of(text.subSequence(1, text.length()));
}
return Optional.empty();
}
private AmountFormats() {
}
//-------------------------------------------------------------------------
// data holder for word-based formats
static final class WordBased {
private final UnitFormat[] units;
private final String separator;
private final String lastSeparator;
public WordBased(UnitFormat[] units, String separator, String lastSeparator) {
this.units = units;
this.separator = separator;
this.lastSeparator = lastSeparator;
}
String format(int[] values) {
StringBuilder buf = new StringBuilder(32);
int nonZeroCount = 0;
for (int i = 0; i < values.length; i++) {
if (values[i] != 0) {
nonZeroCount++;
}
}
int count = 0;
for (int i = 0; i < values.length; i++) {
if (values[i] != 0 || (count == 0 && i == values.length - 1)) {
units[i].formatTo(values[i], buf);
if (count < nonZeroCount - 2) {
buf.append(separator);
} else if (count == nonZeroCount - 2) {
buf.append(lastSeparator);
}
count++;
}
}
return buf.toString();
}
}
// data holder for single/plural formats
static interface UnitFormat {
static UnitFormat of(ResourceBundle bundle, String keyStem) {
if (bundle.containsKey(keyStem + "s.predicates")) {
String predicateList = bundle.getString(keyStem + "s.predicates");
String textList = bundle.getString(keyStem + "s.list");
String[] regexes = SPLITTER.split(predicateList);
String[] text = SPLITTER.split(textList);
return new PredicateFormat(regexes, text);
} else {
String single = bundle.getString(keyStem);
String plural = bundle.getString(keyStem + "s");
return new SinglePluralFormat(single, plural);
}
}
void formatTo(int value, StringBuilder buf);
}
// data holder for single/plural formats
static final class SinglePluralFormat implements UnitFormat {
private final String single;
private final String plural;
SinglePluralFormat(String single, String plural) {
this.single = single;
this.plural = plural;
}
@Override
public void formatTo(int value, StringBuilder buf) {
buf.append(value).append(value == 1 || value == -1 ? single : plural);
}
}
// data holder for predicate formats
static final class PredicateFormat implements UnitFormat {
private final IntPredicate[] predicates;
private final String[] text;
PredicateFormat(String[] predicateStrs, String[] text) {
if (predicateStrs.length + 1 != text.length) {
throw new IllegalStateException("Invalid word-based resource");
}
this.predicates = Stream.of(predicateStrs)
.map(predicateStr -> findPredicate(predicateStr))
.toArray(IntPredicate[]::new);
this.text = text;
}
private IntPredicate findPredicate(String predicateStr) {
switch (predicateStr) {
case "One": return PREDICATE_1;
case "End234NotTeens": return PREDICATE_END234_NOTTEENS;
case "End1Not11": return PREDICATE_END1_NOT11;
default: throw new IllegalStateException("Invalid word-based resource");
}
}
@Override
public void formatTo(int value, StringBuilder buf) {
for (int i = 0; i < predicates.length; i++) {
if (predicates[i].test(value)) {
buf.append(value).append(text[i]);
return;
}
}
buf.append(value).append(text[predicates.length]);
}
}
// -------------------------------------------------------------------------
// data holder for a duration unit string and its associated Duration value.
static final class DurationUnit {
private final String abbrev;
private final Duration value;
private DurationUnit(String abbrev, Duration value) {
this.abbrev = abbrev;
this.value = value;
}
// whether the input text starts with the unit abbreviation.
boolean prefixMatchesUnit(CharSequence text) {
return text.length() >= abbrev.length()
&& abbrev.equals(text.subSequence(0, abbrev.length()));
}
// consume the duration unit and returning the remaining text.
CharSequence consumeDurationUnit(CharSequence text) {
return text.subSequence(abbrev.length(), text.length());
}
// scale the unit by the input scalingFunction, returning a value if
// one is produced, or an empty result when the operation results in an
// arithmetic overflow.
Duration scaleBy(Function<Duration, Duration> scaleFunc) {
return scaleFunc.apply(value);
}
}
// interface for computing a duration from a duration unit and a scalar.
static interface DurationScalar {
// returns a duration value on a successful computation, and an empty
// result otherwise.
Duration applyTo(DurationUnit unit);
}
// data holder for parsed fragments of a floating point duration scalar.
static final class ParsedUnitPart implements DurationScalar {
private final CharSequence remainingText;
private final DurationScalar scalar;
private ParsedUnitPart(CharSequence remainingText, DurationScalar scalar) {
this.remainingText = remainingText;
this.scalar = scalar;
}
@Override
public Duration applyTo(DurationUnit unit) {
return scalar.applyTo(unit);
}
CharSequence remainingText() {
return remainingText;
}
}
// data holder for the leading integer value of a duration scalar.
static final class IntegerScalarPart implements DurationScalar {
private final long value;
private IntegerScalarPart(long value) {
this.value = value;
}
@Override
public Duration applyTo(DurationUnit unit) {
return unit.scaleBy(d -> d.multipliedBy(value));
}
}
// data holder for the fractional floating point value of a duration
// scalar.
static final class FractionScalarPart implements DurationScalar {
private final long value;
private final long scale;
private FractionScalarPart(long value, long scale) {
this.value = value;
this.scale = scale;
}
@Override
public Duration applyTo(DurationUnit unit) {
if (value == 0) {
return Duration.ZERO;
}
return unit.scaleBy(d -> d.multipliedBy(value).dividedBy(scale));
}
}
}