Skip to content

Add type checking for ident expressions#279

Merged
srikrsna-buf merged 7 commits intobufbuild:mainfrom
jafaircl:idents
Feb 11, 2026
Merged

Add type checking for ident expressions#279
srikrsna-buf merged 7 commits intobufbuild:mainfrom
jafaircl:idents

Conversation

@jafaircl
Copy link
Contributor

@jafaircl jafaircl commented Feb 3, 2026

This PR adds type checking for ident expressions

Comment on lines 155 to 158
const ident = this.env.variables.find(name);
if (ident) {
return ident;
}
Copy link
Member

Choose a reason for hiding this comment

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

I think we should always resolve candidate names because there is a well defined order for them

Suggested change
const ident = this.env.variables.find(name);
if (ident) {
return ident;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think when we get to macros it will be an issue. The macro vars can shadow global namespaced vars

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Revisiting the go code this is based on, they make it clearer that this early return should be a local variable and explicitly not a global. I've updated this method and VariableScope to make the different flavors of variable resolution more explicit.

Copy link
Member

@srikrsna-buf srikrsna-buf left a comment

Choose a reason for hiding this comment

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

From the Name Resolution section of the spec:

Note: Comprehensions (.exists, .all, etc) introduce new scopes that add variables as simple identifiers. When resolving a name within a comprehension body, the name is first compared against the variables declared by the comprehension. If there is a match, the name resolves to that variable, taking precedence over the package-based resolution rules above. If not, resolution proceeds checking for variable matches in parent comprehension scopes recursively. Finally the name follows the package resolution rules above against declarations in the environment. A name with a leading '.' always resolves in the root scope, bypassing local scopes from comprehensions.
For example, in [1].exists(x, x == 1), x is a local variable which shadows any identifier named 'x' in ancestor scopes or the package namespace. In [1].exists(x, .x == 1), x is the global variable x.

My understanding of this is to have scope chain for comprehensions and first look in them if the variable name doesn't start with .. If not found in the comprehension scopes or if name starts with ., follow the package resolution rules and look for them in the root scope.

I think we can simplify this by separating the VariableScopes for comprehensions and simply using the root scope as a backup. This gives a clear direction of how it is being used. The root variables need not be in a scope either. They can simply be a readonly map and the scope type can be internal.

@jafaircl
Copy link
Contributor Author

My understanding of this is to have scope chain for comprehensions and first look in them if the variable name doesn't start with .. If not found in the comprehension scopes or if name starts with ., follow the package resolution rules and look for them in the root scope.

This explains some of my confusion about the cel-go code regarding when requiresDisambiguation would be necessary. We can implement in the same way if that sounds good. A private attributeResolution type extends CelType and the dot prefix can be added/removed as necessary to resolve variables while retaining the original intention from the source expression.

I think we can simplify this by separating the VariableScopes for comprehensions and simply using the root scope as a backup. This gives a clear direction of how it is being used. The root variables need not be in a scope either. They can simply be a readonly map and the scope type can be internal.

A VariableScope is basically a readonly map already. It seems like a distinction without a difference that will increase complexity to make the root scope use a different data structure than any other scope. I don't see the benefit in deviating significantly from the reference implementation in regards to this.

@srikrsna-buf
Copy link
Member

I am not sure about the cel-go implementation but we can keep the VariableScope for now and use two separate chains, one an immutable root that expects the results of resolveCandidateNames and other a mutable chain which resets to an empty scope at the beginning of the type checking just like the two maps.

@jafaircl
Copy link
Contributor Author

Ok just pushed some changes. I also found why I hadn't noticed that section of the spec before: it was just added last month

MessageInitShape<typeof ReferenceSchema>
> = new Map();
private readonly typeMap: Map<bigint, CelType> = new Map();
private scope: VariableScope | undefined;
Copy link
Member

Choose a reason for hiding this comment

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

We are always creating the scope so this need not be undefined. Instead we can create a singleton with no variables and use that as a reset.

const { type, requiresDisambiguation } = this.resolveSimpleVariableType(
ident.name,
);
if (type) {
Copy link
Member

Choose a reason for hiding this comment

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

Inverting the check and throwing the error early will reduce nesting for the happy path

Comment on lines 172 to 199
private resolveSimpleVariableType(name: string): {
type: CelType | undefined;
requiresDisambiguation: boolean;
} {
const local = this.scope?.find(name);
// Local variables that do not start with a "." shadow global variables
// and do not require disambiguation.
if (local !== undefined && !name.startsWith(".")) {
return {
type: local,
requiresDisambiguation: false,
};
}
for (const candidate of resolveCandidateNames(this.env.namespace, name)) {
const type = this.env.variables.find(candidate);
if (type) {
return {
type,
requiresDisambiguation: local !== undefined,
};
}
}
return {
type: undefined,
requiresDisambiguation: false,
};
}
}
Copy link
Member

Choose a reason for hiding this comment

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

We can slightly rewrite the function to avoid the boolean and return an optimized name when needed:

Suggested change
private resolveSimpleVariableType(name: string): {
type: CelType | undefined;
requiresDisambiguation: boolean;
} {
const local = this.scope?.find(name);
// Local variables that do not start with a "." shadow global variables
// and do not require disambiguation.
if (local !== undefined && !name.startsWith(".")) {
return {
type: local,
requiresDisambiguation: false,
};
}
for (const candidate of resolveCandidateNames(this.env.namespace, name)) {
const type = this.env.variables.find(candidate);
if (type) {
return {
type,
requiresDisambiguation: local !== undefined,
};
}
}
return {
type: undefined,
requiresDisambiguation: false,
};
}
}
private resolveSimpleVariableType(name: string): {
type: CelType;
name: string;
} | undefined {
if (name.startsWith(".")) {
const local = this.scope.find(name);
// Local variables that do not start with a "." shadow global variables
// and do not require disambiguation.
if (local !== undefined) {
return {
type: local,
name,
};
}
}
for (const candidate of resolveCandidateNames(this.env.namespace, name)) {
const type = this.env.variables.find(candidate);
if (type) {
return {
type,
name: "." + candidate,
};
}
}
return undefined;
}

And in the ident check we can simply use the name that we get back

Comment on lines 108 to 113
findInScope(name: string): CelType | undefined {
if (name.startsWith(".")) {
return this._variables.get(name.slice(1));
}
return this._variables.get(name);
}
Copy link
Member

Choose a reason for hiding this comment

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

This seems wrong, we should not do the check if there is a .. See the function update comment above.

@srikrsna-buf srikrsna-buf merged commit e7c1ad3 into bufbuild:main Feb 11, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments