Skip to main content

rumdl_lib/utils/
fence_language.rs

1//! Shared classification of fence info strings for language coverage checks.
2
3use crate::config::MarkdownFlavor;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum FenceLanguage {
7    Language,
8    /// Valid flavor code syntax, accepted by MD040 but still subject to tool coverage.
9    CodeSyntax,
10    Missing,
11    /// A flavor construct whose info string is not a plain language label.
12    Exempt,
13}
14
15pub fn is_superfences_attribute(info: &str) -> bool {
16    ["title=", "hl_lines=", "linenums=", ".", "#"]
17        .iter()
18        .any(|prefix| info.starts_with(prefix))
19}
20
21pub fn classify(info: &str, flavor: MarkdownFlavor) -> FenceLanguage {
22    // Only spaces and tabs surround an info string; U+00A0 and the other Unicode
23    // spaces are part of it.
24    let info = info.trim_matches([' ', '\t']);
25    if flavor.supports_myst_directives() && info.starts_with('{') && info.contains('}') {
26        let name = info[1..].split('}').next().unwrap_or("");
27        if name.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') {
28            return if matches!(name, "code" | "code-block" | "sourcecode" | "code-cell") {
29                FenceLanguage::CodeSyntax
30            } else {
31                FenceLanguage::Exempt
32            };
33        }
34    }
35    if flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_raw_block_lang(info) {
36        return FenceLanguage::Exempt;
37    }
38    if (flavor.is_pandoc_compatible() && crate::utils::pandoc::is_pandoc_code_class_attr(info))
39        || (flavor == MarkdownFlavor::Quarto && info.starts_with('{') && info.ends_with('}'))
40    {
41        return FenceLanguage::CodeSyntax;
42    }
43    if flavor == MarkdownFlavor::MkDocs && is_superfences_attribute(info) {
44        FenceLanguage::Exempt
45    } else if info.is_empty() || is_superfences_attribute(info) || (info.starts_with('{') && info.ends_with('}')) {
46        FenceLanguage::Missing
47    } else {
48        FenceLanguage::Language
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn flavor_constructs_are_exempt_but_unknown_plain_labels_are_languages() {
58        for (flavor, info) in [
59            (MarkdownFlavor::Pandoc, "{=html}"),
60            (MarkdownFlavor::MyST, "{note}"),
61            (MarkdownFlavor::MkDocs, "title=\"example\""),
62        ] {
63            assert_eq!(classify(info, flavor), FenceLanguage::Exempt);
64        }
65        for (flavor, info) in [
66            (MarkdownFlavor::Quarto, "{python}"),
67            (MarkdownFlavor::Quarto, "{{python}}"),
68            (MarkdownFlavor::Pandoc, "{.python}"),
69            (MarkdownFlavor::MyST, "{code-cell} python"),
70        ] {
71            assert_eq!(classify(info, flavor), FenceLanguage::CodeSyntax);
72        }
73        assert_eq!(classify("pyhton", MarkdownFlavor::Standard), FenceLanguage::Language);
74        for info in ["", "{}", "{python}", "title=example"] {
75            assert_eq!(classify(info, MarkdownFlavor::Standard), FenceLanguage::Missing);
76        }
77    }
78
79    #[test]
80    fn unicode_space_is_part_of_the_info_string() {
81        assert_eq!(classify("\u{a0}", MarkdownFlavor::Standard), FenceLanguage::Language);
82        assert_eq!(classify(" \t", MarkdownFlavor::Standard), FenceLanguage::Missing);
83    }
84}