Skip to main content

ephemeris_documents/markdown/
ast.rs

1//! Wire types for Markdown documents and selectors.
2
3use facet::Facet;
4
5use super::marker_name::MarkerNameBuf;
6use crate::error::MarkdownError;
7
8/// A heading level from 1 to 6.
9#[derive(Debug, Clone, Copy, Facet, PartialEq, Eq)]
10#[facet(proxy = u8)]
11pub struct HeadingLevel(u8);
12
13impl HeadingLevel {
14    /// Level 1.
15    pub const H1: Self = Self(1);
16    /// Level 2.
17    pub const H2: Self = Self(2);
18    /// Level 3.
19    pub const H3: Self = Self(3);
20    /// Level 4.
21    pub const H4: Self = Self(4);
22    /// Level 5.
23    pub const H5: Self = Self(5);
24    /// Level 6.
25    pub const H6: Self = Self(6);
26
27    /// The level for a number from 1 to 6, or `None` outside that range.
28    #[must_use]
29    pub const fn new(level: u8) -> Option<Self> {
30        if matches!(level, 1..=6) {
31            Some(Self(level))
32        } else {
33            None
34        }
35    }
36
37    /// The level as a number from 1 to 6.
38    #[must_use]
39    pub const fn get(self) -> u8 {
40        self.0
41    }
42}
43
44impl TryFrom<u8> for HeadingLevel {
45    type Error = MarkdownError;
46
47    fn try_from(level: u8) -> Result<Self, Self::Error> {
48        Self::new(level).ok_or(MarkdownError::HeadingLevelOutOfRange { level })
49    }
50}
51
52impl From<&HeadingLevel> for u8 {
53    fn from(level: &HeadingLevel) -> Self {
54        level.0
55    }
56}
57
58/// Inline Markdown content within a block.
59#[derive(Debug, Clone, Facet, PartialEq, Eq)]
60#[facet(tag = "type", deny_unknown_fields)]
61#[repr(u8)]
62pub enum MarkdownInline {
63    /// Plain text.
64    #[facet(rename = "text")]
65    Text {
66        /// The literal text.
67        value: String,
68    },
69    /// Emphasized (`*italic*`) text.
70    #[facet(rename = "emphasis")]
71    Emphasis {
72        /// The emphasized content.
73        content: Vec<Self>,
74    },
75    /// Strong (`**bold**`) text.
76    #[facet(rename = "strong")]
77    Strong {
78        /// The strong content.
79        content: Vec<Self>,
80    },
81    /// Inline code (`` `code` ``).
82    #[facet(rename = "code")]
83    Code {
84        /// The literal code text.
85        value: String,
86    },
87    /// A hyperlink.
88    #[facet(rename = "link")]
89    Link {
90        /// The link target.
91        destination: String,
92        /// The link's visible content.
93        content: Vec<Self>,
94    },
95}
96
97/// A block-level Markdown element.
98#[derive(Debug, Clone, Facet, PartialEq, Eq)]
99#[facet(tag = "type", deny_unknown_fields)]
100#[repr(u8)]
101pub enum MarkdownBlock {
102    /// An ATX heading (`# Heading`).
103    #[facet(rename = "heading")]
104    Heading {
105        /// The heading level.
106        level: HeadingLevel,
107        /// The heading's inline content.
108        content: Vec<MarkdownInline>,
109    },
110    /// A paragraph.
111    #[facet(rename = "paragraph")]
112    Paragraph {
113        /// The paragraph's inline content.
114        content: Vec<MarkdownInline>,
115    },
116    /// A bullet list.
117    #[facet(rename = "unordered-list")]
118    UnorderedList {
119        /// Each item's blocks.
120        items: Vec<Vec<Self>>,
121    },
122    /// A fenced code block.
123    #[facet(rename = "code-block")]
124    CodeBlock {
125        /// The info string after the opening fence, if any.
126        language: Option<String>,
127        /// The verbatim code.
128        value: String,
129    },
130}
131
132impl MarkdownBlock {
133    /// Whether this block, or a block nested in it, is a heading of `level`.
134    pub(crate) fn contains_heading(&self, level: HeadingLevel) -> bool {
135        match self {
136            Self::Heading {
137                level: own,
138                content: _,
139            } => *own == level,
140            Self::Paragraph { content: _ }
141            | Self::CodeBlock {
142                language: _,
143                value: _,
144            } => false,
145            Self::UnorderedList { items } => items
146                .iter()
147                .any(|item| item.iter().any(|block| block.contains_heading(level))),
148        }
149    }
150}
151
152/// A selector naming one region of an existing Markdown document to manage.
153#[derive(Debug, Clone, Facet, PartialEq, Eq)]
154#[facet(tag = "type", deny_unknown_fields)]
155#[repr(u8)]
156pub enum MarkdownSelector {
157    /// Selects the unique heading of a level and replaces its text.
158    #[facet(rename = "heading")]
159    Heading {
160        /// The level of the one heading to match.
161        level: HeadingLevel,
162        /// The replacement heading text.
163        content: String,
164    },
165    /// Selects a named marker region.
166    #[facet(rename = "marker")]
167    Marker {
168        /// The marker's name.
169        name: MarkerNameBuf,
170        /// The replacement blocks.
171        blocks: Vec<MarkdownBlock>,
172    },
173}
174
175#[cfg(test)]
176mod tests {
177    use facet_value::Value;
178
179    use super::{HeadingLevel, MarkdownBlock, MarkdownSelector};
180
181    const GOOD_LEVEL: &str = r#"{"type":"heading","level":2,"content":[]}"#;
182    const BAD_LEVEL: &str = r#"{"type":"heading","level":7,"content":[]}"#;
183    const GOOD_NAME: &str = r#"{"type":"marker","name":"a","blocks":[]}"#;
184    const BAD_NAME: &str = r#"{"type":"marker","name":" a","blocks":[]}"#;
185
186    #[test]
187    fn heading_level_is_checked_by_the_json_decoder() {
188        let block: MarkdownBlock = facet_json::from_str(GOOD_LEVEL).unwrap();
189        assert!(matches!(
190            block,
191            MarkdownBlock::Heading {
192                level: HeadingLevel::H2,
193                content: _,
194            }
195        ));
196        assert_eq!(facet_json::to_string(&block).unwrap(), GOOD_LEVEL);
197        assert!(facet_json::from_str::<MarkdownBlock>(BAD_LEVEL).is_err());
198    }
199
200    #[test]
201    fn heading_level_is_checked_by_the_value_decoder() {
202        let good: Value = facet_json::from_str(GOOD_LEVEL).unwrap();
203        let block: MarkdownBlock = facet_value::from_value(good).unwrap();
204        assert!(matches!(
205            block,
206            MarkdownBlock::Heading {
207                level: HeadingLevel::H2,
208                content: _,
209            }
210        ));
211        let bad: Value = facet_json::from_str(BAD_LEVEL).unwrap();
212        assert!(facet_value::from_value::<MarkdownBlock>(bad).is_err());
213    }
214
215    #[test]
216    fn marker_name_is_checked_by_both_decoders() {
217        assert!(facet_json::from_str::<MarkdownSelector>(GOOD_NAME).is_ok());
218        assert!(facet_json::from_str::<MarkdownSelector>(BAD_NAME).is_err());
219        let good: Value = facet_json::from_str(GOOD_NAME).unwrap();
220        assert!(facet_value::from_value::<MarkdownSelector>(good).is_ok());
221        let bad: Value = facet_json::from_str(BAD_NAME).unwrap();
222        assert!(facet_value::from_value::<MarkdownSelector>(bad).is_err());
223    }
224}