[ty] Initial implementation of signature help provider (#19194)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks-instrumented (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

This PR includes:
* Implemented core signature help logic
* Added new docstring method on Definition that returns a docstring for
function and class definitions
* Modified the display code for Signature that allows a signature string
to be broken into text ranges that correspond to each parameter in the
signature
* Augmented Signature struct so it can track the Definition for a
signature when available; this allows us to find the docstring
associated with the signature
* Added utility functions for parsing parameter documentation from three
popular docstring formats (Google, NumPy and reST)
* Implemented tests for all of the above

"Signature help" is displayed by an editor when you are typing a
function call expression. It is typically triggered when you type an
open parenthesis. The language server provides information about the
target function's signature (or multiple signatures), documentation, and
parameters.

Here is how this appears:


![image](https://github.com/user-attachments/assets/40dce616-ed74-4810-be62-42a5b5e4b334)

---------

Co-authored-by: UnboundVariable <unbound@gmail.com>
Co-authored-by: Micha Reiser <micha@reiser.io>
This commit is contained in:
UnboundVariable 2025-07-10 19:32:00 -07:00 committed by GitHub
parent 08bc6d2589
commit b0b65c24ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1914 additions and 51 deletions

View file

@ -1,7 +1,7 @@
use std::ops::Deref;
use ruff_db::files::{File, FileRange};
use ruff_db::parsed::ParsedModuleRef;
use ruff_db::parsed::{ParsedModuleRef, parsed_module};
use ruff_python_ast as ast;
use ruff_text_size::{Ranged, TextRange};
@ -57,6 +57,45 @@ impl<'db> Definition<'db> {
pub fn focus_range(self, db: &'db dyn Db, module: &ParsedModuleRef) -> FileRange {
FileRange::new(self.file(db), self.kind(db).target_range(module))
}
/// Extract a docstring from this definition, if applicable.
/// This method returns a docstring for function and class definitions.
/// The docstring is extracted from the first statement in the body if it's a string literal.
pub fn docstring(self, db: &'db dyn Db) -> Option<String> {
let file = self.file(db);
let module = parsed_module(db, file).load(db);
let kind = self.kind(db);
match kind {
DefinitionKind::Function(function_def) => {
let function_node = function_def.node(&module);
docstring_from_body(&function_node.body)
.map(|docstring_expr| docstring_expr.value.to_str().to_owned())
}
DefinitionKind::Class(class_def) => {
let class_node = class_def.node(&module);
docstring_from_body(&class_node.body)
.map(|docstring_expr| docstring_expr.value.to_str().to_owned())
}
_ => None,
}
}
}
/// Extract a docstring from a function or class body.
fn docstring_from_body(body: &[ast::Stmt]) -> Option<&ast::ExprStringLiteral> {
let stmt = body.first()?;
// Require the docstring to be a standalone expression.
let ast::Stmt::Expr(ast::StmtExpr {
value,
range: _,
node_index: _,
}) = stmt
else {
return None;
};
// Only match string literals.
value.as_string_literal_expr()
}
/// One or more [`Definition`]s.

View file

@ -46,7 +46,9 @@ use crate::types::generics::{
GenericContext, PartialSpecialization, Specialization, walk_generic_context,
walk_partial_specialization, walk_specialization,
};
pub use crate::types::ide_support::{all_members, definition_kind_for_name};
pub use crate::types::ide_support::{
CallSignatureDetails, all_members, call_signature_details, definition_kind_for_name,
};
use crate::types::infer::infer_unpack_types;
use crate::types::mro::{Mro, MroError, MroIterator};
pub(crate) use crate::types::narrow::infer_narrowing_constraint;

View file

@ -3,7 +3,7 @@ use super::{Signature, Type};
use crate::Db;
mod arguments;
mod bind;
pub(crate) mod bind;
pub(super) use arguments::{Argument, CallArgumentTypes, CallArguments};
pub(super) use bind::{Binding, Bindings, CallableBinding};

View file

@ -2,6 +2,7 @@ use std::borrow::Cow;
use std::ops::{Deref, DerefMut};
use itertools::{Either, Itertools};
use ruff_python_ast as ast;
use crate::Db;
use crate::types::KnownClass;
@ -14,6 +15,26 @@ use super::Type;
pub(crate) struct CallArguments<'a>(Vec<Argument<'a>>);
impl<'a> CallArguments<'a> {
/// Create `CallArguments` from AST arguments
pub(crate) fn from_arguments(arguments: &'a ast::Arguments) -> Self {
arguments
.arguments_source_order()
.map(|arg_or_keyword| match arg_or_keyword {
ast::ArgOrKeyword::Arg(arg) => match arg {
ast::Expr::Starred(ast::ExprStarred { .. }) => Argument::Variadic,
_ => Argument::Positional,
},
ast::ArgOrKeyword::Keyword(ast::Keyword { arg, .. }) => {
if let Some(arg) = arg {
Argument::Keyword(&arg.id)
} else {
Argument::Keywords
}
}
})
.collect()
}
/// Prepend an optional extra synthetic argument (for a `self` or `cls` parameter) to the front
/// of this argument list. (If `bound_self` is none, we return the argument list
/// unmodified.)

View file

@ -2109,7 +2109,7 @@ impl<'db> Binding<'db> {
}
}
fn match_parameters(
pub(crate) fn match_parameters(
&mut self,
arguments: &CallArguments<'_>,
argument_forms: &mut [Option<ParameterForm>],
@ -2267,6 +2267,12 @@ impl<'db> Binding<'db> {
self.parameter_tys = parameter_tys;
self.errors = errors;
}
/// Returns a vector where each index corresponds to an argument position,
/// and the value is the parameter index that argument maps to (if any).
pub(crate) fn argument_to_parameter_mapping(&self) -> &[Option<usize>] {
&self.argument_parameters
}
}
#[derive(Clone, Debug)]

View file

@ -678,6 +678,7 @@ impl<'db> ClassType<'db> {
if let Some(signature) = signature {
let synthesized_signature = |signature: &Signature<'db>| {
Signature::new(signature.parameters().clone(), Some(correct_return_type))
.with_definition(signature.definition())
.bind_self()
};

View file

@ -5,6 +5,7 @@ use std::fmt::{self, Display, Formatter, Write};
use ruff_db::display::FormatterJoinExtension;
use ruff_python_ast::str::{Quote, TripleQuotes};
use ruff_python_literal::escape::AsciiEscape;
use ruff_text_size::{TextRange, TextSize};
use crate::types::class::{ClassLiteral, ClassType, GenericAlias};
use crate::types::function::{FunctionType, OverloadLiteral};
@ -557,46 +558,193 @@ pub(crate) struct DisplaySignature<'db> {
db: &'db dyn Db,
}
impl Display for DisplaySignature<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_char('(')?;
impl DisplaySignature<'_> {
/// Get detailed display information including component ranges
pub(crate) fn to_string_parts(&self) -> SignatureDisplayDetails {
let mut writer = SignatureWriter::Details(SignatureDetailsWriter::new());
self.write_signature(&mut writer).unwrap();
match writer {
SignatureWriter::Details(details) => details.finish(),
SignatureWriter::Formatter(_) => unreachable!("Expected Details variant"),
}
}
/// Internal method to write signature with the signature writer
fn write_signature(&self, writer: &mut SignatureWriter) -> fmt::Result {
// Opening parenthesis
writer.write_char('(')?;
if self.parameters.is_gradual() {
// We represent gradual form as `...` in the signature, internally the parameters still
// contain `(*args, **kwargs)` parameters.
f.write_str("...")?;
writer.write_str("...")?;
} else {
let mut star_added = false;
let mut needs_slash = false;
let mut join = f.join(", ");
let mut first = true;
for parameter in self.parameters.as_slice() {
// Handle special separators
if !star_added && parameter.is_keyword_only() {
join.entry(&'*');
if !first {
writer.write_str(", ")?;
}
writer.write_char('*')?;
star_added = true;
first = false;
}
if parameter.is_positional_only() {
needs_slash = true;
} else if needs_slash {
join.entry(&'/');
if !first {
writer.write_str(", ")?;
}
writer.write_char('/')?;
needs_slash = false;
first = false;
}
join.entry(&parameter.display(self.db));
// Add comma before parameter if not first
if !first {
writer.write_str(", ")?;
}
// Write parameter with range tracking
let param_name = parameter.display_name();
writer.write_parameter(&parameter.display(self.db), param_name.as_deref())?;
first = false;
}
if needs_slash {
join.entry(&'/');
if !first {
writer.write_str(", ")?;
}
writer.write_char('/')?;
}
join.finish()?;
}
write!(
f,
") -> {}",
self.return_ty.unwrap_or(Type::unknown()).display(self.db)
)
// Closing parenthesis
writer.write_char(')')?;
// Return type
let return_ty = self.return_ty.unwrap_or_else(Type::unknown);
writer.write_return_type(&return_ty.display(self.db))?;
Ok(())
}
}
impl Display for DisplaySignature<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let mut writer = SignatureWriter::Formatter(f);
self.write_signature(&mut writer)
}
}
/// Writer for building signature strings with different output targets
enum SignatureWriter<'a, 'b> {
/// Write directly to a formatter (for Display trait)
Formatter(&'a mut Formatter<'b>),
/// Build a string with range tracking (for `to_string_parts`)
Details(SignatureDetailsWriter),
}
/// Writer that builds a string with range tracking
struct SignatureDetailsWriter {
label: String,
parameter_ranges: Vec<TextRange>,
parameter_names: Vec<String>,
}
impl SignatureDetailsWriter {
fn new() -> Self {
Self {
label: String::new(),
parameter_ranges: Vec::new(),
parameter_names: Vec::new(),
}
}
fn finish(self) -> SignatureDisplayDetails {
SignatureDisplayDetails {
label: self.label,
parameter_ranges: self.parameter_ranges,
parameter_names: self.parameter_names,
}
}
}
impl SignatureWriter<'_, '_> {
fn write_char(&mut self, c: char) -> fmt::Result {
match self {
SignatureWriter::Formatter(f) => f.write_char(c),
SignatureWriter::Details(details) => {
details.label.push(c);
Ok(())
}
}
}
fn write_str(&mut self, s: &str) -> fmt::Result {
match self {
SignatureWriter::Formatter(f) => f.write_str(s),
SignatureWriter::Details(details) => {
details.label.push_str(s);
Ok(())
}
}
}
fn write_parameter<T: Display>(&mut self, param: &T, param_name: Option<&str>) -> fmt::Result {
match self {
SignatureWriter::Formatter(f) => param.fmt(f),
SignatureWriter::Details(details) => {
let param_start = details.label.len();
let param_display = param.to_string();
details.label.push_str(&param_display);
// Use TextSize::try_from for safe conversion, falling back to empty range on overflow
let start = TextSize::try_from(param_start).unwrap_or_default();
let length = TextSize::try_from(param_display.len()).unwrap_or_default();
details.parameter_ranges.push(TextRange::at(start, length));
// Store the parameter name if available
if let Some(name) = param_name {
details.parameter_names.push(name.to_string());
} else {
details.parameter_names.push(String::new());
}
Ok(())
}
}
}
fn write_return_type<T: Display>(&mut self, return_ty: &T) -> fmt::Result {
match self {
SignatureWriter::Formatter(f) => write!(f, " -> {return_ty}"),
SignatureWriter::Details(details) => {
let return_display = format!(" -> {return_ty}");
details.label.push_str(&return_display);
Ok(())
}
}
}
}
/// Details about signature display components, including ranges for parameters and return type
#[derive(Debug, Clone)]
pub(crate) struct SignatureDisplayDetails {
/// The full signature string
pub label: String,
/// Ranges for each parameter within the label
pub parameter_ranges: Vec<TextRange>,
/// Names of the parameters in order
pub parameter_names: Vec<String>,
}
impl<'db> Parameter<'db> {
fn display(&'db self, db: &'db dyn Db) -> DisplayParameter<'db> {
DisplayParameter { param: self, db }

View file

@ -1,16 +1,20 @@
use std::cmp::Ordering;
use crate::place::{Place, imported_symbol, place_from_bindings, place_from_declarations};
use crate::semantic_index::definition::Definition;
use crate::semantic_index::definition::DefinitionKind;
use crate::semantic_index::place::ScopeId;
use crate::semantic_index::{
attribute_scopes, global_scope, place_table, semantic_index, use_def_map,
};
use crate::types::call::CallArguments;
use crate::types::signatures::Signature;
use crate::types::{ClassBase, ClassLiteral, KnownClass, KnownInstanceType, Type};
use crate::{Db, NameKind};
use crate::{Db, HasType, NameKind, SemanticModel};
use ruff_db::files::File;
use ruff_python_ast as ast;
use ruff_python_ast::name::Name;
use ruff_text_size::TextRange;
use rustc_hash::FxHashSet;
pub(crate) fn all_declarations_and_bindings<'db>(
@ -353,3 +357,73 @@ pub fn definition_kind_for_name<'db>(
None
}
/// Details about a callable signature for IDE support.
#[derive(Debug, Clone)]
pub struct CallSignatureDetails<'db> {
/// The signature itself
pub signature: Signature<'db>,
/// The display label for this signature (e.g., "(param1: str, param2: int) -> str")
pub label: String,
/// Label offsets for each parameter in the signature string.
/// Each range specifies the start position and length of a parameter label
/// within the full signature string.
pub parameter_label_offsets: Vec<TextRange>,
/// The names of the parameters in the signature, in order.
/// This provides easy access to parameter names for documentation lookup.
pub parameter_names: Vec<String>,
/// The definition where this callable was originally defined (useful for
/// extracting docstrings).
pub definition: Option<Definition<'db>>,
/// Mapping from argument indices to parameter indices. This helps
/// determine which parameter corresponds to which argument position.
pub argument_to_parameter_mapping: Vec<Option<usize>>,
}
/// Extract signature details from a function call expression.
/// This function analyzes the callable being invoked and returns zero or more
/// `CallSignatureDetails` objects, each representing one possible signature
/// (in case of overloads or union types).
pub fn call_signature_details<'db>(
db: &'db dyn Db,
file: File,
call_expr: &ast::ExprCall,
) -> Vec<CallSignatureDetails<'db>> {
let model = SemanticModel::new(db, file);
let func_type = call_expr.func.inferred_type(&model);
// Use into_callable to handle all the complex type conversions
if let Some(callable_type) = func_type.into_callable(db) {
let call_arguments = CallArguments::from_arguments(&call_expr.arguments);
let bindings = callable_type.bindings(db).match_parameters(&call_arguments);
// Extract signature details from all callable bindings
bindings
.into_iter()
.flat_map(std::iter::IntoIterator::into_iter)
.map(|binding| {
let signature = &binding.signature;
let display_details = signature.display(db).to_string_parts();
let parameter_label_offsets = display_details.parameter_ranges.clone();
let parameter_names = display_details.parameter_names.clone();
CallSignatureDetails {
signature: signature.clone(),
label: display_details.label,
parameter_label_offsets,
parameter_names,
definition: signature.definition(),
argument_to_parameter_mapping: binding.argument_to_parameter_mapping().to_vec(),
}
})
.collect()
} else {
// Type is not callable, return empty signatures
vec![]
}
}

