diff --git a/packages/cel/src/checker/checker.test.ts b/packages/cel/src/checker/checker.test.ts new file mode 100644 index 00000000..8e8f1901 --- /dev/null +++ b/packages/cel/src/checker/checker.test.ts @@ -0,0 +1,486 @@ +// Copyright 2024-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { celVariable } from "../ident.js"; +import type { + Expr, + ParsedExpr, +} from "@bufbuild/cel-spec/cel/expr/syntax_pb.js"; +import * as assert from "node:assert/strict"; +import { suite, test } from "node:test"; +import { _CelChecker } from "./checker.js"; +import { parse } from "../parse.js"; +import { CelScalar, listType, mapType, objectType } from "../type.js"; +import type { CelType } from "../type.js"; +import { celCheckerEnv } from "./env.js"; +import { createRegistry } from "@bufbuild/protobuf"; +import { TestAllTypes_NestedMessageSchema } from "@bufbuild/cel-spec/cel/expr/conformance/proto3/test_all_types_pb.js"; +import { + AnySchema, + BoolValueSchema, + BytesValueSchema, + DoubleValueSchema, + DurationSchema, + FloatValueSchema, + Int32ValueSchema, + Int64ValueSchema, + ListValueSchema, + StringValueSchema, + StructSchema, + TimestampSchema, + UInt64ValueSchema, + ValueSchema, +} from "@bufbuild/protobuf/wkt"; +import { celFunc, celMemberOverload, celOverload } from "../func.js"; + +function internal__checkForTest(expr: Expr | undefined): CelType { + if (expr === undefined) { + throw new Error("expr is undefined"); + } + const checker = new _CelChecker( + celCheckerEnv({ + registry: createRegistry(TestAllTypes_NestedMessageSchema), + funcs: [ + celFunc("fg_s", [ + celOverload("fg_s_0", [], CelScalar.STRING, () => ""), + ]), + celFunc("fi_s_s", [ + celMemberOverload( + "fi_s_s_0", + [CelScalar.STRING], + CelScalar.STRING, + (s) => "", + ), + ]), + ], + idents: [ + celVariable("is", CelScalar.STRING), + celVariable("ii", CelScalar.INT), + celVariable("iu", CelScalar.UINT), + celVariable("iz", CelScalar.BOOL), + celVariable("ib", CelScalar.BYTES), + celVariable("id", CelScalar.DOUBLE), + celVariable("ix", CelScalar.NULL), + celVariable("b", listType(CelScalar.STRING)), + celVariable("c", mapType(CelScalar.STRING, CelScalar.BOOL)), + ], + }), + ); + checker.checkExpr(expr); + if (checker.errors.length > 0) { + throw new Error( + `type checking failed: ${checker.errors.map((e) => e.message).join("\n")}`, + ); + } + return checker.getType(expr) as CelType; +} + +void suite("checker", () => { + void test("const", () => { + const cases: { expr: ParsedExpr; want: CelType }[] = [ + { expr: parse("1"), want: CelScalar.INT }, + { expr: parse("1.0"), want: CelScalar.DOUBLE }, + { expr: parse("1u"), want: CelScalar.UINT }, + { expr: parse("true"), want: CelScalar.BOOL }, + { expr: parse("false"), want: CelScalar.BOOL }, + { expr: parse('"str"'), want: CelScalar.STRING }, + { expr: parse('b"bytes"'), want: CelScalar.BYTES }, + { expr: parse("null"), want: CelScalar.NULL }, + ]; + for (const c of cases) { + const got = internal__checkForTest(c.expr.expr); + assert.equal(got.toString(), c.want.toString(), `case ${c.expr}`); + } + }); + + void test("ident", () => { + const cases: { expr: ParsedExpr; want: CelType }[] = [ + { expr: parse("is"), want: CelScalar.STRING }, + { expr: parse("ii"), want: CelScalar.INT }, + { expr: parse("iu"), want: CelScalar.UINT }, + { expr: parse("iz"), want: CelScalar.BOOL }, + { expr: parse("ib"), want: CelScalar.BYTES }, + { expr: parse("id"), want: CelScalar.DOUBLE }, + { expr: parse("ix"), want: CelScalar.NULL }, + { expr: parse("b"), want: listType(CelScalar.STRING) }, + { expr: parse("c"), want: mapType(CelScalar.STRING, CelScalar.BOOL) }, + ]; + for (const c of cases) { + const got = internal__checkForTest(c.expr.expr); + assert.equal(got.toString(), c.want.toString(), `case ${c.expr}`); + } + }); + + void test("list", () => { + const cases: { expr: ParsedExpr; want: CelType }[] = [ + { expr: parse("[1, 2, 3]"), want: listType(CelScalar.INT) }, + { expr: parse("[1.0, 2.0, 3.0]"), want: listType(CelScalar.DOUBLE) }, + { expr: parse("[true, false, true]"), want: listType(CelScalar.BOOL) }, + { expr: parse('["a", "b", "c"]'), want: listType(CelScalar.STRING) }, + { expr: parse('[b"a", b"b", b"c"]'), want: listType(CelScalar.BYTES) }, + { expr: parse('["a", 1, 1.0]'), want: listType(CelScalar.DYN) }, + ]; + for (const c of cases) { + const got = internal__checkForTest(c.expr.expr); + assert.equal(got.toString(), c.want.toString(), `case ${c.expr}`); + } + }); + + void test("map", () => { + const cases: { expr: ParsedExpr; want: CelType }[] = [ + { + expr: parse('{"a": 1, "b": 2}'), + want: mapType(CelScalar.STRING, CelScalar.INT), + }, + { + expr: parse('{"a": 1.0, "b": 2.0}'), + want: mapType(CelScalar.STRING, CelScalar.DOUBLE), + }, + { + expr: parse('{"a": true, "b": false}'), + want: mapType(CelScalar.STRING, CelScalar.BOOL), + }, + { + expr: parse('{"a": "x", "b": "y"}'), + want: mapType(CelScalar.STRING, CelScalar.STRING), + }, + { + expr: parse('{"a": b"x", "b": b"y"}'), + want: mapType(CelScalar.STRING, CelScalar.BYTES), + }, + { + expr: parse('{"a": 1, "b": 2.0}'), + want: mapType(CelScalar.STRING, CelScalar.DYN), + }, + { + expr: parse('{1: "a", 2: "b"}'), + want: mapType(CelScalar.INT, CelScalar.STRING), + }, + { + expr: parse('{1u: "a", 2u: "b"}'), + want: mapType(CelScalar.UINT, CelScalar.STRING), + }, + { + expr: parse('{1: "a", 2u: "b"}'), + want: mapType(CelScalar.DYN, CelScalar.STRING), + }, + { + expr: parse('{true: "a", false: "b"}'), + want: mapType(CelScalar.BOOL, CelScalar.STRING), + }, + { + expr: parse('{1: "a", "b": 2}'), + want: mapType(CelScalar.DYN, CelScalar.DYN), + }, + ]; + for (const c of cases) { + const got = internal__checkForTest(c.expr.expr); + assert.equal(got.toString(), c.want.toString(), `case ${c.expr}`); + } + }); + + void test("message struct", () => { + const cases = [ + { + expr: parse("cel.expr.conformance.proto3.TestAllTypes.NestedMessage{}"), + want: objectType(TestAllTypes_NestedMessageSchema), + }, + { + expr: parse( + "cel.expr.conformance.proto3.TestAllTypes.NestedMessage{bb: 1}", + ), + want: objectType(TestAllTypes_NestedMessageSchema), + }, + { + expr: parse( + "cel.expr.conformance.proto3.TestAllTypes.NestedMessage{}.bb", + ), + want: CelScalar.INT, + }, + ]; + for (const c of cases) { + const got = internal__checkForTest(c.expr.expr); + assert.equal(got.toString(), c.want.toString(), `case ${c.expr}`); + } + }); + + void test("wkt wrappers", () => { + const cases = [ + { + expr: parse("google.protobuf.BoolValue{}"), + want: objectType(BoolValueSchema), + }, + { + expr: parse("google.protobuf.BoolValue{}.value"), + want: CelScalar.BOOL, + }, + { + expr: parse("google.protobuf.BytesValue{}"), + want: objectType(BytesValueSchema), + }, + { + expr: parse("google.protobuf.BytesValue{}.value"), + want: CelScalar.BYTES, + }, + { + expr: parse("google.protobuf.DoubleValue{}"), + want: objectType(DoubleValueSchema), + }, + { + expr: parse("google.protobuf.DoubleValue{}.value"), + want: CelScalar.DOUBLE, + }, + { + expr: parse("google.protobuf.FloatValue{}"), + want: objectType(FloatValueSchema), + }, + { + expr: parse("google.protobuf.FloatValue{}.value"), + want: CelScalar.DOUBLE, + }, + { + expr: parse("google.protobuf.Int32Value{}"), + want: objectType(Int32ValueSchema), + }, + { + expr: parse("google.protobuf.Int32Value{}.value"), + want: CelScalar.INT, + }, + { + expr: parse("google.protobuf.Int64Value{}"), + want: objectType(Int64ValueSchema), + }, + { + expr: parse("google.protobuf.Int64Value{}.value"), + want: CelScalar.INT, + }, + { + expr: parse("google.protobuf.StringValue{}"), + want: objectType(StringValueSchema), + }, + { + expr: parse("google.protobuf.StringValue{}.value"), + want: CelScalar.STRING, + }, + { + expr: parse("google.protobuf.UInt64Value{value: 42u}"), + want: objectType(UInt64ValueSchema), + }, + { + expr: parse("google.protobuf.UInt64Value{}.value"), + want: CelScalar.UINT, + }, + { + expr: parse("google.protobuf.Any{}"), + want: objectType(AnySchema), + }, + { + expr: parse("google.protobuf.Duration{}"), + want: objectType(DurationSchema), + }, + { + expr: parse("google.protobuf.Duration{}.seconds"), + want: CelScalar.INT, + }, + { + expr: parse("google.protobuf.Timestamp{}"), + want: objectType(TimestampSchema), + }, + { + expr: parse("google.protobuf.Timestamp{}.seconds"), + want: CelScalar.INT, + }, + { + expr: parse("google.protobuf.ListValue{}"), + want: objectType(ListValueSchema), + }, + { + expr: parse("google.protobuf.ListValue{}.values"), + want: listType(objectType(ValueSchema)), + }, + { + expr: parse("google.protobuf.Struct{}"), + want: objectType(StructSchema), + }, + { + expr: parse("google.protobuf.Struct{}.fields"), + want: mapType(CelScalar.STRING, objectType(ValueSchema)), + }, + { + expr: parse("google.protobuf.Value{}"), + want: objectType(ValueSchema), + }, + ]; + for (const c of cases) { + const got = internal__checkForTest(c.expr.expr); + assert.equal(got.toString(), c.want.toString(), `case ${c.expr}`); + } + }); + + void test("map select", () => { + const cases = [ + { + expr: parse("{'a': 1, 'b': '2'}.a"), + want: CelScalar.DYN, + }, + { + expr: parse("{'a': 1, 'b': 2}.a"), + want: CelScalar.INT, + }, + { + expr: parse("{'a': true, 'b': false}.b"), + want: CelScalar.BOOL, + }, + // TODO: optional syntax + // { + // expr: parse("c.?d"), + // want: optionalCelType(CelScalar.BOOL), + // } + ]; + for (const c of cases) { + const got = internal__checkForTest(c.expr.expr); + assert.equal(got.toString(), c.want.toString(), `case ${c.expr}`); + } + }); + + void test("call expr", () => { + const cases = [ + { + expr: parse("is == 'str'"), + want: CelScalar.BOOL, + }, + { + expr: parse("ii + 1"), + want: CelScalar.INT, + }, + { + expr: parse("iu + 1u"), + want: CelScalar.UINT, + }, + { + expr: parse("id + 1.0"), + want: CelScalar.DOUBLE, + }, + { + expr: parse("[1, 2] + [3, 4]"), + want: listType(CelScalar.INT), + }, + { + expr: parse("[1.0, 2] + [3u, 4]"), + want: listType(CelScalar.DYN), + }, + { + expr: parse("iz && true"), + want: CelScalar.BOOL, + }, + { + expr: parse("iz || false"), + want: CelScalar.BOOL, + }, + { + expr: parse("!iz"), + want: CelScalar.BOOL, + }, + { + expr: parse("fg_s()"), + want: CelScalar.STRING, + }, + { + expr: parse('"hello".fi_s_s()'), + want: CelScalar.STRING, + }, + { + expr: parse("size(b)"), + want: CelScalar.INT, + }, + { + expr: parse("c.size()"), + want: CelScalar.INT, + }, + { + expr: parse("'hello'.startsWith('he')"), + want: CelScalar.BOOL, + }, + { + expr: parse("is.startsWith('str')"), + want: CelScalar.BOOL, + }, + { + expr: parse("'hello'.contains('he')"), + want: CelScalar.BOOL, + }, + { + expr: parse("is.contains('str')"), + want: CelScalar.BOOL, + }, + { + expr: parse('["1", "2", "3"][3]'), + want: CelScalar.STRING, + }, + { + expr: parse('[1, 2.0, 3u, "4"][3]'), + want: CelScalar.DYN, + }, + { + expr: parse('{ 1: "2", 3: "4" }[1]'), + want: CelScalar.STRING, + }, + { + expr: parse('{ "1": 2, "3": 4.0 }["1"]'), + want: CelScalar.DYN, + }, + ]; + for (const c of cases) { + const got = internal__checkForTest(c.expr.expr); + assert.equal(got.toString(), c.want.toString(), `case ${c.expr}`); + } + }); + + void test("macros", () => { + const cases = [ + { + expr: parse("[1, 2, 3].all(e, e > 0)"), + want: CelScalar.BOOL, + }, + { + expr: parse("[1, 2, 3].exists(e, e > 0)"), + want: CelScalar.BOOL, + }, + { + expr: parse("[1, 2, 3].exists_one(e, e > 2)"), + want: CelScalar.BOOL, + }, + { + expr: parse("[1, 2, 3].filter(e, e > 2)"), + want: listType(CelScalar.INT), + }, + { + expr: parse("[1, 2.0, 3u].filter(e, e > 2)"), + want: listType(CelScalar.DYN), + }, + { + expr: parse("[1, 2, 3].map(e, e + 1)"), + want: listType(CelScalar.INT), + }, + { + expr: parse("[1, 2.0, 3u].map(e, e + 1)"), + want: listType(CelScalar.INT), + }, + ]; + for (const c of cases) { + const got = internal__checkForTest(c.expr.expr); + assert.equal(got.toString(), c.want.toString(), `case ${c.expr}`); + } + }); +}); diff --git a/packages/cel/src/checker/checker.ts b/packages/cel/src/checker/checker.ts new file mode 100644 index 00000000..99f64c6b --- /dev/null +++ b/packages/cel/src/checker/checker.ts @@ -0,0 +1,1197 @@ +// Copyright 2024-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AggregateLiteralElementType, type CelCheckerEnv } from "./env.js"; +import { Mapping } from "./mapping.js"; +import { + type CheckedExpr, + type Reference, + type Type, + CheckedExprSchema, + ReferenceSchema, +} from "@bufbuild/cel-spec/cel/expr/checked_pb.js"; +import { + ConstantSchema, + Expr_CallSchema, + Expr_CreateStructSchema, + Expr_IdentSchema, + type Constant, + type Expr, + type Expr_CreateStruct, +} from "@bufbuild/cel-spec/cel/expr/syntax_pb.js"; +import { create } from "@bufbuild/protobuf"; +import { celError, type CelError } from "../error.js"; +import { + celTypeToProtoType, + functionType, + isAssignable, + isAssignableList, + mostGeneral, + substitute, +} from "./types.js"; +import { + type CelOpaqueType, + CelScalar, + celType, + type CelType, + type CelValue, + DURATION, + errorType, + fieldDescToCelType, + isAssignableType, + isDynCelType, + isDynOrErrorCelType, + isErrorCelType, + isExactCelType, + isOptionalCelType, + listType, + type mapKeyType, + mapType, + maybeUnwrapOptionalCelType, + objectType, + optionalCelType, + TIMESTAMP, + typeParamType, +} from "../type.js"; +import { toQualifiedName } from "../namespace.js"; +import { + functionReference, + identReference, + type ReferenceInfo, +} from "../referenceinfo.js"; +import { overloadTypeParams, type CelFunc } from "../func.js"; +import { + LOGICAL_AND, + LOGICAL_OR, + OPT_SELECT, +} from "../gen/dev/cel/expr/operator_const.js"; +import { celVariable } from "../ident.js"; + +export interface CelChecker { + check(expr: Expr): void; +} + +/** + * Takes a CEL expression and environment and produces a checked expression. + */ +export function check(expr: Expr, env: CelCheckerEnv): CheckedExpr { + const checker = new _CelChecker(env); + checker.check(expr); + return create(CheckedExprSchema, { + expr, + typeMap: checker.protoTypeMap(), + referenceMap: checker.protoReferenceMap(), + // TODO: sourceInfo + }); +} + +interface OverloadResolution { + type: CelType; + reference: ReferenceInfo; +} + +function overloadResolution( + type: CelType, + reference: ReferenceInfo, +): OverloadResolution { + return { type, reference }; +} + +// TODO: do not export +export class _CelChecker implements CelChecker { + typeMap = new Map(); + referenceMap = new Map(); + errors: CelError[] = []; + mappings: Mapping = new Mapping(); + freeTypeVarCounter = 0; + #containerName = ""; + + constructor(private env: CelCheckerEnv) { + if (this.env.namespace) { + this.#containerName = this.env.namespace.name(); + } + } + + check(expr: Expr): void { + this.checkExpr(expr); + // Walk over the final type map substituting any type parameters either by their bound value + // or by DYN. + for (const [id, t] of this.typeMap.entries()) { + this.typeMap.set(id, substitute(this.mappings, t, true)); + } + } + + checkExpr(expr: Expr): void { + switch (expr.exprKind.case) { + case "constExpr": + this.checkConstExpr(expr); + break; + case "identExpr": + this.checkIdentExpr(expr); + break; + case "selectExpr": + this.checkSelectExpr(expr); + break; + case "callExpr": + this.checkCallExpr(expr); + break; + case "listExpr": + this.checkCreateListExpr(expr); + break; + case "structExpr": + if (!expr.exprKind.value.messageName) { + this.checkCreateMapExpr(expr); + break; + } + this.checkCreateStructExpr(expr); + break; + case "comprehensionExpr": + this.checkComprehensionExpr(expr); + break; + default: + throw new Error(`unexpected expression kind: ${expr.exprKind.case}`); + } + } + + checkConstExpr(expr: Expr): void { + if (expr.exprKind.case !== "constExpr") { + this.errors.push(celError(`expected constExpr`, expr.id)); + return; + } + const constant = expr.exprKind.value; + switch (constant.constantKind.case) { + case "boolValue": + this.setType(expr, CelScalar.BOOL); + break; + case "bytesValue": + this.setType(expr, CelScalar.BYTES); + break; + case "doubleValue": + this.setType(expr, CelScalar.DOUBLE); + break; + case "durationValue": + this.setType(expr, DURATION); + break; + case "int64Value": + this.setType(expr, CelScalar.INT); + break; + case "nullValue": + this.setType(expr, CelScalar.NULL); + break; + case "stringValue": + this.setType(expr, CelScalar.STRING); + break; + case "timestampValue": + this.setType(expr, TIMESTAMP); + break; + case "uint64Value": + this.setType(expr, CelScalar.UINT); + break; + default: + throw new Error( + `unexpected constant kind: ${constant.constantKind.case}`, + ); + } + } + + checkIdentExpr(expr: Expr): void { + if (expr.exprKind.case !== "identExpr") { + this.errors.push(celError(`expected identExpr`, expr.id)); + return; + } + const ident = expr.exprKind.value; + // Check to see if the identifier is declared. + const found = this.env.lookupIdent(ident.name); + if (found) { + this.setType(expr, found.type); + this.setReference(expr, identReference(found.name, found.value)); + // Overwrite the identifier with its fully qualified name. + expr.exprKind = { + case: "identExpr", + value: create(Expr_IdentSchema, { name: found.name }), + }; + return; + } + const error = celError( + `undeclared reference to '${ + ident.name + }' (in container '${this.#containerName}')`, + expr.id, + ); + this.setType(expr, errorType(error)); + this.errors.push(error); + } + + checkSelectExpr(expr: Expr): void { + if (expr.exprKind.case !== "selectExpr") { + this.errors.push(celError(`expected selectExpr`, expr.id)); + return; + } + const sel = expr.exprKind.value; + // Before traversing down the tree, try to interpret as qualified name. + const [qname, found] = toQualifiedName(expr); + if (found) { + const ident = this.env.lookupIdent(qname); + if (ident) { + // We don't check for a TestOnly expression here since the `found` result is + // always going to be false for TestOnly expressions. + + // Rewrite the node to be a variable reference to the resolved fully-qualified + // variable name. + this.setType(expr, ident.type); + this.setReference(expr, identReference(ident.name, ident.value)); + expr.exprKind = { + case: "identExpr", + value: create(Expr_IdentSchema, { name: ident.name }), + }; + return; + } + } + + let resultType = this.checkSelectField(expr, sel.operand, sel.field, false); + if (sel.testOnly) { + resultType = CelScalar.BOOL; + } + this.setType(expr, substitute(this.mappings, resultType, false)); + } + + checkOptSelect(expr: Expr): void { + if (expr.exprKind.case !== "callExpr") { + this.errors.push(celError(`expected callExpr`, expr.id)); + return; + } + // Collect metadata related to the opt select call packaged by the parser. + const call = expr.exprKind.value; + if (call.args.length !== 2 || call.target) { + const msg = `incorrect signature${ + call.target ? " member call with" : "" + } argument count: ${call.args.length}`; + this.errors.push( + celError(`unsupported optional field selection: ${msg}`), + ); + return; + } + + const operand = call.args[0]; + const field = call.args[1]; + if ( + field.exprKind.case !== "constExpr" || + field.exprKind.value.constantKind.case !== "stringValue" + ) { + this.errors.push( + celError(`unsupported optional field selection: ${field}`, field.id), + ); + return; + } + // Perform type-checking using the field selection logic. + const resultType = this.checkSelectField( + expr, + operand, + field.exprKind.value.constantKind.value, + true, + ); + this.setType(expr, substitute(this.mappings, resultType, false)); + this.setReference(expr, functionReference(["select_optional_field"])); + } + + checkSelectField( + expr: Expr, + operand: Expr | undefined, + field: string, + optional: boolean, + ): CelType { + if (!operand) { + this.errors.push(celError(`expected select operand`, expr.id)); + return errorType(celError(`invalid select operand`, expr.id)); + } + // Interpret as field selection, first traversing down the operand. + this.checkExpr(operand); + let operandType = this.getType(operand); + if (!operandType) { + this.errors.push( + celError(`unable to determine type of operand`, expr.id), + ); + return errorType(celError(`invalid select operand`, expr.id)); + } + operandType = substitute(this.mappings, operandType, false); + + // If the target type is 'optional', unwrap it for the sake of this check. + const isOpt = isOptionalCelType(operandType); + const targetType = maybeUnwrapOptionalCelType(operandType); + // Assume error type by default as most types do not support field selection. + let resultType: CelType = errorType( + celError( + `type '${operandType.toString()}' does not support field selection`, + expr.id, + ), + ); + switch (targetType.kind) { + case "map": + // Maps yield their value type as the selection result type. + return targetType.value; + case "object": + // Objects yield their field type declaration as the selection result type, but only if + // the field is defined. + const fieldType = this.lookupFieldType( + expr.id, + targetType.desc.typeName, + field, + ); + if (fieldType) { + resultType = fieldType; + } + break; + case "type_param": + // Set the operand type to DYN to prevent assignment to a potentially incorrect type + // at a later point in type-checking. The isAssignable call will update the type + // substitutions for the type param under the covers. + this.isAssignable(CelScalar.DYN, targetType); + // Also, set the result type to DYN. + resultType = CelScalar.DYN; + break; + default: + // Dynamic / error values are treated as DYN type. Errors are handled this way as well + // in order to allow forward progress on the check. + if (!isDynOrErrorCelType(targetType)) { + this.errors.push( + celError( + `type '${operandType.toString()}' does not support field selection`, + expr.id, + ), + ); + } + resultType = CelScalar.DYN; + break; + } + + // If the target type was optional coming in, then the result must be optional going out. + if (isOpt || optional) { + return optionalCelType(resultType); + } + return resultType; + } + + checkCallExpr(expr: Expr): void { + if (expr.exprKind.case !== "callExpr") { + this.errors.push(celError(`expected callExpr`, expr.id)); + return; + } + const call = expr.exprKind.value; + const fnName = call.function; + if (fnName === OPT_SELECT) { + this.checkOptSelect(expr); + return; + } + + const args = call.args; + // Traverse arguments. + for (const arg of args) { + this.checkExpr(arg); + } + + // Regular static call with simple name. + if (!call.target) { + // Check for the existence of the function. + const fn = this.env.lookupFunction(fnName); + if (!fn) { + const err = celError( + `undeclared reference to '${fnName}' (in container '${this.#containerName}')`, + expr.id, + ); + this.errors.push(err); + this.setType(expr, errorType(err)); + return; + } + // Overwrite the function name with its fully qualified resolved name. + expr.exprKind = { + case: "callExpr", + value: create(Expr_CallSchema, { + function: fn.name, + args: args, + }), + }; + // Check to see whether the overload resolves. + this.resolveOverloadOrError(expr, fn, undefined, args); + return; + } + + // If a receiver 'target' is present, it may either be a receiver function, or a namespaced + // function, but not both. Given a.b.c() either a.b.c is a function or c is a function with + // target a.b. + // + // Check whether the target is a namespaced function name. + const target = call.target as Expr; + let [qualifiedPrefix, maybeQualified] = toQualifiedName(target); + if (maybeQualified) { + const maybeQualifiedName = qualifiedPrefix + "." + fnName; + const fn = this.env.lookupFunction(maybeQualifiedName); + if (fn) { + // The function name is namespaced and so preserving the target operand would + // be an inaccurate representation of the desired evaluation behavior. + // Overwrite with fully-qualified resolved function name sans receiver target. + expr.exprKind = { + case: "callExpr", + value: create(Expr_CallSchema, { + function: fn.name, + args: args, + }), + }; + this.resolveOverloadOrError(expr, fn, undefined, args); + } + } + + // Regular instance call. + this.checkExpr(target); + const fn = this.env.lookupFunction(fnName); + // Function found, attempt overload resolution. + if (fn) { + this.resolveOverloadOrError(expr, fn, target, args); + return; + } + // Function name not declared, record error. + const err = celError( + `undeclared reference to '${fnName}' (in container '${this.#containerName}')`, + expr.id, + ); + this.errors.push(err); + this.setType(expr, errorType(err)); + } + + resolveOverloadOrError( + call: Expr, + fn: CelFunc, + target?: Expr, + args: Expr[] = [], + ) { + // Attempt to resolve the overload. + const resolution = this.resolveOverload(call, fn, target, args); + if (!resolution) { + const err = celError(`no matching overload for '${fn.name}'`, call.id); + this.errors.push(err); + this.setType(call, errorType(err)); + return; + } + // Overload found. + this.setType(call, resolution.type); + this.setReference(call, resolution.reference); + } + + resolveOverload( + call: Expr, + fn: CelFunc, + target?: Expr, + args: Expr[] = [], + ): OverloadResolution | undefined { + const argTypes: CelType[] = []; + if (target) { + const targetType = this.getType(target); + if (!targetType) { + this.errors.push( + celError(`unable to determine type of target`, call.id), + ); + return; + } + argTypes.push(targetType); + } + for (const arg of args) { + const argType = this.getType(arg); + if (!argType) { + this.errors.push( + celError(`unable to determine type of argument`, call.id), + ); + return; + } + argTypes.push(argType); + } + + let resultType: CelType | undefined; + let checkedRef: ReferenceInfo | undefined; + for (const overload of fn.overloads) { + // Determine whether the overload is currently considered. + if (this.env.isOverloadDisabled(overload.id)) { + continue; + } + + // Ensure the call style for the overload matches. + if ( + (target && !overload.isMemberFunction) || + (!target && overload.isMemberFunction) + ) { + // not a compatible call style. + continue; + } + + // Alternative type-checking behavior when the logical operators are compacted into + // variadic AST representations. + if (fn.name === LOGICAL_AND || fn.name === LOGICAL_OR) { + checkedRef = functionReference([fn.name]); + for (const argType of argTypes) { + if (!this.isAssignable(argType, CelScalar.BOOL)) { + const err = celError( + `expected type 'bool' but got '${argType.toString()}'`, + call.id, + ); + this.errors.push(err); + resultType = errorType(err); + } + } + if (resultType && isErrorCelType(resultType)) { + return undefined; + } + return overloadResolution(CelScalar.BOOL, checkedRef); + } + + let overloadType: CelOpaqueType = functionType( + overload.result, + ...overload.parameters, + ); + let typeParams = overloadTypeParams(overload); + if (typeParams.length > 0) { + // Instantiate overload's type with fresh type variables. + const substitutions = new Mapping(); + for (const tp of typeParams) { + substitutions.add(typeParamType(tp), this.newTypeVar()); + } + overloadType = substitute( + substitutions, + overloadType, + false, + ) as CelOpaqueType; + } + + const candidateArgTypes: CelType[] = overloadType.parameters.slice(1); + if (this.isAssignableList(argTypes, candidateArgTypes)) { + if (!checkedRef) { + checkedRef = functionReference([overload.id]); + } else { + checkedRef.addOverload(overload.id); + } + // First matching overload, determines result type. + const fnResultType = substitute( + this.mappings, + overloadType.parameters[0], + false, + ); + if (!resultType) { + resultType = fnResultType; + } else if ( + !isDynCelType(resultType) && + !isExactCelType(fnResultType, resultType) + ) { + resultType = CelScalar.DYN; + } + } + } + if (!resultType) { + for (let i = 0; i < argTypes.length; i++) { + argTypes[i] = substitute(this.mappings, argTypes[i], true); + } + this.errors.push( + celError( + // TODO: improve error message with arg types + `no matching overload for '${fn.name}' applied to '(${argTypes + .map((t) => t.toString()) + .join(", ")})'`, + call.id, + ), + ); + return; + } + return overloadResolution(resultType, checkedRef as ReferenceInfo); + } + + checkCreateListExpr(expr: Expr): void { + if (expr.exprKind.case !== "listExpr") { + this.errors.push(celError(`expected listExpr`, expr.id)); + return; + } + const create = expr.exprKind.value; + let elemsType: CelType | undefined; + const optionalIndices = create.optionalIndices; + const optionals: Record = {}; + for (const idx of optionalIndices) { + optionals[idx] = true; + } + for (let i = 0; i < create.elements.length; i++) { + const e = create.elements[i]; + this.checkExpr(e); + let elemType = this.getType(e); + if (optionals[i] && elemType) { + const isOptional = isOptionalCelType(elemType); + elemType = maybeUnwrapOptionalCelType(elemType); + if (!isOptional && !isDynCelType(elemType)) { + this.errors.push( + celError( + `expected type '${optionalCelType( + elemType, + ).toString()}' but got '${elemType.toString()}'`, + e.id, + ), + ); + return; + } + } + elemsType = this.joinTypes(e, elemsType, elemType); + } + if (!elemsType) { + // If the list is empty, assign free type var to elem type. + elemsType = this.newTypeVar(); + } + this.setType(expr, listType(elemsType)); + } + + checkCreateMapExpr(expr: Expr): void { + if (expr.exprKind.case !== "structExpr") { + this.errors.push(celError(`expected mapExpr`, expr.id)); + return; + } + const mapVal = expr.exprKind.value; + let mapKeyType: CelType | undefined; + let mapValueType: CelType | undefined; + for (const entry of mapVal.entries) { + if (entry.keyKind.case !== "mapKey") { + this.errors.push(celError(`expected map key`, entry.id)); + return; + } + const key = entry.keyKind.value; + this.checkExpr(key); + mapKeyType = this.joinTypes(key, mapKeyType, this.getType(key)); + + const val = entry.value; + if (!val) { + this.errors.push(celError(`expected map value`, entry.id)); + return; + } + this.checkExpr(val); + let valType = this.getType(val) as CelType; + if (entry.optionalEntry) { + let isOptional = isOptionalCelType(valType); + valType = maybeUnwrapOptionalCelType(valType); + if (!isOptional && !isDynCelType(valType)) { + const expected = optionalCelType(valType); + this.errors.push( + celError( + `expected type '${expected.toString()}' but got '${valType.toString()}'`, + val.id, + ), + ); + } + } + mapValueType = this.joinTypes(val, mapValueType, valType); + } + if (!mapKeyType) { + // If the map is empty, assign free type variables to typeKey and value type. + mapKeyType = this.newTypeVar(); + mapValueType = this.newTypeVar(); + } + this.setType( + expr, + mapType(mapKeyType as mapKeyType, mapValueType as CelType), + ); + } + + checkCreateStructExpr(expr: Expr): void { + if (expr.exprKind.case !== "structExpr") { + this.errors.push(celError(`expected structExpr`, expr.id)); + return; + } + let msgVal = expr.exprKind.value; + // Determine the type of the message. + let resultType: CelType = errorType( + celError(`'${msgVal.messageName}' is not a message type`, expr.id), + ); + const ident = this.env.lookupIdent(msgVal.messageName); + if (!ident) { + const error = celError( + `undeclared reference to '${ + msgVal.messageName + }' (in container '${this.#containerName}')`, + expr.id, + ); + this.setType(expr, errorType(error)); + this.errors.push(error); + return; + } + // Ensure the type name is fully qualified in the AST. + let typeName = ident.name; + if (msgVal.messageName !== typeName) { + expr.exprKind = { + case: "structExpr", + value: create(Expr_CreateStructSchema, { + messageName: typeName, + entries: msgVal.entries, + }), + }; + msgVal = expr.exprKind.value as Expr_CreateStruct; + } + this.setReference(expr, identReference(typeName, undefined)); + const identKind = ident.type.kind; + if (identKind !== "error") { + if (identKind !== "object") { + this.errors.push( + celError(`'${ident.type.name}' is not a type`, expr.id), + ); + } else { + resultType = objectType(ident.type.desc); + // Backwards compatibility test between well-known types and message types + // In this context, the type is being instantiated by its protobuf name which + // is not ideal or recommended, but some users expect this to work. + if (isWellKnownType(resultType)) { + typeName = getWellKnownTypeName(resultType) as string; + } else if (resultType.kind === "object") { + typeName = resultType.desc.typeName; + } else { + const error = celError( + `'${ident.type.name}' is not a message type`, + expr.id, + ); + this.errors.push(error); + resultType = errorType(error); + } + } + } + this.setType(expr, resultType); + + // Check the field initializers. + for (const field of msgVal.entries) { + if (field.keyKind.case !== "fieldKey") { + this.errors.push(celError(`expected field key`, field.id)); + return; + } + const fieldName = field.keyKind.value; + const value = field.value; + if (!value) { + this.errors.push(celError(`expected field value`, field.id)); + return; + } + this.checkExpr(value); + + let fieldType: CelType = errorType( + celError(`unable to determine type of field '${fieldName}'`, field.id), + ); + const ft = this.lookupFieldType(field.id, typeName, fieldName); + if (ft) { + fieldType = ft; + } + + const valType = this.getType(value) as CelType; + if (field.optionalEntry) { + let isOptional = isOptionalCelType(valType); + const unwrapped = maybeUnwrapOptionalCelType(valType); + if (!isOptional && !isDynCelType(unwrapped)) { + const expected = optionalCelType(unwrapped); + this.errors.push( + celError( + `expected type '${expected.toString()}' but got '${unwrapped.toString()}'`, + value.id, + ), + ); + } + } + if (!this.isAssignable(fieldType, valType)) { + this.errors.push( + celError( + `expected type '${fieldType.toString()}' but got '${valType.toString()}'`, + value.id, + ), + ); + } + } + } + + checkComprehensionExpr(expr: Expr): void { + if (expr.exprKind.case !== "comprehensionExpr") { + this.errors.push(celError(`expected comprehensionExpr`, expr.id)); + return; + } + const comp = expr.exprKind.value; + if (!comp.iterRange) { + // This should not happen, anyway, report an error. + this.errors.push(celError(`expected comprehension iter_range`, expr.id)); + return; + } + this.checkExpr(comp.iterRange); + if (!comp.accuInit) { + // This should not happen, anyway, report an error. + this.errors.push(celError(`expected comprehension accu_init`, expr.id)); + return; + } + this.checkExpr(comp.accuInit); + let rangeType = this.getType(comp.iterRange); + if (!rangeType) { + // This should not happen, anyway, report an error. + this.errors.push( + celError( + `unable to determine type of comprehension iter_range`, + expr.id, + ), + ); + return; + } + rangeType = substitute(this.mappings, rangeType, false); + + // Create a scope for the comprehension since it has a local accumulation variable. + // This scope will contain the accumulation variable used to compute the result. + const accuType = this.getType(comp.accuInit); + if (!accuType) { + // This should not happen, anyway, report an error. + this.errors.push( + celError( + `unable to determine type of comprehension accu_init`, + expr.id, + ), + ); + return; + } + this.env = this.env.enterScope(); + this.env.addIdents([celVariable(comp.accuVar, accuType)]); + + let varType: CelType | undefined; + let var2Type: CelType | undefined; + switch (rangeType.kind) { + case "list": + // varType represents the list element type for one-variable comprehensions. + varType = rangeType.element; + if (comp.iterVar2) { + // varType represents the list index (int) for two-variable comprehensions, + // and var2Type represents the list element type. + var2Type = varType; + varType = CelScalar.INT; + } + break; + case "map": + // varType represents the map entry key for all comprehension types. + varType = rangeType.key; + if (comp.iterVar2) { + // var2Type represents the map entry value for two-variable comprehensions. + var2Type = rangeType.value; + } + break; + case "error": + case "type_param": + case "scalar": + if (rangeType.kind === "scalar" && rangeType.scalar !== "dyn") { + const err = celError( + `expression of type '${rangeType.kind}' cannot be range of a comprehension (must be list, map, or dynamic)`, + ); + this.errors.push(err); + varType = errorType(err); + if (comp.iterVar2) { + var2Type = errorType(err); + } + break; + } + // Set the range type to DYN to prevent assignment to a potentially incorrect type + // at a later point in type-checking. The isAssignable call will update the type + // substitutions for the type param under the covers. + this.isAssignable(CelScalar.DYN, rangeType); + // Set the range iteration variable to type DYN as well. + varType = CelScalar.DYN; + if (comp.iterVar2) { + var2Type = CelScalar.DYN; + } + break; + default: + const err = celError( + `expression of type '${rangeType.kind}' cannot be range of a comprehension (must be list, map, or dynamic)`, + expr.id, + ); + this.errors.push(err); + varType = errorType(err); + if (comp.iterVar2) { + var2Type = errorType(err); + } + break; + } + + // Create a block scope for the loop. + this.env = this.env.enterScope(); + this.env.addIdents([celVariable(comp.iterVar, varType)]); + if (comp.iterVar2) { + this.env.addIdents([celVariable(comp.iterVar2, var2Type as CelType)]); + } + // Check the variable references in the condition and step. + if (!comp.loopCondition) { + this.errors.push( + celError(`expected comprehension loop_condition`, expr.id), + ); + return; + } + this.checkExpr(comp.loopCondition); + this.assertType(comp.loopCondition, CelScalar.BOOL); + if (!comp.loopStep) { + this.errors.push(celError(`expected comprehension loop_step`, expr.id)); + return; + } + this.checkExpr(comp.loopStep); + this.assertType(comp.loopStep, accuType); + // Exit the loop's block scope before checking the result. + this.env = this.env.exitScope(); + if (!comp.result) { + this.errors.push(celError(`expected comprehension result`, expr.id)); + return; + } + this.checkExpr(comp.result); + + // Exit the comprehension scope. + this.env = this.env.enterScope(); + const resultType = this.getType(comp.result); + if (!resultType) { + this.errors.push( + celError(`unable to determine type of comprehension result`, expr.id), + ); + return; + } + this.setType(expr, substitute(this.mappings, resultType, false)); + } + + joinTypes( + expr: Expr, + previous: CelType | undefined, + current: CelType | undefined, + ): CelType | undefined { + if (!previous) { + return current; + } + if (!current) { + return previous; + } + if (this.isAssignable(previous, current)) { + return mostGeneral(previous, current); + } + if ( + this.env.aggregateLiteralElementType === + AggregateLiteralElementType.DynElementType + ) { + return CelScalar.DYN; + } + const err = celError( + `expected type '${previous.toString()}' but got '${current?.toString()}'`, + expr.id, + ); + this.errors.push(err); + return errorType(err); + } + + newTypeVar(): CelType { + const id = this.freeTypeVarCounter; + this.freeTypeVarCounter++; + return typeParamType(`_var${id}`); + } + + isAssignable(t1: CelType, t2: CelType): boolean { + const subs = isAssignable(this.mappings, t1, t2); + if (subs) { + this.mappings = subs; + return true; + } + return false; + } + + isAssignableList(l1: CelType[], l2: CelType[]): boolean { + const subs = isAssignableList(this.mappings, l1, l2); + if (subs) { + this.mappings = subs; + return true; + } + return false; + } + + setType(expr: Expr, type: CelType): void { + const found = this.typeMap.get(expr.id); + if (found && found.kind !== type.kind) { + this.errors.push( + celError(`incompatible type already exists for expression`, expr.id), + ); + return; + } + this.typeMap.set(expr.id, type); + } + + getType(expr: Expr): CelType | undefined { + return this.typeMap.get(expr.id); + } + + setReference(expr: Expr, ref: ReferenceInfo): void { + const old = this.referenceMap.get(expr.id); + if (old && !old.equals(ref)) { + this.errors.push( + celError( + `reference already exists for expression: ${expr}(${expr.id}) old:${old}, new:${ref}`, + expr.id, + ), + ); + return; + } + this.referenceMap.set(expr.id, ref); + } + + assertType(expr: Expr, t: CelType): void { + const exprType = this.getType(expr); + if (!exprType) { + this.errors.push( + celError(`unable to determine type of expression`, expr.id), + ); + return; + } + if (!this.isAssignable(t, exprType)) { + this.errors.push( + celError( + `expected type '${t.toString()}' but got '${exprType.toString()}'`, + expr.id, + ), + ); + } + } + + lookupFieldType( + id: bigint, + structType: string, + fieldName: string, + ): CelType | undefined { + const msg = this.env.registry.getMessage(structType); + if (!msg) { + // This should not happen, anyway, report an error. + this.errors.push( + celError(`unexpected failed resolution of '${structType}'`, id), + ); + return undefined; + } + const field = msg.field[fieldName]; + if (!field) { + this.errors.push(celError(`undefined field '${fieldName}'`, id)); + return undefined; + } + return fieldDescToCelType(field); + } + + protoTypeMap(): Record { + const result: Record = {}; + const mappings = this.mappings.copy(); + for (const [id, t] of this.typeMap.entries()) { + result[id.toString()] = celTypeToProtoType(substitute(mappings, t, true)); + } + return result; + } + + protoReferenceMap(): Record { + const result: Record = {}; + for (const [id, ref] of this.referenceMap.entries()) { + result[id.toString()] = referenceInfoToProtoReference(ref); + } + return result; + } +} + +function isWellKnownType(t: CelType): boolean { + switch (t.kind) { + case "scalar": + switch (t.scalar) { + case "bool": + case "bytes": + case "double": + case "int": + case "string": + case "uint": + return isAssignableType(t, CelScalar.NULL); + case "dyn": + case "null_type": + return true; + default: + return false; + } + case "object": + switch (t.desc.typeName) { + case "google.protobuf.Any": + case "google.protobuf.Timestamp": + case "google.protobuf.Duration": + return true; + default: + return false; + } + case "list": + return isDynCelType(t.element); + case "map": + return t.key.scalar === "string" && isDynCelType(t.value); + default: + return false; + } +} + +function getWellKnownTypeName(t: CelType): string | undefined { + switch (t.kind) { + case "scalar": + switch (t.scalar) { + case "bool": + return "google.protobuf.BoolValue"; + case "bytes": + return "google.protobuf.BytesValue"; + case "double": + return "google.protobuf.DoubleValue"; + case "int": + return "google.protobuf.Int64Value"; + case "string": + return "google.protobuf.StringValue"; + case "uint": + return "google.protobuf.UInt64Value"; + case "dyn": + return "google.protobuf.Value"; + case "null_type": + return "google.protobuf.NullValue"; + default: + return undefined; + } + case "object": + switch (t.desc.typeName) { + case "google.protobuf.Any": + case "google.protobuf.Timestamp": + case "google.protobuf.Duration": + return t.desc.typeName; + default: + return undefined; + } + case "list": + return "google.protobuf.ListValue"; + case "map": + return "google.protobuf.Struct"; + default: + return undefined; + } +} + +function valToConstant(val: CelValue): Constant { + const t = celType(val); + switch (t.kind) { + case "scalar": + switch (t.scalar) { + case "bool": + return create(ConstantSchema, { + constantKind: { + case: "boolValue", + value: val as boolean, + }, + }); + } + } + throw new Error(`unsupported constant type: ${t.toString()}`); +} + +function referenceInfoToProtoReference(ref: ReferenceInfo): Reference { + const protoRef = create(ReferenceSchema, { + name: ref.name, + overloadId: Array.from(ref.overloadIds), + value: ref.value ? valToConstant(ref.value) : undefined, + }); + return protoRef; +} diff --git a/packages/cel/src/checker/env.ts b/packages/cel/src/checker/env.ts new file mode 100644 index 00000000..6a4d208c --- /dev/null +++ b/packages/cel/src/checker/env.ts @@ -0,0 +1,393 @@ +// Copyright 2024-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as olc from "../gen/dev/cel/expr/overload_const.js"; +import { _CelChecker } from "./checker.js"; +import type { Registry } from "@bufbuild/protobuf"; +import { Namespace } from "../namespace.js"; +import { Group, Scopes } from "./scopes.js"; +import type { CelFunc } from "../func.js"; +import { mergeFuncs } from "../func.js"; +import type { CelIdent } from "../ident.js"; +import { + celConstant, + celVariable, + identDeclarationIsEquivalent, +} from "../ident.js"; +import { createRegistryWithWKT } from "../registry.js"; +import { CelScalar, objectType } from "../type.js"; +import { STD_FUNCS } from "../std/std.js"; +import { STD_TYPES } from "../std/types.js"; + +const privateSymbol = Symbol.for("@bufbuild/cel/checker/env"); + +export enum AggregateLiteralElementType { + DynElementType = 1, + HomogenousElementType = 2, +} + +/** + * CEL checker environment. + * + * The environment defines the functions and types that are available + * during CEL expression checking. + */ +export interface CelCheckerEnv { + [privateSymbol]: unknown; + /** + * Namespace of the environment. + */ + readonly namespace: Namespace | undefined; + /** + * The protobuf registry to use. + */ + readonly registry: Registry; + /** + * The declarations available in this environment. + */ + readonly declarations: Scopes; + /** + * The aggregate literal element type strategy to use. + */ + readonly aggregateLiteralElementType: AggregateLiteralElementType; + /** + * The filtered overload ids. + */ + readonly filteredOverloadIds: Set; + /** + * AddIdents configures the checker with a list of variable declarations. + * + * If there are overlapping declarations, the method will error. + */ + addIdents(idents: CelIdent[]): void; + /** + * AddFunctions configures the checker with a list of function declarations. + * + * If there are overlapping declarations, the method will error. + */ + addFunctions(funcs: CelFunc[]): void; + /** + * LookupIdent returns an identifier in the Env. + * Returns undefined if no such identifier is found in the Env. + */ + lookupIdent(name: string): CelIdent | undefined; + /** + * LookupFunction returns a function declaration in the env. + * Returns undefined if no such function is found in the env. + */ + lookupFunction(name: string): CelFunc | undefined; + /** + * IsOverloadDisabled returns whether the overloadID is disabled in the current environment. + */ + isOverloadDisabled(overloadID: string): boolean; + /** + * validatedDeclarations returns a reference to the validated variable and function declaration scope stack. + */ + validatedDeclarations(): Scopes; + /** + * enterScope creates a new Env instance with a new innermost declaration scope. + */ + enterScope(): CelCheckerEnv; + /** + * exitScope creates a new Env instance with the nearest outer declaration scope. + */ + exitScope(): CelCheckerEnv; +} + +export interface CelCheckerEnvOptions { + /** + * Namespace of the environment. + */ + namespace?: string; + /** + * The protobuf registry to use. + */ + registry?: Registry; + /** + * Additional functions to add. + * + * All functions must be unique. This can be used to override any std function. + */ + funcs?: CelFunc[]; + /** + * Idents available in this environment. + */ + idents?: CelIdent[]; + /** + * Whether to enforce homogenous types in aggregate literals. + */ + homogenousAggregateLiterals?: boolean; + /** + * Whether to allow cross-type numeric comparisons. + */ + crossTypeNumericComparisons?: boolean; +} + +const crossTypeNumericComparisonOverloads = new Set([ + // double <-> int | uint + olc.LESS_DOUBLE_INT64, + olc.LESS_DOUBLE_UINT64, + olc.LESS_EQUALS_DOUBLE_INT64, + olc.LESS_EQUALS_DOUBLE_UINT64, + olc.GREATER_DOUBLE_INT64, + olc.GREATER_DOUBLE_UINT64, + olc.GREATER_EQUALS_DOUBLE_INT64, + olc.GREATER_EQUALS_DOUBLE_UINT64, + // int <-> double | uint + olc.LESS_INT64_DOUBLE, + olc.LESS_INT64_UINT64, + olc.LESS_EQUALS_INT64_DOUBLE, + olc.LESS_EQUALS_INT64_UINT64, + olc.GREATER_INT64_DOUBLE, + olc.GREATER_INT64_UINT64, + olc.GREATER_EQUALS_INT64_DOUBLE, + olc.GREATER_EQUALS_INT64_UINT64, + // uint <-> double | int + olc.LESS_UINT64_DOUBLE, + olc.LESS_UINT64_INT64, + olc.LESS_EQUALS_UINT64_DOUBLE, + olc.LESS_EQUALS_UINT64_INT64, + olc.GREATER_UINT64_DOUBLE, + olc.GREATER_UINT64_INT64, + olc.GREATER_EQUALS_UINT64_DOUBLE, + olc.GREATER_EQUALS_UINT64_INT64, +]); + +export function celCheckerEnv(options?: CelCheckerEnvOptions): CelCheckerEnv { + const idents = new Map(); + if (options?.idents) { + for (const ident of options.idents) { + idents.set(ident.name, ident); + } + } + for (const ident of STD_TYPES) { + // TODO: how do other implementations handle this? Can users overwrite std types? + if (idents.has(ident.name)) { + continue; + } + idents.set(ident.name, ident); + } + const funcs = new Map(); + for (const func of STD_FUNCS.declarations) { + funcs.set(func.name, func); + } + if (options?.funcs) { + for (const func of options.funcs) { + funcs.set(func.name, func); + } + } + const declarations = new Scopes(new Group(idents, funcs)); + declarations.push(); + + let aggLitElemType = AggregateLiteralElementType.DynElementType; + if (options?.homogenousAggregateLiterals) { + aggLitElemType = AggregateLiteralElementType.HomogenousElementType; + } + + let filteredOverloadIds = crossTypeNumericComparisonOverloads; + if (options?.crossTypeNumericComparisons) { + filteredOverloadIds = new Set(); + } + return new _CelCheckerEnv( + new Namespace(options?.namespace ?? ""), + options?.registry + ? createRegistryWithWKT(options.registry) + : createRegistryWithWKT(), + declarations, + aggLitElemType, + filteredOverloadIds, + ); +} + +class _CelCheckerEnv implements CelCheckerEnv { + [privateSymbol] = {}; + constructor( + public readonly namespace: Namespace, + public readonly registry: Registry, + public readonly declarations: Scopes, + public readonly aggregateLiteralElementType: AggregateLiteralElementType, + public readonly filteredOverloadIds: Set, + ) {} + + /** + * AddIdents configures the checker with a list of variable declarations. + * + * If there are overlapping declarations, the method will error. + */ + addIdents(idents: CelIdent[]): void { + let errMsgs: string[] = []; + for (const ident of idents) { + const errMsg = this.#addIdent(ident); + if (errMsg) { + errMsgs.push(errMsg); + } + } + if (errMsgs.length > 0) { + throw new Error(errMsgs.join("\n")); + } + } + + /** + * AddFunctions configures the checker with a list of function declarations. + * + * If there are overlapping declarations, the method will error. + */ + addFunctions(funcs: CelFunc[]): void { + let errMsgs: string[] = []; + for (const fn of funcs) { + errMsgs = errMsgs.concat(this.#setFunction(fn)); + } + if (errMsgs.length > 0) { + throw new Error(errMsgs.join("\n")); + } + } + + /** + * LookupIdent returns an identifier in the Env. + * Returns undefined if no such identifier is found in the Env. + */ + lookupIdent(name: string): CelIdent | undefined { + for (const candidate of this.namespace.resolveCandidateNames(name)) { + const ident = this.declarations.findIdent(candidate); + if (ident) { + return ident; + } + + // Next try to import the name as a reference to a message type. + const msg = this.registry.getMessage(candidate); + if (msg) { + return celVariable(candidate, objectType(msg)); + } + + // Next try to import this as an enum value by splitting the name in a type prefix and + // the enum inside. + const lastDot = candidate.lastIndexOf("."); + if (lastDot !== -1) { + const enumTypeName = candidate.substring(0, lastDot); + const enumValueName = candidate.substring(lastDot + 1); + const enumType = this.registry.getEnum(enumTypeName); + if (enumType) { + const enumValueDesc = enumType.values.find( + (v) => v.name === enumValueName, + ); + if (enumValueDesc) { + return celConstant(candidate, CelScalar.INT, enumValueDesc.number); + } + } + } + } + return undefined; + } + + /** + * LookupFunction returns a function declaration in the env. + * Returns undefined if no such function is found in the env. + */ + lookupFunction(name: string): CelFunc | undefined { + for (const candidate of this.namespace.resolveCandidateNames(name)) { + const fn = this.declarations.findFunction(candidate); + if (fn) { + return fn; + } + } + return undefined; + } + + /** + * setFunction adds the function declaration to the Env. + * Adds a function decl if one doesn't already exist, then adds all overloads from the Decl. + * If overload overlaps with an existing overload, adds to the errors in the Env instead. + */ + #setFunction(fn: CelFunc): string[] { + const errMsgs: string[] = []; + let current = this.declarations.findFunction(fn.name); + if (!current) { + current = fn; + } else { + current = mergeFuncs(current, fn); + } + // TODO: check macros + // for (const overload of current.overloads) { + // for _, macro := range parser.AllMacros { + // if macro.Function() == current.Name() && + // macro.IsReceiverStyle() == overload.IsMemberFunction() && + // macro.ArgCount() == len(overload.ArgTypes()) { + // errMsgs = append(errMsgs, overlappingMacroError(current.Name(), macro.ArgCount())) + // } + // } + // if len(errMsgs) > 0 { + // return errMsgs + // } + // } + this.declarations.setFunction(current); + return errMsgs; + } + + /** + * addIdent adds the Decl to the declarations in the Env. + * Returns a non-empty errorMsg if the identifier is already declared in the scope + */ + #addIdent(ident: CelIdent): string | null { + const current = this.declarations.findIdentInScope(ident.name); + if (current) { + if (identDeclarationIsEquivalent(current, ident)) { + return null; + } + return `overlapping identifier for name '${ident.name}'`; + } + this.declarations.addIdent(ident); + return null; + } + + /** + * isOverloadDisabled returns whether the overloadID is disabled in the current environment. + */ + isOverloadDisabled(overloadID: string): boolean { + return this.filteredOverloadIds.has(overloadID); + } + + /** + * validatedDeclarations returns a reference to the validated variable and function declaration scope stack. + * must be copied before use. + */ + validatedDeclarations(): Scopes { + return this.declarations; + } + + /** + * enterScope creates a new Env instance with a new innermost declaration scope. + */ + enterScope(): CelCheckerEnv { + return new _CelCheckerEnv( + this.namespace, + this.registry, + this.declarations.push(), + this.aggregateLiteralElementType, + this.filteredOverloadIds, + ); + } + + /** + * exitScope creates a new Env instance with the nearest outer declaration scope. + */ + exitScope(): CelCheckerEnv { + return new _CelCheckerEnv( + this.namespace, + this.registry, + this.declarations.pop(), + this.aggregateLiteralElementType, + this.filteredOverloadIds, + ); + } +} diff --git a/packages/cel/src/checker/mapping.ts b/packages/cel/src/checker/mapping.ts new file mode 100644 index 00000000..3a8c80f9 --- /dev/null +++ b/packages/cel/src/checker/mapping.ts @@ -0,0 +1,35 @@ +// Copyright 2024-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { CelType } from "../type.js"; + +export class Mapping { + #mapping: Map; + + constructor(mapping: Map = new Map()) { + this.#mapping = mapping; + } + + add(from: CelType, to: CelType): void { + this.#mapping.set(from.toString(), to); + } + + find(from: CelType): CelType | undefined { + return this.#mapping.get(from.toString()); + } + + copy(): Mapping { + return new Mapping(new Map(this.#mapping)); + } +} diff --git a/packages/cel/src/checker/scopes.ts b/packages/cel/src/checker/scopes.ts new file mode 100644 index 00000000..c94df0be --- /dev/null +++ b/packages/cel/src/checker/scopes.ts @@ -0,0 +1,112 @@ +// Copyright 2024-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { CelFunc } from "../func.js"; +import type { CelIdent } from "../ident.js"; + +/** + * Group is a set of Decls that is pushed on or popped off a Scopes as a unit. + * Contains separate namespaces for identifier and function Decls. + */ +export class Group { + constructor( + public readonly idents: Map = new Map(), + public readonly functions: Map = new Map(), + ) {} + + /** + * Creates a new Group instance with a shallow copy of the variables and functions. + */ + copy(): Group { + return new Group(new Map(this.idents), new Map(this.functions)); + } +} + +/** + * Scopes represents nested Decl sets where the Scopes value contains a Groups containing all + * identifiers in scope and an optional parent representing outer scopes. + * Each Groups value is a mapping of names to Decls in the ident and function namespaces. + * Lookups are performed such that bindings in inner scopes shadow those in outer scopes. + */ +export class Scopes { + constructor( + public readonly scopes = new Group(), + public readonly parent?: Scopes, + ) { + this.scopes = scopes; + this.parent = parent; + } + + /** + * Creates a copy of the current Scopes values, including a copy of its parent if present. + */ + copy(): Scopes { + return new Scopes(this.scopes.copy(), this.parent?.copy()); + } + + /** + * Creates a new Scopes value which references the current Scope as its parent. + */ + push(): Scopes { + return new Scopes(new Group(), this); + } + + /** + * Returns the parent Scopes value for the current scope, or the current scope if the parent is undefined. + */ + pop(): Scopes { + return this.parent ?? this; + } + + /** + * Adds the ident in the current scope. + */ + addIdent(ident: CelIdent): void { + this.scopes.idents.set(ident.name, ident); + } + + /** + * Finds the first ident with a matching name in Scopes, or undefined if one cannot be found. + */ + findIdent(name: string): CelIdent | undefined { + if (this.scopes.idents.has(name)) { + return this.scopes.idents.get(name); + } + return this.parent?.findIdent(name); + } + + /** + * Finds the first ident Decl with a matching name in the current Scopes value, or undefined if one cannot be found. + */ + findIdentInScope(name: string): CelIdent | undefined { + return this.scopes.idents.get(name); + } + + /** + * Adds the function in the current scope. + */ + setFunction(func: CelFunc): void { + this.scopes.functions.set(func.name, func); + } + + /** + * Finds the first function with a matching name in Scopes, or undefined if one cannot be found. + */ + findFunction(name: string): CelFunc | undefined { + if (this.scopes.functions.has(name)) { + return this.scopes.functions.get(name); + } + return this.parent?.findFunction(name); + } +} diff --git a/packages/cel/src/checker/types.ts b/packages/cel/src/checker/types.ts new file mode 100644 index 00000000..4b210ad9 --- /dev/null +++ b/packages/cel/src/checker/types.ts @@ -0,0 +1,506 @@ +// Copyright 2024-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Type_PrimitiveType, + TypeSchema, +} from "@bufbuild/cel-spec/cel/expr/checked_pb.js"; +import type { Type } from "@bufbuild/cel-spec/cel/expr/checked_pb.js"; +import { + CelScalar, + isAssignableType, + isDynCelType, + isDynOrErrorCelType, + isExactCelType, + listType, + mapType, + opaqueType, + typeTypeWithParam, +} from "../type.js"; +import type { + CelListType, + CelMapType, + CelOpaqueType, + CelType, + mapKeyType, +} from "../type.js"; +import type { Mapping } from "./mapping.js"; +import { create } from "@bufbuild/protobuf"; +import { NullValue } from "@bufbuild/protobuf/wkt"; + +/** + * isEqualOrLessSpecific checks whether one type is equal or less specific than the other one. + * A type is less specific if it matches the other type using the DYN type. + */ +function isEqualOrLessSpecific(t1: CelType, t2: CelType): boolean { + // The first type is less specific. + if (isDynCelType(t1) || t1.kind === "type") { + return true; + } + // The first type is not less specific. + if (isDynCelType(t2) || t2.kind === "type") { + return false; + } + // Types must be of the same kind to be equal. + if (t1.kind != t2.kind) { + return false; + } + + // With limited exceptions for ANY and JSON values, the types must agree and be equivalent in + // order to return true. + switch (t1.kind) { + case "opaque": + if ( + t1.name !== (t2 as CelOpaqueType).name || + t1.parameters.length !== (t2 as CelOpaqueType).parameters.length + ) { + return false; + } + for (let i = 0; i < t1.parameters.length; i++) { + if ( + !isEqualOrLessSpecific( + t1.parameters[i], + (t2 as CelOpaqueType).parameters[i], + ) + ) { + return false; + } + } + return true; + case "list": + return isEqualOrLessSpecific(t1.element, (t2 as CelListType).element); + case "map": + return ( + isEqualOrLessSpecific(t1.key, (t2 as CelMapType).key) && + isEqualOrLessSpecific(t1.value, (t2 as CelMapType).value) + ); + case "scalar": + return t1.scalar === "type"; + default: + return isExactCelType(t1, t2); + } +} + +function internalIsAssignable(m: Mapping, t1: CelType, t2: CelType): boolean { + // Process type parameters. + const kind1 = t1.kind; + const kind2 = t2.kind; + + if (kind2 === "type_param") { + // If t2 is a valid type substitution for t1, return true. + const [valid, t2HasSub] = isValidTypeSubstitution(m, t1, t2); + if (valid) { + return true; + } + // If t2 is not a valid type sub for t1, and already has a known substitution return false + // since it is not possible for t1 to be a substitution for t2. + if (!valid && t2HasSub) { + return false; + } + // Otherwise, fall through to check whether t1 is a possible substitution for t2. + } + if (kind1 === "type_param") { + // Return whether t1 is a valid substitution for t2. If not, do no additional checks as the + // possible type substitutions have been searched in both directions. + const [valid, _] = isValidTypeSubstitution(m, t2, t1); + return valid; + } + // Next check for wildcard types. + if (isDynOrErrorCelType(t1) || isDynOrErrorCelType(t2)) { + return true; + } + // Preserve the nullness checks of the legacy type-checker. + if (t1.toString() === CelScalar.NULL.toString()) { + return internalIsAssignableNull(t2); + } + if (t2.toString() === CelScalar.NULL.toString()) { + return internalIsAssignableNull(t1); + } + // Test for when the types do not need to agree, but are more specific than dyn. + switch (kind1) { + case "scalar": + case "object": + // Test whether t2 is assignable from t1. The order of this check won't usually matter; + // however, there may be cases where type capabilities are expanded beyond what is supported + // in the current common/types package. For example, an interface designation for a group of + // Struct types. + return isAssignableType(t2, t1); + case "type": + return kind2 === "type"; + case "opaque": + return ( + t1.kind == t2.kind && + t1.name == t2.name && + internalIsAssignableList(m, t1.parameters, t2.parameters) + ); + case "list": + if (kind2 !== "list") { + return false; + } + return internalIsAssignable(m, t1.element, (t2 as CelListType).element); + case "map": + if (kind2 !== "map") { + return false; + } + return ( + internalIsAssignable(m, t1.key, (t2 as CelMapType).key) && + internalIsAssignable(m, t1.value, (t2 as CelMapType).value) + ); + default: + return false; + } +} + +/** + * isValidTypeSubstitution returns whether t2 (or its type substitution) is a valid type + * substitution for t1, and whether t2 has a type substitution in mapping m. + * + * The type t2 is a valid substitution for t1 if any of the following statements is true + * - t2 has a type substitution (t2sub) equal to t1 + * - t2 has a type substitution (t2sub) assignable to t1 + * - t2 does not occur within t1. + */ +function isValidTypeSubstitution( + m: Mapping, + t1: CelType, + t2: CelType, +): [boolean, boolean] { + // Early return if the t1 and t2 are the same instance. + const kind1 = t1.kind; + const kind2 = t2.kind; + if (kind1 === kind2 && isExactCelType(t1, t2)) { + return [true, true]; + } + const t2Sub = m.find(t2); + if (t2Sub) { + // Early return if t1 and t2Sub are the same instance as otherwise the mapping + // might mark a type as being a subtitution for itself. + if (kind1 == t2Sub.kind && isExactCelType(t1, t2Sub)) { + return [true, true]; + } + // If the types are compatible, pick the more general type and return true + if (internalIsAssignable(m, t1, t2Sub)) { + const t2New = mostGeneral(t1, t2Sub); + // only update the type reference map if the target type does not occur within it. + if (notReferencedIn(m, t2, t2New)) { + m.add(t2, t2New); + } + // acknowledge the type agreement, and that the substitution is already tracked. + return [true, true]; + } + return [false, true]; + } + if (notReferencedIn(m, t2, t1)) { + m.add(t2, t1); + return [true, false]; + } + return [false, false]; +} + +/** + * internalIsAssignableList returns true if the element types at each index in the list are + * assignable from l1[i] to l2[i]. The list lengths must also agree for the lists to be + * assignable. + */ +function internalIsAssignableList( + m: Mapping, + l1: CelType[], + l2: CelType[], +): boolean { + if (l1.length !== l2.length) { + return false; + } + for (let i = 0; i < l1.length; i++) { + if (!internalIsAssignable(m, l1[i], l2[i])) { + return false; + } + } + return true; +} + +/** + * internalIsAssignableNull returns true if the type is nullable. + */ +function internalIsAssignableNull(t: CelType): boolean { + return isLegacyNullable(t) || isAssignableType(CelScalar.NULL, t); +} + +/** + * isLegacyNullable preserves the null-ness compatibility of the original type-checker implementation. + */ +function isLegacyNullable(t: CelType): boolean { + switch (t.kind) { + case "opaque": + case "object": + // TODO; go calls out "any", "timestamp", and "duration" as nullable types. + return true; + default: + return false; + } +} + +/** + * isAssignable returns an updated type substitution mapping if t1 is assignable to t2. + */ +export function isAssignable( + m: Mapping, + t1: CelType, + t2: CelType, +): Mapping | undefined { + const mCopy = m.copy(); + if (internalIsAssignable(mCopy, t1, t2)) { + return mCopy; + } + return undefined; +} + +/** + * isAssignableList returns an updated type substitution mapping if l1 is assignable to l2. + */ +export function isAssignableList( + m: Mapping, + l1: CelType[], + l2: CelType[], +): Mapping | undefined { + const mCopy = m.copy(); + if (internalIsAssignableList(mCopy, l1, l2)) { + return mCopy; + } + return undefined; +} + +/** + * mostGeneral returns the more general of two types which are known to unify. + */ +export function mostGeneral(t1: CelType, t2: CelType): CelType { + if (isEqualOrLessSpecific(t1, t2)) { + return t1; + } + return t2; +} + +/** + * notReferencedIn checks whether the type doesn't appear directly or transitively within the other + * type. This is a standard requirement for type unification, commonly referred to as the "occurs + * check". + */ +function notReferencedIn(m: Mapping, t: CelType, withinType: CelType): boolean { + if (isExactCelType(t, withinType)) { + return false; + } + switch (withinType.kind) { + case "type_param": + const wtSub = m.find(withinType); + if (!wtSub) { + return true; + } + return notReferencedIn(m, t, wtSub); + case "opaque": + for (const pt of withinType.parameters) { + if (!notReferencedIn(m, t, pt)) { + return false; + } + } + return true; + case "list": + return notReferencedIn(m, t, withinType.element); + case "map": + return ( + notReferencedIn(m, t, withinType.key) && + notReferencedIn(m, t, withinType.value) + ); + case "type": + if (withinType.type) { + return notReferencedIn(m, t, withinType.type); + } + return true; + default: + return true; + } +} + +/** + * substitute replaces all direct and indirect occurrences of bound type parameters. Unbound type + * parameters are replaced by DYN if typeParamToDyn is true. + */ +export function substitute( + m: Mapping, + t: CelType, + typeParamToDyn: boolean, +): CelType { + const tSub = m.find(t); + if (tSub) { + return substitute(m, tSub, typeParamToDyn); + } + if (typeParamToDyn && t.kind === "type_param") { + return CelScalar.DYN; + } + switch (t.kind) { + case "opaque": + return opaqueType( + t.name, + substituteParams(m, t.parameters, typeParamToDyn), + ); + case "list": + return listType(substitute(m, t.element, typeParamToDyn)); + case "map": + return mapType( + substitute(m, t.key, typeParamToDyn) as mapKeyType, + substitute(m, t.value, typeParamToDyn), + ); + case "type": + if (t.type) { + return typeTypeWithParam(substitute(m, t.type, typeParamToDyn)); + } + return t; + default: + return t; + } +} + +function substituteParams( + m: Mapping, + typeParams: CelType[], + typeParamToDyn: boolean, +): CelType[] { + const subParams: CelType[] = []; + for (let i = 0; i < typeParams.length; i++) { + subParams.push(substitute(m, typeParams[i], typeParamToDyn)); + } + return subParams; +} + +export function functionType( + resultType: CelType, + ...argTypes: CelType[] +): CelOpaqueType { + return opaqueType("function", [resultType, ...argTypes]); +} + +/** + * Converts a primitive CelType to its protobuf Type representation. + */ +function primitiveProtoType(type: Type_PrimitiveType): Type { + return create(TypeSchema, { + typeKind: { + case: "primitive", + value: type, + }, + }); +} + +/** + * Converts a CelType to its protobuf Type representation. + */ +export function celTypeToProtoType(t: CelType): Type { + switch (t.kind) { + case "error": + return create(TypeSchema, { + typeKind: { + case: "error", + // TODO: should we include more info here? + value: {}, + }, + }); + case "list": + return create(TypeSchema, { + typeKind: { + case: "listType", + value: { + elemType: celTypeToProtoType(t.element), + }, + }, + }); + case "map": + return create(TypeSchema, { + typeKind: { + case: "mapType", + value: { + keyType: celTypeToProtoType(t.key), + valueType: celTypeToProtoType(t.value), + }, + }, + }); + case "object": + return create(TypeSchema, { + typeKind: { + case: "messageType", + value: t.name, + }, + }); + case "opaque": + return create(TypeSchema, { + typeKind: { + case: "abstractType", + value: { + name: t.name, + parameterTypes: t.parameters.map((pt) => celTypeToProtoType(pt)), + }, + }, + }); + case "scalar": + switch (t.scalar) { + case "bool": + return primitiveProtoType(Type_PrimitiveType.BOOL); + case "bytes": + return primitiveProtoType(Type_PrimitiveType.BYTES); + case "double": + return primitiveProtoType(Type_PrimitiveType.DOUBLE); + case "int": + return primitiveProtoType(Type_PrimitiveType.INT64); + case "string": + return primitiveProtoType(Type_PrimitiveType.STRING); + case "uint": + return primitiveProtoType(Type_PrimitiveType.UINT64); + case "null_type": + return create(TypeSchema, { + typeKind: { + case: "null", + value: NullValue.NULL_VALUE, + }, + }); + case "dyn": + return create(TypeSchema, { + typeKind: { + case: "dyn", + value: {}, + }, + }); + case "type": + // TODO: is this right? + return create(TypeSchema, { + typeKind: { + case: "typeParam", + value: t.name, + }, + }); + } + case "type": { + return create(TypeSchema, { + typeKind: { + case: "type", + value: celTypeToProtoType(t.type), + }, + }); + } + case "type_param": { + return create(TypeSchema, { + typeKind: { + case: "typeParam", + value: t.name, + }, + }); + } + } +} diff --git a/packages/cel/src/ext/strings/strings.ts b/packages/cel/src/ext/strings/strings.ts index 401042a0..228ad24b 100644 --- a/packages/cel/src/ext/strings/strings.ts +++ b/packages/cel/src/ext/strings/strings.ts @@ -29,6 +29,7 @@ import { isReflectMessage } from "@bufbuild/protobuf/reflect"; const charAt = celFunc("charAt", [ celOverload( + "string_char_at_int", [CelScalar.STRING, CelScalar.INT], CelScalar.STRING, (str, index) => { @@ -43,11 +44,13 @@ const charAt = celFunc("charAt", [ const indexOf = celFunc("indexOf", [ celOverload( + "string_index_of_string", [CelScalar.STRING, CelScalar.STRING], CelScalar.INT, (str, substr) => BigInt(str.indexOf(substr)), ), celOverload( + "string_index_of_string_int", [CelScalar.STRING, CelScalar.STRING, CelScalar.INT], CelScalar.INT, (str, substr, startN) => { @@ -62,11 +65,13 @@ const indexOf = celFunc("indexOf", [ const lastIndexOf = celFunc("lastIndexOf", [ celOverload( + "string_last_index_of_string", [CelScalar.STRING, CelScalar.STRING], CelScalar.INT, (str, substr) => BigInt(str.lastIndexOf(substr)), ), celOverload( + "string_last_index_of_string_int", [CelScalar.STRING, CelScalar.STRING, CelScalar.INT], CelScalar.INT, (str, substr, startN) => { @@ -80,34 +85,44 @@ const lastIndexOf = celFunc("lastIndexOf", [ ]); const lowerAscii = celFunc("lowerAscii", [ - celOverload([CelScalar.STRING], CelScalar.STRING, (str) => { - // Only lower case ascii characters. - let result = ""; - for (let i = 0; i < str.length; i++) { - const code = str.charCodeAt(i); - if (code >= 65 && code <= 90) { - result += String.fromCharCode(code + 32); - } else { - result += str.charAt(i); + celOverload( + "string_lower_ascii", + [CelScalar.STRING], + CelScalar.STRING, + (str) => { + // Only lower case ascii characters. + let result = ""; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code >= 65 && code <= 90) { + result += String.fromCharCode(code + 32); + } else { + result += str.charAt(i); + } } - } - return result; - }), + return result; + }, + ), ]); const upperAscii = celFunc("upperAscii", [ - celOverload([CelScalar.STRING], CelScalar.STRING, (str) => { - let result = ""; - for (let i = 0; i < str.length; i++) { - const c = str.charCodeAt(i); - if (c >= 97 && c <= 122) { - result += String.fromCharCode(c - 32); - } else { - result += str.charAt(i); + celOverload( + "string_upper_ascii", + [CelScalar.STRING], + CelScalar.STRING, + (str) => { + let result = ""; + for (let i = 0; i < str.length; i++) { + const c = str.charCodeAt(i); + if (c >= 97 && c <= 122) { + result += String.fromCharCode(c - 32); + } else { + result += str.charAt(i); + } } - } - return result; - }), + return result; + }, + ), ]); function replaceOp(str: string, substr: string, repl: string, num: number) { @@ -129,11 +144,13 @@ function replaceOp(str: string, substr: string, repl: string, num: number) { const replace = celFunc("replace", [ celOverload( + "string_replace_string_string", [CelScalar.STRING, CelScalar.STRING, CelScalar.STRING], CelScalar.STRING, (str, substr, repl) => replaceOp(str, substr, repl, str.length), ), celOverload( + "string_replace_string_string_int", [CelScalar.STRING, CelScalar.STRING, CelScalar.STRING, CelScalar.INT], CelScalar.STRING, (str, substr, repl, num) => replaceOp(str, substr, repl, Number(num)), @@ -149,11 +166,13 @@ function splitOp(str: string, sep: string, num?: number) { const split = celFunc("split", [ celOverload( + "string_split_string", [CelScalar.STRING, CelScalar.STRING], listType(CelScalar.STRING), splitOp, ), celOverload( + "string_split_string_int", [CelScalar.STRING, CelScalar.STRING, CelScalar.INT], listType(CelScalar.STRING), (str, sep, num) => splitOp(str, sep, Number(num)), @@ -183,8 +202,14 @@ function substringOp(str: string, start: bigint, end?: bigint) { } const substring = celFunc("substring", [ - celOverload([CelScalar.STRING, CelScalar.INT], CelScalar.STRING, substringOp), celOverload( + "string_substring_int", + [CelScalar.STRING, CelScalar.INT], + CelScalar.STRING, + substringOp, + ), + celOverload( + "string_substring_int_int", [CelScalar.STRING, CelScalar.INT, CelScalar.INT], CelScalar.STRING, substringOp, @@ -199,7 +224,7 @@ const WHITE_SPACE = new Set([ ]); const trim = celFunc("trim", [ - celOverload([CelScalar.STRING], CelScalar.STRING, (str) => { + celOverload("string_trim", [CelScalar.STRING], CelScalar.STRING, (str) => { // Trim using the unicode white space definition. let start = 0; let end = str.length - 1; @@ -229,8 +254,9 @@ function joinOp(list: CelList, sep = "") { } const join = celFunc("join", [ - celOverload([listType(CelScalar.DYN)], CelScalar.STRING, joinOp), + celOverload("list_join", [listType(CelScalar.DYN)], CelScalar.STRING, joinOp), celOverload( + "list_join_string", [listType(CelScalar.DYN), CelScalar.STRING], CelScalar.STRING, joinOp, @@ -251,7 +277,7 @@ const QUOTE_MAP: Map = new Map([ ]); const quote = celFunc("strings.quote", [ - celOverload([CelScalar.STRING], CelScalar.STRING, (str) => { + celOverload("strings_quote", [CelScalar.STRING], CelScalar.STRING, (str) => { let result = '"'; for (let i = 0; i < str.length; i++) { const c = str.charCodeAt(i); @@ -563,6 +589,7 @@ function formatImpl(format: string, args: CelList) { const format = celFunc("format", [ celOverload( + "string_format", [CelScalar.STRING, listType(CelScalar.DYN)], CelScalar.STRING, formatImpl, diff --git a/packages/cel/src/func.ts b/packages/cel/src/func.ts index 27bd33ef..fd115efa 100644 --- a/packages/cel/src/func.ts +++ b/packages/cel/src/func.ts @@ -20,6 +20,8 @@ import { isCelType, type CelValue, type CelInput, + isAssignableType, + isEquivalentCelType, } from "./type.js"; import { type CelResult, @@ -74,6 +76,10 @@ export function celFunc( */ export interface CelOverload

