Skip to main content

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}