Skip to main content

deps_lsp/handlers/
inlay_hints.rs

1//! Inlay hints handler using ecosystem trait delegation.
2//!
3//! This handler uses the ecosystem registry to delegate inlay hint generation
4//! to the appropriate ecosystem implementation.
5
6use crate::config::{DepsConfig, InlayHintsConfig};
7use crate::document::{ServerState, ensure_document_loaded};
8use deps_core::{EcosystemConfig, VersionData};
9use std::sync::Arc;
10use tokio::sync::RwLock;
11use tower_lsp_server::Client;
12use tower_lsp_server::ls_types::{InlayHint, InlayHintParams};
13
14/// Handles inlay hint requests using trait-based delegation.
15///
16/// Returns version status hints for all registry dependencies in the document.
17/// Gracefully degrades by returning empty vec on any errors.
18pub async fn handle_inlay_hints(
19    state: Arc<ServerState>,
20    params: InlayHintParams,
21    config: &InlayHintsConfig,
22    client: Client,
23    full_config: Arc<RwLock<DepsConfig>>,
24) -> Vec<InlayHint> {
25    if !config.enabled {
26        return vec![];
27    }
28
29    let uri = &params.text_document.uri;
30
31    // Ensure document is loaded (cold start support)
32    if !ensure_document_loaded(uri, Arc::clone(&state), client, Arc::clone(&full_config)).await {
33        tracing::warn!("Could not load document for inlay hints: {:?}", uri);
34        return vec![];
35    }
36
37    // Snapshot config before the document lookup (Copy value, no lock held across the call)
38    let (loading_config, offline) = {
39        let full_config = full_config.read().await;
40        (
41            full_config.loading_indicator.clone(),
42            full_config.network.offline,
43        )
44    };
45
46    // Own everything `generate_inlay_hints` needs and release the DashMap shard `Ref`
47    // before awaiting it (#333): `with_document` only ever hands `extract` a borrowed
48    // `&DocumentState` synchronously, so the guard can't leak across the `.await` below.
49    let Some(extracted) = state.with_document(uri, |doc| {
50        let Some(ecosystem) = state.ecosystem_registry.get(doc.ecosystem_id()) else {
51            tracing::warn!("Ecosystem not found: {}", doc.ecosystem_id());
52            return None;
53        };
54        let parse_result = doc.parse_result_arc()?;
55        Some((
56            ecosystem,
57            parse_result,
58            doc.cached_versions.clone(),
59            doc.resolved_versions.clone(),
60            doc.loading_state,
61        ))
62    }) else {
63        tracing::warn!("Document not found: {:?}", uri);
64        return vec![];
65    };
66
67    let Some((ecosystem, parse_result, cached_versions, resolved_versions, loading_state)) =
68        extracted
69    else {
70        return vec![];
71    };
72
73    let ecosystem_config = EcosystemConfig {
74        show_up_to_date_hints: true,
75        up_to_date_text: config.up_to_date_text.clone(),
76        needs_update_text: config.needs_update_text.clone(),
77        loading_text: loading_config.loading_text,
78        show_loading_hints: loading_config.enabled && loading_config.fallback_to_hints,
79        offline,
80    };
81
82    ecosystem
83        .generate_inlay_hints(
84            parse_result.as_ref(),
85            VersionData::new(&cached_versions, &resolved_versions),
86            loading_state,
87            &ecosystem_config,
88        )
89        .await
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::document::ServerState;
96    use crate::test_utils::test_helpers::create_test_client_and_config;
97    use deps_core::EcosystemId;
98    use tower_lsp_server::ls_types::TextDocumentIdentifier;
99
100    // Generic tests (no feature flag required)
101
102    #[test]
103    fn test_handle_inlay_hints_disabled() {
104        let config = InlayHintsConfig {
105            enabled: false,
106            up_to_date_text: "✅".to_string(),
107            needs_update_text: "❌ {}".to_string(),
108        };
109
110        assert!(!config.enabled);
111    }
112
113    #[tokio::test]
114    async fn test_handle_inlay_hints_disabled_returns_empty() {
115        let state = Arc::new(ServerState::new());
116        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
117        let config = InlayHintsConfig {
118            enabled: false,
119            up_to_date_text: "✅".to_string(),
120            needs_update_text: "❌ {}".to_string(),
121        };
122
123        let params = InlayHintParams {
124            text_document: TextDocumentIdentifier { uri },
125            work_done_progress_params: Default::default(),
126            range: tower_lsp_server::ls_types::Range::new(
127                tower_lsp_server::ls_types::Position::new(0, 0),
128                tower_lsp_server::ls_types::Position::new(100, 0),
129            ),
130        };
131
132        let (client, full_config) = create_test_client_and_config();
133        let result = handle_inlay_hints(state, params, &config, client, full_config).await;
134        assert!(result.is_empty());
135    }
136
137    #[tokio::test]
138    async fn test_handle_inlay_hints_missing_document() {
139        let state = Arc::new(ServerState::new());
140        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
141        let config = InlayHintsConfig {
142            enabled: true,
143            up_to_date_text: "✅".to_string(),
144            needs_update_text: "❌ {}".to_string(),
145        };
146
147        let params = InlayHintParams {
148            text_document: TextDocumentIdentifier { uri },
149            work_done_progress_params: Default::default(),
150            range: tower_lsp_server::ls_types::Range::new(
151                tower_lsp_server::ls_types::Position::new(0, 0),
152                tower_lsp_server::ls_types::Position::new(100, 0),
153            ),
154        };
155
156        let (client, full_config) = create_test_client_and_config();
157        let result = handle_inlay_hints(state, params, &config, client, full_config).await;
158        assert!(result.is_empty());
159    }
160
161    /// #333 liveness regression: `handle_inlay_hints` must release the DashMap shard
162    /// `Ref` on the document *before* awaiting `Ecosystem::generate_inlay_hints`, so a
163    /// concurrent `documents.get_mut` on the same URI (e.g. a `didChange`) is never
164    /// blocked behind an in-flight (or stuck) hint generation.
165    ///
166    /// `BlockingEcosystem::generate_inlay_hints` waits on a `Barrier` before blocking
167    /// forever (`std::future::pending`), standing in for an override that performs real
168    /// I/O — the worst case for a shard `Ref` held across the call. The test only
169    /// proceeds to race the writer once that future has demonstrably started executing
170    /// (via the barrier); a concurrent write racing here must complete almost
171    /// immediately, proving the `Ref` was already dropped before the call was awaited.
172    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
173    async fn test_concurrent_document_write_not_blocked_by_in_flight_inlay_hints() {
174        use crate::test_utils::blocking_ecosystem::{
175            BlockingEcosystem, BlockingHook, MockParseResult,
176        };
177        use deps_core::ParseResult;
178        use tokio::sync::Barrier;
179
180        let state = Arc::new(ServerState::new());
181        let started = Arc::new(Barrier::new(2));
182        state
183            .ecosystem_registry
184            .register(Arc::new(BlockingEcosystem {
185                started: Arc::clone(&started),
186                hook: BlockingHook::InlayHints,
187            }));
188
189        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
190        let content = "[dependencies]\nserde = \"1.0\"\n".to_string();
191        let parse_result: Box<dyn ParseResult> = Box::new(MockParseResult { uri: uri.clone() });
192        let doc = crate::document::DocumentState::new_from_parse_result(
193            EcosystemId::Cargo,
194            content,
195            parse_result,
196        );
197        state.update_document(uri.clone(), doc);
198
199        let config = InlayHintsConfig {
200            enabled: true,
201            up_to_date_text: "up to date".to_string(),
202            needs_update_text: "outdated: {}".to_string(),
203        };
204        let (client, full_config) = create_test_client_and_config();
205
206        let handler_task = tokio::spawn({
207            let state = Arc::clone(&state);
208            let uri = uri.clone();
209            async move {
210                let params = InlayHintParams {
211                    text_document: TextDocumentIdentifier { uri },
212                    work_done_progress_params: Default::default(),
213                    range: tower_lsp_server::ls_types::Range::new(
214                        tower_lsp_server::ls_types::Position::new(0, 0),
215                        tower_lsp_server::ls_types::Position::new(100, 0),
216                    ),
217                };
218                handle_inlay_hints(state, params, &config, client, full_config).await
219            }
220        });
221
222        // Block until `generate_inlay_hints` has actually started executing —
223        // i.e. `handle_inlay_hints` has reached (and is now inside) the await — before
224        // racing the writer below. Timeout-wrapped so a regression that makes the
225        // handler never reach the awaited call fails loudly instead of hanging forever.
226        tokio::time::timeout(std::time::Duration::from_secs(5), started.wait())
227            .await
228            .expect("handle_inlay_hints did not reach generate_inlay_hints within 5s");
229
230        // Spawned onto its own task (rather than awaited inline) deliberately: see
231        // `completion.rs`'s equivalent #319 regression test for why `DashMap::get_mut`
232        // needs a real async yield point to race against `tokio::time::timeout`.
233        let write_task = tokio::spawn({
234            let state = Arc::clone(&state);
235            let uri = uri.clone();
236            async move {
237                state.documents.get_mut(&uri).unwrap().set_loading();
238            }
239        });
240        let write_result =
241            tokio::time::timeout(std::time::Duration::from_millis(500), write_task).await;
242
243        handler_task.abort();
244
245        assert!(
246            write_result.is_ok(),
247            "#333 regression: a concurrent documents.get_mut on the same URI must not \
248             block on an in-flight generate_inlay_hints call — the DashMap shard Ref \
249             must be dropped before the call is awaited, not after it"
250        );
251    }
252
253    // Cargo-specific tests
254    #[cfg(feature = "cargo")]
255    mod cargo_tests {
256        use super::*;
257        use crate::document::DocumentState;
258
259        #[tokio::test]
260        async fn test_handle_inlay_hints() {
261            let state = Arc::new(ServerState::new());
262            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
263            let config = InlayHintsConfig {
264                enabled: true,
265                up_to_date_text: "✅".to_string(),
266                needs_update_text: "❌ {}".to_string(),
267            };
268
269            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
270            let content = r#"[dependencies]
271serde = "1.0.0"
272"#
273            .to_string();
274
275            let parse_result = ecosystem
276                .parse_manifest(&content, &uri)
277                .await
278                .expect("Failed to parse manifest");
279
280            let doc_state =
281                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
282            state.update_document(uri.clone(), doc_state);
283
284            let params = InlayHintParams {
285                text_document: TextDocumentIdentifier { uri },
286                work_done_progress_params: Default::default(),
287                range: tower_lsp_server::ls_types::Range::new(
288                    tower_lsp_server::ls_types::Position::new(0, 0),
289                    tower_lsp_server::ls_types::Position::new(100, 0),
290                ),
291            };
292
293            let (client, full_config) = create_test_client_and_config();
294            let _result = handle_inlay_hints(state, params, &config, client, full_config).await;
295            // Test passes if no panic occurs
296        }
297
298        #[tokio::test]
299        async fn test_handle_inlay_hints_no_parse_result() {
300            let state = Arc::new(ServerState::new());
301            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
302            let config = InlayHintsConfig {
303                enabled: true,
304                up_to_date_text: "✅".to_string(),
305                needs_update_text: "❌ {}".to_string(),
306            };
307
308            let doc_state =
309                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
310            state.update_document(uri.clone(), doc_state);
311
312            let params = InlayHintParams {
313                text_document: TextDocumentIdentifier { uri },
314                work_done_progress_params: Default::default(),
315                range: tower_lsp_server::ls_types::Range::new(
316                    tower_lsp_server::ls_types::Position::new(0, 0),
317                    tower_lsp_server::ls_types::Position::new(100, 0),
318                ),
319            };
320
321            let (client, full_config) = create_test_client_and_config();
322            let result = handle_inlay_hints(state, params, &config, client, full_config).await;
323            assert!(result.is_empty());
324        }
325
326        #[tokio::test]
327        async fn test_handle_inlay_hints_custom_config() {
328            let state = Arc::new(ServerState::new());
329            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
330            let config = InlayHintsConfig {
331                enabled: true,
332                up_to_date_text: "OK".to_string(),
333                needs_update_text: "UPDATE: {}".to_string(),
334            };
335
336            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
337            let content = r#"[dependencies]
338serde = "1.0.0"
339"#
340            .to_string();
341
342            let parse_result = ecosystem
343                .parse_manifest(&content, &uri)
344                .await
345                .expect("Failed to parse manifest");
346
347            let doc_state =
348                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
349            state.update_document(uri.clone(), doc_state);
350
351            let params = InlayHintParams {
352                text_document: TextDocumentIdentifier { uri },
353                work_done_progress_params: Default::default(),
354                range: tower_lsp_server::ls_types::Range::new(
355                    tower_lsp_server::ls_types::Position::new(0, 0),
356                    tower_lsp_server::ls_types::Position::new(100, 0),
357                ),
358            };
359
360            let (client, full_config) = create_test_client_and_config();
361            let _result = handle_inlay_hints(state, params, &config, client, full_config).await;
362            // Test passes if no panic occurs
363        }
364    }
365
366    // npm-specific tests
367    #[cfg(feature = "npm")]
368    mod npm_tests {
369        use super::*;
370        use crate::document::DocumentState;
371
372        #[tokio::test]
373        async fn test_handle_inlay_hints() {
374            let state = Arc::new(ServerState::new());
375            let uri = deps_core::test_util::test_uri("/test/package.json");
376            let config = InlayHintsConfig {
377                enabled: true,
378                up_to_date_text: "✅".to_string(),
379                needs_update_text: "❌ {}".to_string(),
380            };
381
382            let ecosystem = state.ecosystem_registry.get("npm").unwrap();
383            let content = r#"{"dependencies": {"express": "4.0.0"}}"#.to_string();
384
385            let parse_result = ecosystem
386                .parse_manifest(&content, &uri)
387                .await
388                .expect("Failed to parse manifest");
389
390            let doc_state =
391                DocumentState::new_from_parse_result(EcosystemId::Npm, content, parse_result);
392            state.update_document(uri.clone(), doc_state);
393
394            let params = InlayHintParams {
395                text_document: TextDocumentIdentifier { uri },
396                work_done_progress_params: Default::default(),
397                range: tower_lsp_server::ls_types::Range::new(
398                    tower_lsp_server::ls_types::Position::new(0, 0),
399                    tower_lsp_server::ls_types::Position::new(100, 0),
400                ),
401            };
402
403            let (client, full_config) = create_test_client_and_config();
404            let _result = handle_inlay_hints(state, params, &config, client, full_config).await;
405            // Test passes if no panic occurs
406        }
407    }
408
409    // PyPI-specific tests
410    #[cfg(feature = "pypi")]
411    mod pypi_tests {
412        use super::*;
413        use crate::document::DocumentState;
414
415        #[tokio::test]
416        async fn test_handle_inlay_hints() {
417            let state = Arc::new(ServerState::new());
418            let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
419            let config = InlayHintsConfig {
420                enabled: true,
421                up_to_date_text: "✅".to_string(),
422                needs_update_text: "❌ {}".to_string(),
423            };
424
425            let ecosystem = state.ecosystem_registry.get("pypi").unwrap();
426            let content = r#"[project]
427dependencies = ["requests>=2.0.0"]
428"#
429            .to_string();
430
431            let parse_result = ecosystem
432                .parse_manifest(&content, &uri)
433                .await
434                .expect("Failed to parse manifest");
435
436            let doc_state =
437                DocumentState::new_from_parse_result(EcosystemId::Pypi, content, parse_result);
438            state.update_document(uri.clone(), doc_state);
439
440            let params = InlayHintParams {
441                text_document: TextDocumentIdentifier { uri },
442                work_done_progress_params: Default::default(),
443                range: tower_lsp_server::ls_types::Range::new(
444                    tower_lsp_server::ls_types::Position::new(0, 0),
445                    tower_lsp_server::ls_types::Position::new(100, 0),
446                ),
447            };
448
449            let (client, full_config) = create_test_client_and_config();
450            let _result = handle_inlay_hints(state, params, &config, client, full_config).await;
451            // Test passes if no panic occurs
452        }
453    }
454}