Skip to content

Commit d15191b

Browse files
fix
1 parent 0a528f4 commit d15191b

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
@@ -765,41 +765,48 @@ impl<'a> ClassDisplayContext<'a> {
765765
}
766766
}
767767

768+
/// Small helper describing the formatted code that should appear inside a hover block.
769+
///
770+
/// `body` is the snippet that will live inside the fenced code block, while
771+
/// `default_kind_label` lets callers know which `(kind)` prefix to use when the
772+
/// hover metadata can't provide one (e.g. when resolving a stub-only function).
768773
#[derive(Debug, Clone)]
769774
pub struct HoverCodeSnippet {
770-
pub heading: Option<String>,
771775
pub body: String,
772776
pub default_kind_label: Option<&'static str>,
773777
}
774778

779+
/// Format the string returned by `Type::as_hover_string()` so that callable types
780+
/// always resemble real Python function definitions. When `name` is provided we
781+
/// will synthesize a `def name(...): ...` signature if the rendered type only
782+
/// contains the parenthesized parameter list. This keeps IDE syntax highlighting
783+
/// working even when we fall back to hover strings built from metadata alone.
784+
///
785+
/// `display` is typically `Type::as_hover_string()`, but callers may pass their
786+
/// own rendering (for example, after expanding TypedDict kwargs).
775787
pub fn format_hover_code_snippet(
776788
type_: &Type,
777789
name: Option<&str>,
778790
display: String,
779791
) -> HoverCodeSnippet {
780792
if type_.is_function_type() {
781-
if let Some(name) = name {
782-
let trimmed = display.trim_start();
783-
let body = if trimmed.starts_with('(') {
784-
format!("def {}{}: ...", name, trimmed)
785-
} else {
786-
display
787-
};
788-
HoverCodeSnippet {
789-
heading: Some(name.to_owned()),
790-
body,
791-
default_kind_label: Some("(function) "),
792-
}
793-
} else {
794-
HoverCodeSnippet {
795-
heading: None,
796-
body: display,
797-
default_kind_label: Some("(function) "),
793+
let body = match name {
794+
Some(name) => {
795+
let trimmed = display.trim_start();
796+
if trimmed.starts_with('(') {
797+
format!("def {}{}: ...", name, trimmed)
798+
} else {
799+
display
800+
}
798801
}
802+
None => display,
803+
};
804+
HoverCodeSnippet {
805+
body,
806+
default_kind_label: Some("(function) "),
799807
}
800808
} else {
801809
HoverCodeSnippet {
802-
heading: None,
803810
body: display,
804811
default_kind_label: None,
805812
}

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;
@@ -199,6 +200,9 @@ impl HoverValue {
199200
.display
200201
.clone()
201202
.unwrap_or_else(|| self.type_.as_hover_string());
203+
// Ensure callable hover bodies always contain a proper `def name(...)` so IDE syntax
204+
// highlighting stays consistent, even when metadata is missing and we fall back to
205+
// inferred identifiers.
202206
let snippet = format_hover_code_snippet(&self.type_, self.name.as_deref(), type_display);
203207
let kind_formatted = self.kind.map_or_else(
204208
|| {
@@ -213,11 +217,6 @@ impl HoverValue {
213217
.name
214218
.as_ref()
215219
.map_or("".to_owned(), |s| format!("{s}: "));
216-
let heading = if let Some(callable_heading) = snippet.heading.as_ref() {
217-
format!("{}{}\n", kind_formatted, callable_heading)
218-
} else {
219-
format!("{}{}", kind_formatted, name_formatted)
220-
};
221220

222221
Hover {
223222
contents: HoverContents::Markup(MarkupContent {
@@ -281,30 +280,72 @@ fn expand_callable_kwargs_for_hover<'a>(
281280
}
282281
}
283282

284-
fn fallback_hover_name_from_type(type_: &Type, current_module: ModuleName) -> Option<String> {
283+
/// If we can't determine a symbol name via go-to-definition, fall back to what the
284+
/// type metadata knows about the callable. This primarily handles third-party stubs
285+
/// where we only have typeshed information.
286+
fn fallback_hover_name_from_type(type_: &Type) -> Option<String> {
285287
match type_ {
286-
Type::Function(function) => Some(function.metadata.kind.format(current_module)),
288+
Type::Function(function) => Some(
289+
function
290+
.metadata
291+
.kind
292+
.function_name()
293+
.into_owned()
294+
.to_string(),
295+
),
287296
Type::BoundMethod(bound_method) => match &bound_method.func {
288-
BoundMethodType::Function(function) => {
289-
Some(function.metadata.kind.format(current_module))
290-
}
291-
BoundMethodType::Forall(forall) => {
292-
Some(forall.body.metadata.kind.format(current_module))
293-
}
294-
BoundMethodType::Overload(overload) => {
295-
Some(overload.metadata.kind.format(current_module))
296-
}
297+
BoundMethodType::Function(function) => Some(
298+
function
299+
.metadata
300+
.kind
301+
.function_name()
302+
.into_owned()
303+
.to_string(),
304+
),
305+
BoundMethodType::Forall(forall) => Some(
306+
forall
307+
.body
308+
.metadata
309+
.kind
310+
.function_name()
311+
.into_owned()
312+
.to_string(),
313+
),
314+
BoundMethodType::Overload(overload) => Some(
315+
overload
316+
.metadata
317+
.kind
318+
.function_name()
319+
.into_owned()
320+
.to_string(),
321+
),
297322
},
298-
Type::Overload(overload) => Some(overload.metadata.kind.format(current_module)),
323+
Type::Overload(overload) => Some(
324+
overload
325+
.metadata
326+
.kind
327+
.function_name()
328+
.into_owned()
329+
.to_string(),
330+
),
299331
Type::Forall(forall) => match &forall.body {
300-
Forallable::Function(function) => Some(function.metadata.kind.format(current_module)),
332+
Forallable::Function(function) => Some(
333+
function
334+
.metadata
335+
.kind
336+
.function_name()
337+
.into_owned()
338+
.to_string(),
339+
),
301340
Forallable::Callable(_) | Forallable::TypeAlias(_) => None,
302341
},
303-
Type::Type(inner) => fallback_hover_name_from_type(inner, current_module),
342+
Type::Type(inner) => fallback_hover_name_from_type(inner),
304343
_ => None,
305344
}
306345
}
307346

347+
/// Extract the identifier under the cursor directly from the file contents so we can
348+
/// label hover results even when go-to-definition fails.
308349
fn identifier_text_at(
309350
transaction: &Transaction<'_>,
310351
handle: &Handle,
@@ -314,10 +355,7 @@ fn identifier_text_at(
314355
let contents = module.contents();
315356
let bytes = contents.as_bytes();
316357
let len = bytes.len();
317-
let mut pos = position.to_usize();
318-
if pos > len {
319-
pos = len;
320-
}
358+
let pos = position.to_usize().min(len);
321359
let is_ident_char = |b: u8| b == b'_' || b.is_ascii_alphanumeric();
322360
let mut start = pos;
323361
while start > 0 && is_ident_char(bytes[start - 1]) {
@@ -328,10 +366,10 @@ fn identifier_text_at(
328366
end += 1;
329367
}
330368
if start == end {
331-
None
332-
} else {
333-
Some(contents[start..end].to_string())
369+
return None;
334370
}
371+
let range = TextRange::new(TextSize::new(start as u32), TextSize::new(end as u32));
372+
Some(module.code_at(range).to_owned())
335373
}
336374

337375
pub fn get_hover(
@@ -372,8 +410,7 @@ pub fn get_hover(
372410

373411
// Otherwise, fall through to the existing type hover logic
374412
let type_ = transaction.get_type_at(handle, position)?;
375-
let current_module = handle.module();
376-
let fallback_name_from_type = fallback_hover_name_from_type(&type_, current_module);
413+
let fallback_name_from_type = fallback_hover_name_from_type(&type_);
377414
let type_display = transaction.ad_hoc_solve(handle, {
378415
let mut cloned = type_.clone();
379416
move |solver| {
@@ -383,7 +420,7 @@ pub fn get_hover(
383420
cloned.as_hover_string()
384421
}
385422
});
386-
let (kind, mut name, docstring_range, module) = if let Some(FindDefinitionItemWithDocstring {
423+
let (kind, name, docstring_range, module) = if let Some(FindDefinitionItemWithDocstring {
387424
metadata,
388425
definition_range: definition_location,
389426
module,
@@ -421,9 +458,7 @@ pub fn get_hover(
421458
(None, fallback_name_from_type, None, None)
422459
};
423460

424-
if name.is_none() {
425-
name = identifier_text_at(transaction, handle, position);
426-
}
461+
let name = name.or_else(|| identifier_text_at(transaction, handle, position));
427462

428463
let docstring = if let (Some(docstring), Some(module)) = (docstring_range, module) {
429464
Some(Docstring(docstring, module))
@@ -581,14 +616,14 @@ mod tests {
581616
#[test]
582617
fn fallback_uses_function_metadata() {
583618
let ty = make_function_type("numpy", "arange");
584-
let fallback = fallback_hover_name_from_type(&ty, ModuleName::from_str("user_code"));
585-
assert_eq!(fallback.as_deref(), Some("numpy.arange"));
619+
let fallback = fallback_hover_name_from_type(&ty);
620+
assert_eq!(fallback.as_deref(), Some("arange"));
586621
}
587622

588623
#[test]
589624
fn fallback_recurses_through_type_wrapper() {
590625
let ty = Type::Type(Box::new(make_function_type("pkg.subpkg", "run")));
591-
let fallback = fallback_hover_name_from_type(&ty, ModuleName::from_str("other"));
592-
assert_eq!(fallback.as_deref(), Some("pkg.subpkg.run"));
626+
let fallback = fallback_hover_name_from_type(&ty);
627+
assert_eq!(fallback.as_deref(), Some("run"));
593628
}
594629
}

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,
@@ -340,8 +338,7 @@ lhs @ rhs
340338
13 | lhs @ rhs
341339
^
342340
```python
343-
(method) __matmul__
344-
def __matmul__(
341+
(method) __matmul__: def __matmul__(
345342
self: Matrix,
346343
other: Matrix
347344
) -> Matrix: ...

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,9 @@ fn hover_shows_third_party_function_name() {
9191
..Default::default()
9292
});
9393

94-
interaction.server.did_open("user_code.py");
94+
interaction.client.did_open("user_code.py");
9595
// Column/line values follow LSP's zero-based positions
96-
interaction.server.hover("user_code.py", 14, 25);
96+
interaction.client.hover("user_code.py", 14, 25);
9797
interaction.client.expect_response_with(
9898
|response| {
9999
response

0 commit comments

Comments
 (0)