diff --git a/bundle/src/main/java/dev/cel/bundle/BUILD.bazel b/bundle/src/main/java/dev/cel/bundle/BUILD.bazel index 1c11bb34e..be4fade3d 100644 --- a/bundle/src/main/java/dev/cel/bundle/BUILD.bazel +++ b/bundle/src/main/java/dev/cel/bundle/BUILD.bazel @@ -97,6 +97,7 @@ java_library( name = "environment", srcs = [ "CelEnvironment.java", + "TypeSpecifierParser.java", ], tags = [ ], @@ -111,6 +112,7 @@ java_library( "//common:container", "//common:options", "//common:source", + "//common/formats:parser_context", "//common/types", "//common/types:type_providers", "//compiler:compiler_builder", diff --git a/bundle/src/main/java/dev/cel/bundle/CelEnvironment.java b/bundle/src/main/java/dev/cel/bundle/CelEnvironment.java index 6b4684b27..f26d4e3fd 100644 --- a/bundle/src/main/java/dev/cel/bundle/CelEnvironment.java +++ b/bundle/src/main/java/dev/cel/bundle/CelEnvironment.java @@ -692,6 +692,19 @@ public static TypeDecl create(String name) { return newBuilder().setName(name).build(); } + /** + * Parses a type specifier shorthand string (e.g. {@code "list"}, {@code "map"}, {@code "list<~T>"}) into a {@link TypeDecl}. + */ + static TypeDecl parse(String typeSpecifier) { + return TypeSpecifierParser.parse(typeSpecifier); + } + + /** Creates a new {@link TypeDecl} representing a type parameter with the provided name. */ + static TypeDecl ofTypeParam(String typeParamName) { + return newBuilder().setName(typeParamName).setIsTypeParam(true).build(); + } + public static TypeDecl.Builder newBuilder() { return new AutoValue_CelEnvironment_TypeDecl.Builder().setIsTypeParam(false); } diff --git a/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlParser.java b/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlParser.java index 14f1c93d8..821ca6586 100644 --- a/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlParser.java +++ b/bundle/src/main/java/dev/cel/bundle/CelEnvironmentYamlParser.java @@ -22,7 +22,6 @@ import static dev.cel.common.formats.YamlHelper.newString; import static dev.cel.common.formats.YamlHelper.parseYamlSource; import static dev.cel.common.formats.YamlHelper.validateYamlType; -import static java.util.Collections.singletonList; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; @@ -60,7 +59,7 @@ */ public final class CelEnvironmentYamlParser { // Sentinel values to be returned for various declarations when parsing failure is encountered. - private static final TypeDecl ERROR_TYPE_DECL = TypeDecl.create(ERROR); + private static final TypeDecl ERROR_TYPE_DECL = TypeSpecifierParser.ERROR_TYPE_DECL; private static final VariableDecl ERROR_VARIABLE_DECL = VariableDecl.create(ERROR, ERROR_TYPE_DECL); private static final FunctionDecl ERROR_FUNCTION_DECL = @@ -335,6 +334,7 @@ private ContextVariable parseContextVariable(ParserContext ctx, Node node) Node valueNode = nodeTuple.getValueNode(); String keyName = ((ScalarNode) keyNode).getValue(); switch (keyName) { + case "type": case "type_name": typeName = newString(ctx, valueNode); break; @@ -478,7 +478,7 @@ private FunctionDecl parseFunction(ParserContext ctx, Node node) { return builder.build(); } - private static ImmutableSet parseOverloads(ParserContext ctx, Node node) { + private ImmutableSet parseOverloads(ParserContext ctx, Node node) { long listId = ctx.collectMetadata(node); ImmutableSet.Builder overloadSetBuilder = ImmutableSet.builder(); if (!assertYamlType(ctx, listId, node, YamlNodeType.LIST)) { @@ -553,8 +553,7 @@ private static ImmutableList parseOverloadExamples(ParserContext c return builder.build(); } - private static ImmutableList parseOverloadArguments( - ParserContext ctx, Node node) { + private ImmutableList parseOverloadArguments(ParserContext ctx, Node node) { long listValueId = ctx.collectMetadata(node); if (!assertYamlType(ctx, listValueId, node, YamlNodeType.LIST)) { return ImmutableList.of(); @@ -791,7 +790,7 @@ private static ImmutableSet parseFunctionOverloadsSelector( } @CanIgnoreReturnValue - private static TypeDecl.Builder parseInlinedTypeDecl( + private TypeDecl.Builder parseInlinedTypeDecl( ParserContext ctx, long keyId, Node keyNode, Node valueNode, TypeDecl.Builder builder) { if (!assertYamlType(ctx, keyId, keyNode, YamlNodeType.STRING, YamlNodeType.TEXT)) { return builder; @@ -800,24 +799,28 @@ private static TypeDecl.Builder parseInlinedTypeDecl( // Create a synthetic node to make this behave as if a `type: ` parent node actually exists. MappingNode mapNode = new MappingNode( - Tag.MAP, /* value= */ singletonList(new NodeTuple(keyNode, valueNode)), FlowStyle.AUTO); + Tag.MAP, + /* value= */ ImmutableList.of(new NodeTuple(keyNode, valueNode)), + FlowStyle.AUTO); return parseTypeDeclFields(ctx, mapNode, builder); } - private static TypeDecl parseTypeDecl(ParserContext ctx, Node node) { - TypeDecl.Builder builder = TypeDecl.newBuilder(); + private TypeDecl parseTypeDecl(ParserContext ctx, Node node) { long id = ctx.collectMetadata(node); - if (!assertYamlType(ctx, id, node, YamlNodeType.MAP)) { - return ERROR_TYPE_DECL; + if (validateYamlType(node, YamlNodeType.STRING, YamlNodeType.TEXT)) { + return TypeSpecifierParser.parse(ctx, id, newString(ctx, node)); } - - MappingNode mapNode = (MappingNode) node; - return parseTypeDeclFields(ctx, mapNode, builder).build(); + if (validateYamlType(node, YamlNodeType.MAP)) { + TypeDecl.Builder builder = TypeDecl.newBuilder(); + return parseTypeDeclFields(ctx, (MappingNode) node, builder).build(); + } + assertYamlType(ctx, id, node, YamlNodeType.STRING, YamlNodeType.TEXT, YamlNodeType.MAP); + return ERROR_TYPE_DECL; } @CanIgnoreReturnValue - private static TypeDecl.Builder parseTypeDeclFields( + private TypeDecl.Builder parseTypeDeclFields( ParserContext ctx, MappingNode mapNode, TypeDecl.Builder builder) { for (NodeTuple nodeTuple : mapNode.getValue()) { Node keyNode = nodeTuple.getKeyNode(); diff --git a/bundle/src/main/java/dev/cel/bundle/TypeSpecifierParser.java b/bundle/src/main/java/dev/cel/bundle/TypeSpecifierParser.java new file mode 100644 index 000000000..ca96a955f --- /dev/null +++ b/bundle/src/main/java/dev/cel/bundle/TypeSpecifierParser.java @@ -0,0 +1,200 @@ +// 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.checkNotNull; + +import com.google.common.collect.ImmutableList; +import dev.cel.bundle.CelEnvironment.TypeDecl; +import dev.cel.common.formats.ParserContext; + +/** + * Parses a type specifier shorthand string (e.g. {@code "map"}, {@code "list<~T>"}, + * {@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; + private final int length; + private int pos; + + static TypeDecl parse(String text) { + checkNotNull(text); + TypeSpecifierParser parser = new TypeSpecifierParser(text); + return parser.parse(); + } + + static TypeDecl parse(ParserContext ctx, long nodeId, String text) { + checkNotNull(ctx); + checkNotNull(text); + try { + return parse(text); + } catch (IllegalArgumentException e) { + ctx.reportError(nodeId, e.getMessage()); + return ERROR_TYPE_DECL; + } + } + + private TypeDecl parse() { + TypeDecl res = parseTypeElem(0); + skipWhitespace(); + if (pos < length) { + throw new IllegalArgumentException( + String.format( + "unexpected character '%c' at position %d in %s", + text.charAt(pos), pos, formatQuoted(text))); + } + return res; + } + + private TypeSpecifierParser(String text) { + this.text = text; + this.length = text.length(); + this.pos = 0; + } + + private TypeDecl parseTypeElem(int depth) { + if (depth > MAX_RECURSION_DEPTH) { + throw new IllegalArgumentException( + String.format("exceeded maximum type specifier recursion depth at position %d", pos)); + } + skipWhitespace(); + if (pos < length && text.charAt(pos) == '~') { + pos++; // consume '~' + String id = parseTypeParamIdent(); + return TypeDecl.ofTypeParam(id); + } + return parseConcreteType(depth); + } + + private TypeDecl parseConcreteType(int depth) { + String id = parseNamespaceIdentifier(); + skipWhitespace(); + if (pos < length && text.charAt(pos) == '<') { + pos++; // consume '<' + ImmutableList.Builder params = ImmutableList.builder(); + while (true) { + TypeDecl param = parseTypeElem(depth + 1); + params.add(param); + skipWhitespace(); + if (pos < length && text.charAt(pos) == ',') { + pos++; // consume ',' + continue; + } + if (pos < length && text.charAt(pos) == '>') { + pos++; // consume '>' + break; + } + throw new IllegalArgumentException( + String.format("expected ',' or '>' at position %d", pos)); + } + return TypeDecl.newBuilder().setName(id).addParams(params.build()).build(); + } + return TypeDecl.create(id); + } + + private String parseNamespaceIdentifier() { + StringBuilder id = new StringBuilder(); + while (pos < length && text.charAt(pos) != '<') { + char c = text.charAt(pos); + if (c == '.') { + id.append('.'); + pos++; // consume '.' + } + String ident = parseIdentifier(); + id.append(ident); + if (pos < length && text.charAt(pos) != '.') { + break; + } + } + String identifier = id.toString(); + if (identifier.isEmpty()) { + throw new IllegalArgumentException(String.format("missing identifier at position %d", pos)); + } + return identifier; + } + + private String parseIdentifier() { + if (pos >= length) { + throw new IllegalArgumentException("unexpected end of input"); + } + int start = pos; + while (pos < length) { + char c = text.charAt(pos); + boolean isValid = (pos == start) ? (isAlpha(c) || c == '_') : (isAlphaNumeric(c) || c == '_'); + if (isValid) { + pos++; + continue; + } + if (pos == start) { + throw new IllegalArgumentException( + String.format("identifier is expected, but '%c' was found at position %d", c, pos)); + } + break; + } + return text.substring(start, pos); + } + + private String parseTypeParamIdent() { + if (pos >= length) { + throw new IllegalArgumentException("unexpected end of input"); + } + char c = text.charAt(pos); + if (c < 'A' || c > 'Z') { + throw new IllegalArgumentException( + String.format( + "invalid type parameter identifier '%c' at position %d, must be a single character" + + " from A-Z", + c, pos)); + } + pos++; + if (pos < length) { + char next = text.charAt(pos); + if (isAlphaNumeric(next) || next == '_') { + throw new IllegalArgumentException( + String.format( + "invalid type parameter identifier '%c' at position %d, must be a single character" + + " from A-Z", + next, pos)); + } + } + return String.valueOf(c); + } + + private void skipWhitespace() { + while (pos < length) { + char c = text.charAt(pos); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + pos++; + } else { + break; + } + } + } + + 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/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlParserTest.java b/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlParserTest.java index 043664e8e..9a07a854d 100644 --- a/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlParserTest.java +++ b/bundle/src/test/java/dev/cel/bundle/CelEnvironmentYamlParserTest.java @@ -19,12 +19,14 @@ import static org.junit.Assert.assertThrows; import com.google.common.base.Ascii; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.io.Resources; import com.google.rpc.context.AttributeContext; import com.google.testing.junit.testparameterinjector.TestParameter; import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import dev.cel.bundle.CelEnvironment.ContextVariable; import dev.cel.bundle.CelEnvironment.ExtensionConfig; import dev.cel.bundle.CelEnvironment.FunctionDecl; import dev.cel.bundle.CelEnvironment.LibrarySubset; @@ -378,6 +380,256 @@ public void environment_setMessageVariable() throws Exception { assertThat(environment.extend(CEL_WITH_MESSAGE_TYPES, CelOptions.DEFAULT)).isNotNull(); } + @Test + public void environment_setListVariable_shorthand() throws Exception { + String yamlConfig = + "variables:\n" // + + "- name: 'request'\n" // + + " type: 'list'"; + + CelEnvironment environment = ENVIRONMENT_PARSER.parse(yamlConfig); + + assertThat(environment) + .isEqualTo( + CelEnvironment.newBuilder() + .setSource(environment.source().get()) + .setVariables( + ImmutableSet.of( + VariableDecl.create( + "request", + TypeDecl.newBuilder() + .setName("list") + .addParams(TypeDecl.create("string")) + .build()))) + .build()); + assertThat(environment.extend(CEL_WITH_MESSAGE_TYPES, CelOptions.DEFAULT)).isNotNull(); + } + + @Test + public void environment_setMapVariable_shorthand() throws Exception { + String yamlConfig = + "variables:\n" // + + "- name: 'request'\n" // + + " type: 'map'"; + + CelEnvironment environment = ENVIRONMENT_PARSER.parse(yamlConfig); + + assertThat(environment) + .isEqualTo( + CelEnvironment.newBuilder() + .setSource(environment.source().get()) + .setVariables( + ImmutableSet.of( + VariableDecl.create( + "request", + TypeDecl.newBuilder() + .setName("map") + .addParams(TypeDecl.create("string"), TypeDecl.create("dyn")) + .build()))) + .build()); + assertThat(environment.extend(CEL_WITH_MESSAGE_TYPES, CelOptions.DEFAULT)).isNotNull(); + } + + @Test + public void environment_withTypeSpecifiersEnabled_handlesStructuredMapTypeDecl() + throws Exception { + String yamlConfig = + "variables:\n" // + + "- name: 'request'\n" // + + " type:\n" // + + " type_name: 'map'\n" // + + " params:\n" // + + " - type_name: 'string'\n" // + + " - type_name: 'dyn'"; + + CelEnvironment environment = ENVIRONMENT_PARSER.parse(yamlConfig); + + assertThat(environment) + .isEqualTo( + CelEnvironment.newBuilder() + .setSource(environment.source().get()) + .setVariables( + ImmutableSet.of( + VariableDecl.create( + "request", + TypeDecl.newBuilder() + .setName("map") + .addParams(TypeDecl.create("string"), TypeDecl.create("dyn")) + .build()))) + .build()); + assertThat(environment.extend(CEL_WITH_MESSAGE_TYPES, CelOptions.DEFAULT)).isNotNull(); + } + + @Test + public void environment_withTypeSpecifiersEnabled_handlesBlockScalarTextTypeDecl() + throws Exception { + String yamlConfig = + "variables:\n" // + + "- name: 'request'\n" // + + " type: >-\n" // + + " list"; + + CelEnvironment environment = ENVIRONMENT_PARSER.parse(yamlConfig); + + assertThat(environment) + .isEqualTo( + CelEnvironment.newBuilder() + .setSource(environment.source().get()) + .setVariables( + ImmutableSet.of( + VariableDecl.create( + "request", + TypeDecl.newBuilder() + .setName("list") + .addParams(TypeDecl.create("string")) + .build()))) + .build()); + assertThat(environment.extend(CEL_WITH_MESSAGE_TYPES, CelOptions.DEFAULT)).isNotNull(); + } + + @Test + public void environment_setMessageVariable_shorthand() throws Exception { + String yamlConfig = + "variables:\n" // + + "- name: 'request'\n" // + + " type: 'google.rpc.context.AttributeContext.Request'"; + + CelEnvironment environment = ENVIRONMENT_PARSER.parse(yamlConfig); + + assertThat(environment) + .isEqualTo( + CelEnvironment.newBuilder() + .setSource(environment.source().get()) + .setVariables( + ImmutableSet.of( + VariableDecl.create( + "request", + TypeDecl.create("google.rpc.context.AttributeContext.Request")))) + .build()); + assertThat(environment.extend(CEL_WITH_MESSAGE_TYPES, CelOptions.DEFAULT)).isNotNull(); + } + + @Test + public void environment_setContextVariable_type() throws Exception { + String yamlConfig = + "context_variable:\n" // + + " type: 'google.rpc.context.AttributeContext.Request'"; + + CelEnvironment environment = ENVIRONMENT_PARSER.parse(yamlConfig); + + assertThat(environment) + .isEqualTo( + CelEnvironment.newBuilder() + .setSource(environment.source().get()) + .setContextVariable( + ContextVariable.create("google.rpc.context.AttributeContext.Request")) + .build()); + assertThat(environment.extend(CEL_WITH_MESSAGE_TYPES, CelOptions.DEFAULT)).isNotNull(); + } + + @Test + public void environment_setFunctions_shorthand() throws Exception { + String yamlConfig = + "functions:\n" // + + "- name: 'isEmpty'\n" // + + " overloads:\n" // + + " - id: 'list_isEmpty'\n" // + + " target: 'list<~T>'\n" // + + " return: 'bool'\n" // + + "- name: 'getOrDefault'\n" // + + " overloads:\n" // + + " - id: 'map_getOrDefault'\n" // + + " target: 'map<~K, ~V>'\n" // + + " args:\n" // + + " - '~K'\n" // + + " - '~V'\n" // + + " return: '~V'"; + + CelEnvironment environment = ENVIRONMENT_PARSER.parse(yamlConfig); + + assertThat(environment) + .isEqualTo( + CelEnvironment.newBuilder() + .setSource(environment.source().get()) + .setFunctions( + ImmutableSet.of( + FunctionDecl.create( + "isEmpty", + ImmutableSet.of( + OverloadDecl.newBuilder() + .setId("list_isEmpty") + .setTarget( + TypeDecl.newBuilder() + .setName("list") + .addParams(TypeDecl.ofTypeParam("T")) + .build()) + .setReturnType(TypeDecl.create("bool")) + .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())))) + .build()); + assertThat(environment.extend(CEL_WITH_MESSAGE_TYPES, CelOptions.DEFAULT)).isNotNull(); + } + + @Test + public void environment_withTypeSpecifier_invalidSyntaxError() { + String yamlConfig = + "variables:\n" // + + "- name: 'request'\n" // + + " type: 'list<'"; + + CelEnvironmentException e = + assertThrows(CelEnvironmentException.class, () -> ENVIRONMENT_PARSER.parse(yamlConfig)); + assertThat(e).hasMessageThat().contains("missing identifier at position 5"); + } + + @Test + public void environment_withTypeSpecifier_invalidYamlNodeError() { + String yamlConfig = + "variables:\n" // + + "- name: 'request'\n" // + + " type: 1"; + + CelEnvironmentException e = + assertThrows(CelEnvironmentException.class, () -> ENVIRONMENT_PARSER.parse(yamlConfig)); + assertThat(e) + .hasMessageThat() + .contains("wanted type(s) [tag:yaml.org,2002:str !txt tag:yaml.org,2002:map]"); + } + + @Test + public void environment_evaluatesShorthandVariable() throws Exception { + String yamlConfig = + "variables:\n" // + + "- name: 'values'\n" // + + " type: 'list'"; + + CelEnvironment environment = ENVIRONMENT_PARSER.parse(yamlConfig); + Cel cel = environment.extend(CelFactory.standardCelBuilder().build(), CelOptions.DEFAULT); + + CelAbstractSyntaxTree ast = cel.compile("values.size() == 2 && values[0] == 'hello'").getAst(); + boolean result = + (boolean) + cel.createProgram(ast) + .eval( + ImmutableMap.of("values", ImmutableList.of("hello", "world"))); + assertThat(result).isTrue(); + } + @Test public void environment_setContainer() throws Exception { String yamlConfig = @@ -577,7 +829,7 @@ private enum EnvironmentParseErrorTestcase { + " - name: foo\n" // + " type: 1", "ERROR: :3:10: Got yaml node type tag:yaml.org,2002:int, wanted type(s)" - + " [tag:yaml.org,2002:map]\n" + + " [tag:yaml.org,2002:str !txt tag:yaml.org,2002:map]\n" + " | type: 1\n" + " | .........^"), ILLEGAL_YAML_TYPE_TYPE_VALUE( @@ -890,6 +1142,48 @@ private enum EnvironmentYamlResourceTestCase { .build()) .setReturnType(TypeDecl.create("bool")) .build())) + .build(), + FunctionDecl.newBuilder() + .setName("isEmptyAlt") + .setDescription( + "determines whether a list is empty,\nor a string has no characters") + .setOverloads( + ImmutableSet.of( + OverloadDecl.newBuilder() + .setId("wrapper_string_isEmpty") + .setTarget(TypeDecl.create("google.protobuf.StringValue")) + .addExamples("''.isEmptyAlt() // true") + .setReturnType(TypeDecl.create("bool")) + .build(), + OverloadDecl.newBuilder() + .setId("list_isEmpty") + .addExamples("[].isEmptyAlt() // true") + .addExamples("[1].isEmptyAlt() // false") + .setTarget( + TypeDecl.newBuilder() + .setName("list") + .addParams(TypeDecl.ofTypeParam("T")) + .build()) + .setReturnType(TypeDecl.create("bool")) + .build())) + .build(), + FunctionDecl.newBuilder() + .setName("getOrDefault") + .setDescription( + "Returns the value of a key in a map or the provided\ndefault value.") + .setOverloads( + 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())) .build()) .setFeatures(CelEnvironment.FeatureFlag.create("cel.feature.macro_call_tracking", true)) .setLimits( diff --git a/bundle/src/test/java/dev/cel/bundle/TypeSpecifierParserTest.java b/bundle/src/test/java/dev/cel/bundle/TypeSpecifierParserTest.java new file mode 100644 index 000000000..ee3dab9c6 --- /dev/null +++ b/bundle/src/test/java/dev/cel/bundle/TypeSpecifierParserTest.java @@ -0,0 +1,250 @@ +// 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.testing.junit.testparameterinjector.TestParameter; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import dev.cel.bundle.CelEnvironment.TypeDecl; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class TypeSpecifierParserTest { + + @Test + public void parse_concreteSimpleType() { + assertThat(TypeDecl.parse("int")).isEqualTo(TypeDecl.create("int")); + assertThat(TypeDecl.parse("string")).isEqualTo(TypeDecl.create("string")); + assertThat(TypeDecl.parse("bool")).isEqualTo(TypeDecl.create("bool")); + assertThat(TypeDecl.parse("double")).isEqualTo(TypeDecl.create("double")); + assertThat(TypeDecl.parse("uint")).isEqualTo(TypeDecl.create("uint")); + assertThat(TypeDecl.parse("bytes")).isEqualTo(TypeDecl.create("bytes")); + assertThat(TypeDecl.parse("duration")).isEqualTo(TypeDecl.create("duration")); + assertThat(TypeDecl.parse("timestamp")).isEqualTo(TypeDecl.create("timestamp")); + assertThat(TypeDecl.parse("dyn")).isEqualTo(TypeDecl.create("dyn")); + assertThat(TypeDecl.parse("any")).isEqualTo(TypeDecl.create("any")); + assertThat(TypeDecl.parse("null_type")).isEqualTo(TypeDecl.create("null_type")); + } + + @Test + public void parse_qualifiedMessageType() { + assertThat(TypeDecl.parse("google.protobuf.StringValue")) + .isEqualTo(TypeDecl.create("google.protobuf.StringValue")); + assertThat(TypeDecl.parse("google.rpc.context.AttributeContext.Request")) + .isEqualTo(TypeDecl.create("google.rpc.context.AttributeContext.Request")); + assertThat(TypeDecl.parse(".com.example.Message")) + .isEqualTo(TypeDecl.create(".com.example.Message")); + } + + @Test + public void parse_parameterizedTypes() { + assertThat(TypeDecl.parse("list")) + .isEqualTo(TypeDecl.newBuilder().setName("list").addParams(TypeDecl.create("int")).build()); + assertThat(TypeDecl.parse("map")) + .isEqualTo( + TypeDecl.newBuilder() + .setName("map") + .addParams(TypeDecl.create("string"), TypeDecl.create("dyn")) + .build()); + assertThat(TypeDecl.parse("optional_type")) + .isEqualTo( + TypeDecl.newBuilder() + .setName("optional_type") + .addParams(TypeDecl.create("string")) + .build()); + assertThat(TypeDecl.parse("type")) + .isEqualTo(TypeDecl.newBuilder().setName("type").addParams(TypeDecl.create("int")).build()); + } + + @Test + public void parse_nestedParameterizedTypes() { + assertThat(TypeDecl.parse("map>")) + .isEqualTo( + TypeDecl.newBuilder() + .setName("map") + .addParams( + TypeDecl.create("int"), + TypeDecl.newBuilder() + .setName("list") + .addParams(TypeDecl.create("string")) + .build()) + .build()); + + assertThat(TypeDecl.parse("list>>")) + .isEqualTo( + TypeDecl.newBuilder() + .setName("list") + .addParams( + TypeDecl.newBuilder() + .setName("map") + .addParams( + TypeDecl.create("string"), + TypeDecl.newBuilder() + .setName("optional_type") + .addParams(TypeDecl.create("int")) + .build()) + .build()) + .build()); + } + + @Test + public void parse_whitespaceTolerance() { + assertThat(TypeDecl.parse(" list < int > ")) + .isEqualTo(TypeDecl.newBuilder().setName("list").addParams(TypeDecl.create("int")).build()); + assertThat(TypeDecl.parse(" map < string , list < int > > ")) + .isEqualTo( + TypeDecl.newBuilder() + .setName("map") + .addParams( + TypeDecl.create("string"), + TypeDecl.newBuilder().setName("list").addParams(TypeDecl.create("int")).build()) + .build()); + } + + @Test + public void parse_whitespaceWithTabsAndNewlines() { + assertThat(TypeDecl.parse("list<\tstring\n>")) + .isEqualTo( + TypeDecl.newBuilder().setName("list").addParams(TypeDecl.create("string")).build()); + assertThat(TypeDecl.parse(" map < string ,\t int > ")) + .isEqualTo( + TypeDecl.newBuilder() + .setName("map") + .addParams(TypeDecl.create("string"), TypeDecl.create("int")) + .build()); + assertThat(TypeDecl.parse("map\t<\nint\r,\tstring\n>\r")) + .isEqualTo( + TypeDecl.newBuilder() + .setName("map") + .addParams(TypeDecl.create("int"), TypeDecl.create("string")) + .build()); + assertThat(TypeDecl.parse("\tlist\n<\r~T\t>\n")) + .isEqualTo( + TypeDecl.newBuilder().setName("list").addParams(TypeDecl.ofTypeParam("T")).build()); + } + + @Test + public void parse_typeParameters() { + assertThat(TypeDecl.parse("~T")).isEqualTo(TypeDecl.ofTypeParam("T")); + assertThat(TypeDecl.parse(" ~T ")).isEqualTo(TypeDecl.ofTypeParam("T")); + assertThat(TypeDecl.parse("list<~T>")) + .isEqualTo( + TypeDecl.newBuilder().setName("list").addParams(TypeDecl.ofTypeParam("T")).build()); + assertThat(TypeDecl.parse("list< ~T >")) + .isEqualTo( + TypeDecl.newBuilder().setName("list").addParams(TypeDecl.ofTypeParam("T")).build()); + assertThat(TypeDecl.parse("map<~K, ~V>")) + .isEqualTo( + TypeDecl.newBuilder() + .setName("map") + .addParams(TypeDecl.ofTypeParam("K"), TypeDecl.ofTypeParam("V")) + .build()); + assertThat(TypeDecl.parse("map< ~K , ~V >")) + .isEqualTo( + TypeDecl.newBuilder() + .setName("map") + .addParams(TypeDecl.ofTypeParam("K"), TypeDecl.ofTypeParam("V")) + .build()); + } + + @Test + public void parse_maxRecursionDepth_succeedsAtBoundary() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 64; i++) { + sb.append("list<"); + } + sb.append("int"); + for (int i = 0; i < 64; i++) { + sb.append(">"); + } + String input = sb.toString(); + TypeDecl result = TypeDecl.parse(input); + assertThat(result).isNotNull(); + } + + @Test + public void parse_exceedsMaxRecursionDepth_throws() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 65; i++) { + sb.append("list<"); + } + sb.append("int"); + for (int i = 0; i < 65; i++) { + sb.append(">"); + } + String input = sb.toString(); + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> TypeDecl.parse(input)); + assertThat(e).hasMessageThat().contains("exceeded maximum type specifier recursion depth"); + } + + @Test + public void parse_errors(@TestParameter ParseErrorTestCase testCase) { + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> TypeDecl.parse(testCase.input)); + assertThat(e).hasMessageThat().contains(testCase.expectedMessageSubstring); + } + + private enum ParseErrorTestCase { + EMPTY("", "missing identifier at position 0"), + TRAILING_CHARACTERS("int int", "unexpected character 'i' at position 4 in \"int int\""), + UNEXPECTED_CLOSING_BRACKET("int>", "unexpected character '>' at position 3 in \"int>\""), + TRAILING_DOT(".foo.", "unexpected end of input"), + CONSECUTIVE_DOTS("..foo", "identifier is expected, but '.' was found at position 1"), + EMPTY_TYPE_PARAM("~", "unexpected end of input"), + DIGIT_TYPE_PARAM( + "~1", + "invalid type parameter identifier '1' at position 1, must be a single character from A-Z"), + LOWERCASE_TYPE_PARAM( + "~t", + "invalid type parameter identifier 't' at position 1, must be a single character from A-Z"), + TYPE_PARAM_FOLLOWED_BY_NUMERIC( + "~T1", + "invalid type parameter identifier '1' at position 2, must be a single character from A-Z"), + TYPE_PARAM_FOLLOWED_BY_UNDERSCORE( + "~T_", + "invalid type parameter identifier '_' at position 2, must be a single character from A-Z"), + TYPE_PARAM_FOLLOWED_BY_LOWERCASE( + "~Telem", + "invalid type parameter identifier 'e' at position 2, must be a single character from A-Z"), + MULTI_CHAR_TYPE_PARAM( + "~elem", + "invalid type parameter identifier 'e' at position 1, must be a single character from A-Z"), + WHITESPACE_IN_IDENTIFIER( + "google. protobuf.StringValue", "identifier is expected, but ' ' was found at position 7"), + WHITESPACE_BEFORE_DOT( + "google .protobuf.StringValue", + "unexpected character '.' at position 7 in \"google .protobuf.StringValue\""), + EXTRA_CLOSING_BRACKET("list>", "unexpected character '>' at position 9 in \"list>\""), + CONSECUTIVE_OPENING_BRACKETS("list<", "missing identifier at position 5"), + TRAILING_COMMA("map", "identifier is expected, but '>' was found at position 11"), + EMPTY_GENERIC_PARAM("map<, int>", "identifier is expected, but ',' was found at position 4"), + UNFINISHED_GENERIC("list<", "missing identifier at position 5"), + TRAILING_COMMA_GENERIC("map", "identifier is expected, but '>' was found at position 9"), + UNCLOSED_GENERIC("map' at position 15"), + ; + + private final String input; + private final String expectedMessageSubstring; + + ParseErrorTestCase(String input, String expectedMessageSubstring) { + this.input = input; + this.expectedMessageSubstring = expectedMessageSubstring; + } + } +} diff --git a/compiler/src/main/java/dev/cel/compiler/tools/CelCompilerTool.java b/compiler/src/main/java/dev/cel/compiler/tools/CelCompilerTool.java index abe780c4a..f1d2d4f4b 100644 --- a/compiler/src/main/java/dev/cel/compiler/tools/CelCompilerTool.java +++ b/compiler/src/main/java/dev/cel/compiler/tools/CelCompilerTool.java @@ -14,6 +14,8 @@ package dev.cel.compiler.tools; +import static java.nio.charset.StandardCharsets.UTF_8; + import dev.cel.expr.CheckedExpr; import com.google.common.collect.ImmutableSet; import com.google.common.io.Files; @@ -34,7 +36,6 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Locale; @@ -95,7 +96,7 @@ private static CelCompiler prepareCompiler( } CelEnvironmentYamlParser environmentYamlParser = CelEnvironmentYamlParser.newInstance(); - String yamlContent = new String(readFileBytes(celEnvironmentPath), StandardCharsets.UTF_8); + String yamlContent = new String(readFileBytes(celEnvironmentPath), UTF_8); CelEnvironment environment = environmentYamlParser.parse(yamlContent); return environment.extend(celCompilerBuilder.build(), CEL_OPTIONS); diff --git a/policy/src/test/java/dev/cel/policy/CelPolicyCompilerImplTest.java b/policy/src/test/java/dev/cel/policy/CelPolicyCompilerImplTest.java index 5f697e0b9..3fbc8720c 100644 --- a/policy/src/test/java/dev/cel/policy/CelPolicyCompilerImplTest.java +++ b/policy/src/test/java/dev/cel/policy/CelPolicyCompilerImplTest.java @@ -55,6 +55,7 @@ import dev.cel.testing.testdata.proto3.StandaloneGlobalEnum; import java.io.IOException; import java.net.URL; +import java.util.EnumSet; import java.util.Map; import java.util.Optional; import org.junit.Test; @@ -201,6 +202,37 @@ public void evaluateYamlPolicy_aggregate_cseApplied() throws Exception { assertThat(evalResultFalse).isEqualTo(ImmutableList.of("ALWAYS")); } + @Test + public void evaluateYamlPolicy_withShorthandTypeSpecifiersInEnvironment() throws Exception { + String configSource = + "variables:\n" // + + "- name: 'user_scores'\n" // + + " type: 'map'\n" // + + "- name: 'allowed_users'\n" // + + " type: 'list'\n"; + CelEnvironment celEnvironment = CelEnvironmentYamlParser.newInstance().parse(configSource); + Cel cel = celEnvironment.extend(newCel(), CEL_OPTIONS); + + String policySource = + "name: 'user_access_policy'\n" // + + "rule:\n" // + + " match:\n" // + + " - condition: \"user_scores['alice'] > 50 && 'alice' in allowed_users\"\n" // + + " output: 'true'\n"; + CelPolicy policy = POLICY_PARSER.parse(policySource); + + CelAbstractSyntaxTree ast = + CelPolicyCompilerFactory.newPolicyCompiler(cel).build().compile(policy); + + Object evalResult = + cel.createProgram(ast) + .eval( + ImmutableMap.of( + "user_scores", ImmutableMap.of("alice", 95L), + "allowed_users", ImmutableList.of("alice", "bob"))); + assertThat(evalResult).isEqualTo(Optional.of(true)); + } + @Test public void compileYamlPolicy_aggregate_macrosPreserved() throws Exception { String policySource = @@ -398,7 +430,7 @@ public void compileYamlPolicy_astDepthLimitCheckDisabled_doesNotThrow() throws E } @Test - @SuppressWarnings("unchecked") + @SuppressWarnings("unchecked") // Test only public void evaluateYamlPolicy_withCanonicalTestData( @TestParameter(valuesProvider = EvaluablePolicyTestDataProvider.class) EvaluablePolicyTestData testData) @@ -470,7 +502,7 @@ public void evaluateYamlPolicy_withCanonicalTestData( } @Test - @SuppressWarnings("unchecked") + @SuppressWarnings("unchecked") // Test only public void evaluateYamlPolicy_nestedRuleProducesOptionalOutput() throws Exception { Cel cel = newCel(); String policySource = @@ -527,7 +559,7 @@ public void evaluateYamlPolicy_lateBoundFunction() throws Exception { String evalResult = (String) cel.createProgram(compiledPolicyAst) - .eval((unused) -> Optional.empty(), lateFunctionBindings); + .eval(unused -> Optional.empty(), lateFunctionBindings); assertThat(evalResult).isEqualTo("foo" + exampleValue); } @@ -587,7 +619,7 @@ private static final class EvaluablePolicyTestDataProvider extends TestParameter @Override protected ImmutableList provideValues(Context context) throws Exception { ImmutableList.Builder builder = ImmutableList.builder(); - for (TestYamlPolicy yamlPolicy : TestYamlPolicy.values()) { + for (TestYamlPolicy yamlPolicy : EnumSet.allOf(TestYamlPolicy.class)) { PolicyTestSuite testSuite = yamlPolicy.readTestYamlContent(); for (PolicyTestSection testSection : testSuite.getSection()) { for (PolicyTestCase testCase : testSection.getTests()) { diff --git a/testing/src/test/resources/environment/extended_env.yaml b/testing/src/test/resources/environment/extended_env.yaml index 9fc2d511d..f380f4ed2 100644 --- a/testing/src/test/resources/environment/extended_env.yaml +++ b/testing/src/test/resources/environment/extended_env.yaml @@ -49,6 +49,33 @@ functions: is_type_param: true return: type_name: "bool" +- name: "isEmptyAlt" + description: |- + determines whether a list is empty, + or a string has no characters + overloads: + - id: "wrapper_string_isEmpty" + examples: + - "''.isEmptyAlt() // true" + target: "google.protobuf.StringValue" + return: "bool" + - id: "list_isEmpty" + examples: + - "[].isEmptyAlt() // true" + - "[1].isEmptyAlt() // false" + target: "list<~T>" + return: "bool" +- name: "getOrDefault" + description: |- + Returns the value of a key in a map or the provided + default value. + overloads: + - id: "map_getOrDefault" + target: "map<~K, ~V>" + return: "~V" + args: + - "~K" + - "~V" features: - name: cel.feature.macro_call_tracking enabled: true