pytest_language_server/providers/inlay_hint.rs
1//! Inlay hints provider for pytest fixtures.
2//!
3//! Shows fixture return types inline for fixture parameters in test functions
4//! when the fixture has an explicit return type annotation.
5//!
6//! The displayed type is adapted to the consumer file's import context via
7//! [`adapt_type_for_consumer`]: if the consumer already has `from pathlib import Path`
8//! the hint shows `Path` rather than `pathlib.Path`, and vice versa.
9
10use super::Backend;
11use crate::fixtures::import_analysis::adapt_type_for_consumer;
12use crate::fixtures::string_utils::parameter_has_annotation;
13use crate::fixtures::FixtureDefinition;
14use std::collections::HashMap;
15use std::sync::Arc;
16use tower_lsp_server::jsonrpc::Result;
17use tower_lsp_server::ls_types::*;
18use tracing::info;
19
20impl Backend {
21 /// Handle inlay hints request.
22 ///
23 /// Returns type hints for fixture parameters when the fixture has an explicit
24 /// return type annotation. This helps developers understand what type each
25 /// fixture provides without having to navigate to its definition.
26 ///
27 /// Skips parameters that already have a type annotation to avoid redundancy.
28 pub async fn handle_inlay_hint(
29 &self,
30 params: InlayHintParams,
31 ) -> Result<Option<Vec<InlayHint>>> {
32 let uri = params.text_document.uri;
33 let range = params.range;
34
35 info!("inlay_hint request: uri={:?}, range={:?}", uri, range);
36
37 let Some(file_path) = self.uri_to_path(&uri) else {
38 return Ok(None);
39 };
40
41 let Some(usages) = self.fixture_db.usages.get(&file_path) else {
42 return Ok(None);
43 };
44
45 // Get current file content to check for existing annotations.
46 // The file_cache is updated on every `textDocument/didChange` notification,
47 // which editors send before requesting inlay hints. This ensures we check
48 // against the current buffer state, not stale disk content.
49 // Note: If an editor doesn't follow the LSP spec and requests hints before
50 // sending didChange, hints might be shown/hidden incorrectly until the next sync.
51 let content = self
52 .fixture_db
53 .file_cache
54 .get(&file_path)
55 .map(|c| c.clone());
56 let lines: Vec<&str> = content
57 .as_ref()
58 .map(|c| c.lines().collect())
59 .unwrap_or_default();
60
61 // Build the consumer file's name→TypeImportSpec map so that
62 // adapt_type_for_consumer can rewrite dotted types to short names (or
63 // vice versa) to match the consumer's existing import style.
64 // Cached by content hash — reused across requests without re-parsing.
65 let consumer_import_map = if let Some(ref c) = content {
66 self.fixture_db
67 .get_name_to_import_map(&file_path, c.as_str())
68 } else {
69 Arc::new(HashMap::new())
70 };
71
72 // Pre-compute a map of fixture name → definition for O(1) lookup.
73 // Stores the full FixtureDefinition so we can access return_type_imports
74 // when calling adapt_type_for_consumer.
75 let available = self.fixture_db.get_available_fixtures(&file_path);
76 let fixture_map: HashMap<&str, &FixtureDefinition> = available
77 .iter()
78 .filter_map(|def| {
79 if def.return_type.is_some() {
80 Some((def.name.as_str(), def))
81 } else {
82 None
83 }
84 })
85 .collect();
86
87 // Early return if no fixtures have return types
88 if fixture_map.is_empty() {
89 return Ok(Some(Vec::new()));
90 }
91
92 // Convert LSP range to internal line numbers (1-based)
93 let start_line = Self::lsp_line_to_internal(range.start.line);
94 let end_line = Self::lsp_line_to_internal(range.end.line);
95
96 let mut hints = Vec::new();
97
98 for usage in usages.iter() {
99 // Skip string-based usages from @pytest.mark.usefixtures(...),
100 // pytestmark assignments, and @pytest.mark.parametrize(..., indirect=...).
101 // These are not function parameters and cannot receive type annotations.
102 if !usage.is_parameter {
103 continue;
104 }
105
106 // Only process usages within the requested range
107 if usage.line < start_line || usage.line > end_line {
108 continue;
109 }
110
111 // Look up fixture definition from pre-computed map
112 if let Some(def) = fixture_map.get(usage.name.as_str()) {
113 // Check if this parameter already has a type annotation
114 // by looking at the text after the parameter name in the current buffer
115 if parameter_has_annotation(&lines, usage.line, usage.end_char) {
116 continue;
117 }
118
119 // Safety: fixture_map only contains defs with return_type.is_some()
120 let return_type = def.return_type.as_deref().unwrap();
121
122 // Adapt the type string to the consumer's import style.
123 // e.g. if the consumer has `from pathlib import Path` already,
124 // show `Path` instead of `pathlib.Path`, and vice versa.
125 // The returned import specs are discarded — inlay hints are
126 // display-only and do not insert imports.
127 let (display_type, _) = adapt_type_for_consumer(
128 return_type,
129 &def.return_type_imports,
130 &consumer_import_map,
131 );
132
133 let lsp_line = Self::internal_line_to_lsp(usage.line);
134
135 hints.push(InlayHint {
136 position: Position {
137 line: lsp_line,
138 character: usage.end_char as u32,
139 },
140 label: InlayHintLabel::String(format!(": {}", display_type)),
141 kind: Some(InlayHintKind::TYPE),
142 text_edits: None,
143 tooltip: Some(InlayHintTooltip::String(format!(
144 "Fixture '{}' returns {}",
145 usage.name, display_type
146 ))),
147 padding_left: Some(false),
148 padding_right: Some(false),
149 data: None,
150 });
151 }
152 }
153
154 info!("Returning {} inlay hints", hints.len());
155 Ok(Some(hints))
156 }
157}