Skip to content

Commit e7acd64

Browse files
authored
Backport #1649 fix to 2.18: optimize "NumberInput.looksLikeValidNumber()" (#1701)
1 parent a25790e commit e7acd64

3 files changed

Lines changed: 125 additions & 28 deletions

File tree

‎release-notes/VERSION-2.x‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ a pure JSON library.
1414
=== Releases ===
1515
------------------------------------------------------------------------
1616

17+
2.18.11 (not yet released)
18+
19+
#1649: Optimize `NumberInput.looksLikeValidNumber`
20+
(fix by @cowtowncoder, w/ Claude code)
21+
1722
2.18.10 (15-Aug-2026)
1823

1924
#1642: Fix maxDocumentLength bypass in async parser single-feedInput() case

‎src/main/java/com/fasterxml/jackson/core/io/NumberInput.java‎

Lines changed: 71 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import java.math.BigDecimal;
77
import java.math.BigInteger;
8-
import java.util.regex.Pattern;
98

109
/**
1110
* Helper class for efficient parsing of various JSON numbers.
@@ -32,25 +31,6 @@ public final class NumberInput
3231
final static String MIN_LONG_STR_NO_SIGN = String.valueOf(Long.MIN_VALUE).substring(1);
3332
final static String MAX_LONG_STR = String.valueOf(Long.MAX_VALUE);
3433

35-
/**
36-
* Regexp used to pre-validate "Stringified Numbers": slightly looser than
37-
* JSON Number definition (allows leading zeroes, positive sign).
38-
*
39-
* @since 2.17
40-
*/
41-
private final static Pattern PATTERN_FLOAT = Pattern.compile(
42-
"[+-]?[0-9]*[\\.]?[0-9]+([eE][+-]?[0-9]+)?");
43-
44-
45-
/**
46-
* Secondary regexp used along with {@code PATTERN_FLOAT} to cover
47-
* case where number ends with dot, like {@code "+12."}
48-
*
49-
* @since 2.17.2
50-
*/
51-
private final static Pattern PATTERN_FLOAT_TRAILING_DOT = Pattern.compile(
52-
"[+-]?[0-9]+[\\.]");
53-
5434
/**
5535
* Fast method for parsing unsigned integers that are known to fit into
5636
* regular 32-bit signed int type. This means that length is
@@ -636,23 +616,86 @@ public static BigInteger parseBigIntegerWithRadix(final String s, final int radi
636616
*<p>
637617
* Note: this method returning {@code true} DOES NOT GUARANTEE String is valid
638618
* number but just that it looks close enough.
619+
*<p>
620+
* Note: method rewritten in 2.18.11 to avoid use of JDK regexp functionality.
639621
*
640622
* @param s String to validate
641623
*
642624
* @return True if String looks like valid Java number; false otherwise.
643625
*
644-
* @since 2.17
626+
* @since 2.17 (rewritten in 2.18.11)
645627
*/
646628
public static boolean looksLikeValidNumber(final String s) {
647-
// While PATTERN_FLOAT handles most cases we can optimize some simple ones:
648-
if (s == null || s.isEmpty()) {
629+
// 08-Aug-2026, tatu: [core#1649] Hand-rolled scan; matches (union of)
630+
// "[+-]?[0-9]*[.]?[0-9]+([eE][+-]?[0-9]+)?" and "[+-]?[0-9]+[.]"
631+
if (s == null) {
649632
return false;
650633
}
651-
if (s.length() == 1) {
652-
char c = s.charAt(0);
653-
return (c <= '9') && (c >= '0');
634+
final int len = s.length();
635+
if (len == 0) {
636+
return false;
637+
}
638+
int i = 0;
639+
char c = s.charAt(i);
640+
641+
// Optional sign
642+
if (c == '+' || c == '-') {
643+
if (++i == len) { // sign alone
644+
return false;
645+
}
646+
c = s.charAt(i);
647+
}
648+
649+
// Integer part, if any
650+
final int intStart = i;
651+
while (c >= '0' && c <= '9') {
652+
if (++i == len) { // "[+-]?[0-9]+"
653+
return true;
654+
}
655+
c = s.charAt(i);
656+
}
657+
final int intDigits = i - intStart;
658+
659+
if (c == '.') {
660+
if (++i == len) { // trailing dot only allowed after digit(s)
661+
return intDigits > 0;
662+
}
663+
c = s.charAt(i);
664+
final int fractStart = i;
665+
while (c >= '0' && c <= '9') {
666+
if (++i == len) {
667+
return true;
668+
}
669+
c = s.charAt(i);
670+
}
671+
if (i == fractStart) { // "1.x": no fraction digits, and not trailing dot
672+
return false;
673+
}
674+
} else if (intDigits == 0) { // no mantissa digits at all
675+
return false;
676+
}
677+
678+
// Only an exponent may follow
679+
if (c != 'e' && c != 'E') {
680+
return false;
681+
}
682+
if (++i == len) {
683+
return false;
684+
}
685+
c = s.charAt(i);
686+
if (c == '+' || c == '-') {
687+
if (++i == len) {
688+
return false;
689+
}
690+
c = s.charAt(i);
691+
}
692+
while (c >= '0' && c <= '9') {
693+
if (++i == len) {
694+
return true;
695+
}
696+
c = s.charAt(i);
654697
}
655-
return PATTERN_FLOAT.matcher(s).matches()
656-
|| PATTERN_FLOAT_TRAILING_DOT.matcher(s).matches();
698+
// Either no exponent digits, or trailing garbage
699+
return false;
657700
}
658701
}

‎src/test/java/com/fasterxml/jackson/core/io/NumberInputTest.java‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.fasterxml.jackson.core.io;
22

33
import java.math.BigInteger;
4+
import java.util.regex.Pattern;
45

56
import org.junit.jupiter.api.Test;
67

@@ -105,6 +106,10 @@ void looksLikeValidNumberTrue()
105106
assertTrue(NumberInput.looksLikeValidNumber("1.4E-45"));
106107
assertTrue(NumberInput.looksLikeValidNumber("1.4e+45"));
107108

109+
// Fully populated form: sign, integer part, fraction, signed exponent
110+
assertTrue(NumberInput.looksLikeValidNumber("+1.2e+3"));
111+
assertTrue(NumberInput.looksLikeValidNumber("-12.34E-56"));
112+
108113
// https://github.com/FasterXML/jackson-core/issues/1308
109114
assertTrue(NumberInput.looksLikeValidNumber("0."));
110115
assertTrue(NumberInput.looksLikeValidNumber("6."));
@@ -139,5 +144,49 @@ void looksLikeValidNumberFalse()
139144
assertFalse(NumberInput.looksLikeValidNumber("+."));
140145
assertFalse(NumberInput.looksLikeValidNumber("-E"));
141146
assertFalse(NumberInput.looksLikeValidNumber("+E"));
147+
148+
assertFalse(NumberInput.looksLikeValidNumber("1.2.3"));
149+
assertFalse(NumberInput.looksLikeValidNumber("1.e5"));
150+
assertFalse(NumberInput.looksLikeValidNumber("1e"));
151+
assertFalse(NumberInput.looksLikeValidNumber("1e+"));
152+
assertFalse(NumberInput.looksLikeValidNumber("1e5x"));
153+
assertFalse(NumberInput.looksLikeValidNumber("1e5.0"));
154+
assertFalse(NumberInput.looksLikeValidNumber("--1"));
155+
assertFalse(NumberInput.looksLikeValidNumber("1 "));
156+
assertFalse(NumberInput.looksLikeValidNumber(" 1"));
157+
assertFalse(NumberInput.looksLikeValidNumber("0x1F"));
158+
}
159+
160+
// [core#1649]: hand-rolled implementation must accept exactly what the
161+
// original Regexp-based one did
162+
@Test
163+
void looksLikeValidNumberMatchesLegacyRegexps()
164+
{
165+
final Pattern patternFloat = Pattern.compile("[+-]?[0-9]*[\\.]?[0-9]+([eE][+-]?[0-9]+)?");
166+
final Pattern patternTrailingDot = Pattern.compile("[+-]?[0-9]+[\\.]");
167+
// Digit-class bounds ('0', '9') plus the chars just outside it ('/', ':')
168+
// to catch off-by-one in digit checks; rest are the structural characters
169+
final char[] alphabet = new char[] { '0', '9', '/', ':', '+', '-', '.', 'e', 'E' };
170+
171+
// Length 5 needed to reach forms combining both signs ("+1e+1") or
172+
// integer + fraction + exponent ("1.2e3"); ~66k inputs, runs in ~50 msec
173+
_verifyAgainstRegexps(patternFloat, patternTrailingDot, alphabet, "", 5);
174+
}
175+
176+
private void _verifyAgainstRegexps(Pattern patternFloat, Pattern patternTrailingDot,
177+
char[] alphabet, String prefix, int remainingLength)
178+
{
179+
if (!prefix.isEmpty()) {
180+
boolean exp = patternFloat.matcher(prefix).matches()
181+
|| patternTrailingDot.matcher(prefix).matches();
182+
assertEquals(exp, NumberInput.looksLikeValidNumber(prefix),
183+
"Mismatch for input '"+prefix+"'");
184+
}
185+
if (remainingLength > 0) {
186+
for (char c : alphabet) {
187+
_verifyAgainstRegexps(patternFloat, patternTrailingDot, alphabet,
188+
prefix + c, remainingLength - 1);
189+
}
190+
}
142191
}
143192
}

0 commit comments

Comments
 (0)