Skip to main content

lang_check/
document.rs

1use ropey::Rope;
2
3/// An open document backed by a rope data structure for efficient incremental edits.
4///
5/// This is the primary representation for documents being actively edited
6/// in an LSP context, where frequent small edits need to be applied efficiently.
7#[derive(Debug, Clone)]
8pub struct Document {
9    rope: Rope,
10    version: i32,
11    language_id: String,
12}
13
14impl Document {
15    /// Create a new document from the full text content.
16    #[must_use]
17    pub fn new(text: &str, language_id: String) -> Self {
18        Self {
19            rope: Rope::from_str(text),
20            version: 0,
21            language_id,
22        }
23    }
24
25    /// Apply an incremental edit to the document.
26    ///
27    /// `start_byte` and `end_byte` define the range to replace.
28    /// If they are equal, this is an insertion. If `new_text` is empty,
29    /// this is a deletion.
30    pub fn apply_edit(&mut self, start_byte: usize, end_byte: usize, new_text: &str) {
31        let start_char = self.rope.byte_to_char(start_byte);
32        let end_char = self.rope.byte_to_char(end_byte);
33        self.rope.remove(start_char..end_char);
34        if !new_text.is_empty() {
35            self.rope.insert(start_char, new_text);
36        }
37        self.version += 1;
38    }
39
40    /// Apply an edit using line/column (0-based) coordinates.
41    pub fn apply_edit_lc(
42        &mut self,
43        start_line: usize,
44        start_col: usize,
45        end_line: usize,
46        end_col: usize,
47        new_text: &str,
48    ) {
49        let start_char = self.rope.line_to_char(start_line) + start_col;
50        let end_char = self.rope.line_to_char(end_line) + end_col;
51        self.rope.remove(start_char..end_char);
52        if !new_text.is_empty() {
53            self.rope.insert(start_char, new_text);
54        }
55        self.version += 1;
56    }
57
58    /// Replace the entire content (full sync).
59    pub fn set_content(&mut self, text: &str) {
60        self.rope = Rope::from_str(text);
61        self.version += 1;
62    }
63
64    /// Get the full text content as a String.
65    #[must_use]
66    pub fn text(&self) -> String {
67        self.rope.to_string()
68    }
69
70    /// Get a slice of text by byte range.
71    #[must_use]
72    pub fn slice_bytes(&self, start: usize, end: usize) -> String {
73        let start_char = self.rope.byte_to_char(start);
74        let end_char = self.rope.byte_to_char(end);
75        self.rope.slice(start_char..end_char).to_string()
76    }
77
78    /// Get a specific line (0-indexed).
79    #[must_use]
80    pub fn line(&self, idx: usize) -> Option<String> {
81        if idx < self.rope.len_lines() {
82            Some(self.rope.line(idx).to_string())
83        } else {
84            None
85        }
86    }
87
88    /// Convert a byte offset to (line, column) (0-based).
89    #[must_use]
90    pub fn byte_to_line_col(&self, byte_offset: usize) -> (usize, usize) {
91        let char_idx = self.rope.byte_to_char(byte_offset);
92        let line = self.rope.char_to_line(char_idx);
93        let line_start = self.rope.line_to_char(line);
94        let col = char_idx - line_start;
95        (line, col)
96    }
97
98    /// Convert (line, column) (0-based) to a byte offset.
99    #[must_use]
100    pub fn line_col_to_byte(&self, line: usize, col: usize) -> usize {
101        let char_idx = self.rope.line_to_char(line) + col;
102        self.rope.char_to_byte(char_idx)
103    }
104
105    /// Number of lines in the document.
106    #[must_use]
107    pub fn line_count(&self) -> usize {
108        self.rope.len_lines()
109    }
110
111    /// Length in bytes.
112    #[must_use]
113    pub fn len_bytes(&self) -> usize {
114        self.rope.len_bytes()
115    }
116
117    /// Length in characters.
118    #[must_use]
119    pub fn len_chars(&self) -> usize {
120        self.rope.len_chars()
121    }
122
123    /// Whether the document is empty.
124    #[must_use]
125    pub fn is_empty(&self) -> bool {
126        self.rope.len_bytes() == 0
127    }
128
129    /// The document version (increments on each edit).
130    #[must_use]
131    pub const fn version(&self) -> i32 {
132        self.version
133    }
134
135    /// The document's language ID.
136    #[must_use]
137    pub fn language_id(&self) -> &str {
138        &self.language_id
139    }
140}
141
142/// Manages a set of open documents keyed by URI.
143#[derive(Debug, Default)]
144pub struct DocumentStore {
145    documents: std::collections::HashMap<String, Document>,
146}
147
148impl DocumentStore {
149    #[must_use]
150    pub fn new() -> Self {
151        Self::default()
152    }
153
154    /// Open (or replace) a document in the store.
155    pub fn open(&mut self, uri: String, text: &str, language_id: String) {
156        self.documents.insert(uri, Document::new(text, language_id));
157    }
158
159    /// Close a document, removing it from the store.
160    pub fn close(&mut self, uri: &str) -> Option<Document> {
161        self.documents.remove(uri)
162    }
163
164    /// Get an immutable reference to a document.
165    #[must_use]
166    pub fn get(&self, uri: &str) -> Option<&Document> {
167        self.documents.get(uri)
168    }
169
170    /// Get a mutable reference to a document (for applying edits).
171    pub fn get_mut(&mut self, uri: &str) -> Option<&mut Document> {
172        self.documents.get_mut(uri)
173    }
174
175    /// Number of open documents.
176    #[must_use]
177    pub fn len(&self) -> usize {
178        self.documents.len()
179    }
180
181    /// Whether the store is empty.
182    #[must_use]
183    pub fn is_empty(&self) -> bool {
184        self.documents.is_empty()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn new_document_has_correct_text() {
194        let doc = Document::new("Hello, world!", "markdown".to_string());
195        assert_eq!(doc.text(), "Hello, world!");
196        assert_eq!(doc.version(), 0);
197        assert_eq!(doc.language_id(), "markdown");
198    }
199
200    #[test]
201    fn apply_edit_insertion() {
202        let mut doc = Document::new("Hello world", "markdown".to_string());
203        // Replace " " with ", " between "Hello" and "world" (byte 5..6)
204        doc.apply_edit(5, 6, ", ");
205        assert_eq!(doc.text(), "Hello, world");
206        assert_eq!(doc.version(), 1);
207    }
208
209    #[test]
210    fn apply_edit_deletion() {
211        let mut doc = Document::new("Hello, world!", "markdown".to_string());
212        // Delete ", " (bytes 5..7)
213        doc.apply_edit(5, 7, "");
214        assert_eq!(doc.text(), "Helloworld!");
215        assert_eq!(doc.version(), 1);
216    }
217
218    #[test]
219    fn apply_edit_replacement() {
220        let mut doc = Document::new("Hello, world!", "markdown".to_string());
221        // Replace "world" with "Rust"
222        doc.apply_edit(7, 12, "Rust");
223        assert_eq!(doc.text(), "Hello, Rust!");
224        assert_eq!(doc.version(), 1);
225    }
226
227    #[test]
228    fn apply_edit_lc() {
229        let mut doc = Document::new("line one\nline two\nline three", "markdown".to_string());
230        // Replace "two" on line 1 (cols 5..8) with "TWO"
231        doc.apply_edit_lc(1, 5, 1, 8, "TWO");
232        assert_eq!(doc.text(), "line one\nline TWO\nline three");
233    }
234
235    #[test]
236    fn set_content_replaces_all() {
237        let mut doc = Document::new("old content", "markdown".to_string());
238        doc.set_content("new content");
239        assert_eq!(doc.text(), "new content");
240        assert_eq!(doc.version(), 1);
241    }
242
243    #[test]
244    fn slice_bytes() {
245        let doc = Document::new("Hello, world!", "markdown".to_string());
246        assert_eq!(doc.slice_bytes(7, 12), "world");
247    }
248
249    #[test]
250    fn line_access() {
251        let doc = Document::new("first\nsecond\nthird", "markdown".to_string());
252        assert_eq!(doc.line(0).unwrap(), "first\n");
253        assert_eq!(doc.line(1).unwrap(), "second\n");
254        assert_eq!(doc.line(2).unwrap(), "third");
255        assert!(doc.line(3).is_none());
256        assert_eq!(doc.line_count(), 3);
257    }
258
259    #[test]
260    fn byte_to_line_col_and_back() {
261        let doc = Document::new("abc\ndef\nghi", "markdown".to_string());
262        // 'd' is at byte 4, which is line 1, col 0
263        let (line, col) = doc.byte_to_line_col(4);
264        assert_eq!((line, col), (1, 0));
265        assert_eq!(doc.line_col_to_byte(1, 0), 4);
266    }
267
268    #[test]
269    fn unicode_handling() {
270        let mut doc = Document::new("Hëllo wörld", "markdown".to_string());
271        assert!(doc.len_bytes() > doc.len_chars());
272        // Replace "wörld" with "rust" — need byte positions
273        let text = doc.text();
274        let start = text.find("wörld").unwrap();
275        let end = start + "wörld".len();
276        doc.apply_edit(start, end, "rust");
277        assert_eq!(doc.text(), "Hëllo rust");
278    }
279
280    #[test]
281    fn document_store_operations() {
282        let mut store = DocumentStore::new();
283        assert!(store.is_empty());
284
285        store.open(
286            "file:///test.md".to_string(),
287            "Hello",
288            "markdown".to_string(),
289        );
290        assert_eq!(store.len(), 1);
291        assert!(!store.is_empty());
292
293        let doc = store.get("file:///test.md").unwrap();
294        assert_eq!(doc.text(), "Hello");
295
296        // Apply edit through mutable reference
297        let doc_mut = store.get_mut("file:///test.md").unwrap();
298        doc_mut.apply_edit(5, 5, ", world!");
299        assert_eq!(
300            store.get("file:///test.md").unwrap().text(),
301            "Hello, world!"
302        );
303
304        // Close document
305        let closed = store.close("file:///test.md");
306        assert!(closed.is_some());
307        assert!(store.is_empty());
308    }
309
310    #[test]
311    fn multiple_sequential_edits() {
312        let mut doc = Document::new("The quick brown fox", "markdown".to_string());
313        // Replace "quick" with "slow"
314        doc.apply_edit(4, 9, "slow");
315        assert_eq!(doc.text(), "The slow brown fox");
316        // Replace "brown" with "red"
317        doc.apply_edit(9, 14, "red");
318        assert_eq!(doc.text(), "The slow red fox");
319        assert_eq!(doc.version(), 2);
320    }
321
322    #[test]
323    fn is_empty() {
324        let doc = Document::new("", "markdown".to_string());
325        assert!(doc.is_empty());
326        let doc2 = Document::new("x", "markdown".to_string());
327        assert!(!doc2.is_empty());
328    }
329}