[ty] Make initializer calls GotoTargets (#20014)
Some checks are pending
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / python package (push) Waiting to run
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks-instrumented (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

This introduces `GotoTarget::Call` that represents the kind of
ambiguous/overloaded click of a callable-being-called:

```py
x = mymodule.MyClass(1, 2)
             ^^^^^^^
```

This is equivalent to `GotoTarget::Expression` for the same span but
enriched
with information about the actual callable implementation.

That is, if you click on `MyClass` in `MyClass()` it is *both* a
reference to the class and to the initializer of the class. Therefore
it would be ideal for goto-* and docstrings to be some intelligent
merging of both the class and the initializer.

In particular the callable-implementation (initializer) is prioritized
over the callable-itself (class) so when showing docstrings we will
preferentially show the docs of the initializer if it exists, and then
fallback to the docs of the class.

For goto-definition/goto-declaration we will yield both the class and
the initializer, requiring you to pick which you want (this is perhaps
needlessly pedantic but...).

Fixes https://github.com/astral-sh/ty/issues/898
Fixes https://github.com/astral-sh/ty/issues/1010
This commit is contained in:
Aria Desires 2025-09-02 14:49:14 -04:00 committed by GitHub
parent d5e48a0f80
commit ec5584219e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 261 additions and 19 deletions

View file

@ -8,14 +8,15 @@ use std::borrow::Cow;
use crate::find_node::covering_node;
use crate::stub_mapping::StubMapper;
use ruff_db::parsed::ParsedModuleRef;
use ruff_python_ast::ExprCall;
use ruff_python_ast::{self as ast, AnyNodeRef};
use ruff_python_parser::TokenKind;
use ruff_text_size::{Ranged, TextRange, TextSize};
use ty_python_semantic::HasDefinition;
use ty_python_semantic::ImportAliasResolution;
use ty_python_semantic::ResolvedDefinition;
use ty_python_semantic::types::Type;
use ty_python_semantic::types::definitions_for_keyword_argument;
use ty_python_semantic::types::{Type, call_signature_details};
use ty_python_semantic::{
HasType, SemanticModel, definitions_for_imported_symbol, definitions_for_name,
};
@ -145,6 +146,26 @@ pub(crate) enum GotoTarget<'a> {
Globals {
identifier: &'a ast::Identifier,
},
/// Go to on the invocation of a callable
///
/// ```py
/// x = mymodule.MyClass(1, 2)
/// ^^^^^^^
/// ```
///
/// This is equivalent to `GotoTarget::Expression(callable)` but enriched
/// with information about the actual callable implementation.
///
/// That is, if you click on `MyClass` in `MyClass()` it is *both* a
/// reference to the class and to the initializer of the class. Therefore
/// it would be ideal for goto-* and docstrings to be some intelligent
/// merging of both the class and the initializer.
Call {
/// The callable that can actually be selected by a cursor
callable: ast::ExprRef<'a>,
/// The call of the callable
call: &'a ExprCall,
},
}
/// The resolved definitions for a `GotoTarget`
@ -258,6 +279,9 @@ impl GotoTarget<'_> {
GotoTarget::ImportModuleAlias { alias } => alias.inferred_type(model),
GotoTarget::ExceptVariable(except) => except.inferred_type(model),
GotoTarget::KeywordArgument { keyword, .. } => keyword.value.inferred_type(model),
// When asking the type of a callable, usually you want the callable itself?
// (i.e. the type of `MyClass` in `MyClass()` is `<class MyClass>` and not `() -> MyClass`)
GotoTarget::Call { callable, .. } => callable.inferred_type(model),
// TODO: Support identifier targets
GotoTarget::PatternMatchRest(_)
| GotoTarget::PatternKeywordArgument(_)
@ -293,18 +317,10 @@ impl GotoTarget<'_> {
alias_resolution: ImportAliasResolution,
) -> Option<DefinitionsOrTargets<'db>> {
use crate::NavigationTarget;
use ruff_python_ast as ast;
match self {
GotoTarget::Expression(expression) => match expression {
ast::ExprRef::Name(name) => Some(DefinitionsOrTargets::Definitions(
definitions_for_name(db, file, name),
)),
ast::ExprRef::Attribute(attribute) => Some(DefinitionsOrTargets::Definitions(
ty_python_semantic::definitions_for_attribute(db, file, attribute),
)),
_ => None,
},
GotoTarget::Expression(expression) => definitions_for_expression(db, file, expression)
.map(DefinitionsOrTargets::Definitions),
// For already-defined symbols, they are their own definitions
GotoTarget::FunctionDef(function) => {
@ -417,6 +433,22 @@ impl GotoTarget<'_> {
}
}
// For callables, both the definition of the callable and the actual function impl are relevant.
//
// Prefer the function impl over the callable so that its docstrings win if defined.
GotoTarget::Call { callable, call } => {
let mut definitions = definitions_for_callable(db, file, call);
let expr_definitions =
definitions_for_expression(db, file, callable).unwrap_or_default();
definitions.extend(expr_definitions);
if definitions.is_empty() {
None
} else {
Some(DefinitionsOrTargets::Definitions(definitions))
}
}
_ => None,
}
}
@ -427,7 +459,11 @@ impl GotoTarget<'_> {
/// to this goto target.
pub(crate) fn to_string(&self) -> Option<Cow<'_, str>> {
match self {
GotoTarget::Expression(expression) => match expression {
GotoTarget::Call {
callable: expression,
..
}
| GotoTarget::Expression(expression) => match expression {
ast::ExprRef::Name(name) => Some(Cow::Borrowed(name.id.as_str())),
ast::ExprRef::Attribute(attr) => Some(Cow::Borrowed(attr.attr.as_str())),
_ => None,
@ -627,7 +663,18 @@ impl GotoTarget<'_> {
Some(GotoTarget::TypeParamTypeVarTupleName(var_tuple))
}
Some(AnyNodeRef::ExprAttribute(attribute)) => {
Some(GotoTarget::Expression(attribute.into()))
// Check if this is seemingly a callable being invoked (the `y` in `x.y(...)`)
let grandparent_expr = covering_node.ancestors().nth(2);
let attribute_expr = attribute.into();
if let Some(AnyNodeRef::ExprCall(call)) = grandparent_expr {
if ruff_python_ast::ExprRef::from(&call.func) == attribute_expr {
return Some(GotoTarget::Call {
call,
callable: attribute_expr,
});
}
}
Some(GotoTarget::Expression(attribute_expr))
}
Some(AnyNodeRef::StmtNonlocal(_)) => Some(GotoTarget::NonLocal { identifier }),
Some(AnyNodeRef::StmtGlobal(_)) => Some(GotoTarget::Globals { identifier }),
@ -641,7 +688,19 @@ impl GotoTarget<'_> {
}
},
node => node.as_expr_ref().map(GotoTarget::Expression),
node => {
// Check if this is seemingly a callable being invoked (the `x` in `x(...)`)
let parent = covering_node.parent();
if let (Some(AnyNodeRef::ExprCall(call)), AnyNodeRef::ExprName(name)) =
(parent, node)
{
return Some(GotoTarget::Call {
call,
callable: name.into(),
});
}
node.as_expr_ref().map(GotoTarget::Expression)
}
}
}
}
@ -649,7 +708,11 @@ impl GotoTarget<'_> {
impl Ranged for GotoTarget<'_> {
fn range(&self) -> TextRange {
match self {
GotoTarget::Expression(expression) => match expression {
GotoTarget::Call {
callable: expression,
..
}
| GotoTarget::Expression(expression) => match expression {
ast::ExprRef::Attribute(attribute) => attribute.attr.range,
_ => expression.range(),
},
@ -711,6 +774,35 @@ fn convert_resolved_definitions_to_targets(
.collect()
}
/// Shared helper to get definitions for an expr (that is presumably a name/attr)
fn definitions_for_expression<'db>(
db: &'db dyn crate::Db,
file: ruff_db::files::File,
expression: &ruff_python_ast::ExprRef<'_>,
) -> Option<Vec<ResolvedDefinition<'db>>> {
match expression {
ast::ExprRef::Name(name) => Some(definitions_for_name(db, file, name)),
ast::ExprRef::Attribute(attribute) => Some(ty_python_semantic::definitions_for_attribute(
db, file, attribute,
)),
_ => None,
}
}
fn definitions_for_callable<'db>(
db: &'db dyn crate::Db,
file: ruff_db::files::File,
call: &ExprCall,
) -> Vec<ResolvedDefinition<'db>> {
let model = SemanticModel::new(db, file);
// Attempt to refine to a specific call
let signature_info = call_signature_details(db, &model, call);
signature_info
.into_iter()
.filter_map(|signature| signature.definition.map(ResolvedDefinition::Definition))
.collect()
}
/// Shared helper to map and convert resolved definitions into navigation targets.
fn definitions_to_navigation_targets<'db>(
db: &dyn crate::Db,

