From a001c19cfd1c655bf5cdbc3066538410ee1cea06 Mon Sep 17 00:00:00 2001 From: Gary Gregory Date: Fri, 19 Jul 2024 08:26:07 -0400 Subject: [PATCH 1/4] Add testLang1641() --- .../commons/lang3/time/FastDateFormatTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java b/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java index aa13e28453b..3007c515ef6 100644 --- a/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java +++ b/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java @@ -47,6 +47,9 @@ * Unit tests {@link org.apache.commons.lang3.time.FastDateFormat}. */ public class FastDateFormatTest extends AbstractLangTest { + + private static final String ISO_8601_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZZ"; + private static final int NTHREADS = 10; private static final int NROUNDS = 10000; @@ -269,6 +272,20 @@ public void testLANG_954() { FastDateFormat.getInstance(pattern); } + @Test + public void testLang1641() { + assertSame(FastDateFormat.getInstance(ISO_8601_DATE_FORMAT), FastDateFormat.getInstance(ISO_8601_DATE_FORMAT)); + // commons-lang's GMT TimeZone + assertSame(FastDateFormat.getInstance(ISO_8601_DATE_FORMAT, FastTimeZone.getGmtTimeZone()), + FastDateFormat.getInstance(ISO_8601_DATE_FORMAT, FastTimeZone.getGmtTimeZone())); + // default TimeZone + assertSame(FastDateFormat.getInstance(ISO_8601_DATE_FORMAT, TimeZone.getDefault()), + FastDateFormat.getInstance(ISO_8601_DATE_FORMAT, TimeZone.getDefault())); + // TimeZones that are identical in every way except ID + assertNotSame(FastDateFormat.getInstance(ISO_8601_DATE_FORMAT, TimeZone.getTimeZone("Australia/Broken_Hill")), + FastDateFormat.getInstance(ISO_8601_DATE_FORMAT, TimeZone.getTimeZone("Australia/Yancowinna"))); + } + @Test public void testParseSync() throws InterruptedException { final String pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS"; From abb0ca487cefc6d6df66600769655b3527a7f63e Mon Sep 17 00:00:00 2001 From: Gary Gregory Date: Fri, 19 Jul 2024 08:27:03 -0400 Subject: [PATCH 2/4] Rename some test methods --- .../lang3/time/FastDateFormatTest.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java b/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java index 3007c515ef6..698dd1ade4c 100644 --- a/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java +++ b/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java @@ -248,7 +248,7 @@ public void testDateDefaults() { } @Test - public void testLANG_1152() { + public void testLang1152() { final TimeZone utc = FastTimeZone.getGmtTimeZone(); final Date date = new Date(Long.MAX_VALUE); @@ -259,19 +259,10 @@ public void testLANG_1152() { assertEquals("17/08/292278994", dateAsString); } @Test - public void testLANG_1267() { + public void testLang1267() { FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSSXXX"); } - /** - * According to LANG-954 (https://issues.apache.org/jira/browse/LANG-954) this is broken in Android 2.1. - */ - @Test - public void testLANG_954() { - final String pattern = "yyyy-MM-dd'T'"; - FastDateFormat.getInstance(pattern); - } - @Test public void testLang1641() { assertSame(FastDateFormat.getInstance(ISO_8601_DATE_FORMAT), FastDateFormat.getInstance(ISO_8601_DATE_FORMAT)); @@ -286,6 +277,15 @@ public void testLang1641() { FastDateFormat.getInstance(ISO_8601_DATE_FORMAT, TimeZone.getTimeZone("Australia/Yancowinna"))); } + /** + * According to LANG-954 (https://issues.apache.org/jira/browse/LANG-954) this is broken in Android 2.1. + */ + @Test + public void testLang954() { + final String pattern = "yyyy-MM-dd'T'"; + FastDateFormat.getInstance(pattern); + } + @Test public void testParseSync() throws InterruptedException { final String pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS"; From 6b7c0be1845ebbe61faa5db2e11159ccb4b9be8a Mon Sep 17 00:00:00 2001 From: Gary Gregory Date: Sat, 10 Jan 2026 07:52:14 -0500 Subject: [PATCH 3/4] [LANG-1806] NumberUtils.isParsable("1.f") should return true - Return true for numbers like 1.2e-5d and 1.2e-5f - Use a regular expression --- .../commons/lang3/math/NumberUtils.java | 76 +++++++++---------- .../commons/lang3/math/NumberUtilsTest.java | 64 ++++++++-------- 2 files changed, 67 insertions(+), 73 deletions(-) diff --git a/src/main/java/org/apache/commons/lang3/math/NumberUtils.java b/src/main/java/org/apache/commons/lang3/math/NumberUtils.java index 2ef56f4d952..2d203ac2e25 100644 --- a/src/main/java/org/apache/commons/lang3/math/NumberUtils.java +++ b/src/main/java/org/apache/commons/lang3/math/NumberUtils.java @@ -21,6 +21,7 @@ import java.math.BigInteger; import java.math.RoundingMode; import java.util.Objects; +import java.util.regex.Pattern; import org.apache.commons.lang3.CharUtils; import org.apache.commons.lang3.StringUtils; @@ -104,6 +105,24 @@ public class NumberUtils { */ public static final Long LONG_INT_MIN_VALUE = Long.valueOf(Integer.MIN_VALUE); + /** + * Pattern for ASCII digits only with decimal point, scientific notation, and type suffixes. + * + *
+     * -?              : optional minus sign
+     * (?:             : non-capturing group for number formats
+     *   [0-9]+        : one or more ASCII digits (integer)
+     *   |             : OR
+     *   [0-9]*\.[0-9]+: optional digits, dot, one or more digits (e.g., .5 or 1.5)
+     *   |             : OR
+     *   [0-9]+\.      : one or more digits, dot (e.g., 1.)
+     * )
+     * (?:[eE][+-]?[0-9]+)? : optional exponent (e or E, optional sign, ASCII digits)
+     * [fFdD]?         : optional type suffix (f, F, d, D)
+     * 
+ */ + private static final Pattern NUM_PATTERN = Pattern.compile("^-?(?:[0-9]+|[0-9]*\\.[0-9]+|[0-9]+\\.)(?:[eE][+-]?[0-9]+)?[fFdD]?$"); + /** * Compares two {@code byte} values numerically. This is the same functionality as provided in Java 7. * @@ -730,7 +749,12 @@ public static boolean isNumber(final String str) { *

* *

- * Hexadecimal and scientific notations are not considered parsable. See {@link #isCreatable(String)} on those cases. + * Hexadecimal notations are not considered parsable. See {@link #isCreatable(String)} for those cases. + *

+ * + *

+ * Scientific notation (e.g., {@code "1.2e-5"}) and type suffixes (e.g., {@code "2.0f"}, {@code "2.0d"}) are supported + * as they are valid for {@link Float#parseFloat(String)} and {@link Double#parseDouble(String)}. *

* *

@@ -745,45 +769,19 @@ public static boolean isParsable(final String str) { if (StringUtils.isEmpty(str)) { return false; } - if (str.charAt(0) == '-') { - if (str.length() == 1) { - return false; - } - return isParsableDecimal(str, 1); + final char lastChar = str.charAt(str.length() - 1); + // Use regex for decimal, exponent, or type suffix; otherwise check for integer digits + if (str.indexOf('.') >= 0 || str.indexOf('e') >= 0 || str.indexOf('E') >= 0 || + lastChar == 'f' || lastChar == 'F' || lastChar == 'd' || lastChar == 'D') { + return NUM_PATTERN.matcher(str).matches(); } - return isParsableDecimal(str, 0); - } - - /** - * Tests whether a number string is parsable as a decimal number or integer. - * - *

    - *
  • At most one decimal point is allowed.
  • - *
  • No signs, exponents or type qualifiers are allowed.
  • - *
  • Only ASCII digits are allowed if a decimal point is present.
  • - *
- * - * @param str the String to test. - * @param beginIdx the index to start checking from. - * @return {@code true} if the string is a parsable number. - */ - private static boolean isParsableDecimal(final String str, final int beginIdx) { - // See https://docs.oracle.com/javase/specs/jls/se8/html/jls-3.html#jls-NonZeroDigit - int decimalPoints = 0; - boolean asciiNumeric = true; - for (int i = beginIdx; i < str.length(); i++) { - final char ch = str.charAt(i); - final boolean isDecimalPoint = ch == '.'; - if (isDecimalPoint) { - decimalPoints++; - } - if (decimalPoints > 1 || !isDecimalPoint && !Character.isDigit(ch)) { - return false; - } - if (!isDecimalPoint) { - asciiNumeric &= CharUtils.isAsciiNumeric(ch); - } - if (decimalPoints > 0 && !asciiNumeric) { + // Simple integer: optional minus followed by Unicode digits (for Integer.parseInt compatibility) + final int start = str.charAt(0) == '-' ? 1 : 0; + if (start == str.length()) { + return false; // just "-" + } + for (int i = start; i < str.length(); i++) { + if (!Character.isDigit(str.charAt(i))) { return false; } } diff --git a/src/test/java/org/apache/commons/lang3/math/NumberUtilsTest.java b/src/test/java/org/apache/commons/lang3/math/NumberUtilsTest.java index 528039c9cf6..c96986be7c7 100644 --- a/src/test/java/org/apache/commons/lang3/math/NumberUtilsTest.java +++ b/src/test/java/org/apache/commons/lang3/math/NumberUtilsTest.java @@ -37,6 +37,8 @@ import org.apache.commons.lang3.AbstractLangTest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** * Tests {@link org.apache.commons.lang3.math.NumberUtils}. @@ -1004,36 +1006,14 @@ void testIsNumberLANG992() { compareIsNumberWithCreateNumber("0.4790", true); } - @Test - void testIsParsable() { - assertFalse(NumberUtils.isParsable(null)); - assertFalse(NumberUtils.isParsable("")); - assertFalse(NumberUtils.isParsable("0xC1AB")); - assertFalse(NumberUtils.isParsable("65CBA2")); - assertFalse(NumberUtils.isParsable("pendro")); - assertFalse(NumberUtils.isParsable("64, 2")); - assertFalse(NumberUtils.isParsable("64.2.2")); - assertFalse(NumberUtils.isParsable("64..")); - assertTrue(NumberUtils.isParsable("64.")); - assertTrue(NumberUtils.isParsable("-64.")); - assertFalse(NumberUtils.isParsable("64L")); - assertFalse(NumberUtils.isParsable("-")); - assertFalse(NumberUtils.isParsable("--2")); - assertTrue(NumberUtils.isParsable("64.2")); - assertTrue(NumberUtils.isParsable("64")); - assertTrue(NumberUtils.isParsable("018")); - assertTrue(NumberUtils.isParsable(".18")); - assertTrue(NumberUtils.isParsable("-65")); - assertTrue(NumberUtils.isParsable("-018")); - assertTrue(NumberUtils.isParsable("-018.2")); - assertTrue(NumberUtils.isParsable("-.236")); - assertTrue(NumberUtils.isParsable("2.")); - // TODO assertTrue(NumberUtils.isParsable("2.f")); - // TODO assertTrue(NumberUtils.isParsable("2.d")); - // Float.parseFloat("1.2e-5f") - // TODO assertTrue(NumberUtils.isParsable("1.2e-5f")); - // Double.parseDouble("1.2e-5d") - // TODO assertTrue(NumberUtils.isParsable("1.2e-5d")); + @ParameterizedTest + @ValueSource(strings = { "", "0xC1AB", "65CBA2", "pendro", "64, 2", "64.2.2", "64..", "64L", "-", "--2", + // Invalid scientific notation. + "e5", "1e", "1e+", "1e-", "1ee5", "1e5e5", + // Invalid type suffixes. + "f", "d", "-f", "-d", }) + void testIsParsableFalse(final String input) { + assertFalse(NumberUtils.isParsable(input)); } /** @@ -1056,6 +1036,22 @@ void testIsParsableFullWidthUnicodeJDK8326627() { assertFalse(NumberUtils.isParsable("0." + fullWidth123)); } + @Test + void testIsParsableNull() { + // Can't use null in @ValueSource(strings) + assertFalse(NumberUtils.isParsable(null)); + } + + @ParameterizedTest + @ValueSource(strings = { "64.", "-64.", "64.2", "64", "018", ".18", "-65", "-018", "-018.2", "-.236", "2.", "2.f", "2.d", "1.2e-5f", "1.2e-5d", + // Additional tests for scientific notation. + "1e5", "1E5", "1.2e5", "1.2E5", "1.2e+5", "1.2e-5", "-1.2e-5", "1e5f", "1e5F", "1e5d", "1e5D", + // Additional tests for type suffixes. + "2f", "2F", "2d", "2D", "2.0f", "2.0F", "2.0d", "2.0D", "-2.0f", "-2.0d", }) + void testIsParsableTrue(final String input) { + assertTrue(NumberUtils.isParsable(input)); + } + @Test void testLang1087() { // no sign cases @@ -1087,8 +1083,8 @@ void testLang1729IsParsableByte() { void testLang1729IsParsableDouble() { assertTrue(isParsableDouble("1")); assertTrue(isParsableDouble("1.")); - // TODO assertTrue(isParsableDouble("1.f")); - // TODO assertTrue(isParsableDouble("1.d")); + assertTrue(isParsableDouble("1.f")); + assertTrue(isParsableDouble("1.d")); assertTrue(isParsableDouble("1.0")); assertFalse(isParsableDouble("1.0.")); assertFalse(isParsableDouble("1 2 3")); @@ -1099,8 +1095,8 @@ void testLang1729IsParsableDouble() { void testLang1729IsParsableFloat() { assertTrue(isParsableFloat("1")); assertTrue(isParsableFloat("1.")); - // TODO assertTrue(isParsableFloat("1.f")); - // TODO assertTrue(isParsableFloat("1.d")); + assertTrue(isParsableFloat("1.f")); + assertTrue(isParsableFloat("1.d")); assertTrue(isParsableFloat("1.0")); assertFalse(isParsableFloat("1.0.")); assertFalse(isParsableFloat("1 2 3")); From 06a6b1f085a05fd1ec68fc9c94c9d5d236f75ac4 Mon Sep 17 00:00:00 2001 From: Gary Gregory Date: Sun, 11 Jan 2026 11:00:30 -0500 Subject: [PATCH 4/4] [LANG-1806] NumberUtils.isParsable("1.f") should return true - Return true for numbers like 1.2e-5d and 1.2e-5f - Return true when one of the following would work: -- Double.parseDouble(String) -- Float.parseFloat(String) -- Long.parseLong(String) -- Integer.parseInteger(String) There are so many cases that it's simpler to try to parse and catch exceptions, instead of re-creating all the parsing rules from the JRE. The only downside is that number instances are created and discarded. --- .../commons/lang3/math/NumberUtils.java | 60 ++++--------- .../commons/lang3/math/NumberUtilsTest.java | 90 ++++++++++++++++++- 2 files changed, 104 insertions(+), 46 deletions(-) diff --git a/src/main/java/org/apache/commons/lang3/math/NumberUtils.java b/src/main/java/org/apache/commons/lang3/math/NumberUtils.java index 2d203ac2e25..6abd66502a5 100644 --- a/src/main/java/org/apache/commons/lang3/math/NumberUtils.java +++ b/src/main/java/org/apache/commons/lang3/math/NumberUtils.java @@ -21,7 +21,7 @@ import java.math.BigInteger; import java.math.RoundingMode; import java.util.Objects; -import java.util.regex.Pattern; +import java.util.function.Consumer; import org.apache.commons.lang3.CharUtils; import org.apache.commons.lang3.StringUtils; @@ -105,23 +105,14 @@ public class NumberUtils { */ public static final Long LONG_INT_MIN_VALUE = Long.valueOf(Integer.MIN_VALUE); - /** - * Pattern for ASCII digits only with decimal point, scientific notation, and type suffixes. - * - *
-     * -?              : optional minus sign
-     * (?:             : non-capturing group for number formats
-     *   [0-9]+        : one or more ASCII digits (integer)
-     *   |             : OR
-     *   [0-9]*\.[0-9]+: optional digits, dot, one or more digits (e.g., .5 or 1.5)
-     *   |             : OR
-     *   [0-9]+\.      : one or more digits, dot (e.g., 1.)
-     * )
-     * (?:[eE][+-]?[0-9]+)? : optional exponent (e or E, optional sign, ASCII digits)
-     * [fFdD]?         : optional type suffix (f, F, d, D)
-     * 
- */ - private static final Pattern NUM_PATTERN = Pattern.compile("^-?(?:[0-9]+|[0-9]*\\.[0-9]+|[0-9]+\\.)(?:[eE][+-]?[0-9]+)?[fFdD]?$"); + private static boolean accept(final Consumer consumer, final T obj) { + try { + consumer.accept(obj); + return true; + } catch (Exception e) { + return false; + } + } /** * Compares two {@code byte} values numerically. This is the same functionality as provided in Java 7. @@ -186,7 +177,7 @@ public static int compare(final short x, final short y) { * Returns {@code null} if the string is {@code null}. *

* - * @param str a {@link String} to convert, may be null. + * @param str a {@link String} to convert, may be null.Return * @return converted {@link BigDecimal} (or null if the input is null). * @throws NumberFormatException if the value cannot be converted. */ @@ -749,11 +740,7 @@ public static boolean isNumber(final String str) { *

* *

- * Hexadecimal notations are not considered parsable. See {@link #isCreatable(String)} for those cases. - *

- * - *

- * Scientific notation (e.g., {@code "1.2e-5"}) and type suffixes (e.g., {@code "2.0f"}, {@code "2.0d"}) are supported + * Scientific notation (for example, {@code "1.2e-5"}) and type suffixes (e.g., {@code "2.0f"}, {@code "2.0d"}) are supported * as they are valid for {@link Float#parseFloat(String)} and {@link Double#parseDouble(String)}. *

* @@ -763,29 +750,14 @@ public static boolean isNumber(final String str) { * * @param str the String to check. * @return {@code true} if the string is a parsable number. + * @see Integer#parseInt(String) + * @see Long#parseLong(String) + * @see Double#parseDouble(String) + * @see Float#parseFloat(String) * @since 3.4 */ public static boolean isParsable(final String str) { - if (StringUtils.isEmpty(str)) { - return false; - } - final char lastChar = str.charAt(str.length() - 1); - // Use regex for decimal, exponent, or type suffix; otherwise check for integer digits - if (str.indexOf('.') >= 0 || str.indexOf('e') >= 0 || str.indexOf('E') >= 0 || - lastChar == 'f' || lastChar == 'F' || lastChar == 'd' || lastChar == 'D') { - return NUM_PATTERN.matcher(str).matches(); - } - // Simple integer: optional minus followed by Unicode digits (for Integer.parseInt compatibility) - final int start = str.charAt(0) == '-' ? 1 : 0; - if (start == str.length()) { - return false; // just "-" - } - for (int i = start; i < str.length(); i++) { - if (!Character.isDigit(str.charAt(i))) { - return false; - } - } - return true; + return accept(Double::parseDouble, str) || accept(Long::parseLong, str) || accept(Float::parseFloat, str) || accept(Long::parseLong, str); } private static boolean isSign(final char ch) { diff --git a/src/test/java/org/apache/commons/lang3/math/NumberUtilsTest.java b/src/test/java/org/apache/commons/lang3/math/NumberUtilsTest.java index c96986be7c7..105dda46204 100644 --- a/src/test/java/org/apache/commons/lang3/math/NumberUtilsTest.java +++ b/src/test/java/org/apache/commons/lang3/math/NumberUtilsTest.java @@ -1006,6 +1006,46 @@ void testIsNumberLANG992() { compareIsNumberWithCreateNumber("0.4790", true); } + @ParameterizedTest + // @formatter:off + @ValueSource(strings = { + // Decimal floating-point literals (no suffix or 'd'/'D' suffix) + "3.14", + "3.14d", + "3.14D", + ".5", + ".5d", + "5.", + "5.d", + "5d", + "0.0", + "0.0d", + // Exponential (scientific) notation + "1.23e10", + "1.23e10d", + "1.23E10", + "1.23e-10", + "1.23e-10d", + "1e5", + "1e5d", + ".5e3", + ".5e3d", + // Hexadecimal floating-point literals + "0x1.8p3", + "0x1.8p3d", + "0x.8p0", + "0x1p-3", + "0x1.fffffffffffffp1023", + "0x1.fffffffffffffp1023d", + // With leading zeros + "01.5", + "01.5d" }) + // @formatter:on + void testIsParsableDoubleTrue(final String input) { + Double.parseDouble(input); + assertTrue(NumberUtils.isParsable(input)); + } + @ParameterizedTest @ValueSource(strings = { "", "0xC1AB", "65CBA2", "pendro", "64, 2", "64.2.2", "64..", "64L", "-", "--2", // Invalid scientific notation. @@ -1016,6 +1056,35 @@ void testIsParsableFalse(final String input) { assertFalse(NumberUtils.isParsable(input)); } + @ParameterizedTest + // @formatter:off + @ValueSource(strings = { + // Decimal floating-point literals + "3.14f", + "3.14F", + ".5f", + "5.f", + "5f", + "0.0f", + // Exponential (scientific) notation + "1.23e10f", + "1.23E10f", + "1.23e-10f", + "1e5f", + ".5e3f", + // Hexadecimal floating-point literals + "0x1.8p3f", + "0x.8p0f", + "0x1p-3f", + "0x1.fffffep127f", + // With leading zeros + "01.5f"}) + // @formatter:on + void testIsParsableFloatTrue(final String input) { + Float.parseFloat(input); + assertTrue(NumberUtils.isParsable(input)); + } + /** * Tests https://issues.apache.org/jira/browse/LANG-1729 * @@ -1043,11 +1112,28 @@ void testIsParsableNull() { } @ParameterizedTest - @ValueSource(strings = { "64.", "-64.", "64.2", "64", "018", ".18", "-65", "-018", "-018.2", "-.236", "2.", "2.f", "2.d", "1.2e-5f", "1.2e-5d", + // @formatter:off + @ValueSource(strings = { + "64.", + "-64.", + "64.2", + "64", + "018", + ".18", + "-65", + "-018", + "-018.2", + "-.236", + "2.", + "2.f", + "2.d", + "1.2e-5f", + "1.2e-5d", // Additional tests for scientific notation. "1e5", "1E5", "1.2e5", "1.2E5", "1.2e+5", "1.2e-5", "-1.2e-5", "1e5f", "1e5F", "1e5d", "1e5D", // Additional tests for type suffixes. - "2f", "2F", "2d", "2D", "2.0f", "2.0F", "2.0d", "2.0D", "-2.0f", "-2.0d", }) + "2f", "2F", "2d", "2D", "2.0f", "2.0F", "2.0d", "2.0D", "-2.0f", "-2.0d" }) + // @formatter:on void testIsParsableTrue(final String input) { assertTrue(NumberUtils.isParsable(input)); }