This is an automated email from the ASF dual-hosted git repository. mattjuntunen pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/commons-text.git
The following commit(s) were added to refs/heads/master by this push: new ff372c2 TEXT-207: adding DoubleFormat utility ff372c2 is described below commit ff372c2dd5400c636433ac1d40c885692f7195ac Author: Matt Juntunen <mattjuntu...@apache.org> AuthorDate: Mon Jul 5 22:59:03 2021 -0400 TEXT-207: adding DoubleFormat utility --- pom.xml | 68 +- src/changes/changes.xml | 13 +- .../apache/commons/text/numbers/DoubleFormat.java | 730 ++++++++++++++++++++ .../apache/commons/text/numbers/ParsedDecimal.java | 723 ++++++++++++++++++++ .../apache/commons/text/numbers/package-info.java | 24 + .../commons/text/jmh/DoubleFormatPerformance.java | 273 ++++++++ .../commons/text/numbers/DoubleFormatTest.java | 595 +++++++++++++++++ .../commons/text/numbers/ParsedDecimalTest.java | 732 +++++++++++++++++++++ 8 files changed, 3149 insertions(+), 9 deletions(-) diff --git a/pom.xml b/pom.xml index e402e68..6f0f5a4 100644 --- a/pom.xml +++ b/pom.xml @@ -52,23 +52,26 @@ <spotbugs.plugin.version>4.3.0</spotbugs.plugin.version> <spotbugs.impl.version>4.3.0</spotbugs.impl.version> - + <commons.mockito.version>3.11.2</commons.mockito.version> <commons.jacoco.version>0.8.6</commons.jacoco.version> <commons.javadoc.version>3.2.0</commons.javadoc.version> <graalvm.version>21.1.0</graalvm.version> + <commons.rng.version>1.3</commons.rng.version> <commons.japicmp.version>0.15.3</commons.japicmp.version> <japicmp.skip>false</japicmp.skip> <clirr.skip>true</clirr.skip> + <jmh.version>1.32</jmh.version> + <!-- Commons Release Plugin --> <commons.bc.version>1.9</commons.bc.version> <commons.rc.version>RC1</commons.rc.version> <commons.release.isDistModule>true</commons.release.isDistModule> <commons.distSvnStagingUrl>scm:svn:https://dist.apache.org/repos/dist/dev/commons/${commons.componentid}</commons.distSvnStagingUrl> - <commons.releaseManagerName>Gary Gregory</commons.releaseManagerName> + <commons.releaseManagerName>Gary Gregory</commons.releaseManagerName> <commons.releaseManagerKey>86fdc7e2a11262cb</commons.releaseManagerKey> </properties> @@ -116,6 +119,24 @@ <version>${graalvm.version}</version> <scope>test</scope> </dependency> + <dependency> + <groupId>org.apache.commons</groupId> + <artifactId>commons-rng-simple</artifactId> + <version>${commons.rng.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.openjdk.jmh</groupId> + <artifactId>jmh-core</artifactId> + <version>${jmh.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.openjdk.jmh</groupId> + <artifactId>jmh-generator-annprocess</artifactId> + <version>${jmh.version}</version> + <scope>test</scope> + </dependency> </dependencies> <build> @@ -160,6 +181,7 @@ <suppressionsLocation>${basedir}/checkstyle-suppressions.xml</suppressionsLocation> <suppressionsFileExpression>${basedir}/checkstyle-suppressions.xml</suppressionsFileExpression> <includeTestSourceDirectory>true</includeTestSourceDirectory> + <excludes>**/generated/**.java,**/jmh_generated/**.java</excludes> </configuration> <dependencies> <dependency> @@ -243,6 +265,7 @@ <suppressionsLocation>${basedir}/checkstyle-suppressions.xml</suppressionsLocation> <suppressionsFileExpression>${basedir}/checkstyle-suppressions.xml</suppressionsFileExpression> <includeTestSourceDirectory>true</includeTestSourceDirectory> + <excludes>**/generated/**.java,**/jmh_generated/**.java</excludes> </configuration> <reportSets> <reportSet> @@ -355,7 +378,7 @@ <email>ggregory at apache.org</email> <url>https://www.garygregory.com</url> <organization>The Apache Software Foundation</organization> - <organizationUrl>https://www.apache.org/</organizationUrl> + <organizationUrl>https://www.apache.org/</organizationUrl> <roles> <role>PMC Member</role> </roles> @@ -513,5 +536,44 @@ </plugins> </build> </profile> + <profile> + <id>benchmark</id> + <properties> + <skipTests>true</skipTests> + <benchmark>org.apache</benchmark> + </properties> + <build> + <plugins> + <plugin> + <groupId>org.codehaus.mojo</groupId> + <artifactId>exec-maven-plugin</artifactId> + <version>3.0.0</version> + <executions> + <execution> + <id>benchmark</id> + <phase>test</phase> + <goals> + <goal>exec</goal> + </goals> + <configuration> + <classpathScope>test</classpathScope> + <executable>java</executable> + <arguments> + <argument>-classpath</argument> + <classpath/> + <argument>org.openjdk.jmh.Main</argument> + <argument>-rf</argument> + <argument>json</argument> + <argument>-rff</argument> + <argument>target/jmh-result.${benchmark}.json</argument> + <argument>${benchmark}</argument> + </arguments> + </configuration> + </execution> + </executions> + </plugin> + </plugins> + </build> + </profile> </profiles> </project> diff --git a/src/changes/changes.xml b/src/changes/changes.xml index a7328fa..034a7a6 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -52,6 +52,7 @@ The <action> type attribute can be add,update,fix,remove. <action issue="TEXT-186" type="fix" dev="ggregory" due-to="Gautam Korlam, Gary Gregory">StringSubstitutor map constructor throws NPE on 1.9 with null map.</action> <action issue="TEXT-191" type="fix" dev="kinow" due-to="Bradley David Rumball">JaroWinklerDistance returns the same values as JaroWinklerSimilarity.</action> <!-- ADD --> + <action issue="TEXT-207" type="add" dev="mattjuntunen">Add DoubleFormat utility.</action> <action issue="TEXT-190" type="add" dev="kinow" due-to="Benjamin Bing">Document negative limit for WordUtils abbreviate method</action> <action issue="TEXT-188" type="add" dev="kinow" due-to="Jakob Vesterstrøm">Speed up LevenshteinDistance with threshold by exiting early</action> <action issue="TEXT-185" type="add" dev="ggregory" due-to="Larry West, Gary Gregory">Release Notes page hasn't been updated for 1.9 release yet.</action> @@ -98,7 +99,7 @@ The <action> type attribute can be add,update,fix,remove. <action type="update" dev="ggregory" due-to="Johan Hammar">[javadoc] Fix compiler warnings in Java code example in Javadoc #124.</action> <action issue="TEXT-177" type="update" dev="ggregory" due-to="Gary Gregory">Update from Apache Commons Lang 3.9 to 3.11.</action> <action type="add" dev="ggregory" due-to="Gary Gregory">Add StringMatcher.size().</action> - <action type="add" dev="ggregory" due-to="Gary Gregory">Refactor TextStringBuilder.readFrom(Readable), extracting readFrom(CharBuffer) and readFrom(Reader).</action> + <action type="add" dev="ggregory" due-to="Gary Gregory">Refactor TextStringBuilder.readFrom(Readable), extracting readFrom(CharBuffer) and readFrom(Reader).</action> <action type="add" dev="ggregory" due-to="Gary Gregory">Add BiStringLookup and implementation BiFunctionStringLookup.</action> <action type="add" dev="ggregory" due-to="Gary Gregory">Add org.apache.commons.text.StringSubstitutor.StringSubstitutor(StringSubstitutor).</action> <action type="add" dev="ggregory" due-to="Gary Gregory">Add org.apache.commons.text.TextStringBuilder.TextStringBuilder(CharSequence).</action> @@ -128,9 +129,9 @@ The <action> type attribute can be add,update,fix,remove. <action type="update" dev="ggregory" due-to="Gary Gregory">[build] checkstyle.version 8.27 -> 8.33.</action> <action type="update" dev="ggregory" due-to="Gary Gregory">[build] org.apache.commons:commons-parent 48 -> 51.</action> <action type="update" dev="ggregory" due-to="Gary Gregory">[build] maven-pmd-plugin 3.12.0 -> 3.13.0.</action> - <action type="update" dev="ggregory" due-to="Gary Gregory">[build] org.mockito 3.3.3 -> 3.4.4.</action> + <action type="update" dev="ggregory" due-to="Gary Gregory">[build] org.mockito 3.3.3 -> 3.4.4.</action> </release> - + <release version="1.8" date="2019-08-30" description="Release 1.8. Requires Java 8."> <action issue="TEXT-167" type="fix" dev="ggregory" due-to="Larry West">commons-text web page missing "RELEASE-NOTES-1.7.txt"</action> <action issue="TEXT-168" type="fix" dev="ggregory" due-to="luksan47">(doc) Fixed wrong value for Jaro-Winkler example #117</action> @@ -140,7 +141,7 @@ The <action> type attribute can be add,update,fix,remove. <action type="update" dev="ggregory" due-to="Gary Gregory">Expand Javadoc for StringSubstitutor and friends.</action> <action type="update" dev="ggregory" due-to="Gary Gregory">[site] checkstyle.version 8.21 -> 8.23.</action> </release> - + <release version="1.7" date="2019-06-30" description="Release 1.7. Requires Java 8."> <action issue="TEXT-111" type="fix" dev="kinow" due-to="@CAPS50">WordUtils.wrap must calculate offset increment from wrapOn pattern length</action> <action issue="TEXT-104" type="update" dev="kinow" due-to="Sascha Szott">Jaro Winkler Distance refers to similarity</action> @@ -187,8 +188,8 @@ The <action> type attribute can be add,update,fix,remove. <action issue="TEXT-120" type="fix" dev="pschumacher">StringEscapeUtils#unescapeJson does not unescape double quotes and forward slash</action> <action issue="TEXT-119" type="fix" dev="pschumacher">Remove mention of SQL escaping from user guide</action> <action issue="TEXT-121" type="update" dev="ggregory" due-to="pschumacher">Update Java requirement from version 7 to 8.</action> - <action issue="TEXT-122" type="update" dev="ggregory">Allow full customization with new API org.apache.commons.text.lookup.StringLookupFactory.interpolatorStringLookup(Map<String, StringLookup>, StringLookup, boolean).</action> - <action issue="TEXT-123" type="fix" dev="ggregory" due-to="Takanobu Asanuma">WordUtils.wrap throws StringIndexOutOfBoundsException when wrapLength is Integer.MAX_VALUE.</action> + <action issue="TEXT-122" type="update" dev="ggregory">Allow full customization with new API org.apache.commons.text.lookup.StringLookupFactory.interpolatorStringLookup(Map<String, StringLookup>, StringLookup, boolean).</action> + <action issue="TEXT-123" type="fix" dev="ggregory" due-to="Takanobu Asanuma">WordUtils.wrap throws StringIndexOutOfBoundsException when wrapLength is Integer.MAX_VALUE.</action> </release> <release version="1.3" date="2018-03-16" description="Release 1.3. Requires Java 7."> diff --git a/src/main/java/org/apache/commons/text/numbers/DoubleFormat.java b/src/main/java/org/apache/commons/text/numbers/DoubleFormat.java new file mode 100644 index 0000000..3f3fc74 --- /dev/null +++ b/src/main/java/org/apache/commons/text/numbers/DoubleFormat.java @@ -0,0 +1,730 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.text.numbers; + +import java.text.DecimalFormatSymbols; +import java.util.Objects; +import java.util.function.DoubleFunction; +import java.util.function.Function; + +/** Enum containing standard double format types with methods to produce + * configured formatter instances. This type is intended to provide a + * quick and convenient way to create lightweight, thread-safe double format functions + * for common format types using a builder pattern. Output can be localized by + * passing a {@link DecimalFormatSymbols} instance to the + * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by + * directly calling the various other builder configuration methods, such as + * {@link Builder#digits(String) digits}. + * + * <p><strong>Comparison with DecimalFormat</strong> + * <p>This type provides some of the same functionality as Java's own + * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format + * functions produced by this type are lightweight and thread-safe, making them + * much easier to work with in multi-threaded environments. They also provide performance + * comparable to, and in many cases faster than, {@code DecimalFormat}. + * + * <p><strong>Examples</strong> + * <pre> + * // construct a formatter equivalent to Double.toString() + * DoubleFunction<String> fmt = DoubleFormat.MIXED.builder().build(); + * + * // construct a formatter equivalent to Double.toString() but using + * // format symbols for a specific locale + * DoubleFunction<String> fmt = DoubleFormat.MIXED.builder() + * .formatSymbols(DecimalFormatSymbols.getInstance(locale)) + * .build(); + * + * // construct a formatter equivalent to the DecimalFormat pattern "0.0##" + * DoubleFunction<String> fmt = DoubleFormat.PLAIN.builder() + * .minDecimalExponent(-3) + * .build(); + * + * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##", + * // where whole number groups of thousands are separated + * DoubleFunction<String> fmt = DoubleFormat.PLAIN.builder() + * .minDecimalExponent(-3) + * .groupThousands(true) + * .build(); + * + * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0" + * DoubleFunction<String> fmt = DoubleFormat.SCIENTIFIC.builder() + * .maxPrecision(4) + * .alwaysIncludeExponent(true) + * .build() + * + * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0", + * // i.e. "engineering format" + * DoubleFunction<String> fmt = DoubleFormat.ENGINEERING.builder() + * .maxPrecision(6) + * .alwaysIncludeExponent(true) + * .build() + * </pre> + * + * <p><strong>Implementation Notes</strong> + * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the + * decimal value must be rounded in order to meet the configuration requirements of the formatter + * instance. + */ +public enum DoubleFormat { + + /** Number format without exponents. + * Ex: + * <pre> + * 0.0 + * 12.401 + * 100000.0 + * 1450000000.0 + * 0.0000000000123 + * </pre> + */ + PLAIN(PlainDoubleFormat::new), + + /** Number format that uses exponents and contains a single digit + * to the left of the decimal point. + * Ex: + * <pre> + * 0.0 + * 1.2401E1 + * 1.0E5 + * 1.45E9 + * 1.23E-11 + * </pre> + */ + SCIENTIFIC(ScientificDoubleFormat::new), + + /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted + * so that the exponent value is always a multiple of 3, allowing easier alignment + * with SI prefixes. + * Ex: + * <pre> + * 0.0 + * 12.401 + * 100.0E3 + * 1.45E9 + * 12.3E-12 + * </pre> + */ + ENGINEERING(EngineeringDoubleFormat::new), + + /** Number format that uses {@link #PLAIN plain format} for small numbers and + * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds + * can be configured through the + * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent} + * and + * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent} + * properties. + * Ex: + * <pre> + * 0.0 + * 12.401 + * 100000.0 + * 1.45E9 + * 1.23E-11 + * </pre> + */ + MIXED(MixedDoubleFormat::new); + + /** Function used to construct instances for this format type. */ + private final Function<Builder, DoubleFunction<String>> factory; + + /** Construct a new instance. + * @param factory function used to construct format instances + */ + DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) { + this.factory = factory; + } + + /** Return a {@link Builder} for constructing formatter functions for this format type. + * @return builder instance + */ + public Builder builder() { + return new Builder(factory); + } + + /** Class for constructing configured format functions for standard double format types. + */ + public static final class Builder { + + /** Default value for the plain format max decimal exponent. */ + private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6; + + /** Default value for the plain format min decimal exponent. */ + private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3; + + /** Default decimal digit characters. */ + private static final String DEFAULT_DECIMAL_DIGITS = "0123456789"; + + /** Function used to construct format instances. */ + private final Function<Builder, DoubleFunction<String>> factory; + + /** Maximum number of significant decimal digits in formatted strings. */ + private int maxPrecision = 0; + + /** Minimum decimal exponent. */ + private int minDecimalExponent = Integer.MIN_VALUE; + + /** Max decimal exponent to use with plain formatting with the mixed format type. */ + private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT; + + /** Min decimal exponent to use with plain formatting with the mixed format type. */ + private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT; + + /** String representing infinity. */ + private String infinity = "Infinity"; + + /** String representing NaN. */ + private String nan = "NaN"; + + /** Flag determining if fraction placeholders should be used. */ + private boolean fractionPlaceholder = true; + + /** Flag determining if signed zero strings are allowed. */ + private boolean signedZero = true; + + /** String of digit characters 0-9. */ + private String digits = DEFAULT_DECIMAL_DIGITS; + + /** Decimal separator character. */ + private char decimalSeparator = '.'; + + /** Character used to separate groups of thousands. */ + private char groupingSeparator = ','; + + /** If {@code true}, thousands groups will be separated by the grouping separator. */ + private boolean groupThousands = false; + + /** Minus sign character. */ + private char minusSign = '-'; + + /** Exponent separator character. */ + private String exponentSeparator = "E"; + + /** Flag indicating if the exponent value should always be included, even if zero. */ + private boolean alwaysIncludeExponent = false; + + /** Construct a new instance that delegates double function construction + * to the given factory object. + * @param factory factory function + */ + private Builder(final Function<Builder, DoubleFunction<String>> factory) { + this.factory = factory; + } + + /** Set the maximum number of significant decimal digits used in format + * results. A value of {@code 0} indicates no limit. The default value is {@code 0}. + * @param maxPrecision maximum precision + * @return this instance + */ + public Builder maxPrecision(final int maxPrecision) { + this.maxPrecision = maxPrecision; + return this; + } + + /** Set the minimum decimal exponent for formatted strings. No digits with an + * absolute value of less than <code>10<sup>minDecimalExponent</sup></code> will + * be included in format results. If the number being formatted does not contain + * any such digits, then zero is returned. For example, if {@code minDecimalExponent} + * is set to {@code -2} and the number {@code 3.14159} is formatted, the plain + * format result will be {@code "3.14"}. If {@code 0.001} is formatted, then the + * result is the zero string. + * @param minDecimalExponent minimum decimal exponent + * @return this instance + */ + public Builder minDecimalExponent(final int minDecimalExponent) { + this.minDecimalExponent = minDecimalExponent; + return this; + } + + /** Set the maximum decimal exponent for numbers formatted as plain decimal strings when + * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted + * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and + * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any + * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type. + * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example, + * if this value is set to {@code 2}, the number {@code 999} will be formatted as {@code "999.0"} + * while {@code 1000} will be formatted as {@code "1.0E3"}. + * + * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT}. + * + * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}. + * @param plainFormatMaxDecimalExponent maximum decimal exponent for values formatted as plain + * strings when using the {@link DoubleFormat#MIXED MIXED} format type. + * @return this instance + * @see #plainFormatMinDecimalExponent(int) + */ + public Builder plainFormatMaxDecimalExponent(final int plainFormatMaxDecimalExponent) { + this.plainFormatMaxDecimalExponent = plainFormatMaxDecimalExponent; + return this; + } + + /** Set the minimum decimal exponent for numbers formatted as plain decimal strings when + * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted + * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and + * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any + * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type. + * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example, + * if this value is set to {@code -2}, the number {@code 0.01} will be formatted as {@code "0.01"} + * while {@code 0.0099} will be formatted as {@code "9.9E-3"}. + * + * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT}. + * + * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}. + * @param plainFormatMinDecimalExponent maximum decimal exponent for values formatted as plain + * strings when using the {@link DoubleFormat#MIXED MIXED} format type. + * @return this instance + * @see #plainFormatMinDecimalExponent(int) + */ + public Builder plainFormatMinDecimalExponent(final int plainFormatMinDecimalExponent) { + this.plainFormatMinDecimalExponent = plainFormatMinDecimalExponent; + return this; + } + + /** Set the flag determining whether or not the zero string may be returned with the minus + * sign or if it will always be returned in the positive form. For example, if set to {@code true}, + * the string {@code "-0.0"} may be returned for some input numbers. If {@code false}, only {@code "0.0"} + * will be returned, regardless of the sign of the input number. The default value is {@code true}. + * @param signedZero if {@code true}, the zero string may be returned with a preceding minus sign; + * if {@code false}, the zero string will only be returned in its positive form + * @return this instance + */ + public Builder allowSignedZero(final boolean signedZero) { + this.signedZero = signedZero; + return this; + } + + /** Set the string containing the digit characters 0-9, in that order. The + * default value is the string {@code "0123456789"}. + * @param digits string containing the digit characters 0-9 + * @return this instance + * @throws NullPointerException if the argument is {@code null} + * @throws IllegalArgumentException if the argument does not have a length of exactly 10 + */ + public Builder digits(final String digits) { + Objects.requireNonNull(digits, "Digits string cannot be null"); + if (digits.length() != DEFAULT_DECIMAL_DIGITS.length()) { + throw new IllegalArgumentException("Digits string must contain exactly " + + DEFAULT_DECIMAL_DIGITS.length() + " characters."); + } + + this.digits = digits; + return this; + } + + /** Set the flag determining whether or not a zero character is added in the fraction position + * when no fractional value is present. For example, if set to {@code true}, the number {@code 1} would + * be formatted as {@code "1.0"}. If {@code false}, it would be formatted as {@code "1"}. The default + * value is {@code true}. + * @param fractionPlaceholder if {@code true}, a zero character is placed in the fraction position when + * no fractional value is present; if {@code false}, fractional digits are only included when needed + * @return this instance + */ + public Builder includeFractionPlaceholder(final boolean fractionPlaceholder) { + this.fractionPlaceholder = fractionPlaceholder; + return this; + } + + /** Set the character used as the minus sign. + * @param minusSign character to use as the minus sign + * @return this instance + */ + public Builder minusSign(final char minusSign) { + this.minusSign = minusSign; + return this; + } + + /** Set the decimal separator character, i.e., the character placed between the + * whole number and fractional portions of the formatted strings. The default value + * is {@code '.'}. + * @param decimalSeparator decimal separator character + * @return this instance + */ + public Builder decimalSeparator(final char decimalSeparator) { + this.decimalSeparator = decimalSeparator; + return this; + } + + /** Set the character used to separate groups of thousands. Default value is {@code ','}. + * @param groupingSeparator character used to separate groups of thousands + * @return this instance + * @see #groupThousands(boolean) + */ + public Builder groupingSeparator(final char groupingSeparator) { + this.groupingSeparator = groupingSeparator; + return this; + } + + /** If set to {@code true}, thousands will be grouped with the + * {@link #groupingSeparator(char) grouping separator}. For example, if set to {@code true}, + * the number {@code 1000} could be formatted as {@code "1,000"}. This property only applies + * to the {@link DoubleFormat#PLAIN PLAIN} format. Default value is {@code false}. + * @param groupThousands if {@code true}, thousands will be grouped + * @return this instance + * @see #groupingSeparator(char) + */ + public Builder groupThousands(final boolean groupThousands) { + this.groupThousands = groupThousands; + return this; + } + + /** Set the exponent separator character, i.e., the string placed between + * the mantissa and the exponent. The default value is {@code "E"}, as in + * {@code "1.2E6"}. + * @param exponentSeparator exponent separator string + * @return this instance + * @throws NullPointerException if the argument is {@code null} + */ + public Builder exponentSeparator(final String exponentSeparator) { + this.exponentSeparator = Objects.requireNonNull(exponentSeparator, "Exponent separator cannot be null"); + return this; + } + + /** Set the flag indicating if an exponent value should always be included in the + * formatted value, even if the exponent value is zero. This property only applies + * to formats that use scientific notation, namely + * {@link DoubleFormat#SCIENTIFIC SCIENTIFIC}, + * {@link DoubleFormat#ENGINEERING ENGINEERING}, and + * {@link DoubleFormat#MIXED MIXED}. The default value is {@code false}. + * @param alwaysIncludeExponent if {@code true}, exponents will always be included in formatted + * output even if the exponent value is zero + * @return this instance + */ + public Builder alwaysIncludeExponent(final boolean alwaysIncludeExponent) { + this.alwaysIncludeExponent = alwaysIncludeExponent; + return this; + } + + /** Set the string used to represent infinity. For negative infinity, this string + * is prefixed with the {@link #minusSign(char) minus sign}. + * @param infinity string used to represent infinity + * @return this instance + * @throws NullPointerException if the argument is {@code null} + */ + public Builder infinity(final String infinity) { + this.infinity = Objects.requireNonNull(infinity, "Infinity string cannot be null"); + return this; + } + + /** Set the string used to represent {@link Double#NaN}. + * @param nan string used to represent {@link Double#NaN} + * @return this instance + * @throws NullPointerException if the argument is {@code null} + */ + public Builder nan(final String nan) { + this.nan = Objects.requireNonNull(nan, "NaN string cannot be null"); + return this; + } + + /** Configure this instance with the given format symbols. The following values + * are set: + * <ul> + * <li>{@link #digits(String) digit characters}</li> + * <li>{@link #decimalSeparator(char) decimal separator}</li> + * <li>{@link #groupingSeparator(char) thousands grouping separator}</li> + * <li>{@link #minusSign(char) minus sign}</li> + * <li>{@link #exponentSeparator(String) exponent separator}</li> + * <li>{@link #infinity(String) infinity}</li> + * <li>{@link #nan(String) NaN}</li> + * </ul> + * The digit character string is constructed by starting at the configured + * {@link DecimalFormatSymbols#getZeroDigit() zero digit} and adding the next + * 9 consecutive characters. + * @param symbols format symbols + * @return this instance + * @throws NullPointerException if the argument is {@code null} + */ + public Builder formatSymbols(final DecimalFormatSymbols symbols) { + Objects.requireNonNull(symbols, "Decimal format symbols cannot be null"); + + return digits(getDigitString(symbols)) + .decimalSeparator(symbols.getDecimalSeparator()) + .groupingSeparator(symbols.getGroupingSeparator()) + .minusSign(symbols.getMinusSign()) + .exponentSeparator(symbols.getExponentSeparator()) + .infinity(symbols.getInfinity()) + .nan(symbols.getNaN()); + } + + /** Get a string containing the localized digits 0-9 for the given symbols object. The + * string is constructed by starting at the {@link DecimalFormatSymbols#getZeroDigit() zero digit} + * and adding the next 9 consecutive characters. + * @param symbols symbols object + * @return string containing the localized digits 0-9 + */ + private String getDigitString(final DecimalFormatSymbols symbols) { + final int zeroDelta = symbols.getZeroDigit() - DEFAULT_DECIMAL_DIGITS.charAt(0); + + final char[] digitChars = new char[DEFAULT_DECIMAL_DIGITS.length()]; + for (int i = 0; i < DEFAULT_DECIMAL_DIGITS.length(); ++i) { + digitChars[i] = (char) (DEFAULT_DECIMAL_DIGITS.charAt(i) + zeroDelta); + } + + return String.valueOf(digitChars); + } + + /** Construct a new double format function. + * @return format function + */ + public DoubleFunction<String> build() { + return factory.apply(this); + } + } + + /** Base class for standard double formatting classes. + */ + private abstract static class AbstractDoubleFormat + implements DoubleFunction<String>, ParsedDecimal.FormatOptions { + + /** Maximum precision; 0 indicates no limit. */ + private final int maxPrecision; + + /** Minimum decimal exponent. */ + private final int minDecimalExponent; + + /** String representing positive infinity. */ + private final String positiveInfinity; + + /** String representing negative infinity. */ + private final String negativeInfinity; + + /** String representing NaN. */ + private final String nan; + + /** Flag determining if fraction placeholders should be used. */ + private final boolean fractionPlaceholder; + + /** Flag determining if signed zero strings are allowed. */ + private final boolean signedZero; + + /** String containing the digits 0-9. */ + private final char[] digits; + + /** Decimal separator character. */ + private final char decimalSeparator; + + /** Thousands grouping separator. */ + private final char groupingSeparator; + + /** Flag indicating if thousands should be grouped. */ + private final boolean groupThousands; + + /** Minus sign character. */ + private final char minusSign; + + /** Exponent separator character. */ + private final char[] exponentSeparatorChars; + + /** Flag indicating if exponent values should always be included, even if zero. */ + private final boolean alwaysIncludeExponent; + + /** Construct a new instance. + * @param builder builder instance containing configuration values + */ + AbstractDoubleFormat(final Builder builder) { + this.maxPrecision = builder.maxPrecision; + this.minDecimalExponent = builder.minDecimalExponent; + + this.positiveInfinity = builder.infinity; + this.negativeInfinity = builder.minusSign + builder.infinity; + this.nan = builder.nan; + + this.fractionPlaceholder = builder.fractionPlaceholder; + this.signedZero = builder.signedZero; + this.digits = builder.digits.toCharArray(); + this.decimalSeparator = builder.decimalSeparator; + this.groupingSeparator = builder.groupingSeparator; + this.groupThousands = builder.groupThousands; + this.minusSign = builder.minusSign; + this.exponentSeparatorChars = builder.exponentSeparator.toCharArray(); + this.alwaysIncludeExponent = builder.alwaysIncludeExponent; + } + + /** {@inheritDoc} */ + @Override + public boolean isIncludeFractionPlaceholder() { + return fractionPlaceholder; + } + + /** {@inheritDoc} */ + @Override + public boolean isSignedZero() { + return signedZero; + } + + /** {@inheritDoc} */ + @Override + public char[] getDigits() { + return digits; + } + + /** {@inheritDoc} */ + @Override + public char getDecimalSeparator() { + return decimalSeparator; + } + + /** {@inheritDoc} */ + @Override + public char getGroupingSeparator() { + return groupingSeparator; + } + + /** {@inheritDoc} */ + @Override + public boolean isGroupThousands() { + return groupThousands; + } + + /** {@inheritDoc} */ + @Override + public char getMinusSign() { + return minusSign; + } + + /** {@inheritDoc} */ + @Override + public char[] getExponentSeparatorChars() { + return exponentSeparatorChars; + } + + /** {@inheritDoc} */ + @Override + public boolean isAlwaysIncludeExponent() { + return alwaysIncludeExponent; + } + + /** {@inheritDoc} */ + @Override + public String apply(final double d) { + if (Double.isFinite(d)) { + return applyFinite(d); + } else if (Double.isInfinite(d)) { + return d > 0.0 + ? positiveInfinity + : negativeInfinity; + } + return nan; + } + + /** Return a formatted string representation of the given finite value. + * @param d double value + */ + private String applyFinite(final double d) { + final ParsedDecimal n = ParsedDecimal.from(d); + + int roundExponent = Math.max(n.getExponent(), minDecimalExponent); + if (maxPrecision > 0) { + roundExponent = Math.max(n.getScientificExponent() - maxPrecision + 1, roundExponent); + } + n.round(roundExponent); + + return applyFiniteInternal(n); + } + + /** Return a formatted representation of the given rounded decimal value to {@code dst}. + * @param val value to format + */ + protected abstract String applyFiniteInternal(ParsedDecimal val); + } + + /** Format class that produces plain decimal strings that do not use + * scientific notation. + */ + private static class PlainDoubleFormat extends AbstractDoubleFormat { + + /** Construct a new instance. + * @param builder builder instance containing configuration values + */ + PlainDoubleFormat(final Builder builder) { + super(builder); + } + + /** {@inheritDoc} */ + @Override + protected String applyFiniteInternal(final ParsedDecimal val) { + return val.toPlainString(this); + } + } + + /** Format class producing results similar to {@link Double#toString()}, with + * plain decimal notation for small numbers relatively close to zero and scientific + * notation otherwise. + */ + private static final class MixedDoubleFormat extends AbstractDoubleFormat { + + /** Max decimal exponent for plain format. */ + private final int plainMaxExponent; + + /** Min decimal exponent for plain format. */ + private final int plainMinExponent; + + /** Construct a new instance. + * @param builder builder instance containing configuration values + */ + MixedDoubleFormat(final Builder builder) { + super(builder); + + this.plainMaxExponent = builder.plainFormatMaxDecimalExponent; + this.plainMinExponent = builder.plainFormatMinDecimalExponent; + } + + /** {@inheritDoc} */ + @Override + protected String applyFiniteInternal(final ParsedDecimal val) { + final int sciExp = val.getScientificExponent(); + if (sciExp <= plainMaxExponent && sciExp >= plainMinExponent) { + return val.toPlainString(this); + } + return val.toScientificString(this); + } + } + + /** Format class that uses scientific notation for all values. + */ + private static class ScientificDoubleFormat extends AbstractDoubleFormat { + + /** Construct a new instance. + * @param builder builder instance containing configuration values + */ + ScientificDoubleFormat(final Builder builder) { + super(builder); + } + + /** {@inheritDoc} */ + @Override + public String applyFiniteInternal(final ParsedDecimal val) { + return val.toScientificString(this); + } + } + + /** Format class that uses engineering notation for all values. + */ + private static class EngineeringDoubleFormat extends AbstractDoubleFormat { + + /** Construct a new instance. + * @param builder builder instance containing configuration values + */ + EngineeringDoubleFormat(final Builder builder) { + super(builder); + } + + /** {@inheritDoc} */ + @Override + public String applyFiniteInternal(final ParsedDecimal val) { + return val.toEngineeringString(this); + } + } +} diff --git a/src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java b/src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java new file mode 100644 index 0000000..953eb76 --- /dev/null +++ b/src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java @@ -0,0 +1,723 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.text.numbers; + +/** Internal class representing a decimal value parsed into separate components. Each number + * is represented with + * <ul> + * <li>a boolean flag for the sign,</li> + * <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros + * removed, and</li> + * <li>an exponent value that when applied to the base 10 digits produces a floating point value with the + * correct magnitude.</li> + * </ul> + * <p><strong>Examples</strong></p> + * <table> + * <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr> + * <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr> + * <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr> + * <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr> + * <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr> + * </table> + */ +final class ParsedDecimal { + + /** Interface containing values used during string formatting. + */ + interface FormatOptions { + + /** Return {@code true} if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"}) + * should be included. + * @return {@code true} if fraction placeholders should be included + */ + boolean isIncludeFractionPlaceholder(); + + /** Return {@code true} if the string zero should be prefixed with the minus sign + * for negative zero values. + * @return {@code true} if the minus zero string should be allowed + */ + boolean isSignedZero(); + + /** Get an array containing the localized digit characters 0-9 in that order. + * This string <em>must</em> be non-null and have a length of 10. + * @return array containing the digit characters 0-9 + */ + char[] getDigits(); + + /** Get the decimal separator character. + * @return decimal separator character + */ + char getDecimalSeparator(); + + /** Get the character used to separate thousands groupings. + * @return character used to separate thousands groupings + */ + char getGroupingSeparator(); + + /** Return {@code true} if thousands should be grouped. + * @return {@code true} if thousand should be grouped + */ + boolean isGroupThousands(); + + /** Get the minus sign character. + * @return minus sign character + */ + char getMinusSign(); + + /** Get the exponent separator as an array of characters. + * @return exponent separator as an array of characters + */ + char[] getExponentSeparatorChars(); + + /** Return {@code true} if exponent values should always be included in + * formatted output, even if the value is zero. + * @return {@code true} if exponent values should always be included + */ + boolean isAlwaysIncludeExponent(); + } + + /** Minus sign character. */ + private static final char MINUS_CHAR = '-'; + + /** Decimal separator character. */ + private static final char DECIMAL_SEP_CHAR = '.'; + + /** Exponent character. */ + private static final char EXPONENT_CHAR = 'E'; + + /** Zero digit character. */ + private static final char ZERO_CHAR = '0'; + + /** Number of characters in thousands groupings. */ + private static final int THOUSANDS_GROUP_SIZE = 3; + + /** Radix for decimal numbers. */ + private static final int DECIMAL_RADIX = 10; + + /** Center value used when rounding. */ + private static final int ROUND_CENTER = DECIMAL_RADIX / 2; + + /** Number that exponents in engineering format must be a multiple of. */ + private static final int ENG_EXPONENT_MOD = 3; + + /** True if the value is negative. */ + final boolean negative; + + /** Array containing the significant decimal digits for the value. */ + final int[] digits; + + /** Number of digits used in the digits array; not necessarily equal to the length. */ + int digitCount; + + /** Exponent for the value. */ + int exponent; + + /** Output buffer for use in creating string representations. */ + private char[] outputChars; + + /** Output buffer index. */ + private int outputIdx; + + /** Construct a new instance from its parts. + * @param negative {@code true} if the value is negative + * @param digits array containing significant digits + * @param digitCount number of digits used from the {@code digits} array + * @param exponent exponent value + */ + private ParsedDecimal(final boolean negative, final int[] digits, final int digitCount, + final int exponent) { + this.negative = negative; + this.digits = digits; + this.digitCount = digitCount; + this.exponent = exponent; + } + + /** Get the exponent value. This exponent produces a floating point value with the + * correct magnitude when applied to the internal unsigned integer. + * @return exponent value + */ + public int getExponent() { + return exponent; + } + + /** Get the exponent that would be used when representing this number in scientific + * notation (i.e., with a single non-zero digit in front of the decimal point). + * @return the exponent that would be used when representing this number in scientific + * notation + */ + public int getScientificExponent() { + return digitCount + exponent - 1; + } + + /** Round the instance to the given decimal exponent position using + * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2} + * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01). + * @param roundExponent exponent defining the decimal place to round to + */ + public void round(final int roundExponent) { + if (roundExponent > exponent) { + final int max = digitCount + exponent; + + if (roundExponent < max) { + // rounding to a decimal place less than the max; set max precision + maxPrecision(max - roundExponent); + } else if (roundExponent == max && shouldRoundUp(0)) { + // rounding up directly on the max decimal place + setSingleDigitValue(1, roundExponent); + } else { + // change to zero + setSingleDigitValue(0, 0); + } + } + } + + /** Ensure that this instance has <em>at most</em> the given number of significant digits + * (i.e. precision). If this instance already has a precision less than or equal + * to the argument, nothing is done. If the given precision requires a reduction in the number + * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. + * @param precision maximum number of significant digits to include + */ + public void maxPrecision(final int precision) { + if (precision > 0 && precision < digitCount) { + if (shouldRoundUp(precision)) { + roundUp(precision); + } else { + truncate(precision); + } + } + } + + /** Return a string representation of this value with no exponent field. Ex: + * <pre> + * 10 = "10.0" + * 1e-6 = "0.000001" + * 1e11 = "100000000000.0" + * </pre> + * @param opts format options + * @return value in plain format + */ + public String toPlainString(final FormatOptions opts) { + final int decimalPos = digitCount + exponent; + final int fractionZeroCount = decimalPos < 1 + ? Math.abs(decimalPos) + : 0; + + prepareOutput(getPlainStringSize(decimalPos, opts)); + + final int fractionStartIdx = opts.isGroupThousands() + ? appendWholeGrouped(decimalPos, opts) + : appendWhole(decimalPos, opts); + + appendFraction(fractionZeroCount, fractionStartIdx, opts); + + return outputString(); + } + + /** Return a string representation of this value in scientific notation. Ex: + * <pre> + * 0 = "0.0" + * 10 = "1.0E1" + * 1e-6 = "1.0E-6" + * 1e11 = "1.0E11" + * </pre> + * @param opts format options + * @return value in scientific format + */ + public String toScientificString(final FormatOptions opts) { + return toScientificString(1, opts); + } + + /** Return a string representation of this value in engineering notation. This + * is similar to {@link #toScientificString(FormatOptions) scientific notation} + * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes. + * <pre> + * 0 = "0.0" + * 10 = "10.0" + * 1e-6 = "1.0E-6" + * 1e11 = "100.0E9" + * </pre> + * @param opts format options + * @return value in engineering format + */ + public String toEngineeringString(final FormatOptions opts) { + final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD); + return toScientificString(decimalPos, opts); + } + + /** Return a string representation of the value in scientific notation using the + * given decimal point position. + * @param decimalPos decimal position relative to the {@code digits} array; this value + * is expected to be greater than 0 + * @param opts format options + * @return value in scientific format + */ + private String toScientificString(final int decimalPos, final FormatOptions opts) { + final int targetExponent = digitCount + exponent - decimalPos; + final int absTargetExponent = Math.abs(targetExponent); + final boolean includeExponent = shouldIncludeExponent(targetExponent, opts); + final boolean negativeExponent = targetExponent < 0; + + // determine the size of the full formatted string, including the number of + // characters needed for the exponent digits + int size = getDigitStringSize(decimalPos, opts); + int exponentDigitCount = 0; + if (includeExponent) { + exponentDigitCount = absTargetExponent > 0 + ? (int) Math.floor(Math.log10(absTargetExponent)) + 1 + : 1; + + size += opts.getExponentSeparatorChars().length + exponentDigitCount; + if (negativeExponent) { + ++size; + } + } + + prepareOutput(size); + + // append the portion before the exponent field + final int fractionStartIdx = appendWhole(decimalPos, opts); + appendFraction(0, fractionStartIdx, opts); + + if (includeExponent) { + // append the exponent field + append(opts.getExponentSeparatorChars()); + + if (negativeExponent) { + append(opts.getMinusSign()); + } + + // append the exponent digits themselves; compute the + // string representation directly and add it to the output + // buffer to avoid the overhead of Integer.toString() + final char[] localizedDigits = opts.getDigits(); + int rem = absTargetExponent; + for (int i = size - 1; i >= outputIdx; --i) { + outputChars[i] = localizedDigits[rem % DECIMAL_RADIX]; + rem /= DECIMAL_RADIX; + } + outputIdx = size; + } + + return outputString(); + } + + /** Prepare the output buffer for a string of the given size. + * @param size buffer size + */ + private void prepareOutput(final int size) { + outputChars = new char[size]; + outputIdx = 0; + } + + /** Get the output buffer as a string. + * @return output buffer as a string + */ + private String outputString() { + final String str = String.valueOf(outputChars); + outputChars = null; + return str; + } + + /** Append the given character to the output buffer. + * @param ch character to append + */ + private void append(final char ch) { + outputChars[outputIdx++] = ch; + } + + /** Append the given character array directly to the output buffer. + * @param chars characters to append + */ + private void append(final char[] chars) { + for (final char c : chars) { + append(c); + } + } + + /** Append the localized representation of the digit {@code n} to the output buffer. + * @param n digit to append + * @param digitChars character array containing localized versions of the digits {@code 0-9} + * in that order + */ + private void appendLocalizedDigit(final int n, final char[] digitChars) { + append(digitChars[n]); + } + + /** Append the whole number portion of this value to the output buffer. No thousands + * separators are added. + * @param wholeCount total number of digits required to the left of the decimal point + * @param opts format options + * @return number of digits from {@code digits} appended to the output buffer + * @see #appendWholeGrouped(int, FormatOptions) + */ + private int appendWhole(final int wholeCount, final FormatOptions opts) { + if (shouldIncludeMinus(opts)) { + append(opts.getMinusSign()); + } + + final char[] localizedDigits = opts.getDigits(); + final char localizedZero = localizedDigits[0]; + + final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount)); + + if (significantDigitCount > 0) { + int i; + for (i = 0; i < significantDigitCount; ++i) { + appendLocalizedDigit(digits[i], localizedDigits); + } + + for (; i < wholeCount; ++i) { + append(localizedZero); + } + } else { + append(localizedZero); + } + + return significantDigitCount; + } + + /** Append the whole number portion of this value to the output buffer, adding thousands + * separators as needed. + * @param wholeCount total number of digits required to the right of the decimal point + * @param opts format options + * @return number of digits from {@code digits} appended to the output buffer + * @see #appendWhole(int, FormatOptions) + */ + private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) { + if (shouldIncludeMinus(opts)) { + append(opts.getMinusSign()); + } + + final char[] localizedDigits = opts.getDigits(); + final char localizedZero = localizedDigits[0]; + final char groupingChar = opts.getGroupingSeparator(); + + final int appendCount = Math.max(0, Math.min(wholeCount, digitCount)); + + if (appendCount > 0) { + int i; + int pos = wholeCount; + for (i = 0; i < appendCount; ++i, --pos) { + appendLocalizedDigit(digits[i], localizedDigits); + if (requiresGroupingSeparatorAfterPosition(pos)) { + append(groupingChar); + } + } + + for (; i < wholeCount; ++i, --pos) { + append(localizedZero); + if (requiresGroupingSeparatorAfterPosition(pos)) { + append(groupingChar); + } + } + } else { + append(localizedZero); + } + + return appendCount; + } + + /** Return {@code true} if a grouping separator should be added after the whole digit + * character at the given position. + * @param pos whole digit character position, with values starting at 1 and increasing + * from right to left. + * @return {@code true} if a grouping separator should be added + */ + private boolean requiresGroupingSeparatorAfterPosition(final int pos) { + return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1; + } + + /** Append the fractional component of the number to the current output buffer. + * @param zeroCount number of zeros to add after the decimal point and before the + * first significant digit + * @param startIdx significant digit start index + * @param opts format options + */ + private void appendFraction(final int zeroCount, final int startIdx, final FormatOptions opts) { + final char[] localizedDigits = opts.getDigits(); + final char localizedZero = localizedDigits[0]; + + if (startIdx < digitCount) { + append(opts.getDecimalSeparator()); + + // add the zero prefix + for (int i = 0; i < zeroCount; ++i) { + append(localizedZero); + } + + // add the fraction digits + for (int i = startIdx; i < digitCount; ++i) { + appendLocalizedDigit(digits[i], localizedDigits); + } + } else if (opts.isIncludeFractionPlaceholder()) { + append(opts.getDecimalSeparator()); + append(localizedZero); + } + } + + /** Get the number of characters required to create a plain format representation + * of this value. + * @param decimalPos decimal position relative to the {@code digits} array + * @param opts format options + * @return number of characters in the plain string representation of this value, + * created using the given parameters + */ + private int getPlainStringSize(final int decimalPos, final FormatOptions opts) { + int size = getDigitStringSize(decimalPos, opts); + + // adjust for groupings if needed + if (opts.isGroupThousands() && decimalPos > 0) { + size += (decimalPos - 1) / THOUSANDS_GROUP_SIZE; + } + + return size; + } + + /** Get the number of characters required for the digit portion of a string representation of + * this value. This excludes any exponent or thousands groupings characters. + * @param decimalPos decimal point position relative to the {@code digits} array + * @param opts format options + * @return number of characters required for the digit portion of a string representation of + * this value + */ + private int getDigitStringSize(final int decimalPos, final FormatOptions opts) { + int size = digitCount; + if (shouldIncludeMinus(opts)) { + ++size; + } + if (decimalPos < 1) { + // no whole component; + // add decimal point and leading zeros + size += 2 + Math.abs(decimalPos); + } else if (decimalPos >= digitCount) { + // no fraction component; + // add trailing zeros + size += decimalPos - digitCount; + if (opts.isIncludeFractionPlaceholder()) { + size += 2; + } + } else { + // whole and fraction components; + // add decimal point + size += 1; + } + + return size; + } + + /** Return {@code true} if formatted strings should include the minus sign, considering + * the value of this instance and the given format options. + * @param opts format options + * @return {@code true} if a minus sign should be included in the output + */ + private boolean shouldIncludeMinus(final FormatOptions opts) { + return negative && (opts.isSignedZero() || !isZero()); + } + + /** Return {@code true} if a formatted string with the given target exponent should include + * the exponent field. + * @param targetExponent exponent of the formatted result + * @param opts format options + * @return {@code true} if the formatted string should include the exponent field + */ + private boolean shouldIncludeExponent(final int targetExponent, final FormatOptions opts) { + return targetExponent != 0 || opts.isAlwaysIncludeExponent(); + } + + /** Return {@code true} if a rounding operation for the given number of digits should + * round up. + * @param count number of digits to round to; must be greater than zero and less + * than the current number of digits + * @return {@code true} if a rounding operation for the given number of digits should + * round up + */ + private boolean shouldRoundUp(final int count) { + // Round up in the following cases: + // 1. The digit after the last digit is greater than 5. + // 2. The digit after the last digit is 5 and there are additional (non-zero) + // digits after it. + // 3. The digit after the last digit is 5, there are no additional digits afterward, + // and the last digit is odd (half-even rounding). + final int digitAfterLast = digits[count]; + + return digitAfterLast > ROUND_CENTER || (digitAfterLast == ROUND_CENTER + && (count < digitCount - 1 || (digits[count - 1] % 2) != 0)); + } + + /** Round the value up to the given number of digits. + * @param count target number of digits; must be greater than zero and + * less than the current number of digits + */ + private void roundUp(final int count) { + int removedDigits = digitCount - count; + int i; + for (i = count - 1; i >= 0; --i) { + final int d = digits[i] + 1; + + if (d < DECIMAL_RADIX) { + // value did not carry over; done adding + digits[i] = d; + break; + } else { + // value carried over; the current position is 0 + // which we will ignore by shortening the digit count + ++removedDigits; + } + } + + if (i < 0) { + // all values carried over + setSingleDigitValue(1, exponent + removedDigits); + } else { + // values were updated in-place; just need to update the length + truncate(digitCount - removedDigits); + } + } + + /** Return {@code true} if this value is equal to zero. The sign field is ignored, + * meaning that this method will return {@code true} for both {@code +0} and {@code -0}. + * @return {@code true} if the value is equal to zero + */ + boolean isZero() { + return digits[0] == 0; + } + + /** Set the value of this instance to a single digit with the given exponent. + * The sign of the value is retained. + * @param digit digit value + * @param newExponent new exponent value + */ + private void setSingleDigitValue(final int digit, final int newExponent) { + digits[0] = digit; + digitCount = 1; + exponent = newExponent; + } + + /** Truncate the value to the given number of digits. + * @param count number of digits; must be greater than zero and less than + * the current number of digits + */ + private void truncate(final int count) { + // trim all trailing zero digits, making sure to leave + // at least one digit left + int nonZeroCount = count; + for (int i = count - 1; + i > 0 && digits[i] == 0; + --i) { + --nonZeroCount; + } + exponent += digitCount - nonZeroCount; + digitCount = nonZeroCount; + } + + /** Construct a new instance from the given double value. + * @param d double value + * @return a new instance containing the parsed components of the given double value + * @throws IllegalArgumentException if {@code d} is {@code NaN} or infinite + */ + public static ParsedDecimal from(final double d) { + if (!Double.isFinite(d)) { + throw new IllegalArgumentException("Double is not finite"); + } + + // Get the canonical string representation of the double value and parse + // it to extract the components of the decimal value. From the documentation + // of Double.toString() and the fact that d is finite, we are guaranteed the + // following: + // - the string will not be empty + // - it will contain exactly one decimal point character + // - all digit characters are in the ASCII range + final char[] strChars = Double.toString(d).toCharArray(); + + final boolean negative = strChars[0] == MINUS_CHAR; + final int digitStartIdx = negative ? 1 : 0; + + final int[] digits = new int[strChars.length - digitStartIdx - 1]; + + boolean foundDecimalPoint = false; + int digitCount = 0; + int significantDigitCount = 0; + int decimalPos = 0; + + int i; + for (i = digitStartIdx; i < strChars.length; ++i) { + final char ch = strChars[i]; + + if (ch == DECIMAL_SEP_CHAR) { + foundDecimalPoint = true; + decimalPos = digitCount; + } else if (ch == EXPONENT_CHAR) { + // no more mantissa digits + break; + } else if (ch != ZERO_CHAR || digitCount > 0) { + // this is either the first non-zero digit or one after it + final int val = digitValue(ch); + digits[digitCount++] = val; + + if (val > 0) { + significantDigitCount = digitCount; + } + } else if (foundDecimalPoint) { + // leading zero in a fraction; adjust the decimal position + --decimalPos; + } + } + + if (digitCount > 0) { + // determine the exponent + final int explicitExponent = i < strChars.length + ? parseExponent(strChars, i + 1) + : 0; + final int exponent = explicitExponent + decimalPos - significantDigitCount; + + return new ParsedDecimal(negative, digits, significantDigitCount, exponent); + } + + // no non-zero digits, so value is zero + return new ParsedDecimal(negative, new int[] {0}, 1, 0); + } + + /** Parse a double exponent value from {@code chars}, starting at the {@code start} + * index and continuing through the end of the array. + * @param chars character array to parse a double exponent value from + * @param start start index + * @return parsed exponent value + */ + private static int parseExponent(final char[] chars, final int start) { + int i = start; + boolean neg = chars[i] == MINUS_CHAR; + if (neg) { + ++i; + } + + int exp = 0; + for (; i < chars.length; ++i) { + exp = (exp * DECIMAL_RADIX) + digitValue(chars[i]); + } + + return neg ? -exp : exp; + } + + /** Get the numeric value of the given digit character. No validation of the + * character type is performed. + * @param ch digit character + * @return numeric value of the digit character, ex: '1' = 1 + */ + private static int digitValue(final char ch) { + return ch - ZERO_CHAR; + } +} diff --git a/src/main/java/org/apache/commons/text/numbers/package-info.java b/src/main/java/org/apache/commons/text/numbers/package-info.java new file mode 100644 index 0000000..0e9e671 --- /dev/null +++ b/src/main/java/org/apache/commons/text/numbers/package-info.java @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * <p> + * Provides algorithms for converting numbers to strings. + * </p> + * + * @since 1.10 + */ +package org.apache.commons.text.numbers; diff --git a/src/test/java/org/apache/commons/text/jmh/DoubleFormatPerformance.java b/src/test/java/org/apache/commons/text/jmh/DoubleFormatPerformance.java new file mode 100644 index 0000000..92c3c7c --- /dev/null +++ b/src/test/java/org/apache/commons/text/jmh/DoubleFormatPerformance.java @@ -0,0 +1,273 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.text.jmh; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.DecimalFormat; +import java.util.concurrent.TimeUnit; +import java.util.function.DoubleFunction; + +import org.apache.commons.rng.UniformRandomProvider; +import org.apache.commons.rng.simple.RandomSource; +import org.apache.commons.text.numbers.DoubleFormat; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** Benchmarks for the {@link DoubleFormat} class. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) +@Fork(value = 1, jvmArgs = {"-server", "-Xms512M", "-Xmx512M"}) +public class DoubleFormatPerformance { + + /** Decimal format pattern for plain output. */ + private static final String PLAIN_PATTERN = "0.0##"; + + /** Decimal format pattern for plain output with thousands grouping. */ + private static final String PLAIN_GROUPED_PATTERN = "#,##0.0##"; + + /** Decimal format pattern for scientific output. */ + private static final String SCI_PATTERN = "0.0##E0"; + + /** Decimal format pattern for engineering output. */ + private static final String ENG_PATTERN = "##0.0##E0"; + + /** Benchmark input providing a source of random double values. */ + @State(Scope.Thread) + public static class DoubleInput { + + /** The number of doubles in the input array. */ + @Param({"10000"}) + private int size; + + /** Minimum base 2 exponent for random input doubles. */ + @Param("-100") + private int minExp; + + /** Maximum base 2 exponent for random input doubles. */ + @Param("100") + private int maxExp; + + /** Double input array. */ + private double[] input; + + /** Get the input doubles. + * @return the input doubles + */ + public double[] getInput() { + return input; + } + + /** Set up the instance for the benchmark. */ + @Setup(Level.Iteration) + public void setup() { + input = randomDoubleArray(size, minExp, maxExp, + RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP)); + } + } + + /** Create a random double value with exponent in the range {@code [minExp, maxExp]}. + * @param minExp minimum exponent; must be less than {@code maxExp} + * @param maxExp maximum exponent; must be greater than {@code minExp} + * @param rng random number generator + * @return random double + */ + private static double randomDouble(final int minExp, final int maxExp, final UniformRandomProvider rng) { + // Create random doubles using random bits in the sign bit and the mantissa. + final long mask = ((1L << 52) - 1) | 1L << 63; + final long bits = rng.nextLong() & mask; + // The exponent must be unsigned so + 1023 to the signed exponent + final long exp = rng.nextInt(maxExp - minExp + 1) + minExp + 1023; + return Double.longBitsToDouble(bits | (exp << 52)); + } + + /** Create an array with the given length containing random doubles with exponents in the range + * {@code [minExp, maxExp]}. + * @param len array length + * @param minExp minimum exponent; must be less than {@code maxExp} + * @param maxExp maximum exponent; must be greater than {@code minExp} + * @param rng random number generator + * @return array of random doubles + */ + private static double[] randomDoubleArray(final int len, final int minExp, final int maxExp, + final UniformRandomProvider rng) { + final double[] arr = new double[len]; + for (int i = 0; i < arr.length; ++i) { + arr[i] = randomDouble(minExp, maxExp, rng); + } + return arr; + } + + /** Run a benchmark test on a function accepting a double argument. + * @param <T> function output type + * @param input double array + * @param bh jmh blackhole for consuming output + * @param fn function to call + */ + private static <T> void runDoubleFunction(final DoubleInput input, final Blackhole bh, + final DoubleFunction<T> fn) { + for (final double d : input.getInput()) { + bh.consume(fn.apply(d)); + } + } + + /** Benchmark testing just the overhead of the benchmark harness. + * @param input benchmark state input + * @param bh jmh blackhole for consuming output + */ + @Benchmark + public void baseline(final DoubleInput input, final Blackhole bh) { + runDoubleFunction(input, bh, d -> "0.0"); + } + + /** Benchmark testing the {@link Double#toString()} method. + * @param input benchmark state input + * @param bh jmh blackhole for consuming output + */ + @Benchmark + public void doubleToString(final DoubleInput input, final Blackhole bh) { + runDoubleFunction(input, bh, Double::toString); + } + + /** Benchmark testing the {@link String#format(String, Object...)} method. + * @param input benchmark state input + * @param bh jmh blackhole for consuming output + */ + @Benchmark + public void stringFormat(final DoubleInput input, final Blackhole bh) { + runDoubleFunction(input, bh, d -> String.format("%f", d)); + } + + /** Benchmark testing the BigDecimal formatting performance. + * @param input benchmark state input + * @param bh jmh blackhole for consuming output + */ + @Benchmark + public void bigDecimal(final DoubleInput input, final Blackhole bh) { + final DoubleFunction<String> fn = d -> BigDecimal.valueOf(d) + .setScale(3, RoundingMode.HALF_EVEN) + .stripTrailingZeros() + .toString(); + runDoubleFunction(input, bh, fn); + } + + /** Benchmark testing the {@link DecimalFormat} class. + * @param input benchmark state input + * @param bh jmh blackhole for consuming output + */ + @Benchmark + public void decimalFormatPlain(final DoubleInput input, final Blackhole bh) { + final DecimalFormat fmt = new DecimalFormat(PLAIN_PATTERN); + runDoubleFunction(input, bh, fmt::format); + } + + /** Benchmark testing the {@link DecimalFormat} class with thousands grouping. + * @param input benchmark state input + * @param bh jmh blackhole for consuming output + */ + @Benchmark + public void decimalFormatPlainGrouped(final DoubleInput input, final Blackhole bh) { + final DecimalFormat fmt = new DecimalFormat(PLAIN_GROUPED_PATTERN); + runDoubleFunction(input, bh, fmt::format); + } + + /** Benchmark testing the {@link DecimalFormat} class with scientific format. + * @param input benchmark state input + * @param bh jmh blackhole for consuming output + */ + @Benchmark + public void decimalFormatScientific(final DoubleInput input, final Blackhole bh) { + final DecimalFormat fmt = new DecimalFormat(SCI_PATTERN); + runDoubleFunction(input, bh, fmt::format); + } + + /** Benchmark testing the {@link DecimalFormat} class with engineering format. + * @param input benchmark state input + * @param bh jmh blackhole for consuming output + */ + @Benchmark + public void decimalFormatEngineering(final DoubleInput input, final Blackhole bh) { + final DecimalFormat fmt = new DecimalFormat(ENG_PATTERN); + runDoubleFunction(input, bh, fmt::format); + } + + /** Benchmark testing the {@link DoubleFormat#PLAIN} format. + * @param input benchmark state input + * @param bh jmh blackhole for consuming output + */ + @Benchmark + public void doubleFormatPlain(final DoubleInput input, final Blackhole bh) { + final DoubleFunction<String> fmt = DoubleFormat.PLAIN.builder() + .minDecimalExponent(-3) + .build(); + runDoubleFunction(input, bh, fmt); + } + + /** Benchmark testing the {@link DoubleFormat#PLAIN} format with + * thousands grouping. + * @param input benchmark state input + * @param bh jmh blackhole for consuming output + */ + @Benchmark + public void doubleFormatPlainGrouped(final DoubleInput input, final Blackhole bh) { + final DoubleFunction<String> fmt = DoubleFormat.PLAIN.builder() + .minDecimalExponent(-3) + .groupThousands(true) + .build(); + runDoubleFunction(input, bh, fmt); + } + + /** Benchmark testing the {@link DoubleFormat#SCIENTIFIC} format. + * @param input benchmark state input + * @param bh jmh blackhole for consuming output + */ + @Benchmark + public void doubleFormatScientific(final DoubleInput input, final Blackhole bh) { + final DoubleFunction<String> fmt = DoubleFormat.SCIENTIFIC.builder() + .maxPrecision(4) + .alwaysIncludeExponent(true) + .build(); + runDoubleFunction(input, bh, fmt); + } + + /** Benchmark testing the {@link DoubleFormat#ENGINEERING} format. + * @param input benchmark state input + * @param bh jmh blackhole for consuming output + */ + @Benchmark + public void doubleFormatEngineering(final DoubleInput input, final Blackhole bh) { + final DoubleFunction<String> fmt = DoubleFormat.ENGINEERING.builder() + .maxPrecision(6) + .alwaysIncludeExponent(true) + .build(); + runDoubleFunction(input, bh, fmt); + } +} diff --git a/src/test/java/org/apache/commons/text/numbers/DoubleFormatTest.java b/src/test/java/org/apache/commons/text/numbers/DoubleFormatTest.java new file mode 100644 index 0000000..f144725 --- /dev/null +++ b/src/test/java/org/apache/commons/text/numbers/DoubleFormatTest.java @@ -0,0 +1,595 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.text.numbers; + +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.Locale; +import java.util.Random; +import java.util.function.DoubleFunction; +import java.util.function.Function; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class DoubleFormatTest { + + @Test + void testBuilder_illegalArgs() { + // arrange + final DoubleFormat.Builder builder = DoubleFormat.PLAIN.builder(); + + // act/assert + Assertions.assertThrows(NullPointerException.class, () -> builder.digits(null)); + Assertions.assertThrows(IllegalArgumentException.class, () -> builder.digits("a")); + Assertions.assertThrows(IllegalArgumentException.class, () -> builder.digits("0123456789a")); + + Assertions.assertThrows(NullPointerException.class, () -> builder.exponentSeparator(null)); + Assertions.assertThrows(NullPointerException.class, () -> builder.infinity(null)); + Assertions.assertThrows(NullPointerException.class, () -> builder.nan(null)); + Assertions.assertThrows(NullPointerException.class, () -> builder.formatSymbols(null)); + } + + @Test + void testFormatAccuracy() { + // act/assert + checkFormatAccuracyWithDefaults(DoubleFormat.PLAIN); + checkFormatAccuracyWithDefaults(DoubleFormat.MIXED); + checkFormatAccuracyWithDefaults(DoubleFormat.SCIENTIFIC); + checkFormatAccuracyWithDefaults(DoubleFormat.ENGINEERING); + } + + @Test + void testPlain_defaults() { + // arrange + DoubleFunction<String> fmt = DoubleFormat.PLAIN.builder() + .build(); + + // act/assert + checkFormat(fmt, 0.00001, "0.00001"); + checkFormat(fmt, -0.0001, "-0.0001"); + checkFormat(fmt, 0.001, "0.001"); + checkFormat(fmt, -0.01, "-0.01"); + checkFormat(fmt, 0.1, "0.1"); + checkFormat(fmt, -0.0, "-0.0"); + checkFormat(fmt, 0.0, "0.0"); + checkFormat(fmt, -1.0, "-1.0"); + checkFormat(fmt, 10.0, "10.0"); + checkFormat(fmt, -100.0, "-100.0"); + checkFormat(fmt, 1000.0, "1000.0"); + checkFormat(fmt, -10000.0, "-10000.0"); + checkFormat(fmt, 100000.0, "100000.0"); + checkFormat(fmt, -1000000.0, "-1000000.0"); + checkFormat(fmt, 10000000.0, "10000000.0"); + checkFormat(fmt, -100000000.0, "-100000000.0"); + + checkFormat(fmt, 1.25e-3, "0.00125"); + checkFormat(fmt, -9.975e-4, "-0.0009975"); + checkFormat(fmt, 12345, "12345.0"); + checkFormat(fmt, -9_999_999, "-9999999.0"); + checkFormat(fmt, 1.00001e7, "10000100.0"); + + checkFormat(fmt, Float.MAX_VALUE, "340282346638528860000000000000000000000.0"); + checkFormat(fmt, -Float.MIN_VALUE, "-0.000000000000000000000000000000000000000000001401298464324817"); + checkFormat(fmt, Float.MIN_NORMAL, "0.000000000000000000000000000000000000011754943508222875"); + checkFormat(fmt, Math.PI, "3.141592653589793"); + checkFormat(fmt, Math.E, "2.718281828459045"); + } + + @Test + void testPlain_custom() { + // arrange + DoubleFunction<String> fmt = DoubleFormat.PLAIN.builder() + .maxPrecision(3) + .minDecimalExponent(-3) + .allowSignedZero(false) + .includeFractionPlaceholder(false) + .decimalSeparator(',') + .exponentSeparator("e") + .infinity("inf") + .nan("nan") + .minusSign('!') + .build(); + + // act/assert + checkFormat(fmt, Double.NaN, "nan"); + checkFormat(fmt, Double.POSITIVE_INFINITY, "inf"); + checkFormat(fmt, Double.NEGATIVE_INFINITY, "!inf"); + + checkFormat(fmt, 0.00001, "0"); + checkFormat(fmt, -0.0001, "0"); + checkFormat(fmt, 0.001, "0,001"); + checkFormat(fmt, -0.01, "!0,01"); + checkFormat(fmt, 0.1, "0,1"); + checkFormat(fmt, -0.0, "0"); + checkFormat(fmt, 0.0, "0"); + checkFormat(fmt, -1.0, "!1"); + checkFormat(fmt, 10.0, "10"); + checkFormat(fmt, -100.0, "!100"); + checkFormat(fmt, 1000.0, "1000"); + checkFormat(fmt, -10000.0, "!10000"); + checkFormat(fmt, 100000.0, "100000"); + checkFormat(fmt, -1000000.0, "!1000000"); + checkFormat(fmt, 10000000.0, "10000000"); + checkFormat(fmt, -100000000.0, "!100000000"); + + checkFormat(fmt, 1.25e-3, "0,001"); + checkFormat(fmt, -9.975e-4, "!0,001"); + checkFormat(fmt, 12345, "12300"); + checkFormat(fmt, -9_999_999, "!10000000"); + checkFormat(fmt, 1.00001e7, "10000000"); + + checkFormat(fmt, Float.MAX_VALUE, "340000000000000000000000000000000000000"); + checkFormat(fmt, -Float.MIN_VALUE, "0"); + checkFormat(fmt, Float.MIN_NORMAL, "0"); + checkFormat(fmt, Math.PI, "3,14"); + checkFormat(fmt, Math.E, "2,72"); + } + + @Test + void testPlain_localeFormatComparison() { + // act/assert + checkLocalizedFormats("0.0##", loc -> DoubleFormat.PLAIN.builder() + .minDecimalExponent(-3) + .formatSymbols(DecimalFormatSymbols.getInstance(loc)) + .build()); + checkLocalizedFormats("#,##0.0##", loc -> DoubleFormat.PLAIN.builder() + .minDecimalExponent(-3) + .groupThousands(true) + .formatSymbols(DecimalFormatSymbols.getInstance(loc)) + .build()); + } + + @Test + void testScientific_defaults() { + // arrange + final DoubleFunction<String> fmt = DoubleFormat.SCIENTIFIC.builder().build(); + + // act/assert + checkDefaultFormatSpecial(fmt); + + checkFormat(fmt, 0.00001, "1.0E-5"); + checkFormat(fmt, -0.0001, "-1.0E-4"); + checkFormat(fmt, 0.001, "1.0E-3"); + checkFormat(fmt, -0.01, "-1.0E-2"); + checkFormat(fmt, 0.1, "1.0E-1"); + checkFormat(fmt, -0.0, "-0.0"); + checkFormat(fmt, 0.0, "0.0"); + checkFormat(fmt, -1.0, "-1.0"); + checkFormat(fmt, 10.0, "1.0E1"); + checkFormat(fmt, -100.0, "-1.0E2"); + checkFormat(fmt, 1000.0, "1.0E3"); + checkFormat(fmt, -10000.0, "-1.0E4"); + checkFormat(fmt, 100000.0, "1.0E5"); + checkFormat(fmt, -1000000.0, "-1.0E6"); + checkFormat(fmt, 10000000.0, "1.0E7"); + checkFormat(fmt, -100000000.0, "-1.0E8"); + + checkFormat(fmt, 1.25e-3, "1.25E-3"); + checkFormat(fmt, -9.975e-4, "-9.975E-4"); + checkFormat(fmt, 12345, "1.2345E4"); + checkFormat(fmt, -9_999_999, "-9.999999E6"); + checkFormat(fmt, 1.00001e7, "1.00001E7"); + + checkFormat(fmt, Double.MAX_VALUE, "1.7976931348623157E308"); + checkFormat(fmt, Double.MIN_VALUE, "4.9E-324"); + checkFormat(fmt, Double.MIN_NORMAL, "2.2250738585072014E-308"); + checkFormat(fmt, Math.PI, "3.141592653589793"); + checkFormat(fmt, Math.E, "2.718281828459045"); + } + + @Test + void testScientific_custom() { + // arrange + final DoubleFunction<String> fmt = DoubleFormat.SCIENTIFIC.builder() + .maxPrecision(3) + .minDecimalExponent(-3) + .allowSignedZero(false) + .includeFractionPlaceholder(false) + .decimalSeparator(',') + .exponentSeparator("e") + .infinity("inf") + .nan("nan") + .minusSign('!') + .build(); + + // act/assert + checkFormat(fmt, Double.NaN, "nan"); + checkFormat(fmt, Double.POSITIVE_INFINITY, "inf"); + checkFormat(fmt, Double.NEGATIVE_INFINITY, "!inf"); + + checkFormat(fmt, 0.00001, "0"); + checkFormat(fmt, -0.0001, "0"); + checkFormat(fmt, 0.001, "1e!3"); + checkFormat(fmt, -0.01, "!1e!2"); + checkFormat(fmt, 0.1, "1e!1"); + checkFormat(fmt, -0.0, "0"); + checkFormat(fmt, 0.0, "0"); + checkFormat(fmt, -1.0, "!1"); + checkFormat(fmt, 10.0, "1e1"); + checkFormat(fmt, -100.0, "!1e2"); + checkFormat(fmt, 1000.0, "1e3"); + checkFormat(fmt, -10000.0, "!1e4"); + checkFormat(fmt, 100000.0, "1e5"); + checkFormat(fmt, -1000000.0, "!1e6"); + checkFormat(fmt, 10000000.0, "1e7"); + checkFormat(fmt, -100000000.0, "!1e8"); + + checkFormat(fmt, 1.25e-3, "1e!3"); + checkFormat(fmt, -9.975e-4, "!1e!3"); + checkFormat(fmt, 12345, "1,23e4"); + checkFormat(fmt, -9_999_999, "!1e7"); + checkFormat(fmt, 1.00001e7, "1e7"); + + checkFormat(fmt, Double.MAX_VALUE, "1,8e308"); + checkFormat(fmt, Double.MIN_VALUE, "0"); + checkFormat(fmt, Double.MIN_NORMAL, "0"); + checkFormat(fmt, Math.PI, "3,14"); + checkFormat(fmt, Math.E, "2,72"); + } + + @Test + void testScientific_localeFormatComparison() { + // act/assert + checkLocalizedFormats("0.0##E0", loc -> DoubleFormat.SCIENTIFIC.builder() + .maxPrecision(4) + .alwaysIncludeExponent(true) + .formatSymbols(DecimalFormatSymbols.getInstance(loc)) + .build()); + } + + @Test + void testEngineering_defaults() { + // act + final DoubleFunction<String> fmt = DoubleFormat.ENGINEERING.builder() + .build(); + + // act/assert + checkDefaultFormatSpecial(fmt); + + checkFormat(fmt, 0.00001, "10.0E-6"); + checkFormat(fmt, -0.0001, "-100.0E-6"); + checkFormat(fmt, 0.001, "1.0E-3"); + checkFormat(fmt, -0.01, "-10.0E-3"); + checkFormat(fmt, 0.1, "100.0E-3"); + checkFormat(fmt, -0.0, "-0.0"); + checkFormat(fmt, 0.0, "0.0"); + checkFormat(fmt, -1.0, "-1.0"); + checkFormat(fmt, 10.0, "10.0"); + checkFormat(fmt, -100.0, "-100.0"); + checkFormat(fmt, 1000.0, "1.0E3"); + checkFormat(fmt, -10000.0, "-10.0E3"); + checkFormat(fmt, 100000.0, "100.0E3"); + checkFormat(fmt, -1000000.0, "-1.0E6"); + checkFormat(fmt, 10000000.0, "10.0E6"); + checkFormat(fmt, -100000000.0, "-100.0E6"); + + checkFormat(fmt, 1.25e-3, "1.25E-3"); + checkFormat(fmt, -9.975e-4, "-997.5E-6"); + checkFormat(fmt, 12345, "12.345E3"); + checkFormat(fmt, -9_999_999, "-9.999999E6"); + checkFormat(fmt, 1.00001e7, "10.0001E6"); + + checkFormat(fmt, Double.MAX_VALUE, "179.76931348623157E306"); + checkFormat(fmt, Double.MIN_VALUE, "4.9E-324"); + checkFormat(fmt, Double.MIN_NORMAL, "22.250738585072014E-309"); + checkFormat(fmt, Math.PI, "3.141592653589793"); + checkFormat(fmt, Math.E, "2.718281828459045"); + } + + @Test + void testEngineering_custom() { + // act + final DoubleFunction<String> fmt = DoubleFormat.ENGINEERING.builder() + .maxPrecision(3) + .minDecimalExponent(-3) + .allowSignedZero(false) + .includeFractionPlaceholder(false) + .decimalSeparator(',') + .exponentSeparator("e") + .infinity("inf") + .nan("nan") + .minusSign('!') + .build(); + + // act/assert + checkFormat(fmt, Double.NaN, "nan"); + checkFormat(fmt, Double.POSITIVE_INFINITY, "inf"); + checkFormat(fmt, Double.NEGATIVE_INFINITY, "!inf"); + + checkFormat(fmt, 0.00001, "0"); + checkFormat(fmt, -0.0001, "0"); + checkFormat(fmt, 0.001, "1e!3"); + checkFormat(fmt, -0.01, "!10e!3"); + checkFormat(fmt, 0.1, "100e!3"); + checkFormat(fmt, -0.0, "0"); + checkFormat(fmt, 0.0, "0"); + checkFormat(fmt, -1.0, "!1"); + checkFormat(fmt, 10.0, "10"); + checkFormat(fmt, -100.0, "!100"); + checkFormat(fmt, 1000.0, "1e3"); + checkFormat(fmt, -10000.0, "!10e3"); + checkFormat(fmt, 100000.0, "100e3"); + checkFormat(fmt, -1000000.0, "!1e6"); + checkFormat(fmt, 10000000.0, "10e6"); + checkFormat(fmt, -100000000.0, "!100e6"); + + checkFormat(fmt, 1.25e-3, "1e!3"); + checkFormat(fmt, -9.975e-4, "!1e!3"); + checkFormat(fmt, 12345, "12,3e3"); + checkFormat(fmt, -9_999_999, "!10e6"); + checkFormat(fmt, 1.00001e7, "10e6"); + + checkFormat(fmt, Double.MAX_VALUE, "180e306"); + checkFormat(fmt, Double.MIN_VALUE, "0"); + checkFormat(fmt, Double.MIN_NORMAL, "0"); + checkFormat(fmt, Math.PI, "3,14"); + checkFormat(fmt, Math.E, "2,72"); + } + + @Test + void testEngineering_localeFormatComparison() { + // act/assert + checkLocalizedFormats("##0.0##E0", loc -> DoubleFormat.ENGINEERING.builder() + .maxPrecision(6) + .alwaysIncludeExponent(true) + .formatSymbols(DecimalFormatSymbols.getInstance(loc)) + .build()); + } + + @Test + void testMixed_defaults() { + // arrange + final DoubleFunction<String> fmt = DoubleFormat.MIXED.builder().build(); + + // act/assert + checkDefaultFormatSpecial(fmt); + + checkFormat(fmt, 0.00001, "1.0E-5"); + checkFormat(fmt, -0.0001, "-1.0E-4"); + checkFormat(fmt, 0.001, "0.001"); + checkFormat(fmt, -0.01, "-0.01"); + checkFormat(fmt, 0.1, "0.1"); + checkFormat(fmt, -0.0, "-0.0"); + checkFormat(fmt, 0.0, "0.0"); + checkFormat(fmt, -1.0, "-1.0"); + checkFormat(fmt, 10.0, "10.0"); + checkFormat(fmt, -100.0, "-100.0"); + checkFormat(fmt, 1000.0, "1000.0"); + checkFormat(fmt, -10000.0, "-10000.0"); + checkFormat(fmt, 100000.0, "100000.0"); + checkFormat(fmt, -1000000.0, "-1000000.0"); + checkFormat(fmt, 10000000.0, "1.0E7"); + checkFormat(fmt, -100000000.0, "-1.0E8"); + + checkFormat(fmt, 1.25e-3, "0.00125"); + checkFormat(fmt, -9.975e-4, "-9.975E-4"); + checkFormat(fmt, 12345, "12345.0"); + checkFormat(fmt, -9_999_999, "-9999999.0"); + checkFormat(fmt, 1.00001e7, "1.00001E7"); + + checkFormat(fmt, Double.MAX_VALUE, "1.7976931348623157E308"); + checkFormat(fmt, Double.MIN_VALUE, "4.9E-324"); + checkFormat(fmt, Double.MIN_NORMAL, "2.2250738585072014E-308"); + checkFormat(fmt, Math.PI, "3.141592653589793"); + checkFormat(fmt, Math.E, "2.718281828459045"); + } + + @Test + void testMixed_custom() { + // arrange + final DoubleFunction<String> fmt = DoubleFormat.MIXED.builder() + .maxPrecision(3) + .minDecimalExponent(-3) + .allowSignedZero(false) + .includeFractionPlaceholder(false) + .decimalSeparator(',') + .plainFormatMaxDecimalExponent(4) + .plainFormatMinDecimalExponent(-1) + .exponentSeparator("e") + .infinity("inf") + .nan("nan") + .minusSign('!') + .build(); + + // act/assert + checkFormat(fmt, Double.NaN, "nan"); + checkFormat(fmt, Double.POSITIVE_INFINITY, "inf"); + checkFormat(fmt, Double.NEGATIVE_INFINITY, "!inf"); + + checkFormat(fmt, 0.00001, "0"); + checkFormat(fmt, -0.0001, "0"); + checkFormat(fmt, 0.001, "1e!3"); + checkFormat(fmt, -0.01, "!1e!2"); + checkFormat(fmt, 0.1, "0,1"); + checkFormat(fmt, -0.0, "0"); + checkFormat(fmt, 0.0, "0"); + checkFormat(fmt, -1.0, "!1"); + checkFormat(fmt, 10.0, "10"); + checkFormat(fmt, -100.0, "!100"); + checkFormat(fmt, 1000.0, "1000"); + checkFormat(fmt, -10000.0, "!10000"); + checkFormat(fmt, 100000.0, "1e5"); + checkFormat(fmt, -1000000.0, "!1e6"); + checkFormat(fmt, 10000000.0, "1e7"); + checkFormat(fmt, -100000000.0, "!1e8"); + + checkFormat(fmt, 1.25e-3, "1e!3"); + checkFormat(fmt, -9.975e-4, "!1e!3"); + checkFormat(fmt, 12345, "12300"); + checkFormat(fmt, -9_999_999, "!1e7"); + checkFormat(fmt, 1.00001e7, "1e7"); + + checkFormat(fmt, Double.MAX_VALUE, "1,8e308"); + checkFormat(fmt, Double.MIN_VALUE, "0"); + checkFormat(fmt, Double.MIN_NORMAL, "0"); + checkFormat(fmt, Math.PI, "3,14"); + checkFormat(fmt, Math.E, "2,72"); + } + + @Test + void testCustomDigitString() { + // arrange + final String digits = "abcdefghij"; + final DoubleFunction<String> plain = DoubleFormat.PLAIN.builder().digits(digits).build(); + final DoubleFunction<String> sci = DoubleFormat.SCIENTIFIC.builder().digits(digits).build(); + final DoubleFunction<String> eng = DoubleFormat.ENGINEERING.builder().digits(digits).build(); + final DoubleFunction<String> mixed = DoubleFormat.MIXED.builder().digits(digits).build(); + + // act/assert + checkFormat(plain, 9876543210.0, "jihgfedcba.a"); + checkFormat(sci, 9876543210.0, "j.ihgfedcbEj"); + checkFormat(eng, 9876543210.0, "j.ihgfedcbEj"); + checkFormat(mixed, 9876543210.0, "j.ihgfedcbEj"); + } + + /** Check that the given format type correctly formats doubles when using the + * default configuration options. The format itself is not checked; only the + * fact that the input double can be successfully recovered using {@link Double#parseDouble(String)} + * is asserted. + * @param type format type + */ + private static void checkFormatAccuracyWithDefaults(final DoubleFormat type) { + final DoubleFunction<String> fmt = type.builder().build(); + + checkDefaultFormatSpecial(fmt); + + checkFormatAccuracy(fmt, Double.MIN_VALUE); + checkFormatAccuracy(fmt, -Double.MIN_VALUE); + + checkFormatAccuracy(fmt, Double.MIN_NORMAL); + checkFormatAccuracy(fmt, -Double.MIN_NORMAL); + + checkFormatAccuracy(fmt, Double.MAX_VALUE); + checkFormatAccuracy(fmt, -Double.MAX_VALUE); + + checkFormatAccuracy(fmt, Math.PI); + checkFormatAccuracy(fmt, Math.E); + + final Random rnd = new Random(10L); + final int cnt = 1000; + for (int i = 0; i < cnt; ++i) { + checkFormatAccuracy(fmt, randomDouble(rnd)); + } + } + + /** Check that the given double value can be exactly recovered from formatted string representation + * produced by the format instance. + * @param fmt format instance + * @param d input double value + */ + private static void checkFormatAccuracy(final DoubleFunction<String> fmt, final double d) { + final String str = fmt.apply(d); + final double parsed = Double.parseDouble(str); + Assertions.assertEquals(d, parsed, () -> "Formatted double string [" + str + "] did not match input value"); + } + + private static void checkFormat(final DoubleFunction<String> fmt, final double d, final String str) { + Assertions.assertEquals(str, fmt.apply(d)); + } + + private static void checkDefaultFormatSpecial(final DoubleFunction<String> fmt) { + checkFormat(fmt, 0.0, "0.0"); + checkFormat(fmt, -0.0, "-0.0"); + checkFormat(fmt, Double.NaN, "NaN"); + checkFormat(fmt, Double.POSITIVE_INFINITY, "Infinity"); + checkFormat(fmt, Double.NEGATIVE_INFINITY, "-Infinity"); + } + + private static void checkLocalizedFormats(final String pattern, + final Function<Locale, DoubleFunction<String>> factory) { + for (final Locale loc : Locale.getAvailableLocales()) { + checkLocalizedFormat(loc, pattern, factory); + } + } + + private static void checkLocalizedFormat(final Locale loc, final String pattern, + final Function<Locale, DoubleFunction<String>> factory) { + // arrange + final DecimalFormat df = new DecimalFormat(pattern, DecimalFormatSymbols.getInstance(loc)); + final DoubleFunction<String> fmt = factory.apply(loc); + + // act/assert + assertLocalizedFormatsAreEqual(0.0, df, fmt, loc); + assertLocalizedFormatsAreEqual(Double.POSITIVE_INFINITY, df, fmt, loc); + assertLocalizedFormatsAreEqual(Double.NEGATIVE_INFINITY, df, fmt, loc); + assertLocalizedFormatsAreEqual(Double.NaN, df, fmt, loc); + + assertLocalizedFormatsAreEqual(1.0, df, fmt, loc); + assertLocalizedFormatsAreEqual(-1.0, df, fmt, loc); + assertLocalizedFormatsAreEqual(Math.PI, df, fmt, loc); + assertLocalizedFormatsAreEqual(Math.E, df, fmt, loc); + + final Random rnd = new Random(12L); + final int minExp = -100; + final int maxExp = 100; + final int cnt = 1000; + for (int i = 0; i < cnt; ++i) { + assertLocalizedFormatsAreEqual(randomDouble(minExp, maxExp, rnd), df, fmt, loc); + } + } + + private static void assertLocalizedFormatsAreEqual(final double d, final DecimalFormat df, + final DoubleFunction<String> fmt, final Locale loc) { + // NOTE: Perform the string comparison only on non-format characters. This is required because + // JDK 16 adds directionality characters to strings for certain locales, such as Arabic, whereas + // previous JDKs do not. We will match the behavior of the previous versions here and ignore formatting + // for test purposes. + String dfStr = trimFormatChars(df.format(d)); + String fmtStr = trimFormatChars(fmt.apply(d)); + + Assertions.assertEquals(dfStr, fmtStr, + () -> "Unexpected output for locale [" + loc.toLanguageTag() + "] and double value " + d); + } + + /** Remove Unicode {@link Character#FORMAT format} characters from the given string. + * @param str input string + * @return input string with format characters removed + */ + private static String trimFormatChars(final String str) { + final StringBuilder sb = new StringBuilder(); + for (final char c : str.toCharArray()) { + if (Character.getType(c) != Character.FORMAT) { + sb.append(c); + } + } + return sb.toString(); + } + + /** Create a random double value using the full range of exponent values. + * @param rnd random number generator + * @return random double + */ + private static double randomDouble(final Random rnd) { + return randomDouble(Double.MIN_EXPONENT, Double.MAX_EXPONENT, rnd); + } + + /** Create a random double value with exponent in the range {@code [minExp, maxExp]}. + * @param minExp minimum exponent; must be less than {@code maxExp} + * @param maxExp maximum exponent; must be greater than {@code minExp} + * @param rnd random number generator + * @return random double + */ + private static double randomDouble(final int minExp, final int maxExp, final Random rnd) { + // Create random doubles using random bits in the sign bit and the mantissa. + final long mask = ((1L << 52) - 1) | 1L << 63; + final long bits = rnd.nextLong() & mask; + // The exponent must be unsigned so + 1023 to the signed exponent + final long exp = rnd.nextInt(maxExp - minExp + 1) + minExp + 1023; + return Double.longBitsToDouble(bits | (exp << 52)); + } +} diff --git a/src/test/java/org/apache/commons/text/numbers/ParsedDecimalTest.java b/src/test/java/org/apache/commons/text/numbers/ParsedDecimalTest.java new file mode 100644 index 0000000..3bd7ca8 --- /dev/null +++ b/src/test/java/org/apache/commons/text/numbers/ParsedDecimalTest.java @@ -0,0 +1,732 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.text.numbers; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.util.function.BiFunction; + +import org.apache.commons.rng.UniformRandomProvider; +import org.apache.commons.rng.simple.RandomSource; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +class ParsedDecimalTest { + + @Test + void testFrom() { + // act/assert + checkFrom(0.0, "0", 0); + + checkFrom(1.0, "1", 0); + checkFrom(10.0, "1", 1); + checkFrom(100.0, "1", 2); + checkFrom(1000.0, "1", 3); + checkFrom(10000.0, "1", 4); + + checkFrom(0.1, "1", -1); + checkFrom(0.01, "1", -2); + checkFrom(0.001, "1", -3); + checkFrom(0.0001, "1", -4); + checkFrom(0.00001, "1", -5); + + checkFrom(1.2, "12", -1); + checkFrom(0.00971, "971", -5); + checkFrom(56300, "563", 2); + + checkFrom(123.0, "123", 0); + checkFrom(1230.0, "123", 1); + checkFrom(12300.0, "123", 2); + checkFrom(123000.0, "123", 3); + + checkFrom(12.3, "123", -1); + checkFrom(1.23, "123", -2); + checkFrom(0.123, "123", -3); + checkFrom(0.0123, "123", -4); + + checkFrom(1.987654321e270, "1987654321", 261); + checkFrom(1.987654321e-270, "1987654321", -279); + + checkFrom(Math.PI, "3141592653589793", -15); + checkFrom(Math.E, "2718281828459045", -15); + + checkFrom(Double.MAX_VALUE, "17976931348623157", 292); + checkFrom(Double.MIN_VALUE, "49", -325); + checkFrom(Double.MIN_NORMAL, "22250738585072014", -324); + } + + @Test + void testFrom_notFinite() { + // arrange + final String msg = "Double is not finite"; + + // act/assert + assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NaN), + IllegalArgumentException.class, msg); + assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NEGATIVE_INFINITY), + IllegalArgumentException.class, msg); + assertThrowsWithMessage(() -> ParsedDecimal.from(Double.POSITIVE_INFINITY), + IllegalArgumentException.class, msg); + } + + @Test + void testIsZero() { + // act/assert + Assertions.assertTrue(ParsedDecimal.from(0.0).isZero()); + Assertions.assertTrue(ParsedDecimal.from(-0.0).isZero()); + + Assertions.assertFalse(ParsedDecimal.from(1.0).isZero()); + Assertions.assertFalse(ParsedDecimal.from(-1.0).isZero()); + + Assertions.assertFalse(ParsedDecimal.from(Double.MIN_NORMAL).isZero()); + Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_NORMAL).isZero()); + + Assertions.assertFalse(ParsedDecimal.from(Double.MAX_VALUE).isZero()); + Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_VALUE).isZero()); + } + + @Test + void testRound_one() { + // arrange + final double a = 1e-10; + final double b = -1; + final double c = 1e10; + + // act/assert + assertRound(a, -11, false, "1", -10); + assertRound(a, -10, false, "1", -10); + assertRound(a, -9, false, "0", 0); + + assertRound(b, -1, true, "1", 0); + assertRound(b, 0, true, "1", 0); + assertRound(b, 1, true, "0", 0); + + assertRound(c, 9, false, "1", 10); + assertRound(c, 10, false, "1", 10); + assertRound(c, 11, false, "0", 0); + } + + @Test + void testRound_nine() { + // arrange + final double a = 9e-10; + final double b = -9; + final double c = 9e10; + + // act/assert + assertRound(a, -11, false, "9", -10); + assertRound(a, -10, false, "9", -10); + assertRound(a, -9, false, "1", -9); + + assertRound(b, -1, true, "9", 0); + assertRound(b, 0, true, "9", 0); + assertRound(b, 1, true, "1", 1); + + assertRound(c, 9, false, "9", 10); + assertRound(c, 10, false, "9", 10); + assertRound(c, 11, false, "1", 11); + } + + @Test + void testRound_mixed() { + // arrange + final double a = 9.94e-10; + final double b = -3.1415; + final double c = 5.55e10; + + // act/assert + assertRound(a, -13, false, "994", -12); + assertRound(a, -12, false, "994", -12); + assertRound(a, -11, false, "99", -11); + assertRound(a, -10, false, "1", -9); + assertRound(a, -9, false, "1", -9); + assertRound(a, -8, false, "0", 0); + + assertRound(b, -5, true, "31415", -4); + assertRound(b, -4, true, "31415", -4); + assertRound(b, -3, true, "3142", -3); + assertRound(b, -2, true, "314", -2); + assertRound(b, -1, true, "31", -1); + assertRound(b, 0, true, "3", 0); + assertRound(b, 1, true, "0", 0); + assertRound(b, 2, true, "0", 0); + + assertRound(c, 7, false, "555", 8); + assertRound(c, 8, false, "555", 8); + assertRound(c, 9, false, "56", 9); + assertRound(c, 10, false, "6", 10); + assertRound(c, 11, false, "1", 11); + assertRound(c, 12, false, "0", 0); + } + + @Test + void testMaxPrecision() { + // arrange + final double d = 1.02576552; + + // act + assertMaxPrecision(d, 10, false, "102576552", -8); + assertMaxPrecision(d, 9, false, "102576552", -8); + assertMaxPrecision(d, 8, false, "10257655", -7); + assertMaxPrecision(d, 7, false, "1025766", -6); + assertMaxPrecision(d, 6, false, "102577", -5); + assertMaxPrecision(d, 5, false, "10258", -4); + assertMaxPrecision(d, 4, false, "1026", -3); + assertMaxPrecision(d, 3, false, "103", -2); + assertMaxPrecision(d, 2, false, "1", 0); + assertMaxPrecision(d, 1, false, "1", 0); + + assertMaxPrecision(d, 0, false, "102576552", -8); + } + + @Test + void testMaxPrecision_carry() { + // arrange + final double d = -999.0999e50; + + // act + assertMaxPrecision(d, 8, true, "9990999", 46); + assertMaxPrecision(d, 7, true, "9990999", 46); + assertMaxPrecision(d, 6, true, "9991", 49); + assertMaxPrecision(d, 5, true, "9991", 49); + assertMaxPrecision(d, 4, true, "9991", 49); + assertMaxPrecision(d, 3, true, "999", 50); + assertMaxPrecision(d, 2, true, "1", 53); + assertMaxPrecision(d, 1, true, "1", 53); + + assertMaxPrecision(d, 0, true, "9990999", 46); + } + + @Test + void testMaxPrecision_halfEvenRounding() { + // act/assert + // Test values taken from RoundingMode.HALF_EVEN javadocs + assertMaxPrecision(5.5, 1, false, "6", 0); + assertMaxPrecision(2.5, 1, false, "2", 0); + assertMaxPrecision(1.6, 1, false, "2", 0); + assertMaxPrecision(1.1, 1, false, "1", 0); + assertMaxPrecision(1.0, 1, false, "1", 0); + + assertMaxPrecision(-1.0, 1, true, "1", 0); + assertMaxPrecision(-1.1, 1, true, "1", 0); + assertMaxPrecision(-1.6, 1, true, "2", 0); + assertMaxPrecision(-2.5, 1, true, "2", 0); + assertMaxPrecision(-5.5, 1, true, "6", 0); + } + + @Test + void testMaxPrecision_singleDigits() { + // act + assertMaxPrecision(9.0, 1, false, "9", 0); + assertMaxPrecision(1.0, 1, false, "1", 0); + assertMaxPrecision(0.0, 1, false, "0", 0); + assertMaxPrecision(-0.0, 1, true, "0", 0); + assertMaxPrecision(-1.0, 1, true, "1", 0); + assertMaxPrecision(-9.0, 1, true, "9", 0); + } + + @Test + void testMaxPrecision_random() { + // arrange + final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L); + final ParsedDecimal.FormatOptions opts = new FormatOptionsImpl(); + + for (int i = 0; i < 10_000; ++i) { + final double d = createRandomDouble(rand); + final int precision = rand.nextInt(20) + 1; + final MathContext ctx = new MathContext(precision, RoundingMode.HALF_EVEN); + + final ParsedDecimal dec = ParsedDecimal.from(d); + + // act + dec.maxPrecision(precision); + + // assert + Assertions.assertEquals(new BigDecimal(Double.toString(d), ctx).doubleValue(), + Double.parseDouble(dec.toScientificString(opts))); + } + } + + @Test + void testToPlainString_defaults() { + // arrange + final FormatOptionsImpl opts = new FormatOptionsImpl(); + + // act/assert + checkToPlainString(0.0, "0.0", opts); + checkToPlainString(-0.0, "-0.0", opts); + checkToPlainString(1.0, "1.0", opts); + checkToPlainString(1.5, "1.5", opts); + + checkToPlainString(12, "12.0", opts); + checkToPlainString(123, "123.0", opts); + checkToPlainString(1234, "1234.0", opts); + checkToPlainString(12345, "12345.0", opts); + checkToPlainString(123456, "123456.0", opts); + checkToPlainString(1234567, "1234567.0", opts); + checkToPlainString(12345678, "12345678.0", opts); + checkToPlainString(123456789, "123456789.0", opts); + checkToPlainString(1234567890, "1234567890.0", opts); + + checkToPlainString(-0.000123, "-0.000123", opts); + checkToPlainString(12301, "12301.0", opts); + + checkToPlainString(Math.PI, "3.141592653589793", opts); + checkToPlainString(Math.E, "2.718281828459045", opts); + + checkToPlainString(-12345.6789, "-12345.6789", opts); + checkToPlainString(1.23e12, "1230000000000.0", opts); + checkToPlainString(1.23e-12, "0.00000000000123", opts); + } + + @Test + void testToPlainString_altFormat() { + // arrange + final FormatOptionsImpl opts = new FormatOptionsImpl(); + opts.setIncludeFractionPlaceholder(false); + opts.setSignedZero(false); + opts.setDecimalSeparator(','); + opts.setMinusSign('!'); + opts.setThousandsGroupingSeparator('_'); + opts.setGroupThousands(true); + + // act/assert + checkToPlainString(0.0, "0", opts); + checkToPlainString(-0.0, "0", opts); + checkToPlainString(1.0, "1", opts); + checkToPlainString(1.5, "1,5", opts); + + checkToPlainString(12, "12", opts); + checkToPlainString(123, "123", opts); + checkToPlainString(1234, "1_234", opts); + checkToPlainString(12345, "12_345", opts); + checkToPlainString(123456, "123_456", opts); + checkToPlainString(1234567, "1_234_567", opts); + checkToPlainString(12345678, "12_345_678", opts); + checkToPlainString(123456789, "123_456_789", opts); + checkToPlainString(1234567890, "1_234_567_890", opts); + + checkToPlainString(-0.000123, "!0,000123", opts); + checkToPlainString(12301, "12_301", opts); + + checkToPlainString(Math.PI, "3,141592653589793", opts); + checkToPlainString(Math.E, "2,718281828459045", opts); + + checkToPlainString(-12345.6789, "!12_345,6789", opts); + checkToPlainString(1.23e12, "1_230_000_000_000", opts); + checkToPlainString(1.23e-12, "0,00000000000123", opts); + } + + @Test + void testToScientificString_defaults() { + // arrange + final FormatOptionsImpl opts = new FormatOptionsImpl(); + + // act/assert + checkToScientificString(0.0, "0.0", opts); + checkToScientificString(-0.0, "-0.0", opts); + checkToScientificString(1.0, "1.0", opts); + checkToScientificString(1.5, "1.5", opts); + + checkToScientificString(-0.000123, "-1.23E-4", opts); + checkToScientificString(12301, "1.2301E4", opts); + + checkToScientificString(Math.PI, "3.141592653589793", opts); + checkToScientificString(Math.E, "2.718281828459045", opts); + + checkToScientificString(-Double.MAX_VALUE, "-1.7976931348623157E308", opts); + checkToScientificString(Double.MIN_VALUE, "4.9E-324", opts); + checkToScientificString(Double.MIN_NORMAL, "2.2250738585072014E-308", opts); + } + + @Test + void testToScientificString_altFormats() { + // arrange + final FormatOptionsImpl opts = new FormatOptionsImpl(); + opts.setIncludeFractionPlaceholder(false); + opts.setSignedZero(false); + opts.setDecimalSeparator(','); + opts.setMinusSign('!'); + opts.setExponentSeparator("x10^"); + opts.setAlwaysIncludeExponent(true); + + // act/assert + checkToScientificString(0.0, "0x10^0", opts); + checkToScientificString(-0.0, "0x10^0", opts); + checkToScientificString(1.0, "1x10^0", opts); + checkToScientificString(1.5, "1,5x10^0", opts); + + checkToScientificString(-0.000123, "!1,23x10^!4", opts); + checkToScientificString(12301, "1,2301x10^4", opts); + + checkToScientificString(Math.PI, "3,141592653589793x10^0", opts); + checkToScientificString(Math.E, "2,718281828459045x10^0", opts); + + checkToScientificString(-Double.MAX_VALUE, "!1,7976931348623157x10^308", opts); + checkToScientificString(Double.MIN_VALUE, "4,9x10^!324", opts); + checkToScientificString(Double.MIN_NORMAL, "2,2250738585072014x10^!308", opts); + } + + @Test + void testToEngineeringString_defaults() { + // arrange + final FormatOptionsImpl opts = new FormatOptionsImpl(); + + // act/assert + checkToEngineeringString(0.0, "0.0", opts); + checkToEngineeringString(-0.0, "-0.0", opts); + checkToEngineeringString(1.0, "1.0", opts); + checkToEngineeringString(1.5, "1.5", opts); + + checkToEngineeringString(10, "10.0", opts); + + checkToEngineeringString(-0.000000123, "-123.0E-9", opts); + checkToEngineeringString(12300000, "12.3E6", opts); + + checkToEngineeringString(Math.PI, "3.141592653589793", opts); + checkToEngineeringString(Math.E, "2.718281828459045", opts); + + checkToEngineeringString(-Double.MAX_VALUE, "-179.76931348623157E306", opts); + checkToEngineeringString(Double.MIN_VALUE, "4.9E-324", opts); + checkToEngineeringString(Double.MIN_NORMAL, "22.250738585072014E-309", opts); + } + + @Test + void testToEngineeringString_altFormat() { + // arrange + final FormatOptionsImpl opts = new FormatOptionsImpl(); + opts.setIncludeFractionPlaceholder(false); + opts.setSignedZero(false); + opts.setDecimalSeparator(','); + opts.setMinusSign('!'); + opts.setExponentSeparator("x10^"); + opts.setAlwaysIncludeExponent(true); + + // act/assert + checkToEngineeringString(0.0, "0x10^0", opts); + checkToEngineeringString(-0.0, "0x10^0", opts); + checkToEngineeringString(1.0, "1x10^0", opts); + checkToEngineeringString(1.5, "1,5x10^0", opts); + + checkToEngineeringString(10, "10x10^0", opts); + + checkToEngineeringString(-0.000000123, "!123x10^!9", opts); + checkToEngineeringString(12300000, "12,3x10^6", opts); + + checkToEngineeringString(Math.PI, "3,141592653589793x10^0", opts); + checkToEngineeringString(Math.E, "2,718281828459045x10^0", opts); + + checkToEngineeringString(-Double.MAX_VALUE, "!179,76931348623157x10^306", opts); + checkToEngineeringString(Double.MIN_VALUE, "4,9x10^!324", opts); + checkToEngineeringString(Double.MIN_NORMAL, "22,250738585072014x10^!309", opts); + } + + @Test + void testStringMethods_customDigits() { + // arrange + final FormatOptionsImpl opts = new FormatOptionsImpl(); + opts.setDigitsFromString("abcdefghij"); + + // act/assert + Assertions.assertEquals("b.a", ParsedDecimal.from(1.0).toPlainString(opts)); + Assertions.assertEquals("-a.abcd", ParsedDecimal.from(-0.0123).toPlainString(opts)); + Assertions.assertEquals("bc.de", ParsedDecimal.from(12.34).toPlainString(opts)); + Assertions.assertEquals("baaaa.a", ParsedDecimal.from(10000).toPlainString(opts)); + Assertions.assertEquals("jihgfedcba.a", ParsedDecimal.from(9876543210d).toPlainString(opts)); + + Assertions.assertEquals("b.a", ParsedDecimal.from(1.0).toScientificString(opts)); + Assertions.assertEquals("-b.cdE-c", ParsedDecimal.from(-0.0123).toScientificString(opts)); + Assertions.assertEquals("b.cdeEb", ParsedDecimal.from(12.34).toScientificString(opts)); + Assertions.assertEquals("b.aEe", ParsedDecimal.from(10000).toScientificString(opts)); + Assertions.assertEquals("j.ihgfedcbEj", ParsedDecimal.from(9876543210d).toScientificString(opts)); + + Assertions.assertEquals("b.a", ParsedDecimal.from(1.0).toEngineeringString(opts)); + Assertions.assertEquals("-bc.dE-d", ParsedDecimal.from(-0.0123).toEngineeringString(opts)); + Assertions.assertEquals("bc.de", ParsedDecimal.from(12.34).toEngineeringString(opts)); + Assertions.assertEquals("ba.aEd", ParsedDecimal.from(10000).toEngineeringString(opts)); + Assertions.assertEquals("j.ihgfedcbEj", ParsedDecimal.from(9876543210d).toEngineeringString(opts)); + } + + @Test + void testStringMethodAccuracy_sequence() { + // arrange + final double min = -1000; + final double max = 1000; + final double delta = 0.1; + + final FormatOptionsImpl stdOpts = new FormatOptionsImpl(); + final FormatOptionsImpl altOpts = new FormatOptionsImpl(); + altOpts.setExponentSeparator("e"); + altOpts.setIncludeFractionPlaceholder(false); + + Assertions.assertEquals(10.0, Double.parseDouble(ParsedDecimal.from(10.0).toScientificString(stdOpts))); + + for (double d = min; d <= max; d += delta) { + // act/assert + Assertions.assertEquals(d, Double.parseDouble(ParsedDecimal.from(d).toScientificString(stdOpts))); + Assertions.assertEquals(d, Double.parseDouble(ParsedDecimal.from(d).toScientificString(altOpts))); + + Assertions.assertEquals(d, Double.parseDouble(ParsedDecimal.from(d).toEngineeringString(stdOpts))); + Assertions.assertEquals(d, Double.parseDouble(ParsedDecimal.from(d).toEngineeringString(altOpts))); + + Assertions.assertEquals(d, Double.parseDouble(ParsedDecimal.from(d).toPlainString(stdOpts))); + Assertions.assertEquals(d, Double.parseDouble(ParsedDecimal.from(d).toPlainString(altOpts))); + } + } + + @Test + void testStringMethodAccuracy_random() { + // arrange + final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L); + + final FormatOptionsImpl stdOpts = new FormatOptionsImpl(); + final FormatOptionsImpl altOpts = new FormatOptionsImpl(); + altOpts.setExponentSeparator("e"); + altOpts.setIncludeFractionPlaceholder(false); + + double d; + for (int i = 0; i < 10_000; ++i) { + d = createRandomDouble(rand); + + // act/assert + Assertions.assertEquals(d, Double.parseDouble(ParsedDecimal.from(d).toScientificString(stdOpts))); + Assertions.assertEquals(d, Double.parseDouble(ParsedDecimal.from(d).toScientificString(altOpts))); + + Assertions.assertEquals(d, Double.parseDouble(ParsedDecimal.from(d).toEngineeringString(stdOpts))); + Assertions.assertEquals(d, Double.parseDouble(ParsedDecimal.from(d).toEngineeringString(altOpts))); + + Assertions.assertEquals(d, Double.parseDouble(ParsedDecimal.from(d).toPlainString(stdOpts))); + Assertions.assertEquals(d, Double.parseDouble(ParsedDecimal.from(d).toPlainString(altOpts))); + } + } + + private static void checkFrom(final double d, final String digits, final int exponent) { + final boolean negative = Math.signum(d) < 0; + + assertSimpleDecimal(ParsedDecimal.from(d), negative, digits, exponent); + assertSimpleDecimal(ParsedDecimal.from(-d), !negative, digits, exponent); + } + + private static void checkToPlainString(final double d, final String expected, + final ParsedDecimal.FormatOptions opts) { + checkToStringMethod(d, expected, ParsedDecimal::toPlainString, opts); + } + + private static void checkToScientificString(final double d, final String expected, + final ParsedDecimal.FormatOptions opts) { + checkToStringMethod(d, expected, ParsedDecimal::toScientificString, opts); + } + + private static void checkToEngineeringString(final double d, final String expected, + final ParsedDecimal.FormatOptions opts) { + checkToStringMethod(d, expected, ParsedDecimal::toEngineeringString, opts); + + // check the exponent value to make sure it is a multiple of 3 + final String pos = ParsedDecimal.from(d).toEngineeringString(opts); + Assertions.assertEquals(0, parseExponent(pos, opts) % 3); + + final String neg = ParsedDecimal.from(-d).toEngineeringString(opts); + Assertions.assertEquals(0, parseExponent(neg, opts) % 3); + } + + private static int parseExponent(final String str, final ParsedDecimal.FormatOptions opts) { + final char[] expSep = opts.getExponentSeparatorChars(); + + final int expStartIdx = str.indexOf(String.valueOf(expSep)); + if (expStartIdx > -1) { + int expIdx = expStartIdx + expSep.length; + + boolean neg = false; + if (str.charAt(expIdx) == opts.getMinusSign()) { + ++expIdx; + neg = true; + } + + final String expStr = str.substring(expIdx); + final int val = Integer.parseInt(expStr); + return neg + ? -val + : val; + } + + return 0; + } + + private static void checkToStringMethod(final double d, final String expected, + final BiFunction<ParsedDecimal, ParsedDecimal.FormatOptions, String> fn, + final ParsedDecimal.FormatOptions opts) { + + final ParsedDecimal pos = ParsedDecimal.from(d); + final String actual = fn.apply(pos, opts); + + Assertions.assertEquals(expected, actual); + } + + private static void assertRound(final double d, final int roundExponent, + final boolean negative, final String digits, final int exponent) { + final ParsedDecimal dec = ParsedDecimal.from(d); + dec.round(roundExponent); + + assertSimpleDecimal(dec, negative, digits, exponent); + } + + private static void assertMaxPrecision(final double d, final int maxPrecision, + final boolean negative, final String digits, final int exponent) { + final ParsedDecimal dec = ParsedDecimal.from(d); + dec.maxPrecision(maxPrecision); + + assertSimpleDecimal(dec, negative, digits, exponent); + } + + private static void assertSimpleDecimal(final ParsedDecimal parsed, final boolean negative, final String digits, + final int exponent) { + Assertions.assertEquals(negative, parsed.negative); + Assertions.assertEquals(digits, digitString(parsed)); + Assertions.assertEquals(exponent, parsed.getExponent()); + Assertions.assertEquals(digits.length(), parsed.digitCount); + Assertions.assertEquals(exponent, parsed.getScientificExponent() - digits.length() + 1); + } + + private static void assertThrowsWithMessage(final Executable fn, final Class<? extends Throwable> type, + final String msg) { + Throwable exc = Assertions.assertThrows(type, fn); + Assertions.assertEquals(msg, exc.getMessage()); + } + + private static double createRandomDouble(final UniformRandomProvider rng) { + final long mask = ((1L << 52) - 1) | 1L << 63; + final long bits = rng.nextLong() & mask; + final long exp = rng.nextInt(2045) + 1; + return Double.longBitsToDouble(bits | (exp << 52)); + } + + /** Get the raw digits in the given decimal as a string. + * @param dec decimal instancE + * @return decimal digits as a string + */ + private static String digitString(final ParsedDecimal dec) { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < dec.digitCount; ++i) { + sb.append(dec.digits[i]); + } + return sb.toString(); + } + + private static final class FormatOptionsImpl implements ParsedDecimal.FormatOptions { + + private boolean includeFractionPlaceholder = true; + + private boolean signedZero = true; + + private char[] digits = "0123456789".toCharArray(); + + private char decimalSeparator = '.'; + + private char thousandsGroupingSeparator = ','; + + private boolean groupThousands = false; + + private char minusSign = '-'; + + private String exponentSeparator = "E"; + + private boolean alwaysIncludeExponent = false; + + @Override + public boolean isIncludeFractionPlaceholder() { + return includeFractionPlaceholder; + } + + public void setIncludeFractionPlaceholder(final boolean includeFractionPlaceholder) { + this.includeFractionPlaceholder = includeFractionPlaceholder; + } + + @Override + public boolean isSignedZero() { + return signedZero; + } + + public void setSignedZero(final boolean signedZero) { + this.signedZero = signedZero; + } + + @Override + public char[] getDigits() { + return digits; + } + + public void setDigitsFromString(final String digits) { + this.digits = digits.toCharArray(); + } + + @Override + public char getDecimalSeparator() { + return decimalSeparator; + } + + public void setDecimalSeparator(final char decimalSeparator) { + this.decimalSeparator = decimalSeparator; + } + + @Override + public char getGroupingSeparator() { + return thousandsGroupingSeparator; + } + + public void setThousandsGroupingSeparator(final char thousandsGroupingSeparator) { + this.thousandsGroupingSeparator = thousandsGroupingSeparator; + } + + @Override + public boolean isGroupThousands() { + return groupThousands; + } + + public void setGroupThousands(final boolean groupThousands) { + this.groupThousands = groupThousands; + } + + @Override + public char getMinusSign() { + return minusSign; + } + + public void setMinusSign(final char minusSign) { + this.minusSign = minusSign; + } + + @Override + public char[] getExponentSeparatorChars() { + return exponentSeparator.toCharArray(); + } + + public void setExponentSeparator(final String exponentSeparator) { + this.exponentSeparator = exponentSeparator; + } + + @Override + public boolean isAlwaysIncludeExponent() { + return alwaysIncludeExponent; + } + + public void setAlwaysIncludeExponent(final boolean alwaysIncludeExponent) { + this.alwaysIncludeExponent = alwaysIncludeExponent; + } + } +}