Type inference 2.0 - #21795
Conversation
8ca252c to
30be9c4
Compare
a9b24ec to
15c4c30
Compare
aefd835 to
12256f3
Compare
657b890 to
8d0c5a3
Compare
654fd25 to
1d071ac
Compare
2694a80 to
8093c96
Compare
8093c96 to
96a5210
Compare
981f66e to
ba8029f
Compare
c3189e9 to
04100d4
Compare
d518fe7 to
d96e11b
Compare
13023aa to
d5fa0a3
Compare
d5fa0a3 to
02d48fe
Compare
eb47861 to
6f3e3ba
Compare
ff6a4e0 to
a43fd10
Compare
There was a problem hiding this comment.
Pull request overview
Refactors Rust type inference around the shared library’s new bottom-up and contextual inference architecture.
Changes:
- Adds shared AST inference, contextual typing, closure handling, and diagnostics.
- Reimplements Rust inference through the shared
Make3interface. - Updates Rust tests and consistency expectations.
Show a summary per file
| File | Description |
|---|---|
shared/util/codeql/util/UnboundList.qll |
Adds list append helper. |
shared/typeinference/codeql/typeinference/internal/TypeInference.qll |
Implements shared inference framework. |
rust/ql/test/library-tests/type-inference/type-inference.ql |
Uses shared type-test support. |
rust/ql/test/library-tests/type-inference/pattern_matching.rs |
Updates inference expectations. |
rust/ql/test/library-tests/type-inference/overloading.rs |
Records contextual inference regression. |
rust/ql/test/library-tests/type-inference/main.rs |
Updates coverage and expectations. |
rust/ql/test/library-tests/type-inference/dereference.rs |
Exercises inferred generic arguments. |
rust/ql/test/library-tests/type-inference/CONSISTENCY/PathResolutionConsistency.expected |
Updates generated consistency output. |
rust/ql/test/library-tests/type-inference/closure.rs |
Updates closure expectations. |
rust/ql/test/library-tests/dataflow/sources/web_frameworks/CONSISTENCY/TypeInferenceConsistency.expected |
Updates generated consistency output. |
rust/ql/test/library-tests/dataflow/models/CONSISTENCY/PathResolutionConsistency.expected |
Updates generated consistency output. |
rust/ql/lib/codeql/rust/internal/typeinference/TypeMention.qll |
Adds contextual and constructor type mentions. |
rust/ql/lib/codeql/rust/internal/typeinference/TypeInferenceConsistency.qll |
Adopts shared consistency checks. |
rust/ql/lib/codeql/rust/internal/typeinference/TypeInference.qll |
Adapts Rust inference to Make3. |
rust/ql/lib/codeql/rust/internal/typeinference/Type.qll |
Introduces generalized pseudo-types. |
rust/ql/lib/codeql/rust/internal/typeinference/FunctionType.qll |
Generalizes pseudo-type filtering. |
rust/ql/lib/codeql/rust/internal/typeinference/BlanketImplementation.qll |
Generalizes pseudo-type filtering. |
rust/ql/lib/codeql/rust/internal/CachedStages.qll |
Uses the shared inference cache stage. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Suppressed comments (1)
shared/typeinference/codeql/typeinference/internal/TypeInference.qll:3345
- Remove the duplicated article.
* Holds if the the textual representation `repr` should be used for `n` in
- Files reviewed: 17/19 changed files
- Comments generated: 4
- Review effort level: Balanced
paldepind
left a comment
There was a problem hiding this comment.
We no longer have a dedicated NeverType for ! typed expressions; instead we simply use UnknownType to indicate that the actual type must be inferred from the context.
It's not clear to me why ! requires contextual inference? Could we add a test that demonstrates the need?
Percentage of calls with call target increases by 3.9 % point from 84.9 % to 88.8 % (and, as a result, number of alerts increases as well).
That's a really nice improvement! Do we know why the increase is this large? Is it due to the changes for closures?
| let arr1: [i32; 0] = []; // $ type=arr1@[;]<TArray>:i32 | ||
| let arr2 = [true; 0]; // $ type=arr2@[;]<TArray>:bool | ||
| let arr3 = []; // $ type=arr3@[;]<TArray>:i32 | ||
|
|
There was a problem hiding this comment.
Maybe move the blank line up before arr3 to make it clear that the last three lines are connected?
There was a problem hiding this comment.
The space after arr3 is needed since otherwise the inline expectation framework thinks that // $ type=arr3@[;]<TArray>:i32 is also assigning a name to pin_array 🤦 But I'll put in an extra linebreak.
| pub fn f() -> usize { | ||
| let mut x = 0; | ||
| x = x.f(); // $ target=usizef $ SPURIOUS: target=i32f | ||
| x = x.f(); // $ MISSING: target=usizef $ SPURIOUS: target=i32f |
There was a problem hiding this comment.
Previously, we would push the return type annotation usize onto x, but now we only do that when explicit contextual information is needed.
| * Gets the list obtained by appending the singleton list `e` | ||
| * after `prefix`. |
There was a problem hiding this comment.
Pedantically speaking e is an element an not a singleton list.
| * Gets the list obtained by appending the singleton list `e` | |
| * after `prefix`. | |
| * Gets the list obtained by appending the element `e` after `prefix`. |
Perhaps we should also change the doc for cons to say: "Gets the list obtained by prepending the element e onto suffix"?
|
|
||
| /** | ||
| * A variable, or an entity that behaves like a variable with respect to | ||
| * type inference, for example a local variable, `const`, or `static` in Rust. |
There was a problem hiding this comment.
| * type inference, for example a local variable, `const`, or `static` in Rust. | |
| * type inference, for example a local variable, `const` item, or `static` item in Rust. |
Seems a little clearer to me since these keywords have multiple uses in Rust. For instance, static can also be used for lifetimes.
| /** A declaration. */ | ||
| class Declaration extends AstNode { | ||
| /** | ||
| * Gets the type at `path` of the entity that contains this declaration, if any. |
There was a problem hiding this comment.
| * Gets the type at `path` of the entity that contains this declaration, if any. | |
| * Gets the type mention of the entity that contains this declaration, if any. |
| * By default, this is the declared type of `c` at `path`, but in for example Rust, | ||
| * `async` functions must have their return type wrapped in a `Future` type. | ||
| */ | ||
| default Type getCallableReturnType(Callable c, TypePath path) { |
There was a problem hiding this comment.
We could also do this adjustment on the type mentions in return position in async functions in Rust. Then we could remove this predicate.
There was a problem hiding this comment.
I thought about doing that, but then also thought it would be a bit weird to have explicitly mentioned types that resolve to something different from what is actually mentioned. If we had an AST node for the async modifier we could have used that as a representative, but sadly we don't.
| * | ||
| * Use this predicate to implement any language-specific bottom-up inference logic. | ||
| */ | ||
| predicate stepLanguageSpecific(AstNode n1, TypePath prefix1, AstNode n2, TypePath prefix2); |
There was a problem hiding this comment.
The doc states this as an implication, but since we reverse the steps for contextual types, it must in fact be a biimplication.
Subjectively I'd still prefer the typeEqual name we had before. It still represents the idea that two nodes have equal types (for some prefixes) but with an added directionality that n1 should be below n2 in the AST.
With that name the doc could be something like the following:
Holds if the type tree of
n1atprefix1should be equal to the type tree ofn2atprefix2andn1is belown2in the AST.Type information always flow right-to-left/bottom-up through type equalities. When contextual type inference is needed, type information additionally flows left-to-right/top-down through type equalities.
There was a problem hiding this comment.
How about if I make it clear in the QL doc that it is sometimes reversed as well? I don't think we want to say that n1 is necessarily below n2 in the AST; while it is true for expressions, the opposite is true for patterns.
| exists(LogicalAndExpr lae | n = [lae, lae.getLeftOperand(), lae.getRightOperand()]) or | ||
| exists(LogicalOrExpr loe | n = [loe, loe.getLeftOperand(), loe.getRightOperand()]) | ||
| ) and | ||
| result instanceof BoolType and |
There was a problem hiding this comment.
I guess in the future we'd want to guard this, as there's many languages where logical operators doesn't necessarily evaluate to booleans.
| * } | ||
| * ```rust | ||
| * let x = if cond { Default::default() } else { Default::default() }; | ||
| * let y : i64 = x; |
There was a problem hiding this comment.
| * let y : i64 = x; | |
| * let y: i64 = x; |
| infersCertainTypeAt(n, path, result.getATypeParameter()) | ||
| ) and | ||
| // type annotation may for example include unknown types, such as | ||
| // `x : Vec<_>` in Rust |
There was a problem hiding this comment.
| // `x : Vec<_>` in Rust | |
| // `x: Vec<_>` in Rust |
This PR makes a significant overhaul of our QL based implementation of type inference for Rust (hence the tacky PR title). At a high level, a lot of code is moved from the Rust codebase to the shared type inference library (in preparation for unified/Swift), and there is now a very clear distinction between bottom-up type inference and top-down (contextual) type inference.
Before this PR
Shared logic for mapping AST node to types
We introduce a new
Make3parameterization layer to the shared type inference library, which takes as input a definition of AST nodes, including common concepts such as calls and callables, as well as language-specific typing rules, and constructs theinferTypepredicate for recursively inferring the types of AST nodes.The input signature of
Make3is deliberately similar to that of the shared CFG library, and it may be possible to align them at some point.The shared library takes care of typing of many standard constructs such as calls and field accesses, and also has logic for contextual typing and typing of closures.
Bottom-up vs top-down inference
Perhaps the most important change is that we now distinguish between bottom-up type inference (the default) and top-down type inference. For example, in order to infer the type of a conditional expression,
if cond { e1 } else { e2 }, we propagate type information from either of the branchese1ande2into the conditional expression (for simplicity, we do not attempt to calculate least-upper-bound types or similar). This corresponds to the two bottom-up type inference rules:Now, if we have a conditional expression like
where the type of
Default::default()needs to be inferred from the context, wei64, using thecond-thenrule,Default::default()the specialUnknownType(the shared library has logic for identifying calls where (parts of) the return type needs to be inferred from the context), andelsebranch hasUnknownType, we apply thecond-elserule backwards to infer thatDefault::default()has typei64.Note that
UnknownTypecan propagate bottom-up like any other type, which is needed in cases like for examplewhere the
UknownTypewill propagate upwards using two bottom-up steps, and the contextual inference will then propagate thei64type backwards using two reversed steps.Reversal of bottom-up steps happens inside the
ContextualTyping::inferTypeContextualCand0predicate, and contextual propagation into a nodenat type pathpathis only allowed whennhasUnknownTypeat some prefix ofpath, and furthermore ifpathis non-empty, then it must be compatible with an already inferred type (contextually or not). The latter part means that the Rust-specific typing rule for*eexpressions, whenehas a raw pointer type, can be handled by a single bottom-up rule (the first disjunct ofstepLanguageSpecific) instead of two rules in the old implementation.Simplified and shared certain type inference
The logic for inferring types with certainty has been moved inside the shared library (
Make3::Certain), but we no longer attempt to infer certain type inference for calls. This simplifies the implementation significantly, but without resulting in combinatorial explosions because the revised handling of contextual inference is much less prone to explosions.Improved and shared handling of closure typing
Closures typically need to have their parameter types inferred from the context in which they are used. There are two ways for type information to flow contextually into a closure parameter: (A) either by knowing the types of arguments, or (B) by knowing the return type. While we could assign closure parameters the
UnknownType, this would mean that they could also have their type inferred from the closure body, which we want to avoid (as it can result in combinatorial explosions).Case A
cis assigned the typeFn(UnknownType) -> ...,0has typei32, we can infer thechas typeFn(i32) -> ..., andxhas typei32.Case B
xis assigned a special pseudo typeT_x,cis(T_x, bool)and hence thatchas typeFn(...) -> (T_x, bool),cthe typeFn(...) -> (UnknownType, bool),c(Default::default()).0must haveUnknownType,chas typeFn(...) -> (i32, bool), and finallycalso has typeFn(...) -> (T_x, bool), we conclude thatxhas typei32and hence thatchas typeFn(i32) -> (i32, bool).Improved and shared handling of type arguments and type qualifiers
Type qualifiers and type arguments are now distinguished, so for example in
Foo::<A>::bar::<B>(...),Foo::<A>is the type qualifier andBis the only explicit type argument (we used to also considerAa type argument). This means that type arguments only need positional matching, and henceTypeArgumentPositionis no longer needed.Other minor changes
NeverTypefor!typed expressions; instead we simply useUnknownTypeto indicate that the actual type must be inferred from the context.asyncreturn types are now also taken into account for closures.FunctionOverloading.qllhave changed a column type fromTypeParametertoTypeParamTypeParameter; the reason is that all associated types, which are also modeled as type parameters, are functionally determined from theSelftype, which we already check.Note for the reviewer
As usual, commit-by-commit reviewing is encouraged. The second commit (which compiles and works) moves a bunch of logic around in the Rust implementation, which is then removed in the subsequent commit (which doesn't compile). I found that doing it like this resulted in a cleaner diff on the last commit, and it also makes it more clear some of the parts that are now handled by shared code.
Impact
Percentage of calls with call targetincreases by 3.9 % point from 84.9 % to 88.8 % (and, as a result, number of alerts increases as well).Nodes With Type At Length Limit, which measures type inference explosions, decreases by almost 80 % from 219,692 to 46,302.Analysis timeis mostly unchanged.run_queriestimings unchanged.Future work