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, 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}