Skip to main content

codebook_lsp/
file_cache.rs

1use std::{collections::HashMap, sync::RwLock};
2
3use log::debug;
4use tower_lsp::lsp_types::{TextDocumentItem, Url};
5
6#[derive(Debug, Clone)]
7pub struct TextDocumentCacheItem {
8    pub text: String,
9    pub uri: Url,
10    pub version: Option<i32>,
11    pub language_id: Option<String>,
12}
13
14impl TextDocumentCacheItem {
15    pub fn new(
16        uri: &Url,
17        version: Option<i32>,
18        language_id: Option<&str>,
19        text: Option<&str>,
20    ) -> Self {
21        Self {
22            uri: uri.clone(),
23            version,
24            language_id: language_id.map(|id| id.to_string()),
25            text: match text {
26                Some(text) => text.to_string(),
27                None => String::new(),
28            },
29        }
30    }
31}
32
33/// Cache of the client's open documents, mirroring the didOpen/didClose
34/// lifecycle: didOpen is the only way in, didClose the only way out. That
35/// makes the size exactly the client's open-document count — bounded by the
36/// protocol, with no eviction that could orphan a still-open document.
37#[derive(Debug, Default)]
38pub struct TextDocumentCache {
39    documents: RwLock<HashMap<String, TextDocumentCacheItem>>,
40}
41
42impl TextDocumentCache {
43    pub fn get(&self, uri: &str) -> Option<TextDocumentCacheItem> {
44        self.documents.read().unwrap().get(uri).cloned()
45    }
46
47    pub fn insert(&self, document: &TextDocumentItem) {
48        let document = TextDocumentCacheItem::new(
49            &document.uri,
50            Some(document.version),
51            Some(&document.language_id),
52            Some(&document.text),
53        );
54        self.documents
55            .write()
56            .unwrap()
57            .insert(document.uri.to_string(), document);
58    }
59
60    /// Update an open document's text. `version` replaces the stored version
61    /// when Some (didChange); None keeps the existing one (didSave, which
62    /// carries no version). Updates for documents the client never opened are
63    /// dropped — inserting here would create entries no didClose removes.
64    pub fn update(&self, uri: &Url, text: &str, version: Option<i32>) {
65        let key = uri.to_string();
66        let mut cache = self.documents.write().unwrap();
67        match cache.get_mut(&key) {
68            Some(item) => {
69                item.text = text.to_string();
70                if version.is_some() {
71                    item.version = version;
72                }
73            }
74            None => {
75                debug!("Ignoring update for document that was never opened: {uri}");
76            }
77        }
78    }
79
80    pub fn remove(&self, uri: &Url) {
81        self.documents.write().unwrap().remove(uri.as_str());
82    }
83
84    pub fn cached_urls(&self) -> Vec<Url> {
85        self.documents
86            .read()
87            .unwrap()
88            .values()
89            .map(|v| v.uri.clone())
90            .collect()
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn doc(uri: &Url) -> TextDocumentItem {
99        TextDocumentItem {
100            uri: uri.clone(),
101            language_id: "rust".to_string(),
102            version: 1,
103            text: "hello".to_string(),
104        }
105    }
106
107    #[test]
108    fn test_lifecycle_bounds_cache() {
109        let cache = TextDocumentCache::default();
110        let uri = Url::parse("file:///a.rs").unwrap();
111
112        cache.insert(&doc(&uri));
113        assert_eq!(cache.cached_urls().len(), 1);
114
115        cache.remove(&uri);
116        assert!(cache.cached_urls().is_empty());
117        assert!(cache.get(uri.as_str()).is_none());
118    }
119
120    #[test]
121    fn test_update_changes_text_and_version() {
122        let cache = TextDocumentCache::default();
123        let uri = Url::parse("file:///a.rs").unwrap();
124        cache.insert(&doc(&uri));
125
126        cache.update(&uri, "changed", Some(2));
127        let item = cache.get(uri.as_str()).unwrap();
128        assert_eq!(item.text, "changed");
129        assert_eq!(item.version, Some(2));
130
131        // didSave carries no version; the stored one must survive
132        cache.update(&uri, "saved", None);
133        let item = cache.get(uri.as_str()).unwrap();
134        assert_eq!(item.text, "saved");
135        assert_eq!(item.version, Some(2));
136    }
137
138    #[test]
139    fn test_update_for_unopened_document_is_dropped() {
140        let cache = TextDocumentCache::default();
141        let uri = Url::parse("file:///never-opened.rs").unwrap();
142
143        cache.update(&uri, "text", Some(1));
144
145        // No entry may be created outside didOpen — nothing would remove it
146        assert!(cache.get(uri.as_str()).is_none());
147        assert!(cache.cached_urls().is_empty());
148    }
149}