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}