ephemeris_documents/markdown/
scan.rs1use 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#[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 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#[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 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 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 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
123struct LineIndex {
125 starts: Vec<usize>,
126 len: usize,
127}
128
129impl LineIndex {
130 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 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#[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 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#[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 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 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}