Skip to main content

nova_diagnostics/
line_index.rs

1//! Byte offsets to LSP positions and back (spec
2//! `docs/superpowers/specs/2026-10-08-phase-3-2-lsp-core-design.md` ยง5).
3//!
4//! The compiler's spans are byte offsets. The Language Server Protocol
5//! counts a position as a zero-based line and a column in UTF-16 code units,
6//! so a character above U+FFFF is two columns, and a Thai letter, three bytes
7//! of UTF-8, is one.
8
9/// The start of every line in a text.
10#[derive(Debug, Clone)]
11pub struct LineIndex {
12    text: String,
13    /// The byte offset of each line's first character; `starts[0]` is 0.
14    starts: Vec<u32>,
15}
16
17impl LineIndex {
18    /// Index `text`. `\n` ends a line, and so does `\r\n`, whose `\r` is
19    /// part of the line's end.
20    pub fn new(text: &str) -> LineIndex {
21        let mut starts = vec![0];
22        for (i, b) in text.bytes().enumerate() {
23            if b == b'\n' {
24                starts.push(i as u32 + 1);
25            }
26        }
27        LineIndex {
28            text: text.to_owned(),
29            starts,
30        }
31    }
32
33    /// The text's length in bytes.
34    pub fn len(&self) -> u32 {
35        self.text.len() as u32
36    }
37
38    /// Whether the text is empty.
39    pub fn is_empty(&self) -> bool {
40        self.text.is_empty()
41    }
42
43    /// The zero-based line and UTF-16 column of byte `offset`. An offset
44    /// past the end is the end of the text.
45    pub fn position(&self, offset: u32) -> (u32, u32) {
46        let offset = offset.min(self.len());
47        let line = match self.starts.binary_search(&offset) {
48            Ok(line) => line,
49            Err(next) => next - 1,
50        };
51        let start = self.starts[line] as usize;
52        let mut column = 0u32;
53        for (i, c) in self.text[start..].char_indices() {
54            if start + i >= offset as usize {
55                break;
56            }
57            column += c.len_utf16() as u32;
58        }
59        (line as u32, column)
60    }
61
62    /// The byte offset of zero-based `line` and UTF-16 `column`.
63    ///
64    /// Out-of-range positions clamp:
65    /// - a column past the line's end goes to the line's end, before its
66    ///   `\r\n` or `\n`;
67    /// - a line past the last goes to the end of the text;
68    /// - a column inside a surrogate pair goes to its character's start.
69    pub fn offset(&self, line: u32, column: u32) -> u32 {
70        let Some(&start) = self.starts.get(line as usize) else {
71            return self.len();
72        };
73        let end = self.line_end(line as usize);
74        let mut units = 0u32;
75        for (i, c) in self.text[start as usize..end].char_indices() {
76            let next = units + c.len_utf16() as u32;
77            if next > column {
78                return start + i as u32;
79            }
80            units = next;
81        }
82        end as u32
83    }
84
85    /// The byte offset where `line`'s text ends, before its line break.
86    fn line_end(&self, line: usize) -> usize {
87        let start = self.starts[line] as usize;
88        let mut end = self
89            .starts
90            .get(line + 1)
91            .map_or(self.text.len(), |&s| s as usize);
92        let bytes = self.text.as_bytes();
93        if end > start && bytes[end - 1] == b'\n' {
94            end -= 1;
95        }
96        if end > start && bytes[end - 1] == b'\r' {
97            end -= 1;
98        }
99        end
100    }
101}