ts_bridge/protocol/text_document/
document_highlight.rs

1//! =============================================================================
2//! textDocument/documentHighlight
3//! =============================================================================
4//!
5//! Surfaces tsserver’s `documentHighlights` command so clients like Neovim can
6//! show same-buffer highlight spans (read vs write).  Tsserver reports every
7//! file touched by the symbol, but the LSP request expects highlights scoped to
8//! the current document, so we filter on the originating file.
9
10use anyhow::{Context, Result};
11use lsp_types::{DocumentHighlight, DocumentHighlightKind, DocumentHighlightParams};
12use serde::Deserialize;
13use serde_json::{Value, json};
14
15use crate::protocol::{AdapterResult, RequestSpec};
16use crate::rpc::{Priority, Route};
17use crate::utils::{tsserver_range_from_value_lsp, uri_to_file_path};
18
19const CMD_DOCUMENT_HIGHLIGHTS: &str = "documentHighlights";
20
21#[derive(Debug, Deserialize)]
22struct HighlightContext {
23    file: String,
24}
25
26pub fn handle(params: DocumentHighlightParams) -> RequestSpec {
27    let text_document = params.text_document_position_params.text_document;
28    let position = params.text_document_position_params.position;
29    let uri_string = text_document.uri.to_string();
30    let file = uri_to_file_path(text_document.uri.as_str()).unwrap_or(uri_string);
31
32    let request = json!({
33        "command": CMD_DOCUMENT_HIGHLIGHTS,
34        "arguments": {
35            "file": file,
36            "line": position.line + 1,
37            "offset": position.character + 1,
38        }
39    });
40
41    let context = json!({ "file": file });
42
43    RequestSpec {
44        route: Route::Syntax,
45        payload: request,
46        priority: Priority::Normal,
47        on_response: Some(adapt_document_highlights),
48        response_context: Some(context),
49    }
50}
51
52fn adapt_document_highlights(payload: &Value, context: Option<&Value>) -> Result<AdapterResult> {
53    let ctx: HighlightContext = serde_json::from_value(
54        context
55            .cloned()
56            .context("missing documentHighlight context")?,
57    )?;
58    let items = payload
59        .get("body")
60        .context("tsserver documentHighlights missing body")?
61        .as_array()
62        .cloned()
63        .unwrap_or_default();
64
65    let mut highlights = Vec::new();
66    for item in items {
67        let file = item
68            .get("file")
69            .or_else(|| item.get("fileName"))
70            .and_then(|v| v.as_str())
71            .unwrap_or("");
72        if !file.is_empty() && file != ctx.file {
73            continue;
74        }
75        let spans = item
76            .get("highlightSpans")
77            .and_then(|value| value.as_array())
78            .cloned()
79            .unwrap_or_default();
80        for span in spans {
81            if let Some(range) = tsserver_range_from_value_lsp(&span) {
82                let kind = span
83                    .get("kind")
84                    .and_then(|value| value.as_str())
85                    .and_then(highlight_kind_from_ts_kind);
86                highlights.push(DocumentHighlight { range, kind });
87            }
88        }
89    }
90
91    Ok(AdapterResult::ready(serde_json::to_value(highlights)?))
92}
93
94fn highlight_kind_from_ts_kind(kind: &str) -> Option<DocumentHighlightKind> {
95    match kind {
96        "writtenReference" => Some(DocumentHighlightKind::WRITE),
97        "definition" => Some(DocumentHighlightKind::WRITE),
98        "reference" => Some(DocumentHighlightKind::READ),
99        "none" => Some(DocumentHighlightKind::TEXT),
100        _ => None,
101    }
102}