Skip to main content

ephemeris_documents/markdown/
scan.rs

1//! Locating headings and marker regions in an existing Markdown document.
2
3use std::collections::BTreeMap;
4use std::ops::Range;
5use std::slice::SliceIndex;
6
7use comrak::nodes::NodeValue;
8use comrak::{Arena, Options, parse_document};
9
10use super::ast::HeadingLevel;
11use super::marker_name::MarkerNameBuf;
12use crate::error::{DocumentError, InternalError, MarkdownError};
13
14/// The text of a Markdown document.
15#[derive(Debug, Clone, Copy)]
16pub(super) struct Text<'a>(&'a str);
17
18impl<'a> Text<'a> {
19    pub(super) const fn new(text: &'a str) -> Self {
20        Self(text)
21    }
22
23    pub(super) const fn len(self) -> usize {
24        self.0.len()
25    }
26
27    /// Slice the text fallibly instead of indexing it directly, since the
28    /// offsets spliced into a Markdown document come from a separate parse
29    /// pass and should never land outside the string or across a UTF-8
30    /// boundary, but must not panic if that invariant is ever violated.
31    pub(super) fn slice<I: SliceIndex<str>>(
32        self,
33        range: I,
34    ) -> Result<&'a I::Output, InternalError> {
35        self.0
36            .get(range)
37            .ok_or(InternalError::MarkdownSpliceOutOfBounds)
38    }
39
40    fn lines(self) -> LineIndex {
41        LineIndex::new(self.0)
42    }
43}
44
45/// Top level headings and marker regions of a document, as byte ranges.
46#[derive(Debug)]
47pub(super) struct Targets<'a> {
48    headings: Vec<Heading>,
49    markers: BTreeMap<&'a str, Range<usize>>,
50}
51
52#[derive(Debug)]
53struct Heading {
54    level: HeadingLevel,
55    range: Range<usize>,
56}
57
58impl<'a> Targets<'a> {
59    /// Scan the direct children of the document for headings and marker
60    /// comments. Anything nested in a container or code block is ignored.
61    pub(super) fn scan(source: Text<'a>) -> Result<Self, DocumentError> {
62        let arena = Arena::new();
63        let root = parse_document(&arena, source.0, &Options::default());
64        let lines = source.lines();
65        let mut headings = Vec::new();
66        let mut pairing = Pairing::default();
67        for node in root.children() {
68            let ast = node.data();
69            let first = ast.sourcepos.start.line;
70            #[expect(
71                clippy::wildcard_enum_match_arm,
72                reason = "comrak::nodes::NodeValue is a foreign, growable enum"
73            )]
74            match &ast.value {
75                NodeValue::Heading(heading) => headings.push(Heading {
76                    level: HeadingLevel::try_from(heading.level)?,
77                    range: lines.span(first, ast.sourcepos.end.line)?,
78                }),
79                NodeValue::HtmlBlock(_) => {
80                    let line = lines.span(first, first)?;
81                    if let Some(marker) = Marker::parse(source.slice(line.clone())?, first)? {
82                        match marker.kind {
83                            MarkerKind::Begin => pairing.begin(marker.name, line.end)?,
84                            MarkerKind::End => pairing.end(marker.name, line.start)?,
85                        }
86                    }
87                }
88                _ => {}
89            }
90        }
91        Ok(Self {
92            headings,
93            markers: pairing.into_regions()?,
94        })
95    }
96
97    /// The range of the single heading with this level.
98    pub(super) fn heading(&self, level: HeadingLevel) -> Result<Range<usize>, MarkdownError> {
99        let mut matches = self
100            .headings
101            .iter()
102            .filter(|heading| heading.level == level);
103        let Some(heading) = matches.next() else {
104            return Err(MarkdownError::HeadingNotFound { level: level.get() });
105        };
106        if matches.next().is_some() {
107            return Err(MarkdownError::HeadingAmbiguous { level: level.get() });
108        }
109        Ok(heading.range.clone())
110    }
111
112    /// The range between the begin and end marker lines of this name.
113    pub(super) fn marker(&self, name: &str) -> Result<Range<usize>, MarkdownError> {
114        self.markers
115            .get(name)
116            .cloned()
117            .ok_or_else(|| MarkdownError::MarkerNotFound {
118                marker: name.to_owned(),
119            })
120    }
121}
122
123/// Byte offsets of line starts, for turning line numbers into ranges.
124struct LineIndex {
125    starts: Vec<usize>,
126    len: usize,
127}
128
129impl LineIndex {
130    /// Lines end at `\n`.
131    fn new(source: &str) -> Self {
132        let mut starts = vec![0];
133        starts.extend(
134            source
135                .match_indices('\n')
136                .map(|(index, _)| index.saturating_add(1)),
137        );
138        Self {
139            starts,
140            len: source.len(),
141        }
142    }
143
144    /// The byte range of the 1 based lines `first..=last`, including the
145    /// terminator of the last one. Whole lines never split a character.
146    fn span(&self, first: usize, last: usize) -> Result<Range<usize>, InternalError> {
147        let start = first
148            .checked_sub(1)
149            .and_then(|index| self.starts.get(index))
150            .ok_or(InternalError::MarkdownSpliceOutOfBounds)?;
151        last.checked_sub(1)
152            .and_then(|index| self.starts.get(index))
153            .ok_or(InternalError::MarkdownSpliceOutOfBounds)?;
154        let end = self.starts.get(last).copied().unwrap_or(self.len);
155        Ok(*start..end)
156    }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160enum MarkerKind {
161    Begin,
162    End,
163}
164
165/// A parsed `<!-- ephemeris:begin name -->` or `<!-- ephemeris:end name -->`
166/// line.
167#[derive(Debug)]
168struct Marker<'a> {
169    kind: MarkerKind,
170    name: &'a str,
171}
172
173impl<'a> Marker<'a> {
174    const PREFIX: &'static str = "<!-- ephemeris:";
175    const SUFFIX: &'static str = " -->";
176
177    /// Parse the text of the 1 based line number `number`. Lines that do not
178    /// start with the marker prefix are not markers; lines that do must be
179    /// well formed.
180    fn parse(line: &'a str, number: usize) -> Result<Option<Self>, MarkdownError> {
181        let Some(rest) = line.trim().strip_prefix(Self::PREFIX) else {
182            return Ok(None);
183        };
184        rest.split_once(' ')
185            .and_then(|(kind, rest)| {
186                let kind = match kind {
187                    "begin" => MarkerKind::Begin,
188                    "end" => MarkerKind::End,
189                    _ => return None,
190                };
191                let name = rest.strip_suffix(Self::SUFFIX)?;
192                MarkerNameBuf::check(name).ok()?;
193                Some(Self { kind, name })
194            })
195            .map(Some)
196            .ok_or(MarkdownError::MarkerMalformed { line: number })
197    }
198}
199
200/// Pairs begin and end markers into regions.
201#[derive(Debug, Default)]
202struct Pairing<'a> {
203    open: Option<(&'a str, usize)>,
204    closed: BTreeMap<&'a str, Range<usize>>,
205}
206
207impl<'a> Pairing<'a> {
208    /// Open a region whose body starts at `body_start`.
209    fn begin(&mut self, name: &'a str, body_start: usize) -> Result<(), MarkdownError> {
210        if self.open.is_some() {
211            return Err(MarkdownError::MarkerNested {
212                marker: name.to_owned(),
213            });
214        }
215        if self.closed.contains_key(&name) {
216            return Err(MarkdownError::MarkerDuplicated {
217                marker: name.to_owned(),
218            });
219        }
220        self.open = Some((name, body_start));
221        Ok(())
222    }
223
224    /// Close the open region, whose body ends at `body_end`.
225    fn end(&mut self, name: &'a str, body_end: usize) -> Result<(), MarkdownError> {
226        match self.open.take() {
227            Some((open, start)) if open == name => {
228                self.closed.insert(name, start..body_end);
229                Ok(())
230            }
231            Some(_) | None => Err(MarkdownError::MarkerEndUnmatched {
232                marker: name.to_owned(),
233            }),
234        }
235    }
236
237    fn into_regions(self) -> Result<BTreeMap<&'a str, Range<usize>>, MarkdownError> {
238        if let Some((name, _)) = self.open {
239            return Err(MarkdownError::MarkerBeginUnmatched {
240                marker: name.to_owned(),
241            });
242        }
243        Ok(self.closed)
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::{Targets, Text};
250    use crate::markdown::ast::HeadingLevel;
251
252    fn level(level: u8) -> HeadingLevel {
253        HeadingLevel::try_from(level).unwrap()
254    }
255
256    #[test]
257    fn heading_ranges_cover_whole_lines() {
258        let source = "a\n# One\nb\n\nTwo\n---\n";
259        let targets = Targets::scan(Text::new(source)).unwrap();
260        let one = targets.heading(level(1)).unwrap();
261        assert_eq!(source.get(one).unwrap(), "# One\n");
262        let two = targets.heading(level(2)).unwrap();
263        assert_eq!(source.get(two).unwrap(), "Two\n---\n");
264    }
265
266    #[test]
267    fn marker_ranges_exclude_both_marker_lines() {
268        let source = "<!-- ephemeris:begin a -->\nx\n<!-- ephemeris:end a -->";
269        let targets = Targets::scan(Text::new(source)).unwrap();
270        let range = targets.marker("a").unwrap();
271        assert_eq!(source.get(range).unwrap(), "x\n");
272    }
273
274    #[test]
275    fn leading_byte_order_mark_is_accepted() {
276        let targets = Targets::scan(Text::new("\u{feff}# Title\n")).unwrap();
277        assert!(targets.heading(level(1)).is_ok());
278    }
279}