View file

@ -123,6 +123,23 @@ mod tests {
|
4 | pass
5 |
6 | instance = MyClass()
| ^^^^^^^
|
info[goto-declaration]: Declaration
--> main.py:3:9
|
2 | class MyClass:
3 | def __init__(self):
| ^^^^^^^^
4 | pass
|
info: Source
--> main.py:6:12
|
4 | pass
5 |
6 | instance = MyClass()
| ^^^^^^^
|

View file

@ -466,6 +466,22 @@ class MyOtherClass:
--> main.py:3:5
|
2 | from mymodule import MyClass
3 | x = MyClass(0)
| ^^^^^^^
|
info[goto-definition]: Definition
--> mymodule.py:3:9
|
2 | class MyClass:
3 | def __init__(self, val):
| ^^^^^^^^
4 | self.val = val
|
info: Source
--> main.py:3:5
|
2 | from mymodule import MyClass
3 | x = MyClass(0)
| ^^^^^^^
|

View file

@ -465,6 +465,123 @@ mod tests {
"#,
);
assert_snapshot!(test.hover(), @r"
<class 'MyClass'>
---------------------------------------------
initializes MyClass (perfectly)
---------------------------------------------
```python
<class 'MyClass'>
```
---
```text
initializes MyClass (perfectly)
```
---------------------------------------------
info[hover]: Hovered content is
--> main.py:24:5
|
22 | return 0
23 |
24 | x = MyClass(0)
| ^^^^^-^
| | |
| | Cursor offset
| source
|
");
}
#[test]
fn hover_class_init_attr() {
let test = CursorTest::builder()
.source(
"mymod.py",
r#"
class MyClass:
'''
This is such a great class!!
Don't you know?
Everyone loves my class!!
'''
def __init__(self, val):
"""initializes MyClass (perfectly)"""
self.val = val
"#,
)
.source(
"main.py",
r#"
import mymod
x = mymod.MyCla<CURSOR>ss(0)
"#,
)
.build();
assert_snapshot!(test.hover(), @r"
<class 'MyClass'>
---------------------------------------------
initializes MyClass (perfectly)
---------------------------------------------
```python
<class 'MyClass'>
```
---
```text
initializes MyClass (perfectly)
```
---------------------------------------------
info[hover]: Hovered content is
--> main.py:4:11
|
2 | import mymod
3 |
4 | x = mymod.MyClass(0)
| ^^^^^-^
| | |
| | Cursor offset
| source
|
");
}
#[test]
fn hover_class_init_no_init_docs() {
let test = cursor_test(
r#"
class MyClass:
'''
This is such a great class!!
Don't you know?
Everyone loves my class!!
'''
def __init__(self, val):
self.val = val
def my_method(self, a, b):
'''This is such a great func!!
Args:
a: first for a reason
b: coming for `a`'s title
'''
return 0
x = MyCla<CURSOR>ss(0)
"#,
);
assert_snapshot!(test.hover(), @r"
<class 'MyClass'>
---------------------------------------------
@ -489,11 +606,11 @@ mod tests {
```
---------------------------------------------
info[hover]: Hovered content is
--> main.py:24:5
--> main.py:23:5
|
22 | return 0
23 |
24 | x = MyClass(0)
21 | return 0
22 |
23 | x = MyClass(0)
| ^^^^^-^
| | |
| | Cursor offset