mirror of
https://github.com/astral-sh/ruff.git
synced 2025-10-01 14:21:24 +00:00
[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
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:  --------- Co-authored-by: UnboundVariable <unbound@gmail.com> Co-authored-by: Micha Reiser <micha@reiser.io>
This commit is contained in:
parent
08bc6d2589
commit
b0b65c24ff
20 changed files with 1914 additions and 51 deletions
|
@ -15,9 +15,12 @@ bitflags = { workspace = true }
|
|||
ruff_db = { workspace = true }
|
||||
ruff_python_ast = { workspace = true }
|
||||
ruff_python_parser = { workspace = true }
|
||||
ruff_python_trivia = { workspace = true }
|
||||
ruff_source_file = { workspace = true }
|
||||
ruff_text_size = { workspace = true }
|
||||
ty_python_semantic = { workspace = true }
|
||||
|
||||
regex = { workspace = true }
|
||||
rustc-hash = { workspace = true }
|
||||
salsa = { workspace = true }
|
||||
smallvec = { workspace = true }
|
||||
|
|
664
crates/ty_ide/src/docstring.rs
Normal file
664
crates/ty_ide/src/docstring.rs
Normal file
|
@ -0,0 +1,664 @@
|
|||
//! Docstring parsing utilities for language server features.
|
||||
//!
|
||||
//! This module provides functionality for extracting structured information from
|
||||
//! Python docstrings, including parameter documentation for signature help.
|
||||
//! Supports Google-style, NumPy-style, and reST/Sphinx-style docstrings.
|
||||
//! There are no formal specifications for any of these formats, so the parsing
|
||||
//! logic needs to be tolerant of variations.
|
||||
|
||||
use regex::Regex;
|
||||
use ruff_python_trivia::leading_indentation;
|
||||
use ruff_source_file::UniversalNewlines;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// Static regex instances to avoid recompilation
|
||||
static GOOGLE_SECTION_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)^\s*(Args|Arguments|Parameters)\s*:\s*$")
|
||||
.expect("Google section regex should be valid")
|
||||
});
|
||||
|
||||
static GOOGLE_PARAM_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^\s*(\*?\*?\w+)\s*(\(.*?\))?\s*:\s*(.+)")
|
||||
.expect("Google parameter regex should be valid")
|
||||
});
|
||||
|
||||
static NUMPY_SECTION_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)^\s*Parameters\s*$").expect("NumPy section regex should be valid")
|
||||
});
|
||||
|
||||
static NUMPY_UNDERLINE_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^\s*-+\s*$").expect("NumPy underline regex should be valid"));
|
||||
|
||||
static REST_PARAM_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^\s*:param\s+(?:(\w+)\s+)?(\w+)\s*:\s*(.+)")
|
||||
.expect("reST parameter regex should be valid")
|
||||
});
|
||||
|
||||
/// Extract parameter documentation from popular docstring formats.
|
||||
/// Returns a map of parameter names to their documentation.
|
||||
pub fn get_parameter_documentation(docstring: &str) -> HashMap<String, String> {
|
||||
let mut param_docs = HashMap::new();
|
||||
|
||||
// Google-style docstrings
|
||||
param_docs.extend(extract_google_style_params(docstring));
|
||||
|
||||
// NumPy-style docstrings
|
||||
param_docs.extend(extract_numpy_style_params(docstring));
|
||||
|
||||
// reST/Sphinx-style docstrings
|
||||
param_docs.extend(extract_rest_style_params(docstring));
|
||||
|
||||
param_docs
|
||||
}
|
||||
|
||||
/// Extract parameter documentation from Google-style docstrings.
|
||||
fn extract_google_style_params(docstring: &str) -> HashMap<String, String> {
|
||||
let mut param_docs = HashMap::new();
|
||||
|
||||
let mut in_args_section = false;
|
||||
let mut current_param: Option<String> = None;
|
||||
let mut current_doc = String::new();
|
||||
|
||||
for line_obj in docstring.universal_newlines() {
|
||||
let line = line_obj.as_str();
|
||||
if GOOGLE_SECTION_REGEX.is_match(line) {
|
||||
in_args_section = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_args_section {
|
||||
// Check if we hit another section (starts with a word followed by colon at line start)
|
||||
if !line.starts_with(' ') && !line.starts_with('\t') && line.contains(':') {
|
||||
if let Some(colon_pos) = line.find(':') {
|
||||
let section_name = line[..colon_pos].trim();
|
||||
// If this looks like another section, stop processing args
|
||||
if !section_name.is_empty()
|
||||
&& section_name
|
||||
.chars()
|
||||
.all(|c| c.is_alphabetic() || c.is_whitespace())
|
||||
{
|
||||
// Check if this is a known section name
|
||||
let known_sections = [
|
||||
"Returns", "Return", "Raises", "Yields", "Yield", "Examples",
|
||||
"Example", "Note", "Notes", "Warning", "Warnings",
|
||||
];
|
||||
if known_sections.contains(§ion_name) {
|
||||
if let Some(param_name) = current_param.take() {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
current_doc.clear();
|
||||
}
|
||||
in_args_section = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(captures) = GOOGLE_PARAM_REGEX.captures(line) {
|
||||
// Save previous parameter if exists
|
||||
if let Some(param_name) = current_param.take() {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
current_doc.clear();
|
||||
}
|
||||
|
||||
// Start new parameter
|
||||
if let (Some(param), Some(desc)) = (captures.get(1), captures.get(3)) {
|
||||
current_param = Some(param.as_str().to_string());
|
||||
current_doc = desc.as_str().to_string();
|
||||
}
|
||||
} else if line.starts_with(' ') || line.starts_with('\t') {
|
||||
// This is a continuation of the current parameter documentation
|
||||
if current_param.is_some() {
|
||||
if !current_doc.is_empty() {
|
||||
current_doc.push('\n');
|
||||
}
|
||||
current_doc.push_str(line.trim());
|
||||
}
|
||||
} else {
|
||||
// This is a line that doesn't start with whitespace and isn't a parameter
|
||||
// It might be a section or other content, so stop processing args
|
||||
if let Some(param_name) = current_param.take() {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
current_doc.clear();
|
||||
}
|
||||
in_args_section = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last parameter
|
||||
if let Some(param_name) = current_param {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
}
|
||||
|
||||
param_docs
|
||||
}
|
||||
|
||||
/// Calculate the indentation level of a line (number of leading whitespace characters)
|
||||
fn get_indentation_level(line: &str) -> usize {
|
||||
leading_indentation(line).len()
|
||||
}
|
||||
|
||||
/// Extract parameter documentation from NumPy-style docstrings.
|
||||
fn extract_numpy_style_params(docstring: &str) -> HashMap<String, String> {
|
||||
let mut param_docs = HashMap::new();
|
||||
|
||||
let mut lines = docstring
|
||||
.universal_newlines()
|
||||
.map(|line| line.as_str())
|
||||
.peekable();
|
||||
let mut in_params_section = false;
|
||||
let mut found_underline = false;
|
||||
let mut current_param: Option<String> = None;
|
||||
let mut current_doc = String::new();
|
||||
let mut base_param_indent: Option<usize> = None;
|
||||
let mut base_content_indent: Option<usize> = None;
|
||||
|
||||
while let Some(line) = lines.next() {
|
||||
if NUMPY_SECTION_REGEX.is_match(line) {
|
||||
// Check if the next line is an underline
|
||||
if let Some(next_line) = lines.peek() {
|
||||
if NUMPY_UNDERLINE_REGEX.is_match(next_line) {
|
||||
in_params_section = true;
|
||||
found_underline = false;
|
||||
base_param_indent = None;
|
||||
base_content_indent = None;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if in_params_section && !found_underline {
|
||||
if NUMPY_UNDERLINE_REGEX.is_match(line) {
|
||||
found_underline = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if in_params_section && found_underline {
|
||||
let current_indent = get_indentation_level(line);
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Skip empty lines
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we hit another section
|
||||
if current_indent == 0 {
|
||||
if let Some(next_line) = lines.peek() {
|
||||
if NUMPY_UNDERLINE_REGEX.is_match(next_line) {
|
||||
// This is another section
|
||||
if let Some(param_name) = current_param.take() {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
current_doc.clear();
|
||||
}
|
||||
in_params_section = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if this could be a parameter line
|
||||
let could_be_param = if let Some(base_indent) = base_param_indent {
|
||||
// We've seen parameters before - check if this matches the expected parameter indentation
|
||||
current_indent == base_indent
|
||||
} else {
|
||||
// First potential parameter - check if it has reasonable indentation and content
|
||||
current_indent > 0
|
||||
&& (trimmed.contains(':')
|
||||
|| trimmed.chars().all(|c| c.is_alphanumeric() || c == '_'))
|
||||
};
|
||||
|
||||
if could_be_param {
|
||||
// Check if this could be a section header by looking at the next line
|
||||
if let Some(next_line) = lines.peek() {
|
||||
if NUMPY_UNDERLINE_REGEX.is_match(next_line) {
|
||||
// This is a section header, not a parameter
|
||||
if let Some(param_name) = current_param.take() {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
current_doc.clear();
|
||||
}
|
||||
in_params_section = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Set base indentation levels on first parameter
|
||||
if base_param_indent.is_none() {
|
||||
base_param_indent = Some(current_indent);
|
||||
}
|
||||
|
||||
// Handle parameter with type annotation (param : type)
|
||||
if trimmed.contains(':') {
|
||||
// Save previous parameter if exists
|
||||
if let Some(param_name) = current_param.take() {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
current_doc.clear();
|
||||
}
|
||||
|
||||
// Extract parameter name and description
|
||||
let parts: Vec<&str> = trimmed.splitn(2, ':').collect();
|
||||
if parts.len() == 2 {
|
||||
let param_name = parts[0].trim();
|
||||
|
||||
// Extract just the parameter name (before any type info)
|
||||
let param_name = param_name.split_whitespace().next().unwrap_or(param_name);
|
||||
current_param = Some(param_name.to_string());
|
||||
current_doc.clear(); // Description comes on following lines, not on this line
|
||||
}
|
||||
} else {
|
||||
// Handle parameter without type annotation
|
||||
// Save previous parameter if exists
|
||||
if let Some(param_name) = current_param.take() {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
current_doc.clear();
|
||||
}
|
||||
|
||||
// This line is the parameter name
|
||||
current_param = Some(trimmed.to_string());
|
||||
current_doc.clear();
|
||||
}
|
||||
} else if current_param.is_some() {
|
||||
// Determine if this is content for the current parameter
|
||||
let is_content = if let Some(base_content) = base_content_indent {
|
||||
// We've seen content before - check if this matches expected content indentation
|
||||
current_indent >= base_content
|
||||
} else {
|
||||
// First potential content line - should be more indented than parameter
|
||||
if let Some(base_param) = base_param_indent {
|
||||
current_indent > base_param
|
||||
} else {
|
||||
// Fallback: any indented content
|
||||
current_indent > 0
|
||||
}
|
||||
};
|
||||
|
||||
if is_content {
|
||||
// Set base content indentation on first content line
|
||||
if base_content_indent.is_none() {
|
||||
base_content_indent = Some(current_indent);
|
||||
}
|
||||
|
||||
// This is a continuation of the current parameter documentation
|
||||
if !current_doc.is_empty() {
|
||||
current_doc.push('\n');
|
||||
}
|
||||
current_doc.push_str(trimmed);
|
||||
} else {
|
||||
// This line doesn't match our expected indentation patterns
|
||||
// Save current parameter and stop processing
|
||||
if let Some(param_name) = current_param.take() {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
current_doc.clear();
|
||||
}
|
||||
in_params_section = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last parameter
|
||||
if let Some(param_name) = current_param {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
}
|
||||
|
||||
param_docs
|
||||
}
|
||||
|
||||
/// Extract parameter documentation from reST/Sphinx-style docstrings.
|
||||
fn extract_rest_style_params(docstring: &str) -> HashMap<String, String> {
|
||||
let mut param_docs = HashMap::new();
|
||||
|
||||
let mut current_param: Option<String> = None;
|
||||
let mut current_doc = String::new();
|
||||
|
||||
for line_obj in docstring.universal_newlines() {
|
||||
let line = line_obj.as_str();
|
||||
if let Some(captures) = REST_PARAM_REGEX.captures(line) {
|
||||
// Save previous parameter if exists
|
||||
if let Some(param_name) = current_param.take() {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
current_doc.clear();
|
||||
}
|
||||
|
||||
// Extract parameter name and description
|
||||
if let (Some(param_match), Some(desc_match)) = (captures.get(2), captures.get(3)) {
|
||||
current_param = Some(param_match.as_str().to_string());
|
||||
current_doc = desc_match.as_str().to_string();
|
||||
}
|
||||
} else if current_param.is_some() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Check if this is a new section - stop processing if we hit section headers
|
||||
if trimmed == "Parameters" || trimmed == "Args" || trimmed == "Arguments" {
|
||||
// Save current param and stop processing
|
||||
if let Some(param_name) = current_param.take() {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
current_doc.clear();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if this is another directive line starting with ':'
|
||||
if trimmed.starts_with(':') {
|
||||
// This is a new directive, save current param
|
||||
if let Some(param_name) = current_param.take() {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
current_doc.clear();
|
||||
}
|
||||
// Let the next iteration handle this directive
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a continuation line (indented)
|
||||
if line.starts_with(" ") && !trimmed.is_empty() {
|
||||
// This is a continuation line
|
||||
if !current_doc.is_empty() {
|
||||
current_doc.push('\n');
|
||||
}
|
||||
current_doc.push_str(trimmed);
|
||||
} else if !trimmed.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') {
|
||||
// This is a non-indented line - likely end of the current parameter
|
||||
if let Some(param_name) = current_param.take() {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
current_doc.clear();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last parameter
|
||||
if let Some(param_name) = current_param {
|
||||
param_docs.insert(param_name, current_doc.trim().to_string());
|
||||
}
|
||||
|
||||
param_docs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_google_style_parameter_documentation() {
|
||||
let docstring = r#"
|
||||
This is a function description.
|
||||
|
||||
Args:
|
||||
param1 (str): The first parameter description
|
||||
param2 (int): The second parameter description
|
||||
This is a continuation of param2 description.
|
||||
param3: A parameter without type annotation
|
||||
|
||||
Returns:
|
||||
str: The return value description
|
||||
"#;
|
||||
|
||||
let param_docs = get_parameter_documentation(docstring);
|
||||
|
||||
assert_eq!(param_docs.len(), 3);
|
||||
assert_eq!(¶m_docs["param1"], "The first parameter description");
|
||||
assert_eq!(
|
||||
¶m_docs["param2"],
|
||||
"The second parameter description\nThis is a continuation of param2 description."
|
||||
);
|
||||
assert_eq!(¶m_docs["param3"], "A parameter without type annotation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_numpy_style_parameter_documentation() {
|
||||
let docstring = r#"
|
||||
This is a function description.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
param1 : str
|
||||
The first parameter description
|
||||
param2 : int
|
||||
The second parameter description
|
||||
This is a continuation of param2 description.
|
||||
param3
|
||||
A parameter without type annotation
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The return value description
|
||||
"#;
|
||||
|
||||
let param_docs = get_parameter_documentation(docstring);
|
||||
|
||||
assert_eq!(param_docs.len(), 3);
|
||||
assert_eq!(
|
||||
param_docs.get("param1").expect("param1 should exist"),
|
||||
"The first parameter description"
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs.get("param2").expect("param2 should exist"),
|
||||
"The second parameter description\nThis is a continuation of param2 description."
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs.get("param3").expect("param3 should exist"),
|
||||
"A parameter without type annotation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_parameter_documentation() {
|
||||
let docstring = r#"
|
||||
This is a simple function description without parameter documentation.
|
||||
"#;
|
||||
|
||||
let param_docs = get_parameter_documentation(docstring);
|
||||
assert!(param_docs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_style_parameter_documentation() {
|
||||
let docstring = r#"
|
||||
This is a function description.
|
||||
|
||||
Args:
|
||||
param1 (str): Google-style parameter
|
||||
param2 (int): Another Google-style parameter
|
||||
|
||||
Parameters
|
||||
----------
|
||||
param3 : bool
|
||||
NumPy-style parameter
|
||||
"#;
|
||||
|
||||
let param_docs = get_parameter_documentation(docstring);
|
||||
|
||||
assert_eq!(param_docs.len(), 3);
|
||||
assert_eq!(
|
||||
param_docs.get("param1").expect("param1 should exist"),
|
||||
"Google-style parameter"
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs.get("param2").expect("param2 should exist"),
|
||||
"Another Google-style parameter"
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs.get("param3").expect("param3 should exist"),
|
||||
"NumPy-style parameter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rest_style_parameter_documentation() {
|
||||
let docstring = r#"
|
||||
This is a function description.
|
||||
|
||||
:param str param1: The first parameter description
|
||||
:param int param2: The second parameter description
|
||||
This is a continuation of param2 description.
|
||||
:param param3: A parameter without type annotation
|
||||
:returns: The return value description
|
||||
:rtype: str
|
||||
"#;
|
||||
|
||||
let param_docs = get_parameter_documentation(docstring);
|
||||
|
||||
assert_eq!(param_docs.len(), 3);
|
||||
assert_eq!(
|
||||
param_docs.get("param1").expect("param1 should exist"),
|
||||
"The first parameter description"
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs.get("param2").expect("param2 should exist"),
|
||||
"The second parameter description\nThis is a continuation of param2 description."
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs.get("param3").expect("param3 should exist"),
|
||||
"A parameter without type annotation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_style_with_rest_parameter_documentation() {
|
||||
let docstring = r#"
|
||||
This is a function description.
|
||||
|
||||
Args:
|
||||
param1 (str): Google-style parameter
|
||||
|
||||
:param int param2: reST-style parameter
|
||||
:param param3: Another reST-style parameter
|
||||
|
||||
Parameters
|
||||
----------
|
||||
param4 : bool
|
||||
NumPy-style parameter
|
||||
"#;
|
||||
|
||||
let param_docs = get_parameter_documentation(docstring);
|
||||
|
||||
assert_eq!(param_docs.len(), 4);
|
||||
assert_eq!(
|
||||
param_docs.get("param1").expect("param1 should exist"),
|
||||
"Google-style parameter"
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs.get("param2").expect("param2 should exist"),
|
||||
"reST-style parameter"
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs.get("param3").expect("param3 should exist"),
|
||||
"Another reST-style parameter"
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs.get("param4").expect("param4 should exist"),
|
||||
"NumPy-style parameter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_numpy_style_with_different_indentation() {
|
||||
let docstring = r#"
|
||||
This is a function description.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
param1 : str
|
||||
The first parameter description
|
||||
param2 : int
|
||||
The second parameter description
|
||||
This is a continuation of param2 description.
|
||||
param3
|
||||
A parameter without type annotation
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The return value description
|
||||
"#;
|
||||
|
||||
let param_docs = get_parameter_documentation(docstring);
|
||||
|
||||
assert_eq!(param_docs.len(), 3);
|
||||
assert_eq!(
|
||||
param_docs.get("param1").expect("param1 should exist"),
|
||||
"The first parameter description"
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs.get("param2").expect("param2 should exist"),
|
||||
"The second parameter description\nThis is a continuation of param2 description."
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs.get("param3").expect("param3 should exist"),
|
||||
"A parameter without type annotation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_numpy_style_with_tabs_and_mixed_indentation() {
|
||||
// Using raw strings to avoid tab/space conversion issues in the test
|
||||
let docstring = "
|
||||
This is a function description.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
\tparam1 : str
|
||||
\t\tThe first parameter description
|
||||
\tparam2 : int
|
||||
\t\tThe second parameter description
|
||||
\t\tThis is a continuation of param2 description.
|
||||
\tparam3
|
||||
\t\tA parameter without type annotation
|
||||
";
|
||||
|
||||
let param_docs = get_parameter_documentation(docstring);
|
||||
|
||||
assert_eq!(param_docs.len(), 3);
|
||||
assert_eq!(
|
||||
param_docs.get("param1").expect("param1 should exist"),
|
||||
"The first parameter description"
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs.get("param2").expect("param2 should exist"),
|
||||
"The second parameter description\nThis is a continuation of param2 description."
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs.get("param3").expect("param3 should exist"),
|
||||
"A parameter without type annotation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_universal_newlines() {
|
||||
// Test with Windows-style line endings (\r\n)
|
||||
let docstring_windows = "This is a function description.\r\n\r\nArgs:\r\n param1 (str): The first parameter\r\n param2 (int): The second parameter\r\n";
|
||||
|
||||
// Test with old Mac-style line endings (\r)
|
||||
let docstring_mac = "This is a function description.\r\rArgs:\r param1 (str): The first parameter\r param2 (int): The second parameter\r";
|
||||
|
||||
// Test with Unix-style line endings (\n) - should work the same
|
||||
let docstring_unix = "This is a function description.\n\nArgs:\n param1 (str): The first parameter\n param2 (int): The second parameter\n";
|
||||
|
||||
let param_docs_windows = get_parameter_documentation(docstring_windows);
|
||||
let param_docs_mac = get_parameter_documentation(docstring_mac);
|
||||
let param_docs_unix = get_parameter_documentation(docstring_unix);
|
||||
|
||||
// All should produce the same results
|
||||
assert_eq!(param_docs_windows.len(), 2);
|
||||
assert_eq!(param_docs_mac.len(), 2);
|
||||
assert_eq!(param_docs_unix.len(), 2);
|
||||
|
||||
assert_eq!(
|
||||
param_docs_windows.get("param1"),
|
||||
Some(&"The first parameter".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs_mac.get("param1"),
|
||||
Some(&"The first parameter".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
param_docs_unix.get("param1"),
|
||||
Some(&"The first parameter".to_string())
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,14 +1,17 @@
|
|||
mod completion;
|
||||
mod db;
|
||||
mod docstring;
|
||||
mod find_node;
|
||||
mod goto;
|
||||
mod hover;
|
||||
mod inlay_hints;
|
||||
mod markup;
|
||||
mod semantic_tokens;
|
||||
mod signature_help;
|
||||
|
||||
pub use completion::completion;
|
||||
pub use db::Db;
|
||||
pub use docstring::get_parameter_documentation;
|
||||
pub use goto::goto_type_definition;
|
||||
pub use hover::hover;
|
||||
pub use inlay_hints::inlay_hints;
|
||||
|
@ -16,6 +19,7 @@ pub use markup::MarkupKind;
|
|||
pub use semantic_tokens::{
|
||||
SemanticToken, SemanticTokenModifier, SemanticTokenType, SemanticTokens, semantic_tokens,
|
||||
};
|
||||
pub use signature_help::{ParameterDetails, SignatureDetails, SignatureHelpInfo, signature_help};
|
||||
|
||||
use ruff_db::files::{File, FileRange};
|
||||
use ruff_text_size::{Ranged, TextRange};
|
||||
|
|
687
crates/ty_ide/src/signature_help.rs
Normal file
687
crates/ty_ide/src/signature_help.rs
Normal file
|
@ -0,0 +1,687 @@
|
|||
//! This module handles the "signature help" request in the language server
|
||||
//! protocol. This request is typically issued by a client when the user types
|
||||
//! an open parenthesis and starts to enter arguments for a function call.
|
||||
//! The signature help provides information that the editor displays to the
|
||||
//! user about the target function signature including parameter names,
|
||||
//! types, and documentation. It supports multiple signatures for union types
|
||||
//! and overloads.
|
||||
|
||||
use crate::{Db, docstring::get_parameter_documentation, find_node::covering_node};
|
||||
use ruff_db::files::File;
|
||||
use ruff_db::parsed::parsed_module;
|
||||
use ruff_python_ast::{self as ast, AnyNodeRef};
|
||||
use ruff_text_size::{Ranged, TextRange, TextSize};
|
||||
use ty_python_semantic::semantic_index::definition::Definition;
|
||||
use ty_python_semantic::types::{CallSignatureDetails, call_signature_details};
|
||||
|
||||
// Limitations of the current implementation:
|
||||
|
||||
// TODO - If the target function is declared in a stub file but defined (implemented)
|
||||
// in a source file, the documentation will not reflect the a docstring that appears
|
||||
// only in the implementation. To do this, we'll need to map the function or
|
||||
// method in the stub to the implementation and extract the docstring from there.
|
||||
|
||||
/// Information about a function parameter
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParameterDetails {
|
||||
/// The parameter name (e.g., "param1")
|
||||
pub name: String,
|
||||
/// The parameter label in the signature (e.g., "param1: str")
|
||||
pub label: String,
|
||||
/// Documentation specific to the parameter, typically extracted from the
|
||||
/// function's docstring
|
||||
pub documentation: Option<String>,
|
||||
}
|
||||
|
||||
/// Information about a function signature
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignatureDetails {
|
||||
/// Text representation of the full signature (including input parameters and return type).
|
||||
pub label: String,
|
||||
/// Documentation for the signature, typically from the function's docstring.
|
||||
pub documentation: Option<String>,
|
||||
/// Information about each of the parameters in left-to-right order.
|
||||
pub parameters: Vec<ParameterDetails>,
|
||||
/// Index of the parameter that corresponds to the argument where the
|
||||
/// user's cursor is currently positioned.
|
||||
pub active_parameter: Option<usize>,
|
||||
}
|
||||
|
||||
/// Signature help information for function calls
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignatureHelpInfo {
|
||||
/// Information about each of the signatures for the function call. We
|
||||
/// need to handle multiple because of unions, overloads, and composite
|
||||
/// calls like constructors (which invoke both __new__ and __init__).
|
||||
pub signatures: Vec<SignatureDetails>,
|
||||
/// Index of the "active signature" which is the first signature where
|
||||
/// all arguments that are currently present in the code map to parameters.
|
||||
pub active_signature: Option<usize>,
|
||||
}
|
||||
|
||||
/// Signature help information for function calls at the given position
|
||||
pub fn signature_help(db: &dyn Db, file: File, offset: TextSize) -> Option<SignatureHelpInfo> {
|
||||
let parsed = parsed_module(db, file).load(db);
|
||||
|
||||
// Get the call expression at the given position.
|
||||
let (call_expr, current_arg_index) = get_call_expr(&parsed, offset)?;
|
||||
|
||||
// Get signature details from the semantic analyzer.
|
||||
let signature_details: Vec<CallSignatureDetails<'_>> =
|
||||
call_signature_details(db, file, call_expr);
|
||||
|
||||
if signature_details.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the active signature - the first signature where all arguments map to parameters.
|
||||
let active_signature_index = find_active_signature_from_details(&signature_details);
|
||||
|
||||
// Convert to SignatureDetails objects.
|
||||
let signatures: Vec<SignatureDetails> = signature_details
|
||||
.into_iter()
|
||||
.map(|details| {
|
||||
create_signature_details_from_call_signature_details(db, &details, current_arg_index)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(SignatureHelpInfo {
|
||||
signatures,
|
||||
active_signature: active_signature_index,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the innermost call expression that contains the specified offset
|
||||
/// and the index of the argument that the offset maps to.
|
||||
fn get_call_expr(
|
||||
parsed: &ruff_db::parsed::ParsedModuleRef,
|
||||
offset: TextSize,
|
||||
) -> Option<(&ast::ExprCall, usize)> {
|
||||
// Create a range from the offset for the covering_node function.
|
||||
let range = TextRange::new(offset, offset);
|
||||
|
||||
// Find the covering node at the given position that is a function call.
|
||||
let covering_node = covering_node(parsed.syntax().into(), range)
|
||||
.find_first(|node| matches!(node, AnyNodeRef::ExprCall(_)))
|
||||
.ok()?;
|
||||
|
||||
// Get the function call expression.
|
||||
let AnyNodeRef::ExprCall(call_expr) = covering_node.node() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Determine which argument corresponding to the current cursor location.
|
||||
let current_arg_index = get_argument_index(call_expr, offset);
|
||||
|
||||
Some((call_expr, current_arg_index))
|
||||
}
|
||||
|
||||
/// Determine which argument is associated with the specified offset.
|
||||
/// Returns zero if not within any argument.
|
||||
fn get_argument_index(call_expr: &ast::ExprCall, offset: TextSize) -> usize {
|
||||
let mut current_arg = 0;
|
||||
|
||||
for (i, arg) in call_expr.arguments.arguments_source_order().enumerate() {
|
||||
if offset <= arg.end() {
|
||||
return i;
|
||||
}
|
||||
current_arg = i + 1;
|
||||
}
|
||||
|
||||
current_arg
|
||||
}
|
||||
|
||||
/// Create signature details from `CallSignatureDetails`.
|
||||
fn create_signature_details_from_call_signature_details(
|
||||
db: &dyn crate::Db,
|
||||
details: &CallSignatureDetails,
|
||||
current_arg_index: usize,
|
||||
) -> SignatureDetails {
|
||||
let signature_label = details.label.clone();
|
||||
|
||||
let documentation = get_callable_documentation(db, details.definition);
|
||||
|
||||
// Translate the argument index to parameter index using the mapping.
|
||||
let active_parameter =
|
||||
if details.argument_to_parameter_mapping.is_empty() && current_arg_index == 0 {
|
||||
Some(0)
|
||||
} else {
|
||||
details
|
||||
.argument_to_parameter_mapping
|
||||
.get(current_arg_index)
|
||||
.and_then(|¶m_index| param_index)
|
||||
.or({
|
||||
// If we can't find a mapping for this argument, but we have a current
|
||||
// argument index, use that as the active parameter if it's within bounds.
|
||||
if current_arg_index < details.parameter_label_offsets.len() {
|
||||
Some(current_arg_index)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
SignatureDetails {
|
||||
label: signature_label.clone(),
|
||||
documentation: Some(documentation),
|
||||
parameters: create_parameters_from_offsets(
|
||||
&details.parameter_label_offsets,
|
||||
&signature_label,
|
||||
db,
|
||||
details.definition,
|
||||
&details.parameter_names,
|
||||
),
|
||||
active_parameter,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine appropriate documentation for a callable type based on its original type.
|
||||
fn get_callable_documentation(db: &dyn crate::Db, definition: Option<Definition>) -> String {
|
||||
// TODO: If the definition is located within a stub file and no docstring
|
||||
// is present, try to map the symbol to an implementation file and extract
|
||||
// the docstring from that location.
|
||||
if let Some(definition) = definition {
|
||||
definition.docstring(db).unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create `ParameterDetails` objects from parameter label offsets.
|
||||
fn create_parameters_from_offsets(
|
||||
parameter_offsets: &[TextRange],
|
||||
signature_label: &str,
|
||||
db: &dyn crate::Db,
|
||||
definition: Option<Definition>,
|
||||
parameter_names: &[String],
|
||||
) -> Vec<ParameterDetails> {
|
||||
// Extract parameter documentation from the function's docstring if available.
|
||||
let param_docs = if let Some(definition) = definition {
|
||||
let docstring = definition.docstring(db);
|
||||
docstring
|
||||
.map(|doc| get_parameter_documentation(&doc))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
parameter_offsets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, offset)| {
|
||||
// Extract the parameter label from the signature string.
|
||||
let start = usize::from(offset.start());
|
||||
let end = usize::from(offset.end());
|
||||
let label = signature_label
|
||||
.get(start..end)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
// Get the parameter name for documentation lookup.
|
||||
let param_name = parameter_names.get(i).map(String::as_str).unwrap_or("");
|
||||
|
||||
ParameterDetails {
|
||||
name: param_name.to_string(),
|
||||
label,
|
||||
documentation: param_docs.get(param_name).cloned(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find the active signature index from `CallSignatureDetails`.
|
||||
/// The active signature is the first signature where all arguments present in the call
|
||||
/// have valid mappings to parameters (i.e., none of the mappings are None).
|
||||
fn find_active_signature_from_details(signature_details: &[CallSignatureDetails]) -> Option<usize> {
|
||||
let first = signature_details.first()?;
|
||||
|
||||
// If there are no arguments in the mapping, just return the first signature.
|
||||
if first.argument_to_parameter_mapping.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
|
||||
// First, try to find a signature where all arguments have valid parameter mappings.
|
||||
let perfect_match = signature_details.iter().position(|details| {
|
||||
// Check if all arguments have valid parameter mappings (i.e., are not None).
|
||||
details
|
||||
.argument_to_parameter_mapping
|
||||
.iter()
|
||||
.all(Option::is_some)
|
||||
});
|
||||
|
||||
if let Some(index) = perfect_match {
|
||||
return Some(index);
|
||||
}
|
||||
|
||||
// If no perfect match, find the signature with the most valid argument mappings.
|
||||
let (best_index, _) = signature_details
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, details)| {
|
||||
details
|
||||
.argument_to_parameter_mapping
|
||||
.iter()
|
||||
.filter(|mapping| mapping.is_some())
|
||||
.count()
|
||||
})?;
|
||||
|
||||
Some(best_index)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::signature_help::SignatureHelpInfo;
|
||||
use crate::tests::{CursorTest, cursor_test};
|
||||
|
||||
#[test]
|
||||
fn signature_help_basic_function_call() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def example_function(param1: str, param2: int) -> str:
|
||||
"""This is a docstring for the example function.
|
||||
|
||||
Args:
|
||||
param1: The first parameter as a string
|
||||
param2: The second parameter as an integer
|
||||
|
||||
Returns:
|
||||
A formatted string combining both parameters
|
||||
"""
|
||||
return f"{param1}: {param2}"
|
||||
|
||||
result = example_function(<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
// Test that signature help is provided
|
||||
let result = test.signature_help().expect("Should have signature help");
|
||||
assert_eq!(result.signatures.len(), 1);
|
||||
|
||||
let signature = &result.signatures[0];
|
||||
assert!(signature.label.contains("param1") && signature.label.contains("param2"));
|
||||
|
||||
// Verify that the docstring is extracted and included in the documentation
|
||||
let expected_docstring = concat!(
|
||||
"This is a docstring for the example function.\n",
|
||||
" \n",
|
||||
" Args:\n",
|
||||
" param1: The first parameter as a string\n",
|
||||
" param2: The second parameter as an integer\n",
|
||||
" \n",
|
||||
" Returns:\n",
|
||||
" A formatted string combining both parameters\n",
|
||||
" "
|
||||
);
|
||||
assert_eq!(
|
||||
signature.documentation,
|
||||
Some(expected_docstring.to_string())
|
||||
);
|
||||
|
||||
assert_eq!(result.active_signature, Some(0));
|
||||
assert_eq!(signature.active_parameter, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_help_method_call() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
class MyClass:
|
||||
def my_method(self, arg1: str, arg2: bool) -> None:
|
||||
pass
|
||||
|
||||
obj = MyClass()
|
||||
obj.my_method(arg2=True, arg1=<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
// Test that signature help is provided for method calls
|
||||
let result = test.signature_help().expect("Should have signature help");
|
||||
assert_eq!(result.signatures.len(), 1);
|
||||
|
||||
let signature = &result.signatures[0];
|
||||
assert!(signature.label.contains("arg1") && signature.label.contains("arg2"));
|
||||
assert_eq!(result.active_signature, Some(0));
|
||||
|
||||
// Check the active parameter from the active signature
|
||||
if let Some(active_sig_index) = result.active_signature {
|
||||
let active_signature = &result.signatures[active_sig_index];
|
||||
assert_eq!(active_signature.active_parameter, Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_help_nested_function_calls() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def outer(a: int) -> int:
|
||||
return a * 2
|
||||
|
||||
def inner(b: str) -> str:
|
||||
return b.upper()
|
||||
|
||||
result = outer(inner(<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
// Test that signature help focuses on the innermost function call
|
||||
let result = test.signature_help().expect("Should have signature help");
|
||||
assert_eq!(result.signatures.len(), 1);
|
||||
|
||||
let signature = &result.signatures[0];
|
||||
assert!(signature.label.contains("str") || signature.label.contains("->"));
|
||||
assert_eq!(result.active_signature, Some(0));
|
||||
assert_eq!(signature.active_parameter, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_help_union_callable() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
import random
|
||||
def func_a(x: int) -> int:
|
||||
return x
|
||||
|
||||
def func_b(y: str) -> str:
|
||||
return y
|
||||
|
||||
if random.random() > 0.5:
|
||||
f = func_a
|
||||
else:
|
||||
f = func_b
|
||||
|
||||
f(<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
let result = test.signature_help().expect("Should have signature help");
|
||||
|
||||
assert_eq!(result.signatures.len(), 2);
|
||||
|
||||
let signature = &result.signatures[0];
|
||||
assert_eq!(signature.label, "(x: int) -> int");
|
||||
assert_eq!(signature.parameters.len(), 1);
|
||||
|
||||
// Check parameter information
|
||||
let param = &signature.parameters[0];
|
||||
assert_eq!(param.label, "x: int");
|
||||
assert_eq!(param.name, "x");
|
||||
|
||||
// Validate the second signature (from func_b)
|
||||
let signature_b = &result.signatures[1];
|
||||
assert_eq!(signature_b.label, "(y: str) -> str");
|
||||
assert_eq!(signature_b.parameters.len(), 1);
|
||||
|
||||
// Check parameter information for the second signature
|
||||
let param_b = &signature_b.parameters[0];
|
||||
assert_eq!(param_b.label, "y: str");
|
||||
assert_eq!(param_b.name, "y");
|
||||
|
||||
assert_eq!(result.active_signature, Some(0));
|
||||
|
||||
// Check the active parameter from the active signature
|
||||
if let Some(active_sig_index) = result.active_signature {
|
||||
let active_signature = &result.signatures[active_sig_index];
|
||||
assert_eq!(active_signature.active_parameter, Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_help_overloaded_function() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
from typing import overload
|
||||
|
||||
@overload
|
||||
def process(value: int) -> str: ...
|
||||
|
||||
@overload
|
||||
def process(value: str) -> int: ...
|
||||
|
||||
def process(value):
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
else:
|
||||
return len(value)
|
||||
|
||||
result = process(<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
// Test that signature help is provided for overloaded functions
|
||||
let result = test.signature_help().expect("Should have signature help");
|
||||
|
||||
// We should have signatures for the overloads
|
||||
assert_eq!(result.signatures.len(), 2);
|
||||
assert_eq!(result.active_signature, Some(0));
|
||||
|
||||
// Check the active parameter from the active signature
|
||||
if let Some(active_sig_index) = result.active_signature {
|
||||
let active_signature = &result.signatures[active_sig_index];
|
||||
assert_eq!(active_signature.active_parameter, Some(0));
|
||||
}
|
||||
|
||||
// Validate the first overload: process(value: int) -> str
|
||||
let signature1 = &result.signatures[0];
|
||||
assert_eq!(signature1.label, "(value: int) -> str");
|
||||
assert_eq!(signature1.parameters.len(), 1);
|
||||
|
||||
let param1 = &signature1.parameters[0];
|
||||
assert_eq!(param1.label, "value: int");
|
||||
assert_eq!(param1.name, "value");
|
||||
|
||||
// Validate the second overload: process(value: str) -> int
|
||||
let signature2 = &result.signatures[1];
|
||||
assert_eq!(signature2.label, "(value: str) -> int");
|
||||
assert_eq!(signature2.parameters.len(), 1);
|
||||
|
||||
let param2 = &signature2.parameters[0];
|
||||
assert_eq!(param2.label, "value: str");
|
||||
assert_eq!(param2.name, "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_help_class_constructor() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
class Point:
|
||||
"""A simple point class representing a 2D coordinate."""
|
||||
|
||||
def __init__(self, x: int, y: int):
|
||||
"""Initialize a point with x and y coordinates.
|
||||
|
||||
Args:
|
||||
x: The x-coordinate
|
||||
y: The y-coordinate
|
||||
"""
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
point = Point(<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
let result = test.signature_help().expect("Should have signature help");
|
||||
|
||||
// Should have exactly one signature for the constructor
|
||||
assert_eq!(result.signatures.len(), 1);
|
||||
let signature = &result.signatures[0];
|
||||
|
||||
// Validate the constructor signature
|
||||
assert_eq!(signature.label, "(x: int, y: int) -> Point");
|
||||
assert_eq!(signature.parameters.len(), 2);
|
||||
|
||||
// Validate the first parameter (x: int)
|
||||
let param_x = &signature.parameters[0];
|
||||
assert_eq!(param_x.label, "x: int");
|
||||
assert_eq!(param_x.name, "x");
|
||||
assert_eq!(param_x.documentation, Some("The x-coordinate".to_string()));
|
||||
|
||||
// Validate the second parameter (y: int)
|
||||
let param_y = &signature.parameters[1];
|
||||
assert_eq!(param_y.label, "y: int");
|
||||
assert_eq!(param_y.name, "y");
|
||||
assert_eq!(param_y.documentation, Some("The y-coordinate".to_string()));
|
||||
|
||||
// Should have the __init__ method docstring as documentation (not the class docstring)
|
||||
let expected_docstring = "Initialize a point with x and y coordinates.\n \n Args:\n x: The x-coordinate\n y: The y-coordinate\n ";
|
||||
assert_eq!(
|
||||
signature.documentation,
|
||||
Some(expected_docstring.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_help_callable_object() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
class Multiplier:
|
||||
def __call__(self, x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
multiplier = Multiplier()
|
||||
result = multiplier(<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
let result = test.signature_help().expect("Should have signature help");
|
||||
|
||||
// Should have a signature for the callable object
|
||||
assert!(!result.signatures.is_empty());
|
||||
let signature = &result.signatures[0];
|
||||
|
||||
// Should provide signature help for the callable
|
||||
assert!(signature.label.contains("int") || signature.label.contains("->"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_help_subclass_of_constructor() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
from typing import Type
|
||||
|
||||
def create_instance(cls: Type[list]) -> list:
|
||||
return cls(<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
let result = test.signature_help().expect("Should have signature help");
|
||||
|
||||
// Should have a signature
|
||||
assert!(!result.signatures.is_empty());
|
||||
let signature = &result.signatures[0];
|
||||
|
||||
// Should have empty documentation for now
|
||||
assert_eq!(signature.documentation, Some(String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_help_parameter_label_offsets() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def test_function(param1: str, param2: int, param3: bool) -> str:
|
||||
return f"{param1}: {param2}, {param3}"
|
||||
|
||||
result = test_function(<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
let result = test.signature_help().expect("Should have signature help");
|
||||
assert_eq!(result.signatures.len(), 1);
|
||||
|
||||
let signature = &result.signatures[0];
|
||||
assert_eq!(signature.parameters.len(), 3);
|
||||
|
||||
// Check that we have parameter labels
|
||||
for (i, param) in signature.parameters.iter().enumerate() {
|
||||
let expected_param_spec = match i {
|
||||
0 => "param1: str",
|
||||
1 => "param2: int",
|
||||
2 => "param3: bool",
|
||||
_ => panic!("Unexpected parameter index"),
|
||||
};
|
||||
assert_eq!(param.label, expected_param_spec);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_help_active_signature_selection() {
|
||||
// This test verifies that the algorithm correctly selects the first signature
|
||||
// where all arguments present in the call have valid parameter mappings.
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
from typing import overload
|
||||
|
||||
@overload
|
||||
def process(value: int) -> str: ...
|
||||
|
||||
@overload
|
||||
def process(value: str, flag: bool) -> int: ...
|
||||
|
||||
def process(value, flag=None):
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
elif flag is not None:
|
||||
return len(value) if flag else 0
|
||||
else:
|
||||
return len(value)
|
||||
|
||||
# Call with two arguments - should select the second overload
|
||||
result = process("hello", True<CURSOR>)
|
||||
"#,
|
||||
);
|
||||
|
||||
let result = test.signature_help().expect("Should have signature help");
|
||||
|
||||
// Should have signatures for the overloads.
|
||||
assert!(!result.signatures.is_empty());
|
||||
|
||||
// Check that we have an active signature and parameter
|
||||
if let Some(active_sig_index) = result.active_signature {
|
||||
let active_signature = &result.signatures[active_sig_index];
|
||||
assert_eq!(active_signature.active_parameter, Some(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_help_parameter_documentation() {
|
||||
let test = cursor_test(
|
||||
r#"
|
||||
def documented_function(param1: str, param2: int) -> str:
|
||||
"""This is a function with parameter documentation.
|
||||
|
||||
Args:
|
||||
param1: The first parameter description
|
||||
param2: The second parameter description
|
||||
"""
|
||||
return f"{param1}: {param2}"
|
||||
|
||||
result = documented_function(<CURSOR>
|
||||
"#,
|
||||
);
|
||||
|
||||
let result = test.signature_help().expect("Should have signature help");
|
||||
assert_eq!(result.signatures.len(), 1);
|
||||
|
||||
let signature = &result.signatures[0];
|
||||
assert_eq!(signature.parameters.len(), 2);
|
||||
|
||||
// Check that parameter documentation is extracted
|
||||
let param1 = &signature.parameters[0];
|
||||
assert_eq!(
|
||||
param1.documentation,
|
||||
Some("The first parameter description".to_string())
|
||||
);
|
||||
|
||||
let param2 = &signature.parameters[1];
|
||||
assert_eq!(
|
||||
param2.documentation,
|
||||
Some("The second parameter description".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
impl CursorTest {
|
||||
fn signature_help(&self) -> Option<SignatureHelpInfo> {
|
||||
crate::signature_help::signature_help(&self.db, self.cursor.file, self.cursor.offset)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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};
|
||||
|
||||
|
|
|
@ -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.)
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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()
|
||||
};
|
||||
|
||||
|
|
|
@ -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(¶meter.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(¶meter.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(¶m_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 }
|
||||
|
|
|
@ -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![]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -8,8 +8,8 @@ use lsp_types::{
|
|||
ClientCapabilities, DiagnosticOptions, DiagnosticServerCapabilities, HoverProviderCapability,
|
||||
InlayHintOptions, InlayHintServerCapabilities, MessageType, SemanticTokensLegend,
|
||||
SemanticTokensOptions, SemanticTokensServerCapabilities, ServerCapabilities,
|
||||
TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
|
||||
TypeDefinitionProviderCapability, Url, WorkDoneProgressOptions,
|
||||
SignatureHelpOptions, TextDocumentSyncCapability, TextDocumentSyncKind,
|
||||
TextDocumentSyncOptions, TypeDefinitionProviderCapability, Url, WorkDoneProgressOptions,
|
||||
};
|
||||
use std::num::NonZeroUsize;
|
||||
use std::panic::PanicHookInfo;
|
||||
|
@ -186,6 +186,11 @@ impl Server {
|
|||
)),
|
||||
type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
|
||||
hover_provider: Some(HoverProviderCapability::Simple(true)),
|
||||
signature_help_provider: Some(SignatureHelpOptions {
|
||||
trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
|
||||
retrigger_characters: Some(vec![")".to_string()]),
|
||||
work_done_progress_options: lsp_types::WorkDoneProgressOptions::default(),
|
||||
}),
|
||||
inlay_hint_provider: Some(lsp_types::OneOf::Right(
|
||||
InlayHintServerCapabilities::Options(InlayHintOptions::default()),
|
||||
)),
|
||||
|
|
|
@ -58,6 +58,9 @@ pub(super) fn request(req: server::Request) -> Task {
|
|||
>(
|
||||
req, BackgroundSchedule::Worker
|
||||
),
|
||||
requests::SignatureHelpRequestHandler::METHOD => background_document_request_task::<
|
||||
requests::SignatureHelpRequestHandler,
|
||||
>(req, BackgroundSchedule::Worker),
|
||||
requests::CompletionRequestHandler::METHOD => background_document_request_task::<
|
||||
requests::CompletionRequestHandler,
|
||||
>(
|
||||
|
|
|
@ -6,6 +6,7 @@ mod inlay_hints;
|
|||
mod semantic_tokens;
|
||||
mod semantic_tokens_range;
|
||||
mod shutdown;
|
||||
mod signature_help;
|
||||
mod workspace_diagnostic;
|
||||
|
||||
pub(super) use completion::CompletionRequestHandler;
|
||||
|
@ -16,4 +17,5 @@ pub(super) use inlay_hints::InlayHintRequestHandler;
|
|||
pub(super) use semantic_tokens::SemanticTokensRequestHandler;
|
||||
pub(super) use semantic_tokens_range::SemanticTokensRangeRequestHandler;
|
||||
pub(super) use shutdown::ShutdownHandler;
|
||||
pub(super) use signature_help::SignatureHelpRequestHandler;
|
||||
pub(super) use workspace_diagnostic::WorkspaceDiagnosticRequestHandler;
|
||||
|
|
145
crates/ty_server/src/server/api/requests/signature_help.rs
Normal file
145
crates/ty_server/src/server/api/requests/signature_help.rs
Normal file
|
@ -0,0 +1,145 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use crate::DocumentSnapshot;
|
||||
use crate::document::{PositionEncoding, PositionExt};
|
||||
use crate::server::api::traits::{
|
||||
BackgroundDocumentRequestHandler, RequestHandler, RetriableRequestHandler,
|
||||
};
|
||||
use crate::session::client::Client;
|
||||
use lsp_types::request::SignatureHelpRequest;
|
||||
use lsp_types::{
|
||||
Documentation, ParameterInformation, ParameterLabel, SignatureHelp, SignatureHelpParams,
|
||||
SignatureInformation, Url,
|
||||
};
|
||||
use ruff_db::source::{line_index, source_text};
|
||||
use ty_ide::signature_help;
|
||||
use ty_project::ProjectDatabase;
|
||||
|
||||
pub(crate) struct SignatureHelpRequestHandler;
|
||||
|
||||
impl RequestHandler for SignatureHelpRequestHandler {
|
||||
type RequestType = SignatureHelpRequest;
|
||||
}
|
||||
|
||||
impl BackgroundDocumentRequestHandler for SignatureHelpRequestHandler {
|
||||
fn document_url(params: &SignatureHelpParams) -> Cow<Url> {
|
||||
Cow::Borrowed(¶ms.text_document_position_params.text_document.uri)
|
||||
}
|
||||
|
||||
fn run_with_snapshot(
|
||||
db: &ProjectDatabase,
|
||||
snapshot: DocumentSnapshot,
|
||||
_client: &Client,
|
||||
params: SignatureHelpParams,
|
||||
) -> crate::server::Result<Option<SignatureHelp>> {
|
||||
if snapshot.client_settings().is_language_services_disabled() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(file) = snapshot.file(db) else {
|
||||
tracing::debug!("Failed to resolve file for {:?}", params);
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let source = source_text(db, file);
|
||||
let line_index = line_index(db, file);
|
||||
let offset = params.text_document_position_params.position.to_text_size(
|
||||
&source,
|
||||
&line_index,
|
||||
snapshot.encoding(),
|
||||
);
|
||||
|
||||
// Extract signature help capabilities from the client
|
||||
let resolved_capabilities = snapshot.resolved_client_capabilities();
|
||||
|
||||
let Some(signature_help_info) = signature_help(db, file, offset) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Compute active parameter from the active signature
|
||||
let active_parameter = signature_help_info
|
||||
.active_signature
|
||||
.and_then(|s| signature_help_info.signatures.get(s))
|
||||
.and_then(|sig| sig.active_parameter)
|
||||
.and_then(|p| u32::try_from(p).ok());
|
||||
|
||||
// Convert from IDE types to LSP types
|
||||
let signatures = signature_help_info
|
||||
.signatures
|
||||
.into_iter()
|
||||
.map(|sig| {
|
||||
let parameters = sig
|
||||
.parameters
|
||||
.into_iter()
|
||||
.map(|param| {
|
||||
let label = if resolved_capabilities.signature_label_offset_support {
|
||||
// Find the parameter's offset in the signature label
|
||||
if let Some(start) = sig.label.find(¶m.label) {
|
||||
let encoding = snapshot.encoding();
|
||||
|
||||
// Convert byte offsets to character offsets based on negotiated encoding
|
||||
let start_char_offset = match encoding {
|
||||
PositionEncoding::UTF8 => start,
|
||||
PositionEncoding::UTF16 => {
|
||||
sig.label[..start].encode_utf16().count()
|
||||
}
|
||||
PositionEncoding::UTF32 => sig.label[..start].chars().count(),
|
||||
};
|
||||
|
||||
let end_char_offset = match encoding {
|
||||
PositionEncoding::UTF8 => start + param.label.len(),
|
||||
PositionEncoding::UTF16 => sig.label
|
||||
[..start + param.label.len()]
|
||||
.encode_utf16()
|
||||
.count(),
|
||||
PositionEncoding::UTF32 => {
|
||||
sig.label[..start + param.label.len()].chars().count()
|
||||
}
|
||||
};
|
||||
|
||||
let start_u32 =
|
||||
u32::try_from(start_char_offset).unwrap_or(u32::MAX);
|
||||
let end_u32 = u32::try_from(end_char_offset).unwrap_or(u32::MAX);
|
||||
ParameterLabel::LabelOffsets([start_u32, end_u32])
|
||||
} else {
|
||||
ParameterLabel::Simple(param.label)
|
||||
}
|
||||
} else {
|
||||
ParameterLabel::Simple(param.label)
|
||||
};
|
||||
|
||||
ParameterInformation {
|
||||
label,
|
||||
documentation: param.documentation.map(Documentation::String),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let active_parameter = if resolved_capabilities.signature_active_parameter_support {
|
||||
sig.active_parameter.and_then(|p| u32::try_from(p).ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
SignatureInformation {
|
||||
label: sig.label,
|
||||
documentation: sig.documentation.map(Documentation::String),
|
||||
parameters: Some(parameters),
|
||||
active_parameter,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let signature_help = SignatureHelp {
|
||||
signatures,
|
||||
active_signature: signature_help_info
|
||||
.active_signature
|
||||
.and_then(|s| u32::try_from(s).ok()),
|
||||
active_parameter,
|
||||
};
|
||||
|
||||
Ok(Some(signature_help))
|
||||
}
|
||||
}
|
||||
|
||||
impl RetriableRequestHandler for SignatureHelpRequestHandler {}
|
|
@ -22,6 +22,12 @@ pub(crate) struct ResolvedClientCapabilities {
|
|||
|
||||
/// Whether the client supports multiline semantic tokens
|
||||
pub(crate) semantic_tokens_multiline_support: bool,
|
||||
|
||||
/// Whether the client supports signature label offsets in signature help
|
||||
pub(crate) signature_label_offset_support: bool,
|
||||
|
||||
/// Whether the client supports per-signature active parameter in signature help
|
||||
pub(crate) signature_active_parameter_support: bool,
|
||||
}
|
||||
|
||||
impl ResolvedClientCapabilities {
|
||||
|
@ -95,6 +101,34 @@ impl ResolvedClientCapabilities {
|
|||
.and_then(|semantic_tokens| semantic_tokens.multiline_token_support)
|
||||
.unwrap_or(false);
|
||||
|
||||
let signature_label_offset_support = client_capabilities
|
||||
.text_document
|
||||
.as_ref()
|
||||
.and_then(|text_document| {
|
||||
text_document
|
||||
.signature_help
|
||||
.as_ref()?
|
||||
.signature_information
|
||||
.as_ref()?
|
||||
.parameter_information
|
||||
.as_ref()?
|
||||
.label_offset_support
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let signature_active_parameter_support = client_capabilities
|
||||
.text_document
|
||||
.as_ref()
|
||||
.and_then(|text_document| {
|
||||
text_document
|
||||
.signature_help
|
||||
.as_ref()?
|
||||
.signature_information
|
||||
.as_ref()?
|
||||
.active_parameter_support
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Self {
|
||||
code_action_deferred_edit_resolution: code_action_data_support
|
||||
&& code_action_edit_resolution,
|
||||
|
@ -106,6 +140,8 @@ impl ResolvedClientCapabilities {
|
|||
type_definition_link_support: declaration_link_support,
|
||||
hover_prefer_markdown,
|
||||
semantic_tokens_multiline_support,
|
||||
signature_label_offset_support,
|
||||
signature_active_parameter_support,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue