diff --git a/bundle/src/main/java/dev/cel/bundle/BUILD.bazel b/bundle/src/main/java/dev/cel/bundle/BUILD.bazel index 40ce8df32..3c0b7565f 100644 --- a/bundle/src/main/java/dev/cel/bundle/BUILD.bazel +++ b/bundle/src/main/java/dev/cel/bundle/BUILD.bazel @@ -110,6 +110,7 @@ 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", diff --git a/bundle/src/main/java/dev/cel/bundle/TypeSpecifierParser.java b/bundle/src/main/java/dev/cel/bundle/TypeSpecifierParser.java index ca96a955f..6d64dc97e 100644 --- a/bundle/src/main/java/dev/cel/bundle/TypeSpecifierParser.java +++ b/bundle/src/main/java/dev/cel/bundle/TypeSpecifierParser.java @@ -15,6 +15,10 @@ package dev.cel.bundle; import static com.google.common.base.Preconditions.checkNotNull; +import static dev.cel.common.CelSignatureUtils.MAX_RECURSION_DEPTH; +import static dev.cel.common.CelSignatureUtils.isAlpha; +import static dev.cel.common.CelSignatureUtils.isAlphaNumeric; +import static dev.cel.common.CelSignatureUtils.isWhitespace; import com.google.common.collect.ImmutableList; import dev.cel.bundle.CelEnvironment.TypeDecl; @@ -25,7 +29,6 @@ * {@code "int"}) into a {@link TypeDecl}. */ final class TypeSpecifierParser { - private static final int MAX_RECURSION_DEPTH = 64; static final TypeDecl ERROR_TYPE_DECL = TypeDecl.create("*error*"); private final String text; @@ -178,7 +181,7 @@ private String parseTypeParamIdent() { private void skipWhitespace() { while (pos < length) { char c = text.charAt(pos); - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + if (isWhitespace(c)) { pos++; } else { break; @@ -186,14 +189,6 @@ private void skipWhitespace() { } } - private static boolean isAlpha(char c) { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); - } - - private static boolean isAlphaNumeric(char c) { - return isAlpha(c) || (c >= '0' && c <= '9'); - } - private static String formatQuoted(String s) { return "\"" + s + "\""; } diff --git a/checker/src/test/java/dev/cel/checker/CelOverloadDeclTest.java b/checker/src/test/java/dev/cel/checker/CelOverloadDeclTest.java index 4e851a56f..eb157e6d0 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,26 @@ 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 memberOverload = + newMemberOverload( + SimpleType.BOOL, ListType.create(TypeParamType.create("T")), TypeParamType.create("T")); + CelOverloadDecl explicitOverload = + newGlobalOverload("custom_id", SimpleType.STRING, SimpleType.STRING); + + assertThat(globalOverload.overloadId()).isEmpty(); + assertThat(memberOverload.overloadId()).isEmpty(); + + CelFunctionDecl functionDecl = + CelFunctionDecl.newFunctionDeclaration( + "myFunc", globalOverload, memberOverload, explicitOverload); + + assertThat(functionDecl.overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly("myFunc(int,int)", "list<~T>.myFunc(~T)", "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..0e80e8caf 100644 --- a/common/src/main/java/dev/cel/common/CelFunctionDecl.java +++ b/common/src/main/java/dev/cel/common/CelFunctionDecl.java @@ -70,9 +70,36 @@ public Builder addOverloads(Iterable overloads) { return this; } + @CheckReturnValue + abstract CelFunctionDecl autoBuild(); + /** Builds a new instance of {@link CelFunctionDecl} */ @CheckReturnValue - public abstract CelFunctionDecl build(); + public CelFunctionDecl build() { + CelFunctionDecl functionDecl = autoBuild(); + boolean needsOverloadId = false; + for (CelOverloadDecl overload : functionDecl.overloads()) { + if (overload.overloadId().isEmpty()) { + needsOverloadId = true; + break; + } + } + if (!needsOverloadId) { + return functionDecl; + } + ImmutableSet.Builder updatedOverloads = ImmutableSet.builder(); + for (CelOverloadDecl overload : functionDecl.overloads()) { + if (overload.overloadId().isEmpty()) { + String generatedId = + CelSignatureUtils.makeOverloadSignature( + functionDecl.name(), overload.parameterTypes(), overload.isInstanceFunction()); + updatedOverloads.add(overload.toBuilder().setOverloadId(generatedId).build()); + } else { + updatedOverloads.add(overload); + } + } + return functionDecl.toBuilder().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..dccb1a965 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,24 @@ public CelOverloadDecl build() { } } + /** + * Helper method for declaring a member function overload with an automatically generated overload + * ID. + */ + @CheckReturnValue + public static CelOverloadDecl newMemberOverload(CelType resultType, CelType... paramTypes) { + return newMemberOverload("", resultType, Arrays.asList(paramTypes)); + } + + /** + * Helper method for declaring a member function overload with an automatically generated overload + * ID. + */ + @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 +202,24 @@ public static CelOverloadDecl newMemberOverload( return newOverload(overloadId, doc, resultType, paramTypes, /* isInstanceFunction= */ true); } + /** + * Helper method for declaring a global function overload with an automatically generated overload + * ID. + */ + @CheckReturnValue + public static CelOverloadDecl newGlobalOverload(CelType resultType, CelType... paramTypes) { + return newGlobalOverload("", resultType, Arrays.asList(paramTypes)); + } + + /** + * Helper method for declaring a global function overload with an automatically generated overload + * ID. + */ + @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..2c666716c --- /dev/null +++ b/common/src/main/java/dev/cel/common/CelSignatureUtils.java @@ -0,0 +1,692 @@ +// 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.checkNotNull; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import com.google.errorprone.annotations.Immutable; +import dev.cel.common.annotations.Internal; +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.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.Arrays; +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 { + + /** Maximum recursion depth when parsing nested type signatures. */ + public 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(); + + /** + * An opaque {@code function} type with unknown return ({@code DYN}) and arguments matching + * {@link #argTypes()}. + */ + public abstract CelType signatureType(); + + public static ParsedFunctionOverload create( + String functionName, boolean isMember, Iterable argTypes, CelType signatureType) { + return new AutoValue_CelSignatureUtils_ParsedFunctionOverload( + functionName, isMember, ImmutableList.copyOf(argTypes), signatureType); + } + } + + /** + * 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) { + if (args.isEmpty()) { + throw new IllegalArgumentException("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(); + } + + /** + * Generates a canonical overload ID / signature string for a function overload. + * + * @see #makeOverloadSignature(String, List, boolean) + */ + public static String makeOverloadSignature( + String functionName, boolean isMember, CelType... args) { + return makeOverloadSignature(functionName, Arrays.asList(args), isMember); + } + + /** + * 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); + return parseTypeSignatureInternal(signature, /* typeProvider= */ null); + } + + /** + * Parses a canonical type signature string into a {@link CelType}, resolving custom type names + * via {@code typeProvider}. + * + *

If a custom type name is not found in {@code typeProvider}, it falls back to an {@link + * OpaqueType}. + */ + public static CelType parseTypeSignature(String signature, CelTypeProvider typeProvider) { + checkNotNull(signature); + checkNotNull(typeProvider); + return parseTypeSignatureInternal(signature, typeProvider); + } + + /** + * 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, /* typeProvider= */ null); + } + + /** + * Parses a canonical function overload signature string into a {@link ParsedFunctionOverload}, + * resolving custom argument and receiver types via {@code typeProvider}. + */ + public static ParsedFunctionOverload parseFunctionSignature( + String signature, CelTypeProvider typeProvider) { + checkNotNull(signature); + checkNotNull(typeProvider); + return parseFunctionSignatureInternal(signature, typeProvider); + } + + /** Returns true if {@code c} is an ASCII whitespace character (' ', '\t', '\n', '\r', etc.). */ + public static boolean isWhitespace(char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\u000b'; + } + + /** Returns true if {@code c} is an ASCII alphabetic character ('a'-'z' or 'A'-'Z'). */ + public static boolean isAlpha(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + + /** Returns true if {@code c} is an ASCII alphanumeric character ('a'-'z', 'A'-'Z', '0'-'9'). */ + public static boolean isAlphaNumeric(char c) { + return isAlpha(c) || (c >= '0' && c <= '9'); + } + + 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 String unescape(String str) { + StringBuilder result = new StringBuilder(str.length()); + boolean escaped = false; + for (int i = 0; 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 removeUnescapedWhitespace(String 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); + } + } + 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: + default: + 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 parseTypeSignatureInternal( + String signature, CelTypeProvider typeProvider) { + String cleaned = removeUnescapedWhitespace(signature); + return parseTypeSignatureFromCleaned(cleaned, typeProvider, /* depth= */ 0); + } + + private static CelType parseTypeSignatureFromCleaned( + String cleaned, CelTypeProvider typeProvider, int depth) { + if (depth > MAX_RECURSION_DEPTH) { + throw new IllegalArgumentException("Exceeded maximum recursion depth"); + } + if (cleaned.isEmpty()) { + throw new IllegalArgumentException("Empty type signature"); + } + if (cleaned.charAt(0) == '~') { + String paramName = cleaned.substring(1); + if (paramName.isEmpty()) { + throw new IllegalArgumentException("Empty type parameter name"); + } + if (findTopLevelChar(paramName, '<', /* findFirst= */ true, "Invalid type parameter") != -1 + || findTopLevelChar(paramName, ',', /* findFirst= */ true, "Invalid type parameter") + != -1) { + throw new IllegalArgumentException("Invalid type parameter name: " + 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) { + if (cleaned.charAt(cleaned.length() - 1) != '>') { + throw new IllegalArgumentException( + "Invalid type signature, missing closing '>': " + cleaned); + } + baseName = cleaned.substring(0, openBracket); + String paramsStr = cleaned.substring(openBracket + 1, cleaned.length() - 1); + if (paramsStr.isEmpty()) { + throw new IllegalArgumentException("Empty type parameters in signature: " + cleaned); + } + List paramSlices = splitTopLevel(paramsStr, ',', "Invalid type parameters"); + for (String paramSlice : paramSlices) { + params.add(parseTypeSignatureFromCleaned(paramSlice, typeProvider, depth + 1)); + } + } + + String nameStr = unescape(baseName); + CelType builtinOrWrapper = resolveBuiltinOrWrapperType(nameStr); + if (builtinOrWrapper != null) { + if (!params.isEmpty()) { + throw new IllegalArgumentException( + "Invalid type signature: " + nameStr + " cannot have type parameters"); + } + return builtinOrWrapper; + } + + switch (nameStr) { + case "type": + if (params.size() > 1) { + throw new IllegalArgumentException( + "Invalid type signature: type expected at most 1 parameter"); + } + return TypeType.create(params.isEmpty() ? SimpleType.DYN : params.get(0)); + case "list": + if (params.size() > 1) { + throw new IllegalArgumentException( + "Invalid type signature: list expected at most 1 parameter"); + } + return ListType.create(params.isEmpty() ? SimpleType.DYN : params.get(0)); + case "map": + if (!params.isEmpty() && params.size() != 2) { + throw new IllegalArgumentException( + "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 = params.isEmpty() ? SimpleType.DYN : params.get(0); + List argTypes = + params.size() <= 1 ? ImmutableList.of() : params.subList(1, params.size()); + return CelTypes.createFunctionType(resultType, argTypes); + case OptionalType.NAME: + if (params.size() != 1) { + throw new IllegalArgumentException( + "Invalid type signature: optional_type expected 1 parameter"); + } + return OptionalType.create(params.get(0)); + default: + break; + } + + if (nameStr.isEmpty() || nameStr.contains("..")) { + throw new IllegalArgumentException("Invalid type name: " + nameStr); + } + + if (typeProvider != null) { + Optional resolved = typeProvider.findType(nameStr); + if (resolved.isPresent()) { + CelType type = resolved.get(); + if (!params.isEmpty()) { + if (type.kind() != CelKind.OPAQUE + && type.kind() != CelKind.LIST + && type.kind() != CelKind.MAP + && type.kind() != CelKind.TYPE) { + throw new IllegalArgumentException( + "Type '" + nameStr + "' cannot have type parameters"); + } + return type.withParameters(ImmutableList.copyOf(params)); + } + return type; + } + } + + return OpaqueType.create(nameStr, ImmutableList.copyOf(params)); + } + + private static CelType resolveBuiltinOrWrapperType(String nameStr) { + switch (nameStr) { + case "null": + case "null_type": + return SimpleType.NULL_TYPE; + case "bool": + return SimpleType.BOOL; + case "int": + return SimpleType.INT; + case "uint": + return SimpleType.UINT; + case "double": + return SimpleType.DOUBLE; + case "string": + return SimpleType.STRING; + case "bytes": + return SimpleType.BYTES; + case "any": + case "google.protobuf.Any": + return SimpleType.ANY; + case "timestamp": + case "google.protobuf.Timestamp": + return SimpleType.TIMESTAMP; + case "duration": + case "google.protobuf.Duration": + return SimpleType.DURATION; + case "dyn": + case "google.protobuf.Value": + return SimpleType.DYN; + case "bool_wrapper": + case "google.protobuf.BoolValue": + return NullableType.create(SimpleType.BOOL); + case "int_wrapper": + case "google.protobuf.Int64Value": + case "google.protobuf.Int32Value": + return NullableType.create(SimpleType.INT); + case "uint_wrapper": + case "google.protobuf.UInt64Value": + case "google.protobuf.UInt32Value": + return NullableType.create(SimpleType.UINT); + case "double_wrapper": + case "google.protobuf.DoubleValue": + case "google.protobuf.FloatValue": + return NullableType.create(SimpleType.DOUBLE); + case "string_wrapper": + case "google.protobuf.StringValue": + return NullableType.create(SimpleType.STRING); + case "bytes_wrapper": + case "google.protobuf.BytesValue": + return NullableType.create(SimpleType.BYTES); + case "google.protobuf.ListValue": + return ListType.create(SimpleType.DYN); + case "google.protobuf.Struct": + return MapType.create(SimpleType.STRING, SimpleType.DYN); + default: + return null; + } + } + + private static ParsedFunctionOverload parseFunctionSignatureInternal( + String signature, CelTypeProvider typeProvider) { + String cleaned = removeUnescapedWhitespace(signature); + if (cleaned.isEmpty()) { + throw new IllegalArgumentException("Empty function signature"); + } + + int openParen = + findTopLevelChar(cleaned, '(', /* findFirst= */ true, "Invalid function signature"); + if (openParen == -1) { + throw new IllegalArgumentException("Invalid function signature, missing '(': " + cleaned); + } + if (cleaned.charAt(cleaned.length() - 1) != ')') { + throw new IllegalArgumentException( + "Invalid function signature, missing closing ')': " + cleaned); + } + + String prefix = cleaned.substring(0, openParen); + String argsStr = cleaned.substring(openParen + 1, cleaned.length() - 1); + + List argTypes = new ArrayList<>(); + boolean isMember = false; + String functionName; + + int dotPos = + findTopLevelChar(prefix, '.', /* findFirst= */ false, "Invalid function signature"); + if (dotPos != -1) { + isMember = true; + String receiverStr = prefix.substring(0, dotPos); + functionName = unescape(prefix.substring(dotPos + 1)); + argTypes.add(parseTypeSignatureFromCleaned(receiverStr, typeProvider, /* depth= */ 0)); + } else { + functionName = unescape(prefix); + } + + if (functionName.isEmpty()) { + throw new IllegalArgumentException("Invalid function signature: empty function name"); + } + + if (!argsStr.isEmpty()) { + List argSlices = splitTopLevel(argsStr, ',', "Invalid function arguments"); + for (String argSlice : argSlices) { + argTypes.add(parseTypeSignatureFromCleaned(argSlice, typeProvider, /* depth= */ 0)); + } + } + + CelType signatureType = CelTypes.createFunctionType(SimpleType.DYN, argTypes); + return ParsedFunctionOverload.create(functionName, isMember, argTypes, signatureType); + } + + 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..0dbcab1dd 100644 --- a/common/src/test/java/dev/cel/common/BUILD.bazel +++ b/common/src/test/java/dev/cel/common/BUILD.bazel @@ -16,8 +16,11 @@ java_library( "//common:cel_ast", "//common:cel_descriptor_util", "//common:cel_descriptors", + "//common:cel_function_decl", "//common:cel_issue", + "//common:cel_overload_decl", "//common:cel_proto_decl_converter", + "//common:cel_signature_utils", "//common:cel_source", "//common:cel_validation_exception", "//common:cel_var_decl", @@ -32,8 +35,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..c70ebd2dc --- /dev/null +++ b/common/src/test/java/dev/cel/common/CelSignatureUtilsTest.java @@ -0,0 +1,347 @@ +// 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.CelTypeProvider; +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 java.util.Optional; +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() { + assertThrows(IllegalArgumentException.class, () -> makeTypeSignature(SimpleType.ERROR)); + assertThrows(IllegalArgumentException.class, () -> makeTypeSignature(UnspecifiedType.create())); + } + + @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 + public void parseTypeSignature_withTypeProvider() { + CelType structType = StructTypeReference.create("my.pkg.MyMessage"); + CelType genericOpaque = OpaqueType.create("my.pkg.Box", TypeParamType.create("T")); + CelTypeProvider provider = + new CelTypeProvider() { + @Override + public ImmutableList types() { + return ImmutableList.of(structType, genericOpaque); + } + + @Override + public Optional findType(String typeName) { + if (typeName.equals("my.pkg.MyMessage")) { + return Optional.of(structType); + } + if (typeName.equals("my.pkg.Box")) { + return Optional.of(genericOpaque); + } + return Optional.empty(); + } + }; + + assertThat(parseTypeSignature("my.pkg.MyMessage", provider)).isEqualTo(structType); + assertThat(parseTypeSignature("my.pkg.Box", provider)) + .isEqualTo(OpaqueType.create("my.pkg.Box", SimpleType.INT)); + assertThat(parseTypeSignature("my.pkg.Unknown", provider)) + .isEqualTo(OpaqueType.create("my.pkg.Unknown")); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> parseTypeSignature("my.pkg.MyMessage", provider)); + assertThat(e).hasMessageThat().contains("cannot have type parameters"); + } + + @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: '', 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", /* isMember= */ false); + assertThat(globalNoArg).isEqualTo("now()"); + ParsedFunctionOverload parsedGlobalNoArg = parseFunctionSignature(globalNoArg); + assertThat(parsedGlobalNoArg.functionName()).isEqualTo("now"); + assertThat(parsedGlobalNoArg.isMember()).isFalse(); + assertThat(parsedGlobalNoArg.argTypes()).isEmpty(); + assertThat(parsedGlobalNoArg.signatureType()) + .isEqualTo(CelTypes.createFunctionType(SimpleType.DYN, ImmutableList.of())); + + // Global multi-arg function + String globalMultiArg = + makeOverloadSignature( + "add", /* isMember= */ false, SimpleType.INT, ListType.create(SimpleType.STRING)); + 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", + /* isMember= */ true, + MapType.create(TypeParamType.create("K"), TypeParamType.create("V")), + TypeParamType.create("K")); + 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", /* isMember= */ false, SimpleType.INT, SimpleType.INT); + 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", /* 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 parseFunctionSignature(signature)); + assertThat(e).hasMessageThat().isEqualTo(expectedError); + } +}