Skip to main content

mant_engine/markdown/
container.rs

1//! Extracts `ManT`'s optional document-owned tldr preface.
2//!
3//! Invisible HTML comments delimit the structural extension so GitHub and
4//! other `CommonMark` renderers show only valid Markdown content. The opening
5//! marker must be the first non-empty construct, and its contents use the
6//! tldr-pages dialect. The returned document text masks the complete preface
7//! while preserving byte offsets and line numbers for source diagnostics.
8
9use std::{borrow::Cow, error::Error, fmt, ops::Range};
10
11const OPENING_MARKER: &str = "<!-- mant:tldr:start -->";
12const CLOSING_MARKER: &str = "<!-- mant:tldr:end -->";
13
14#[derive(Debug)]
15pub(super) struct MarkdownParts<'a> {
16    pub(super) document: Cow<'a, str>,
17    pub(super) tldr: Option<&'a str>,
18}
19
20/// Invalid structure in a document-owned tldr directive.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum TldrDirectiveError {
23    /// A leading opening marker has no matching closing marker.
24    Unterminated,
25}
26
27impl fmt::Display for TldrDirectiveError {
28    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Self::Unterminated => {
31                formatter.write_str(
32                    "top-level <!-- mant:tldr:start --> marker is missing its <!-- mant:tldr:end --> marker",
33                )
34            }
35        }
36    }
37}
38
39impl Error for TldrDirectiveError {}
40
41pub(super) fn split_markdown(source: &str) -> Result<MarkdownParts<'_>, TldrDirectiveError> {
42    let lines = source_lines(source);
43    let Some(opening_index) = lines
44        .iter()
45        .position(|line| !line_text(source, line).trim().is_empty())
46    else {
47        return Ok(MarkdownParts {
48            document: Cow::Borrowed(source),
49            tldr: None,
50        });
51    };
52    let opening = &lines[opening_index];
53    if line_text(source, opening).trim() != OPENING_MARKER {
54        return Ok(MarkdownParts {
55            document: Cow::Borrowed(source),
56            tldr: None,
57        });
58    }
59
60    let closing = lines
61        .iter()
62        .skip(opening_index + 1)
63        .find(|line| line_text(source, line).trim() == CLOSING_MARKER)
64        .ok_or(TldrDirectiveError::Unterminated)?;
65    let tldr_start = opening.end;
66    let tldr_end = closing.start;
67    let masked_end = closing.end;
68
69    Ok(MarkdownParts {
70        document: Cow::Owned(mask_range(source, opening.start..masked_end)),
71        tldr: Some(&source[tldr_start..tldr_end]),
72    })
73}
74
75#[derive(Clone)]
76struct SourceLine {
77    start: usize,
78    content_end: usize,
79    end: usize,
80}
81
82fn source_lines(source: &str) -> Vec<SourceLine> {
83    if source.is_empty() {
84        return Vec::new();
85    }
86    let mut lines = Vec::new();
87    let mut start = 0;
88    for segment in source.split_inclusive('\n') {
89        let end = start + segment.len();
90        let content_end = if segment.ends_with("\r\n") {
91            end - 2
92        } else if segment.ends_with('\n') {
93            end - 1
94        } else {
95            end
96        };
97        lines.push(SourceLine {
98            start,
99            content_end,
100            end,
101        });
102        start = end;
103    }
104    // split_inclusive yields every byte, including a final line without a
105    // trailing newline, so no trailing-segment fixup is needed here.
106    lines
107}
108
109fn line_text<'a>(source: &'a str, line: &SourceLine) -> &'a str {
110    &source[line.start..line.content_end]
111}
112
113fn mask_range(source: &str, range: Range<usize>) -> String {
114    let mut masked = String::with_capacity(source.len());
115    masked.push_str(&source[..range.start]);
116    for character in source[range.clone()].chars() {
117        if matches!(character, '\n' | '\r') {
118            masked.push(character);
119        } else {
120            masked.extend(std::iter::repeat_n(' ', character.len_utf8()));
121        }
122    }
123    masked.push_str(&source[range.end..]);
124    masked
125}
126
127#[cfg(test)]
128mod tests {
129    use std::borrow::Cow;
130
131    use pulldown_cmark::{Event, Parser};
132
133    use super::{TldrDirectiveError, split_markdown};
134
135    #[test]
136    fn extracts_only_a_leading_directive_and_preserves_source_coordinates() {
137        let source = "\n<!-- mant:tldr:start -->\n# demo\n\n- Run:\n\n`demo`\n<!-- mant:tldr:end -->\n\n# Demo\n\nBody.\n";
138        let parts = split_markdown(source).expect("directive");
139
140        assert_eq!(parts.tldr, Some("# demo\n\n- Run:\n\n`demo`\n"));
141        assert_eq!(parts.document.len(), source.len());
142        assert_eq!(
143            parts.document.matches('\n').count(),
144            source.matches('\n').count()
145        );
146        assert_eq!(parts.document.find("# Demo"), source.find("# Demo"));
147    }
148
149    #[test]
150    fn leaves_later_directives_as_ordinary_markdown() {
151        let source = "# Demo\n\n<!-- mant:tldr:start -->\n# late\n<!-- mant:tldr:end -->\n";
152        let parts = split_markdown(source).expect("ordinary Markdown");
153
154        assert!(parts.tldr.is_none());
155        assert!(matches!(parts.document, Cow::Borrowed(_)));
156    }
157
158    #[test]
159    fn reports_an_unterminated_leading_directive() {
160        let error = split_markdown("<!-- mant:tldr:start -->\n# demo\n").expect_err("unterminated");
161        assert_eq!(error, TldrDirectiveError::Unterminated);
162    }
163
164    #[test]
165    fn does_not_accept_the_obsolete_fenced_container() {
166        let source = ":::tldr\n# demo\n:::\n\n# Demo\n";
167        let parts = split_markdown(source).expect("ordinary Markdown");
168
169        assert!(parts.tldr.is_none());
170        assert!(matches!(parts.document, Cow::Borrowed(_)));
171    }
172
173    #[test]
174    fn boundary_comments_are_not_visible_commonmark_text() {
175        let source = "<!-- mant:tldr:start -->\n# demo\n\n> Quick reference.\n<!-- mant:tldr:end -->\n\n# Demo\n";
176        let visible = Parser::new(source)
177            .filter_map(|event| match event {
178                Event::Text(text) => Some(text.into_string()),
179                _ => None,
180            })
181            .collect::<String>();
182
183        assert!(!visible.contains("mant:tldr"));
184        assert!(visible.contains("Quick reference."));
185        assert!(visible.contains("Demo"));
186    }
187}