Skip to main content

bynk_syntax/
span.rs

1//! Source position spans.
2
3/// A byte range in the source. Half-open: `[start, end)`.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
5pub struct Span {
6    pub start: usize,
7    pub end: usize,
8}
9
10impl Span {
11    pub fn new(start: usize, end: usize) -> Self {
12        Self { start, end }
13    }
14
15    pub fn range(&self) -> std::ops::Range<usize> {
16        self.start..self.end
17    }
18
19    /// This span shifted right by `delta` bytes. Used to rebase spans produced
20    /// against a substring (e.g. a re-lexed interpolation hole) into the full
21    /// source. (#716.)
22    pub fn offset(self, delta: usize) -> Span {
23        Span {
24            start: self.start + delta,
25            end: self.end + delta,
26        }
27    }
28
29    /// Span covering both `self` and `other` (the smallest enclosing range).
30    pub fn merge(self, other: Span) -> Span {
31        Span {
32            start: self.start.min(other.start),
33            end: self.end.max(other.end),
34        }
35    }
36}
37
38impl From<std::ops::Range<usize>> for Span {
39    fn from(r: std::ops::Range<usize>) -> Self {
40        Span {
41            start: r.start,
42            end: r.end,
43        }
44    }
45}
46
47#[cfg(test)]
48mod line_index_tests {
49    use super::{LineIndex, line_col};
50
51    /// `LineIndex::line_col` must agree with the scanning `line_col` at every
52    /// offset, including past-the-end and non-ASCII sources.
53    #[test]
54    fn line_index_matches_scanning_line_col() {
55        for src in [
56            "",
57            "abc",
58            "abc\ndef",
59            "abc\ndef\n",
60            "\n\n\n",
61            "π = 3\n-- naïve café €10 🦀\nend",
62        ] {
63            let index = LineIndex::new(src);
64            // Include one past-the-end offset to exercise the clamp.
65            for offset in 0..=src.len() + 2 {
66                if !src.is_char_boundary(offset.min(src.len())) {
67                    continue;
68                }
69                assert_eq!(
70                    index.line_col(src, offset),
71                    line_col(src, offset),
72                    "mismatch at offset {offset} in {src:?}",
73                );
74            }
75        }
76    }
77
78    /// UTF-16 columns count code units: BMP chars are 1, astral chars 2. Line is
79    /// 0-based and column resets to 0 after each newline.
80    #[test]
81    fn utf16_line_col_counts_code_units() {
82        let src = "-- café\nlet 🦀 x";
83        let index = LineIndex::new(src);
84        // After "café" on line 0: c,a,f + 2-byte é → 4 UTF-16 units.
85        let after_cafe = "-- café".len();
86        assert_eq!(index.utf16_line_col(src, after_cafe), (0, 7));
87        // Start of line 1.
88        let line1 = src.find("let").unwrap();
89        assert_eq!(index.utf16_line_col(src, line1), (1, 0));
90        // Just past the 4-byte crab on line 1: "let " (4) + 🦀 (2 units).
91        let after_crab = line1 + "let 🦀".len();
92        assert_eq!(index.utf16_line_col(src, after_crab), (1, 6));
93    }
94
95    #[test]
96    fn line_and_line_start_round_trip() {
97        let src = "one\ntwo\nthree";
98        let index = LineIndex::new(src);
99        assert_eq!(index.line(0), 0);
100        assert_eq!(index.line(3), 0); // the '\n' terminating line 0
101        assert_eq!(index.line(4), 1); // start of "two"
102        assert_eq!(index.line(src.len()), 2);
103        assert_eq!(index.line_start(1), 4);
104        assert_eq!(index.line_start(2), 8);
105    }
106}
107
108/// 1-indexed (line, column) of a byte offset in `source`. Columns count
109/// characters, not bytes. Lives in the syntax leaf so every layer that maps a
110/// span to a position — the emitter's assertion locations, `bynkc`'s `short`
111/// rendering, and (slice 6) `bynk-render` — shares one implementation.
112///
113/// This scans from byte 0, so it is O(offset). For repeated lookups over one
114/// snapshot (an LSP request emitting many positions, or the emit source-map
115/// builder resolving every checkpoint), build a [`LineIndex`] once and query
116/// it in O(log n) instead — see #732.
117pub fn line_col(source: &str, offset: usize) -> (usize, usize) {
118    let mut line = 1;
119    let mut col = 1;
120    for (i, ch) in source.char_indices() {
121        if i >= offset {
122            break;
123        }
124        if ch == '\n' {
125            line += 1;
126            col = 1;
127        } else {
128            col += 1;
129        }
130    }
131    (line, col)
132}
133
134/// A per-snapshot table of line-start byte offsets, built once and shared by
135/// every position lookup over that snapshot (#732).
136///
137/// `line_col` scans from byte 0 on every call, so emitting `n` positions over
138/// an `n`-byte snapshot is O(n²). This precomputes the byte offset where each
139/// line begins; a lookup binary-searches for the line (O(log n)) and then
140/// counts columns only within that one line. Consumers that map many spans per
141/// request — semantic tokens, folding ranges, diagnostics, inlay hints,
142/// document symbols, the emit source map — build one of these per snapshot and
143/// reuse it.
144#[derive(Debug, Clone)]
145pub struct LineIndex {
146    /// Byte offset of the start of each line; `line_starts[0]` is always `0`.
147    /// A trailing newline yields a final (empty) line start, matching the
148    /// convention that offset == `len` after a `\n` sits on the next line.
149    line_starts: Vec<usize>,
150    /// Byte length of the indexed source, so out-of-range offsets clamp to the
151    /// end exactly as the scanning `line_col` would.
152    len: usize,
153}
154
155impl LineIndex {
156    /// Precompute the line-start table for `source` in one O(n) pass.
157    pub fn new(source: &str) -> Self {
158        let mut line_starts = vec![0usize];
159        for (i, b) in source.bytes().enumerate() {
160            if b == b'\n' {
161                line_starts.push(i + 1);
162            }
163        }
164        Self {
165            line_starts,
166            len: source.len(),
167        }
168    }
169
170    /// 0-based line containing `offset`, by binary search over the line starts.
171    pub fn line(&self, offset: usize) -> usize {
172        match self.line_starts.binary_search(&offset) {
173            Ok(i) => i,
174            // `line_starts[0] == 0 <= offset`, so `Err(0)` is impossible and
175            // `i - 1` never underflows.
176            Err(i) => i - 1,
177        }
178    }
179
180    /// Byte offset where the 0-based `line` begins.
181    pub fn line_start(&self, line: usize) -> usize {
182        self.line_starts[line]
183    }
184
185    /// 1-indexed (line, column) of `offset`, columns counting characters —
186    /// identical to [`line_col`] but O(log n + line length) after the one-time
187    /// build. `source` must be the same string the index was built from.
188    pub fn line_col(&self, source: &str, offset: usize) -> (usize, usize) {
189        let offset = offset.min(self.len);
190        let line = self.line(offset);
191        let start = self.line_starts[line];
192        let mut col = 1;
193        for (i, _) in source[start..].char_indices() {
194            if start + i >= offset {
195                break;
196            }
197            col += 1;
198        }
199        (line + 1, col)
200    }
201
202    /// 0-based (line, UTF-16 column) of `offset` — the LSP default position
203    /// encoding (columns count UTF-16 code units, so a 4-byte astral char is 2).
204    /// `source` must be the same string the index was built from.
205    pub fn utf16_line_col(&self, source: &str, offset: usize) -> (u32, u32) {
206        let offset = offset.min(self.len);
207        let line = self.line(offset);
208        let start = self.line_starts[line];
209        let mut col: u32 = 0;
210        for (i, ch) in source[start..].char_indices() {
211            if start + i >= offset {
212                break;
213            }
214            col += ch.len_utf16() as u32;
215        }
216        (line as u32, col)
217    }
218}