From d24938b46225b2fa532295e6bd15ae965aa212d3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:21:24 +0700 Subject: [PATCH 1/7] Fix the iOS String.format crash and the toString it relied on (issue #5482) String.format was a native method whose Objective-C branch formatted a string, threw it away, and returned fromNSString([NSString init]) -- sending init to the NSString class object, which aborts the process with "+[NSString<0x...> init]: cannot init a class object". Every String.format call on iOS killed the app. The C fallback that ran everywhere else did not crash but ignored width and precision, so "%.3f" printed every digit of the double. The Apple branch sits behind #if defined(__APPLE__) && defined(__OBJC__) and parparvm-tests runs on ubuntu-latest, so CI only ever compiled the #else branch and nothing flagged either problem. Formatting is string manipulation, so drop the native entirely and implement it once in java.lang.StringFormatter. One implementation now serves iOS, the JavaScript target and the C fallback, which also makes the Linux CI test meaningful for iOS. Supported conversions are s S b B h H c C d o x X e E f g G n %, with the - + ' ' 0 , ( # flags, width, precision and the %n$ / %< argument selectors. %a (hexadecimal float) and %t (date and time) are not implemented and raise UnknownFormatConversionException rather than producing something wrong. A malformed format string now raises the java.util exception the JVM raises (the ten missing classes are added here) instead of taking the process down, which is what the reporter asked for. Two further defects surfaced once the output could be compared with a JVM: - Double.toString and Float.toString were badly non-conforming. They asked snprintf for a fixed "%f" (six decimals) in the plain range and "%1.20E" (twenty-one significant digits) in the scientific range, so 1.0/3.0 rendered as "0.333333" instead of "0.3333333333333333" and 1e30 rendered as "1.00000000000000001988E30" instead of "1.0E30". That affected every concatenation of a double, not just formatting. Replaced with a search for the shortest rendering that round trips, which is what the specification asks for. - Math.abs(-0.0) returned -0.0, because "a < 0" is false for negative zero. Now fabs/fabsf. Coverage: StringFormatIntegrationTest and GetClassIntegrationTest run the same program on a real JVM and under ParparVM and diff it case by case, so the expectations are the JDK's rather than hand written. On macOS the harness compiles nativeMethods.m as Objective-C, so these exercise the branch that was crashing. Off-line, the implementation was diffed against the JDK over 384k value cases, 300k randomly assembled format strings and 399k random double and float bit patterns with no divergence. GetClassIntegrationTest also covers the reporter's other suspicion, that getClass() was returning null. It does not: getClassImpl cannot return null for a non-null receiver, and Class identity, hashing, string conversion and use as a HashMap key all match the JVM under allocation churn. The test pins that. Co-Authored-By: Claude Opus 5 (1M context) --- .../translator/JavascriptNativeRegistry.java | 1 - .../src/javascript/parparvm_runtime.js | 79 -- vm/ByteCodeTranslator/src/nativeMethods.m | 308 ++++--- vm/JavaAPI/src/java/lang/String.java | 10 +- vm/JavaAPI/src/java/lang/StringFormatter.java | 750 ++++++++++++++++++ .../util/DuplicateFormatFlagsException.java | 46 ++ ...ormatFlagsConversionMismatchException.java | 52 ++ .../util/IllegalFormatCodePointException.java | 44 + .../IllegalFormatConversionException.java | 52 ++ .../src/java/util/IllegalFormatException.java | 36 + .../util/IllegalFormatFlagsException.java | 46 ++ .../util/IllegalFormatPrecisionException.java | 44 + .../util/IllegalFormatWidthException.java | 44 + .../util/MissingFormatArgumentException.java | 46 ++ .../util/MissingFormatWidthException.java | 46 ++ .../UnknownFormatConversionException.java | 46 ++ .../translator/GetClassIntegrationTest.java | 182 +++++ .../StringFormatIntegrationTest.java | 202 +++++ .../tools/translator/GetClassApp.java | 87 ++ .../tools/translator/StringFormatApp.java | 219 +++++ 20 files changed, 2092 insertions(+), 248 deletions(-) create mode 100644 vm/JavaAPI/src/java/lang/StringFormatter.java create mode 100644 vm/JavaAPI/src/java/util/DuplicateFormatFlagsException.java create mode 100644 vm/JavaAPI/src/java/util/FormatFlagsConversionMismatchException.java create mode 100644 vm/JavaAPI/src/java/util/IllegalFormatCodePointException.java create mode 100644 vm/JavaAPI/src/java/util/IllegalFormatConversionException.java create mode 100644 vm/JavaAPI/src/java/util/IllegalFormatException.java create mode 100644 vm/JavaAPI/src/java/util/IllegalFormatFlagsException.java create mode 100644 vm/JavaAPI/src/java/util/IllegalFormatPrecisionException.java create mode 100644 vm/JavaAPI/src/java/util/IllegalFormatWidthException.java create mode 100644 vm/JavaAPI/src/java/util/MissingFormatArgumentException.java create mode 100644 vm/JavaAPI/src/java/util/MissingFormatWidthException.java create mode 100644 vm/JavaAPI/src/java/util/UnknownFormatConversionException.java create mode 100644 vm/tests/src/test/java/com/codename1/tools/translator/GetClassIntegrationTest.java create mode 100644 vm/tests/src/test/java/com/codename1/tools/translator/StringFormatIntegrationTest.java create mode 100644 vm/tests/src/test/resources/com/codename1/tools/translator/GetClassApp.java create mode 100644 vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptNativeRegistry.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptNativeRegistry.java index 6107862ea18..d7c37e5c1be 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptNativeRegistry.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptNativeRegistry.java @@ -110,7 +110,6 @@ enum NativeCategory { "cn1_java_lang_String_cn1FusedConcat5_java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_R_java_lang_String", "cn1_java_lang_String_equalsIgnoreCase_java_lang_String_R_boolean", "cn1_java_lang_String_equals_java_lang_Object_R_boolean", - "cn1_java_lang_String_format_java_lang_String_java_lang_Object_1ARRAY_R_java_lang_String", "cn1_java_lang_String_getChars_int_int_char_1ARRAY_int", "cn1_java_lang_String_hashCode_R_int", "cn1_java_lang_String_indexOf_int_int_R_int", diff --git a/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js b/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js index 6e0c150b9ab..40713faf2e7 100644 --- a/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js +++ b/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js @@ -4728,32 +4728,6 @@ function runtimeBoxedPrimitiveValue(value) { return null; } } -function* runtimeFormatTokenValue(token, value) { - if (token === "%s") { - return yield* runtimeToNativeString(value); - } - if (token === "%c") { - const primitive = runtimeBoxedPrimitiveValue(value); - if (primitive != null) { - return String.fromCharCode(primitive | 0); - } - return String.fromCharCode((value == null ? 0 : value) | 0); - } - if (token === "%d" || token === "%i") { - const primitive = runtimeBoxedPrimitiveValue(value); - const numeric = primitive != null ? primitive : (value == null ? 0 : value); - // A long is a BigInt -- print it exactly; Number(bigint) would lose >2^53. - return (numeric && numeric.__l === 1) ? _LtoStr(numeric) : String(Math.trunc(Number(numeric))); - } - if (token === "%f") { - const primitive = runtimeBoxedPrimitiveValue(value); - if (primitive != null) { - return String(Number(primitive)); - } - return String(Number(value == null ? 0 : value)); - } - return yield* runtimeToNativeString(value); -} function sbEnsureCapacity(sb, size) { let data = sb[CN1_SB_VALUE]; if (!data) { @@ -5623,59 +5597,6 @@ bindNative(["cn1_java_lang_String_charsToBytes_char_1ARRAY_char_1ARRAY_R_byte_1A } return out; }); -bindNative(["cn1_java_lang_String_format_java_lang_String_java_lang_Object_1ARRAY_R_java_lang_String"], function*(format, args) { - const text = jvm.toNativeString(format); - const values = []; - if (args && args.__array) { - for (let i = 0; i < args.length; i++) { - values.push(args[i]); - } - } - - const nextArgString = function*(token) { - const arg = values.length ? values.shift() : null; - return yield* runtimeFormatTokenValue("%" + token, arg); - }; - - let out = ""; - for (let i = 0; i < text.length; i++) { - const ch = text.charAt(i); - if (ch !== "%" || i === text.length - 1) { - out += ch; - continue; - } - - const next = text.charAt(i + 1); - if (next === "%") { - out += "%"; - i++; - continue; - } - - let j = i + 1; - while (j < text.length && "-#+ 0,(".indexOf(text.charAt(j)) >= 0) { - j++; - } - while (j < text.length && text.charAt(j) >= "0" && text.charAt(j) <= "9") { - j++; - } - if (j < text.length && text.charAt(j) === ".") { - j++; - while (j < text.length && text.charAt(j) >= "0" && text.charAt(j) <= "9") { - j++; - } - } - const token = j < text.length ? text.charAt(j) : ""; - if ("sdifc".indexOf(token) >= 0) { - out += yield* nextArgString(token); - i = j; - } else { - out += "%"; - } - } - - return createJavaString(out); -}); bindNative(["cn1_java_lang_StringToReal_parseDblImpl_java_lang_String_int_R_double"], function(value, exponentIndex) { // Contract (per Apache Harmony StringToReal.parseDblImpl): the input string // is the pre-processed digits with no decimal point, and exponentIndex is diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 6b40f077b39..d9e55cabd55 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -1055,120 +1055,158 @@ JAVA_INT java_lang_Float_floatToIntBits___float_R_int(CODENAME_ONE_THREAD_STATE, } -JAVA_OBJECT java_lang_Double_toStringImpl___double_boolean_R_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE d, JAVA_BOOLEAN b) { - char s[32]; - if ( !b ){ - snprintf(s, 32, "%lf", d); - } else { - snprintf(s, 32, "%1.20E", d); +// java.lang.Double.toString / java.lang.Float.toString must print "the smallest number +// of digits that uniquely distinguishes the argument value from adjacent values of the +// same type". The previous implementation instead asked snprintf for a fixed "%f" (six +// decimals) in the plain range and "%1.20E" (twenty-one significant digits) in the +// scientific range, so Double.toString(1.0/3.0) came back as "0.333333" instead of +// "0.3333333333333333", and Double.toString(1e30) came back as +// "1.00000000000000001988E30" instead of "1.0E30". Both are wrong in every direction +// that matters: too few digits loses information, too many invent it, and neither round +// trips. String concatenation of a double went through this, so did String.format. +// +// snprintf("%.*e") is correctly rounded, and a rendering that round trips stays round +// tripping as digits are added, so a binary search over the digit count finds the +// shortest faithful rendering in a handful of formatting attempts. The search starts at +// two significand digits because the specification requires at least one digit after the +// decimal point: Double.toString(Double.MIN_VALUE) is "4.9E-324", not a zero-padded +// "5.0E-324", even though a single digit round trips. +static void cn1ShortestDouble(char* buffer, int bufferSize, JAVA_DOUBLE d) { + int low = 2; + int high = 17; + while (low < high) { + int mid = (low + high) / 2; + snprintf(buffer, bufferSize, "%.*e", mid - 1, d); + if (strtod(buffer, NULL) == d) { + high = mid; + } else { + low = mid + 1; + } } - - // We need to match the format of Java spec. That includes: - // No "+" for positive exponent. - // No leading zeroes in positive exponents. - // No trailing zeroes in decimal portion. - int j=0; - // Process only the actual formatted length, not the uninitialized buffer tail - // (see the matching note in the Float variant below): walking the garbage past - // the snprintf'd string can push `j` past the end of s2[32] and smash the stack. - int i = (int) strlen(s); - char s2[32]; - BOOL inside=NO; - while (i-->0 && j < 30){ - if (inside){ - if (s[i]=='.'){ - s2[j++]='0'; - } - if (s[i]!='0'){ - inside=NO; - s2[j++]=s[i]; - } + snprintf(buffer, bufferSize, "%.*e", low - 1, d); +} +static void cn1ShortestFloat(char* buffer, int bufferSize, JAVA_FLOAT f) { + int low = 2; + int high = 9; + while (low < high) { + int mid = (low + high) / 2; + snprintf(buffer, bufferSize, "%.*e", mid - 1, (double)f); + if (strtof(buffer, NULL) == f) { + high = mid; } else { - if (s[i]=='E'){ - inside=YES; + low = mid + 1; + } + } + snprintf(buffer, bufferSize, "%.*e", low - 1, (double)f); +} + +// Rewrites the "[-]d.dddde[+-]dd" rendering above into the shape java.lang.Double.toString +// specifies: no '+' and no leading zeros on the exponent, no trailing zeros in the +// significand, and always at least one digit after the decimal point. +static void cn1JavaFloatingText(char* out, int outSize, const char* raw, JAVA_BOOLEAN scientific) { + const char* p = raw; + int at = 0; + int limit = outSize - 2; + char digits[32]; + int digitCount = 0; + int exponent = 0; + int i; + if (*p == '-') { + out[at++] = '-'; + p++; + } + while (*p != 0 && *p != 'e' && *p != 'E') { + if (*p >= '0' && *p <= '9' && digitCount < (int)sizeof(digits) - 1) { + digits[digitCount++] = *p; + } + p++; + } + if (*p == 'e' || *p == 'E') { + exponent = (int)strtol(p + 1, NULL, 10); + } + while (digitCount > 1 && digits[digitCount - 1] == '0') { + digitCount--; + } + if (digitCount == 0) { + digits[digitCount++] = '0'; + } + if (scientific) { + out[at++] = digits[0]; + out[at++] = '.'; + if (digitCount == 1) { + out[at++] = '0'; + } else { + for (i = 1; i < digitCount && at < limit; i++) { + out[at++] = digits[i]; } - if (s[i]=='+'){ - // If a positive exponent, we don't need leading zeroes in - // the exponent - while (s2[--j]=='0'){ - - } - j++; - continue; + } + out[at++] = 'E'; + snprintf(out + at, outSize - at, "%d", exponent); + return; + } + // Double.toString only takes the plain branch for 1e-3 <= |d| < 1e7, but the native + // is reachable on its own, so every write below is bounded rather than trusting the + // exponent to be small. + if (exponent < 0) { + out[at++] = '0'; + out[at++] = '.'; + for (i = 0; i < -exponent - 1 && at < limit; i++) { + out[at++] = '0'; + } + for (i = 0; i < digitCount && at < limit; i++) { + out[at++] = digits[i]; + } + } else { + for (i = 0; i <= exponent && at < limit; i++) { + out[at++] = i < digitCount ? digits[i] : '0'; + } + out[at++] = '.'; + if (exponent + 1 >= digitCount) { + out[at++] = '0'; + } else { + for (i = exponent + 1; i < digitCount && at < limit; i++) { + out[at++] = digits[i]; } - s2[j++]=s[i]; } } - i=0; - while (j-->0 && i < 31){ - s[i++]=s2[j]; + out[at] = 0; +} + +JAVA_OBJECT java_lang_Double_toStringImpl___double_boolean_R_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE d, JAVA_BOOLEAN b) { + // Double.toString handles these before delegating, but the method is reachable on + // its own and the parser below has no meaning for them. + if (d != d) { + return newStringFromCString(threadStateData, "NaN"); } - s[i]='\0'; - if (strcmp(s, "NAN") == 0) { - s[1] = 'a'; + if (d > 1.7976931348623157E308) { + return newStringFromCString(threadStateData, "Infinity"); } - return newStringFromCString(threadStateData, s); + if (d < -1.7976931348623157E308) { + return newStringFromCString(threadStateData, "-Infinity"); + } + char raw[48]; + char out[512]; + cn1ShortestDouble(raw, (int)sizeof(raw), d); + cn1JavaFloatingText(out, (int)sizeof(out), raw, b); + return newStringFromCString(threadStateData, out); } JAVA_OBJECT java_lang_Float_toStringImpl___float_boolean_R_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_FLOAT d, JAVA_BOOLEAN b) { - char s[32]; - if ( !b ){ - snprintf(s, 32, "%f", d); - } else { - snprintf(s, 32, "%1.20E", d); - } - // We need to match the format of Java spec. That includes: - // No "+" for positive exponent. - // No leading zeroes in positive exponents. - // No trailing zeroes in decimal portion. - int j=0; - // Start the reversal at the actual formatted length, NOT the full 32-byte - // buffer: the bytes past the snprintf'd string are uninitialized stack, and - // walking them feeds garbage into the loop below -- each iteration can do up to - // two `s2[j++]` writes, so a non-'0' tail pushes `j` past the end of s2[32] and - // smashes the stack (a top-of-loop `j < 32` guard cannot stop a 2-wide write). - // glibc/musl don't zero this region, so on the Linux clean target this - // overflowed reliably (formatting a derived font size). Processing only strlen(s) - // is both safe and what the algorithm always intended. - int i = (int) strlen(s); - char s2[32]; - BOOL inside=NO; - while (i-->0 && j < 30){ - if (inside){ - if (s[i]=='.'){ - s2[j++]='0'; - } - if (s[i]!='0'){ - inside=NO; - s2[j++]=s[i]; - } - - } else { - if (s[i]=='E'){ - inside=YES; - } - if (s[i]=='+'){ - // If a positive exponent, we don't need leading zeroes in - // the exponent - while (s2[--j]=='0'){ - - } - j++; - continue; - } - s2[j++]=s[i]; - } + if (d != d) { + return newStringFromCString(threadStateData, "NaN"); } - i=0; - while (j-->0 && i < 31){ - s[i++]=s2[j]; + if (d > 3.4028234663852886E38f) { + return newStringFromCString(threadStateData, "Infinity"); } - s[i]='\0'; - if (strcmp(s, "NAN") == 0) { - s[1] = 'a'; + if (d < -3.4028234663852886E38f) { + return newStringFromCString(threadStateData, "-Infinity"); } - return newStringFromCString(threadStateData, s); + char raw[48]; + char out[512]; + cn1ShortestFloat(raw, (int)sizeof(raw), d); + cn1JavaFloatingText(out, (int)sizeof(out), raw, b); + return newStringFromCString(threadStateData, out); } @@ -1332,18 +1370,14 @@ JAVA_DOUBLE java_lang_Math_sin___double_R_double(CODENAME_ONE_THREAD_STATE, JAVA return sin(a); } +// "a < 0" is false for negative zero, so the old form returned -0.0 where the JDK +// specifies positive zero. fabs clears the sign bit unconditionally. JAVA_DOUBLE java_lang_Math_abs___double_R_double(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE a) { - if(a < 0) { - return a * -1; - } - return a; + return fabs(a); } JAVA_FLOAT java_lang_Math_abs___float_R_float(CODENAME_ONE_THREAD_STATE, JAVA_FLOAT a) { - if(a < 0) { - return a * -1; - } - return a; + return fabsf(a); } JAVA_INT java_lang_Math_abs___int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_INT a) { @@ -3181,63 +3215,3 @@ JAVA_OBJECT java_lang_String_toLowerCase___R_java_lang_String(CODENAME_ONE_THREA #endif } -JAVA_OBJECT java_lang_String_format___java_lang_String_java_lang_Object_1ARRAY_R_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT format, JAVA_OBJECT args) { -#if defined(__APPLE__) && defined(__OBJC__) - enteringNativeAllocations(); - JAVA_ARRAY argsArray = (JAVA_ARRAY)args; - JAVA_ARRAY_OBJECT* objs = (JAVA_ARRAY_OBJECT*)argsArray->data; - int len = argsArray->length; - NSMutableArray* argsList1 = [NSMutableArray arrayWithCapacity:len]; - for (int i=0; idata; - int valuesLength = argsArray == JAVA_NULL ? 0 : argsArray->length; - int argIndex = 0; - - for (int i = 0; i < formatLength; i++) { - JAVA_CHAR ch = java_lang_String_charAt___int_R_char(threadStateData, format, i); - if (ch != '%' || i == formatLength - 1) { - java_lang_StringBuilder_append___char_R_java_lang_StringBuilder(threadStateData, builder, ch); - continue; - } - - JAVA_CHAR token = java_lang_String_charAt___int_R_char(threadStateData, format, i + 1); - i++; - if (token == '%') { - java_lang_StringBuilder_append___char_R_java_lang_StringBuilder(threadStateData, builder, '%'); - continue; - } - - if (argIndex >= valuesLength || values == JAVA_NULL) { - java_lang_StringBuilder_append___char_R_java_lang_StringBuilder(threadStateData, builder, '%'); - java_lang_StringBuilder_append___char_R_java_lang_StringBuilder(threadStateData, builder, token); - continue; - } - - JAVA_OBJECT value = values[argIndex++]; - JAVA_OBJECT valueText = java_lang_String_valueOf___java_lang_Object_R_java_lang_String(threadStateData, value); - if (token == 'c') { - int valueTextLength = java_lang_String_length___R_int(threadStateData, valueText); - if (valueTextLength > 0) { - JAVA_CHAR out = java_lang_String_charAt___int_R_char(threadStateData, valueText, 0); - java_lang_StringBuilder_append___char_R_java_lang_StringBuilder(threadStateData, builder, out); - } - } else { - java_lang_StringBuilder_append___java_lang_String_R_java_lang_StringBuilder(threadStateData, builder, valueText); - } - } - return java_lang_StringBuilder_toString___R_java_lang_String(threadStateData, builder); -#endif -} diff --git a/vm/JavaAPI/src/java/lang/String.java b/vm/JavaAPI/src/java/lang/String.java index 81b4fe8b8a1..2acccabe7af 100644 --- a/vm/JavaAPI/src/java/lang/String.java +++ b/vm/JavaAPI/src/java/lang/String.java @@ -1077,7 +1077,15 @@ public CharSequence subSequence(int start, int end) { // strings. - public native static String format(String format, Object... args); + /** + * Returns a formatted string using the specified format string and arguments. + * Supports the {@code s b h c d o x e f g n %} conversions (and their uppercase + * variants) with the {@code - + ' ' 0 , ( #} flags, width, precision, and the + * {@code %n$} / {@code %<} argument selectors. + */ + public static String format(String format, Object... args) { + return StringFormatter.format(format, args); + } public boolean contains(CharSequence seq) { return seq == null ? false : indexOf(seq.toString()) != -1; diff --git a/vm/JavaAPI/src/java/lang/StringFormatter.java b/vm/JavaAPI/src/java/lang/StringFormatter.java new file mode 100644 index 00000000000..e22c34bdbb1 --- /dev/null +++ b/vm/JavaAPI/src/java/lang/StringFormatter.java @@ -0,0 +1,750 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.lang; + +import java.util.DuplicateFormatFlagsException; +import java.util.FormatFlagsConversionMismatchException; +import java.util.IllegalFormatCodePointException; +import java.util.IllegalFormatConversionException; +import java.util.IllegalFormatFlagsException; +import java.util.IllegalFormatPrecisionException; +import java.util.IllegalFormatWidthException; +import java.util.MissingFormatArgumentException; +import java.util.MissingFormatWidthException; +import java.util.UnknownFormatConversionException; + +/** + * The implementation behind {@link java.lang.String#format(String, Object[])}. + * + *

This used to be a native method with two wildly divergent implementations: an + * Objective-C one that fed {@code -[NSString initWithFormat:arguments:]} a hand-rolled + * argument vector (and then discarded the result and returned {@code [NSString init]}, + * which aborts the process), and a C fallback that ignored width and precision entirely. + * Formatting is pure string manipulation, so there is no reason for it to be native at + * all -- a single Java implementation behaves identically on every ParparVM target and + * can be diffed against the JDK in a normal test.

+ * + *

Floating point conversions deliberately round the shortest round-tripping + * decimal representation of the value (i.e. what {@link Double#toString(double)} + * produces) using HALF_UP, which is what {@code java.util.Formatter} does. That is not + * the same as rounding the exact binary value the way C's {@code printf} does: Java + * renders {@code String.format("%.1f", 0.15)} as {@code 0.2} where {@code printf} says + * {@code 0.1}.

+ * + *

Supported conversions are {@code s S b B h H c C d o x X e E f g G n %}, with the + * {@code - + ' ' 0 , ( #} flags, width, precision, and the {@code %n$} and {@code %<} + * argument selectors. Rendering is locale independent: grouping uses {@code ','} in + * groups of three, the decimal separator is always {@code '.'}, and {@code %n} emits + * {@code '\n'} (the line separator on every platform ParparVM targets).

+ * + *

Two JDK conversions are not implemented: {@code %a} (hexadecimal floating + * point) and {@code %t} (date and time). Both raise + * {@link java.util.UnknownFormatConversionException} rather than producing something + * wrong. Use {@code com.codename1.l10n.SimpleDateFormat} for dates.

+ */ +final class StringFormatter { + private static final int FLAG_MINUS = 1; + private static final int FLAG_PLUS = 2; + private static final int FLAG_SPACE = 4; + private static final int FLAG_ZERO = 8; + private static final int FLAG_COMMA = 16; + private static final int FLAG_PAREN = 32; + private static final int FLAG_HASH = 64; + private static final int FLAG_PREVIOUS = 128; + + private static final char[] DIGITS = { + '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + }; + + private StringFormatter() { + } + + /** + * A decimal significand plus the position of the decimal point, such that the value + * being formatted is {@code 0.digits * 10^point}. {@code digits} never has leading or + * trailing zeros, except for the value zero which is the single digit {@code "0"}. + */ + private static final class Decimal { + private String digits; + private int point; + } + + static String format(String format, Object[] args) { + if (format == null) { + throw new NullPointerException(); + } + int len = format.length(); + StringBuilder out = new StringBuilder(len + 16); + int pos = 0; + int nextArg = 0; + int lastArg = -1; + while (pos < len) { + char c = format.charAt(pos); + if (c != '%') { + out.append(c); + pos++; + continue; + } + int specStart = pos; + pos++; + if (pos >= len) { + throw new UnknownFormatConversionException("%"); + } + + // An explicit argument index is a run of digits terminated by '$'. If the '$' + // is not there the same digits are a width, so rewind. + int argIndex = -1; + int digitsEnd = pos; + while (digitsEnd < len && isDigit(format.charAt(digitsEnd))) { + digitsEnd++; + } + if (digitsEnd > pos && digitsEnd < len && format.charAt(digitsEnd) == '$') { + argIndex = parseNumber(format, pos, digitsEnd); + pos = digitsEnd + 1; + } + + int flags = 0; + boolean previous = false; + while (pos < len) { + char f = format.charAt(pos); + int flag; + if (f == '-') { + flag = FLAG_MINUS; + } else if (f == '+') { + flag = FLAG_PLUS; + } else if (f == ' ') { + flag = FLAG_SPACE; + } else if (f == '0') { + flag = FLAG_ZERO; + } else if (f == ',') { + flag = FLAG_COMMA; + } else if (f == '(') { + flag = FLAG_PAREN; + } else if (f == '#') { + flag = FLAG_HASH; + } else if (f == '<') { + previous = true; + pos++; + continue; + } else { + break; + } + if ((flags & flag) != 0) { + throw new DuplicateFormatFlagsException(String.valueOf(f)); + } + flags |= flag; + pos++; + } + + int width = -1; + int widthStart = pos; + while (pos < len && isDigit(format.charAt(pos))) { + pos++; + } + if (pos > widthStart) { + width = parseNumber(format, widthStart, pos); + } + + int precision = -1; + if (pos < len && format.charAt(pos) == '.') { + pos++; + int precisionStart = pos; + while (pos < len && isDigit(format.charAt(pos))) { + pos++; + } + precision = pos > precisionStart ? parseNumber(format, precisionStart, pos) : 0; + } + + if (pos >= len) { + throw new UnknownFormatConversionException(format.substring(specStart + 1)); + } + char conversion = format.charAt(pos); + pos++; + + if (conversion == '%' || conversion == 'n') { + checkTextFlags(conversion, previous ? flags | FLAG_PREVIOUS : flags, width, precision); + out.append(conversion == '%' ? pad("%", width, flags) : "\n"); + continue; + } + + Object arg; + if (previous) { + if (lastArg < 0) { + throw new MissingFormatArgumentException(format.substring(specStart, pos)); + } + arg = args[lastArg]; + } else if (argIndex > 0) { + if (args == null || argIndex > args.length) { + throw new MissingFormatArgumentException(format.substring(specStart, pos)); + } + lastArg = argIndex - 1; + arg = args[lastArg]; + } else { + if (args == null || nextArg >= args.length) { + throw new MissingFormatArgumentException(format.substring(specStart, pos)); + } + lastArg = nextArg; + arg = args[nextArg]; + nextArg++; + } + out.append(convert(conversion, arg, flags, width, precision)); + } + return out.toString(); + } + + private static String convert(char conversion, Object arg, int flags, int width, int precision) { + boolean upper = conversion >= 'A' && conversion <= 'Z'; + if (upper && "SBHCXEG".indexOf(conversion) < 0) { + // 'D' and 'O' have no uppercase form in java.util.Formatter. + throw new UnknownFormatConversionException(String.valueOf(conversion)); + } + char lower = upper ? (char) (conversion + ('a' - 'A')) : conversion; + if ("sbhcdoxefg".indexOf(lower) < 0) { + // %a (hexadecimal float) and %t (date and time) land here: unimplemented + // rather than silently wrong. See the class javadoc. + throw new UnknownFormatConversionException(String.valueOf(conversion)); + } + checkFlags(conversion, lower, flags, width, precision); + switch (lower) { + case 's': + return text(arg == null ? "null" : arg.toString(), upper, flags, width, precision); + case 'b': { + String value; + if (arg == null) { + value = "false"; + } else if (arg instanceof Boolean) { + value = ((Boolean) arg).booleanValue() ? "true" : "false"; + } else { + value = "true"; + } + return text(value, upper, flags, width, precision); + } + case 'h': + return text(arg == null ? "null" : Integer.toHexString(arg.hashCode()), + upper, flags, width, precision); + case 'c': { + String value; + if (arg == null) { + value = "null"; + } else if (arg instanceof Character) { + value = String.valueOf(((Character) arg).charValue()); + } else if (arg instanceof Integer || arg instanceof Short || arg instanceof Byte) { + int codePoint = ((Number) arg).intValue(); + if (!Character.isValidCodePoint(codePoint)) { + throw new IllegalFormatCodePointException(codePoint); + } + value = new String(Character.toChars(codePoint)); + } else { + throw new IllegalFormatConversionException(conversion, arg.getClass()); + } + return text(value, upper, flags, width, -1); + } + case 'd': + return decimal(arg, conversion, upper, flags, width, precision); + case 'o': + case 'x': + return radix(arg, conversion, lower == 'o' ? 3 : 4, upper, flags, width, precision); + case 'e': + case 'f': + case 'g': + return floatingPoint(arg, conversion, lower, upper, flags, width, precision); + default: + throw new UnknownFormatConversionException(String.valueOf(conversion)); + } + } + + /** + * {@code %%} takes at most a left-justification flag with a width, {@code %n} takes + * nothing at all. + */ + private static void checkTextFlags(char conversion, int flags, int width, int precision) { + if (precision >= 0) { + throw new IllegalFormatPrecisionException(precision); + } + if (conversion == 'n') { + if (width >= 0) { + throw new IllegalFormatWidthException(width); + } + if (flags != 0) { + throw new IllegalFormatFlagsException(flagString(flags)); + } + return; + } + if ((flags & ~FLAG_MINUS) != 0) { + throw new IllegalFormatFlagsException(flagString(flags)); + } + if (width < 0 && (flags & FLAG_MINUS) != 0) { + throw new MissingFormatWidthException("%" + flagString(flags) + conversion); + } + } + + /** + * Rejects the flag combinations that {@code java.util.Formatter} rejects, so that a + * bogus format string fails the same way here as it does on the JVM. + */ + private static void checkFlags(char conversion, char lower, int flags, int width, int precision) { + if (lower == 's' || lower == 'b' || lower == 'h') { + // '#' on a boolean or hash code is reported ahead of the width check; on a + // string it is reported after the other flags. + if ((flags & FLAG_HASH) != 0 && lower != 's') { + throw new FormatFlagsConversionMismatchException("#", conversion); + } + failMissingWidth(conversion, flags, width, FLAG_MINUS); + failMismatch(conversion, flags, + FLAG_PLUS | FLAG_SPACE | FLAG_ZERO | FLAG_COMMA | FLAG_PAREN); + failMismatch(conversion, flags, FLAG_HASH); + return; + } + if (lower == 'c') { + if (precision >= 0) { + throw new IllegalFormatPrecisionException(precision); + } + failMissingWidth(conversion, flags, width, FLAG_MINUS); + failMismatch(conversion, flags, + FLAG_PLUS | FLAG_SPACE | FLAG_ZERO | FLAG_COMMA | FLAG_PAREN | FLAG_HASH); + return; + } + // Numeric conversions: zero padding is meaningful, so it needs a width too. + failMissingWidth(conversion, flags, width, FLAG_MINUS | FLAG_ZERO); + if ((flags & FLAG_PLUS) != 0 && (flags & FLAG_SPACE) != 0) { + throw new IllegalFormatFlagsException(flagString(flags)); + } + if ((flags & FLAG_MINUS) != 0 && (flags & FLAG_ZERO) != 0) { + throw new IllegalFormatFlagsException(flagString(flags)); + } + if (lower == 'd' || lower == 'o' || lower == 'x') { + if (precision >= 0) { + throw new IllegalFormatPrecisionException(precision); + } + failMismatch(conversion, flags, lower == 'd' ? FLAG_HASH : FLAG_COMMA); + } else if (lower == 'e') { + failMismatch(conversion, flags, FLAG_COMMA); + } else if (lower == 'g') { + failMismatch(conversion, flags, FLAG_HASH); + } + } + + private static void failMissingWidth(char conversion, int flags, int width, int needsWidth) { + if (width < 0 && (flags & needsWidth) != 0) { + throw new MissingFormatWidthException("%" + flagString(flags) + conversion); + } + } + + private static void failMismatch(char conversion, int flags, int illegal) { + int mismatch = flags & illegal; + if (mismatch != 0) { + throw new FormatFlagsConversionMismatchException(flagString(mismatch), conversion); + } + } + + private static String flagString(int flags) { + StringBuilder sb = new StringBuilder(); + if ((flags & FLAG_MINUS) != 0) { + sb.append('-'); + } + if ((flags & FLAG_HASH) != 0) { + sb.append('#'); + } + if ((flags & FLAG_PLUS) != 0) { + sb.append('+'); + } + if ((flags & FLAG_SPACE) != 0) { + sb.append(' '); + } + if ((flags & FLAG_ZERO) != 0) { + sb.append('0'); + } + if ((flags & FLAG_COMMA) != 0) { + sb.append(','); + } + if ((flags & FLAG_PAREN) != 0) { + sb.append('('); + } + if ((flags & FLAG_PREVIOUS) != 0) { + sb.append('<'); + } + return sb.toString(); + } + + private static String text(String value, boolean upper, int flags, int width, int precision) { + if (precision >= 0 && precision < value.length()) { + value = value.substring(0, precision); + } + if (upper) { + value = value.toUpperCase(); + } + return pad(value, width, flags); + } + + private static String decimal(Object arg, char conversion, boolean upper, int flags, + int width, int precision) { + if (arg == null) { + return text("null", upper, flags, width, precision); + } + long value = longValue(arg, conversion); + boolean negative = value < 0; + // Long.MIN_VALUE has no positive counterpart, so strip the sign textually. + String digits = negative ? Long.toString(value).substring(1) : Long.toString(value); + return padNumeric(signPrefix(negative, flags), digits, signSuffix(negative, flags), + flags, width); + } + + private static String radix(Object arg, char conversion, int shift, boolean upper, + int flags, int width, int precision) { + if (arg == null) { + return text("null", upper, flags, width, precision); + } + // The JVM defers these to print time, so a null argument outranks them. + int printTimeIllegal = flags & (FLAG_PAREN | FLAG_SPACE | FLAG_PLUS); + if (printTimeIllegal != 0) { + throw new FormatFlagsConversionMismatchException(flagString(printTimeIllegal), conversion); + } + long value; + if (arg instanceof Long) { + value = ((Long) arg).longValue(); + } else if (arg instanceof Integer) { + value = ((Integer) arg).intValue() & 0xffffffffL; + } else if (arg instanceof Short) { + value = ((Short) arg).shortValue() & 0xffffL; + } else if (arg instanceof Byte) { + value = ((Byte) arg).byteValue() & 0xffL; + } else { + throw new IllegalFormatConversionException(conversion, arg.getClass()); + } + String digits = unsigned(value, shift); + String prefix = ""; + if ((flags & FLAG_HASH) != 0) { + prefix = shift == 3 ? "0" : "0x"; + } + if (upper) { + digits = digits.toUpperCase(); + prefix = prefix.toUpperCase(); + } + // Grouping is not defined for these conversions. + return padNumeric(prefix, digits, "", flags & ~FLAG_COMMA, width); + } + + private static String floatingPoint(Object arg, char conversion, char lower, boolean upper, + int flags, int width, int precision) { + if (arg == null) { + return text("null", upper, flags, width, precision); + } + double value; + if (arg instanceof Double) { + value = ((Double) arg).doubleValue(); + } else if (arg instanceof Float) { + value = ((Float) arg).floatValue(); + } else { + throw new IllegalFormatConversionException(conversion, arg.getClass()); + } + if (Double.isNaN(value)) { + return pad(upper ? "NAN" : "NaN", width, flags & ~FLAG_ZERO); + } + boolean negative = value < 0 || (value == 0.0 && 1.0 / value < 0); + if (Double.isInfinite(value)) { + // Neither zero padding nor grouping applies to a non-numeric rendering. + String body = upper ? "INFINITY" : "Infinity"; + return padNumeric(signPrefix(negative, flags), body, signSuffix(negative, flags), + flags & ~(FLAG_ZERO | FLAG_COMMA), width); + } + + Decimal d = decompose(negative ? -value : value); + String body; + boolean scientificForm = lower == 'e'; + if (lower == 'f') { + int scale = precision < 0 ? 6 : precision; + roundTo(d, d.point + scale); + body = fixed(d, scale, (flags & FLAG_HASH) != 0); + } else if (lower == 'e') { + int scale = precision < 0 ? 6 : precision; + roundTo(d, scale + 1); + body = scientific(d, scale, value == 0.0, upper, (flags & FLAG_HASH) != 0); + } else { + int significant = precision < 0 ? 6 : (precision == 0 ? 1 : precision); + if (value == 0.0) { + body = fixed(d, significant - 1, false); + } else { + roundTo(d, significant); + if (d.point >= -3 && d.point <= significant) { + body = fixed(d, significant - d.point, false); + } else { + body = scientific(d, significant - 1, false, upper, false); + scientificForm = true; + } + } + } + if (upper) { + body = body.toUpperCase(); + } + // The exponent must never be group-separated: "%,g" of 1e-5 is "1e-05". + int effectiveFlags = scientificForm ? flags & ~FLAG_COMMA : flags; + return padNumeric(signPrefix(negative, flags), body, signSuffix(negative, flags), + effectiveFlags, width); + } + + /** + * Splits {@code Double.toString(abs)} into a significand and a decimal exponent. + */ + private static Decimal decompose(double abs) { + String s = Double.toString(abs); + String mantissa = s; + int exponent = 0; + int e = s.indexOf('E'); + if (e < 0) { + e = s.indexOf('e'); + } + if (e >= 0) { + mantissa = s.substring(0, e); + exponent = Integer.parseInt(s.substring(e + 1)); + } + int dot = mantissa.indexOf('.'); + String intPart = dot < 0 ? mantissa : mantissa.substring(0, dot); + String fracPart = dot < 0 ? "" : mantissa.substring(dot + 1); + String digits = intPart + fracPart; + int point = intPart.length() + exponent; + int start = 0; + while (start < digits.length() - 1 && digits.charAt(start) == '0') { + start++; + point--; + } + digits = digits.substring(start); + int end = digits.length(); + while (end > 1 && digits.charAt(end - 1) == '0') { + end--; + } + digits = digits.substring(0, end); + Decimal d = new Decimal(); + if (digits.equals("0")) { + d.digits = "0"; + d.point = 0; + } else { + d.digits = digits; + d.point = point; + } + return d; + } + + /** + * Rounds the significand to {@code keep} significant digits, HALF_UP. A {@code keep} + * at or below zero still rounds: it decides whether the value survives at all. + */ + private static void roundTo(Decimal d, int keep) { + if (keep < 0) { + keep = 0; + } + if (keep >= d.digits.length()) { + return; + } + // The extra leading slot absorbs a carry out of the most significant digit. + char[] buf = new char[keep + 1]; + buf[0] = '0'; + for (int i = 0; i < keep; i++) { + buf[i + 1] = d.digits.charAt(i); + } + if (d.digits.charAt(keep) >= '5') { + int i = keep; + while (i >= 0) { + if (buf[i] == '9') { + buf[i] = '0'; + i--; + } else { + buf[i] = (char) (buf[i] + 1); + break; + } + } + } + if (buf[0] != '0') { + d.point++; + d.digits = new String(buf); + } else { + d.digits = keep == 0 ? "0" : new String(buf, 1, keep); + } + } + + private static String fixed(Decimal d, int scale, boolean alternate) { + StringBuilder sb = new StringBuilder(); + if (d.point <= 0) { + sb.append('0'); + } else { + for (int i = 0; i < d.point; i++) { + sb.append(i < d.digits.length() ? d.digits.charAt(i) : '0'); + } + } + if (scale > 0 || alternate) { + sb.append('.'); + } + for (int i = 0; i < scale; i++) { + int idx = d.point + i; + sb.append(idx >= 0 && idx < d.digits.length() ? d.digits.charAt(idx) : '0'); + } + return sb.toString(); + } + + private static String scientific(Decimal d, int scale, boolean zero, boolean upper, boolean alternate) { + StringBuilder sb = new StringBuilder(); + sb.append(d.digits.charAt(0)); + if (scale > 0 || alternate) { + sb.append('.'); + } + for (int i = 1; i <= scale; i++) { + sb.append(i < d.digits.length() ? d.digits.charAt(i) : '0'); + } + sb.append(upper ? 'E' : 'e'); + int exponent = zero ? 0 : d.point - 1; + sb.append(exponent < 0 ? '-' : '+'); + int magnitude = exponent < 0 ? -exponent : exponent; + String exponentDigits = Integer.toString(magnitude); + if (exponentDigits.length() < 2) { + sb.append('0'); + } + sb.append(exponentDigits); + return sb.toString(); + } + + private static long longValue(Object arg, char conversion) { + if (arg instanceof Long) { + return ((Long) arg).longValue(); + } + if (arg instanceof Integer) { + return ((Integer) arg).intValue(); + } + if (arg instanceof Short) { + return ((Short) arg).shortValue(); + } + if (arg instanceof Byte) { + return ((Byte) arg).byteValue(); + } + throw new IllegalFormatConversionException(conversion, arg.getClass()); + } + + private static String unsigned(long value, int shift) { + if (value == 0) { + return "0"; + } + char[] buf = new char[64]; + int pos = buf.length; + int mask = (1 << shift) - 1; + long v = value; + while (v != 0) { + pos--; + buf[pos] = DIGITS[(int) (v & mask)]; + v >>>= shift; + } + return new String(buf, pos, buf.length - pos); + } + + private static String signPrefix(boolean negative, int flags) { + if (negative) { + return (flags & FLAG_PAREN) != 0 ? "(" : "-"; + } + if ((flags & FLAG_PLUS) != 0) { + return "+"; + } + if ((flags & FLAG_SPACE) != 0) { + return " "; + } + return ""; + } + + private static String signSuffix(boolean negative, int flags) { + return negative && (flags & FLAG_PAREN) != 0 ? ")" : ""; + } + + private static String padNumeric(String prefix, String digits, String suffix, int flags, int width) { + String body = (flags & FLAG_COMMA) != 0 ? group(digits) : digits; + int fixedLength = prefix.length() + suffix.length(); + if (width > fixedLength + body.length() + && (flags & FLAG_ZERO) != 0 && (flags & FLAG_MINUS) == 0) { + // The padding zeros are not themselves grouped: "%,012d" of 1234 is + // "00000001,234", not "0,000,001,234". + StringBuilder padded = new StringBuilder(); + for (int i = fixedLength + body.length(); i < width; i++) { + padded.append('0'); + } + padded.append(body); + body = padded.toString(); + } + return pad(prefix + body + suffix, width, flags); + } + + /** + * Inserts grouping separators into the integer part of {@code value}, which may carry + * a fractional tail that must be left alone. + */ + private static String group(String value) { + int dot = value.indexOf('.'); + String head = dot < 0 ? value : value.substring(0, dot); + String tail = dot < 0 ? "" : value.substring(dot); + if (head.length() <= 3) { + return value; + } + StringBuilder sb = new StringBuilder(); + int first = head.length() % 3; + if (first == 0) { + first = 3; + } + sb.append(head.substring(0, first)); + for (int i = first; i < head.length(); i += 3) { + sb.append(','); + sb.append(head.substring(i, i + 3)); + } + sb.append(tail); + return sb.toString(); + } + + private static String pad(String value, int width, int flags) { + if (width <= value.length()) { + return value; + } + StringBuilder sb = new StringBuilder(width); + if ((flags & FLAG_MINUS) != 0) { + sb.append(value); + while (sb.length() < width) { + sb.append(' '); + } + } else { + for (int i = value.length(); i < width; i++) { + sb.append(' '); + } + sb.append(value); + } + return sb.toString(); + } + + private static boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } + + private static int parseNumber(String value, int start, int end) { + int result = 0; + for (int i = start; i < end; i++) { + result = result * 10 + (value.charAt(i) - '0'); + if (result > 1000000) { + // Guard against a width so large it would only be a denial of service. + throw new IllegalArgumentException(value.substring(start, end)); + } + } + return result; + } +} diff --git a/vm/JavaAPI/src/java/util/DuplicateFormatFlagsException.java b/vm/JavaAPI/src/java/util/DuplicateFormatFlagsException.java new file mode 100644 index 00000000000..9e2139a9f77 --- /dev/null +++ b/vm/JavaAPI/src/java/util/DuplicateFormatFlagsException.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util; + +/** + * Thrown when a format flag is supplied more than once. + */ +public class DuplicateFormatFlagsException extends IllegalFormatException { + private final String flags; + + public DuplicateFormatFlagsException(String flags) { + if (flags == null) { + throw new NullPointerException(); + } + this.flags = flags; + } + + public String getFlags() { + return flags; + } + + @Override + public String getMessage() { + return "Flags = '" + flags + "'"; + } +} diff --git a/vm/JavaAPI/src/java/util/FormatFlagsConversionMismatchException.java b/vm/JavaAPI/src/java/util/FormatFlagsConversionMismatchException.java new file mode 100644 index 00000000000..78069a1d10e --- /dev/null +++ b/vm/JavaAPI/src/java/util/FormatFlagsConversionMismatchException.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util; + +/** + * Thrown when a format flag is not supported by the conversion it is applied to. + */ +public class FormatFlagsConversionMismatchException extends IllegalFormatException { + private final String flags; + private final char conversion; + + public FormatFlagsConversionMismatchException(String flags, char conversion) { + if (flags == null) { + throw new NullPointerException(); + } + this.flags = flags; + this.conversion = conversion; + } + + public String getFlags() { + return flags; + } + + public char getConversion() { + return conversion; + } + + @Override + public String getMessage() { + return "Conversion = " + conversion + ", Flags = " + flags; + } +} diff --git a/vm/JavaAPI/src/java/util/IllegalFormatCodePointException.java b/vm/JavaAPI/src/java/util/IllegalFormatCodePointException.java new file mode 100644 index 00000000000..47bf4587fba --- /dev/null +++ b/vm/JavaAPI/src/java/util/IllegalFormatCodePointException.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util; + +/** + * Thrown when a character conversion is given an argument that is not a valid + * Unicode code point. + */ +public class IllegalFormatCodePointException extends IllegalFormatException { + private final int codePoint; + + public IllegalFormatCodePointException(int codePoint) { + this.codePoint = codePoint; + } + + public int getCodePoint() { + return codePoint; + } + + @Override + public String getMessage() { + return "Code point = 0x" + Integer.toHexString(codePoint); + } +} diff --git a/vm/JavaAPI/src/java/util/IllegalFormatConversionException.java b/vm/JavaAPI/src/java/util/IllegalFormatConversionException.java new file mode 100644 index 00000000000..7a3e821b3d9 --- /dev/null +++ b/vm/JavaAPI/src/java/util/IllegalFormatConversionException.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util; + +/** + * Thrown when the argument supplied for a conversion has an incompatible type. + */ +public class IllegalFormatConversionException extends IllegalFormatException { + private final char conversion; + private final Class argumentClass; + + public IllegalFormatConversionException(char conversion, Class argumentClass) { + if (argumentClass == null) { + throw new NullPointerException(); + } + this.conversion = conversion; + this.argumentClass = argumentClass; + } + + public char getConversion() { + return conversion; + } + + public Class getArgumentClass() { + return argumentClass; + } + + @Override + public String getMessage() { + return String.valueOf(conversion) + " != " + argumentClass.getName(); + } +} diff --git a/vm/JavaAPI/src/java/util/IllegalFormatException.java b/vm/JavaAPI/src/java/util/IllegalFormatException.java new file mode 100644 index 00000000000..259c59f72c4 --- /dev/null +++ b/vm/JavaAPI/src/java/util/IllegalFormatException.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util; + +/** + * Superclass of the unchecked exceptions thrown when a format string or its + * arguments are rejected by {@link java.lang.String#format(String, Object[])}. + */ +public class IllegalFormatException extends IllegalArgumentException { + IllegalFormatException() { + } + + IllegalFormatException(String message) { + super(message); + } +} diff --git a/vm/JavaAPI/src/java/util/IllegalFormatFlagsException.java b/vm/JavaAPI/src/java/util/IllegalFormatFlagsException.java new file mode 100644 index 00000000000..ef7d5f49b09 --- /dev/null +++ b/vm/JavaAPI/src/java/util/IllegalFormatFlagsException.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util; + +/** + * Thrown when a combination of format flags is illegal. + */ +public class IllegalFormatFlagsException extends IllegalFormatException { + private final String flags; + + public IllegalFormatFlagsException(String flags) { + if (flags == null) { + throw new NullPointerException(); + } + this.flags = flags; + } + + public String getFlags() { + return flags; + } + + @Override + public String getMessage() { + return "Flags = '" + flags + "'"; + } +} diff --git a/vm/JavaAPI/src/java/util/IllegalFormatPrecisionException.java b/vm/JavaAPI/src/java/util/IllegalFormatPrecisionException.java new file mode 100644 index 00000000000..010aa86555f --- /dev/null +++ b/vm/JavaAPI/src/java/util/IllegalFormatPrecisionException.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util; + +/** + * Thrown when a precision is negative, or is supplied for a conversion that does + * not accept one. + */ +public class IllegalFormatPrecisionException extends IllegalFormatException { + private final int precision; + + public IllegalFormatPrecisionException(int precision) { + this.precision = precision; + } + + public int getPrecision() { + return precision; + } + + @Override + public String getMessage() { + return Integer.toString(precision); + } +} diff --git a/vm/JavaAPI/src/java/util/IllegalFormatWidthException.java b/vm/JavaAPI/src/java/util/IllegalFormatWidthException.java new file mode 100644 index 00000000000..b6f8da61a35 --- /dev/null +++ b/vm/JavaAPI/src/java/util/IllegalFormatWidthException.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util; + +/** + * Thrown when a width is negative, or is supplied for a conversion that does not + * accept one. + */ +public class IllegalFormatWidthException extends IllegalFormatException { + private final int width; + + public IllegalFormatWidthException(int width) { + this.width = width; + } + + public int getWidth() { + return width; + } + + @Override + public String getMessage() { + return Integer.toString(width); + } +} diff --git a/vm/JavaAPI/src/java/util/MissingFormatArgumentException.java b/vm/JavaAPI/src/java/util/MissingFormatArgumentException.java new file mode 100644 index 00000000000..e51503a4de9 --- /dev/null +++ b/vm/JavaAPI/src/java/util/MissingFormatArgumentException.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util; + +/** + * Thrown when a format specifier has no corresponding argument. + */ +public class MissingFormatArgumentException extends IllegalFormatException { + private final String formatSpecifier; + + public MissingFormatArgumentException(String formatSpecifier) { + if (formatSpecifier == null) { + throw new NullPointerException(); + } + this.formatSpecifier = formatSpecifier; + } + + public String getFormatSpecifier() { + return formatSpecifier; + } + + @Override + public String getMessage() { + return "Format specifier '" + formatSpecifier + "'"; + } +} diff --git a/vm/JavaAPI/src/java/util/MissingFormatWidthException.java b/vm/JavaAPI/src/java/util/MissingFormatWidthException.java new file mode 100644 index 00000000000..9fe1a3d8107 --- /dev/null +++ b/vm/JavaAPI/src/java/util/MissingFormatWidthException.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util; + +/** + * Thrown when a format specifier uses a flag that requires a width but supplies none. + */ +public class MissingFormatWidthException extends IllegalFormatException { + private final String formatSpecifier; + + public MissingFormatWidthException(String formatSpecifier) { + if (formatSpecifier == null) { + throw new NullPointerException(); + } + this.formatSpecifier = formatSpecifier; + } + + public String getFormatSpecifier() { + return formatSpecifier; + } + + @Override + public String getMessage() { + return formatSpecifier; + } +} diff --git a/vm/JavaAPI/src/java/util/UnknownFormatConversionException.java b/vm/JavaAPI/src/java/util/UnknownFormatConversionException.java new file mode 100644 index 00000000000..d5de7f73a22 --- /dev/null +++ b/vm/JavaAPI/src/java/util/UnknownFormatConversionException.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util; + +/** + * Thrown when a format string contains a conversion that is not supported. + */ +public class UnknownFormatConversionException extends IllegalFormatException { + private final String conversion; + + public UnknownFormatConversionException(String conversion) { + if (conversion == null) { + throw new NullPointerException(); + } + this.conversion = conversion; + } + + public String getConversion() { + return conversion; + } + + @Override + public String getMessage() { + return "Conversion = '" + conversion + "'"; + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/GetClassIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/GetClassIntegrationTest.java new file mode 100644 index 00000000000..e57030905e5 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/GetClassIntegrationTest.java @@ -0,0 +1,182 @@ +package com.codename1.tools.translator; + +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Pins {@code Object.getClass()} and the behavior of Class objects as map keys + * (issue #5482, where the reporter observed {@code getClass()} apparently returning + * null while a dictionary load was allocating heavily). + * + *

Class objects are not ordinary heap objects in ParparVM: they are static + * {@code struct clazz} instances, and {@code getClassImpl} hands the struct back + * directly. That makes their identity, hash, and virtual {@code toString} dispatch worth + * regression coverage independently of whatever else the reporter's program was doing.

+ */ +class GetClassIntegrationTest { + + @Test + void classObjectsBehaveLikeTheJvmUnderAllocationChurn() throws Exception { + Parser.cleanup(); + + Path sourceDir = Files.createTempDirectory("get-class-sources"); + Path classesDir = Files.createTempDirectory("get-class-classes"); + Path javaApiDir = Files.createTempDirectory("get-class-java-api"); + + Path source = sourceDir.resolve("GetClassApp.java"); + Files.write(source, loadAppSource().getBytes(StandardCharsets.UTF_8)); + + CompilerHelper.CompilerConfig config = selectCompiler(); + if (config == null) { + fail("No compatible compiler available for the getClass integration test"); + } + assertTrue(CompilerHelper.isJavaApiCompatible(config), + "JDK " + config.jdkVersion + " must target matching bytecode level for JavaAPI"); + + CompilerHelper.compileJavaAPI(javaApiDir, config); + + List compileArgs = new ArrayList<>(); + compileArgs.add("-source"); + compileArgs.add(config.targetVersion); + compileArgs.add("-target"); + compileArgs.add(config.targetVersion); + if (CompilerHelper.useClasspath(config)) { + compileArgs.add("-classpath"); + compileArgs.add(javaApiDir.toString()); + } else { + compileArgs.add("-bootclasspath"); + compileArgs.add(javaApiDir.toString()); + compileArgs.add("-Xlint:-options"); + } + compileArgs.add("-d"); + compileArgs.add(classesDir.toString()); + compileArgs.add(source.toString()); + + assertEquals(0, CompilerHelper.compile(config.jdkHome, compileArgs), + "GetClassApp should compile against the JavaAPI"); + + Map expected = parseCases(runJavaMain(config, classesDir, javaApiDir)); + assertFalse(expected.isEmpty(), "JVM run should emit cases"); + + CompilerHelper.copyDirectory(javaApiDir, classesDir); + + Path outputDir = Files.createTempDirectory("get-class-output"); + CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, "GetClassApp"); + + Path distDir = outputDir.resolve("dist"); + Path cmakeLists = distDir.resolve("CMakeLists.txt"); + assertTrue(Files.exists(cmakeLists), "Translator should emit a CMake project"); + CleanTargetIntegrationTest.replaceLibraryWithExecutableTarget(cmakeLists, "GetClassApp-src"); + + Path buildDir = distDir.resolve("build"); + Files.createDirectories(buildDir); + CleanTargetIntegrationTest.runCommand(Arrays.asList( + "cmake", + "-S", distDir.toString(), + "-B", buildDir.toString(), + "-DCMAKE_C_COMPILER=clang", + "-DCMAKE_OBJC_COMPILER=clang" + ), distDir); + CleanTargetIntegrationTest.runCommand(Arrays.asList("cmake", "--build", buildDir.toString()), distDir); + + Path executable = buildDir.resolve("GetClassApp"); + String parparOutput = CleanTargetIntegrationTest.runCommand( + Arrays.asList(executable.toString()), buildDir); + assertTrue(parparOutput.contains("DONE"), + "ParparVM run should complete. Output: " + parparOutput); + + Map actual = parseCases(parparOutput); + assertEquals(expected.keySet(), actual.keySet(), "ParparVM should emit the same cases"); + + List differences = new ArrayList<>(); + for (Map.Entry entry : expected.entrySet()) { + if (!entry.getValue().equals(actual.get(entry.getKey()))) { + differences.add(entry.getKey() + + "\n jvm : " + entry.getValue() + + "\n parparvm: " + actual.get(entry.getKey())); + } + } + assertTrue(differences.isEmpty(), + "Class object behavior diverged from the JVM:\n" + String.join("\n", differences)); + + // Stated explicitly so a regression names the reported symptom rather than a + // generic diff. + assertEquals("true", actual.get("notNull"), "getClass() must never return null"); + assertEquals("0", actual.get("churnFailures"), + "getClass() must stay correct while the heap churns"); + } + + private Map parseCases(String output) { + Map cases = new LinkedHashMap<>(); + for (String line : output.split("\\R")) { + if (!line.startsWith("CASE|")) { + continue; + } + String body = line.substring("CASE|".length()); + int separator = body.indexOf('|'); + assertTrue(separator > 0, "Malformed case line: " + line); + cases.put(body.substring(0, separator), body.substring(separator + 1)); + } + return cases; + } + + private String loadAppSource() throws Exception { + java.io.InputStream in = GetClassIntegrationTest.class + .getResourceAsStream("/com/codename1/tools/translator/GetClassApp.java"); + assertNotNull(in, "GetClassApp.java test resource should exist"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")) + "\n"; + } + } + + private String runJavaMain(CompilerHelper.CompilerConfig config, Path classesDir, Path javaApiDir) + throws Exception { + String javaExe = config.jdkHome.resolve("bin").resolve(CompilerHelper.executableName("java")).toString(); + ProcessBuilder pb = new ProcessBuilder( + javaExe, + "-cp", + classesDir + System.getProperty("path.separator") + javaApiDir, + "GetClassApp" + ); + pb.redirectErrorStream(true); + + Process process = pb.start(); + String output; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + output = reader.lines().collect(Collectors.joining("\n")); + } + assertEquals(0, process.waitFor(), "JVM run should exit cleanly. Output: " + output); + return output; + } + + private CompilerHelper.CompilerConfig selectCompiler() { + String[] preferredTargets = {"11", "17", "21", "25", "1.8"}; + for (String target : preferredTargets) { + List configs = CompilerHelper.getAvailableCompilers(target); + for (CompilerHelper.CompilerConfig config : configs) { + if (CompilerHelper.isJavaApiCompatible(config)) { + return config; + } + } + } + return null; + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/StringFormatIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/StringFormatIntegrationTest.java new file mode 100644 index 00000000000..c9f0d0a639b --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/StringFormatIntegrationTest.java @@ -0,0 +1,202 @@ +package com.codename1.tools.translator; + +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Differential test for {@code java.lang.String.format} (issue #5482). + * + *

{@code String.format} used to be a native method. Its Objective-C branch built an + * argument vector by hand, threw away the string it formatted, and returned + * {@code fromNSString([NSString init])} -- sending {@code init} to the {@code NSString} + * class object, which aborts the process with + * {@code +[NSString init]: cannot init a class object}. Because that branch is behind + * {@code #if defined(__APPLE__) && defined(__OBJC__)} and this suite runs on Linux in CI, + * nothing ever executed it. The C fallback that CI did run silently ignored width and + * precision, so {@code "%.3f"} rendered every digit of the double.

+ * + *

Formatting is now plain Java shared by every target, so this one test covers the + * iOS behavior too. It runs the same program on the JVM and under ParparVM and requires + * the two renderings to be identical, case by case -- which pins the output against the + * real {@code java.util.Formatter}, not against a hand-written expectation.

+ */ +class StringFormatIntegrationTest { + + @Test + void formatOutputMatchesTheJdkCaseByCase() throws Exception { + Parser.cleanup(); + + Path sourceDir = Files.createTempDirectory("string-format-sources"); + Path classesDir = Files.createTempDirectory("string-format-classes"); + Path javaApiDir = Files.createTempDirectory("string-format-java-api"); + + Path source = sourceDir.resolve("StringFormatApp.java"); + Files.write(source, loadAppSource().getBytes(StandardCharsets.UTF_8)); + + CompilerHelper.CompilerConfig config = selectCompiler(); + if (config == null) { + fail("No compatible compiler available for the String.format integration test"); + } + assertTrue(CompilerHelper.isJavaApiCompatible(config), + "JDK " + config.jdkVersion + " must target matching bytecode level for JavaAPI"); + + CompilerHelper.compileJavaAPI(javaApiDir, config); + + List compileArgs = new ArrayList<>(); + compileArgs.add("-source"); + compileArgs.add(config.targetVersion); + compileArgs.add("-target"); + compileArgs.add(config.targetVersion); + if (CompilerHelper.useClasspath(config)) { + compileArgs.add("-classpath"); + compileArgs.add(javaApiDir.toString()); + } else { + compileArgs.add("-bootclasspath"); + compileArgs.add(javaApiDir.toString()); + compileArgs.add("-Xlint:-options"); + } + compileArgs.add("-d"); + compileArgs.add(classesDir.toString()); + compileArgs.add(source.toString()); + + assertEquals(0, CompilerHelper.compile(config.jdkHome, compileArgs), + "StringFormatApp should compile against the JavaAPI"); + + // The JVM run resolves java.lang.String from the boot class path, so this side is + // the real java.util.Formatter no matter what is on the classpath. + String javaOutput = runJavaMain(config, classesDir, javaApiDir); + Map expected = parseCases(javaOutput, "CASE|"); + assertFalse(expected.isEmpty(), "JVM run should emit cases. Output: " + javaOutput); + + CompilerHelper.copyDirectory(javaApiDir, classesDir); + + Path outputDir = Files.createTempDirectory("string-format-output"); + CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, "StringFormatApp"); + + Path distDir = outputDir.resolve("dist"); + Path cmakeLists = distDir.resolve("CMakeLists.txt"); + assertTrue(Files.exists(cmakeLists), "Translator should emit a CMake project"); + CleanTargetIntegrationTest.replaceLibraryWithExecutableTarget(cmakeLists, "StringFormatApp-src"); + + Path buildDir = distDir.resolve("build"); + Files.createDirectories(buildDir); + CleanTargetIntegrationTest.runCommand(Arrays.asList( + "cmake", + "-S", distDir.toString(), + "-B", buildDir.toString(), + "-DCMAKE_C_COMPILER=clang", + "-DCMAKE_OBJC_COMPILER=clang" + ), distDir); + CleanTargetIntegrationTest.runCommand(Arrays.asList("cmake", "--build", buildDir.toString()), distDir); + + Path executable = buildDir.resolve("StringFormatApp"); + String parparOutput = CleanTargetIntegrationTest.runCommand( + Arrays.asList(executable.toString()), buildDir); + + // The Objective-C native aborted the process here rather than returning. + assertTrue(parparOutput.contains("DONE"), + "ParparVM run should complete String.format without dying. Output: " + parparOutput); + + Map actual = parseCases(parparOutput, "CASE|"); + assertEquals(expected.keySet(), actual.keySet(), + "ParparVM should emit exactly the cases the JVM emitted"); + + List differences = new ArrayList<>(); + for (Map.Entry entry : expected.entrySet()) { + String parpar = actual.get(entry.getKey()); + if (!entry.getValue().equals(parpar)) { + differences.add(entry.getKey() + + "\n jdk : " + entry.getValue() + + "\n parparvm: " + parpar); + } + } + assertTrue(differences.isEmpty(), + "ParparVM String.format diverged from the JDK in " + differences.size() + + " case(s):\n" + String.join("\n", differences)); + + // %a and %t are the two JDK conversions ParparVM does not implement. They must + // surface as a catchable Java exception -- the point of issue #5482 was that a + // formatting problem took the whole process down instead. + Map unsupported = parseCases(parparOutput, "CN1ONLY|"); + assertEquals(2, unsupported.size(), + "Expected the unsupported-conversion cases. Output: " + parparOutput); + for (Map.Entry entry : unsupported.entrySet()) { + assertEquals("EX|java.util.UnknownFormatConversionException", entry.getValue(), + "Unsupported conversion '" + entry.getKey() + "' should throw, not crash or guess"); + } + } + + private Map parseCases(String output, String prefix) { + Map cases = new LinkedHashMap<>(); + for (String line : output.split("\\R")) { + if (!line.startsWith(prefix)) { + continue; + } + String body = line.substring(prefix.length()); + int separator = body.indexOf('|'); + assertTrue(separator > 0, "Malformed case line: " + line); + cases.put(body.substring(0, separator), body.substring(separator + 1)); + } + return cases; + } + + private String loadAppSource() throws Exception { + java.io.InputStream in = StringFormatIntegrationTest.class + .getResourceAsStream("/com/codename1/tools/translator/StringFormatApp.java"); + assertNotNull(in, "StringFormatApp.java test resource should exist"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")) + "\n"; + } + } + + private String runJavaMain(CompilerHelper.CompilerConfig config, Path classesDir, Path javaApiDir) + throws Exception { + String javaExe = config.jdkHome.resolve("bin").resolve(CompilerHelper.executableName("java")).toString(); + ProcessBuilder pb = new ProcessBuilder( + javaExe, + "-cp", + classesDir + System.getProperty("path.separator") + javaApiDir, + "StringFormatApp" + ); + pb.redirectErrorStream(true); + + Process process = pb.start(); + String output; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + output = reader.lines().collect(Collectors.joining("\n")); + } + assertEquals(0, process.waitFor(), "JVM run should exit cleanly. Output: " + output); + return output; + } + + private CompilerHelper.CompilerConfig selectCompiler() { + String[] preferredTargets = {"11", "17", "21", "25", "1.8"}; + for (String target : preferredTargets) { + List configs = CompilerHelper.getAvailableCompilers(target); + for (CompilerHelper.CompilerConfig config : configs) { + if (CompilerHelper.isJavaApiCompatible(config)) { + return config; + } + } + } + return null; + } +} diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/GetClassApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/GetClassApp.java new file mode 100644 index 00000000000..f2374dbb8c5 --- /dev/null +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/GetClassApp.java @@ -0,0 +1,87 @@ +import java.util.HashMap; + +/** + * Reproduces the shape of the class-code table in issue #5482, where the reporter saw + * {@code getClass()} apparently return null: an interface-typed reference is asked for + * its class, and that Class object is used as a HashMap key while the program allocates + * heavily. Class objects are not ordinary heap objects in ParparVM -- they are static + * structs whose vtable is wired up separately -- so identity, hashing and string + * conversion of a Class all deserve pinning. + * + *

Every line is a property that holds on any conforming JVM, so the harness can diff + * this output against a real JVM run without depending on how ParparVM spells class + * names.

+ */ +public class GetClassApp { + interface Bulkable { + } + + static class Entry implements Bulkable { + } + + static class Other implements Bulkable { + } + + private static final StringBuilder OUT = new StringBuilder(); + + private static void say(String label, Object value) { + OUT.append("CASE|").append(label).append('|').append(value).append('\n'); + } + + public static void main(String[] args) { + Bulkable a = new Entry(); + Bulkable b = new Other(); + + Class classOfA = a.getClass(); + Class classOfB = b.getClass(); + + say("notNull", Boolean.valueOf(classOfA != null && classOfB != null)); + say("stable", Boolean.valueOf(classOfA == a.getClass())); + say("distinct", Boolean.valueOf(classOfA != classOfB)); + say("hashStable", Boolean.valueOf(classOfA.hashCode() == a.getClass().hashCode())); + say("equalsSelf", Boolean.valueOf(classOfA.equals(a.getClass()))); + + // "class is " + cl -- the concatenation that printed "null" in the report. This + // goes through StringBuilder.append(Object), i.e. a virtual toString dispatch on + // a Class object. + String concatenated = "class is " + classOfA; + say("concatNotNull", Boolean.valueOf(concatenated != null)); + say("concatHasClass", Boolean.valueOf(!"class is null".equals(concatenated) + && concatenated.length() > "class is ".length())); + say("nameNotEmpty", Boolean.valueOf(classOfA.getName() != null + && classOfA.getName().length() > 0)); + say("namesDiffer", Boolean.valueOf(!classOfA.getName().equals(classOfB.getName()))); + + HashMap classCode = new HashMap(); + classCode.put(classOfA, Byte.valueOf((byte) 1)); + classCode.put(classOfB, Byte.valueOf((byte) 2)); + say("mapLookupA", classCode.get(a.getClass())); + say("mapLookupB", classCode.get(b.getClass())); + say("mapSize", Integer.valueOf(classCode.size())); + + // The reporter's failure only showed up while a dictionary load was allocating + // hard, so re-check every invariant under churn instead of once at startup. + int failures = 0; + StringBuilder scratch = new StringBuilder(); + for (int i = 0; i < 200000; i++) { + Bulkable fresh = (i & 1) == 0 ? (Bulkable) new Entry() : (Bulkable) new Other(); + Class cls = fresh.getClass(); + if (cls == null) { + failures++; + continue; + } + if (cls != (((i & 1) == 0) ? classOfA : classOfB)) { + failures++; + } + if (classCode.get(cls) == null) { + failures++; + } + scratch.setLength(0); + scratch.append("pad").append(i).append(cls); + } + say("churnFailures", Integer.valueOf(failures)); + + System.out.println(OUT.toString()); + System.out.println("DONE"); + } +} diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java new file mode 100644 index 00000000000..d10460b106a --- /dev/null +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java @@ -0,0 +1,219 @@ +/** + * Exercises java.lang.String.format and prints one line per case so the JDK's + * output and ParparVM's output can be diffed line by line. + * + * Every line is emitted as CASE|<label>|<rendering>. A rendering is either the + * formatted text (with control characters escaped so a case stays on one line) or + * EX|<exception class name> when the call threw -- a thrown Java exception is a + * legitimate outcome that both runtimes must agree on. + */ +public class StringFormatApp { + private static final StringBuilder OUT = new StringBuilder(); + + private static void f(String label, String format, Object... args) { + String rendering; + try { + rendering = escape(String.format(format, args)); + } catch (Throwable t) { + rendering = "EX|" + t.getClass().getName(); + } + OUT.append("CASE|").append(label).append('|').append(rendering).append('\n'); + } + + private static String escape(String value) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\n') { + sb.append("\\n"); + } else if (c == '\r') { + sb.append("\\r"); + } else if (c == '\\') { + sb.append("\\\\"); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static void strings() { + f("s.plain", "%s", "hello"); + f("s.null", "[%s]", new Object[] { null }); + f("s.width", "[%10s]", "abc"); + f("s.left", "[%-10s]", "abc"); + f("s.precision", "[%.3s]", "abcdef"); + f("s.widthPrecision", "[%8.3s]", "abcdef"); + f("s.upper", "%S", "abc"); + f("s.number", "%s", Integer.valueOf(42)); + f("s.double", "%s", Double.valueOf(1.5)); + // %s on a double is Double.toString, which the float conversions build on. + f("s.doubleThird", "%s", Double.valueOf(1.0 / 3.0)); + f("s.doubleLarge", "%s", Double.valueOf(1e30)); + f("s.doubleTiny", "%s", Double.valueOf(1e-10)); + f("s.doubleNegativeZero", "%s", Double.valueOf(-0.0)); + f("s.float", "%s", Float.valueOf(0.1f)); + f("s.longNegative", "%s", Long.valueOf(-9223372036854775808L)); + f("b.true", "%b", Boolean.TRUE); + f("b.false", "%b", Boolean.FALSE); + f("b.null", "%b", new Object[] { null }); + f("b.object", "%b", "text"); + f("b.upper", "%B", Boolean.TRUE); + f("h.string", "%h", "abc"); + f("h.null", "%h", new Object[] { null }); + f("c.char", "%c", Character.valueOf('x')); + f("c.upper", "%C", Character.valueOf('x')); + f("c.int", "%c", Integer.valueOf(65)); + f("c.width", "[%5c]", Character.valueOf('x')); + f("c.null", "%c", new Object[] { null }); + f("literal.percent", "100%%"); + f("literal.newline", "a%nb"); + f("literal.mixed", "%s-%d-%c%%", "cn1", Integer.valueOf(7), Character.valueOf('A')); + f("index.explicit", "%2$s %1$s %2$s", "one", "two"); + f("index.previous", "%s % Date: Sun, 2 Aug 2026 17:11:09 +0700 Subject: [PATCH 2/7] Reject a zero argument index and an empty precision, add the missing headers Three review findings: - "%0$s" recorded argIndex == 0, which the parser then treated as "no explicit index given" and satisfied from the next sequential argument. Argument indexes are 1-based, so this now raises IllegalFormatArgumentIndexException the way JDK 16 and later do. JDK 11 still accepts index zero, so the case is asserted against ParparVM alone rather than through the shared diff. - A '.' with no digits after it defaulted the precision to zero, so "%.s" quietly produced an empty string. Every supported JDK rejects it with UnknownFormatConversionException naming '.' as the conversion; so do we now. - The four new test files were missing the Codename One GPLv2 + Classpath Exception header, which failed check-copyright-headers. Co-Authored-By: Claude Opus 5 (1M context) --- vm/JavaAPI/src/java/lang/StringFormatter.java | 11 ++++- .../IllegalFormatArgumentIndexException.java | 44 +++++++++++++++++++ .../translator/GetClassIntegrationTest.java | 22 ++++++++++ .../StringFormatIntegrationTest.java | 43 ++++++++++++++---- .../tools/translator/GetClassApp.java | 22 ++++++++++ .../tools/translator/StringFormatApp.java | 30 +++++++++++++ 6 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 vm/JavaAPI/src/java/util/IllegalFormatArgumentIndexException.java diff --git a/vm/JavaAPI/src/java/lang/StringFormatter.java b/vm/JavaAPI/src/java/lang/StringFormatter.java index e22c34bdbb1..8e8df491635 100644 --- a/vm/JavaAPI/src/java/lang/StringFormatter.java +++ b/vm/JavaAPI/src/java/lang/StringFormatter.java @@ -24,6 +24,7 @@ import java.util.DuplicateFormatFlagsException; import java.util.FormatFlagsConversionMismatchException; +import java.util.IllegalFormatArgumentIndexException; import java.util.IllegalFormatCodePointException; import java.util.IllegalFormatConversionException; import java.util.IllegalFormatFlagsException; @@ -121,6 +122,10 @@ static String format(String format, Object[] args) { } if (digitsEnd > pos && digitsEnd < len && format.charAt(digitsEnd) == '$') { argIndex = parseNumber(format, pos, digitsEnd); + if (argIndex == 0) { + // Argument indexes are 1-based; "%0$s" has no argument to select. + throw new IllegalFormatArgumentIndexException(argIndex); + } pos = digitsEnd + 1; } @@ -173,7 +178,11 @@ static String format(String format, Object[] args) { while (pos < len && isDigit(format.charAt(pos))) { pos++; } - precision = pos > precisionStart ? parseNumber(format, precisionStart, pos) : 0; + if (pos == precisionStart) { + // "%.s" is malformed; the JVM reports the '.' as the conversion. + throw new UnknownFormatConversionException("."); + } + precision = parseNumber(format, precisionStart, pos); } if (pos >= len) { diff --git a/vm/JavaAPI/src/java/util/IllegalFormatArgumentIndexException.java b/vm/JavaAPI/src/java/util/IllegalFormatArgumentIndexException.java new file mode 100644 index 00000000000..d599eb7d1b7 --- /dev/null +++ b/vm/JavaAPI/src/java/util/IllegalFormatArgumentIndexException.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util; + +/** + * Thrown when a format specifier uses an explicit argument index that is not a + * valid position, such as the zero in {@code "%0$s"}. + */ +public class IllegalFormatArgumentIndexException extends IllegalFormatException { + private final int index; + + public IllegalFormatArgumentIndexException(int index) { + this.index = index; + } + + public int getIndex() { + return index; + } + + @Override + public String getMessage() { + return "Illegal format argument index = " + index; + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/GetClassIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/GetClassIntegrationTest.java index e57030905e5..d20fbe2510b 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/GetClassIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/GetClassIntegrationTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.tools.translator; import org.junit.jupiter.api.Test; diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/StringFormatIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/StringFormatIntegrationTest.java index c9f0d0a639b..d6b050ea627 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/StringFormatIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/StringFormatIntegrationTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.tools.translator; import org.junit.jupiter.api.Test; @@ -131,16 +153,19 @@ void formatOutputMatchesTheJdkCaseByCase() throws Exception { "ParparVM String.format diverged from the JDK in " + differences.size() + " case(s):\n" + String.join("\n", differences)); - // %a and %t are the two JDK conversions ParparVM does not implement. They must - // surface as a catchable Java exception -- the point of issue #5482 was that a - // formatting problem took the whole process down instead. + // Cases that cannot go through the shared diff, because the JDK either formats + // them (%a, %t, which ParparVM does not implement) or because the JDK's own + // answer moved between versions (%0$s is accepted on 11, rejected from 16 on). + // Either way the requirement is the same: a catchable Java exception, since the + // point of issue #5482 was that a formatting problem took the whole process down. + Map expectedCn1Only = new LinkedHashMap<>(); + expectedCn1Only.put("hexFloat", "EX|java.util.UnknownFormatConversionException"); + expectedCn1Only.put("dateTime", "EX|java.util.UnknownFormatConversionException"); + expectedCn1Only.put("zeroArgumentIndex", "EX|java.util.IllegalFormatArgumentIndexException"); + Map unsupported = parseCases(parparOutput, "CN1ONLY|"); - assertEquals(2, unsupported.size(), - "Expected the unsupported-conversion cases. Output: " + parparOutput); - for (Map.Entry entry : unsupported.entrySet()) { - assertEquals("EX|java.util.UnknownFormatConversionException", entry.getValue(), - "Unsupported conversion '" + entry.getKey() + "' should throw, not crash or guess"); - } + assertEquals(expectedCn1Only, unsupported, + "Unsupported and version-dependent specifiers must throw, not crash or guess"); } private Map parseCases(String output, String prefix) { diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/GetClassApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/GetClassApp.java index f2374dbb8c5..08925585f82 100644 --- a/vm/tests/src/test/resources/com/codename1/tools/translator/GetClassApp.java +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/GetClassApp.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ import java.util.HashMap; /** diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java index d10460b106a..35bd4bbc03f 100644 --- a/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ /** * Exercises java.lang.String.format and prints one line per case so the JDK's * output and ParparVM's output can be diffed line by line. @@ -185,6 +207,11 @@ private static void failures() { f("bad.wrongTypeForC", "%c", Double.valueOf(1.0)); f("bad.precisionOnD", "%.2d", Integer.valueOf(1)); f("bad.uppercaseD", "%D", Integer.valueOf(1)); + // A '.' with no digits after it is a malformed specifier, not a precision of + // zero: every supported JDK reports the '.' as an unknown conversion. + f("bad.emptyPrecision", "%.s", "x"); + f("bad.emptyPrecisionOnD", "%.d", Integer.valueOf(1)); + f("bad.emptyPrecisionOnF", "%.f", Double.valueOf(1.0)); } /** @@ -195,6 +222,9 @@ private static void failures() { private static void unsupported() { g("hexFloat", "%a", Double.valueOf(2.5)); g("dateTime", "%tY", Long.valueOf(0L)); + // Argument indexes are 1-based. JDK 16 and later reject index zero; JDK 11 still + // accepts it, so this cannot go through the shared diff. + g("zeroArgumentIndex", "%0$s", "A"); } private static void g(String label, String format, Object... args) { From 4ef3fb5c582ee584847a96a33846ab05f32d066b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:13:01 +0700 Subject: [PATCH 3/7] Handle a null varargs array and a repeated '<' flag Two more review findings, both confirmed against the JDK first: - format(fmt, (Object[]) null) is not an empty argument list. The JVM skips the bounds checks and hands every specifier a null, so "%s %s" renders "null null" where this threw MissingFormatArgumentException. "%<" is the one exception: it reuses the previous argument, so it still requires that one existed, which is what the parser fuzz caught after the first attempt made null unconditional. - '<' was consumed outside the duplicate-flag check, so "%s %< --- vm/JavaAPI/src/java/lang/StringFormatter.java | 23 ++++++++++--------- .../tools/translator/StringFormatApp.java | 18 +++++++++++++++ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/vm/JavaAPI/src/java/lang/StringFormatter.java b/vm/JavaAPI/src/java/lang/StringFormatter.java index 8e8df491635..a61132fdb0a 100644 --- a/vm/JavaAPI/src/java/lang/StringFormatter.java +++ b/vm/JavaAPI/src/java/lang/StringFormatter.java @@ -130,7 +130,6 @@ static String format(String format, Object[] args) { } int flags = 0; - boolean previous = false; while (pos < len) { char f = format.charAt(pos); int flag; @@ -149,9 +148,7 @@ static String format(String format, Object[] args) { } else if (f == '#') { flag = FLAG_HASH; } else if (f == '<') { - previous = true; - pos++; - continue; + flag = FLAG_PREVIOUS; } else { break; } @@ -161,6 +158,7 @@ static String format(String format, Object[] args) { flags |= flag; pos++; } + boolean previous = (flags & FLAG_PREVIOUS) != 0; int width = -1; int widthStart = pos; @@ -192,29 +190,32 @@ static String format(String format, Object[] args) { pos++; if (conversion == '%' || conversion == 'n') { - checkTextFlags(conversion, previous ? flags | FLAG_PREVIOUS : flags, width, precision); + checkTextFlags(conversion, flags, width, precision); out.append(conversion == '%' ? pad("%", width, flags) : "\n"); continue; } + // format(fmt, (Object[]) null) is not an empty argument list: the JVM skips + // the bounds checks and hands every specifier a null. "%<" is the exception + // -- it reuses the previous argument, so there must have been one either way. Object arg; if (previous) { - if (lastArg < 0) { + if (lastArg < 0 || (args != null && lastArg >= args.length)) { throw new MissingFormatArgumentException(format.substring(specStart, pos)); } - arg = args[lastArg]; + arg = args == null ? null : args[lastArg]; } else if (argIndex > 0) { - if (args == null || argIndex > args.length) { + if (args != null && argIndex > args.length) { throw new MissingFormatArgumentException(format.substring(specStart, pos)); } lastArg = argIndex - 1; - arg = args[lastArg]; + arg = args == null ? null : args[lastArg]; } else { - if (args == null || nextArg >= args.length) { + if (args != null && nextArg >= args.length) { throw new MissingFormatArgumentException(format.substring(specStart, pos)); } lastArg = nextArg; - arg = args[nextArg]; + arg = args == null ? null : args[nextArg]; nextArg++; } out.append(convert(conversion, arg, flags, width, precision)); diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java index 35bd4bbc03f..bc66cd31640 100644 --- a/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java @@ -93,6 +93,19 @@ private static void strings() { f("literal.mixed", "%s-%d-%c%%", "cn1", Integer.valueOf(7), Character.valueOf('A')); f("index.explicit", "%2$s %1$s %2$s", "one", "two"); f("index.previous", "%s % Date: Mon, 3 Aug 2026 08:15:59 +0700 Subject: [PATCH 4/7] Assert String.format consistency on every port (issue #5482) The vm/tests differential covers ParparVM's C and Objective-C target, but that is not the path a shipping app takes: the iOS build translates the core out of the bundled iOSPort.jar rather than out of the reactor, JavaSE and Android run their own java.util.Formatter, and the JavaScript port runs the translated Java through its own runtime. Those are four different code paths reaching the same API, and nothing was asserting that they agree. StringFormatTest runs the same 124 expectations on every port the suite covers: iOS GL and Metal, tvOS, watchOS, Android, JavaScript, mac native, Linux, Windows and the JavaSE simulator. It takes no screenshot; it is a pure assertion test in the shape of the existing FloatingToStringTest. Every expected value was produced by a real Java SE java.util.Formatter rather than written by hand. The generator is checked in as scripts/hellocodenameone/tools/generate-string-format-cases.java so the table can be regenerated and audited, and it emits a byte-identical table on JDK 8, 11, 17, 21 and 25 -- so this pins behaviour that does not drift with the JDK the suite happens to build against. Coverage includes the conversions, flags, widths and precisions an app actually uses, the HALF_UP rounding cases where Java disagrees with C printf, and the four malformed-format cases found in review on this branch (empty precision, repeated flags, grouping on hex, zero padding on a string). Three things are deliberately excluded because they are genuinely not consistent across these runtimes, rather than papered over: - %a and %t, which ParparVM does not implement. - %0$s, accepted before JDK 16 and rejected from 16 on. - a pinned value for %n, since the JDK emits the platform line separator and that is "\r\n" on a Windows JVM. The test asserts it is a line separator instead. JavaSE and Android format through the default locale, so the test probes the platform's decimal and grouping separators rather than assuming them, and fails with a specific message if the platform also localises the digits. Registered in Cn1ssDeviceRunner and the java-standard-apis feature group. The stored per-port reports get a not-run entry, which the next master publish replaces with the real result; the pinned test count moves 170 -> 171. Co-Authored-By: Claude Opus 5 (1M context) --- docs/website/data/port_status.json | 4 +- .../data/port_status_reports/android.json | 6 +- .../data/port_status_reports/ios-gl.json | 6 +- .../data/port_status_reports/ios-metal.json | 6 +- .../data/port_status_reports/javascript.json | 6 +- .../data/port_status_reports/linux-arm64.json | 6 +- .../data/port_status_reports/linux-x64.json | 6 +- .../data/port_status_reports/mac-native.json | 6 +- .../data/port_status_reports/tvos.json | 6 +- .../data/port_status_reports/watchos.json | 6 +- .../port_status_reports/windows-arm64.json | 6 +- .../data/port_status_reports/windows-x64.json | 6 +- .../tests/Cn1ssDeviceRunner.java | 1 + .../tests/StringFormatTest.java | 320 ++++++++++++++++++ .../conformance/test_port_status.py | 2 +- .../tools/generate-string-format-cases.java | 313 +++++++++++++++++ 16 files changed, 692 insertions(+), 14 deletions(-) create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java create mode 100644 scripts/hellocodenameone/tools/generate-string-format-cases.java diff --git a/docs/website/data/port_status.json b/docs/website/data/port_status.json index 5da97844775..3ef169cba41 100644 --- a/docs/website/data/port_status.json +++ b/docs/website/data/port_status.json @@ -304,8 +304,8 @@ "id": "java-standard-apis", "category": "Application runtime", "name": "Java standard APIs", - "description": "Checks streams, strings, time, monotonic time, floating-point conversion, and Java 17 language/runtime support.", - "tests": ["FloatingToStringTest", "Java17Tests", "NanoTimeApiTest", "StreamApiTest", "StringApiTest", "TimeApiTest"] + "description": "Checks streams, strings, string formatting, time, monotonic time, floating-point conversion, and Java 17 language/runtime support.", + "tests": ["FloatingToStringTest", "Java17Tests", "NanoTimeApiTest", "StreamApiTest", "StringApiTest", "StringFormatTest", "TimeApiTest"] }, { "id": "threading", diff --git a/docs/website/data/port_status_reports/android.json b/docs/website/data/port_status_reports/android.json index fb044632f8f..14f26ac0e95 100644 --- a/docs/website/data/port_status_reports/android.json +++ b/docs/website/data/port_status_reports/android.json @@ -7,7 +7,7 @@ "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, + "not-run": 6, "pass": 164, "skip": 1 }, @@ -551,6 +551,10 @@ "feature": "java-standard-apis", "status": "pass" }, + "StringFormatTest": { + "feature": "java-standard-apis", + "status": "not-run" + }, "StrokeTest": { "feature": "graphics-shapes-strokes", "status": "pass" diff --git a/docs/website/data/port_status_reports/ios-gl.json b/docs/website/data/port_status_reports/ios-gl.json index 56dceeb7689..06ed823c481 100644 --- a/docs/website/data/port_status_reports/ios-gl.json +++ b/docs/website/data/port_status_reports/ios-gl.json @@ -7,7 +7,7 @@ "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, + "not-run": 6, "pass": 163, "skip": 2 }, @@ -551,6 +551,10 @@ "feature": "java-standard-apis", "status": "pass" }, + "StringFormatTest": { + "feature": "java-standard-apis", + "status": "not-run" + }, "StrokeTest": { "feature": "graphics-shapes-strokes", "status": "pass" diff --git a/docs/website/data/port_status_reports/ios-metal.json b/docs/website/data/port_status_reports/ios-metal.json index 41e6d8bb21c..88deea01db0 100644 --- a/docs/website/data/port_status_reports/ios-metal.json +++ b/docs/website/data/port_status_reports/ios-metal.json @@ -7,7 +7,7 @@ "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, + "not-run": 6, "pass": 163, "skip": 2 }, @@ -551,6 +551,10 @@ "feature": "java-standard-apis", "status": "pass" }, + "StringFormatTest": { + "feature": "java-standard-apis", + "status": "not-run" + }, "StrokeTest": { "feature": "graphics-shapes-strokes", "status": "pass" diff --git a/docs/website/data/port_status_reports/javascript.json b/docs/website/data/port_status_reports/javascript.json index a31881d039b..f493d2af6aa 100644 --- a/docs/website/data/port_status_reports/javascript.json +++ b/docs/website/data/port_status_reports/javascript.json @@ -7,7 +7,7 @@ "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, + "not-run": 6, "pass": 164, "skip": 1 }, @@ -551,6 +551,10 @@ "feature": "java-standard-apis", "status": "pass" }, + "StringFormatTest": { + "feature": "java-standard-apis", + "status": "not-run" + }, "StrokeTest": { "feature": "graphics-shapes-strokes", "status": "pass" diff --git a/docs/website/data/port_status_reports/linux-arm64.json b/docs/website/data/port_status_reports/linux-arm64.json index e2858afe2d2..c3029161dca 100644 --- a/docs/website/data/port_status_reports/linux-arm64.json +++ b/docs/website/data/port_status_reports/linux-arm64.json @@ -7,7 +7,7 @@ "suite_finished": false, "summary": { "fail": 0, - "not-run": 5, + "not-run": 6, "pass": 165, "skip": 0 }, @@ -548,6 +548,10 @@ "feature": "java-standard-apis", "status": "pass" }, + "StringFormatTest": { + "feature": "java-standard-apis", + "status": "not-run" + }, "StrokeTest": { "feature": "graphics-shapes-strokes", "status": "pass" diff --git a/docs/website/data/port_status_reports/linux-x64.json b/docs/website/data/port_status_reports/linux-x64.json index 91949b47702..0c000286df3 100644 --- a/docs/website/data/port_status_reports/linux-x64.json +++ b/docs/website/data/port_status_reports/linux-x64.json @@ -7,7 +7,7 @@ "suite_finished": false, "summary": { "fail": 0, - "not-run": 5, + "not-run": 6, "pass": 165, "skip": 0 }, @@ -548,6 +548,10 @@ "feature": "java-standard-apis", "status": "pass" }, + "StringFormatTest": { + "feature": "java-standard-apis", + "status": "not-run" + }, "StrokeTest": { "feature": "graphics-shapes-strokes", "status": "pass" diff --git a/docs/website/data/port_status_reports/mac-native.json b/docs/website/data/port_status_reports/mac-native.json index d7792f7aa4b..dceae838715 100644 --- a/docs/website/data/port_status_reports/mac-native.json +++ b/docs/website/data/port_status_reports/mac-native.json @@ -7,7 +7,7 @@ "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, + "not-run": 6, "pass": 163, "skip": 2 }, @@ -551,6 +551,10 @@ "feature": "java-standard-apis", "status": "pass" }, + "StringFormatTest": { + "feature": "java-standard-apis", + "status": "not-run" + }, "StrokeTest": { "feature": "graphics-shapes-strokes", "status": "pass" diff --git a/docs/website/data/port_status_reports/tvos.json b/docs/website/data/port_status_reports/tvos.json index d9c49c0ec83..8ab1f119752 100644 --- a/docs/website/data/port_status_reports/tvos.json +++ b/docs/website/data/port_status_reports/tvos.json @@ -7,7 +7,7 @@ "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, + "not-run": 6, "pass": 163, "skip": 2 }, @@ -551,6 +551,10 @@ "feature": "java-standard-apis", "status": "pass" }, + "StringFormatTest": { + "feature": "java-standard-apis", + "status": "not-run" + }, "StrokeTest": { "feature": "graphics-shapes-strokes", "status": "pass" diff --git a/docs/website/data/port_status_reports/watchos.json b/docs/website/data/port_status_reports/watchos.json index b2bdbfad26b..0379aa9c6d9 100644 --- a/docs/website/data/port_status_reports/watchos.json +++ b/docs/website/data/port_status_reports/watchos.json @@ -7,7 +7,7 @@ "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, + "not-run": 6, "pass": 163, "skip": 2 }, @@ -551,6 +551,10 @@ "feature": "java-standard-apis", "status": "pass" }, + "StringFormatTest": { + "feature": "java-standard-apis", + "status": "not-run" + }, "StrokeTest": { "feature": "graphics-shapes-strokes", "status": "pass" diff --git a/docs/website/data/port_status_reports/windows-arm64.json b/docs/website/data/port_status_reports/windows-arm64.json index 591628f26f5..ffb6fe44103 100644 --- a/docs/website/data/port_status_reports/windows-arm64.json +++ b/docs/website/data/port_status_reports/windows-arm64.json @@ -7,7 +7,7 @@ "suite_finished": false, "summary": { "fail": 0, - "not-run": 5, + "not-run": 6, "pass": 165, "skip": 0 }, @@ -548,6 +548,10 @@ "feature": "java-standard-apis", "status": "pass" }, + "StringFormatTest": { + "feature": "java-standard-apis", + "status": "not-run" + }, "StrokeTest": { "feature": "graphics-shapes-strokes", "status": "pass" diff --git a/docs/website/data/port_status_reports/windows-x64.json b/docs/website/data/port_status_reports/windows-x64.json index 1530b2cc8e0..e39ec2b7c60 100644 --- a/docs/website/data/port_status_reports/windows-x64.json +++ b/docs/website/data/port_status_reports/windows-x64.json @@ -7,7 +7,7 @@ "suite_finished": false, "summary": { "fail": 0, - "not-run": 5, + "not-run": 6, "pass": 165, "skip": 0 }, @@ -548,6 +548,10 @@ "feature": "java-standard-apis", "status": "pass" }, + "StringFormatTest": { + "feature": "java-standard-apis", + "status": "not-run" + }, "StrokeTest": { "feature": "graphics-shapes-strokes", "status": "pass" diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 3b31c5bb407..28c344c7c04 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -393,6 +393,7 @@ private static int testTimeoutMs(BaseTest testClass) { new TimeApiTest(), new NanoTimeApiTest(), new FloatingToStringTest(), + new StringFormatTest(), new ClipboardRoundTripTest(), // External surfaces assertion tests (no screenshots): the serializer wire format // round-tripped through JSONParser on the device VM, the timeline-selection helpers diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java new file mode 100644 index 00000000000..ff89846f1da --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java @@ -0,0 +1,320 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.examples.hellocodenameone.tests; + +/** + * Cross-platform conformance for {@code java.lang.String.format} (issue #5482). + * + *

On JavaSE and Android this runs the platform's own {@code java.util.Formatter}. On + * iOS, tvOS, watchOS, the native desktop targets and JavaScript it runs Codename One's + * replacement {@code java.lang.String}, whose {@code format} used to be a native method + * that aborted the process on iOS. Those are different code paths reached through + * different toolchains -- the iOS app translates the core out of the bundled iOSPort.jar + * rather than out of the reactor -- so the only way to know they agree is to assert the + * same expectations on every device.

+ * + *

Every expected value below was produced by a real Java SE + * {@code java.util.Formatter} rather than written by hand, and the generator is checked + * in at {@code scripts/hellocodenameone/tools/generate-string-format-cases.java}. The + * table is byte-identical when generated on JDK 8, 11, 17, 21 and 25, so it pins + * behaviour that does not drift with the JDK the suite happens to build against.

+ * + *

Deliberately excluded, because they are not consistent across the runtimes this + * suite covers: {@code %a} and {@code %t} (unimplemented on ParparVM), {@code %0$s} + * (accepted before JDK 16, rejected from 16 on) and a pinned value for {@code %n} (the + * JDK emits the platform line separator).

+ */ +public class StringFormatTest extends BaseTest { + + /** Typed so {@code String.format(fmt, NULL_ARRAY)} passes a null varargs array. */ + private static final Object[] NULL_ARRAY = null; + + private interface FormatCall { + String run(); + } + + private char decimalSeparator = '.'; + private char groupingSeparator = ','; + private int checked; + + @Override + public boolean runTest() { + try { + probeLocale(); + +// Generated by scripts/hellocodenameone/tools/generate-string-format-cases.java +// on Azul Systems, Inc. JDK 25 -- do not edit by hand. + // ---- strings and characters ---- + check("s.plain", "hello", String.format("%s", "hello")); + check("s.null", "[null]", String.format("[%s]", (Object) null)); + check("s.width", "[ abc]", String.format("[%10s]", "abc")); + check("s.left", "[abc ]", String.format("[%-10s]", "abc")); + check("s.precision", "[abc]", String.format("[%.3s]", "abcdef")); + check("s.widthPrecision", "[ abc]", String.format("[%8.3s]", "abcdef")); + check("s.upper", "ABC", String.format("%S", "abc")); + check("s.boxedInt", "42", String.format("%s", Integer.valueOf(42))); + check("s.boxedLongMin", "-9223372036854775808", String.format("%s", Long.valueOf(-9223372036854775808L))); + check("b.true", "true", String.format("%b", Boolean.valueOf(true))); + check("b.false", "false", String.format("%b", Boolean.valueOf(false))); + check("b.null", "false", String.format("%b", (Object) null)); + check("b.nonBoolean", "true", String.format("%b", "text")); + check("b.upper", "TRUE", String.format("%B", Boolean.valueOf(true))); + check("h.string", "17862", String.format("%h", "abc")); + check("h.null", "null", String.format("%h", (Object) null)); + check("c.char", "x", String.format("%c", Character.valueOf('x'))); + check("c.upper", "X", String.format("%C", Character.valueOf('x'))); + check("c.codePoint", "A", String.format("%c", Integer.valueOf(65))); + check("c.width", "[ x]", String.format("[%5c]", Character.valueOf('x'))); + check("c.null", "null", String.format("%c", (Object) null)); + check("literal.percent", "100%", String.format("100%%")); + check("literal.mixed", "cn1-7-A%", String.format("%s-%d-%c%%", "cn1", Integer.valueOf(7), Character.valueOf('A'))); + check("index.explicit", "two one two", String.format("%2$s %1$s %2$s", "one", "two")); + check("index.previous", "echo echo echo", String.format("%s % String.format("%q", "x")); + checkThrows("bad.trailingPercent", "java.util.UnknownFormatConversionException", () -> String.format("abc%")); + checkThrows("bad.missingArgument", "java.util.MissingFormatArgumentException", () -> String.format("%s %s", "only")); + checkThrows("bad.noArguments", "java.util.MissingFormatArgumentException", () -> String.format("%s")); + checkThrows("bad.wrongTypeForD", "java.util.IllegalFormatConversionException", () -> String.format("%d", "text")); + checkThrows("bad.wrongTypeForF", "java.util.IllegalFormatConversionException", () -> String.format("%f", "text")); + checkThrows("bad.precisionOnD", "java.util.IllegalFormatPrecisionException", () -> String.format("%.2d", Integer.valueOf(1))); + checkThrows("bad.emptyPrecision", "java.util.UnknownFormatConversionException", () -> String.format("%.s", "x")); + checkThrows("bad.repeatedPrevious", "java.util.DuplicateFormatFlagsException", () -> String.format("%s %< String.format("%--5s", "a")); + checkThrows("bad.groupingOnHex", "java.util.FormatFlagsConversionMismatchException", () -> String.format("%,x", Integer.valueOf(1))); + checkThrows("bad.zeroPadOnString", "java.util.FormatFlagsConversionMismatchException", () -> String.format("%08s", "a")); + + checkLineSeparator(); + assertTrue(checked > 100, "expected the full conformance table to run, only " + checked + " cases did"); + } catch (Throwable t) { + fail("String.format conformance failed: " + t); + return false; + } + done(); + return true; + } + + /** + * JavaSE and Android format through the default locale, so a runner in a locale that + * uses ',' as the decimal separator would disagree with the table for reasons that + * have nothing to do with this test. Discover the separators instead of assuming, and + * fail loudly rather than confusingly if the platform also localises the digits. + */ + private void probeLocale() { + String decimalProbe = String.format("%.1f", Double.valueOf(1.5)); + assertTrue(decimalProbe.length() == 3, + "expected a d.d rendering from %.1f, got [" + decimalProbe + "]"); + decimalSeparator = decimalProbe.charAt(1); + + String groupingProbe = String.format("%,d", Integer.valueOf(1000)); + assertTrue(groupingProbe.length() == 5, + "expected a d,ddd rendering from %,d, got [" + groupingProbe + "]"); + groupingSeparator = groupingProbe.charAt(1); + + assertEqual("1234567890", String.format("%d", Integer.valueOf(1234567890)), + "platform formats integers with non-ASCII digits; the table cannot apply"); + } + + /** + * {@code %n} is the platform line separator by specification, so it is "\r\n" on a + * Windows JVM and "\n" everywhere else. Pin that it is a line separator rather than + * pinning which one. + */ + private void checkLineSeparator() { + String value = String.format("a%nb"); + assertTrue("a\nb".equals(value) || "a\r\nb".equals(value), + "%n should emit a line separator, got " + describe(value)); + } + + private void check(String label, String expected, String actual) { + checked++; + String localised = localise(expected); + assertEqual(localised, actual, + "String.format case '" + label + "' expected " + describe(localised) + + " but was " + describe(actual)); + } + + private void checkThrows(String label, String expectedType, FormatCall call) { + checked++; + String result; + try { + result = call.run(); + } catch (Throwable t) { + // Every java.util format exception extends IllegalArgumentException. The + // package is not pinned because a port may mangle class names; the specific + // exception is, because picking the wrong one is the bug this catches. + assertTrue(t instanceof IllegalArgumentException, + "String.format case '" + label + "' should raise an IllegalArgumentException, raised " + + t.getClass().getName()); + String simple = simpleName(expectedType); + assertTrue(simpleName(t.getClass().getName()).equals(simple), + "String.format case '" + label + "' should raise " + simple + + ", raised " + t.getClass().getName()); + return; + } + fail("String.format case '" + label + "' should have raised " + simpleName(expectedType) + + " but returned " + describe(result)); + } + + private static String simpleName(String className) { + int dot = className.lastIndexOf('.'); + return dot < 0 ? className : className.substring(dot + 1); + } + + /** Rewrites a canonical expectation into the separators this platform actually uses. */ + private String localise(String expected) { + if (decimalSeparator == '.' && groupingSeparator == ',') { + return expected; + } + StringBuilder sb = new StringBuilder(expected.length()); + for (int i = 0; i < expected.length(); i++) { + char c = expected.charAt(i); + if (c == '.') { + sb.append(decimalSeparator); + } else if (c == ',') { + sb.append(groupingSeparator); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static String describe(String value) { + if (value == null) { + return "null"; + } + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\n') { + sb.append("\\n"); + } else if (c == '\r') { + sb.append("\\r"); + } else { + sb.append(c); + } + } + return sb.append("] len=").append(value.length()).toString(); + } + + @Override + public boolean shouldTakeScreenshot() { + return false; + } +} diff --git a/scripts/hellocodenameone/conformance/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index b5817f12521..b1d0dd9a893 100755 --- a/scripts/hellocodenameone/conformance/test_port_status.py +++ b/scripts/hellocodenameone/conformance/test_port_status.py @@ -16,7 +16,7 @@ def setUpClass(cls): def test_contract_covers_registered_tests_and_goldens(self): counts = port_status.validate(self.manifest) - self.assertEqual(170, counts["tests"]) + self.assertEqual(171, counts["tests"]) self.assertEqual(1, counts["performance_tests"]) self.assertGreaterEqual(counts["features"], 54) self.assertEqual(11, counts["ports"]) diff --git a/scripts/hellocodenameone/tools/generate-string-format-cases.java b/scripts/hellocodenameone/tools/generate-string-format-cases.java new file mode 100644 index 00000000000..c15e8de46a5 --- /dev/null +++ b/scripts/hellocodenameone/tools/generate-string-format-cases.java @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Generates the expected-value table baked into StringFormatTest, so the device-side + * expectations are demonstrably what a real Java SE java.util.Formatter produces rather + * than something hand-written. + * + *
+ *   java scripts/hellocodenameone/tools/generate-string-format-cases.java
+ * 
+ * + * Paste the emitted block over the corresponding block in StringFormatTest.java. + * + * Cases are deliberately restricted to behaviour that is identical on every runtime the + * suite runs on -- JDK 8 through 25, Android's libcore, and ParparVM. Excluded on purpose: + * + *
    + *
  • {@code %a} and {@code %t}: ParparVM does not implement them.
  • + *
  • {@code %0$s}: accepted before JDK 16, rejected from JDK 16 on.
  • + *
  • {@code %n}: the JDK emits the platform line separator, so it is "\r\n" on a + * Windows JVM and "\n" everywhere else. StringFormatTest asserts it is one of the + * two rather than pinning a value.
  • + *
+ * + * Everything is generated with Locale.ROOT and written with '.' as the decimal separator + * and ',' as the grouping separator; StringFormatTest re-localises before comparing, + * because on JavaSE and Android the platform formatter follows the default locale. + */ +public class GenerateStringFormatCases { + + private static final List LINES = new ArrayList(); + + private static void c(String label, String format, Object... args) { + String value = String.format(Locale.ROOT, format, args); + LINES.add(" check(\"" + label + "\", " + literal(value) + + ", String.format(" + literal(format) + argList(args) + "));"); + } + + /** A case whose format string is malformed; both sides must raise the same exception. */ + private static void bad(String label, String format, Object... args) { + String type; + try { + String.format(Locale.ROOT, format, args); + throw new IllegalStateException("expected " + label + " to throw"); + } catch (IllegalArgumentException e) { + type = e.getClass().getName(); + } + LINES.add(" checkThrows(\"" + label + "\", \"" + type + "\", () -> String.format(" + + literal(format) + argList(args) + "));"); + } + + private static String argList(Object[] args) { + StringBuilder sb = new StringBuilder(); + for (Object a : args) { + sb.append(", ").append(argLiteral(a)); + } + return sb.toString(); + } + + private static String argLiteral(Object a) { + if (a == null) { + return "(Object) null"; + } + if (a instanceof String) { + return literal((String) a); + } + if (a instanceof Integer) { + return "Integer.valueOf(" + a + ")"; + } + if (a instanceof Long) { + return "Long.valueOf(" + a + "L)"; + } + if (a instanceof Short) { + return "Short.valueOf((short) " + a + ")"; + } + if (a instanceof Byte) { + return "Byte.valueOf((byte) " + a + ")"; + } + if (a instanceof Character) { + return "Character.valueOf('" + a + "')"; + } + if (a instanceof Boolean) { + return "Boolean.valueOf(" + a + ")"; + } + if (a instanceof Float) { + return "Float.valueOf(" + floatLiteral(((Float) a).floatValue()) + "f)"; + } + if (a instanceof Double) { + return "Double.valueOf(" + doubleLiteral(((Double) a).doubleValue()) + ")"; + } + throw new IllegalArgumentException("unsupported argument type " + a.getClass()); + } + + private static String doubleLiteral(double d) { + if (Double.isNaN(d)) { + return "Double.NaN"; + } + if (d == Double.POSITIVE_INFINITY) { + return "Double.POSITIVE_INFINITY"; + } + if (d == Double.NEGATIVE_INFINITY) { + return "Double.NEGATIVE_INFINITY"; + } + return Double.toString(d); + } + + private static String floatLiteral(float f) { + return Float.toString(f); + } + + private static String literal(String s) { + StringBuilder sb = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char ch = s.charAt(i); + if (ch == '"' || ch == '\\') { + sb.append('\\').append(ch); + } else if (ch == '\n') { + sb.append("\\n"); + } else if (ch == '\r') { + sb.append("\\r"); + } else if (ch < 0x20 || ch > 0x7e) { + sb.append(String.format("\\u%04x", (int) ch)); + } else { + sb.append(ch); + } + } + return sb.append('"').toString(); + } + + public static void main(String[] args) { + LINES.add(" // ---- strings and characters ----"); + c("s.plain", "%s", "hello"); + c("s.null", "[%s]", (Object) null); + c("s.width", "[%10s]", "abc"); + c("s.left", "[%-10s]", "abc"); + c("s.precision", "[%.3s]", "abcdef"); + c("s.widthPrecision", "[%8.3s]", "abcdef"); + c("s.upper", "%S", "abc"); + c("s.boxedInt", "%s", Integer.valueOf(42)); + c("s.boxedLongMin", "%s", Long.valueOf(-9223372036854775808L)); + c("b.true", "%b", Boolean.TRUE); + c("b.false", "%b", Boolean.FALSE); + c("b.null", "%b", (Object) null); + c("b.nonBoolean", "%b", "text"); + c("b.upper", "%B", Boolean.TRUE); + c("h.string", "%h", "abc"); + c("h.null", "%h", (Object) null); + c("c.char", "%c", Character.valueOf('x')); + c("c.upper", "%C", Character.valueOf('x')); + c("c.codePoint", "%c", Integer.valueOf(65)); + c("c.width", "[%5c]", Character.valueOf('x')); + c("c.null", "%c", (Object) null); + c("literal.percent", "100%%"); + c("literal.mixed", "%s-%d-%c%%", "cn1", Integer.valueOf(7), Character.valueOf('A')); + c("index.explicit", "%2$s %1$s %2$s", "one", "two"); + c("index.previous", "%s % Date: Mon, 3 Aug 2026 09:18:58 +0700 Subject: [PATCH 5/7] Pin the exception family, not the subtype, in the cross-platform format test The new conformance test did its job on its first run: Android rejected "%.s" with IllegalFormatPrecisionException where OpenJDK raises UnknownFormatConversionException. That is a real disagreement between Android's libcore and OpenJDK, and neither is Codename One's to change. Verified the whole table against real Android libcore on an API 34 emulator rather than guessing which other cases might differ: all 112 value cases match exactly, all 12 malformed formats are rejected, and the "%.s" subtype is the only divergence in the set. So the cross-platform test now requires that a malformed format raises some IllegalArgumentException -- which is the behaviour issue #5482 was actually about, a bad format being rejected rather than quietly producing something or killing the process. The exact subtype ParparVM raises stays pinned against the JDK in vm/tests StringFormatIntegrationTest, which covers every port that runs Codename One's own formatter. Failures are now collected and reported together instead of aborting on the first one. The Android run stopped at case 8 of the 12 malformed cases, leaving four unevaluated, and each device round trip is expensive; one run should report everything that diverges. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/StringFormatTest.java | 61 ++++++++++++++----- .../tools/generate-string-format-cases.java | 8 ++- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java index ff89846f1da..318ba7d0793 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java @@ -57,6 +57,8 @@ private interface FormatCall { private char decimalSeparator = '.'; private char groupingSeparator = ','; private int checked; + private int problemCount; + private final StringBuilder problems = new StringBuilder(); @Override public boolean runTest() { @@ -205,10 +207,28 @@ public boolean runTest() { fail("String.format conformance failed: " + t); return false; } + if (problemCount > 0) { + fail(problemCount + " String.format divergence(s) out of " + checked + " cases:\n" + problems); + return false; + } done(); return true; } + /** + * Records a divergence instead of throwing, so one run of the suite reports every + * case that disagrees rather than only the first. A port that diverges usually + * diverges in more than one place, and each device round trip is expensive. + */ + private void problem(String text) { + problemCount++; + if (problemCount <= 12) { + problems.append(problemCount).append(") ").append(text).append('\n'); + } else if (problemCount == 13) { + problems.append("... further divergences suppressed\n"); + } + } + /** * JavaSE and Android format through the default locale, so a runner in a locale that * uses ',' as the decimal separator would disagree with the table for reasons that @@ -244,31 +264,40 @@ private void checkLineSeparator() { private void check(String label, String expected, String actual) { checked++; String localised = localise(expected); - assertEqual(localised, actual, - "String.format case '" + label + "' expected " + describe(localised) - + " but was " + describe(actual)); + if (!localised.equals(actual)) { + problem("case '" + label + "' expected " + describe(localised) + + " but was " + describe(actual)); + } } - private void checkThrows(String label, String expectedType, FormatCall call) { + /** + * Asserts that a malformed format is rejected rather than quietly producing something, + * which is the behaviour issue #5482 was about. + * + *

Only the exception family is pinned here, not the exact subtype: Android's + * libcore and OpenJDK genuinely disagree on some of them -- {@code "%.s"} raises + * IllegalFormatPrecisionException on Android and UnknownFormatConversionException on + * OpenJDK, for instance. Neither is Codename One's to change. The exact subtype + * ParparVM raises is pinned against the JDK in + * vm/tests StringFormatIntegrationTest, which covers every port that runs Codename + * One's own formatter; {@code javaSeType} is carried here only to make a failure + * message say what Java SE does.

+ */ + private void checkThrows(String label, String javaSeType, FormatCall call) { checked++; String result; try { result = call.run(); } catch (Throwable t) { - // Every java.util format exception extends IllegalArgumentException. The - // package is not pinned because a port may mangle class names; the specific - // exception is, because picking the wrong one is the bug this catches. - assertTrue(t instanceof IllegalArgumentException, - "String.format case '" + label + "' should raise an IllegalArgumentException, raised " - + t.getClass().getName()); - String simple = simpleName(expectedType); - assertTrue(simpleName(t.getClass().getName()).equals(simple), - "String.format case '" + label + "' should raise " + simple - + ", raised " + t.getClass().getName()); + if (!(t instanceof IllegalArgumentException)) { + problem("case '" + label + "' raised " + t.getClass().getName() + + " instead of an IllegalArgumentException (Java SE raises " + + simpleName(javaSeType) + ")"); + } return; } - fail("String.format case '" + label + "' should have raised " + simpleName(expectedType) - + " but returned " + describe(result)); + problem("case '" + label + "' returned " + describe(result) + + " instead of rejecting the format (Java SE raises " + simpleName(javaSeType) + ")"); } private static String simpleName(String className) { diff --git a/scripts/hellocodenameone/tools/generate-string-format-cases.java b/scripts/hellocodenameone/tools/generate-string-format-cases.java index c15e8de46a5..faabcb5d2c8 100644 --- a/scripts/hellocodenameone/tools/generate-string-format-cases.java +++ b/scripts/hellocodenameone/tools/generate-string-format-cases.java @@ -50,6 +50,12 @@ * Everything is generated with Locale.ROOT and written with '.' as the decimal separator * and ',' as the grouping separator; StringFormatTest re-localises before comparing, * because on JavaSE and Android the platform formatter follows the default locale. + * + *

The exception type recorded for a malformed format is what Java SE raises. + * StringFormatTest only requires that some IllegalArgumentException is raised, because + * Android's libcore does not always agree with OpenJDK on the subtype -- {@code "%.s"} + * raises IllegalFormatPrecisionException there and UnknownFormatConversionException on + * OpenJDK. ParparVM's exact subtypes are pinned against the JDK in vm/tests instead.

*/ public class GenerateStringFormatCases { @@ -61,7 +67,7 @@ private static void c(String label, String format, Object... args) { + ", String.format(" + literal(format) + argList(args) + "));"); } - /** A case whose format string is malformed; both sides must raise the same exception. */ + /** A case whose format string is malformed and must be rejected on every port. */ private static void bad(String label, String format, Object... args) { String type; try { From 2087fbc9063db57032397fa00e7ef9f23259407f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:18:20 +0700 Subject: [PATCH 6/7] Validate the specifier before selecting an argument, and round Float to binary32 Three review findings. Specifier validation ran after argument selection, so "%q" with no arguments raised MissingFormatArgumentException where the JVM raises UnknownFormatConversionException. The reported case was one of seven: "%z", "%,x", "%08s", "%.2d", "%2$q" and "%.java is single-file source mode and does not require the name to match, and it was working -- but javac does enforce it, so the rename removes a papercut and both invocations work now. Verified: 384k value cases and the curated table against the JDK with no divergence, the refreshed 128-case device table against real Android libcore on an API 34 emulator with no divergence, and the full ParparVM suite at 411 green. Across 300k randomly assembled format strings the only remaining divergence is that the JVM validates the whole format string before formatting any of it and so reports a later broken specifier first, where this formatter reports the one it reaches first. Zero value divergences; both sides reject every malformed format. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/StringFormatTest.java | 6 +++- ...es.java => GenerateStringFormatCases.java} | 8 ++++- vm/JavaAPI/src/java/lang/StringFormatter.java | 33 +++++++++++++++---- .../tools/translator/StringFormatApp.java | 10 ++++++ 4 files changed, 49 insertions(+), 8 deletions(-) rename scripts/hellocodenameone/tools/{generate-string-format-cases.java => GenerateStringFormatCases.java} (97%) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java index 318ba7d0793..1c321c38db7 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java @@ -36,7 +36,7 @@ * *

Every expected value below was produced by a real Java SE * {@code java.util.Formatter} rather than written by hand, and the generator is checked - * in at {@code scripts/hellocodenameone/tools/generate-string-format-cases.java}. The + * in at {@code scripts/hellocodenameone/tools/GenerateStringFormatCases.java}. The * table is byte-identical when generated on JDK 8, 11, 17, 21 and 25, so it pins * behaviour that does not drift with the JDK the suite happens to build against.

* @@ -139,6 +139,8 @@ public boolean runTest() { check("f.tiny", "0.000000", String.format("%f", Double.valueOf(1.0E-10))); check("f.float", "1.500000", String.format("%f", Float.valueOf(1.5f))); check("f.floatImprecise", "1.100000", String.format("%f", Float.valueOf(1.1f))); + check("f.floatTenDecimals", "1.1000000238", String.format("%.10f", Float.valueOf(1.1f))); + check("f.floatSeventh", "0.1428571492", String.format("%.10f", Float.valueOf(0.14285715f))); check("f.nan", "NaN", String.format("%f", Double.valueOf(Double.NaN))); check("f.infinity", "Infinity", String.format("%f", Double.valueOf(Double.POSITIVE_INFINITY))); check("f.negativeInfinity", "-Infinity", String.format("%f", Double.valueOf(Double.NEGATIVE_INFINITY))); @@ -189,6 +191,8 @@ public boolean runTest() { // ---- malformed formats must raise the same exception everywhere ---- checkThrows("bad.unknownConversion", "java.util.UnknownFormatConversionException", () -> String.format("%q", "x")); + checkThrows("bad.unknownConversionNoArgs", "java.util.UnknownFormatConversionException", () -> String.format("%q")); + checkThrows("bad.groupingOnHexNoArgs", "java.util.FormatFlagsConversionMismatchException", () -> String.format("%,x")); checkThrows("bad.trailingPercent", "java.util.UnknownFormatConversionException", () -> String.format("abc%")); checkThrows("bad.missingArgument", "java.util.MissingFormatArgumentException", () -> String.format("%s %s", "only")); checkThrows("bad.noArguments", "java.util.MissingFormatArgumentException", () -> String.format("%s")); diff --git a/scripts/hellocodenameone/tools/generate-string-format-cases.java b/scripts/hellocodenameone/tools/GenerateStringFormatCases.java similarity index 97% rename from scripts/hellocodenameone/tools/generate-string-format-cases.java rename to scripts/hellocodenameone/tools/GenerateStringFormatCases.java index faabcb5d2c8..50e73f7932d 100644 --- a/scripts/hellocodenameone/tools/generate-string-format-cases.java +++ b/scripts/hellocodenameone/tools/GenerateStringFormatCases.java @@ -31,7 +31,7 @@ * than something hand-written. * *
- *   java scripts/hellocodenameone/tools/generate-string-format-cases.java
+ *   java scripts/hellocodenameone/tools/GenerateStringFormatCases.java
  * 
* * Paste the emitted block over the corresponding block in StringFormatTest.java. @@ -233,6 +233,9 @@ public static void main(String[] args) { c("f.tiny", "%f", Double.valueOf(1e-10)); c("f.float", "%f", Float.valueOf(1.5f)); c("f.floatImprecise", "%f", Float.valueOf(1.1f)); + // A float must format at its binary32 value, which only shows past six decimals. + c("f.floatTenDecimals", "%.10f", Float.valueOf(1.1f)); + c("f.floatSeventh", "%.10f", Float.valueOf(1.0f / 7.0f)); c("f.nan", "%f", Double.valueOf(Double.NaN)); c("f.infinity", "%f", Double.valueOf(Double.POSITIVE_INFINITY)); c("f.negativeInfinity", "%f", Double.valueOf(Double.NEGATIVE_INFINITY)); @@ -296,6 +299,9 @@ public static void main(String[] args) { LINES.add(""); LINES.add(" // ---- malformed formats must raise the same exception everywhere ----"); bad("bad.unknownConversion", "%q", "x"); + // The conversion is validated before an argument is looked for. + bad("bad.unknownConversionNoArgs", "%q"); + bad("bad.groupingOnHexNoArgs", "%,x"); bad("bad.trailingPercent", "abc%"); bad("bad.missingArgument", "%s %s", "only"); bad("bad.noArguments", "%s"); diff --git a/vm/JavaAPI/src/java/lang/StringFormatter.java b/vm/JavaAPI/src/java/lang/StringFormatter.java index a61132fdb0a..3af9015b957 100644 --- a/vm/JavaAPI/src/java/lang/StringFormatter.java +++ b/vm/JavaAPI/src/java/lang/StringFormatter.java @@ -198,6 +198,11 @@ static String format(String format, Object[] args) { // format(fmt, (Object[]) null) is not an empty argument list: the JVM skips // the bounds checks and hands every specifier a null. "%<" is the exception // -- it reuses the previous argument, so there must have been one either way. + // The JVM validates the specifier before it looks for an argument: "%q" with + // no arguments is an unknown conversion, not a missing argument. + boolean upper = conversion >= 'A' && conversion <= 'Z'; + char lower = validateSpecifier(conversion, upper, flags, width, precision); + Object arg; if (previous) { if (lastArg < 0 || (args != null && lastArg >= args.length)) { @@ -218,13 +223,17 @@ static String format(String format, Object[] args) { arg = args == null ? null : args[nextArg]; nextArg++; } - out.append(convert(conversion, arg, flags, width, precision)); + out.append(convert(conversion, lower, upper, arg, flags, width, precision)); } return out.toString(); } - private static String convert(char conversion, Object arg, int flags, int width, int precision) { - boolean upper = conversion >= 'A' && conversion <= 'Z'; + /** + * Everything the JVM rejects before it selects an argument. Returns the lowercase + * conversion so the caller does not recompute it. + */ + private static char validateSpecifier(char conversion, boolean upper, int flags, + int width, int precision) { if (upper && "SBHCXEG".indexOf(conversion) < 0) { // 'D' and 'O' have no uppercase form in java.util.Formatter. throw new UnknownFormatConversionException(String.valueOf(conversion)); @@ -236,8 +245,18 @@ private static String convert(char conversion, Object arg, int flags, int width, throw new UnknownFormatConversionException(String.valueOf(conversion)); } checkFlags(conversion, lower, flags, width, precision); + return lower; + } + + private static String convert(char conversion, char lower, boolean upper, Object arg, + int flags, int width, int precision) { switch (lower) { case 's': + // '#' on a string is a print-time check on the JVM, so a missing argument + // outranks it while a null argument does not. + if ((flags & FLAG_HASH) != 0) { + throw new FormatFlagsConversionMismatchException("#", conversion); + } return text(arg == null ? "null" : arg.toString(), upper, flags, width, precision); case 'b': { String value; @@ -316,14 +335,13 @@ private static void checkTextFlags(char conversion, int flags, int width, int pr private static void checkFlags(char conversion, char lower, int flags, int width, int precision) { if (lower == 's' || lower == 'b' || lower == 'h') { // '#' on a boolean or hash code is reported ahead of the width check; on a - // string it is reported after the other flags. + // string it is deferred to print time (see convert). if ((flags & FLAG_HASH) != 0 && lower != 's') { throw new FormatFlagsConversionMismatchException("#", conversion); } failMissingWidth(conversion, flags, width, FLAG_MINUS); failMismatch(conversion, flags, FLAG_PLUS | FLAG_SPACE | FLAG_ZERO | FLAG_COMMA | FLAG_PAREN); - failMismatch(conversion, flags, FLAG_HASH); return; } if (lower == 'c') { @@ -464,7 +482,10 @@ private static String floatingPoint(Object arg, char conversion, char lower, boo if (arg instanceof Double) { value = ((Double) arg).doubleValue(); } else if (arg instanceof Float) { - value = ((Float) arg).floatValue(); + // The JavaScript backend treats D2F as a no-op, so a float there is still an + // unrounded double until it goes through the bit conversions. On every other + // target this round trip is the identity. + value = Float.intBitsToFloat(Float.floatToIntBits(((Float) arg).floatValue())); } else { throw new IllegalFormatConversionException(conversion, arg.getClass()); } diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java index bc66cd31640..982ea318f8d 100644 --- a/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java @@ -157,6 +157,10 @@ private static void floats() { f("f.tiny", "%f", Double.valueOf(1e-10)); f("f.float", "%f", Float.valueOf(1.5f)); f("f.floatImprecise", "%f", Float.valueOf(1.1f)); + // A Float must format at its binary32 value; the divergence only shows past six + // decimals, which is why the six-decimal default hid it. + f("f.floatTenDecimals", "%.10f", Float.valueOf(1.1f)); + f("f.floatSeventh", "%.10f", Float.valueOf(1.0f / 7.0f)); f("f.nan", "%f", Double.valueOf(Double.NaN)); f("f.infinity", "%f", Double.valueOf(Double.POSITIVE_INFINITY)); f("f.negativeInfinity", "%f", Double.valueOf(Double.NEGATIVE_INFINITY)); @@ -212,6 +216,12 @@ private static void floats() { private static void failures() { f("bad.unknownConversion", "%q", "x"); + // The conversion is validated before an argument is looked for, so a bad + // conversion with no arguments is an unknown conversion, not a missing argument. + f("bad.unknownConversionNoArgs", "%q"); + f("bad.groupingOnHexNoArgs", "%,x"); + f("bad.zeroPadOnStringNoArgs", "%08s"); + f("bad.precisionOnDNoArgs", "%.2d"); f("bad.trailingPercent", "abc%"); f("bad.missingArgument", "%s %s", "only"); f("bad.noArguments", "%s"); From 1514d3e431e433ed6f33251e54b3b40792ff2a04 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:43:46 +0700 Subject: [PATCH 7/7] Report a character's invalid flags before its missing width, and sweep exhaustively The reported case is real and JDK 11, 17 and 25 agree on it: for a character conversion the JVM reports an unsupported flag ahead of the missing width, which is the opposite order from every other conversion. "%-0c" -> FormatFlagsConversionMismatchException (not MissingFormatWidth) "%-0s" -> MissingFormatWidthException "%-0d" -> MissingFormatWidthException "%-#b" -> FormatFlagsConversionMismatchException Six variants were wrong; the character branch now checks flags first. Finding it this way was the fourth validation-ordering defect review has caught that the curated tables could not, so this adds the net that finds them automatically instead. StringFormatConformanceTest copies the formatter and its exceptions into a neutral package -- nothing can load a java.lang class otherwise -- compiles them, and drives about a million single-specifier format strings through both it and the JDK, comparing the exact rendering or the exact exception. Single-specifier is deliberate: the JVM validates a whole format string before formatting any of it, so a format with several broken specifiers can legitimately report a different one first, and restricting to one specifier removes that variable. It immediately found a fifth defect nobody had reported: the JVM rejects a wrong argument type for %x and %o ahead of the sign-flag check it defers to print time, so "%+x" with a String raised FormatFlagsConversionMismatchException here where the JVM raises IllegalFormatConversionException. 1470 cases. Fixed by accepting the argument's type before applying the deferred flags. The sweep is now zero mismatches on JDK 8, 11, 17, 21 and 25. Java 8 ignores a width on the literal "%" conversion where Java 9 honours it, so the test detects that at runtime rather than pinning a version, and asserts a modern JDK skips nothing. Confirmed the test fails when the code is wrong by restoring the character bug: it reports "%-0c" by name. Regression cases for both orderings added to the device and differential tables. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/StringFormatTest.java | 5 + .../tools/GenerateStringFormatCases.java | 8 + vm/JavaAPI/src/java/lang/StringFormatter.java | 16 +- .../StringFormatConformanceTest.java | 246 ++++++++++++++++++ .../tools/translator/StringFormatApp.java | 8 + 5 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 vm/tests/src/test/java/com/codename1/tools/translator/StringFormatConformanceTest.java diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java index 1c321c38db7..e134f98d4f0 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java @@ -193,6 +193,11 @@ public boolean runTest() { checkThrows("bad.unknownConversion", "java.util.UnknownFormatConversionException", () -> String.format("%q", "x")); checkThrows("bad.unknownConversionNoArgs", "java.util.UnknownFormatConversionException", () -> String.format("%q")); checkThrows("bad.groupingOnHexNoArgs", "java.util.FormatFlagsConversionMismatchException", () -> String.format("%,x")); + checkThrows("bad.charFlagBeforeWidth", "java.util.FormatFlagsConversionMismatchException", () -> String.format("%-0c", Character.valueOf('a'))); + checkThrows("bad.charAltBeforeWidth", "java.util.FormatFlagsConversionMismatchException", () -> String.format("%-#c", Character.valueOf('a'))); + checkThrows("bad.stringWidthBeforeFlag", "java.util.MissingFormatWidthException", () -> String.format("%-0s", "a")); + checkThrows("bad.hexWrongTypeBeforeFlag", "java.util.IllegalFormatConversionException", () -> String.format("%+x", "text")); + checkThrows("bad.octalWrongTypeBeforeFlag", "java.util.IllegalFormatConversionException", () -> String.format("%(o", Double.valueOf(1.5))); checkThrows("bad.trailingPercent", "java.util.UnknownFormatConversionException", () -> String.format("abc%")); checkThrows("bad.missingArgument", "java.util.MissingFormatArgumentException", () -> String.format("%s %s", "only")); checkThrows("bad.noArguments", "java.util.MissingFormatArgumentException", () -> String.format("%s")); diff --git a/scripts/hellocodenameone/tools/GenerateStringFormatCases.java b/scripts/hellocodenameone/tools/GenerateStringFormatCases.java index 50e73f7932d..7204cc78b7a 100644 --- a/scripts/hellocodenameone/tools/GenerateStringFormatCases.java +++ b/scripts/hellocodenameone/tools/GenerateStringFormatCases.java @@ -302,6 +302,14 @@ public static void main(String[] args) { // The conversion is validated before an argument is looked for. bad("bad.unknownConversionNoArgs", "%q"); bad("bad.groupingOnHexNoArgs", "%,x"); + // The JVM reports an unsupported flag on a character ahead of the missing width, + // which is the opposite order from every other conversion. + bad("bad.charFlagBeforeWidth", "%-0c", Character.valueOf('a')); + bad("bad.charAltBeforeWidth", "%-#c", Character.valueOf('a')); + bad("bad.stringWidthBeforeFlag", "%-0s", "a"); + // A wrong argument type outranks the deferred sign-flag check on %x and %o. + bad("bad.hexWrongTypeBeforeFlag", "%+x", "text"); + bad("bad.octalWrongTypeBeforeFlag", "%(o", Double.valueOf(1.5)); bad("bad.trailingPercent", "abc%"); bad("bad.missingArgument", "%s %s", "only"); bad("bad.noArguments", "%s"); diff --git a/vm/JavaAPI/src/java/lang/StringFormatter.java b/vm/JavaAPI/src/java/lang/StringFormatter.java index 3af9015b957..b9276d6f45c 100644 --- a/vm/JavaAPI/src/java/lang/StringFormatter.java +++ b/vm/JavaAPI/src/java/lang/StringFormatter.java @@ -348,9 +348,12 @@ private static void checkFlags(char conversion, char lower, int flags, int width if (precision >= 0) { throw new IllegalFormatPrecisionException(precision); } - failMissingWidth(conversion, flags, width, FLAG_MINUS); + // Unlike every other conversion, the JVM reports an unsupported flag on a + // character ahead of the missing width: "%-0c" is a flag mismatch, not a + // missing width, even though '-' has no width to justify against. failMismatch(conversion, flags, FLAG_PLUS | FLAG_SPACE | FLAG_ZERO | FLAG_COMMA | FLAG_PAREN | FLAG_HASH); + failMissingWidth(conversion, flags, width, FLAG_MINUS); return; } // Numeric conversions: zero padding is meaningful, so it needs a width too. @@ -443,11 +446,6 @@ private static String radix(Object arg, char conversion, int shift, boolean uppe if (arg == null) { return text("null", upper, flags, width, precision); } - // The JVM defers these to print time, so a null argument outranks them. - int printTimeIllegal = flags & (FLAG_PAREN | FLAG_SPACE | FLAG_PLUS); - if (printTimeIllegal != 0) { - throw new FormatFlagsConversionMismatchException(flagString(printTimeIllegal), conversion); - } long value; if (arg instanceof Long) { value = ((Long) arg).longValue(); @@ -460,6 +458,12 @@ private static String radix(Object arg, char conversion, int shift, boolean uppe } else { throw new IllegalFormatConversionException(conversion, arg.getClass()); } + // The JVM defers these to print time, after it has accepted the argument's type, + // so both a null and a wrong-typed argument outrank them. + int printTimeIllegal = flags & (FLAG_PAREN | FLAG_SPACE | FLAG_PLUS); + if (printTimeIllegal != 0) { + throw new FormatFlagsConversionMismatchException(flagString(printTimeIllegal), conversion); + } String digits = unsigned(value, shift); String prefix = ""; if ((flags & FLAG_HASH) != 0) { diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/StringFormatConformanceTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/StringFormatConformanceTest.java new file mode 100644 index 00000000000..a98cb0e1a95 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/StringFormatConformanceTest.java @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.tools.translator; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Stream; + +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exhaustive conformance for {@code java.lang.StringFormatter} against the JDK's own + * {@code java.util.Formatter} (issue #5482). + * + *

The formatter lives in {@code java.lang}, so no ordinary test can load it -- a + * class loader refuses any name starting with {@code java.}. This test copies the + * JavaAPI sources into a neutral package, compiles them, and drives the result through + * reflection, which makes a million-case differential possible in a plain unit test.

+ * + *

It walks every combination of the eight flags, the supported conversions, a set of + * widths and precisions, and a set of argument types: roughly a million single-specifier + * format strings, each compared against the JDK for the exact rendering or the exact + * exception. Single-specifier is deliberate -- the JVM validates a whole format string + * before formatting any of it, so a format with several broken specifiers can legitimately + * report a different one first. Restricting to one specifier removes that variable, so + * every mismatch here is a real defect.

+ * + *

This exists because the curated tables did not catch several validation-ordering + * defects that review did: the JVM reports an unsupported flag on {@code %c} ahead of a + * missing width but the other way round for {@code %s}, and it rejects a wrong argument + * type for {@code %x} ahead of an unsupported sign flag. Ordering rules like those are + * not something a hand-written table finds.

+ */ +class StringFormatConformanceTest { + + private static final char[] FLAGS = {'-', '+', ' ', '0', ',', '(', '#', '<'}; + private static final String[] CONVERSIONS = { + "s", "S", "b", "B", "h", "H", "c", "C", "d", "o", "x", "X", "e", "E", "f", "g", "G", "n", "%" + }; + private static final String[] WIDTHS = {"", "0", "5", "12"}; + private static final String[] PRECISIONS = {"", ".0", ".2", ".6"}; + + /** + * {@code %a} and {@code %t} are unimplemented by design and raise + * UnknownFormatConversionException; they are covered by StringFormatIntegrationTest + * and deliberately absent from CONVERSIONS. + */ + @Test + void everySingleSpecifierMatchesTheJdk() throws Exception { + Method format = loadFormatter(); + + Object[] arguments = { + "txt", "", Integer.valueOf(42), Integer.valueOf(-42), Integer.valueOf(0), + Long.valueOf(-1L), Long.valueOf(Long.MIN_VALUE), Double.valueOf(1.5), + Double.valueOf(-0.0), Double.valueOf(1.0 / 3.0), Double.valueOf(Double.NaN), + Double.valueOf(Double.POSITIVE_INFINITY), Float.valueOf(1.1f), + Character.valueOf('a'), Boolean.TRUE, null, Byte.valueOf((byte) -1), + Short.valueOf((short) -1) + }; + + // The JDK formats through the default locale; this formatter is locale + // independent by design, so compare on the locale whose separators it uses. + // Java 8 ignores a width on the literal "%" conversion; Java 9 honours it, and + // this formatter follows the modern behaviour. Detect which JDK is running the + // test rather than pinning a version, and account for the skips explicitly. + boolean legacyPercentWidth = "%".equals(String.format("%5%")); + + Locale previous = Locale.getDefault(); + Locale.setDefault(Locale.ROOT); + long total = 0; + long skipped = 0; + Map mismatches = new LinkedHashMap<>(); + try { + for (int mask = 0; mask < (1 << FLAGS.length); mask++) { + StringBuilder flags = new StringBuilder(); + for (int bit = 0; bit < FLAGS.length; bit++) { + if ((mask & (1 << bit)) != 0) { + flags.append(FLAGS[bit]); + } + } + for (String conversion : CONVERSIONS) { + for (String width : WIDTHS) { + for (String precision : PRECISIONS) { + String spec = "%" + flags + width + precision + conversion; + boolean legacySkip = legacyPercentWidth && "%".equals(conversion) + && !width.isEmpty() && !"0".equals(width); + for (Object argument : arguments) { + if (legacySkip) { + skipped++; + continue; + } + total++; + String expected = referenceOutcome(spec, argument); + String actual = actualOutcome(format, spec, argument); + if (!expected.equals(actual) && mismatches.size() < 25) { + mismatches.put(spec + " <- " + describeArgument(argument), + "jdk=" + expected + " cn1=" + actual); + } else if (!expected.equals(actual)) { + mismatches.put("(further mismatches suppressed)", ""); + } + } + } + } + } + } + } finally { + Locale.setDefault(previous); + } + + assertTrue(total > 900000, "expected the full sweep to run, only " + total + " cases did"); + assertTrue(legacyPercentWidth || skipped == 0, + "a modern JDK should not skip any case, skipped " + skipped); + StringBuilder report = new StringBuilder(); + for (Map.Entry entry : mismatches.entrySet()) { + report.append("\n ").append(entry.getKey()).append(" ").append(entry.getValue()); + } + assertEquals("", report.toString(), + mismatches.size() + " of " + total + " single-specifier cases diverged from the JDK:"); + } + + private static String describeArgument(Object argument) { + if (argument == null) { + return "null"; + } + return argument.getClass().getSimpleName() + " " + argument; + } + + /** The JDK's own answer: either the rendering or the exception it raises. */ + private static String referenceOutcome(String spec, Object argument) { + try { + return "V:" + String.format(spec, new Object[] { argument }); + } catch (Throwable t) { + return "E:" + t.getClass().getSimpleName(); + } + } + + private static String actualOutcome(Method format, String spec, Object argument) { + try { + return "V:" + format.invoke(null, spec, new Object[] { argument }); + } catch (InvocationTargetException e) { + return "E:" + e.getCause().getClass().getSimpleName(); + } catch (Exception e) { + return "E:" + e.getClass().getSimpleName(); + } + } + + /** + * Copies java.lang.StringFormatter and the java.util format exceptions it uses into a + * neutral package, compiles them, and returns the relocated {@code format} method. The + * rewrite is textual and deliberately narrow: only the package and import statements + * move, so the logic under test is the shipping source. + */ + private Method loadFormatter() throws Exception { + Path javaApi = Paths.get("..", "JavaAPI", "src").normalize().toAbsolutePath(); + assertTrue(Files.isDirectory(javaApi), "JavaAPI sources should be at " + javaApi); + + Path work = Files.createTempDirectory("string-format-conformance"); + Path pkg = work.resolve("src").resolve("cn1format"); + Files.createDirectories(pkg); + + List files = new ArrayList<>(); + files.add(relocate(javaApi.resolve("java/lang/StringFormatter.java"), pkg)); + try (Stream paths = Files.list(javaApi.resolve("java/util"))) { + for (Path candidate : (Iterable) paths.sorted()::iterator) { + String name = candidate.getFileName().toString(); + if (name.endsWith("FormatException.java") || name.contains("Format") + && name.endsWith("Exception.java")) { + files.add(relocate(candidate, pkg)); + } + } + } + assertTrue(files.size() > 5, "expected the format exceptions to be relocated, found " + files); + + Path classes = work.resolve("classes"); + Files.createDirectories(classes); + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "a JDK (not a JRE) is required to run this test"); + List args = new ArrayList<>(); + args.add("-nowarn"); + args.add("-d"); + args.add(classes.toString()); + args.addAll(files); + assertEquals(0, compiler.run(null, null, System.err, args.toArray(new String[0])), + "the relocated formatter should compile"); + + URLClassLoader loader = new URLClassLoader(new URL[] { classes.toUri().toURL() }, + getClass().getClassLoader()); + Class formatter = loader.loadClass("cn1format.StringFormatter"); + Method format = formatter.getDeclaredMethod("format", String.class, Object[].class); + format.setAccessible(true); + return format; + } + + private String relocate(Path source, Path targetDirectory) throws IOException { + assertTrue(Files.isRegularFile(source), "expected to relocate " + source); + String text = new String(Files.readAllBytes(source), StandardCharsets.UTF_8); + text = text.replace("package java.lang;", "package cn1format;") + .replace("package java.util;", "package cn1format;") + .replace("import java.util.", "import cn1format."); + // Package private in the shipping source; reflection needs it reachable here. + text = text.replace("final class StringFormatter", "public final class StringFormatter"); + Path target = targetDirectory.resolve(source.getFileName().toString()); + Files.write(target, text.getBytes(StandardCharsets.UTF_8)); + return target.toString(); + } +} diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java index 982ea318f8d..c7609d29b2d 100644 --- a/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java @@ -220,6 +220,14 @@ private static void failures() { // conversion with no arguments is an unknown conversion, not a missing argument. f("bad.unknownConversionNoArgs", "%q"); f("bad.groupingOnHexNoArgs", "%,x"); + // Validation order: a character reports an unsupported flag ahead of the missing + // width, the opposite of every other conversion, and a wrong argument type + // outranks the deferred sign-flag check on %x and %o. + f("bad.charFlagBeforeWidth", "%-0c", Character.valueOf('a')); + f("bad.charAltBeforeWidth", "%-#c", Character.valueOf('a')); + f("bad.stringWidthBeforeFlag", "%-0s", "a"); + f("bad.hexWrongTypeBeforeFlag", "%+x", "text"); + f("bad.octalWrongTypeBeforeFlag", "%(o", Double.valueOf(1.5)); f("bad.zeroPadOnStringNoArgs", "%08s"); f("bad.precisionOnDNoArgs", "%.2d"); f("bad.trailingPercent", "abc%");