From deee22f5477a4141b2ef1faef19032e4b2ad874f Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 13:13:09 +0200 Subject: [PATCH 01/33] Align CEL-Spec conformance behavior Update string-to-bool conversion to accept the CEL-Spec spellings while rejecting unsupported mixed-case values. Compare type values by CEL type name so well-known object type values match their built-in timestamp and duration type values. Allow null assignment to wrapper fields and leave wrapper, message, timestamp, and duration fields unset during protobuf construction. Support backtick-quoted field names in select expressions and message field initializers, then remove the now-passing conformance skips. --- .../conformance/SimpleConformanceTest.java | 19 ++--------- .../org/projectnessie/cel/checker/Types.java | 3 ++ .../cel/common/types/StringT.java | 18 +++++++--- .../projectnessie/cel/common/types/TypeT.java | 4 +-- .../common/types/pb/ProtoTypeRegistry.java | 22 +++++++++++-- .../org/projectnessie/cel/parser/Parser.java | 15 +++++++-- .../cel/checker/CheckerTest.java | 4 +++ .../cel/common/types/ProviderTest.java | 33 +++++++++++++++++++ .../cel/common/types/StringTest.java | 17 ++++++++++ .../cel/common/types/TypeTest.java | 13 ++++++++ .../projectnessie/cel/parser/ParserTest.java | 6 ++-- .../org.projectnessie.cel.parser.gen/CEL.g4 | 11 +++++-- 12 files changed, 131 insertions(+), 34 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index b71ec3f1..5f30a7ef 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -146,26 +146,11 @@ class SimpleConformanceTest { // TODO Actual known issue: protobuf Any returned by this test is wrapped twice. "dynamic/any/var", // New CEL-Spec v0.25.2 expectations that need follow-up parser/runtime changes. - "conversions/bool/string_1,string_t,string_0,string_f,string_true_badcase,string_false_badcase", - "fields/quoted_map_fields/field_access_slash,field_access_dash,field_access_dot,has_field_slash,has_field_dash,has_field_dot", "namespace/namespace_shadowing/comprehension_shadowing,comprehension_shadowing_disambiguation,comprehension_shadowing_parse_only,comprehension_shadowing_selector,comprehension_shadowing_selector_parse_only,comprehension_shadowing_namespaced_selector,comprehension_shadowing_namespaced_selector_parse_only,comprehension_shadowing_nesting", - "proto2/set_null/single_message,single_duration,single_timestamp,repeated_field_timestamp_null_pruned,repeated_field_duration_null_pruned,repeated_field_wrapper_null_pruned,map_timestamp_null_pruned,map_duration_null_pruned,map_wrapper_null_pruned,map_anytype_null_retained,single_scalar,repeated,map,list_value,single_struct", - "proto2/quoted_fields/set_field_with_quoted_name,get_field_with_quoted_name", + "proto2/set_null/repeated_field_timestamp_null_pruned,repeated_field_duration_null_pruned,repeated_field_wrapper_null_pruned,map_timestamp_null_pruned,map_duration_null_pruned,map_wrapper_null_pruned,map_anytype_null_retained,single_scalar,repeated,map,list_value,single_struct", "proto2/extensions_has/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", "proto2/extensions_get/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", - "proto3/set_null/single_message,single_duration,single_timestamp,repeated_field_timestamp_null_pruned,repeated_field_duration_null_pruned,repeated_field_wrapper_null_pruned,map_timestamp_null_pruned,map_duration_null_pruned,map_wrapper_null_pruned,map_anytype_null_retained,single_scalar,repeated,map,list_value,single_struct", - "proto3/quoted_fields/set_field,get_field", - "timestamps/timestamp_conversions/type_comparison", - "timestamps/duration_conversions/type_comparison", - "wrappers/bool/to_null", - "wrappers/int32/to_null", - "wrappers/int64/to_null", - "wrappers/uint32/to_null", - "wrappers/uint64/to_null", - "wrappers/float/to_null", - "wrappers/double/to_null", - "wrappers/bytes/to_null", - "wrappers/string/to_null", + "proto3/set_null/repeated_field_timestamp_null_pruned,repeated_field_duration_null_pruned,repeated_field_wrapper_null_pruned,map_timestamp_null_pruned,map_duration_null_pruned,map_wrapper_null_pruned,map_anytype_null_retained,single_scalar,repeated,map,list_value,single_struct", "dynamic/int32/field_assign_proto2_range,field_assign_proto3_range", "dynamic/uint32/field_assign_proto2_range,field_assign_proto3_range", "dynamic/float/field_assign_proto2_range,field_assign_proto3_range", diff --git a/core/src/main/java/org/projectnessie/cel/checker/Types.java b/core/src/main/java/org/projectnessie/cel/checker/Types.java index 1032310e..db41fd73 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/Types.java +++ b/core/src/main/java/org/projectnessie/cel/checker/Types.java @@ -337,6 +337,9 @@ static boolean internalIsAssignable(Mapping m, Type t1, Type t2) { if (isDynOrError(t1) || isDynOrError(t2)) { return true; } + if (kind2 == Kind.kindNull) { + return internalIsAssignableNull(t1); + } // Test for when the types do not need to agree, but are more specific than dyn. switch (kind1) { diff --git a/core/src/main/java/org/projectnessie/cel/common/types/StringT.java b/core/src/main/java/org/projectnessie/cel/common/types/StringT.java index db308eb4..bc214752 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/StringT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/StringT.java @@ -128,11 +128,19 @@ public Val convertToType(Type typeVal) { case Double: return doubleOf(Double.parseDouble(s)); case Bool: - if ("true".equalsIgnoreCase(s)) { - return True; - } - if ("false".equalsIgnoreCase(s)) { - return False; + switch (s) { + case "1": + case "t": + case "true": + case "TRUE": + case "True": + return True; + case "0": + case "f": + case "false": + case "FALSE": + case "False": + return False; } break; case Bytes: diff --git a/core/src/main/java/org/projectnessie/cel/common/types/TypeT.java b/core/src/main/java/org/projectnessie/cel/common/types/TypeT.java index 56ddacef..bd1f320d 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/TypeT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/TypeT.java @@ -154,11 +154,11 @@ public boolean equals(Object o) { if (this == o) { return true; } - if (o == null || getClass() != o.getClass()) { + if (!(o instanceof Type)) { return false; } Type typeValue = (Type) o; - return typeEnum == typeValue.typeEnum() && typeName().equals(typeValue.typeName()); + return typeName().equals(typeValue.typeName()); } @Override diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java index 18aa9fe1..973863f4 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java @@ -268,13 +268,17 @@ private Val newValueSetFields(Map fields, PbTypeDescription td, Bui // TODO resolve inefficiency for maps: first converted from a MapT to a native Java map and // then to a protobuf struct. The intermediate step (the Java map) could be omitted. + FieldDescriptor pbDesc = field.descriptor(); + if (nv.getValue() == org.projectnessie.cel.common.types.NullT.NullValue + && isNullClearedField(pbDesc)) { + continue; + } + Object value = nv.getValue().convertToNative(field.reflectType()); if (value.getClass().isArray()) { value = Arrays.asList((Object[]) value); } - FieldDescriptor pbDesc = field.descriptor(); - if (pbDesc.getJavaType() == JavaType.ENUM) { value = intToProtoEnumValues(field, value); } @@ -288,6 +292,20 @@ private Val newValueSetFields(Map fields, PbTypeDescription td, Bui return null; } + private static boolean isNullClearedField(FieldDescriptor field) { + if (field.getJavaType() != JavaType.MESSAGE || field.isRepeated() || field.isMapField()) { + return false; + } + String typeName = field.getMessageType().getFullName(); + Type wellKnownType = Checked.CheckedWellKnowns.get(typeName); + if (wellKnownType == null) { + return true; + } + return wellKnownType.hasWrapper() + || typeName.equals("google.protobuf.Duration") + || typeName.equals("google.protobuf.Timestamp"); + } + /** * Converts {@code value}, of the map-field {@code fieldDesc} from its Java {@link Map} * representation to the protobuf-y {@code {@link List}<{@link MapEntry}>} representation. diff --git a/core/src/main/java/org/projectnessie/cel/parser/Parser.java b/core/src/main/java/org/projectnessie/cel/parser/Parser.java index 832cbecd..a59ea8a8 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/Parser.java +++ b/core/src/main/java/org/projectnessie/cel/parser/Parser.java @@ -52,6 +52,7 @@ import org.projectnessie.cel.parser.gen.CELParser.DoubleContext; import org.projectnessie.cel.parser.gen.CELParser.ExprContext; import org.projectnessie.cel.parser.gen.CELParser.ExprListContext; +import org.projectnessie.cel.parser.gen.CELParser.FieldContext; import org.projectnessie.cel.parser.gen.CELParser.FieldInitializerListContext; import org.projectnessie.cel.parser.gen.CELParser.IdentOrGlobalCallContext; import org.projectnessie.cel.parser.gen.CELParser.IndexContext; @@ -694,14 +695,14 @@ List visitIFieldInitializerList(FieldInitializerListContext ctx) { List cols = ctx.cols; List vals = ctx.values; for (int i = 0; i < ctx.fields.size(); i++) { - Token f = ctx.fields.get(i); + FieldContext f = ctx.fields.get(i); if (i >= cols.size() || i >= vals.size()) { // This is the result of a syntax error detected elsewhere. return Collections.emptyList(); } long initID = helper.id(cols.get(i)); Expr value = exprVisit(vals.get(i)); - Entry field = helper.newObjectField(initID, f.getText(), value); + Entry field = helper.newObjectField(initID, fieldName(f), value); result.add(field); } return result; @@ -739,7 +740,7 @@ private Expr visitSelectOrCall(SelectOrCallContext ctx) { if (ctx.id == null) { return helper.newExpr(ctx); } - String id = ctx.id.getText(); + String id = fieldName(ctx.id); if (ctx.open != null) { long opID = helper.id(ctx.open); return receiverCallOrMacro(opID, id, operand, visitList(ctx.args)); @@ -747,6 +748,14 @@ private Expr visitSelectOrCall(SelectOrCallContext ctx) { return helper.newSelect(ctx.op, operand, id); } + private String fieldName(FieldContext ctx) { + String text = ctx.getText(); + if (text.length() >= 2 && text.charAt(0) == '`' && text.charAt(text.length() - 1) == '`') { + return text.substring(1, text.length() - 1); + } + return text; + } + private List visitMapInitializerList(MapInitializerListContext ctx) { if (ctx == null || ctx.keys.isEmpty()) { // This is the result of a syntax error handled elswhere, return empty. diff --git a/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java b/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java index ad079656..7ae4492e 100644 --- a/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java +++ b/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java @@ -269,6 +269,10 @@ static TestCase[] checkTestCases() { + " single_int64 : 2~int\n" + "}~cel.expr.conformance.proto3.TestAllTypes^cel.expr.conformance.proto3.TestAllTypes") .type(Decls.newObjectType("cel.expr.conformance.proto3.TestAllTypes")), + new TestCase() + .i("TestAllTypes{single_bool_wrapper: null}") + .container("cel.expr.conformance.proto3") + .type(Decls.newObjectType("cel.expr.conformance.proto3.TestAllTypes")), new TestCase() .i("TestAllTypes{single_int32: 1u}") .container("cel.expr.conformance.proto3") diff --git a/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java b/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java index 6396e1f9..625041b6 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java @@ -207,6 +207,39 @@ void typeRegistryNewValue_WrapperFields() { .isEqualTo(123); } + @Test + void typeRegistryNewValue_NullWrapperFieldIsUnset() { + TypeRegistry reg = newRegistry(TestAllTypes.getDefaultInstance()); + Val exp = + reg.newValue( + "cel.expr.conformance.proto3.TestAllTypes", mapOf("single_int32_wrapper", NullValue)); + + assertThat(exp).matches(v -> !Err.isError(v)); + TestAllTypes ce = exp.convertToNative(TestAllTypes.class); + assertThat(ce.hasSingleInt32Wrapper()).isFalse(); + } + + @Test + void typeRegistryNewValue_NullMessageFieldsAreUnset() { + TypeRegistry reg = newRegistry(TestAllTypes.getDefaultInstance()); + Val exp = + reg.newValue( + "cel.expr.conformance.proto3.TestAllTypes", + mapOf( + "single_nested_message", + NullValue, + "single_duration", + NullValue, + "single_timestamp", + NullValue)); + + assertThat(exp).matches(v -> !Err.isError(v)); + TestAllTypes ce = exp.convertToNative(TestAllTypes.class); + assertThat(ce.hasSingleNestedMessage()).isFalse(); + assertThat(ce.hasSingleDuration()).isFalse(); + assertThat(ce.hasSingleTimestamp()).isFalse(); + } + @Test void typeRegistryGetters() { TypeRegistry reg = newRegistry(ParsedExpr.getDefaultInstance()); diff --git a/core/src/test/java/org/projectnessie/cel/common/types/StringTest.java b/core/src/test/java/org/projectnessie/cel/common/types/StringTest.java index 6ef673fa..5a203def 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/StringTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/StringTest.java @@ -122,7 +122,24 @@ void stringConvertToNative_Wrapper() { @Test void stringConvertToType() { assertThat(stringOf("-1").convertToType(IntType).equal(IntNegOne)).isSameAs(True); + assertThat(stringOf("1").convertToType(BoolType).equal(True)).isSameAs(True); + assertThat(stringOf("t").convertToType(BoolType).equal(True)).isSameAs(True); + assertThat(stringOf("true").convertToType(BoolType).equal(True)).isSameAs(True); + assertThat(stringOf("TRUE").convertToType(BoolType).equal(True)).isSameAs(True); + assertThat(stringOf("True").convertToType(BoolType).equal(True)).isSameAs(True); + assertThat(stringOf("0").convertToType(BoolType).equal(False)).isSameAs(True); + assertThat(stringOf("f").convertToType(BoolType).equal(False)).isSameAs(True); assertThat(stringOf("false").convertToType(BoolType).equal(False)).isSameAs(True); + assertThat(stringOf("FALSE").convertToType(BoolType).equal(False)).isSameAs(True); + assertThat(stringOf("False").convertToType(BoolType).equal(False)).isSameAs(True); + assertThat(stringOf("TrUe").convertToType(BoolType)) + .isInstanceOf(Err.class) + .extracting(Object::toString) + .isEqualTo("type conversion error from 'string' to 'bool'"); + assertThat(stringOf("FaLsE").convertToType(BoolType)) + .isInstanceOf(Err.class) + .extracting(Object::toString) + .isEqualTo("type conversion error from 'string' to 'bool'"); assertThat(stringOf("1").convertToType(UintType).equal(uintOf(1))).isSameAs(True); assertThat(stringOf("2.5").convertToType(DoubleType).equal(doubleOf(2.5))).isSameAs(True); assertThat( diff --git a/core/src/test/java/org/projectnessie/cel/common/types/TypeTest.java b/core/src/test/java/org/projectnessie/cel/common/types/TypeTest.java index ae658729..e08ca174 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/TypeTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/TypeTest.java @@ -62,4 +62,17 @@ void typeConvertToType() { void typeType() { assertThat(TypeType.type()).isSameAs(TypeType); } + + @Test + void objectTypeEqualsBuiltInTypeWithSameName() { + assertThat(TypeT.newObjectTypeValue("google.protobuf.Timestamp").equal(TimestampType)) + .isSameAs(BoolT.True); + assertThat(TimestampType.equal(TypeT.newObjectTypeValue("google.protobuf.Timestamp"))) + .isSameAs(BoolT.True); + + assertThat(TypeT.newObjectTypeValue("google.protobuf.Duration").equal(DurationType)) + .isSameAs(BoolT.True); + assertThat(DurationType.equal(TypeT.newObjectTypeValue("google.protobuf.Duration"))) + .isSameAs(BoolT.True); + } } diff --git a/core/src/test/java/org/projectnessie/cel/parser/ParserTest.java b/core/src/test/java/org/projectnessie/cel/parser/ParserTest.java index c7de0414..41d23226 100644 --- a/core/src/test/java/org/projectnessie/cel/parser/ParserTest.java +++ b/core/src/test/java/org/projectnessie/cel/parser/ParserTest.java @@ -965,7 +965,7 @@ public static String[][] testCases() { "93", "{\"a\": 1}.\"a\"", "", - "ERROR: :1:10: Syntax error: mismatched input '\"a\"' expecting IDENTIFIER\n" + "ERROR: :1:10: Syntax error: mismatched input '\"a\"' expecting {IDENTIFIER, ESC_IDENTIFIER}\n" + " | {\"a\": 1}.\"a\"\n" + " | .........^", "", @@ -1200,7 +1200,7 @@ public static String[][] testCases() { "122", "func{{a}}", "", - "ERROR: :1:6: Syntax error: extraneous input '{' expecting {'}', ',', IDENTIFIER}\n" + "ERROR: :1:6: Syntax error: extraneous input '{' expecting {'}', ',', IDENTIFIER, ESC_IDENTIFIER}\n" + " | func{{a}}\n" + " | .....^\n" + "ERROR: :1:8: Syntax error: mismatched input '}' expecting ':'\n" @@ -1215,7 +1215,7 @@ public static String[][] testCases() { "123", "msg{:a}", "", - "ERROR: :1:5: Syntax error: extraneous input ':' expecting {'}', ',', IDENTIFIER}\n" + "ERROR: :1:5: Syntax error: extraneous input ':' expecting {'}', ',', IDENTIFIER, ESC_IDENTIFIER}\n" + " | msg{:a}\n" + " | ....^\n" + "ERROR: :1:7: Syntax error: mismatched input '}' expecting ':'\n" diff --git a/generated-antlr/src/main/antlr/org.projectnessie.cel.parser.gen/CEL.g4 b/generated-antlr/src/main/antlr/org.projectnessie.cel.parser.gen/CEL.g4 index 75add0e6..b25a937f 100644 --- a/generated-antlr/src/main/antlr/org.projectnessie.cel.parser.gen/CEL.g4 +++ b/generated-antlr/src/main/antlr/org.projectnessie.cel.parser.gen/CEL.g4 @@ -56,7 +56,7 @@ unary member : primary # PrimaryExpr - | member op='.' id=IDENTIFIER (open='(' args=exprList? ')')? # SelectOrCall + | member op='.' id=field (open='(' args=exprList? ')')? # SelectOrCall | member op='[' index=expr ']' # Index | member op='{' entries=fieldInitializerList? ','? '}' # CreateMessage ; @@ -74,7 +74,12 @@ exprList ; fieldInitializerList - : fields+=IDENTIFIER cols+=':' values+=expr (',' fields+=IDENTIFIER cols+=':' values+=expr)* + : fields+=field cols+=':' values+=expr (',' fields+=field cols+=':' values+=expr)* + ; + +field + : IDENTIFIER + | ESC_IDENTIFIER ; mapInitializerList @@ -188,3 +193,5 @@ STRING BYTES : ('b' | 'B') STRING; IDENTIFIER : (LETTER | '_') ( LETTER | DIGIT | '_')*; + +ESC_IDENTIFIER : '`' (ESC_SEQ | ~('\\' | '`' | '\n' | '\r'))* '`'; From 18d03b11bd29a63341cece2ad745726e6bf96b22 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 13:20:14 +0200 Subject: [PATCH 02/33] Prune nulls from protobuf repeated and map fields Handle CEL null values at the protobuf construction boundary for repeated and map fields whose value type is timestamp, duration, or a protobuf wrapper. Convert map message values to the actual target protobuf message type instead of always using google.protobuf.Value. Avoid hasField() for repeated wrapper fields when reading protobuf values, since repeated fields do not support presence checks. --- .../conformance/SimpleConformanceTest.java | 4 +- .../cel/common/types/pb/FieldDescription.java | 2 +- .../common/types/pb/ProtoTypeRegistry.java | 85 ++++++++++++++++++- .../cel/common/types/ProviderTest.java | 57 +++++++++++++ 4 files changed, 141 insertions(+), 7 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 5f30a7ef..75413b49 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -147,10 +147,10 @@ class SimpleConformanceTest { "dynamic/any/var", // New CEL-Spec v0.25.2 expectations that need follow-up parser/runtime changes. "namespace/namespace_shadowing/comprehension_shadowing,comprehension_shadowing_disambiguation,comprehension_shadowing_parse_only,comprehension_shadowing_selector,comprehension_shadowing_selector_parse_only,comprehension_shadowing_namespaced_selector,comprehension_shadowing_namespaced_selector_parse_only,comprehension_shadowing_nesting", - "proto2/set_null/repeated_field_timestamp_null_pruned,repeated_field_duration_null_pruned,repeated_field_wrapper_null_pruned,map_timestamp_null_pruned,map_duration_null_pruned,map_wrapper_null_pruned,map_anytype_null_retained,single_scalar,repeated,map,list_value,single_struct", + "proto2/set_null/map_anytype_null_retained,single_scalar,repeated,map,list_value,single_struct", "proto2/extensions_has/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", "proto2/extensions_get/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", - "proto3/set_null/repeated_field_timestamp_null_pruned,repeated_field_duration_null_pruned,repeated_field_wrapper_null_pruned,map_timestamp_null_pruned,map_duration_null_pruned,map_wrapper_null_pruned,map_anytype_null_retained,single_scalar,repeated,map,list_value,single_struct", + "proto3/set_null/map_anytype_null_retained,single_scalar,repeated,map,list_value,single_struct", "dynamic/int32/field_assign_proto2_range,field_assign_proto3_range", "dynamic/uint32/field_assign_proto2_range,field_assign_proto3_range", "dynamic/float/field_assign_proto2_range,field_assign_proto3_range", diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/FieldDescription.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/FieldDescription.java index 9eb6ea86..781752d2 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/FieldDescription.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/FieldDescription.java @@ -466,7 +466,7 @@ public Object getField(Object target, TypeAdapter adapter) { public static Object getValueFromField(FieldDescriptor desc, Message message) { - if (isWellKnownType(desc) && !message.hasField(desc)) { + if (!desc.isRepeated() && isWellKnownType(desc) && !message.hasField(desc)) { return NullValue.NULL_VALUE; } diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java index 973863f4..34a47698 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java @@ -79,10 +79,12 @@ import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import org.projectnessie.cel.common.types.NullT; import org.projectnessie.cel.common.types.TypeT; import org.projectnessie.cel.common.types.ref.FieldType; import org.projectnessie.cel.common.types.ref.TypeRegistry; import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.common.types.traits.Lister; public final class ProtoTypeRegistry implements TypeRegistry { private static final ProtoTypeRegistry DEFAULT_REGISTRY = newDefaultRegistry(); @@ -274,7 +276,7 @@ && isNullClearedField(pbDesc)) { continue; } - Object value = nv.getValue().convertToNative(field.reflectType()); + Object value = toNativeFieldValue(nv.getValue(), field); if (value.getClass().isArray()) { value = Arrays.asList((Object[]) value); } @@ -292,20 +294,86 @@ && isNullClearedField(pbDesc)) { return null; } + private Object toNativeFieldValue(Val value, FieldDescription field) { + FieldDescriptor fieldDesc = field.descriptor(); + if (fieldDesc.isRepeated() + && !fieldDesc.isMapField() + && isNullPrunedMessageField(fieldDesc) + && value instanceof Lister) { + return toNativeRepeatedFieldValue((Lister) value, fieldDesc); + } + return value.convertToNative(field.reflectType()); + } + + private Object toNativeRepeatedFieldValue(Lister value, FieldDescriptor fieldDesc) { + Class elementType = messageNativeType(fieldDesc); + int size = (int) value.size().intValue(); + List converted = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + Val element = value.get(intOf(i)); + if (element == NullT.NullValue && isNullPrunedMessageField(fieldDesc)) { + continue; + } + converted.add(element.convertToNative(elementType)); + } + return converted; + } + private static boolean isNullClearedField(FieldDescriptor field) { if (field.getJavaType() != JavaType.MESSAGE || field.isRepeated() || field.isMapField()) { return false; } + Type wellKnownType = Checked.CheckedWellKnowns.get(field.getMessageType().getFullName()); + return wellKnownType == null || isNullPrunedMessageField(field); + } + + private static boolean isNullPrunedMessageField(FieldDescriptor field) { + if (field.getJavaType() != JavaType.MESSAGE) { + return false; + } String typeName = field.getMessageType().getFullName(); Type wellKnownType = Checked.CheckedWellKnowns.get(typeName); if (wellKnownType == null) { - return true; + return false; } return wellKnownType.hasWrapper() || typeName.equals("google.protobuf.Duration") || typeName.equals("google.protobuf.Timestamp"); } + private static Class messageNativeType(FieldDescriptor field) { + switch (field.getMessageType().getFullName()) { + case "google.protobuf.Any": + return Any.class; + case "google.protobuf.BoolValue": + return BoolValue.class; + case "google.protobuf.BytesValue": + return BytesValue.class; + case "google.protobuf.DoubleValue": + return DoubleValue.class; + case "google.protobuf.Duration": + return Duration.class; + case "google.protobuf.FloatValue": + return FloatValue.class; + case "google.protobuf.Int32Value": + return Int32Value.class; + case "google.protobuf.Int64Value": + return Int64Value.class; + case "google.protobuf.StringValue": + return StringValue.class; + case "google.protobuf.Timestamp": + return Timestamp.class; + case "google.protobuf.UInt32Value": + return UInt32Value.class; + case "google.protobuf.UInt64Value": + return UInt64Value.class; + case "google.protobuf.Value": + return Value.class; + default: + return Message.class; + } + } + /** * Converts {@code value}, of the map-field {@code fieldDesc} from its Java {@link Map} * representation to the protobuf-y {@code {@link List}<{@link MapEntry}>} representation. @@ -327,8 +395,13 @@ private Object toProtoMapStructure(FieldDescriptor fieldDesc, Object value) { // if (!(k instanceof String)) { // return Err.newTypeConversionError(k.getClass().getName(), String.class.getName()); // } - if (valueFieldType == WireFormat.FieldType.MESSAGE && !(v instanceof Message)) { - v = nativeToValue(v).convertToNative(Value.class); + if (valueFieldType == WireFormat.FieldType.MESSAGE) { + if (isNullNativeValue(v) && isNullPrunedMessageField(valueType)) { + continue; + } + if (!(v instanceof Message)) { + v = nativeToValue(v).convertToNative(messageNativeType(valueType)); + } } MapEntry newEntry = @@ -341,6 +414,10 @@ private Object toProtoMapStructure(FieldDescriptor fieldDesc, Object value) { return value; } + private static boolean isNullNativeValue(Object value) { + return value == null || value == com.google.protobuf.NullValue.NULL_VALUE; + } + /** * Converts a value of type {@link Number} to {@link EnumValueDescriptor}, also works for arrays * and {@link List}s containing {@link Number}s. diff --git a/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java b/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java index 625041b6..65c64ffd 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java @@ -240,6 +240,63 @@ void typeRegistryNewValue_NullMessageFieldsAreUnset() { assertThat(ce.hasSingleTimestamp()).isFalse(); } + @Test + void typeRegistryNewValue_RepeatedMessageFieldNullsArePruned() { + TypeRegistry reg = newRegistry(TestAllTypes.getDefaultInstance()); + Val exp = + reg.newValue( + "cel.expr.conformance.proto3.TestAllTypes", + mapOf( + "repeated_timestamp", + newGenericArrayList( + reg, + new Val[] {timestampOf(Instant.ofEpochSecond(1).atZone(ZoneIdZ)), NullValue}), + "repeated_duration", + newGenericArrayList(reg, new Val[] {durationOf(Duration.ofSeconds(1)), NullValue}), + "repeated_int32_wrapper", + newGenericArrayList(reg, new Val[] {intOf(1), NullValue}))); + + assertThat(exp).matches(v -> !Err.isError(v)); + TestAllTypes ce = exp.convertToNative(TestAllTypes.class); + assertThat(ce.getRepeatedTimestampList()) + .containsExactly(Timestamp.newBuilder().setSeconds(1).build()); + assertThat(ce.getRepeatedDurationList()) + .containsExactly(com.google.protobuf.Duration.newBuilder().setSeconds(1).build()); + assertThat(ce.getRepeatedInt32WrapperList()).containsExactly(Int32Value.of(1)); + } + + @Test + void typeRegistryNewValue_MapMessageFieldNullsArePruned() { + TypeRegistry reg = newRegistry(TestAllTypes.getDefaultInstance()); + Val exp = + reg.newValue( + "cel.expr.conformance.proto3.TestAllTypes", + mapOf( + "map_bool_timestamp", + newMaybeWrappedMap( + reg, + mapOf( + true, + NullValue, + false, + timestampOf(Instant.ofEpochSecond(1).atZone(ZoneIdZ)))), + "map_bool_duration", + newMaybeWrappedMap( + reg, mapOf(true, NullValue, false, durationOf(Duration.ofSeconds(1)))), + "map_bool_int32_wrapper", + newMaybeWrappedMap(reg, mapOf(true, NullValue, false, intOf(1))))); + + assertThat(exp).matches(v -> !Err.isError(v)); + TestAllTypes ce = exp.convertToNative(TestAllTypes.class); + assertThat(ce.getMapBoolTimestampMap()) + .containsExactlyEntriesOf(mapOf(false, Timestamp.newBuilder().setSeconds(1).build())); + assertThat(ce.getMapBoolDurationMap()) + .containsExactlyEntriesOf( + mapOf(false, com.google.protobuf.Duration.newBuilder().setSeconds(1).build())); + assertThat(ce.getMapBoolInt32WrapperMap()) + .containsExactlyEntriesOf(mapOf(false, Int32Value.of(1))); + } + @Test void typeRegistryGetters() { TypeRegistry reg = newRegistry(ParsedExpr.getDefaultInstance()); From 02ba1963f49ae33863c7aa37fef2d2628380cc8f Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 13:22:17 +0200 Subject: [PATCH 03/33] Remove stale map Any null-retention skips The map Any null-retention conformance cases now pass after the protobuf map conversion changes. Remove the proto2 and proto3 skip entries so the JUnit conformance suite covers those cases. --- .../projectnessie/cel/conformance/SimpleConformanceTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 75413b49..d7404ed1 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -147,10 +147,10 @@ class SimpleConformanceTest { "dynamic/any/var", // New CEL-Spec v0.25.2 expectations that need follow-up parser/runtime changes. "namespace/namespace_shadowing/comprehension_shadowing,comprehension_shadowing_disambiguation,comprehension_shadowing_parse_only,comprehension_shadowing_selector,comprehension_shadowing_selector_parse_only,comprehension_shadowing_namespaced_selector,comprehension_shadowing_namespaced_selector_parse_only,comprehension_shadowing_nesting", - "proto2/set_null/map_anytype_null_retained,single_scalar,repeated,map,list_value,single_struct", + "proto2/set_null/single_scalar,repeated,map,list_value,single_struct", "proto2/extensions_has/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", "proto2/extensions_get/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", - "proto3/set_null/map_anytype_null_retained,single_scalar,repeated,map,list_value,single_struct", + "proto3/set_null/single_scalar,repeated,map,list_value,single_struct", "dynamic/int32/field_assign_proto2_range,field_assign_proto3_range", "dynamic/uint32/field_assign_proto2_range,field_assign_proto3_range", "dynamic/float/field_assign_proto2_range,field_assign_proto3_range", From d71f24b63149c5c3bbd6dd0bf1abd498a329a3ac Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 13:26:33 +0200 Subject: [PATCH 04/33] Return CEL errors for invalid protobuf field values Protobuf message construction could let bad field conversions escape as Java runtime exceptions, which broke disabled-check conformance cases expecting evaluation errors. Wrap field conversion and assignment failures from newValue() as CEL Err values, add provider coverage for invalid null assignments, and enable the remaining proto2/proto3 set_null conformance cases. --- .../conformance/SimpleConformanceTest.java | 2 -- .../common/types/pb/ProtoTypeRegistry.java | 26 +++++++++++-------- .../cel/common/types/ProviderTest.java | 12 +++++++++ 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index d7404ed1..1878ed97 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -147,10 +147,8 @@ class SimpleConformanceTest { "dynamic/any/var", // New CEL-Spec v0.25.2 expectations that need follow-up parser/runtime changes. "namespace/namespace_shadowing/comprehension_shadowing,comprehension_shadowing_disambiguation,comprehension_shadowing_parse_only,comprehension_shadowing_selector,comprehension_shadowing_selector_parse_only,comprehension_shadowing_namespaced_selector,comprehension_shadowing_namespaced_selector_parse_only,comprehension_shadowing_nesting", - "proto2/set_null/single_scalar,repeated,map,list_value,single_struct", "proto2/extensions_has/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", "proto2/extensions_get/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", - "proto3/set_null/single_scalar,repeated,map,list_value,single_struct", "dynamic/int32/field_assign_proto2_range,field_assign_proto3_range", "dynamic/uint32/field_assign_proto2_range,field_assign_proto3_range", "dynamic/float/field_assign_proto2_range,field_assign_proto3_range", diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java index 34a47698..08e5916e 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java @@ -276,20 +276,24 @@ && isNullClearedField(pbDesc)) { continue; } - Object value = toNativeFieldValue(nv.getValue(), field); - if (value.getClass().isArray()) { - value = Arrays.asList((Object[]) value); - } + try { + Object value = toNativeFieldValue(nv.getValue(), field); + if (value.getClass().isArray()) { + value = Arrays.asList((Object[]) value); + } - if (pbDesc.getJavaType() == JavaType.ENUM) { - value = intToProtoEnumValues(field, value); - } + if (pbDesc.getJavaType() == JavaType.ENUM) { + value = intToProtoEnumValues(field, value); + } - if (pbDesc.isMapField()) { - value = toProtoMapStructure(pbDesc, value); - } + if (pbDesc.isMapField()) { + value = toProtoMapStructure(pbDesc, value); + } - builder.setField(pbDesc, value); + builder.setField(pbDesc, value); + } catch (RuntimeException e) { + return newErr(e, "invalid value for field '%s': %s", name, e.getMessage()); + } } return null; } diff --git a/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java b/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java index 65c64ffd..da9bf1d5 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java @@ -297,6 +297,18 @@ reg, mapOf(true, NullValue, false, durationOf(Duration.ofSeconds(1)))), .containsExactlyEntriesOf(mapOf(false, Int32Value.of(1))); } + @Test + void typeRegistryNewValue_InvalidNullFieldAssignmentsReturnErrors() { + TypeRegistry reg = newRegistry(TestAllTypes.getDefaultInstance()); + String typeName = "cel.expr.conformance.proto3.TestAllTypes"; + + assertThat(reg.newValue(typeName, mapOf("single_bool", NullValue))).matches(Err::isError); + assertThat(reg.newValue(typeName, mapOf("repeated_int32", NullValue))).matches(Err::isError); + assertThat(reg.newValue(typeName, mapOf("map_string_string", NullValue))).matches(Err::isError); + assertThat(reg.newValue(typeName, mapOf("list_value", NullValue))).matches(Err::isError); + assertThat(reg.newValue(typeName, mapOf("single_struct", NullValue))).matches(Err::isError); + } + @Test void typeRegistryGetters() { TypeRegistry reg = newRegistry(ParsedExpr.getDefaultInstance()); From 26ede44fc49204f4e53718c7971090589ecfabcc Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 13:28:21 +0200 Subject: [PATCH 05/33] Add type deduction conformance coverage Include the CEL-Spec type_deduction suite in the JUnit conformance runner to cover checker result types for already-supported language features. Keep explicit skips for the remaining unsupported checker cases around optionals, flexible type parameter generality, wrapper promotion, and legacy nullable generic candidates. --- .../cel/conformance/SimpleConformanceTest.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 1878ed97..536892ca 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -126,6 +126,7 @@ class SimpleConformanceTest { "proto3.textproto", "string.textproto", "timestamps.textproto", + "type_deduction.textproto", "unknowns.textproto", "wrappers.textproto"); @@ -149,6 +150,13 @@ class SimpleConformanceTest { "namespace/namespace_shadowing/comprehension_shadowing,comprehension_shadowing_disambiguation,comprehension_shadowing_parse_only,comprehension_shadowing_selector,comprehension_shadowing_selector_parse_only,comprehension_shadowing_namespaced_selector,comprehension_shadowing_namespaced_selector_parse_only,comprehension_shadowing_nesting", "proto2/extensions_has/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", "proto2/extensions_get/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", + // type_deduction.textproto coverage was added opportunistically. The remaining skips are + // checker limitations around optionals, flexible type parameter generality, wrapper + // promotion, and legacy nullable generic candidates. + "type_deductions/flexible_type_parameter_assignment/comprehension_type_var_aliasing,optional_none,optional_none_2,optional_dyn_promotion,optional_dyn_promotion_2,optional_in_ternary", + "type_deductions/wrappers/wrapper_promotion", + "type_deductions/type_parameters/multiple_parameters_generality,multiple_parameters_generality_2", + "type_deductions/legacy_nullable_types/null_assignable_to_message_parameter_candidate,null_assignable_to_duration_parameter_candidate,null_assignable_to_timestamp_parameter_candidate,null_assignable_to_abstract_parameter_candidate", "dynamic/int32/field_assign_proto2_range,field_assign_proto3_range", "dynamic/uint32/field_assign_proto2_range,field_assign_proto3_range", "dynamic/float/field_assign_proto2_range,field_assign_proto3_range", From 9eae48710ff05ebd1025c9a80bc61cbd12f837e5 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 13:31:44 +0200 Subject: [PATCH 06/33] Enable passing float range conformance cases The dynamic float protobuf range-assignment cases now pass under the JUnit conformance runner. Remove the stale proto2 and proto3 float range skip entries while keeping the int32 and uint32 range cases skipped because they still overflow into protobuf field values instead of returning range errors. --- .../org/projectnessie/cel/conformance/SimpleConformanceTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 536892ca..62fecdbf 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -159,7 +159,6 @@ class SimpleConformanceTest { "type_deductions/legacy_nullable_types/null_assignable_to_message_parameter_candidate,null_assignable_to_duration_parameter_candidate,null_assignable_to_timestamp_parameter_candidate,null_assignable_to_abstract_parameter_candidate", "dynamic/int32/field_assign_proto2_range,field_assign_proto3_range", "dynamic/uint32/field_assign_proto2_range,field_assign_proto3_range", - "dynamic/float/field_assign_proto2_range,field_assign_proto3_range", "enums/legacy_proto2/assign_standalone_int_too_big,assign_standalone_int_too_neg", "enums/legacy_proto3/assign_standalone_int_too_big,assign_standalone_int_too_neg", "enums/strong_proto2", From 703778418657fb1b407f8581f237f29e3a7e67d6 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 13:32:28 +0200 Subject: [PATCH 07/33] Enable passing legacy enum range conformance cases The legacy proto2 and proto3 standalone enum out-of-range assignment cases now pass under the JUnit conformance runner. Remove the stale skip entries for the too-large and too-negative enum assignment cases. --- .../projectnessie/cel/conformance/SimpleConformanceTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 62fecdbf..7843e93b 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -159,8 +159,6 @@ class SimpleConformanceTest { "type_deductions/legacy_nullable_types/null_assignable_to_message_parameter_candidate,null_assignable_to_duration_parameter_candidate,null_assignable_to_timestamp_parameter_candidate,null_assignable_to_abstract_parameter_candidate", "dynamic/int32/field_assign_proto2_range,field_assign_proto3_range", "dynamic/uint32/field_assign_proto2_range,field_assign_proto3_range", - "enums/legacy_proto2/assign_standalone_int_too_big,assign_standalone_int_too_neg", - "enums/legacy_proto3/assign_standalone_int_too_big,assign_standalone_int_too_neg", "enums/strong_proto2", "enums/strong_proto3", "fields/qualified_identifier_resolution/map_key_float", From a75f6dc913cabb14ecbdb622098c1fbef8a00360 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 13:37:52 +0200 Subject: [PATCH 08/33] Check 32-bit protobuf wrapper ranges Int32Value and UInt32Value native conversions accepted values outside their protobuf wrapper ranges by truncating them to Java int. Add range checks for those wrapper conversions, cover the error paths in unit tests, and enable the int32 and uint32 dynamic range conformance cases. --- .../cel/conformance/SimpleConformanceTest.java | 2 -- .../org/projectnessie/cel/common/types/IntT.java | 3 +++ .../org/projectnessie/cel/common/types/UintT.java | 3 +++ .../org/projectnessie/cel/common/types/IntTest.java | 12 ++++++++++++ .../org/projectnessie/cel/common/types/UintTest.java | 11 +++++++++++ 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 7843e93b..22e26de5 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -157,8 +157,6 @@ class SimpleConformanceTest { "type_deductions/wrappers/wrapper_promotion", "type_deductions/type_parameters/multiple_parameters_generality,multiple_parameters_generality_2", "type_deductions/legacy_nullable_types/null_assignable_to_message_parameter_candidate,null_assignable_to_duration_parameter_candidate,null_assignable_to_timestamp_parameter_candidate,null_assignable_to_abstract_parameter_candidate", - "dynamic/int32/field_assign_proto2_range,field_assign_proto3_range", - "dynamic/uint32/field_assign_proto2_range,field_assign_proto3_range", "enums/strong_proto2", "enums/strong_proto3", "fields/qualified_identifier_resolution/map_key_float", diff --git a/core/src/main/java/org/projectnessie/cel/common/types/IntT.java b/core/src/main/java/org/projectnessie/cel/common/types/IntT.java index 17c2baf9..c098531f 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/IntT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/IntT.java @@ -139,6 +139,9 @@ public T convertToNative(Class typeDesc) { return (T) Int64Value.of(i); } if (typeDesc == Int32Value.class) { + if (i < Integer.MIN_VALUE || i > Integer.MAX_VALUE) { + Err.throwErrorAsIllegalStateException(rangeError(i, "Java int")); + } return (T) Int32Value.of((int) i); } if (typeDesc == Val.class || typeDesc == IntT.class) { diff --git a/core/src/main/java/org/projectnessie/cel/common/types/UintT.java b/core/src/main/java/org/projectnessie/cel/common/types/UintT.java index f0fe8b7b..715b28fe 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/UintT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/UintT.java @@ -120,6 +120,9 @@ public T convertToNative(Class typeDesc) { return (T) UInt64Value.of(i); } if (typeDesc == UInt32Value.class) { + if (Long.compareUnsigned(i, 0xffffffffL) > 0) { + Err.throwErrorAsIllegalStateException(rangeError(Long.toUnsignedString(i), "uint32")); + } return (T) UInt32Value.of((int) i); } if (typeDesc == Val.class || typeDesc == UintT.class) { diff --git a/core/src/test/java/org/projectnessie/cel/common/types/IntTest.java b/core/src/test/java/org/projectnessie/cel/common/types/IntTest.java index 48295aea..b0708c52 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/IntTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/IntTest.java @@ -16,6 +16,7 @@ package org.projectnessie.cel.common.types; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.projectnessie.cel.common.types.BoolT.False; import static org.projectnessie.cel.common.types.BoolT.True; import static org.projectnessie.cel.common.types.DoubleT.DoubleType; @@ -148,6 +149,17 @@ void intConvertToNative_Wrapper() { assertThat(val2).isEqualTo(want2); } + @Test + void intConvertToNative_Int32WrapperRangeError() { + assertThatThrownBy(() -> intOf(1L + Integer.MAX_VALUE).convertToNative(Int32Value.class)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("range error"); + + assertThatThrownBy(() -> intOf(-1L + Integer.MIN_VALUE).convertToNative(Int32Value.class)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("range error"); + } + @Test void intConvertToType() { assertThat(intOf(-4).convertToType(IntType).equal(intOf(-4))).isSameAs(True); diff --git a/core/src/test/java/org/projectnessie/cel/common/types/UintTest.java b/core/src/test/java/org/projectnessie/cel/common/types/UintTest.java index 61e9ce56..623482ef 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/UintTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/UintTest.java @@ -142,6 +142,17 @@ void uintConvertToNative_Wrapper() { assertThat(val2).isEqualTo(want2); } + @Test + void uintConvertToNative_UInt32WrapperRangeError() { + assertThatThrownBy(() -> uintOf(0x100000000L).convertToNative(UInt32Value.class)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("range error"); + + assertThatThrownBy(() -> uintOf(-1L).convertToNative(UInt32Value.class)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("range error"); + } + @Test void uintConvertToType() { // 18446744073709551612L From d3adc4c77cf360ac3dc5d0c09c61a27806c253be Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 13:41:40 +0200 Subject: [PATCH 09/33] Convert large uint values to JSON strings Unsigned CEL uint values stored in negative Java long values were treated as JSON numbers because the conversion compared the signed long directly. Use unsigned comparison for uint-to-JSON conversion, add coverage for the uint64 max value, and enable the uint64 wrapper JSON conformance case. --- .../cel/conformance/SimpleConformanceTest.java | 1 - .../java/org/projectnessie/cel/common/types/UintT.java | 2 +- .../java/org/projectnessie/cel/common/types/UintTest.java | 7 +++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 22e26de5..1efbb33a 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -160,7 +160,6 @@ class SimpleConformanceTest { "enums/strong_proto2", "enums/strong_proto3", "fields/qualified_identifier_resolution/map_key_float", - "wrappers/uint64/to_json_string", "wrappers/field_mask/to_json", "wrappers/timestamp/to_json", "wrappers/empty/to_json"); diff --git a/core/src/main/java/org/projectnessie/cel/common/types/UintT.java b/core/src/main/java/org/projectnessie/cel/common/types/UintT.java index 715b28fe..1c229ea3 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/UintT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/UintT.java @@ -129,7 +129,7 @@ public T convertToNative(Class typeDesc) { return (T) this; } if (typeDesc == Value.class) { - if (i <= maxIntJSON) { + if (Long.compareUnsigned(i, maxIntJSON) <= 0) { // JSON can accurately represent 32-bit uints as floating point values. return (T) Value.newBuilder().setNumberValue(i).build(); } else { diff --git a/core/src/test/java/org/projectnessie/cel/common/types/UintTest.java b/core/src/test/java/org/projectnessie/cel/common/types/UintTest.java index 623482ef..5972ded4 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/UintTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/UintTest.java @@ -109,9 +109,12 @@ void uintConvertToNative_Json() { Value val = uintOf(maxIntJSON).convertToNative(Value.class); assertThat(val).isEqualTo(Value.newBuilder().setNumberValue(9007199254740991.0d).build()); - // Value converts to a JSON decimal string - val = intOf(maxIntJSON + 1).convertToNative(Value.class); + // Value converts to a JSON decimal string. + val = uintOf(maxIntJSON + 1).convertToNative(Value.class); assertThat(val).isEqualTo(Value.newBuilder().setStringValue("9007199254740992").build()); + + val = uintOf(-1L).convertToNative(Value.class); + assertThat(val).isEqualTo(Value.newBuilder().setStringValue("18446744073709551615").build()); } @Test From 66dbcd35f20c9c5f2dc87035e81527e569634731 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 13:49:58 +0200 Subject: [PATCH 10/33] Support protobuf well-known JSON values Timestamp JSON conversion returned a wrapper message instead of google.protobuf.Value and omitted fractional seconds. FieldMask was also missing from the default well-known protobuf registration path, which kept its conformance case from checking. Return proper JSON Value instances for timestamp conversion, register FieldMask in the default protobuf type metadata, add narrow JSON Value conversion for FieldMask, Timestamp, and Empty message objects, and enable the corresponding wrapper JSON conformance cases. --- .../conformance/SimpleConformanceTest.java | 5 +- .../cel/common/types/TimestampT.java | 20 +------ .../cel/common/types/pb/Checked.java | 2 + .../projectnessie/cel/common/types/pb/Db.java | 2 + .../cel/common/types/pb/PbObjectT.java | 55 ++++++++++++++----- .../common/types/pb/ProtoTypeRegistry.java | 4 ++ .../cel/common/types/TimestampTest.java | 8 ++- .../cel/common/types/pb/PbObjectTest.java | 25 +++++++++ 8 files changed, 83 insertions(+), 38 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 1efbb33a..e32d7279 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -159,10 +159,7 @@ class SimpleConformanceTest { "type_deductions/legacy_nullable_types/null_assignable_to_message_parameter_candidate,null_assignable_to_duration_parameter_candidate,null_assignable_to_timestamp_parameter_candidate,null_assignable_to_abstract_parameter_candidate", "enums/strong_proto2", "enums/strong_proto3", - "fields/qualified_identifier_resolution/map_key_float", - "wrappers/field_mask/to_json", - "wrappers/timestamp/to_json", - "wrappers/empty/to_json"); + "fields/qualified_identifier_resolution/map_key_float"); private static final Set matchedSkips = new LinkedHashSet<>(); private static final AtomicInteger total = new AtomicInteger(); diff --git a/core/src/main/java/org/projectnessie/cel/common/types/TimestampT.java b/core/src/main/java/org/projectnessie/cel/common/types/TimestampT.java index b99caac0..e853b092 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/TimestampT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/TimestampT.java @@ -31,7 +31,6 @@ import static org.projectnessie.cel.common.types.Types.boolOf; import com.google.protobuf.Any; -import com.google.protobuf.StringValue; import com.google.protobuf.Timestamp; import com.google.protobuf.Value; import java.time.DateTimeException; @@ -249,7 +248,8 @@ public T convertToNative(Class typeDesc) { } if (typeDesc == Value.class) { // CEL follows the proto3 to JSON conversion which formats as an RFC 3339 encoded JSON string. - return (T) StringValue.of(jsonFormatter.format(t)); + DateTimeFormatter df = (t.getNano() > 0L) ? rfc3339nanoFormatter : rfc3339formatter; + return (T) Value.newBuilder().setStringValue(df.format(t)).build(); } throw new RuntimeException( @@ -283,22 +283,6 @@ public Val convertToType(Type typeValue) { return newTypeConversionError(TimestampType, typeValue); } - private static final DateTimeFormatter jsonFormatter = - new DateTimeFormatterBuilder() - .appendValue(ChronoField.YEAR, 4, 5, SignStyle.EXCEEDS_PAD) - .appendLiteral('-') - .appendValue(ChronoField.MONTH_OF_YEAR, 2) - .appendLiteral('-') - .appendValue(ChronoField.DAY_OF_MONTH, 2) - .appendLiteral('T') - .appendValue(ChronoField.HOUR_OF_DAY, 2) - .appendLiteral(':') - .appendValue(ChronoField.MINUTE_OF_HOUR, 2) - .appendLiteral(':') - .appendValue(ChronoField.SECOND_OF_MINUTE, 2) - .appendLiteral('Z') - .toFormatter(); - /** Only used for format a string, never for parsing. */ private static final DateTimeFormatter rfc3339formatter = new DateTimeFormatterBuilder() diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/Checked.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/Checked.java index 04b5b232..0a0f3dee 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/Checked.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/Checked.java @@ -92,6 +92,8 @@ public final class Checked { // Well-known types. CheckedWellKnowns.put("google.protobuf.Any", checkedAny); CheckedWellKnowns.put("google.protobuf.Duration", checkedDuration); + CheckedWellKnowns.put( + "google.protobuf.FieldMask", checkedMessageType("google.protobuf.FieldMask")); CheckedWellKnowns.put("google.protobuf.Timestamp", checkedTimestamp); // Json types. CheckedWellKnowns.put("google.protobuf.ListValue", checkedListDyn); diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java index ced0befa..bf96e2df 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java @@ -24,6 +24,7 @@ import com.google.protobuf.Descriptors.FileDescriptor; import com.google.protobuf.Duration; import com.google.protobuf.Empty; +import com.google.protobuf.FieldMask; import com.google.protobuf.Message; import com.google.protobuf.Timestamp; import com.google.protobuf.Value; @@ -62,6 +63,7 @@ public final class Db { defaultDb.registerMessage(Any.getDefaultInstance()); defaultDb.registerMessage(Duration.getDefaultInstance()); defaultDb.registerMessage(Empty.getDefaultInstance()); + defaultDb.registerMessage(FieldMask.getDefaultInstance()); defaultDb.registerMessage(Timestamp.getDefaultInstance()); defaultDb.registerMessage(Value.getDefaultInstance()); defaultDb.registerMessage(BoolValue.getDefaultInstance()); diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java index d139b618..13836dae 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java @@ -22,7 +22,11 @@ import com.google.protobuf.Any; import com.google.protobuf.DynamicMessage; +import com.google.protobuf.Empty; +import com.google.protobuf.FieldMask; import com.google.protobuf.Message; +import com.google.protobuf.Struct; +import com.google.protobuf.Timestamp; import com.google.protobuf.Value; import org.projectnessie.cel.common.types.ObjectT; import org.projectnessie.cel.common.types.StringT; @@ -102,20 +106,15 @@ public T convertToNative(Class typeDesc) { } if (typeDesc == Value.class) { // jsonValueType - throw new UnsupportedOperationException("IMPLEMENT proto-to-json"); - // TODO proto-to-json - // // Marshal the proto to JSON first, and then rehydrate as protobuf.Value as there is no - // // support for direct conversion from proto.Message to protobuf.Value. - // bytes, err := protojson.Marshal(pb) - // if err != nil { - // return nil, err - // } - // json := &structpb.Value{} - // err = protojson.Unmarshal(bytes, json) - // if err != nil { - // return nil, err - // } - // return json, nil + if (value instanceof Empty) { + return (T) Value.newBuilder().setStructValue(Struct.getDefaultInstance()).build(); + } + if (value instanceof FieldMask) { + return (T) Value.newBuilder().setStringValue(fieldMaskJsonValue((FieldMask) value)).build(); + } + if (value instanceof Timestamp) { + return adapter.nativeToValue(value).convertToNative(typeDesc); + } } if (typeDesc.isAssignableFrom(this.typeDesc.reflectType()) || typeDesc == Object.class) { if (value instanceof Any || value instanceof DynamicMessage) { @@ -141,6 +140,34 @@ private Message message() { return (Message) value; } + private static String fieldMaskJsonValue(FieldMask fieldMask) { + StringBuilder value = new StringBuilder(); + for (String path : fieldMask.getPathsList()) { + if (value.length() > 0) { + value.append(','); + } + value.append(fieldMaskPathJsonValue(path)); + } + return value.toString(); + } + + private static String fieldMaskPathJsonValue(String path) { + StringBuilder value = new StringBuilder(path.length()); + boolean upperNext = false; + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if (c == '_') { + upperNext = true; + } else if (upperNext) { + value.append(Character.toUpperCase(c)); + upperNext = false; + } else { + value.append(c); + } + } + return value.toString(); + } + private PbTypeDescription typeDesc() { return (PbTypeDescription) typeDesc; } diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java index 08e5916e..e6b2fac9 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java @@ -54,6 +54,7 @@ import com.google.protobuf.DoubleValue; import com.google.protobuf.Duration; import com.google.protobuf.Empty; +import com.google.protobuf.FieldMask; import com.google.protobuf.FloatValue; import com.google.protobuf.Int32Value; import com.google.protobuf.Int64Value; @@ -134,6 +135,7 @@ private static ProtoTypeRegistry newDefaultRegistry() { Arrays.asList( DoubleValue.getDescriptor().getFile(), Empty.getDescriptor().getFile(), + FieldMask.getDescriptor().getFile(), Timestamp.getDescriptor().getFile(), UInt64Value.getDescriptor().getFile(), Any.getDescriptor().getFile(), @@ -357,6 +359,8 @@ private static Class messageNativeType(FieldDescriptor field) { return DoubleValue.class; case "google.protobuf.Duration": return Duration.class; + case "google.protobuf.FieldMask": + return FieldMask.class; case "google.protobuf.FloatValue": return FloatValue.class; case "google.protobuf.Int32Value": diff --git a/core/src/test/java/org/projectnessie/cel/common/types/TimestampTest.java b/core/src/test/java/org/projectnessie/cel/common/types/TimestampTest.java index e80ba683..3a1b8a1a 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/TimestampTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/TimestampTest.java @@ -34,7 +34,6 @@ import static org.projectnessie.cel.common.types.TypeT.TypeType; import com.google.protobuf.Any; -import com.google.protobuf.StringValue; import com.google.protobuf.Timestamp; import com.google.protobuf.Value; import java.time.DateTimeException; @@ -107,7 +106,12 @@ void timestampConvertToNative_JSON() { // JSON Object val = ts.convertToNative(Value.class); - Object want = StringValue.of("1970-01-01T02:05:06Z"); + Object want = Value.newBuilder().setStringValue("1970-01-01T02:05:06Z").build(); + assertThat(val).isEqualTo(want); + + ts = timestampOf(Instant.ofEpochSecond(253402300799L, 999999999).atZone(ZoneIdZ)); + val = ts.convertToNative(Value.class); + want = Value.newBuilder().setStringValue("9999-12-31T23:59:59.999999999Z").build(); assertThat(val).isEqualTo(want); } diff --git a/core/src/test/java/org/projectnessie/cel/common/types/pb/PbObjectTest.java b/core/src/test/java/org/projectnessie/cel/common/types/pb/PbObjectTest.java index 55d30073..01608869 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/pb/PbObjectTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/pb/PbObjectTest.java @@ -28,7 +28,12 @@ import com.google.api.expr.v1alpha1.ParsedExpr; import com.google.api.expr.v1alpha1.SourceInfo; import com.google.protobuf.Any; +import com.google.protobuf.Empty; +import com.google.protobuf.FieldMask; import com.google.protobuf.Message; +import com.google.protobuf.Struct; +import com.google.protobuf.Timestamp; +import com.google.protobuf.Value; import java.util.Arrays; import java.util.Collections; import org.junit.jupiter.api.Disabled; @@ -90,6 +95,26 @@ void protoObjectConvertToNative() throws Exception { assertThat(unpackedAny).isEqualTo(objVal.value()); } + @Test + void wellKnownProtoObjectsConvertToJsonValue() { + TypeRegistry reg = newRegistry(Empty.getDefaultInstance(), FieldMask.getDefaultInstance()); + + Val empty = reg.nativeToValue(Empty.getDefaultInstance()); + assertThat(empty.convertToNative(Value.class)) + .isEqualTo(Value.newBuilder().setStructValue(Struct.getDefaultInstance()).build()); + + Val fieldMask = + reg.nativeToValue(FieldMask.newBuilder().addPaths("foo").addPaths("bar_baz").build()); + assertThat(fieldMask.convertToNative(Value.class)) + .isEqualTo(Value.newBuilder().setStringValue("foo,barBaz").build()); + + Val timestamp = + reg.nativeToValue( + Timestamp.newBuilder().setSeconds(253402300799L).setNanos(999999999).build()); + assertThat(timestamp.convertToNative(Value.class)) + .isEqualTo(Value.newBuilder().setStringValue("9999-12-31T23:59:59.999999999Z").build()); + } + @Test @Disabled("IMPLEMENT ME") void protoObjectConvertToNative_JSON() { From 92a21318dd2a57f577387524fb3db57fedad08b4 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 14:45:01 +0200 Subject: [PATCH 11/33] Reject float keys in map literals CEL map literals only support bool, int, uint, and string keys. Unchecked map literals with float keys were accepted at evaluation time and could resolve through numeric key equality instead of producing the expected unsupported-key-type error. Validate literal map keys during evaluation with the supported CEL key-type set, preserve native object map wrapping behavior for Jackson-backed objects, add interpreter coverage, and enable the corresponding conformance case. --- .../cel/conformance/SimpleConformanceTest.java | 3 +-- .../org/projectnessie/cel/common/types/MapT.java | 12 ++++++++++++ .../projectnessie/cel/interpreter/Interpretable.java | 3 +-- .../cel/interpreter/InterpreterTest.java | 4 ++++ .../cel/interpreter/InterpreterTestCase.java | 1 + 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index e32d7279..3fb02063 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -158,8 +158,7 @@ class SimpleConformanceTest { "type_deductions/type_parameters/multiple_parameters_generality,multiple_parameters_generality_2", "type_deductions/legacy_nullable_types/null_assignable_to_message_parameter_candidate,null_assignable_to_duration_parameter_candidate,null_assignable_to_timestamp_parameter_candidate,null_assignable_to_abstract_parameter_candidate", "enums/strong_proto2", - "enums/strong_proto3", - "fields/qualified_identifier_resolution/map_key_float"); + "enums/strong_proto3"); private static final Set matchedSkips = new LinkedHashSet<>(); private static final AtomicInteger total = new AtomicInteger(); diff --git a/core/src/main/java/org/projectnessie/cel/common/types/MapT.java b/core/src/main/java/org/projectnessie/cel/common/types/MapT.java index e15b7c43..441898e2 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/MapT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/MapT.java @@ -87,6 +87,18 @@ public static Val newMaybeWrappedMap(TypeAdapter adapter, Map value) { return newWrappedMap(adapter, newMap); } + public static boolean isSupportedLiteralKeyType(Val key) { + switch (key.type().typeEnum()) { + case Bool: + case Int: + case String: + case Uint: + return true; + default: + return false; + } + } + @Override public Type type() { return MapType; diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java b/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java index 1c8ee0c6..8961488e 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java @@ -47,7 +47,6 @@ import org.projectnessie.cel.common.types.StringT; import org.projectnessie.cel.common.types.ref.FieldType; import org.projectnessie.cel.common.types.ref.TypeAdapter; -import org.projectnessie.cel.common.types.ref.TypeEnum; import org.projectnessie.cel.common.types.ref.TypeProvider; import org.projectnessie.cel.common.types.ref.Val; import org.projectnessie.cel.common.types.traits.Container; @@ -934,7 +933,7 @@ public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { if (isUnknownOrError(keyVal)) { return keyVal; } - if (keyVal.type().typeEnum() == TypeEnum.Null) { + if (!MapT.isSupportedLiteralKeyType(keyVal)) { return newErr("unsupported key type"); } Val valVal = vals[i].eval(ctx); diff --git a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java index 06a7d0d2..8d9cdf74 100644 --- a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java +++ b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java @@ -345,6 +345,10 @@ static TestCase[] testCases() { new TestCase(InterpreterTestCase.map_key_null) .expr("{null:false}[null]") .err("message: unsupported key type"), + new TestCase(InterpreterTestCase.map_key_float) + .expr("{3.3:15.15, 1.0: 5}[1.0]") + .unchecked() + .err("message: unsupported key type"), new TestCase(InterpreterTestCase.map_value_repeat_key_heterogeneous) .expr("{0: 1, 0u: 2}[0.0]") .err("message: Failed with repeated key"), diff --git a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTestCase.java b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTestCase.java index 5c585a21..46b061aa 100644 --- a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTestCase.java +++ b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTestCase.java @@ -72,6 +72,7 @@ public enum InterpreterTestCase { lt_dyn_int_big_uint, lt_dyn_uint_big_double, lt_ne_dyn_int_double, + map_key_float, map_key_mixed_numbers_lossy_double_key, map_key_string_and_int_are_distinct, eq_dyn_string_int, From 1800ac529bdd74091f1f81bbd186e009a415edf3 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 14:49:35 +0200 Subject: [PATCH 12/33] Honor protobuf NaN equality semantics Generated protobuf message equality treats NaN fields as equal, but CEL protobuf equality treats messages with present NaN float or double fields as not equal. Add protobuf-object equality handling that detects NaN float and double fields, including nested and repeated message fields, update interpreter coverage, and enable the matching conformance cases. --- .../conformance/SimpleConformanceTest.java | 3 -- .../cel/common/types/pb/PbObjectT.java | 45 +++++++++++++++++++ .../cel/interpreter/InterpreterTest.java | 8 +++- .../cel/interpreter/InterpreterTestCase.java | 1 + 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 3fb02063..c8f33a0b 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -141,9 +141,6 @@ class SimpleConformanceTest { "dynamic/float/field_assign_proto3_round_to_zero", // Malicious too-deep protobuf structure. "parse/nest/message_literal", - // Proto equality specialties do not seem to be in effect for Java. - "comparisons/eq_wrapper/eq_proto_nan_equal", - "comparisons/ne_literal/ne_proto_nan_not_equal", // TODO Actual known issue: protobuf Any returned by this test is wrapped twice. "dynamic/any/var", // New CEL-Spec v0.25.2 expectations that need follow-up parser/runtime changes. diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java index 13836dae..e76ad92d 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java @@ -21,6 +21,8 @@ import static org.projectnessie.cel.common.types.Types.boolOf; import com.google.protobuf.Any; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Descriptors.FieldDescriptor.JavaType; import com.google.protobuf.DynamicMessage; import com.google.protobuf.Empty; import com.google.protobuf.FieldMask; @@ -81,6 +83,22 @@ public Val get(Val index) { return nativeToValue(fd.getField(value, adapter)); } + @Override + public Val equal(Val other) { + if (!(other instanceof PbObjectT)) { + return super.equal(other); + } + + PbObjectT otherObject = (PbObjectT) other; + if (!typeDesc().name().equals(otherObject.typeDesc().name())) { + return boolOf(false); + } + if (containsNaN(message()) || containsNaN(otherObject.message())) { + return boolOf(false); + } + return boolOf(message().equals(otherObject.message())); + } + @SuppressWarnings("unchecked") @Override public T convertToNative(Class typeDesc) { @@ -140,6 +158,33 @@ private Message message() { return (Message) value; } + private static boolean containsNaN(Message message) { + for (FieldDescriptor field : message.getAllFields().keySet()) { + Object fieldValue = message.getField(field); + if (field.isRepeated()) { + for (Object element : (Iterable) fieldValue) { + if (containsNaN(field, element)) { + return true; + } + } + } else if (containsNaN(field, fieldValue)) { + return true; + } + } + return false; + } + + private static boolean containsNaN(FieldDescriptor field, Object value) { + JavaType javaType = field.getJavaType(); + if (javaType == JavaType.DOUBLE) { + return Double.isNaN((Double) value); + } + if (javaType == JavaType.FLOAT) { + return Float.isNaN((Float) value); + } + return javaType == JavaType.MESSAGE && containsNaN((Message) value); + } + private static String fieldMaskJsonValue(FieldMask fieldMask) { StringBuilder value = new StringBuilder(); for (String path : fieldMask.getPathsList()) { diff --git a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java index 8d9cdf74..45625645 100644 --- a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java +++ b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java @@ -398,8 +398,12 @@ static TestCase[] testCases() { "TestAllTypes{single_double: double('NaN')} == TestAllTypes{single_double: double('NaN')}") .container("cel.expr.conformance.proto3") .types(dev.cel.expr.conformance.proto3.TestAllTypes.getDefaultInstance()) - // The outcome in the generated Java proto code is different than in the conformance-test, - // it is NOT: "For proto equality, fields with NaN value are treated as not equal." + .out(False), + new TestCase(InterpreterTestCase.ne_proto_nan_not_equal) + .expr( + "TestAllTypes{single_double: double('NaN')} != TestAllTypes{single_double: double('NaN')}") + .container("cel.expr.conformance.proto3") + .types(dev.cel.expr.conformance.proto3.TestAllTypes.getDefaultInstance()) .out(True), new TestCase(InterpreterTestCase.eq_bool_not_null) .expr("google.protobuf.BoolValue{} != null") diff --git a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTestCase.java b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTestCase.java index 46b061aa..b45eb5a8 100644 --- a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTestCase.java +++ b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTestCase.java @@ -105,6 +105,7 @@ public enum InterpreterTestCase { not_eq_list_one_element, not_eq_list_one_element2, not_eq_list_mixed_type_numbers, + ne_proto_nan_not_equal, not_int32_eq_uint, not_uint32_eq_double, not_lt_dyn_big_uint_int, From 07aaee079e082bf84e3ce5b35f55d647e2eb7384 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 14:53:43 +0200 Subject: [PATCH 13/33] Preserve nested Any conformance values Conformance bindings can provide google.protobuf.Any object values whose payload is the expected application message. The result conversion path preserved neither existing Any values nor nested Any payload shape, producing an extra wrapper compared with the expected object value. Preserve evaluated Any object values, unwrap nested Any payloads when converting evaluation results back to conformance values, and enable the matching dynamic Any conformance case. --- .../conformance/SimpleConformanceTest.java | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index c8f33a0b..24492e7e 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -141,8 +141,6 @@ class SimpleConformanceTest { "dynamic/float/field_assign_proto3_round_to_zero", // Malicious too-deep protobuf structure. "parse/nest/message_literal", - // TODO Actual known issue: protobuf Any returned by this test is wrapped twice. - "dynamic/any/var", // New CEL-Spec v0.25.2 expectations that need follow-up parser/runtime changes. "namespace/namespace_shadowing/comprehension_shadowing,comprehension_shadowing_disambiguation,comprehension_shadowing_parse_only,comprehension_shadowing_selector,comprehension_shadowing_selector_parse_only,comprehension_shadowing_namespaced_selector,comprehension_shadowing_namespaced_selector_parse_only,comprehension_shadowing_nesting", "proto2/extensions_has/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", @@ -613,7 +611,9 @@ private static Value refValueToValue(Val res) { case Object: Message pb = (Message) res.value(); Value.Builder value = Value.newBuilder(); - if (pb instanceof ListValue) { + if (pb instanceof Any) { + value.setObjectValue(unwrapNestedAny((Any) pb)); + } else if (pb instanceof ListValue) { value.setListValue((ListValue) pb); } else if (pb instanceof MapValue) { value.setMapValue((MapValue) pb); @@ -626,6 +626,22 @@ private static Value refValueToValue(Val res) { } } + private static Any unwrapNestedAny(Any any) { + Any current = any; + while (current.is(Any.class)) { + try { + Any next = current.unpack(Any.class); + if (next.equals(current)) { + return current; + } + current = next; + } catch (InvalidProtocolBufferException e) { + return current; + } + } + return current; + } + private static T convert(Message message, Class targetType) throws InvalidProtocolBufferException { try { From bca2f7267fde07f5746cbe6f238c32586befcc7e Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 15:12:40 +0200 Subject: [PATCH 14/33] Reject non-string keys for protobuf Struct conversion CEL maps were converted to protobuf Struct values by coercing every key through CEL string conversion. That allowed invalid message construction such as assigning {1: 'one'} to a google.protobuf.Struct field, producing a Struct with key "1" instead of an eval error. Require map keys to already be CEL strings when converting to protobuf Struct values. Add direct map conversion and protobuf message construction regression coverage, and enable the corresponding dynamic struct conformance cases. --- .../cel/conformance/SimpleConformanceTest.java | 5 ----- .../org/projectnessie/cel/common/types/MapT.java | 10 ++++++---- .../projectnessie/cel/common/types/MapTest.java | 15 +++++++++++++++ .../cel/common/types/ProviderTest.java | 14 ++++++++++++++ 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 24492e7e..9887b5fc 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -132,11 +132,6 @@ class SimpleConformanceTest { private static final Set SKIP_TESTS = SkipList.parse( - // Without the checker, verifying whether an assignment is allowed by the CEL spec is - // difficult, especially from a map to a struct. The checker catches this case, while the - // evaluator currently converts int(1) to string("1"). - "dynamic/struct/field_assign_proto2_bad", - "dynamic/struct/field_assign_proto3_bad", // The test expects -0.0d, but in Java -0.0d == 0.0d, so -0.0d is evaluated as not set. "dynamic/float/field_assign_proto3_round_to_zero", // Malicious too-deep protobuf structure. diff --git a/core/src/main/java/org/projectnessie/cel/common/types/MapT.java b/core/src/main/java/org/projectnessie/cel/common/types/MapT.java index 441898e2..788c0bff 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/MapT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/MapT.java @@ -20,7 +20,6 @@ import static org.projectnessie.cel.common.types.Err.isError; import static org.projectnessie.cel.common.types.Err.newErr; import static org.projectnessie.cel.common.types.Err.newTypeConversionError; -import static org.projectnessie.cel.common.types.StringT.StringType; import static org.projectnessie.cel.common.types.TypeT.TypeType; import static org.projectnessie.cel.common.types.Types.boolOf; @@ -148,9 +147,12 @@ private Value toPbValue() { private Struct toPbStruct() { Struct.Builder struct = Struct.newBuilder(); map.forEach( - (k, v) -> - struct.putFields( - k.convertToType(StringType).value().toString(), v.convertToNative(Value.class))); + (k, v) -> { + if (k.type().typeEnum() != TypeEnum.String) { + throw new IllegalArgumentException("bad key type"); + } + struct.putFields(k.value().toString(), v.convertToNative(Value.class)); + }); return struct.build(); } diff --git a/core/src/test/java/org/projectnessie/cel/common/types/MapTest.java b/core/src/test/java/org/projectnessie/cel/common/types/MapTest.java index f409c6f7..bd22dedd 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/MapTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/MapTest.java @@ -16,6 +16,7 @@ package org.projectnessie.cel.common.types; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.projectnessie.cel.common.types.BoolT.True; import static org.projectnessie.cel.common.types.DoubleT.doubleOf; import static org.projectnessie.cel.common.types.IntT.intOf; @@ -26,6 +27,7 @@ import static org.projectnessie.cel.common.types.pb.ProtoTypeRegistry.newRegistry; import com.google.common.collect.ImmutableMap; +import com.google.protobuf.Struct; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Disabled; @@ -99,6 +101,19 @@ void heterogenousKeys() { ImmutableMap.of(1L, "one", 2L, "two", 3.1d, "three", true, "true", "str", "string")); } + @Test + void mapToProtobufStructRequiresStringKeys() { + MapT stringKeyMap = + (MapT) newWrappedMap(newRegistry(), ImmutableMap.of(stringOf("one"), doubleOf(1.0d))); + assertThat(stringKeyMap.convertToNative(Struct.class).getFieldsMap()).containsOnlyKeys("one"); + + MapT intKeyMap = + (MapT) newWrappedMap(newRegistry(), ImmutableMap.of(intOf(1), stringOf("one"))); + assertThatThrownBy(() -> intKeyMap.convertToNative(Struct.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("bad key type"); + } + // type testStruct struct { // M string // Details []string diff --git a/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java b/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java index da9bf1d5..ad59033f 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java @@ -309,6 +309,20 @@ void typeRegistryNewValue_InvalidNullFieldAssignmentsReturnErrors() { assertThat(reg.newValue(typeName, mapOf("single_struct", NullValue))).matches(Err::isError); } + @Test + void typeRegistryNewValue_ProtobufStructFieldRequiresStringKeys() { + TypeRegistry reg = newRegistry(TestAllTypes.getDefaultInstance()); + String typeName = "cel.expr.conformance.proto3.TestAllTypes"; + + Val value = + reg.newValue( + typeName, + mapOf("single_struct", newMaybeWrappedMap(reg, mapOf(intOf(1), stringOf("one"))))); + + assertThat(value).matches(Err::isError); + assertThat(value.toString()).contains("invalid value for field 'single_struct': bad key type"); + } + @Test void typeRegistryGetters() { TypeRegistry reg = newRegistry(ParsedExpr.getDefaultInstance()); From 14a0a87cde4b9355e9e6fa8a11f9a36642fd9248 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 15:14:07 +0200 Subject: [PATCH 15/33] Enable proto3 negative-zero float conformance case The proto3 float assignment case for a negative-zero value now passes under the current protobuf conversion behavior. The skip list still marked it as unsupported, which kept valid conformance coverage disabled. Remove the stale skip so the dynamic float assignment case runs with the rest of the simple conformance suite. --- .../projectnessie/cel/conformance/SimpleConformanceTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 9887b5fc..245e7f79 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -132,8 +132,6 @@ class SimpleConformanceTest { private static final Set SKIP_TESTS = SkipList.parse( - // The test expects -0.0d, but in Java -0.0d == 0.0d, so -0.0d is evaluated as not set. - "dynamic/float/field_assign_proto3_round_to_zero", // Malicious too-deep protobuf structure. "parse/nest/message_literal", // New CEL-Spec v0.25.2 expectations that need follow-up parser/runtime changes. From af652ac78f0e00859caebf04d270cc1be0341098 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 15:20:47 +0200 Subject: [PATCH 16/33] Prefer wrapper types when unifying list elements List literal element type deduction was order-sensitive for matching primitive and wrapper types. When a wrapper appeared before its primitive, the common type was reduced to the primitive, while the reverse order preserved the wrapper type. Prefer the matching wrapper type as the common type for primitive/wrapper unification, add checker coverage for both element orders, and enable the wrapper promotion conformance case. --- .../cel/conformance/SimpleConformanceTest.java | 5 ++--- .../java/org/projectnessie/cel/checker/Types.java | 12 ++++++++++++ .../org/projectnessie/cel/checker/CheckerTest.java | 10 ++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 245e7f79..6ad64b45 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -139,10 +139,9 @@ class SimpleConformanceTest { "proto2/extensions_has/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", "proto2/extensions_get/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", // type_deduction.textproto coverage was added opportunistically. The remaining skips are - // checker limitations around optionals, flexible type parameter generality, wrapper - // promotion, and legacy nullable generic candidates. + // checker limitations around optionals, flexible type parameter generality, and legacy + // nullable generic candidates. "type_deductions/flexible_type_parameter_assignment/comprehension_type_var_aliasing,optional_none,optional_none_2,optional_dyn_promotion,optional_dyn_promotion_2,optional_in_ternary", - "type_deductions/wrappers/wrapper_promotion", "type_deductions/type_parameters/multiple_parameters_generality,multiple_parameters_generality_2", "type_deductions/legacy_nullable_types/null_assignable_to_message_parameter_candidate,null_assignable_to_duration_parameter_candidate,null_assignable_to_timestamp_parameter_candidate,null_assignable_to_abstract_parameter_candidate", "enums/strong_proto2", diff --git a/core/src/main/java/org/projectnessie/cel/checker/Types.java b/core/src/main/java/org/projectnessie/cel/checker/Types.java index db41fd73..2daee052 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/Types.java +++ b/core/src/main/java/org/projectnessie/cel/checker/Types.java @@ -510,6 +510,18 @@ static Kind kindOf(Type t) { /** mostGeneral returns the more general of two types which are known to unify. */ static Type mostGeneral(Type t1, Type t2) { + Kind kind1 = kindOf(t1); + Kind kind2 = kindOf(t2); + if (kind1 == Kind.kindPrimitive && kind2 == Kind.kindWrapper) { + if (t1.getPrimitive() == t2.getWrapper()) { + return t2; + } + } + if (kind1 == Kind.kindWrapper && kind2 == Kind.kindPrimitive) { + if (t1.getWrapper() == t2.getPrimitive()) { + return t1; + } + } if (isEqualOrLessSpecific(t1, t2)) { return t1; } diff --git a/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java b/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java index 7ae4492e..4a14d0db 100644 --- a/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java +++ b/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java @@ -189,6 +189,16 @@ static TestCase[] checkTestCases() { new TestCase().i("id").r("id~double^id").type(Decls.Double).env(testEnvs.get("default")), new TestCase().i("[]").r("[]~list(dyn)").type(Decls.newListType(Decls.Dyn)), new TestCase().i("[1]").r("[1~int]~list(int)").type(Decls.newListType(Decls.Int)), + new TestCase() + .i("[y, 1]") + .env(new env().idents(Decls.newVar("y", Decls.newWrapperType(Decls.Int)))) + .r("[y~wrapper(int)^y, 1~int]~list(wrapper(int))") + .type(Decls.newListType(Decls.newWrapperType(Decls.Int))), + new TestCase() + .i("[1, y]") + .env(new env().idents(Decls.newVar("y", Decls.newWrapperType(Decls.Int)))) + .r("[1~int, y~wrapper(int)^y]~list(wrapper(int))") + .type(Decls.newListType(Decls.newWrapperType(Decls.Int))), new TestCase() .i("[1, \"A\"]") .r("[1~int, \"A\"~string]~list(dyn)") From f0b4f82c285bc495ff123cd51e52adf8463beefb Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 15:24:36 +0200 Subject: [PATCH 17/33] Preserve nullable types when unifying with null List literal type deduction selected null as the common type when nullable concrete values were joined with null. This caused message, duration, and timestamp candidates to lose their concrete element type. Prefer the nullable concrete type when it is unified with null, add checker coverage for both element orders, and enable the corresponding legacy nullable conformance cases. --- .../conformance/SimpleConformanceTest.java | 2 +- .../org/projectnessie/cel/checker/Types.java | 6 ++++++ .../cel/checker/CheckerTest.java | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 6ad64b45..554e22f6 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -143,7 +143,7 @@ class SimpleConformanceTest { // nullable generic candidates. "type_deductions/flexible_type_parameter_assignment/comprehension_type_var_aliasing,optional_none,optional_none_2,optional_dyn_promotion,optional_dyn_promotion_2,optional_in_ternary", "type_deductions/type_parameters/multiple_parameters_generality,multiple_parameters_generality_2", - "type_deductions/legacy_nullable_types/null_assignable_to_message_parameter_candidate,null_assignable_to_duration_parameter_candidate,null_assignable_to_timestamp_parameter_candidate,null_assignable_to_abstract_parameter_candidate", + "type_deductions/legacy_nullable_types/null_assignable_to_abstract_parameter_candidate", "enums/strong_proto2", "enums/strong_proto3"); diff --git a/core/src/main/java/org/projectnessie/cel/checker/Types.java b/core/src/main/java/org/projectnessie/cel/checker/Types.java index 2daee052..2f20d8fd 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/Types.java +++ b/core/src/main/java/org/projectnessie/cel/checker/Types.java @@ -512,6 +512,12 @@ static Kind kindOf(Type t) { static Type mostGeneral(Type t1, Type t2) { Kind kind1 = kindOf(t1); Kind kind2 = kindOf(t2); + if (kind1 == Kind.kindNull && internalIsAssignableNull(t2)) { + return t2; + } + if (kind2 == Kind.kindNull && internalIsAssignableNull(t1)) { + return t1; + } if (kind1 == Kind.kindPrimitive && kind2 == Kind.kindWrapper) { if (t1.getPrimitive() == t2.getWrapper()) { return t2; diff --git a/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java b/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java index 4a14d0db..d67aaedd 100644 --- a/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java +++ b/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java @@ -199,6 +199,26 @@ static TestCase[] checkTestCases() { .env(new env().idents(Decls.newVar("y", Decls.newWrapperType(Decls.Int)))) .r("[1~int, y~wrapper(int)^y]~list(wrapper(int))") .type(Decls.newListType(Decls.newWrapperType(Decls.Int))), + new TestCase() + .i("[x, null]") + .env(new env().idents(Decls.newVar("x", Decls.newObjectType("test.Message")))) + .r("[x~test.Message^x, null~null]~list(test.Message)") + .type(Decls.newListType(Decls.newObjectType("test.Message"))), + new TestCase() + .i("[null, x]") + .env(new env().idents(Decls.newVar("x", Decls.newObjectType("test.Message")))) + .r("[null~null, x~test.Message^x]~list(test.Message)") + .type(Decls.newListType(Decls.newObjectType("test.Message"))), + new TestCase() + .i("[d, null]") + .env(new env().idents(Decls.newVar("d", Decls.Duration))) + .r("[d~duration^d, null~null]~list(duration)") + .type(Decls.newListType(Decls.Duration)), + new TestCase() + .i("[null, t]") + .env(new env().idents(Decls.newVar("t", Decls.Timestamp))) + .r("[null~null, t~timestamp^t]~list(timestamp)") + .type(Decls.newListType(Decls.Timestamp)), new TestCase() .i("[1, \"A\"]") .r("[1~int, \"A\"~string]~list(dyn)") From 24c83ecb1f1e3645e69fbd061e866feb8bea3f16 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 15:33:27 +0200 Subject: [PATCH 18/33] Improve checker type parameter inference The checker only freshened overload type parameters listed explicitly on the overload declaration. Conformance declarations can also use type_param entries directly in the parameter or result types, which left raw type parameters in resolved result types and broke later overload matching. Classify protobuf abstract types, derive overload type parameters from the full overload signature, and expose current substitutions while checking nested expressions so comprehension results keep refined accumulator types. Enable the matching type-deduction conformance cases. --- .../conformance/SimpleConformanceTest.java | 6 +-- .../projectnessie/cel/checker/Checker.java | 47 +++++++++++++++++-- .../org/projectnessie/cel/checker/Types.java | 2 + .../cel/checker/CheckerTest.java | 22 ++++++++- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 554e22f6..f618d01c 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -139,10 +139,8 @@ class SimpleConformanceTest { "proto2/extensions_has/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", "proto2/extensions_get/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", // type_deduction.textproto coverage was added opportunistically. The remaining skips are - // checker limitations around optionals, flexible type parameter generality, and legacy - // nullable generic candidates. - "type_deductions/flexible_type_parameter_assignment/comprehension_type_var_aliasing,optional_none,optional_none_2,optional_dyn_promotion,optional_dyn_promotion_2,optional_in_ternary", - "type_deductions/type_parameters/multiple_parameters_generality,multiple_parameters_generality_2", + // checker limitations around optionals and legacy nullable generic candidates. + "type_deductions/flexible_type_parameter_assignment/optional_none,optional_none_2,optional_dyn_promotion,optional_dyn_promotion_2,optional_in_ternary", "type_deductions/legacy_nullable_types/null_assignable_to_abstract_parameter_candidate", "enums/strong_proto2", "enums/strong_proto3"); diff --git a/core/src/main/java/org/projectnessie/cel/checker/Checker.java b/core/src/main/java/org/projectnessie/cel/checker/Checker.java index 25737a5a..5fa843ac 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/Checker.java +++ b/core/src/main/java/org/projectnessie/cel/checker/Checker.java @@ -48,8 +48,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.projectnessie.cel.checker.Types.Kind; import org.projectnessie.cel.common.Location; import org.projectnessie.cel.common.Source; @@ -415,10 +417,11 @@ OverloadResolution resolveOverload( } Type overloadType = Decls.newFunctionType(overload.getResultType(), overload.getParamsList()); - if (overload.getTypeParamsCount() > 0) { + Set typeParams = collectOverloadTypeParams(overload); + if (!typeParams.isEmpty()) { // Instantiate overload's type with fresh type variables. Mapping substitutions = newMapping(); - for (String typePar : overload.getTypeParamsList()) { + for (String typePar : typeParams) { substitutions.add(Decls.newTypeParamType(typePar), newTypeVar()); } overloadType = substitute(substitutions, overloadType, false); @@ -682,7 +685,7 @@ void setType(Expr.Builder e, Type t) { } Type getType(Expr.Builder e) { - return types.get(e.getId()); + return substitute(mappings, types.get(e.getId()), false); } void setReference(Expr.Builder e, Reference r) { @@ -716,6 +719,44 @@ static OverloadResolution newResolution(Reference checkedRef, Type t) { return new OverloadResolution(checkedRef, t); } + private static Set collectOverloadTypeParams(Overload overload) { + Set typeParams = new LinkedHashSet<>(overload.getTypeParamsList()); + overload.getParamsList().forEach(type -> collectTypeParams(type, typeParams)); + collectTypeParams(overload.getResultType(), typeParams); + return typeParams; + } + + private static void collectTypeParams(Type type, Set typeParams) { + switch (kindOf(type)) { + case kindTypeParam: + typeParams.add(type.getTypeParam()); + return; + case kindAbstract: + type.getAbstractType() + .getParameterTypesList() + .forEach(t -> collectTypeParams(t, typeParams)); + return; + case kindFunction: + type.getFunction().getArgTypesList().forEach(t -> collectTypeParams(t, typeParams)); + collectTypeParams(type.getFunction().getResultType(), typeParams); + return; + case kindList: + collectTypeParams(type.getListType().getElemType(), typeParams); + return; + case kindMap: + MapType mapType = type.getMapType(); + collectTypeParams(mapType.getKeyType(), typeParams); + collectTypeParams(mapType.getValueType(), typeParams); + return; + case kindType: + if (type.getType() != Type.getDefaultInstance()) { + collectTypeParams(type.getType(), typeParams); + } + return; + default: + } + } + Location location(Expr.Builder e) { return locationByID(e.getId()); } diff --git a/core/src/main/java/org/projectnessie/cel/checker/Types.java b/core/src/main/java/org/projectnessie/cel/checker/Types.java index 2f20d8fd..682837a6 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/Types.java +++ b/core/src/main/java/org/projectnessie/cel/checker/Types.java @@ -494,6 +494,8 @@ static Kind kindOf(Type t) { return Kind.kindWrapper; case NULL: return Kind.kindNull; + case ABSTRACT_TYPE: + return Kind.kindAbstract; case TYPE: return Kind.kindType; case LIST_TYPE: diff --git a/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java b/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java index d67aaedd..09df7ee1 100644 --- a/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java +++ b/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java @@ -219,6 +219,26 @@ static TestCase[] checkTestCases() { .env(new env().idents(Decls.newVar("t", Decls.Timestamp))) .r("[null~null, t~timestamp^t]~list(timestamp)") .type(Decls.newListType(Decls.Timestamp)), + new TestCase() + .i("tuple(1, dyn(2u), 3.0)") + .env( + new env() + .functions( + Decls.newFunction( + "tuple", + Decls.newOverload( + "tuple_T_U_V", + asList( + Decls.newTypeParamType("T"), + Decls.newTypeParamType("U"), + Decls.newTypeParamType("V")), + Decls.newAbstractType( + "tuple", + asList( + Decls.newTypeParamType("T"), + Decls.newTypeParamType("U"), + Decls.newTypeParamType("V"))))))) + .type(Decls.newAbstractType("tuple", asList(Decls.Int, Decls.Dyn, Decls.Double))), new TestCase() .i("[1, \"A\"]") .r("[1~int, \"A\"~string]~list(dyn)") @@ -1411,7 +1431,7 @@ static TestCase[] checkTestCases() { new TestCase() .i("[].map(x, [].map(y, x in y && y in x))") .error( - "ERROR: :1:33: found no matching overload for '@in' applied to '(type_param: \"_var2\", type_param: \"_var0\")'\n" + "ERROR: :1:33: found no matching overload for '@in' applied to '(list(type_param: \"_var0\"), type_param: \"_var0\")'\n" + " | [].map(x, [].map(y, x in y && y in x))\n" + " | ................................^"), new TestCase() From 308cd819af72b712badd73e28508ff8e9a7c29d9 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 15:45:34 +0200 Subject: [PATCH 19/33] Preserve comprehension variable shadowing Namespace resolution preferred container-qualified identifiers before lexical fold variables, so comprehension-local names could resolve to outer bindings. Parsed-only ASTs had the same issue through maybe-attribute resolution, and leading-dot absolute references could not bypass local fold variables. Make checker lookup prefer current lexical identifiers only in nested scopes, keep absolute references marked for evaluation, and make unchecked attributes prefer current fold variables before namespaced candidates. Add regression coverage and enable the namespace shadowing conformance cases. --- .../conformance/SimpleConformanceTest.java | 1 - .../projectnessie/cel/checker/Checker.java | 18 +++++- .../projectnessie/cel/checker/CheckerEnv.java | 10 +++ .../org/projectnessie/cel/checker/Scopes.java | 4 ++ .../cel/interpreter/Activation.java | 15 +++++ .../cel/interpreter/AttributeFactory.java | 61 ++++++++++++++----- .../cel/interpreter/InterpretablePlanner.java | 10 +-- .../java/org/projectnessie/cel/CELTest.java | 25 ++++++++ .../cel/checker/CheckerEnvTest.java | 17 ++++++ 9 files changed, 139 insertions(+), 22 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index f618d01c..2ca0dacc 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -135,7 +135,6 @@ class SimpleConformanceTest { // Malicious too-deep protobuf structure. "parse/nest/message_literal", // New CEL-Spec v0.25.2 expectations that need follow-up parser/runtime changes. - "namespace/namespace_shadowing/comprehension_shadowing,comprehension_shadowing_disambiguation,comprehension_shadowing_parse_only,comprehension_shadowing_selector,comprehension_shadowing_selector_parse_only,comprehension_shadowing_namespaced_selector,comprehension_shadowing_namespaced_selector_parse_only,comprehension_shadowing_nesting", "proto2/extensions_has/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", "proto2/extensions_get/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", // type_deduction.textproto coverage was added opportunistically. The remaining skips are diff --git a/core/src/main/java/org/projectnessie/cel/checker/Checker.java b/core/src/main/java/org/projectnessie/cel/checker/Checker.java index 5fa843ac..1360a3ad 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/Checker.java +++ b/core/src/main/java/org/projectnessie/cel/checker/Checker.java @@ -230,9 +230,11 @@ void checkIdent(Expr.Builder e) { Decl ident = env.lookupIdent(identExpr.getName()); if (ident != null) { setType(e, ident.getIdent().getType()); - setReference(e, newIdentReference(ident.getName(), ident.getIdent().getValue())); + String identName = + identExpr.getName().startsWith(".") ? "." + ident.getName() : ident.getName(); + setReference(e, newIdentReference(identName, ident.getIdent().getValue())); // Overwrite the identifier with its fully qualified name. - identExpr.setName(ident.getName()); + identExpr.setName(identName); return; } @@ -244,7 +246,7 @@ void checkSelect(Expr.Builder e) { Select.Builder sel = e.getSelectExprBuilder(); // Before traversing down the tree, try to interpret as qualified name. String qname = Container.toQualifiedName(e.build()); - if (qname != null) { + if (qname != null && !isQualifiedLocalVariableSelection(sel.getOperandBuilder())) { Decl ident = env.lookupIdent(qname); if (ident != null) { if (sel.getTestOnly()) { @@ -307,6 +309,16 @@ void checkSelect(Expr.Builder e) { setType(e, resultType); } + private boolean isQualifiedLocalVariableSelection(Expr.Builder e) { + if (e.getExprKindCase() == Expr.ExprKindCase.IDENT_EXPR) { + return env.hasLocalIdent(e.getIdentExpr().getName()); + } + if (e.getExprKindCase() == Expr.ExprKindCase.SELECT_EXPR) { + return isQualifiedLocalVariableSelection(e.getSelectExprBuilder().getOperandBuilder()); + } + return false; + } + void checkCall(Expr.Builder e) { // Note: similar logic exists within the `interpreter/planner.go`. If making changes here // please consider the impact on planner.go and consolidate implementations or mirror code diff --git a/core/src/main/java/org/projectnessie/cel/checker/CheckerEnv.java b/core/src/main/java/org/projectnessie/cel/checker/CheckerEnv.java index 6ed4258e..78902cb5 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/CheckerEnv.java +++ b/core/src/main/java/org/projectnessie/cel/checker/CheckerEnv.java @@ -118,6 +118,12 @@ public void add(List decls) { * such identifier is found in the Env. */ public Decl lookupIdent(String name) { + if (!name.startsWith(".") && hasLocalIdent(name)) { + Decl ident = declarations.findIdentInScope(name); + if (ident != null) { + return ident; + } + } for (String candidate : container.resolveCandidateNames(name)) { Decl ident = declarations.findIdent(candidate); if (ident != null) { @@ -150,6 +156,10 @@ public Decl lookupIdent(String name) { return null; } + boolean hasLocalIdent(String name) { + return declarations.hasParent() && declarations.findIdentInScope(name) != null; + } + /** * LookupFunction returns a Decl proto for typeName as a function in env. Returns nil if no such * function is found in env. diff --git a/core/src/main/java/org/projectnessie/cel/checker/Scopes.java b/core/src/main/java/org/projectnessie/cel/checker/Scopes.java index 7e21e558..3a966fd9 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/Scopes.java +++ b/core/src/main/java/org/projectnessie/cel/checker/Scopes.java @@ -59,6 +59,10 @@ public Scopes pop() { return this; } + boolean hasParent() { + return parent != null; + } + /** * AddIdent adds the ident Decl in the current scope. Note: If the name collides with an existing * identifier in the scope, the Decl is overwritten. diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/Activation.java b/core/src/main/java/org/projectnessie/cel/interpreter/Activation.java index ff0ea5e2..d68b68aa 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/Activation.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/Activation.java @@ -101,6 +101,9 @@ public Activation parent() { /** ResolveName implements the Activation interface method. */ @Override public ResolvedValue resolveName(String name) { + if (name.startsWith(".")) { + name = name.substring(1); + } Object obj = bindings.get(name); if (obj == null) { if (!bindings.containsKey(name)) { @@ -139,6 +142,9 @@ public Activation parent() { /** ResolveName implements the Activation interface method. */ @Override public ResolvedValue resolveName(String name) { + if (name.startsWith(".")) { + name = name.substring(1); + } Object result = provider.apply(name); if (result instanceof ResolvedValue) { return (ResolvedValue) result; @@ -176,6 +182,9 @@ public Activation parent() { /** ResolveName implements the Activation interface method. */ @Override public ResolvedValue resolveName(String name) { + if (name.startsWith(".")) { + return parent.resolveName(name.substring(1)); + } ResolvedValue object = child.resolveName(name); if (object.present()) { return object; @@ -236,6 +245,9 @@ public Activation parent() { @Override public ResolvedValue resolveName(String name) { + if (name.startsWith(".")) { + return delegate.resolveName(name); + } return delegate.resolveName(name); } @@ -278,6 +290,9 @@ public Activation parent() { /** ResolveName implements the Activation interface method. */ @Override public ResolvedValue resolveName(String name) { + if (name.startsWith(".")) { + return parent.resolveName(name.substring(1)); + } if (name.equals(this.name)) { return ResolvedValue.resolvedValue(val); } diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/AttributeFactory.java b/core/src/main/java/org/projectnessie/cel/interpreter/AttributeFactory.java index 6eecaa5b..04b6e856 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/AttributeFactory.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/AttributeFactory.java @@ -216,7 +216,10 @@ public AttributeFactory.Attribute conditionalAttribute( @Override public Attribute maybeAttribute(long id, String name) { List attrs = new ArrayList<>(); - attrs.add(absoluteAttribute(id, container.resolveCandidateNames(name))); + attrs.add( + name.startsWith(".") + ? absoluteAttribute(id, name) + : absoluteAttribute(id, container.resolveCandidateNames(name))); return new MaybeAttribute(id, attrs, adapter, provider, this); } @@ -357,24 +360,16 @@ public Object resolve(org.projectnessie.cel.interpreter.Activation vars) { */ @Override public Object tryResolve(org.projectnessie.cel.interpreter.Activation vars) { + Object local = tryResolveCurrentVar(vars); + if (local != null) { + return local; + } for (String nm : namespaceNames) { // If the variable is found, process it. Otherwise, wait until the checks to // determine whether the type is unknown before returning. ResolvedValue obj = vars.resolveName(nm); if (obj.present()) { - Object op = obj.value(); - for (int i = 0; i < qualifiers.size(); i++) { - Qualifier qual = qualifiers.get(i); - Object op2 = qualify(vars, op, qual, i == qualifiers.size() - 1); - if (op2 instanceof Err) { - return op2; - } - if (op2 == null) { - break; - } - op = op2; - } - return op; + return resolveQualifiers(vars, obj.value()); } // Attempt to resolve the qualified type name if the name is not a variable identifier. Val typ = provider.findIdent(nm); @@ -388,6 +383,36 @@ public Object tryResolve(org.projectnessie.cel.interpreter.Activation vars) { throw noSuchAttributeException(this); } + private Object tryResolveCurrentVar(org.projectnessie.cel.interpreter.Activation vars) { + if (vars instanceof org.projectnessie.cel.interpreter.Activation.VarActivation + && (namespaceNames.length > 1 || !qualifiers.isEmpty())) { + org.projectnessie.cel.interpreter.Activation.VarActivation var = + (org.projectnessie.cel.interpreter.Activation.VarActivation) vars; + String localName = namespaceNames[namespaceNames.length - 1]; + if (localName.equals(var.name)) { + return resolveQualifiers(vars, var.val); + } + } + return null; + } + + private Object resolveQualifiers( + org.projectnessie.cel.interpreter.Activation vars, Object obj) { + Object op = obj; + for (int i = 0; i < qualifiers.size(); i++) { + Qualifier qual = qualifiers.get(i); + Object op2 = qualify(vars, op, qual, i == qualifiers.size() - 1); + if (op2 instanceof Err) { + return op2; + } + if (op2 == null) { + break; + } + op = op2; + } + return op; + } + private Object qualify( org.projectnessie.cel.interpreter.Activation vars, Object obj, @@ -624,6 +649,14 @@ public Object qualify(org.projectnessie.cel.interpreter.Activation vars, Object */ @Override public Object resolve(org.projectnessie.cel.interpreter.Activation vars) { + for (NamespacedAttribute attr : attrs) { + if (attr instanceof AbsoluteAttribute) { + Object result = ((AbsoluteAttribute) attr).tryResolveCurrentVar(vars); + if (result != null) { + return result; + } + } + } for (NamespacedAttribute attr : attrs) { try { return attr.tryResolve(vars); diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/InterpretablePlanner.java b/core/src/main/java/org/projectnessie/cel/interpreter/InterpretablePlanner.java index 7d9c7416..e4f72609 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/InterpretablePlanner.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/InterpretablePlanner.java @@ -223,6 +223,8 @@ Interpretable planIdent(Expr expr) { } Interpretable planCheckedIdent(long id, Reference identRef) { + String identName = identRef.getName(); + String providerName = identName.startsWith(".") ? identName.substring(1) : identName; // Plan a constant reference if this is the case for this simple identifier. if (identRef.getValue() != Reference.getDefaultInstance().getValue()) { return plan(Expr.newBuilder().setId(id).setConstExpr(identRef.getValue()).build()); @@ -232,10 +234,10 @@ Interpretable planCheckedIdent(long id, Reference identRef) { // registered with the provider. Type cType = typeMap.get(id); if (cType != null && cType.getType() != Type.getDefaultInstance()) { - Val cVal = provider.findIdent(identRef.getName()); + Val cVal = provider.findIdent(providerName); if (cVal == null) { throw new IllegalStateException( - String.format("reference to undefined type: %s", identRef.getName())); + String.format("reference to undefined type: %s", providerName)); } return newConstValue(id, cVal); } @@ -243,9 +245,9 @@ Interpretable planCheckedIdent(long id, Reference identRef) { // Otherwise, evaluate the checked top-level variable directly for ordinary plans. Decorated // programs keep the attribute shape because custom decorators may inspect attributes. if (decorators.length == 0) { - return new EvalIdent(id, identRef.getName(), adapter); + return new EvalIdent(id, identName, adapter); } - return new EvalAttr(adapter, attrFactory.absoluteAttribute(id, identRef.getName())); + return new EvalAttr(adapter, attrFactory.absoluteAttribute(id, identName)); } /** diff --git a/core/src/test/java/org/projectnessie/cel/CELTest.java b/core/src/test/java/org/projectnessie/cel/CELTest.java index c2cd488d..78131d3c 100644 --- a/core/src/test/java/org/projectnessie/cel/CELTest.java +++ b/core/src/test/java/org/projectnessie/cel/CELTest.java @@ -126,6 +126,31 @@ void AstToProto() { assertThat(ast3.getExpr()).isEqualTo(astIss2.getAst().getExpr()); } + @Test + void comprehensionLocalVariablesShadowNamespacedIdentifiers() { + Env env = + newEnv( + container("com.example"), + declarations( + Decls.newVar("com.example.y", Decls.Int), + Decls.newVar("y", Decls.String), + Decls.newVar("com.example.y.z", Decls.Int))); + + AstIssuesTuple astIss = env.compile("[0].exists(y, y == 0)"); + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(mapOf("com.example.y", 42L)).getVal()) + .isSameAs(True); + + astIss = env.compile("['compre'].exists(y, .y == 'y')"); + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(mapOf("y", "y")).getVal()).isSameAs(True); + + astIss = env.compile("[{'z': 0}].exists(y, y.z == 0)"); + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(mapOf("com.example.y.z", 42L)).getVal()) + .isSameAs(True); + } + @Test void AstToString() { Env stdEnv = newEnv(); diff --git a/core/src/test/java/org/projectnessie/cel/checker/CheckerEnvTest.java b/core/src/test/java/org/projectnessie/cel/checker/CheckerEnvTest.java index f7bb0e47..e3dea123 100644 --- a/core/src/test/java/org/projectnessie/cel/checker/CheckerEnvTest.java +++ b/core/src/test/java/org/projectnessie/cel/checker/CheckerEnvTest.java @@ -17,6 +17,8 @@ import static java.util.Collections.singletonList; import static org.projectnessie.cel.checker.CheckerEnv.newStandardCheckerEnv; +import static org.projectnessie.cel.common.containers.Container.name; +import static org.projectnessie.cel.common.containers.Container.newContainer; import static org.projectnessie.cel.common.types.pb.ProtoTypeRegistry.newRegistry; import com.google.api.expr.v1alpha1.Type; @@ -63,6 +65,21 @@ Overloads.ToDyn, singletonList(paramA), Decls.Dyn, typeParamAList)))) "overlapping overload for name 'dyn' (type '(type_param: \"A\") -> dyn' with overloadId: 'to_dyn' cannot be distinguished from '(type_param: \"A\") -> dyn' with overloadId: 'to_dyn')"); } + @Test + void lexicalIdentifierShadowsContainerQualifiedIdentifier() { + CheckerEnv env = newStandardCheckerEnv(newContainer(name("com.example")), newRegistry()); + env.add(Decls.newVar("com.example.y", Decls.Int)); + env.add(Decls.newVar("y", Decls.Bool)); + + Assertions.assertThat(env.lookupIdent("y").getName()).isEqualTo("com.example.y"); + + env = env.enterScope(); + env.add(Decls.newVar("y", Decls.String)); + + Assertions.assertThat(env.lookupIdent("y").getName()).isEqualTo("y"); + Assertions.assertThat(env.lookupIdent(".com.example.y").getName()).isEqualTo("com.example.y"); + } + @Test void overloadsWithDifferentArityOrStyleDoNotOverlap() { CheckerEnv env = newStandardCheckerEnv(Container.defaultContainer, newRegistry()); From 41252f6a3b3afcd2911f91211c11a6fea6908ce8 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 16:00:10 +0200 Subject: [PATCH 20/33] Support proto2 extension field access Proto extension descriptors were not indexed as fields, so checked expressions could not resolve extension selects and parsed-only evaluation treated extension fields as absent. Any payloads with extension fields also lost those values when decoded without an extension registry. Index file-level and message-scoped extension descriptors, resolve them for matching containing message types, and use descriptor-backed extension registries when unpacking Any values. Add regression coverage and enable the proto2 extension has/get conformance cases. --- .../conformance/SimpleConformanceTest.java | 4 +- .../projectnessie/cel/common/types/pb/Db.java | 46 ++++++++++++++ .../cel/common/types/pb/FieldDescription.java | 48 +++++++-------- .../cel/common/types/pb/FileDescription.java | 60 ++++++++++++++++--- .../cel/common/types/pb/PbObjectT.java | 12 +++- .../common/types/pb/PbTypeDescription.java | 19 ++++-- .../common/types/pb/ProtoTypeRegistry.java | 18 +++++- .../projectnessie/cel/proto/ProtoTest.java | 44 ++++++++++++++ 8 files changed, 206 insertions(+), 45 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 2ca0dacc..34a7197d 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -135,8 +135,6 @@ class SimpleConformanceTest { // Malicious too-deep protobuf structure. "parse/nest/message_literal", // New CEL-Spec v0.25.2 expectations that need follow-up parser/runtime changes. - "proto2/extensions_has/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", - "proto2/extensions_get/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", // type_deduction.textproto coverage was added opportunistically. The remaining skips are // checker limitations around optionals and legacy nullable generic candidates. "type_deductions/flexible_type_parameter_assignment/optional_none,optional_none_2,optional_dyn_promotion,optional_dyn_promotion_2,optional_in_ternary", @@ -323,6 +321,7 @@ private static CheckedExpr check(SimpleTest test, ParsedExpr parsedExpr) declarations(typeEnv), types( dev.cel.expr.conformance.proto2.TestAllTypes.getDefaultInstance(), + dev.cel.expr.conformance.proto2.Proto2ExtensionScopedMessage.getDefaultInstance(), dev.cel.expr.conformance.proto3.TestAllTypes.getDefaultInstance())); AstIssuesTuple astIss = env.check(parsedExprToAst(parsedExpr)); @@ -346,6 +345,7 @@ private static ExprValue eval(SimpleTest test, Ast ast) { container(test.getContainer()), types( dev.cel.expr.conformance.proto2.TestAllTypes.getDefaultInstance(), + dev.cel.expr.conformance.proto2.Proto2ExtensionScopedMessage.getDefaultInstance(), dev.cel.expr.conformance.proto3.TestAllTypes.getDefaultInstance())); Program program = env.program(ast); diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java index bf96e2df..a3633cea 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java @@ -24,6 +24,7 @@ import com.google.protobuf.Descriptors.FileDescriptor; import com.google.protobuf.Duration; import com.google.protobuf.Empty; +import com.google.protobuf.ExtensionRegistry; import com.google.protobuf.FieldMask; import com.google.protobuf.Message; import com.google.protobuf.Timestamp; @@ -50,6 +51,8 @@ public final class Db { /** files contains the deduped set of FileDescriptions whose types are contained in the pb.Db. */ private final List files; + private volatile ExtensionRegistry extensionRegistry; + /** DefaultDb used at evaluation time or unless overridden at check time. */ public static final Db defaultDb = new Db(new HashMap<>(), new ArrayList<>()); @@ -123,10 +126,14 @@ public FileDescription registerDescriptor(FileDescriptor fileDesc) { for (String enumValName : fd.getEnumNames()) { revFileDescriptorMap.put(enumValName, fd); } + for (String extensionName : fd.getExtensionNames()) { + revFileDescriptorMap.put(extensionName, fd); + } for (String msgTypeName : fd.getTypeNames()) { revFileDescriptorMap.put(msgTypeName, fd); } revFileDescriptorMap.put(path, fd); + extensionRegistry = null; // Return the specific file descriptor registered. files.add(fd); @@ -171,6 +178,45 @@ public PbTypeDescription describeType(String typeName) { return fd != null ? fd.getTypeDescription(typeName) : null; } + public FieldDescription describeExtension(String messageType, String extensionName) { + extensionName = sanitizeProtoName(extensionName); + FileDescription fd = revFileDescriptorMap.get(extensionName); + if (fd == null) { + return null; + } + FieldDescription extension = fd.getExtensionDescription(extensionName); + if (extension == null + || !sanitizeProtoName(messageType) + .equals(extension.descriptor().getContainingType().getFullName())) { + return null; + } + return extension; + } + + ExtensionRegistry extensionRegistry() { + ExtensionRegistry registry = extensionRegistry; + if (registry != null) { + return registry; + } + synchronized (this) { + registry = extensionRegistry; + if (registry == null) { + registry = ExtensionRegistry.newInstance(); + for (FileDescription file : files) { + for (FieldDescription extension : file.getExtensionDescriptions()) { + if (extension.isMessage()) { + registry.add(extension.descriptor(), extension.zero()); + } else { + registry.add(extension.descriptor()); + } + } + } + extensionRegistry = registry; + } + return registry; + } + } + /** * CollectFileDescriptorSet builds a file descriptor set associated with the file where the input * message is declared. diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/FieldDescription.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/FieldDescription.java index 781752d2..d5f6be7b 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/FieldDescription.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/FieldDescription.java @@ -211,16 +211,8 @@ public FieldDescriptor descriptor() { public boolean isSet(Object target) { if (target instanceof Message) { Message v = (Message) target; - // pbRef = v.ProtoReflect() - Descriptor pbDesc = v.getDescriptorForType(); - if (pbDesc == desc.getContainingType()) { - // When the target protobuf shares the same message descriptor instance as the field - // descriptor, use the cached field descriptor value. - return FieldDescription.hasValueForField(desc, v); - } - // Otherwise, fallback to a dynamic lookup of the field descriptor from the target - // instance as an attempt to use the cached field descriptor will result in a panic. - return FieldDescription.hasValueForField(pbDesc.findFieldByName(name()), v); + FieldDescriptor fd = fieldDescriptorFor(v); + return fd != null && FieldDescription.hasValueForField(fd, v); } return false; } @@ -242,20 +234,8 @@ public Object getFrom(Db db, Object target) { } Message v = (Message) target; // pbRef = v.protoReflect(); - Descriptor pbDesc = v.getDescriptorForType(); - Object fieldVal; - - FieldDescriptor fd; - if (pbDesc == desc.getContainingType()) { - // When the target protobuf shares the same message descriptor instance as the field - // descriptor, use the cached field descriptor value. - fd = desc; - } else { - // Otherwise, fallback to a dynamic lookup of the field descriptor from the target - // instance as an attempt to use the cached field descriptor will result in a panic. - fd = pbDesc.findFieldByName(name()); - } - fieldVal = getValueFromField(fd, v); + FieldDescriptor fd = fieldDescriptorFor(v); + Object fieldVal = getValueFromField(fd, v); Class fieldType = fieldVal.getClass(); if (fd.getJavaType() != JavaType.MESSAGE @@ -447,7 +427,12 @@ public int hashCode() { } public boolean hasField(Object target) { - return hasValueForField(desc, (Message) target); + if (!(target instanceof Message)) { + return false; + } + Message message = (Message) target; + FieldDescriptor fd = fieldDescriptorFor(message); + return fd != null && hasValueForField(fd, message); } public Object getField(Object target) { @@ -541,7 +526,18 @@ private FieldDescriptor fieldDescriptorFor(Message message) { if (messageDesc == desc.getContainingType()) { return desc; } - return messageDesc.findFieldByName(name()); + if (!desc.isExtension()) { + return messageDesc.findFieldByName(name()); + } + for (FieldDescriptor field : message.getAllFields().keySet()) { + if (field.getFullName().equals(desc.getFullName())) { + return field; + } + } + if (messageDesc.getFullName().equals(desc.getContainingType().getFullName())) { + return desc; + } + return null; } private static final class UnsignedLongList extends AbstractList { diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/FileDescription.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/FileDescription.java index 3e554c1c..c9d229d9 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/FileDescription.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/FileDescription.java @@ -18,6 +18,7 @@ import com.google.protobuf.Descriptors.Descriptor; import com.google.protobuf.Descriptors.EnumDescriptor; import com.google.protobuf.Descriptors.EnumValueDescriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; import com.google.protobuf.Descriptors.FileDescriptor; import java.util.HashMap; import java.util.List; @@ -29,11 +30,15 @@ public final class FileDescription { private final Map types; private final Map enums; + private final Map extensions; private FileDescription( - Map types, Map enums) { + Map types, + Map enums, + Map extensions) { this.types = types; this.enums = enums; + this.extensions = extensions; } @Override @@ -45,12 +50,14 @@ public boolean equals(Object o) { return false; } FileDescription that = (FileDescription) o; - return Objects.equals(types, that.types) && Objects.equals(enums, that.enums); + return Objects.equals(types, that.types) + && Objects.equals(enums, that.enums) + && Objects.equals(extensions, that.extensions); } @Override public int hashCode() { - return Objects.hash(types, enums); + return Objects.hash(types, enums, extensions); } /** @@ -66,7 +73,10 @@ public static FileDescription newFileDescription(FileDescriptor fileDesc) { Map types = new HashMap<>(); metadata.msgTypes.forEach( (name, msgType) -> types.put(name, PbTypeDescription.newTypeDescription(name, msgType))); - return new FileDescription(types, enums); + Map extensions = new HashMap<>(); + metadata.extensions.forEach( + (name, extension) -> extensions.put(name, FieldDescription.newFieldDescription(extension))); + return new FileDescription(types, enums, extensions); } /** @@ -82,6 +92,21 @@ public String[] getEnumNames() { return enums.keySet().toArray(new String[0]); } + /** GetExtensionDescription returns a field description for a qualified extension name. */ + public FieldDescription getExtensionDescription(String extensionName) { + return extensions.get(sanitizeProtoName(extensionName)); + } + + /** GetExtensionNames returns the string names of all extensions in the file. */ + public String[] getExtensionNames() { + return extensions.keySet().toArray(new String[0]); + } + + /** GetExtensionDescriptions returns all extension field descriptions in the file. */ + public Iterable getExtensionDescriptions() { + return extensions.values(); + } + /** * GetTypeDescription returns a TypeDescription for a qualified protobuf message type name * declared within the .proto file. @@ -111,12 +136,18 @@ static final class FileMetadata { /** enumValues maps from fully-qualified enum value to enum value descriptor. */ final Map enumValues; + /** extensions maps from fully-qualified extension name to field descriptor. */ + final Map extensions; + // TODO: support enum type definitions for use in future type-check enhancements. private FileMetadata( - Map msgTypes, Map enumValues) { + Map msgTypes, + Map enumValues, + Map extensions) { this.msgTypes = msgTypes; this.enumValues = enumValues; + this.extensions = extensions; } /** @@ -126,10 +157,12 @@ private FileMetadata( static FileMetadata collectFileMetadata(FileDescriptor fileDesc) { Map msgTypes = new HashMap<>(); Map enumValues = new HashMap<>(); + Map extensions = new HashMap<>(); - collectMsgTypes(fileDesc.getMessageTypes(), msgTypes, enumValues); + collectMsgTypes(fileDesc.getMessageTypes(), msgTypes, enumValues, extensions); collectEnumValues(fileDesc.getEnumTypes(), enumValues); - return new FileMetadata(msgTypes, enumValues); + collectExtensions(fileDesc.getExtensions(), extensions); + return new FileMetadata(msgTypes, enumValues, extensions); } /** @@ -139,17 +172,26 @@ static FileMetadata collectFileMetadata(FileDescriptor fileDesc) { private static void collectMsgTypes( List msgTypes, Map msgTypeMap, - Map enumValueMap) { + Map enumValueMap, + Map extensionMap) { for (Descriptor msgType : msgTypes) { msgTypeMap.put(msgType.getFullName(), msgType); List nestedMsgTypes = msgType.getNestedTypes(); if (!nestedMsgTypes.isEmpty()) { - collectMsgTypes(nestedMsgTypes, msgTypeMap, enumValueMap); + collectMsgTypes(nestedMsgTypes, msgTypeMap, enumValueMap, extensionMap); } List nestedEnumTypes = msgType.getEnumTypes(); if (!nestedEnumTypes.isEmpty()) { collectEnumValues(nestedEnumTypes, enumValueMap); } + collectExtensions(msgType.getExtensions(), extensionMap); + } + } + + private static void collectExtensions( + List extensions, Map extensionMap) { + for (FieldDescriptor extension : extensions) { + extensionMap.put(extension.getFullName(), extension); } } diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java index e76ad92d..cb8c8ced 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java @@ -63,7 +63,7 @@ public Val isSet(Val field) { return noSuchOverload(this, "isSet", field); } String protoFieldStr = (String) field.value(); - FieldDescription fd = typeDesc().fieldByName(protoFieldStr); + FieldDescription fd = fieldDescription(protoFieldStr); if (fd == null) { return noSuchField(protoFieldStr); } @@ -76,7 +76,7 @@ public Val get(Val index) { return noSuchOverload(this, "get", index); } String protoFieldStr = (String) index.value(); - FieldDescription fd = typeDesc().fieldByName(protoFieldStr); + FieldDescription fd = fieldDescription(protoFieldStr); if (fd == null) { return noSuchField(protoFieldStr); } @@ -217,6 +217,14 @@ private PbTypeDescription typeDesc() { return (PbTypeDescription) typeDesc; } + private FieldDescription fieldDescription(String fieldName) { + FieldDescription field = typeDesc().fieldByName(fieldName); + if (field != null || !(adapter instanceof ProtoTypeRegistry)) { + return field; + } + return ((ProtoTypeRegistry) adapter).findFieldDescription(typeDesc().name(), fieldName); + } + @SuppressWarnings("unchecked") private T buildFrom(Class typeDesc) { try { diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/PbTypeDescription.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/PbTypeDescription.java index fd1fc2d4..782b6ec2 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/PbTypeDescription.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/PbTypeDescription.java @@ -138,17 +138,19 @@ public Object maybeUnwrap(Db db, Object m) { return anyWithEmptyType(); } PbTypeDescription realTypeDescriptor = db.describeType(realTypeName); - Message realMsg = realTypeDescriptor.zeroMsg.getParserForType().parseFrom(realValue); + Message realMsg = + DynamicMessage.parseFrom( + realTypeDescriptor.getDescriptor(), realValue, db.extensionRegistry()); return realTypeDescriptor.maybeUnwrap(db, realMsg); } if (!(zeroMsg instanceof DynamicMessage)) { if (msg instanceof Any) { Any any = (Any) msg; - msg = zeroMsg.getParserForType().parseFrom(any.getValue()); - } else if (msg instanceof DynamicMessage) { + msg = DynamicMessage.parseFrom(getDescriptor(), any.getValue(), db.extensionRegistry()); + } else if (msg instanceof DynamicMessage && !hasExtensions(msg)) { DynamicMessage dyn = (DynamicMessage) msg; - msg = zeroMsg.getParserForType().parseFrom(dyn.toByteString()); + msg = zeroMsg.getParserForType().parseFrom(dyn.toByteString(), db.extensionRegistry()); } } } catch (InvalidProtocolBufferException e) { @@ -275,6 +277,15 @@ static Object unwrap(Db db, Description desc, Message msg) { return msg; } + private static boolean hasExtensions(Message message) { + for (FieldDescriptor field : message.getAllFields().keySet()) { + if (field.isExtension()) { + return true; + } + } + return false; + } + private static java.time.Duration asJavaDuration(Duration d) { return java.time.Duration.ofSeconds(d.getSeconds(), d.getNanos()); } diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java index e6b2fac9..425b23a2 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java @@ -209,16 +209,30 @@ public FieldType findFieldType(String messageType, String fieldName) { } private FieldType loadFieldType(String messageType, String fieldName) { + FieldDescription field = findFieldDescription(messageType, fieldName); + if (field == null) { + return null; + } + FieldDescription resolvedField = field; + return new FieldType( + resolvedField.checkedType(), + resolvedField::hasField, + target -> resolvedField.getField(target, this)); + } + + FieldDescription findFieldDescription(String messageType, String fieldName) { PbTypeDescription msgType = pbdb.describeType(messageType); if (msgType == null) { return null; } FieldDescription field = msgType.fieldByName(fieldName); + if (field == null) { + field = pbdb.describeExtension(messageType, fieldName); + } if (field == null) { return null; } - return new FieldType( - field.checkedType(), field::hasField, target -> field.getField(target, this)); + return field; } @Override diff --git a/core/src/test/java/org/projectnessie/cel/proto/ProtoTest.java b/core/src/test/java/org/projectnessie/cel/proto/ProtoTest.java index 4975727c..4f888e82 100644 --- a/core/src/test/java/org/projectnessie/cel/proto/ProtoTest.java +++ b/core/src/test/java/org/projectnessie/cel/proto/ProtoTest.java @@ -19,16 +19,23 @@ import static java.lang.Boolean.TRUE; import static org.assertj.core.api.Assertions.assertThat; import static org.projectnessie.cel.Env.newCustomEnv; +import static org.projectnessie.cel.Env.newEnv; import static org.projectnessie.cel.EnvOption.declarations; import static org.projectnessie.cel.EnvOption.types; import static org.projectnessie.cel.Library.StdLib; +import static org.projectnessie.cel.Util.mapOf; +import com.google.protobuf.Any; import com.google.protobuf.Descriptors; import com.google.protobuf.DynamicMessage; +import dev.cel.expr.conformance.proto2.Proto2ExtensionScopedMessage; +import dev.cel.expr.conformance.proto2.TestAllTypes; +import dev.cel.expr.conformance.proto2.TestAllTypesExtensions; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.projectnessie.cel.Ast; @@ -43,6 +50,43 @@ import org.projectnessie.cel.proto.tests.ProtoTestTypes; public class ProtoTest { + @Test + void proto2ExtensionFieldSelection() { + Env env = + newEnv( + types( + TestAllTypes.getDefaultInstance(), + Proto2ExtensionScopedMessage.getDefaultInstance()), + declarations( + Decls.newVar( + "msg", Decls.newObjectType("cel.expr.conformance.proto2.TestAllTypes")))); + TestAllTypes message = + TestAllTypes.newBuilder().setExtension(TestAllTypesExtensions.int32Ext, 42).build(); + + String expression = + "has(msg.`cel.expr.conformance.proto2.int32_ext`) " + + "&& msg.`cel.expr.conformance.proto2.int32_ext` == 42"; + + AstIssuesTuple parsed = env.parse(expression); + assertThat(parsed).extracting(AstIssuesTuple::hasIssues).isEqualTo(FALSE); + assertThat(env.program(parsed.getAst()).eval(mapOf("msg", message)).getVal()) + .extracting(Val::booleanValue) + .isEqualTo(TRUE); + + AstIssuesTuple checked = env.check(parsed.getAst()); + assertThat(checked).extracting(AstIssuesTuple::hasIssues).isEqualTo(FALSE); + assertThat(env.program(checked.getAst()).eval(mapOf("msg", message)).getVal()) + .extracting(Val::booleanValue) + .isEqualTo(TRUE); + Val anyValue = env.getTypeAdapter().nativeToValue(Any.pack(message)); + assertThat(env.program(parsed.getAst()).eval(mapOf("msg", anyValue)).getVal()) + .extracting(Val::booleanValue) + .isEqualTo(TRUE); + assertThat(env.program(checked.getAst()).eval(mapOf("msg", anyValue)).getVal()) + .extracting(Val::booleanValue) + .isEqualTo(TRUE); + } + @ParameterizedTest @ValueSource( strings = { From bd48e61b12047afb22f16a6270e3b5be815ba43a Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 16:11:42 +0200 Subject: [PATCH 21/33] Add proto extension helper library The CEL proto extension conformance suite uses proto.hasExt/proto.getExt and references protobuf extension identifiers as expression values. Those identifiers were not available through checker/provider resolution, and the helper functions were not exposed as an explicit extension library. Expose registered protobuf extension names as string constants, add the opt-in proto extension library functions, and run the proto2 extension conformance file through that library. --- .../conformance/SimpleConformanceTest.java | 35 +++++--- .../projectnessie/cel/checker/CheckerEnv.java | 12 +++ .../projectnessie/cel/common/types/pb/Db.java | 12 +-- .../common/types/pb/ProtoTypeRegistry.java | 4 + .../projectnessie/cel/extension/ProtoLib.java | 87 +++++++++++++++++++ .../projectnessie/cel/proto/ProtoTest.java | 32 +++++++ 6 files changed, 163 insertions(+), 19 deletions(-) create mode 100644 core/src/main/java/org/projectnessie/cel/extension/ProtoLib.java diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 34a7197d..c9351380 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -43,6 +43,7 @@ import static org.projectnessie.cel.common.types.UintT.uintOf; import static org.projectnessie.cel.common.types.UnknownT.isUnknown; import static org.projectnessie.cel.common.types.UnknownT.unknownOf; +import static org.projectnessie.cel.extension.ProtoLib.proto; import com.google.api.expr.v1alpha1.CheckedExpr; import com.google.api.expr.v1alpha1.Decl; @@ -123,6 +124,7 @@ class SimpleConformanceTest { "parse.textproto", "plumbing.textproto", "proto2.textproto", + "proto2_ext.textproto", "proto3.textproto", "string.textproto", "timestamps.textproto", @@ -316,13 +318,8 @@ private static CheckedExpr check(SimpleTest test, ParsedExpr parsedExpr) Env env = newCustomEnv( - StdLib(), - container(test.getContainer()), - declarations(typeEnv), - types( - dev.cel.expr.conformance.proto2.TestAllTypes.getDefaultInstance(), - dev.cel.expr.conformance.proto2.Proto2ExtensionScopedMessage.getDefaultInstance(), - dev.cel.expr.conformance.proto3.TestAllTypes.getDefaultInstance())); + conformanceEnvOptions(test, StdLib(), declarations(typeEnv)) + .toArray(new EnvOption[0])); AstIssuesTuple astIss = env.check(parsedExprToAst(parsedExpr)); if (astIss.hasIssues()) { @@ -340,13 +337,7 @@ private static ExprValue evalChecked(SimpleTest test, CheckedExpr checkedExpr) { } private static ExprValue eval(SimpleTest test, Ast ast) { - Env env = - newEnv( - container(test.getContainer()), - types( - dev.cel.expr.conformance.proto2.TestAllTypes.getDefaultInstance(), - dev.cel.expr.conformance.proto2.Proto2ExtensionScopedMessage.getDefaultInstance(), - dev.cel.expr.conformance.proto3.TestAllTypes.getDefaultInstance())); + Env env = newEnv(conformanceEnvOptions(test).toArray(new EnvOption[0])); Program program = env.program(ast); Map args = new HashMap<>(); @@ -365,6 +356,22 @@ private static ExprValue eval(SimpleTest test, Ast ast) { .setError(ErrorSet.newBuilder().addErrors(Status.newBuilder().setMessage(err.toString()))) .build(); } + + private static List conformanceEnvOptions(SimpleTest test, EnvOption... options) { + List envOptions = new ArrayList<>(); + envOptions.add(container(test.getContainer())); + envOptions.add( + types( + dev.cel.expr.conformance.proto2.TestAllTypes.getDefaultInstance(), + dev.cel.expr.conformance.proto2.Proto2ExtensionScopedMessage.getDefaultInstance(), + dev.cel.expr.conformance.proto3.TestAllTypes.getDefaultInstance())); + if (test.getExpr().startsWith("proto.hasExt(") + || test.getExpr().startsWith("proto.getExt(")) { + envOptions.add(proto()); + } + envOptions.addAll(List.of(options)); + return envOptions; + } } private static void match(String testPath, SimpleTest test, ExprValue actual) diff --git a/core/src/main/java/org/projectnessie/cel/checker/CheckerEnv.java b/core/src/main/java/org/projectnessie/cel/checker/CheckerEnv.java index 78902cb5..cc68d810 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/CheckerEnv.java +++ b/core/src/main/java/org/projectnessie/cel/checker/CheckerEnv.java @@ -36,6 +36,7 @@ import java.util.Collections; import java.util.List; import org.projectnessie.cel.common.containers.Container; +import org.projectnessie.cel.common.types.ref.TypeEnum; import org.projectnessie.cel.common.types.ref.TypeProvider; import org.projectnessie.cel.common.types.ref.Val; import org.projectnessie.cel.parser.Macro; @@ -152,6 +153,17 @@ public Decl lookupIdent(String name) { declarations.addIdent(decl); return decl; } + + Val identValue = provider.findIdent(candidate); + if (identValue != null && identValue.type().typeEnum() == TypeEnum.String) { + Decl decl = + Decls.newIdent( + candidate, + Decls.String, + Constant.newBuilder().setStringValue(identValue.value().toString()).build()); + declarations.addIdent(decl); + return decl; + } } return null; } diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java index a3633cea..2fb01465 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java @@ -180,11 +180,7 @@ public PbTypeDescription describeType(String typeName) { public FieldDescription describeExtension(String messageType, String extensionName) { extensionName = sanitizeProtoName(extensionName); - FileDescription fd = revFileDescriptorMap.get(extensionName); - if (fd == null) { - return null; - } - FieldDescription extension = fd.getExtensionDescription(extensionName); + FieldDescription extension = describeExtension(extensionName); if (extension == null || !sanitizeProtoName(messageType) .equals(extension.descriptor().getContainingType().getFullName())) { @@ -193,6 +189,12 @@ public FieldDescription describeExtension(String messageType, String extensionNa return extension; } + public FieldDescription describeExtension(String extensionName) { + extensionName = sanitizeProtoName(extensionName); + FileDescription fd = revFileDescriptorMap.get(extensionName); + return fd != null ? fd.getExtensionDescription(extensionName) : null; + } + ExtensionRegistry extensionRegistry() { ExtensionRegistry registry = extensionRegistry; if (registry != null) { diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java index 425b23a2..dd567ead 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java @@ -30,6 +30,7 @@ import static org.projectnessie.cel.common.types.MapT.MapType; import static org.projectnessie.cel.common.types.NullT.NullType; import static org.projectnessie.cel.common.types.StringT.StringType; +import static org.projectnessie.cel.common.types.StringT.stringOf; import static org.projectnessie.cel.common.types.TimestampT.TimestampType; import static org.projectnessie.cel.common.types.TypeT.TypeType; import static org.projectnessie.cel.common.types.TypeT.newObjectTypeValue; @@ -245,6 +246,9 @@ public Val findIdent(String identName) { if (enumVal != null) { return intOf(enumVal.value()); } + if (pbdb.describeExtension(identName) != null) { + return stringOf(identName); + } return null; } diff --git a/core/src/main/java/org/projectnessie/cel/extension/ProtoLib.java b/core/src/main/java/org/projectnessie/cel/extension/ProtoLib.java new file mode 100644 index 00000000..1fa2ebc1 --- /dev/null +++ b/core/src/main/java/org/projectnessie/cel/extension/ProtoLib.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.projectnessie.cel.extension; + +import static java.util.Arrays.asList; +import static org.projectnessie.cel.common.types.Err.noSuchOverload; + +import java.util.List; +import org.projectnessie.cel.EnvOption; +import org.projectnessie.cel.Library; +import org.projectnessie.cel.ProgramOption; +import org.projectnessie.cel.checker.Decls; +import org.projectnessie.cel.common.types.StringT; +import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.common.types.traits.FieldTester; +import org.projectnessie.cel.common.types.traits.Indexer; +import org.projectnessie.cel.interpreter.functions.Overload; + +/** + * ProtoLib provides CEL protobuf extension helper functions. + * + *

The extension-name argument is a fully-qualified protobuf extension field name. Expressions + * that use extension identifiers instead of string literals need those identifiers registered with + * the type provider, for example through {@link EnvOption#types(Object...)} with protobuf + * descriptors that contain the extensions. + */ +public final class ProtoLib implements Library { + private static final String HAS_EXT = "proto.hasExt"; + private static final String GET_EXT = "proto.getExt"; + private static final String HAS_EXT_OVERLOAD = "proto_has_ext"; + private static final String GET_EXT_OVERLOAD = "proto_get_ext"; + + private ProtoLib() {} + + public static EnvOption proto() { + return Library.Lib(new ProtoLib()); + } + + @Override + public List getCompileOptions() { + return List.of( + EnvOption.declarations( + Decls.newFunction( + HAS_EXT, + Decls.newOverload(HAS_EXT_OVERLOAD, asList(Decls.Dyn, Decls.String), Decls.Bool)), + Decls.newFunction( + GET_EXT, + Decls.newOverload(GET_EXT_OVERLOAD, asList(Decls.Dyn, Decls.String), Decls.Dyn)))); + } + + @Override + public List getProgramOptions() { + return List.of( + ProgramOption.functions( + Overload.binary(HAS_EXT, ProtoLib::hasExt), + Overload.binary(HAS_EXT_OVERLOAD, ProtoLib::hasExt), + Overload.binary(GET_EXT, ProtoLib::getExt), + Overload.binary(GET_EXT_OVERLOAD, ProtoLib::getExt))); + } + + private static Val hasExt(Val object, Val extensionName) { + if (!(object instanceof FieldTester) || !(extensionName instanceof StringT)) { + return noSuchOverload(object, HAS_EXT, HAS_EXT_OVERLOAD, new Val[] {object, extensionName}); + } + return ((FieldTester) object).isSet(extensionName); + } + + private static Val getExt(Val object, Val extensionName) { + if (!(object instanceof Indexer) || !(extensionName instanceof StringT)) { + return noSuchOverload(object, GET_EXT, GET_EXT_OVERLOAD, new Val[] {object, extensionName}); + } + return ((Indexer) object).get(extensionName); + } +} diff --git a/core/src/test/java/org/projectnessie/cel/proto/ProtoTest.java b/core/src/test/java/org/projectnessie/cel/proto/ProtoTest.java index 4f888e82..2a159349 100644 --- a/core/src/test/java/org/projectnessie/cel/proto/ProtoTest.java +++ b/core/src/test/java/org/projectnessie/cel/proto/ProtoTest.java @@ -24,6 +24,7 @@ import static org.projectnessie.cel.EnvOption.types; import static org.projectnessie.cel.Library.StdLib; import static org.projectnessie.cel.Util.mapOf; +import static org.projectnessie.cel.extension.ProtoLib.proto; import com.google.protobuf.Any; import com.google.protobuf.Descriptors; @@ -87,6 +88,37 @@ void proto2ExtensionFieldSelection() { .isEqualTo(TRUE); } + @Test + void protoExtensionLibraryFunctions() { + Env env = + newEnv( + proto(), + types( + TestAllTypes.getDefaultInstance(), + Proto2ExtensionScopedMessage.getDefaultInstance()), + declarations( + Decls.newVar( + "msg", Decls.newObjectType("cel.expr.conformance.proto2.TestAllTypes")))); + TestAllTypes message = + TestAllTypes.newBuilder().setExtension(TestAllTypesExtensions.int32Ext, 42).build(); + + String expression = + "proto.hasExt(msg, cel.expr.conformance.proto2.int32_ext) " + + "&& proto.getExt(msg, cel.expr.conformance.proto2.int32_ext) == 42"; + + AstIssuesTuple parsed = env.parse(expression); + assertThat(parsed).extracting(AstIssuesTuple::hasIssues).isEqualTo(FALSE); + assertThat(env.program(parsed.getAst()).eval(mapOf("msg", message)).getVal()) + .extracting(Val::booleanValue) + .isEqualTo(TRUE); + + AstIssuesTuple checked = env.check(parsed.getAst()); + assertThat(checked).extracting(AstIssuesTuple::hasIssues).isEqualTo(FALSE); + assertThat(env.program(checked.getAst()).eval(mapOf("msg", message)).getVal()) + .extracting(Val::booleanValue) + .isEqualTo(TRUE); + } + @ParameterizedTest @ValueSource( strings = { From d36c9ac9418cd370ef281802b578ba7fb140d253 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 16:16:01 +0200 Subject: [PATCH 22/33] Add supported string extension conformance The CEL string extension conformance file covers APIs that are already implemented by StringsLib, but the harness did not include that file or enable the library for those expressions. Add the string extension conformance file with explicit skips for unsupported quote, format, and reverse sections. Tighten multi-arity string extension dispatch so calls with too many arguments return no-such-overload instead of evaluating the expected argument prefix. --- .../conformance/SimpleConformanceTest.java | 26 +++++++++++++++++++ .../cel/extension/StringsLib.java | 20 +++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index c9351380..32a31f38 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -44,6 +44,7 @@ import static org.projectnessie.cel.common.types.UnknownT.isUnknown; import static org.projectnessie.cel.common.types.UnknownT.unknownOf; import static org.projectnessie.cel.extension.ProtoLib.proto; +import static org.projectnessie.cel.extension.StringsLib.strings; import com.google.api.expr.v1alpha1.CheckedExpr; import com.google.api.expr.v1alpha1.Decl; @@ -127,6 +128,7 @@ class SimpleConformanceTest { "proto2_ext.textproto", "proto3.textproto", "string.textproto", + "string_ext.textproto", "timestamps.textproto", "type_deduction.textproto", "unknowns.textproto", @@ -141,6 +143,11 @@ class SimpleConformanceTest { // checker limitations around optionals and legacy nullable generic candidates. "type_deductions/flexible_type_parameter_assignment/optional_none,optional_none_2,optional_dyn_promotion,optional_dyn_promotion_2,optional_in_ternary", "type_deductions/legacy_nullable_types/null_assignable_to_abstract_parameter_candidate", + // StringsLib does not implement the CEL string quote/format/reverse extensions. + "string_ext/quote", + "string_ext/format", + "string_ext/format_errors", + "string_ext/reverse", "enums/strong_proto2", "enums/strong_proto3"); @@ -369,9 +376,28 @@ private static List conformanceEnvOptions(SimpleTest test, EnvOption. || test.getExpr().startsWith("proto.getExt(")) { envOptions.add(proto()); } + if (usesStringExtensions(test.getExpr())) { + envOptions.add(strings()); + } envOptions.addAll(List.of(options)); return envOptions; } + + private static boolean usesStringExtensions(String expression) { + return expression.contains(".charAt(") + || expression.contains(".indexOf(") + || expression.contains(".lastIndexOf(") + || expression.contains(".lowerAscii(") + || expression.contains(".upperAscii(") + || expression.contains(".replace(") + || expression.contains(".split(") + || expression.contains(".substring(") + || expression.contains(".trim(") + || expression.contains(".join(") + || expression.contains("strings.quote(") + || expression.contains(".format(") + || expression.contains(".reverse("); + } } private static void match(String testPath, SimpleTest test, ExprValue actual) diff --git a/core/src/main/java/org/projectnessie/cel/extension/StringsLib.java b/core/src/main/java/org/projectnessie/cel/extension/StringsLib.java index 4469648c..5097c8eb 100644 --- a/core/src/main/java/org/projectnessie/cel/extension/StringsLib.java +++ b/core/src/main/java/org/projectnessie/cel/extension/StringsLib.java @@ -363,7 +363,10 @@ public List getProgramOptions() { null, null, Guards.callInStrStrOutInt(StringsLib::indexOf), - Guards.callInStrStrIntOutInt(StringsLib::indexOfOffset)), + values -> + values.length == 3 + ? Guards.callInStrStrIntOutInt(StringsLib::indexOfOffset).invoke(values) + : Err.maybeNoSuchOverloadErr(null)), Overload.overload( JOIN, null, @@ -375,7 +378,10 @@ public List getProgramOptions() { null, null, Guards.callInStrStrOutInt(StringsLib::lastIndexOf), - Guards.callInStrStrIntOutInt(StringsLib::lastIndexOfOffset)), + values -> + values.length == 3 + ? Guards.callInStrStrIntOutInt(StringsLib::lastIndexOfOffset).invoke(values) + : Err.maybeNoSuchOverloadErr(null)), Overload.unary(LOWER_ASCII, Guards.callInStrOutStr(StringsLib::lowerASCII)), Overload.overload( REPLACE, @@ -396,13 +402,19 @@ public List getProgramOptions() { null, null, Guards.callInStrStrOutStrArr(StringsLib::split), - Guards.callInStrStrIntOutStrArr(StringsLib::splitN)), + values -> + values.length == 3 + ? Guards.callInStrStrIntOutStrArr(StringsLib::splitN).invoke(values) + : Err.maybeNoSuchOverloadErr(null)), Overload.overload( SUBSTR, null, null, Guards.callInStrIntOutStr(StringsLib::substr), - Guards.callInStrIntIntOutStr(StringsLib::substrRange)), + values -> + values.length == 3 + ? Guards.callInStrIntIntOutStr(StringsLib::substrRange).invoke(values) + : Err.maybeNoSuchOverloadErr(null)), Overload.unary(TRIM_SPACE, Guards.callInStrOutStr(StringsLib::trimSpace)), Overload.unary(UPPER_ASCII, Guards.callInStrOutStr(StringsLib::upperASCII))); return List.of(functions); From 25c412e9854f89c6b6f55f338e4932fa68bf8198 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 16:24:52 +0200 Subject: [PATCH 23/33] Complete string extension conformance The string extension conformance file still skipped quote, format, format error, and reverse coverage. StringsLib also lacked the corresponding declarations and runtime implementations. Add strings.quote, reverse, and CEL string formatting support for the covered clauses, including recursive list and map rendering. Fix escaped string literal unescaping for raw non-BMP Unicode code points, and remove the remaining string extension conformance skips. --- .../conformance/SimpleConformanceTest.java | 5 - .../cel/extension/StringsLib.java | 347 +++++++++++++++++- .../projectnessie/cel/parser/Unescape.java | 8 +- .../cel/extension/StringsTest.java | 11 +- .../cel/parser/UnescapeTest.java | 6 + 5 files changed, 367 insertions(+), 10 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 32a31f38..b005d1ae 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -143,11 +143,6 @@ class SimpleConformanceTest { // checker limitations around optionals and legacy nullable generic candidates. "type_deductions/flexible_type_parameter_assignment/optional_none,optional_none_2,optional_dyn_promotion,optional_dyn_promotion_2,optional_in_ternary", "type_deductions/legacy_nullable_types/null_assignable_to_abstract_parameter_candidate", - // StringsLib does not implement the CEL string quote/format/reverse extensions. - "string_ext/quote", - "string_ext/format", - "string_ext/format_errors", - "string_ext/reverse", "enums/strong_proto2", "enums/strong_proto3"); diff --git a/core/src/main/java/org/projectnessie/cel/extension/StringsLib.java b/core/src/main/java/org/projectnessie/cel/extension/StringsLib.java index 5097c8eb..32056c91 100644 --- a/core/src/main/java/org/projectnessie/cel/extension/StringsLib.java +++ b/core/src/main/java/org/projectnessie/cel/extension/StringsLib.java @@ -15,6 +15,11 @@ */ package org.projectnessie.cel.extension; +import static java.math.RoundingMode.HALF_EVEN; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.projectnessie.cel.common.types.IntT.intOf; + +import java.math.BigDecimal; import java.util.*; import java.util.regex.Pattern; import org.projectnessie.cel.EnvOption; @@ -22,6 +27,10 @@ import org.projectnessie.cel.ProgramOption; import org.projectnessie.cel.checker.Decls; import org.projectnessie.cel.common.types.Err; +import org.projectnessie.cel.common.types.StringT; +import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.common.types.traits.Indexer; +import org.projectnessie.cel.common.types.traits.Sizer; import org.projectnessie.cel.interpreter.functions.Overload; /** @@ -239,10 +248,13 @@ public class StringsLib implements Library { private static final String LAST_INDEX_OF = "lastIndexOf"; private static final String LOWER_ASCII = "lowerAscii"; private static final String REPLACE = "replace"; + private static final String REVERSE = "reverse"; private static final String SPLIT = "split"; private static final String SUBSTR = "substring"; private static final String TRIM_SPACE = "trim"; private static final String UPPER_ASCII = "upperAscii"; + private static final String FORMAT = "format"; + private static final String QUOTE = "strings.quote"; // whitespace characters definition from // https://en.wikipedia.org/wiki/Whitespace_character#Unicode @@ -326,6 +338,10 @@ public List getCompileOptions() { "string_replace_string_string_int", Arrays.asList(Decls.String, Decls.String, Decls.String, Decls.Int), Decls.String)), + Decls.newFunction( + REVERSE, + Decls.newInstanceOverload( + "string_reverse", Arrays.asList(Decls.String), Decls.String)), Decls.newFunction( SPLIT, Decls.newInstanceOverload( @@ -349,7 +365,16 @@ public List getCompileOptions() { Decls.newFunction( UPPER_ASCII, Decls.newInstanceOverload( - "string_upper_ascii", Arrays.asList(Decls.String), Decls.String))); + "string_upper_ascii", Arrays.asList(Decls.String), Decls.String)), + Decls.newFunction( + FORMAT, + Decls.newInstanceOverload( + "string_format", + Arrays.asList(Decls.String, Decls.newListType(Decls.Dyn)), + Decls.String)), + Decls.newFunction( + QUOTE, + Decls.newOverload("strings_quote", Arrays.asList(Decls.String), Decls.String))); return List.of(option); } @@ -397,6 +422,7 @@ public List getProgramOptions() { } return Err.maybeNoSuchOverloadErr(null); }), + Overload.unary(REVERSE, Guards.callInStrOutStr(StringsLib::reverse)), Overload.overload( SPLIT, null, @@ -416,7 +442,9 @@ public List getProgramOptions() { ? Guards.callInStrIntIntOutStr(StringsLib::substrRange).invoke(values) : Err.maybeNoSuchOverloadErr(null)), Overload.unary(TRIM_SPACE, Guards.callInStrOutStr(StringsLib::trimSpace)), - Overload.unary(UPPER_ASCII, Guards.callInStrOutStr(StringsLib::upperASCII))); + Overload.unary(UPPER_ASCII, Guards.callInStrOutStr(StringsLib::upperASCII)), + Overload.binary(FORMAT, StringsLib::format), + Overload.unary(QUOTE, Guards.callInStrOutStr(StringsLib::quote))); return List.of(functions); } @@ -475,6 +503,51 @@ static String replace(String str, String old, String replacement) { return str.replace(old, replacement); } + static String reverse(String str) { + return new StringBuilder(str).reverse().toString(); + } + + static String quote(String str) { + StringBuilder quoted = new StringBuilder(str.length() + 2); + quoted.append('"'); + for (int offset = 0; offset < str.length(); ) { + int codePoint = str.codePointAt(offset); + offset += Character.charCount(codePoint); + switch (codePoint) { + case '\u0007': + quoted.append("\\a"); + break; + case '\b': + quoted.append("\\b"); + break; + case '\f': + quoted.append("\\f"); + break; + case '\n': + quoted.append("\\n"); + break; + case '\r': + quoted.append("\\r"); + break; + case '\t': + quoted.append("\\t"); + break; + case '\u000b': + quoted.append("\\v"); + break; + case '\\': + quoted.append("\\\\"); + break; + case '"': + quoted.append("\\\""); + break; + default: + quoted.appendCodePoint(codePoint); + } + } + return quoted.append('"').toString(); + } + /** * replace first n non-overlapping instance of {old} replaced by {replacement}. It works as strings.Replace in Go to have consistent behavior @@ -659,4 +732,274 @@ static String upperASCII(String str) { } return stringBuilder.toString(); } + + private static Val format(Val pattern, Val args) { + if (!(pattern instanceof StringT) || !(args instanceof Sizer) || !(args instanceof Indexer)) { + return Err.maybeNoSuchOverloadErr(null); + } + try { + return StringT.stringOf( + formatPattern((String) pattern.value(), (Sizer) args, (Indexer) args)); + } catch (FormatException e) { + return Err.newErr("%s", e.getMessage()); + } + } + + private static String formatPattern(String pattern, Sizer argsSizer, Indexer argsIndexer) { + int argCount = Math.toIntExact(argsSizer.size().intValue()); + int argIndex = 0; + StringBuilder out = new StringBuilder(pattern.length()); + for (int i = 0; i < pattern.length(); i++) { + char ch = pattern.charAt(i); + if (ch != '%') { + out.append(ch); + continue; + } + if (++i >= pattern.length()) { + throw new FormatException("could not parse formatting clause: missing formatting clause"); + } + ch = pattern.charAt(i); + if (ch == '%') { + out.append('%'); + continue; + } + int precision = -1; + if (ch == '.') { + int precisionStart = ++i; + while (i < pattern.length() && Character.isDigit(pattern.charAt(i))) { + i++; + } + if (precisionStart == i || i >= pattern.length()) { + throw new FormatException("could not parse formatting clause: malformed precision"); + } + precision = Integer.parseInt(pattern.substring(precisionStart, i)); + ch = pattern.charAt(i); + } + if ("sdboxXfe".indexOf(ch) < 0) { + throw new FormatException( + "could not parse formatting clause: unrecognized formatting clause \"%s\"", ch); + } + if (argIndex >= argCount) { + throw new FormatException("index %d out of range", argIndex); + } + Val arg = argsIndexer.get(intOf(argIndex++)); + out.append(formatValue(ch, precision, arg)); + } + return out.toString(); + } + + private static String formatValue(char clause, int precision, Val arg) { + switch (clause) { + case 's': + return renderStringClause(arg); + case 'd': + return renderDecimalClause(arg); + case 'b': + return renderBinaryClause(arg); + case 'o': + return renderOctalClause(arg); + case 'x': + case 'X': + return renderHexClause(arg, clause == 'X'); + case 'f': + return renderFixedPointClause(arg, precision >= 0 ? precision : 6); + case 'e': + return renderScientificClause(arg, precision >= 0 ? precision : 6); + default: + throw new FormatException( + "could not parse formatting clause: unrecognized formatting clause \"%s\"", clause); + } + } + + private static String renderStringClause(Val value) { + switch (value.type().typeEnum()) { + case String: + return value.value().toString(); + case Bool: + return Boolean.toString(value.booleanValue()); + case Bytes: + return new String(value.convertToNative(byte[].class), UTF_8); + case Int: + return Long.toString(value.intValue()); + case Uint: + return Long.toUnsignedString(value.intValue()); + case Double: + return renderDouble(value.doubleValue()); + case Null: + return "null"; + case Type: + case Duration: + case Timestamp: + return value.convertToType(StringT.StringType).value().toString(); + case List: + return renderList(value); + case Map: + return renderMap(value); + default: + throw new FormatException( + "error during formatting: string clause can only be used on strings, bools, bytes, ints, doubles, maps, lists, types, durations, and timestamps, was given %s", + value.type().typeName()); + } + } + + private static String renderDecimalClause(Val value) { + switch (value.type().typeEnum()) { + case Int: + return Long.toString(value.intValue()); + case Uint: + return Long.toUnsignedString(value.intValue()); + case Double: + double d = value.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + return renderDouble(d); + } + break; + default: + } + throw new FormatException( + "error during formatting: decimal clause can only be used on integers, was given %s", + value.type().typeName()); + } + + private static String renderBinaryClause(Val value) { + switch (value.type().typeEnum()) { + case Int: + return Long.toBinaryString(value.intValue()); + case Uint: + return Long.toUnsignedString(value.intValue(), 2); + case Bool: + return value.booleanValue() ? "1" : "0"; + default: + throw new FormatException( + "error during formatting: only integers and bools can be formatted as binary, was given %s", + value.type().typeName()); + } + } + + private static String renderOctalClause(Val value) { + switch (value.type().typeEnum()) { + case Int: + return Long.toOctalString(value.intValue()); + case Uint: + return Long.toUnsignedString(value.intValue(), 8); + default: + throw new FormatException( + "error during formatting: octal clause can only be used on integers, was given %s", + value.type().typeName()); + } + } + + private static String renderHexClause(Val value, boolean upperCase) { + String hex; + switch (value.type().typeEnum()) { + case Int: + hex = Long.toHexString(value.intValue()); + break; + case Uint: + hex = Long.toUnsignedString(value.intValue(), 16); + break; + case String: + hex = bytesToHex(value.value().toString().getBytes(UTF_8)); + break; + case Bytes: + hex = bytesToHex(value.convertToNative(byte[].class)); + break; + default: + throw new FormatException( + "error during formatting: only integers, byte buffers, and strings can be formatted as hex, was given %s", + value.type().typeName()); + } + return upperCase ? hex.toUpperCase(Locale.ROOT) : hex; + } + + private static String renderFixedPointClause(Val value, int precision) { + switch (value.type().typeEnum()) { + case Int: + case Uint: + case Double: + double d = value.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + return renderDouble(d); + } + return BigDecimal.valueOf(d).setScale(precision, HALF_EVEN).toPlainString(); + default: + throw new FormatException( + "error during formatting: fixed-point clause can only be used on doubles, was given %s", + value.type().typeName()); + } + } + + private static String renderScientificClause(Val value, int precision) { + switch (value.type().typeEnum()) { + case Int: + case Uint: + case Double: + double d = value.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + return renderDouble(d); + } + return String.format(Locale.ROOT, "%." + precision + "e", d); + default: + throw new FormatException( + "error during formatting: scientific clause can only be used on doubles, was given %s", + value.type().typeName()); + } + } + + private static String renderList(Val value) { + Sizer sizer = (Sizer) value; + Indexer indexer = (Indexer) value; + int size = Math.toIntExact(sizer.size().intValue()); + StringBuilder out = new StringBuilder("["); + for (int i = 0; i < size; i++) { + if (i > 0) { + out.append(", "); + } + out.append(renderStringClause(indexer.get(intOf(i)))); + } + return out.append(']').toString(); + } + + private static String renderMap(Val value) { + org.projectnessie.cel.common.types.IteratorT iterator = + ((org.projectnessie.cel.common.types.IterableT) value).iterator(); + Indexer indexer = (Indexer) value; + List entries = new ArrayList<>(); + while (iterator.hasNext().booleanValue()) { + Val key = iterator.next(); + entries.add(renderStringClause(key) + ": " + renderStringClause(indexer.get(key))); + } + Collections.sort(entries); + return "{" + String.join(", ", entries) + "}"; + } + + private static String renderDouble(double value) { + if (Double.isNaN(value)) { + return "NaN"; + } + if (value == Double.POSITIVE_INFINITY) { + return "Infinity"; + } + if (value == Double.NEGATIVE_INFINITY) { + return "-Infinity"; + } + return Double.toString(value); + } + + private static String bytesToHex(byte[] bytes) { + char[] hex = new char[bytes.length * 2]; + char[] digits = "0123456789abcdef".toCharArray(); + for (int i = 0; i < bytes.length; i++) { + int b = bytes[i] & 0xff; + hex[i * 2] = digits[b >>> 4]; + hex[i * 2 + 1] = digits[b & 0x0f]; + } + return new String(hex); + } + + private static final class FormatException extends RuntimeException { + FormatException(String message, Object... args) { + super(String.format(Locale.ROOT, message, args)); + } + } } diff --git a/core/src/main/java/org/projectnessie/cel/parser/Unescape.java b/core/src/main/java/org/projectnessie/cel/parser/Unescape.java index dc260f3f..a6a8ef35 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/Unescape.java +++ b/core/src/main/java/org/projectnessie/cel/parser/Unescape.java @@ -90,7 +90,7 @@ public static ByteBuffer unescape(String value, boolean isBytes) { } // Otherwise the string contains escape characters. - ByteBuffer buf = ByteBuffer.allocate(value.length() * 3 / 2); + ByteBuffer buf = ByteBuffer.allocate(value.length() * 4); for (int i = 0; i < n; i++) { char c = value.charAt(i); if (c == '\\') { @@ -215,7 +215,11 @@ public static ByteBuffer unescape(String value, boolean isBytes) { } else { // not an escape sequence if (!isBytes) { - encodeCodePoint(buf, c, cb, enc); + int codePoint = value.codePointAt(i); + encodeCodePoint(buf, codePoint, cb, enc); + if (Character.charCount(codePoint) == 2) { + i++; + } } else { buf.put((byte) c); } diff --git a/core/src/test/java/org/projectnessie/cel/extension/StringsTest.java b/core/src/test/java/org/projectnessie/cel/extension/StringsTest.java index d252e2f5..58a38279 100644 --- a/core/src/test/java/org/projectnessie/cel/extension/StringsTest.java +++ b/core/src/test/java/org/projectnessie/cel/extension/StringsTest.java @@ -111,6 +111,12 @@ static Stream testCases() { new TestData("[].join('-') == ''"), new TestData("['x'].join() == 'x'"), new TestData("['x'].join('-') == 'x'"), + new TestData("strings.quote(\"first\\nsecond\") == \"\\\"first\\\\nsecond\\\"\""), + new TestData("strings.quote(\"printable unicode😀\") == \"\\\"printable unicode😀\\\"\""), + new TestData("'Ta©oCαt'.reverse() == 'tαCo©aT'"), + new TestData("\"%d %s %.0f\".format([1, \"two\", 2.5]) == \"1 two 2\""), + new TestData("\"%x\".format([\"Hello world!\"]) == \"48656c6c6f20776f726c6421\""), + new TestData("\"%s\".format([[\"abc\", 3.14, null]]) == \"[abc, 3.14, null]\""), // Error test cases based on checked expression usage. new TestData("'tacocat'.indexOf('a', 30) == -1", "String index out of range: 30"), @@ -175,7 +181,10 @@ static Stream testCases() { new TestData("'hello'.substring(1, 2, 3) == \"\"", "no matching overload", true), new TestData("30.substring(true, 3) == \"\"", "no matching overload", true), new TestData("\"tacocat\".substring(true, 3) == \"\"", "no matching overload", true), - new TestData("\"tacocat\".substring(0, false) == \"\"", "no matching overload", true)); + new TestData("\"tacocat\".substring(0, false) == \"\"", "no matching overload", true), + new TestData( + "\"%a\".format([1])", + "could not parse formatting clause: unrecognized formatting clause \"a\"")); } @Test diff --git a/core/src/test/java/org/projectnessie/cel/parser/UnescapeTest.java b/core/src/test/java/org/projectnessie/cel/parser/UnescapeTest.java index 3f8cf262..6d8a3223 100644 --- a/core/src/test/java/org/projectnessie/cel/parser/UnescapeTest.java +++ b/core/src/test/java/org/projectnessie/cel/parser/UnescapeTest.java @@ -47,6 +47,12 @@ void unescapeEscapedQuote() { assertThat(text).isEqualTo("\\\""); } + @Test + void unescapeNonBmpUnicodeInEscapedString() { + String text = utf8(unescape("\"\\\"printable unicode😀\\\"\"", false)); + assertThat(text).isEqualTo("\"printable unicode😀\""); + } + @Test void unescapeEscapedEscape() { String text = utf8(unescape("\"\\\\\"", false)); From 34e0fadcec07ad57cc5925c0f336313642584058 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 16:31:30 +0200 Subject: [PATCH 24/33] Add optional type-check declarations The CEL-Spec type-deduction suite uses optional.none and optional.of in check-only cases. Those expressions could not type-check because the optional helper functions were not declared. Add an opt-in optional library with compile-time declarations for the optional helper functions, and use it in the conformance harness for optional expressions. This enables optional type deduction without adding runtime optional semantics. --- .../conformance/SimpleConformanceTest.java | 11 +-- .../cel/extension/OptionalLib.java | 76 +++++++++++++++++++ .../cel/extension/OptionalLibTest.java | 64 ++++++++++++++++ 3 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 core/src/main/java/org/projectnessie/cel/extension/OptionalLib.java create mode 100644 core/src/test/java/org/projectnessie/cel/extension/OptionalLibTest.java diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index b005d1ae..3b57b740 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -43,6 +43,7 @@ import static org.projectnessie.cel.common.types.UintT.uintOf; import static org.projectnessie.cel.common.types.UnknownT.isUnknown; import static org.projectnessie.cel.common.types.UnknownT.unknownOf; +import static org.projectnessie.cel.extension.OptionalLib.optionals; import static org.projectnessie.cel.extension.ProtoLib.proto; import static org.projectnessie.cel.extension.StringsLib.strings; @@ -138,11 +139,8 @@ class SimpleConformanceTest { SkipList.parse( // Malicious too-deep protobuf structure. "parse/nest/message_literal", - // New CEL-Spec v0.25.2 expectations that need follow-up parser/runtime changes. - // type_deduction.textproto coverage was added opportunistically. The remaining skips are - // checker limitations around optionals and legacy nullable generic candidates. - "type_deductions/flexible_type_parameter_assignment/optional_none,optional_none_2,optional_dyn_promotion,optional_dyn_promotion_2,optional_in_ternary", - "type_deductions/legacy_nullable_types/null_assignable_to_abstract_parameter_candidate", + // Strong enum semantics require typed enum values rather than treating enum literals as + // ints. "enums/strong_proto2", "enums/strong_proto3"); @@ -374,6 +372,9 @@ private static List conformanceEnvOptions(SimpleTest test, EnvOption. if (usesStringExtensions(test.getExpr())) { envOptions.add(strings()); } + if (test.getExpr().contains("optional.")) { + envOptions.add(optionals()); + } envOptions.addAll(List.of(options)); return envOptions; } diff --git a/core/src/main/java/org/projectnessie/cel/extension/OptionalLib.java b/core/src/main/java/org/projectnessie/cel/extension/OptionalLib.java new file mode 100644 index 00000000..0fb18242 --- /dev/null +++ b/core/src/main/java/org/projectnessie/cel/extension/OptionalLib.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.projectnessie.cel.extension; + +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; + +import java.util.List; +import org.projectnessie.cel.EnvOption; +import org.projectnessie.cel.Library; +import org.projectnessie.cel.ProgramOption; +import org.projectnessie.cel.checker.Decls; + +/** + * OptionalLib provides compile-time declarations for CEL optional helper functions. + * + *

The current implementation intentionally exposes type-checking support only. It is sufficient + * for check-only conformance cases that exercise optional type deduction, but it does not provide + * runtime optional values or optional-selection semantics. + */ +public final class OptionalLib implements Library { + private static final String OPTIONAL_TYPE = "optional_type"; + private static final String OPTIONAL_NONE = "optional.none"; + private static final String OPTIONAL_OF = "optional.of"; + private static final String OPTIONAL_OF_NON_ZERO_VALUE = "optional.ofNonZeroValue"; + private static final String TYPE_PARAM_A = "A"; + + private OptionalLib() {} + + public static EnvOption optionals() { + return Library.Lib(new OptionalLib()); + } + + @Override + public List getCompileOptions() { + var typeParamA = Decls.newTypeParamType(TYPE_PARAM_A); + var optionalA = Decls.newAbstractType(OPTIONAL_TYPE, singletonList(typeParamA)); + var typeParams = singletonList(TYPE_PARAM_A); + + return List.of( + EnvOption.declarations( + Decls.newFunction( + OPTIONAL_NONE, + Decls.newParameterizedOverload( + "optional_none", emptyList(), optionalA, typeParams)), + Decls.newFunction( + OPTIONAL_OF, + Decls.newParameterizedOverload( + "optional_of", singletonList(typeParamA), optionalA, typeParams)), + Decls.newFunction( + OPTIONAL_OF_NON_ZERO_VALUE, + Decls.newParameterizedOverload( + "optional_of_non_zero_value", + singletonList(typeParamA), + optionalA, + typeParams)))); + } + + @Override + public List getProgramOptions() { + return emptyList(); + } +} diff --git a/core/src/test/java/org/projectnessie/cel/extension/OptionalLibTest.java b/core/src/test/java/org/projectnessie/cel/extension/OptionalLibTest.java new file mode 100644 index 00000000..9fd7fef2 --- /dev/null +++ b/core/src/test/java/org/projectnessie/cel/extension/OptionalLibTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.projectnessie.cel.extension; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.projectnessie.cel.Env.newEnv; +import static org.projectnessie.cel.extension.OptionalLib.optionals; + +import com.google.api.expr.v1alpha1.Type; +import org.junit.jupiter.api.Test; +import org.projectnessie.cel.Env; +import org.projectnessie.cel.checker.Decls; + +class OptionalLibTest { + + @Test + void declaresOptionalNone() { + assertCheckedType("[optional.none(), optional.of(1)]", Decls.newListType(optional(Decls.Int))); + } + + @Test + void promotesOptionalTypeParameterToDyn() { + assertCheckedType( + "[optional.of(1), optional.of(dyn(1))]", Decls.newListType(optional(Decls.Dyn))); + } + + @Test + void promotesTernaryOptionalTypeParameterToDyn() { + assertCheckedType("true ? optional.of(dyn(1)) : optional.of(1)", optional(Decls.Dyn)); + } + + @Test + void keepsNullableOptionalType() { + assertCheckedType("[optional.of(1), null][0]", optional(Decls.Int)); + } + + private static void assertCheckedType(String expression, Type expectedType) { + Env env = newEnv(optionals()); + Env.AstIssuesTuple parsed = env.parse(expression); + assertThat(parsed.hasIssues()).isFalse(); + + Env.AstIssuesTuple checked = env.check(parsed.getAst()); + assertThat(checked.hasIssues()).isFalse(); + assertThat(checked.getAst().getResultType()).isEqualTo(expectedType); + } + + private static Type optional(Type type) { + return Decls.newAbstractType("optional_type", singletonList(type)); + } +} From 6fd9df09658b2946bf720803e58b80f0cf7f2d33 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 16:33:33 +0200 Subject: [PATCH 25/33] Narrow strong enum conformance skips The strong enum sections contain a mix of unsupported typed-enum semantics and cases that already work with the current int-backed enum behavior. Replace the broad section skips with targeted skips for the remaining typed-enum gaps, and convert conformance enum_value bindings to integer values so disabled-check conversion cases can run. --- .../conformance/SimpleConformanceTest.java | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 3b57b740..efe377e7 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -141,8 +141,41 @@ class SimpleConformanceTest { "parse/nest/message_literal", // Strong enum semantics require typed enum values rather than treating enum literals as // ints. - "enums/strong_proto2", - "enums/strong_proto3"); + "enums/strong_proto2/literal_global", + "enums/strong_proto2/literal_nested", + "enums/strong_proto2/literal_zero", + "enums/strong_proto2/type_global", + "enums/strong_proto2/type_nested", + "enums/strong_proto2/select_default", + "enums/strong_proto2/field_type", + "enums/strong_proto2/assign_standalone_int", + "enums/strong_proto2/convert_int_inrange", + "enums/strong_proto2/convert_int_big", + "enums/strong_proto2/convert_int_neg", + "enums/strong_proto2/convert_int_too_big", + "enums/strong_proto2/convert_int_too_neg", + "enums/strong_proto2/convert_string", + "enums/strong_proto2/convert_string_bad", + "enums/strong_proto3/literal_global", + "enums/strong_proto3/literal_nested", + "enums/strong_proto3/literal_zero", + "enums/strong_proto3/type_global", + "enums/strong_proto3/type_nested", + "enums/strong_proto3/select_default", + "enums/strong_proto3/select", + "enums/strong_proto3/select_big", + "enums/strong_proto3/select_neg", + "enums/strong_proto3/field_type", + "enums/strong_proto3/assign_standalone_int", + "enums/strong_proto3/assign_standalone_int_big", + "enums/strong_proto3/assign_standalone_int_neg", + "enums/strong_proto3/convert_int_inrange", + "enums/strong_proto3/convert_int_big", + "enums/strong_proto3/convert_int_neg", + "enums/strong_proto3/convert_int_too_big", + "enums/strong_proto3/convert_int_too_neg", + "enums/strong_proto3/convert_string", + "enums/strong_proto3/convert_string_bad"); private static final Set matchedSkips = new LinkedHashSet<>(); private static final AtomicInteger total = new AtomicInteger(); @@ -568,6 +601,8 @@ private static Val valueToRefValue(TypeAdapter adapter, Value v) { return type; } return newObjectTypeValue(typeName); + case ENUM_VALUE: + return intOf(v.getEnumValue().getValue()); default: throw new IllegalArgumentException("unknown value " + v.getKindCase()); } From 089cc18fa6a5077aa327ab536363a1bf6034c253 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 16:34:29 +0200 Subject: [PATCH 26/33] Re-enable deep message literal conformance The nested protobuf message literal case now passes under the current conformance runner and evaluator. Remove the stale skip so the parse nesting coverage runs with the rest of the CEL-Spec simple suite. --- .../projectnessie/cel/conformance/SimpleConformanceTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index efe377e7..59c4d49f 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -137,8 +137,6 @@ class SimpleConformanceTest { private static final Set SKIP_TESTS = SkipList.parse( - // Malicious too-deep protobuf structure. - "parse/nest/message_literal", // Strong enum semantics require typed enum values rather than treating enum literals as // ints. "enums/strong_proto2/literal_global", From d444c92aacdf31baa0ebc7663a534c801a460720 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 16:41:59 +0200 Subject: [PATCH 27/33] Add encoder extension conformance The CEL-Spec encoder extension suite covers Base64 encode and decode helpers, including decode input without padding. Add an opt-in encoder extension library with base64.encode and base64.decode, wire it into the conformance harness for base64 expressions, and include direct core coverage for successful and invalid decode inputs. --- .../conformance/SimpleConformanceTest.java | 5 + .../cel/extension/EncodersLib.java | 91 +++++++++++++++++++ .../cel/extension/EncodersLibTest.java | 71 +++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 core/src/main/java/org/projectnessie/cel/extension/EncodersLib.java create mode 100644 core/src/test/java/org/projectnessie/cel/extension/EncodersLibTest.java diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 59c4d49f..6ce3df64 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -43,6 +43,7 @@ import static org.projectnessie.cel.common.types.UintT.uintOf; import static org.projectnessie.cel.common.types.UnknownT.isUnknown; import static org.projectnessie.cel.common.types.UnknownT.unknownOf; +import static org.projectnessie.cel.extension.EncodersLib.encoders; import static org.projectnessie.cel.extension.OptionalLib.optionals; import static org.projectnessie.cel.extension.ProtoLib.proto; import static org.projectnessie.cel.extension.StringsLib.strings; @@ -115,6 +116,7 @@ class SimpleConformanceTest { "comparisons.textproto", "conversions.textproto", "dynamic.textproto", + "encoders_ext.textproto", "enums.textproto", "fields.textproto", "fp_math.textproto", @@ -403,6 +405,9 @@ private static List conformanceEnvOptions(SimpleTest test, EnvOption. if (usesStringExtensions(test.getExpr())) { envOptions.add(strings()); } + if (test.getExpr().contains("base64.")) { + envOptions.add(encoders()); + } if (test.getExpr().contains("optional.")) { envOptions.add(optionals()); } diff --git a/core/src/main/java/org/projectnessie/cel/extension/EncodersLib.java b/core/src/main/java/org/projectnessie/cel/extension/EncodersLib.java new file mode 100644 index 00000000..2f3972aa --- /dev/null +++ b/core/src/main/java/org/projectnessie/cel/extension/EncodersLib.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.projectnessie.cel.extension; + +import static java.util.Collections.singletonList; +import static org.projectnessie.cel.common.types.BytesT.bytesOf; +import static org.projectnessie.cel.common.types.Err.newErr; +import static org.projectnessie.cel.common.types.Err.noSuchOverload; +import static org.projectnessie.cel.common.types.StringT.stringOf; + +import java.util.Base64; +import java.util.List; +import org.projectnessie.cel.EnvOption; +import org.projectnessie.cel.Library; +import org.projectnessie.cel.ProgramOption; +import org.projectnessie.cel.checker.Decls; +import org.projectnessie.cel.common.types.BytesT; +import org.projectnessie.cel.common.types.StringT; +import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.interpreter.functions.Overload; + +/** EncodersLib provides CEL helper functions for common binary-to-text encodings. */ +public final class EncodersLib implements Library { + private static final String BASE64_ENCODE = "base64.encode"; + private static final String BASE64_DECODE = "base64.decode"; + private static final String BASE64_ENCODE_OVERLOAD = "base64_encode_bytes"; + private static final String BASE64_DECODE_OVERLOAD = "base64_decode_string"; + + private EncodersLib() {} + + public static EnvOption encoders() { + return Library.Lib(new EncodersLib()); + } + + @Override + public List getCompileOptions() { + return List.of( + EnvOption.declarations( + Decls.newFunction( + BASE64_ENCODE, + Decls.newOverload( + BASE64_ENCODE_OVERLOAD, singletonList(Decls.Bytes), Decls.String)), + Decls.newFunction( + BASE64_DECODE, + Decls.newOverload( + BASE64_DECODE_OVERLOAD, singletonList(Decls.String), Decls.Bytes)))); + } + + @Override + public List getProgramOptions() { + return List.of( + ProgramOption.functions( + Overload.unary(BASE64_ENCODE, EncodersLib::base64Encode), + Overload.unary(BASE64_ENCODE_OVERLOAD, EncodersLib::base64Encode), + Overload.unary(BASE64_DECODE, EncodersLib::base64Decode), + Overload.unary(BASE64_DECODE_OVERLOAD, EncodersLib::base64Decode))); + } + + private static Val base64Encode(Val value) { + if (!(value instanceof BytesT)) { + return noSuchOverload(value, BASE64_ENCODE, BASE64_ENCODE_OVERLOAD, new Val[] {value}); + } + byte[] bytes = value.convertToNative(byte[].class); + return stringOf(Base64.getEncoder().encodeToString(bytes)); + } + + private static Val base64Decode(Val value) { + if (!(value instanceof StringT)) { + return noSuchOverload(value, BASE64_DECODE, BASE64_DECODE_OVERLOAD, new Val[] {value}); + } + String text = value.convertToNative(String.class); + try { + return bytesOf(Base64.getDecoder().decode(text)); + } catch (IllegalArgumentException e) { + return newErr(e, "invalid base64 string"); + } + } +} diff --git a/core/src/test/java/org/projectnessie/cel/extension/EncodersLibTest.java b/core/src/test/java/org/projectnessie/cel/extension/EncodersLibTest.java new file mode 100644 index 00000000..b2577568 --- /dev/null +++ b/core/src/test/java/org/projectnessie/cel/extension/EncodersLibTest.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.projectnessie.cel.extension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.projectnessie.cel.Env.newEnv; +import static org.projectnessie.cel.common.types.BytesT.bytesOf; +import static org.projectnessie.cel.common.types.StringT.stringOf; +import static org.projectnessie.cel.extension.EncodersLib.encoders; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.projectnessie.cel.Env; +import org.projectnessie.cel.Program; +import org.projectnessie.cel.Program.EvalResult; +import org.projectnessie.cel.common.types.Err; + +class EncodersLibTest { + + @Test + void encodesBase64Bytes() { + assertEvaluates("base64.encode(b'hello')", stringOf("aGVsbG8=")); + } + + @Test + void decodesBase64String() { + assertEvaluates("base64.decode('aGVsbG8=')", bytesOf("hello")); + } + + @Test + void decodesBase64StringWithoutPadding() { + assertEvaluates("base64.decode('aGVsbG8')", bytesOf("hello")); + } + + @Test + void rejectsInvalidBase64String() { + EvalResult result = evaluate("base64.decode('not valid base64')"); + + assertThat(result.getVal()).isInstanceOf(Err.class); + assertThat(result.getVal().toString()).contains("invalid base64 string"); + } + + private static void assertEvaluates(String expression, Object expectedValue) { + assertThat(evaluate(expression).getVal()).isEqualTo(expectedValue); + } + + private static EvalResult evaluate(String expression) { + Env env = newEnv(encoders()); + Env.AstIssuesTuple parsed = env.parse(expression); + assertThat(parsed.hasIssues()).isFalse(); + + Env.AstIssuesTuple checked = env.check(parsed.getAst()); + assertThat(checked.hasIssues()).isFalse(); + + Program program = env.program(checked.getAst()); + return program.eval(Map.of()); + } +} From 9c9b0ac8f73b2de8ef6283a42d76191826dcd156 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 16:48:19 +0200 Subject: [PATCH 28/33] Add binding macro conformance The CEL-Spec bindings extension uses cel.bind to introduce a local variable in the result expression. CEL-Java parsed those expressions as ordinary calls, so the checker reported undeclared local variables. Add cel.bind as a standard macro, expanding it to a single-iteration comprehension with a dynamic accumulator initializer. Add direct macro coverage and include bindings_ext.textproto in the conformance suite. --- .../conformance/SimpleConformanceTest.java | 1 + .../projectnessie/cel/parser/ExprHelper.java | 3 ++ .../cel/parser/ExprHelperImpl.java | 6 ++++ .../org/projectnessie/cel/parser/Helper.java | 5 +++ .../org/projectnessie/cel/parser/Macro.java | 33 ++++++++++++++++++- .../java/org/projectnessie/cel/CELTest.java | 20 +++++++++++ 6 files changed, 67 insertions(+), 1 deletion(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 6ce3df64..b1dd9c69 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -113,6 +113,7 @@ class SimpleConformanceTest { private static final List TEST_FILES = List.of( "basic.textproto", + "bindings_ext.textproto", "comparisons.textproto", "conversions.textproto", "dynamic.textproto", diff --git a/core/src/main/java/org/projectnessie/cel/parser/ExprHelper.java b/core/src/main/java/org/projectnessie/cel/parser/ExprHelper.java index 286e3e32..037b1901 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/ExprHelper.java +++ b/core/src/main/java/org/projectnessie/cel/parser/ExprHelper.java @@ -40,6 +40,9 @@ public interface ExprHelper { /** LiteralInt creates an Expr value for an int literal. */ Expr literalInt(long value); + /** LiteralNull creates an Expr value for a null literal. */ + Expr literalNull(); + /** LiteralString creates am Expr value for a string literal. */ Expr literalString(String value); diff --git a/core/src/main/java/org/projectnessie/cel/parser/ExprHelperImpl.java b/core/src/main/java/org/projectnessie/cel/parser/ExprHelperImpl.java index 457a576f..0c4bf00e 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/ExprHelperImpl.java +++ b/core/src/main/java/org/projectnessie/cel/parser/ExprHelperImpl.java @@ -60,6 +60,12 @@ public Expr literalInt(long value) { return parserHelper.newLiteralInt(nextMacroID(), value); } + // LiteralNull implements the ExprHelper interface method. + @Override + public Expr literalNull() { + return parserHelper.newLiteralNull(nextMacroID()); + } + // LiteralString implements the ExprHelper interface method. @Override public Expr literalString(String value) { diff --git a/core/src/main/java/org/projectnessie/cel/parser/Helper.java b/core/src/main/java/org/projectnessie/cel/parser/Helper.java index edea6552..9f5d447a 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/Helper.java +++ b/core/src/main/java/org/projectnessie/cel/parser/Helper.java @@ -27,6 +27,7 @@ import com.google.api.expr.v1alpha1.Expr.Select; import com.google.api.expr.v1alpha1.SourceInfo; import com.google.protobuf.ByteString; +import com.google.protobuf.NullValue; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -77,6 +78,10 @@ Expr newLiteralInt(Object ctx, long value) { return newLiteral(ctx, Constant.newBuilder().setInt64Value(value)); } + Expr newLiteralNull(Object ctx) { + return newLiteral(ctx, Constant.newBuilder().setNullValue(NullValue.NULL_VALUE)); + } + Expr newLiteralUint(Object ctx, long value) { return newLiteral(ctx, Constant.newBuilder().setUint64Value(value)); } diff --git a/core/src/main/java/org/projectnessie/cel/parser/Macro.java b/core/src/main/java/org/projectnessie/cel/parser/Macro.java index ba507490..5bb4042b 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/Macro.java +++ b/core/src/main/java/org/projectnessie/cel/parser/Macro.java @@ -26,6 +26,7 @@ import org.projectnessie.cel.common.ErrorWithLocation; import org.projectnessie.cel.common.Location; import org.projectnessie.cel.common.operators.Operator; +import org.projectnessie.cel.common.types.Overloads; public final class Macro { /** AccumulatorName is the traditional variable name assigned to the fold accumulator variable. */ @@ -65,7 +66,10 @@ public final class Macro { /* The macro "range.filter(var, predicate)", filters out the variables for which the * predicate is false. */ - newReceiverMacro(Operator.Filter.id, 2, Macro::makeFilter)); + newReceiverMacro(Operator.Filter.id, 2, Macro::makeFilter), + + /* The macro "cel.bind(var, value, result)" binds a local variable for use in result. */ + newReceiverMacro("bind", 3, Macro::makeBind)); /** NoMacros list. */ public static List MoMacros = emptyList(); @@ -237,6 +241,33 @@ static Expr makeFilter(ExprHelper eh, Expr target, List args) { return eh.fold(v, target, AccumulatorName, init, condition, step, accuExpr); } + static Expr makeBind(ExprHelper eh, Expr target, List args) { + if (target == null + || target.getExprKindCase() != ExprKindCase.IDENT_EXPR + || !"cel".equals(target.getIdentExpr().getName())) { + Location location = target != null ? eh.offsetLocation(target.getId()) : null; + throw new ErrorWithLocation( + location, "cel.bind() must be called with receiver identifier cel"); + } + + String v = extractIdent(args.get(0)); + if (v == null) { + Location location = eh.offsetLocation(args.get(0).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + + Expr accuExpr = eh.ident(AccumulatorName); + Expr dynNull = eh.globalCall(Overloads.TypeConvertDyn, eh.literalNull()); + return eh.fold( + v, + eh.newList(args.get(1)), + AccumulatorName, + dynNull, + eh.literalBool(true), + args.get(2), + accuExpr); + } + static String extractIdent(Expr e) { if (e.getExprKindCase() == ExprKindCase.IDENT_EXPR) { return e.getIdentExpr().getName(); diff --git a/core/src/test/java/org/projectnessie/cel/CELTest.java b/core/src/test/java/org/projectnessie/cel/CELTest.java index 78131d3c..94506fa8 100644 --- a/core/src/test/java/org/projectnessie/cel/CELTest.java +++ b/core/src/test/java/org/projectnessie/cel/CELTest.java @@ -151,6 +151,26 @@ void comprehensionLocalVariablesShadowNamespacedIdentifiers() { .isSameAs(True); } + @Test + void bindMacroIntroducesLocalVariable() { + Env env = + newEnv(container("com.example"), declarations(Decls.newVar("com.example.x", Decls.Int))); + + AstIssuesTuple astIss = env.compile("cel.bind(x, 1, x + 1)"); + assertThat(astIss.hasIssues()).isFalse(); + assertThat( + env.program(astIss.getAst()) + .eval(mapOf("com.example.x", 42L)) + .getVal() + .equal(IntT.intOf(2))) + .isSameAs(True); + + astIss = env.compile("cel.bind(x, {'y': 0}, x.y == 0)"); + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(mapOf("com.example.x.y", 42L)).getVal()) + .isSameAs(True); + } + @Test void AstToString() { Env stdEnv = newEnv(); From 62b74bdcaac2c40fefb8c1d21aaaf2f7cfa02515 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 17:00:14 +0200 Subject: [PATCH 29/33] Add math extension conformance The CEL-Spec math extension suite exercises numeric helpers such as min/max, rounding, finite checks, and bitwise operations. CEL-Java did not expose those extension functions, so the suite could not be enabled. Add an opt-in math extension library, wire it into the conformance harness for math expressions, and include direct coverage for representative numeric, rounding, and bitwise cases. --- .../conformance/SimpleConformanceTest.java | 5 + .../projectnessie/cel/extension/MathLib.java | 432 ++++++++++++++++++ .../cel/extension/MathLibTest.java | 77 ++++ 3 files changed, 514 insertions(+) create mode 100644 core/src/main/java/org/projectnessie/cel/extension/MathLib.java create mode 100644 core/src/test/java/org/projectnessie/cel/extension/MathLibTest.java diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index b1dd9c69..f0a2c3b0 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -44,6 +44,7 @@ import static org.projectnessie.cel.common.types.UnknownT.isUnknown; import static org.projectnessie.cel.common.types.UnknownT.unknownOf; import static org.projectnessie.cel.extension.EncodersLib.encoders; +import static org.projectnessie.cel.extension.MathLib.math; import static org.projectnessie.cel.extension.OptionalLib.optionals; import static org.projectnessie.cel.extension.ProtoLib.proto; import static org.projectnessie.cel.extension.StringsLib.strings; @@ -125,6 +126,7 @@ class SimpleConformanceTest { "lists.textproto", "logic.textproto", "macros.textproto", + "math_ext.textproto", "namespace.textproto", "parse.textproto", "plumbing.textproto", @@ -409,6 +411,9 @@ private static List conformanceEnvOptions(SimpleTest test, EnvOption. if (test.getExpr().contains("base64.")) { envOptions.add(encoders()); } + if (test.getExpr().contains("math.")) { + envOptions.add(math()); + } if (test.getExpr().contains("optional.")) { envOptions.add(optionals()); } diff --git a/core/src/main/java/org/projectnessie/cel/extension/MathLib.java b/core/src/main/java/org/projectnessie/cel/extension/MathLib.java new file mode 100644 index 00000000..0c801e10 --- /dev/null +++ b/core/src/main/java/org/projectnessie/cel/extension/MathLib.java @@ -0,0 +1,432 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.projectnessie.cel.extension; + +import static org.projectnessie.cel.common.types.BoolT.False; +import static org.projectnessie.cel.common.types.BoolT.True; +import static org.projectnessie.cel.common.types.DoubleT.doubleOf; +import static org.projectnessie.cel.common.types.Err.errIntOverflow; +import static org.projectnessie.cel.common.types.Err.newErr; +import static org.projectnessie.cel.common.types.IntT.intOf; +import static org.projectnessie.cel.common.types.UintT.uintOf; + +import com.google.api.expr.v1alpha1.Decl; +import com.google.api.expr.v1alpha1.Type; +import java.util.ArrayList; +import java.util.List; +import org.projectnessie.cel.EnvOption; +import org.projectnessie.cel.Library; +import org.projectnessie.cel.ProgramOption; +import org.projectnessie.cel.checker.Decls; +import org.projectnessie.cel.common.types.DoubleT; +import org.projectnessie.cel.common.types.IntT; +import org.projectnessie.cel.common.types.UintT; +import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.common.types.traits.Lister; +import org.projectnessie.cel.interpreter.functions.Overload; + +/** MathLib provides CEL helper functions from the standard math extension library. */ +public final class MathLib implements Library { + private static final String GREATEST = "math.greatest"; + private static final String LEAST = "math.least"; + private static final String CEIL = "math.ceil"; + private static final String FLOOR = "math.floor"; + private static final String ROUND = "math.round"; + private static final String TRUNC = "math.trunc"; + private static final String ABS = "math.abs"; + private static final String SIGN = "math.sign"; + private static final String IS_NAN = "math.isNaN"; + private static final String IS_INF = "math.isInf"; + private static final String IS_FINITE = "math.isFinite"; + private static final String BIT_AND = "math.bitAnd"; + private static final String BIT_OR = "math.bitOr"; + private static final String BIT_XOR = "math.bitXor"; + private static final String BIT_NOT = "math.bitNot"; + private static final String BIT_SHIFT_LEFT = "math.bitShiftLeft"; + private static final String BIT_SHIFT_RIGHT = "math.bitShiftRight"; + + private MathLib() {} + + public static EnvOption math() { + return Library.Lib(new MathLib()); + } + + @Override + public List getCompileOptions() { + List declarations = new ArrayList<>(); + declarations.add(minMaxDeclaration(GREATEST)); + declarations.add(minMaxDeclaration(LEAST)); + declarations.add(unaryDeclaration(CEIL, Decls.Double)); + declarations.add(unaryDeclaration(FLOOR, Decls.Double)); + declarations.add(unaryDeclaration(ROUND, Decls.Double)); + declarations.add(unaryDeclaration(TRUNC, Decls.Double)); + declarations.add(unaryDeclaration(ABS, Decls.Dyn)); + declarations.add(unaryDeclaration(SIGN, Decls.Dyn)); + declarations.add(unaryDeclaration(IS_NAN, Decls.Bool)); + declarations.add(unaryDeclaration(IS_INF, Decls.Bool)); + declarations.add(unaryDeclaration(IS_FINITE, Decls.Bool)); + declarations.add(binaryDeclaration(BIT_AND, Decls.Dyn)); + declarations.add(binaryDeclaration(BIT_OR, Decls.Dyn)); + declarations.add(binaryDeclaration(BIT_XOR, Decls.Dyn)); + declarations.add(unaryDeclaration(BIT_NOT, Decls.Dyn)); + declarations.add(binaryDeclaration(BIT_SHIFT_LEFT, Decls.Dyn)); + declarations.add(binaryDeclaration(BIT_SHIFT_RIGHT, Decls.Dyn)); + return List.of(EnvOption.declarations(declarations)); + } + + @Override + public List getProgramOptions() { + List overloads = new ArrayList<>(); + overloads.add( + Overload.overload(GREATEST, null, MathLib::greatest, MathLib::greatest, MathLib::greatest)); + overloads.add(Overload.overload(LEAST, null, MathLib::least, MathLib::least, MathLib::least)); + addArityOverloads(overloads, GREATEST, MathLib::greatest); + addArityOverloads(overloads, LEAST, MathLib::least); + overloads.add(Overload.unary(CEIL, MathLib::ceil)); + overloads.add(Overload.unary(overloadId(CEIL, 1), MathLib::ceil)); + overloads.add(Overload.unary(FLOOR, MathLib::floor)); + overloads.add(Overload.unary(overloadId(FLOOR, 1), MathLib::floor)); + overloads.add(Overload.unary(ROUND, MathLib::round)); + overloads.add(Overload.unary(overloadId(ROUND, 1), MathLib::round)); + overloads.add(Overload.unary(TRUNC, MathLib::trunc)); + overloads.add(Overload.unary(overloadId(TRUNC, 1), MathLib::trunc)); + overloads.add(Overload.unary(ABS, MathLib::abs)); + overloads.add(Overload.unary(overloadId(ABS, 1), MathLib::abs)); + overloads.add(Overload.unary(SIGN, MathLib::sign)); + overloads.add(Overload.unary(overloadId(SIGN, 1), MathLib::sign)); + overloads.add(Overload.unary(IS_NAN, MathLib::isNaN)); + overloads.add(Overload.unary(overloadId(IS_NAN, 1), MathLib::isNaN)); + overloads.add(Overload.unary(IS_INF, MathLib::isInf)); + overloads.add(Overload.unary(overloadId(IS_INF, 1), MathLib::isInf)); + overloads.add(Overload.unary(IS_FINITE, MathLib::isFinite)); + overloads.add(Overload.unary(overloadId(IS_FINITE, 1), MathLib::isFinite)); + overloads.add(Overload.binary(BIT_AND, MathLib::bitAnd)); + overloads.add(Overload.binary(overloadId(BIT_AND, 2), MathLib::bitAnd)); + overloads.add(Overload.binary(BIT_OR, MathLib::bitOr)); + overloads.add(Overload.binary(overloadId(BIT_OR, 2), MathLib::bitOr)); + overloads.add(Overload.binary(BIT_XOR, MathLib::bitXor)); + overloads.add(Overload.binary(overloadId(BIT_XOR, 2), MathLib::bitXor)); + overloads.add(Overload.unary(BIT_NOT, MathLib::bitNot)); + overloads.add(Overload.unary(overloadId(BIT_NOT, 1), MathLib::bitNot)); + overloads.add(Overload.binary(BIT_SHIFT_LEFT, MathLib::bitShiftLeft)); + overloads.add(Overload.binary(overloadId(BIT_SHIFT_LEFT, 2), MathLib::bitShiftLeft)); + overloads.add(Overload.binary(BIT_SHIFT_RIGHT, MathLib::bitShiftRight)); + overloads.add(Overload.binary(overloadId(BIT_SHIFT_RIGHT, 2), MathLib::bitShiftRight)); + return List.of(ProgramOption.functions(overloads.toArray(Overload[]::new))); + } + + private static Decl minMaxDeclaration(String function) { + List overloads = new ArrayList<>(); + overloads.add(Decls.newOverload(overloadId(function, "int"), List.of(Decls.Int), Decls.Int)); + overloads.add(Decls.newOverload(overloadId(function, "uint"), List.of(Decls.Uint), Decls.Uint)); + overloads.add( + Decls.newOverload(overloadId(function, "double"), List.of(Decls.Double), Decls.Double)); + for (int arity = 2; arity <= 5; arity++) { + overloads.add(Decls.newOverload(overloadId(function, arity), dynArgs(arity), Decls.Dyn)); + } + overloads.add( + Decls.newOverload( + overloadId(function, "list"), List.of(Decls.newListType(Decls.Dyn)), Decls.Dyn)); + return Decls.newFunction(function, overloads); + } + + private static Decl unaryDeclaration(String function, Type result) { + return Decls.newFunction( + function, Decls.newOverload(overloadId(function, 1), List.of(Decls.Dyn), result)); + } + + private static Decl binaryDeclaration(String function, Type result) { + return Decls.newFunction( + function, Decls.newOverload(overloadId(function, 2), dynArgs(2), result)); + } + + private static List dynArgs(int count) { + List args = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + args.add(Decls.Dyn); + } + return args; + } + + private static void addArityOverloads( + List overloads, + String function, + org.projectnessie.cel.interpreter.functions.FunctionOp op) { + overloads.add(Overload.unary(overloadId(function, "int"), op::invoke)); + overloads.add(Overload.unary(overloadId(function, "uint"), op::invoke)); + overloads.add(Overload.unary(overloadId(function, "double"), op::invoke)); + overloads.add( + Overload.binary(overloadId(function, 2), (left, right) -> op.invoke(left, right))); + for (int arity = 3; arity <= 5; arity++) { + overloads.add(Overload.function(overloadId(function, arity), op)); + } + overloads.add(Overload.unary(overloadId(function, "list"), op::invoke)); + } + + private static String overloadId(String function, int arity) { + return function.replace('.', '_') + "_" + arity; + } + + private static String overloadId(String function, String suffix) { + return function.replace('.', '_') + "_" + suffix; + } + + private static Val greatest(Val... values) { + return minMax(values, true); + } + + private static Val least(Val... values) { + return minMax(values, false); + } + + private static Val minMax(Val[] values, boolean greatest) { + List candidates = candidates(values); + if (candidates.isEmpty()) { + return newErr("empty argument list"); + } + + Val result = candidates.get(0); + if (!isNumber(result)) { + return noSuchOverload(); + } + for (int i = 1; i < candidates.size(); i++) { + Val candidate = candidates.get(i); + if (!isNumber(candidate)) { + return noSuchOverload(); + } + int cmp = compareNumbers(candidate, result); + if ((greatest && cmp > 0) || (!greatest && cmp < 0)) { + result = candidate; + } + } + return result; + } + + private static List candidates(Val[] values) { + if (values.length == 1 && values[0] instanceof Lister list) { + int size = (int) list.size().intValue(); + List elements = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + elements.add(list.get(intOf(i))); + } + return elements; + } + return List.of(values); + } + + private static int compareNumbers(Val left, Val right) { + if (left instanceof DoubleT || right instanceof DoubleT) { + return Double.compare(asDouble(left), asDouble(right)); + } + if (left instanceof UintT && right instanceof UintT) { + return Long.compareUnsigned(left.intValue(), right.intValue()); + } + if (left instanceof UintT && right instanceof IntT) { + long rightValue = right.intValue(); + return rightValue < 0 ? 1 : Long.compareUnsigned(left.intValue(), rightValue); + } + if (left instanceof IntT && right instanceof UintT) { + long leftValue = left.intValue(); + return leftValue < 0 ? -1 : Long.compareUnsigned(leftValue, right.intValue()); + } + return Long.compare(left.intValue(), right.intValue()); + } + + private static double asDouble(Val value) { + if (value instanceof DoubleT) { + return value.doubleValue(); + } + if (value instanceof UintT) { + return Long.toUnsignedString(value.intValue()).equals("18446744073709551615") + ? 18446744073709551615.0 + : Double.parseDouble(Long.toUnsignedString(value.intValue())); + } + return value.intValue(); + } + + private static Val ceil(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + return doubleOf(Math.ceil(value.doubleValue())); + } + + private static Val floor(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + return doubleOf(Math.floor(value.doubleValue())); + } + + private static Val round(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + double d = value.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + return doubleOf(d); + } + return doubleOf(d < 0 ? Math.ceil(d - 0.5d) : Math.floor(d + 0.5d)); + } + + private static Val trunc(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + double d = value.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + return doubleOf(d); + } + return doubleOf(d < 0 ? Math.ceil(d) : Math.floor(d)); + } + + private static Val abs(Val value) { + if (value instanceof UintT) { + return value; + } + if (value instanceof IntT) { + long v = value.intValue(); + if (v == Long.MIN_VALUE) { + return errIntOverflow; + } + return intOf(Math.abs(v)); + } + if (value instanceof DoubleT) { + return doubleOf(Math.abs(value.doubleValue())); + } + return noSuchOverload(); + } + + private static Val sign(Val value) { + if (value instanceof UintT) { + return uintOf(value.intValue() == 0 ? 0 : 1); + } + if (value instanceof IntT) { + return intOf(Long.compare(value.intValue(), 0)); + } + if (value instanceof DoubleT) { + return doubleOf(Double.compare(value.doubleValue(), 0.0d)); + } + return noSuchOverload(); + } + + private static Val isNaN(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + return Double.isNaN(value.doubleValue()) ? True : False; + } + + private static Val isInf(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + return Double.isInfinite(value.doubleValue()) ? True : False; + } + + private static Val isFinite(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + double d = value.doubleValue(); + return !Double.isNaN(d) && !Double.isInfinite(d) ? True : False; + } + + private static Val bitAnd(Val left, Val right) { + return bitwise(left, right, (a, b) -> a & b); + } + + private static Val bitOr(Val left, Val right) { + return bitwise(left, right, (a, b) -> a | b); + } + + private static Val bitXor(Val left, Val right) { + return bitwise(left, right, (a, b) -> a ^ b); + } + + private static Val bitwise(Val left, Val right, LongOperator op) { + if (left instanceof IntT && right instanceof IntT) { + return intOf(op.apply(left.intValue(), right.intValue())); + } + if (left instanceof UintT && right instanceof UintT) { + return uintOf(op.apply(left.intValue(), right.intValue())); + } + return noSuchOverload(); + } + + private static Val bitNot(Val value) { + if (value instanceof IntT) { + return intOf(~value.intValue()); + } + if (value instanceof UintT) { + return uintOf(~value.intValue()); + } + return noSuchOverload(); + } + + private static Val bitShiftLeft(Val left, Val right) { + if (!(right instanceof IntT)) { + return noSuchOverload(); + } + long shift = right.intValue(); + if (shift < 0) { + return newErr("negative offset"); + } + if (shift >= Long.SIZE) { + return left instanceof UintT ? uintOf(0) : left instanceof IntT ? intOf(0) : noSuchOverload(); + } + if (left instanceof IntT) { + return intOf(left.intValue() << shift); + } + if (left instanceof UintT) { + return uintOf(left.intValue() << shift); + } + return noSuchOverload(); + } + + private static Val bitShiftRight(Val left, Val right) { + if (!(right instanceof IntT)) { + return noSuchOverload(); + } + long shift = right.intValue(); + if (shift < 0) { + return newErr("negative offset"); + } + if (shift >= Long.SIZE) { + return left instanceof UintT ? uintOf(0) : left instanceof IntT ? intOf(0) : noSuchOverload(); + } + if (left instanceof IntT) { + return intOf(left.intValue() >>> shift); + } + if (left instanceof UintT) { + return uintOf(left.intValue() >>> shift); + } + return noSuchOverload(); + } + + private static boolean isNumber(Val value) { + return value instanceof IntT || value instanceof UintT || value instanceof DoubleT; + } + + private static Val noSuchOverload() { + return newErr("no such overload"); + } + + @FunctionalInterface + private interface LongOperator { + long apply(long left, long right); + } +} diff --git a/core/src/test/java/org/projectnessie/cel/extension/MathLibTest.java b/core/src/test/java/org/projectnessie/cel/extension/MathLibTest.java new file mode 100644 index 00000000..205a1d4c --- /dev/null +++ b/core/src/test/java/org/projectnessie/cel/extension/MathLibTest.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.projectnessie.cel.extension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.projectnessie.cel.Env.newEnv; +import static org.projectnessie.cel.common.types.DoubleT.doubleOf; +import static org.projectnessie.cel.common.types.IntT.intOf; +import static org.projectnessie.cel.common.types.UintT.uintOf; +import static org.projectnessie.cel.extension.MathLib.math; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.projectnessie.cel.Env; +import org.projectnessie.cel.Program; +import org.projectnessie.cel.Program.EvalResult; +import org.projectnessie.cel.common.types.Err; + +class MathLibTest { + + @Test + void findsGreatestScalar() { + assertEvaluates("math.greatest(5.4, 10, 3u, -5.0, 9223372036854775807)", intOf(Long.MAX_VALUE)); + } + + @Test + void findsLeastListElement() { + assertEvaluates("math.least([5.4, 10u, 3u, 1u, 3.5])", uintOf(1)); + } + + @Test + void roundsHalfAwayFromZero() { + assertEvaluates("math.round(-1.5)", doubleOf(-2.0)); + } + + @Test + void shiftsSignedIntsLogicallyRight() { + assertEvaluates("math.bitShiftRight(-1024, 3)", intOf(2305843009213693824L)); + } + + @Test + void rejectsNegativeShiftOffset() { + EvalResult result = evaluate("math.bitShiftLeft(1u, -1)"); + + assertThat(result.getVal()).isInstanceOf(Err.class); + assertThat(result.getVal().toString()).contains("negative offset"); + } + + private static void assertEvaluates(String expression, Object expectedValue) { + assertThat(evaluate(expression).getVal()).isEqualTo(expectedValue); + } + + private static EvalResult evaluate(String expression) { + Env env = newEnv(math()); + Env.AstIssuesTuple parsed = env.parse(expression); + assertThat(parsed.hasIssues()).isFalse(); + + Env.AstIssuesTuple checked = env.check(parsed.getAst()); + assertThat(checked.hasIssues()).isFalse(); + + Program program = env.program(checked.getAst()); + return program.eval(Map.of()); + } +} From b45ba932606a5b3bacbbf6a63a7603ef7bdc6c93 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 17:09:39 +0200 Subject: [PATCH 30/33] Add network extension conformance The CEL-Spec network extension suite requires net.IP and net.CIDR values with strict literal parsing, canonical formatting, receiver methods, and type-literal support. CEL-Java did not expose those extension functions or custom network types. Add an opt-in network extension library, support custom object type values with explicit traits, wire the library into the conformance harness, and cover representative IP and CIDR behavior with direct tests. --- .../conformance/SimpleConformanceTest.java | 14 + .../projectnessie/cel/common/types/TypeT.java | 11 +- .../cel/extension/NetworkLib.java | 547 ++++++++++++++++++ .../cel/extension/NetworkLibTest.java | 75 +++ 4 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/org/projectnessie/cel/extension/NetworkLib.java create mode 100644 core/src/test/java/org/projectnessie/cel/extension/NetworkLibTest.java diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index f0a2c3b0..62d57f7e 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -45,6 +45,7 @@ import static org.projectnessie.cel.common.types.UnknownT.unknownOf; import static org.projectnessie.cel.extension.EncodersLib.encoders; import static org.projectnessie.cel.extension.MathLib.math; +import static org.projectnessie.cel.extension.NetworkLib.network; import static org.projectnessie.cel.extension.OptionalLib.optionals; import static org.projectnessie.cel.extension.ProtoLib.proto; import static org.projectnessie.cel.extension.StringsLib.strings; @@ -128,6 +129,7 @@ class SimpleConformanceTest { "macros.textproto", "math_ext.textproto", "namespace.textproto", + "network_ext.textproto", "parse.textproto", "plumbing.textproto", "proto2.textproto", @@ -414,6 +416,9 @@ private static List conformanceEnvOptions(SimpleTest test, EnvOption. if (test.getExpr().contains("math.")) { envOptions.add(math()); } + if (usesNetworkExtensions(test.getExpr())) { + envOptions.add(network()); + } if (test.getExpr().contains("optional.")) { envOptions.add(optionals()); } @@ -436,6 +441,15 @@ private static boolean usesStringExtensions(String expression) { || expression.contains(".format(") || expression.contains(".reverse("); } + + private static boolean usesNetworkExtensions(String expression) { + return expression.contains("ip(") + || expression.contains("cidr(") + || expression.contains("isIP(") + || expression.contains("ip.isCanonical(") + || expression.contains("net.IP") + || expression.contains("net.CIDR"); + } } private static void match(String testPath, SimpleTest test, ExprValue actual) diff --git a/core/src/main/java/org/projectnessie/cel/common/types/TypeT.java b/core/src/main/java/org/projectnessie/cel/common/types/TypeT.java index bd1f320d..5806974c 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/TypeT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/TypeT.java @@ -55,11 +55,20 @@ public static Type newObjectTypeValue(String name) { return new ObjectTypeT(name); } + /** NewObjectTypeValue returns a *TypeValue with the supplied traits for a qualified type name. */ + public static Type newObjectTypeValue(String name, Trait... traits) { + return new ObjectTypeT(name, traits); + } + static final class ObjectTypeT extends TypeT { private final String typeName; ObjectTypeT(String typeName) { - super(TypeEnum.Object, Trait.FieldTesterType, Trait.IndexerType); + this(typeName, Trait.FieldTesterType, Trait.IndexerType); + } + + ObjectTypeT(String typeName, Trait... traits) { + super(TypeEnum.Object, traits); this.typeName = typeName; } diff --git a/core/src/main/java/org/projectnessie/cel/extension/NetworkLib.java b/core/src/main/java/org/projectnessie/cel/extension/NetworkLib.java new file mode 100644 index 00000000..25ba2fe4 --- /dev/null +++ b/core/src/main/java/org/projectnessie/cel/extension/NetworkLib.java @@ -0,0 +1,547 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.projectnessie.cel.extension; + +import static java.util.Collections.singletonList; +import static org.projectnessie.cel.common.types.BoolT.False; +import static org.projectnessie.cel.common.types.BoolT.True; +import static org.projectnessie.cel.common.types.Err.newErr; +import static org.projectnessie.cel.common.types.Err.newTypeConversionError; +import static org.projectnessie.cel.common.types.Err.noSuchOverload; +import static org.projectnessie.cel.common.types.IntT.intOf; +import static org.projectnessie.cel.common.types.StringT.StringType; +import static org.projectnessie.cel.common.types.StringT.stringOf; +import static org.projectnessie.cel.common.types.TypeT.newObjectTypeValue; +import static org.projectnessie.cel.common.types.Types.boolOf; + +import com.google.api.expr.v1alpha1.Type; +import java.math.BigInteger; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.projectnessie.cel.EnvOption; +import org.projectnessie.cel.Library; +import org.projectnessie.cel.ProgramOption; +import org.projectnessie.cel.checker.Decls; +import org.projectnessie.cel.common.types.ref.BaseVal; +import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.common.types.traits.Receiver; +import org.projectnessie.cel.common.types.traits.Trait; +import org.projectnessie.cel.interpreter.functions.Overload; + +/** NetworkLib provides CEL helper functions from the standard network extension library. */ +public final class NetworkLib implements Library { + private static final String IP = "ip"; + private static final String CIDR = "cidr"; + private static final String IS_IP = "isIP"; + private static final String IP_IS_CANONICAL = "ip.isCanonical"; + private static final String NET_IP = "net.IP"; + private static final String NET_CIDR = "net.CIDR"; + + private static final Type IP_TYPE = Decls.newObjectType(NET_IP); + private static final Type CIDR_TYPE = Decls.newObjectType(NET_CIDR); + + private static final org.projectnessie.cel.common.types.ref.Type IP_TYPE_VALUE = + newObjectTypeValue(NET_IP, Trait.ReceiverType); + private static final org.projectnessie.cel.common.types.ref.Type CIDR_TYPE_VALUE = + newObjectTypeValue(NET_CIDR, Trait.ReceiverType); + + private NetworkLib() {} + + public static EnvOption network() { + return Library.Lib(new NetworkLib()); + } + + @Override + public List getCompileOptions() { + return List.of( + EnvOption.types(IP_TYPE_VALUE, CIDR_TYPE_VALUE), + EnvOption.declarations( + Decls.newVar(NET_IP, Decls.newTypeType(IP_TYPE)), + Decls.newVar(NET_CIDR, Decls.newTypeType(CIDR_TYPE)), + Decls.newFunction( + IP, Decls.newOverload("ip_string", singletonList(Decls.String), IP_TYPE)), + Decls.newFunction( + CIDR, Decls.newOverload("cidr_string", singletonList(Decls.String), CIDR_TYPE)), + Decls.newFunction( + IS_IP, + Decls.newOverload("is_ip_string", singletonList(Decls.String), Decls.Bool), + Decls.newOverload("is_ip_cidr", singletonList(CIDR_TYPE), Decls.Bool)), + Decls.newFunction( + IP_IS_CANONICAL, + Decls.newOverload( + "ip_is_canonical_string", singletonList(Decls.String), Decls.Bool)), + Decls.newFunction( + "string", + Decls.newOverload("string_ip", singletonList(IP_TYPE), Decls.String), + Decls.newOverload("string_cidr", singletonList(CIDR_TYPE), Decls.String)), + Decls.newFunction( + "family", + Decls.newInstanceOverload("ip_family", singletonList(IP_TYPE), Decls.Int)), + Decls.newFunction( + "isUnspecified", + Decls.newInstanceOverload("ip_is_unspecified", singletonList(IP_TYPE), Decls.Bool)), + Decls.newFunction( + "isLoopback", + Decls.newInstanceOverload("ip_is_loopback", singletonList(IP_TYPE), Decls.Bool)), + Decls.newFunction( + "isGlobalUnicast", + Decls.newInstanceOverload( + "ip_is_global_unicast", singletonList(IP_TYPE), Decls.Bool)), + Decls.newFunction( + "isLinkLocalMulticast", + Decls.newInstanceOverload( + "ip_is_link_local_multicast", singletonList(IP_TYPE), Decls.Bool)), + Decls.newFunction( + "isLinkLocalUnicast", + Decls.newInstanceOverload( + "ip_is_link_local_unicast", singletonList(IP_TYPE), Decls.Bool)), + Decls.newFunction( + "containsIP", + Decls.newInstanceOverload( + "cidr_contains_ip", List.of(CIDR_TYPE, IP_TYPE), Decls.Bool), + Decls.newInstanceOverload( + "cidr_contains_ip_string", List.of(CIDR_TYPE, Decls.String), Decls.Bool)), + Decls.newFunction( + "containsCIDR", + Decls.newInstanceOverload( + "cidr_contains_cidr", List.of(CIDR_TYPE, CIDR_TYPE), Decls.Bool), + Decls.newInstanceOverload( + "cidr_contains_cidr_string", List.of(CIDR_TYPE, Decls.String), Decls.Bool)), + Decls.newFunction( + "ip", Decls.newInstanceOverload("cidr_ip", singletonList(CIDR_TYPE), IP_TYPE)), + Decls.newFunction( + "masked", + Decls.newInstanceOverload("cidr_masked", singletonList(CIDR_TYPE), CIDR_TYPE)), + Decls.newFunction( + "prefixLength", + Decls.newInstanceOverload( + "cidr_prefix_length", singletonList(CIDR_TYPE), Decls.Int)))); + } + + @Override + public List getProgramOptions() { + return List.of( + ProgramOption.functions( + Overload.unary(IP, NetworkLib::ip), + Overload.unary("ip_string", NetworkLib::ip), + Overload.unary(CIDR, NetworkLib::cidr), + Overload.unary("cidr_string", NetworkLib::cidr), + Overload.unary(IS_IP, NetworkLib::isIp), + Overload.unary("is_ip_string", NetworkLib::isIp), + Overload.unary("is_ip_cidr", value -> newErr("no such overload")), + Overload.unary(IP_IS_CANONICAL, NetworkLib::ipIsCanonical), + Overload.unary("ip_is_canonical_string", NetworkLib::ipIsCanonical), + Overload.unary("string_ip", value -> value.convertToType(StringType)), + Overload.unary("string_cidr", value -> value.convertToType(StringType)), + Overload.unary("cidr_ip", value -> ((CidrT) value).receive("ip", "cidr_ip"))), + ProgramOption.globals(Map.of(NET_IP, IP_TYPE_VALUE, NET_CIDR, CIDR_TYPE_VALUE))); + } + + private static Val ip(Val value) { + if (value instanceof CidrT cidr) { + return cidr.ip; + } + if (!isString(value)) { + return noSuchOverload(null, IP, value); + } + return parseIp(value.value().toString()); + } + + private static Val cidr(Val value) { + if (!isString(value)) { + return noSuchOverload(null, CIDR, value); + } + return parseCidr(value.value().toString()); + } + + private static Val isIp(Val value) { + if (!isString(value)) { + return noSuchOverload(null, IS_IP, value); + } + return parseIp(value.value().toString()) instanceof IpT ? True : False; + } + + private static Val ipIsCanonical(Val value) { + if (!isString(value)) { + return noSuchOverload(null, IP_IS_CANONICAL, value); + } + Val parsed = parseIp(value.value().toString()); + if (!(parsed instanceof IpT ip)) { + return parsed; + } + return boolOf(value.value().toString().equals(ip.canonical)); + } + + private static boolean isString(Val value) { + return value.type().typeEnum() == org.projectnessie.cel.common.types.ref.TypeEnum.String; + } + + private static Val parseIp(String text) { + if (text.contains("%")) { + return newErr("IP Address with zone value is not allowed"); + } + if (text.indexOf(':') >= 0 && text.indexOf('.') >= 0) { + return newErr("IPv4-mapped IPv6 address is not allowed"); + } + try { + if (text.indexOf(':') >= 0) { + InetAddress address = InetAddress.getByName(text); + byte[] bytes = address.getAddress(); + if (bytes.length == 4) { + return new IpT(bytes, 4); + } + if (bytes.length == 16) { + return new IpT(bytes, 6); + } + } else { + return new IpT(parseIpv4(text), 4); + } + } catch (IllegalArgumentException | UnknownHostException e) { + // fall through + } + return newErr("IP Address '%s' parse error during conversion from string", text); + } + + private static byte[] parseIpv4(String text) { + String[] parts = text.split("\\.", -1); + if (parts.length != 4) { + throw new IllegalArgumentException("invalid IPv4 address"); + } + byte[] bytes = new byte[4]; + for (int i = 0; i < 4; i++) { + String part = parts[i]; + if (part.isEmpty() || (part.length() > 1 && part.charAt(0) == '0')) { + throw new IllegalArgumentException("invalid IPv4 address"); + } + int value = Integer.parseInt(part); + if (value < 0 || value > 255) { + throw new IllegalArgumentException("invalid IPv4 address"); + } + bytes[i] = (byte) value; + } + return bytes; + } + + private static Val parseCidr(String text) { + int slash = text.indexOf('/'); + if (slash < 0 || slash != text.lastIndexOf('/') || slash == text.length() - 1) { + return newErr("network address parse error during conversion from string"); + } + String ipText = text.substring(0, slash); + String prefixText = text.substring(slash + 1); + if (ipText.contains("%")) { + return newErr("CIDR with zone value is not allowed"); + } + Val parsedIp = parseIp(ipText); + if (!(parsedIp instanceof IpT ip)) { + return parsedIp; + } + try { + int prefix = Integer.parseInt(prefixText); + int bits = ip.family == 4 ? 32 : 128; + if (prefix < 0 || prefix > bits) { + return newErr("network address parse error during conversion from string"); + } + return new CidrT(ip, prefix); + } catch (NumberFormatException e) { + return newErr("network address parse error during conversion from string"); + } + } + + private static BigInteger unsigned(byte[] bytes) { + return new BigInteger(1, bytes); + } + + private static byte[] bytes(BigInteger value, int length) { + byte[] source = value.toByteArray(); + byte[] target = new byte[length]; + int copy = Math.min(source.length, length); + System.arraycopy(source, source.length - copy, target, length - copy, copy); + return target; + } + + private static byte[] mask(byte[] address, int prefix) { + int bits = address.length * Byte.SIZE; + if (prefix == bits) { + return address.clone(); + } + BigInteger value = unsigned(address); + BigInteger mask = BigInteger.ONE.shiftLeft(bits).subtract(BigInteger.ONE); + mask = mask.xor(BigInteger.ONE.shiftLeft(bits - prefix).subtract(BigInteger.ONE)); + return bytes(value.and(mask), address.length); + } + + private static String canonicalIpv4(byte[] bytes) { + return (bytes[0] & 0xff) + + "." + + (bytes[1] & 0xff) + + "." + + (bytes[2] & 0xff) + + "." + + (bytes[3] & 0xff); + } + + private static String canonicalIpv6(byte[] bytes) { + int[] words = new int[8]; + for (int i = 0; i < words.length; i++) { + words[i] = ((bytes[i * 2] & 0xff) << 8) | (bytes[i * 2 + 1] & 0xff); + } + + int bestStart = -1; + int bestLength = 0; + for (int i = 0; i < words.length; ) { + if (words[i] != 0) { + i++; + continue; + } + int start = i; + while (i < words.length && words[i] == 0) { + i++; + } + int length = i - start; + if (length > bestLength && length > 1) { + bestStart = start; + bestLength = length; + } + } + + StringBuilder result = new StringBuilder(); + for (int i = 0; i < words.length; i++) { + if (i == bestStart) { + if (!result.isEmpty() && result.charAt(result.length() - 1) != ':') { + result.append(':'); + } + result.append(':'); + i += bestLength - 1; + continue; + } + if (!result.isEmpty() && result.charAt(result.length() - 1) != ':') { + result.append(':'); + } + result.append(Integer.toHexString(words[i])); + } + return result.toString(); + } + + private static final class IpT extends BaseVal implements Receiver { + private final byte[] bytes; + private final int family; + private final String canonical; + + private IpT(byte[] bytes, int family) { + this.bytes = bytes.clone(); + this.family = family; + this.canonical = family == 4 ? canonicalIpv4(bytes) : canonicalIpv6(bytes); + } + + @Override + @SuppressWarnings("unchecked") + public T convertToNative(Class typeDesc) { + if (typeDesc == String.class || typeDesc == Object.class) { + return (T) canonical; + } + if (typeDesc == byte[].class) { + return (T) bytes.clone(); + } + throw new IllegalArgumentException( + String.format("Unsupported conversion of '%s' to '%s'", NET_IP, typeDesc.getName())); + } + + @Override + public Val convertToType(org.projectnessie.cel.common.types.ref.Type typeVal) { + if (typeVal == StringType) { + return stringOf(canonical); + } + if (typeVal.typeName().equals(org.projectnessie.cel.common.types.TypeT.TypeType.typeName())) { + return IP_TYPE_VALUE; + } + if (typeVal.typeName().equals(NET_IP)) { + return this; + } + return newTypeConversionError(NET_IP, typeVal); + } + + @Override + public Val equal(Val other) { + if (!(other instanceof IpT otherIp)) { + return False; + } + return boolOf(Arrays.equals(bytes, otherIp.bytes)); + } + + @Override + public Val receive(String function, String overload, Val... args) { + if (args.length != 0) { + return noSuchOverload(this, function, overload, args); + } + switch (function) { + case "family": + return intOf(family); + case "isUnspecified": + return boolOf(unsigned(bytes).signum() == 0); + case "isLoopback": + return boolOf( + family == 4 ? (bytes[0] & 0xff) == 127 : unsigned(bytes).equals(BigInteger.ONE)); + case "isGlobalUnicast": + return boolOf(!isMulticast() && unsigned(bytes).signum() != 0 && !isBroadcast()); + case "isLinkLocalMulticast": + return boolOf( + family == 4 + ? canonical.startsWith("224.0.0.") + : (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0x02); + case "isLinkLocalUnicast": + return boolOf( + family == 4 + ? (bytes[0] & 0xff) == 169 && (bytes[1] & 0xff) == 254 + : (bytes[0] & 0xff) == 0xfe && ((bytes[1] & 0xc0) == 0x80)); + default: + return noSuchOverload(this, function, overload, args); + } + } + + private boolean isMulticast() { + return family == 4 ? (bytes[0] & 0xf0) == 0xe0 : (bytes[0] & 0xff) == 0xff; + } + + private boolean isBroadcast() { + return family == 4 + && unsigned(bytes).equals(BigInteger.ONE.shiftLeft(32).subtract(BigInteger.ONE)); + } + + @Override + public org.projectnessie.cel.common.types.ref.Type type() { + return IP_TYPE_VALUE; + } + + @Override + public Object value() { + return canonical; + } + } + + private static final class CidrT extends BaseVal implements Receiver { + private final IpT ip; + private final int prefix; + private final String canonical; + + private CidrT(IpT ip, int prefix) { + this.ip = ip; + this.prefix = prefix; + this.canonical = ip.canonical + "/" + prefix; + } + + @Override + @SuppressWarnings("unchecked") + public T convertToNative(Class typeDesc) { + if (typeDesc == String.class || typeDesc == Object.class) { + return (T) canonical; + } + throw new IllegalArgumentException( + String.format("Unsupported conversion of '%s' to '%s'", NET_CIDR, typeDesc.getName())); + } + + @Override + public Val convertToType(org.projectnessie.cel.common.types.ref.Type typeVal) { + if (typeVal == StringType) { + return stringOf(canonical); + } + if (typeVal.typeName().equals(org.projectnessie.cel.common.types.TypeT.TypeType.typeName())) { + return CIDR_TYPE_VALUE; + } + if (typeVal.typeName().equals(NET_CIDR)) { + return this; + } + return newTypeConversionError(NET_CIDR, typeVal); + } + + @Override + public Val equal(Val other) { + if (!(other instanceof CidrT otherCidr)) { + return False; + } + return boolOf(prefix == otherCidr.prefix && ip.equal(otherCidr.ip) == True); + } + + @Override + public Val receive(String function, String overload, Val... args) { + switch (function) { + case "containsIP": + return containsIp(args); + case "containsCIDR": + return containsCidr(args); + case "ip": + return args.length == 0 ? ip : noSuchOverload(this, function, overload, args); + case "masked": + return args.length == 0 ? masked() : noSuchOverload(this, function, overload, args); + case "prefixLength": + return args.length == 0 ? intOf(prefix) : noSuchOverload(this, function, overload, args); + default: + return noSuchOverload(this, function, overload, args); + } + } + + private Val containsIp(Val[] args) { + if (args.length != 1) { + return noSuchOverload(this, "containsIP", "", args); + } + Val candidate = + args[0] instanceof IpT + ? args[0] + : isString(args[0]) ? parseIp(args[0].value().toString()) : null; + if (!(candidate instanceof IpT candidateIp)) { + return candidate != null ? candidate : noSuchOverload(this, "containsIP", "", args); + } + if (candidateIp.family != ip.family) { + return False; + } + return boolOf(Arrays.equals(mask(candidateIp.bytes, prefix), mask(ip.bytes, prefix))); + } + + private Val containsCidr(Val[] args) { + if (args.length != 1) { + return noSuchOverload(this, "containsCIDR", "", args); + } + Val candidate = + args[0] instanceof CidrT + ? args[0] + : isString(args[0]) ? parseCidr(args[0].value().toString()) : null; + if (!(candidate instanceof CidrT candidateCidr)) { + return candidate != null ? candidate : noSuchOverload(this, "containsCIDR", "", args); + } + if (candidateCidr.ip.family != ip.family || candidateCidr.prefix < prefix) { + return False; + } + return boolOf(Arrays.equals(mask(candidateCidr.ip.bytes, prefix), mask(ip.bytes, prefix))); + } + + private CidrT masked() { + return new CidrT(new IpT(mask(ip.bytes, prefix), ip.family), prefix); + } + + @Override + public org.projectnessie.cel.common.types.ref.Type type() { + return CIDR_TYPE_VALUE; + } + + @Override + public Object value() { + return canonical; + } + } +} diff --git a/core/src/test/java/org/projectnessie/cel/extension/NetworkLibTest.java b/core/src/test/java/org/projectnessie/cel/extension/NetworkLibTest.java new file mode 100644 index 00000000..d20f9430 --- /dev/null +++ b/core/src/test/java/org/projectnessie/cel/extension/NetworkLibTest.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.projectnessie.cel.extension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.projectnessie.cel.Env.newEnv; +import static org.projectnessie.cel.common.types.BoolT.False; +import static org.projectnessie.cel.common.types.BoolT.True; +import static org.projectnessie.cel.common.types.IntT.intOf; +import static org.projectnessie.cel.common.types.StringT.stringOf; +import static org.projectnessie.cel.extension.NetworkLib.network; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.projectnessie.cel.Env; +import org.projectnessie.cel.Program; +import org.projectnessie.cel.Program.EvalResult; +import org.projectnessie.cel.common.types.Err; + +class NetworkLibTest { + + @Test + void canonicalizesIpStrings() { + assertEvaluates("string(ip('2001:db8::68'))", stringOf("2001:db8::68")); + } + + @Test + void checksIpProperties() { + assertEvaluates("ip('192.168.0.1').family()", intOf(4)); + assertEvaluates("ip('fe80::1').isLinkLocalUnicast()", True); + } + + @Test + void checksCidrContainment() { + assertEvaluates("cidr('192.168.0.0/24').containsIP('192.168.0.1')", True); + assertEvaluates("cidr('192.168.0.0/24').containsCIDR('192.168.0.0/23')", False); + } + + @Test + void rejectsInvalidIpLiterals() { + EvalResult result = evaluate("ip('192.168.0.1.0')"); + + assertThat(result.getVal()).isInstanceOf(Err.class); + assertThat(result.getVal().toString()).contains("parse error"); + } + + private static void assertEvaluates(String expression, Object expectedValue) { + assertThat(evaluate(expression).getVal()).isEqualTo(expectedValue); + } + + private static EvalResult evaluate(String expression) { + Env env = newEnv(network()); + Env.AstIssuesTuple parsed = env.parse(expression); + assertThat(parsed.hasIssues()).isFalse(); + + Env.AstIssuesTuple checked = env.check(parsed.getAst()); + assertThat(checked.hasIssues()).isFalse(); + + Program program = env.program(checked.getAst()); + return program.eval(Map.of()); + } +} From 53bfe274917c95f7274508ca84ddba998d9e7b17 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 17:16:43 +0200 Subject: [PATCH 31/33] Add block extension conformance The CEL-Spec block suite uses test-only macros to model sequential local bindings and generated comprehension variable names. CEL-Java did not provide those macros, so the suite could not be parsed or executed in the pure Java conformance harness. Add opt-in parser macros for the block conformance cases, keep them out of the default macro set, wire them into the conformance parser only when needed, and enable block_ext with explicit skips for the optional-dependent cases. --- .../conformance/SimpleConformanceTest.java | 20 +++- .../org/projectnessie/cel/parser/Macro.java | 92 +++++++++++++++++++ .../java/org/projectnessie/cel/CELTest.java | 25 +++++ 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index 62d57f7e..b735421b 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -29,6 +29,7 @@ import static org.projectnessie.cel.EnvOption.clearMacros; import static org.projectnessie.cel.EnvOption.container; import static org.projectnessie.cel.EnvOption.declarations; +import static org.projectnessie.cel.EnvOption.macros; import static org.projectnessie.cel.EnvOption.types; import static org.projectnessie.cel.Library.StdLib; import static org.projectnessie.cel.common.types.BoolT.True; @@ -105,6 +106,7 @@ import org.projectnessie.cel.common.types.ref.Val; import org.projectnessie.cel.common.types.traits.Lister; import org.projectnessie.cel.common.types.traits.Mapper; +import org.projectnessie.cel.parser.Macro; class SimpleConformanceTest { @@ -116,6 +118,7 @@ class SimpleConformanceTest { List.of( "basic.textproto", "bindings_ext.textproto", + "block_ext.textproto", "comparisons.textproto", "conversions.textproto", "dynamic.textproto", @@ -180,7 +183,12 @@ class SimpleConformanceTest { "enums/strong_proto3/convert_int_too_big", "enums/strong_proto3/convert_int_too_neg", "enums/strong_proto3/convert_string", - "enums/strong_proto3/convert_string_bad"); + "enums/strong_proto3/convert_string_bad", + // Optional list/map/message syntax and runtime support is not implemented yet. + "block_ext/basic/optional_list", + "block_ext/basic/optional_map", + "block_ext/basic/optional_map_chained", + "block_ext/basic/optional_message"); private static final Set matchedSkips = new LinkedHashSet<>(); private static final AtomicInteger total = new AtomicInteger(); @@ -338,6 +346,9 @@ private static ParsedExpr parse(SimpleTest test) { if (test.getDisableMacros()) { parseOptions.add(clearMacros()); } + if (usesTestOnlyBlockMacros(test.getExpr())) { + parseOptions.add(macros(Macro.TestOnlyBlockMacros)); + } Env env = newEnv(parseOptions.toArray(new EnvOption[0])); AstIssuesTuple astIss = env.parse(sourceText); @@ -450,6 +461,13 @@ private static boolean usesNetworkExtensions(String expression) { || expression.contains("net.IP") || expression.contains("net.CIDR"); } + + private static boolean usesTestOnlyBlockMacros(String expression) { + return expression.contains("cel.block(") + || expression.contains("cel.index(") + || expression.contains("cel.iterVar(") + || expression.contains("cel.accuVar("); + } } private static void match(String testPath, SimpleTest test, ExprValue actual) diff --git a/core/src/main/java/org/projectnessie/cel/parser/Macro.java b/core/src/main/java/org/projectnessie/cel/parser/Macro.java index 5bb4042b..c0532e2c 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/Macro.java +++ b/core/src/main/java/org/projectnessie/cel/parser/Macro.java @@ -19,6 +19,7 @@ import static java.util.Collections.emptyList; import com.google.api.expr.v1alpha1.Expr; +import com.google.api.expr.v1alpha1.Expr.CreateList; import com.google.api.expr.v1alpha1.Expr.ExprKindCase; import com.google.api.expr.v1alpha1.Expr.Select; import java.util.List; @@ -71,6 +72,14 @@ public final class Macro { /* The macro "cel.bind(var, value, result)" binds a local variable for use in result. */ newReceiverMacro("bind", 3, Macro::makeBind)); + /** TestOnlyBlockMacros includes the test-only macros used by CEL-Spec block conformance tests. */ + public static final List TestOnlyBlockMacros = + asList( + newReceiverMacro("block", 2, Macro::makeBlock), + newReceiverMacro("index", 1, Macro::makeIndex), + newReceiverMacro("iterVar", 2, Macro::makeIterVar), + newReceiverMacro("accuVar", 2, Macro::makeAccuVar)); + /** NoMacros list. */ public static List MoMacros = emptyList(); @@ -268,6 +277,89 @@ static Expr makeBind(ExprHelper eh, Expr target, List args) { accuExpr); } + static Expr makeBlock(ExprHelper eh, Expr target, List args) { + validateCelReceiver(eh, target, "cel.block()"); + + Expr bindings = args.get(0); + if (bindings.getExprKindCase() != ExprKindCase.LIST_EXPR) { + Location location = eh.offsetLocation(bindings.getId()); + throw new ErrorWithLocation(location, "cel.block() first argument must be a list literal"); + } + + CreateList list = bindings.getListExpr(); + Expr result = args.get(1); + for (int i = list.getElementsCount() - 1; i >= 0; i--) { + result = makeLocalBinding(eh, indexName(i), list.getElements(i), result); + } + return result; + } + + static Expr makeIndex(ExprHelper eh, Expr target, List args) { + validateCelReceiver(eh, target, "cel.index()"); + return eh.ident(indexName(extractIntegerArgument(eh, args.get(0), "cel.index()"))); + } + + static Expr makeIterVar(ExprHelper eh, Expr target, List args) { + validateCelReceiver(eh, target, "cel.iterVar()"); + return eh.ident( + String.format( + "@it:%d:%d", + extractIntegerArgument(eh, args.get(0), "cel.iterVar()"), + extractIntegerArgument(eh, args.get(1), "cel.iterVar()"))); + } + + static Expr makeAccuVar(ExprHelper eh, Expr target, List args) { + validateCelReceiver(eh, target, "cel.accuVar()"); + return eh.ident( + String.format( + "@ac:%d:%d", + extractIntegerArgument(eh, args.get(0), "cel.accuVar()"), + extractIntegerArgument(eh, args.get(1), "cel.accuVar()"))); + } + + private static Expr makeLocalBinding(ExprHelper eh, String variable, Expr value, Expr result) { + Expr accuExpr = eh.ident(AccumulatorName); + Expr dynNull = eh.globalCall(Overloads.TypeConvertDyn, eh.literalNull()); + return eh.fold( + variable, + eh.newList(value), + AccumulatorName, + dynNull, + eh.literalBool(true), + result, + accuExpr); + } + + private static void validateCelReceiver(ExprHelper eh, Expr target, String macroName) { + if (target != null + && target.getExprKindCase() == ExprKindCase.IDENT_EXPR + && "cel".equals(target.getIdentExpr().getName())) { + return; + } + + Location location = target != null ? eh.offsetLocation(target.getId()) : null; + throw new ErrorWithLocation( + location, macroName + " must be called with receiver identifier cel"); + } + + private static int extractIntegerArgument(ExprHelper eh, Expr expr, String macroName) { + if (expr.getExprKindCase() != ExprKindCase.CONST_EXPR || !expr.getConstExpr().hasInt64Value()) { + Location location = eh.offsetLocation(expr.getId()); + throw new ErrorWithLocation(location, macroName + " argument must be an integer literal"); + } + + long value = expr.getConstExpr().getInt64Value(); + if (value < 0 || value > Integer.MAX_VALUE) { + Location location = eh.offsetLocation(expr.getId()); + throw new ErrorWithLocation(location, macroName + " argument out of range"); + } + return (int) value; + } + + private static String indexName(int index) { + return "@index" + index; + } + static String extractIdent(Expr e) { if (e.getExprKindCase() == ExprKindCase.IDENT_EXPR) { return e.getIdentExpr().getName(); diff --git a/core/src/test/java/org/projectnessie/cel/CELTest.java b/core/src/test/java/org/projectnessie/cel/CELTest.java index 94506fa8..5c6a3300 100644 --- a/core/src/test/java/org/projectnessie/cel/CELTest.java +++ b/core/src/test/java/org/projectnessie/cel/CELTest.java @@ -171,6 +171,31 @@ void bindMacroIntroducesLocalVariable() { .isSameAs(True); } + @Test + void blockMacrosIntroduceSequentialLocalVariables() { + Env env = newEnv(macros(Macro.TestOnlyBlockMacros)); + + AstIssuesTuple astIss = + env.compile("cel.block([1, cel.index(0) + 1, cel.index(1) + 1], cel.index(2))"); + + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(emptyMap()).getVal().equal(IntT.intOf(3))) + .isSameAs(True); + } + + @Test + void blockMacrosCanNameComprehensionVariables() { + Env env = newEnv(macros(Macro.TestOnlyBlockMacros)); + + AstIssuesTuple astIss = + env.compile( + "[1, 2].map(cel.iterVar(0, 0), " + + "[cel.iterVar(0, 0) + cel.iterVar(0, 0)])[1][0] == 4"); + + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(emptyMap()).getVal()).isSameAs(True); + } + @Test void AstToString() { Env stdEnv = newEnv(); From 3b43ae1b289f8f573c59e47ba7461e8c72c8156f Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 17:30:03 +0200 Subject: [PATCH 32/33] Add comprehension v2 conformance The updated CEL-Spec includes macros that expose both key/index and value variables during comprehension evaluation. CEL-Java only modeled one iteration variable, which prevented exists/all/existsOne and transform macros from covering list indexes and map values. Add parser, checker, and interpreter support for the second comprehension variable, add transformList and transformMap macro expansion, preserve iter_var2 in AST pruning and exhaustive evaluation, and enable the macros2 conformance file with direct unit coverage. --- .../conformance/SimpleConformanceTest.java | 1 + .../projectnessie/cel/checker/Checker.java | 20 +- .../cel/interpreter/AstPruner.java | 1 + .../cel/interpreter/Interpretable.java | 366 +++++++++++++++++- .../interpreter/InterpretableDecorator.java | 6 + .../cel/interpreter/InterpretablePlanner.java | 104 ++++- .../projectnessie/cel/parser/ExprHelper.java | 11 + .../cel/parser/ExprHelperImpl.java | 14 + .../org/projectnessie/cel/parser/Helper.java | 15 + .../org/projectnessie/cel/parser/Macro.java | 147 +++++++ .../java/org/projectnessie/cel/CELTest.java | 27 ++ 11 files changed, 697 insertions(+), 15 deletions(-) diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index b735421b..f4bebc55 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -130,6 +130,7 @@ class SimpleConformanceTest { "lists.textproto", "logic.textproto", "macros.textproto", + "macros2.textproto", "math_ext.textproto", "namespace.textproto", "network_ext.textproto", diff --git a/core/src/main/java/org/projectnessie/cel/checker/Checker.java b/core/src/main/java/org/projectnessie/cel/checker/Checker.java index 1360a3ad..ef02447d 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/Checker.java +++ b/core/src/main/java/org/projectnessie/cel/checker/Checker.java @@ -566,14 +566,23 @@ void checkComprehension(Expr.Builder e) { Type accuType = getType(comp.getAccuInitBuilder()); Type rangeType = getType(comp.getIterRangeBuilder()); Type varType; + Type var2Type = null; switch (kindOf(rangeType)) { case kindList: - varType = rangeType.getListType().getElemType(); + if (comp.getIterVar2().isEmpty()) { + varType = rangeType.getListType().getElemType(); + } else { + varType = Decls.Int; + var2Type = rangeType.getListType().getElemType(); + } break; case kindMap: // Ranges over the keys. varType = rangeType.getMapType().getKeyType(); + if (!comp.getIterVar2().isEmpty()) { + var2Type = rangeType.getMapType().getValueType(); + } break; case kindDyn: case kindError: @@ -584,10 +593,16 @@ void checkComprehension(Expr.Builder e) { isAssignable(Decls.Dyn, rangeType); // Set the range iteration variable to type DYN as well. varType = Decls.Dyn; + if (!comp.getIterVar2().isEmpty()) { + var2Type = Decls.Dyn; + } break; default: errors.notAComprehensionRange(location(comp.getIterRangeBuilder()), rangeType); varType = Decls.Error; + if (!comp.getIterVar2().isEmpty()) { + var2Type = Decls.Error; + } break; } @@ -598,6 +613,9 @@ void checkComprehension(Expr.Builder e) { // Create a block scope for the loop. env = env.enterScope(); env.add(Decls.newVar(comp.getIterVar(), varType)); + if (!comp.getIterVar2().isEmpty()) { + env.add(Decls.newVar(comp.getIterVar2(), var2Type)); + } // Check the variable references in the condition and step. check(comp.getLoopConditionBuilder()); assertType(comp.getLoopConditionBuilder(), Decls.Bool); diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/AstPruner.java b/core/src/main/java/org/projectnessie/cel/interpreter/AstPruner.java index 47f16cc9..e8b701f1 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/AstPruner.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/AstPruner.java @@ -363,6 +363,7 @@ Expr prune(Expr node) { .setComprehensionExpr( Comprehension.newBuilder() .setIterVar(compre.getIterVar()) + .setIterVar2(compre.getIterVar2()) .setIterRange(newRange) .setAccuVar(compre.getAccuVar()) .setAccuInit(compre.getAccuInit()) diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java b/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java index 8961488e..ed0c3f3d 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java @@ -22,6 +22,7 @@ import static org.projectnessie.cel.common.types.Err.noSuchAttributeException; import static org.projectnessie.cel.common.types.Err.noSuchOverload; import static org.projectnessie.cel.common.types.Err.valOrErr; +import static org.projectnessie.cel.common.types.IntT.intOf; import static org.projectnessie.cel.common.types.UnknownT.isUnknown; import static org.projectnessie.cel.common.types.UnknownT.unknownOf; import static org.projectnessie.cel.common.types.Util.isUnknownOrError; @@ -51,6 +52,8 @@ import org.projectnessie.cel.common.types.ref.Val; import org.projectnessie.cel.common.types.traits.Container; import org.projectnessie.cel.common.types.traits.FieldTester; +import org.projectnessie.cel.common.types.traits.Lister; +import org.projectnessie.cel.common.types.traits.Mapper; import org.projectnessie.cel.common.types.traits.Negater; import org.projectnessie.cel.common.types.traits.Receiver; import org.projectnessie.cel.common.types.traits.Sizer; @@ -1039,6 +1042,7 @@ final class EvalFold extends AbstractEval implements Coster { // TODO combine with EvalExhaustiveFold final String accuVar; final String iterVar; + final String iterVar2; final Interpretable iterRange; final Interpretable accu; final Interpretable cond; @@ -1050,6 +1054,7 @@ final class EvalFold extends AbstractEval implements Coster { String accuVar, Interpretable accu, String iterVar, + String iterVar2, Interpretable iterRange, Interpretable cond, Interpretable step, @@ -1057,6 +1062,7 @@ final class EvalFold extends AbstractEval implements Coster { super(id); this.accuVar = accuVar; this.iterVar = iterVar; + this.iterVar2 = iterVar2; this.iterRange = iterRange; this.accu = accu; this.cond = cond; @@ -1080,19 +1086,43 @@ public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { VarActivation iterCtx = new VarActivation(); iterCtx.parent = accuCtx; iterCtx.name = iterVar; + VarActivation iterCtx2 = null; + if (!iterVar2.isEmpty()) { + iterCtx2 = new VarActivation(); + iterCtx2.parent = iterCtx; + iterCtx2.name = iterVar2; + } IteratorT it = ((IterableT) foldRange).iterator(); + long index = 0L; while (it.hasNext() == True) { // Modify the iter var in the fold activation. - iterCtx.val = it.next(); + Val next = it.next(); + Activation loopCtx = iterCtx; + if (iterCtx2 != null) { + if (foldRange instanceof Lister) { + iterCtx.val = intOf(index); + iterCtx2.val = next; + } else if (foldRange instanceof Mapper) { + iterCtx.val = next; + iterCtx2.val = ((Mapper) foldRange).get(next); + } else { + return valOrErr( + foldRange, "got '%s', expected list or map type", foldRange.getClass().getName()); + } + loopCtx = iterCtx2; + } else { + iterCtx.val = next; + } + index++; // Evaluate the condition, terminate the loop if false. - Val c = cond.eval(iterCtx); + Val c = cond.eval(loopCtx); if (c == False) { break; } // Evalute the evaluation step into accu var. - accuCtx.val = step.eval(iterCtx); + accuCtx.val = step.eval(loopCtx); } // Compute the result. return result.eval(accuCtx); @@ -1141,6 +1171,9 @@ public String toString() { + ", iterVar='" + iterVar + '\'' + + ", iterVar2='" + + iterVar2 + + '\'' + ", iterRange=" + iterRange + ", accu=" @@ -1157,6 +1190,7 @@ public String toString() { final class EvalListFold extends AbstractEval implements Coster { final String iterVar; + final String iterVar2; final Interpretable iterRange; final Interpretable filter; final Interpretable transform; @@ -1165,12 +1199,14 @@ final class EvalListFold extends AbstractEval implements Coster { EvalListFold( long id, String iterVar, + String iterVar2, Interpretable iterRange, Interpretable filter, Interpretable transform, TypeAdapter adapter) { super(id); this.iterVar = iterVar; + this.iterVar2 = iterVar2; this.iterRange = iterRange; this.filter = filter; this.transform = transform; @@ -1188,13 +1224,37 @@ public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { VarActivation iterCtx = new VarActivation(); iterCtx.parent = ctx; iterCtx.name = iterVar; + VarActivation iterCtx2 = null; + if (!iterVar2.isEmpty()) { + iterCtx2 = new VarActivation(); + iterCtx2.parent = iterCtx; + iterCtx2.name = iterVar2; + } List values = new ArrayList<>(listCapacity(foldRange)); IteratorT it = ((IterableT) foldRange).iterator(); + long index = 0L; while (it.hasNext() == True) { - iterCtx.val = it.next(); + Val next = it.next(); + Activation loopCtx = iterCtx; + if (iterCtx2 != null) { + if (foldRange instanceof Lister) { + iterCtx.val = intOf(index); + iterCtx2.val = next; + } else if (foldRange instanceof Mapper) { + iterCtx.val = next; + iterCtx2.val = ((Mapper) foldRange).get(next); + } else { + return valOrErr( + foldRange, "got '%s', expected list or map type", foldRange.getClass().getName()); + } + loopCtx = iterCtx2; + } else { + iterCtx.val = next; + } + index++; if (filter != null) { - Val include = filter.eval(iterCtx); + Val include = filter.eval(loopCtx); if (include == False) { continue; } @@ -1203,7 +1263,7 @@ public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { } } - Val value = transform.eval(iterCtx); + Val value = transform.eval(loopCtx); if (isUnknownOrError(value)) { return value; } @@ -1250,6 +1310,149 @@ public String toString() { + ", iterVar='" + iterVar + '\'' + + ", iterVar2='" + + iterVar2 + + '\'' + + ", iterRange=" + + iterRange + + ", filter=" + + filter + + ", transform=" + + transform + + '}'; + } + } + + final class EvalMapFold extends AbstractEval implements Coster { + final String iterVar; + final String iterVar2; + final Interpretable iterRange; + final Interpretable filter; + final Interpretable transform; + private final TypeAdapter adapter; + + EvalMapFold( + long id, + String iterVar, + String iterVar2, + Interpretable iterRange, + Interpretable filter, + Interpretable transform, + TypeAdapter adapter) { + super(id); + this.iterVar = iterVar; + this.iterVar2 = iterVar2; + this.iterRange = iterRange; + this.filter = filter; + this.transform = transform; + this.adapter = adapter; + } + + @Override + public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { + Val foldRange = iterRange.eval(ctx); + if (!foldRange.type().hasTrait(Trait.IterableType)) { + return valOrErr( + foldRange, "got '%s', expected iterable type", foldRange.getClass().getName()); + } + + VarActivation iterCtx = new VarActivation(); + iterCtx.parent = ctx; + iterCtx.name = iterVar; + VarActivation iterCtx2 = null; + if (!iterVar2.isEmpty()) { + iterCtx2 = new VarActivation(); + iterCtx2.parent = iterCtx; + iterCtx2.name = iterVar2; + } + Map values = new HashMap<>(mapCapacity(foldRange)); + IteratorT it = ((IterableT) foldRange).iterator(); + long index = 0L; + while (it.hasNext() == True) { + Val next = it.next(); + Val key; + Activation loopCtx = iterCtx; + if (iterCtx2 != null) { + if (foldRange instanceof Lister) { + key = intOf(index); + iterCtx.val = key; + iterCtx2.val = next; + } else if (foldRange instanceof Mapper) { + key = next; + iterCtx.val = key; + iterCtx2.val = ((Mapper) foldRange).get(next); + } else { + return valOrErr( + foldRange, "got '%s', expected list or map type", foldRange.getClass().getName()); + } + loopCtx = iterCtx2; + } else { + key = next; + iterCtx.val = next; + } + index++; + + if (filter != null) { + Val include = filter.eval(loopCtx); + if (include == False) { + continue; + } + if (include != True) { + return noSuchOverload(null, Operator.Conditional.id, include); + } + } + + Val value = transform.eval(loopCtx); + if (isUnknownOrError(value)) { + return value; + } + values.put(key, value); + } + return MapT.newWrappedMap(adapter, values); + } + + private int mapCapacity(Val foldRange) { + if (foldRange.type().hasTrait(Trait.SizerType)) { + long size = ((Sizer) foldRange).size().intValue(); + if (size > 0 && size <= Integer.MAX_VALUE) { + long capacity = size * 4 / 3 + 1; + return capacity <= Integer.MAX_VALUE ? (int) capacity : Integer.MAX_VALUE; + } + } + return 0; + } + + @Override + public Cost cost() { + Cost range = estimateCost(iterRange); + Cost result = estimateCost(transform); + if (filter != null) { + result = result.add(estimateCost(filter)); + } + Val foldRange = iterRange.eval(emptyActivation()); + if (!foldRange.type().hasTrait(Trait.IterableType)) { + return Cost.Unknown; + } + long rangeCnt = 0L; + IteratorT it = ((IterableT) foldRange).iterator(); + while (it.hasNext() == True) { + it.next(); + rangeCnt++; + } + return range.add(result.multiply(rangeCnt)); + } + + @Override + public String toString() { + return "EvalMapFold{" + + "id=" + + id + + ", iterVar='" + + iterVar + + '\'' + + ", iterVar2='" + + iterVar2 + + '\'' + ", iterRange=" + iterRange + ", filter=" @@ -1853,6 +2056,7 @@ final class EvalExhaustiveFold extends AbstractEval implements Coster { // TODO combine with EvalFold private final String accuVar; private final String iterVar; + private final String iterVar2; private final Interpretable iterRange; private final Interpretable accu; private final Interpretable cond; @@ -1865,12 +2069,14 @@ final class EvalExhaustiveFold extends AbstractEval implements Coster { String accuVar, Interpretable iterRange, String iterVar, + String iterVar2, Interpretable cond, Interpretable step, Interpretable result) { super(id); this.accuVar = accuVar; this.iterVar = iterVar; + this.iterVar2 = iterVar2; this.iterRange = iterRange; this.accu = accu; this.cond = cond; @@ -1894,16 +2100,40 @@ public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { VarActivation iterCtx = new VarActivation(); iterCtx.parent = accuCtx; iterCtx.name = iterVar; + VarActivation iterCtx2 = null; + if (!iterVar2.isEmpty()) { + iterCtx2 = new VarActivation(); + iterCtx2.parent = iterCtx; + iterCtx2.name = iterVar2; + } IteratorT it = ((IterableT) foldRange).iterator(); + long index = 0L; while (it.hasNext() == True) { // Modify the iter var in the fold activation. - iterCtx.val = it.next(); + Val next = it.next(); + Activation loopCtx = iterCtx; + if (iterCtx2 != null) { + if (foldRange instanceof Lister) { + iterCtx.val = intOf(index); + iterCtx2.val = next; + } else if (foldRange instanceof Mapper) { + iterCtx.val = next; + iterCtx2.val = ((Mapper) foldRange).get(next); + } else { + return valOrErr( + foldRange, "got '%s', expected list or map type", foldRange.getClass().getName()); + } + loopCtx = iterCtx2; + } else { + iterCtx.val = next; + } + index++; // Evaluate the condition, but don't terminate the loop as this is exhaustive eval! - cond.eval(iterCtx); + cond.eval(loopCtx); // Evalute the evaluation step into accu var. - accuCtx.val = step.eval(iterCtx); + accuCtx.val = step.eval(loopCtx); } // Compute the result. return result.eval(accuCtx); @@ -1949,6 +2179,9 @@ public String toString() { + ", iterVar='" + iterVar + '\'' + + ", iterVar2='" + + iterVar2 + + '\'' + ", iterRange=" + iterRange + ", accu=" @@ -1983,14 +2216,38 @@ public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { VarActivation iterCtx = new VarActivation(); iterCtx.parent = ctx; iterCtx.name = fold.iterVar; + VarActivation iterCtx2 = null; + if (!fold.iterVar2.isEmpty()) { + iterCtx2 = new VarActivation(); + iterCtx2.parent = iterCtx; + iterCtx2.name = fold.iterVar2; + } List values = new ArrayList<>(fold.listCapacity(foldRange)); Val result = null; IteratorT it = ((IterableT) foldRange).iterator(); + long index = 0L; while (it.hasNext() == True) { - iterCtx.val = it.next(); + Val next = it.next(); + Activation loopCtx = iterCtx; + if (iterCtx2 != null) { + if (foldRange instanceof Lister) { + iterCtx.val = intOf(index); + iterCtx2.val = next; + } else if (foldRange instanceof Mapper) { + iterCtx.val = next; + iterCtx2.val = ((Mapper) foldRange).get(next); + } else { + return valOrErr( + foldRange, "got '%s', expected list or map type", foldRange.getClass().getName()); + } + loopCtx = iterCtx2; + } else { + iterCtx.val = next; + } + index++; - Val include = fold.filter != null ? fold.filter.eval(iterCtx) : True; - Val value = fold.transform.eval(iterCtx); + Val include = fold.filter != null ? fold.filter.eval(loopCtx) : True; + Val value = fold.transform.eval(loopCtx); if (include == False) { continue; } @@ -2022,6 +2279,91 @@ public String toString() { } } + /** EvalExhaustiveMapFold evaluates every filter and transform without short-circuiting. */ + final class EvalExhaustiveMapFold extends AbstractEval implements Coster { + private final EvalMapFold fold; + + EvalExhaustiveMapFold(EvalMapFold fold) { + super(fold.id); + this.fold = fold; + } + + @Override + public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { + Val foldRange = fold.iterRange.eval(ctx); + if (!foldRange.type().hasTrait(Trait.IterableType)) { + return valOrErr( + foldRange, "got '%s', expected iterable type", foldRange.getClass().getName()); + } + + VarActivation iterCtx = new VarActivation(); + iterCtx.parent = ctx; + iterCtx.name = fold.iterVar; + VarActivation iterCtx2 = null; + if (!fold.iterVar2.isEmpty()) { + iterCtx2 = new VarActivation(); + iterCtx2.parent = iterCtx; + iterCtx2.name = fold.iterVar2; + } + Map values = new HashMap<>(fold.mapCapacity(foldRange)); + Val result = null; + IteratorT it = ((IterableT) foldRange).iterator(); + long index = 0L; + while (it.hasNext() == True) { + Val next = it.next(); + Val key; + Activation loopCtx = iterCtx; + if (iterCtx2 != null) { + if (foldRange instanceof Lister) { + key = intOf(index); + iterCtx.val = key; + iterCtx2.val = next; + } else if (foldRange instanceof Mapper) { + key = next; + iterCtx.val = key; + iterCtx2.val = ((Mapper) foldRange).get(next); + } else { + return valOrErr( + foldRange, "got '%s', expected list or map type", foldRange.getClass().getName()); + } + loopCtx = iterCtx2; + } else { + key = next; + iterCtx.val = next; + } + index++; + + Val include = fold.filter != null ? fold.filter.eval(loopCtx) : True; + Val value = fold.transform.eval(loopCtx); + if (include == False) { + continue; + } + if (include != True) { + result = noSuchOverload(null, Operator.Conditional.id, include); + continue; + } + if (result == null) { + if (isUnknownOrError(value)) { + result = value; + } else { + values.put(key, value); + } + } + } + return result != null ? result : MapT.newWrappedMap(fold.adapter, values); + } + + @Override + public Cost cost() { + return fold.cost(); + } + + @Override + public String toString() { + return "EvalExhaustiveMapFold{" + fold + '}'; + } + } + /** evalAttr evaluates an Attribute value. */ final class EvalAttr extends AbstractEval implements InterpretableAttribute, Coster, Qualifier, Attribute { diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/InterpretableDecorator.java b/core/src/main/java/org/projectnessie/cel/interpreter/InterpretableDecorator.java index eb4f9978..a7665ef1 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/InterpretableDecorator.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/InterpretableDecorator.java @@ -36,11 +36,13 @@ import org.projectnessie.cel.interpreter.Interpretable.EvalExhaustiveConditional; import org.projectnessie.cel.interpreter.Interpretable.EvalExhaustiveFold; import org.projectnessie.cel.interpreter.Interpretable.EvalExhaustiveListFold; +import org.projectnessie.cel.interpreter.Interpretable.EvalExhaustiveMapFold; import org.projectnessie.cel.interpreter.Interpretable.EvalExhaustiveOr; import org.projectnessie.cel.interpreter.Interpretable.EvalFold; import org.projectnessie.cel.interpreter.Interpretable.EvalList; import org.projectnessie.cel.interpreter.Interpretable.EvalListFold; import org.projectnessie.cel.interpreter.Interpretable.EvalMap; +import org.projectnessie.cel.interpreter.Interpretable.EvalMapFold; import org.projectnessie.cel.interpreter.Interpretable.EvalOr; import org.projectnessie.cel.interpreter.Interpretable.EvalSetMembership; import org.projectnessie.cel.interpreter.Interpretable.EvalWatch; @@ -105,6 +107,7 @@ static InterpretableDecorator decDisableShortcircuits() { expr.accuVar, expr.iterRange, expr.iterVar, + expr.iterVar2, expr.cond, expr.step, expr.result); @@ -112,6 +115,9 @@ static InterpretableDecorator decDisableShortcircuits() { if (i instanceof EvalListFold) { return new EvalExhaustiveListFold((EvalListFold) i); } + if (i instanceof EvalMapFold) { + return new EvalExhaustiveMapFold((EvalMapFold) i); + } if (i instanceof InterpretableAttribute) { InterpretableAttribute expr = (InterpretableAttribute) i; if (expr.attr() instanceof ConditionalAttribute) { diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/InterpretablePlanner.java b/core/src/main/java/org/projectnessie/cel/interpreter/InterpretablePlanner.java index e4f72609..f550e22a 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/InterpretablePlanner.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/InterpretablePlanner.java @@ -59,6 +59,7 @@ import org.projectnessie.cel.interpreter.Interpretable.EvalList; import org.projectnessie.cel.interpreter.Interpretable.EvalListFold; import org.projectnessie.cel.interpreter.Interpretable.EvalMap; +import org.projectnessie.cel.interpreter.Interpretable.EvalMapFold; import org.projectnessie.cel.interpreter.Interpretable.EvalNe; import org.projectnessie.cel.interpreter.Interpretable.EvalObj; import org.projectnessie.cel.interpreter.Interpretable.EvalOr; @@ -621,6 +622,33 @@ Interpretable planCreateObj(Expr expr) { /** planComprehension generates an Interpretable fold operation. */ Interpretable planComprehension(Expr expr) { Comprehension fold = expr.getComprehensionExpr(); + MacroMapFold macroMapFold = macroMapFold(fold); + if (macroMapFold != null) { + Interpretable iterRange = plan(fold.getIterRange()); + if (iterRange == null) { + return null; + } + Interpretable filter = null; + if (macroMapFold.filter != null) { + filter = plan(macroMapFold.filter); + if (filter == null) { + return null; + } + } + Interpretable transform = plan(macroMapFold.transform); + if (transform == null) { + return null; + } + return new EvalMapFold( + expr.getId(), + fold.getIterVar(), + fold.getIterVar2(), + iterRange, + filter, + transform, + adapter); + } + MacroListFold macroListFold = macroListFold(fold); if (macroListFold != null) { Interpretable iterRange = plan(fold.getIterRange()); @@ -639,7 +667,13 @@ Interpretable planComprehension(Expr expr) { return null; } return new EvalListFold( - expr.getId(), fold.getIterVar(), iterRange, filter, transform, adapter); + expr.getId(), + fold.getIterVar(), + fold.getIterVar2(), + iterRange, + filter, + transform, + adapter); } Interpretable accu = plan(fold.getAccuInit()); @@ -663,7 +697,42 @@ Interpretable planComprehension(Expr expr) { return null; } return new EvalFold( - expr.getId(), fold.getAccuVar(), accu, fold.getIterVar(), iterRange, cond, step, result); + expr.getId(), + fold.getAccuVar(), + accu, + fold.getIterVar(), + fold.getIterVar2(), + iterRange, + cond, + step, + result); + } + + private static MacroMapFold macroMapFold(Comprehension fold) { + if (!isEmptyMap(fold.getAccuInit()) + || !isBoolConst(fold.getLoopCondition(), true) + || !isIdent(fold.getResult(), fold.getAccuVar())) { + return null; + } + + Expr step = fold.getLoopStep(); + Expr filter = null; + if (isCall(step, Operator.Conditional.id, 3)) { + Call conditional = step.getCallExpr(); + if (!isIdent(conditional.getArgs(2), fold.getAccuVar())) { + return null; + } + filter = conditional.getArgs(0); + step = conditional.getArgs(1); + } + + Expr transform = mapEntryValue(fold.getIterVar(), step); + if (transform == null + || referencesIdent(transform, fold.getAccuVar()) + || (filter != null && referencesIdent(filter, fold.getAccuVar()))) { + return null; + } + return new MacroMapFold(filter, transform); } private static MacroListFold macroListFold(Comprehension fold) { @@ -726,6 +795,25 @@ private static boolean isEmptyList(Expr expr) { && expr.getListExpr().getElementsCount() == 0; } + private static boolean isEmptyMap(Expr expr) { + return expr.getExprKindCase() == Expr.ExprKindCase.STRUCT_EXPR + && expr.getStructExpr().getMessageName().isEmpty() + && expr.getStructExpr().getEntriesCount() == 0; + } + + private static Expr mapEntryValue(String keyVar, Expr step) { + if (step.getExprKindCase() != Expr.ExprKindCase.STRUCT_EXPR + || !step.getStructExpr().getMessageName().isEmpty() + || step.getStructExpr().getEntriesCount() != 1) { + return null; + } + Entry entry = step.getStructExpr().getEntries(0); + if (!isIdent(entry.getMapKey(), keyVar)) { + return null; + } + return entry.getValue(); + } + private static boolean isBoolConst(Expr expr, boolean value) { return expr.getExprKindCase() == Expr.ExprKindCase.CONST_EXPR && expr.getConstExpr().getConstantKindCase() == Constant.ConstantKindCase.BOOL_VALUE @@ -768,9 +856,11 @@ private static boolean referencesIdent(Expr expr, String name) { return referencesIdent(comprehension.getIterRange(), name) || referencesIdent(comprehension.getAccuInit(), name) || (!comprehension.getIterVar().equals(name) + && !comprehension.getIterVar2().equals(name) && !comprehension.getAccuVar().equals(name) && referencesIdent(comprehension.getLoopCondition(), name)) || (!comprehension.getIterVar().equals(name) + && !comprehension.getIterVar2().equals(name) && !comprehension.getAccuVar().equals(name) && referencesIdent(comprehension.getLoopStep(), name)) || (!comprehension.getAccuVar().equals(name) @@ -790,6 +880,16 @@ private MacroListFold(Expr filter, Expr transform) { } } + private static final class MacroMapFold { + final Expr filter; + final Expr transform; + + private MacroMapFold(Expr filter, Expr transform) { + this.filter = filter; + this.transform = transform; + } + } + /** planConst generates a constant valued Interpretable. */ static Interpretable planConst(Expr expr) { Val val = constValue(expr.getConstExpr()); diff --git a/core/src/main/java/org/projectnessie/cel/parser/ExprHelper.java b/core/src/main/java/org/projectnessie/cel/parser/ExprHelper.java index 037b1901..5a24fd85 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/ExprHelper.java +++ b/core/src/main/java/org/projectnessie/cel/parser/ExprHelper.java @@ -103,6 +103,17 @@ Expr fold( Expr step, Expr result); + /** Fold creates a fold comprehension instruction with two iteration variables. */ + Expr fold( + String iterVar, + String iterVar2, + Expr iterRange, + String accuVar, + Expr accuInit, + Expr condition, + Expr step, + Expr result); + /** Ident creates an identifier Expr value. */ Expr ident(String name); diff --git a/core/src/main/java/org/projectnessie/cel/parser/ExprHelperImpl.java b/core/src/main/java/org/projectnessie/cel/parser/ExprHelperImpl.java index 0c4bf00e..0c39b695 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/ExprHelperImpl.java +++ b/core/src/main/java/org/projectnessie/cel/parser/ExprHelperImpl.java @@ -127,6 +127,20 @@ public Expr fold( nextMacroID(), iterVar, iterRange, accuVar, accuInit, condition, step, result); } + @Override + public Expr fold( + String iterVar, + String iterVar2, + Expr iterRange, + String accuVar, + Expr accuInit, + Expr condition, + Expr step, + Expr result) { + return parserHelper.newComprehension( + nextMacroID(), iterVar, iterVar2, iterRange, accuVar, accuInit, condition, step, result); + } + // Ident implements the ExprHelper interface method. @Override public Expr ident(String name) { diff --git a/core/src/main/java/org/projectnessie/cel/parser/Helper.java b/core/src/main/java/org/projectnessie/cel/parser/Helper.java index 9f5d447a..5e9f1a61 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/Helper.java +++ b/core/src/main/java/org/projectnessie/cel/parser/Helper.java @@ -157,12 +157,27 @@ Expr newComprehension( Expr condition, Expr step, Expr result) { + return newComprehension( + ctx, iterVar, "", iterRange, accuVar, accuInit, condition, step, result); + } + + Expr newComprehension( + Object ctx, + String iterVar, + String iterVar2, + Expr iterRange, + String accuVar, + Expr accuInit, + Expr condition, + Expr step, + Expr result) { return newExprBuilder(ctx) .setComprehensionExpr( Comprehension.newBuilder() .setAccuVar(accuVar) .setAccuInit(accuInit) .setIterVar(iterVar) + .setIterVar2(iterVar2) .setIterRange(iterRange) .setLoopCondition(condition) .setLoopStep(step) diff --git a/core/src/main/java/org/projectnessie/cel/parser/Macro.java b/core/src/main/java/org/projectnessie/cel/parser/Macro.java index c0532e2c..374289c1 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/Macro.java +++ b/core/src/main/java/org/projectnessie/cel/parser/Macro.java @@ -20,6 +20,7 @@ import com.google.api.expr.v1alpha1.Expr; import com.google.api.expr.v1alpha1.Expr.CreateList; +import com.google.api.expr.v1alpha1.Expr.CreateStruct.Entry; import com.google.api.expr.v1alpha1.Expr.ExprKindCase; import com.google.api.expr.v1alpha1.Expr.Select; import java.util.List; @@ -45,16 +46,20 @@ public final class Macro { * predicate holds. */ newReceiverMacro(Operator.All.id, 2, Macro::makeAll), + newReceiverMacro(Operator.All.id, 3, Macro::makeAll2), /* The macro "range.exists(var, predicate)", which is true if for at least one element in * range the predicate holds. */ newReceiverMacro(Operator.Exists.id, 2, Macro::makeExists), + newReceiverMacro(Operator.Exists.id, 3, Macro::makeExists2), /* The macro "range.exists_one(var, predicate)", which is true if for exactly one element * in range the predicate holds. */ newReceiverMacro(Operator.ExistsOne.id, 2, Macro::makeExistsOne), + newReceiverMacro(Operator.ExistsOne.id, 3, Macro::makeExistsOne2), + newReceiverMacro("existsOne", 3, Macro::makeExistsOne2), /* The macro "range.map(var, function)", applies the function to the vars in the range. */ newReceiverMacro(Operator.Map.id, 2, Macro::makeMap), @@ -69,6 +74,14 @@ public final class Macro { */ newReceiverMacro(Operator.Filter.id, 2, Macro::makeFilter), + /* The macro "range.transformList(index, value, function)" transforms entries to a list. */ + newReceiverMacro("transformList", 3, Macro::makeTransformList), + newReceiverMacro("transformList", 4, Macro::makeTransformList), + + /* The macro "range.transformMap(key, value, function)" transforms map values. */ + newReceiverMacro("transformMap", 3, Macro::makeTransformMap), + newReceiverMacro("transformMap", 4, Macro::makeTransformMap), + /* The macro "cel.bind(var, value, result)" binds a local variable for use in result. */ newReceiverMacro("bind", 3, Macro::makeBind)); @@ -151,14 +164,26 @@ static Expr makeAll(ExprHelper eh, Expr target, List args) { return makeQuantifier(QuantifierKind.quantifierAll, eh, target, args); } + static Expr makeAll2(ExprHelper eh, Expr target, List args) { + return makeQuantifier2(QuantifierKind.quantifierAll, eh, target, args); + } + static Expr makeExists(ExprHelper eh, Expr target, List args) { return makeQuantifier(QuantifierKind.quantifierExists, eh, target, args); } + static Expr makeExists2(ExprHelper eh, Expr target, List args) { + return makeQuantifier2(QuantifierKind.quantifierExists, eh, target, args); + } + static Expr makeExistsOne(ExprHelper eh, Expr target, List args) { return makeQuantifier(QuantifierKind.quantifierExistsOne, eh, target, args); } + static Expr makeExistsOne2(ExprHelper eh, Expr target, List args) { + return makeQuantifier2(QuantifierKind.quantifierExistsOne, eh, target, args); + } + static Expr makeQuantifier(QuantifierKind kind, ExprHelper eh, Expr target, List args) { String v = extractIdent(args.get(0)); if (v == null) { @@ -207,6 +232,59 @@ static Expr makeQuantifier(QuantifierKind kind, ExprHelper eh, Expr target, List return eh.fold(v, target, AccumulatorName, init, condition, step, result); } + static Expr makeQuantifier2(QuantifierKind kind, ExprHelper eh, Expr target, List args) { + String v = extractIdent(args.get(0)); + if (v == null) { + Location location = eh.offsetLocation(args.get(0).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + String v2 = extractIdent(args.get(1)); + if (v2 == null) { + Location location = eh.offsetLocation(args.get(1).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + + Supplier accuIdent = () -> eh.ident(AccumulatorName); + + Expr init; + Expr condition; + Expr step; + Expr result; + switch (kind) { + case quantifierAll: + init = eh.literalBool(true); + condition = eh.globalCall(Operator.NotStrictlyFalse.id, accuIdent.get()); + step = eh.globalCall(Operator.LogicalAnd.id, accuIdent.get(), args.get(2)); + result = accuIdent.get(); + break; + case quantifierExists: + init = eh.literalBool(false); + condition = + eh.globalCall( + Operator.NotStrictlyFalse.id, + eh.globalCall(Operator.LogicalNot.id, accuIdent.get())); + step = eh.globalCall(Operator.LogicalOr.id, accuIdent.get(), args.get(2)); + result = accuIdent.get(); + break; + case quantifierExistsOne: + Expr zeroExpr = eh.literalInt(0); + Expr oneExpr = eh.literalInt(1); + init = zeroExpr; + condition = eh.literalBool(true); + step = + eh.globalCall( + Operator.Conditional.id, + args.get(2), + eh.globalCall(Operator.Add.id, accuIdent.get(), oneExpr), + accuIdent.get()); + result = eh.globalCall(Operator.Equals.id, accuIdent.get(), oneExpr); + break; + default: + throw new ErrorWithLocation(null, String.format("unrecognized quantifier '%s'", kind)); + } + return eh.fold(v, v2, target, AccumulatorName, init, condition, step, result); + } + static Expr makeMap(ExprHelper eh, Expr target, List args) { String v = extractIdent(args.get(0)); if (v == null) { @@ -250,6 +328,75 @@ static Expr makeFilter(ExprHelper eh, Expr target, List args) { return eh.fold(v, target, AccumulatorName, init, condition, step, accuExpr); } + static Expr makeTransformList(ExprHelper eh, Expr target, List args) { + String v = extractIdent(args.get(0)); + if (v == null) { + Location location = eh.offsetLocation(args.get(0).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + String v2 = extractIdent(args.get(1)); + if (v2 == null) { + Location location = eh.offsetLocation(args.get(1).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + + Expr fn; + Expr filter; + + if (args.size() == 4) { + filter = args.get(2); + fn = args.get(3); + } else { + filter = null; + fn = args.get(2); + } + + Expr accuExpr = eh.ident(AccumulatorName); + Expr init = eh.newList(); + Expr condition = eh.literalBool(true); + Expr step = eh.globalCall(Operator.Add.id, accuExpr, eh.newList(fn)); + + if (filter != null) { + step = eh.globalCall(Operator.Conditional.id, filter, step, accuExpr); + } + return eh.fold(v, v2, target, AccumulatorName, init, condition, step, accuExpr); + } + + static Expr makeTransformMap(ExprHelper eh, Expr target, List args) { + String v = extractIdent(args.get(0)); + if (v == null) { + Location location = eh.offsetLocation(args.get(0).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + String v2 = extractIdent(args.get(1)); + if (v2 == null) { + Location location = eh.offsetLocation(args.get(1).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + + Expr fn; + Expr filter; + + if (args.size() == 4) { + filter = args.get(2); + fn = args.get(3); + } else { + filter = null; + fn = args.get(2); + } + + Expr accuExpr = eh.ident(AccumulatorName); + Expr init = eh.newMap(emptyList()); + Expr condition = eh.literalBool(true); + Entry transformedEntry = eh.newMapEntry(eh.ident(v), fn); + Expr step = eh.newMap(asList(transformedEntry)); + + if (filter != null) { + step = eh.globalCall(Operator.Conditional.id, filter, step, accuExpr); + } + return eh.fold(v, v2, target, AccumulatorName, init, condition, step, accuExpr); + } + static Expr makeBind(ExprHelper eh, Expr target, List args) { if (target == null || target.getExprKindCase() != ExprKindCase.IDENT_EXPR diff --git a/core/src/test/java/org/projectnessie/cel/CELTest.java b/core/src/test/java/org/projectnessie/cel/CELTest.java index 5c6a3300..389f0dec 100644 --- a/core/src/test/java/org/projectnessie/cel/CELTest.java +++ b/core/src/test/java/org/projectnessie/cel/CELTest.java @@ -196,6 +196,33 @@ void blockMacrosCanNameComprehensionVariables() { assertThat(env.program(astIss.getAst()).eval(emptyMap()).getVal()).isSameAs(True); } + @Test + void twoVariableComprehensionsBindListIndexAndValue() { + Env env = newEnv(); + + AstIssuesTuple astIss = env.compile("[2, 4, 6].transformList(i, v, i + v)[2] == 8"); + + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(emptyMap()).getVal()).isSameAs(True); + assertThat( + env.program(astIss.getAst(), evalOptions(OptExhaustiveEval)).eval(emptyMap()).getVal()) + .isSameAs(True); + } + + @Test + void twoVariableComprehensionsBindMapKeyAndValue() { + Env env = newEnv(); + + AstIssuesTuple astIss = + env.compile("{'foo': 'bar'}.transformMap(k, v, k + v)['foo'] == 'foobar'"); + + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(emptyMap()).getVal()).isSameAs(True); + assertThat( + env.program(astIss.getAst(), evalOptions(OptExhaustiveEval)).eval(emptyMap()).getVal()) + .isSameAs(True); + } + @Test void AstToString() { Env stdEnv = newEnv(); From ddd4100f9548f38fdf6c56011bf01befac8451a0 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Fri, 17 Jul 2026 18:20:05 +0200 Subject: [PATCH 33/33] Note about CEL strong-enums --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index fee5f965..2e61ff56 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The CEL specification can be found [here](https://github.com/google/cel-spec). - [Motivation](#motivation) - [Arbitrary Java classes](#arbitrary-java-classes) - [Unsigned 64-bit `uint`](#unsigned-64-bit-uint) + - [Protobuf enum semantics](#protobuf-enum-semantics) - [Native image and package verification](#native-image-and-package-verification) - [Not yet implemented](#not-yet-implemented) - [Unclear double-to-int rounding behavior](#unclear-double-to-int-rounding-behavior) @@ -394,6 +395,24 @@ but `123u == 123u` and `123 == 123` are. If you have a `uint32` or `uint64` in protobuf objects, or use `uint`s in CEL expressions, wrap those values with `org.projectnessie.cel.common.ULong`. +### Protobuf enum semantics + +CEL-Java follows the CEL-Spec v0.25.2 language definition for protobuf enum values: protobuf enum +constants and enum fields are represented as CEL `int` values. + +The upstream CEL-Spec conformance testdata also contains strong-enum cases where enum values +preserve their enum type. CEL-Java does not currently enable those strong-enum conformance cases. +They are not part of the v0.25.2 language-definition baseline and are mutually incompatible with the +legacy enum-as-int conformance sections in a single-mode runtime. + +Use numeric enum values directly, or use `int(...)` when writing expressions that should remain clear +if strong enum support is added in the future: + +```cel +TestAllTypes.NestedEnum.BAR == 1 +int(TestAllTypes.NestedEnum.BAR) == 1 +``` + ### Native image and package verification Native-image and package behavior must be verified in the consuming application's exact build.