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}