Skip to main content

logicaffeine_lsp/
line_index.rs

1use tower_lsp::lsp_types::Position;
2
3/// Maps between byte offsets and LSP `Position` (line, character).
4///
5/// LSP positions use zero-based line and UTF-16 code unit offsets.
6/// Our source strings use byte offsets. This struct pre-computes
7/// line start byte offsets for efficient bidirectional conversion.
8#[derive(Debug, Clone)]
9pub struct LineIndex {
10    /// Byte offset of each line start. `line_starts[0]` is always 0.
11    line_starts: Vec<usize>,
12    /// The full source text (needed for UTF-16 offset computation).
13    source: String,
14}
15
16impl LineIndex {
17    pub fn new(source: &str) -> Self {
18        let mut line_starts = vec![0];
19        for (i, b) in source.bytes().enumerate() {
20            if b == b'\n' {
21                line_starts.push(i + 1);
22            }
23        }
24        LineIndex {
25            line_starts,
26            source: source.to_string(),
27        }
28    }
29
30    /// Convert a byte offset to an LSP `Position`.
31    ///
32    /// Returns `(line, character)` where character is a UTF-16 code unit offset.
33    pub fn position(&self, byte_offset: usize) -> Position {
34        let byte_offset = byte_offset.min(self.source.len());
35
36        let line = self
37            .line_starts
38            .partition_point(|&start| start <= byte_offset)
39            .saturating_sub(1);
40
41        let line_start = self.line_starts[line];
42        let line_text = &self.source[line_start..byte_offset];
43        let character = line_text.encode_utf16().count() as u32;
44
45        Position {
46            line: line as u32,
47            character,
48        }
49    }
50
51    /// Return the byte offset of the start of `line` (0-indexed).
52    /// Returns `source.len()` if `line` is out of bounds.
53    pub fn line_start_offset(&self, line: usize) -> usize {
54        self.line_starts
55            .get(line)
56            .copied()
57            .unwrap_or(self.source.len())
58    }
59
60    /// Compute the UTF-16 length of a byte range in the source.
61    pub fn utf16_length(&self, byte_start: usize, byte_end: usize) -> u32 {
62        let start = byte_start.min(self.source.len());
63        let end = byte_end.min(self.source.len());
64        if start >= end {
65            return 0;
66        }
67        self.source[start..end].encode_utf16().count() as u32
68    }
69
70    /// Convert an LSP `Position` to a byte offset.
71    pub fn offset(&self, position: Position) -> usize {
72        let line = position.line as usize;
73        if line >= self.line_starts.len() {
74            return self.source.len();
75        }
76
77        let line_start = self.line_starts[line];
78        let line_end = self
79            .line_starts
80            .get(line + 1)
81            .copied()
82            .unwrap_or(self.source.len());
83
84        let line_text = &self.source[line_start..line_end];
85        let mut utf16_offset = 0u32;
86        let target = position.character;
87
88        for (byte_idx, ch) in line_text.char_indices() {
89            if utf16_offset >= target {
90                return line_start + byte_idx;
91            }
92            utf16_offset += ch.len_utf16() as u32;
93        }
94
95        line_end
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn single_line() {
105        let idx = LineIndex::new("hello world");
106        assert_eq!(idx.position(0), Position { line: 0, character: 0 });
107        assert_eq!(idx.position(5), Position { line: 0, character: 5 });
108        assert_eq!(idx.position(11), Position { line: 0, character: 11 });
109    }
110
111    #[test]
112    fn multi_line() {
113        let idx = LineIndex::new("abc\ndef\nghi");
114        assert_eq!(idx.position(0), Position { line: 0, character: 0 });
115        assert_eq!(idx.position(3), Position { line: 0, character: 3 });
116        assert_eq!(idx.position(4), Position { line: 1, character: 0 });
117        assert_eq!(idx.position(7), Position { line: 1, character: 3 });
118        assert_eq!(idx.position(8), Position { line: 2, character: 0 });
119    }
120
121    #[test]
122    fn roundtrip() {
123        let src = "Let x be 5.\nSet x to 10.\nShow x.\n";
124        let idx = LineIndex::new(src);
125        for offset in 0..src.len() {
126            let pos = idx.position(offset);
127            let back = idx.offset(pos);
128            assert_eq!(back, offset, "roundtrip failed at offset {offset}");
129        }
130    }
131
132    #[test]
133    fn offset_from_position() {
134        let idx = LineIndex::new("abc\ndef\nghi");
135        assert_eq!(idx.offset(Position { line: 0, character: 0 }), 0);
136        assert_eq!(idx.offset(Position { line: 1, character: 0 }), 4);
137        assert_eq!(idx.offset(Position { line: 2, character: 2 }), 10);
138    }
139
140    #[test]
141    fn empty_source() {
142        let idx = LineIndex::new("");
143        assert_eq!(idx.position(0), Position { line: 0, character: 0 });
144        assert_eq!(idx.offset(Position { line: 0, character: 0 }), 0);
145    }
146
147    #[test]
148    fn out_of_bounds_offset() {
149        let idx = LineIndex::new("abc");
150        let pos = idx.position(100);
151        assert_eq!(pos, Position { line: 0, character: 3 });
152    }
153
154    #[test]
155    fn out_of_bounds_position() {
156        let idx = LineIndex::new("abc");
157        let offset = idx.offset(Position { line: 5, character: 0 });
158        assert_eq!(offset, 3);
159    }
160
161    #[test]
162    fn line_start_offset_returns_correct_values() {
163        let idx = LineIndex::new("abc\ndef\nghi");
164        assert_eq!(idx.line_start_offset(0), 0);
165        assert_eq!(idx.line_start_offset(1), 4);
166        assert_eq!(idx.line_start_offset(2), 8);
167    }
168
169    #[test]
170    fn line_start_offset_out_of_bounds() {
171        let idx = LineIndex::new("abc\ndef");
172        // Out of bounds should return source length
173        assert_eq!(idx.line_start_offset(99), 7);
174    }
175
176    #[test]
177    fn windows_line_endings() {
178        let src = "abc\r\ndef\r\nghi";
179        let idx = LineIndex::new(src);
180        // \r\n: the \n is at byte 4, so line 1 starts at byte 5
181        let pos = idx.position(5);
182        assert_eq!(pos, Position { line: 1, character: 0 });
183        let back = idx.offset(pos);
184        assert_eq!(back, 5);
185    }
186
187    #[test]
188    fn multibyte_utf8_roundtrip() {
189        // 'é' is 2 bytes in UTF-8 but 1 UTF-16 code unit
190        let src = "café\nworld";
191        let idx = LineIndex::new(src);
192        // 'c'=0, 'a'=1, 'f'=2, 'é'=3..4, '\n'=5, 'w'=6
193        let pos_e_accent = idx.position(3);
194        assert_eq!(pos_e_accent.line, 0);
195        assert_eq!(pos_e_accent.character, 3, "UTF-16 offset of 'é' should be 3");
196        let pos_world = idx.position(6);
197        assert_eq!(pos_world, Position { line: 1, character: 0 });
198        let back = idx.offset(pos_world);
199        assert_eq!(back, 6);
200    }
201}