Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion pyrefly/lib/lsp/wasm/hover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use pyrefly_build::handle::Handle;
use pyrefly_python::ast::Ast;
use pyrefly_python::docstring::Docstring;
use pyrefly_python::docstring::parse_parameter_documentation;
use pyrefly_python::dunder;
use pyrefly_python::ignore::Ignore;
use pyrefly_python::ignore::Tool;
use pyrefly_python::ignore::TypeIgnoreUnknownTagBehavior;
Expand Down Expand Up @@ -1375,6 +1376,51 @@ fn resolve_hover_parameter_doc(
None
}

fn is_constructor_dunder(name: Option<&str>) -> bool {
name.is_some_and(|name| name == dunder::INIT.as_str() || name == dunder::NEW.as_str())
}

fn nonempty_docstring(docstring: Docstring) -> Option<Docstring> {
(!docstring.resolve().trim().is_empty()).then_some(docstring)
}

/// Class docstring for a constructor call whose `__init__` or `__new__` has none.
///
/// `find_definition` follows those methods when the cursor is on a call. This looks up the
/// class itself instead. A stub docstring wins over the implementation, matching other hover
/// documentation.
fn class_docstring_for_constructor(
transaction: &Transaction<'_>,
handle: &Handle,
position: TextSize,
) -> Option<Docstring> {
let preferences = [
FindPreference {
resolve_call_dunders: false,
..FindPreference::default()
},
FindPreference {
prefer_pyi: false,
include_interpreter_stdlib: true,
resolve_call_dunders: false,
..FindPreference::default()
},
];
preferences.into_iter().find_map(|preference| {
let item = transaction
.find_definition(handle, position, preference)
.ok()
.and_then(|items| items.into_vec().into_iter().next())?;

if item.metadata.symbol_kind() != Some(SymbolKind::Class) {
return None;
}
item.docstring_range
.map(|range| Docstring(range, item.module))
.and_then(nonempty_docstring)
})
}

pub fn get_hover(
transaction: &Transaction<'_>,
handle: &Handle,
Expand Down Expand Up @@ -1494,6 +1540,9 @@ pub fn get_hover_with_verbosity(
&& executable.display_name == interface.display_name
})
});
let hover_constructor_dunder = definition
.as_ref()
.is_some_and(|item| is_constructor_dunder(item.display_name.as_deref()));
let (kind, name, fallback_docstring) = if let Some(FindDefinitionItemWithDocstring {
metadata,
definition_range: definition_location,
Expand All @@ -1518,9 +1567,17 @@ pub fn get_hover_with_verbosity(
item.docstring_range
.map(|range| Docstring(range, item.module))
})
.filter(|docstring| !docstring.resolve().trim().is_empty())
.and_then(nonempty_docstring)
.or(fallback_docstring);

let docstring = if hover_constructor_dunder {
docstring
.and_then(nonempty_docstring)
.or_else(|| class_docstring_for_constructor(transaction, handle, position))
} else {
docstring
};

let name = name.or_else(|| identifier_text_at(transaction, handle, position));

let name_for_display = name.clone();
Expand Down
88 changes: 88 additions & 0 deletions pyrefly/lib/test/lsp/hover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2274,6 +2274,94 @@ Person()
);
}

#[test]
fn hover_on_constructor_falls_back_to_class_docstring() {
// When __init__ has no docstring but the class does, hover should show the class docstring.
let code = r#"
class Person:
"""Person class docstring."""
def __init__(self, name: str, age: int) -> None: ...

Person()
#^
"#;
let report = get_batched_lsp_operations_report_allow_error(&[("main", code)], get_test_report);
assert!(
report.contains("Person class docstring."),
"Expected constructor hover to show class docstring when __init__ has none, got: {report}"
);
// Also verify the signature is shown
assert!(
report.contains("-> Person"),
"Expected constructor hover to show signature, got: {report}"
);
}

#[test]
fn hover_on_constructor_falls_back_to_class_docstring_for_new() {
// When __new__ has no docstring but the class does, hover should show the class docstring.
let code = r#"
class Person:
"""Person class docstring."""
def __new__(cls) -> Person: ...

Person()
#^
"#;
let report = get_batched_lsp_operations_report_allow_error(&[("main", code)], get_test_report);
assert!(
report.contains("Person class docstring."),
"Expected __new__ hover to show class docstring when __new__ has none, got: {report}"
);
}

#[test]
fn hover_on_constructor_no_class_docstring() {
// When neither __init__ nor class has a docstring, hover should not show a docstring section.
let code = r#"
class Person:
def __init__(self, name: str, age: int) -> None: ...

Person()
#^
"#;
let report = get_batched_lsp_operations_report_allow_error(&[("main", code)], get_test_report);
assert!(
!report.contains("---"),
"Expected constructor hover to not show docstring section when neither has one, got: {report}"
);
}

#[test]
fn hover_on_constructor_cross_module_falls_back_to_class_docstring() {
// When __init__ has no docstring but the class does, hover should show the class docstring
// even when the class is imported from another module.
let lib = r#"
class Person:
"""Person class docstring from lib."""
def __init__(self, name: str, age: int) -> None: ...
"#;
let code = r#"
from lib import Person

Person()
#^
"#;
let report = get_batched_lsp_operations_report_allow_error(
&[("main", code), ("lib", lib)],
get_test_report,
);
assert!(
report.contains("Person class docstring from lib."),
"Expected constructor hover to show class docstring from lib when __init__ has none, got: {report}"
);
// Also verify the signature is shown
assert!(
report.contains("-> Person"),
"Expected constructor hover to show signature, got: {report}"
);
}

#[test]
fn hover_over_in_operator_shows_contains_dunder() {
let code = r#"
Expand Down
Loading