mdbook_admonish/
parse.rs

1use anyhow::{anyhow, Result};
2use std::borrow::Cow;
3
4use crate::{
5    book_config::OnFailure,
6    render::Admonition,
7    resolve::AdmonitionMeta,
8    types::{BuiltinDirective, CssId, Overrides},
9};
10
11/// Given the content in the span of the code block, and the info string,
12/// return `Some(Admonition)` if the code block is an admonition.
13///
14/// If there is an error parsing the admonition, either:
15///
16/// - Display a UI error message output in the book.
17/// - If configured, break the build.
18///
19/// If the code block is not an admonition, return `None`.
20pub(crate) fn parse_admonition<'a>(
21    info_string: &'a str,
22    overrides: &'a Overrides,
23    content: &'a str,
24    on_failure: OnFailure,
25    indent: usize,
26) -> Option<Result<Admonition<'a>>> {
27    // We need to know fence details anyway for error messages
28    let extracted = extract_admonish_body(content);
29
30    let info = AdmonitionMeta::from_info_string(info_string, overrides)?;
31    let info = match info {
32        Ok(info) => info,
33        Err(message) => {
34            // Construct a fence capable of enclosing whatever we wrote for the
35            // actual input block
36            let fence = extracted.fence;
37            let enclosing_fence: String = std::iter::repeat(fence.character)
38                .take(fence.length + 1)
39                .collect();
40            return Some(match on_failure {
41                OnFailure::Continue => {
42                    log::warn!(
43                        r#"Error processing admonition. To fail the build instead of continuing, set 'on_failure = "bail"'"#
44                    );
45                    Ok(Admonition {
46                        directive: BuiltinDirective::Bug.to_string(),
47                        title: "Error rendering admonishment".to_owned(),
48                        css_id: CssId::Prefix("admonition-".to_owned()),
49                        additional_classnames: Vec::new(),
50                        collapsible: false,
51                        content: Cow::Owned(format!(
52                            r#"Failed with:
53
54```log
55{message}
56```
57
58Original markdown input:
59
60{enclosing_fence}markdown
61{content}
62{enclosing_fence}
63"#
64                        )),
65                        indent,
66                    })
67                }
68                OnFailure::Bail => Err(anyhow!("Error processing admonition, bailing:\n{content}")),
69            });
70        }
71    };
72
73    Some(Ok(Admonition::new(
74        info,
75        extracted.body,
76        // Note that this is a bit hacky - the fence information comes from the start
77        // of the block, and includes the whole line.
78        //
79        // This is more likely to be what we want, as ending indentation is unrelated
80        // according to the commonmark spec (ref https://spec.commonmark.org/0.12/#example-85)
81        //
82        // The main case we're worried about here is indenting enough to be inside list items,
83        // and in this case the starting code fence must be indented enough to be considered
84        // part of the list item.
85        //
86        // The hacky thing is that we're considering line indent in the document as a whole,
87        // not relative to the context of some containing item. But I think that's what we
88        // want for now, anyway.
89        indent,
90    )))
91}
92
93/// We can't trust the info string length to find the start of the body
94/// it may change length if it contains HTML or character escapes.
95///
96/// So we scan for the first newline and use that.
97/// If gods forbid it doesn't exist for some reason, just include the whole info string.
98fn extract_admonish_body_start_index(content: &str) -> usize {
99    let index = content
100        .find('\n')
101        // Start one character _after_ the newline
102        .map(|index| index + 1);
103
104    // If we can't get a valid index, include all content
105    match index {
106        // Couldn't find a newline
107        None => 0,
108        Some(index) => {
109            // Index out of bound of content
110            if index > (content.len() - 1) {
111                0
112            } else {
113                index
114            }
115        }
116    }
117}
118
119fn extract_admonish_body_end_index(content: &str) -> (usize, Fence) {
120    let fence_character = content.chars().next_back().unwrap_or('`');
121    let number_fence_characters = content
122        .chars()
123        .rev()
124        .position(|c| c != fence_character)
125        .unwrap_or_default();
126    let fence = Fence::new(fence_character, number_fence_characters);
127
128    let index = content.len() - fence.length;
129    (index, fence)
130}
131
132#[derive(Debug, PartialEq)]
133struct Fence {
134    character: char,
135    length: usize,
136}
137
138impl Fence {
139    fn new(character: char, length: usize) -> Self {
140        Self { character, length }
141    }
142}
143
144#[derive(Debug, PartialEq)]
145struct Extracted<'a> {
146    body: &'a str,
147    fence: Fence,
148}
149
150/// Given the whole text content of the code fence, extract the body.
151///
152/// This really feels like we should get the markdown parser to do it for us,
153/// but it's not really clear a good way of doing that.
154///
155/// ref: https://spec.commonmark.org/0.30/#fenced-code-blocks
156fn extract_admonish_body(content: &str) -> Extracted<'_> {
157    let start_index = extract_admonish_body_start_index(content);
158    let (end_index, fence) = extract_admonish_body_end_index(content);
159
160    let admonish_content = &content[start_index..end_index];
161    // The newline after a code block is technically optional, so we have to
162    // trim it off dynamically.
163    let body = admonish_content.trim_end();
164    Extracted { body, fence }
165}
166
167#[cfg(test)]
168mod test {
169    use super::*;
170    use pretty_assertions::assert_eq;
171
172    #[test]
173    fn test_extract_start() {
174        for (text, expected) in [
175            ("```sane example\ncontent```", 16),
176            ("~~~~~\nlonger fence", 6),
177            // empty
178            ("```\n```", 4),
179            // bounds check, should not index outside of content
180            ("```\n", 0),
181        ] {
182            let actual = extract_admonish_body_start_index(text);
183            assert_eq!(actual, expected);
184        }
185    }
186
187    #[test]
188    fn test_extract_end() {
189        for (text, expected) in [
190            ("\n```", (1, Fence::new('`', 3))),
191            // different lengths
192            ("\n``````", (1, Fence::new('`', 6))),
193            ("\n~~~~", (1, Fence::new('~', 4))),
194            // whitespace before fence end
195            ("\n   ```", (4, Fence::new('`', 3))),
196            ("content\n```", (8, Fence::new('`', 3))),
197        ] {
198            let actual = extract_admonish_body_end_index(text);
199            assert_eq!(actual, expected);
200        }
201    }
202
203    #[test]
204    fn test_extract() {
205        fn content_fence(body: &'static str, character: char, length: usize) -> Extracted<'static> {
206            Extracted {
207                body,
208                fence: Fence::new(character, length),
209            }
210        }
211        for (text, expected) in [
212            // empty
213            ("```\n```", content_fence("", '`', 3)),
214            // standard
215            (
216                "```admonish\ncontent\n```",
217                content_fence("content", '`', 3),
218            ),
219            // whitespace
220            (
221                "```admonish  \n  content  \n  ```",
222                content_fence("  content", '`', 3),
223            ),
224            // longer
225            (
226                "``````admonish\ncontent\n``````",
227                content_fence("content", '`', 6),
228            ),
229            // unequal
230            (
231                "~~~admonish\ncontent\n~~~~~",
232                // longer (end) fence returned
233                content_fence("content", '~', 5),
234            ),
235        ] {
236            let actual = extract_admonish_body(text);
237            assert_eq!(actual, expected);
238        }
239    }
240}