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