WIP: support goto/hover on string annotations

This commit is contained in:
Aria Desires 2025-08-13 18:36:16 -04:00
parent d5e48a0f80
commit 86e9b4d337
10 changed files with 120 additions and 38 deletions

View File

@ -15,7 +15,7 @@ pub fn document_highlights(
let module = parsed.load(db);
// Get the definitions for the symbol at the cursor position
let goto_target = find_goto_target(&module, offset)?;
let goto_target = find_goto_target(db, file, &module, offset)?;
// Use DocumentHighlights mode which limits search to current file only
references(db, file, &goto_target, ReferencesMode::DocumentHighlights)

View File

@ -7,9 +7,15 @@ use std::borrow::Cow;
use crate::find_node::covering_node;
use crate::stub_mapping::StubMapper;
use ruff_db::Db;
use ruff_db::files::File;
use ruff_db::parsed::ParsedModuleRef;
use ruff_db::source::source_text;
use ruff_python_ast::ExprRef;
use ruff_python_ast::{self as ast, AnyNodeRef};
use ruff_python_parser::TokenKind;
use ruff_python_parser::Tokens;
use ruff_python_parser::parse_string_annotation;
use ruff_text_size::{Ranged, TextRange, TextSize};
use ty_python_semantic::HasDefinition;
use ty_python_semantic::ImportAliasResolution;
@ -139,6 +145,22 @@ pub(crate) enum GotoTarget<'a> {
/// ```
TypeParamTypeVarTupleName(&'a ast::TypeParamTypeVarTuple),
/// Go to some name found in a string annotation
///
/// ```py
/// def my_func(x: "MyType | int"): ...
/// ^^^^^^
/// ```
///
/// For various reasons we can't and shouldn't store the sub-AST node,
/// and instead store the covering string literal and the name/range
/// we parsed out of it.
StringAnnotationExprName {
string_literal: &'a ast::ExprStringLiteral,
name: String,
range: TextRange,
},
NonLocal {
identifier: &'a ast::Identifier,
},
@ -258,6 +280,10 @@ impl GotoTarget<'_> {
GotoTarget::ImportModuleAlias { alias } => alias.inferred_type(model),
GotoTarget::ExceptVariable(except) => except.inferred_type(model),
GotoTarget::KeywordArgument { keyword, .. } => keyword.value.inferred_type(model),
GotoTarget::StringAnnotationExprName { .. } => {
// TODO: make a way to ask the inference engine about a sub-expr of a string annotation
return None;
}
// TODO: Support identifier targets
GotoTarget::PatternMatchRest(_)
| GotoTarget::PatternKeywordArgument(_)
@ -298,7 +324,7 @@ impl GotoTarget<'_> {
match self {
GotoTarget::Expression(expression) => match expression {
ast::ExprRef::Name(name) => Some(DefinitionsOrTargets::Definitions(
definitions_for_name(db, file, name),
definitions_for_name(db, file, name.id.as_str(), ExprRef::Name(name)),
)),
ast::ExprRef::Attribute(attribute) => Some(DefinitionsOrTargets::Definitions(
ty_python_semantic::definitions_for_attribute(db, file, attribute),
@ -306,6 +332,17 @@ impl GotoTarget<'_> {
_ => None,
},
GotoTarget::StringAnnotationExprName {
string_literal,
name,
..
} => Some(DefinitionsOrTargets::Definitions(definitions_for_name(
db,
file,
name,
ExprRef::StringLiteral(string_literal),
))),
// For already-defined symbols, they are their own definitions
GotoTarget::FunctionDef(function) => {
let model = SemanticModel::new(db, file);
@ -416,7 +453,6 @@ impl GotoTarget<'_> {
None
}
}
_ => None,
}
}
@ -486,11 +522,14 @@ impl GotoTarget<'_> {
}
GotoTarget::NonLocal { identifier, .. } => Some(Cow::Borrowed(identifier.as_str())),
GotoTarget::Globals { identifier, .. } => Some(Cow::Borrowed(identifier.as_str())),
GotoTarget::StringAnnotationExprName { name, .. } => Some(Cow::Borrowed(name)),
}
}
/// Creates a `GotoTarget` from a `CoveringNode` and an offset within the node
pub(crate) fn from_covering_node<'a>(
db: &dyn Db,
file: File,
covering_node: &crate::find_node::CoveringNode<'a>,
offset: TextSize,
) -> Option<GotoTarget<'a>> {
@ -641,6 +680,51 @@ impl GotoTarget<'_> {
}
},
AnyNodeRef::ExprStringLiteral(string_literal) => {
// If we encounter a string literal, blindly assume it's a string annotation
// like `x: "str | int"` by parsing it as a sub-AST.
//
// TODO: we *really* should be asking the semantic analysis if this is actually
// a string annotation, because this behaviour will effect any random string
// literal that looks like a type..!
let source = source_text(db, file);
let sub_ast = parse_string_annotation(
source.as_str(),
string_literal.as_single_part_string()?,
)
.ok()?;
// Now we can resume the search for a GotoTarget in the sub-AST.
let sub_target = find_goto_target_impl(
db,
file,
sub_ast.tokens(),
sub_ast.syntax().into(),
offset,
)?;
// Our search should only really be considered a success if we found a GotoTarget
// for a bare name, as we only care about those things in a string annotation
// [CITATION EXTREMELY NEEDED]
let GotoTarget::Expression(ExprRef::Name(name)) = sub_target else {
return None;
};
// We *cannot* return this GotoTarget (or the sub-AST node it contains)
// but that's ~fine because that node will make basically everything else
// freak out because e.g. it has no defined scope in the typechecker.
//
// Instead we record the covering string literal node and the name/range we parsed.
// The string literal will be used in much the same way as ty_python_semantic's
// `DeferredExpressionState::InStringAnnotation`.
let range = sub_target.range();
Some(GotoTarget::StringAnnotationExprName {
string_literal,
name: name.id.to_string(),
range,
})
}
node => node.as_expr_ref().map(GotoTarget::Expression),
}
}
@ -672,6 +756,7 @@ impl Ranged for GotoTarget<'_> {
GotoTarget::TypeParamTypeVarTupleName(tuple) => tuple.name.range,
GotoTarget::NonLocal { identifier, .. } => identifier.range,
GotoTarget::Globals { identifier, .. } => identifier.range,
GotoTarget::StringAnnotationExprName { range, .. } => *range,
}
}
}
@ -728,12 +813,23 @@ fn definitions_to_navigation_targets<'db>(
}
}
pub(crate) fn find_goto_target(
parsed: &ParsedModuleRef,
pub(crate) fn find_goto_target<'a>(
db: &dyn Db,
file: File,
parsed: &'a ParsedModuleRef,
offset: TextSize,
) -> Option<GotoTarget<'_>> {
let token = parsed
.tokens()
) -> Option<GotoTarget<'a>> {
find_goto_target_impl(db, file, parsed.tokens(), parsed.syntax().into(), offset)
}
fn find_goto_target_impl<'a>(
db: &dyn Db,
file: File,
tokens: &'a Tokens,
root: AnyNodeRef<'a>,
offset: TextSize,
) -> Option<GotoTarget<'a>> {
let token = tokens
.at_offset(offset)
.max_by_key(|token| match token.kind() {
TokenKind::Name
@ -744,11 +840,11 @@ pub(crate) fn find_goto_target(
_ => 0,
})?;
let covering_node = covering_node(parsed.syntax().into(), token.range())
let covering_node = covering_node(root, token.range())
.find_first(|node| node.is_identifier() || node.is_expression())
.ok()?;
GotoTarget::from_covering_node(&covering_node, offset)
GotoTarget::from_covering_node(db, file, &covering_node, offset)
}
/// Helper function to resolve a module name and create a navigation target.

