ts_bridge/
documents.rs

1//! =============================================================================
2//! Open Document Store
3//! =============================================================================
4//!
5//! Tracks the latest text for each open buffer so features that rely on
6//! absolute offsets (e.g. inlay hints) can translate between LSP ranges and
7//! tsserver’s 1-D spans without round-tripping through the process.
8
9use std::cmp;
10use std::collections::HashMap;
11
12use lsp_types::{Position as LspPosition, Range as LspRange, Uri};
13
14use crate::types::{
15    Position as PluginPosition, Range as PluginRange, TextDocumentContentChangeEvent,
16};
17
18/// Captures the current snapshot for every open text document.
19#[derive(Default)]
20pub struct DocumentStore {
21    docs: HashMap<String, DocumentState>,
22}
23
24impl DocumentStore {
25    /// Inserts or replaces the document snapshot whenever Neovim fires
26    /// textDocument/didOpen.
27    pub fn open(
28        &mut self,
29        uri: &Uri,
30        text: &str,
31        version: Option<i32>,
32        language_id: Option<String>,
33    ) {
34        let state = DocumentState::new(text, version, language_id);
35        self.docs.insert(uri.to_string(), state);
36    }
37
38    /// Applies incremental text changes using the same ordering LSP specifies.
39    pub fn apply_changes(
40        &mut self,
41        uri: &Uri,
42        changes: &[TextDocumentContentChangeEvent],
43        version: Option<i32>,
44    ) {
45        let Some(state) = self.docs.get_mut(uri.as_str()) else {
46            log::warn!("received didChange for unopened document {}", uri.as_str());
47            return;
48        };
49        for change in changes {
50            state.apply_change(change);
51        }
52        state.version = version;
53    }
54
55    /// Drops the cached snapshot as soon as the client closes the buffer.
56    pub fn close(&mut self, uri: &Uri) {
57        self.docs.remove(uri.as_str());
58    }
59
60    pub fn is_open(&self, uri: &Uri) -> bool {
61        self.docs.contains_key(uri.as_str())
62    }
63
64    pub fn open_documents(&self) -> Vec<OpenDocumentSnapshot> {
65        self.docs
66            .iter()
67            .map(|(uri, doc)| OpenDocumentSnapshot {
68                uri: uri.clone(),
69                text: doc.text.clone(),
70                version: doc.version,
71                language_id: doc.language_id.clone(),
72            })
73            .collect()
74    }
75
76    /// Converts a visible LSP range into a tsserver-style text span measured in
77    /// UTF-16 code units. Returns `None` when the document has not been opened
78    /// yet.
79    pub fn span_for_range(&self, uri: &Uri, range: &LspRange) -> Option<TextSpan> {
80        self.docs.get(uri.as_str()).map(|doc| doc.text_span(range))
81    }
82}
83
84/// Represents a tsserver text span using UTF-16 offsets.
85#[derive(Debug, Clone, Copy)]
86pub struct TextSpan {
87    pub start: u32,
88    pub length: u32,
89}
90
91impl TextSpan {
92    pub fn covering_length(len: u32) -> Self {
93        Self {
94            start: 0,
95            length: len,
96        }
97    }
98}
99
100struct DocumentState {
101    text: String,
102    line_metrics: Vec<LineMetrics>,
103    total_utf16: u32,
104    version: Option<i32>,
105    language_id: Option<String>,
106}
107
108impl DocumentState {
109    fn new(text: &str, version: Option<i32>, language_id: Option<String>) -> Self {
110        let mut state = Self {
111            text: text.to_string(),
112            line_metrics: Vec::new(),
113            total_utf16: 0,
114            version,
115            language_id,
116        };
117        state.recompute_metrics();
118        state
119    }
120
121    fn apply_change(&mut self, change: &TextDocumentContentChangeEvent) {
122        if let Some(range) = &change.range {
123            let lsp_range = convert_range(range);
124            let start = self.byte_index(&lsp_range.start);
125            let end = self.byte_index(&lsp_range.end);
126            if start > end || end > self.text.len() {
127                log::warn!(
128                    "inlay hint document store received out-of-bounds change ({start}-{end} vs len {})",
129                    self.text.len()
130                );
131                return;
132            }
133            self.text.replace_range(start..end, &change.text);
134        } else {
135            self.text = change.text.clone();
136        }
137        self.recompute_metrics();
138    }
139
140    fn text_span(&self, range: &LspRange) -> TextSpan {
141        let start = self.utf16_offset(&range.start);
142        let end = self.utf16_offset(&range.end);
143        if end >= start {
144            TextSpan {
145                start,
146                length: end - start,
147            }
148        } else {
149            TextSpan {
150                start: end,
151                length: start - end,
152            }
153        }
154    }
155
156    fn utf16_offset(&self, position: &LspPosition) -> u32 {
157        let line_idx = self.clamp_line_idx(position.line);
158        let line = &self.line_metrics[line_idx];
159        let column = cmp::min(position.character, line.content_utf16);
160        line.start_utf16 + column
161    }
162
163    fn byte_index(&self, position: &LspPosition) -> usize {
164        let line_idx = self.clamp_line_idx(position.line);
165        let line = &self.line_metrics[line_idx];
166        let mut byte_index = line.start_byte;
167        let mut remaining = cmp::min(position.character, line.content_utf16);
168        let line_text = &self.text[line.start_byte..line.start_byte + line.content_bytes];
169        for ch in line_text.chars() {
170            if remaining == 0 {
171                break;
172            }
173            let units = ch.len_utf16() as u32;
174            if remaining < units {
175                break;
176            }
177            remaining -= units;
178            byte_index += ch.len_utf8();
179        }
180        byte_index
181    }
182
183    fn clamp_line_idx(&self, line: u32) -> usize {
184        if self.line_metrics.is_empty() {
185            return 0;
186        }
187        cmp::min(line as usize, self.line_metrics.len() - 1)
188    }
189
190    fn recompute_metrics(&mut self) {
191        let mut metrics = Vec::new();
192        let mut cursor = 0;
193        let mut utf16_offset = 0u32;
194        let bytes = self.text.as_bytes();
195
196        while cursor < bytes.len() {
197            let line_start = cursor;
198            while cursor < bytes.len() && bytes[cursor] != b'\n' && bytes[cursor] != b'\r' {
199                cursor += 1;
200            }
201            let content_end = cursor;
202            let content = &self.text[line_start..content_end];
203            let content_utf16 = content.encode_utf16().count() as u32;
204
205            let mut newline_utf16 = 0u32;
206            if cursor < bytes.len() {
207                match bytes[cursor] {
208                    b'\r' => {
209                        newline_utf16 += 1;
210                        cursor += 1;
211                        if cursor < bytes.len() && bytes[cursor] == b'\n' {
212                            newline_utf16 += 1;
213                            cursor += 1;
214                        }
215                    }
216                    b'\n' => {
217                        newline_utf16 += 1;
218                        cursor += 1;
219                    }
220                    _ => {}
221                }
222            }
223
224            metrics.push(LineMetrics {
225                start_byte: line_start,
226                start_utf16: utf16_offset,
227                content_bytes: content_end - line_start,
228                content_utf16,
229            });
230            utf16_offset = utf16_offset.saturating_add(content_utf16 + newline_utf16);
231        }
232
233        if metrics.is_empty() {
234            metrics.push(LineMetrics::empty());
235        } else if self.text.ends_with('\n') || self.text.ends_with('\r') {
236            metrics.push(LineMetrics {
237                start_byte: self.text.len(),
238                start_utf16: utf16_offset,
239                content_bytes: 0,
240                content_utf16: 0,
241            });
242        }
243
244        self.line_metrics = metrics;
245        self.total_utf16 = utf16_offset;
246    }
247}
248
249#[derive(Debug, Clone)]
250struct LineMetrics {
251    start_byte: usize,
252    start_utf16: u32,
253    content_bytes: usize,
254    content_utf16: u32,
255}
256
257impl LineMetrics {
258    fn empty() -> Self {
259        Self {
260            start_byte: 0,
261            start_utf16: 0,
262            content_bytes: 0,
263            content_utf16: 0,
264        }
265    }
266}
267
268fn convert_range(range: &PluginRange) -> LspRange {
269    LspRange {
270        start: convert_position(&range.start),
271        end: convert_position(&range.end),
272    }
273}
274
275fn convert_position(position: &PluginPosition) -> LspPosition {
276    LspPosition {
277        line: position.line,
278        character: position.character,
279    }
280}
281
282pub struct OpenDocumentSnapshot {
283    pub uri: String,
284    pub text: String,
285    pub version: Option<i32>,
286    pub language_id: Option<String>,
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::types::{
293        Position as PluginPosition, Range as PluginRange, TextDocumentContentChangeEvent,
294    };
295    use lsp_types::{Position as LspPosition, Range as LspRange};
296    use std::str::FromStr;
297
298    fn sample_uri() -> Uri {
299        Uri::from_str("file:///workspace/main.ts").expect("valid URI")
300    }
301
302    #[test]
303    fn span_for_range_accounts_for_previous_lines() {
304        let mut store = DocumentStore::default();
305        let uri = sample_uri();
306        store.open(&uri, "ab\ncd", Some(1), Some("typescript".into()));
307
308        let range = LspRange {
309            start: LspPosition {
310                line: 1,
311                character: 0,
312            },
313            end: LspPosition {
314                line: 1,
315                character: 1,
316            },
317        };
318        let span = store
319            .span_for_range(&uri, &range)
320            .expect("document should be open");
321        assert_eq!(span.start, 3, "start must include prior line and newline");
322        assert_eq!(span.length, 1);
323    }
324
325    #[test]
326    fn apply_changes_updates_snapshot_and_offsets() {
327        let mut store = DocumentStore::default();
328        let uri = sample_uri();
329        store.open(&uri, "const value = 1;", Some(1), Some("typescript".into()));
330
331        let change = TextDocumentContentChangeEvent {
332            range: Some(PluginRange {
333                start: PluginPosition {
334                    line: 0,
335                    character: 6,
336                },
337                end: PluginPosition {
338                    line: 0,
339                    character: 11,
340                },
341            }),
342            text: "answer".into(),
343        };
344        store.apply_changes(&uri, &[change], Some(2));
345
346        let snapshot = store
347            .open_documents()
348            .into_iter()
349            .find(|doc| doc.uri == uri.to_string())
350            .expect("snapshot present");
351        assert_eq!(snapshot.text, "const answer = 1;");
352        assert_eq!(snapshot.version, Some(2));
353
354        let highlight_range = LspRange {
355            start: LspPosition {
356                line: 0,
357                character: 6,
358            },
359            end: LspPosition {
360                line: 0,
361                character: 12,
362            },
363        };
364        let span = store
365            .span_for_range(&uri, &highlight_range)
366            .expect("span available after edit");
367        assert_eq!(span.start, 6);
368        assert_eq!(span.length, 6);
369    }
370
371    #[test]
372    fn closing_document_drops_snapshot() {
373        let mut store = DocumentStore::default();
374        let uri = sample_uri();
375        store.open(&uri, "let a = 1;\n", Some(1), Some("typescript".into()));
376        assert!(store.is_open(&uri));
377
378        store.close(&uri);
379        assert!(!store.is_open(&uri));
380        let range = LspRange {
381            start: LspPosition {
382                line: 0,
383                character: 0,
384            },
385            end: LspPosition {
386                line: 0,
387                character: 1,
388            },
389        };
390        assert!(
391            store.span_for_range(&uri, &range).is_none(),
392            "span lookups should fail after close"
393        );
394        assert!(
395            store.open_documents().is_empty(),
396            "close removes snapshot entirely"
397        );
398    }
399}