Skip to main content

bynk_lsp/
position.rs

1//! Byte-offset ↔ LSP position conversion.
2//!
3//! Bynk source spans are byte offsets into the UTF-8 source. LSP positions
4//! use UTF-16 code units (per the protocol's default position encoding).
5//! For ASCII-only Bynk sources the two agree, but we go through code points
6//! to handle multi-byte characters correctly in identifiers and strings.
7
8use bynk_syntax::span::{LineIndex, Span};
9use tower_lsp::lsp_types::{Position, Range};
10
11/// A source snapshot paired with its precomputed [`LineIndex`], so a request
12/// that maps many spans (semantic tokens, folding ranges, diagnostics, inlay
13/// hints, document symbols) pays the O(n) line-scan **once** and then converts
14/// each offset in O(log n) — closing the O(n²) blow-up of #732.
15///
16/// Positions match [`offset_to_position`] exactly (0-based line, UTF-16-code-unit
17/// columns), so callers can swap a free-function call for a `PositionMap` method
18/// without changing observable output.
19pub struct PositionMap<'a> {
20    source: &'a str,
21    index: LineIndex,
22}
23
24impl<'a> PositionMap<'a> {
25    /// Build the index for `source` in one pass. Reuse the returned map for
26    /// every conversion over this snapshot.
27    pub fn new(source: &'a str) -> Self {
28        Self {
29            source,
30            index: LineIndex::new(source),
31        }
32    }
33
34    /// LSP position of a byte offset — the indexed equivalent of
35    /// [`offset_to_position`].
36    pub fn position(&self, offset: usize) -> Position {
37        let (line, character) = self.index.utf16_line_col(self.source, offset);
38        Position { line, character }
39    }
40
41    /// LSP range of a span — the indexed equivalent of [`span_to_range`].
42    pub fn range(&self, span: Span) -> Range {
43        Range {
44            start: self.position(span.start),
45            end: self.position(span.end),
46        }
47    }
48
49    /// Position one past the end of the source (whole-document edits).
50    pub fn end(&self) -> Position {
51        self.position(self.source.len())
52    }
53}
54
55/// Convert a byte offset into the source string into an LSP position.
56pub fn offset_to_position(source: &str, offset: usize) -> Position {
57    let mut line: u32 = 0;
58    let mut column: u32 = 0;
59    let bytes = source.as_bytes();
60    let limit = offset.min(bytes.len());
61    let mut i = 0;
62    while i < limit {
63        let b = bytes[i];
64        if b == b'\n' {
65            line += 1;
66            column = 0;
67            i += 1;
68            continue;
69        }
70        // Move to next UTF-8 code point boundary.
71        let cp_len = utf8_char_len(b);
72        // LSP default encoding is UTF-16; count UTF-16 code units.
73        // For ASCII (1 byte) and 2/3-byte UTF-8 (1 code unit) we increment
74        // column by 1; for 4-byte UTF-8 (supplementary plane) it's 2 code
75        // units.
76        column += if cp_len == 4 { 2 } else { 1 };
77        i += cp_len;
78    }
79    Position {
80        line,
81        character: column,
82    }
83}
84
85/// Convert an LSP position into a byte offset. Returns None if the position
86/// is past the end of the source.
87pub fn position_to_offset(source: &str, position: Position) -> Option<usize> {
88    let target_line = position.line;
89    let target_char = position.character;
90    let mut line: u32 = 0;
91    let mut character: u32 = 0;
92    let bytes = source.as_bytes();
93    let mut i = 0;
94    while i < bytes.len() {
95        if line == target_line && character == target_char {
96            return Some(i);
97        }
98        let b = bytes[i];
99        if b == b'\n' {
100            if line == target_line {
101                // Position is past end of this line; clamp to line end.
102                return Some(i);
103            }
104            line += 1;
105            character = 0;
106            i += 1;
107            continue;
108        }
109        let cp_len = utf8_char_len(b);
110        character += if cp_len == 4 { 2 } else { 1 };
111        i += cp_len;
112    }
113    if line == target_line && character >= target_char {
114        Some(i)
115    } else {
116        None
117    }
118}
119
120fn utf8_char_len(first: u8) -> usize {
121    if first < 0x80 {
122        1
123    } else if first < 0xC0 {
124        // Continuation byte; should not be the first byte of a char.
125        1
126    } else if first < 0xE0 {
127        2
128    } else if first < 0xF0 {
129        3
130    } else {
131        4
132    }
133}
134
135/// Convert a compiler [`Span`] into an LSP [`Range`].
136pub fn span_to_range(source: &str, span: Span) -> Range {
137    Range {
138        start: offset_to_position(source, span.start),
139        end: offset_to_position(source, span.end),
140    }
141}
142
143/// The position one past the end of the source — used for "replace whole
144/// document" formatting edits.
145pub fn end_position(source: &str) -> Position {
146    offset_to_position(source, source.len())
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn ascii_offsets_match_columns() {
155        let src = "abc\ndef";
156        assert_eq!(offset_to_position(src, 0), Position::new(0, 0));
157        assert_eq!(offset_to_position(src, 2), Position::new(0, 2));
158        assert_eq!(offset_to_position(src, 4), Position::new(1, 0));
159        assert_eq!(offset_to_position(src, 6), Position::new(1, 2));
160    }
161
162    #[test]
163    fn position_round_trip() {
164        let src = "alpha\n  beta\ngamma";
165        let p = Position::new(1, 4);
166        let off = position_to_offset(src, p).unwrap();
167        assert_eq!(offset_to_position(src, off), p);
168    }
169
170    /// LSP characters are UTF-16 code units, not bytes: a 2-byte `é` and a
171    /// 3-byte `€` each count 1, a 4-byte `🦀` counts 2. Cursor positions on
172    /// lines with non-ASCII text before them must land on char boundaries.
173    #[test]
174    fn non_ascii_offsets_count_utf16_units() {
175        // "-- café\nlet x" — é is 2 bytes / 1 UTF-16 unit.
176        let src = "-- café\nlet x";
177        // After the é: 7 UTF-16 units into line 0, 8 bytes into the source.
178        assert_eq!(position_to_offset(src, Position::new(0, 7)), Some(8));
179        assert_eq!(offset_to_position(src, 8), Position::new(0, 7));
180        // Next line is unaffected.
181        assert_eq!(
182            position_to_offset(src, Position::new(1, 3)),
183            Some(src.find("let").unwrap() + 3)
184        );
185
186        // 4-byte astral char: 2 UTF-16 units.
187        let crab = "🦀ab";
188        assert_eq!(position_to_offset(crab, Position::new(0, 2)), Some(4));
189        assert_eq!(position_to_offset(crab, Position::new(0, 3)), Some(5));
190        assert_eq!(offset_to_position(crab, 4), Position::new(0, 2));
191    }
192
193    /// Every offset the converter returns is a char boundary — slicing the
194    /// source at it can never panic.
195    #[test]
196    fn non_ascii_round_trips_on_char_boundaries() {
197        let src = "π = 3\n-- naïve café €10 🦀\nend";
198        for line in 0..3u32 {
199            for character in 0..24u32 {
200                if let Some(off) = position_to_offset(src, Position::new(line, character)) {
201                    assert!(
202                        src.is_char_boundary(off),
203                        "offset {off} for ({line},{character}) splits a codepoint"
204                    );
205                    let _ = &src[..off];
206                }
207            }
208        }
209    }
210}