Skip to main content

pytest_language_server/providers/
call_hierarchy.rs

1//! Call hierarchy provider for pytest fixtures.
2//!
3//! Provides fixture dependency visualization:
4//! - Incoming calls: fixtures/tests that use this fixture
5//! - Outgoing calls: fixtures this fixture depends on
6
7use super::Backend;
8use tower_lsp_server::jsonrpc::Result;
9use tower_lsp_server::ls_types::*;
10use tracing::info;
11
12impl Backend {
13    /// Handle prepareCallHierarchy request.
14    ///
15    /// Returns a CallHierarchyItem for the fixture at the cursor position.
16    pub async fn handle_prepare_call_hierarchy(
17        &self,
18        params: CallHierarchyPrepareParams,
19    ) -> Result<Option<Vec<CallHierarchyItem>>> {
20        let uri = params.text_document_position_params.text_document.uri;
21        let position = params.text_document_position_params.position;
22
23        info!(
24            "prepareCallHierarchy request: uri={:?}, line={}, char={}",
25            uri, position.line, position.character
26        );
27
28        if let Some(file_path) = self.uri_to_path(&uri) {
29            // Find the fixture at the cursor position (works on both definitions and usages)
30            if let Some(definition) = self.fixture_db.find_fixture_or_definition_at_position(
31                &file_path,
32                position.line,
33                position.character,
34            ) {
35                let Some(def_uri) = self.path_to_uri(&definition.file_path) else {
36                    return Ok(None);
37                };
38
39                let def_line = Self::internal_line_to_lsp(definition.line);
40                let selection_range = Range {
41                    start: Position {
42                        line: def_line,
43                        character: definition.start_char as u32,
44                    },
45                    end: Position {
46                        line: def_line,
47                        character: definition.end_char as u32,
48                    },
49                };
50
51                // Range covers the whole fixture definition line
52                let range = Self::create_point_range(def_line, 0);
53
54                let item = CallHierarchyItem {
55                    name: definition.name.clone(),
56                    kind: SymbolKind::FUNCTION,
57                    tags: None,
58                    detail: Some(format!(
59                        "@pytest.fixture{}",
60                        if definition.scope != crate::fixtures::types::FixtureScope::Function {
61                            format!("(scope=\"{}\")", definition.scope.as_str())
62                        } else {
63                            String::new()
64                        }
65                    )),
66                    uri: def_uri,
67                    range,
68                    selection_range,
69                    data: None,
70                };
71
72                info!("Returning call hierarchy item: {:?}", item);
73                return Ok(Some(vec![item]));
74            }
75        }
76
77        Ok(None)
78    }
79
80    /// Handle callHierarchy/incomingCalls request.
81    ///
82    /// Returns all fixtures and tests that use the given fixture.
83    pub async fn handle_incoming_calls(
84        &self,
85        params: CallHierarchyIncomingCallsParams,
86    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
87        let item = &params.item;
88        info!("incomingCalls request for: {}", item.name);
89
90        let Some(file_path) = self.uri_to_path(&item.uri) else {
91            return Ok(None);
92        };
93
94        // Get the fixture definition
95        let Some(defs) = self.fixture_db.definitions.get(&item.name) else {
96            return Ok(None);
97        };
98
99        // Find the matching definition by file path
100        let Some(definition) = defs.iter().find(|d| d.file_path == file_path) else {
101            return Ok(None);
102        };
103
104        // Find all references to this fixture
105        let references = self.fixture_db.find_references_for_definition(definition);
106
107        let mut incoming_calls: Vec<CallHierarchyIncomingCall> = Vec::new();
108
109        for usage in references {
110            // Skip self-references (the definition itself)
111            if usage.file_path == definition.file_path && usage.line == definition.line {
112                continue;
113            }
114
115            let Some(usage_uri) = self.path_to_uri(&usage.file_path) else {
116                continue;
117            };
118
119            let usage_line = Self::internal_line_to_lsp(usage.line);
120            let from_range = Range {
121                start: Position {
122                    line: usage_line,
123                    character: usage.start_char as u32,
124                },
125                end: Position {
126                    line: usage_line,
127                    character: usage.end_char as u32,
128                },
129            };
130
131            // Try to find what fixture/test this usage is in
132            let caller_name = self
133                .fixture_db
134                .find_containing_function(&usage.file_path, usage.line)
135                .unwrap_or_else(|| "<unknown>".to_string());
136
137            let from_item = CallHierarchyItem {
138                name: caller_name,
139                kind: SymbolKind::FUNCTION,
140                tags: None,
141                detail: Some(usage.file_path.display().to_string()),
142                uri: usage_uri,
143                range: from_range,
144                selection_range: from_range,
145                data: None,
146            };
147
148            incoming_calls.push(CallHierarchyIncomingCall {
149                from: from_item,
150                from_ranges: vec![from_range],
151            });
152        }
153
154        info!("Found {} incoming calls", incoming_calls.len());
155        Ok(Some(incoming_calls))
156    }
157
158    /// Handle callHierarchy/outgoingCalls request.
159    ///
160    /// Returns all fixtures that the given fixture depends on.
161    pub async fn handle_outgoing_calls(
162        &self,
163        params: CallHierarchyOutgoingCallsParams,
164    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
165        let item = &params.item;
166        info!("outgoingCalls request for: {}", item.name);
167
168        let Some(file_path) = self.uri_to_path(&item.uri) else {
169            return Ok(None);
170        };
171
172        // Get the fixture definition
173        let Some(defs) = self.fixture_db.definitions.get(&item.name) else {
174            return Ok(None);
175        };
176
177        // Find the matching definition by file path
178        let Some(definition) = defs.iter().find(|d| d.file_path == file_path) else {
179            return Ok(None);
180        };
181
182        let mut outgoing_calls: Vec<CallHierarchyOutgoingCall> = Vec::new();
183
184        // Each dependency is an outgoing call
185        for dep_name in &definition.dependencies {
186            // Resolve the dependency to its definition
187            if let Some(dep_def) = self
188                .fixture_db
189                .resolve_fixture_for_file(&file_path, dep_name)
190            {
191                let Some(dep_uri) = self.path_to_uri(&dep_def.file_path) else {
192                    continue;
193                };
194
195                let dep_line = Self::internal_line_to_lsp(dep_def.line);
196                let to_range = Range {
197                    start: Position {
198                        line: dep_line,
199                        character: dep_def.start_char as u32,
200                    },
201                    end: Position {
202                        line: dep_line,
203                        character: dep_def.end_char as u32,
204                    },
205                };
206
207                let to_item = CallHierarchyItem {
208                    name: dep_def.name.clone(),
209                    kind: SymbolKind::FUNCTION,
210                    tags: None,
211                    detail: Some(format!(
212                        "@pytest.fixture{}",
213                        if dep_def.scope != crate::fixtures::types::FixtureScope::Function {
214                            format!("(scope=\"{}\")", dep_def.scope.as_str())
215                        } else {
216                            String::new()
217                        }
218                    )),
219                    uri: dep_uri,
220                    range: Self::create_point_range(dep_line, 0),
221                    selection_range: to_range,
222                    data: None,
223                };
224
225                // Find where in the fixture the dependency is referenced
226                // (parameter position in the signature)
227                let from_ranges = self
228                    .find_parameter_ranges(&file_path, definition.line, dep_name)
229                    .unwrap_or_else(|| vec![to_range]);
230
231                outgoing_calls.push(CallHierarchyOutgoingCall {
232                    to: to_item,
233                    from_ranges,
234                });
235            }
236        }
237
238        info!("Found {} outgoing calls", outgoing_calls.len());
239        Ok(Some(outgoing_calls))
240    }
241
242    /// Find the range(s) where a parameter name appears in a function signature.
243    fn find_parameter_ranges(
244        &self,
245        file_path: &std::path::Path,
246        line: usize,
247        param_name: &str,
248    ) -> Option<Vec<Range>> {
249        let content = self.fixture_db.file_cache.get(file_path)?;
250        let lines: Vec<&str> = content.lines().collect();
251
252        // Get the line (0-indexed internally, but definition.line is 1-indexed)
253        let line_content = lines.get(line.saturating_sub(1))?;
254
255        // Find the parameter in the line
256        if let Some(start) = line_content.find(param_name) {
257            let lsp_line = Self::internal_line_to_lsp(line);
258            let range = Range {
259                start: Position {
260                    line: lsp_line,
261                    character: start as u32,
262                },
263                end: Position {
264                    line: lsp_line,
265                    character: (start + param_name.len()) as u32,
266                },
267            };
268            return Some(vec![range]);
269        }
270
271        None
272    }
273}