1use 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#[derive(Default)]
20pub struct DocumentStore {
21 docs: HashMap<String, DocumentState>,
22}
23
24impl DocumentStore {
25 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 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 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 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#[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}