Skip to main content

use_markdown/
code_fence.rs

1use crate::frontmatter::frontmatter_line_count;
2
3/// A fenced code block extracted from Markdown.
4#[derive(Clone, Debug, Eq, PartialEq)]
5pub struct MarkdownCodeFence {
6    /// The optional info-string language token.
7    pub language: Option<String>,
8    /// The raw fenced content without the opening or closing fence lines.
9    pub content: String,
10    /// The 1-based line where the opening fence appears.
11    pub start_line: usize,
12    /// The 1-based line where the closing fence appears, or the last line when unclosed.
13    pub end_line: usize,
14}
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub(crate) struct FenceDelimiter {
18    pub character: char,
19    pub length: usize,
20}
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub(crate) struct FenceOpening<'a> {
24    pub delimiter: FenceDelimiter,
25    pub info: &'a str,
26}
27
28/// Extracts fenced code blocks delimited by triple backticks or triple tildes.
29pub fn extract_code_fences(markdown: &str) -> Vec<MarkdownCodeFence> {
30    let mut fences = Vec::new();
31    let frontmatter_lines = frontmatter_line_count(markdown);
32    let total_lines = markdown.lines().count();
33    let mut current: Option<(FenceDelimiter, Option<String>, usize, Vec<String>)> = None;
34
35    for (index, line) in markdown.lines().enumerate() {
36        if index < frontmatter_lines {
37            continue;
38        }
39
40        let line_number = index + 1;
41
42        if let Some((delimiter, _, _, _)) = current {
43            if is_closing_fence(line, delimiter) {
44                let (delimiter, language, start_line, content) = current
45                    .take()
46                    .expect("fence state should exist when closing");
47                let _ = delimiter;
48                fences.push(MarkdownCodeFence {
49                    language,
50                    content: content.join("\n"),
51                    start_line,
52                    end_line: line_number,
53                });
54                continue;
55            }
56
57            if let Some((_, _, _, content)) = current.as_mut() {
58                content.push(line.to_owned());
59            }
60            continue;
61        }
62
63        if let Some(opening) = parse_opening_fence(line) {
64            current = Some((
65                opening.delimiter,
66                language_from_info(opening.info),
67                line_number,
68                Vec::new(),
69            ));
70        }
71    }
72
73    if let Some((_, language, start_line, content)) = current {
74        fences.push(MarkdownCodeFence {
75            language,
76            content: content.join("\n"),
77            start_line,
78            end_line: total_lines.max(start_line),
79        });
80    }
81
82    fences
83}
84
85pub(crate) fn parse_opening_fence(line: &str) -> Option<FenceOpening<'_>> {
86    let candidate = crate::strip_up_to_three_leading_spaces(line);
87    let marker = candidate.chars().next()?;
88    if !matches!(marker, '`' | '~') {
89        return None;
90    }
91
92    let count = candidate
93        .chars()
94        .take_while(|character| *character == marker)
95        .count();
96    if count < 3 {
97        return None;
98    }
99
100    let info_start = count * marker.len_utf8();
101    Some(FenceOpening {
102        delimiter: FenceDelimiter {
103            character: marker,
104            length: count,
105        },
106        info: candidate[info_start..].trim(),
107    })
108}
109
110pub(crate) fn is_closing_fence(line: &str, delimiter: FenceDelimiter) -> bool {
111    let candidate = crate::strip_up_to_three_leading_spaces(line);
112    let count = candidate
113        .chars()
114        .take_while(|character| *character == delimiter.character)
115        .count();
116
117    if count < delimiter.length {
118        return false;
119    }
120
121    candidate[count * delimiter.character.len_utf8()..]
122        .trim()
123        .is_empty()
124}
125
126fn language_from_info(info: &str) -> Option<String> {
127    info.split_whitespace().next().map(ToOwned::to_owned)
128}