From f2b1e0eeb6a519fad0d79ba9e1f825e101bed507 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 - .../exceptions/CelBadFormatException.java | 4 + .../exceptions/CelRuntimeException.java | 5 + .../test/java/dev/cel/conformance/BUILD.bazel | 7 - .../main/java/dev/cel/extensions/BUILD.bazel | 7 + .../cel/extensions/CelStringExtensions.java | 419 ++++++++++++++++-- .../dev/cel/extensions/CelExtensionsTest.java | 1 + .../extensions/CelStringExtensionsTest.java | 385 ++++++++++++++-- 8 files changed, 757 insertions(+), 72 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/common/src/main/java/dev/cel/common/exceptions/CelBadFormatException.java b/common/src/main/java/dev/cel/common/exceptions/CelBadFormatException.java index ba4db602a..57634d8b6 100644 --- a/common/src/main/java/dev/cel/common/exceptions/CelBadFormatException.java +++ b/common/src/main/java/dev/cel/common/exceptions/CelBadFormatException.java @@ -28,4 +28,8 @@ public CelBadFormatException(Throwable cause) { public CelBadFormatException(String errorMessage) { super(errorMessage, CelErrorCode.BAD_FORMAT); } + + public CelBadFormatException(String errorMessage, Throwable cause) { + super(errorMessage, cause, CelErrorCode.BAD_FORMAT); + } } diff --git a/common/src/main/java/dev/cel/common/exceptions/CelRuntimeException.java b/common/src/main/java/dev/cel/common/exceptions/CelRuntimeException.java index c87e192bd..2fc5b7c11 100644 --- a/common/src/main/java/dev/cel/common/exceptions/CelRuntimeException.java +++ b/common/src/main/java/dev/cel/common/exceptions/CelRuntimeException.java @@ -37,6 +37,11 @@ public CelRuntimeException(Throwable cause, CelErrorCode errorCode) { this.errorCode = errorCode; } + public CelRuntimeException(String errorMessage, Throwable cause, CelErrorCode errorCode) { + super(errorMessage, cause); + this.errorCode = errorCode; + } + public CelErrorCode getErrorCode() { return errorCode; } diff --git a/conformance/src/test/java/dev/cel/conformance/BUILD.bazel b/conformance/src/test/java/dev/cel/conformance/BUILD.bazel index c5364b146..25c2c0f2f 100644 --- a/conformance/src/test/java/dev/cel/conformance/BUILD.bazel +++ b/conformance/src/test/java/dev/cel/conformance/BUILD.bazel @@ -115,9 +115,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. @@ -143,10 +140,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 ba57a07c3..696415cef 100644 --- a/extensions/src/main/java/dev/cel/extensions/BUILD.bazel +++ b/extensions/src/main/java/dev/cel/extensions/BUILD.bazel @@ -88,8 +88,15 @@ java_library( deps = [ "//checker:checker_builder", "//common:compiler_common", + "//common/exceptions:bad_format", + "//common/exceptions:index_out_of_bounds", + "//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", diff --git a/extensions/src/main/java/dev/cel/extensions/CelStringExtensions.java b/extensions/src/main/java/dev/cel/extensions/CelStringExtensions.java index 2bb477b82..2382c5e43 100644 --- a/extensions/src/main/java/dev/cel/extensions/CelStringExtensions.java +++ b/extensions/src/main/java/dev/cel/extensions/CelStringExtensions.java @@ -17,26 +17,45 @@ 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.primitives.UnsignedLong; import com.google.errorprone.annotations.Immutable; 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.CelIndexOutOfBoundsException; +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.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Set; /** Internal implementation of CEL string extensions. */ @@ -58,6 +77,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 +433,345 @@ 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) == '%') { + if (i + 1 < formatSpecifier.length() && formatSpecifier.charAt(i + 1) == '%') { + builtStr.append('%'); + i += 2; + } else { + if (argIndex >= args.size()) { + throw new CelBadFormatException("index " + argIndex + " out of range"); + } + Object arg = args.get(argIndex++); + i++; // Skip '%' + + // Safely ignore width specifiers + while (i < formatSpecifier.length() && Character.isDigit(formatSpecifier.charAt(i))) { + i++; + } + + 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 == start) { + precision = 0; // Default to 0 for empty precision + } else { + precision = Integer.parseInt(formatSpecifier.substring(start, i)); + if (precision > 1000) { + throw new CelInvalidArgumentException( + "precision " + precision + " exceeds maximum allowed (1000)"); + } + } + } + + 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 + "\""); + } + } + } else { + builtStr.append(formatSpecifier.charAt(i++)); + } + } + return builtStr.toString(); + } + + private static String formatString(Object val) { + Preconditions.checkNotNull(val); + if (val instanceof String) { + return (String) val; + } + if (val instanceof CelByteString) { + return ((CelByteString) val).toStringUtf8(); + } + if (val instanceof Duration) { + return DateTimeHelpers.toString((Duration) val); + } + if (val instanceof Instant) { + return val.toString(); + } + if (val instanceof Boolean) { + return val.toString(); + } + if (val instanceof Long) { + return val.toString(); + } + if (val instanceof UnsignedLong) { + return val.toString(); + } + if (val instanceof Double) { + double dbl = (Double) val; + if (!Double.isFinite(dbl)) { + return val.toString(); + } + return BigDecimal.valueOf(dbl).stripTrailingZeros().toPlainString(); + } + 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 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 Object key; + final String keyStr; + final Object value; + + MapEntry(Object key, String keyStr, Object value) { + this.key = key; + this.keyStr = keyStr; + this.value = value; + } + } + + private static String formatMap(Map map) { + List entries = new ArrayList<>(); + for (Map.Entry entry : map.entrySet()) { + entries.add(new MapEntry(entry.getKey(), formatString(entry.getKey()), entry.getValue())); + } + + Collections.sort( + entries, + (a, b) -> { + int cmp = a.keyStr.compareTo(b.keyStr); + if (cmp != 0) { + return cmp; + } + // Tie breaker for different types that format to same string + if (a.key == null && b.key == null) { + return 0; + } + if (a.key == null) { + return -1; + } + if (b.key == null) { + return 1; + } + if (a.key.getClass() != b.key.getClass()) { + return a.key.getClass().getName().compareTo(b.key.getClass().getName()); + } + return 0; + }); + + StringBuilder sb = new StringBuilder("{"); + for (int i = 0; i < entries.size(); i++) { + MapEntry entry = entries.get(i); + sb.append(entry.keyStr).append(": ").append(formatString(entry.value)); + 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) { + return arg.toString(); + } + if (arg instanceof Double) { + double val = (Double) arg; + if (Double.isFinite(val)) { + return BigDecimal.valueOf(val).stripTrailingZeros().toPlainString(); + } + return arg.toString(); + } + throw new CelInvalidArgumentException( + "decimal clause can only be used on numbers, was given " + arg.getClass().getName()); + } + + private static String formatFixed(Object arg, int precision) { + int p = precision >= 0 ? precision : 6; + BigDecimal bd; + if (arg instanceof Double) { + double val = (Double) arg; + if (!Double.isFinite(val)) { + return arg.toString(); + } + bd = BigDecimal.valueOf(val); + } else if (arg instanceof Long) { + bd = new BigDecimal((Long) arg); + } else if (arg instanceof UnsignedLong) { + bd = new BigDecimal(arg.toString()); + } else { + throw new CelInvalidArgumentException( + "fixed point clause can only be used on doubles, integers, and unsigned integers, was" + + " given " + + arg.getClass().getName()); + } + bd = bd.setScale(p, RoundingMode.HALF_EVEN); + return bd.toPlainString(); + } + + private static String formatScientific(Object arg, int precision) { + BigDecimal bd; + if (arg instanceof Double) { + double val = (Double) arg; + if (!Double.isFinite(val)) { + return arg.toString(); + } + bd = BigDecimal.valueOf(val); + } else if (arg instanceof Long) { + bd = new BigDecimal((Long) arg); + } else if (arg instanceof UnsignedLong) { + bd = new BigDecimal(arg.toString()); + } else { + throw new CelInvalidArgumentException( + "scientific clause can only be used on doubles, integers, and unsigned integers, was" + + " given " + + arg.getClass().getName()); + } + String fmtStr = precision >= 0 ? "%." + precision + "e" : "%.6e"; + return String.format(Locale.ROOT, fmtStr, bd); + } + + private static String formatBinary(Object arg) { + if (arg instanceof Long) { + Long val = (Long) arg; + if (val < 0) { + // Note: We handle negative numbers by forcing a leading '-' and taking the binary of the + // absolute value (e.g., -5 -> -101) for consistency with Go's %b behavior + if (val == Long.MIN_VALUE) { + return "-1" + new String(new char[63]).replace('\0', '0'); + } + 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 final char[] hexArray = "0123456789abcdef".toCharArray(); + + private static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = hexArray[v >>> 4]; + hexChars[j * 2 + 1] = hexArray[v & 0x0F]; + } + return new String(hexChars); + } + + 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 = "-8000000000000000"; + } else { + result = "-" + String.format("%x", -val); + } + } else { + result = String.format("%x", val); + } + } else if (arg instanceof UnsignedLong) { + UnsignedLong unsignedLong = (UnsignedLong) arg; + result = unsignedLong.toString(16); + } else if (arg instanceof CelByteString) { + CelByteString byteString = (CelByteString) arg; + result = bytesToHex(byteString.toByteArray()); + } else if (arg instanceof String) { + String str = (String) arg; + result = bytesToHex(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 "-1000000000000000000000"; + } + return "-" + String.format("%o", -val); + } + return String.format("%o", 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); @@ -436,16 +804,14 @@ private static Long lastIndexOf(CelCodePointArray str, CelCodePointArray substr, try { off = Math.toIntExact(offset); } catch (ArithmeticException e) { - throw CelEvaluationExceptionBuilder.newBuilder( - "lastIndexOf failure: Offset must not exceed the int32 range: %d", offset) - .setCause(e) - .build(); + throw new IllegalArgumentException( + String.format("lastIndexOf failure: Offset must not exceed the int32 range: %d", offset), + e); } if (off < 0 || off >= str.length()) { - throw CelEvaluationExceptionBuilder.newBuilder( - "lastIndexOf failure: Offset out of range: %d", offset) - .build(); + throw new CelIndexOutOfBoundsException( + String.format("lastIndexOf failure: Offset out of range: %d", offset)); } if (off > str.length() - substr.length()) { @@ -536,10 +902,9 @@ private static String replace(Object[] objects) throws CelEvaluationException { try { index = Math.toIntExact(indexInLong); } catch (ArithmeticException e) { - throw CelEvaluationExceptionBuilder.newBuilder( - "replace failure: Index must not exceed the int32 range: %d", indexInLong) - .setCause(e) - .build(); + throw new IllegalArgumentException( + String.format("replace failure: Index must not exceed the int32 range: %d", indexInLong), + e); } return replace((String) objects[0], (String) objects[1], (String) objects[2], index); @@ -598,10 +963,9 @@ private static ImmutableList split(Object[] args) throws CelEvaluationEx try { limit = Math.toIntExact(limitInLong); } catch (ArithmeticException e) { - throw CelEvaluationExceptionBuilder.newBuilder( - "split failure: Limit must not exceed the int32 range: %d", limitInLong) - .setCause(e) - .build(); + throw new IllegalArgumentException( + String.format("split failure: Limit must not exceed the int32 range: %d", limitInLong), + e); } return split((String) args[0], (String) args[1], limit); @@ -660,20 +1024,18 @@ private static Object substring(String s, long i) throws CelEvaluationException try { beginIndex = Math.toIntExact(i); } catch (ArithmeticException e) { - throw CelEvaluationExceptionBuilder.newBuilder( - "substring failure: Index must not exceed the int32 range: %d", i) - .setCause(e) - .build(); + throw new IllegalArgumentException( + String.format("substring failure: Index must not exceed the int32 range: %d", i), e); } CelCodePointArray codePointArray = CelCodePointArray.fromString(s); boolean indexIsInRange = beginIndex <= codePointArray.length() && beginIndex >= 0; if (!indexIsInRange) { - throw CelEvaluationExceptionBuilder.newBuilder( + throw new CelIndexOutOfBoundsException( + String.format( "substring failure: Range [%d, %d) out of bounds", - beginIndex, codePointArray.length()) - .build(); + beginIndex, codePointArray.length())); } if (beginIndex == codePointArray.length()) { @@ -695,11 +1057,11 @@ private static String substring(Object[] args) throws CelEvaluationException { beginIndex = Math.toIntExact(beginIndexInLong); endIndex = Math.toIntExact(endIndexInLong); } catch (ArithmeticException e) { - throw CelEvaluationExceptionBuilder.newBuilder( + throw new IllegalArgumentException( + String.format( "substring failure: Indices must not exceed the int32 range: [%d, %d)", - beginIndexInLong, endIndexInLong) - .setCause(e) - .build(); + beginIndexInLong, endIndexInLong), + e); } String s = (String) args[0]; @@ -711,9 +1073,8 @@ private static String substring(Object[] args) throws CelEvaluationException { && beginIndex <= codePointArray.length() && endIndex <= codePointArray.length(); if (!indicesIsInRange) { - throw CelEvaluationExceptionBuilder.newBuilder( - "substring failure: Range [%d, %d) out of bounds", beginIndex, endIndex) - .build(); + throw new CelIndexOutOfBoundsException( + String.format("substring failure: Range [%d, %d) out of bounds", beginIndex, endIndex)); } if (beginIndex == endIndex) { diff --git a/extensions/src/test/java/dev/cel/extensions/CelExtensionsTest.java b/extensions/src/test/java/dev/cel/extensions/CelExtensionsTest.java index 31c7d65c8..b1d7af2c0 100644 --- a/extensions/src/test/java/dev/cel/extensions/CelExtensionsTest.java +++ b/extensions/src/test/java/dev/cel/extensions/CelExtensionsTest.java @@ -164,6 +164,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..61235b683 100644 --- a/extensions/src/test/java/dev/cel/extensions/CelStringExtensionsTest.java +++ b/extensions/src/test/java/dev/cel/extensions/CelStringExtensionsTest.java @@ -27,14 +27,20 @@ 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 java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.junit.Assume; +import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; @@ -55,6 +61,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 +75,7 @@ public void library() { assertThat(library.version(0).functions().stream().map(CelFunctionDecl::name)) .containsExactly( "charAt", + "format", "indexOf", "join", "lastIndexOf", @@ -382,9 +391,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 +512,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 +532,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 +598,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 +664,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 +782,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 +928,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 +1075,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 +1277,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 { @@ -1473,5 +1487,306 @@ public void stringExtension_evaluateUnallowedFunction_throws() throws Exception assertThrows(CelEvaluationException.class, () -> customRuntimeCel.createProgram(ast).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: \"'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]'}") + @TestParameters("{expr: \"'%s'.format([10002.71])\", expectedResult: '10002.71'}") + @TestParameters("{expr: \"'%s'.format([0.000000002])\", expectedResult: '0.000000002'}") + @TestParameters("{expr: \"'%s'.format([[0.000000002]])\", expectedResult: '[0.000000002]'}") + @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'}") + // length + 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: \"'%.f'.format([3.14])\", expectedResult: '3'}") + @TestParameters("{expr: \"'%.f'.format([3.54])\", expectedResult: '4'}") // Rounding check + @TestParameters( + "{expr: \"'%f'.format([9223372036854775807])\", expectedResult:" + + " '9223372036854775807.000000'}") + @TestParameters( + "{expr: \"'%f'.format([18446744073709551615u])\", expectedResult:" + + " '18446744073709551615.000000'}") + public void format_verbF_success(String expr, String expectedResult) throws Exception { + Object evaluatedResult = eval(expr); + assertThat(evaluatedResult).isEqualTo(expectedResult); + } + + @Test + @TestParameters("{expr: \"'%10s'.format(['foo'])\", expectedResult: 'foo'}") + @TestParameters("{expr: \"'%5d'.format([42])\", expectedResult: '42'}") + @TestParameters("{expr: \"'%10.2f'.format([3.14159])\", expectedResult: '3.14'}") + public void format_ignoredWidth_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: \"'%.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.223372036854775807e+18'}") + @TestParameters( + "{expr: \"'%.19e'.format([18446744073709551615u])\", expectedResult:" + + " '1.8446744073709551615e+19'}") + 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'}") + 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'}") + 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'}") + public void format_verbB_success(String expr, String expectedResult) throws Exception { + Object evaluatedResult = eval(expr); + assertThat(evaluatedResult).isEqualTo(expectedResult); + } + + @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}'}") // Both + // format to + // "1", but + // both are + // present + public void format_mixed_success(String expr, String expectedResult) throws Exception { + Object evaluatedResult = eval(expr); + assertThat(evaluatedResult).isEqualTo(expectedResult); + } + + @Test + @Ignore("Tests recursion limits of the evaluation engine itself") + public void format_cyclicMap_success() throws Exception { + Map cyclicMap = new HashMap<>(); + cyclicMap.put("self", cyclicMap); + Object evaluatedResult = eval("'%s'.format([dynMap])", ImmutableMap.of("dynMap", cyclicMap)); + assertThat(evaluatedResult).isEqualTo("{self: {...}}"); + } + + @Test + @Ignore("Tests recursion limits of the evaluation engine itself") + public void format_cyclicList_success() throws Exception { + List cyclicList = new ArrayList<>(); + cyclicList.add(cyclicList); + Object evaluatedResult = eval("'%s'.format([dynList])", ImmutableMap.of("dynList", cyclicList)); + assertThat(evaluatedResult).isEqualTo("[[...]]"); + } + + @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'}") + 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\"'}") + 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)); + // The exception message might be wrapped by the runtime, so check cause if message doesn't contain it + 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("'%.1001f'.format([3.14])").getAst(); + CelRuntime.Program program = cel.createProgram(ast); + + CelEvaluationException e = assertThrows(CelEvaluationException.class, program::eval); + assertThat(e).hasMessageThat().contains("precision 1001 exceeds maximum allowed (1000)"); + } + + @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"); + } }