Skip to main content

snapper_fmt/parser/
span.rs

1//! Source-byte spans for splice reflow.
2//!
3//! Native parsers record half-open `[start, end)` ranges into the original
4//! input. Structure, blank, and code regions are those slices; only prose
5//! (and a configured code-comment body) is rewritten. Output is assembled
6//! by copying the gaps between rewrite ranges.
7
8use crate::parser::Region;
9
10/// Half-open byte range `[start, end)` into the parser input.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
12pub struct ByteSpan {
13    pub start: usize,
14    pub end: usize,
15}
16
17impl ByteSpan {
18    pub const fn new(start: usize, end: usize) -> Self {
19        Self { start, end }
20    }
21
22    pub const fn is_empty(self) -> bool {
23        self.start == self.end
24    }
25
26    /// Slice `input` if the range is in bounds and on a char boundary.
27    pub fn slice(self, input: &str) -> Option<&str> {
28        input.get(self.start..self.end)
29    }
30}
31
32/// Origin recorded by a parser for one [`Region`].
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum RegionOrigin {
35    /// One contiguous source range (prose, structure, blank).
36    Whole(ByteSpan),
37    /// Fenced/env code block with independently recorded parts.
38    Code {
39        header: ByteSpan,
40        body: ByteSpan,
41        footer: ByteSpan,
42    },
43}
44
45impl RegionOrigin {
46    pub fn whole(self) -> ByteSpan {
47        match self {
48            RegionOrigin::Whole(s) => s,
49            RegionOrigin::Code { header, footer, .. } => {
50                ByteSpan::new(header.start, footer.end.max(header.end))
51            }
52        }
53    }
54
55    pub fn code(self) -> Option<CodeSpans> {
56        match self {
57            RegionOrigin::Code {
58                header,
59                body,
60                footer,
61            } => Some(CodeSpans {
62                header,
63                body,
64                footer,
65            }),
66            RegionOrigin::Whole(_) => None,
67        }
68    }
69}
70
71/// Header/body/footer spans of a [`Region::Code`].
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct CodeSpans {
74    pub header: ByteSpan,
75    pub body: ByteSpan,
76    pub footer: ByteSpan,
77}
78
79/// A classified region plus the parser-recorded source origin, if any.
80///
81/// Pandoc reconstructs regions from an AST and leaves `origin` unset.
82/// Native line parsers always set it.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct SpannedRegion {
85    pub region: Region,
86    pub origin: Option<RegionOrigin>,
87}
88
89impl SpannedRegion {
90    pub fn unspanned(region: Region) -> Self {
91        Self {
92            region,
93            origin: None,
94        }
95    }
96
97    pub fn prose(text: String, span: ByteSpan) -> Self {
98        Self {
99            region: Region::Prose(text),
100            origin: Some(RegionOrigin::Whole(span)),
101        }
102    }
103
104    pub fn structure(input: &str, span: ByteSpan) -> Self {
105        Self {
106            region: Region::Structure(input[span.start..span.end].to_string()),
107            origin: Some(RegionOrigin::Whole(span)),
108        }
109    }
110
111    pub fn blank(input: &str, span: ByteSpan) -> Self {
112        Self {
113            region: Region::BlankLines(input[span.start..span.end].to_string()),
114            origin: Some(RegionOrigin::Whole(span)),
115        }
116    }
117
118    pub fn code(
119        input: &str,
120        lang: Option<String>,
121        header: ByteSpan,
122        body: ByteSpan,
123        footer: ByteSpan,
124    ) -> Self {
125        Self {
126            region: Region::Code {
127                lang,
128                header: input[header.start..header.end].to_string(),
129                body: input[body.start..body.end].to_string(),
130                footer: input[footer.start..footer.end].to_string(),
131            },
132            origin: Some(RegionOrigin::Code {
133                header,
134                body,
135                footer,
136            }),
137        }
138    }
139}
140
141/// One physical line of `input`, with byte offsets.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub struct Line<'a> {
144    /// Byte offset of the first character of the line.
145    pub start: usize,
146    /// Byte offset past the terminator (`\n` or `\r\n`), or EOF.
147    pub end: usize,
148    /// Line text without the terminator.
149    pub text: &'a str,
150}
151
152impl Line<'_> {
153    pub fn span(self) -> ByteSpan {
154        ByteSpan::new(self.start, self.end)
155    }
156
157    /// Range of `text` only (no terminator).
158    pub fn content_span(self) -> ByteSpan {
159        ByteSpan::new(self.start, self.start + self.text.len())
160    }
161
162    /// Range of the terminator, empty at EOF with no trailing newline.
163    pub fn terminator_span(self) -> ByteSpan {
164        ByteSpan::new(self.start + self.text.len(), self.end)
165    }
166}
167
168/// Split `input` into lines the way [`str::lines`] does, but keep offsets.
169///
170/// Terminators are `\n` or `\r\n`. A final line without a terminator is
171/// included. `""` yields no lines.
172pub fn iter_lines(input: &str) -> Vec<Line<'_>> {
173    let mut lines = Vec::new();
174    let bytes = input.as_bytes();
175    let mut start = 0;
176    let mut i = 0;
177    while i < bytes.len() {
178        if bytes[i] == b'\n' {
179            let content_end = if i > start && bytes[i - 1] == b'\r' {
180                i - 1
181            } else {
182                i
183            };
184            lines.push(Line {
185                start,
186                end: i + 1,
187                text: &input[start..content_end],
188            });
189            start = i + 1;
190        }
191        i += 1;
192    }
193    if start < input.len() {
194        lines.push(Line {
195            start,
196            end: input.len(),
197            text: &input[start..],
198        });
199    }
200    lines
201}
202
203/// Append a physical line to the running prose buffer and extend its span.
204///
205/// `include_terminator` is true for ordinary paragraphs (the original
206/// newline is inside the rewrite range) and false for list-item text
207/// (the terminator is a separate Structure slice).
208pub fn push_prose_line(
209    prose: &mut String,
210    prose_span: &mut Option<ByteSpan>,
211    line: &Line<'_>,
212    join_space: bool,
213    include_terminator: bool,
214) {
215    if !prose.is_empty() && join_space {
216        prose.push(' ');
217    }
218    prose.push_str(line.text.trim());
219    let end = if include_terminator {
220        line.end
221    } else {
222        line.start + line.text.len()
223    };
224    match prose_span {
225        None => *prose_span = Some(ByteSpan::new(line.start, end)),
226        Some(s) => s.end = end,
227    }
228}
229
230/// Flush accumulated prose into the region list.
231pub fn flush_prose_spanned(
232    prose: &mut String,
233    prose_span: &mut Option<ByteSpan>,
234    regions: &mut Vec<SpannedRegion>,
235) {
236    if prose.is_empty() {
237        return;
238    }
239    let span = prose_span.take().unwrap_or(ByteSpan::new(0, 0));
240    regions.push(SpannedRegion::prose(std::mem::take(prose), span));
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn iter_lines_matches_str_lines_and_keeps_newlines() {
249        let input = "a\nb\n\nc";
250        let lines = iter_lines(input);
251        let plain: Vec<&str> = input.lines().collect();
252        assert_eq!(lines.iter().map(|l| l.text).collect::<Vec<_>>(), plain);
253        assert_eq!(lines[0].span().slice(input), Some("a\n"));
254        assert_eq!(lines[1].span().slice(input), Some("b\n"));
255        assert_eq!(lines[2].span().slice(input), Some("\n"));
256        assert_eq!(lines[3].span().slice(input), Some("c"));
257        assert!(lines[3].terminator_span().is_empty());
258    }
259
260    #[test]
261    fn iter_lines_empty() {
262        assert!(iter_lines("").is_empty());
263    }
264
265    #[test]
266    fn iter_lines_crlf() {
267        let input = "a\r\nb\r\n";
268        let lines = iter_lines(input);
269        assert_eq!(lines.len(), 2);
270        assert_eq!(lines[0].text, "a");
271        assert_eq!(lines[0].span().slice(input), Some("a\r\n"));
272        assert_eq!(lines[1].text, "b");
273    }
274}