View file

@ -84,9 +84,7 @@ use crate::semantic_index::place::{
use crate::semantic_index::{
ApplicableConstraints, EagerSnapshotResult, SemanticIndex, place_table, semantic_index,
};
use crate::types::call::{
Argument, Binding, Bindings, CallArgumentTypes, CallArguments, CallError,
};
use crate::types::call::{Binding, Bindings, CallArgumentTypes, CallArguments, CallError};
use crate::types::class::{CodeGeneratorKind, MetaclassErrorKind, SliceLiteral};
use crate::types::diagnostic::{
self, CALL_NON_CALLABLE, CONFLICTING_DECLARATIONS, CONFLICTING_METACLASS,
@ -1917,7 +1915,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
self.infer_type_parameters(type_params);
if let Some(arguments) = class.arguments.as_deref() {
let call_arguments = Self::parse_arguments(arguments);
let call_arguments = CallArguments::from_arguments(arguments);
let argument_forms = vec![Some(ParameterForm::Value); call_arguments.len()];
self.infer_argument_types(arguments, call_arguments, &argument_forms);
}
@ -4626,29 +4624,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
self.infer_expression(expression)
}
fn parse_arguments(arguments: &ast::Arguments) -> CallArguments<'_> {
arguments
.arguments_source_order()
.map(|arg_or_keyword| {
match arg_or_keyword {
ast::ArgOrKeyword::Arg(arg) => match arg {
ast::Expr::Starred(ast::ExprStarred { .. }) => Argument::Variadic,
// TODO diagnostic if after a keyword argument
_ => Argument::Positional,
},
ast::ArgOrKeyword::Keyword(ast::Keyword { arg, .. }) => {
if let Some(arg) = arg {
Argument::Keyword(&arg.id)
} else {
// TODO diagnostic if not last
Argument::Keywords
}
}
}
})
.collect()
}
fn infer_argument_types<'a>(
&mut self,
ast_arguments: &ast::Arguments,
@ -5362,7 +5337,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
// We don't call `Type::try_call`, because we want to perform type inference on the
// arguments after matching them to parameters, but before checking that the argument types
// are assignable to any parameter annotations.
let call_arguments = Self::parse_arguments(arguments);
let call_arguments = CallArguments::from_arguments(arguments);
let callable_type = self.infer_maybe_standalone_expression(func);

