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