Skip to main content

devscout_rs/
offsets.rs

1// UTF-8 byte offset -> UTF-16 code-unit offset translation (compatibility
2// contract §5.5, the port design notes).
3//
4// web-tree-sitter (WASM, what the Node implementation loads) parses a JS string
5// and reports spans in UTF-16 code units. Native tree-sitter parses a UTF-8 byte
6// buffer and reports spans in UTF-8 bytes. Rows always agree between the two and
7// CRLF is harmless either way -- only byte offsets and byte-relative columns
8// diverge, and only across multi-byte characters:
9//   - a 2-byte UTF-8 char is 1 UTF-16 unit               -> divergence -1
10//   - a 3-byte UTF-8 char (incl. an unstripped BOM) is 1  -> divergence -2
11//   - a 4-byte UTF-8 char (astral, UTF-16 surrogate pair) -> divergence -2
12
13/// Maps UTF-8 byte offsets in one source file to UTF-16 code-unit offsets.
14pub struct OffsetTable {
15    /// (byte offset immediately after a multi-byte char, cumulative
16    /// UTF-8-bytes-minus-UTF-16-units divergence up to and including that
17    /// char). Sorted ascending by construction (`char_indices` is
18    /// monotonic); empty for ASCII-only source, where byte offset always
19    /// equals UTF-16 offset.
20    breakpoints: Vec<(usize, i64)>,
21}
22
23impl OffsetTable {
24    /// Builds a lookup table for the byte boundaries in `src`.
25    pub fn build(src: &str) -> Self {
26        let mut breakpoints = Vec::new();
27        let mut divergence: i64 = 0;
28        for (byte_start, ch) in src.char_indices() {
29            let utf8_len = ch.len_utf8() as i64;
30            let utf16_len = ch.len_utf16() as i64;
31            if utf8_len != utf16_len {
32                divergence += utf8_len - utf16_len;
33                breakpoints.push((byte_start + ch.len_utf8(), divergence));
34            }
35        }
36        OffsetTable { breakpoints }
37    }
38
39    /// Cumulative divergence in effect at `byte_offset` (i.e. contributed by
40    /// every multi-byte char that ends at or before it).
41    fn divergence_at(&self, byte_offset: usize) -> i64 {
42        match self
43            .breakpoints
44            .binary_search_by(|(pos, _)| pos.cmp(&byte_offset))
45        {
46            Ok(idx) => self.breakpoints[idx].1,
47            Err(0) => 0,
48            Err(idx) => self.breakpoints[idx - 1].1,
49        }
50    }
51
52    /// Translate one UTF-8 byte offset (must land on a char boundary, which
53    /// every tree-sitter node boundary does for valid UTF-8 source) to its
54    /// UTF-16 code-unit offset from the start of the file.
55    pub fn byte_to_utf16(&self, byte_offset: usize) -> usize {
56        (byte_offset as i64 - self.divergence_at(byte_offset)) as usize
57    }
58
59    /// Translate a tree-sitter point -- `byte_offset` from the start of the
60    /// file, `column_bytes` from the start of its row -- into
61    /// (utf16 offset from file start, utf16 column from row start). Rows
62    /// themselves are never translated; §5.5 rows always agree.
63    pub fn translate_point(&self, byte_offset: usize, column_bytes: usize) -> (usize, usize) {
64        let line_start = byte_offset - column_bytes;
65        let offset_u16 = self.byte_to_utf16(byte_offset);
66        let line_start_u16 = self.byte_to_utf16(line_start);
67        (offset_u16, offset_u16 - line_start_u16)
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn ascii_has_no_divergence() {
77        let src = "class Widget {}\n";
78        let table = OffsetTable::build(src);
79        for byte in 0..=src.len() {
80            assert_eq!(table.byte_to_utf16(byte), byte);
81        }
82    }
83
84    #[test]
85    fn bom_diverges_by_two() {
86        // U+FEFF (unstripped BOM): 3-byte UTF-8, 1 UTF-16 unit -> -2.
87        let src = "\u{FEFF}class Widget {}";
88        let table = OffsetTable::build(src);
89        assert_eq!(table.byte_to_utf16(0), 0);
90        assert_eq!(table.byte_to_utf16(3), 1); // right after the BOM
91        assert_eq!(table.byte_to_utf16(3 + 5), 1 + 5); // "class" follows it
92    }
93
94    #[test]
95    fn accented_char_diverges_by_one() {
96        // 'e' with acute accent (U+00E9): 2-byte UTF-8, 1 UTF-16 unit -> -1.
97        let src = "// caf\u{E9}\n";
98        let table = OffsetTable::build(src);
99        let accent_byte_start = 6; // "// caf" = 6 ASCII bytes before it
100        assert_eq!(table.byte_to_utf16(accent_byte_start), 6);
101        assert_eq!(table.byte_to_utf16(accent_byte_start + 2), 7);
102    }
103
104    #[test]
105    fn cjk_char_diverges_by_two() {
106        // U+4E2D ('中'): 3-byte UTF-8, 1 UTF-16 unit -> -2.
107        let src = "// \u{4E2D}\n";
108        let table = OffsetTable::build(src);
109        assert_eq!(table.byte_to_utf16(3), 3); // before the CJK char
110        assert_eq!(table.byte_to_utf16(3 + 3), 4);
111    }
112
113    #[test]
114    fn astral_emoji_diverges_by_two_via_surrogate_pair() {
115        // U+1F600 (emoji): 4-byte UTF-8, 2 UTF-16 units (surrogate pair) -> -2.
116        let src = "// \u{1F600}\n";
117        let table = OffsetTable::build(src);
118        assert_eq!(table.byte_to_utf16(3), 3);
119        assert_eq!(table.byte_to_utf16(3 + 4), 3 + 2);
120    }
121
122    #[test]
123    fn multiple_multibyte_chars_accumulate() {
124        let src = "\u{00E9}\u{00E9}"; // two 2-byte chars back to back
125        let table = OffsetTable::build(src);
126        assert_eq!(table.byte_to_utf16(0), 0);
127        assert_eq!(table.byte_to_utf16(2), 1);
128        assert_eq!(table.byte_to_utf16(4), 2);
129    }
130
131    #[test]
132    fn column_ignores_divergence_from_earlier_lines() {
133        // Line 1 has two 2-byte accented chars (-1 each, -2 total by its
134        // end). Line 2 is pure ASCII: its column must not inherit line 1's
135        // divergence, only the file-global byte offset should.
136        let src = "// \u{00E9}\u{00E9}\nclass Widget {}\n";
137        let table = OffsetTable::build(src);
138        let widget_byte = src.find("Widget").unwrap();
139        let line_start_byte = src.find("class").unwrap();
140        let column_bytes = widget_byte - line_start_byte;
141        assert_eq!(column_bytes, 6);
142
143        let (offset_u16, column_u16) = table.translate_point(widget_byte, column_bytes);
144        assert_eq!(
145            column_u16, 6,
146            "line 2 is ASCII; column must equal raw byte column"
147        );
148        assert_eq!(
149            offset_u16,
150            widget_byte - 2,
151            "global offset reflects line 1's -2 divergence"
152        );
153    }
154}