View file

@ -213,7 +213,7 @@ impl<'a, 'db> IntoIterator for &'a CallableSignature<'db> {
}
/// The signature of one of the overloads of a callable.
#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)]
#[derive(Clone, Debug, salsa::Update, get_size2::GetSize)]
pub struct Signature<'db> {
/// The generic context for this overload, if it is generic.
pub(crate) generic_context: Option<GenericContext<'db>>,
@ -223,6 +223,10 @@ pub struct Signature<'db> {
/// to its own generic context.
pub(crate) inherited_generic_context: Option<GenericContext<'db>>,
/// The original definition associated with this function, if available.
/// This is useful for locating and extracting docstring information for the signature.
pub(crate) definition: Option<Definition<'db>>,
/// Parameters, in source order.
///
/// The ordering of parameters in a valid signature must be: first positional-only parameters,
@ -265,6 +269,7 @@ impl<'db> Signature<'db> {
Self {
generic_context: None,
inherited_generic_context: None,
definition: None,
parameters,
return_ty,
}
@ -278,6 +283,7 @@ impl<'db> Signature<'db> {
Self {
generic_context,
inherited_generic_context: None,
definition: None,
parameters,
return_ty,
}
@ -288,6 +294,7 @@ impl<'db> Signature<'db> {
Signature {
generic_context: None,
inherited_generic_context: None,
definition: None,
parameters: Parameters::gradual_form(),
return_ty: Some(signature_type),
}
@ -300,6 +307,7 @@ impl<'db> Signature<'db> {
Signature {
generic_context: None,
inherited_generic_context: None,
definition: None,
parameters: Parameters::todo(),
return_ty: Some(signature_type),
}
@ -332,6 +340,7 @@ impl<'db> Signature<'db> {
Self {
generic_context: generic_context.or(legacy_generic_context),
inherited_generic_context,
definition: Some(definition),
parameters,
return_ty,
}
@ -351,6 +360,7 @@ impl<'db> Signature<'db> {
Self {
generic_context: self.generic_context,
inherited_generic_context: self.inherited_generic_context,
definition: self.definition,
// Parameters are at contravariant position, so the variance is flipped.
parameters: self.parameters.materialize(db, variance.flip()),
return_ty: Some(
@ -373,6 +383,7 @@ impl<'db> Signature<'db> {
inherited_generic_context: self
.inherited_generic_context
.map(|ctx| ctx.normalized_impl(db, visitor)),
definition: self.definition,
parameters: self
.parameters
.iter()
@ -392,6 +403,7 @@ impl<'db> Signature<'db> {
Self {
generic_context: self.generic_context,
inherited_generic_context: self.inherited_generic_context,
definition: self.definition,
parameters: self.parameters.apply_type_mapping(db, type_mapping),
return_ty: self
.return_ty
@ -422,10 +434,16 @@ impl<'db> Signature<'db> {
&self.parameters
}
/// Return the definition associated with this signature, if any.
pub(crate) fn definition(&self) -> Option<Definition<'db>> {
self.definition
}
pub(crate) fn bind_self(&self) -> Self {
Self {
generic_context: self.generic_context,
inherited_generic_context: self.inherited_generic_context,
definition: self.definition,
parameters: Parameters::new(self.parameters().iter().skip(1).cloned()),
return_ty: self.return_ty,
}
@ -899,6 +917,33 @@ impl<'db> Signature<'db> {
true
}
/// Create a new signature with the given definition.
pub(crate) fn with_definition(self, definition: Option<Definition<'db>>) -> Self {
Self { definition, ..self }
}
}
// Manual implementations of PartialEq, Eq, and Hash that exclude the definition field
// since the definition is not relevant for type equality/equivalence
impl PartialEq for Signature<'_> {
fn eq(&self, other: &Self) -> bool {
self.generic_context == other.generic_context
&& self.inherited_generic_context == other.inherited_generic_context
&& self.parameters == other.parameters
&& self.return_ty == other.return_ty
}
}
impl Eq for Signature<'_> {}
impl std::hash::Hash for Signature<'_> {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.generic_context.hash(state);
self.inherited_generic_context.hash(state);
self.parameters.hash(state);
self.return_ty.hash(state);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)]