Skip to main content

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