Skip to main content

rumdl_lib/utils/
kramdown_utils.rs

1//! Utilities for handling Kramdown-specific syntax
2//!
3//! Kramdown is a superset of Markdown that adds additional features like
4//! Inline Attribute Lists (IAL) for adding attributes to elements.
5
6use regex::Regex;
7use std::sync::LazyLock;
8
9/// Pattern for Kramdown span IAL: text{:.class #id key="value"}
10static SPAN_IAL_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{[:\.#][^}]*\}$").unwrap());
11
12/// Pattern for Kramdown extensions opening (multi-line): {::comment}, {::nomarkdown}, etc.
13/// Does NOT match self-closing blocks like {::options ... /}
14static EXTENSION_OPEN_PATTERN: LazyLock<Regex> =
15    LazyLock::new(|| Regex::new(r"^\s*\{::([a-z]+)(?:\s+[^}]*)?\}\s*$").unwrap());
16
17/// Pattern for self-closing extension blocks: {::options ... /}, {::comment /}
18static EXTENSION_SELF_CLOSING_PATTERN: LazyLock<Regex> =
19    LazyLock::new(|| Regex::new(r"^\s*\{::[a-z]+(?:\s+[^}]*)?\s*/\}\s*$").unwrap());
20
21/// Pattern for Kramdown extensions closing: {:/comment}, {:/}, etc.
22static EXTENSION_CLOSE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\{:/([a-z]+)?\}\s*$").unwrap());
23
24/// Pattern for math blocks: $$
25static MATH_BLOCK_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\$\$").unwrap());
26
27/// Check if a line is a Kramdown block attribute (IAL - Inline Attribute List)
28///
29/// Kramdown IAL syntax allows adding attributes to block elements:
30/// - `{:.class}` - CSS class
31/// - `{:#id}` - Element ID
32/// - `{:attribute="value"}` - Generic attributes
33/// - `{:.class #id attribute="value"}` - Combinations
34///
35/// # Examples
36///
37/// ```
38/// use rumdl_lib::utils::kramdown_utils::is_kramdown_block_attribute;
39///
40/// assert!(is_kramdown_block_attribute("{:.wrap}"));
41/// assert!(is_kramdown_block_attribute("{:#my-id}"));
42/// assert!(is_kramdown_block_attribute("{:.class #id}"));
43/// assert!(is_kramdown_block_attribute("{:style=\"color: red\"}"));
44///
45/// assert!(!is_kramdown_block_attribute("{just text}"));
46/// assert!(!is_kramdown_block_attribute("{}"));
47/// assert!(!is_kramdown_block_attribute("{"));
48/// ```
49pub fn is_kramdown_block_attribute(line: &str) -> bool {
50    let trimmed = line.trim();
51
52    // Must start with { and end with }
53    if !trimmed.starts_with('{') || !trimmed.ends_with('}') || trimmed.len() < 3 {
54        return false;
55    }
56
57    // Check if it matches Kramdown IAL patterns
58    // Valid patterns start with {: or {# or {.
59    let second_char = trimmed.chars().nth(1);
60    matches!(second_char, Some(':') | Some('#') | Some('.'))
61}
62
63/// Check if text ends with a Kramdown span IAL (inline attribute)
64///
65/// # Examples
66/// ```
67/// use rumdl_lib::utils::kramdown_utils::has_span_ial;
68///
69/// assert!(has_span_ial("*emphasized*{:.highlight}"));
70/// assert!(has_span_ial("[link](url){:target=\"_blank\"}"));
71/// assert!(!has_span_ial("regular text"));
72/// ```
73pub fn has_span_ial(text: &str) -> bool {
74    SPAN_IAL_PATTERN.is_match(text.trim())
75}
76
77/// Check if a line is a self-closing Kramdown extension: {::options ... /}, {::comment /}
78pub fn is_kramdown_extension_self_closing(line: &str) -> bool {
79    EXTENSION_SELF_CLOSING_PATTERN.is_match(line)
80}
81
82/// Check if a line is a Kramdown extension opening tag (multi-line, not self-closing)
83///
84/// Extensions include: comment, nomarkdown, options
85pub fn is_kramdown_extension_open(line: &str) -> bool {
86    EXTENSION_OPEN_PATTERN.is_match(line) && !is_kramdown_extension_self_closing(line)
87}
88
89/// Check if a line is a Kramdown extension closing tag
90pub fn is_kramdown_extension_close(line: &str) -> bool {
91    EXTENSION_CLOSE_PATTERN.is_match(line)
92}
93
94/// Check if a line starts a math block
95pub fn is_math_block_delimiter(line: &str) -> bool {
96    let trimmed = line.trim();
97    trimmed == "$$" || MATH_BLOCK_PATTERN.is_match(trimmed)
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn test_kramdown_class_attributes() {
106        assert!(is_kramdown_block_attribute("{:.wrap}"));
107        assert!(is_kramdown_block_attribute("{:.class-name}"));
108        assert!(is_kramdown_block_attribute("{:.multiple .classes}"));
109    }
110
111    #[test]
112    fn test_kramdown_id_attributes() {
113        assert!(is_kramdown_block_attribute("{:#my-id}"));
114        assert!(is_kramdown_block_attribute("{:#section-1}"));
115    }
116
117    #[test]
118    fn test_kramdown_generic_attributes() {
119        assert!(is_kramdown_block_attribute("{:style=\"color: red\"}"));
120        assert!(is_kramdown_block_attribute("{:data-value=\"123\"}"));
121    }
122
123    #[test]
124    fn test_kramdown_combined_attributes() {
125        assert!(is_kramdown_block_attribute("{:.class #id}"));
126        assert!(is_kramdown_block_attribute("{:#id .class style=\"color: blue\"}"));
127        assert!(is_kramdown_block_attribute("{:.wrap #my-code .highlight}"));
128    }
129
130    #[test]
131    fn test_non_kramdown_braces() {
132        assert!(!is_kramdown_block_attribute("{just some text}"));
133        assert!(!is_kramdown_block_attribute("{not kramdown}"));
134        assert!(!is_kramdown_block_attribute("{ spaces }"));
135    }
136
137    #[test]
138    fn test_edge_cases() {
139        assert!(!is_kramdown_block_attribute("{}"));
140        assert!(!is_kramdown_block_attribute("{"));
141        assert!(!is_kramdown_block_attribute("}"));
142        assert!(!is_kramdown_block_attribute(""));
143        assert!(!is_kramdown_block_attribute("not braces"));
144    }
145
146    #[test]
147    fn test_whitespace_handling() {
148        assert!(is_kramdown_block_attribute("  {:.wrap}  "));
149        assert!(is_kramdown_block_attribute("\t{:#id}\t"));
150        assert!(is_kramdown_block_attribute(" {:.class #id} "));
151    }
152
153    #[test]
154    fn test_self_closing_extension_blocks() {
155        // Self-closing extension blocks end with /}
156        assert!(is_kramdown_extension_self_closing("{::options toc_levels=\"2..4\" /}"));
157        assert!(is_kramdown_extension_self_closing("{::comment /}"));
158        assert!(is_kramdown_extension_self_closing("{::nomarkdown this='is' .ignore /}"));
159        assert!(is_kramdown_extension_self_closing("  {::options key=\"val\" /}  "));
160
161        // Multi-line openers should NOT match
162        assert!(!is_kramdown_extension_self_closing("{::comment}"));
163        assert!(!is_kramdown_extension_self_closing("{::nomarkdown}"));
164        assert!(!is_kramdown_extension_self_closing("{::nomarkdown type='html'}"));
165    }
166
167    #[test]
168    fn test_extension_open_excludes_self_closing() {
169        // Multi-line openers should match
170        assert!(is_kramdown_extension_open("{::comment}"));
171        assert!(is_kramdown_extension_open("{::nomarkdown}"));
172        assert!(is_kramdown_extension_open("{::nomarkdown type='html'}"));
173
174        // Self-closing should NOT match as multi-line opener
175        assert!(!is_kramdown_extension_open("{::options toc_levels=\"2..4\" /}"));
176        assert!(!is_kramdown_extension_open("{::comment /}"));
177    }
178}