mcpls_core/bridge/translator/
highlights.rs1use super::Translator;
4use super::dto::{
5 DocumentHighlightEntry, DocumentHighlightKind, DocumentHighlightsResult, Position,
6};
7use super::navigation::ItemBudget;
8use super::positioned::Positioned;
9use super::routing::{FileLocalCapability, IndexingGate};
10use crate::bridge::ClientPath;
11use crate::error::Result;
12
13const fn highlight_kind(kind: Option<lsp_types::DocumentHighlightKind>) -> DocumentHighlightKind {
17 match kind {
18 Some(lsp_types::DocumentHighlightKind::Read) => DocumentHighlightKind::Read,
19 Some(lsp_types::DocumentHighlightKind::Write) => DocumentHighlightKind::Write,
20 Some(
21 lsp_types::DocumentHighlightKind::Text | lsp_types::DocumentHighlightKind::Custom(_),
22 )
23 | None => DocumentHighlightKind::Text,
24 }
25}
26
27impl Translator {
28 pub async fn handle_document_highlights(
36 &self,
37 file_path: ClientPath,
38 position: Position,
39 ) -> Result<DocumentHighlightsResult> {
40 let Positioned {
41 result: response,
42 ctx,
43 doc,
44 } = self
45 .position_request::<lsp_types::DocumentHighlightRequest>(
46 &file_path,
47 position,
48 IndexingGate::FileLocal(FileLocalCapability::DocumentHighlights),
49 (),
50 )
51 .await?;
52 let response_uri = doc.uri();
53
54 let mut budget = ItemBudget::new();
55 let lsp_highlights = budget.admit(response.unwrap_or_default());
56 let mut highlights = Vec::with_capacity(lsp_highlights.len());
57 for highlight in lsp_highlights {
58 highlights.push(DocumentHighlightEntry {
59 range: ctx.normalize_range(response_uri, highlight.range).await,
60 kind: highlight_kind(highlight.kind),
61 });
62 }
63
64 Ok(DocumentHighlightsResult {
65 highlights,
66 truncated: budget.truncated(),
67 positions_degraded: ctx.positions_degraded(),
68 })
69 }
70}
71
72#[cfg(test)]
73mod tests {
74 use std::sync::Arc;
75 use std::time::Duration;
76 use std::{assert_matches, fs};
77
78 use tempfile::TempDir;
79 use tokio::io::BufReader;
80 use tokio::time::timeout;
81
82 use super::*;
83 use crate::bridge::translator::testing::*;
84 use crate::config::ServerId;
85 use crate::error::Error;
86 use crate::test_lsp::client_path;
87
88 fn caps() -> lsp_types::ServerCapabilities {
89 lsp_types::ServerCapabilities {
90 document_highlight_provider: Some(lsp_types::DocumentHighlightProvider::Bool(true)),
91 ..Default::default()
92 }
93 }
94
95 fn highlight_json(line: u32, start: u32, end: u32, kind: Option<u32>) -> serde_json::Value {
96 let mut value = serde_json::json!({
97 "range": {
98 "start": {"line": line, "character": start},
99 "end": {"line": line, "character": end}
100 }
101 });
102 if let Some(kind) = kind {
103 value["kind"] = kind.into();
104 }
105 value
106 }
107
108 async fn highlights_with_response(
109 source: &str,
110 response: serde_json::Value,
111 encoding: Option<lsp_types::PositionEncodingKind>,
112 ) -> (serde_json::Value, DocumentHighlightsResult) {
113 let dir = TempDir::new().unwrap();
114 let server_id = ServerId::from_static("rust");
115 let (translator, mut server) = encoding.map_or_else(
116 || translator_with_capabilities(&dir, &server_id, caps()),
117 |encoding| {
118 translator_with_capabilities_and_encoding(&dir, &server_id, caps(), encoding)
119 },
120 );
121 let path = dir.path().join("a.rs");
122 fs::write(&path, source).unwrap();
123
124 let translator = Arc::new(translator);
125 let handle = {
126 let translator = Arc::clone(&translator);
127 let path = path.to_string_lossy().into_owned();
128 tokio::spawn(async move {
129 translator
130 .handle_document_highlights(client_path(path), pos(1, 4))
131 .await
132 })
133 };
134 let mut wire = BufReader::new(&mut server.write_stdout);
135 assert_eq!(
136 read_framed_message(&mut wire).await["method"],
137 "textDocument/didOpen"
138 );
139 let request = read_framed_message(&mut wire).await;
140 assert_eq!(request["method"], "textDocument/documentHighlight");
141 write_response(&mut server.read_half_stdin, &request["id"], response).await;
142
143 let result = timeout(Duration::from_secs(30), handle)
144 .await
145 .expect("handler call should not hang")
146 .unwrap()
147 .unwrap();
148 (request, result)
149 }
150
151 #[tokio::test]
152 async fn maps_kinds_and_defaults_an_omitted_kind_to_text() {
153 let (request, result) = highlights_with_response(
154 "let a = 1; a; a",
155 serde_json::json!([
156 highlight_json(0, 4, 5, Some(3)),
157 highlight_json(0, 11, 12, Some(2)),
158 highlight_json(0, 14, 15, None),
159 highlight_json(0, 0, 1, Some(42)),
160 ]),
161 None,
162 )
163 .await;
164 assert_eq!(request["params"]["position"]["character"], 3);
165 let kinds: Vec<_> = result.highlights.iter().map(|h| h.kind).collect();
166 assert_eq!(
167 kinds,
168 [
169 DocumentHighlightKind::Write,
170 DocumentHighlightKind::Read,
171 DocumentHighlightKind::Text,
172 DocumentHighlightKind::Text,
173 ]
174 );
175 assert_eq!(result.highlights[0].range.start.character, 5);
176 let wire = serde_json::to_value(&result).unwrap();
177 assert_eq!(wire["highlights"][0]["kind"], "write");
178 assert!(wire.get("truncated").is_none());
179 }
180
181 #[tokio::test]
182 async fn null_response_is_an_empty_result() {
183 let (_, result) =
184 highlights_with_response("let a = 1;", serde_json::Value::Null, None).await;
185 assert_eq!(result.highlights.len(), 0);
186 }
187
188 #[tokio::test]
189 async fn utf8_server_ranges_are_converted_to_mcp_columns() {
190 let (_, result) = highlights_with_response(
192 "let é = 1;",
193 serde_json::json!([highlight_json(0, 4, 6, Some(1))]),
194 Some(lsp_types::PositionEncodingKind::UTF8),
195 )
196 .await;
197 assert_eq!(result.highlights[0].range.start.character, 5);
198 assert_eq!(result.highlights[0].range.end.character, 6);
199 assert_eq!(result.positions_degraded, None);
200 }
201
202 #[tokio::test]
203 async fn rejects_missing_capability() {
204 let dir = TempDir::new().unwrap();
205 let (translator, _server) = translator_with_capabilities(
206 &dir,
207 &ServerId::from_static("rust"),
208 lsp_types::ServerCapabilities::default(),
209 );
210 let path = dir.path().join("a.rs");
211 fs::write(&path, "let a = 1;").unwrap();
212 let result = translator
213 .handle_document_highlights(client_path(path.to_string_lossy().into_owned()), pos(1, 1))
214 .await;
215 assert_matches!(result, Err(Error::CapabilityNotSupported { .. }));
216 }
217
218 #[test]
219 fn highlight_kind_defaults_to_text() {
220 assert_eq!(highlight_kind(None), DocumentHighlightKind::Text);
221 assert_eq!(
222 highlight_kind(Some(lsp_types::DocumentHighlightKind::Custom(9))),
223 DocumentHighlightKind::Text
224 );
225 }
226}