Skip to main content

use_diagnostic_span/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5
6/// A 1-based line and column position.
7#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
8pub struct DiagnosticPosition {
9    line: usize,
10    column: usize,
11}
12
13impl DiagnosticPosition {
14    /// Creates a 1-based position.
15    ///
16    /// # Errors
17    ///
18    /// Returns [`DiagnosticPositionError::ZeroLine`] when `line` is zero and
19    /// [`DiagnosticPositionError::ZeroColumn`] when `column` is zero.
20    pub const fn new(line: usize, column: usize) -> Result<Self, DiagnosticPositionError> {
21        if line == 0 {
22            return Err(DiagnosticPositionError::ZeroLine);
23        }
24
25        if column == 0 {
26            return Err(DiagnosticPositionError::ZeroColumn);
27        }
28
29        Ok(Self { line, column })
30    }
31
32    /// Returns the 1-based line number.
33    #[must_use]
34    pub const fn line(self) -> usize {
35        self.line
36    }
37
38    /// Returns the 1-based column number.
39    #[must_use]
40    pub const fn column(self) -> usize {
41        self.column
42    }
43}
44
45/// Errors returned while constructing a [`DiagnosticPosition`].
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum DiagnosticPositionError {
48    /// The line number was zero.
49    ZeroLine,
50    /// The column number was zero.
51    ZeroColumn,
52}
53
54impl fmt::Display for DiagnosticPositionError {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::ZeroLine => formatter.write_str("diagnostic position line must be at least 1"),
58            Self::ZeroColumn => {
59                formatter.write_str("diagnostic position column must be at least 1")
60            },
61        }
62    }
63}
64
65impl std::error::Error for DiagnosticPositionError {}
66
67/// A stable identifier for a source, file, buffer, document, or virtual source.
68#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
69pub struct DiagnosticSourceId(String);
70
71impl DiagnosticSourceId {
72    /// Creates a source identifier from non-empty text after trimming surrounding whitespace.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`DiagnosticSourceIdError::Empty`] when the trimmed value is empty.
77    pub fn new(value: impl AsRef<str>) -> Result<Self, DiagnosticSourceIdError> {
78        let trimmed = value.as_ref().trim();
79
80        if trimmed.is_empty() {
81            return Err(DiagnosticSourceIdError::Empty);
82        }
83
84        Ok(Self(trimmed.to_string()))
85    }
86
87    /// Returns the source identifier text.
88    #[must_use]
89    pub fn as_str(&self) -> &str {
90        &self.0
91    }
92
93    /// Consumes the source identifier and returns the owned string.
94    #[must_use]
95    pub fn into_string(self) -> String {
96        self.0
97    }
98}
99
100impl AsRef<str> for DiagnosticSourceId {
101    fn as_ref(&self) -> &str {
102        self.as_str()
103    }
104}
105
106impl fmt::Display for DiagnosticSourceId {
107    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108        formatter.write_str(self.as_str())
109    }
110}
111
112impl FromStr for DiagnosticSourceId {
113    type Err = DiagnosticSourceIdError;
114
115    fn from_str(value: &str) -> Result<Self, Self::Err> {
116        Self::new(value)
117    }
118}
119
120/// Errors returned while constructing a [`DiagnosticSourceId`].
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub enum DiagnosticSourceIdError {
123    /// The source ID was empty after trimming surrounding whitespace.
124    Empty,
125}
126
127impl fmt::Display for DiagnosticSourceIdError {
128    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            Self::Empty => formatter.write_str("diagnostic source ID cannot be empty"),
131        }
132    }
133}
134
135impl std::error::Error for DiagnosticSourceIdError {}
136
137/// A generic source span with optional source identity.
138#[derive(Clone, Debug, Eq, Hash, PartialEq)]
139pub struct DiagnosticSpan {
140    source: Option<DiagnosticSourceId>,
141    start: DiagnosticPosition,
142    end: DiagnosticPosition,
143}
144
145impl DiagnosticSpan {
146    /// Creates a diagnostic span and validates that the end is not before the start.
147    ///
148    /// # Errors
149    ///
150    /// Returns [`DiagnosticSpanError::Reversed`] when `end` is before `start`.
151    pub fn new(
152        source: Option<DiagnosticSourceId>,
153        start: DiagnosticPosition,
154        end: DiagnosticPosition,
155    ) -> Result<Self, DiagnosticSpanError> {
156        if end < start {
157            return Err(DiagnosticSpanError::Reversed);
158        }
159
160        Ok(Self { source, start, end })
161    }
162
163    /// Creates a span with a source identifier.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`DiagnosticSpanError::Reversed`] when `end` is before `start`.
168    pub fn with_source(
169        source: DiagnosticSourceId,
170        start: DiagnosticPosition,
171        end: DiagnosticPosition,
172    ) -> Result<Self, DiagnosticSpanError> {
173        Self::new(Some(source), start, end)
174    }
175
176    /// Creates a span without a source identifier.
177    ///
178    /// # Errors
179    ///
180    /// Returns [`DiagnosticSpanError::Reversed`] when `end` is before `start`.
181    pub fn without_source(
182        start: DiagnosticPosition,
183        end: DiagnosticPosition,
184    ) -> Result<Self, DiagnosticSpanError> {
185        Self::new(None, start, end)
186    }
187
188    /// Returns the optional source identifier.
189    #[must_use]
190    pub const fn source(&self) -> Option<&DiagnosticSourceId> {
191        self.source.as_ref()
192    }
193
194    /// Returns the start position.
195    #[must_use]
196    pub const fn start(&self) -> DiagnosticPosition {
197        self.start
198    }
199
200    /// Returns the end position.
201    #[must_use]
202    pub const fn end(&self) -> DiagnosticPosition {
203        self.end
204    }
205}
206
207/// Errors returned while constructing a [`DiagnosticSpan`].
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209pub enum DiagnosticSpanError {
210    /// The span end position was before the start position.
211    Reversed,
212}
213
214impl fmt::Display for DiagnosticSpanError {
215    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
216        match self {
217            Self::Reversed => formatter.write_str("diagnostic span end cannot be before start"),
218        }
219    }
220}
221
222impl std::error::Error for DiagnosticSpanError {}
223
224#[cfg(test)]
225mod tests {
226    use super::{
227        DiagnosticPosition, DiagnosticPositionError, DiagnosticSourceId, DiagnosticSpan,
228        DiagnosticSpanError,
229    };
230
231    #[test]
232    fn accepts_valid_position() {
233        let position = DiagnosticPosition::new(1, 1).expect("position should be valid");
234
235        assert_eq!(position.line(), 1);
236        assert_eq!(position.column(), 1);
237    }
238
239    #[test]
240    fn rejects_zero_line_or_column() {
241        assert_eq!(
242            DiagnosticPosition::new(0, 1),
243            Err(DiagnosticPositionError::ZeroLine)
244        );
245        assert_eq!(
246            DiagnosticPosition::new(1, 0),
247            Err(DiagnosticPositionError::ZeroColumn)
248        );
249    }
250
251    #[test]
252    fn accepts_valid_span() {
253        let start = DiagnosticPosition::new(2, 4).expect("position should be valid");
254        let end = DiagnosticPosition::new(2, 9).expect("position should be valid");
255        let span = DiagnosticSpan::without_source(start, end).expect("span should be valid");
256
257        assert_eq!(span.start(), start);
258        assert_eq!(span.end(), end);
259    }
260
261    #[test]
262    fn rejects_reversed_span() {
263        let start = DiagnosticPosition::new(3, 10).expect("position should be valid");
264        let end = DiagnosticPosition::new(3, 5).expect("position should be valid");
265
266        assert_eq!(
267            DiagnosticSpan::without_source(start, end),
268            Err(DiagnosticSpanError::Reversed)
269        );
270    }
271
272    #[test]
273    fn creates_span_with_source_id() {
274        let source = DiagnosticSourceId::new(" config.toml ").expect("source should be valid");
275        let start = DiagnosticPosition::new(4, 1).expect("position should be valid");
276        let end = DiagnosticPosition::new(4, 3).expect("position should be valid");
277        let span = DiagnosticSpan::with_source(source, start, end).expect("span should be valid");
278
279        assert_eq!(
280            span.source().map(DiagnosticSourceId::as_str),
281            Some("config.toml")
282        );
283    }
284
285    #[test]
286    fn creates_span_without_source_id() {
287        let start = DiagnosticPosition::new(1, 1).expect("position should be valid");
288        let end = DiagnosticPosition::new(1, 1).expect("position should be valid");
289        let span = DiagnosticSpan::without_source(start, end).expect("span should be valid");
290
291        assert!(span.source().is_none());
292    }
293}