Skip to main content

panache_parser/parser/inlines/
shortcodes.rs

1//! Quarto shortcode parsing.
2//!
3//! Syntax:
4//! - Normal: `{{< name args >}}`
5//! - Escaped: `{{{< name args >}}}` (displays as `{{< name args >}}` in output)
6
7use crate::syntax::SyntaxKind;
8use rowan::GreenNodeBuilder;
9
10/// Try to parse a shortcode starting from the current position.
11/// Returns (total_length, content, is_escaped) if successful.
12///
13/// A shortcode is: {{< content >}} or {{{< content >}}}
14/// - Must start with `{{<` or `{{{<`
15/// - Must end with matching `>}}` or `>}}}`
16/// - Content between markers is preserved as-is
17pub(crate) fn try_parse_shortcode(text: &str) -> Option<(usize, String, bool)> {
18    let bytes = text.as_bytes();
19
20    // Check if we have enough characters for the opening marker
21    if bytes.len() < 4 {
22        return None;
23    }
24
25    // Check for escaped shortcode first: {{{<
26    let (is_escaped, marker_len) = if bytes.len() >= 4
27        && bytes[0] == b'{'
28        && bytes[1] == b'{'
29        && bytes[2] == b'{'
30        && bytes[3] == b'<'
31    {
32        (true, 4)
33    } else if bytes[0] == b'{' && bytes[1] == b'{' && bytes[2] == b'<' {
34        (false, 3)
35    } else {
36        return None;
37    };
38
39    // Find the closing marker (>}} or >}}})
40    let close_marker = if is_escaped { ">}}}" } else { ">}}" };
41    let close_marker_bytes = close_marker.as_bytes();
42    let close_marker_len = close_marker_bytes.len();
43
44    // Search for the closing marker
45    let mut pos = marker_len;
46    let mut brace_depth: i32 = 0; // Track nested braces in content
47
48    while pos < text.len() {
49        if pos + close_marker_len <= bytes.len()
50            && &bytes[pos..pos + close_marker_len] == close_marker_bytes
51            && brace_depth == 0
52        {
53            // Found matching close marker with correct brace depth
54            let content = &text[marker_len..pos];
55            let total_len = pos + close_marker_len;
56            return Some((total_len, content.to_string(), is_escaped));
57        }
58
59        // Track brace depth to handle nested braces in content
60        match bytes[pos] {
61            b'{' => brace_depth += 1,
62            b'}' => brace_depth = brace_depth.saturating_sub(1),
63            _ => {}
64        }
65
66        pos += 1;
67    }
68
69    // No matching close marker found
70    None
71}
72
73/// Emit a shortcode node
74pub(crate) fn emit_shortcode(builder: &mut GreenNodeBuilder, content: &str, is_escaped: bool) {
75    builder.start_node(SyntaxKind::SHORTCODE.into());
76
77    // Opening marker
78    let open_marker = if is_escaped { "{{{<" } else { "{{<" };
79    builder.token(SyntaxKind::SHORTCODE_MARKER_OPEN.into(), open_marker);
80
81    // Content (preserved as-is, formatter will normalize)
82    builder.start_node(SyntaxKind::SHORTCODE_CONTENT.into());
83
84    // Emit content as TEXT, preserving all whitespace
85    if !content.is_empty() {
86        builder.token(SyntaxKind::TEXT.into(), content);
87    }
88
89    builder.finish_node(); // ShortcodeContent
90
91    // Closing marker
92    let close_marker = if is_escaped { ">}}}" } else { ">}}" };
93    builder.token(SyntaxKind::SHORTCODE_MARKER_CLOSE.into(), close_marker);
94
95    builder.finish_node(); // Shortcode
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn parses_simple_shortcode() {
104        let result = try_parse_shortcode("{{< meta title >}}");
105        assert!(result.is_some());
106        let (len, content, is_escaped) = result.unwrap();
107        assert_eq!(len, 18);
108        assert_eq!(content, " meta title ");
109        assert!(!is_escaped);
110    }
111
112    #[test]
113    fn parses_shortcode_without_spaces() {
114        let result = try_parse_shortcode("{{<meta title>}}");
115        assert!(result.is_some());
116        let (len, content, is_escaped) = result.unwrap();
117        assert_eq!(len, 16);
118        assert_eq!(content, "meta title");
119        assert!(!is_escaped);
120    }
121
122    #[test]
123    fn parses_shortcode_with_extra_spaces() {
124        let result = try_parse_shortcode("{{<  meta  title  >}}");
125        assert!(result.is_some());
126        let (len, content, _) = result.unwrap();
127        assert_eq!(len, 21);
128        assert_eq!(content, "  meta  title  ");
129    }
130
131    #[test]
132    fn parses_shortcode_with_arguments() {
133        let result = try_parse_shortcode("{{< video src=\"url\" >}}");
134        assert!(result.is_some());
135        let (len, content, _) = result.unwrap();
136        assert_eq!(len, 23);
137        assert_eq!(content, " video src=\"url\" ");
138    }
139
140    #[test]
141    fn parses_shortcode_with_unicode_quotes() {
142        let result = try_parse_shortcode("{{< video “https://www.youtube.com/watch?v=test” >}}");
143        assert!(result.is_some());
144        let (len, content, is_escaped) = result.unwrap();
145        assert_eq!(
146            len,
147            "{{< video “https://www.youtube.com/watch?v=test” >}}".len()
148        );
149        assert_eq!(content, " video “https://www.youtube.com/watch?v=test” ");
150        assert!(!is_escaped);
151    }
152
153    #[test]
154    fn parses_shortcode_with_multiple_arguments() {
155        let result = try_parse_shortcode("{{< env VAR \"default\" >}}");
156        assert!(result.is_some());
157        let (len, content, _) = result.unwrap();
158        assert_eq!(len, 25);
159        assert_eq!(content, " env VAR \"default\" ");
160    }
161
162    #[test]
163    fn parses_escaped_shortcode() {
164        let result = try_parse_shortcode("{{{< var version >}}}");
165        assert!(result.is_some());
166        let (len, content, is_escaped) = result.unwrap();
167        assert_eq!(len, 21);
168        assert_eq!(content, " var version ");
169        assert!(is_escaped);
170    }
171
172    #[test]
173    fn parses_shortcode_with_nested_braces() {
174        let result = try_parse_shortcode("{{< meta key={nested} >}}");
175        assert!(result.is_some());
176        let (len, content, _) = result.unwrap();
177        assert_eq!(len, 25);
178        assert_eq!(content, " meta key={nested} ");
179    }
180
181    #[test]
182    fn parses_shortcode_with_dot_notation() {
183        let result = try_parse_shortcode("{{< meta author.1 >}}");
184        assert!(result.is_some());
185        let (len, content, _) = result.unwrap();
186        assert_eq!(len, 21);
187        assert_eq!(content, " meta author.1 ");
188    }
189
190    #[test]
191    fn parses_shortcode_with_escaped_dots() {
192        let result = try_parse_shortcode(r"{{< meta field\\.with\\.dots >}}");
193        assert!(result.is_some());
194        let (len, content, _) = result.unwrap();
195        assert_eq!(len, 32);
196        assert_eq!(content, r" meta field\\.with\\.dots ");
197    }
198
199    #[test]
200    fn parses_empty_shortcode() {
201        let result = try_parse_shortcode("{{< >}}");
202        assert!(result.is_some());
203        let (len, content, _) = result.unwrap();
204        assert_eq!(len, 7);
205        assert_eq!(content, " ");
206    }
207
208    #[test]
209    fn fails_on_unclosed_shortcode() {
210        let result = try_parse_shortcode("{{< meta title");
211        assert!(result.is_none());
212    }
213
214    #[test]
215    fn fails_on_mismatched_braces() {
216        let result = try_parse_shortcode("{{< meta >}");
217        assert!(result.is_none());
218    }
219
220    #[test]
221    fn fails_on_mismatched_escape_braces() {
222        let result = try_parse_shortcode("{{{< meta >}}");
223        assert!(result.is_none());
224    }
225
226    #[test]
227    fn does_not_parse_regular_braces() {
228        let result = try_parse_shortcode("{{not a shortcode}}");
229        assert!(result.is_none());
230    }
231
232    #[test]
233    fn handles_shortcode_with_key_value_pairs() {
234        let result = try_parse_shortcode("{{< video src=\"url\" width=\"100%\" >}}");
235        assert!(result.is_some());
236        let (len, content, _) = result.unwrap();
237        assert_eq!(len, 36);
238        assert_eq!(content, " video src=\"url\" width=\"100%\" ");
239    }
240}