Skip to main content

mars_schema/
markdown.rs

1//! Small Markdown syntax helpers shared by build and compiler paths.
2//!
3//! This lives beside `frontmatter` because fenced-block recognition is a
4//! property of Markdown input, not of either consumer's orchestration layer.
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct Fence {
8    pub marker: char,
9    pub length: usize,
10    pub indentation: usize,
11}
12
13#[derive(Debug, Default)]
14pub struct FenceTracker {
15    open: Option<Fence>,
16}
17
18impl FenceTracker {
19    /// Observe one source line, returning whether it belongs to a fenced block.
20    pub fn visit(&mut self, line: &str) -> bool {
21        if let Some(open) = self.open() {
22            if is_closing_fence(line, open) {
23                self.open = None;
24            }
25            return true;
26        }
27
28        if let Some(open) = opening_fence(line) {
29            self.open = Some(open);
30            return true;
31        }
32
33        false
34    }
35
36    pub fn open(&self) -> Option<Fence> {
37        self.open
38    }
39}
40
41fn opening_fence(line: &str) -> Option<Fence> {
42    let (indentation, content) = after_fence_indentation(line)?;
43    let marker = match content.as_bytes().first()? {
44        b'`' => b'`',
45        b'~' => b'~',
46        _ => return None,
47    };
48    let length = content
49        .as_bytes()
50        .iter()
51        .take_while(|candidate| **candidate == marker)
52        .count();
53    if length < 3 || (marker == b'`' && content[length..].contains('`')) {
54        return None;
55    }
56
57    Some(Fence {
58        marker: char::from(marker),
59        length,
60        indentation,
61    })
62}
63
64fn is_closing_fence(line: &str, open: Fence) -> bool {
65    let Some((_, content)) = after_fence_indentation(line) else {
66        return false;
67    };
68    let marker = open.marker as u8;
69    let length = content
70        .as_bytes()
71        .iter()
72        .take_while(|candidate| **candidate == marker)
73        .count();
74    length >= open.length
75        && content.as_bytes().first() == Some(&marker)
76        && without_line_ending(&content[length..])
77            .bytes()
78            .all(|byte| matches!(byte, b' ' | b'\t'))
79}
80
81fn after_fence_indentation(line: &str) -> Option<(usize, &str)> {
82    let indentation = line
83        .as_bytes()
84        .iter()
85        .take_while(|byte| **byte == b' ')
86        .count();
87    (indentation <= 3).then_some((indentation, &line[indentation..]))
88}
89
90fn without_line_ending(line: &str) -> &str {
91    let line = line.strip_suffix('\n').unwrap_or(line);
92    line.strip_suffix('\r').unwrap_or(line)
93}