Skip to main content

praxis_source/
span.rs

1//! Source spans.
2//!
3//! A [`Span`] is a half-open byte range `[start, end)` within a single source
4//! file. It is stored as a start offset plus a **length**, never as two
5//! independent offsets, so an inverted span (`end < start`) is literally
6//! unrepresentable.
7
8use std::fmt;
9
10/// A byte offset into a source file.
11///
12/// This is a `u32`, not a `usize`: source files are capped at 4 GiB, and a
13/// 32-bit offset halves the storage cost of every span.
14#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
15pub struct BytePos(pub u32);
16
17impl BytePos {
18    /// The zero offset — the start of every file.
19    pub const ZERO: BytePos = BytePos(0);
20
21    #[inline]
22    pub const fn to_u32(self) -> u32 {
23        self.0
24    }
25
26    #[inline]
27    pub const fn to_usize(self) -> usize {
28        self.0 as usize
29    }
30
31    /// Saturating addition. Offsets never overflow; they clamp at `u32::MAX`.
32    #[inline]
33    pub const fn saturating_add(self, bytes: u32) -> BytePos {
34        BytePos(self.0.saturating_add(bytes))
35    }
36
37    /// Difference between two offsets, clamped at zero so subtraction can never
38    /// underflow or produce a negative span.
39    #[inline]
40    pub const fn saturating_sub(self, other: BytePos) -> u32 {
41        self.0.saturating_sub(other.0)
42    }
43}
44
45impl fmt::Debug for BytePos {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        write!(f, "BytePos({})", self.0)
48    }
49}
50
51impl From<u32> for BytePos {
52    fn from(value: u32) -> Self {
53        BytePos(value)
54    }
55}
56
57/// A half-open byte range `[start, end)` within one file.
58///
59/// Internally stored as `start` plus `len`, so the only constructor
60/// [`Span::new`] can reject inversion at construction time and the invariant is
61/// preserved by construction thereafter. An empty span (`len == 0`) is valid and
62/// marks a position rather than a range.
63#[derive(Clone, Copy, PartialEq, Eq, Hash)]
64pub struct Span {
65    start: BytePos,
66    len: u32,
67}
68
69impl Span {
70    /// The only public constructor. `end` must be `>= start`.
71    ///
72    /// Panics (debug only) if `end < start`. In release builds an inverted range
73    /// collapses to an empty span at `start`, so the type invariant is never
74    /// violated even if a caller bypasses the debug assertion.
75    #[track_caller]
76    pub fn new(start: impl Into<BytePos>, end: impl Into<BytePos>) -> Span {
77        let start = start.into();
78        let end = end.into();
79        debug_assert!(
80            start <= end,
81            "Span::new called with inverted range {start:?}..{end:?}"
82        );
83        // `end.saturating_sub(start)` is a plain u32 that can never underflow;
84        // in the inverted case (release builds only, since debug would have
85        // panicked above) it clamps to 0, preserving the type invariant.
86        let len = end.saturating_sub(start);
87        Span { start, len }
88    }
89
90    /// An empty span anchored at a single offset. Useful for "at this position".
91    #[inline]
92    pub fn at(pos: impl Into<BytePos>) -> Span {
93        let pos = pos.into();
94        Span { start: pos, len: 0 }
95    }
96
97    /// An empty span at the very start of a file. A sensible default.
98    pub const EMPTY: Span = Span {
99        start: BytePos::ZERO,
100        len: 0,
101    };
102
103    #[inline]
104    pub const fn start(self) -> BytePos {
105        self.start
106    }
107
108    #[inline]
109    pub const fn end(self) -> BytePos {
110        BytePos(self.start.0.saturating_add(self.len))
111    }
112
113    #[inline]
114    pub const fn len(self) -> u32 {
115        self.len
116    }
117
118    #[inline]
119    pub const fn is_empty(self) -> bool {
120        self.len == 0
121    }
122
123    /// The same span, moved `delta` bytes later in the file.
124    ///
125    /// The length is preserved, so the invariant holds by construction. Used to
126    /// rebase spans produced against a *fragment* onto the file that contains
127    /// it: the input-parser's template scanner works in offsets relative to a
128    /// backtick template's interior, and the HIR bridge rebases the tree by the
129    /// token's start.
130    #[inline]
131    #[must_use]
132    pub const fn shifted(self, delta: u32) -> Span {
133        Span {
134            start: BytePos(self.start.0.saturating_add(delta)),
135            len: self.len,
136        }
137    }
138
139    /// True if `pos` lies within `[start, end)`.
140    #[inline]
141    pub fn contains(self, pos: BytePos) -> bool {
142        pos >= self.start && pos < self.end()
143    }
144
145    /// The smallest span covering both `self` and `other`. If the two spans are
146    /// in different files the caller must use [`FileSpan::union`] instead.
147    ///
148    /// An empty span acts as a neutral element: unioning with one returns the
149    /// other span unchanged.
150    pub fn cover(self, other: Span) -> Span {
151        if self.is_empty() {
152            return other;
153        }
154        if other.is_empty() {
155            return self;
156        }
157        let start = self.start.min(other.start);
158        let end = self.end().max(other.end());
159        Span::new(start, end)
160    }
161}
162
163impl fmt::Debug for Span {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        write!(
166            f,
167            "{}..{} (len {})",
168            self.start.to_u32(),
169            self.end().to_u32(),
170            self.len
171        )
172    }
173}
174
175/// A [`Span`] together with the file it belongs to.
176///
177/// Combining the file into the type means there is no such thing as an "orphan"
178/// span whose file has to be guessed from context. Operations that combine two
179/// spans (`FileSpan::union`) require them to share a file, enforced at runtime.
180#[derive(Clone, Copy, PartialEq, Eq, Hash)]
181pub struct FileSpan {
182    pub file: FileId,
183    pub span: Span,
184}
185
186impl FileSpan {
187    #[inline]
188    pub fn new(file: FileId, span: Span) -> FileSpan {
189        FileSpan { file, span }
190    }
191
192    /// The smallest span covering both `self` and `other`.
193    ///
194    /// Returns `None` if the two spans belong to different files — there is no
195    /// well-defined union across files, and `None` forces the caller to handle
196    /// that case rather than silently picking one.
197    pub fn union(self, other: FileSpan) -> Option<FileSpan> {
198        if self.file != other.file {
199            return None;
200        }
201        Some(FileSpan::new(self.file, self.span.cover(other.span)))
202    }
203}
204
205impl fmt::Debug for FileSpan {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        write!(f, "{:?}@{:?}", self.span, self.file)
208    }
209}
210
211use crate::file::FileId;
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn span_round_trip_and_endpoints() {
219        let s = Span::new(10, 25);
220        assert_eq!(s.start(), BytePos(10));
221        assert_eq!(s.end(), BytePos(25));
222        assert_eq!(s.len(), 15);
223        assert!(!s.is_empty());
224
225        let empty = Span::new(7, 7);
226        assert!(empty.is_empty());
227        assert_eq!(empty.len(), 0);
228    }
229
230    #[test]
231    fn at_and_empty_constants() {
232        assert_eq!(Span::at(5), Span::new(5, 5));
233        assert!(Span::EMPTY.is_empty());
234        assert_eq!(Span::EMPTY.start(), BytePos::ZERO);
235    }
236
237    #[test]
238    fn contains_respects_half_open_semantics() {
239        let s = Span::new(10, 20);
240        assert!(!s.contains(9.into()));
241        assert!(s.contains(10.into()));
242        assert!(s.contains(19.into()));
243        assert!(!s.contains(20.into())); // half-open: end excluded
244    }
245
246    #[test]
247    fn cover_smallest_enclosing() {
248        let a = Span::new(10, 20);
249        let b = Span::new(15, 30);
250        assert_eq!(a.cover(b), Span::new(10, 30));
251
252        let c = Span::new(100, 110);
253        assert_eq!(a.cover(c), Span::new(10, 110));
254    }
255
256    #[test]
257    fn cover_empty_is_neutral() {
258        let a = Span::new(10, 20);
259        assert_eq!(a.cover(Span::EMPTY), a);
260        assert_eq!(Span::EMPTY.cover(a), a);
261        assert_eq!(Span::EMPTY.cover(Span::EMPTY), Span::EMPTY);
262    }
263
264    #[test]
265    fn bytepos_saturating_arithmetic() {
266        assert_eq!(BytePos(5).saturating_add(10), BytePos(15));
267        assert_eq!(BytePos(u32::MAX).saturating_add(1), BytePos(u32::MAX));
268        assert_eq!(BytePos(10).saturating_sub(BytePos(3)), 7);
269        assert_eq!(BytePos(3).saturating_sub(BytePos(10)), 0); // clamped
270    }
271
272    // Only in debug: the assertion this test is about is compiled out when
273    // `debug_assertions` is off, so under `cargo test --release` there is
274    // nothing to panic and `should_panic` would fail on a *correct* build.
275    #[cfg(debug_assertions)]
276    #[test]
277    #[should_panic(expected = "inverted range")]
278    fn inverted_span_is_rejected_in_debug() {
279        // "Make illegal states unrepresentable" (AGENTS.md): `Span::new` stores
280        // `start + len`, so an inverted span cannot be constructed. The
281        // `debug_assert!` catches the bug in test/debug builds (the default test
282        // profile); release builds clamp to an empty span so the invariant still
283        // holds even if a caller bypasses the assertion.
284        let _ = Span::new(25, 10);
285    }
286}