Skip to main content

just_lsp/
rope_ext.rs

1//! Extensions that bridge `ropey::Rope` with Language Server Protocol positions
2//! and tree-sitter edit bookkeeping.
3//!
4//! The [`RopeExt`] trait is used inside `just-lsp` to keep three different
5//! coordinate spaces (bytes, UTF-16 code units, and tree-sitter points) in sync
6//! whenever an editor sends a `textDocument/didChange` notification.
7//!
8//! ```
9//! use {
10//!   just_lsp::RopeExt,
11//!   ropey::Rope,
12//!   tower_lsp::lsp_types::{Position, Range, TextDocumentContentChangeEvent}
13//! };
14//!
15//! let mut rope = Rope::from_str("hello world");
16//!
17//! let change = TextDocumentContentChangeEvent {
18//!   range: Some(Range {
19//!     start: Position::new(0, 6),
20//!     end: Position::new(0, 11),
21//!   }),
22//!   range_length: None,
23//!   text: "rope".into(),
24//! };
25//!
26//! let edit = rope.build_edit(&change);
27//! rope.apply_edit(&edit);
28//!
29//! assert_eq!(rope.to_string(), "hello rope");
30//! ```
31
32use super::*;
33
34#[derive(Clone, Debug, PartialEq)]
35pub struct Position {
36  pub byte: usize,
37  pub char: usize,
38  pub point: Point,
39}
40
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct Edit<'a> {
43  pub end_char: usize,
44  pub input_edit: InputEdit,
45  pub start_char: usize,
46  pub text: &'a str,
47}
48
49pub trait RopeExt {
50  fn apply_edit(&mut self, edit: &Edit);
51  fn build_edit<'a>(
52    &self,
53    change: &'a lsp::TextDocumentContentChangeEvent,
54  ) -> Edit<'a>;
55  fn byte_to_lsp_position(&self, byte: usize) -> lsp::Position;
56  fn lsp_position_to_position(&self, position: lsp::Position) -> Position;
57}
58
59impl RopeExt for Rope {
60  /// Applies a previously constructed [`Edit`] to the rope, keeping both
61  /// the textual contents and the internal tree-sitter offsets in sync.
62  fn apply_edit(&mut self, edit: &Edit) {
63    self.remove(edit.start_char..edit.end_char);
64
65    if !edit.text.is_empty() {
66      self.insert(edit.start_char, edit.text);
67    }
68  }
69
70  /// Converts an LSP `textDocument/didChange` event into a [`Edit`] that
71  /// can be consumed both by `ropey` and tree-sitter.
72  fn build_edit<'a>(
73    &self,
74    change: &'a lsp::TextDocumentContentChangeEvent,
75  ) -> Edit<'a> {
76    let text = change.text.as_str();
77
78    let text_end_bytes = text.len();
79
80    let range = change.range.unwrap_or_else(|| lsp::Range {
81      start: self.byte_to_lsp_position(0),
82      end: self.byte_to_lsp_position(self.len_bytes()),
83    });
84
85    let (start, old_end) = (
86      self.lsp_position_to_position(range.start),
87      self.lsp_position_to_position(range.end),
88    );
89
90    let input_edit = InputEdit {
91      new_end_byte: start.byte + text_end_bytes,
92      new_end_position: start.point.advance(text.point_delta()),
93      old_end_byte: old_end.byte,
94      old_end_position: old_end.point,
95      start_byte: start.byte,
96      start_position: start.point,
97    };
98
99    Edit {
100      end_char: old_end.char,
101      input_edit,
102      start_char: start.char,
103      text,
104    }
105  }
106
107  /// Maps an absolute byte offset into an LSP line/character pair where the
108  /// column is expressed in UTF-16 code units as required by the spec.
109  fn byte_to_lsp_position(&self, byte: usize) -> lsp::Position {
110    let line = self.byte_to_line(byte);
111
112    let line_char = self.line_to_char(line);
113    let line_utf16_cu = self.char_to_utf16_cu(line_char);
114
115    let char = self.byte_to_char(byte);
116    let char_utf16_cu = self.char_to_utf16_cu(char);
117
118    lsp::Position::new(
119      u32::try_from(line).expect("line index exceeds u32::MAX"),
120      u32::try_from(char_utf16_cu - line_utf16_cu)
121        .expect("character offset exceeds u32::MAX"),
122    )
123  }
124
125  /// Converts an LSP position back into absolute byte/char offsets and a
126  /// tree-sitter point so downstream consumers can choose whichever coordinate
127  /// space they need.
128  fn lsp_position_to_position(&self, position: lsp::Position) -> Position {
129    let row = position.line as usize;
130
131    let row_char = self.line_to_char(row);
132    let row_byte = self.line_to_byte(row);
133
134    let col_char = self.utf16_cu_to_char(
135      self.char_to_utf16_cu(row_char) + position.character as usize,
136    );
137
138    let col_byte = self.char_to_byte(col_char);
139
140    Position {
141      byte: col_byte,
142      char: col_char,
143      point: Point::new(row, col_byte - row_byte),
144    }
145  }
146}
147
148#[cfg(test)]
149mod tests {
150  use {super::*, pretty_assertions::assert_eq, ropey::Rope};
151
152  fn change(
153    text: &str,
154    range: lsp::Range,
155  ) -> lsp::TextDocumentContentChangeEvent {
156    lsp::TextDocumentContentChangeEvent {
157      range: Some(range),
158      range_length: None,
159      text: text.into(),
160    }
161  }
162
163  #[test]
164  fn apply_insert_into_empty_document() {
165    let mut rope = Rope::from_str("");
166
167    let change = change("🧪\nnew", lsp::Range::at(0, 0, 0, 0));
168
169    let edit = rope.build_edit(&change);
170
171    assert_eq!(
172      edit,
173      Edit {
174        start_char: 0,
175        end_char: 0,
176        input_edit: InputEdit {
177          start_byte: 0,
178          old_end_byte: 0,
179          new_end_byte: "🧪\nnew".len(),
180          start_position: Point::new(0, 0),
181          old_end_position: Point::new(0, 0),
182          new_end_position: Point::new(1, 3),
183        },
184        text: "🧪\nnew",
185      }
186    );
187
188    rope.apply_edit(&edit);
189
190    assert_eq!(rope.to_string(), "🧪\nnew");
191  }
192
193  #[test]
194  fn apply_insert_edit_updates_rope_contents() {
195    let mut rope = Rope::from_str("hello world");
196
197    let change = change("rope", lsp::Range::at(0, 6, 0, 11));
198
199    let edit = rope.build_edit(&change);
200
201    assert_eq!(
202      edit,
203      Edit {
204        start_char: 6,
205        end_char: 11,
206        input_edit: InputEdit {
207          new_end_byte: 10,
208          new_end_position: Point::new(0, 10),
209          old_end_byte: 11,
210          old_end_position: Point::new(0, 11),
211          start_byte: 6,
212          start_position: Point::new(0, 6),
213        },
214        text: "rope",
215      }
216    );
217
218    rope.apply_edit(&edit);
219
220    assert_eq!(rope.to_string(), "hello rope");
221  }
222
223  #[test]
224  fn apply_insert_edit_respects_utf16_columns() {
225    let mut rope = Rope::from_str("ab");
226
227    let change = change("🧪", lsp::Range::at(0, 1, 0, 1));
228
229    let edit = rope.build_edit(&change);
230
231    assert_eq!(
232      edit,
233      Edit {
234        start_char: 1,
235        end_char: 1,
236        input_edit: InputEdit {
237          new_end_byte: 5,
238          new_end_position: Point::new(0, 5),
239          old_end_byte: 1,
240          old_end_position: Point::new(0, 1),
241          start_byte: 1,
242          start_position: Point::new(0, 1),
243        },
244        text: "🧪",
245      }
246    );
247
248    rope.apply_edit(&edit);
249
250    assert_eq!(rope.to_string(), "a🧪b");
251  }
252
253  #[test]
254  fn apply_delete_edit_respects_utf16_columns() {
255    let mut rope = Rope::from_str("a😊b");
256
257    let change = change("", lsp::Range::at(0, 1, 0, 3));
258
259    let edit = rope.build_edit(&change);
260
261    assert_eq!(
262      edit,
263      Edit {
264        start_char: 1,
265        end_char: 2,
266        input_edit: InputEdit {
267          new_end_byte: 1,
268          new_end_position: Point::new(0, 1),
269          old_end_byte: 5,
270          old_end_position: Point::new(0, 5),
271          start_byte: 1,
272          start_position: Point::new(0, 1),
273        },
274        text: "",
275      }
276    );
277
278    rope.apply_edit(&edit);
279
280    assert_eq!(rope.to_string(), "ab");
281  }
282
283  #[test]
284  fn lsp_round_trip_handles_utf16_columns() {
285    let rope = Rope::from_str("a😊b\nsecond");
286
287    let position = rope.byte_to_lsp_position(5);
288
289    assert_eq!(position, lsp::Position::new(0, 3));
290
291    assert_eq!(
292      rope.lsp_position_to_position(position),
293      Position {
294        byte: 5,
295        char: 2,
296        point: Point::new(0, 5),
297      }
298    );
299  }
300
301  #[test]
302  fn replacement_across_surrogates_is_consistent() {
303    let mut rope = Rope::from_str("foo😊bar");
304
305    let change = change("🧪", lsp::Range::at(0, 3, 0, 5));
306
307    let edit = rope.build_edit(&change);
308
309    assert_eq!(
310      edit,
311      Edit {
312        start_char: 3,
313        end_char: 4,
314        input_edit: InputEdit {
315          start_byte: 3,
316          old_end_byte: 7,
317          new_end_byte: 7,
318          start_position: Point::new(0, 3),
319          old_end_position: Point::new(0, 7),
320          new_end_position: Point::new(0, 7),
321        },
322        text: "🧪",
323      }
324    );
325
326    rope.apply_edit(&edit);
327
328    assert_eq!(rope.to_string(), "foo🧪bar");
329  }
330
331  #[test]
332  fn multiline_edit_handles_utf16_offsets() {
333    let mut rope = Rope::from_str("foo😊\nbar");
334
335    let change = change("XX", lsp::Range::at(0, 2, 1, 1));
336
337    let edit = rope.build_edit(&change);
338
339    assert_eq!(
340      edit,
341      Edit {
342        start_char: 2,
343        end_char: 6,
344        input_edit: InputEdit {
345          start_byte: 2,
346          old_end_byte: 9,
347          new_end_byte: 4,
348          start_position: Point::new(0, 2),
349          old_end_position: Point::new(1, 1),
350          new_end_position: Point::new(0, 4),
351        },
352        text: "XX",
353      }
354    );
355
356    rope.apply_edit(&edit);
357
358    assert_eq!(rope.to_string(), "foXXar");
359  }
360
361  #[test]
362  fn append_beyond_eof_updates_point() {
363    let mut rope = Rope::from_str("hi");
364
365    let change = change("🧪\nnew", lsp::Range::at(0, 2, 0, 2));
366
367    let edit = rope.build_edit(&change);
368
369    assert_eq!(
370      edit,
371      Edit {
372        start_char: 2,
373        end_char: 2,
374        input_edit: InputEdit {
375          start_byte: 2,
376          old_end_byte: 2,
377          new_end_byte: 10,
378          start_position: Point::new(0, 2),
379          old_end_position: Point::new(0, 2),
380          new_end_position: Point::new(1, 3),
381        },
382        text: "🧪\nnew",
383      }
384    );
385
386    rope.apply_edit(&edit);
387
388    assert_eq!(rope.to_string(), "hi🧪\nnew");
389  }
390
391  #[test]
392  fn replace_entire_document_via_full_range() {
393    let mut rope = Rope::from_str("foo😊bar");
394
395    let change = lsp::TextDocumentContentChangeEvent {
396      range: None,
397      range_length: None,
398      text: "🧪baz".into(),
399    };
400
401    let edit = rope.build_edit(&change);
402
403    assert_eq!(
404      edit,
405      Edit {
406        start_char: 0,
407        end_char: 7,
408        input_edit: InputEdit {
409          start_byte: 0,
410          old_end_byte: 10,
411          new_end_byte: 7,
412          start_position: Point::new(0, 0),
413          old_end_position: Point::new(0, 10),
414          new_end_position: Point::new(0, 7),
415        },
416        text: "🧪baz",
417      }
418    );
419
420    rope.apply_edit(&edit);
421
422    assert_eq!(rope.to_string(), "🧪baz");
423  }
424}