From 782764ff8d94b4ca3469cf044a73c805173a9c10 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 --- .../test/java/dev/cel/conformance/BUILD.bazel | 7 - .../main/java/dev/cel/extensions/BUILD.bazel | 4 + .../cel/extensions/CelStringExtensions.java | 291 ++++++++++++++++++ .../dev/cel/extensions/CelExtensionsTest.java | 1 + .../extensions/CelStringExtensionsTest.java | 209 +++++++++++++ 5 files changed, 505 insertions(+), 7 deletions(-) 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..54955c301 100644 --- a/extensions/src/main/java/dev/cel/extensions/BUILD.bazel +++ b/extensions/src/main/java/dev/cel/extensions/BUILD.bazel @@ -89,7 +89,11 @@ java_library( "//checker:checker_builder", "//common:compiler_common", "//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..1b4303973 100644 --- a/extensions/src/main/java/dev/cel/extensions/CelStringExtensions.java +++ b/extensions/src/main/java/dev/cel/extensions/CelStringExtensions.java @@ -17,27 +17,42 @@ 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.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.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.HexFormat; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Set; +import java.util.TreeMap; /** Internal implementation of CEL string extensions. */ @Immutable @@ -58,6 +73,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 +429,272 @@ private static String join(List stringList, String separator) { return Joiner.on(separator).join(stringList); } + private static String format(String formatSpecifier, List args) + throws CelEvaluationException { + 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 CelEvaluationException("index " + argIndex + " out of range"); + } + Object arg = args.get(argIndex++); + i++; // Skip '%' + + 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) { + throw new CelEvaluationException("could not find end of precision specifier"); + } + try { + precision = Integer.parseInt(formatSpecifier.substring(start, i)); + } catch (NumberFormatException e) { + throw new CelEvaluationException("error while converting precision to integer", e); + } + } + + if (i >= formatSpecifier.length()) { + throw new CelEvaluationException("unexpected end of string"); + } + char verb = formatSpecifier.charAt(i++); + + switch (verb) { + case 's' -> builtStr.append(formatString(arg)); + case 'd' -> builtStr.append(formatDecimal(arg)); + case 'f' -> builtStr.append(formatFixed(arg, precision)); + case 'e' -> builtStr.append(formatScientific(arg, precision)); + case 'b' -> builtStr.append(formatBinary(arg)); + case 'x', 'X' -> builtStr.append(formatHex(arg, verb == 'X')); + case 'o' -> builtStr.append(formatOctal(arg)); + default -> + throw new CelEvaluationException("unrecognized formatting clause \"" + verb + "\""); + } + } + } else { + builtStr.append(formatSpecifier.charAt(i++)); + } + } + return builtStr.toString(); + } + + private static String formatString(Object val) throws CelEvaluationException { + if (val == null) { + return "null"; + } + if (val instanceof String s) { + return s; + } + if (val instanceof CelByteString byteString) { + return byteString.toStringUtf8(); + } + if (val instanceof Duration duration) { + return DateTimeHelpers.toString(duration); + } + 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 d) { + if (d.isNaN()) { + return "NaN"; + } + if (d.isInfinite()) { + return d > 0 ? "Infinity" : "-Infinity"; + } + return d.toString(); + } + if (val instanceof List list) { + return formatList(list); + } + if (val instanceof Map map) { + return formatMap(map); + } + if (val instanceof NullValue) { + return "null"; + } + if (val instanceof TypeType typeType) { + return typeType.containingTypeName(); + } + if (val instanceof CelType celType) { + return celType.name(); + } + throw new CelEvaluationException( + "could not convert argument " + val.getClass().getName() + " to string"); + } + + private static String formatList(List list) throws CelEvaluationException { + 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 String formatMap(Map map) throws CelEvaluationException { + TreeMap sortedMap = new TreeMap<>(); + for (Map.Entry entry : map.entrySet()) { + String keyStr = formatString(entry.getKey()); + sortedMap.put(keyStr, entry.getValue()); + } + StringBuilder sb = new StringBuilder("{"); + int i = 0; + for (Map.Entry entry : sortedMap.entrySet()) { + sb.append(entry.getKey()).append(": ").append(formatString(entry.getValue())); + if (i < sortedMap.size() - 1) { + sb.append(", "); + } + i++; + } + sb.append("}"); + return sb.toString(); + } + + private static String formatDecimal(Object arg) throws CelEvaluationException { + if (arg instanceof Long || arg instanceof UnsignedLong) { + return arg.toString(); + } + if (arg instanceof Double) { + return formatFixed(arg, -1); + } + throw new CelEvaluationException( + "decimal clause can only be used on numbers, was given " + arg.getClass().getName()); + } + + private static String formatFixed(Object arg, int precision) throws CelEvaluationException { + if (arg instanceof Double d) { + double val = d; + if (Double.isNaN(val)) { + return "NaN"; + } + if (Double.isInfinite(val)) { + return val > 0 ? "Infinity" : "-Infinity"; + } + int p = precision >= 0 ? precision : 6; + BigDecimal bd = BigDecimal.valueOf(val); + bd = bd.setScale(p, RoundingMode.HALF_EVEN); + return bd.toPlainString(); + } + if (arg instanceof Long l) { + return formatFixed((double) l, precision); + } + if (arg instanceof UnsignedLong ulong) { + return formatFixed(ulong.doubleValue(), precision); + } + throw new CelEvaluationException( + "fixed point clause can only be used on doubles, integers, and unsigned integers, was given" + + " " + + arg.getClass().getName()); + } + + private static String formatScientific(Object arg, int precision) throws CelEvaluationException { + if (arg instanceof Double d) { + double val = d; + 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); + } + if (arg instanceof Long l) { + return formatScientific((double) l, precision); + } + if (arg instanceof UnsignedLong ulong) { + return formatScientific(ulong.doubleValue(), precision); + } + throw new CelEvaluationException( + "scientific clause can only be used on doubles, integers, and unsigned integers, was given " + + arg.getClass().getName()); + } + + private static String formatBinary(Object arg) throws CelEvaluationException { + if (arg instanceof Long val) { + if (val < 0) { + if (val == Long.MIN_VALUE) { + return "-1" + "0".repeat(63); + } + return "-" + Long.toBinaryString(-val); + } + return Long.toBinaryString(val); + } + if (arg instanceof UnsignedLong ulong) { + return ulong.toString(2); + } + if (arg instanceof Boolean b) { + return b ? "1" : "0"; + } + throw new CelEvaluationException( + "binary clause can only be used on integers and bools, was given " + + arg.getClass().getName()); + } + + private static String formatHex(Object arg, boolean upper) throws CelEvaluationException { + String result; + if (arg instanceof Long val) { + 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) { + result = unsignedLong.toString(16); + } else if (arg instanceof CelByteString byteString) { + result = HexFormat.of().formatHex(byteString.toByteArray()); + } else if (arg instanceof String str) { + result = HexFormat.of().formatHex(str.getBytes(UTF_8)); + } else { + throw new CelEvaluationException( + "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) throws CelEvaluationException { + if (arg instanceof Long val) { + if (val < 0) { + if (val == Long.MIN_VALUE) { + return "-1000000000000000000000"; + } + return "-" + String.format("%o", -val); + } + return String.format("%o", val); + } + if (arg instanceof UnsignedLong ulong) { + return ulong.toString(8); + } + throw new CelEvaluationException( + "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..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..bdd973a24 100644 --- a/extensions/src/test/java/dev/cel/extensions/CelStringExtensionsTest.java +++ b/extensions/src/test/java/dev/cel/extensions/CelStringExtensionsTest.java @@ -67,6 +67,7 @@ public void library() { assertThat(library.version(0).functions().stream().map(CelFunctionDecl::name)) .containsExactly( "charAt", + "format", "indexOf", "join", "lastIndexOf", @@ -1473,5 +1474,213 @@ 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([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: \"'%s'.format([duration('2ns')])\", expectedResult: '0.000000002s'}") + 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: \"'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])\", expectedResult: '3.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: \"'%.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'}") + 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: \"'%d %s'.format([42, {'key': 1}])\", expectedResult: '42 {key: 1}'}") + 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: 'could not find end of precision specifier'}") + @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)); + assertThat(exception).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)); + assertThat(exception).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)); + assertThat(exception).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)); + assertThat(exception).hasMessageThat().contains(expectedMessage); + } }