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 % 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")); + 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; + } + 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 + * 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); + if (!localised.equals(actual)) { + problem("case '" + label + "' expected " + describe(localised) + + " but was " + describe(actual)); + } + } + + /** + * 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) { + 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: + * + * + * + * 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 { + + 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 and must be rejected on every port. */ + 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 %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..b9276d6f45c --- /dev/null +++ b/vm/JavaAPI/src/java/lang/StringFormatter.java @@ -0,0 +1,785 @@ +/* + * 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.IllegalFormatArgumentIndexException; +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); + 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); + + 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/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/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..d6b050ea627 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/StringFormatIntegrationTest.java @@ -0,0 +1,227 @@ +/* + * 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; + +/** + * 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)); + + // 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(expectedCn1Only, unsupported, + "Unsupported and version-dependent specifiers must 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..08925585f82 --- /dev/null +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/GetClassApp.java @@ -0,0 +1,109 @@ +/* + * 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; + +/** + * 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..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 %