From 6c37af993bf9b074fc2d6a5b2760ae33256d4b25 Mon Sep 17 00:00:00 2001 From: Muhammad Askri Date: Mon, 3 Aug 2026 15:53:36 -0700 Subject: [PATCH] Implement `strings.format` in CEL string extensions. PiperOrigin-RevId: 958621592 --- .../src/main/java/dev/cel/common/BUILD.bazel | 1 - .../test/java/dev/cel/conformance/BUILD.bazel | 7 - .../main/java/dev/cel/extensions/BUILD.bazel | 7 + .../cel/extensions/CelStringExtensions.java | 454 ++++++++++++++++++ .../dev/cel/extensions/CelExtensionsTest.java | 15 +- .../extensions/CelStringExtensionsTest.java | 423 ++++++++++++++-- 6 files changed, 855 insertions(+), 52 deletions(-) diff --git a/common/src/main/java/dev/cel/common/BUILD.bazel b/common/src/main/java/dev/cel/common/BUILD.bazel index 11b762220..173772e97 100644 --- a/common/src/main/java/dev/cel/common/BUILD.bazel +++ b/common/src/main/java/dev/cel/common/BUILD.bazel @@ -108,7 +108,6 @@ java_library( ], deps = [ "//:auto_value", - "//common/annotations", "@maven//:com_google_errorprone_error_prone_annotations", ], ) diff --git a/conformance/src/test/java/dev/cel/conformance/BUILD.bazel b/conformance/src/test/java/dev/cel/conformance/BUILD.bazel index 4abc705c3..26741faff 100644 --- a/conformance/src/test/java/dev/cel/conformance/BUILD.bazel +++ b/conformance/src/test/java/dev/cel/conformance/BUILD.bazel @@ -120,9 +120,6 @@ _TESTS_TO_SKIP_LEGACY = [ # Skip until fixed. "fields/qualified_identifier_resolution/map_value_repeat_key_heterogeneous", - # TODO: Add strings.format.quote. - "string_ext/format", - "string_ext/format_errors", # Future features for CEL 1.0 # TODO: Strong typing support for enums, specified but not implemented. @@ -148,10 +145,6 @@ _TESTS_TO_SKIP_LEGACY = [ ] _TESTS_TO_SKIP_PLANNER = [ - # TODO: Add strings.format. - "string_ext/format", - "string_ext/format_errors", - # TODO: This is actually a user experience degradation. # Not worth fixing until we see a concrete need. "basic/functions/unbound_is_runtime_error", diff --git a/extensions/src/main/java/dev/cel/extensions/BUILD.bazel b/extensions/src/main/java/dev/cel/extensions/BUILD.bazel index 8b7991cc0..18737957b 100644 --- a/extensions/src/main/java/dev/cel/extensions/BUILD.bazel +++ b/extensions/src/main/java/dev/cel/extensions/BUILD.bazel @@ -88,8 +88,14 @@ java_library( deps = [ "//checker:checker_builder", "//common:compiler_common", + "//common/exceptions:bad_format", + "//common/exceptions:invalid_argument", "//common/internal", + "//common/internal:date_time_helpers", "//common/types", + "//common/types:type_providers", + "//common/values", + "//common/values:cel_byte_string", "//compiler:compiler_builder", "//extensions:extension_library", "//runtime", @@ -97,6 +103,7 @@ java_library( "//runtime:function_binding", "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:com_google_guava_guava", + "@maven_android//:com_google_protobuf_protobuf_javalite", ], ) diff --git a/extensions/src/main/java/dev/cel/extensions/CelStringExtensions.java b/extensions/src/main/java/dev/cel/extensions/CelStringExtensions.java index 2bb477b82..be732f453 100644 --- a/extensions/src/main/java/dev/cel/extensions/CelStringExtensions.java +++ b/extensions/src/main/java/dev/cel/extensions/CelStringExtensions.java @@ -17,26 +17,44 @@ import static com.google.common.collect.ImmutableSet.toImmutableSet; import static java.lang.Math.max; import static java.lang.Math.min; +import static java.nio.charset.StandardCharsets.UTF_8; import com.google.common.base.Ascii; import com.google.common.base.Joiner; +import com.google.common.base.Preconditions; import com.google.common.base.Splitter; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; +import com.google.common.io.BaseEncoding; +import com.google.common.primitives.UnsignedLong; import com.google.errorprone.annotations.Immutable; +import com.google.protobuf.ByteString; import dev.cel.checker.CelCheckerBuilder; import dev.cel.common.CelFunctionDecl; import dev.cel.common.CelOverloadDecl; +import dev.cel.common.exceptions.CelBadFormatException; +import dev.cel.common.exceptions.CelInvalidArgumentException; import dev.cel.common.internal.CelCodePointArray; +import dev.cel.common.internal.DateTimeHelpers; +import dev.cel.common.types.CelType; import dev.cel.common.types.ListType; import dev.cel.common.types.SimpleType; +import dev.cel.common.types.TypeType; +import dev.cel.common.values.CelByteString; +import dev.cel.common.values.NullValue; import dev.cel.compiler.CelCompilerLibrary; import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelEvaluationExceptionBuilder; import dev.cel.runtime.CelFunctionBinding; import dev.cel.runtime.CelRuntimeBuilder; import dev.cel.runtime.CelRuntimeLibrary; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Set; /** Internal implementation of CEL string extensions. */ @@ -44,6 +62,21 @@ public final class CelStringExtensions implements CelCompilerLibrary, CelRuntimeLibrary, CelExtensionLibrary.FeatureSet { + // ROOT is equivalent to US locale. Formatting should be machine-oriented, not UI-oriented. + private static final Locale LOCALE_ROOT = Locale.ROOT; + + // TODO: Make max precision limit configurable via CelStringExtensions options. + private static final int MAX_PRECISION = 100; + + // Constants for Long.MIN_VALUE because negating it to find its absolute value overflows in signed + // 64-bit arithmetic. + private static final String MIN_LONG_BINARY = + "-100000000000000000000000000000000000000000000000000000000000000"; + private static final String MIN_LONG_HEX = "-8000000000000000"; + private static final String MIN_LONG_OCTAL = "-1000000000000000000000"; + + private static final BaseEncoding BASE16_LOWER = BaseEncoding.base16().lowerCase(); + /** Denotes the string extension function */ @SuppressWarnings({"unchecked"}) // Unchecked: Type-checker guarantees casting safety. public enum Function { @@ -58,6 +91,16 @@ public enum Function { ImmutableList.of(SimpleType.STRING, SimpleType.INT))), CelFunctionBinding.from( "string_char_at_int", String.class, Long.class, CelStringExtensions::charAt)), + FORMAT( + CelFunctionDecl.newFunctionDeclaration( + "format", + CelOverloadDecl.newMemberOverload( + "string_format", + "Formats the string using the provided arguments.", + SimpleType.STRING, + ImmutableList.of(SimpleType.STRING, ListType.create(SimpleType.DYN)))), + CelFunctionBinding.from( + "string_format", String.class, List.class, CelStringExtensions::format)), INDEX_OF( CelFunctionDecl.newFunctionDeclaration( "indexOf", @@ -404,6 +447,417 @@ private static String join(List stringList, String separator) { return Joiner.on(separator).join(stringList); } + private static String format(String formatSpecifier, List args) { + StringBuilder builtStr = new StringBuilder(formatSpecifier.length()); + int i = 0; + int argIndex = 0; + while (i < formatSpecifier.length()) { + if (formatSpecifier.charAt(i) != '%') { + builtStr.append(formatSpecifier.charAt(i++)); + continue; + } + + if (i + 1 < formatSpecifier.length() && formatSpecifier.charAt(i + 1) == '%') { + builtStr.append('%'); + i += 2; + continue; + } + + if (argIndex >= args.size()) { + throw new CelBadFormatException("index " + argIndex + " out of range"); + } + + Object arg = args.get(argIndex++); + i = parseAndFormatClause(formatSpecifier, i + 1, arg, builtStr); + } + return builtStr.toString(); + } + + /** + * Parses and formats a single format clause after '%', starting at index {@code offset}. Returns + * the new index in {@code formatSpecifier} after consuming the clause. + */ + private static int parseAndFormatClause( + String formatSpecifier, int offset, Object arg, StringBuilder builtStr) { + int i = offset; + int precision = -1; + if (i < formatSpecifier.length() && formatSpecifier.charAt(i) == '.') { + i++; + int start = i; + while (i < formatSpecifier.length() && Character.isDigit(formatSpecifier.charAt(i))) { + i++; + } + if (i >= formatSpecifier.length()) { + throw new CelBadFormatException("unexpected end of string"); + } + if (i == start) { + throw new CelBadFormatException("empty precision is not allowed"); + } else { + try { + precision = Integer.parseInt(formatSpecifier.substring(start, i)); + } catch (NumberFormatException e) { + throw new CelBadFormatException( + "invalid precision format: " + formatSpecifier.substring(start, i)); + } + // TODO: Make max precision limit configurable via CelStringExtensions options. + if (precision > MAX_PRECISION) { + throw new CelInvalidArgumentException( + "precision " + precision + " exceeds maximum allowed (" + MAX_PRECISION + ")"); + } + } + } + if (i >= formatSpecifier.length()) { + throw new CelBadFormatException("unexpected end of string"); + } + char verb = formatSpecifier.charAt(i++); + switch (verb) { + case 's': + builtStr.append(formatString(arg)); + break; + case 'd': + builtStr.append(formatDecimal(arg)); + break; + case 'f': + builtStr.append(formatFixed(arg, precision)); + break; + case 'e': + builtStr.append(formatScientific(arg, precision)); + break; + case 'b': + builtStr.append(formatBinary(arg)); + break; + case 'x': + case 'X': + builtStr.append(formatHex(arg, verb == 'X')); + break; + case 'o': + builtStr.append(formatOctal(arg)); + break; + default: + throw new CelBadFormatException("unrecognized formatting clause \"" + verb + "\""); + } + return i; + } + + private static String formatString(Object val) { + Preconditions.checkNotNull(val); + if (val instanceof String) { + return (String) val; + } + if (val instanceof CelByteString) { + return formatByteString((CelByteString) val); + } + if (val instanceof ByteString) { + ByteString byteString = (ByteString) val; + if (byteString.isValidUtf8()) { + return byteString.toStringUtf8(); + } + return decodeUtf8Lossy(byteString.toByteArray()); + } + if (val instanceof Duration) { + return DateTimeHelpers.toString((Duration) val); + } + if (val instanceof Instant + || val instanceof Boolean + || val instanceof Long + || val instanceof UnsignedLong + || val instanceof Double) { + return val.toString(); + } + if (val instanceof List) { + return formatList((List) val); + } + if (val instanceof Map) { + return formatMap((Map) val); + } + if (val instanceof NullValue) { + return "null"; + } + if (val instanceof TypeType) { + return ((TypeType) val).containingTypeName(); + } + if (val instanceof CelType) { + return ((CelType) val).name(); + } + throw new CelInvalidArgumentException( + "could not convert argument " + val.getClass().getName() + " to string"); + } + + private static String formatByteString(CelByteString byteString) { + if (byteString.isValidUtf8()) { + return byteString.toStringUtf8(); + } + return decodeUtf8Lossy(byteString.toByteArray()); + } + + private static String decodeUtf8Lossy(byte[] bytes) { + if (bytes.length == 0) { + return ""; + } + StringBuilder sb = new StringBuilder(bytes.length); + boolean inInvalidSequence = false; + int i = 0; + int len = bytes.length; + + while (i < len) { + int b0 = bytes[i] & 0xFF; + if (b0 < 0x80) { + // 1-byte ASCII (0x00 - 0x7F) + if (inInvalidSequence) { + sb.append('\uFFFD'); + inInvalidSequence = false; + } + sb.append((char) b0); + i++; + } else if (b0 >= 0xC2 && b0 <= 0xDF) { + // 2-byte sequence + if (i + 1 < len) { + int b1 = bytes[i + 1] & 0xFF; + if (b1 >= 0x80 && b1 <= 0xBF) { + if (inInvalidSequence) { + sb.append('\uFFFD'); + inInvalidSequence = false; + } + int codePoint = ((b0 & 0x1F) << 6) | (b1 & 0x3F); + sb.append((char) codePoint); + i += 2; + continue; + } + } + inInvalidSequence = true; + i++; + } else if (b0 >= 0xE0 && b0 <= 0xEF) { + // 3-byte sequence + if (i + 2 < len) { + int b1 = bytes[i + 1] & 0xFF; + int b2 = bytes[i + 2] & 0xFF; + boolean valid = + (b2 >= 0x80 && b2 <= 0xBF) + && ((b0 == 0xE0 && b1 >= 0xA0 && b1 <= 0xBF) + || (b0 >= 0xE1 && b0 <= 0xEC && b1 >= 0x80 && b1 <= 0xBF) + || (b0 == 0xED && b1 >= 0x80 && b1 <= 0x9F) + || (b0 >= 0xEE && b0 <= 0xEF && b1 >= 0x80 && b1 <= 0xBF)); + if (valid) { + if (inInvalidSequence) { + sb.append('\uFFFD'); + inInvalidSequence = false; + } + int codePoint = ((b0 & 0x0F) << 12) | ((b1 & 0x3F) << 6) | (b2 & 0x3F); + sb.append((char) codePoint); + i += 3; + continue; + } + } + inInvalidSequence = true; + i++; + } else if (b0 >= 0xF0 && b0 <= 0xF4) { + // 4-byte sequence + if (i + 3 < len) { + int b1 = bytes[i + 1] & 0xFF; + int b2 = bytes[i + 2] & 0xFF; + int b3 = bytes[i + 3] & 0xFF; + boolean valid = + (b2 >= 0x80 && b2 <= 0xBF) + && (b3 >= 0x80 && b3 <= 0xBF) + && ((b0 == 0xF0 && b1 >= 0x90 && b1 <= 0xBF) + || (b0 >= 0xF1 && b0 <= 0xF3 && b1 >= 0x80 && b1 <= 0xBF) + || (b0 == 0xF4 && b1 >= 0x80 && b1 <= 0x8F)); + if (valid) { + if (inInvalidSequence) { + sb.append('\uFFFD'); + inInvalidSequence = false; + } + int codePoint = + ((b0 & 0x07) << 18) | ((b1 & 0x3F) << 12) | ((b2 & 0x3F) << 6) | (b3 & 0x3F); + sb.append(Character.toChars(codePoint)); + i += 4; + continue; + } + } + inInvalidSequence = true; + i++; + } else { + // Invalid leading byte (0x80-0xC1, 0xF5-0xFF) + inInvalidSequence = true; + i++; + } + } + if (inInvalidSequence) { + sb.append('\uFFFD'); + } + return sb.toString(); + } + + private static String formatList(List list) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < list.size(); i++) { + sb.append(formatString(list.get(i))); + if (i < list.size() - 1) { + sb.append(", "); + } + } + sb.append("]"); + return sb.toString(); + } + + private static class MapEntry { + final String keyStr; + final String valStr; + + MapEntry(String keyStr, String valStr) { + this.keyStr = keyStr; + this.valStr = valStr; + } + } + + private static String formatMap(Map map) { + List entries = new ArrayList<>(map.size()); + for (Map.Entry entry : map.entrySet()) { + entries.add(new MapEntry(formatString(entry.getKey()), formatString(entry.getValue()))); + } + entries.sort(Comparator.comparing(e -> e.keyStr)); + + StringBuilder sb = new StringBuilder("{"); + for (int i = 0; i < entries.size(); i++) { + MapEntry entry = entries.get(i); + sb.append(entry.keyStr).append(": ").append(entry.valStr); + if (i < entries.size() - 1) { + sb.append(", "); + } + } + sb.append("}"); + return sb.toString(); + } + + private static String formatDecimal(Object arg) { + if (arg instanceof Long || arg instanceof UnsignedLong || arg instanceof Double) { + return arg.toString(); + } + throw new CelInvalidArgumentException( + "decimal clause can only be used on numbers, was given " + arg.getClass().getName()); + } + + private static double getDoubleValue(Object arg, String clauseName) { + if (arg instanceof Double) { + return (Double) arg; + } + if (arg instanceof Long) { + return ((Long) arg).doubleValue(); + } + if (arg instanceof UnsignedLong) { + return ((UnsignedLong) arg).doubleValue(); + } + throw new CelInvalidArgumentException( + clauseName + + " clause can only be used on doubles, integers, and unsigned integers, was given " + + arg.getClass().getName()); + } + + private static String formatFixed(Object arg, int precision) { + double val = getDoubleValue(arg, "fixed point"); + if (Double.isNaN(val)) { + return "NaN"; + } + if (Double.isInfinite(val)) { + return val > 0 ? "Infinity" : "-Infinity"; + } + int p = precision >= 0 ? precision : 6; + if (p <= 15 && Math.abs(val) < 1e14) { + double factor = Math.pow(10, p); + val = Math.rint(val * factor) / factor; + } + String fmtStr = "%." + p + "f"; + return String.format(LOCALE_ROOT, fmtStr, val); + } + + private static String formatScientific(Object arg, int precision) { + double val = getDoubleValue(arg, "scientific"); + if (Double.isNaN(val)) { + return "NaN"; + } + if (Double.isInfinite(val)) { + return val > 0 ? "Infinity" : "-Infinity"; + } + String fmtStr = precision >= 0 ? "%." + precision + "e" : "%.6e"; + return String.format(LOCALE_ROOT, fmtStr, val); + } + + private static String formatBinary(Object arg) { + if (arg instanceof Long) { + long val = (Long) arg; + if (val < 0) { + if (val == Long.MIN_VALUE) { + return MIN_LONG_BINARY; + } + return "-" + Long.toBinaryString(-val); + } + return Long.toBinaryString(val); + } + if (arg instanceof UnsignedLong) { + UnsignedLong ulong = (UnsignedLong) arg; + return ulong.toString(2); + } + if (arg instanceof Boolean) { + Boolean b = (Boolean) arg; + return b ? "1" : "0"; + } + throw new CelInvalidArgumentException( + "binary clause can only be used on integers and bools, was given " + + arg.getClass().getName()); + } + + private static String formatHex(Object arg, boolean upper) { + String result; + if (arg instanceof Long) { + long val = (Long) arg; + if (val < 0) { + if (val == Long.MIN_VALUE) { + result = MIN_LONG_HEX; + } else { + result = "-" + Long.toHexString(-val); + } + } else { + result = Long.toHexString(val); + } + } else if (arg instanceof UnsignedLong) { + UnsignedLong unsignedLong = (UnsignedLong) arg; + result = unsignedLong.toString(16); + } else if (arg instanceof CelByteString) { + CelByteString byteString = (CelByteString) arg; + result = BASE16_LOWER.encode(byteString.toByteArray()); + } else if (arg instanceof ByteString) { + ByteString byteString = (ByteString) arg; + result = BASE16_LOWER.encode(byteString.toByteArray()); + } else if (arg instanceof String) { + String str = (String) arg; + result = BASE16_LOWER.encode(str.getBytes(UTF_8)); + } else { + throw new CelInvalidArgumentException( + "hex clause can only be used on integers, byte buffers, and strings, was given " + + arg.getClass().getName()); + } + return upper ? result.toUpperCase(LOCALE_ROOT) : result; + } + + private static String formatOctal(Object arg) { + if (arg instanceof Long) { + long val = (Long) arg; + if (val < 0) { + if (val == Long.MIN_VALUE) { + return MIN_LONG_OCTAL; + } + return "-" + Long.toOctalString(-val); + } + return Long.toOctalString(val); + } + if (arg instanceof UnsignedLong) { + UnsignedLong ulong = (UnsignedLong) arg; + return ulong.toString(8); + } + throw new CelInvalidArgumentException( + "octal clause can only be used on integers, was given " + arg.getClass().getName()); + } + private static Long lastIndexOf(String str, String substr) throws CelEvaluationException { CelCodePointArray strCpa = CelCodePointArray.fromString(str); CelCodePointArray substrCpa = CelCodePointArray.fromString(substr); diff --git a/extensions/src/test/java/dev/cel/extensions/CelExtensionsTest.java b/extensions/src/test/java/dev/cel/extensions/CelExtensionsTest.java index 31c7d65c8..81baf9eb5 100644 --- a/extensions/src/test/java/dev/cel/extensions/CelExtensionsTest.java +++ b/extensions/src/test/java/dev/cel/extensions/CelExtensionsTest.java @@ -96,11 +96,10 @@ public void addStringExtensionsForCompilerOnly_throwsEvaluationException() throw @Test public void addAllMathExtensions_success() throws Exception { - CelOptions celOptions = CelOptions.current().build(); Cel cel = CelFactory.standardCelBuilder() - .addCompilerLibraries(CelExtensions.math(celOptions)) - .addRuntimeLibraries(CelExtensions.math(celOptions)) + .addCompilerLibraries(CelExtensions.math()) + .addRuntimeLibraries(CelExtensions.math()) .build(); String allMathExtExpr = "math.greatest(1, 2.0) == 2.0 && math.least(1, 2.0) == 1"; @@ -112,11 +111,10 @@ public void addAllMathExtensions_success() throws Exception { @Test public void addSubsetOfMathExtensions_success() throws Exception { - CelOptions celOptions = CelOptions.current().build(); Cel cel = CelFactory.standardCelBuilder() - .addCompilerLibraries(CelExtensions.math(celOptions, CelMathExtensions.Function.MAX)) - .addRuntimeLibraries(CelExtensions.math(celOptions, CelMathExtensions.Function.MAX)) + .addCompilerLibraries(CelExtensions.math(CelMathExtensions.Function.MAX)) + .addRuntimeLibraries(CelExtensions.math(CelMathExtensions.Function.MAX)) .build(); boolean evaluatedResult = @@ -130,8 +128,8 @@ public void addSubsetOfMathExtensions_success() throws Exception { public void addEncoderExtension_success() throws Exception { Cel cel = CelFactory.standardCelBuilder() - .addCompilerLibraries(CelExtensions.encoders()) - .addRuntimeLibraries(CelExtensions.encoders()) + .addCompilerLibraries(CelExtensions.encoders(CelOptions.DEFAULT)) + .addRuntimeLibraries(CelExtensions.encoders(CelOptions.DEFAULT)) .build(); boolean evaluatedResult = @@ -164,6 +162,7 @@ public void getAllFunctionNames() { "math.bitShiftRight", "math.sqrt", "charAt", + "format", "indexOf", "join", "lastIndexOf", diff --git a/extensions/src/test/java/dev/cel/extensions/CelStringExtensionsTest.java b/extensions/src/test/java/dev/cel/extensions/CelStringExtensionsTest.java index 4b242ddcd..1f3c87881 100644 --- a/extensions/src/test/java/dev/cel/extensions/CelStringExtensionsTest.java +++ b/extensions/src/test/java/dev/cel/extensions/CelStringExtensionsTest.java @@ -27,13 +27,18 @@ import dev.cel.common.CelOptions; import dev.cel.common.CelValidationException; import dev.cel.common.CelValidationResult; +import dev.cel.common.types.ListType; +import dev.cel.common.types.MapType; import dev.cel.common.types.SimpleType; import dev.cel.compiler.CelCompiler; import dev.cel.compiler.CelCompilerFactory; import dev.cel.extensions.CelStringExtensions.Function; import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelRuntime; +import dev.cel.runtime.CelRuntime.Program; +import dev.cel.testing.CelRuntimeFlavor; import java.util.List; +import java.util.Locale; import org.junit.Assume; import org.junit.Test; import org.junit.runner.RunWith; @@ -55,6 +60,8 @@ protected Cel newCelEnv() { .addVar("beginIndex", SimpleType.INT) .addVar("endIndex", SimpleType.INT) .addVar("limit", SimpleType.INT) + .addVar("dynMap", MapType.create(SimpleType.DYN, SimpleType.DYN)) + .addVar("dynList", ListType.create(SimpleType.DYN)) .build(); } @@ -67,6 +74,7 @@ public void library() { assertThat(library.version(0).functions().stream().map(CelFunctionDecl::name)) .containsExactly( "charAt", + "format", "indexOf", "join", "lastIndexOf", @@ -382,9 +390,11 @@ public void split_withLimitOverflow_throwsException() throws Exception { assertThrows( CelEvaluationException.class, () -> eval("'test'.split('', limit)", variables)); - assertThat(exception) - .hasMessageThat() - .contains("split failure: Limit must not exceed the int32 range: 2147483648"); + String expectedMessage = "split failure: Limit must not exceed the int32 range: 2147483648"; + if (!exception.getMessage().contains(expectedMessage)) { + assertThat(exception).hasCauseThat().isNotNull(); + assertThat(exception).hasCauseThat().hasMessageThat().contains(expectedMessage); + } } @Test @@ -501,9 +511,11 @@ public void substring_beginIndexOverflow_throwsException() throws Exception { assertThrows( CelEvaluationException.class, () -> eval("'abcd'.substring(beginIndex)", variables)); - assertThat(exception) - .hasMessageThat() - .contains("substring failure: Index must not exceed the int32 range: 2147483648"); + String expectedMessage = "substring failure: Index must not exceed the int32 range: 2147483648"; + if (!exception.getMessage().contains(expectedMessage)) { + assertThat(exception).hasCauseThat().isNotNull(); + assertThat(exception).hasCauseThat().hasMessageThat().contains(expectedMessage); + } } @Test @@ -519,9 +531,11 @@ public void substring_beginOrEndIndexOverflow_throwsException(long beginIndex, l "'abcd'.substring(beginIndex, endIndex)", ImmutableMap.of("beginIndex", beginIndex, "endIndex", endIndex))); - assertThat(exception) - .hasMessageThat() - .contains("substring failure: Indices must not exceed the int32 range"); + String expectedMessage = "substring failure: Indices must not exceed the int32 range"; + if (!exception.getMessage().contains(expectedMessage)) { + assertThat(exception).hasCauseThat().isNotNull(); + assertThat(exception).hasCauseThat().hasMessageThat().contains(expectedMessage); + } } @Test @@ -583,9 +597,11 @@ public void charAt_indexOverflow_throwsException() throws Exception { () -> eval("'test'.charAt(index)", ImmutableMap.of("index", 2147483648L))); // INT_MAX + 1 - assertThat(exception) - .hasMessageThat() - .contains("charAt failure: Index must not exceed the int32 range: 2147483648"); + String expectedMessage = "charAt failure: Index must not exceed the int32 range: 2147483648"; + if (!exception.getMessage().contains(expectedMessage)) { + assertThat(exception).hasCauseThat().isNotNull(); + assertThat(exception).hasCauseThat().hasMessageThat().contains(expectedMessage); + } } @Test @@ -647,12 +663,8 @@ public void indexOf_unicode_success(String string, String indexOf, int expectedR } @Test - @TestParameters("{indexOf: ' '}") - @TestParameters("{indexOf: 'a'}") - @TestParameters("{indexOf: 'abc'}") - @TestParameters("{indexOf: '나'}") - @TestParameters("{indexOf: '😁'}") - public void indexOf_onEmptyString_throwsException(String indexOf) throws Exception { + public void indexOf_onEmptyString_throwsException( + @TestParameter({" ", "a", "abc", "나", "😁"}) String indexOf) throws Exception { CelEvaluationException exception = assertThrows( CelEvaluationException.class, @@ -769,9 +781,11 @@ public void indexOf_offsetOverflow_throwsException() throws Exception { "'test'.indexOf('t', offset)", ImmutableMap.of("offset", 2147483648L))); // INT_MAX + 1 - assertThat(exception) - .hasMessageThat() - .contains("indexOf failure: Offset must not exceed the int32 range: 2147483648"); + String expectedMessage = "indexOf failure: Offset must not exceed the int32 range: 2147483648"; + if (!exception.getMessage().contains(expectedMessage)) { + assertThat(exception).hasCauseThat().isNotNull(); + assertThat(exception).hasCauseThat().hasMessageThat().contains(expectedMessage); + } } @Test @@ -913,14 +927,8 @@ public void lastIndexOf_unicode_success(String string, String lastIndexOf, int e } @Test - @TestParameters("{lastIndexOf: '@@'}") - @TestParameters("{lastIndexOf: ' '}") - @TestParameters("{lastIndexOf: 'a'}") - @TestParameters("{lastIndexOf: 'abc'}") - @TestParameters("{lastIndexOf: '나'}") - @TestParameters("{lastIndexOf: '😁'}") - public void lastIndexOf_strLengthLessThanSubstrLength_returnsMinusOne(String lastIndexOf) - throws Exception { + public void lastIndexOf_strLengthLessThanSubstrLength_returnsMinusOne( + @TestParameter({"@@", " ", "a", "abc", "나", "😁"}) String lastIndexOf) throws Exception { Object evaluatedResult = eval("''.lastIndexOf(indexOfParam)", ImmutableMap.of("s", "", "indexOfParam", lastIndexOf)); @@ -1066,9 +1074,12 @@ public void lastIndexOf_offsetOverflow_throwsException() throws Exception { "'test'.lastIndexOf('t', offset)", ImmutableMap.of("offset", 2147483648L))); // INT_MAX + 1 - assertThat(exception) - .hasMessageThat() - .contains("lastIndexOf failure: Offset must not exceed the int32 range: 2147483648"); + String expectedMessage = + "lastIndexOf failure: Offset must not exceed the int32 range: 2147483648"; + if (!exception.getMessage().contains(expectedMessage)) { + assertThat(exception).hasCauseThat().isNotNull(); + assertThat(exception).hasCauseThat().hasMessageThat().contains(expectedMessage); + } } @Test @@ -1265,9 +1276,11 @@ public void replace_limitOverflow_throwsException() throws Exception { "'test'.replace('','',index)", ImmutableMap.of("index", 2147483648L))); // INT_MAX + 1 - assertThat(exception) - .hasMessageThat() - .contains("replace failure: Index must not exceed the int32 range: 2147483648"); + String expectedMessage = "replace failure: Index must not exceed the int32 range: 2147483648"; + if (!exception.getMessage().contains(expectedMessage)) { + assertThat(exception).hasCauseThat().isNotNull(); + assertThat(exception).hasCauseThat().hasMessageThat().contains(expectedMessage); + } } private enum TrimTestCase { @@ -1469,9 +1482,347 @@ public void stringExtension_evaluateUnallowedFunction_throws() throws Exception isParseOnly ? customCompilerCel.parse("'test'.substring(2) == 'st'").getAst() : customCompilerCel.compile("'test'.substring(2) == 'st'").getAst(); + if (runtimeFlavor == CelRuntimeFlavor.PLANNER && !isParseOnly) { + assertThrows(CelEvaluationException.class, () -> customRuntimeCel.createProgram(ast)); + } else { + Program program = customRuntimeCel.createProgram(ast); + assertThrows(CelEvaluationException.class, program::eval); + } + } + + @Test + @TestParameters( + "{expr: \"'Percent sign %%!'.format(['hello', 'world'])\", expectedResult: 'Percent sign" + + " %!'}") + public void format_escaped_success(String expr, String expectedResult) throws Exception { + Object evaluatedResult = eval(expr); + assertThat(evaluatedResult).isEqualTo(expectedResult); + } + + @Test + @TestParameters("{expr: \"'%s'.format(['foo'])\", expectedResult: 'foo'}") + @TestParameters("{expr: \"'%s'.format([b'foo'])\", expectedResult: 'foo'}") + @TestParameters( + "{expr: \"'%s'.format([[double('NaN'), double('Infinity'), double('-Infinity')]])\"," + + " expectedResult: '[NaN, Infinity, -Infinity]'}") + @TestParameters( + "{expr: \"'str is %s and some more'.format(['filler'])\", expectedResult: 'str is filler and" + + " some more'}") + @TestParameters("{expr: \"'%%%s%%'.format(['text'])\", expectedResult: '%text%'}") + @TestParameters( + "{expr: \"'%s%%'.format(['percent on the right'])\", expectedResult: 'percent on the" + + " right%'}") + @TestParameters( + "{expr: \"'%%%s'.format(['percent on the left'])\", expectedResult: '%percent on the left'}") + @TestParameters("{expr: \"'null: %s'.format([null])\", expectedResult: 'null: null'}") + @TestParameters("{expr: \"'%s'.format([999999999999])\", expectedResult: '999999999999'}") + @TestParameters( + "{expr: \"'some bytes: %s'.format([b'xyz'])\", expectedResult: 'some bytes: xyz'}") + @TestParameters("{expr: \"'%s'.format([b'\\\\xff'])\", expectedResult: '\uFFFD'}") + @TestParameters("{expr: \"'%s'.format([b'\\\\xff\\\\xff'])\", expectedResult: '\uFFFD'}") + @TestParameters("{expr: \"'%s'.format([b'\\\\xc2'])\", expectedResult: '\uFFFD'}") + @TestParameters( + "{expr: \"'%s'.format([b'hello\\\\xff\\\\xfe\\\\xfdworld'])\", expectedResult:" + + " 'hello\uFFFDworld'}") + @TestParameters( + "{expr: \"'%s'.format([b'a\\\\xff\\\\xffb\\\\xfe\\\\xfec'])\", expectedResult:" + + " 'a\uFFFDb\uFFFDc'}") + @TestParameters( + "{expr: \"'%s'.format([b'\\\\xef\\\\xbf\\\\xbd\\\\xff\\\\xff'])\", expectedResult:" + + " '\uFFFD\uFFFD'}") + @TestParameters( + "{expr: \"'type is %s'.format([type('test string')])\", expectedResult: 'type is string'}") + @TestParameters( + "{expr: \"'%s'.format([timestamp('2023-02-03T23:31:20+00:00')])\", expectedResult:" + + " '2023-02-03T23:31:20Z'}") + @TestParameters("{expr: \"'%s'.format([duration('1h45m47s')])\", expectedResult: '6347s'}") + @TestParameters( + "{expr: \"'%s'.format([['abc', 3.14, null, [9, 8, 7, 6]," + + " timestamp('2023-02-03T23:31:20Z')]])\", expectedResult: '[abc, 3.14, null, [9, 8, 7," + + " 6], 2023-02-03T23:31:20Z]'}") + @TestParameters( + "{expr: \"'%s'.format([{'key1': b'xyz', 'key5': null, 'key2': duration('7200s'), 'key4':" + + " true, 'key3': 2.71828}])\", expectedResult: '{key1: xyz, key2: 7200s, key3: 2.71828," + + " key4: true, key5: null}'}") + @TestParameters( + "{expr: \"'map with multiple key types: %s'.format([{1: 'value1', 2u: 'value2', true:" + + " double('NaN')}])\", expectedResult: 'map with multiple key types: {1: value1, 2:" + + " value2, true: NaN}'}") + @TestParameters( + "{expr: \"'true bool: %s, false bool: %s'.format([true, false])\", expectedResult: 'true" + + " bool: true, false bool: false'}") + @TestParameters( + "{expr: \"'Durations with subseconds: %s'.format([[duration('422s'), duration('2s123ms')," + + " duration('1us'), duration('1ns'), duration('-1000000ns')]])\", expectedResult:" + + " 'Durations with subseconds: [422s, 2.123s, 0.000001s, 0.000000001s, -0.001s]'}") + @TestParameters("{expr: \"'%s'.format([2.71])\", expectedResult: '2.71'}") + @TestParameters("{expr: \"'%s'.format([[2.71]])\", expectedResult: '[2.71]'}") + @TestParameters("{expr: \"'%s'.format([[1.0]])\", expectedResult: '[1.0]'}") + @TestParameters("{expr: \"'%s'.format([10002.71])\", expectedResult: '10002.71'}") + @TestParameters("{expr: \"'%s'.format([0.000000002])\", expectedResult: '2.0E-9'}") + @TestParameters("{expr: \"'%s'.format([[0.000000002]])\", expectedResult: '[2.0E-9]'}") + @TestParameters("{expr: \"'%.5s'.format(['foobar'])\", expectedResult: 'foobar'}") + @TestParameters("{expr: \"'%.3s'.format(['foobar'])\", expectedResult: 'foobar'}") + @TestParameters("{expr: \"'%.0s'.format(['foobar'])\", expectedResult: 'foobar'}") + @TestParameters("{expr: \"'%.10s'.format(['foobar'])\", expectedResult: 'foobar'}") + public void format_verbS_success(String expr, String expectedResult) throws Exception { + Object evaluatedResult = eval(expr); + assertThat(evaluatedResult).isEqualTo(expectedResult); + } + + @Test + @TestParameters("{expr: \"'%d'.format([1])\", expectedResult: '1'}") + @TestParameters("{expr: \"'%d'.format([1u])\", expectedResult: '1'}") + @TestParameters("{expr: \"'%d'.format([3.14])\", expectedResult: '3.14'}") + @TestParameters( + "{expr: \"'int %d, uint %d'.format([-1, 2u])\", expectedResult: 'int -1, uint 2'}") + public void format_verbD_success(String expr, String expectedResult) throws Exception { + Object evaluatedResult = eval(expr); + assertThat(evaluatedResult).isEqualTo(expectedResult); + } + + @Test + @TestParameters("{expr: \"'%f'.format([1])\", expectedResult: '1.000000'}") + @TestParameters("{expr: \"'%f'.format([1u])\", expectedResult: '1.000000'}") + @TestParameters("{expr: \"'%f'.format([3.14])\", expectedResult: '3.140000'}") + @TestParameters("{expr: \"'%.1f'.format([3.14])\", expectedResult: '3.1'}") + @TestParameters("{expr: \"'%.3f'.format([123.4999])\", expectedResult: '123.500'}") + @TestParameters("{expr: \"'%.3f'.format([123.4994])\", expectedResult: '123.499'}") + @TestParameters("{expr: \"'%f'.format([10000.1234])\", expectedResult: '10000.123400'}") + @TestParameters("{expr: \"'%.2f'.format([10000.1234])\", expectedResult: '10000.12'}") + @TestParameters("{expr: \"'%f'.format([2.71828])\", expectedResult: '2.718280'}") + @TestParameters("{expr: \"'%.6f'.format([-0.0])\", expectedResult: '-0.000000'}") + @TestParameters("{expr: \"'%f'.format([-0.0])\", expectedResult: '-0.000000'}") + @TestParameters("{expr: \"'%.0f'.format([-0.0])\", expectedResult: '-0'}") + @TestParameters( + "{expr: \"'%f'.format([9223372036854775807])\", expectedResult:" + + " '9223372036854776000.000000'}") + @TestParameters( + "{expr: \"'%f'.format([-9223372036854775808])\", expectedResult:" + + " '-9223372036854776000.000000'}") + @TestParameters( + "{expr: \"'%f'.format([18446744073709551615u])\", expectedResult:" + + " '18446744073709552000.000000'}") + public void format_verbF_success(String expr, String expectedResult) throws Exception { + Object evaluatedResult = eval(expr); + assertThat(evaluatedResult).isEqualTo(expectedResult); + } + + @Test + @TestParameters("{expr: \"'%e'.format([1])\", expectedResult: '1.000000e+00'}") + @TestParameters("{expr: \"'%e'.format([1u])\", expectedResult: '1.000000e+00'}") + @TestParameters("{expr: \"'%e'.format([3.14])\", expectedResult: '3.140000e+00'}") + @TestParameters("{expr: \"'%e'.format([-0.0])\", expectedResult: '-0.000000e+00'}") + @TestParameters("{expr: \"'%.2e'.format([-0.0])\", expectedResult: '-0.00e+00'}") + @TestParameters("{expr: \"'%.0e'.format([-0.0])\", expectedResult: '-0e+00'}") + @TestParameters("{expr: \"'%.1e'.format([3.14])\", expectedResult: '3.1e+00'}") + @TestParameters("{expr: \"'%.1e'.format([-3.14])\", expectedResult: '-3.1e+00'}") + @TestParameters("{expr: \"'%.6e'.format([1052.032911275])\", expectedResult: '1.052033e+03'}") + @TestParameters("{expr: \"'%e'.format([1234.0])\", expectedResult: '1.234000e+03'}") + @TestParameters("{expr: \"'%e'.format([2.71828])\", expectedResult: '2.718280e+00'}") + @TestParameters("{expr: \"'%e'.format([3u])\", expectedResult: '3.000000e+00'}") + @TestParameters( + "{expr: \"'%.18e'.format([9223372036854775807])\", expectedResult:" + + " '9.223372036854776000e+18'}") + @TestParameters( + "{expr: \"'%e'.format([-9223372036854775808])\", expectedResult:" + " '-9.223372e+18'}") + @TestParameters( + "{expr: \"'%.19e'.format([18446744073709551615u])\", expectedResult:" + + " '1.8446744073709552000e+19'}") + @TestParameters("{expr: \"'%e'.format([double('4.9e-324')])\", expectedResult: '4.900000e-324'}") + @TestParameters("{expr: \"'%.1e'.format([double('4.9e-324')])\", expectedResult: '4.9e-324'}") + public void format_verbE_success(String expr, String expectedResult) throws Exception { + Object evaluatedResult = eval(expr); + assertThat(evaluatedResult).isEqualTo(expectedResult); + } + + @Test + @TestParameters("{expr: \"'%x'.format([255])\", expectedResult: 'ff'}") + @TestParameters("{expr: \"'%X'.format([255u])\", expectedResult: 'FF'}") + @TestParameters( + "{expr: \"'int %x, uint %X, string %x, bytes %X'.format([-10, 255u, 'hello', b'world'])\"," + + " expectedResult: 'int -a, uint FF, string 68656c6c6f, bytes 776F726C64'}") + @TestParameters( + "{expr: \"'string: %x'.format([b'\\x00\\x00hello\\x00'])\", expectedResult: 'string:" + + " 000068656c6c6f00'}") + @TestParameters( + "{expr: \"'%x is -30 in hexadecimal'.format([-30])\", expectedResult: '-1e is -30 in" + + " hexadecimal'}") + @TestParameters( + "{expr: \"'%x'.format([-9223372036854775808])\", expectedResult: '-8000000000000000'}") + @TestParameters( + "{expr: \"'%X'.format([-9223372036854775808])\", expectedResult: '-8000000000000000'}") + public void format_verbX_success(String expr, String expectedResult) throws Exception { + Object evaluatedResult = eval(expr); + assertThat(evaluatedResult).isEqualTo(expectedResult); + } + + @Test + @TestParameters("{expr: \"'%o'.format([8])\", expectedResult: '10'}") + @TestParameters( + "{expr: \"'int %o, uint %o'.format([-10, 20u])\", expectedResult: 'int -12, uint 24'}") + @TestParameters("{expr: \"'%o'.format([-11])\", expectedResult: '-13'}") + @TestParameters( + "{expr: \"'%o'.format([-9223372036854775808])\", expectedResult: '-1000000000000000000000'}") + public void format_verbO_success(String expr, String expectedResult) throws Exception { + Object evaluatedResult = eval(expr); + assertThat(evaluatedResult).isEqualTo(expectedResult); + } + + @Test + @TestParameters("{expr: \"'%b'.format([5])\", expectedResult: '101'}") + @TestParameters("{expr: \"'%b'.format([true])\", expectedResult: '1'}") + @TestParameters( + "{expr: \"'int %b, uint %b, bool %b, bool %b'.format([-32, 20u, false, true])\"," + + " expectedResult: 'int -100000, uint 10100, bool 0, bool 1'}") + @TestParameters("{expr: \"'zero %b'.format([0])\", expectedResult: 'zero 0'}") + @TestParameters( + "{expr: \"'this is -5 in binary: %b'.format([-5])\", expectedResult: 'this is -5 in binary:" + + " -101'}") + @TestParameters( + "{expr: \"'%b'.format([-9223372036854775808])\", expectedResult:" + + " '-100000000000000000000000000000000000000000000000000000000000000'}") + public void format_verbB_success(String expr, String expectedResult) throws Exception { + Object evaluatedResult = eval(expr); + assertThat(evaluatedResult).isEqualTo(expectedResult); + } - assertThrows(CelEvaluationException.class, () -> customRuntimeCel.createProgram(ast).eval()); + @Test + @TestParameters( + "{expr: \"'%d %d %d, %s %s %s, %d %d %d, %s %s %s'.format([1, 2, 3, 'A', 'B', 'C', 4, 5, 6," + + " 'D', 'E', 'F'])\", expectedResult: '1 2 3, A B C, 4 5 6, D E F'}") + @TestParameters("{expr: \"'%s'.format([{1: 'a', '1': 'b'}])\", expectedResult: '{1: a, 1: b}'}") + public void format_mixed_success(String expr, String expectedResult) throws Exception { + Object evaluatedResult = eval(expr); + assertThat(evaluatedResult).isEqualTo(expectedResult); } + @Test + @TestParameters("{expr: \"'%'.format([1])\", expectedMessage: 'unexpected end of string'}") + @TestParameters("{expr: \"'%.' .format([1])\", expectedMessage: 'unexpected end of string'}") + @TestParameters("{expr: \"'%.6'.format([1])\", expectedMessage: 'unexpected end of string'}") + @TestParameters( + "{expr: \"'%.f'.format([3.14])\", expectedMessage: 'empty precision is not allowed'}") + @TestParameters( + "{expr: \"'%.e'.format([3.14])\", expectedMessage: 'empty precision is not allowed'}") + @TestParameters( + "{expr: \"'%.9999999999999999f'.format([3.14])\", expectedMessage: 'invalid precision" + + " format'}") + public void format_syntaxFailure_throwsException(String expr, String expectedMessage) + throws Exception { + CelEvaluationException exception = assertThrows(CelEvaluationException.class, () -> eval(expr)); + if (!exception.getMessage().contains(expectedMessage)) { + assertThat(exception).hasCauseThat().isNotNull(); + assertThat(exception).hasCauseThat().hasMessageThat().contains(expectedMessage); + } + } + @Test + @TestParameters("{expr: \"'%s'.format([])\", expectedMessage: 'index 0 out of range'}") + public void format_argumentCountFailure_throwsException(String expr, String expectedMessage) + throws Exception { + CelEvaluationException exception = assertThrows(CelEvaluationException.class, () -> eval(expr)); + if (!exception.getMessage().contains(expectedMessage)) { + assertThat(exception).hasCauseThat().isNotNull(); + assertThat(exception).hasCauseThat().hasMessageThat().contains(expectedMessage); + } + } + + @Test + @TestParameters( + "{expr: \"'%a'.format(['foo'])\", expectedMessage: 'unrecognized formatting clause \"a\"'}") + @TestParameters( + "{expr: \"'%10s'.format(['foo'])\", expectedMessage: 'unrecognized formatting clause \"1\"'}") + public void format_unrecognizedVerbFailure_throwsException(String expr, String expectedMessage) + throws Exception { + CelEvaluationException exception = assertThrows(CelEvaluationException.class, () -> eval(expr)); + if (!exception.getMessage().contains(expectedMessage)) { + assertThat(exception).hasCauseThat().isNotNull(); + assertThat(exception).hasCauseThat().hasMessageThat().contains(expectedMessage); + } + } + + @Test + @TestParameters( + "{expr: \"'%b'.format(['foo'])\", expectedMessage: 'binary clause can only be used on" + + " integers and bools'}") + @TestParameters( + "{expr: \"'%d'.format(['foo'])\", expectedMessage: 'decimal clause can only be used on" + + " numbers'}") + @TestParameters( + "{expr: \"'%o'.format(['foo'])\", expectedMessage: 'octal clause can only be used on" + + " integers'}") + @TestParameters( + "{expr: \"'%x'.format([3.14])\", expectedMessage: 'hex clause can only be used on integers," + + " byte buffers, and strings'}") + @TestParameters( + "{expr: \"'%f'.format(['foo'])\", expectedMessage: 'fixed point clause can only be used on" + + " doubles, integers, and unsigned integers'}") + @TestParameters( + "{expr: \"'%e'.format(['foo'])\", expectedMessage: 'scientific clause can only be used on" + + " doubles, integers, and unsigned integers'}") + public void format_typeMismatchFailure_throwsException(String expr, String expectedMessage) + throws Exception { + CelEvaluationException exception = assertThrows(CelEvaluationException.class, () -> eval(expr)); + if (!exception.getMessage().contains(expectedMessage)) { + assertThat(exception).hasCauseThat().isNotNull(); + assertThat(exception).hasCauseThat().hasMessageThat().contains(expectedMessage); + } + } + + @Test + public void format_precisionLimit_exceeded() throws Exception { + Cel cel = + runtimeFlavor + .builder() + .addCompilerLibraries(CelExtensions.strings()) + .addRuntimeLibraries(CelExtensions.strings()) + .build(); + + CelAbstractSyntaxTree ast = cel.compile("'%.101f'.format([3.14])").getAst(); + CelRuntime.Program program = cel.createProgram(ast); + + CelEvaluationException e = assertThrows(CelEvaluationException.class, program::eval); + assertThat(e).hasMessageThat().contains("precision 101 exceeds maximum allowed (100)"); + } + + @Test + public void format_precisionLimit_success() throws Exception { + Cel cel = + runtimeFlavor + .builder() + .addCompilerLibraries(CelExtensions.strings()) + .addRuntimeLibraries(CelExtensions.strings()) + .build(); + + CelAbstractSyntaxTree ast = cel.compile("'%.10f'.format([3.14])").getAst(); + Object result = cel.createProgram(ast).eval(); + assertThat(result).isEqualTo("3.1400000000"); + } + + @Test + public void format_localeIndependent_success() throws Exception { + Locale originalLocale = Locale.getDefault(); + try { + // Verify with Germany locale (uses ',' as decimal separator) + Locale.setDefault(Locale.GERMANY); + assertThat(eval("'%f'.format([3.14])")).isEqualTo("3.140000"); + assertThat(eval("'%e'.format([3.14])")).isEqualTo("3.140000e+00"); + assertThat(eval("'%d'.format([3.14])")).isEqualTo("3.14"); + + // Verify with Turkish locale (strict locale-immunity tests for case mapping 'i'/'I') + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertThat(eval("'%X'.format([255])")).isEqualTo("FF"); + assertThat(eval("'%X'.format([b'title'])")).isEqualTo("7469746C65"); + assertThat(eval("'%s'.format([double('Infinity')])")).isEqualTo("Infinity"); + + // Verify with Arabic locale (uses Eastern Arabic numerals) + Locale.setDefault(Locale.forLanguageTag("ar-SA")); + assertThat(eval("'%d'.format([12345])")).isEqualTo("12345"); + assertThat(eval("'%b'.format([5])")).isEqualTo("101"); + assertThat(eval("'%o'.format([11])")).isEqualTo("13"); + } finally { + Locale.setDefault(originalLocale); + } + } }