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..e134f98d4f0 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StringFormatTest.java @@ -0,0 +1,358 @@ +/* + * 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/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.
+ * + *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; + private int problemCount; + private final StringBuilder problems = new StringBuilder(); + + @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 %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) { + if (!(t instanceof IllegalArgumentException)) { + problem("case '" + label + "' raised " + t.getClass().getName() + + " instead of an IllegalArgumentException (Java SE raises " + + simpleName(javaSeType) + ")"); + } + return; + } + problem("case '" + label + "' returned " + describe(result) + + " instead of rejecting the format (Java SE raises " + simpleName(javaSeType) + ")"); + } + + 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/GenerateStringFormatCases.java b/scripts/hellocodenameone/tools/GenerateStringFormatCases.java new file mode 100644 index 00000000000..7204cc78b7a --- /dev/null +++ b/scripts/hellocodenameone/tools/GenerateStringFormatCases.java @@ -0,0 +1,333 @@ +/* + * 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/GenerateStringFormatCases.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: + * + *
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 { + + private static final ListThis 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); + if (argIndex == 0) { + // Argument indexes are 1-based; "%0$s" has no argument to select. + throw new IllegalFormatArgumentIndexException(argIndex); + } + pos = digitsEnd + 1; + } + + int flags = 0; + 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 == '<') { + flag = FLAG_PREVIOUS; + } else { + break; + } + if ((flags & flag) != 0) { + throw new DuplicateFormatFlagsException(String.valueOf(f)); + } + flags |= flag; + pos++; + } + boolean previous = (flags & FLAG_PREVIOUS) != 0; + + 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++; + } + if (pos == precisionStart) { + // "%.s" is malformed; the JVM reports the '.' as the conversion. + throw new UnknownFormatConversionException("."); + } + precision = parseNumber(format, precisionStart, pos); + } + + if (pos >= len) { + throw new UnknownFormatConversionException(format.substring(specStart + 1)); + } + char conversion = format.charAt(pos); + pos++; + + if (conversion == '%' || conversion == 'n') { + 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. + // 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)) { + throw new MissingFormatArgumentException(format.substring(specStart, pos)); + } + arg = args == null ? null : args[lastArg]; + } else if (argIndex > 0) { + if (args != null && argIndex > args.length) { + throw new MissingFormatArgumentException(format.substring(specStart, pos)); + } + lastArg = argIndex - 1; + arg = args == null ? null : args[lastArg]; + } else { + if (args != null && nextArg >= args.length) { + throw new MissingFormatArgumentException(format.substring(specStart, pos)); + } + lastArg = nextArg; + arg = args == null ? null : args[nextArg]; + nextArg++; + } + out.append(convert(conversion, lower, upper, arg, flags, width, precision)); + } + return out.toString(); + } + + /** + * 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)); + } + 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); + 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; + 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 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); + return; + } + if (lower == 'c') { + if (precision >= 0) { + throw new IllegalFormatPrecisionException(precision); + } + // 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. + 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); + } + 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()); + } + // 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) { + 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) { + // 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()); + } + 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/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/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..d20fbe2510b --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/GetClassIntegrationTest.java @@ -0,0 +1,204 @@ +/* + * 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.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); + + ListThe 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{@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); + + ListEvery 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..c7609d29b2d --- /dev/null +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/StringFormatApp.java @@ -0,0 +1,285 @@ +/* + * 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. + * + * 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 %