From 04eaecfa244f68ff41cd43b0104fc447ba77af0b Mon Sep 17 00:00:00 2001 From: Dmitri Plotnikov Date: Thu, 15 Oct 2026 21:46:42 -0700 Subject: [PATCH] Add canonical CEL signature parsing and YAML environment signature support. - Add `parseTypeSignature` and `parseFunctionSignature` to `CelSignatureUtils` for parsing canonical CEL type and function overload signatures. - Support the `signature` field on function overloads in `CelEnvironmentYamlParser` and allow `LibrarySubset` overload selectors to match by canonical signature. - Add `useTypeSignatures` support to `CelEnvironmentYamlSerializer.toYaml` to serialize variable types, function overload signatures, and return types using concise signature strings. PiperOrigin-RevId: 1000599959 --- .../src/main/java/dev/cel/bundle/BUILD.bazel | 10 + .../main/java/dev/cel/bundle/CelBuilder.java | 14 + .../java/dev/cel/bundle/CelEnvironment.java | 161 ++++- .../cel/bundle/CelEnvironmentExporter.java | 8 +- .../cel/bundle/CelEnvironmentYamlParser.java | 93 ++- .../bundle/CelEnvironmentYamlSerializer.java | 112 +++- .../main/java/dev/cel/bundle/CelFunction.java | 244 +++++++ .../src/main/java/dev/cel/bundle/CelImpl.java | 17 + .../dev/cel/bundle/CelEnvironmentTest.java | 98 +++ .../bundle/CelEnvironmentYamlParserTest.java | 180 ++++- .../CelEnvironmentYamlSerializerTest.java | 110 ++++ .../java/dev/cel/bundle/CelFunctionTest.java | 245 +++++++ .../dev/cel/checker/CelOverloadDeclTest.java | 40 ++ common/BUILD.bazel | 12 + .../src/main/java/dev/cel/common/BUILD.bazel | 34 + .../java/dev/cel/common/CelFunctionDecl.java | 48 +- .../java/dev/cel/common/CelOverloadDecl.java | 79 ++- .../dev/cel/common/CelSignatureUtils.java | 615 ++++++++++++++++++ .../src/test/java/dev/cel/common/BUILD.bazel | 4 + .../dev/cel/common/CelSignatureUtilsTest.java | 319 +++++++++ .../resources/environment/extended_env.yaml | 6 + .../resources/environment/subset_env.yaml | 4 +- 22 files changed, 2401 insertions(+), 52 deletions(-) create mode 100644 bundle/src/main/java/dev/cel/bundle/CelFunction.java create mode 100644 bundle/src/test/java/dev/cel/bundle/CelFunctionTest.java create mode 100644 common/src/main/java/dev/cel/common/CelSignatureUtils.java create mode 100644 common/src/test/java/dev/cel/common/CelSignatureUtilsTest.java diff --git a/bundle/src/main/java/dev/cel/bundle/BUILD.bazel b/bundle/src/main/java/dev/cel/bundle/BUILD.bazel index 40ce8df32..b3d1dab94 100644 --- a/bundle/src/main/java/dev/cel/bundle/BUILD.bazel +++ b/bundle/src/main/java/dev/cel/bundle/BUILD.bazel @@ -11,6 +11,7 @@ package( CEL_SOURCES = [ "Cel.java", "CelBuilder.java", + "CelFunction.java", ] java_library( @@ -19,9 +20,12 @@ java_library( tags = [ ], deps = [ + "//:auto_value", "//checker:checker_legacy_environment", "//checker:proto_type_mask", "//checker:standard_decl", + "//common:cel_function_decl", + "//common:cel_overload_decl", "//common:compiler_common", "//common:container", "//common:options", @@ -35,6 +39,7 @@ java_library( "@cel_spec//proto/cel/expr:checked_java_proto", "@maven//:com_google_code_findbugs_annotations", "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:com_google_guava_guava", "@maven//:com_google_protobuf_protobuf_java", ], ) @@ -110,12 +115,14 @@ java_library( "//checker:standard_decl", "//common:cel_function_decl", "//common:cel_overload_decl", + "//common:cel_signature_utils", "//common:cel_var_decl", "//common:container", "//common:options", "//common:source", "//common/formats:parser_context", "//common/types", + "//common/types:cel_types", "//common/types:type_providers", "//compiler:compiler_builder", "//extensions", @@ -147,13 +154,16 @@ java_library( deps = [ ":environment", ":environment_exception", + "//common:cel_signature_utils", "//common:compiler_common", "//common:container", "//common/formats:file_source", "//common/formats:parser_context", + "//common/formats:value_string", "//common/formats:yaml_helper", "//common/formats:yaml_parser_context_impl", "//common/internal", + "//common/types:type_providers", "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:com_google_guava_guava", "@maven//:org_jspecify_jspecify", diff --git a/bundle/src/main/java/dev/cel/bundle/CelBuilder.java b/bundle/src/main/java/dev/cel/bundle/CelBuilder.java index 53eb0126b..b3dcc270d 100644 --- a/bundle/src/main/java/dev/cel/bundle/CelBuilder.java +++ b/bundle/src/main/java/dev/cel/bundle/CelBuilder.java @@ -117,6 +117,20 @@ public interface CelBuilder { @CanIgnoreReturnValue CelBuilder addFunctionDeclarations(Iterable celFunctionDecls); + /** + * Add one or more {@link CelFunction} definitions (combining compile-time declarations and + * runtime bindings) to the CEL environment. + */ + @CanIgnoreReturnValue + CelBuilder addFunctions(CelFunction... functions); + + /** + * Add a collection of {@link CelFunction} definitions (combining compile-time declarations and + * runtime bindings) to the CEL environment. + */ + @CanIgnoreReturnValue + CelBuilder addFunctions(Iterable functions); + /** Add variable declaration {@code CelVarDecl} to the CEL environment. */ @CanIgnoreReturnValue CelBuilder addVarDeclarations(CelVarDecl... celVarDecls); diff --git a/bundle/src/main/java/dev/cel/bundle/CelEnvironment.java b/bundle/src/main/java/dev/cel/bundle/CelEnvironment.java index 192f368b3..9cf17c3a0 100644 --- a/bundle/src/main/java/dev/cel/bundle/CelEnvironment.java +++ b/bundle/src/main/java/dev/cel/bundle/CelEnvironment.java @@ -35,12 +35,17 @@ import dev.cel.common.CelFunctionDecl; import dev.cel.common.CelOptions; import dev.cel.common.CelOverloadDecl; +import dev.cel.common.CelSignatureUtils; import dev.cel.common.CelVarDecl; import dev.cel.common.Source; +import dev.cel.common.types.CelKind; import dev.cel.common.types.CelType; import dev.cel.common.types.CelTypeProvider; +import dev.cel.common.types.CelTypes; import dev.cel.common.types.ListType; import dev.cel.common.types.MapType; +import dev.cel.common.types.NullableType; +import dev.cel.common.types.OpaqueType; import dev.cel.common.types.OptionalType; import dev.cel.common.types.SimpleType; import dev.cel.common.types.TypeParamType; @@ -55,6 +60,7 @@ import dev.cel.runtime.CelRuntimeLibrary; import java.util.Arrays; import java.util.Optional; +import java.util.function.Function; import java.util.function.ObjIntConsumer; /** @@ -545,8 +551,12 @@ public CelFunctionDecl toCelFunctionDecl(CelTypeProvider celTypeProvider) { public abstract static class OverloadDecl { /** - * A unique overload ID. Required. This should follow the typical naming convention used in CEL - * (e.g: targetType_func_argType1_argType...) + * A unique overload ID. Required. + * + *

This can follow the typical naming convention used in CEL (e.g. {@code + * targetType_func_argType1_argType...}) or use a canonical overload signature (e.g. {@code + * targetType.func(argType1,argType2)}). When parsed from YAML with a {@code signature} field + * and no explicit {@code id}, this defaults to the {@code signature} string. */ public abstract String id(); @@ -709,20 +719,70 @@ static TypeDecl ofTypeParam(String typeParamName) { return newBuilder().setName(typeParamName).setIsTypeParam(true).build(); } + /** Converts a {@link CelType} into a {@link TypeDecl}. */ + static TypeDecl fromCelType(CelType type) { + if (type.kind() == CelKind.ERROR + || type.kind() == CelKind.UNSPECIFIED + || (type.kind() == CelKind.OPAQUE && type.name().equals("function"))) { + throw new IllegalArgumentException("Unsupported type: " + type.name()); + } + if (type instanceof NullableType) { + switch (((NullableType) type).targetType().kind()) { + case BOOL: + return TypeDecl.create(CelTypes.BOOL_WRAPPER_MESSAGE); + case BYTES: + return TypeDecl.create(CelTypes.BYTES_WRAPPER_MESSAGE); + case DOUBLE: + return TypeDecl.create(CelTypes.DOUBLE_WRAPPER_MESSAGE); + case INT: + return TypeDecl.create(CelTypes.INT64_WRAPPER_MESSAGE); + case STRING: + return TypeDecl.create(CelTypes.STRING_WRAPPER_MESSAGE); + case UINT: + return TypeDecl.create(CelTypes.UINT64_WRAPPER_MESSAGE); + default: + throw new IllegalArgumentException("Unsupported wrapper type: " + type.name()); + } + } + return TypeDecl.newBuilder() + .setName(type.name()) + .setIsTypeParam(type.kind() == CelKind.TYPE_PARAM) + .addParams( + type.parameters().stream().map(TypeDecl::fromCelType).collect(toImmutableList())) + .build(); + } + public static TypeDecl.Builder newBuilder() { return new AutoValue_CelEnvironment_TypeDecl.Builder().setIsTypeParam(false); } /** Converts this type declaration into a {@link CelType}. */ public CelType toCelType(CelTypeProvider celTypeProvider) { + return toCelType(celTypeProvider::findType); + } + + CelType toCelType() { + return toCelType(typeName -> Optional.of(OpaqueType.create(typeName))); + } + + private CelType toCelType(Function> typeFinder) { + if (isTypeParam()) { + if (!params().isEmpty()) { + throw new IllegalArgumentException( + "Type param has unexpected param count: " + params().size()); + } + return TypeParamType.create(name()); + } switch (name()) { + case "*error*": + throw new IllegalArgumentException("Unsupported type name: " + name()); case "list": if (params().size() != 1) { throw new IllegalArgumentException( "List type has unexpected param count: " + params().size()); } - CelType elementType = params().get(0).toCelType(celTypeProvider); + CelType elementType = params().get(0).toCelType(typeFinder); return ListType.create(elementType); case "map": if (params().size() != 2) { @@ -730,38 +790,62 @@ public CelType toCelType(CelTypeProvider celTypeProvider) { "Map type has unexpected param count: " + params().size()); } - CelType keyType = params().get(0).toCelType(celTypeProvider); - CelType valueType = params().get(1).toCelType(celTypeProvider); + CelType keyType = params().get(0).toCelType(typeFinder); + CelType valueType = params().get(1).toCelType(typeFinder); return MapType.create(keyType, valueType); case "type": checkState( params().size() == 1, "Expected 1 parameter for type, got %s", params().size()); - return TypeType.create(params().get(0).toCelType(celTypeProvider)); + return TypeType.create(params().get(0).toCelType(typeFinder)); + case "dyn": + return SimpleType.DYN; + case "null": + return SimpleType.NULL_TYPE; + case "any": + return SimpleType.ANY; + case "timestamp": + return SimpleType.TIMESTAMP; + case "duration": + return SimpleType.DURATION; + case "bool_wrapper": + return NullableType.create(SimpleType.BOOL); + case "bytes_wrapper": + return NullableType.create(SimpleType.BYTES); + case "double_wrapper": + return NullableType.create(SimpleType.DOUBLE); + case "int_wrapper": + return NullableType.create(SimpleType.INT); + case "string_wrapper": + return NullableType.create(SimpleType.STRING); + case "uint_wrapper": + return NullableType.create(SimpleType.UINT); + case OptionalType.NAME: + checkState( + params().size() == 1, + "Optional type must have exactly 1 parameter. Found %s", + params().size()); + return OptionalType.create(params().get(0).toCelType(typeFinder)); default: - if (isTypeParam()) { - return TypeParamType.create(name()); - } - - if (name().equals("dyn")) { - return SimpleType.DYN; - } - CelType simpleType = SimpleType.findByName(name()).orElse(null); if (simpleType != null) { return simpleType; } - if (name().equals(OptionalType.NAME)) { - checkState( - params().size() == 1, - "Optional type must have exactly 1 parameter. Found %s", - params().size()); - return OptionalType.create(params().get(0).toCelType(celTypeProvider)); + CelType wellKnownType = CelTypes.getWellKnownCelType(name()).orElse(null); + if (wellKnownType != null) { + return wellKnownType; } - return celTypeProvider - .findType(name()) - .orElseThrow(() -> new IllegalArgumentException("Undefined type name: " + name())); + CelType resolvedType = + typeFinder + .apply(name()) + .orElseThrow( + () -> new IllegalArgumentException("Undefined type name: " + name())); + if (!params().isEmpty()) { + return resolvedType.withParameters( + params().stream().map(p -> p.toCelType(typeFinder)).collect(toImmutableList())); + } + return resolvedType; } } } @@ -1057,6 +1141,15 @@ public static FunctionSelector.Builder newBuilder() { .setOverloads(ImmutableSet.of()); } + /** + * Creates a new {@link FunctionSelector} matching the given function {@code name} and {@code + * overloads}. + * + * @param name the function name to match + * @param overloads the overload IDs or canonical overload signatures (e.g. {@code + * "add_bytes"} or {@code "_+_(bytes,bytes)"}) to match, or empty to match all overloads + * of the function + */ public static FunctionSelector create(String name, ImmutableSet overloads) { return newBuilder() .setName(name) @@ -1072,6 +1165,7 @@ private static boolean matchesAny( StandardOverload overload, ImmutableSet selectors) { String functionName = function.functionDecl().name(); + String overloadSignature = null; for (FunctionSelector functionSelector : selectors) { if (!functionSelector.name().equals(functionName)) { continue; @@ -1081,12 +1175,26 @@ private static boolean matchesAny( return true; } - String overloadId = overload.celOverloadDecl().overloadId(); + CelOverloadDecl celOverload = overload.celOverloadDecl(); + String overloadId = celOverload.overloadId(); for (OverloadSelector overloadSelector : functionSelector.overloads()) { if (overloadSelector.id().equals(overloadId)) { return true; } } + + if (overloadSignature == null) { + overloadSignature = + CelSignatureUtils.makeOverloadSignature( + functionName, celOverload.parameterTypes(), celOverload.isInstanceFunction()); + } + if (!overloadSignature.equals(overloadId)) { + for (OverloadSelector overloadSelector : functionSelector.overloads()) { + if (overloadSelector.id().equals(overloadSignature)) { + return true; + } + } + } } return false; } @@ -1096,7 +1204,10 @@ private static boolean matchesAny( @AutoValue public abstract static class OverloadSelector { - /** An overload ID. Required. Follows the same format as {@link OverloadDecl#id()} */ + /** + * An overload ID or canonical overload signature (e.g. {@code "add_bytes"} or {@code + * "_+_(bytes,bytes)"}). Required. Follows the same format as {@link OverloadDecl#id()}. + */ public abstract String id(); /** Builder for {@link OverloadSelector}. */ diff --git a/bundle/src/main/java/dev/cel/bundle/CelEnvironmentExporter.java b/bundle/src/main/java/dev/cel/bundle/CelEnvironmentExporter.java index 462de653d..9c0fc3a7b 100644 --- a/bundle/src/main/java/dev/cel/bundle/CelEnvironmentExporter.java +++ b/bundle/src/main/java/dev/cel/bundle/CelEnvironmentExporter.java @@ -41,7 +41,6 @@ import dev.cel.common.CelVarDecl; import dev.cel.common.internal.EnvVisitable; import dev.cel.common.internal.EnvVisitor; -import dev.cel.common.types.CelKind; import dev.cel.common.types.CelType; import dev.cel.compiler.CelCompiler; import dev.cel.extensions.CelExtensionLibrary; @@ -492,12 +491,7 @@ private CelEnvironment.OverloadDecl toCelEnvOverloadDecl(CelOverloadDecl overloa } private CelEnvironment.TypeDecl toCelEnvTypeDecl(CelType type) { - return CelEnvironment.TypeDecl.newBuilder() - .setName(type.name()) - .setIsTypeParam(type.kind() == CelKind.TYPE_PARAM) - .addParams( - type.parameters().stream().map(this::toCelEnvTypeDecl).collect(toImmutableList())) - .build(); + return CelEnvironment.TypeDecl.fromCelType(type); } /** Wrapper for CelOverloadDecl, associating it with the corresponding function name. */ diff --git a/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlParser.java b/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlParser.java index 821ca6586..d21d26063 100644 --- a/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlParser.java +++ b/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlParser.java @@ -38,11 +38,15 @@ import dev.cel.bundle.CelEnvironment.VariableDecl; import dev.cel.common.CelContainer; import dev.cel.common.CelIssue; +import dev.cel.common.CelSignatureUtils; +import dev.cel.common.CelSignatureUtils.ParsedFunctionOverload; import dev.cel.common.formats.CelFileSource; import dev.cel.common.formats.ParserContext; +import dev.cel.common.formats.ValueString; import dev.cel.common.formats.YamlHelper.YamlNodeType; import dev.cel.common.formats.YamlParserContextImpl; import dev.cel.common.internal.CelCodePointArray; +import dev.cel.common.types.CelType; import java.util.Optional; import org.jspecify.annotations.Nullable; import org.yaml.snakeyaml.DumperOptions.FlowStyle; @@ -450,6 +454,7 @@ private FunctionDecl parseFunction(ParserContext ctx, Node node) { MappingNode functionMap = (MappingNode) node; FunctionDecl.Builder builder = FunctionDecl.newBuilder(); + Node overloadsNode = null; for (NodeTuple nodeTuple : functionMap.getValue()) { Node keyNode = nodeTuple.getKeyNode(); long keyId = ctx.collectMetadata(keyNode); @@ -460,7 +465,7 @@ private FunctionDecl parseFunction(ParserContext ctx, Node node) { builder.setName(newString(ctx, valueNode)); break; case "overloads": - builder.setOverloads(parseOverloads(ctx, valueNode)); + overloadsNode = valueNode; break; case "description": builder.setDescription(newString(ctx, valueNode).trim()); @@ -471,6 +476,10 @@ private FunctionDecl parseFunction(ParserContext ctx, Node node) { } } + if (overloadsNode != null) { + builder.setOverloads(parseOverloads(ctx, overloadsNode, builder.name().orElse(""))); + } + if (!assertRequiredFields(ctx, functionId, builder.getMissingRequiredFieldNames())) { return ERROR_FUNCTION_DECL; } @@ -478,7 +487,8 @@ private FunctionDecl parseFunction(ParserContext ctx, Node node) { return builder.build(); } - private ImmutableSet parseOverloads(ParserContext ctx, Node node) { + private ImmutableSet parseOverloads( + ParserContext ctx, Node node, String functionName) { long listId = ctx.collectMetadata(node); ImmutableSet.Builder overloadSetBuilder = ImmutableSet.builder(); if (!assertYamlType(ctx, listId, node, YamlNodeType.LIST)) { @@ -494,6 +504,8 @@ private ImmutableSet parseOverloads(ParserContext ctx, Node MappingNode mapNode = (MappingNode) overloadMapNode; OverloadDecl.Builder overloadDeclBuilder = OverloadDecl.newBuilder(); + boolean hasSignature = false; + boolean hasTargetOrArgs = false; for (NodeTuple nodeTuple : mapNode.getValue()) { Node keyNode = nodeTuple.getKeyNode(); long keyId = ctx.collectMetadata(keyNode); @@ -507,13 +519,46 @@ private ImmutableSet parseOverloads(ParserContext ctx, Node case "id": overloadDeclBuilder.setId(newString(ctx, valueNode)); break; + case "signature": + if (hasTargetOrArgs) { + if (!overloadDeclBuilder.id().isPresent()) { + overloadDeclBuilder.setId(ERROR); + } + ctx.reportError( + keyId, + String.format( + "'signature' tag cannot be used together with 'target' or 'args': %s", + fieldName)); + break; + } + hasSignature = true; + parseOverloadSignature(ctx, valueNode, functionName, overloadDeclBuilder); + break; case "args": + if (hasSignature) { + ctx.reportError( + keyId, + String.format( + "'signature' tag cannot be used together with 'target' or 'args': %s", + fieldName)); + break; + } + hasTargetOrArgs = true; overloadDeclBuilder.addArguments(parseOverloadArguments(ctx, valueNode)); break; case "return": overloadDeclBuilder.setReturnType(parseTypeDecl(ctx, valueNode)); break; case "target": + if (hasSignature) { + ctx.reportError( + keyId, + String.format( + "'signature' tag cannot be used together with 'target' or 'args': %s", + fieldName)); + break; + } + hasTargetOrArgs = true; overloadDeclBuilder.setTarget(parseTypeDecl(ctx, valueNode)); break; case "examples": @@ -534,6 +579,50 @@ private ImmutableSet parseOverloads(ParserContext ctx, Node return overloadSetBuilder.build(); } + private static void parseOverloadSignature( + ParserContext ctx, + Node valueNode, + String functionName, + OverloadDecl.Builder overloadDeclBuilder) { + ValueString signatureValue = ctx.newYamlString(valueNode); + String signature = signatureValue.value(); + if (!overloadDeclBuilder.id().isPresent()) { + overloadDeclBuilder.setId(signature); + } + if (signature.equals(ERROR)) { + return; + } + + ParsedFunctionOverload parsedSignature; + try { + parsedSignature = CelSignatureUtils.parseFunctionSignature(signature); + } catch (IllegalArgumentException e) { + ctx.reportError(signatureValue.id(), e.getMessage()); + return; + } + + if (!functionName.isEmpty() && !parsedSignature.functionName().equals(functionName)) { + ctx.reportError( + signatureValue.id(), + String.format( + "Function overload name '%s' does not match function name '%s'", + parsedSignature.functionName(), functionName)); + return; + } + + ImmutableList argTypes = parsedSignature.argTypes(); + if (parsedSignature.isMember()) { + overloadDeclBuilder.setTarget(TypeDecl.fromCelType(argTypes.get(0))); + for (int i = 1; i < argTypes.size(); i++) { + overloadDeclBuilder.addArguments(TypeDecl.fromCelType(argTypes.get(i))); + } + } else { + for (CelType argType : argTypes) { + overloadDeclBuilder.addArguments(TypeDecl.fromCelType(argType)); + } + } + } + private static ImmutableList parseOverloadExamples(ParserContext ctx, Node node) { long listValueId = ctx.collectMetadata(node); if (!assertYamlType(ctx, listValueId, node, YamlNodeType.LIST)) { diff --git a/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlSerializer.java b/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlSerializer.java index 9d5b4b69e..58ce6fac1 100644 --- a/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlSerializer.java +++ b/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlSerializer.java @@ -21,6 +21,8 @@ import dev.cel.bundle.CelEnvironment.LibrarySubset.FunctionSelector; import dev.cel.bundle.CelEnvironment.LibrarySubset.OverloadSelector; import dev.cel.common.CelContainer; +import dev.cel.common.CelSignatureUtils; +import dev.cel.common.types.CelType; import java.util.Comparator; import java.util.Map; import org.yaml.snakeyaml.DumperOptions; @@ -42,10 +44,16 @@ private static DumperOptions initDumperOptions() { private static final DumperOptions YAML_OPTIONS = initDumperOptions(); - private static final CelEnvironmentYamlSerializer INSTANCE = new CelEnvironmentYamlSerializer(); + private static final CelEnvironmentYamlSerializer DEFAULT_INSTANCE = + new CelEnvironmentYamlSerializer(/* useTypeSignatures= */ false); + private static final CelEnvironmentYamlSerializer SIGNATURE_INSTANCE = + new CelEnvironmentYamlSerializer(/* useTypeSignatures= */ true); - private CelEnvironmentYamlSerializer() { + private final boolean useTypeSignatures; + + private CelEnvironmentYamlSerializer(boolean useTypeSignatures) { super(YAML_OPTIONS); + this.useTypeSignatures = useTypeSignatures; this.multiRepresenters.put(CelEnvironment.class, new RepresentCelEnvironment()); this.multiRepresenters.put(CelEnvironment.VariableDecl.class, new RepresentVariableDecl()); this.multiRepresenters.put(CelEnvironment.FunctionDecl.class, new RepresentFunctionDecl()); @@ -65,8 +73,14 @@ private CelEnvironmentYamlSerializer() { } public static String toYaml(CelEnvironment environment) { + return toYaml(environment, /* useTypeSignatures= */ false); + } + + public static String toYaml(CelEnvironment environment, boolean useTypeSignatures) { // Yaml is not thread-safe, so we create a new instance for each serialization. - Yaml yaml = new Yaml(INSTANCE, YAML_OPTIONS); + CelEnvironmentYamlSerializer serializer = + useTypeSignatures ? SIGNATURE_INSTANCE : DEFAULT_INSTANCE; + Yaml yaml = new Yaml(serializer, YAML_OPTIONS); return yaml.dump(environment); } @@ -159,9 +173,28 @@ private final class RepresentVariableDecl implements Represent { public Node representData(Object data) { CelEnvironment.VariableDecl variable = (CelEnvironment.VariableDecl) data; ImmutableMap.Builder configMap = new ImmutableMap.Builder<>(); - configMap.put("name", variable.name()).put("type_name", variable.type().name()); - if (!variable.type().params().isEmpty()) { - configMap.put("params", variable.type().params()); + configMap.put("name", variable.name()); + if (variable.description().isPresent() && !variable.description().get().isEmpty()) { + configMap.put("description", variable.description().get()); + } + boolean signatureGenerated = false; + if (useTypeSignatures) { + try { + String typeSignature = CelSignatureUtils.makeTypeSignature(variable.type().toCelType()); + configMap.put("type", typeSignature); + signatureGenerated = true; + } catch (IllegalArgumentException | IllegalStateException e) { + // Fall back to verbose representation + } + } + if (!signatureGenerated) { + configMap.put("type_name", variable.type().name()); + if (variable.type().isTypeParam()) { + configMap.put("is_type_param", variable.type().isTypeParam()); + } + if (!variable.type().params().isEmpty()) { + configMap.put("params", variable.type().params()); + } } return represent(configMap.buildOrThrow()); } @@ -172,7 +205,15 @@ private final class RepresentFunctionDecl implements Represent { public Node representData(Object data) { CelEnvironment.FunctionDecl function = (CelEnvironment.FunctionDecl) data; ImmutableMap.Builder configMap = new ImmutableMap.Builder<>(); - configMap.put("name", function.name()).put("overloads", function.overloads().asList()); + configMap.put("name", function.name()); + if (function.description().isPresent() && !function.description().get().isEmpty()) { + configMap.put("description", function.description().get()); + } + ImmutableList.Builder overloads = ImmutableList.builder(); + for (CelEnvironment.OverloadDecl overload : function.overloads()) { + overloads.add(representOverloadDecl(function.name(), overload)); + } + configMap.put("overloads", overloads.build()); return represent(configMap.buildOrThrow()); } } @@ -181,14 +222,63 @@ private final class RepresentOverloadDecl implements Represent { @Override public Node representData(Object data) { CelEnvironment.OverloadDecl overload = (CelEnvironment.OverloadDecl) data; - ImmutableMap.Builder configMap = new ImmutableMap.Builder<>(); - configMap.put("id", overload.id()); + return represent(representOverloadDecl("", overload)); + } + } + + private ImmutableMap representOverloadDecl( + String functionName, CelEnvironment.OverloadDecl overload) { + ImmutableMap.Builder configMap = new ImmutableMap.Builder<>(); + boolean signatureGenerated = false; + String signature = null; + if (useTypeSignatures && !functionName.isEmpty()) { + try { + ImmutableList.Builder paramTypes = ImmutableList.builder(); + if (overload.target().isPresent()) { + paramTypes.add(overload.target().get().toCelType()); + } + for (CelEnvironment.TypeDecl arg : overload.arguments()) { + paramTypes.add(arg.toCelType()); + } + signature = + CelSignatureUtils.makeOverloadSignature( + functionName, paramTypes.build(), overload.target().isPresent()); + signatureGenerated = true; + } catch (IllegalArgumentException | IllegalStateException e) { + // Fall back to verbose representation + } + } + if (!overload.id().isEmpty()) { + if (!signatureGenerated || !overload.id().equals(signature)) { + configMap.put("id", overload.id()); + } + } + if (signatureGenerated) { + configMap.put("signature", signature); + } else { if (overload.target().isPresent()) { configMap.put("target", overload.target().get()); } - configMap.put("args", overload.arguments()).put("return", overload.returnType()); - return represent(configMap.buildOrThrow()); + configMap.put("args", overload.arguments()); + } + if (!overload.examples().isEmpty()) { + configMap.put("examples", overload.examples()); + } + boolean returnTypeSignatureGenerated = false; + if (useTypeSignatures) { + try { + String returnSignature = + CelSignatureUtils.makeTypeSignature(overload.returnType().toCelType()); + configMap.put("return", returnSignature); + returnTypeSignatureGenerated = true; + } catch (IllegalArgumentException | IllegalStateException e) { + // Fall back to verbose representation + } + } + if (!returnTypeSignatureGenerated) { + configMap.put("return", overload.returnType()); } + return configMap.buildOrThrow(); } private final class RepresentTypeDecl implements Represent { diff --git a/bundle/src/main/java/dev/cel/bundle/CelFunction.java b/bundle/src/main/java/dev/cel/bundle/CelFunction.java new file mode 100644 index 000000000..8beb5fafd --- /dev/null +++ b/bundle/src/main/java/dev/cel/bundle/CelFunction.java @@ -0,0 +1,244 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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 dev.cel.bundle; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.auto.value.AutoValue; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.google.errorprone.annotations.CheckReturnValue; +import com.google.errorprone.annotations.Immutable; +import dev.cel.common.CelFunctionDecl; +import dev.cel.common.CelOverloadDecl; +import dev.cel.common.types.CelType; +import dev.cel.runtime.CelFunctionBinding; +import dev.cel.runtime.CelFunctionOverload; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +/** + * Bundles a {@link CelFunctionDecl} and its corresponding runtime {@link CelFunctionBinding}s into + * a single unit that can be registered on {@link CelBuilder#addFunctions}. + * + *

Overload IDs are automatically generated from the function name and parameter types. + */ +@AutoValue +@Immutable +public abstract class CelFunction { + + CelFunction() {} + + /** The compile-time declaration for this function. */ + public abstract CelFunctionDecl functionDecl(); + + /** The runtime bindings for this function's overloads. */ + public abstract ImmutableSet functionBindings(); + + /** + * Creates a new builder to construct a {@link CelFunction} with the given {@code functionName}. + */ + public static Builder newBuilder(String functionName) { + return new Builder(functionName); + } + + /** Builder for configuring a {@link CelFunction}. */ + public static final class Builder { + private final String functionName; + private final List overloadDecls = new ArrayList<>(); + // List of functions that take an overload ID and create a CelFunctionBinding. Since the + // overload ID is not always known until build time, this list is applied to the overload IDs + // in `build()` + private final List> bindings = new ArrayList<>(); + + private Builder(String functionName) { + checkArgument(!Strings.isNullOrEmpty(functionName), "Function name cannot be null or empty"); + this.functionName = functionName; + } + + /** + * Adds a unary member overload (e.g. {@code x.f()}) with an automatically generated overload ID + * (e.g. {@code "targetType.f()"}). + */ + @CanIgnoreReturnValue + public Builder addMemberOverload( + CelType resultType, + CelType targetType, + Class targetClass, + CelFunctionOverload.Unary impl) { + checkNotNull(resultType); + checkNotNull(targetType); + checkNotNull(targetClass); + checkNotNull(impl); + overloadDecls.add(CelOverloadDecl.newMemberOverload(resultType, targetType)); + bindings.add(overloadId -> CelFunctionBinding.from(overloadId, targetClass, impl)); + return this; + } + + /** + * Adds a binary member overload (e.g. {@code x.f(y)}) with an automatically generated overload + * ID (e.g. {@code "targetType.f(argType)"}). + */ + @CanIgnoreReturnValue + public Builder addMemberOverload( + CelType resultType, + CelType targetType, + CelType argType, + Class targetClass, + Class argClass, + CelFunctionOverload.Binary impl) { + checkNotNull(resultType); + checkNotNull(targetType); + checkNotNull(argType); + checkNotNull(targetClass); + checkNotNull(argClass); + checkNotNull(impl); + overloadDecls.add(CelOverloadDecl.newMemberOverload(resultType, targetType, argType)); + bindings.add(overloadId -> CelFunctionBinding.from(overloadId, targetClass, argClass, impl)); + return this; + } + + /** + * Adds an N-ary member overload (e.g. {@code x.f(y, z, ...)}) with an automatically generated + * overload ID (e.g. {@code "targetType.f(arg1Type,arg2Type,...)"}), where the first element of + * {@code argTypes} and {@code argClasses} is the receiver {@code x}. + */ + @CanIgnoreReturnValue + public Builder addMemberOverload( + CelType resultType, + List argTypes, + List> argClasses, + CelFunctionOverload impl) { + return addNaryOverload( + resultType, argTypes, argClasses, impl, /* isInstanceFunction= */ true); + } + + /** + * Adds a unary global overload (e.g. {@code f(x)}) with an automatically generated overload ID + * (e.g. {@code "f(argType)"}). + */ + @CanIgnoreReturnValue + public Builder addGlobalOverload( + CelType resultType, CelType argType, Class argClass, CelFunctionOverload.Unary impl) { + checkNotNull(resultType); + checkNotNull(argType); + checkNotNull(argClass); + checkNotNull(impl); + overloadDecls.add(CelOverloadDecl.newGlobalOverload(resultType, argType)); + bindings.add(overloadId -> CelFunctionBinding.from(overloadId, argClass, impl)); + return this; + } + + /** + * Adds a binary global overload (e.g. {@code f(x, y)}) with an automatically generated overload + * ID (e.g. {@code "f(arg1Type,arg2Type)"}). + */ + @CanIgnoreReturnValue + public Builder addGlobalOverload( + CelType resultType, + CelType arg1Type, + CelType arg2Type, + Class arg1Class, + Class arg2Class, + CelFunctionOverload.Binary impl) { + checkNotNull(resultType); + checkNotNull(arg1Type); + checkNotNull(arg2Type); + checkNotNull(arg1Class); + checkNotNull(arg2Class); + checkNotNull(impl); + overloadDecls.add(CelOverloadDecl.newGlobalOverload(resultType, arg1Type, arg2Type)); + bindings.add(overloadId -> CelFunctionBinding.from(overloadId, arg1Class, arg2Class, impl)); + return this; + } + + /** + * Adds an N-ary global overload (including zero-argument {@code f()}) with an automatically + * generated overload ID (e.g. {@code "f(arg1Type,arg2Type,...)"}). + */ + @CanIgnoreReturnValue + public Builder addGlobalOverload( + CelType resultType, + List argTypes, + List> argClasses, + CelFunctionOverload impl) { + return addNaryOverload( + resultType, argTypes, argClasses, impl, /* isInstanceFunction= */ false); + } + + @CanIgnoreReturnValue + private Builder addNaryOverload( + CelType resultType, + List argTypes, + List> argClasses, + CelFunctionOverload impl, + boolean isInstanceFunction) { + checkNotNull(resultType); + checkNotNull(argTypes); + checkNotNull(argClasses); + checkNotNull(impl); + checkArgument(!isInstanceFunction || !argTypes.isEmpty(), "Member function with no receiver"); + checkArgument( + argTypes.size() == argClasses.size(), + "Number of CelTypes (%s) must match number of Java classes (%s)", + argTypes.size(), + argClasses.size()); + overloadDecls.add( + isInstanceFunction + ? CelOverloadDecl.newMemberOverload(resultType, ImmutableList.copyOf(argTypes)) + : CelOverloadDecl.newGlobalOverload(resultType, ImmutableList.copyOf(argTypes))); + bindings.add( + overloadId -> + CelFunctionBinding.from(overloadId, ImmutableList.copyOf(argClasses), impl)); + return this; + } + + /** + * Builds a new {@link CelFunction} instance. + * + * @throws IllegalArgumentException if no overloads were added or if multiple overloads have the + * same canonical signature + */ + @CheckReturnValue + public CelFunction build() { + checkArgument( + !overloadDecls.isEmpty(), "Function '%s' must have at least one overload", functionName); + CelFunctionDecl functionDecl = + CelFunctionDecl.newFunctionDeclaration(functionName, overloadDecls); + ImmutableList populatedOverloads = functionDecl.overloads().asList(); + Set seen = new HashSet<>(); + ImmutableList.Builder bindingsBuilder = + ImmutableList.builderWithExpectedSize(populatedOverloads.size()); + for (int i = 0; i < overloadDecls.size(); i++) { + CelOverloadDecl decl = overloadDecls.get(i); + if (!seen.add(decl)) { + int firstIndex = overloadDecls.indexOf(decl); + throw new IllegalArgumentException( + "Duplicate overload signature: " + populatedOverloads.get(firstIndex).overloadId()); + } + bindingsBuilder.add(this.bindings.get(i).apply(populatedOverloads.get(i).overloadId())); + } + ImmutableSet bindings = + CelFunctionBinding.fromOverloads(functionName, bindingsBuilder.build()); + return new AutoValue_CelFunction(functionDecl, bindings); + } + } +} diff --git a/bundle/src/main/java/dev/cel/bundle/CelImpl.java b/bundle/src/main/java/dev/cel/bundle/CelImpl.java index b8c7c36e9..eed899562 100644 --- a/bundle/src/main/java/dev/cel/bundle/CelImpl.java +++ b/bundle/src/main/java/dev/cel/bundle/CelImpl.java @@ -245,6 +245,23 @@ public CelBuilder addFunctionDeclarations(Iterable celFunctionD return this; } + @Override + public CelBuilder addFunctions(CelFunction... functions) { + checkNotNull(functions); + return addFunctions(Arrays.asList(functions)); + } + + @Override + public CelBuilder addFunctions(Iterable functions) { + checkNotNull(functions); + for (CelFunction function : functions) { + checkNotNull(function); + compilerBuilder.addFunctionDeclarations(function.functionDecl()); + runtimeBuilder.addFunctionBindings(function.functionBindings()); + } + return this; + } + @Override public CelBuilder addVarDeclarations(CelVarDecl... celVarDecls) { compilerBuilder.addVarDeclarations(celVarDecls); diff --git a/bundle/src/test/java/dev/cel/bundle/CelEnvironmentTest.java b/bundle/src/test/java/dev/cel/bundle/CelEnvironmentTest.java index 7ff605995..571b6c4ce 100644 --- a/bundle/src/test/java/dev/cel/bundle/CelEnvironmentTest.java +++ b/bundle/src/test/java/dev/cel/bundle/CelEnvironmentTest.java @@ -23,6 +23,7 @@ import dev.cel.bundle.CelEnvironment.ExtensionConfig; import dev.cel.bundle.CelEnvironment.LibrarySubset; import dev.cel.bundle.CelEnvironment.LibrarySubset.FunctionSelector; +import dev.cel.bundle.CelEnvironment.TypeDecl; import dev.cel.common.CelAbstractSyntaxTree; import dev.cel.common.CelContainer; import dev.cel.common.CelOptions; @@ -30,7 +31,13 @@ import dev.cel.common.CelValidationResult; import dev.cel.common.types.CelType; import dev.cel.common.types.CelTypeProvider; +import dev.cel.common.types.ListType; +import dev.cel.common.types.MapType; +import dev.cel.common.types.NullableType; +import dev.cel.common.types.OpaqueType; +import dev.cel.common.types.OptionalType; import dev.cel.common.types.SimpleType; +import dev.cel.common.types.TypeParamType; import dev.cel.common.types.TypeType; import dev.cel.compiler.CelCompiler; import dev.cel.compiler.CelCompilerFactory; @@ -465,6 +472,55 @@ public void stdlibSubset_functionOverloadExcluded() throws Exception { assertThat(result.getErrorString()).contains("found no matching overload for '_+_'"); } + @Test + public void stdlibSubset_functionOverloadIncludedBySignature() throws Exception { + CelEnvironment environment = + CelEnvironment.newBuilder() + .setStandardLibrarySubset( + LibrarySubset.newBuilder() + .setDisabled(false) + .setIncludedFunctions( + ImmutableSet.of( + FunctionSelector.create("_+_", ImmutableSet.of("_+_(int,int)")))) + .build()) + .build(); + + CelCompiler compiler = CelCompilerFactory.standardCelCompilerBuilder().build(); + CelCompiler extendedCompiler = environment.extend(compiler, CelOptions.DEFAULT); + CelValidationResult result = extendedCompiler.compile("1 + 2"); + assertThat(result.hasError()).isFalse(); + + result = extendedCompiler.compile("1.0 + 2.0"); + assertThat(result.getErrorString()) + .contains("found no matching overload for '_+_' applied to '(double, double)'"); + } + + @Test + public void stdlibSubset_functionOverloadExcludedBySignature() throws Exception { + CelEnvironment environment = + CelEnvironment.newBuilder() + .setStandardLibrarySubset( + LibrarySubset.newBuilder() + .setDisabled(false) + .setExcludedFunctions( + ImmutableSet.of( + FunctionSelector.create( + "_+_", ImmutableSet.of("_+_(int,int)", "_+_(list<~A>,list<~A>)")))) + .build()) + .build(); + + CelCompiler compiler = CelCompilerFactory.standardCelCompilerBuilder().build(); + CelCompiler extendedCompiler = environment.extend(compiler, CelOptions.DEFAULT); + CelValidationResult result = extendedCompiler.compile("'a' + 'b' == 'ab'"); + assertThat(result.hasError()).isFalse(); + + result = extendedCompiler.compile("1 + 1"); + assertThat(result.getErrorString()).contains("found no matching overload for '_+_'"); + + result = extendedCompiler.compile("[1] + [2]"); + assertThat(result.getErrorString()).contains("found no matching overload for '_+_'"); + } + @Test public void typeDecl_toCelType_type() { CelTypeProvider typeProvider = @@ -490,4 +546,46 @@ public void typeDecl_toCelType_type_wrongParamCount_throws() { assertThrows(IllegalStateException.class, () -> typeDecl.toCelType(typeProvider)); assertThat(e).hasMessageThat().contains("Expected 1 parameter for type, got 0"); } + + @Test + public void typeDecl_fromCelType_and_toCelType() { + CelTypeProvider typeProvider = + CelCompilerFactory.standardCelCompilerBuilder().build().getTypeProvider(); + + assertThat(TypeDecl.fromCelType(SimpleType.INT).toCelType(typeProvider)) + .isEqualTo(SimpleType.INT); + assertThat(TypeDecl.fromCelType(NullableType.create(SimpleType.INT)).toCelType(typeProvider)) + .isEqualTo(NullableType.create(SimpleType.INT)); + assertThat(TypeDecl.create("int_wrapper").toCelType(typeProvider)) + .isEqualTo(NullableType.create(SimpleType.INT)); + assertThat(TypeDecl.create("bool_wrapper").toCelType(typeProvider)) + .isEqualTo(NullableType.create(SimpleType.BOOL)); + assertThat(TypeDecl.create("uint_wrapper").toCelType(typeProvider)) + .isEqualTo(NullableType.create(SimpleType.UINT)); + assertThat(TypeDecl.create("double_wrapper").toCelType(typeProvider)) + .isEqualTo(NullableType.create(SimpleType.DOUBLE)); + assertThat(TypeDecl.create("string_wrapper").toCelType(typeProvider)) + .isEqualTo(NullableType.create(SimpleType.STRING)); + assertThat(TypeDecl.create("bytes_wrapper").toCelType(typeProvider)) + .isEqualTo(NullableType.create(SimpleType.BYTES)); + assertThat(TypeDecl.create("null").toCelType(typeProvider)).isEqualTo(SimpleType.NULL_TYPE); + assertThat(TypeDecl.create("any").toCelType(typeProvider)).isEqualTo(SimpleType.ANY); + assertThat(TypeDecl.create("timestamp").toCelType(typeProvider)) + .isEqualTo(SimpleType.TIMESTAMP); + assertThat(TypeDecl.create("duration").toCelType(typeProvider)).isEqualTo(SimpleType.DURATION); + assertThat( + TypeDecl.fromCelType( + MapType.create(SimpleType.STRING, ListType.create(TypeParamType.create("T")))) + .toCelType()) + .isEqualTo(MapType.create(SimpleType.STRING, ListType.create(TypeParamType.create("T")))); + assertThat(TypeDecl.fromCelType(OptionalType.create(SimpleType.INT)).toCelType()) + .isEqualTo(OptionalType.create(SimpleType.INT)); + assertThat(TypeDecl.fromCelType(OpaqueType.create("my_opaque", SimpleType.INT)).toCelType()) + .isEqualTo(OpaqueType.create("my_opaque", SimpleType.INT)); + + assertThrows(IllegalArgumentException.class, () -> TypeDecl.fromCelType(SimpleType.ERROR)); + assertThrows( + IllegalArgumentException.class, + () -> TypeDecl.fromCelType(OpaqueType.create("function", SimpleType.INT))); + } } diff --git a/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlParserTest.java b/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlParserTest.java index acc89ff18..55e04dcab 100644 --- a/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlParserTest.java +++ b/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlParserTest.java @@ -558,6 +558,103 @@ public void environment_setFunctions_shorthand() throws Exception { assertThat(environment.extend(CEL_WITH_MESSAGE_TYPES, CEL_OPTIONS)).isNotNull(); } + @Test + public void environment_setFunctions_signature() throws Exception { + String yamlConfig = + "functions:\n" // + + "- name: 'isEmpty'\n" // + + " description: |-\n" // + + " determines whether a list is empty,\n" // + + " or a string has no characters\n" // + + " overloads:\n" // + + " - signature: 'google.protobuf.StringValue.isEmpty()'\n" // + + " examples:\n" // + + " - \"''.isEmpty() // true\"\n" // + + " return: 'bool'\n" // + + " - signature: 'list<~T>.isEmpty()'\n" // + + " examples:\n" // + + " - '[].isEmpty() // true'\n" // + + " - '[1].isEmpty() // false'\n" // + + " return: 'bool'\n" // + + "- name: 'getOrDefault'\n" // + + " overloads:\n" // + + " - id: 'map_getOrDefault'\n" // + + " signature: 'map<~K, ~V>.getOrDefault(~K, ~V)'\n" // + + " return: '~V'\n" // + + "- name: 'contains'\n" // + + " overloads:\n" // + + " - signature: 'contains(list<~T>, ~T)'\n" // + + " examples:\n" // + + " - 'contains([1, 2, 3], 2) // true'\n" // + + " return: 'bool'"; + + CelEnvironment environment = ENVIRONMENT_PARSER.parse(yamlConfig); + + assertThat(environment) + .isEqualTo( + CelEnvironment.newBuilder() + .setSource(environment.source().get()) + .setFunctions( + ImmutableSet.of( + FunctionDecl.newBuilder() + .setName("isEmpty") + .setDescription( + "determines whether a list is empty,\n" + + "or a string has no characters") + .setOverloads( + ImmutableSet.of( + OverloadDecl.newBuilder() + .setId("google.protobuf.StringValue.isEmpty()") + .setTarget(TypeDecl.create("google.protobuf.StringValue")) + .addExamples("''.isEmpty() // true") + .setReturnType(TypeDecl.create("bool")) + .build(), + OverloadDecl.newBuilder() + .setId("list<~T>.isEmpty()") + .setTarget( + TypeDecl.newBuilder() + .setName("list") + .addParams(TypeDecl.ofTypeParam("T")) + .build()) + .addExamples("[].isEmpty() // true") + .addExamples("[1].isEmpty() // false") + .setReturnType(TypeDecl.create("bool")) + .build())) + .build(), + FunctionDecl.create( + "getOrDefault", + ImmutableSet.of( + OverloadDecl.newBuilder() + .setId("map_getOrDefault") + .setTarget( + TypeDecl.newBuilder() + .setName("map") + .addParams( + TypeDecl.ofTypeParam("K"), + TypeDecl.ofTypeParam("V")) + .build()) + .addArguments( + TypeDecl.ofTypeParam("K"), TypeDecl.ofTypeParam("V")) + .setReturnType(TypeDecl.ofTypeParam("V")) + .build())), + FunctionDecl.create( + "contains", + ImmutableSet.of( + OverloadDecl.newBuilder() + .setId("contains(list<~T>, ~T)") + .addArguments( + TypeDecl.newBuilder() + .setName("list") + .addParams(TypeDecl.ofTypeParam("T")) + .build(), + TypeDecl.ofTypeParam("T")) + .addExamples("contains([1, 2, 3], 2) // true") + .setReturnType(TypeDecl.create("bool")) + .build())))) + .build()); + assertThat(environment.extend(CEL_WITH_MESSAGE_TYPES, CEL_OPTIONS)).isNotNull(); + } + @Test public void environment_withTypeSpecifier_invalidSyntaxError() { String yamlConfig = @@ -985,6 +1082,69 @@ private enum EnvironmentParseErrorTestcase { "ERROR: :2:5: Missing required attribute(s): name\n" + " | - enabled: true\n" + " | ....^"), + ILLEGAL_YAML_TYPE_OVERLOAD_SIGNATURE( + "functions:\n" + + " - name: 'foo'\n" + + " overloads:\n" + + " - signature: [ 'foo()' ]\n" + + " return: 'bool'\n", + "ERROR: :4:20: Got yaml node type tag:yaml.org,2002:seq, wanted type(s)" + + " [tag:yaml.org,2002:str !txt]\n" + + " | - signature: [ 'foo()' ]\n" + + " | ...................^"), + OVERLOAD_SIGNATURE_NAME_MISMATCH( + "functions:\n" + + " - name: 'foo'\n" + + " overloads:\n" + + " - id: 'foo_int64'\n" + + " signature: 'bar()'\n" + + " return: 'bool'\n", + "ERROR: :5:21: Function overload name 'bar' does not match function name 'foo'\n" + + " | signature: 'bar()'\n" + + " | ....................^"), + OVERLOAD_SIGNATURE_INVALID_SYNTAX( + "functions:\n" + + " - name: 'foo'\n" + + " overloads:\n" + + " - signature: 'foo(int'\n" + + " return: 'bool'\n", + "ERROR: :4:21: Invalid function signature, missing closing ')': foo(int\n" + + " | - signature: 'foo(int'\n" + + " | ....................^"), + OVERLOAD_SIGNATURE_WITH_TARGET( + "functions:\n" + + " - name: 'foo'\n" + + " overloads:\n" + + " - signature: 'foo()'\n" + + " target: 'int'\n" + + " return: 'bool'\n", + "ERROR: :5:9: 'signature' tag cannot be used together with 'target' or 'args':" + + " target\n" + + " | target: 'int'\n" + + " | ........^"), + OVERLOAD_SIGNATURE_WITH_ARGS( + "functions:\n" + + " - name: 'foo'\n" + + " overloads:\n" + + " - signature: 'foo()'\n" + + " args:\n" + + " - 'int'\n" + + " return: 'bool'\n", + "ERROR: :5:9: 'signature' tag cannot be used together with 'target' or 'args':" + + " args\n" + + " | args:\n" + + " | ........^"), + OVERLOAD_TARGET_WITH_SIGNATURE( + "functions:\n" + + " - name: 'foo'\n" + + " overloads:\n" + + " - target: 'int'\n" + + " signature: 'int.foo()'\n" + + " return: 'bool'\n", + "ERROR: :5:9: 'signature' tag cannot be used together with 'target' or 'args':" + + " signature\n" + + " | signature: 'int.foo()'\n" + + " | ........^"), ; private final String yamlConfig; @@ -1157,6 +1317,22 @@ private enum EnvironmentYamlResourceTestCase { .addArguments(TypeDecl.ofTypeParam("K"), TypeDecl.ofTypeParam("V")) .setReturnType(TypeDecl.ofTypeParam("V")) .build())) + .build(), + FunctionDecl.newBuilder() + .setName("contains") + .setOverloads( + ImmutableSet.of( + OverloadDecl.newBuilder() + .setId("contains(list<~T>, ~T)") + .addArguments( + TypeDecl.newBuilder() + .setName("list") + .addParams(TypeDecl.ofTypeParam("T")) + .build(), + TypeDecl.ofTypeParam("T")) + .addExamples("contains([1, 2, 3], 2) // true") + .setReturnType(TypeDecl.create("bool")) + .build())) .build()) .setFeatures(CelEnvironment.FeatureFlag.create("cel.feature.macro_call_tracking", true)) .setLimits( @@ -1176,7 +1352,9 @@ private enum EnvironmentYamlResourceTestCase { .setExcludedFunctions( ImmutableSet.of( FunctionSelector.create( - "_+_", ImmutableSet.of("add_bytes", "add_list", "add_string")), + "_+_", + ImmutableSet.of( + "_+_(bytes,bytes)", "_+_(list<~A>,list<~A>)", "add_string")), FunctionSelector.create("matches", ImmutableSet.of()), FunctionSelector.create( "timestamp", ImmutableSet.of("string_to_timestamp")), diff --git a/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlSerializerTest.java b/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlSerializerTest.java index aad72a578..8847882e0 100644 --- a/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlSerializerTest.java +++ b/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlSerializerTest.java @@ -31,6 +31,7 @@ import dev.cel.common.CelContainer; import java.io.IOException; import java.net.URL; +import java.util.Optional; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -247,4 +248,113 @@ public void standardLibrary_excludeMacrosAndFunctions() throws Exception { assertThat(yamlOutput).isEqualTo(expectedYaml); } + + @Test + public void toYaml_useTypeSignatures() throws Exception { + CelEnvironment environment = + CelEnvironment.newBuilder() + .setName("signature_env") + .setVariables( + ImmutableSet.of( + VariableDecl.newBuilder() + .setName("msg") + .setDescription("message variable") + .setType(TypeDecl.create("google.rpc.context.AttributeContext.Request")) + .build(), + VariableDecl.create( + "map_var", + TypeDecl.newBuilder() + .setName("map") + .addParams(TypeDecl.create("string")) + .addParams(TypeDecl.ofTypeParam("V")) + .build()))) + .setFunctions( + ImmutableSet.of( + FunctionDecl.newBuilder() + .setName("getOrDefault") + .setDescription("Returns the value of a key or default.") + .setOverloads( + ImmutableSet.of( + OverloadDecl.newBuilder() + .setId("getOrDefault_key_value") + .setTarget( + TypeDecl.newBuilder() + .setName("map") + .addParams( + TypeDecl.ofTypeParam("K"), + TypeDecl.ofTypeParam("V")) + .build()) + .setArguments( + ImmutableList.of( + TypeDecl.ofTypeParam("K"), TypeDecl.ofTypeParam("V"))) + .addExamples("{'a': 1}.getOrDefault('a', 0) // 1") + .setReturnType(TypeDecl.ofTypeParam("V")) + .build())) + .build(), + FunctionDecl.create( + "contains", + ImmutableSet.of( + OverloadDecl.newBuilder() + .setId("contains(list<~T>,~T)") + .setArguments( + ImmutableList.of( + TypeDecl.newBuilder() + .setName("list") + .addParams(TypeDecl.ofTypeParam("T")) + .build(), + TypeDecl.ofTypeParam("T"))) + .addExamples("contains([1, 2, 3], 2) // true") + .setReturnType(TypeDecl.create("bool")) + .build())), + FunctionDecl.create( + "coalesce", + ImmutableSet.of( + OverloadDecl.newBuilder() + .setId("coalesce_null_int") + .setTarget(TypeDecl.create("google.protobuf.Int64Value")) + .setArguments(ImmutableList.of(TypeDecl.create("int"))) + .setReturnType(TypeDecl.create("int")) + .build())))) + .build(); + + String yamlOutput = + CelEnvironmentYamlSerializer.toYaml(environment, /* useTypeSignatures= */ true); + + String expectedYaml = + "name: signature_env\n" + + "variables:\n" + + "- name: msg\n" + + " description: message variable\n" + + " type: google.rpc.context.AttributeContext.Request\n" + + "- name: map_var\n" + + " type: map\n" + + "functions:\n" + + "- name: getOrDefault\n" + + " description: Returns the value of a key or default.\n" + + " overloads:\n" + + " - id: getOrDefault_key_value\n" + + " signature: map<~K,~V>.getOrDefault(~K,~V)\n" + + " examples:\n" + + " - '{''a'': 1}.getOrDefault(''a'', 0) // 1'\n" + + " return: ~V\n" + + "- name: contains\n" + + " overloads:\n" + + " - signature: contains(list<~T>,~T)\n" + + " examples:\n" + + " - contains([1, 2, 3], 2) // true\n" + + " return: bool\n" + + "- name: coalesce\n" + + " overloads:\n" + + " - id: coalesce_null_int\n" + + " signature: int_wrapper.coalesce(int)\n" + + " return: int\n"; + + assertThat(yamlOutput).isEqualTo(expectedYaml); + + CelEnvironment roundTripped = + CelEnvironmentYamlParser.newInstance().parse(yamlOutput).toBuilder() + .setSource(Optional.empty()) + .build(); + assertThat(roundTripped).isEqualTo(environment); + } } diff --git a/bundle/src/test/java/dev/cel/bundle/CelFunctionTest.java b/bundle/src/test/java/dev/cel/bundle/CelFunctionTest.java new file mode 100644 index 000000000..adb798da5 --- /dev/null +++ b/bundle/src/test/java/dev/cel/bundle/CelFunctionTest.java @@ -0,0 +1,245 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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 dev.cel.bundle; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.testing.junit.testparameterinjector.TestParameter; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelOverloadDecl; +import dev.cel.common.types.CelType; +import dev.cel.common.types.OpaqueType; +import dev.cel.common.types.SimpleType; +import dev.cel.testing.CelRuntimeFlavor; +import java.math.BigDecimal; +import java.math.RoundingMode; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class CelFunctionTest { + + private static final CelType DECIMAL_TYPE = OpaqueType.create("decimal"); + + @Test + public void memberOverloads_unary_evaluatesAndDispatchesDynamically( + @TestParameter CelRuntimeFlavor runtimeFlavor) throws Exception { + CelFunction floor = + CelFunction.newBuilder("floor") + .addMemberOverload( + SimpleType.INT, SimpleType.DOUBLE, Double.class, d -> (long) Math.floor(d)) + .addMemberOverload( + SimpleType.INT, + DECIMAL_TYPE, + BigDecimal.class, + bd -> bd.setScale(0, RoundingMode.FLOOR).longValue()) + .build(); + + assertThat(floor.functionDecl().name()).isEqualTo("floor"); + assertThat(floor.functionDecl().overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly("double.floor()", "decimal.floor()") + .inOrder(); + + Cel cel = + runtimeFlavor + .builder() + .addVar("d", SimpleType.DOUBLE) + .addVar("bd", DECIMAL_TYPE) + .addVar("x", SimpleType.DYN) + .addFunctions(floor) + .build(); + + CelAbstractSyntaxTree staticAst = cel.compile("d.floor() + bd.floor()").getAst(); + Object staticResult = + cel.createProgram(staticAst).eval(ImmutableMap.of("d", 3.9, "bd", new BigDecimal("7.8"))); + assertThat(staticResult).isEqualTo(10L); + + // Dynamic dispatch via dyn variable + CelAbstractSyntaxTree dynAst = cel.compile("x.floor()").getAst(); + assertThat(cel.createProgram(dynAst).eval(ImmutableMap.of("x", 5.7))).isEqualTo(5L); + assertThat(cel.createProgram(dynAst).eval(ImmutableMap.of("x", new BigDecimal("9.9")))) + .isEqualTo(9L); + } + + @Test + public void memberOverloads_binaryAndNary_evaluates(@TestParameter CelRuntimeFlavor runtimeFlavor) + throws Exception { + CelFunction repeat = + CelFunction.newBuilder("repeat") + .addMemberOverload( + SimpleType.STRING, + SimpleType.STRING, + SimpleType.INT, + String.class, + Long.class, + (s, count) -> { + StringBuilder sb = new StringBuilder(); + for (long i = 0; i < count; i++) { + sb.append(s); + } + return sb.toString(); + }) + .build(); + + CelFunction slice = + CelFunction.newBuilder("slice") + .addMemberOverload( + SimpleType.STRING, + ImmutableList.of(SimpleType.STRING, SimpleType.INT, SimpleType.INT), + ImmutableList.of(String.class, Long.class, Long.class), + args -> { + String s = (String) args[0]; + int start = ((Long) args[1]).intValue(); + int end = ((Long) args[2]).intValue(); + return s.substring(start, end); + }) + .build(); + + assertThat(repeat.functionDecl().overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly("string.repeat(int)"); + assertThat(slice.functionDecl().overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly("string.slice(int,int)"); + + Cel cel = runtimeFlavor.builder().addFunctions(ImmutableList.of(repeat, slice)).build(); + + CelAbstractSyntaxTree ast = cel.compile("'abcdef'.slice(1, 4).repeat(2)").getAst(); + assertThat(cel.createProgram(ast).eval()).isEqualTo("bcdbcd"); + } + + @Test + public void globalOverloads_unaryBinaryAndNary_evaluates( + @TestParameter CelRuntimeFlavor runtimeFlavor) throws Exception { + CelFunction doubleIt = + CelFunction.newBuilder("doubleIt") + .addGlobalOverload(SimpleType.INT, SimpleType.INT, Long.class, x -> x * 2) + .addGlobalOverload(SimpleType.STRING, SimpleType.STRING, String.class, s -> s + s) + .build(); + + CelFunction joinWith = + CelFunction.newBuilder("joinWith") + .addGlobalOverload( + SimpleType.STRING, + SimpleType.STRING, + SimpleType.STRING, + String.class, + String.class, + (a, b) -> a + ":" + b) + .build(); + + CelFunction clamp = + CelFunction.newBuilder("clamp") + .addGlobalOverload(SimpleType.INT, ImmutableList.of(), ImmutableList.of(), args -> 0L) + .addGlobalOverload( + SimpleType.INT, + ImmutableList.of(SimpleType.INT, SimpleType.INT, SimpleType.INT), + ImmutableList.of(Long.class, Long.class, Long.class), + args -> { + long val = (Long) args[0]; + long min = (Long) args[1]; + long max = (Long) args[2]; + return Math.max(min, Math.min(max, val)); + }) + .build(); + + assertThat(doubleIt.functionDecl().overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly("doubleIt(int)", "doubleIt(string)") + .inOrder(); + assertThat(joinWith.functionDecl().overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly("joinWith(string,string)"); + assertThat(clamp.functionDecl().overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly("clamp()", "clamp(int,int,int)") + .inOrder(); + + Cel cel = + runtimeFlavor + .builder() + .addVar("dynVal", SimpleType.DYN) + .addFunctions(doubleIt, joinWith, clamp) + .build(); + + CelAbstractSyntaxTree ast = + cel.compile("joinWith(doubleIt('a'), string(doubleIt(clamp(25, clamp(), 10))))").getAst(); + assertThat(cel.createProgram(ast).eval()).isEqualTo("aa:20"); + + // Dynamic dispatch on global overloaded function + CelAbstractSyntaxTree dynAst = cel.compile("doubleIt(dynVal)").getAst(); + assertThat(cel.createProgram(dynAst).eval(ImmutableMap.of("dynVal", 21L))).isEqualTo(42L); + assertThat(cel.createProgram(dynAst).eval(ImmutableMap.of("dynVal", "hi"))).isEqualTo("hihi"); + } + + @Test + public void builder_invalidInputs_throws() { + IllegalArgumentException emptyName = + assertThrows(IllegalArgumentException.class, () -> CelFunction.newBuilder("")); + assertThat(emptyName).hasMessageThat().isEqualTo("Function name cannot be null or empty"); + + IllegalArgumentException noOverloads = + assertThrows(IllegalArgumentException.class, () -> CelFunction.newBuilder("empty").build()); + assertThat(noOverloads) + .hasMessageThat() + .isEqualTo("Function 'empty' must have at least one overload"); + + IllegalArgumentException duplicateOverload = + assertThrows( + IllegalArgumentException.class, + () -> + CelFunction.newBuilder("dup") + .addGlobalOverload(SimpleType.INT, SimpleType.INT, Long.class, x -> x) + .addGlobalOverload(SimpleType.INT, SimpleType.INT, Long.class, x -> x + 1) + .build()); + assertThat(duplicateOverload) + .hasMessageThat() + .isEqualTo("Duplicate overload signature: dup(int)"); + + IllegalArgumentException duplicateOverloadDifferentReturn = + assertThrows( + IllegalArgumentException.class, + () -> + CelFunction.newBuilder("dup") + .addGlobalOverload(SimpleType.INT, SimpleType.INT, Long.class, x -> x) + .addGlobalOverload(SimpleType.STRING, SimpleType.INT, Long.class, x -> "a") + .build()); + assertThat(duplicateOverloadDifferentReturn) + .hasMessageThat() + .isEqualTo("Duplicate overload signature: dup(int)"); + + IllegalArgumentException memberNoArgs = + assertThrows( + IllegalArgumentException.class, + () -> + CelFunction.newBuilder("badMember") + .addMemberOverload( + SimpleType.INT, ImmutableList.of(), ImmutableList.of(), args -> 0L)); + assertThat(memberNoArgs).hasMessageThat().isEqualTo("Member function with no receiver"); + + IllegalArgumentException mismatchedArity = + assertThrows( + IllegalArgumentException.class, + () -> + CelFunction.newBuilder("mismatch") + .addGlobalOverload( + SimpleType.INT, + ImmutableList.of(SimpleType.INT, SimpleType.INT), + ImmutableList.of(Long.class), + args -> 0L)); + assertThat(mismatchedArity) + .hasMessageThat() + .isEqualTo("Number of CelTypes (2) must match number of Java classes (1)"); + } +} diff --git a/checker/src/test/java/dev/cel/checker/CelOverloadDeclTest.java b/checker/src/test/java/dev/cel/checker/CelOverloadDeclTest.java index 4e851a56f..5a3e07bbc 100644 --- a/checker/src/test/java/dev/cel/checker/CelOverloadDeclTest.java +++ b/checker/src/test/java/dev/cel/checker/CelOverloadDeclTest.java @@ -21,9 +21,11 @@ import dev.cel.expr.Decl.FunctionDecl.Overload; import com.google.common.collect.ImmutableList; +import dev.cel.common.CelFunctionDecl; import dev.cel.common.CelOverloadDecl; import dev.cel.common.CelProtoDeclConverter; import dev.cel.common.types.CelProtoTypes; +import dev.cel.common.types.ListType; import dev.cel.common.types.SimpleType; import dev.cel.common.types.TypeParamType; import org.junit.Test; @@ -105,4 +107,42 @@ public void setParameterTypes_doesNotDedupe() { .containsExactly(SimpleType.STRING, SimpleType.STRING, SimpleType.STRING, SimpleType.INT) .inOrder(); } + + @Test + public void newFunctionDeclaration_populatesEmptyOverloadIds() { + CelOverloadDecl globalOverload = + newGlobalOverload(SimpleType.INT, SimpleType.INT, SimpleType.INT); + CelOverloadDecl globalListOverload = + newGlobalOverload(SimpleType.STRING, ImmutableList.of(SimpleType.INT)); + CelOverloadDecl memberOverload = + newMemberOverload( + SimpleType.BOOL, ListType.create(TypeParamType.create("T")), TypeParamType.create("T")); + CelOverloadDecl memberListOverload = + newMemberOverload(SimpleType.INT, ImmutableList.of(SimpleType.STRING)); + CelOverloadDecl explicitOverload = + newGlobalOverload("custom_id", SimpleType.STRING, SimpleType.STRING); + + assertThat(globalOverload.overloadId()).isEmpty(); + assertThat(globalListOverload.overloadId()).isEmpty(); + assertThat(memberOverload.overloadId()).isEmpty(); + assertThat(memberListOverload.overloadId()).isEmpty(); + + CelFunctionDecl functionDecl = + CelFunctionDecl.newFunctionDeclaration( + "myFunc", + globalOverload, + globalListOverload, + memberOverload, + memberListOverload, + explicitOverload); + + assertThat(functionDecl.overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly( + "myFunc(int,int)", + "myFunc(int)", + "list<~T>.myFunc(~T)", + "string.myFunc()", + "custom_id") + .inOrder(); + } } diff --git a/common/BUILD.bazel b/common/BUILD.bazel index c84701d17..e609ff6e7 100644 --- a/common/BUILD.bazel +++ b/common/BUILD.bazel @@ -40,6 +40,18 @@ cel_android_library( exports = ["//common/src/main/java/dev/cel/common:cel_overload_decl_android"], ) +java_library( + name = "cel_signature_utils", + visibility = ["//:internal"], + exports = ["//common/src/main/java/dev/cel/common:cel_signature_utils"], +) + +cel_android_library( + name = "cel_signature_utils_android", + visibility = ["//:internal"], + exports = ["//common/src/main/java/dev/cel/common:cel_signature_utils_android"], +) + java_library( name = "cel_proto_decl_converter", exports = ["//common/src/main/java/dev/cel/common:cel_proto_decl_converter"], diff --git a/common/src/main/java/dev/cel/common/BUILD.bazel b/common/src/main/java/dev/cel/common/BUILD.bazel index cb9487b8c..5ab781dfd 100644 --- a/common/src/main/java/dev/cel/common/BUILD.bazel +++ b/common/src/main/java/dev/cel/common/BUILD.bazel @@ -63,6 +63,7 @@ java_library( ], deps = [ ":cel_overload_decl", + ":cel_signature_utils", "//:auto_value", "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:com_google_guava_guava", @@ -76,12 +77,45 @@ cel_android_library( ], deps = [ ":cel_overload_decl_android", + ":cel_signature_utils_android", "//:auto_value", "@maven//:com_google_errorprone_error_prone_annotations", "@maven_android//:com_google_guava_guava", ], ) +java_library( + name = "cel_signature_utils", + srcs = ["CelSignatureUtils.java"], + tags = [ + ], + deps = [ + "//:auto_value", + "//common/annotations", + "//common/types", + "//common/types:cel_types", + "//common/types:type_providers", + "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:com_google_guava_guava", + ], +) + +cel_android_library( + name = "cel_signature_utils_android", + srcs = ["CelSignatureUtils.java"], + tags = [ + ], + deps = [ + "//:auto_value", + "//common/annotations", + "//common/types:cel_types_android", + "//common/types:type_providers_android", + "//common/types:types_android", + "@maven//:com_google_errorprone_error_prone_annotations", + "@maven_android//:com_google_guava_guava", + ], +) + java_library( name = "cel_overload_decl", srcs = ["CelOverloadDecl.java"], diff --git a/common/src/main/java/dev/cel/common/CelFunctionDecl.java b/common/src/main/java/dev/cel/common/CelFunctionDecl.java index c8cc79908..37bdc5941 100644 --- a/common/src/main/java/dev/cel/common/CelFunctionDecl.java +++ b/common/src/main/java/dev/cel/common/CelFunctionDecl.java @@ -14,6 +14,7 @@ package dev.cel.common; +import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import com.google.auto.value.AutoValue; @@ -22,6 +23,8 @@ import com.google.errorprone.annotations.CheckReturnValue; import com.google.errorprone.annotations.Immutable; import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; /** Abstract representation of a CEL Function declaration. */ @AutoValue @@ -70,9 +73,50 @@ public Builder addOverloads(Iterable overloads) { return this; } - /** Builds a new instance of {@link CelFunctionDecl} */ @CheckReturnValue - public abstract CelFunctionDecl build(); + abstract CelFunctionDecl autoBuild(); + + /** + * Builds a new instance of {@link CelFunctionDecl}. + * + *

Any {@link CelOverloadDecl} with an empty {@link CelOverloadDecl#overloadId()} will have + * its ID automatically populated with its canonical overload signature. + */ + @CheckReturnValue + public CelFunctionDecl build() { + ImmutableSet currentOverloads = overloads(); + boolean needsOverloadId = false; + for (CelOverloadDecl overload : currentOverloads) { + if (overload.overloadId().isEmpty()) { + needsOverloadId = true; + break; + } + } + if (!needsOverloadId) { + return autoBuild(); + } + String functionName = name(); + ImmutableSet.Builder updatedOverloads = + ImmutableSet.builderWithExpectedSize(currentOverloads.size()); + Set seenOverloadIds = new HashSet<>(); + for (CelOverloadDecl overload : currentOverloads) { + String overloadId = overload.overloadId(); + if (overloadId.isEmpty()) { + overloadId = + CelSignatureUtils.makeOverloadSignature( + functionName, overload.parameterTypes(), overload.isInstanceFunction()); + updatedOverloads.add(overload.toBuilder().setOverloadId(overloadId).build()); + } else { + updatedOverloads.add(overload); + } + checkArgument( + seenOverloadIds.add(overloadId), "Duplicate overload signature: %s", overloadId); + } + return new AutoValue_CelFunctionDecl.Builder() + .setName(functionName) + .setOverloads(updatedOverloads.build()) + .autoBuild(); + } } public abstract Builder toBuilder(); diff --git a/common/src/main/java/dev/cel/common/CelOverloadDecl.java b/common/src/main/java/dev/cel/common/CelOverloadDecl.java index f17dd87b1..777281359 100644 --- a/common/src/main/java/dev/cel/common/CelOverloadDecl.java +++ b/common/src/main/java/dev/cel/common/CelOverloadDecl.java @@ -41,7 +41,10 @@ @AutoValue @Immutable public abstract class CelOverloadDecl { - /** Required. Globally unique overload name. */ + /** + * Globally unique overload name. If empty when building {@link CelFunctionDecl}, an overload ID + * is automatically generated from the function name and parameter types. + */ public abstract String overloadId(); /** @@ -79,7 +82,7 @@ public abstract class CelOverloadDecl { /** Create a new builder to construct a {@link CelOverloadDecl} instance */ public static Builder newBuilder() { - return new AutoValue_CelOverloadDecl.Builder().setDoc(""); + return new AutoValue_CelOverloadDecl.Builder().setOverloadId("").setDoc(""); } /** Builder for configuring the {@link CelOverloadDecl}. */ @@ -153,6 +156,44 @@ public CelOverloadDecl build() { } } + /** + * Helper method for declaring a member function overload (e.g. {@code x.f(y, ...)}) with an + * automatically generated overload ID. + * + *

The returned declaration has an empty {@link #overloadId()}, which is automatically + * populated with the canonical signature (e.g. {@code "target.func(arg1,arg2)"}) when built into + * a {@link CelFunctionDecl} via {@link CelFunctionDecl.Builder#build()} or {@link + * CelFunctionDecl#newFunctionDeclaration}. + * + * @param resultType the return type of the overload + * @param paramTypes the parameter types of the overload, where the first element ({@code + * paramTypes[0]}) is the receiver (target) type {@code x} and any subsequent elements are the + * method argument types {@code y, ...} + */ + @CheckReturnValue + public static CelOverloadDecl newMemberOverload(CelType resultType, CelType... paramTypes) { + return newMemberOverload("", resultType, Arrays.asList(paramTypes)); + } + + /** + * Helper method for declaring a member function overload (e.g. {@code x.f(y, ...)}) with an + * automatically generated overload ID. + * + *

The returned declaration has an empty {@link #overloadId()}, which is automatically + * populated with the canonical signature (e.g. {@code "target.func(arg1,arg2)"}) when built into + * a {@link CelFunctionDecl} via {@link CelFunctionDecl.Builder#build()} or {@link + * CelFunctionDecl#newFunctionDeclaration}. + * + * @param resultType the return type of the overload + * @param paramTypes the parameter types of the overload, where the first element ({@code + * paramTypes.get(0)}) is the receiver (target) type {@code x} and any subsequent elements are + * the method argument types {@code y, ...} + */ + @CheckReturnValue + public static CelOverloadDecl newMemberOverload(CelType resultType, List paramTypes) { + return newMemberOverload("", /* doc= */ "", resultType, paramTypes); + } + /** Helper method for declaring a member function overload */ @CheckReturnValue public static CelOverloadDecl newMemberOverload( @@ -181,6 +222,40 @@ public static CelOverloadDecl newMemberOverload( return newOverload(overloadId, doc, resultType, paramTypes, /* isInstanceFunction= */ true); } + /** + * Helper method for declaring a global function overload (e.g. {@code f(x, ...)}) with an + * automatically generated overload ID. + * + *

The returned declaration has an empty {@link #overloadId()}, which is automatically + * populated with the canonical signature (e.g. {@code "func(arg1,arg2)"}) when built into a + * {@link CelFunctionDecl} via {@link CelFunctionDecl.Builder#build()} or {@link + * CelFunctionDecl#newFunctionDeclaration}. + * + * @param resultType the return type of the overload + * @param paramTypes the argument types of the overload + */ + @CheckReturnValue + public static CelOverloadDecl newGlobalOverload(CelType resultType, CelType... paramTypes) { + return newGlobalOverload("", resultType, Arrays.asList(paramTypes)); + } + + /** + * Helper method for declaring a global function overload (e.g. {@code f(x, ...)}) with an + * automatically generated overload ID. + * + *

The returned declaration has an empty {@link #overloadId()}, which is automatically + * populated with the canonical signature (e.g. {@code "func(arg1,arg2)"}) when built into a + * {@link CelFunctionDecl} via {@link CelFunctionDecl.Builder#build()} or {@link + * CelFunctionDecl#newFunctionDeclaration}. + * + * @param resultType the return type of the overload + * @param paramTypes the argument types of the overload + */ + @CheckReturnValue + public static CelOverloadDecl newGlobalOverload(CelType resultType, List paramTypes) { + return newGlobalOverload("", /* doc= */ "", resultType, paramTypes); + } + /** Helper method for declaring a global function overload */ @CheckReturnValue public static CelOverloadDecl newGlobalOverload( diff --git a/common/src/main/java/dev/cel/common/CelSignatureUtils.java b/common/src/main/java/dev/cel/common/CelSignatureUtils.java new file mode 100644 index 000000000..38196f074 --- /dev/null +++ b/common/src/main/java/dev/cel/common/CelSignatureUtils.java @@ -0,0 +1,615 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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 dev.cel.common; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.errorprone.annotations.Immutable; +import dev.cel.common.annotations.Internal; +import dev.cel.common.types.CelType; +import dev.cel.common.types.CelTypes; +import dev.cel.common.types.EnumType; +import dev.cel.common.types.ListType; +import dev.cel.common.types.MapType; +import dev.cel.common.types.NullableType; +import dev.cel.common.types.OpaqueType; +import dev.cel.common.types.OptionalType; +import dev.cel.common.types.SimpleType; +import dev.cel.common.types.TypeParamType; +import dev.cel.common.types.TypeType; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Internal utility methods for generating and parsing canonical CEL type and function overload + * signatures. + * + *

CEL Library Internals. Do Not Use. + */ +@Internal +public final class CelSignatureUtils { + + private static final int MAX_RECURSION_DEPTH = 64; + + /** Represents a parsed function overload signature. */ + @AutoValue + @Immutable + public abstract static class ParsedFunctionOverload { + public abstract String functionName(); + + public abstract boolean isMember(); + + public abstract ImmutableList argTypes(); + + public static ParsedFunctionOverload create( + String functionName, boolean isMember, Iterable argTypes) { + return new AutoValue_CelSignatureUtils_ParsedFunctionOverload( + functionName, isMember, ImmutableList.copyOf(argTypes)); + } + } + + /** + * Generates a canonical signature string for a {@link CelType}. + * + *

For example: + * + *

    + *
  • {@code SimpleType.INT} -> {@code "int"} + *
  • {@code ListType.create(SimpleType.STRING)} -> {@code "list"} + *
  • {@code TypeParamType.create("T")} -> {@code "~T"} + *
+ * + * @throws IllegalArgumentException if the type cannot be represented as a signature (e.g. {@code + * ERROR}). + */ + public static String makeTypeSignature(CelType type) { + checkNotNull(type); + StringBuilder result = new StringBuilder(); + appendTypeSignature(result, type); + return result.toString(); + } + + /** + * Generates a canonical overload ID / signature string for a function overload. + * + *

For example: + * + *

    + *
  • {@code makeOverloadSignature("add", ImmutableList.of(INT, INT), false)} -> {@code + * "add(int,int)"} + *
  • {@code makeOverloadSignature("contains", ImmutableList.of(STRING, STRING), true)} -> + * {@code "string.contains(string)"} + *
+ * + * @throws IllegalArgumentException if {@code isMember} is true and {@code args} is empty, or if + * any argument type is unsupported in signatures. + */ + public static String makeOverloadSignature( + String functionName, List args, boolean isMember) { + checkNotNull(functionName); + checkNotNull(args); + StringBuilder result = new StringBuilder(); + if (isMember) { + checkArgument(!args.isEmpty(), "Member function with no receiver"); + appendTypeSignature(result, args.get(0)); + result.append('.'); + } + appendEscaped(result, functionName, /* escapeDot= */ true); + result.append('('); + int startIndex = isMember ? 1 : 0; + for (int i = startIndex; i < args.size(); i++) { + if (i > startIndex) { + result.append(','); + } + appendTypeSignature(result, args.get(i)); + } + result.append(')'); + return result.toString(); + } + + /** + * Parses a canonical type signature string into a {@link CelType}. + * + *

Unresolved custom type names are returned as {@link OpaqueType} instances. + */ + public static CelType parseTypeSignature(String signature) { + checkNotNull(signature); + String cleaned = removeUnescapedWhitespace(signature); + return parseTypeSignatureFromCleaned(cleaned, /* depth= */ 0); + } + + /** + * Parses a canonical function overload signature string into a {@link ParsedFunctionOverload}. + * + *

For example, {@code "string.contains(string)"} or {@code "add(int, int)"}. + */ + public static ParsedFunctionOverload parseFunctionSignature(String signature) { + checkNotNull(signature); + return parseFunctionSignatureInternal(signature); + } + + private static Optional resolveBuiltinOrWrapperType(String typeName) { + Optional simpleType = SimpleType.findByName(typeName); + if (simpleType.isPresent()) { + return simpleType; + } + Optional wellKnownType = CelTypes.getWellKnownCelType(typeName); + if (wellKnownType.isPresent()) { + return wellKnownType; + } + switch (typeName) { + case "null": + return Optional.of(SimpleType.NULL_TYPE); + case "any": + return Optional.of(SimpleType.ANY); + case "timestamp": + return Optional.of(SimpleType.TIMESTAMP); + case "duration": + return Optional.of(SimpleType.DURATION); + case "dyn": + return Optional.of(SimpleType.DYN); + case "bool_wrapper": + return Optional.of(NullableType.create(SimpleType.BOOL)); + case "int_wrapper": + return Optional.of(NullableType.create(SimpleType.INT)); + case "uint_wrapper": + return Optional.of(NullableType.create(SimpleType.UINT)); + case "double_wrapper": + return Optional.of(NullableType.create(SimpleType.DOUBLE)); + case "string_wrapper": + return Optional.of(NullableType.create(SimpleType.STRING)); + case "bytes_wrapper": + return Optional.of(NullableType.create(SimpleType.BYTES)); + default: + return Optional.empty(); + } + } + + private static boolean isWhitespace(char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\u000b'; + } + + private static void appendEscaped(StringBuilder result, String str, boolean escapeDot) { + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (c == '\\' + || c == '(' + || c == ')' + || c == '<' + || c == '>' + || c == '"' + || c == ',' + || c == '~' + || (escapeDot && c == '.')) { + result.append('\\'); + } + result.append(c); + } + } + + private static boolean hasUnescapedSpecialChar(String str, boolean disallowDot) { + boolean escaped = false; + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if (c == '\\') { + escaped = true; + continue; + } + if (c == '(' + || c == ')' + || c == '<' + || c == '>' + || c == '"' + || c == ',' + || c == '~' + || (disallowDot && c == '.')) { + return true; + } + } + return escaped; + } + + private static String unescape(String str) { + int firstEscape = str.indexOf('\\'); + if (firstEscape == -1) { + return str; + } + StringBuilder result = new StringBuilder(str.length()); + result.append(str, 0, firstEscape); + boolean escaped = false; + for (int i = firstEscape; i < str.length(); i++) { + char c = str.charAt(i); + if (escaped) { + result.append(c); + escaped = false; + } else if (c == '\\') { + escaped = true; + } else { + result.append(c); + } + } + checkArgument(!escaped, "Invalid escape sequence, trailing '\\': %s", str); + return result.toString(); + } + + private static String removeUnescapedWhitespace(String signature) { + boolean needsCopy = false; + for (int i = 0; i < signature.length(); i++) { + char c = signature.charAt(i); + if (c == '\\' || isWhitespace(c)) { + needsCopy = true; + break; + } + } + if (!needsCopy) { + return signature; + } + StringBuilder cleaned = new StringBuilder(signature.length()); + boolean escaped = false; + for (int i = 0; i < signature.length(); i++) { + char c = signature.charAt(i); + if (escaped) { + cleaned.append(c); + escaped = false; + } else if (c == '\\') { + cleaned.append(c); + escaped = true; + } else if (!isWhitespace(c)) { + cleaned.append(c); + } + } + checkArgument(!escaped, "Invalid signature, trailing '\\': %s", signature); + return cleaned.toString(); + } + + private static void appendTypeSignature(StringBuilder result, CelType type) { + checkNotNull(type); + if (type instanceof NullableType) { + CelType targetType = ((NullableType) type).targetType(); + switch (targetType.kind()) { + case BOOL: + result.append("bool_wrapper"); + return; + case INT: + result.append("int_wrapper"); + return; + case UINT: + result.append("uint_wrapper"); + return; + case DOUBLE: + result.append("double_wrapper"); + return; + case STRING: + result.append("string_wrapper"); + return; + case BYTES: + result.append("bytes_wrapper"); + return; + default: + throw new IllegalArgumentException("Unsupported wrapper type: " + type.name()); + } + } + if (type instanceof EnumType) { + appendEscaped(result, type.name(), /* escapeDot= */ false); + return; + } + + switch (type.kind()) { + case NULL_TYPE: + result.append("null"); + break; + case DYN: + result.append("dyn"); + break; + case BOOL: + result.append("bool"); + break; + case INT: + result.append("int"); + break; + case UINT: + result.append("uint"); + break; + case DOUBLE: + result.append("double"); + break; + case STRING: + result.append("string"); + break; + case BYTES: + result.append("bytes"); + break; + case ANY: + result.append("any"); + break; + case TIMESTAMP: + result.append("timestamp"); + break; + case DURATION: + result.append("duration"); + break; + case LIST: + ListType listType = (ListType) type; + result.append("list<"); + if (listType.hasElemType()) { + appendTypeSignature(result, listType.elemType()); + } else { + result.append("dyn"); + } + result.append('>'); + break; + case MAP: + MapType mapType = (MapType) type; + result.append("map<"); + appendTypeSignature(result, mapType.keyType()); + result.append(','); + appendTypeSignature(result, mapType.valueType()); + result.append('>'); + break; + case TYPE: + TypeType typeType = (TypeType) type; + result.append("type<"); + appendTypeSignature(result, typeType.type()); + result.append('>'); + break; + case TYPE_PARAM: + result.append('~'); + appendEscaped(result, type.name(), /* escapeDot= */ true); + break; + case OPAQUE: + if (type.name().equals("function")) { + result.append("function<"); + ImmutableList params = type.parameters(); + if (params.isEmpty()) { + result.append("dyn"); + } else { + for (int i = 0; i < params.size(); i++) { + if (i > 0) { + result.append(','); + } + appendTypeSignature(result, params.get(i)); + } + } + result.append('>'); + } else { + appendEscaped(result, type.name(), /* escapeDot= */ false); + ImmutableList params = type.parameters(); + if (!params.isEmpty()) { + result.append('<'); + for (int i = 0; i < params.size(); i++) { + if (i > 0) { + result.append(','); + } + appendTypeSignature(result, params.get(i)); + } + result.append('>'); + } + } + break; + case STRUCT: + appendEscaped(result, type.name(), /* escapeDot= */ false); + break; + case ERROR: + case UNSPECIFIED: + throw new IllegalArgumentException("Unsupported type in signature: " + type.name()); + } + } + + private static int findTopLevelChar( + String str, char target, boolean findFirst, String errorPrefix) { + int bracketDepth = 0; + boolean escaped = false; + int foundPos = -1; + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if (c == '\\') { + escaped = true; + continue; + } + if (c == target && bracketDepth == 0) { + if (findFirst) { + return i; + } + foundPos = i; + } + if (c == '<') { + bracketDepth++; + } else if (c == '>') { + bracketDepth--; + if (bracketDepth < 0) { + throw new IllegalArgumentException(errorPrefix + ": mismatched brackets"); + } + } + } + if (bracketDepth != 0) { + throw new IllegalArgumentException(errorPrefix + ": mismatched brackets"); + } + return foundPos; + } + + private static List splitTopLevel(String str, char delimiter, String errorPrefix) { + List slices = new ArrayList<>(); + int bracketDepth = 0; + boolean escaped = false; + int start = 0; + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if (c == '\\') { + escaped = true; + continue; + } + if (c == '<') { + bracketDepth++; + } else if (c == '>') { + bracketDepth--; + if (bracketDepth < 0) { + throw new IllegalArgumentException(errorPrefix + ": mismatched brackets"); + } + } else if (c == delimiter && bracketDepth == 0) { + slices.add(str.substring(start, i)); + start = i + 1; + } + } + if (bracketDepth != 0) { + throw new IllegalArgumentException(errorPrefix + ": mismatched brackets"); + } + slices.add(str.substring(start)); + return slices; + } + + private static CelType parseTypeSignatureFromCleaned(String cleaned, int depth) { + checkArgument(depth <= MAX_RECURSION_DEPTH, "Exceeded maximum recursion depth"); + checkArgument(!cleaned.isEmpty(), "Empty type signature"); + if (cleaned.charAt(0) == '~') { + String paramName = cleaned.substring(1); + checkArgument(!paramName.isEmpty(), "Empty type parameter name"); + checkArgument( + !hasUnescapedSpecialChar(paramName, /* disallowDot= */ true), + "Invalid type parameter name: %s", + paramName); + return TypeParamType.create(unescape(paramName)); + } + + int openBracket = + findTopLevelChar(cleaned, '<', /* findFirst= */ true, "Invalid type signature"); + String baseName = cleaned; + List params = new ArrayList<>(); + if (openBracket != -1) { + checkArgument( + cleaned.charAt(cleaned.length() - 1) == '>', + "Invalid type signature, missing closing '>': %s", + cleaned); + baseName = cleaned.substring(0, openBracket); + String paramsStr = cleaned.substring(openBracket + 1, cleaned.length() - 1); + checkArgument(!paramsStr.isEmpty(), "Empty type parameters in signature: %s", cleaned); + List paramSlices = splitTopLevel(paramsStr, ',', "Invalid type parameters"); + for (String paramSlice : paramSlices) { + params.add(parseTypeSignatureFromCleaned(paramSlice, depth + 1)); + } + } + + checkArgument( + !hasUnescapedSpecialChar(baseName, /* disallowDot= */ false), + "Invalid type name: %s", + baseName); + String nameStr = unescape(baseName); + Optional builtinOrWrapper = resolveBuiltinOrWrapperType(nameStr); + if (builtinOrWrapper.isPresent()) { + checkArgument( + params.isEmpty(), "Invalid type signature: %s cannot have type parameters", nameStr); + return builtinOrWrapper.get(); + } + + switch (nameStr) { + case "type": + checkArgument( + params.size() <= 1, "Invalid type signature: type expected at most 1 parameter"); + return TypeType.create(Iterables.getFirst(params, SimpleType.DYN)); + case "list": + checkArgument( + params.size() <= 1, "Invalid type signature: list expected at most 1 parameter"); + return ListType.create(Iterables.getFirst(params, SimpleType.DYN)); + case "map": + checkArgument( + params.isEmpty() || params.size() == 2, + "Invalid type signature: map expected 0 or 2 parameters"); + if (params.isEmpty()) { + return MapType.create(SimpleType.DYN, SimpleType.DYN); + } + return MapType.create(params.get(0), params.get(1)); + case "function": + CelType resultType = Iterables.getFirst(params, SimpleType.DYN); + List argTypes = + params.size() <= 1 ? ImmutableList.of() : params.subList(1, params.size()); + return CelTypes.createFunctionType(resultType, argTypes); + case OptionalType.NAME: + checkArgument( + params.size() == 1, "Invalid type signature: optional_type expected 1 parameter"); + return OptionalType.create(params.get(0)); + default: + break; + } + + checkArgument(!nameStr.isEmpty() && !nameStr.contains(".."), "Invalid type name: %s", nameStr); + + return OpaqueType.create(nameStr, ImmutableList.copyOf(params)); + } + + private static ParsedFunctionOverload parseFunctionSignatureInternal(String signature) { + String cleaned = removeUnescapedWhitespace(signature); + checkArgument(!cleaned.isEmpty(), "Empty function signature"); + + int openParen = + findTopLevelChar(cleaned, '(', /* findFirst= */ true, "Invalid function signature"); + checkArgument(openParen != -1, "Invalid function signature, missing '(': %s", cleaned); + checkArgument( + cleaned.charAt(cleaned.length() - 1) == ')', + "Invalid function signature, missing closing ')': %s", + cleaned); + + String prefix = cleaned.substring(0, openParen); + String argsStr = cleaned.substring(openParen + 1, cleaned.length() - 1); + + List argTypes = new ArrayList<>(); + boolean isMember = false; + String rawFunctionName; + + int dotPos = + findTopLevelChar(prefix, '.', /* findFirst= */ false, "Invalid function signature"); + if (dotPos != -1) { + isMember = true; + String receiverStr = prefix.substring(0, dotPos); + rawFunctionName = prefix.substring(dotPos + 1); + argTypes.add(parseTypeSignatureFromCleaned(receiverStr, /* depth= */ 0)); + } else { + rawFunctionName = prefix; + } + + checkArgument(!rawFunctionName.isEmpty(), "Invalid function signature: empty function name"); + checkArgument( + !hasUnescapedSpecialChar(rawFunctionName, /* disallowDot= */ true), + "Invalid function name: %s", + rawFunctionName); + String functionName = unescape(rawFunctionName); + + if (!argsStr.isEmpty()) { + List argSlices = splitTopLevel(argsStr, ',', "Invalid function arguments"); + for (String argSlice : argSlices) { + argTypes.add(parseTypeSignatureFromCleaned(argSlice, /* depth= */ 0)); + } + } + + return ParsedFunctionOverload.create(functionName, isMember, argTypes); + } + + private CelSignatureUtils() {} +} diff --git a/common/src/test/java/dev/cel/common/BUILD.bazel b/common/src/test/java/dev/cel/common/BUILD.bazel index fb918db80..c9d707b65 100644 --- a/common/src/test/java/dev/cel/common/BUILD.bazel +++ b/common/src/test/java/dev/cel/common/BUILD.bazel @@ -18,6 +18,7 @@ java_library( "//common:cel_descriptors", "//common:cel_issue", "//common:cel_proto_decl_converter", + "//common:cel_signature_utils", "//common:cel_source", "//common:cel_validation_exception", "//common:cel_var_decl", @@ -32,8 +33,11 @@ java_library( "//common/internal", "//common/internal:code_point_stream", "//common/types", + "//common/types:cel_internal_types", "//common/types:cel_proto_types", + "//common/types:cel_types", "//common/types:cel_v1alpha1_types", + "//common/types:type_providers", "//common/values:cel_byte_string", "//compiler", "//compiler:compiler_builder", diff --git a/common/src/test/java/dev/cel/common/CelSignatureUtilsTest.java b/common/src/test/java/dev/cel/common/CelSignatureUtilsTest.java new file mode 100644 index 000000000..f77300601 --- /dev/null +++ b/common/src/test/java/dev/cel/common/CelSignatureUtilsTest.java @@ -0,0 +1,319 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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 dev.cel.common; + +import static com.google.common.truth.Truth.assertThat; +import static dev.cel.common.CelSignatureUtils.makeOverloadSignature; +import static dev.cel.common.CelSignatureUtils.makeTypeSignature; +import static dev.cel.common.CelSignatureUtils.parseFunctionSignature; +import static dev.cel.common.CelSignatureUtils.parseTypeSignature; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.testing.junit.testparameterinjector.TestParameter; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import com.google.testing.junit.testparameterinjector.TestParameters; +import dev.cel.common.CelSignatureUtils.ParsedFunctionOverload; +import dev.cel.common.types.CelType; +import dev.cel.common.types.CelTypes; +import dev.cel.common.types.EnumType; +import dev.cel.common.types.ListType; +import dev.cel.common.types.MapType; +import dev.cel.common.types.NullableType; +import dev.cel.common.types.OpaqueType; +import dev.cel.common.types.OptionalType; +import dev.cel.common.types.SimpleType; +import dev.cel.common.types.StructTypeReference; +import dev.cel.common.types.TypeParamType; +import dev.cel.common.types.TypeType; +import dev.cel.common.types.UnspecifiedType; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class CelSignatureUtilsTest { + + private enum RoundTripTypeTestCase { + NULL(SimpleType.NULL_TYPE, "null"), + DYN(SimpleType.DYN, "dyn"), + BOOL(SimpleType.BOOL, "bool"), + INT(SimpleType.INT, "int"), + UINT(SimpleType.UINT, "uint"), + DOUBLE(SimpleType.DOUBLE, "double"), + STRING(SimpleType.STRING, "string"), + BYTES(SimpleType.BYTES, "bytes"), + ANY(SimpleType.ANY, "any"), + TIMESTAMP(SimpleType.TIMESTAMP, "timestamp"), + DURATION(SimpleType.DURATION, "duration"), + BOOL_WRAPPER(NullableType.create(SimpleType.BOOL), "bool_wrapper"), + INT_WRAPPER(NullableType.create(SimpleType.INT), "int_wrapper"), + UINT_WRAPPER(NullableType.create(SimpleType.UINT), "uint_wrapper"), + DOUBLE_WRAPPER(NullableType.create(SimpleType.DOUBLE), "double_wrapper"), + STRING_WRAPPER(NullableType.create(SimpleType.STRING), "string_wrapper"), + BYTES_WRAPPER(NullableType.create(SimpleType.BYTES), "bytes_wrapper"), + LIST_INT(ListType.create(SimpleType.INT), "list"), + LIST_DYN(ListType.create(SimpleType.DYN), "list"), + MAP_STRING_INT(MapType.create(SimpleType.STRING, SimpleType.INT), "map"), + TYPE_INT(TypeType.create(SimpleType.INT), "type"), + TYPE_DYN(TypeType.create(SimpleType.DYN), "type"), + TYPE_PARAM_T(TypeParamType.create("T"), "~T"), + TYPE_PARAM_ESCAPED(TypeParamType.create("foo.bar"), "~foo\\.bar\\"), + OPTIONAL_STRING(OptionalType.create(SimpleType.STRING), "optional_type"), + OPAQUE_SIMPLE(OpaqueType.create("my.pkg.CustomType"), "my.pkg.CustomType"), + OPAQUE_PARAMETERIZED( + OpaqueType.create("my.pkg.CustomType", SimpleType.INT, TypeParamType.create("T")), + "my.pkg.CustomType"), + FUNCTION_NO_ARGS( + CelTypes.createFunctionType(SimpleType.INT, ImmutableList.of()), "function"), + FUNCTION_WITH_ARGS( + CelTypes.createFunctionType( + SimpleType.BOOL, ImmutableList.of(SimpleType.STRING, SimpleType.INT)), + "function"), + NESTED_COMPLEX( + MapType.create( + SimpleType.STRING, ListType.create(OptionalType.create(TypeParamType.create("V")))), + "map>>"); + + private final CelType type; + private final String expectedSignature; + + RoundTripTypeTestCase(CelType type, String expectedSignature) { + this.type = type; + this.expectedSignature = expectedSignature; + } + } + + @Test + public void makeAndParseTypeSignature_roundTrip(@TestParameter RoundTripTypeTestCase testCase) { + String signature = makeTypeSignature(testCase.type); + assertThat(signature).isEqualTo(testCase.expectedSignature); + assertThat(parseTypeSignature(signature)).isEqualTo(testCase.type); + } + + @Test + public void makeTypeSignature_structAndEnum() { + CelType structType = StructTypeReference.create("cel.expr.conformance.proto3.TestAllTypes"); + CelType enumType = + EnumType.create( + "cel.expr.conformance.proto3.GlobalEnum", ImmutableMap.of("GOO", 0, "BAR", 1)); + + assertThat(makeTypeSignature(structType)).isEqualTo("cel.expr.conformance.proto3.TestAllTypes"); + assertThat(makeTypeSignature(enumType)).isEqualTo("cel.expr.conformance.proto3.GlobalEnum"); + } + + @Test + public void makeTypeSignature_unparameterizedListAndFunction() { + assertThat(makeTypeSignature(ListType.create())).isEqualTo("list"); + assertThat(makeTypeSignature(OpaqueType.create("function"))).isEqualTo("function"); + } + + @Test + public void makeTypeSignature_unsupportedTypes_throws() { + CelType unspecifiedType = UnspecifiedType.create(); + assertThrows(IllegalArgumentException.class, () -> makeTypeSignature(SimpleType.ERROR)); + assertThrows(IllegalArgumentException.class, () -> makeTypeSignature(unspecifiedType)); + } + + @Test + public void parseTypeSignature_wellKnownAliasesAndDefaults() { + assertThat(parseTypeSignature("null_type")).isEqualTo(SimpleType.NULL_TYPE); + assertThat(parseTypeSignature("google.protobuf.Any")).isEqualTo(SimpleType.ANY); + assertThat(parseTypeSignature("google.protobuf.Timestamp")).isEqualTo(SimpleType.TIMESTAMP); + assertThat(parseTypeSignature("google.protobuf.Duration")).isEqualTo(SimpleType.DURATION); + assertThat(parseTypeSignature("google.protobuf.Value")).isEqualTo(SimpleType.DYN); + assertThat(parseTypeSignature("google.protobuf.BoolValue")) + .isEqualTo(NullableType.create(SimpleType.BOOL)); + assertThat(parseTypeSignature("google.protobuf.Int64Value")) + .isEqualTo(NullableType.create(SimpleType.INT)); + assertThat(parseTypeSignature("google.protobuf.Int32Value")) + .isEqualTo(NullableType.create(SimpleType.INT)); + assertThat(parseTypeSignature("google.protobuf.UInt64Value")) + .isEqualTo(NullableType.create(SimpleType.UINT)); + assertThat(parseTypeSignature("google.protobuf.UInt32Value")) + .isEqualTo(NullableType.create(SimpleType.UINT)); + assertThat(parseTypeSignature("google.protobuf.DoubleValue")) + .isEqualTo(NullableType.create(SimpleType.DOUBLE)); + assertThat(parseTypeSignature("google.protobuf.FloatValue")) + .isEqualTo(NullableType.create(SimpleType.DOUBLE)); + assertThat(parseTypeSignature("google.protobuf.StringValue")) + .isEqualTo(NullableType.create(SimpleType.STRING)); + assertThat(parseTypeSignature("google.protobuf.BytesValue")) + .isEqualTo(NullableType.create(SimpleType.BYTES)); + assertThat(parseTypeSignature("google.protobuf.ListValue")) + .isEqualTo(ListType.create(SimpleType.DYN)); + assertThat(parseTypeSignature("google.protobuf.Struct")) + .isEqualTo(MapType.create(SimpleType.STRING, SimpleType.DYN)); + + // Parameterless composite types default to dyn + assertThat(parseTypeSignature("list")).isEqualTo(ListType.create(SimpleType.DYN)); + assertThat(parseTypeSignature("map")) + .isEqualTo( + MapType.create(SimpleType.STRING, SimpleType.DYN) + .withParameters(ImmutableList.of(SimpleType.DYN, SimpleType.DYN))); + assertThat(parseTypeSignature("type")).isEqualTo(TypeType.create(SimpleType.DYN)); + assertThat(parseTypeSignature("function")) + .isEqualTo(CelTypes.createFunctionType(SimpleType.DYN, ImmutableList.of())); + } + + @Test + @TestParameters({ + "{signature: '', expectedError: 'Empty type signature'}", + "{signature: ' ', expectedError: 'Empty type signature'}", + "{signature: '~', expectedError: 'Empty type parameter name'}", + "{signature: '~a', expectedError: 'Empty type parameters in signature: list<>'}", + "{signature: 'list':" + + " list>', expectedError: 'Invalid type parameters: mismatched brackets'}", + "{signature: 'list<', expectedError: 'Invalid type parameters: mismatched brackets'}", + "{signature: 'listextra', " + + "expectedError: \"Invalid type signature, missing closing '>': listextra\"}", + "{signature: 'list', " + + "expectedError: 'Invalid type signature: list expected at most 1 parameter'}", + "{signature: 'map', " + + "expectedError: 'Invalid type signature: map expected 0 or 2 parameters'}", + "{signature: 'map', " + + "expectedError: 'Invalid type signature: map expected 0 or 2 parameters'}", + "{signature: 'type', " + + "expectedError: 'Invalid type signature: type expected at most 1 parameter'}", + "{signature: 'optional_type', " + + "expectedError: 'Invalid type signature: optional_type expected 1 parameter'}", + "{signature: 'optional_type', " + + "expectedError: 'Invalid type signature: optional_type expected 1 parameter'}", + "{signature: 'int', " + + "expectedError: 'Invalid type signature: int cannot have type parameters'}", + "{signature: 'int_wrapper', " + + "expectedError: 'Invalid type signature: int_wrapper cannot have type parameters'}", + "{signature: 'foo..bar', expectedError: 'Invalid type name: foo..bar'}", + "{signature: 'foo(bar)', expectedError: 'Invalid type name: foo(bar)'}", + "{signature: 'int\\', expectedError: \"Invalid signature, trailing '\\\\': int\\\\\"}", + "{signature: '', expectedError: 'Invalid type name: '}", + }) + public void parseTypeSignature_invalid_throws(String signature, String expectedError) { + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> parseTypeSignature(signature)); + assertThat(e).hasMessageThat().isEqualTo(expectedError); + } + + @Test + public void parseTypeSignature_recursionLimit() { + StringBuilder valid = new StringBuilder(); + for (int i = 0; i < 64; i++) { + valid.append("list<"); + } + valid.append("int"); + for (int i = 0; i < 64; i++) { + valid.append(">"); + } + assertThat(parseTypeSignature(valid.toString())).isNotNull(); + + String exceeded = "list<" + valid + ">"; + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> parseTypeSignature(exceeded)); + assertThat(e).hasMessageThat().contains("Exceeded maximum recursion depth"); + } + + @Test + public void makeAndParseFunctionSignature_roundTrip() { + // Global no-arg function + String globalNoArg = makeOverloadSignature("now", ImmutableList.of(), /* isMember= */ false); + assertThat(globalNoArg).isEqualTo("now()"); + ParsedFunctionOverload parsedGlobalNoArg = parseFunctionSignature(globalNoArg); + assertThat(parsedGlobalNoArg.functionName()).isEqualTo("now"); + assertThat(parsedGlobalNoArg.isMember()).isFalse(); + assertThat(parsedGlobalNoArg.argTypes()).isEmpty(); + + // Global multi-arg function + String globalMultiArg = + makeOverloadSignature( + "add", + ImmutableList.of(SimpleType.INT, ListType.create(SimpleType.STRING)), + /* isMember= */ false); + assertThat(globalMultiArg).isEqualTo("add(int,list)"); + ParsedFunctionOverload parsedGlobalMultiArg = + parseFunctionSignature(" add ( int , list < string > ) "); + assertThat(parsedGlobalMultiArg.functionName()).isEqualTo("add"); + assertThat(parsedGlobalMultiArg.isMember()).isFalse(); + assertThat(parsedGlobalMultiArg.argTypes()) + .containsExactly(SimpleType.INT, ListType.create(SimpleType.STRING)) + .inOrder(); + + // Member function with parameterized receiver + String memberSig = + makeOverloadSignature( + "get", + ImmutableList.of( + MapType.create(TypeParamType.create("K"), TypeParamType.create("V")), + TypeParamType.create("K")), + /* isMember= */ true); + assertThat(memberSig).isEqualTo("map<~K,~V>.get(~K)"); + ParsedFunctionOverload parsedMember = parseFunctionSignature(memberSig); + assertThat(parsedMember.functionName()).isEqualTo("get"); + assertThat(parsedMember.isMember()).isTrue(); + assertThat(parsedMember.argTypes()) + .containsExactly( + MapType.create(TypeParamType.create("K"), TypeParamType.create("V")), + TypeParamType.create("K")) + .inOrder(); + + // Global function with dot in name (escaped) + String namespacedGlobal = + makeOverloadSignature( + "math.greatest", + ImmutableList.of(SimpleType.INT, SimpleType.INT), + /* isMember= */ false); + assertThat(namespacedGlobal).isEqualTo("math\\.greatest(int,int)"); + ParsedFunctionOverload parsedNamespaced = parseFunctionSignature(namespacedGlobal); + assertThat(parsedNamespaced.functionName()).isEqualTo("math.greatest"); + assertThat(parsedNamespaced.isMember()).isFalse(); + assertThat(parsedNamespaced.argTypes()) + .containsExactly(SimpleType.INT, SimpleType.INT) + .inOrder(); + } + + @Test + public void makeOverloadSignature_memberWithNoArgs_throws() { + assertThrows( + IllegalArgumentException.class, + () -> makeOverloadSignature("size", ImmutableList.of(), /* isMember= */ true)); + } + + @Test + @TestParameters({ + "{signature: '', expectedError: 'Empty function signature'}", + "{signature: 'foo', expectedError: \"Invalid function signature, missing '(': foo\"}", + "{signature: 'foo(int', " + + "expectedError: \"Invalid function signature, missing closing ')': foo(int\"}", + "{signature: 'foo(int)extra', " + + "expectedError: \"Invalid function signature, missing closing ')': foo(int)extra\"}", + "{signature: '(int)', expectedError: 'Invalid function signature: empty function name'}", + "{signature: 'string.(int)', " + + "expectedError: 'Invalid function signature: empty function name'}", + "{signature: 'list(int)', expectedError: 'Invalid function name: foo'}", + }) + public void parseFunctionSignature_invalid_throws(String signature, String expectedError) { + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> parseFunctionSignature(signature)); + assertThat(e).hasMessageThat().isEqualTo(expectedError); + } +} diff --git a/testing/src/test/resources/environment/extended_env.yaml b/testing/src/test/resources/environment/extended_env.yaml index f380f4ed2..c7b26b7dc 100644 --- a/testing/src/test/resources/environment/extended_env.yaml +++ b/testing/src/test/resources/environment/extended_env.yaml @@ -76,6 +76,12 @@ functions: args: - "~K" - "~V" +- name: "contains" + overloads: + - signature: "contains(list<~T>, ~T)" + examples: + - "contains([1, 2, 3], 2) // true" + return: "bool" features: - name: cel.feature.macro_call_tracking enabled: true diff --git a/testing/src/test/resources/environment/subset_env.yaml b/testing/src/test/resources/environment/subset_env.yaml index f721a1426..f689fb152 100644 --- a/testing/src/test/resources/environment/subset_env.yaml +++ b/testing/src/test/resources/environment/subset_env.yaml @@ -20,8 +20,8 @@ stdlib: exclude_functions: - name: "_+_" overloads: - - id: add_bytes - - id: add_list + - id: _+_(bytes,bytes) + - id: _+_(list<~A>,list<~A>) - id: add_string - name: "matches" - name: "timestamp"