-
Notifications
You must be signed in to change notification settings - Fork 3
Add type checker #237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add type checker #237
Changes from all commits
42aece1
c80362d
c963eb2
83ad794
1d256bb
24701aa
6d67227
355c1a9
f18aada
53b0e8b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| 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 { | ||
| [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[]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The compound word |
||
| 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, | ||
| ); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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-gois 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-esif 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.