Skip to content
486 changes: 486 additions & 0 deletions packages/cel/src/checker/checker.test.ts

Large diffs are not rendered by default.

1,197 changes: 1,197 additions & 0 deletions packages/cel/src/checker/checker.ts

Large diffs are not rendered by default.

393 changes: 393 additions & 0 deletions packages/cel/src/checker/env.ts
Original file line number Diff line number Diff line change
@@ -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 {
Copy link

@hudlow hudlow Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recognize this may be painful, so feel free to push back (and I'd like to hear @timostamm and/or @srikrsna-buf's thoughts) but I'd like to avoid having multiple "environment" implementations. cel-go is really hard to work with, in part because for any given noun there are seven different abstractions all related to each other in some convoluted way.

For cel-es if we could follow the rule that "an environment is an environment" I think we'd be better for it.

The ontological relationships of "declarations," "scopes," "groups," "namespaces," and "registries" is also going to be a very steep learning curve for the uninitiated. Let's try to narrow the terminology and use the terms very crisply if possible.

[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<string>;
/**
* 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[];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just "variables"?

/**
* Whether to enforce homogenous types in aggregate literals.
*/
homogenousAggregateLiterals?: boolean;
/**
* Whether to allow cross-type numeric comparisons.
*/
crossTypeNumericComparisons?: boolean;
Comment on lines +131 to +134
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned on the other thread, I'm not convinced we need this flexibility.

}

const crossTypeNumericComparisonOverloads = new Set<string>([
// 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<string, CelIdent>();
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<string, CelFunc>();
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<string>();
}
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<string>,
) {}

/**
* 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"));
}
}
Comment on lines +223 to +254
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's eliminate mutation if possible, and require variables and functions to be provided when an environment or scope is created.


/**
* LookupIdent returns an identifier in the Env.
* Returns undefined if no such identifier is found in the Env.
*/
lookupIdent(name: string): CelIdent | undefined {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The compound word lookup is a noun, only — this should be lookUpIdent (or lookUpVariable)

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,
);
}
}
Loading