Skip to main content

mcpls_core/bridge/translator/
highlights.rs

1//! Document highlights handler.
2
3use 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
13/// The MCP kind of an LSP highlight kind. A server that omits the kind, or
14/// sends a custom value outside the three defined ones, means the LSP
15/// default: a textual occurrence.
16const 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    /// Handle a document highlights request.
29    ///
30    /// # Errors
31    ///
32    /// Returns an error if the position is invalid, the LSP request fails,
33    /// the file cannot be opened, or the routed server does not advertise
34    /// `documentHighlightProvider` support.
35    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!(result.highlights.is_empty());
186    }
187
188    #[tokio::test]
189    async fn utf8_server_ranges_are_converted_to_mcp_columns() {
190        // "é" is 2 UTF-8 bytes but 1 character: byte offset 5 is character 4.
191        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}