Skip to main content

whitaker_common/
span.rs

1//! Utilities for working with source locations and spans.
2#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
3
4use std::ops::RangeInclusive;
5
6/// Errors produced when constructing spans.
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum SpanError {
9    /// Indicates the start location occurs after the end location.
10    StartAfterEnd,
11}
12
13/// Represents a location in source code using one-based line and column numbers.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct SourceLocation {
16    line: usize,
17    column: usize,
18}
19
20impl SourceLocation {
21    /// Builds a new location.
22    ///
23    /// # Examples
24    ///
25    /// ```
26    /// use whitaker_common::span::SourceLocation;
27    ///
28    /// let location = SourceLocation::new(3, 5);
29    /// assert_eq!(location.line(), 3);
30    /// ```
31    #[must_use]
32    pub const fn new(line: usize, column: usize) -> Self {
33        Self { line, column }
34    }
35
36    /// Returns the one-based line number.
37    #[must_use]
38    pub const fn line(self) -> usize {
39        self.line
40    }
41
42    /// Returns the one-based column number.
43    #[must_use]
44    pub const fn column(self) -> usize {
45        self.column
46    }
47
48    /// Returns true when this location is positioned after other.
49    #[must_use]
50    pub const fn is_after(self, other: SourceLocation) -> bool {
51        self.line > other.line || (self.line == other.line && self.column > other.column)
52    }
53}
54
55/// Represents a span of source code.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub struct SourceSpan {
58    start: SourceLocation,
59    end: SourceLocation,
60}
61
62impl SourceSpan {
63    /// Constructs a new span from two locations.
64    ///
65    /// # Examples
66    ///
67    /// ```
68    /// use whitaker_common::span::{SourceLocation, SourceSpan};
69    ///
70    /// let span = SourceSpan::new(SourceLocation::new(1, 0), SourceLocation::new(3, 2)).expect("valid span for example");
71    /// assert_eq!(span.start().line(), 1);
72    /// ```
73    #[must_use = "Inspect the span creation result to handle invalid ranges"]
74    pub fn new(start: SourceLocation, end: SourceLocation) -> Result<Self, SpanError> {
75        if start.is_after(end) {
76            Err(SpanError::StartAfterEnd)
77        } else {
78            Ok(Self { start, end })
79        }
80    }
81
82    /// Returns the start location.
83    #[must_use]
84    pub const fn start(self) -> SourceLocation {
85        self.start
86    }
87
88    /// Returns the end location.
89    #[must_use]
90    pub const fn end(self) -> SourceLocation {
91        self.end
92    }
93}
94
95/// Converts a span into the inclusive range of line numbers it covers.
96///
97/// # Examples
98///
99/// ```
100/// use whitaker_common::span::{SourceLocation, SourceSpan, span_to_lines};
101///
102/// let span = SourceSpan::new(SourceLocation::new(4, 0), SourceLocation::new(6, 5)).expect("valid span for example");
103/// assert_eq!(span_to_lines(span), 4..=6);
104/// ```
105#[must_use]
106pub fn span_to_lines(span: SourceSpan) -> RangeInclusive<usize> {
107    span.start.line()..=span.end.line()
108}
109
110/// Calculates the number of lines covered by the span (inclusive).
111///
112/// # Examples
113///
114/// ```
115/// use whitaker_common::span::{SourceLocation, SourceSpan, span_line_count};
116///
117/// let span = SourceSpan::new(SourceLocation::new(2, 0), SourceLocation::new(5, 1)).expect("valid span for example");
118/// assert_eq!(span_line_count(span), 4);
119/// ```
120#[must_use]
121pub fn span_line_count(span: SourceSpan) -> usize {
122    span.end.line() - span.start.line() + 1
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use rstest::rstest;
129
130    #[rstest]
131    fn span_construction_validates_order() {
132        let err = SourceSpan::new(SourceLocation::new(3, 1), SourceLocation::new(2, 0));
133        assert!(matches!(err, Err(SpanError::StartAfterEnd)));
134    }
135
136    #[rstest]
137    fn calculates_line_ranges() {
138        let span = SourceSpan::new(SourceLocation::new(5, 0), SourceLocation::new(7, 3))
139            .expect("valid span for line range test");
140        assert_eq!(span_to_lines(span), 5..=7);
141        assert_eq!(span_line_count(span), 3);
142    }
143}