diff --git a/bundle/src/main/java/dev/cel/bundle/BUILD.bazel b/bundle/src/main/java/dev/cel/bundle/BUILD.bazel index ce120220e..3c7608900 100644 --- a/bundle/src/main/java/dev/cel/bundle/BUILD.bazel +++ b/bundle/src/main/java/dev/cel/bundle/BUILD.bazel @@ -102,6 +102,7 @@ java_library( name = "environment", srcs = [ "CelEnvironment.java", + "CelOverloadDeclParser.java", "TypeSpecifierParser.java", ], tags = [ @@ -115,12 +116,14 @@ java_library( "//checker:standard_decl", "//common:cel_function_decl", "//common:cel_overload_decl", + "//common:cel_overload_decl_formatter", "//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", @@ -152,10 +155,13 @@ java_library( deps = [ ":environment", ":environment_exception", + "//common:cel_overload_decl_formatter", "//common:compiler_common", "//common:container", + "//common:type_specifier_formatter", "//common/formats:file_source", "//common/formats:parser_context", + "//common/formats:value_string", "//common/formats:yaml_helper", "//common/formats:yaml_parser_context_impl", "//common/internal", diff --git a/bundle/src/main/java/dev/cel/bundle/CelEnvironment.java b/bundle/src/main/java/dev/cel/bundle/CelEnvironment.java index 192f368b3..5fe2ff300 100644 --- a/bundle/src/main/java/dev/cel/bundle/CelEnvironment.java +++ b/bundle/src/main/java/dev/cel/bundle/CelEnvironment.java @@ -14,6 +14,7 @@ package dev.cel.bundle; +import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; import static com.google.common.collect.ImmutableList.toImmutableList; @@ -35,12 +36,17 @@ import dev.cel.common.CelFunctionDecl; import dev.cel.common.CelOptions; import dev.cel.common.CelOverloadDecl; +import dev.cel.common.CelOverloadDeclFormatter; 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 +61,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 +552,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(); @@ -623,21 +634,29 @@ public static OverloadDecl.Builder newBuilder() { /** Converts this policy function overload into a {@link CelOverloadDecl}. */ public CelOverloadDecl toCelOverloadDecl(CelTypeProvider celTypeProvider) { + return toCelOverloadDecl(celTypeProvider::findType); + } + + CelOverloadDecl toCelOverloadDecl() { + return toCelOverloadDecl(typeName -> Optional.of(OpaqueType.create(typeName))); + } + + private CelOverloadDecl toCelOverloadDecl(Function> typeFinder) { CelOverloadDecl.Builder builder = CelOverloadDecl.newBuilder() .setIsInstanceFunction(false) .setOverloadId(id()) - .setResultType(returnType().toCelType(celTypeProvider)); + .setResultType(returnType().toCelType(typeFinder)); target() .ifPresent( t -> builder .setIsInstanceFunction(true) - .addParameterTypes(t.toCelType(celTypeProvider))); + .addParameterTypes(t.toCelType(typeFinder))); for (TypeDecl type : arguments()) { - builder.addParameterTypes(type.toCelType(celTypeProvider)); + builder.addParameterTypes(type.toCelType(typeFinder)); } return builder.build(); @@ -709,20 +728,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 +799,72 @@ 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 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()); - } + break; + } - if (name().equals("dyn")) { - return SimpleType.DYN; - } + Optional builtinType = findCelTypeByName(name()); + if (builtinType.isPresent()) { + checkArgument( + params().isEmpty(), "Type %s cannot have type parameters", name()); + return builtinType.get(); + } - CelType simpleType = SimpleType.findByName(name()).orElse(null); - if (simpleType != null) { - return simpleType; - } + 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; + } - 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)); + private static Optional findCelTypeByName(String name) { + switch (name) { + case "dyn": + return Optional.of(SimpleType.DYN); + 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 "bool_wrapper": + return Optional.of(NullableType.create(SimpleType.BOOL)); + case "bytes_wrapper": + return Optional.of(NullableType.create(SimpleType.BYTES)); + case "double_wrapper": + return Optional.of(NullableType.create(SimpleType.DOUBLE)); + case "int_wrapper": + return Optional.of(NullableType.create(SimpleType.INT)); + case "string_wrapper": + return Optional.of(NullableType.create(SimpleType.STRING)); + case "uint_wrapper": + return Optional.of(NullableType.create(SimpleType.UINT)); + default: + Optional simpleType = SimpleType.findByName(name); + if (simpleType.isPresent()) { + return simpleType; } - - return celTypeProvider - .findType(name()) - .orElseThrow(() -> new IllegalArgumentException("Undefined type name: " + name())); + return CelTypes.getWellKnownCelType(name); } } } @@ -1057,6 +1160,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 +1184,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 +1194,24 @@ 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 = CelOverloadDeclFormatter.format(functionName, celOverload); + } + if (!overloadSignature.equals(overloadId)) { + for (OverloadSelector overloadSelector : functionSelector.overloads()) { + if (overloadSelector.id().equals(overloadSignature)) { + return true; + } + } + } } return false; } @@ -1096,7 +1221,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..dcdec0d15 100644 --- a/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlParser.java +++ b/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlParser.java @@ -36,10 +36,12 @@ import dev.cel.bundle.CelEnvironment.OverloadDecl; import dev.cel.bundle.CelEnvironment.TypeDecl; import dev.cel.bundle.CelEnvironment.VariableDecl; +import dev.cel.bundle.CelOverloadDeclParser.ParsedOverloadDecl; import dev.cel.common.CelContainer; import dev.cel.common.CelIssue; 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; @@ -450,6 +452,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 +463,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 +474,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 +485,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 +502,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,15 +517,41 @@ 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": - overloadDeclBuilder.addArguments(parseOverloadArguments(ctx, valueNode)); + case "target": + if (hasSignature) { + ctx.reportError( + keyId, + String.format( + "'signature' tag cannot be used together with 'target' or 'args': %s", + fieldName)); + break; + } + hasTargetOrArgs = true; + if (fieldName.equals("args")) { + overloadDeclBuilder.addArguments(parseOverloadArguments(ctx, valueNode)); + } else { + overloadDeclBuilder.setTarget(parseTypeDecl(ctx, valueNode)); + } break; case "return": overloadDeclBuilder.setReturnType(parseTypeDecl(ctx, valueNode)); break; - case "target": - overloadDeclBuilder.setTarget(parseTypeDecl(ctx, valueNode)); - break; case "examples": overloadDeclBuilder.addExamples(parseOverloadExamples(ctx, valueNode)); break; @@ -534,6 +570,43 @@ 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; + } + + ParsedOverloadDecl parsedSignature; + try { + parsedSignature = CelOverloadDeclParser.parse(signature); + } catch (IllegalArgumentException e) { + ctx.reportError(signatureValue.id(), e.getMessage()); + return; + } + + if (!functionName.isEmpty() + && !functionName.equals(ERROR) + && !parsedSignature.functionName().equals(functionName)) { + ctx.reportError( + signatureValue.id(), + String.format( + "Function overload name '%s' does not match function name '%s'", + parsedSignature.functionName(), functionName)); + return; + } + + parsedSignature.target().ifPresent(overloadDeclBuilder::setTarget); + overloadDeclBuilder.addArguments(parsedSignature.arguments()); + } + 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..92f0b50a4 100644 --- a/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlSerializer.java +++ b/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlSerializer.java @@ -21,6 +21,9 @@ import dev.cel.bundle.CelEnvironment.LibrarySubset.FunctionSelector; import dev.cel.bundle.CelEnvironment.LibrarySubset.OverloadSelector; import dev.cel.common.CelContainer; +import dev.cel.common.CelOverloadDecl; +import dev.cel.common.CelOverloadDeclFormatter; +import dev.cel.common.TypeSpecifierFormatter; import java.util.Comparator; import java.util.Map; import org.yaml.snakeyaml.DumperOptions; @@ -42,14 +45,19 @@ 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()); - this.multiRepresenters.put(CelEnvironment.OverloadDecl.class, new RepresentOverloadDecl()); this.multiRepresenters.put(CelEnvironment.TypeDecl.class, new RepresentTypeDecl()); this.multiRepresenters.put( CelEnvironment.ExtensionConfig.class, new RepresentExtensionConfig()); @@ -64,9 +72,18 @@ private CelEnvironmentYamlSerializer() { this.multiRepresenters.put(CelEnvironment.Limit.class, new RepresentLimit()); } - public static String toYaml(CelEnvironment environment) { + /** + * Serializes the given {@link CelEnvironment} into a YAML string. + * + * @param environment the environment to serialize + * @param useTypeSignatures if true, formats variable types, function overload signatures, and + * return types using canonical CEL signature strings instead of verbose type maps + */ + 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 +176,17 @@ 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()); + } + if (useTypeSignatures) { + configMap.put("type", TypeSpecifierFormatter.format(variable.type().toCelType())); + } else { + configMap.put("type_name", variable.type().name()); + if (!variable.type().params().isEmpty()) { + configMap.put("params", variable.type().params()); + } } return represent(configMap.buildOrThrow()); } @@ -172,23 +197,45 @@ 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()); } } - private final class RepresentOverloadDecl implements Represent { - @Override - public Node representData(Object data) { - CelEnvironment.OverloadDecl overload = (CelEnvironment.OverloadDecl) data; - ImmutableMap.Builder configMap = new ImmutableMap.Builder<>(); + private ImmutableMap representOverloadDecl( + String functionName, CelEnvironment.OverloadDecl overload) { + ImmutableMap.Builder configMap = new ImmutableMap.Builder<>(); + if (useTypeSignatures) { + CelOverloadDecl celOverload = overload.toCelOverloadDecl(); + String signature = CelOverloadDeclFormatter.format(functionName, celOverload); + if (!overload.id().equals(signature)) { + configMap.put("id", overload.id()); + } + configMap.put("signature", signature); + if (!overload.examples().isEmpty()) { + configMap.put("examples", overload.examples()); + } + configMap.put("return", TypeSpecifierFormatter.format(celOverload.resultType())); + } else { configMap.put("id", overload.id()); 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()); + } + configMap.put("return", overload.returnType()); } + return configMap.buildOrThrow(); } private final class RepresentTypeDecl implements Represent { diff --git a/bundle/src/main/java/dev/cel/bundle/CelOverloadDeclParser.java b/bundle/src/main/java/dev/cel/bundle/CelOverloadDeclParser.java new file mode 100644 index 000000000..5ed8943ab --- /dev/null +++ b/bundle/src/main/java/dev/cel/bundle/CelOverloadDeclParser.java @@ -0,0 +1,179 @@ +// 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.collect.ImmutableList; +import dev.cel.bundle.CelEnvironment.TypeDecl; +import java.util.Optional; + +/** Parser for function overload signatures into {@link ParsedOverloadDecl}. */ +final class CelOverloadDeclParser { + + @AutoValue + abstract static class ParsedOverloadDecl { + abstract String functionName(); + + abstract Optional target(); + + abstract ImmutableList arguments(); + + static ParsedOverloadDecl create( + String functionName, Optional target, Iterable arguments) { + return new AutoValue_CelOverloadDeclParser_ParsedOverloadDecl( + functionName, target, ImmutableList.copyOf(arguments)); + } + } + + static ParsedOverloadDecl parse(String signature) { + checkNotNull(signature); + String trimmed = signature.trim(); + checkArgument(!trimmed.isEmpty(), "Empty function signature"); + + int openParen = + findTopLevelChar(trimmed, '(', /* findFirst= */ true, "Invalid function signature"); + checkArgument(openParen != -1, "Invalid function signature, missing '(': %s", trimmed); + checkArgument( + trimmed.charAt(trimmed.length() - 1) == ')', + "Invalid function signature, missing closing ')': %s", + trimmed); + + String prefix = trimTrailingUnescapedWhitespace(trimmed.substring(0, openParen)); + String argsStr = trimmed.substring(openParen + 1, trimmed.length() - 1); + + Optional target; + String rawFunctionName; + + int dotPos = + findTopLevelChar(prefix, '.', /* findFirst= */ false, "Invalid function signature"); + if (dotPos != -1) { + String receiverStr = prefix.substring(0, dotPos); + rawFunctionName = prefix.substring(dotPos + 1); + target = Optional.of(TypeSpecifierParser.parse(receiverStr)); + } else { + rawFunctionName = prefix; + target = Optional.empty(); + } + + checkArgument(!rawFunctionName.isEmpty(), "Invalid function signature: empty function name"); + checkArgument( + !hasUnescapedSpecialChar(rawFunctionName), + "Invalid function name: %s", + rawFunctionName); + String functionName = unescape(rawFunctionName); + + ImmutableList arguments = TypeSpecifierParser.parseTypeList(argsStr); + + return ParsedOverloadDecl.create(functionName, target, arguments); + } + + 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; + } else if (c == '\\') { + escaped = true; + } else { + 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 boolean hasUnescapedSpecialChar(String str) { + boolean escaped = false; + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (Character.isWhitespace(c) + || c == ')' + || c == '<' + || c == '"' + || c == ',' + || c == '~') { + return true; + } + } + return false; + } + + 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); + } + } + return result.toString(); + } + + private static String trimTrailingUnescapedWhitespace(String str) { + int lastNonWhitespaceEnd = 0; + boolean escaped = false; + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (escaped) { + escaped = false; + lastNonWhitespaceEnd = i + 1; + } else if (c == '\\') { + escaped = true; + } else if (!Character.isWhitespace(c)) { + lastNonWhitespaceEnd = i + 1; + } + } + return str.substring(0, lastNonWhitespaceEnd); + } + + private CelOverloadDeclParser() {} +} diff --git a/bundle/src/main/java/dev/cel/bundle/TypeSpecifierParser.java b/bundle/src/main/java/dev/cel/bundle/TypeSpecifierParser.java index ca96a955f..c77404ed8 100644 --- a/bundle/src/main/java/dev/cel/bundle/TypeSpecifierParser.java +++ b/bundle/src/main/java/dev/cel/bundle/TypeSpecifierParser.java @@ -61,6 +61,36 @@ private TypeDecl parse() { return res; } + static ImmutableList parseTypeList(String text) { + checkNotNull(text); + TypeSpecifierParser parser = new TypeSpecifierParser(text); + return parser.parseTypeList(); + } + + private ImmutableList parseTypeList() { + skipWhitespace(); + if (pos >= length) { + return ImmutableList.of(); + } + ImmutableList.Builder types = ImmutableList.builder(); + while (true) { + types.add(parseTypeElem(0)); + skipWhitespace(); + if (pos < length && text.charAt(pos) == ',') { + pos++; // consume ',' + continue; + } + break; + } + if (pos < length) { + throw new IllegalArgumentException( + String.format( + "unexpected character '%c' at position %d in %s", + text.charAt(pos), pos, formatQuoted(text))); + } + return types.build(); + } + private TypeSpecifierParser(String text) { this.text = text; this.length = text.length(); diff --git a/bundle/src/test/java/dev/cel/bundle/CelEnvironmentTest.java b/bundle/src/test/java/dev/cel/bundle/CelEnvironmentTest.java index 7ff605995..9b85a2018 100644 --- a/bundle/src/test/java/dev/cel/bundle/CelEnvironmentTest.java +++ b/bundle/src/test/java/dev/cel/bundle/CelEnvironmentTest.java @@ -18,11 +18,13 @@ import static org.junit.Assert.assertThrows; import com.google.common.collect.ImmutableSet; +import com.google.testing.junit.testparameterinjector.TestParameter; import com.google.testing.junit.testparameterinjector.TestParameterInjector; import dev.cel.bundle.CelEnvironment.CanonicalCelExtension; 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 +32,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 +473,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 = @@ -489,5 +546,103 @@ public void typeDecl_toCelType_type_wrongParamCount_throws() { IllegalStateException e = assertThrows(IllegalStateException.class, () -> typeDecl.toCelType(typeProvider)); assertThat(e).hasMessageThat().contains("Expected 1 parameter for type, got 0"); + + CelEnvironment.TypeDecl optionalDecl = + CelEnvironment.TypeDecl.newBuilder() + .setName("optional_type") + .addParams(CelEnvironment.TypeDecl.create("int")) + .addParams(CelEnvironment.TypeDecl.create("string")) + .build(); + IllegalStateException optionalException = + assertThrows(IllegalStateException.class, () -> optionalDecl.toCelType(typeProvider)); + assertThat(optionalException) + .hasMessageThat() + .contains("Optional type must have exactly 1 parameter. Found 2"); + } + + private enum FromAndToCelTypeTestCase { + INT(SimpleType.INT), + BOOL_WRAPPER(NullableType.create(SimpleType.BOOL)), + BYTES_WRAPPER(NullableType.create(SimpleType.BYTES)), + DOUBLE_WRAPPER(NullableType.create(SimpleType.DOUBLE)), + INT_WRAPPER(NullableType.create(SimpleType.INT)), + STRING_WRAPPER(NullableType.create(SimpleType.STRING)), + UINT_WRAPPER(NullableType.create(SimpleType.UINT)), + MAP_LIST_TYPE_PARAM( + MapType.create(SimpleType.STRING, ListType.create(TypeParamType.create("T")))), + OPTIONAL_INT(OptionalType.create(SimpleType.INT)), + OPAQUE_WITH_PARAM(OpaqueType.create("my_opaque", SimpleType.INT)), + ; + + private final CelType celType; + + FromAndToCelTypeTestCase(CelType celType) { + this.celType = celType; + } + } + + @Test + public void typeDecl_fromCelType_and_toCelType(@TestParameter FromAndToCelTypeTestCase testCase) { + assertThat(TypeDecl.fromCelType(testCase.celType).toCelType()).isEqualTo(testCase.celType); + } + + private enum ShorthandTypeDeclToCelTypeTestCase { + INT_WRAPPER("int_wrapper", NullableType.create(SimpleType.INT)), + BOOL_WRAPPER("bool_wrapper", NullableType.create(SimpleType.BOOL)), + UINT_WRAPPER("uint_wrapper", NullableType.create(SimpleType.UINT)), + DOUBLE_WRAPPER("double_wrapper", NullableType.create(SimpleType.DOUBLE)), + STRING_WRAPPER("string_wrapper", NullableType.create(SimpleType.STRING)), + BYTES_WRAPPER("bytes_wrapper", NullableType.create(SimpleType.BYTES)), + NULL("null", SimpleType.NULL_TYPE), + ANY("any", SimpleType.ANY), + TIMESTAMP("timestamp", SimpleType.TIMESTAMP), + DURATION("duration", SimpleType.DURATION), + ; + + private final String typeName; + private final CelType expectedCelType; + + ShorthandTypeDeclToCelTypeTestCase(String typeName, CelType expectedCelType) { + this.typeName = typeName; + this.expectedCelType = expectedCelType; + } + } + + @Test + public void typeDecl_shorthandToCelType( + @TestParameter ShorthandTypeDeclToCelTypeTestCase testCase) { + CelTypeProvider typeProvider = + CelCompilerFactory.standardCelCompilerBuilder().build().getTypeProvider(); + assertThat(TypeDecl.create(testCase.typeName).toCelType(typeProvider)) + .isEqualTo(testCase.expectedCelType); + } + + @Test + public void typeDecl_invalidTypes_throws() { + CelTypeProvider typeProvider = + CelCompilerFactory.standardCelCompilerBuilder().build().getTypeProvider(); + CelType durationWrapperType = NullableType.create(SimpleType.DURATION); + CelType functionType = OpaqueType.create("function", SimpleType.INT); + TypeDecl parameterizedTypeParam = + TypeDecl.newBuilder() + .setName("T") + .setIsTypeParam(true) + .addParams(TypeDecl.create("int")) + .build(); + TypeDecl errorTypeDecl = TypeDecl.create("*error*"); + TypeDecl parameterizedInt = TypeDecl.parse("int"); + TypeDecl parameterizedDyn = TypeDecl.parse("dyn"); + TypeDecl parameterizedTimestamp = TypeDecl.parse("google.protobuf.Timestamp"); + + assertThrows(IllegalArgumentException.class, () -> TypeDecl.fromCelType(SimpleType.ERROR)); + assertThrows(IllegalArgumentException.class, () -> TypeDecl.fromCelType(durationWrapperType)); + assertThrows(IllegalArgumentException.class, () -> TypeDecl.fromCelType(functionType)); + assertThrows( + IllegalArgumentException.class, () -> parameterizedTypeParam.toCelType(typeProvider)); + assertThrows(IllegalArgumentException.class, () -> errorTypeDecl.toCelType(typeProvider)); + assertThrows(IllegalArgumentException.class, () -> parameterizedInt.toCelType(typeProvider)); + assertThrows(IllegalArgumentException.class, () -> parameterizedDyn.toCelType(typeProvider)); + assertThrows( + IllegalArgumentException.class, () -> parameterizedTimestamp.toCelType(typeProvider)); } } diff --git a/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlParserTest.java b/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlParserTest.java index acc89ff18..aea2d9011 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,91 @@ private enum EnvironmentParseErrorTestcase { "ERROR: :2:5: Missing required attribute(s): name\n" + " | - enabled: true\n" + " | ....^"), + ILLEGAL_YAML_TYPE_FUNCTION_NAME( + "functions:\n" + + " - name: [ 'bogus' ]\n" + + " overloads:\n" + + " - signature: 'foo()'\n" + + " return: 'bool'\n", + "ERROR: :2:11: Got yaml node type tag:yaml.org,2002:seq, wanted type(s)" + + " [tag:yaml.org,2002:str !txt]\n" + + " | - name: [ 'bogus' ]\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" + + " | ........^"), + OVERLOAD_ARGS_WITH_SIGNATURE( + "functions:\n" + + " - name: 'foo'\n" + + " overloads:\n" + + " - args:\n" + + " - 'int'\n" + + " signature: 'foo(int)'\n" + + " return: 'bool'\n", + "ERROR: :6:9: 'signature' tag cannot be used together with 'target' or 'args':" + + " signature\n" + + " | signature: 'foo(int)'\n" + + " | ........^"), ; private final String yamlConfig; @@ -1157,6 +1339,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 +1374,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..311865bc3 100644 --- a/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlSerializerTest.java +++ b/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlSerializerTest.java @@ -16,6 +16,7 @@ import static com.google.common.truth.Truth.assertThat; import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertThrows; import com.google.common.base.Ascii; import com.google.common.collect.ImmutableList; @@ -31,6 +32,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; @@ -168,15 +170,18 @@ public void toYaml_success() throws Exception { .build()) .build()) .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())))) + FunctionDecl.newBuilder() + .setName("coalesce") + .setDescription("") + .setOverloads( + 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())) .setStandardLibrarySubset( LibrarySubset.newBuilder() .setDisabled(true) @@ -197,7 +202,8 @@ public void toYaml_success() throws Exception { CelEnvironment.Limit.create("cel.limit.parse_recursion_depth", 7)) .build(); - String yamlOutput = CelEnvironmentYamlSerializer.toYaml(environment); + String yamlOutput = + CelEnvironmentYamlSerializer.toYaml(environment, /* useTypeSignatures= */ false); try { String yamlFileContent = readFile("environment/dump_env.yaml"); // Strip the license at the beginning of the file @@ -230,7 +236,8 @@ public void standardLibrary_excludeMacrosAndFunctions() throws Exception { .build()) .build(); - String yamlOutput = CelEnvironmentYamlSerializer.toYaml(environment); + String yamlOutput = + CelEnvironmentYamlSerializer.toYaml(environment, /* useTypeSignatures= */ false); String expectedYaml = "name: dump_env\n" @@ -247,4 +254,133 @@ 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()), + VariableDecl.create("str_wrapper", TypeDecl.create("string_wrapper")), + VariableDecl.create("timestamp_var", TypeDecl.create("timestamp")))) + .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("int_wrapper")) + .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" + + "- name: str_wrapper\n" + + " type: string_wrapper\n" + + "- name: timestamp_var\n" + + " type: timestamp\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); + } + + @Test + public void toYaml_useTypeSignatures_invalidTypeDecl_throws() { + CelEnvironment environment = + CelEnvironment.newBuilder() + .setName("invalid_env") + .setVariables( + ImmutableSet.of(VariableDecl.create("invalid_var", TypeDecl.create("list")))) + .build(); + + assertThrows( + IllegalArgumentException.class, + () -> CelEnvironmentYamlSerializer.toYaml(environment, /* useTypeSignatures= */ true)); + } } diff --git a/bundle/src/test/java/dev/cel/bundle/CelOverloadDeclParserTest.java b/bundle/src/test/java/dev/cel/bundle/CelOverloadDeclParserTest.java new file mode 100644 index 000000000..8c2c860c0 --- /dev/null +++ b/bundle/src/test/java/dev/cel/bundle/CelOverloadDeclParserTest.java @@ -0,0 +1,154 @@ +// 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.testing.junit.testparameterinjector.TestParameter; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import dev.cel.bundle.CelEnvironment.TypeDecl; +import dev.cel.bundle.CelOverloadDeclParser.ParsedOverloadDecl; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class CelOverloadDeclParserTest { + + @SuppressWarnings("ImmutableEnumChecker") // Test only + private enum ValidSignatureTestCase { + GLOBAL_NO_ARG("now()", ParsedOverloadDecl.create("now", Optional.empty(), ImmutableList.of())), + GLOBAL_MULTI_ARG_WITH_WHITESPACE( + " add ( int , list < string > ) ", + ParsedOverloadDecl.create( + "add", + Optional.empty(), + ImmutableList.of( + TypeDecl.create("int"), + TypeDecl.newBuilder() + .setName("list") + .addParams(TypeDecl.create("string")) + .build()))), + MEMBER_WITH_PARAMETERIZED_RECEIVER( + "map<~K,~V>.get(~K)", + ParsedOverloadDecl.create( + "get", + Optional.of( + TypeDecl.newBuilder() + .setName("map") + .addParams(TypeDecl.ofTypeParam("K"), TypeDecl.ofTypeParam("V")) + .build()), + ImmutableList.of(TypeDecl.ofTypeParam("K")))), + MEMBER_WITH_SIMPLE_RECEIVER( + "int.add(int)", + ParsedOverloadDecl.create( + "add", Optional.of(TypeDecl.create("int")), ImmutableList.of(TypeDecl.create("int")))), + MEMBER_WITH_WRAPPER_RECEIVER( + "int_wrapper.coalesce(int)", + ParsedOverloadDecl.create( + "coalesce", + Optional.of(TypeDecl.create("int_wrapper")), + ImmutableList.of(TypeDecl.create("int")))), + MEMBER_WITH_QUALIFIED_RECEIVER( + "google.protobuf.StringValue.isEmpty()", + ParsedOverloadDecl.create( + "isEmpty", + Optional.of(TypeDecl.create("google.protobuf.StringValue")), + ImmutableList.of())), + NAMESPACED_GLOBAL_WITH_ESCAPED_DOT( + "math\\.greatest(int,int)", + ParsedOverloadDecl.create( + "math.greatest", + Optional.empty(), + ImmutableList.of(TypeDecl.create("int"), TypeDecl.create("int")))), + OPERATOR( + "_==_(int, int)", + ParsedOverloadDecl.create( + "_==_", + Optional.empty(), + ImmutableList.of(TypeDecl.create("int"), TypeDecl.create("int")))), + ESCAPED_SPECIAL_CHARS_IN_FUNCTION_NAME( + "\\~foo\\(bar\\)\\\\\"qux\\,\\.\\ (int)", + ParsedOverloadDecl.create( + "~foo(bar)\"qux,. ", + Optional.empty(), + ImmutableList.of(TypeDecl.create("int")))), + ESCAPED_SPACE_WITH_TRAILING_WHITESPACE( + "\\ (int)", + ParsedOverloadDecl.create( + " ", + Optional.empty(), + ImmutableList.of(TypeDecl.create("int")))), + ; + + private final String signature; + private final ParsedOverloadDecl expected; + + ValidSignatureTestCase(String signature, ParsedOverloadDecl expected) { + this.signature = signature; + this.expected = expected; + } + } + + @Test + public void parse_validSignature_success(@TestParameter ValidSignatureTestCase testCase) { + assertThat(CelOverloadDeclParser.parse(testCase.signature)).isEqualTo(testCase.expected); + } + + private enum InvalidSignatureTestCase { + EMPTY("", "Empty function signature"), + MISSING_OPEN_PAREN("foo", "Invalid function signature, missing '(': foo"), + MISSING_CLOSE_PAREN("foo(int", "Invalid function signature, missing closing ')': foo(int"), + TRAILING_AFTER_CLOSE_PAREN( + "foo(int)extra", "Invalid function signature, missing closing ')': foo(int)extra"), + EMPTY_FUNCTION_NAME("(int)", "Invalid function signature: empty function name"), + EMPTY_MEMBER_FUNCTION_NAME("string.(int)", "Invalid function signature: empty function name"), + MISMATCHED_BRACKETS_RECEIVER( + "listint<.foo()", "Invalid function signature: mismatched brackets"), + UNESCAPED_TILDE_IN_FUNCTION_NAME("~foo(int)", "Invalid function name: ~foo"), + UNESCAPED_QUOTE_IN_FUNCTION_NAME("\"foo(int)", "Invalid function name: \"foo"), + UNESCAPED_COMMA_IN_FUNCTION_NAME("foo,bar(int)", "Invalid function name: foo,bar"), + UNESCAPED_CLOSE_PAREN_IN_FUNCTION_NAME("foo)bar(int)", "Invalid function name: foo)bar"), + UNESCAPED_BRACKETS_IN_FUNCTION_NAME("foo(int)", "Invalid function name: foo"), + TRAILING_BACKSLASH( + "foo(int)\\", "Invalid function signature, missing closing ')': foo(int)\\"), + MISSING_COMMA_BETWEEN_ARGS( + "foo(int string)", "unexpected character 's' at position 4 in \"int string\""), + UNESCAPED_SPACE_IN_FUNCTION_NAME("fo o(int)", "Invalid function name: fo o"), + UNESCAPED_SPACE_IN_RECEIVER( + "int string.foo()", "unexpected character 's' at position 4 in \"int string\""), + ; + + private final String signature; + private final String expectedError; + + InvalidSignatureTestCase(String signature, String expectedError) { + this.signature = signature; + this.expectedError = expectedError; + } + } + + @Test + public void parse_invalidSignature_throws(@TestParameter InvalidSignatureTestCase testCase) { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, () -> CelOverloadDeclParser.parse(testCase.signature)); + assertThat(e).hasMessageThat().isEqualTo(testCase.expectedError); + } +} diff --git a/bundle/src/test/java/dev/cel/bundle/TypeSpecifierParserTest.java b/bundle/src/test/java/dev/cel/bundle/TypeSpecifierParserTest.java index ee3dab9c6..f6b6cf5d1 100644 --- a/bundle/src/test/java/dev/cel/bundle/TypeSpecifierParserTest.java +++ b/bundle/src/test/java/dev/cel/bundle/TypeSpecifierParserTest.java @@ -175,6 +175,7 @@ public void parse_maxRecursionDepth_succeedsAtBoundary() { String input = sb.toString(); TypeDecl result = TypeDecl.parse(input); assertThat(result).isNotNull(); + assertThat(TypeSpecifierParser.parseTypeList(input)).containsExactly(result); } @Test @@ -193,6 +194,45 @@ public void parse_exceedsMaxRecursionDepth_throws() { assertThat(e).hasMessageThat().contains("exceeded maximum type specifier recursion depth"); } + @Test + public void parseTypeList_empty() { + assertThat(TypeSpecifierParser.parseTypeList("")).isEmpty(); + assertThat(TypeSpecifierParser.parseTypeList(" ")).isEmpty(); + } + + @Test + public void parseTypeList_singleAndMultiple() { + assertThat(TypeSpecifierParser.parseTypeList("int")).containsExactly(TypeDecl.create("int")); + assertThat(TypeSpecifierParser.parseTypeList("int, string")) + .containsExactly(TypeDecl.create("int"), TypeDecl.create("string")) + .inOrder(); + assertThat(TypeSpecifierParser.parseTypeList(" list < int > , map < string , ~T > ")) + .containsExactly( + TypeDecl.newBuilder().setName("list").addParams(TypeDecl.create("int")).build(), + TypeDecl.newBuilder() + .setName("map") + .addParams(TypeDecl.create("string"), TypeDecl.ofTypeParam("T")) + .build()) + .inOrder(); + } + + @Test + public void parseTypeList_invalid_throws() { + assertThrows( + IllegalArgumentException.class, () -> TypeSpecifierParser.parseTypeList("int,")); + assertThrows( + IllegalArgumentException.class, () -> TypeSpecifierParser.parseTypeList(",int")); + assertThrows( + IllegalArgumentException.class, () -> TypeSpecifierParser.parseTypeList("int,,string")); + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, () -> TypeSpecifierParser.parseTypeList("int string")); + assertThat(e) + .hasMessageThat() + .isEqualTo("unexpected character 's' at position 4 in \"int string\""); + assertThrows(IllegalArgumentException.class, () -> TypeSpecifierParser.parseTypeList("int>")); + } + @Test public void parse_errors(@TestParameter ParseErrorTestCase testCase) { IllegalArgumentException e = diff --git a/common/BUILD.bazel b/common/BUILD.bazel index e609ff6e7..1c751bf53 100644 --- a/common/BUILD.bazel +++ b/common/BUILD.bazel @@ -41,15 +41,27 @@ cel_android_library( ) java_library( - name = "cel_signature_utils", + name = "cel_overload_decl_formatter", visibility = ["//:internal"], - exports = ["//common/src/main/java/dev/cel/common:cel_signature_utils"], + exports = ["//common/src/main/java/dev/cel/common:cel_overload_decl_formatter"], ) cel_android_library( - name = "cel_signature_utils_android", + name = "cel_overload_decl_formatter_android", visibility = ["//:internal"], - exports = ["//common/src/main/java/dev/cel/common:cel_signature_utils_android"], + exports = ["//common/src/main/java/dev/cel/common:cel_overload_decl_formatter_android"], +) + +java_library( + name = "type_specifier_formatter", + visibility = ["//:internal"], + exports = ["//common/src/main/java/dev/cel/common:type_specifier_formatter"], +) + +cel_android_library( + name = "type_specifier_formatter_android", + visibility = ["//:internal"], + exports = ["//common/src/main/java/dev/cel/common:type_specifier_formatter_android"], ) java_library( diff --git a/common/src/main/java/dev/cel/common/BUILD.bazel b/common/src/main/java/dev/cel/common/BUILD.bazel index 77054cb81..d69cb0b1f 100644 --- a/common/src/main/java/dev/cel/common/BUILD.bazel +++ b/common/src/main/java/dev/cel/common/BUILD.bazel @@ -63,7 +63,7 @@ java_library( ], deps = [ ":cel_overload_decl", - ":cel_signature_utils", + ":cel_overload_decl_formatter", "//:auto_value", "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:com_google_guava_guava", @@ -77,7 +77,7 @@ cel_android_library( ], deps = [ ":cel_overload_decl_android", - ":cel_signature_utils_android", + ":cel_overload_decl_formatter_android", "//:auto_value", "@maven//:com_google_errorprone_error_prone_annotations", "@maven_android//:com_google_guava_guava", @@ -85,8 +85,8 @@ cel_android_library( ) java_library( - name = "cel_signature_utils", - srcs = ["CelSignatureUtils.java"], + name = "type_specifier_formatter", + srcs = ["TypeSpecifierFormatter.java"], tags = [ ], deps = [ @@ -98,8 +98,8 @@ java_library( ) cel_android_library( - name = "cel_signature_utils_android", - srcs = ["CelSignatureUtils.java"], + name = "type_specifier_formatter_android", + srcs = ["TypeSpecifierFormatter.java"], tags = [ ], deps = [ @@ -110,6 +110,34 @@ cel_android_library( ], ) +java_library( + name = "cel_overload_decl_formatter", + srcs = ["CelOverloadDeclFormatter.java"], + tags = [ + ], + deps = [ + ":cel_overload_decl", + ":type_specifier_formatter", + "//common/annotations", + "//common/types:type_providers", + "@maven//:com_google_guava_guava", + ], +) + +cel_android_library( + name = "cel_overload_decl_formatter_android", + srcs = ["CelOverloadDeclFormatter.java"], + tags = [ + ], + deps = [ + ":cel_overload_decl_android", + ":type_specifier_formatter_android", + "//common/annotations", + "//common/types:type_providers_android", + "@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 37bdc5941..67c046e7f 100644 --- a/common/src/main/java/dev/cel/common/CelFunctionDecl.java +++ b/common/src/main/java/dev/cel/common/CelFunctionDecl.java @@ -102,9 +102,7 @@ public CelFunctionDecl build() { for (CelOverloadDecl overload : currentOverloads) { String overloadId = overload.overloadId(); if (overloadId.isEmpty()) { - overloadId = - CelSignatureUtils.makeOverloadSignature( - functionName, overload.parameterTypes(), overload.isInstanceFunction()); + overloadId = CelOverloadDeclFormatter.format(functionName, overload); updatedOverloads.add(overload.toBuilder().setOverloadId(overloadId).build()); } else { updatedOverloads.add(overload); diff --git a/common/src/main/java/dev/cel/common/CelOverloadDeclFormatter.java b/common/src/main/java/dev/cel/common/CelOverloadDeclFormatter.java new file mode 100644 index 000000000..4294cb3f3 --- /dev/null +++ b/common/src/main/java/dev/cel/common/CelOverloadDeclFormatter.java @@ -0,0 +1,67 @@ +// 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.common.collect.ImmutableList; +import dev.cel.common.annotations.Internal; +import dev.cel.common.types.CelType; + +/** + * Internal utility methods for formatting CEL function overloads into canonical overload ID signatures. + * + *

