Skip to main content

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}