Skip to main content

compose_lens/source/
mod.rs

1//! Source identities and byte-accurate locations.
2
3use std::fmt;
4use std::ops::Range;
5
6/// Identifies one source document within a caller-managed source collection.
7///
8/// `ComposeLens` deliberately does not infer paths or allocate global identifiers. A caller assigns
9/// an identifier when it supplies source text and can maintain any path or URI mapping separately.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct SourceId(u32);
12
13impl SourceId {
14    /// Creates an identifier from a caller-managed numeric value.
15    #[must_use]
16    pub const fn new(value: u32) -> Self {
17        Self(value)
18    }
19
20    /// Returns the caller-managed numeric value.
21    #[must_use]
22    pub const fn get(self) -> u32 {
23        self.0
24    }
25}
26
27impl fmt::Display for SourceId {
28    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29        write!(formatter, "source#{}", self.0)
30    }
31}
32
33/// A half-open byte range associated with one source document.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub struct SourceSpan {
36    source_id: SourceId,
37    start: usize,
38    end: usize,
39}
40
41impl SourceSpan {
42    /// Creates a span when `start` is not after `end`.
43    #[must_use]
44    pub const fn new(source_id: SourceId, start: usize, end: usize) -> Option<Self> {
45        if start <= end {
46            Some(Self { source_id, start, end })
47        } else {
48            None
49        }
50    }
51
52    pub(crate) const fn from_valid_offsets(source_id: SourceId, start: usize, end: usize) -> Self {
53        Self { source_id, start, end }
54    }
55
56    /// Returns the source document identifier.
57    #[must_use]
58    pub const fn source_id(self) -> SourceId {
59        self.source_id
60    }
61
62    /// Returns the inclusive start byte offset.
63    #[must_use]
64    pub const fn start(self) -> usize {
65        self.start
66    }
67
68    /// Returns the exclusive end byte offset.
69    #[must_use]
70    pub const fn end(self) -> usize {
71        self.end
72    }
73
74    /// Returns the half-open byte range.
75    #[must_use]
76    pub fn range(self) -> Range<usize> {
77        self.start..self.end
78    }
79
80    /// Returns the span length in bytes.
81    #[must_use]
82    pub const fn len(self) -> usize {
83        self.end - self.start
84    }
85
86    /// Reports whether this is an empty span.
87    #[must_use]
88    pub const fn is_empty(self) -> bool {
89        self.start == self.end
90    }
91
92    /// Reports whether the byte offset is inside the half-open span.
93    #[must_use]
94    pub const fn contains(self, byte_offset: usize) -> bool {
95        self.start <= byte_offset && byte_offset < self.end
96    }
97}
98
99/// A one-based line and Unicode-scalar column.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101pub struct LineColumn {
102    line: usize,
103    column: usize,
104}
105
106impl LineColumn {
107    /// Returns the one-based line number.
108    #[must_use]
109    pub const fn line(self) -> usize {
110        self.line
111    }
112
113    /// Returns the one-based column number.
114    #[must_use]
115    pub const fn column(self) -> usize {
116        self.column
117    }
118}
119
120/// Converts a byte offset into a one-based line and Unicode-scalar column.
121///
122/// Returns `None` when the offset is outside the text or is not a UTF-8 character boundary.
123#[must_use]
124pub fn line_column(text: &str, byte_offset: usize) -> Option<LineColumn> {
125    if byte_offset > text.len() || !text.is_char_boundary(byte_offset) {
126        return None;
127    }
128
129    let prefix = &text[..byte_offset];
130    let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1;
131    let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
132    let column = text[line_start..byte_offset].chars().count() + 1;
133
134    Some(LineColumn { line, column })
135}
136
137#[cfg(test)]
138mod tests {
139    use super::{SourceId, SourceSpan, line_column};
140
141    #[test]
142    fn rejects_a_reversed_span() {
143        assert_eq!(SourceSpan::new(SourceId::new(1), 4, 3), None);
144    }
145
146    #[test]
147    fn calculates_unicode_scalar_columns() {
148        let text = "name: Käfer\nimage: demo\n";
149        let position = line_column(text, "name: Kä".len());
150
151        assert_eq!(position.map(super::LineColumn::line), Some(1));
152        assert_eq!(position.map(super::LineColumn::column), Some(9));
153    }
154}