CEL Library Internals. Do Not Use. + */ +@Internal +public final class CelOverloadDeclFormatter { + + /** + * Generates a canonical overload ID / signature string for a {@link CelOverloadDecl}. + * + *

For example: + * + *

    + *
  • {@code format("add", globalOverload)} -> {@code "add(int,int)"} + *
  • {@code format("contains", memberOverload)} -> {@code "string.contains(string)"} + *
+ */ + public static String format(String functionName, CelOverloadDecl overload) { + checkNotNull(functionName); + checkNotNull(overload); + ImmutableList parameterTypes = overload.parameterTypes(); + boolean isInstanceFunction = overload.isInstanceFunction(); + StringBuilder result = new StringBuilder(); + if (isInstanceFunction) { + checkArgument(!parameterTypes.isEmpty(), "Member function with no receiver"); + TypeSpecifierFormatter.appendTypeSignature(result, parameterTypes.get(0)); + result.append('.'); + } + TypeSpecifierFormatter.appendEscaped(result, functionName, /* escapeDot= */ true); + result.append('('); + int startIndex = isInstanceFunction ? 1 : 0; + for (int i = startIndex; i < parameterTypes.size(); i++) { + if (i > startIndex) { + result.append(','); + } + TypeSpecifierFormatter.appendTypeSignature(result, parameterTypes.get(i)); + } + result.append(')'); + return result.toString(); + } + + private CelOverloadDeclFormatter() {} +} diff --git a/common/src/main/java/dev/cel/common/CelSignatureUtils.java b/common/src/main/java/dev/cel/common/TypeSpecifierFormatter.java similarity index 75% rename from common/src/main/java/dev/cel/common/CelSignatureUtils.java rename to common/src/main/java/dev/cel/common/TypeSpecifierFormatter.java index 05ae6095e..9f9860ea4 100644 --- a/common/src/main/java/dev/cel/common/CelSignatureUtils.java +++ b/common/src/main/java/dev/cel/common/TypeSpecifierFormatter.java @@ -14,7 +14,6 @@ package dev.cel.common; -import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.collect.ImmutableList; @@ -25,18 +24,17 @@ import dev.cel.common.types.MapType; import dev.cel.common.types.NullableType; import dev.cel.common.types.TypeType; -import java.util.List; /** - * Internal utility methods for generating canonical CEL type and function overload signatures. + * Internal utility methods for formatting CEL types into canonical type specifier signatures. * *

CEL Library Internals. Do Not Use. */ @Internal -public final class CelSignatureUtils { +public final class TypeSpecifierFormatter { /** - * Generates a canonical signature string for a {@link CelType}. + * Generates a canonical type specifier signature string for a {@link CelType}. * *

For example: * @@ -49,70 +47,14 @@ public final class CelSignatureUtils { * @throws IllegalArgumentException if the type cannot be represented as a signature (e.g. {@code * ERROR}). */ - public static String makeTypeSignature(CelType type) { + public static String format(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(); - } - - 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 void appendTypeSignature(StringBuilder result, CelType type) { + static void appendTypeSignature(StringBuilder result, CelType type) { checkNotNull(type); if (type instanceof NullableType) { CelType targetType = ((NullableType) type).targetType(); @@ -245,5 +187,23 @@ private static void appendTypeSignature(StringBuilder result, CelType type) { } } - private CelSignatureUtils() {} + 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 TypeSpecifierFormatter() {} } diff --git a/common/src/test/java/dev/cel/common/BUILD.bazel b/common/src/test/java/dev/cel/common/BUILD.bazel index c9d707b65..bc2df38ec 100644 --- a/common/src/test/java/dev/cel/common/BUILD.bazel +++ b/common/src/test/java/dev/cel/common/BUILD.bazel @@ -17,8 +17,9 @@ java_library( "//common:cel_descriptor_util", "//common:cel_descriptors", "//common:cel_issue", + "//common:cel_overload_decl", + "//common:cel_overload_decl_formatter", "//common:cel_proto_decl_converter", - "//common:cel_signature_utils", "//common:cel_source", "//common:cel_validation_exception", "//common:cel_var_decl", @@ -29,6 +30,7 @@ java_library( "//common:proto_json_adapter", "//common:proto_v1alpha1_ast", "//common:source_location", + "//common:type_specifier_formatter", "//common/ast", "//common/internal", "//common/internal:code_point_stream", diff --git a/common/src/test/java/dev/cel/common/CelOverloadDeclFormatterTest.java b/common/src/test/java/dev/cel/common/CelOverloadDeclFormatterTest.java new file mode 100644 index 000000000..35a9135a1 --- /dev/null +++ b/common/src/test/java/dev/cel/common/CelOverloadDeclFormatterTest.java @@ -0,0 +1,83 @@ +// 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 org.junit.Assert.assertThrows; + +import dev.cel.common.types.ListType; +import dev.cel.common.types.MapType; +import dev.cel.common.types.SimpleType; +import dev.cel.common.types.TypeParamType; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CelOverloadDeclFormatterTest { + + @Test + public void format_globalNoArg() { + CelOverloadDecl overload = CelOverloadDecl.newGlobalOverload("now", SimpleType.TIMESTAMP); + assertThat(CelOverloadDeclFormatter.format("now", overload)).isEqualTo("now()"); + } + + @Test + public void format_globalMultiArg() { + CelOverloadDecl overload = + CelOverloadDecl.newGlobalOverload( + "add", + SimpleType.INT, + SimpleType.INT, + ListType.create(SimpleType.STRING)); + assertThat(CelOverloadDeclFormatter.format("add", overload)).isEqualTo("add(int,list)"); + } + + @Test + public void format_memberWithParameterizedReceiver() { + CelOverloadDecl overload = + CelOverloadDecl.newMemberOverload( + "get", + TypeParamType.create("V"), + MapType.create(TypeParamType.create("K"), TypeParamType.create("V")), + TypeParamType.create("K")); + assertThat(CelOverloadDeclFormatter.format("get", overload)).isEqualTo("map<~K,~V>.get(~K)"); + } + + @Test + public void format_namespacedGlobal() { + CelOverloadDecl overload = + CelOverloadDecl.newGlobalOverload( + "math.greatest", SimpleType.INT, SimpleType.INT, SimpleType.INT); + assertThat(CelOverloadDeclFormatter.format("math.greatest", overload)) + .isEqualTo("math\\.greatest(int,int)"); + } + + @Test + public void format_memberWithNoArgs_throws() { + CelOverloadDecl overload = CelOverloadDecl.newMemberOverload("size", SimpleType.INT); + assertThrows( + IllegalArgumentException.class, () -> CelOverloadDeclFormatter.format("size", overload)); + } + + @Test + public void format_nullArguments_throws() { + CelOverloadDecl overload = CelOverloadDecl.newGlobalOverload("now", SimpleType.TIMESTAMP); + assertThrows( + NullPointerException.class, () -> CelOverloadDeclFormatter.format(null, overload)); + assertThrows( + NullPointerException.class, () -> CelOverloadDeclFormatter.format("now", null)); + } +} diff --git a/common/src/test/java/dev/cel/common/CelSignatureUtilsTest.java b/common/src/test/java/dev/cel/common/TypeSpecifierFormatterTest.java similarity index 61% rename from common/src/test/java/dev/cel/common/CelSignatureUtilsTest.java rename to common/src/test/java/dev/cel/common/TypeSpecifierFormatterTest.java index 52dfd0eaf..5fe88198a 100644 --- a/common/src/test/java/dev/cel/common/CelSignatureUtilsTest.java +++ b/common/src/test/java/dev/cel/common/TypeSpecifierFormatterTest.java @@ -15,8 +15,6 @@ 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 org.junit.Assert.assertThrows; import com.google.common.collect.ImmutableList; @@ -40,9 +38,9 @@ import org.junit.runner.RunWith; @RunWith(TestParameterInjector.class) -public final class CelSignatureUtilsTest { +public final class TypeSpecifierFormatterTest { - private enum TypeSignatureTestCase { + private enum FormatTypeTestCase { NULL(SimpleType.NULL_TYPE, "null"), DYN(SimpleType.DYN, "dyn"), BOOL(SimpleType.BOOL, "bool"), @@ -81,83 +79,53 @@ private enum TypeSignatureTestCase { NESTED_COMPLEX( MapType.create( SimpleType.STRING, ListType.create(OptionalType.create(TypeParamType.create("V")))), - "map>>"); + "map>>"), + LIST_ESCAPED_TYPE_PARAM( + ListType.create(TypeParamType.create("a,b")), "list<~a\\,b\\>"); private final CelType type; private final String expectedSignature; - TypeSignatureTestCase(CelType type, String expectedSignature) { + FormatTypeTestCase(CelType type, String expectedSignature) { this.type = type; this.expectedSignature = expectedSignature; } } @Test - public void makeTypeSignature_success(@TestParameter TypeSignatureTestCase testCase) { - assertThat(makeTypeSignature(testCase.type)).isEqualTo(testCase.expectedSignature); + public void format_success(@TestParameter FormatTypeTestCase testCase) { + assertThat(TypeSpecifierFormatter.format(testCase.type)).isEqualTo(testCase.expectedSignature); } @Test - public void makeTypeSignature_structAndEnum() { + public void format_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"); + assertThat(TypeSpecifierFormatter.format(structType)) + .isEqualTo("cel.expr.conformance.proto3.TestAllTypes"); + assertThat(TypeSpecifierFormatter.format(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"); + public void format_unparameterizedListAndFunction() { + assertThat(TypeSpecifierFormatter.format(ListType.create())).isEqualTo("list"); + assertThat(TypeSpecifierFormatter.format(OpaqueType.create("function"))) + .isEqualTo("function"); } @Test - public void makeTypeSignature_unsupportedTypes_throws() { + public void format_unsupportedTypes_throws() { CelType unspecifiedType = UnspecifiedType.create(); - assertThrows(IllegalArgumentException.class, () -> makeTypeSignature(SimpleType.ERROR)); - assertThrows(IllegalArgumentException.class, () -> makeTypeSignature(unspecifiedType)); - } - - @Test - public void makeOverloadSignature_success() { - // Global no-arg function - assertThat(makeOverloadSignature("now", ImmutableList.of(), /* isMember= */ false)) - .isEqualTo("now()"); - - // Global multi-arg function - assertThat( - makeOverloadSignature( - "add", - ImmutableList.of(SimpleType.INT, ListType.create(SimpleType.STRING)), - /* isMember= */ false)) - .isEqualTo("add(int,list)"); - - // Member function with parameterized receiver - assertThat( - makeOverloadSignature( - "get", - ImmutableList.of( - MapType.create(TypeParamType.create("K"), TypeParamType.create("V")), - TypeParamType.create("K")), - /* isMember= */ true)) - .isEqualTo("map<~K,~V>.get(~K)"); - - // Global function with dot in name (escaped) - assertThat( - makeOverloadSignature( - "math.greatest", - ImmutableList.of(SimpleType.INT, SimpleType.INT), - /* isMember= */ false)) - .isEqualTo("math\\.greatest(int,int)"); - } - - @Test - public void makeOverloadSignature_memberWithNoArgs_throws() { + CelType durationWrapperType = NullableType.create(SimpleType.DURATION); + assertThrows( + IllegalArgumentException.class, () -> TypeSpecifierFormatter.format(SimpleType.ERROR)); + assertThrows( + IllegalArgumentException.class, () -> TypeSpecifierFormatter.format(unspecifiedType)); assertThrows( - IllegalArgumentException.class, - () -> makeOverloadSignature("size", ImmutableList.of(), /* isMember= */ true)); + IllegalArgumentException.class, () -> TypeSpecifierFormatter.format(durationWrapperType)); } } 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"