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}