Skip to main content

lsp_max/primitives/
document_store.rs

1use lsp_types_max::{TextDocumentContentChangeEvent, Uri as Url};
2use parking_lot::RwLock;
3use rustc_hash::FxHashMap;
4use std::sync::Arc;
5
6/// A single open document tracked by the server.
7#[derive(Debug)]
8pub struct VersionedDocument {
9    /// UTF-8 text content, kept in sync with all `didChange` events.
10    pub content: String,
11    /// LSP document version counter, incremented by the client on every edit.
12    pub version: i32,
13    /// Number of times this document has been updated since it was opened.
14    ///
15    /// Used by adaptive debounce to estimate activation density ρ_act: the
16    /// higher this value, the longer the debounce quiet window should be.
17    pub activations: u64,
18}
19
20/// Versioned concurrent document store — replaces `RwLock<HashMap<Url, String>>`
21/// in every LSP backend.
22///
23/// Clone is O(1): all clones share the same inner map via `Arc`.
24#[derive(Clone, Debug, Default)]
25pub struct DocumentStore {
26    inner: Arc<RwLock<FxHashMap<Url, VersionedDocument>>>,
27}
28
29impl DocumentStore {
30    /// Creates an empty `DocumentStore`.
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// Registers an opened document with its initial content and version.
36    pub fn open(&self, uri: Url, content: String, version: i32) {
37        self.inner.write().insert(
38            uri,
39            VersionedDocument {
40                content,
41                version,
42                activations: 0,
43            },
44        );
45    }
46
47    /// Apply a batch of content-change events, then stamp the new version.
48    pub fn update(&self, uri: &Url, changes: Vec<TextDocumentContentChangeEvent>, version: i32) {
49        let mut map = self.inner.write();
50        if let Some(doc) = map.get_mut(uri) {
51            for change in changes {
52                if change.range.is_none() {
53                    doc.content = change.text;
54                } else {
55                    doc.content = apply_incremental(&doc.content, &change);
56                }
57            }
58            doc.version = version;
59            doc.activations = doc.activations.saturating_add(1);
60        }
61    }
62
63    /// Removes a document from the store (called on `textDocument/didClose`).
64    pub fn close(&self, uri: &Url) {
65        self.inner.write().remove(uri);
66    }
67
68    /// Returns a clone of the document's current content, or `None` if not open.
69    pub fn get_content(&self, uri: &Url) -> Option<String> {
70        self.inner.read().get(uri).map(|d| d.content.clone())
71    }
72
73    /// Returns the current version counter for the document.
74    pub fn version(&self, uri: &Url) -> Option<i32> {
75        self.inner.read().get(uri).map(|d| d.version)
76    }
77
78    /// Applies `f` to the document without cloning the content string.
79    pub fn with<F, T>(&self, uri: &Url, f: F) -> Option<T>
80    where
81        F: FnOnce(&VersionedDocument) -> T,
82    {
83        self.inner.read().get(uri).map(f)
84    }
85
86    /// Returns `true` if the URI is currently open in the store.
87    pub fn is_open(&self, uri: &Url) -> bool {
88        self.inner.read().contains_key(uri)
89    }
90
91    /// Returns a FNV-1a hash of the document content, or `None` if not open.
92    ///
93    /// Callers can use this to skip re-analysis when content hasn't changed,
94    /// without cloning the full String.
95    pub fn content_hash(&self, uri: &Url) -> Option<u64> {
96        self.inner
97            .read()
98            .get(uri)
99            .map(|d| fnv1a_64(d.content.as_bytes()))
100    }
101
102    /// Returns the number of times this document has been updated since open.
103    ///
104    /// Used to scale debounce delay: high-activation documents (many edits)
105    /// benefit from a longer quiet window before re-analysis fires.
106    pub fn activation_count(&self, uri: &Url) -> u64 {
107        self.inner
108            .read()
109            .get(uri)
110            .map(|d| d.activations)
111            .unwrap_or(0)
112    }
113}
114
115/// FNV-1a 64-bit hash — deterministic, non-cryptographic, allocation-free.
116///
117/// The ring structure: each byte is XOR'd into the accumulator then multiplied
118/// by the FNV prime. The result is an element of ℤ/2^64ℤ used as a content
119/// address; collision probability over realistic diagnostic sets is negligible.
120#[inline]
121pub(crate) fn fnv1a_64(bytes: &[u8]) -> u64 {
122    const OFFSET: u64 = 0xcbf29ce484222325;
123    const PRIME: u64 = 0x100000001b3;
124    bytes
125        .iter()
126        .fold(OFFSET, |h, &b| (h ^ b as u64).wrapping_mul(PRIME))
127}
128
129/// Apply one incremental change event to the document content.
130///
131/// LSP line/character offsets are UTF-16 code units.  We convert to byte
132/// positions before splicing so that multi-byte characters (emoji, CJK, etc.)
133/// are handled correctly.
134fn apply_incremental(content: &str, change: &TextDocumentContentChangeEvent) -> String {
135    let Some(range) = change.range else {
136        return change.text.clone();
137    };
138
139    let start = lsp_pos_to_byte(
140        content,
141        range.start.line as usize,
142        range.start.character as usize,
143    );
144    let end = lsp_pos_to_byte(
145        content,
146        range.end.line as usize,
147        range.end.character as usize,
148    );
149
150    let mut out = String::with_capacity(content.len() + change.text.len());
151    out.push_str(&content[..start]);
152    out.push_str(&change.text);
153    out.push_str(&content[end..]);
154    out
155}
156
157/// Convert a `(line, utf16_col)` LSP position to a UTF-8 byte offset.
158fn lsp_pos_to_byte(content: &str, line: usize, character: usize) -> usize {
159    // Walk to the start of `line`.
160    let mut byte = 0;
161    let mut current_line = 0;
162    for ch in content.chars() {
163        if current_line == line {
164            break;
165        }
166        byte += ch.len_utf8();
167        if ch == '\n' {
168            current_line += 1;
169        }
170    }
171    // Advance `character` UTF-16 code units from the line start.
172    let mut utf16 = 0;
173    for ch in content[byte..].chars() {
174        if utf16 >= character {
175            break;
176        }
177        byte += ch.len_utf8();
178        utf16 += ch.len_utf16();
179    }
180    byte.min(content.len())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use lsp_types_max::Uri as Url;
187
188    fn test_url() -> Url {
189        "file:///tmp/test.rs".parse().unwrap()
190    }
191
192    fn whole_change(text: &str) -> TextDocumentContentChangeEvent {
193        TextDocumentContentChangeEvent {
194            range: None,
195            range_length: None,
196            text: text.to_string(),
197        }
198    }
199
200    #[test]
201    fn fnv1a_64_empty_returns_offset_basis() {
202        assert_eq!(fnv1a_64(b""), 0xcbf29ce484222325u64);
203    }
204
205    #[test]
206    fn fnv1a_64_deterministic() {
207        assert_eq!(fnv1a_64(b"hello"), fnv1a_64(b"hello"));
208    }
209
210    #[test]
211    fn fnv1a_64_distinct_inputs_produce_different_hashes() {
212        assert_ne!(fnv1a_64(b"hello"), fnv1a_64(b"world"));
213    }
214
215    #[test]
216    fn open_and_get_content_round_trips() {
217        let store = DocumentStore::new();
218        let uri = test_url();
219        store.open(uri.clone(), "hello".to_string(), 1);
220        assert_eq!(store.get_content(&uri).as_deref(), Some("hello"));
221    }
222
223    #[test]
224    fn version_tracks_open_version() {
225        let store = DocumentStore::new();
226        let uri = test_url();
227        store.open(uri.clone(), String::new(), 7);
228        assert_eq!(store.version(&uri), Some(7));
229    }
230
231    #[test]
232    fn update_whole_document_replaces_content_and_version() {
233        let store = DocumentStore::new();
234        let uri = test_url();
235        store.open(uri.clone(), "old".to_string(), 1);
236        store.update(&uri, vec![whole_change("new")], 2);
237        assert_eq!(store.get_content(&uri).as_deref(), Some("new"));
238        assert_eq!(store.version(&uri), Some(2));
239    }
240
241    #[test]
242    fn close_removes_document() {
243        let store = DocumentStore::new();
244        let uri = test_url();
245        store.open(uri.clone(), "x".to_string(), 1);
246        store.close(&uri);
247        assert_eq!(store.get_content(&uri), None);
248        assert!(!store.is_open(&uri));
249    }
250
251    #[test]
252    fn is_open_false_for_never_opened_uri() {
253        let store = DocumentStore::new();
254        assert!(!store.is_open(&test_url()));
255    }
256
257    #[test]
258    fn activation_count_increments_on_each_update() {
259        let store = DocumentStore::new();
260        let uri = test_url();
261        store.open(uri.clone(), "v1".to_string(), 1);
262        assert_eq!(store.activation_count(&uri), 0);
263        store.update(&uri, vec![whole_change("v2")], 2);
264        assert_eq!(store.activation_count(&uri), 1);
265        store.update(&uri, vec![whole_change("v3")], 3);
266        assert_eq!(store.activation_count(&uri), 2);
267    }
268
269    #[test]
270    fn content_hash_changes_when_content_changes() {
271        let store = DocumentStore::new();
272        let uri = test_url();
273        store.open(uri.clone(), "a".to_string(), 1);
274        let h1 = store.content_hash(&uri).unwrap();
275        store.update(&uri, vec![whole_change("b")], 2);
276        let h2 = store.content_hash(&uri).unwrap();
277        assert_ne!(h1, h2);
278    }
279
280    #[test]
281    fn with_applies_function_without_cloning_content() {
282        let store = DocumentStore::new();
283        let uri = test_url();
284        store.open(uri.clone(), "probe".to_string(), 42);
285        let version = store.with(&uri, |doc| doc.version).unwrap();
286        assert_eq!(version, 42);
287    }
288
289    #[test]
290    fn clone_shares_inner_store() {
291        let store = DocumentStore::new();
292        let clone = store.clone();
293        let uri = test_url();
294        store.open(uri.clone(), "shared".to_string(), 1);
295        assert_eq!(clone.get_content(&uri).as_deref(), Some("shared"));
296    }
297}