View File

@ -16,7 +16,7 @@ pub fn goto_declaration(
offset: TextSize,
) -> Option<RangedValue<NavigationTargets>> {
let module = parsed_module(db, file).load(db);
let goto_target = find_goto_target(&module, offset)?;
let goto_target = find_goto_target(db, file, &module, offset)?;
let declaration_targets = goto_target
.get_definition_targets(file, db, ImportAliasResolution::ResolveAliases)?

View File

@ -17,7 +17,7 @@ pub fn goto_definition(
offset: TextSize,
) -> Option<RangedValue<NavigationTargets>> {
let module = parsed_module(db, file).load(db);
let goto_target = find_goto_target(&module, offset)?;
let goto_target = find_goto_target(db, file, &module, offset)?;
let definition_targets = goto_target
.get_definition_targets(file, db, ImportAliasResolution::ResolveAliases)?

View File

@ -16,7 +16,7 @@ pub fn goto_references(
let module = parsed.load(db);
// Get the definitions for the symbol at the cursor position
let goto_target = find_goto_target(&module, offset)?;
let goto_target = find_goto_target(db, file, &module, offset)?;
let mode = if include_declaration {
ReferencesMode::References

View File

@ -11,7 +11,7 @@ pub fn goto_type_definition(
offset: TextSize,
) -> Option<RangedValue<NavigationTargets>> {
let module = parsed_module(db, file).load(db);
let goto_target = find_goto_target(&module, offset)?;
let goto_target = find_goto_target(db, file, &module, offset)?;
let model = SemanticModel::new(db, file);
let ty = goto_target.inferred_type(&model)?;
@ -225,23 +225,7 @@ mod tests {
"#,
);
assert_snapshot!(test.goto_type_definition(), @r#"
info[goto-type-definition]: Type definition
--> stdlib/builtins.pyi:911:7
|
910 | @disjoint_base
911 | class str(Sequence[str]):
| ^^^
912 | """str(object='') -> str
913 | str(bytes_or_buffer[, encoding[, errors]]) -> str
|
info: Source
--> main.py:2:10
|
2 | a: str = "test"
| ^^^^^^
|
"#);
assert_snapshot!(test.goto_type_definition(), @"No goto target found");
}
#[test]

View File

@ -11,7 +11,7 @@ use ty_python_semantic::{DisplaySettings, SemanticModel};
pub fn hover(db: &dyn Db, file: File, offset: TextSize) -> Option<RangedValue<Hover<'_>>> {
let parsed = parsed_module(db, file).load(db);
let goto_target = find_goto_target(&parsed, offset)?;
let goto_target = find_goto_target(db, file, &parsed, offset)?;
if let GotoTarget::Expression(expr) = goto_target {
if expr.is_literal_expr() {

View File

@ -282,7 +282,9 @@ impl LocalReferencesFinder<'_> {
// where the identifier might be a multi-part module name.
let offset = covering_node.node().start();
if let Some(goto_target) = GotoTarget::from_covering_node(covering_node, offset) {
if let Some(goto_target) =
GotoTarget::from_covering_node(self.db, self.file, covering_node, offset)
{
// Get the definitions for this goto target
if let Some(current_definitions_nav) = goto_target
.get_definition_targets(self.file, self.db, ImportAliasResolution::PreserveAliases)

View File

@ -11,7 +11,7 @@ pub fn can_rename(db: &dyn Db, file: File, offset: TextSize) -> Option<ruff_text
let module = parsed.load(db);
// Get the definitions for the symbol at the offset
let goto_target = find_goto_target(&module, offset)?;
let goto_target = find_goto_target(db, file, &module, offset)?;
// Don't allow renaming of import module components
if matches!(
@ -60,7 +60,7 @@ pub fn rename(
let module = parsed.load(db);
// Get the definitions for the symbol at the offset
let goto_target = find_goto_target(&module, offset)?;
let goto_target = find_goto_target(db, file, &module, offset)?;
// Clients shouldn't call us with an empty new name, but just in case...
if new_name.is_empty() {

View File

@ -429,13 +429,13 @@ pub fn definition_kind_for_name<'db>(
pub fn definitions_for_name<'db>(
db: &'db dyn Db,
file: File,
name: &ast::ExprName,
name_str: &str,
enclosing_node: ast::ExprRef,
) -> Vec<ResolvedDefinition<'db>> {
let index = semantic_index(db, file);
let name_str = name.id.as_str();
// Get the scope for this name expression
let file_scope = index.expression_scope_id(&ast::ExprRef::from(name));
let file_scope = index.expression_scope_id(&enclosing_node);
let mut all_definitions = Vec::new();