panache_parser/syntax/
myst.rs1use super::{AstNode, PanacheLanguage, SyntaxKind, SyntaxNode};
10
11fn strip_braces(text: &str) -> &str {
17 text.strip_prefix('{')
18 .and_then(|rest| rest.strip_suffix('}'))
19 .unwrap_or(text)
20}
21
22fn 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
30pub 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 pub fn label(&self) -> Option<String> {
56 child_token_text(&self.0, SyntaxKind::MYST_TARGET_LABEL)
57 }
58}
59
60pub 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 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 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
102pub 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 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
134pub 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 fn open(&self) -> Option<SyntaxNode> {
161 self.0
162 .children()
163 .find(|node| node.kind() == SyntaxKind::MYST_DIRECTIVE_OPEN)
164 }
165
166 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 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 pub fn options(&self) -> Vec<MystDirectiveOption> {
182 self.0
183 .children()
184 .filter_map(MystDirectiveOption::cast)
185 .collect()
186 }
187
188 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
200pub 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 pub fn name(&self) -> Option<String> {
226 child_token_text(&self.0, SyntaxKind::MYST_DIRECTIVE_OPTION_NAME)
227 }
228
229 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}