Skip to main content

panache_parser/syntax/
shortcodes.rs

1//! Quarto shortcode AST node wrappers.
2
3use super::{AstNode, PanacheLanguage, SyntaxKind, SyntaxNode};
4
5pub struct Shortcode(SyntaxNode);
6
7impl AstNode for Shortcode {
8    type Language = PanacheLanguage;
9
10    fn can_cast(kind: SyntaxKind) -> bool {
11        kind == SyntaxKind::SHORTCODE
12    }
13
14    fn cast(syntax: SyntaxNode) -> Option<Self> {
15        if Self::can_cast(syntax.kind()) {
16            Some(Self(syntax))
17        } else {
18            None
19        }
20    }
21
22    fn syntax(&self) -> &SyntaxNode {
23        &self.0
24    }
25}
26
27impl Shortcode {
28    /// Returns true if the shortcode is escaped (`{{{< ... >}}}`).
29    pub fn is_escaped(&self) -> bool {
30        self.0.children_with_tokens().any(|child| match child {
31            rowan::NodeOrToken::Token(token) => {
32                token.kind() == SyntaxKind::SHORTCODE_MARKER_OPEN && token.text() == "{{{<"
33            }
34            _ => false,
35        })
36    }
37
38    /// Returns shortcode content between markers.
39    pub fn content(&self) -> Option<String> {
40        self.0.children().find_map(|child| {
41            if child.kind() == SyntaxKind::SHORTCODE_CONTENT {
42                Some(child.text().to_string())
43            } else {
44                None
45            }
46        })
47    }
48
49    /// Returns shortcode name (first argument), when present.
50    pub fn name(&self) -> Option<String> {
51        self.args().first().cloned()
52    }
53
54    /// Returns shortcode arguments split on shell-like whitespace/quotes.
55    pub fn args(&self) -> Vec<String> {
56        let Some(content) = self.content() else {
57            return Vec::new();
58        };
59
60        split_shortcode_args(&content)
61    }
62}
63
64pub fn split_shortcode_args(content: &str) -> Vec<String> {
65    let mut args = Vec::new();
66    let mut current = String::new();
67    let mut in_quotes = false;
68    let mut quote_char = None;
69
70    for ch in content.trim().chars() {
71        match ch {
72            '"' | '\'' if !in_quotes => {
73                in_quotes = true;
74                quote_char = Some(ch);
75            }
76            c if Some(c) == quote_char && in_quotes => {
77                in_quotes = false;
78                quote_char = None;
79            }
80            c if c.is_whitespace() && !in_quotes => {
81                if !current.is_empty() {
82                    args.push(current.clone());
83                    current.clear();
84                }
85            }
86            c => current.push(c),
87        }
88    }
89
90    if !current.is_empty() {
91        args.push(current);
92    }
93
94    args
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::ParserOptions;
101    use crate::parser::parse;
102
103    #[test]
104    fn shortcode_wrapper_extracts_name_and_args() {
105        let tree = parse(
106            "{{< include \"chapters/part 1.qmd\" >}}",
107            Some(ParserOptions::default()),
108        );
109        let shortcode = tree
110            .descendants()
111            .find_map(Shortcode::cast)
112            .expect("shortcode");
113
114        assert_eq!(shortcode.name().as_deref(), Some("include"));
115        assert_eq!(
116            shortcode.args(),
117            vec!["include".to_string(), "chapters/part 1.qmd".to_string()]
118        );
119    }
120
121    #[test]
122    fn shortcode_wrapper_detects_escaped_shortcode() {
123        let tree = parse(
124            "{{{< include child.qmd >}}}",
125            Some(ParserOptions::default()),
126        );
127        let shortcode = tree
128            .descendants()
129            .find_map(Shortcode::cast)
130            .expect("shortcode");
131
132        assert!(shortcode.is_escaped());
133    }
134}