Skip to main content

substrait_explain/parser/
chunks.rs

1//! Physical-line / chunk management for the structural parser.
2//!
3//! This layer sits *below* the grammar and the relation tree. Its only job is
4//! to walk the input one physical line at a time and group adjacent lines into
5//! a **chunk**: a single contiguous slice of the original input that may span
6//! several physical lines. A chunk is later handed to the grammar as one unit.
7//!
8//! The layer is deliberately ignorant of syntax. It does not know what a
9//! continuation line (`- ...`) is, how indentation maps to depth, or that blank
10//! lines should be skipped. All of that is *policy*, owned by the caller. This
11//! layer only provides the mechanism:
12//!
13//! - [`ChunkCursor::peek_line`] — look at the next physical line without
14//!   consuming it,
15//! - [`ChunkCursor::merge`] — extend the current chunk to include that line,
16//! - [`ChunkCursor::next`] — finish the current chunk and produce a cursor for
17//!   the next one.
18//!
19//! ## The cursor is the chunk
20//!
21//! The cursor itself represents the current chunk: it tracks how much of the
22//! input the chunk has grown to cover and what remains after it. A chunk starts
23//! empty, so the first physical line is added through the same `peek_line` /
24//! `merge` path as every continuation line — there is no special case for the
25//! first line.
26//!
27//! Because the only line you can `merge` is the one [`peek_line`] just returned,
28//! a chunk is always a span of *contiguously consumed* lines. That invariant is
29//! what keeps a chunk a single `&str` slice instead of a list of fragments.
30//!
31//! [`peek_line`]: ChunkCursor::peek_line
32
33/// A single physical line peeked from a [`ChunkCursor`], together with the
34/// offset needed to consume it.
35///
36/// `line` is the line's content with the trailing newline (and any preceding
37/// `\r`) removed — this is what the caller inspects to decide whether to merge.
38/// `end` is the byte offset, within the originating cursor's `text`, of the
39/// *start of the following line* (i.e. just past the consumed `\n`). It is only
40/// meaningful when passed back to the same cursor via [`ChunkCursor::merge`].
41#[derive(Debug, Clone, Copy)]
42pub struct Line<'a> {
43    line: &'a str,
44    end: usize,
45}
46
47impl<'a> Line<'a> {
48    /// The line's content, without the trailing newline or a preceding `\r`.
49    pub fn as_str(&self) -> &'a str {
50        self.line
51    }
52}
53
54/// A forward cursor over the input that yields one chunk at a time.
55///
56/// Each chunk is a contiguous slice of the input spanning one or more physical
57/// lines. Use [`peek_line`] to inspect the next line, [`merge`] to extend the
58/// current chunk to include it, and [`next`] to finish the chunk and obtain a
59/// cursor for what follows. See the [module docs](self) for the overall model.
60///
61/// The cursor is move-only on purpose: [`next`] consumes it by value, so a
62/// finished chunk cannot be accidentally reused or finished twice.
63///
64/// [`peek_line`]: ChunkCursor::peek_line
65/// [`merge`]: ChunkCursor::merge
66/// [`next`]: ChunkCursor::next
67#[derive(Debug)]
68pub struct ChunkCursor<'a> {
69    /// The original input, starting at the first byte of the current chunk.
70    text: &'a str,
71    /// Number of bytes of `text` merged into the current chunk so far. The
72    /// chunk is `&text[..current_end]`; this always sits on a line boundary.
73    current_end: usize,
74    /// 1-based line number of the chunk's first physical line.
75    start_line_no: i64,
76    /// Line number that the next [`peek_line`](Self::peek_line) will return.
77    /// Equivalently, `start_line_no + (lines merged so far)`.
78    next_line_no: i64,
79}
80
81impl<'a> ChunkCursor<'a> {
82    /// Create a cursor over `text`, whose first physical line is numbered
83    /// `start_line_no`. Returns `None` if `text` is empty (no chunk to build).
84    pub fn new(text: &'a str, start_line_no: i64) -> Option<Self> {
85        if text.is_empty() {
86            return None;
87        }
88        Some(ChunkCursor {
89            text,
90            current_end: 0,
91            start_line_no,
92            next_line_no: start_line_no,
93        })
94    }
95
96    /// 1-based line number of the chunk's first physical line.
97    pub fn start_line_no(&self) -> i64 {
98        self.start_line_no
99    }
100
101    /// The next physical line after the current chunk, without consuming it.
102    /// Returns `None` once the whole input has been consumed.
103    pub fn peek_line(&self) -> Option<Line<'a>> {
104        if self.current_end >= self.text.len() {
105            return None;
106        }
107
108        let rest = &self.text[self.current_end..];
109        match rest.find('\n') {
110            Some(nl) => {
111                // Exclude the '\n' and a preceding '\r' from the content, but
112                // advance `end` past the '\n' to the start of the next line.
113                let line = rest[..nl].strip_suffix('\r').unwrap_or(&rest[..nl]);
114                Some(Line {
115                    line,
116                    end: self.current_end + nl + 1,
117                })
118            }
119            // Last line: no trailing newline.
120            None => Some(Line {
121                line: rest,
122                end: self.text.len(),
123            }),
124        }
125    }
126
127    /// Extend the current chunk to include `line`.
128    ///
129    /// `line` must be the value most recently returned by [`peek_line`] on this
130    /// same cursor; passing anything else is a programming error.
131    ///
132    /// [`peek_line`]: Self::peek_line
133    pub fn merge(&mut self, line: Line<'a>) {
134        debug_assert!(
135            line.end > self.current_end && line.end <= self.text.len(),
136            "merge() given a Line whose end {} is outside the unread region [{}, {}]",
137            line.end,
138            self.current_end,
139            self.text.len(),
140        );
141        debug_assert_eq!(
142            line.line.as_ptr(),
143            self.text[self.current_end..].as_ptr(),
144            "merge() given a Line that did not come from this cursor's peek_line()",
145        );
146
147        self.current_end = line.end;
148        self.next_line_no += 1;
149    }
150
151    /// Finish the current chunk: return its text together with a cursor for the
152    /// next chunk, or `None` if the input is exhausted.
153    pub fn next(self) -> (&'a str, Option<ChunkCursor<'a>>) {
154        let chunk = &self.text[..self.current_end];
155        let rest = &self.text[self.current_end..];
156
157        let next = if rest.is_empty() {
158            None
159        } else {
160            Some(ChunkCursor {
161                text: rest,
162                current_end: 0,
163                start_line_no: self.next_line_no,
164                next_line_no: self.next_line_no,
165            })
166        };
167
168        (chunk, next)
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    /// Consume `text` into chunks, merging a line only when `merge` accepts it.
177    /// Returns `(chunk_text, start_line_no, end_line_no)` for each chunk.
178    fn collect(
179        text: &str,
180        mut merge: impl FnMut(&ChunkCursor, &Line) -> bool,
181    ) -> Vec<(String, i64, i64)> {
182        let mut out = Vec::new();
183        let mut cursor = ChunkCursor::new(text, 1);
184        while let Some(mut c) = cursor {
185            // First line is always part of the chunk.
186            let first = c.peek_line().expect("a fresh cursor has at least one line");
187            c.merge(first);
188            // Subsequent lines merge only if the policy accepts them.
189            while let Some(line) = c.peek_line() {
190                if merge(&c, &line) {
191                    c.merge(line);
192                } else {
193                    break;
194                }
195            }
196            // `end` reads the internal counter directly (white-box): the public
197            // surface intentionally exposes only the start line, as no caller
198            // needs the end line yet.
199            let (start, end) = (c.start_line_no(), c.next_line_no - 1);
200            let (chunk, rest) = c.next();
201            out.push((chunk.to_string(), start, end));
202            cursor = rest;
203        }
204        out
205    }
206
207    /// Never merge past the first line: one chunk per physical line.
208    fn one_per_line(text: &str) -> Vec<(String, i64, i64)> {
209        collect(text, |_, _| false)
210    }
211
212    #[test]
213    fn empty_input_has_no_chunks() {
214        assert!(ChunkCursor::new("", 1).is_none());
215    }
216
217    #[test]
218    fn single_line_no_trailing_newline() {
219        assert_eq!(one_per_line("abc"), vec![("abc".to_string(), 1, 1)]);
220    }
221
222    #[test]
223    fn single_line_with_trailing_newline() {
224        // The trailing newline does not produce a spurious empty chunk, but it
225        // is part of the (non-final) chunk slice.
226        assert_eq!(one_per_line("abc\n"), vec![("abc\n".to_string(), 1, 1)]);
227    }
228
229    #[test]
230    fn multiple_lines_one_per_chunk() {
231        assert_eq!(
232            one_per_line("a\nb\nc"),
233            vec![
234                ("a\n".to_string(), 1, 1),
235                ("b\n".to_string(), 2, 2),
236                ("c".to_string(), 3, 3),
237            ],
238        );
239    }
240
241    #[test]
242    fn blank_lines_are_their_own_chunks() {
243        // The layer does not skip blanks; that is the caller's policy.
244        assert_eq!(
245            one_per_line("a\n\nb"),
246            vec![
247                ("a\n".to_string(), 1, 1),
248                ("\n".to_string(), 2, 2),
249                ("b".to_string(), 3, 3),
250            ],
251        );
252    }
253
254    #[test]
255    fn peek_does_not_consume() {
256        let cursor = ChunkCursor::new("a\nb", 1).unwrap();
257        let first = cursor.peek_line().unwrap();
258        let again = cursor.peek_line().unwrap();
259        assert_eq!(first.as_str(), "a");
260        assert_eq!(again.as_str(), "a");
261        assert_eq!(cursor.current_end, 0); // nothing consumed yet
262    }
263
264    #[test]
265    fn merge_builds_a_contiguous_multiline_slice() {
266        // Merge a line when it is indented under the first and starts with "- ".
267        let text = "Read:Virtual[\n  - (1, 'alice')\n  - => id:i64]\nNext";
268        let chunks = collect(text, |_, line| line.as_str().trim_start().starts_with("- "));
269
270        assert_eq!(chunks.len(), 2);
271        // The whole multi-line relation is one contiguous slice...
272        assert_eq!(
273            chunks[0],
274            (
275                "Read:Virtual[\n  - (1, 'alice')\n  - => id:i64]\n".to_string(),
276                1,
277                3,
278            ),
279        );
280        // ...and the following line starts a fresh chunk at the right line number.
281        assert_eq!(chunks[1], ("Next".to_string(), 4, 4));
282    }
283
284    #[test]
285    fn carriage_returns_are_stripped_from_line_content_but_kept_in_chunk() {
286        let cursor = ChunkCursor::new("a\r\nb", 1).unwrap();
287        let line = cursor.peek_line().unwrap();
288        // Content has the \r stripped...
289        assert_eq!(line.as_str(), "a");
290        // ...but merging keeps the original bytes in the chunk slice.
291        let mut c = cursor;
292        c.merge(line);
293        let (chunk, _) = c.next();
294        assert_eq!(chunk, "a\r\n");
295    }
296
297    #[test]
298    fn line_numbers_account_for_a_custom_start() {
299        // A cursor can start at an arbitrary line number (e.g. mid-file).
300        let chunks = collect("x\ny", |_, _| false)
301            .into_iter()
302            .map(|(_, s, e)| (s, e))
303            .collect::<Vec<_>>();
304        assert_eq!(chunks, vec![(1, 1), (2, 2)]);
305
306        let mut cursor = ChunkCursor::new("x\ny", 10);
307        let mut starts = Vec::new();
308        while let Some(mut c) = cursor {
309            c.merge(c.peek_line().unwrap());
310            starts.push(c.start_line_no());
311            cursor = c.next().1;
312        }
313        assert_eq!(starts, vec![10, 11]);
314    }
315
316    #[test]
317    fn end_line_no_tracks_merged_lines() {
318        let text = "a\nb\nc\nd";
319        // Merge everything into one chunk.
320        let chunks = collect(text, |_, _| true);
321        assert_eq!(chunks.len(), 1);
322        assert_eq!(chunks[0].1, 1); // start
323        assert_eq!(chunks[0].2, 4); // end
324        assert_eq!(chunks[0].0, "a\nb\nc\nd");
325    }
326}