Skip to content

Commit 9066433

Browse files
fix
1 parent 2991fe2 commit 9066433

File tree

4 files changed

+103
-64
lines changed

4 files changed

+103
-64
lines changed

crates/pyrefly_types/src/display.rs

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -809,41 +809,48 @@ impl<'a> ClassDisplayContext<'a> {
809809
}
810810
}
811811

812+
/// Small helper describing the formatted code that should appear inside a hover block.
813+
///
814+
/// `body` is the snippet that will live inside the fenced code block, while
815+
/// `default_kind_label` lets callers know which `(kind)` prefix to use when the
816+
/// hover metadata can't provide one (e.g. when resolving a stub-only function).
812817
#[derive(Debug, Clone)]
813818
pub struct HoverCodeSnippet {
814-
pub heading: Option<String>,
815819
pub body: String,
816820
pub default_kind_label: Option<&'static str>,
817821
}
818822

823+
/// Format the string returned by `Type::as_hover_string()` so that callable types
824+
/// always resemble real Python function definitions. When `name` is provided we
825+
/// will synthesize a `def name(...): ...` signature if the rendered type only
826+
/// contains the parenthesized parameter list. This keeps IDE syntax highlighting
827+
/// working even when we fall back to hover strings built from metadata alone.
828+
///
829+
/// `display` is typically `Type::as_hover_string()`, but callers may pass their
830+
/// own rendering (for example, after expanding TypedDict kwargs).
819831
pub fn format_hover_code_snippet(
820832
type_: &Type,
821833
name: Option<&str>,
822834
display: String,
823835
) -> HoverCodeSnippet {
824836
if type_.is_function_type() {
825-
if let Some(name) = name {
826-
let trimmed = display.trim_start();
827-
let body = if trimmed.starts_with('(') {
828-
format!("def {}{}: ...", name, trimmed)
829-
} else {
830-
display
831-
};
832-
HoverCodeSnippet {
833-
heading: Some(name.to_owned()),
834-
body,
835-
default_kind_label: Some("(function) "),
836-
}
837-
} else {
838-
HoverCodeSnippet {
839-
heading: None,
840-
body: display,
841-
default_kind_label: Some("(function) "),
837+
let body = match name {
838+
Some(name) => {
839+
let trimmed = display.trim_start();
840+
if trimmed.starts_with('(') {
841+
format!("def {}{}: ...", name, trimmed)
842+
} else {
843+
display
844+
}
842845
}
846+
None => display,
847+
};
848+
HoverCodeSnippet {
849+
body,
850+
default_kind_label: Some("(function) "),
843851
}
844852
} else {
845853
HoverCodeSnippet {
846-
heading: None,
847854
body: display,
848855
default_kind_label: None,
849856
}

pyrefly/lib/lsp/wasm/hover.rs

Lines changed: 71 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use pyrefly_python::docstring::parse_parameter_documentation;
1818
use pyrefly_python::ignore::Ignore;
1919
use pyrefly_python::ignore::Tool;
2020
use pyrefly_python::ignore::find_comment_start_in_line;
21+
#[cfg(test)]
2122
use pyrefly_python::module_name::ModuleName;
2223
use pyrefly_python::symbol_kind::SymbolKind;
2324
use pyrefly_types::callable::Callable;
@@ -234,6 +235,9 @@ impl HoverValue {
234235
.display
235236
.clone()
236237
.unwrap_or_else(|| self.type_.as_hover_string());
238+
// Ensure callable hover bodies always contain a proper `def name(...)` so IDE syntax
239+
// highlighting stays consistent, even when metadata is missing and we fall back to
240+
// inferred identifiers.
237241
let snippet = format_hover_code_snippet(&self.type_, self.name.as_deref(), type_display);
238242
let kind_formatted = self.kind.map_or_else(
239243
|| {
@@ -248,11 +252,6 @@ impl HoverValue {
248252
.name
249253
.as_ref()
250254
.map_or("".to_owned(), |s| format!("{s}: "));
251-
let heading = if let Some(callable_heading) = snippet.heading.as_ref() {
252-
format!("{}{}\n", kind_formatted, callable_heading)
253-
} else {
254-
format!("{}{}", kind_formatted, name_formatted)
255-
};
256255

257256
Hover {
258257
contents: HoverContents::Markup(MarkupContent {
@@ -316,30 +315,72 @@ fn expand_callable_kwargs_for_hover<'a>(
316315
}
317316
}
318317

319-
fn fallback_hover_name_from_type(type_: &Type, current_module: ModuleName) -> Option<String> {
318+
/// If we can't determine a symbol name via go-to-definition, fall back to what the
319+
/// type metadata knows about the callable. This primarily handles third-party stubs
320+
/// where we only have typeshed information.
321+
fn fallback_hover_name_from_type(type_: &Type) -> Option<String> {
320322
match type_ {
321-
Type::Function(function) => Some(function.metadata.kind.format(current_module)),
323+
Type::Function(function) => Some(
324+
function
325+
.metadata
326+
.kind
327+
.function_name()
328+
.into_owned()
329+
.to_string(),
330+
),
322331
Type::BoundMethod(bound_method) => match &bound_method.func {
323-
BoundMethodType::Function(function) => {
324-
Some(function.metadata.kind.format(current_module))
325-
}
326-
BoundMethodType::Forall(forall) => {
327-
Some(forall.body.metadata.kind.format(current_module))
328-
}
329-
BoundMethodType::Overload(overload) => {
330-
Some(overload.metadata.kind.format(current_module))
331-
}
332+
BoundMethodType::Function(function) => Some(
333+
function
334+
.metadata
335+
.kind
336+
.function_name()
337+
.into_owned()
338+
.to_string(),
339+
),
340+
BoundMethodType::Forall(forall) => Some(
341+
forall
342+
.body
343+
.metadata
344+
.kind
345+
.function_name()
346+
.into_owned()
347+
.to_string(),
348+
),
349+
BoundMethodType::Overload(overload) => Some(
350+
overload
351+
.metadata
352+
.kind
353+
.function_name()
354+
.into_owned()
355+
.to_string(),
356+
),
332357
},
333-
Type::Overload(overload) => Some(overload.metadata.kind.format(current_module)),
358+
Type::Overload(overload) => Some(
359+
overload
360+
.metadata
361+
.kind
362+
.function_name()
363+
.into_owned()
364+
.to_string(),
365+
),
334366
Type::Forall(forall) => match &forall.body {
335-
Forallable::Function(function) => Some(function.metadata.kind.format(current_module)),
367+
Forallable::Function(function) => Some(
368+
function
369+
.metadata
370+
.kind
371+
.function_name()
372+
.into_owned()
373+
.to_string(),
374+
),
336375
Forallable::Callable(_) | Forallable::TypeAlias(_) => None,
337376
},
338-
Type::Type(inner) => fallback_hover_name_from_type(inner, current_module),
377+
Type::Type(inner) => fallback_hover_name_from_type(inner),
339378
_ => None,
340379
}
341380
}
342381

382+
/// Extract the identifier under the cursor directly from the file contents so we can
383+
/// label hover results even when go-to-definition fails.
343384
fn identifier_text_at(
344385
transaction: &Transaction<'_>,
345386
handle: &Handle,
@@ -349,10 +390,7 @@ fn identifier_text_at(
349390
let contents = module.contents();
350391
let bytes = contents.as_bytes();
351392
let len = bytes.len();
352-
let mut pos = position.to_usize();
353-
if pos > len {
354-
pos = len;
355-
}
393+
let pos = position.to_usize().min(len);
356394
let is_ident_char = |b: u8| b == b'_' || b.is_ascii_alphanumeric();
357395
let mut start = pos;
358396
while start > 0 && is_ident_char(bytes[start - 1]) {
@@ -363,10 +401,10 @@ fn identifier_text_at(
363401
end += 1;
364402
}
365403
if start == end {
366-
None
367-
} else {
368-
Some(contents[start..end].to_string())
404+
return None;
369405
}
406+
let range = TextRange::new(TextSize::new(start as u32), TextSize::new(end as u32));
407+
Some(module.code_at(range).to_owned())
370408
}
371409

372410
pub fn get_hover(
@@ -411,8 +449,7 @@ pub fn get_hover(
411449

412450
// Otherwise, fall through to the existing type hover logic
413451
let type_ = transaction.get_type_at(handle, position)?;
414-
let current_module = handle.module();
415-
let fallback_name_from_type = fallback_hover_name_from_type(&type_, current_module);
452+
let fallback_name_from_type = fallback_hover_name_from_type(&type_);
416453
let type_display = transaction.ad_hoc_solve(handle, {
417454
let mut cloned = type_.clone();
418455
move |solver| {
@@ -422,7 +459,7 @@ pub fn get_hover(
422459
cloned.as_hover_string()
423460
}
424461
});
425-
let (kind, mut name, docstring_range, module) = if let Some(FindDefinitionItemWithDocstring {
462+
let (kind, name, docstring_range, module) = if let Some(FindDefinitionItemWithDocstring {
426463
metadata,
427464
definition_range: definition_location,
428465
module,
@@ -460,9 +497,7 @@ pub fn get_hover(
460497
(None, fallback_name_from_type, None, None)
461498
};
462499

463-
if name.is_none() {
464-
name = identifier_text_at(transaction, handle, position);
465-
}
500+
let name = name.or_else(|| identifier_text_at(transaction, handle, position));
466501

467502
let docstring = if let (Some(docstring), Some(module)) = (docstring_range, module) {
468503
Some(Docstring(docstring, module))
@@ -620,14 +655,14 @@ mod tests {
620655
#[test]
621656
fn fallback_uses_function_metadata() {
622657
let ty = make_function_type("numpy", "arange");
623-
let fallback = fallback_hover_name_from_type(&ty, ModuleName::from_str("user_code"));
624-
assert_eq!(fallback.as_deref(), Some("numpy.arange"));
658+
let fallback = fallback_hover_name_from_type(&ty);
659+
assert_eq!(fallback.as_deref(), Some("arange"));
625660
}
626661

627662
#[test]
628663
fn fallback_recurses_through_type_wrapper() {
629664
let ty = Type::Type(Box::new(make_function_type("pkg.subpkg", "run")));
630-
let fallback = fallback_hover_name_from_type(&ty, ModuleName::from_str("other"));
631-
assert_eq!(fallback.as_deref(), Some("pkg.subpkg.run"));
665+
let fallback = fallback_hover_name_from_type(&ty);
666+
assert_eq!(fallback.as_deref(), Some("run"));
632667
}
633668
}

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,
@@ -361,8 +359,7 @@ lhs @ rhs
361359
13 | lhs @ rhs
362360
^
363361
```python
364-
(method) __matmul__
365-
def __matmul__(
362+
(method) __matmul__: def __matmul__(
366363
self: Matrix,
367364
other: Matrix
368365
) -> Matrix: ...

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,9 @@ fn hover_shows_third_party_function_name() {
100100
..Default::default()
101101
});
102102

103-
interaction.server.did_open("user_code.py");
103+
interaction.client.did_open("user_code.py");
104104
// Column/line values follow LSP's zero-based positions
105-
interaction.server.hover("user_code.py", 14, 25);
105+
interaction.client.hover("user_code.py", 14, 25);
106106
interaction.client.expect_response_with(
107107
|response| {
108108
response

0 commit comments

Comments
 (0)