Layer UnionDiagnostic and IntersectionDiagnostic for nested types
When an intersection fails inside a union, report errors with both union and intersection context: - Add LayeredDiagnostic that combines both contexts - Show the correct intersection type (not the full union) - Include test with snapshot diagnostics demonstrating the layered output This addresses review comment 9 from PR #22469.
This commit is contained in:
@@ -834,3 +834,34 @@ def _(flag: bool):
|
||||
# error: [invalid-argument-type] "Argument to function `f` is incorrect: Expected `T`, found `dict[str, int] & dict[Unknown | str, Unknown | int]`"
|
||||
f({"y": 1})
|
||||
```
|
||||
|
||||
## Union of intersections with failing bindings
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
When calling a union where one element is an intersection of callables, and all bindings
|
||||
in that intersection fail, we should report errors with both union and intersection context.
|
||||
|
||||
```py
|
||||
from ty_extensions import Intersection
|
||||
from typing import Callable
|
||||
|
||||
class IntCaller:
|
||||
def __call__(self, x: int) -> int:
|
||||
return x
|
||||
|
||||
class StrCaller:
|
||||
def __call__(self, x: str) -> str:
|
||||
return x
|
||||
|
||||
class BytesCaller:
|
||||
def __call__(self, x: bytes) -> bytes:
|
||||
return x
|
||||
|
||||
def test(f: Intersection[IntCaller, StrCaller] | BytesCaller):
|
||||
# Call with None - should fail for IntCaller, StrCaller, and BytesCaller
|
||||
# error: [invalid-argument-type]
|
||||
# error: [invalid-argument-type]
|
||||
# error: [invalid-argument-type]
|
||||
f(None)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
source: crates/ty_test/src/lib.rs
|
||||
assertion_line: 623
|
||||
expression: snapshot
|
||||
---
|
||||
|
||||
---
|
||||
mdtest name: union.md - Unions in calls - Union of intersections with failing bindings
|
||||
mdtest path: crates/ty_python_semantic/resources/mdtest/call/union.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | from ty_extensions import Intersection
|
||||
2 | from typing import Callable
|
||||
3 |
|
||||
4 | class IntCaller:
|
||||
5 | def __call__(self, x: int) -> int:
|
||||
6 | return x
|
||||
7 |
|
||||
8 | class StrCaller:
|
||||
9 | def __call__(self, x: str) -> str:
|
||||
10 | return x
|
||||
11 |
|
||||
12 | class BytesCaller:
|
||||
13 | def __call__(self, x: bytes) -> bytes:
|
||||
14 | return x
|
||||
15 |
|
||||
16 | def test(f: Intersection[IntCaller, StrCaller] | BytesCaller):
|
||||
17 | # Call with None - should fail for IntCaller, StrCaller, and BytesCaller
|
||||
18 | # error: [invalid-argument-type]
|
||||
19 | # error: [invalid-argument-type]
|
||||
20 | # error: [invalid-argument-type]
|
||||
21 | f(None)
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error[invalid-argument-type]: Argument to bound method `__call__` is incorrect
|
||||
--> src/mdtest_snippet.py:21:7
|
||||
|
|
||||
19 | # error: [invalid-argument-type]
|
||||
20 | # error: [invalid-argument-type]
|
||||
21 | f(None)
|
||||
| ^^^^ Expected `int`, found `None`
|
||||
|
|
||||
info: Method defined here
|
||||
--> src/mdtest_snippet.py:5:9
|
||||
|
|
||||
4 | class IntCaller:
|
||||
5 | def __call__(self, x: int) -> int:
|
||||
| ^^^^^^^^ ------ Parameter declared here
|
||||
6 | return x
|
||||
|
|
||||
info: Intersection element `IntCaller` is incompatible with this call site
|
||||
info: Attempted to call intersection type `IntCaller & StrCaller`
|
||||
info: Attempted to call union type `(IntCaller & StrCaller) | BytesCaller`
|
||||
info: rule `invalid-argument-type` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-argument-type]: Argument to bound method `__call__` is incorrect
|
||||
--> src/mdtest_snippet.py:21:7
|
||||
|
|
||||
19 | # error: [invalid-argument-type]
|
||||
20 | # error: [invalid-argument-type]
|
||||
21 | f(None)
|
||||
| ^^^^ Expected `str`, found `None`
|
||||
|
|
||||
info: Method defined here
|
||||
--> src/mdtest_snippet.py:9:9
|
||||
|
|
||||
8 | class StrCaller:
|
||||
9 | def __call__(self, x: str) -> str:
|
||||
| ^^^^^^^^ ------ Parameter declared here
|
||||
10 | return x
|
||||
|
|
||||
info: Intersection element `StrCaller` is incompatible with this call site
|
||||
info: Attempted to call intersection type `IntCaller & StrCaller`
|
||||
info: Attempted to call union type `(IntCaller & StrCaller) | BytesCaller`
|
||||
info: rule `invalid-argument-type` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-argument-type]: Argument to bound method `__call__` is incorrect
|
||||
--> src/mdtest_snippet.py:21:7
|
||||
|
|
||||
19 | # error: [invalid-argument-type]
|
||||
20 | # error: [invalid-argument-type]
|
||||
21 | f(None)
|
||||
| ^^^^ Expected `bytes`, found `None`
|
||||
|
|
||||
info: Method defined here
|
||||
--> src/mdtest_snippet.py:13:9
|
||||
|
|
||||
12 | class BytesCaller:
|
||||
13 | def __call__(self, x: bytes) -> bytes:
|
||||
| ^^^^^^^^ -------- Parameter declared here
|
||||
14 | return x
|
||||
|
|
||||
info: Union variant `BytesCaller` is incompatible with this call site
|
||||
info: Attempted to call union type `(IntCaller & StrCaller) | BytesCaller`
|
||||
info: rule `invalid-argument-type` is enabled by default
|
||||
|
||||
```
|
||||
@@ -538,19 +538,38 @@ impl<'db> Bindings<'db> {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_union = self.elements.len() > 1;
|
||||
|
||||
// For intersection elements, use priority hierarchy
|
||||
if element.is_intersection() {
|
||||
// Find the highest priority error among bindings in this element
|
||||
let max_priority = element.error_priority();
|
||||
|
||||
// Construct the intersection type from the bindings
|
||||
let intersection_type = IntersectionType::from_elements(
|
||||
context.db(),
|
||||
element.bindings.iter().map(|b| b.callable_type),
|
||||
);
|
||||
|
||||
// Only report errors from bindings with the highest priority
|
||||
for binding in &element.bindings {
|
||||
if binding.error_priority() == max_priority {
|
||||
let intersection_diag = IntersectionDiagnostic {
|
||||
callable_type: self.callable_type(),
|
||||
binding,
|
||||
};
|
||||
binding.report_diagnostics(context, node, Some(&intersection_diag));
|
||||
if is_union {
|
||||
// Use layered diagnostic for intersection inside a union
|
||||
let layered_diag = LayeredDiagnostic {
|
||||
union_callable_type: self.callable_type(),
|
||||
intersection_callable_type: intersection_type,
|
||||
binding,
|
||||
};
|
||||
binding.report_diagnostics(context, node, Some(&layered_diag));
|
||||
} else {
|
||||
// Just intersection, no union context needed
|
||||
let intersection_diag = IntersectionDiagnostic {
|
||||
callable_type: intersection_type,
|
||||
binding,
|
||||
};
|
||||
binding.report_diagnostics(context, node, Some(&intersection_diag));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -4954,6 +4973,52 @@ impl CompoundDiagnostic for IntersectionDiagnostic<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Contains both union and intersection context for layered diagnostics.
|
||||
///
|
||||
/// Used when an intersection fails inside a union - we want to report both
|
||||
/// that this is a union variant AND that this is an intersection element.
|
||||
struct LayeredDiagnostic<'b, 'db> {
|
||||
/// The type of the union.
|
||||
union_callable_type: Type<'db>,
|
||||
/// The type of the intersection (for intersection context).
|
||||
intersection_callable_type: Type<'db>,
|
||||
/// The specific binding that failed.
|
||||
binding: &'b CallableBinding<'db>,
|
||||
}
|
||||
|
||||
impl CompoundDiagnostic for LayeredDiagnostic<'_, '_> {
|
||||
fn add_context(&self, db: &dyn Db, diag: &mut Diagnostic) {
|
||||
// Add intersection context first (more specific)
|
||||
let sub = SubDiagnostic::new(
|
||||
SubDiagnosticSeverity::Info,
|
||||
format_args!(
|
||||
"Intersection element `{callable_ty}` is incompatible with this call site",
|
||||
callable_ty = self.binding.callable_type.display(db),
|
||||
),
|
||||
);
|
||||
diag.sub(sub);
|
||||
|
||||
let sub = SubDiagnostic::new(
|
||||
SubDiagnosticSeverity::Info,
|
||||
format_args!(
|
||||
"Attempted to call intersection type `{}`",
|
||||
self.intersection_callable_type.display(db)
|
||||
),
|
||||
);
|
||||
diag.sub(sub);
|
||||
|
||||
// Then add union context (outer layer)
|
||||
let sub = SubDiagnostic::new(
|
||||
SubDiagnosticSeverity::Info,
|
||||
format_args!(
|
||||
"Attempted to call union type `{}`",
|
||||
self.union_callable_type.display(db)
|
||||
),
|
||||
);
|
||||
diag.sub(sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the matching overload of a function literal that was found via the overload call
|
||||
/// evaluation algorithm.
|
||||
struct MatchingOverloadLiteral<'db> {
|
||||
|
||||
Reference in New Issue
Block a user