{ [privateOverloadSymbol]: unknown; + /** + * Identifier of the overload. + */ + readonly id: string; /** * Array of parameter types. */ @@ -86,6 +92,10 @@ export interface CelOverload

{ * Implementation for this overload. */ readonly impl: (...args: CelValueTuple

) => CelInput; + /** + * Whether this is a member function overload. + */ + readonly isMemberFunction: boolean; } /** @@ -95,11 +105,27 @@ export function celOverload< const P extends readonly CelType[], const R extends CelType, >( + id: string, + parameters: P, + result: R, + impl: (...args: CelValueTuple

) => CelInput, +): CelOverload { + return new FuncOverload(id, parameters, result, impl, false); +} + +/** + * Creates a new member function CelOverload. + */ +export function celMemberOverload< + const P extends readonly CelType[], + const R extends CelType, +>( + id: string, parameters: P, result: R, impl: (...args: CelValueTuple

) => CelInput, ): CelOverload { - return new FuncOverload(parameters, result, impl); + return new FuncOverload(id, parameters, result, impl, true); } class Func implements CelFunc { @@ -152,11 +178,16 @@ class FuncOverload { [privateOverloadSymbol] = {}; constructor( + private readonly _id: string, private readonly _parameters: P, private readonly _result: R, private readonly _impl: (...args: CelValueTuple

) => CelInput, + private readonly _isMemberFunction: boolean, ) {} + get id() { + return this._id; + } get parameters() { return this._parameters; } @@ -166,6 +197,41 @@ class FuncOverload get impl() { return this._impl; } + get isMemberFunction() { + return this._isMemberFunction; + } +} + +function collectParamNames(paramNames: string[], arg: CelType) { + switch (arg.kind) { + case "type_param": + paramNames.push(arg.name); + break; + case "list": + collectParamNames(paramNames, arg.element); + break; + case "map": + collectParamNames(paramNames, arg.key); + collectParamNames(paramNames, arg.value); + break; + default: + break; + } +} + +/** + * TypeParams returns the type parameter names associated with the overload. + */ +export function overloadTypeParams< + P extends readonly CelType[], + R extends CelType, +>(func: CelOverload): string[] { + const typeNames: string[] = []; + collectParamNames(typeNames, func.result); + for (const paramType of func.parameters) { + collectParamNames(typeNames, paramType); + } + return typeNames; } /** @@ -173,6 +239,7 @@ class FuncOverload */ export class FuncRegistry implements Dispatcher { private functions = new Map(); + private functionDeclarations = new Map(); constructor(funcs?: CelFunc[]) { funcs && this.add(funcs); @@ -198,6 +265,7 @@ export class FuncRegistry implements Dispatcher { return; } call = nameOrFunc; + this.functionDeclarations.set(nameOrFunc.name, nameOrFunc); nameOrFunc = nameOrFunc.name; } if (call === undefined) { @@ -219,6 +287,10 @@ export class FuncRegistry implements Dispatcher { } this.functions.set(name, call); } + + get declarations(): CelFunc[] { + return Array.from(this.functionDeclarations.values()); + } } export class OrderedDispatcher implements Dispatcher { @@ -291,3 +363,122 @@ function unwrapResults(args: CelResult[]) { } return vals; } + +/** + * SignatureOverlaps indicates whether two functions have non-equal, but overloapping function signatures. + * + * For example, list(dyn) collides with list(string) since the 'dyn' type can contain a 'string' type. + */ +export function overloadSignatureOverlaps< + P extends readonly CelType[], + R extends CelType, +>(overload: CelOverload, other: CelOverload): boolean { + if ( + overload.isMemberFunction !== other.isMemberFunction || + overload.parameters.length !== other.parameters.length + ) { + return false; + } + let argsOverlap = true; + for (let i = 0; i < overload.parameters.length; i++) { + const argType = overload.parameters[i]; + const otherArgType = other.parameters[i]; + argsOverlap = + argsOverlap && + (isAssignableType(argType, otherArgType) || + isAssignableType(otherArgType, argType)); + } + return argsOverlap; +} + +/** + * SignatureEquals determines whether the incoming overload declaration signature is equal to the current signature. + * + * Result type, operand trait, and strict-ness are not considered as part of signature equality. + */ +export function overloadSignatureEquals< + P extends readonly CelType[], + R extends CelType, +>(overload: CelOverload, other: CelOverload): boolean { + if (overload === other) { + return true; + } + if ( + overload.id !== other.id || + overload.isMemberFunction !== other.isMemberFunction || + overload.parameters.length !== other.parameters.length + ) { + return false; + } + for (let i = 0; i < overload.parameters.length; i++) { + const argType = overload.parameters[i]; + const otherArgType = other.parameters[i]; + if (!isEquivalentCelType(argType, otherArgType)) { + return false; + } + } + return isEquivalentCelType(overload.result, other.result); +} + +/** + * AddOverload ensures that the new overload does not collide with an existing overload signature; + * however, if the function signatures are identical, the implementation may be rewritten as its + * difficult to compare functions by object identity. + */ +export function addOverloadToFunc( + func: CelFunc, + overload: CelOverload, +): void { + for (let i = 0; i < func.overloads.length; i += 1) { + const o = func.overloads[i]; + if (o.id !== overload.id && overloadSignatureOverlaps(o, overload)) { + throw new Error( + `overload signature collision in function ${func.name}: ${o.id} collides with ${overload.id}`, + ); + } + if (o.id === overload.id) { + if (overloadSignatureEquals(o, overload)) { + // Allow redefinition of an overload implementation so long as the signatures match. + func.overloads[i] = overload; + // TODO: Allow redefinition of the doc string. + // if len(overload.doc) != 0 && o.doc != overload.doc { + // o.doc = overload.doc + // } + return; + } + throw new Error( + `overload redefinition in function. ${func.name}: ${o.id} has multiple definitions`, + ); + } + } + func.overloads.push(overload); +} + +/** + * Merge combines an existing function declaration with another. + * + * If a function is extended, by say adding new overloads to an existing function, then it is merged with the + * prior definition of the function at which point its overloads must not collide with pre-existing overloads + * and its bindings (singleton, or per-overload) must not conflict with previous definitions either. + */ +export function mergeFuncs(f: CelFunc, other: CelFunc): CelFunc { + if (f === other) { + return f; + } + if (f.name !== other.name) { + throw new Error( + `cannot merge unrelated functions. ${f.name} and ${other.name}`, + ); + } + const merged = celFunc(f.name, []); + + // baseline copy of the overloads + for (const overload of f.overloads) { + merged.overloads.push(overload); + } + // add new overloads + for (const overload of other.overloads) { + addOverloadToFunc(merged, overload); + } + return merged; +} diff --git a/packages/cel/src/ident.ts b/packages/cel/src/ident.ts new file mode 100644 index 00000000..83287bda --- /dev/null +++ b/packages/cel/src/ident.ts @@ -0,0 +1,98 @@ +// Copyright 2024-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + type CelValue, + type CelType, + isEquivalentCelType, + typeTypeWithParam, +} from "./type.js"; + +const privateIdentSymbol = Symbol.for("@bufbuild/cel/ident"); + +/** + * A CEL ident definition. + */ +export interface CelIdent { + [privateIdentSymbol]: unknown; + + readonly name: string; + + readonly type: CelType; + + readonly value?: CelValue; + + readonly doc?: string; +} + +/** + * Creates a new CelVariable. + */ +export function celVariable( + name: string, + type: CelType, + doc?: string, +): CelIdent { + return new Ident(name, type, undefined, doc); +} + +/** + * Creates a new type identifier + */ +export function celTypeVariable(type: CelType, doc?: string): CelIdent { + return new Ident(type.name, typeTypeWithParam(type), undefined, doc); +} + +/** + * Creates a new CelConstant. + */ +export function celConstant( + name: string, + type: CelType, + value: CelValue, + doc?: string, +): CelIdent { + return new Ident(name, type, value, doc); +} + +class Ident implements CelIdent { + [privateIdentSymbol]: unknown; + + constructor( + public readonly name: string, + public readonly type: CelType, + public readonly value?: CelValue, + public readonly doc?: string, + ) {} +} + +/** + * DeclarationIsEquivalent returns true if one variable declaration has the + * same name and same type as the input. + */ +export function identDeclarationIsEquivalent( + ident: CelIdent, + other: CelIdent, +): boolean { + if (ident === other) { + return true; + } + // If either type is undefined, we cannot be equivalent. + if (!ident.type || !other.type) { + return false; + } + return ( + ident.name === other.name && isEquivalentCelType(ident.type, other.type) + ); +} diff --git a/packages/cel/src/namespace.ts b/packages/cel/src/namespace.ts index 10fc5131..72e7b968 100644 --- a/packages/cel/src/namespace.ts +++ b/packages/cel/src/namespace.ts @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import type { Expr } from "@bufbuild/cel-spec/cel/expr/syntax_pb.js"; + export class Namespace { private readonly _name: string; private readonly _aliases: Map; @@ -76,3 +78,26 @@ export class Namespace { return alias + qualifier; } } + +/** + * ToQualifiedName converts an expression AST into a qualified name if possible, with a boolean + * 'found' value that indicates if the conversion is successful. + */ +export function toQualifiedName(e: Expr): [string, boolean] { + switch (e.exprKind.case) { + case "identExpr": + return [e.exprKind.value.name, true]; + case "selectExpr": + const sel = e.exprKind.value; + // Test only expressions are not valid as qualified names. + if (sel.testOnly || !sel.operand) { + return ["", false]; + } + const [qual, found] = toQualifiedName(sel.operand); + if (found) { + return [qual + "." + sel.field, true]; + } + break; + } + return ["", false]; +} diff --git a/packages/cel/src/referenceinfo.test.ts b/packages/cel/src/referenceinfo.test.ts new file mode 100644 index 00000000..7b790e7b --- /dev/null +++ b/packages/cel/src/referenceinfo.test.ts @@ -0,0 +1,104 @@ +// Copyright 2024-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from "node:assert/strict"; +import { suite, test } from "node:test"; +import { ADD_BYTES, ADD_DOUBLE } from "./gen/dev/cel/expr/overload_const.js"; +import { functionReference, identReference } from "./referenceinfo.js"; +import { toCel } from "./value.js"; + +void suite("ReferenceInfo", () => { + void test("equals", () => { + const testCases = [ + { + name: "single overload equal", + a: functionReference([ADD_BYTES]), + b: functionReference([ADD_BYTES]), + equal: true, + }, + { + name: "single overload not equal", + a: functionReference([ADD_BYTES]), + b: functionReference([ADD_DOUBLE]), + equal: false, + }, + { + name: "single and multiple overload not equal", + a: functionReference([ADD_BYTES]), + b: functionReference([ADD_BYTES, ADD_DOUBLE]), + equal: false, + }, + { + name: "multiple overloads equal", + a: functionReference([ADD_BYTES, ADD_DOUBLE]), + b: functionReference([ADD_DOUBLE, ADD_BYTES]), + equal: true, + }, + { + name: "identifier reference equal", + a: identReference("BYTES"), + b: identReference("BYTES"), + equal: true, + }, + { + name: "identifier reference not equal", + a: identReference("BYTES"), + b: identReference("TRUE"), + equal: false, + }, + { + name: "identifier and constant reference not equal", + a: identReference("BYTES"), + b: identReference("BYTES", toCel(new TextEncoder().encode("bytes"))), + equal: false, + }, + { + name: "constant references equal", + a: identReference("BYTES", toCel(new TextEncoder().encode("bytes"))), + b: identReference("BYTES", toCel(new TextEncoder().encode("bytes"))), + equal: true, + }, + { + name: "constant references not equal", + a: identReference("BYTES", toCel(new TextEncoder().encode("bytes"))), + b: identReference( + "BYTES", + toCel(new TextEncoder().encode("bytes-other")), + ), + equal: false, + }, + { + name: "constant and overload reference not equal", + a: identReference("BYTES", toCel(new TextEncoder().encode("bytes"))), + b: functionReference([ADD_DOUBLE, ADD_BYTES]), + equal: false, + }, + ]; + for (const tc of testCases) { + assert.equal( + tc.a.equals(tc.b), + tc.equal, + `unexpected equality for ${tc.name}`, + ); + } + }); + + void test("add overload", () => { + const add = functionReference([ADD_BYTES]); + add.addOverload(ADD_DOUBLE); + assert.equal(functionReference([ADD_BYTES, ADD_DOUBLE]).equals(add), true); + add.addOverload(ADD_DOUBLE); + assert.equal(functionReference([ADD_BYTES, ADD_DOUBLE]).equals(add), true); + }); +}); diff --git a/packages/cel/src/referenceinfo.ts b/packages/cel/src/referenceinfo.ts new file mode 100644 index 00000000..bb6161b8 --- /dev/null +++ b/packages/cel/src/referenceinfo.ts @@ -0,0 +1,82 @@ +// Copyright 2024-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { equals } from "./equals.js"; +import type { CelValue } from "./type.js"; + +/** + * ReferenceInfo contains a CEL native representation of an identifier + * reference which may refer to either a qualified identifier name, a set of + * overload ids, or a constant value from an enum. + */ +export class ReferenceInfo { + constructor( + public readonly name?: string, + public readonly value?: CelValue, + public readonly overloadIds: Set = new Set(), + ) {} + + /** + * AddOverload appends a function overload ID to the ReferenceInfo. + */ + addOverload(id: string) { + this.overloadIds.add(id); + } + + /** + * Equals returns whether two references are identical to each other. + */ + equals(other: ReferenceInfo) { + if (this.name !== other.name) { + return false; + } + if (this.overloadIds.size !== other.overloadIds.size) { + return false; + } + const otherOverloads = other.overloadIds; + for (const overload of this.overloadIds) { + if (!otherOverloads.has(overload)) { + return false; + } + } + if (this.value && !other.value) { + return false; + } + if (other.value && !this.value) { + return false; + } + if (this.value && other.value && !equals(this.value, other.value)) { + return false; + } + return true; + } +} + +/** + * identReference creates a ReferenceInfo instance for an identifier with an + * optional constant value. + */ +export function identReference(name: string, value?: CelValue): ReferenceInfo { + return new ReferenceInfo(name, value); +} + +/** + * functionReference creates a ReferenceInfo instance for a set of function + * overloads. + */ +export function functionReference( + overloadIds: Set | string[], +): ReferenceInfo { + return new ReferenceInfo("", undefined, new Set(overloadIds)); +} diff --git a/packages/cel/src/std/cast.ts b/packages/cel/src/std/cast.ts index b80711e7..27d824b6 100644 --- a/packages/cel/src/std/cast.ts +++ b/packages/cel/src/std/cast.ts @@ -37,6 +37,7 @@ import { import { celUint } from "../uint.js"; import { getMsgDesc } from "../eval.js"; import { parseDuration } from "../duration.js"; +import * as olc from "../gen/dev/cel/expr/overload_const.js"; const encoder = new TextEncoder(); @@ -52,35 +53,35 @@ const TYPE = "type"; const DYN = "dyn"; const intFunc = celFunc(INT, [ - celOverload([CelScalar.INT], CelScalar.INT, (x) => x), - celOverload([CelScalar.UINT], CelScalar.INT, (x) => { + celOverload(olc.INT_TO_INT, [CelScalar.INT], CelScalar.INT, (x) => x), + celOverload(olc.UINT_TO_INT, [CelScalar.UINT], CelScalar.INT, (x) => { const val = x.value; if (isOverflowInt(val)) { throw overflow(INT, CelScalar.INT); } return x.value; }), - celOverload([CelScalar.DOUBLE], CelScalar.INT, (x) => { + celOverload(olc.DOUBLE_TO_INT, [CelScalar.DOUBLE], CelScalar.INT, (x) => { if (isOverflowIntNum(x)) { throw overflow(INT, CelScalar.INT); } return BigInt(Math.trunc(x)); }), - celOverload([CelScalar.STRING], CelScalar.INT, (x) => { + celOverload(olc.STRING_TO_INT, [CelScalar.STRING], CelScalar.INT, (x) => { const val = BigInt(x); if (isOverflowInt(val)) { throw overflow(INT, CelScalar.INT); } return val; }), - celOverload([TIMESTAMP_TYPE], CelScalar.INT, (x) => { + celOverload(olc.TIMESTAMP_TO_INT, [TIMESTAMP_TYPE], CelScalar.INT, (x) => { const val = x.message.seconds; if (isOverflowInt(val)) { throw overflow(INT, CelScalar.INT); } return BigInt(val); }), - celOverload([DURATION_TYPE], CelScalar.INT, (x) => { + celOverload(olc.DURATION_TO_INT, [DURATION_TYPE], CelScalar.INT, (x) => { const val = x.message.seconds; if (isOverflowInt(val)) { throw overflow(INT, CelScalar.INT); @@ -90,20 +91,20 @@ const intFunc = celFunc(INT, [ ]); const uintFunc = celFunc(UINT, [ - celOverload([CelScalar.UINT], CelScalar.UINT, (x) => x), - celOverload([CelScalar.INT], CelScalar.UINT, (x) => { + celOverload(olc.UINT_TO_UINT, [CelScalar.UINT], CelScalar.UINT, (x) => x), + celOverload(olc.INT_TO_UINT, [CelScalar.INT], CelScalar.UINT, (x) => { if (isOverflowUint(x)) { throw overflow(UINT, CelScalar.UINT); } return celUint(x); }), - celOverload([CelScalar.DOUBLE], CelScalar.UINT, (x) => { + celOverload(olc.DOUBLE_TO_UINT, [CelScalar.DOUBLE], CelScalar.UINT, (x) => { if (isOverflowUintNum(x)) { throw overflow(UINT, CelScalar.UINT); } return celUint(BigInt(Math.trunc(x))); }), - celOverload([CelScalar.STRING], CelScalar.UINT, (x) => { + celOverload(olc.STRING_TO_UINT, [CelScalar.STRING], CelScalar.UINT, (x) => { const val = BigInt(x); if (isOverflowUint(val)) { throw overflow(UINT, CelScalar.UINT); @@ -113,15 +114,26 @@ const uintFunc = celFunc(UINT, [ ]); const doubleFunc = celFunc(DOUBLE, [ - celOverload([CelScalar.DOUBLE], CelScalar.DOUBLE, (x) => x), - celOverload([CelScalar.INT], CelScalar.DOUBLE, (x) => Number(x)), - celOverload([CelScalar.UINT], CelScalar.DOUBLE, (x) => Number(x.value)), - celOverload([CelScalar.STRING], CelScalar.DOUBLE, (x) => Number(x)), + celOverload( + olc.DOUBLE_TO_DOUBLE, + [CelScalar.DOUBLE], + CelScalar.DOUBLE, + (x) => x, + ), + celOverload(olc.INT_TO_DOUBLE, [CelScalar.INT], CelScalar.DOUBLE, (x) => + Number(x), + ), + celOverload(olc.DOUBLE_TO_UINT, [CelScalar.UINT], CelScalar.DOUBLE, (x) => + Number(x.value), + ), + celOverload(olc.STRING_TO_DOUBLE, [CelScalar.STRING], CelScalar.DOUBLE, (x) => + Number(x), + ), ]); const boolFunc = celFunc(BOOL, [ - celOverload([CelScalar.BOOL], CelScalar.BOOL, (x) => x), - celOverload([CelScalar.STRING], CelScalar.BOOL, (x) => { + celOverload(olc.BOOL_TO_BOOL, [CelScalar.BOOL], CelScalar.BOOL, (x) => x), + celOverload(olc.STRING_TO_BOOL, [CelScalar.STRING], CelScalar.BOOL, (x) => { switch (x) { case "true": case "True": @@ -142,19 +154,32 @@ const boolFunc = celFunc(BOOL, [ ]); const bytesFunc = celFunc(BYTES, [ - celOverload([CelScalar.BYTES], CelScalar.BYTES, (x) => x), - celOverload([CelScalar.STRING], CelScalar.BYTES, (x) => encoder.encode(x)), + celOverload(olc.BYTES_TO_BYTES, [CelScalar.BYTES], CelScalar.BYTES, (x) => x), + celOverload(olc.STRING_TO_BYTES, [CelScalar.STRING], CelScalar.BYTES, (x) => + encoder.encode(x), + ), ]); const stringFunc = celFunc(STRING, [ - celOverload([CelScalar.STRING], CelScalar.STRING, (x) => x), - celOverload([CelScalar.BOOL], CelScalar.STRING, (x) => + celOverload( + olc.STRING_TO_STRING, + [CelScalar.STRING], + CelScalar.STRING, + (x) => x, + ), + celOverload(olc.BOOL_TO_STRING, [CelScalar.BOOL], CelScalar.STRING, (x) => x ? "true" : "false", ), - celOverload([CelScalar.INT], CelScalar.STRING, (x) => x.toString()), - celOverload([CelScalar.UINT], CelScalar.STRING, (x) => x.value.toString()), - celOverload([CelScalar.DOUBLE], CelScalar.STRING, (x) => x.toString()), - celOverload([CelScalar.BYTES], CelScalar.STRING, (x) => { + celOverload(olc.INT_TO_STRING, [CelScalar.INT], CelScalar.STRING, (x) => + x.toString(), + ), + celOverload(olc.UINT_TO_STRING, [CelScalar.UINT], CelScalar.STRING, (x) => + x.value.toString(), + ), + celOverload(olc.DOUBLE_TO_STRING, [CelScalar.DOUBLE], CelScalar.STRING, (x) => + x.toString(), + ), + celOverload(olc.BYTES_TO_STRING, [CelScalar.BYTES], CelScalar.STRING, (x) => { const coder = new TextDecoder(undefined, { fatal: true }); try { return coder.decode(x); @@ -162,38 +187,61 @@ const stringFunc = celFunc(STRING, [ throw new Error(`Failed to decode bytes as string: ${e}`); } }), - celOverload([TIMESTAMP_TYPE], CelScalar.STRING, (x) => - toJson(TimestampSchema, x.message), + celOverload( + olc.TIMESTAMP_TO_STRING, + [TIMESTAMP_TYPE], + CelScalar.STRING, + (x) => toJson(TimestampSchema, x.message), ), - celOverload([DURATION_TYPE], CelScalar.STRING, (x) => + celOverload(olc.DURATION_TO_STRING, [DURATION_TYPE], CelScalar.STRING, (x) => toJson(DurationSchema, x.message), ), ]); const timestampFunc = celFunc(TIMESTAMP, [ - celOverload([TIMESTAMP_TYPE], TIMESTAMP_TYPE, (x) => x), - celOverload([CelScalar.STRING], TIMESTAMP_TYPE, (x) => { - try { - return fromJson(TimestampSchema, x); - } catch (e) { - throw new Error(`Failed to parse timestamp: ${e}`); - } - }), - celOverload([CelScalar.INT], TIMESTAMP_TYPE, (x) => + celOverload( + olc.TIMESTAMP_TO_TIMESTAMP, + [TIMESTAMP_TYPE], + TIMESTAMP_TYPE, + (x) => x, + ), + celOverload( + olc.STRING_TO_TIMESTAMP, + [CelScalar.STRING], + TIMESTAMP_TYPE, + (x) => { + try { + return fromJson(TimestampSchema, x); + } catch (e) { + throw new Error(`Failed to parse timestamp: ${e}`); + } + }, + ), + celOverload(olc.INT_TO_TIMESTAMP, [CelScalar.INT], TIMESTAMP_TYPE, (x) => timestampFromMs(Number(x)), ), ]); const durationFunc = celFunc(DURATION, [ - celOverload([DURATION_TYPE], DURATION_TYPE, (x) => x), - celOverload([CelScalar.STRING], DURATION_TYPE, parseDuration), - celOverload([CelScalar.INT], DURATION_TYPE, (x) => + celOverload( + olc.DURATION_TO_DURATION, + [DURATION_TYPE], + DURATION_TYPE, + (x) => x, + ), + celOverload( + olc.STRING_TO_DURATION, + [CelScalar.STRING], + DURATION_TYPE, + parseDuration, + ), + celOverload(olc.INT_TO_DURATION, [CelScalar.INT], DURATION_TYPE, (x) => create(DurationSchema, { seconds: x }), ), ]); const typeFunc = celFunc(TYPE, [ - celOverload([CelScalar.DYN], CelScalar.TYPE, (v) => { + celOverload(olc.TYPE_CONVERT_TYPE, [CelScalar.DYN], CelScalar.TYPE, (v) => { if (isMessage(v)) { return objectType(getMsgDesc(v.$typeName)); } @@ -202,7 +250,7 @@ const typeFunc = celFunc(TYPE, [ ]); const dynFunc = celFunc(DYN, [ - celOverload([CelScalar.DYN], CelScalar.DYN, (x) => x), + celOverload(olc.TO_DYN, [CelScalar.DYN], CelScalar.DYN, (x) => x), ]); export function addCasts(funcs: FuncRegistry) { diff --git a/packages/cel/src/std/logic.ts b/packages/cel/src/std/logic.ts index 371d852b..699ec261 100644 --- a/packages/cel/src/std/logic.ts +++ b/packages/cel/src/std/logic.ts @@ -16,7 +16,7 @@ import { type FuncRegistry, celFunc, celOverload, - type CallDispatch, + celMemberOverload, } from "../func.js"; import * as opc from "../gen/dev/cel/expr/operator_const.js"; import * as olc from "../gen/dev/cel/expr/overload_const.js"; @@ -31,82 +31,138 @@ import { } from "../type.js"; import { equals } from "../equals.js"; import type { CelMap } from "../map.js"; +import { listOfA, mapOfAB, paramA, paramB } from "./types.js"; /** * This is not in the spec but is part of at least go,java, and cpp implementations. * * It should return true for anything exept for the literal `false`. */ -const notStrictlyFalse: CallDispatch = { - dispatch(_, args) { - const raw = args[0]; - if (isCelError(raw)) { - return true; - } - return raw !== false; - }, +const notStrictlyFalse = celFunc(opc.NOT_STRICTLY_FALSE, [ + celOverload( + olc.NOT_STRICTLY_FALSE, + [CelScalar.BOOL], + CelScalar.BOOL, + (x) => true, // Irrelevant because we overwrite dispatch below + ), +]); +notStrictlyFalse.dispatch = (_, args) => { + const raw = args[0]; + if (isCelError(raw)) { + return true; + } + return raw !== false; }; const notFunc = celFunc(opc.LOGICAL_NOT, [ - celOverload([CelScalar.BOOL], CelScalar.BOOL, (x) => !x), + celOverload(olc.LOGICAL_NOT, [CelScalar.BOOL], CelScalar.BOOL, (x) => !x), ]); -const and: CallDispatch = { - dispatch(_id, args) { - let allBools = true; - const errors: CelError[] = []; - for (let i = 0; i < args.length; i++) { - let arg = args[i]; - if (typeof arg === "boolean") { - if (!arg) return false; // short-circuit - } else { - allBools = false; - if (isCelError(arg)) { - errors.push(arg); - } +const and = celFunc(opc.LOGICAL_AND, [ + celOverload( + olc.LOGICAL_AND, + [CelScalar.BOOL, CelScalar.BOOL], + CelScalar.BOOL, + (x, y) => x && y, // Irrelevant because we overwrite dispatch below + ), +]); +and.dispatch = (_id, args) => { + let allBools = true; + const errors: CelError[] = []; + for (let i = 0; i < args.length; i++) { + let arg = args[i]; + if (typeof arg === "boolean") { + if (!arg) return false; // short-circuit + } else { + allBools = false; + if (isCelError(arg)) { + errors.push(arg); } } - if (allBools) { - return true; - } - if (errors.length > 0) { - return celErrorMerge(errors[0], ...errors.slice(1)); - } - return undefined; - }, + } + if (allBools) { + return true; + } + if (errors.length > 0) { + return celErrorMerge(errors[0], ...errors.slice(1)); + } + return undefined; }; -const or: CallDispatch = { - dispatch(_, args) { - let allBools = true; - const errors: CelError[] = []; - for (let i = 0; i < args.length; i++) { - let arg = args[i]; - if (typeof arg === "boolean") { - if (arg) return true; // short-circuit - } else { - allBools = false; - if (isCelError(arg)) { - errors.push(arg); - } +const or = celFunc(opc.LOGICAL_OR, [ + celOverload( + olc.LOGICAL_OR, + [CelScalar.BOOL, CelScalar.BOOL], + CelScalar.BOOL, + (x, y) => x || y, // Irrelevant because we overwrite dispatch below + ), +]); +or.dispatch = (_, args) => { + let allBools = true; + const errors: CelError[] = []; + for (let i = 0; i < args.length; i++) { + let arg = args[i]; + if (typeof arg === "boolean") { + if (arg) return true; // short-circuit + } else { + allBools = false; + if (isCelError(arg)) { + errors.push(arg); } } - if (allBools) { - return false; - } - if (errors.length > 0) { - return celErrorMerge(errors[0], ...errors.slice(1)); - } - return undefined; - }, + } + if (allBools) { + return false; + } + if (errors.length > 0) { + return celErrorMerge(errors[0], ...errors.slice(1)); + } + return undefined; }; +/** + * This is not actually used by the planner since it handles conditionals + * directly, but it is defined here for type checking. + */ +const conditional = celFunc(opc.CONDITIONAL, [ + celOverload( + olc.CONDITIONAL, + [CelScalar.BOOL, paramA, paramA], + paramA, + (_cond, thenBranch, elseBranch) => { + // Irrelevant because dispatch is never called by the planner for conditionals + return _cond ? thenBranch : elseBranch; + }, + ), +]); + +/** + * This is not actually used by the planner since it handles indexing directly, + * but it is defined here for type checking. + */ +const index = celFunc(opc.INDEX, [ + celOverload(olc.INDEX_LIST, [listOfA, CelScalar.INT], paramA, (lst, idx) => { + // Irrelevant because dispatch is never called by the planner for indexing + return lst.get(Number(idx)) ?? null; + }), + celOverload(olc.INDEX_MAP, [mapOfAB, paramA], paramB, (mp, key) => { + // Irrelevant because dispatch is never called by the planner for indexing + return mp.get(key as string) ?? null; + }), +]); + const eqFunc = celFunc(opc.EQUALS, [ - celOverload([CelScalar.DYN, CelScalar.DYN], CelScalar.BOOL, equals), + celOverload( + olc.EQUALS, + [CelScalar.DYN, CelScalar.DYN], + CelScalar.BOOL, + equals, + ), ]); const neFunc = celFunc(opc.NOT_EQUALS, [ celOverload( + olc.NOT_EQUALS, [CelScalar.DYN, CelScalar.DYN], CelScalar.BOOL, (lhs, rhs) => !equals(lhs, rhs), @@ -118,22 +174,22 @@ function ltOp(lhs: T, rhs: T) { } // biome-ignore format: Easier to read it like a table const ltFunc = celFunc(opc.LESS, [ - celOverload([CelScalar.BOOL, CelScalar.BOOL], CelScalar.BOOL, ltOp), - celOverload([CelScalar.BYTES, CelScalar.BYTES], CelScalar.BOOL, (l, r) => compareBytes(l, r) < 0), - celOverload([CelScalar.DOUBLE, CelScalar.DOUBLE], CelScalar.BOOL, ltOp), - celOverload([CelScalar.STRING, CelScalar.STRING], CelScalar.BOOL, ltOp), - celOverload([CelScalar.INT, CelScalar.INT], CelScalar.BOOL, ltOp), - celOverload([CelScalar.INT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l < r.value), - celOverload([CelScalar.UINT, CelScalar.INT], CelScalar.BOOL, (l, r) => l.value < r), - celOverload([CelScalar.UINT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l.value < r.value), + celOverload(olc.LESS_BOOL, [CelScalar.BOOL, CelScalar.BOOL], CelScalar.BOOL, ltOp), + celOverload(olc.LESS_BYTES, [CelScalar.BYTES, CelScalar.BYTES], CelScalar.BOOL, (l, r) => compareBytes(l, r) < 0), + celOverload(olc.LESS_DOUBLE, [CelScalar.DOUBLE, CelScalar.DOUBLE], CelScalar.BOOL, ltOp), + celOverload(olc.LESS_STRING, [CelScalar.STRING, CelScalar.STRING], CelScalar.BOOL, ltOp), + celOverload(olc.LESS_INT64, [CelScalar.INT, CelScalar.INT], CelScalar.BOOL, ltOp), + celOverload(olc.LESS_INT64_UINT64, [CelScalar.INT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l < r.value), + celOverload(olc.LESS_UINT64_INT64, [CelScalar.UINT, CelScalar.INT], CelScalar.BOOL, (l, r) => l.value < r), + celOverload(olc.LESS_UINT64, [CelScalar.UINT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l.value < r.value), // TODO investigate: ECMAScript relational operators support mixed bigint/number operands, // but removing the coercion to number here fails the conformance test "not_lt_dyn_int_big_lossy_double" - celOverload([CelScalar.INT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l) < r), - celOverload([CelScalar.DOUBLE, CelScalar.INT], CelScalar.BOOL, (l, r) => l < Number(r)), - celOverload([CelScalar.DOUBLE, CelScalar.UINT], CelScalar.BOOL, (l, r) => l < Number(r.value)), - celOverload([CelScalar.UINT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l.value) < r), - celOverload([DURATION, DURATION], CelScalar.BOOL, (l, r) => compareDuration(l, r) < 0), - celOverload([TIMESTAMP, TIMESTAMP], CelScalar.BOOL, (l, r) => compareTimestamp(l, r) < 0), + celOverload(olc.LESS_INT64_DOUBLE, [CelScalar.INT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l) < r), + celOverload(olc.LESS_DOUBLE_INT64, [CelScalar.DOUBLE, CelScalar.INT], CelScalar.BOOL, (l, r) => l < Number(r)), + celOverload(olc.LESS_DOUBLE_UINT64, [CelScalar.DOUBLE, CelScalar.UINT], CelScalar.BOOL, (l, r) => l < Number(r.value)), + celOverload(olc.LESS_UINT64_DOUBLE, [CelScalar.UINT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l.value) < r), + celOverload(olc.LESS_DURATION, [DURATION, DURATION], CelScalar.BOOL, (l, r) => compareDuration(l, r) < 0), + celOverload(olc.LESS_TIMESTAMP, [TIMESTAMP, TIMESTAMP], CelScalar.BOOL, (l, r) => compareTimestamp(l, r) < 0), ]); function lteOp(lhs: T, rhs: T) { @@ -141,20 +197,20 @@ function lteOp(lhs: T, rhs: T) { } // biome-ignore format: Easier to read it like a table const leFunc = celFunc(opc.LESS_EQUALS, [ - celOverload([CelScalar.BOOL, CelScalar.BOOL], CelScalar.BOOL, lteOp), - celOverload([CelScalar.BYTES, CelScalar.BYTES], CelScalar.BOOL, (l, r) => compareBytes(l, r) <= 0), - celOverload([CelScalar.DOUBLE, CelScalar.DOUBLE], CelScalar.BOOL, lteOp), - celOverload([CelScalar.STRING, CelScalar.STRING], CelScalar.BOOL, lteOp), - celOverload([CelScalar.INT, CelScalar.INT], CelScalar.BOOL, lteOp), - celOverload([CelScalar.INT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l <= r.value), - celOverload([CelScalar.UINT, CelScalar.INT], CelScalar.BOOL, (l, r) => l.value <= r), - celOverload([CelScalar.UINT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l.value <= r.value), - celOverload([CelScalar.INT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l) <= r), - celOverload([CelScalar.DOUBLE, CelScalar.INT], CelScalar.BOOL, (l, r) => l <= Number(r)), - celOverload([CelScalar.DOUBLE, CelScalar.UINT], CelScalar.BOOL, (l, r) => l <= Number(r.value)), - celOverload([CelScalar.UINT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l.value) <= r), - celOverload([DURATION, DURATION], CelScalar.BOOL, (l, r) => compareDuration(l, r) <= 0), - celOverload([TIMESTAMP, TIMESTAMP], CelScalar.BOOL, (l, r) => compareTimestamp(l, r) <= 0), + celOverload(olc.LESS_EQUALS_BOOL, [CelScalar.BOOL, CelScalar.BOOL], CelScalar.BOOL, lteOp), + celOverload(olc.LESS_EQUALS_BYTES, [CelScalar.BYTES, CelScalar.BYTES], CelScalar.BOOL, (l, r) => compareBytes(l, r) <= 0), + celOverload(olc.LESS_EQUALS_DOUBLE, [CelScalar.DOUBLE, CelScalar.DOUBLE], CelScalar.BOOL, lteOp), + celOverload(olc.LESS_EQUALS_STRING, [CelScalar.STRING, CelScalar.STRING], CelScalar.BOOL, lteOp), + celOverload(olc.LESS_EQUALS_INT64, [CelScalar.INT, CelScalar.INT], CelScalar.BOOL, lteOp), + celOverload(olc.LESS_EQUALS_INT64_UINT64, [CelScalar.INT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l <= r.value), + celOverload(olc.LESS_EQUALS_UINT64_INT64, [CelScalar.UINT, CelScalar.INT], CelScalar.BOOL, (l, r) => l.value <= r), + celOverload(olc.LESS_EQUALS_UINT64, [CelScalar.UINT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l.value <= r.value), + celOverload(olc.LESS_EQUALS_INT64_DOUBLE, [CelScalar.INT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l) <= r), + celOverload(olc.LESS_EQUALS_DOUBLE_INT64, [CelScalar.DOUBLE, CelScalar.INT], CelScalar.BOOL, (l, r) => l <= Number(r)), + celOverload(olc.LESS_EQUALS_DOUBLE_UINT64, [CelScalar.DOUBLE, CelScalar.UINT], CelScalar.BOOL, (l, r) => l <= Number(r.value)), + celOverload(olc.LESS_EQUALS_UINT64_DOUBLE, [CelScalar.UINT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l.value) <= r), + celOverload(olc.LESS_EQUALS_DURATION, [DURATION, DURATION], CelScalar.BOOL, (l, r) => compareDuration(l, r) <= 0), + celOverload(olc.LESS_EQUALS_TIMESTAMP, [TIMESTAMP, TIMESTAMP], CelScalar.BOOL, (l, r) => compareTimestamp(l, r) <= 0), ]); function gtOp(lhs: T, rhs: T) { @@ -162,20 +218,20 @@ function gtOp(lhs: T, rhs: T) { } // biome-ignore format: Easier to read it like a table const gtFunc = celFunc(opc.GREATER, [ - celOverload([CelScalar.BOOL, CelScalar.BOOL], CelScalar.BOOL, gtOp), - celOverload([CelScalar.BYTES, CelScalar.BYTES], CelScalar.BOOL, (l, r) => compareBytes(l, r) > 0), - celOverload([CelScalar.DOUBLE, CelScalar.DOUBLE], CelScalar.BOOL, gtOp), - celOverload([CelScalar.STRING, CelScalar.STRING], CelScalar.BOOL, gtOp), - celOverload([CelScalar.INT, CelScalar.INT], CelScalar.BOOL, gtOp), - celOverload([CelScalar.INT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l > r.value), - celOverload([CelScalar.UINT, CelScalar.INT], CelScalar.BOOL, (l, r) => l.value > r), - celOverload([CelScalar.UINT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l.value > r.value), - celOverload([CelScalar.INT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l) > r), - celOverload([CelScalar.DOUBLE, CelScalar.INT], CelScalar.BOOL, (l, r) => l > Number(r)), - celOverload([CelScalar.DOUBLE, CelScalar.UINT], CelScalar.BOOL, (l, r) => l > Number(r.value)), - celOverload([CelScalar.UINT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l.value) > r), - celOverload([DURATION, DURATION], CelScalar.BOOL, (l, r) => compareDuration(l, r) > 0), - celOverload([TIMESTAMP, TIMESTAMP], CelScalar.BOOL, (l, r) => compareTimestamp(l, r) > 0), + celOverload(olc.GREATER_BOOL, [CelScalar.BOOL, CelScalar.BOOL], CelScalar.BOOL, gtOp), + celOverload(olc.GREATER_BYTES, [CelScalar.BYTES, CelScalar.BYTES], CelScalar.BOOL, (l, r) => compareBytes(l, r) > 0), + celOverload(olc.GREATER_DOUBLE, [CelScalar.DOUBLE, CelScalar.DOUBLE], CelScalar.BOOL, gtOp), + celOverload(olc.GREATER_STRING, [CelScalar.STRING, CelScalar.STRING], CelScalar.BOOL, gtOp), + celOverload(olc.GREATER_INT64, [CelScalar.INT, CelScalar.INT], CelScalar.BOOL, gtOp), + celOverload(olc.GREATER_INT64_UINT64, [CelScalar.INT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l > r.value), + celOverload(olc.GREATER_UINT64_INT64, [CelScalar.UINT, CelScalar.INT], CelScalar.BOOL, (l, r) => l.value > r), + celOverload(olc.GREATER_UINT64, [CelScalar.UINT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l.value > r.value), + celOverload(olc.GREATER_INT64_DOUBLE, [CelScalar.INT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l) > r), + celOverload(olc.GREATER_DOUBLE_INT64, [CelScalar.DOUBLE, CelScalar.INT], CelScalar.BOOL, (l, r) => l > Number(r)), + celOverload(olc.GREATER_DOUBLE_UINT64, [CelScalar.DOUBLE, CelScalar.UINT], CelScalar.BOOL, (l, r) => l > Number(r.value)), + celOverload(olc.GREATER_UINT64_DOUBLE, [CelScalar.UINT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l.value) > r), + celOverload(olc.GREATER_DURATION, [DURATION, DURATION], CelScalar.BOOL, (l, r) => compareDuration(l, r) > 0), + celOverload(olc.GREATER_TIMESTAMP, [TIMESTAMP, TIMESTAMP], CelScalar.BOOL, (l, r) => compareTimestamp(l, r) > 0), ]); function gteOp(lhs: T, rhs: T) { @@ -183,37 +239,46 @@ function gteOp(lhs: T, rhs: T) { } // biome-ignore format: Easier to read it like a table const geFunc = celFunc(opc.GREATER_EQUALS, [ - celOverload([CelScalar.BOOL, CelScalar.BOOL], CelScalar.BOOL, gteOp), - celOverload([CelScalar.BYTES, CelScalar.BYTES], CelScalar.BOOL, (l, r) => compareBytes(l, r) >= 0), - celOverload([CelScalar.DOUBLE, CelScalar.DOUBLE], CelScalar.BOOL, gteOp), - celOverload([CelScalar.STRING, CelScalar.STRING], CelScalar.BOOL, gteOp), - celOverload([CelScalar.INT, CelScalar.INT], CelScalar.BOOL, gteOp), - celOverload([CelScalar.INT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l >= r.value), - celOverload([CelScalar.UINT, CelScalar.INT], CelScalar.BOOL, (l, r) => l.value >= r), - celOverload([CelScalar.UINT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l.value >= r.value), - celOverload([CelScalar.INT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l) >= r), - celOverload([CelScalar.DOUBLE, CelScalar.INT], CelScalar.BOOL, (l, r) => l >= Number(r)), - celOverload([CelScalar.DOUBLE, CelScalar.UINT], CelScalar.BOOL, (l, r) => l >= Number(r.value)), - celOverload([CelScalar.UINT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l.value) >= r), - celOverload([DURATION, DURATION], CelScalar.BOOL, (l, r) => compareDuration(l, r) >= 0), - celOverload([TIMESTAMP, TIMESTAMP], CelScalar.BOOL, (l, r) => compareTimestamp(l, r) >= 0), + celOverload(olc.GREATER_EQUALS_BOOL, [CelScalar.BOOL, CelScalar.BOOL], CelScalar.BOOL, gteOp), + celOverload(olc.GREATER_EQUALS_BYTES, [CelScalar.BYTES, CelScalar.BYTES], CelScalar.BOOL, (l, r) => compareBytes(l, r) >= 0), + celOverload(olc.GREATER_EQUALS_DOUBLE, [CelScalar.DOUBLE, CelScalar.DOUBLE], CelScalar.BOOL, gteOp), + celOverload(olc.GREATER_EQUALS_STRING, [CelScalar.STRING, CelScalar.STRING], CelScalar.BOOL, gteOp), + celOverload(olc.GREATER_EQUALS_INT64, [CelScalar.INT, CelScalar.INT], CelScalar.BOOL, gteOp), + celOverload(olc.GREATER_EQUALS_INT64_UINT64, [CelScalar.INT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l >= r.value), + celOverload(olc.GREATER_EQUALS_UINT64_INT64, [CelScalar.UINT, CelScalar.INT], CelScalar.BOOL, (l, r) => l.value >= r), + celOverload(olc.GREATER_EQUALS_UINT64, [CelScalar.UINT, CelScalar.UINT], CelScalar.BOOL, (l, r) => l.value >= r.value), + celOverload(olc.GREATER_EQUALS_INT64_DOUBLE, [CelScalar.INT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l) >= r), + celOverload(olc.GREATER_EQUALS_DOUBLE_INT64, [CelScalar.DOUBLE, CelScalar.INT], CelScalar.BOOL, (l, r) => l >= Number(r)), + celOverload(olc.GREATER_EQUALS_DOUBLE_UINT64, [CelScalar.DOUBLE, CelScalar.UINT], CelScalar.BOOL, (l, r) => l >= Number(r.value)), + celOverload(olc.GREATER_EQUALS_UINT64_DOUBLE, [CelScalar.UINT, CelScalar.DOUBLE], CelScalar.BOOL, (l, r) => Number(l.value) >= r), + celOverload(olc.GREATER_EQUALS_DURATION, [DURATION, DURATION], CelScalar.BOOL, (l, r) => compareDuration(l, r) >= 0), + celOverload(olc.GREATER_EQUALS_TIMESTAMP, [TIMESTAMP, TIMESTAMP], CelScalar.BOOL, (l, r) => compareTimestamp(l, r) >= 0), ]); const containsFunc = celFunc(olc.CONTAINS, [ - celOverload([CelScalar.STRING, CelScalar.STRING], CelScalar.BOOL, (x, y) => - x.includes(y), + celMemberOverload( + olc.CONTAINS_STRING, + [CelScalar.STRING, CelScalar.STRING], + CelScalar.BOOL, + (x, y) => x.includes(y), ), ]); const endsWithFunc = celFunc(olc.ENDS_WITH, [ - celOverload([CelScalar.STRING, CelScalar.STRING], CelScalar.BOOL, (x, y) => - x.endsWith(y), + celMemberOverload( + olc.ENDS_WITH_STRING, + [CelScalar.STRING, CelScalar.STRING], + CelScalar.BOOL, + (x, y) => x.endsWith(y), ), ]); const startsWithFunc = celFunc(olc.STARTS_WITH, [ - celOverload([CelScalar.STRING, CelScalar.STRING], CelScalar.BOOL, (x, y) => - x.startsWith(y), + celMemberOverload( + olc.STARTS_WITH_STRING, + [CelScalar.STRING, CelScalar.STRING], + CelScalar.BOOL, + (x, y) => x.startsWith(y), ), ]); @@ -274,7 +339,8 @@ export function matchesString(x: string, y: string): boolean { } const matchesFunc = celFunc(olc.MATCHES, [ - celOverload( + celMemberOverload( + olc.MATCHES_STRING, [CelScalar.STRING, CelScalar.STRING], CelScalar.BOOL, matchesString, @@ -282,25 +348,40 @@ const matchesFunc = celFunc(olc.MATCHES, [ ]); const sizeFunc = celFunc(olc.SIZE, [ - celOverload([CelScalar.STRING], CelScalar.INT, (x) => { + celOverload(olc.SIZE_STRING, [CelScalar.STRING], CelScalar.INT, (x) => { let size = 0; for (const _ of x) { size++; } return BigInt(size); }), - celOverload([CelScalar.BYTES], CelScalar.INT, (x) => BigInt(x.length)), - celOverload([listType(CelScalar.DYN)], CelScalar.INT, (x) => BigInt(x.size)), - celOverload([mapType(CelScalar.INT, CelScalar.DYN)], CelScalar.INT, (x) => - BigInt(x.size), + celMemberOverload( + olc.SIZE_STRING_INST, + [CelScalar.STRING], + CelScalar.INT, + (x) => { + let size = 0; + for (const _ of x) { + size++; + } + return BigInt(size); + }, ), - celOverload([mapType(CelScalar.UINT, CelScalar.DYN)], CelScalar.INT, (x) => - BigInt(x.size), + celOverload(olc.SIZE_BYTES, [CelScalar.BYTES], CelScalar.INT, (x) => + BigInt(x.length), + ), + celMemberOverload( + olc.SIZE_BYTES_INST, + [CelScalar.BYTES], + CelScalar.INT, + (x) => BigInt(x.length), ), - celOverload([mapType(CelScalar.BOOL, CelScalar.DYN)], CelScalar.INT, (x) => + celOverload(olc.SIZE_LIST, [listOfA], CelScalar.INT, (x) => BigInt(x.size)), + celMemberOverload(olc.SIZE_LIST_INST, [listOfA], CelScalar.INT, (x) => BigInt(x.size), ), - celOverload([mapType(CelScalar.STRING, CelScalar.DYN)], CelScalar.INT, (x) => + celOverload(olc.SIZE_MAP, [mapOfAB], CelScalar.INT, (x) => BigInt(x.size)), + celMemberOverload(olc.SIZE_MAP_INST, [mapOfAB], CelScalar.INT, (x) => BigInt(x.size), ), ]); @@ -311,6 +392,7 @@ function mapInOp(x: CelValue, y: CelMap) { const inFunc = celFunc(opc.IN, [ celOverload( + olc.IN_LIST, [CelScalar.DYN, listType(CelScalar.DYN)], CelScalar.BOOL, (x, y) => { @@ -322,22 +404,29 @@ const inFunc = celFunc(opc.IN, [ return false; }, ), + // TODO: other implementations use listOfA/mapOfAB here instead of having a separate + // overload for each key type. Switching it out causes some conformance tests to fail + // because the planner cannot resolve the overloads correctly. celOverload( + olc.IN_MAP, [CelScalar.DYN, mapType(CelScalar.STRING, CelScalar.DYN)], CelScalar.BOOL, mapInOp, ), celOverload( + olc.IN_MAP + "_int_key", [CelScalar.DYN, mapType(CelScalar.INT, CelScalar.DYN)], CelScalar.BOOL, mapInOp, ), celOverload( + olc.IN_MAP + "_uint_key", [CelScalar.DYN, mapType(CelScalar.UINT, CelScalar.DYN)], CelScalar.BOOL, mapInOp, ), celOverload( + olc.IN_MAP + "_bool_key", [CelScalar.DYN, mapType(CelScalar.BOOL, CelScalar.DYN)], CelScalar.BOOL, mapInOp, @@ -345,10 +434,12 @@ const inFunc = celFunc(opc.IN, [ ]); export function addLogic(funcs: FuncRegistry) { - funcs.add(opc.NOT_STRICTLY_FALSE, notStrictlyFalse); - funcs.add(opc.LOGICAL_AND, and); - funcs.add(opc.LOGICAL_OR, or); + funcs.add(notStrictlyFalse); + funcs.add(and); + funcs.add(or); funcs.add(notFunc); + funcs.add(conditional); + funcs.add(index); funcs.add(eqFunc); funcs.add(neFunc); funcs.add(ltFunc); diff --git a/packages/cel/src/std/math.ts b/packages/cel/src/std/math.ts index 9d401489..be0fd8c4 100644 --- a/packages/cel/src/std/math.ts +++ b/packages/cel/src/std/math.ts @@ -17,10 +17,10 @@ import { DurationSchema, TimestampSchema } from "@bufbuild/protobuf/wkt"; import { type FuncRegistry, celOverload, celFunc } from "../func.js"; import * as opc from "../gen/dev/cel/expr/operator_const.js"; +import * as olc from "../gen/dev/cel/expr/overload_const.js"; import { CelScalar, DURATION, - listType, TIMESTAMP, type CelType, type CelValue, @@ -29,6 +29,7 @@ import { celListConcat } from "../list.js"; import { celUint } from "../uint.js"; import { createDuration } from "../duration.js"; import { createTimestamp } from "../timestamp.js"; +import { listOfA } from "./types.js"; const MAX_INT = 9223372036854775807n; // biome-ignore lint/correctness/noPrecisionLoss: No symbol exists in the std. @@ -107,31 +108,44 @@ function subtractDurationOrTimestamp< } const add = celFunc(opc.ADD, [ - celOverload([CelScalar.INT, CelScalar.INT], CelScalar.INT, (lhs, rhs) => { - const val = lhs + rhs; - if (isOverflowInt(val)) { - throw overflow(opc.SUBTRACT, CelScalar.INT); - } - return val; - }), - celOverload([CelScalar.UINT, CelScalar.UINT], CelScalar.UINT, (lhs, rhs) => { - const val = lhs.value + rhs.value; - if (isOverflowUint(val)) { - throw overflow(opc.SUBTRACT, CelScalar.UINT); - } - return celUint(val); - }), celOverload( + olc.ADD_INT64, + [CelScalar.INT, CelScalar.INT], + CelScalar.INT, + (lhs, rhs) => { + const val = lhs + rhs; + if (isOverflowInt(val)) { + throw overflow(opc.SUBTRACT, CelScalar.INT); + } + return val; + }, + ), + celOverload( + olc.ADD_UINT64, + [CelScalar.UINT, CelScalar.UINT], + CelScalar.UINT, + (lhs, rhs) => { + const val = lhs.value + rhs.value; + if (isOverflowUint(val)) { + throw overflow(opc.SUBTRACT, CelScalar.UINT); + } + return celUint(val); + }, + ), + celOverload( + olc.ADD_DOUBLE, [CelScalar.DOUBLE, CelScalar.DOUBLE], CelScalar.DOUBLE, (lhs, rhs) => lhs + rhs, ), celOverload( + olc.ADD_STRING, [CelScalar.STRING, CelScalar.STRING], CelScalar.STRING, (lhs, rhs) => lhs + rhs, ), celOverload( + olc.ADD_BYTES, [CelScalar.BYTES, CelScalar.BYTES], CelScalar.BYTES, (lhs, rhs) => { @@ -141,65 +155,115 @@ const add = celFunc(opc.ADD, [ return val; }, ), - celOverload([TIMESTAMP, TIMESTAMP], TIMESTAMP, addTimestamp), - celOverload([TIMESTAMP, DURATION], TIMESTAMP, addTimestamp), - celOverload([DURATION, TIMESTAMP], TIMESTAMP, (lhs, rhs) => - addTimestamp(rhs, lhs), + celOverload( + "add_timestamp_timestamp", + [TIMESTAMP, TIMESTAMP], + TIMESTAMP, + addTimestamp, + ), + celOverload( + olc.ADD_TIMESTAMP_DURATION, + [TIMESTAMP, DURATION], + TIMESTAMP, + addTimestamp, + ), + celOverload( + olc.ADD_DURATION_TIMESTAMP, + [DURATION, TIMESTAMP], + TIMESTAMP, + (lhs, rhs) => addTimestamp(rhs, lhs), ), - celOverload([DURATION, DURATION], DURATION, addDuration), celOverload( - [listType(CelScalar.DYN), listType(CelScalar.DYN)], - listType(CelScalar.DYN), - celListConcat, + olc.ADD_DURATION_DURATION, + [DURATION, DURATION], + DURATION, + addDuration, ), + celOverload(olc.ADD_LIST, [listOfA, listOfA], listOfA, celListConcat), ]); const subtract = celFunc(opc.SUBTRACT, [ - celOverload([CelScalar.INT, CelScalar.INT], CelScalar.INT, (lhs, rhs) => { - const val = lhs - rhs; - if (isOverflowInt(val)) { - throw overflow(opc.SUBTRACT, CelScalar.INT); - } - return val; - }), - celOverload([CelScalar.UINT, CelScalar.UINT], CelScalar.UINT, (lhs, rhs) => { - const val = lhs.value - rhs.value; - if (isOverflowUint(val)) { - throw overflow(opc.SUBTRACT, CelScalar.UINT); - } - return celUint(val); - }), celOverload( + olc.SUBTRACT_INT64, + [CelScalar.INT, CelScalar.INT], + CelScalar.INT, + (lhs, rhs) => { + const val = lhs - rhs; + if (isOverflowInt(val)) { + throw overflow(opc.SUBTRACT, CelScalar.INT); + } + return val; + }, + ), + celOverload( + olc.SUBTRACT_UINT64, + [CelScalar.UINT, CelScalar.UINT], + CelScalar.UINT, + (lhs, rhs) => { + const val = lhs.value - rhs.value; + if (isOverflowUint(val)) { + throw overflow(opc.SUBTRACT, CelScalar.UINT); + } + return celUint(val); + }, + ), + celOverload( + olc.SUBTRACT_DOUBLE, [CelScalar.DOUBLE, CelScalar.DOUBLE], CelScalar.DOUBLE, (lhs, rhs) => lhs - rhs, ), - celOverload([TIMESTAMP, TIMESTAMP], DURATION, subtractDurationOrTimestamp), - celOverload([DURATION, DURATION], DURATION, subtractDurationOrTimestamp), - celOverload([TIMESTAMP, DURATION], TIMESTAMP, (lhs, rhs) => - createTimestamp( - lhs.message.seconds - rhs.message.seconds, - lhs.message.nanos - rhs.message.nanos, - ), + celOverload( + olc.SUBTRACT_TIMESTAMP_TIMESTAMP, + [TIMESTAMP, TIMESTAMP], + DURATION, + subtractDurationOrTimestamp, + ), + celOverload( + olc.SUBTRACT_DURATION_DURATION, + [DURATION, DURATION], + DURATION, + subtractDurationOrTimestamp, + ), + celOverload( + olc.SUBTRACT_TIMESTAMP_DURATION, + [TIMESTAMP, DURATION], + TIMESTAMP, + (lhs, rhs) => + createTimestamp( + lhs.message.seconds - rhs.message.seconds, + lhs.message.nanos - rhs.message.nanos, + ), ), ]); const multiply = celFunc(opc.MULTIPLY, [ - celOverload([CelScalar.INT, CelScalar.INT], CelScalar.INT, (lhs, rhs) => { - const product = lhs * rhs; - if (isOverflowInt(product)) { - throw overflow(opc.MULTIPLY, CelScalar.INT); - } - return product; - }), - celOverload([CelScalar.UINT, CelScalar.UINT], CelScalar.UINT, (lhs, rhs) => { - const product = lhs.value * rhs.value; - if (isOverflowUint(product)) { - throw overflow(opc.MULTIPLY, CelScalar.UINT); - } - return celUint(product); - }), celOverload( + olc.MULTIPLY_INT64, + [CelScalar.INT, CelScalar.INT], + CelScalar.INT, + (lhs, rhs) => { + const product = lhs * rhs; + if (isOverflowInt(product)) { + throw overflow(opc.MULTIPLY, CelScalar.INT); + } + return product; + }, + ), + celOverload( + olc.MULTIPLY_UINT64, + [CelScalar.UINT, CelScalar.UINT], + CelScalar.UINT, + (lhs, rhs) => { + const product = lhs.value * rhs.value; + if (isOverflowUint(product)) { + throw overflow(opc.MULTIPLY, CelScalar.UINT); + } + return celUint(product); + }, + ), + celOverload( + olc.MULTIPLY_DOUBLE, [CelScalar.DOUBLE, CelScalar.DOUBLE], CelScalar.DOUBLE, (lhs, rhs) => lhs * rhs, @@ -207,52 +271,78 @@ const multiply = celFunc(opc.MULTIPLY, [ ]); const divide = celFunc(opc.DIVIDE, [ - celOverload([CelScalar.INT, CelScalar.INT], CelScalar.INT, (lhs, rhs) => { - if (rhs === 0n) { - throw divisionByZero(CelScalar.INT); - } - if (lhs === MIN_INT && rhs === -1n) { - throw overflow(opc.DIVIDE, CelScalar.INT); - } - return lhs / rhs; - }), celOverload( + olc.DIVIDE_INT64, + [CelScalar.INT, CelScalar.INT], + CelScalar.INT, + (lhs, rhs) => { + if (rhs === 0n) { + throw divisionByZero(CelScalar.INT); + } + if (lhs === MIN_INT && rhs === -1n) { + throw overflow(opc.DIVIDE, CelScalar.INT); + } + return lhs / rhs; + }, + ), + celOverload( + olc.DIVIDE_DOUBLE, [CelScalar.DOUBLE, CelScalar.DOUBLE], CelScalar.DOUBLE, (lhs, rhs) => lhs / rhs, ), - celOverload([CelScalar.UINT, CelScalar.UINT], CelScalar.UINT, (lhs, rhs) => { - if (rhs.value === 0n) { - throw divisionByZero(CelScalar.UINT); - } - return celUint(lhs.value / rhs.value); - }), + celOverload( + olc.DIVIDE_UINT64, + [CelScalar.UINT, CelScalar.UINT], + CelScalar.UINT, + (lhs, rhs) => { + if (rhs.value === 0n) { + throw divisionByZero(CelScalar.UINT); + } + return celUint(lhs.value / rhs.value); + }, + ), ]); const modulo = celFunc(opc.MODULO, [ - celOverload([CelScalar.INT, CelScalar.INT], CelScalar.INT, (lhs, rhs) => { - if (rhs === 0n) { - throw moduloByZero(CelScalar.INT); - } - return lhs % rhs; - }), - celOverload([CelScalar.UINT, CelScalar.UINT], CelScalar.UINT, (lhs, rhs) => { - if (rhs.value === 0n) { - throw moduloByZero(CelScalar.UINT); - } - return celUint(lhs.value % rhs.value); - }), + celOverload( + olc.MODULO_INT64, + [CelScalar.INT, CelScalar.INT], + CelScalar.INT, + (lhs, rhs) => { + if (rhs === 0n) { + throw moduloByZero(CelScalar.INT); + } + return lhs % rhs; + }, + ), + celOverload( + olc.MODULO_UINT64, + [CelScalar.UINT, CelScalar.UINT], + CelScalar.UINT, + (lhs, rhs) => { + if (rhs.value === 0n) { + throw moduloByZero(CelScalar.UINT); + } + return celUint(lhs.value % rhs.value); + }, + ), ]); const negate = celFunc(opc.NEGATE, [ - celOverload([CelScalar.INT], CelScalar.INT, (arg) => { + celOverload(olc.NEGATE_INT64, [CelScalar.INT], CelScalar.INT, (arg) => { const val = -arg; if (isOverflowInt(val)) { throw overflow(opc.NEGATE, CelScalar.INT); } return val; }), - celOverload([CelScalar.DOUBLE], CelScalar.DOUBLE, (arg) => -arg), + celOverload( + olc.NEGATE_DOUBLE, + [CelScalar.DOUBLE], + CelScalar.DOUBLE, + (arg) => -arg, + ), ]); function overflow(op: string, type: CelType) { diff --git a/packages/cel/src/std/time.ts b/packages/cel/src/std/time.ts index 12368935..611e7a35 100644 --- a/packages/cel/src/std/time.ts +++ b/packages/cel/src/std/time.ts @@ -15,7 +15,7 @@ import { timestampDate, TimestampSchema } from "@bufbuild/protobuf/wkt"; import { CelScalar, TIMESTAMP, DURATION, type CelValue } from "../type.js"; -import { type FuncRegistry, celOverload, celFunc } from "../func.js"; +import { type FuncRegistry, celFunc, celMemberOverload } from "../func.js"; import * as olc from "../gen/dev/cel/expr/overload_const.js"; import { toJson } from "@bufbuild/protobuf"; @@ -158,12 +158,14 @@ function makeTimeOp(t: TimeFunc) { type TimeFunc = (date: Date) => number; const getFullYearFunc = celFunc(olc.TIME_GET_FULL_YEAR, [ - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_YEAR, [TIMESTAMP], CelScalar.INT, makeTimeOp((d) => d.getFullYear()), ), - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_YEAR_WITH_TZ, [TIMESTAMP, CelScalar.STRING], CelScalar.INT, makeTimeOp((d) => d.getFullYear()), @@ -171,12 +173,14 @@ const getFullYearFunc = celFunc(olc.TIME_GET_FULL_YEAR, [ ]); const getMonthFunc = celFunc(olc.TIME_GET_MONTH, [ - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_MONTH, [TIMESTAMP], CelScalar.INT, makeTimeOp((d) => d.getMonth()), ), - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_MONTH_WITH_TZ, [TIMESTAMP, CelScalar.STRING], CelScalar.INT, makeTimeOp((d) => d.getMonth()), @@ -184,12 +188,14 @@ const getMonthFunc = celFunc(olc.TIME_GET_MONTH, [ ]); const getDateFunc = celFunc(olc.TIME_GET_DATE, [ - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_DAY_OF_MONTH_ONE_BASED, [TIMESTAMP], CelScalar.INT, makeTimeOp((d) => d.getDate()), ), - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_DAY_OF_MONTH_ONE_BASED_WITH_TZ, [TIMESTAMP, CelScalar.STRING], CelScalar.INT, makeTimeOp((d) => d.getDate()), @@ -197,12 +203,14 @@ const getDateFunc = celFunc(olc.TIME_GET_DATE, [ ]); const getDayOfMonthFunc = celFunc(olc.TIME_GET_DAY_OF_MONTH, [ - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_DAY_OF_MONTH_ZERO_BASED, [TIMESTAMP], CelScalar.INT, makeTimeOp((d) => d.getDate() - 1), ), - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_DAY_OF_MONTH_ZERO_BASED_WITH_TZ, [TIMESTAMP, CelScalar.STRING], CelScalar.INT, makeTimeOp((d) => d.getDate() - 1), @@ -210,12 +218,14 @@ const getDayOfMonthFunc = celFunc(olc.TIME_GET_DAY_OF_MONTH, [ ]); const getDayOfWeekFunc = celFunc(olc.TIME_GET_DAY_OF_WEEK, [ - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_DAY_OF_WEEK, [TIMESTAMP], CelScalar.INT, makeTimeOp((d) => d.getDay()), ), - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_DAY_OF_WEEK_WITH_TZ, [TIMESTAMP, CelScalar.STRING], CelScalar.INT, makeTimeOp((d) => d.getDay()), @@ -223,12 +233,14 @@ const getDayOfWeekFunc = celFunc(olc.TIME_GET_DAY_OF_WEEK, [ ]); const getDayOfYearFunc = celFunc(olc.TIME_GET_DAY_OF_YEAR, [ - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_DAY_OF_YEAR, [TIMESTAMP], CelScalar.INT, makeTimeOp((d) => getDayOfYear(d)), ), - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_DAY_OF_YEAR_WITH_TZ, [TIMESTAMP, CelScalar.STRING], CelScalar.INT, makeTimeOp((d) => getDayOfYear(d)), @@ -236,59 +248,83 @@ const getDayOfYearFunc = celFunc(olc.TIME_GET_DAY_OF_YEAR, [ ]); const getSecondsFunc = celFunc(olc.TIME_GET_SECONDS, [ - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_SECONDS, [TIMESTAMP], CelScalar.INT, makeTimeOp((d) => d.getSeconds()), ), - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_SECONDS_WITH_TZ, [TIMESTAMP, CelScalar.STRING], CelScalar.INT, makeTimeOp((d) => d.getSeconds()), ), - celOverload([DURATION], CelScalar.INT, (dur) => dur.message.seconds), + celMemberOverload( + olc.DURATION_TO_SECONDS, + [DURATION], + CelScalar.INT, + (dur) => dur.message.seconds, + ), ]); const getMinutesFunc = celFunc(olc.TIME_GET_MINUTES, [ - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_MINUTES, [TIMESTAMP], CelScalar.INT, makeTimeOp((d) => d.getMinutes()), ), - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_MINUTES_WITH_TZ, [TIMESTAMP, CelScalar.STRING], CelScalar.INT, makeTimeOp((d) => d.getMinutes()), ), - celOverload([DURATION], CelScalar.INT, (dur) => dur.message.seconds / 60n), + celMemberOverload( + olc.DURATION_TO_MINUTES, + [DURATION], + CelScalar.INT, + (dur) => dur.message.seconds / 60n, + ), ]); const getHoursFunc = celFunc(olc.TIME_GET_HOURS, [ - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_HOURS, [TIMESTAMP], CelScalar.INT, makeTimeOp((d) => d.getHours()), ), - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_HOURS_WITH_TZ, [TIMESTAMP, CelScalar.STRING], CelScalar.INT, makeTimeOp((d) => d.getHours()), ), - celOverload([DURATION], CelScalar.INT, (dur) => dur.message.seconds / 3600n), + celMemberOverload( + olc.DURATION_TO_HOURS, + [DURATION], + CelScalar.INT, + (dur) => dur.message.seconds / 3600n, + ), ]); const getMillisecondsFunc = celFunc(olc.TIME_GET_MILLISECONDS, [ - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_MILLISECONDS, [TIMESTAMP], CelScalar.INT, makeTimeOp((d) => d.getMilliseconds()), ), - celOverload( + celMemberOverload( + olc.TIMESTAMP_TO_MILLISECONDS_WITH_TZ, [TIMESTAMP, CelScalar.STRING], CelScalar.INT, makeTimeOp((d) => d.getMilliseconds()), ), - celOverload( + celMemberOverload( + olc.DURATION_TO_MILLISECONDS, [DURATION], CelScalar.INT, (dur) => BigInt(dur.message.nanos) / 1000000n, diff --git a/packages/cel/src/std/types.ts b/packages/cel/src/std/types.ts new file mode 100644 index 00000000..0ed8cab9 --- /dev/null +++ b/packages/cel/src/std/types.ts @@ -0,0 +1,46 @@ +// Copyright 2024-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { celTypeVariable } from "../ident.js"; +import { + CelScalar, + DURATION, + listType, + mapType, + TIMESTAMP, + typeParamType, + typeType, + type CelType, + type mapKeyType, +} from "../type.js"; + +export const paramA = typeParamType("A") as CelType; +export const paramB = typeParamType("B") as CelType; +export const listOfA = listType(paramA); +export const mapOfAB = mapType(paramA as mapKeyType, paramB); + +export const STD_TYPES = [ + celTypeVariable(typeType(CelScalar.BOOL)), + celTypeVariable(typeType(CelScalar.BYTES)), + celTypeVariable(typeType(CelScalar.DOUBLE)), + celTypeVariable(typeType(DURATION)), + celTypeVariable(typeType(CelScalar.INT)), + celTypeVariable(typeType(listOfA)), + celTypeVariable(typeType(mapOfAB)), + celTypeVariable(typeType(CelScalar.NULL)), + celTypeVariable(typeType(CelScalar.STRING)), + celTypeVariable(typeType(TIMESTAMP)), + celTypeVariable(typeType(CelScalar.TYPE)), + celTypeVariable(typeType(CelScalar.UINT)), +]; diff --git a/packages/cel/src/type.ts b/packages/cel/src/type.ts index 6cd615e5..2e5b4a43 100644 --- a/packages/cel/src/type.ts +++ b/packages/cel/src/type.ts @@ -12,7 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { DescMessage, Message, MessageShape } from "@bufbuild/protobuf"; +import { + ScalarType, + type DescField, + type DescMessage, + type Message, + type MessageShape, +} from "@bufbuild/protobuf"; import { isCelList, type CelList } from "./list.js"; import { isCelMap, type CelMap } from "./map.js"; import { isCelUint, type CelUint } from "./uint.js"; @@ -22,7 +28,12 @@ import { type ReflectMap, type ReflectMessage, } from "@bufbuild/protobuf/reflect"; -import { TimestampSchema, DurationSchema } from "@bufbuild/protobuf/wkt"; +import { + TimestampSchema, + DurationSchema, + AnySchema, +} from "@bufbuild/protobuf/wkt"; +import { isCelError, type CelError } from "./error.js"; const privateSymbol = Symbol.for("@bufbuild/cel/type"); @@ -36,7 +47,14 @@ export type CelType = | CelMapType | CelObjectType | CelTypeType - | CelScalarType; + | CelScalarType + | CelErrorType + | CelOpaqueType + | CelTypeParamType; + +export type CelNullableType = T & { + readonly wrapped: T; +}; /** * Scalar CEL value types. @@ -99,6 +117,24 @@ export interface CelObjectType readonly name: Desc["typeName"]; } +export interface CelErrorType extends celTypeShared { + readonly kind: "error"; + readonly name: "error"; + readonly error: CelError; +} + +export interface CelOpaqueType + extends celTypeShared { + readonly kind: "opaque"; + readonly name: string; + readonly parameters: T[]; +} + +export interface CelTypeParamType extends celTypeShared { + readonly kind: "type_param"; + readonly name: string; +} + interface celTypeShared { [privateSymbol]: unknown; /** @@ -186,6 +222,63 @@ export function objectType( }; } +export function errorType(error: CelError): CelErrorType { + return { + [privateSymbol]: {}, + kind: "error", + name: "error", + error, + toString() { + return error.message; + }, + }; +} + +export function opaqueType( + name: string, + parameters: T[], +): CelOpaqueType { + return { + [privateSymbol]: {}, + kind: "opaque", + name, + parameters, + toString() { + if (parameters.length === 0) { + return name; + } + return `${name}(${parameters.map((p) => p.toString()).join(", ")})`; + }, + }; +} + +export function typeParamType(name: string): CelTypeParamType { + return { + [privateSymbol]: {}, + kind: "type_param", + name, + toString() { + return name; + }, + }; +} + +/** + * TypeTypeWithParam creates a type with a type parameter. + * Used for type-checking purposes, but equivalent to TypeType otherwise. + */ +export function typeTypeWithParam(param: CelType): CelTypeType { + return { + [privateSymbol]: {}, + kind: "type", + type: param, + name: "type", + toString() { + return `type(${param.toString()})`; + }, + }; +} + function celScalarType< const S extends | "int" @@ -209,7 +302,7 @@ function celScalarType< } as const; } -type mapKeyType = +export type mapKeyType = | typeof CelScalar.INT | typeof CelScalar.UINT | typeof CelScalar.BOOL @@ -290,6 +383,8 @@ export function celType(v: CelValue): CelType { return mapType(CelScalar.DYN, CelScalar.DYN); case isCelUint(v): return CelScalar.UINT; + case isCelError(v): + return errorType(v); default: // This can also be a case statement, but TS fails to // narrow the type. @@ -311,3 +406,269 @@ export function isCelType(v: unknown): v is CelType { export function isObjectCelType(v: NonNullable): v is CelType { return privateSymbol in v; } + +export function isDynCelType( + v: CelType, +): v is typeof CelScalar.DYN | CelObjectType { + switch (v.kind) { + case "scalar": + return v.scalar === "dyn"; + case "object": + return v.desc.typeName === AnySchema.typeName; + default: + return false; + } +} + +export function isErrorCelType(v: CelType): v is CelErrorType { + return isCelType(v) && v.kind === "error"; +} + +export function isDynOrErrorCelType( + v: CelType, +): v is CelErrorType | typeof CelScalar.DYN | CelObjectType { + return isErrorCelType(v) || isDynCelType(v); +} + +export function optionalCelType(paramType: CelType): CelOpaqueType { + return opaqueType("optional_type", [paramType]); +} + +export function isOptionalCelType(v: CelType): v is CelOpaqueType { + switch (v.kind) { + case "opaque": + return v.name === "optional_type"; + default: + return false; + } +} + +export function maybeUnwrapOptionalCelType(v: CelType): CelType { + if (isOptionalCelType(v)) { + return v.parameters[0]; + } + return v; +} + +/** + * Creates an instance of a nullable type with the provided wrapped type. + * + * Note: only primitive types are supported as wrapped types. + */ +export function nullableType(paramType: CelType): CelNullableType { + return { + ...paramType, + wrapped: paramType, + }; +} + +export function isNullableCelType(v: CelType): v is CelNullableType { + return isCelType(v) && "wrapped" in v && isCelType(v.wrapped); +} + +/** + * isExactType indicates whether the two types are exactly the same. This + * check also verifies type parameter type names. + */ +export function isExactCelType(self: CelType, other: CelType): boolean { + return _isTypeInternal(self, other, true); +} + +/** + * isEquivalentType indicates whether two types are equivalent. This check + * ignores type parameter type names. + */ +export function isEquivalentCelType(self: CelType, other: CelType): boolean { + return _isTypeInternal(self, other, false); +} + +function _isTypeInternal( + self: CelType, + other: CelType, + checkTypeParamName: boolean, +): boolean { + if (self === other) { + return true; + } + if (self.kind !== other.kind) { + return false; + } + if ((checkTypeParamName || self.kind != "type") && self.name != other.name) { + return false; + } + switch (self.kind) { + case "list": + return _isTypeInternal( + self.element, + (other as CelListType).element, + checkTypeParamName, + ); + case "map": + return ( + _isTypeInternal( + self.key, + (other as CelMapType).key, + checkTypeParamName, + ) && + _isTypeInternal( + self.value, + (other as CelMapType).value, + checkTypeParamName, + ) + ); + case "type": + return _isTypeInternal( + self.type, + (other as CelTypeType).type, + checkTypeParamName, + ); + case "object": + return self.desc.typeName === (other as CelObjectType).desc.typeName; + case "scalar": + return (self as CelScalarType).scalar === (other as CelScalarType).scalar; + default: + return false; + } +} + +/** + * A mapping of Protobuf Scalar types to CEL types. + */ +// biome-ignore format: indented for readability +export const ProtoScalarCELPrimitives = { + [ScalarType.BOOL]: CelScalar.BOOL, + [ScalarType.BYTES]: CelScalar.BYTES, + [ScalarType.DOUBLE]: CelScalar.DOUBLE, + [ScalarType.FLOAT]: CelScalar.DOUBLE, + [ScalarType.INT32]: CelScalar.INT, + [ScalarType.INT64]: CelScalar.INT, + [ScalarType.SINT32]: CelScalar.INT, + [ScalarType.SINT64]: CelScalar.INT, + [ScalarType.UINT32]: CelScalar.UINT, + [ScalarType.UINT64]: CelScalar.UINT, + [ScalarType.FIXED32]: CelScalar.UINT, + [ScalarType.FIXED64]: CelScalar.UINT, + [ScalarType.SFIXED32]: CelScalar.INT, + [ScalarType.SFIXED64]: CelScalar.INT, + [ScalarType.STRING]: CelScalar.STRING, +} as const; + +export const CheckedWellKnownCELTypes: Record = { + // Wrapper types. + "google.protobuf.BoolValue": nullableType(CelScalar.BOOL), + "google.protobuf.BytesValue": nullableType(CelScalar.BYTES), + "google.protobuf.DoubleValue": nullableType(CelScalar.DOUBLE), + "google.protobuf.FloatValue": nullableType(CelScalar.DOUBLE), + "google.protobuf.Int64Value": nullableType(CelScalar.INT), + "google.protobuf.Int32Value": nullableType(CelScalar.INT), + "google.protobuf.UInt64Value": nullableType(CelScalar.UINT), + "google.protobuf.UInt32Value": nullableType(CelScalar.UINT), + "google.protobuf.StringValue": nullableType(CelScalar.STRING), + // Well-known types. + "google.protobuf.Any": objectType(AnySchema), + "google.protobuf.Timestamp": TIMESTAMP, + "google.protobuf.Duration": DURATION, + // Json types. + "google.protobuf.ListValue": listType(CelScalar.DYN), + "google.protobuf.NullValue": CelScalar.NULL, + "google.protobuf.Struct": mapType(CelScalar.STRING, CelScalar.DYN), + "google.protobuf.Value": CelScalar.DYN, +} as const; + +export function fieldDescToCelType(field: DescField) { + switch (field.fieldKind) { + case "scalar": + return ProtoScalarCELPrimitives[field.scalar]; + case "enum": + return CelScalar.INT; + case "message": + if (CheckedWellKnownCELTypes[field.message.typeName]) { + return CheckedWellKnownCELTypes[field.message.typeName]; + } + return objectType(field.message); + case "list": + switch (field.listKind) { + case "enum": + return listType(CelScalar.INT); + case "message": + return listType(objectType(field.message)); + case "scalar": + return listType(ProtoScalarCELPrimitives[field.scalar]); + } + case "map": + const keyType = ProtoScalarCELPrimitives[field.mapKey]; + switch (field.mapKind) { + case "enum": + return mapType(keyType, CelScalar.INT); + case "message": + return mapType(keyType, objectType(field.message)); + case "scalar": + return mapType(keyType, ProtoScalarCELPrimitives[field.scalar]); + } + } +} + +/** + * isAssignableType determines whether the current type is type-check assignable from the input fromType. + */ +export function isAssignableType(t: CelType, fromType: CelType): boolean { + if (isExactCelType(t, CelScalar.NULL)) { + return isAssignableType(CelScalar.NULL, fromType); + } + if (isNullableCelType(t)) { + return isAssignableType(t.wrapped, fromType); + } + return defaultIsAssignableType(t, fromType); +} + +/** + * defaultIsAssignableType provides the standard definition of what it means for one type to be assignable to another + * where any of the following may return a true result: + * - The from types are the same instance + * - The target type is dynamic + * - The fromType has the same kind and type name as the target type, and all parameters of the target type + * are isAssignableType() from the parameters of the fromType. + */ +function defaultIsAssignableType(t: CelType, fromType: CelType): boolean { + if (t === fromType || isDynCelType(t)) { + return true; + } + if (t.kind !== fromType.kind || t.name !== fromType.name) { + return false; + } + switch (t.kind) { + case "list": + if (fromType.kind !== "list") { + return false; + } + return isAssignableType(t.element, fromType.element); + case "map": + if (fromType.kind !== "map") { + return false; + } + return ( + isAssignableType(t.key, fromType.key) && + isAssignableType(t.value, fromType.value) + ); + case "type": + if (fromType.kind !== "type") { + return false; + } + return isAssignableType(t.type, fromType.type); + case "opaque": + if (fromType.kind !== "opaque") { + return false; + } + if (t.parameters.length !== fromType.parameters.length) { + return false; + } + for (let i = 0; i < t.parameters.length; i++) { + if (!isAssignableType(t.parameters[i], fromType.parameters[i])) { + return false; + } + } + return true; + default: + return true; + } +} diff --git a/packages/example/src/example.ts b/packages/example/src/example.ts index e14e05c0..3d00f6b3 100644 --- a/packages/example/src/example.ts +++ b/packages/example/src/example.ts @@ -52,6 +52,8 @@ console.log(result); // true const similar = celFunc("similar", [ celOverload( + // Overload name. + "similar_string_string", // Parameter types. [CelScalar.STRING, CelScalar.STRING], // Return type.