Skip to main content

panache_parser/syntax/
myst.rs

1//! MyST AST node wrappers.
2//!
3//! Typed views over the `MYST_*` CST kinds emitted by the parser (directives,
4//! roles, targets, substitutions). These hide the concrete tree shape --- brace
5//! delimiters, colon markers, optional whitespace --- behind a small
6//! `name()`/`content()`/`label()` surface for downstream consumers (LSP
7//! semantic tokens, lint rules, reference resolution).
8
9use super::{AstNode, PanacheLanguage, SyntaxKind, SyntaxNode};
10
11/// Strips a single pair of surrounding braces (`{name}` -> `name`).
12///
13/// `MYST_DIRECTIVE_NAME` and `MYST_ROLE_NAME` tokens include their braces; the
14/// wrappers expose the bare identifier. Falls back to the input unchanged when
15/// the braces are absent (defensive; the parser always emits them).
16fn strip_braces(text: &str) -> &str {
17    text.strip_prefix('{')
18        .and_then(|rest| rest.strip_suffix('}'))
19        .unwrap_or(text)
20}
21
22/// Returns the text of the first direct child token of `kind`.
23fn child_token_text(node: &SyntaxNode, kind: SyntaxKind) -> Option<String> {
24    node.children_with_tokens()
25        .filter_map(|element| element.into_token())
26        .find(|token| token.kind() == kind)
27        .map(|token| token.text().to_string())
28}
29
30/// A MyST target definition (`(label)=`).
31pub struct MystTarget(SyntaxNode);
32
33impl AstNode for MystTarget {
34    type Language = PanacheLanguage;
35
36    fn can_cast(kind: SyntaxKind) -> bool {
37        kind == SyntaxKind::MYST_TARGET
38    }
39
40    fn cast(syntax: SyntaxNode) -> Option<Self> {
41        if Self::can_cast(syntax.kind()) {
42            Some(Self(syntax))
43        } else {
44            None
45        }
46    }
47
48    fn syntax(&self) -> &SyntaxNode {
49        &self.0
50    }
51}
52
53impl MystTarget {
54    /// Returns the bare target label (no `(` / `)=` delimiters).
55    pub fn label(&self) -> Option<String> {
56        child_token_text(&self.0, SyntaxKind::MYST_TARGET_LABEL)
57    }
58}
59
60/// A MyST inline role (`` {name}`content` ``).
61pub struct MystRole(SyntaxNode);
62
63impl AstNode for MystRole {
64    type Language = PanacheLanguage;
65
66    fn can_cast(kind: SyntaxKind) -> bool {
67        kind == SyntaxKind::MYST_ROLE
68    }
69
70    fn cast(syntax: SyntaxNode) -> Option<Self> {
71        if Self::can_cast(syntax.kind()) {
72            Some(Self(syntax))
73        } else {
74            None
75        }
76    }
77
78    fn syntax(&self) -> &SyntaxNode {
79        &self.0
80    }
81}
82
83impl MystRole {
84    /// Returns the bare role name (braces stripped): `{math}` -> `math`.
85    pub fn name(&self) -> Option<String> {
86        child_token_text(&self.0, SyntaxKind::MYST_ROLE_NAME)
87            .map(|text| strip_braces(&text).to_string())
88    }
89
90    /// Returns the verbatim content between the backtick markers.
91    ///
92    /// An empty role (`` {x}`` ``) yields `Some("")`; a role with no content
93    /// node yields `None`.
94    pub fn content(&self) -> Option<String> {
95        self.0
96            .children()
97            .find(|node| node.kind() == SyntaxKind::MYST_ROLE_CONTENT)
98            .map(|node| node.text().to_string())
99    }
100}
101
102/// A MyST inline substitution (`{{ name }}`).
103pub struct MystSubstitution(SyntaxNode);
104
105impl AstNode for MystSubstitution {
106    type Language = PanacheLanguage;
107
108    fn can_cast(kind: SyntaxKind) -> bool {
109        kind == SyntaxKind::MYST_SUBSTITUTION
110    }
111
112    fn cast(syntax: SyntaxNode) -> Option<Self> {
113        if Self::can_cast(syntax.kind()) {
114            Some(Self(syntax))
115        } else {
116            None
117        }
118    }
119
120    fn syntax(&self) -> &SyntaxNode {
121        &self.0
122    }
123}
124
125impl MystSubstitution {
126    /// Returns the substitution key, trimmed of the inner whitespace the token
127    /// preserves (`{{ version }}` stores `" version "` -> `"version"`).
128    pub fn name(&self) -> Option<String> {
129        child_token_text(&self.0, SyntaxKind::MYST_SUBSTITUTION_NAME)
130            .map(|text| text.trim().to_string())
131    }
132}
133
134/// A MyST directive (```` ```{name} ```` or, with `colon_fence`, `:::{name}`).
135pub struct MystDirective(SyntaxNode);
136
137impl AstNode for MystDirective {
138    type Language = PanacheLanguage;
139
140    fn can_cast(kind: SyntaxKind) -> bool {
141        kind == SyntaxKind::MYST_DIRECTIVE
142    }
143
144    fn cast(syntax: SyntaxNode) -> Option<Self> {
145        if Self::can_cast(syntax.kind()) {
146            Some(Self(syntax))
147        } else {
148            None
149        }
150    }
151
152    fn syntax(&self) -> &SyntaxNode {
153        &self.0
154    }
155}
156
157impl MystDirective {
158    /// The opener line node (`MYST_DIRECTIVE_OPEN`), which holds the name and
159    /// argument tokens.
160    fn open(&self) -> Option<SyntaxNode> {
161        self.0
162            .children()
163            .find(|node| node.kind() == SyntaxKind::MYST_DIRECTIVE_OPEN)
164    }
165
166    /// Returns the bare directive name (braces stripped): `{note}` -> `note`.
167    pub fn name(&self) -> Option<String> {
168        let open = self.open()?;
169        child_token_text(&open, SyntaxKind::MYST_DIRECTIVE_NAME)
170            .map(|text| strip_braces(&text).to_string())
171    }
172
173    /// Returns the trimmed argument following the name on the opener line, if
174    /// any (e.g. `` ```{code-block} python `` -> `python`).
175    pub fn argument(&self) -> Option<String> {
176        let open = self.open()?;
177        child_token_text(&open, SyntaxKind::MYST_DIRECTIVE_ARG).map(|text| text.trim().to_string())
178    }
179
180    /// Returns the directive's leading `:key: value` option lines.
181    pub fn options(&self) -> Vec<MystDirectiveOption> {
182        self.0
183            .children()
184            .filter_map(MystDirectiveOption::cast)
185            .collect()
186    }
187
188    /// Returns the verbatim body of a verbatim directive (`code`, `math`, ...).
189    ///
190    /// Prose directives whose body is recursively parsed as ordinary blocks
191    /// have no `MYST_DIRECTIVE_BODY` node and yield `None`.
192    pub fn body(&self) -> Option<String> {
193        self.0
194            .children()
195            .find(|node| node.kind() == SyntaxKind::MYST_DIRECTIVE_BODY)
196            .map(|node| node.text().to_string())
197    }
198}
199
200/// A single `:key: value` option line within a directive.
201pub struct MystDirectiveOption(SyntaxNode);
202
203impl AstNode for MystDirectiveOption {
204    type Language = PanacheLanguage;
205
206    fn can_cast(kind: SyntaxKind) -> bool {
207        kind == SyntaxKind::MYST_DIRECTIVE_OPTION
208    }
209
210    fn cast(syntax: SyntaxNode) -> Option<Self> {
211        if Self::can_cast(syntax.kind()) {
212            Some(Self(syntax))
213        } else {
214            None
215        }
216    }
217
218    fn syntax(&self) -> &SyntaxNode {
219        &self.0
220    }
221}
222
223impl MystDirectiveOption {
224    /// Returns the option key (no surrounding colons): `:alt: text` -> `alt`.
225    pub fn name(&self) -> Option<String> {
226        child_token_text(&self.0, SyntaxKind::MYST_DIRECTIVE_OPTION_NAME)
227    }
228
229    /// Returns the option value, if present. Valueless options (`:hidden:`)
230    /// yield `None`.
231    pub fn value(&self) -> Option<String> {
232        child_token_text(&self.0, SyntaxKind::MYST_DIRECTIVE_OPTION_VALUE)
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::parser::parse;
240    use crate::{Dialect, Extensions, Flavor, ParserOptions};
241
242    fn myst_options() -> ParserOptions {
243        ParserOptions {
244            flavor: Flavor::Myst,
245            dialect: Dialect::for_flavor(Flavor::Myst),
246            extensions: Extensions {
247                myst_substitutions: true,
248                ..Extensions::for_flavor(Flavor::Myst)
249            },
250            ..ParserOptions::default()
251        }
252    }
253
254    fn cast_first<T: AstNode<Language = PanacheLanguage>>(input: &str) -> T {
255        parse(input, Some(myst_options()))
256            .descendants()
257            .find_map(T::cast)
258            .expect("expected a MyST node")
259    }
260
261    #[test]
262    fn target_wrapper_extracts_label() {
263        let target: MystTarget = cast_first("(my-target)=\n\n# Heading");
264        assert_eq!(target.label().as_deref(), Some("my-target"));
265    }
266
267    #[test]
268    fn role_wrapper_extracts_name_and_content() {
269        let role: MystRole = cast_first("See {math}`a^2 + b^2`.");
270        assert_eq!(role.name().as_deref(), Some("math"));
271        assert_eq!(role.content().as_deref(), Some("a^2 + b^2"));
272    }
273
274    #[test]
275    fn directive_wrapper_extracts_name_arg_and_options() {
276        let directive: MystDirective =
277            cast_first("````{figure} img.png\n:alt: An image\n:width: 200px\n\nCaption\n````");
278        assert_eq!(directive.name().as_deref(), Some("figure"));
279        assert_eq!(directive.argument().as_deref(), Some("img.png"));
280
281        let options = directive.options();
282        assert_eq!(options.len(), 2);
283        assert_eq!(options[0].name().as_deref(), Some("alt"));
284        assert_eq!(options[0].value().as_deref(), Some("An image"));
285        assert_eq!(options[1].name().as_deref(), Some("width"));
286        assert_eq!(options[1].value().as_deref(), Some("200px"));
287    }
288
289    #[test]
290    fn directive_wrapper_extracts_verbatim_body() {
291        let directive: MystDirective =
292            cast_first("```{code} python\n:number-lines: 1\ndef five():\n  return 5\n```");
293        assert_eq!(directive.name().as_deref(), Some("code"));
294        assert_eq!(directive.argument().as_deref(), Some("python"));
295        let body = directive.body().expect("verbatim body");
296        assert!(body.contains("def five():"));
297        assert!(body.contains("return 5"));
298    }
299
300    #[test]
301    fn directive_wrapper_handles_valueless_option() {
302        let directive: MystDirective = cast_first("```{toctree}\n:hidden:\n\nquickstart\n```");
303        let options = directive.options();
304        let hidden = options
305            .iter()
306            .find(|option| option.name().as_deref() == Some("hidden"))
307            .expect("hidden option");
308        assert_eq!(hidden.value(), None);
309    }
310
311    #[test]
312    fn substitution_wrapper_trims_name() {
313        let substitution: MystSubstitution = cast_first("Released {{ version }}.");
314        assert_eq!(substitution.name().as_deref(), Some("version"));
315    }
316}