[ty] Smarter semantic token classification for attribute access on union type#23841
[ty] Smarter semantic token classification for attribute access on union type#23841Geo5 wants to merge 4 commits intoastral-sh:mainfrom
Conversation
access on objects with a union type. Mirrors the semantic_tokens::tests::attribute_classification test. Also fixes that test in regards to `property` classification. Previously the comment did not match the result.
on union types. Update snapshot test from previous commit to the desired results.
crates/ty_ide/src/semantic_tokens.rs
Outdated
| // type variant and if so, recursively call `classify_from_type_for_attribute` | ||
| // for that variant. | ||
| if let Type::Union(union) = ty | ||
| && let Some(classification) = (|| { |
There was a problem hiding this comment.
I could not find any previously existing function that checks for this, so i decided to only use a closure here instead of adding it to UnionType or Type itself
crates/ty_ide/src/semantic_tokens.rs
Outdated
| y = obj.method # method should be method (bound method) | ||
| z = obj.CONSTANT # CONSTANT should be variable with readonly modifier | ||
| w = obj.prop # prop should be property | ||
| w = obj.prop # prop should be variable on an instance |
There was a problem hiding this comment.
Let's leave this comment as is. I do think we want to classify this as a property and I find variable on an instance more difficult to understand. We can rephrase it to instance property if you prefer that.
crates/ty_ide/src/semantic_tokens.rs
Outdated
| if Self::is_constant_name(attr_name_str) { | ||
| modifiers |= SemanticTokenModifier::READONLY; | ||
| } | ||
| // If we got a union type, check if all elements of the union belong to the same |
There was a problem hiding this comment.
The issue with foobar.prop is that ty isn't the type of the value (that is, the type of foobar) but the type of foobar.prop and foobar.pop inferes to Unknown (because the properties lack a return type annotation).
If we change the code snippet like so
from random import random
class Foo:
@property
def prop(self) -> int:
return 4
class Bar:
@property
def prop(self) -> int:
return 3
foobar: Foo | Bar = Foo() if random() else Bar()
w = foobar.prop # prop should be variable on an instancethen ty is int as expected.
Which makes me question if the fix here is correct. I think what we want is to infer the type of foobar and if that's a union, classify variant.attr for each union variant.
There was a problem hiding this comment.
Mhm yes, i agree with your analysis, but i don't think the semantic token type changes between Unknown and int, because the same match arm will be taken?
So i think the match arm to check for a property here does not do the correct thing for foobar.prop as you said, but i think that preexists this change and is also happening in the preexisting, other test where i changed the comment.
Regarding your second sentence; i think this is already done somewhat by the type inference anyway? Since at least for foobar.method the inferred type is a union of bound methods, which we can than classify.
Would you be fine with deferring the bugfix for property classification or would you rather have it fixed in this PR? (I feel like that fix could be more involved than the simple loop i added here until now 😅 )
There was a problem hiding this comment.
I don't think the fix requires that many changes. It's probably something like:
let value_ty = value.inferred_type(model);
if let Some(union) = value_ty.as_union() {
// classify each variant
// if all classify the same, return early
}
// Old, unchanged logic:
let ty = expr.inferred_type(model);
...
There was a problem hiding this comment.
I thought a bit more about this and i think this change alone would still not be perfect, consider this case:
from random import random
class Baz:
if random():
@property
def prop(self) -> int:
return 42
else:
@property
def prop(self) -> str:
return "hello"
baz = Baz()
x = baz.propwhere the value_ty would not be a union.
(There may actually be a real bug in type inference for the above example: astral-sh/ty#3032)
I think to fix the property classification you would need to look up the attribute name on the class of the object and check if it is defined with @property. Is that something that is already supported by some function you are aware of in the codebase?
crates/ty_ide/src/semantic_tokens.rs
Outdated
| } else { | ||
| None | ||
| } | ||
| })() |
There was a problem hiding this comment.
What's the reason that this is an immediately invoked lambda?
There was a problem hiding this comment.
I thought i could combine it with the outer match somehow, but iiuc r-a transformed it to a "if let guard" which seems to only be stabilized very recently. I'll change it.
There was a problem hiding this comment.
lmk if you find the functional style even less appealing 😅
- Move union check above match
Summary
ref ty/#791
Updates the semantic token classification for attribute access on union types. It now checks if all elements of the union belong to the same type variant and if so, recursively tries to classify that common variant. Imo this produces nicer results especially when calling a method on an object of some union type.
I decided not to specifically check if all union elements are of some fixed set of variants (e.g. only
Type::BoundMethodetc), because i felt like duplicating the logic may be brittle. That means, that some hypothetical type ofUnion[Union[BoundMethod, BoundMethod], Union[something_else, something_else]]may be misclassified asMethodin this case, by i am unsure if it is possible to create such a type.
I also did not change behavior for free function classification, because i could not think of a way where this matters, i.e. creating an alias to a function, already changes the classification to `Variable` without involving unions at all:https://play.ty.dev/d8acae76-5642-4841-b4cf-8fd6066cea87
The first commit adds a unit test, the second commit implements the change and updates the snapshot.
I also added a line in the existing
semantic_token::test::attribute_classificationtest, where previously the comment said thatobj.propshould be classified asProperty, but was classified asVariableand there was no test forMyClass.prop, which actually classifies asProperty. Happy to change this back, if this changes the intention!Test Plan
Added this unit test to
semantic_tokens.rs:Current behavior: https://play.ty.dev/48bd1df0-42e8-43a3-9164-cd7779300019
The implementation surprisingly only changes the
foobar.methodandfoobar_cls.methodclassifications, not thefoobar_cls.propclassification, which must somehow already be handled by this match arm:ruff/crates/ty_ide/src/semantic_tokens.rs
Lines 467 to 470 in e4dfddc