diff --git a/Cargo.lock b/Cargo.lock index 77ffccf5b0..ff1226ba27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4152,9 +4152,12 @@ version = "0.0.0" dependencies = [ "bitflags 2.9.1", "insta", + "regex", "ruff_db", "ruff_python_ast", "ruff_python_parser", + "ruff_python_trivia", + "ruff_source_file", "ruff_text_size", "rustc-hash", "salsa", diff --git a/crates/ty_ide/Cargo.toml b/crates/ty_ide/Cargo.toml index 65199dd73b..f54aa96ae7 100644 --- a/crates/ty_ide/Cargo.toml +++ b/crates/ty_ide/Cargo.toml @@ -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 } diff --git a/crates/ty_ide/src/docstring.rs b/crates/ty_ide/src/docstring.rs new file mode 100644 index 0000000000..c592ed84aa --- /dev/null +++ b/crates/ty_ide/src/docstring.rs @@ -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 = LazyLock::new(|| { + Regex::new(r"(?i)^\s*(Args|Arguments|Parameters)\s*:\s*$") + .expect("Google section regex should be valid") +}); + +static GOOGLE_PARAM_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(\*?\*?\w+)\s*(\(.*?\))?\s*:\s*(.+)") + .expect("Google parameter regex should be valid") +}); + +static NUMPY_SECTION_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)^\s*Parameters\s*$").expect("NumPy section regex should be valid") +}); + +static NUMPY_UNDERLINE_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*-+\s*$").expect("NumPy underline regex should be valid")); + +static REST_PARAM_REGEX: LazyLock = 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 { + 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 { + let mut param_docs = HashMap::new(); + + let mut in_args_section = false; + let mut current_param: Option = 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 { + 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 = None; + let mut current_doc = String::new(); + let mut base_param_indent: Option = None; + let mut base_content_indent: Option = 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 { + let mut param_docs = HashMap::new(); + + let mut current_param: Option = 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()) + ); + } +} diff --git a/crates/ty_ide/src/lib.rs b/crates/ty_ide/src/lib.rs index b9729016cb..b3706539cb 100644 --- a/crates/ty_ide/src/lib.rs +++ b/crates/ty_ide/src/lib.rs @@ -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}; diff --git a/crates/ty_ide/src/signature_help.rs b/crates/ty_ide/src/signature_help.rs new file mode 100644 index 0000000000..d3038e3fb1 --- /dev/null +++ b/crates/ty_ide/src/signature_help.rs @@ -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, +} + +/// 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, + /// Information about each of the parameters in left-to-right order. + pub parameters: Vec, + /// Index of the parameter that corresponds to the argument where the + /// user's cursor is currently positioned. + pub active_parameter: Option, +} + +/// 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, + /// 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, +} + +/// Signature help information for function calls at the given position +pub fn signature_help(db: &dyn Db, file: File, offset: TextSize) -> Option { + 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> = + 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 = 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) -> 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, + parameter_names: &[String], +) -> Vec { + // 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 { + 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( + "#, + ); + + // 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= + "#, + ); + + // 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( + "#, + ); + + // 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( + "#, + ); + + 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( + "#, + ); + + // 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( + "#, + ); + + 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( + "#, + ); + + 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( + "#, + ); + + 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( + "#, + ); + + 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) + "#, + ); + + 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( + "#, + ); + + 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 { + crate::signature_help::signature_help(&self.db, self.cursor.file, self.cursor.offset) + } + } +} diff --git a/crates/ty_python_semantic/src/semantic_index/definition.rs b/crates/ty_python_semantic/src/semantic_index/definition.rs index c66daa78a8..aa9d336800 100644 --- a/crates/ty_python_semantic/src/semantic_index/definition.rs +++ b/crates/ty_python_semantic/src/semantic_index/definition.rs @@ -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 { + 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. diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 42defc64f3..ed37dcc0c6 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -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; diff --git a/crates/ty_python_semantic/src/types/call.rs b/crates/ty_python_semantic/src/types/call.rs index bbceeec704..f77cc429a5 100644 --- a/crates/ty_python_semantic/src/types/call.rs +++ b/crates/ty_python_semantic/src/types/call.rs @@ -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}; diff --git a/crates/ty_python_semantic/src/types/call/arguments.rs b/crates/ty_python_semantic/src/types/call/arguments.rs index f563eee7bf..77a4eb3976 100644 --- a/crates/ty_python_semantic/src/types/call/arguments.rs +++ b/crates/ty_python_semantic/src/types/call/arguments.rs @@ -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>); 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.) diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 54833cbcf3..18cb581773 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -2109,7 +2109,7 @@ impl<'db> Binding<'db> { } } - fn match_parameters( + pub(crate) fn match_parameters( &mut self, arguments: &CallArguments<'_>, argument_forms: &mut [Option], @@ -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] { + &self.argument_parameters + } } #[derive(Clone, Debug)] diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 6055e8a163..6fadfa7df9 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -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() }; diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index cab663e29e..a2f0c380e8 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -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, + parameter_names: Vec, +} + +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(&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(&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, + /// Names of the parameters in order + pub parameter_names: Vec, +} + impl<'db> Parameter<'db> { fn display(&'db self, db: &'db dyn Db) -> DisplayParameter<'db> { DisplayParameter { param: self, db } diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index b5acd3b920..3df5a4bb91 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -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, + + /// The names of the parameters in the signature, in order. + /// This provides easy access to parameter names for documentation lookup. + pub parameter_names: Vec, + + /// The definition where this callable was originally defined (useful for + /// extracting docstrings). + pub definition: Option>, + + /// Mapping from argument indices to parameter indices. This helps + /// determine which parameter corresponds to which argument position. + pub argument_to_parameter_mapping: Vec>, +} + +/// 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> { + 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![] + } +} diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 8790a1d0d3..fff33b7826 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -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); diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index 82b66c9a3d..5322f09aaf 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -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>, @@ -223,6 +223,10 @@ pub struct Signature<'db> { /// to its own generic context. pub(crate) inherited_generic_context: Option>, + /// 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>, + /// 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> { + 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>) -> 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(&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)] diff --git a/crates/ty_server/src/server.rs b/crates/ty_server/src/server.rs index 7938899ed8..128ecf68a8 100644 --- a/crates/ty_server/src/server.rs +++ b/crates/ty_server/src/server.rs @@ -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()), )), diff --git a/crates/ty_server/src/server/api.rs b/crates/ty_server/src/server/api.rs index 42582021da..4b2cb86d1c 100644 --- a/crates/ty_server/src/server/api.rs +++ b/crates/ty_server/src/server/api.rs @@ -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, >( diff --git a/crates/ty_server/src/server/api/requests.rs b/crates/ty_server/src/server/api/requests.rs index 2f9aa26e94..8c0278d573 100644 --- a/crates/ty_server/src/server/api/requests.rs +++ b/crates/ty_server/src/server/api/requests.rs @@ -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; diff --git a/crates/ty_server/src/server/api/requests/signature_help.rs b/crates/ty_server/src/server/api/requests/signature_help.rs new file mode 100644 index 0000000000..07f3adf669 --- /dev/null +++ b/crates/ty_server/src/server/api/requests/signature_help.rs @@ -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 { + 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> { + 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 {} diff --git a/crates/ty_server/src/session/capabilities.rs b/crates/ty_server/src/session/capabilities.rs index 3de25e48f1..e84212fe79 100644 --- a/crates/ty_server/src/session/capabilities.rs +++ b/crates/ty_server/src/session/capabilities.rs @@ -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, } } }