Skip to main content

sql_dialect_fmt_text/
lib.rs

1//! Shared source text position helpers.
2//!
3//! The parser and lexer report diagnostics in byte offsets, while humans and editor protocols need
4//! line/column coordinates. This crate keeps that mapping in one place without depending on LSP
5//! types or parser internals.
6
7/// A one-based human-readable source position.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub struct LineColumn {
10    pub line: usize,
11    pub column: usize,
12}
13
14impl LineColumn {
15    pub const fn new(line: usize, column: usize) -> Self {
16        Self { line, column }
17    }
18}
19
20/// A zero-based LSP-style position whose character offset is measured in UTF-16 code units.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub struct Utf16Position {
23    pub line: u32,
24    pub character: u32,
25}
26
27impl Utf16Position {
28    pub const fn new(line: u32, character: u32) -> Self {
29        Self { line, character }
30    }
31}
32
33/// A zero-based LSP-style position whose character offset is measured in UTF-8 bytes.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub struct Utf8Position {
36    pub line: u32,
37    pub character: u32,
38}
39
40impl Utf8Position {
41    pub const fn new(line: u32, character: u32) -> Self {
42        Self { line, character }
43    }
44}
45
46/// Maps byte offsets into a source string to line/column coordinates.
47pub struct LineIndex<'a> {
48    text: &'a str,
49    line_starts: Vec<usize>,
50}
51
52impl<'a> LineIndex<'a> {
53    pub fn new(text: &'a str) -> Self {
54        let mut line_starts = vec![0];
55        line_starts.extend(
56            text.bytes()
57                .enumerate()
58                .filter(|&(_, b)| b == b'\n')
59                .map(|(i, _)| i + 1),
60        );
61        Self { text, line_starts }
62    }
63
64    /// Return the one-based human-readable line and column for `offset`.
65    ///
66    /// Columns are counted in Unicode scalar values, matching Rust `char`s. Out-of-range offsets
67    /// clamp to the document end; offsets in the middle of a UTF-8 sequence clamp back to the
68    /// previous character boundary.
69    pub fn line_column(&self, offset: usize) -> LineColumn {
70        let offset = self.clamp_offset(offset);
71        let line = self.line_for_offset(offset);
72        let line_start = self.line_starts[line];
73        let column = self.text[line_start..offset].chars().count() + 1;
74        LineColumn::new(line + 1, column)
75    }
76
77    /// Return the zero-based line and UTF-16 column for `offset`.
78    ///
79    /// This is the coordinate system used by LSP. Out-of-range offsets clamp to the document end;
80    /// offsets in the middle of a UTF-8 sequence clamp back to the previous character boundary.
81    pub fn utf16_position(&self, offset: usize) -> Utf16Position {
82        let offset = self.clamp_offset(offset);
83        let line = self.line_for_offset(offset);
84        let line_start = self.line_starts[line];
85        let character = utf16_len(&self.text[line_start..offset]);
86        Utf16Position::new(line as u32, character)
87    }
88
89    /// Return the zero-based line and UTF-8 byte column for `offset`.
90    pub fn utf8_position(&self, offset: usize) -> Utf8Position {
91        let offset = self.clamp_offset(offset);
92        let line = self.line_for_offset(offset);
93        let line_start = self.line_starts[line];
94        Utf8Position::new(line as u32, (offset - line_start) as u32)
95    }
96
97    /// The UTF-16 position one past the last character.
98    pub fn end_utf16_position(&self) -> Utf16Position {
99        self.utf16_position(self.text.len())
100    }
101
102    /// The UTF-8 position one past the last byte.
103    pub fn end_utf8_position(&self) -> Utf8Position {
104        self.utf8_position(self.text.len())
105    }
106
107    /// The byte offset for a zero-based line and UTF-16 column.
108    ///
109    /// Out-of-range lines and columns clamp to the line or document end. A UTF-16 column landing in
110    /// the middle of a surrogate pair maps to the start of that character.
111    pub fn offset_for_utf16_position(&self, position: Utf16Position) -> usize {
112        let line = position.line as usize;
113        let Some(&line_start) = self.line_starts.get(line) else {
114            return self.text.len();
115        };
116        let mut remaining = position.character as usize;
117        let mut offset = line_start;
118        for ch in self.text[line_start..].chars() {
119            let width = ch.len_utf16();
120            if remaining < width || ch == '\n' {
121                break;
122            }
123            remaining -= width;
124            offset += ch.len_utf8();
125        }
126        offset
127    }
128
129    /// The byte offset for a zero-based line and UTF-8 byte column.
130    pub fn offset_for_utf8_position(&self, position: Utf8Position) -> usize {
131        let line = position.line as usize;
132        let Some(&line_start) = self.line_starts.get(line) else {
133            return self.text.len();
134        };
135        self.clamp_offset(line_start.saturating_add(position.character as usize))
136    }
137
138    fn line_for_offset(&self, offset: usize) -> usize {
139        match self.line_starts.binary_search(&offset) {
140            Ok(line) => line,
141            Err(next) => next - 1,
142        }
143    }
144
145    fn clamp_offset(&self, offset: usize) -> usize {
146        let mut offset = offset.min(self.text.len());
147        while !self.text.is_char_boundary(offset) {
148            offset -= 1;
149        }
150        offset
151    }
152}
153
154/// Count UTF-16 code units in `text`.
155pub fn utf16_len(text: &str) -> u32 {
156    text.chars().map(|ch| ch.len_utf16() as u32).sum()
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn maps_offsets_to_one_based_line_columns() {
165        let text = "abc\ndefg\nhi";
166        let index = LineIndex::new(text);
167        assert_eq!(index.line_column(0), LineColumn::new(1, 1));
168        assert_eq!(index.line_column(4), LineColumn::new(2, 1));
169        assert_eq!(index.line_column(6), LineColumn::new(2, 3));
170        assert_eq!(index.line_column(999), LineColumn::new(3, 3));
171    }
172
173    #[test]
174    fn maps_offsets_to_utf16_positions() {
175        let text = "SELECT a\nFROM 芋;\n";
176        let index = LineIndex::new(text);
177        assert_eq!(index.utf16_position(0), Utf16Position::new(0, 0));
178        assert_eq!(index.utf16_position(7), Utf16Position::new(0, 7));
179        assert_eq!(
180            index.utf16_position(text.find("FROM").unwrap()),
181            Utf16Position::new(1, 0)
182        );
183        assert_eq!(
184            index.utf16_position(text.find(';').unwrap()),
185            Utf16Position::new(1, 6)
186        );
187    }
188
189    #[test]
190    fn maps_offsets_to_utf8_positions() {
191        let text = "SELECT '長芋'\nFROM t";
192        let index = LineIndex::new(text);
193        assert_eq!(index.utf8_position(0), Utf8Position::new(0, 0));
194        assert_eq!(
195            index.utf8_position(text.find("FROM").unwrap()),
196            Utf8Position::new(1, 0)
197        );
198        let newline = text.find('\n').unwrap();
199        assert_eq!(
200            index.utf8_position(newline),
201            Utf8Position::new(0, newline as u32)
202        );
203    }
204
205    #[test]
206    fn maps_utf16_positions_back_to_offsets() {
207        let text = "SELECT a\nFROM 芋;\nSELECT 😀;\n";
208        let index = LineIndex::new(text);
209        for offset in [
210            0usize,
211            7,
212            text.find("FROM").unwrap(),
213            text.find(';').unwrap(),
214            text.find("😀").unwrap(),
215        ] {
216            assert_eq!(
217                index.offset_for_utf16_position(index.utf16_position(offset)),
218                offset
219            );
220        }
221    }
222
223    #[test]
224    fn maps_utf8_positions_back_to_offsets() {
225        let text = "SELECT '長芋'\nFROM t";
226        let index = LineIndex::new(text);
227        for offset in [
228            0usize,
229            7,
230            text.find("FROM").unwrap(),
231            text.find('\n').unwrap(),
232        ] {
233            assert_eq!(
234                index.offset_for_utf8_position(index.utf8_position(offset)),
235                offset
236            );
237        }
238    }
239
240    #[test]
241    fn clamps_mid_character_offsets() {
242        let text = "a😀b";
243        let index = LineIndex::new(text);
244        assert_eq!(index.line_column(2), LineColumn::new(1, 2));
245        assert_eq!(index.utf16_position(2), Utf16Position::new(0, 1));
246    }
247}