Skip to content

Commit 593a75b

Browse files
fix
1 parent ab31d30 commit 593a75b

File tree

4 files changed

+110
-66
lines changed

4 files changed

+110
-66
lines changed

crates/pyrefly_types/src/display.rs

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -697,41 +697,48 @@ impl<'a> ClassDisplayContext<'a> {
697697
}
698698
}
699699

700+
/// Small helper describing the formatted code that should appear inside a hover block.
701+
///
702+
/// `body` is the snippet that will live inside the fenced code block, while
703+
/// `default_kind_label` lets callers know which `(kind)` prefix to use when the
704+
/// hover metadata can't provide one (e.g. when resolving a stub-only function).
700705
#[derive(Debug, Clone)]
701706
pub struct HoverCodeSnippet {
702-
pub heading: Option<String>,
703707
pub body: String,
704708
pub default_kind_label: Option<&'static str>,
705709
}
706710

711+
/// Format the string returned by `Type::as_hover_string()` so that callable types
712+
/// always resemble real Python function definitions. When `name` is provided we
713+
/// will synthesize a `def name(...): ...` signature if the rendered type only
714+
/// contains the parenthesized parameter list. This keeps IDE syntax highlighting
715+
/// working even when we fall back to hover strings built from metadata alone.
716+
///
717+
/// `display` is typically `Type::as_hover_string()`, but callers may pass their
718+
/// own rendering (for example, after expanding TypedDict kwargs).
707719
pub fn format_hover_code_snippet(
708720
type_: &Type,
709721
name: Option<&str>,
710722
display: String,
711723
) -> HoverCodeSnippet {
712724
if type_.is_function_type() {
713-
if let Some(name) = name {
714-
let trimmed = display.trim_start();
715-
let body = if trimmed.starts_with('(') {
716-
format!("def {}{}: ...", name, trimmed)
717-
} else {
718-
display
719-
};
720-
HoverCodeSnippet {
721-
heading: Some(name.to_owned()),
722-
body,
723-
default_kind_label: Some("(function) "),
724-
}
725-
} else {
726-
HoverCodeSnippet {
727-
heading: None,
728-
body: display,
729-
default_kind_label: Some("(function) "),
725+
let body = match name {
726+
Some(name) => {
727+
let trimmed = display.trim_start();
728+
if trimmed.starts_with('(') {
729+
format!("def {}{}: ...", name, trimmed)
730+
} else {
731+
display
732+
}
730733
}
734+
None => display,
735+
};
736+
HoverCodeSnippet {
737+
body,
738+
default_kind_label: Some("(function) "),
731739
}
732740
} else {
733741
HoverCodeSnippet {
734-
heading: None,
735742
body: display,
736743
default_kind_label: None,
737744
}

pyrefly/lib/lsp/wasm/hover.rs

Lines changed: 78 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use pyrefly_build::handle::Handle;
1414
use pyrefly_python::docstring::Docstring;
1515
use pyrefly_python::ignore::Ignore;
1616
use pyrefly_python::ignore::find_comment_start_in_line;
17+
#[cfg(test)]
1718
use pyrefly_python::module_name::ModuleName;
1819
use pyrefly_python::symbol_kind::SymbolKind;
1920
use pyrefly_types::callable::Callable;
@@ -27,6 +28,7 @@ use pyrefly_types::types::Forallable;
2728
use pyrefly_types::types::Type;
2829
use pyrefly_util::lined_buffer::LineNumber;
2930
use ruff_python_ast::name::Name;
31+
use ruff_text_size::TextRange;
3032
use ruff_text_size::TextSize;
3133

3234
use crate::alt::answers_solver::AnswersSolver;
@@ -172,6 +174,9 @@ impl HoverValue {
172174
.display
173175
.clone()
174176
.unwrap_or_else(|| self.type_.as_hover_string());
177+
// Ensure callable hover bodies always contain a proper `def name(...)` so IDE syntax
178+
// highlighting stays consistent, even when metadata is missing and we fall back to
179+
// inferred identifiers.
175180
let snippet = format_hover_code_snippet(&self.type_, self.name.as_deref(), type_display);
176181
let kind_formatted = self.kind.map_or_else(
177182
|| {
@@ -186,18 +191,17 @@ impl HoverValue {
186191
.name
187192
.as_ref()
188193
.map_or("".to_owned(), |s| format!("{s}: "));
189-
let heading = if let Some(callable_heading) = snippet.heading.as_ref() {
190-
format!("{}{}\n", kind_formatted, callable_heading)
191-
} else {
192-
format!("{}{}", kind_formatted, name_formatted)
193-
};
194194

195195
Hover {
196196
contents: HoverContents::Markup(MarkupContent {
197197
kind: MarkupKind::Markdown,
198198
value: format!(
199-
"```python\n{}{}\n```{}{}",
200-
heading, snippet.body, docstring_formatted, symbol_def_formatted
199+
"```python\n{}{}{}\n```{}{}",
200+
kind_formatted,
201+
name_formatted,
202+
snippet.body,
203+
docstring_formatted,
204+
symbol_def_formatted
201205
),
202206
}),
203207
range: None,
@@ -249,30 +253,72 @@ fn expand_callable_kwargs_for_hover<'a>(
249253
}
250254
}
251255

252-
fn fallback_hover_name_from_type(type_: &Type, current_module: ModuleName) -> Option<String> {
256+
/// If we can't determine a symbol name via go-to-definition, fall back to what the
257+
/// type metadata knows about the callable. This primarily handles third-party stubs
258+
/// where we only have typeshed information.
259+
fn fallback_hover_name_from_type(type_: &Type) -> Option<String> {
253260
match type_ {
254-
Type::Function(function) => Some(function.metadata.kind.format(current_module)),
261+
Type::Function(function) => Some(
262+
function
263+
.metadata
264+
.kind
265+
.function_name()
266+
.into_owned()
267+
.to_string(),
268+
),
255269
Type::BoundMethod(bound_method) => match &bound_method.func {
256-
BoundMethodType::Function(function) => {
257-
Some(function.metadata.kind.format(current_module))
258-
}
259-
BoundMethodType::Forall(forall) => {
260-
Some(forall.body.metadata.kind.format(current_module))
261-
}
262-
BoundMethodType::Overload(overload) => {
263-
Some(overload.metadata.kind.format(current_module))
264-
}
270+
BoundMethodType::Function(function) => Some(
271+
function
272+
.metadata
273+
.kind
274+
.function_name()
275+
.into_owned()
276+
.to_string(),
277+
),
278+
BoundMethodType::Forall(forall) => Some(
279+
forall
280+
.body
281+
.metadata
282+
.kind
283+
.function_name()
284+
.into_owned()
285+
.to_string(),
286+
),
287+
BoundMethodType::Overload(overload) => Some(
288+
overload
289+
.metadata
290+
.kind
291+
.function_name()
292+
.into_owned()
293+
.to_string(),
294+
),
265295
},
266-
Type::Overload(overload) => Some(overload.metadata.kind.format(current_module)),
296+
Type::Overload(overload) => Some(
297+
overload
298+
.metadata
299+
.kind
300+
.function_name()
301+
.into_owned()
302+
.to_string(),
303+
),
267304
Type::Forall(forall) => match &forall.body {
268-
Forallable::Function(function) => Some(function.metadata.kind.format(current_module)),
305+
Forallable::Function(function) => Some(
306+
function
307+
.metadata
308+
.kind
309+
.function_name()
310+
.into_owned()
311+
.to_string(),
312+
),
269313
Forallable::Callable(_) | Forallable::TypeAlias(_) => None,
270314
},
271-
Type::Type(inner) => fallback_hover_name_from_type(inner, current_module),
315+
Type::Type(inner) => fallback_hover_name_from_type(inner),
272316
_ => None,
273317
}
274318
}
275319

320+
/// Extract the identifier under the cursor directly from the file contents so we can
321+
/// label hover results even when go-to-definition fails.
276322
fn identifier_text_at(
277323
transaction: &Transaction<'_>,
278324
handle: &Handle,
@@ -282,10 +328,7 @@ fn identifier_text_at(
282328
let contents = module.contents();
283329
let bytes = contents.as_bytes();
284330
let len = bytes.len();
285-
let mut pos = position.to_usize();
286-
if pos > len {
287-
pos = len;
288-
}
331+
let pos = position.to_usize().min(len);
289332
let is_ident_char = |b: u8| b == b'_' || b.is_ascii_alphanumeric();
290333
let mut start = pos;
291334
while start > 0 && is_ident_char(bytes[start - 1]) {
@@ -296,10 +339,10 @@ fn identifier_text_at(
296339
end += 1;
297340
}
298341
if start == end {
299-
None
300-
} else {
301-
Some(contents[start..end].to_string())
342+
return None;
302343
}
344+
let range = TextRange::new(TextSize::new(start as u32), TextSize::new(end as u32));
345+
Some(module.code_at(range).to_owned())
303346
}
304347

305348
pub fn get_hover(
@@ -340,8 +383,7 @@ pub fn get_hover(
340383

341384
// Otherwise, fall through to the existing type hover logic
342385
let type_ = transaction.get_type_at(handle, position)?;
343-
let current_module = handle.module();
344-
let fallback_name_from_type = fallback_hover_name_from_type(&type_, current_module);
386+
let fallback_name_from_type = fallback_hover_name_from_type(&type_);
345387
let type_display = transaction.ad_hoc_solve(handle, {
346388
let mut cloned = type_.clone();
347389
move |solver| {
@@ -351,7 +393,7 @@ pub fn get_hover(
351393
cloned.as_hover_string()
352394
}
353395
});
354-
let (kind, mut name, docstring_range, module) = if let Some(FindDefinitionItemWithDocstring {
396+
let (kind, name, docstring_range, module) = if let Some(FindDefinitionItemWithDocstring {
355397
metadata,
356398
definition_range: definition_location,
357399
module,
@@ -389,9 +431,7 @@ pub fn get_hover(
389431
(None, fallback_name_from_type, None, None)
390432
};
391433

392-
if name.is_none() {
393-
name = identifier_text_at(transaction, handle, position);
394-
}
434+
let name = name.or_else(|| identifier_text_at(transaction, handle, position));
395435

396436
let docstring = if let (Some(docstring), Some(module)) = (docstring_range, module) {
397437
Some(Docstring(docstring, module))
@@ -452,14 +492,14 @@ mod tests {
452492
#[test]
453493
fn fallback_uses_function_metadata() {
454494
let ty = make_function_type("numpy", "arange");
455-
let fallback = fallback_hover_name_from_type(&ty, ModuleName::from_str("user_code"));
456-
assert_eq!(fallback.as_deref(), Some("numpy.arange"));
495+
let fallback = fallback_hover_name_from_type(&ty);
496+
assert_eq!(fallback.as_deref(), Some("arange"));
457497
}
458498

459499
#[test]
460500
fn fallback_recurses_through_type_wrapper() {
461501
let ty = Type::Type(Box::new(make_function_type("pkg.subpkg", "run")));
462-
let fallback = fallback_hover_name_from_type(&ty, ModuleName::from_str("other"));
463-
assert_eq!(fallback.as_deref(), Some("pkg.subpkg.run"));
502+
let fallback = fallback_hover_name_from_type(&ty);
503+
assert_eq!(fallback.as_deref(), Some("run"));
464504
}
465505
}

pyrefly/lib/test/lsp/hover.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ xyz = [foo.meth]
3939
#^
4040
"#;
4141
let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report);
42-
assert!(report.contains("(method) meth\ndef meth(self: Foo) -> None: ..."));
42+
assert!(report.contains("(method) meth: def meth(self: Foo) -> None: ..."));
4343
assert!(report.contains("(variable) xyz: list[(self: Foo) -> None]"));
4444
assert!(
4545
report.contains("Go to [list]"),
@@ -75,8 +75,7 @@ from lib import foo_renamed
7575
2 | from lib import foo_renamed
7676
^
7777
```python
78-
(function) foo
79-
def foo() -> None: ...
78+
(function) foo: def foo() -> None: ...
8079
```
8180
8281
@@ -112,8 +111,7 @@ takes(foo=1, bar="x", baz=None)
112111
12 | takes(foo=1, bar="x", baz=None)
113112
^
114113
```python
115-
(function) takes
116-
def takes(
114+
(function) takes: def takes(
117115
*,
118116
foo: int,
119117
bar: str,
@@ -230,8 +228,7 @@ lhs @ rhs
230228
13 | lhs @ rhs
231229
^
232230
```python
233-
(method) __matmul__
234-
def __matmul__(
231+
(method) __matmul__: def __matmul__(
235232
self: Matrix,
236233
other: Matrix
237234
) -> Matrix: ...

pyrefly/lib/test/lsp/lsp_interaction/hover.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,9 @@ fn hover_shows_third_party_function_name() {
111111
..Default::default()
112112
});
113113

114-
interaction.server.did_open("user_code.py");
114+
interaction.client.did_open("user_code.py");
115115
// Column/line values follow LSP's zero-based positions
116-
interaction.server.hover("user_code.py", 14, 25);
116+
interaction.client.hover("user_code.py", 14, 25);
117117
interaction.client.expect_response_with(
118118
|response| {
119119
response

0 commit comments

Comments
 (0)