Skip to main content

adze_linecol_core/
lib.rs

1//! Core line/column byte-position tracking utilities.
2//!
3//! The tracker is byte-oriented and supports `\n`, `\r`, and `\r\n` line endings.
4
5#![forbid(unsafe_op_in_unsafe_fn)]
6#![deny(missing_docs)]
7#![cfg_attr(feature = "strict_api", deny(unreachable_pub))]
8#![cfg_attr(not(feature = "strict_api"), warn(unreachable_pub))]
9#![cfg_attr(feature = "strict_docs", deny(missing_docs))]
10#![cfg_attr(not(feature = "strict_docs"), allow(missing_docs))]
11
12/// Tracks a zero-based line index and the byte offset where that line starts.
13///
14/// # Examples
15///
16/// ```
17/// use adze_linecol_core::LineCol;
18///
19/// let lc = LineCol::at_position(b"hello\nworld", 8);
20/// assert_eq!(lc.line, 1);
21/// assert_eq!(lc.column(8), 2);
22/// ```
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub struct LineCol {
25    /// Zero-based line index.
26    pub line: usize,
27    /// Byte offset for the start of the current line.
28    pub line_start: usize,
29}
30
31impl LineCol {
32    /// Create a new tracker at line `0`, byte offset `0`.
33    ///
34    /// # Examples
35    ///
36    /// ```
37    /// use adze_linecol_core::LineCol;
38    ///
39    /// let lc = LineCol::new();
40    /// assert_eq!(lc.line, 0);
41    /// assert_eq!(lc.line_start, 0);
42    /// ```
43    #[must_use]
44    pub const fn new() -> Self {
45        Self {
46            line: 0,
47            line_start: 0,
48        }
49    }
50
51    /// Compute line metadata for a byte position in `input`.
52    ///
53    /// If `position` is beyond `input.len()`, the end of input is used.
54    ///
55    /// # Examples
56    ///
57    /// ```
58    /// use adze_linecol_core::LineCol;
59    ///
60    /// let lc = LineCol::at_position(b"hello\nworld\n", 6);
61    /// assert_eq!(lc.line, 1);
62    /// assert_eq!(lc.line_start, 6);
63    /// assert_eq!(lc.column(8), 2);
64    /// ```
65    #[must_use]
66    pub fn at_position(input: &[u8], position: usize) -> Self {
67        let mut tracker = Self::new();
68        let end = position.min(input.len());
69
70        for i in 0..end {
71            if input[i] == b'\n' {
72                tracker.advance_line(i + 1);
73            } else if input[i] == b'\r' {
74                // CRLF is counted on the LF byte, not the CR byte.
75                if i + 1 < input.len() && input[i + 1] == b'\n' {
76                    continue;
77                }
78                tracker.advance_line(i + 1);
79            }
80        }
81
82        tracker
83    }
84
85    /// Advance to a new line, setting the new line's starting byte offset.
86    ///
87    /// # Examples
88    ///
89    /// ```
90    /// use adze_linecol_core::LineCol;
91    ///
92    /// let mut lc = LineCol::new();
93    /// lc.advance_line(5);
94    /// assert_eq!(lc.line, 1);
95    /// assert_eq!(lc.line_start, 5);
96    /// ```
97    pub fn advance_line(&mut self, new_line_start: usize) {
98        self.line += 1;
99        self.line_start = new_line_start;
100    }
101
102    /// Process one byte while scanning a stream and update line metadata.
103    ///
104    /// Returns `true` if the byte advanced to a new line.
105    ///
106    /// Note: for CRLF, this returns `false` for the CR byte and `true` for the LF byte.
107    ///
108    /// # Examples
109    ///
110    /// ```
111    /// use adze_linecol_core::LineCol;
112    ///
113    /// let mut lc = LineCol::new();
114    /// assert!(!lc.process_byte(b'a', None, 0));
115    /// assert!(lc.process_byte(b'\n', None, 1));
116    /// assert_eq!(lc.line, 1);
117    /// assert_eq!(lc.line_start, 2);
118    /// ```
119    pub fn process_byte(&mut self, byte: u8, next_byte: Option<u8>, current_offset: usize) -> bool {
120        match byte {
121            b'\n' => {
122                self.advance_line(current_offset + 1);
123                true
124            }
125            b'\r' => {
126                if next_byte == Some(b'\n') {
127                    false
128                } else {
129                    self.advance_line(current_offset + 1);
130                    true
131                }
132            }
133            _ => false,
134        }
135    }
136
137    /// Compute a byte-based column for `position`.
138    ///
139    /// # Examples
140    ///
141    /// ```
142    /// use adze_linecol_core::LineCol;
143    ///
144    /// let lc = LineCol::at_position(b"ab\ncd", 3);
145    /// assert_eq!(lc.column(3), 0); // start of line
146    /// assert_eq!(lc.column(4), 1); // one byte into line
147    /// ```
148    #[must_use]
149    pub fn column(&self, position: usize) -> usize {
150        position.saturating_sub(self.line_start)
151    }
152}
153
154impl std::fmt::Display for LineCol {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        write!(f, "line {}, col {}", self.line, self.line_start)
157    }
158}
159
160impl Default for LineCol {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn basic_newline_tracking() {
172        let input = b"hello\nworld\n";
173        let tracker = LineCol::at_position(input, 6);
174        assert_eq!(tracker.line, 1);
175        assert_eq!(tracker.line_start, 6);
176        assert_eq!(tracker.column(8), 2);
177    }
178
179    #[test]
180    fn crlf_handling() {
181        let input = b"hello\r\nworld\r\n";
182        let tracker = LineCol::at_position(input, 7);
183        assert_eq!(tracker.line, 1);
184        assert_eq!(tracker.line_start, 7);
185        assert_eq!(tracker.column(9), 2);
186    }
187
188    #[test]
189    fn cr_only_handling() {
190        let input = b"hello\rworld\r";
191        let tracker = LineCol::at_position(input, 6);
192        assert_eq!(tracker.line, 1);
193        assert_eq!(tracker.line_start, 6);
194    }
195
196    #[test]
197    fn process_byte_tracks_line_boundaries() {
198        let mut tracker = LineCol::new();
199
200        assert!(!tracker.process_byte(b'a', None, 0));
201        assert_eq!(tracker.line, 0);
202
203        assert!(tracker.process_byte(b'\n', None, 5));
204        assert_eq!(tracker.line, 1);
205        assert_eq!(tracker.line_start, 6);
206
207        assert!(tracker.process_byte(b'\r', Some(b'x'), 10));
208        assert_eq!(tracker.line, 2);
209        assert_eq!(tracker.line_start, 11);
210
211        assert!(!tracker.process_byte(b'\r', Some(b'\n'), 15));
212        assert_eq!(tracker.line, 2);
213    }
214
215    // --- Mutation-catching tests ---
216
217    #[test]
218    fn column_at_line_start_is_zero() {
219        let tracker = LineCol::at_position(b"ab\ncd", 3);
220        assert_eq!(tracker.column(3), 0);
221    }
222
223    #[test]
224    fn column_saturates_when_position_below_line_start() {
225        let tracker = LineCol {
226            line: 1,
227            line_start: 10,
228        };
229        assert_eq!(tracker.column(5), 0);
230    }
231
232    #[test]
233    fn advance_line_increments_line_by_exactly_one() {
234        let mut tracker = LineCol::new();
235        tracker.advance_line(5);
236        assert_eq!(tracker.line, 1);
237        assert_eq!(tracker.line_start, 5);
238        tracker.advance_line(10);
239        assert_eq!(tracker.line, 2);
240        assert_eq!(tracker.line_start, 10);
241    }
242
243    #[test]
244    fn at_position_zero_returns_initial_state() {
245        let tracker = LineCol::at_position(b"hello\nworld", 0);
246        assert_eq!(tracker.line, 0);
247        assert_eq!(tracker.line_start, 0);
248    }
249
250    #[test]
251    fn at_position_just_past_newline() {
252        let input = b"a\nb";
253        let before = LineCol::at_position(input, 1);
254        let after = LineCol::at_position(input, 2);
255        assert_eq!(before.line, 0);
256        assert_eq!(after.line, 1);
257        assert_eq!(after.line_start, 2);
258    }
259
260    #[test]
261    fn multiple_consecutive_newlines() {
262        let input = b"\n\n\n";
263        let tracker = LineCol::at_position(input, 3);
264        assert_eq!(tracker.line, 3);
265        assert_eq!(tracker.line_start, 3);
266    }
267
268    #[test]
269    fn new_fields_are_zero() {
270        let tracker = LineCol::new();
271        assert_eq!(tracker.line, 0);
272        assert_eq!(tracker.line_start, 0);
273    }
274}