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
48 changes: 48 additions & 0 deletions crates/pyrefly_types/src/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,54 @@ impl<'a> ClassDisplayContext<'a> {
}
}

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

/// Format the string returned by `Type::as_hover_string()` so that callable types
/// always resemble real Python function definitions. When `name` is provided we
/// will synthesize a `def name(...): ...` signature if the rendered type only
/// contains the parenthesized parameter list. This keeps IDE syntax highlighting
/// working even when we fall back to hover strings built from metadata alone.
///
/// `display` is typically `Type::as_hover_string()`, but callers may pass their
/// own rendering (for example, after expanding TypedDict kwargs).
pub fn format_hover_code_snippet(
type_: &Type,
name: Option<&str>,
display: String,
) -> HoverCodeSnippet {
if type_.is_function_type() {
let body = match name {
Some(name) => {
let trimmed = display.trim_start();
if trimmed.starts_with('(') {
format!("def {}{}: ...", name, trimmed)
} else {
display
}
}
None => display,
};
HoverCodeSnippet {
body,
default_kind_label: Some("(function) "),
}
} else {
HoverCodeSnippet {
body: display,
default_kind_label: None,
}
}
}

#[cfg(test)]
pub mod tests {
use std::path::PathBuf;
Expand Down
2 changes: 2 additions & 0 deletions pyrefly/lib/lsp/non_wasm/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2248,6 +2248,7 @@ impl Server {
definition_range,
module,
docstring_range: _,
..
} = definition;
// find_global_implementations_from_definition returns Vec<TextRangeWithModule>
// but we need to return Vec<(ModuleInfo, Vec<TextRange>)> to match the helper's
Expand Down Expand Up @@ -2499,6 +2500,7 @@ impl Server {
definition_range,
module,
docstring_range: _,
..
} = definition;
transaction.find_global_references_from_definition(
handle.sys_info(),
Expand Down
192 changes: 184 additions & 8 deletions pyrefly/lib/lsp/wasm/hover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,17 @@ use pyrefly_python::docstring::parse_parameter_documentation;
use pyrefly_python::ignore::Ignore;
use pyrefly_python::ignore::Tool;
use pyrefly_python::ignore::find_comment_start_in_line;
#[cfg(test)]
use pyrefly_python::module_name::ModuleName;
use pyrefly_python::symbol_kind::SymbolKind;
use pyrefly_types::callable::Callable;
use pyrefly_types::callable::Param;
use pyrefly_types::callable::ParamList;
use pyrefly_types::callable::Params;
use pyrefly_types::callable::Required;
use pyrefly_types::display::format_hover_code_snippet;
use pyrefly_types::types::BoundMethodType;
use pyrefly_types::types::Forallable;
use pyrefly_types::types::Type;
use pyrefly_util::lined_buffer::LineNumber;
use ruff_python_ast::name::Name;
Expand Down Expand Up @@ -179,7 +184,7 @@ impl HoverValue {
let cleaned = doc.trim().replace('\n', " \n");
format!("{prefix}**Parameter `{}`**\n{}", name, cleaned)
});
let kind_formatted = self.kind.map_or("".to_owned(), |kind| {
let mut kind_formatted = self.kind.map_or("".to_owned(), |kind| {
format!("{} ", kind.display_for_hover())
});
let name_formatted = self
Expand All @@ -195,6 +200,23 @@ impl HoverValue {
.display
.clone()
.unwrap_or_else(|| self.type_.as_hover_string());
// Ensure callable hover bodies always contain a proper `def name(...)` so IDE syntax
// highlighting stays consistent, even when metadata is missing and we fall back to
// inferred identifiers.
let snippet = format_hover_code_snippet(&self.type_, self.name.as_deref(), type_display);
let kind_formatted = self.kind.map_or_else(
|| {
snippet
.default_kind_label
.map(str::to_owned)
.unwrap_or_default()
},
|kind| format!("{} ", kind.display_for_hover()),
);
let name_formatted = self
.name
.as_ref()
.map_or("".to_owned(), |s| format!("{s}: "));

Hover {
contents: HoverContents::Markup(MarkupContent {
Expand Down Expand Up @@ -257,6 +279,99 @@ fn expand_callable_kwargs_for_hover<'a>(
}
}
}

/// If we can't determine a symbol name via go-to-definition, fall back to what the
/// type metadata knows about the callable. This primarily handles third-party stubs
/// where we only have typeshed information.
fn fallback_hover_name_from_type(type_: &Type) -> Option<String> {
match type_ {
Type::Function(function) => Some(
function
.metadata
.kind
.function_name()
.into_owned()
.to_string(),
),
Type::BoundMethod(bound_method) => match &bound_method.func {
BoundMethodType::Function(function) => Some(
function
.metadata
.kind
.function_name()
.into_owned()
.to_string(),
),
BoundMethodType::Forall(forall) => Some(
forall
.body
.metadata
.kind
.function_name()
.into_owned()
.to_string(),
),
BoundMethodType::Overload(overload) => Some(
overload
.metadata
.kind
.function_name()
.into_owned()
.to_string(),
),
},
Type::Overload(overload) => Some(
overload
.metadata
.kind
.function_name()
.into_owned()
.to_string(),
),
Type::Forall(forall) => match &forall.body {
Forallable::Function(function) => Some(
function
.metadata
.kind
.function_name()
.into_owned()
.to_string(),
),
Forallable::Callable(_) | Forallable::TypeAlias(_) => None,
},
Type::Type(inner) => fallback_hover_name_from_type(inner),
_ => None,
}
}

/// Extract the identifier under the cursor directly from the file contents so we can
/// label hover results even when go-to-definition fails.
fn identifier_text_at(
transaction: &Transaction<'_>,
handle: &Handle,
position: TextSize,
) -> Option<String> {
let module = transaction.get_module_info(handle)?;
let contents = module.contents();
let bytes = contents.as_bytes();
let len = bytes.len();
let pos = position.to_usize().min(len);
let is_ident_char = |b: u8| b == b'_' || b.is_ascii_alphanumeric();
let mut start = pos;
while start > 0 && is_ident_char(bytes[start - 1]) {
start -= 1;
}
let mut end = pos;
while end < len && is_ident_char(bytes[end]) {
end += 1;
}
if start == end {
return None;
}
let range = TextRange::new(TextSize::new(start as u32), TextSize::new(end as u32));
Some(module.code_at(range).to_owned())
}

pub fn get_hover(
transaction: &Transaction<'_>,
handle: &Handle,
Expand Down Expand Up @@ -295,6 +410,7 @@ pub fn get_hover(

// Otherwise, fall through to the existing type hover logic
let type_ = transaction.get_type_at(handle, position)?;
let fallback_name_from_type = fallback_hover_name_from_type(&type_);
let type_display = transaction.ad_hoc_solve(handle, {
let mut cloned = type_.clone();
move |solver| {
Expand All @@ -309,6 +425,7 @@ pub fn get_hover(
definition_range: definition_location,
module,
docstring_range,
display_name,
}) = transaction
.find_definition(
handle,
Expand All @@ -326,16 +443,23 @@ pub fn get_hover(
if matches!(kind, Some(SymbolKind::Attribute)) && type_.is_function_type() {
kind = Some(SymbolKind::Method);
}
(
kind,
Some(module.code_at(definition_location).to_owned()),
docstring_range,
Some(module),
)
let name = {
let snippet = module.code_at(definition_location);
if snippet.chars().any(|c| !c.is_whitespace()) {
Some(snippet.to_owned())
} else if let Some(name) = display_name.clone() {
Some(name)
} else {
fallback_name_from_type.clone()
}
};
(kind, name, docstring_range, Some(module))
} else {
(None, None, None, None)
(None, fallback_name_from_type, None, None)
};

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

let docstring = if let (Some(docstring), Some(module)) = (docstring_range, module) {
Some(Docstring(docstring, module))
} else {
Expand Down Expand Up @@ -451,3 +575,55 @@ fn parameter_documentation_for_callee(
let docs = parse_parameter_documentation(module.code_at(range));
if docs.is_empty() { None } else { Some(docs) }
}

#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::Arc;

use pyrefly_python::module::Module;
use pyrefly_python::module_path::ModulePath;
use pyrefly_types::callable::Callable;
use pyrefly_types::callable::FuncFlags;
use pyrefly_types::callable::FuncId;
use pyrefly_types::callable::FuncMetadata;
use pyrefly_types::callable::Function;
use pyrefly_types::callable::FunctionKind;
use ruff_python_ast::name::Name;

use super::*;

fn make_function_type(module_name: &str, func_name: &str) -> Type {
let module = Module::new(
ModuleName::from_str(module_name),
ModulePath::filesystem(PathBuf::from(format!("{module_name}.pyi"))),
Arc::new(String::new()),
);
let metadata = FuncMetadata {
kind: FunctionKind::Def(Box::new(FuncId {
module,
cls: None,
name: Name::new(func_name),
})),
flags: FuncFlags::default(),
};
Type::Function(Box::new(Function {
signature: Callable::ellipsis(Type::None),
metadata,
}))
}

#[test]
fn fallback_uses_function_metadata() {
let ty = make_function_type("numpy", "arange");
let fallback = fallback_hover_name_from_type(&ty);
assert_eq!(fallback.as_deref(), Some("arange"));
}

#[test]
fn fallback_recurses_through_type_wrapper() {
let ty = Type::Type(Box::new(make_function_type("pkg.subpkg", "run")));
let fallback = fallback_hover_name_from_type(&ty);
assert_eq!(fallback.as_deref(), Some("run"));
}
}
Loading