panache_parser/syntax/
code_blocks.rs1use super::{
4 AstNode, ChunkInfoItem, ChunkLabel, ChunkLabelEntry, ChunkLabelSource, ChunkOption,
5 ChunkOptionEntry, ChunkOptionSource, ChunkOptions, HashpipeYamlPreamble, PanacheLanguage,
6 SyntaxKind, SyntaxNode, collect_option_entries_from_descendants,
7};
8
9pub struct CodeBlock(SyntaxNode);
10
11impl AstNode for CodeBlock {
12 type Language = PanacheLanguage;
13
14 fn can_cast(kind: SyntaxKind) -> bool {
15 kind == SyntaxKind::CODE_BLOCK
16 }
17
18 fn cast(syntax: SyntaxNode) -> Option<Self> {
19 if Self::can_cast(syntax.kind()) {
20 Some(Self(syntax))
21 } else {
22 None
23 }
24 }
25
26 fn syntax(&self) -> &SyntaxNode {
27 &self.0
28 }
29}
30
31impl CodeBlock {
32 pub fn info(&self) -> Option<CodeInfo> {
33 self.0.descendants().find_map(CodeInfo::cast)
34 }
35
36 pub fn language(&self) -> Option<String> {
37 self.info()
38 .and_then(|info| info.language())
39 .filter(|language| !language.is_empty())
40 }
41
42 pub fn content_text(&self) -> String {
43 self.0
44 .children()
45 .find(|child| child.kind() == SyntaxKind::CODE_CONTENT)
46 .map(|child| child.text().to_string())
47 .unwrap_or_default()
48 }
49
50 pub fn content_range(&self) -> Option<rowan::TextRange> {
51 self.0
52 .children()
53 .find(|child| child.kind() == SyntaxKind::CODE_CONTENT)
54 .map(|child| child.text_range())
55 }
56
57 pub fn is_executable_chunk(&self) -> bool {
58 self.info().is_some_and(|info| info.is_executable())
59 }
60
61 pub fn is_display_code_block(&self) -> bool {
62 self.language().is_some() && !self.is_executable_chunk()
63 }
64
65 pub fn hashpipe_yaml_preamble(&self) -> Option<HashpipeYamlPreamble> {
66 self.0.descendants().find_map(HashpipeYamlPreamble::cast)
67 }
68
69 pub fn hashpipe_chunk_options(&self) -> Vec<ChunkOption> {
70 self.hashpipe_chunk_option_entries()
71 .into_iter()
72 .map(ChunkOptionEntry::into_option)
73 .collect()
74 }
75
76 pub fn inline_chunk_options(&self) -> Vec<ChunkOption> {
77 self.inline_chunk_option_entries()
78 .into_iter()
79 .map(ChunkOptionEntry::into_option)
80 .collect()
81 }
82
83 pub fn inline_chunk_option_entries(&self) -> Vec<ChunkOptionEntry> {
84 self.info()
85 .map(|info| {
86 info.chunk_options()
87 .map(|option| ChunkOptionEntry::new(option, ChunkOptionSource::InlineInfo))
88 .collect()
89 })
90 .unwrap_or_default()
91 }
92
93 pub fn hashpipe_chunk_option_entries(&self) -> Vec<ChunkOptionEntry> {
94 self.hashpipe_yaml_preamble()
95 .map(|preamble| {
96 collect_option_entries_from_descendants(
97 preamble.syntax(),
98 ChunkOptionSource::HashpipeYaml,
99 )
100 })
101 .unwrap_or_default()
102 }
103
104 pub fn merged_chunk_option_entries(&self) -> Vec<ChunkOptionEntry> {
105 fn normalized_key(entry: &ChunkOptionEntry) -> Option<String> {
106 entry.key().map(|key| key.trim().to_ascii_lowercase())
107 }
108
109 let mut seen_inline_keys = std::collections::HashSet::new();
110 let mut merged = self.inline_chunk_option_entries();
111 for entry in &merged {
112 if let Some(key) = normalized_key(entry) {
113 seen_inline_keys.insert(key);
114 }
115 }
116
117 for entry in self.hashpipe_chunk_option_entries() {
118 if normalized_key(&entry).is_some_and(|key| seen_inline_keys.contains(&key)) {
119 continue;
120 }
121 merged.push(entry);
122 }
123
124 merged
125 }
126
127 pub fn chunk_options(&self) -> Vec<ChunkOption> {
128 self.merged_chunk_option_entries()
129 .into_iter()
130 .map(ChunkOptionEntry::into_option)
131 .collect()
132 }
133
134 pub fn inline_chunk_options_node(&self) -> Option<ChunkOptions> {
135 self.info().and_then(|info| info.chunk_options_node())
136 }
137
138 pub fn chunk_label_entries(&self) -> Vec<ChunkLabelEntry> {
139 let mut labels = Vec::new();
140
141 if let Some(info) = self.info() {
142 for label in info.chunk_labels() {
143 let text = label.text();
144 if text.is_empty() {
145 continue;
146 }
147 let range = label.syntax().text_range();
148 labels.push(ChunkLabelEntry::new(
149 text,
150 range,
151 range,
152 ChunkLabelSource::InlineLabel,
153 ));
154 }
155 }
156
157 for entry in self.merged_chunk_option_entries() {
158 let Some(key) = entry.key() else {
159 continue;
160 };
161 if !key.eq_ignore_ascii_case("label") {
162 continue;
163 }
164 let Some(value) = entry.value() else {
165 continue;
166 };
167 if value.is_empty() {
168 continue;
169 }
170 let value_range = entry
171 .value_range()
172 .unwrap_or_else(|| entry.option().syntax().text_range());
173 labels.push(ChunkLabelEntry::new(
174 value,
175 entry.option().syntax().text_range(),
176 value_range,
177 ChunkLabelSource::LabelOption,
178 ));
179 }
180
181 labels
182 }
183
184 pub fn chunk_labels(&self) -> Vec<String> {
185 self.chunk_label_entries()
186 .into_iter()
187 .map(|entry| entry.value().to_string())
188 .collect()
189 }
190
191 pub fn has_chunk_option_key_with_nonempty_value(&self, key_name: &str) -> bool {
192 self.chunk_options().into_iter().any(|option| {
193 option
194 .key()
195 .is_some_and(|key| key.eq_ignore_ascii_case(key_name))
196 && option.value().is_some_and(|value| !value.is_empty())
197 })
198 }
199
200 pub fn has_chunk_label(&self) -> bool {
201 !self.chunk_labels().is_empty()
202 }
203}
204
205pub struct CodeInfo(SyntaxNode);
206
207impl AstNode for CodeInfo {
208 type Language = PanacheLanguage;
209
210 fn can_cast(kind: SyntaxKind) -> bool {
211 kind == SyntaxKind::CODE_INFO
212 }
213
214 fn cast(syntax: SyntaxNode) -> Option<Self> {
215 if Self::can_cast(syntax.kind()) {
216 Some(Self(syntax))
217 } else {
218 None
219 }
220 }
221
222 fn syntax(&self) -> &SyntaxNode {
223 &self.0
224 }
225}
226
227impl CodeInfo {
228 pub fn language(&self) -> Option<String> {
229 self.0.children_with_tokens().find_map(|child| {
230 child.into_token().and_then(|token| {
231 (token.kind() == SyntaxKind::CODE_LANGUAGE).then(|| token.text().to_string())
232 })
233 })
234 }
235
236 pub fn is_executable(&self) -> bool {
237 self.chunk_options_node().is_some()
238 }
239
240 pub fn chunk_options(&self) -> impl Iterator<Item = ChunkOption> {
241 self.chunk_options_node()
242 .map(|chunk_options| chunk_options.options().collect::<Vec<_>>())
243 .unwrap_or_default()
244 .into_iter()
245 }
246
247 pub fn chunk_labels(&self) -> impl Iterator<Item = ChunkLabel> {
248 self.chunk_options_node()
249 .map(|chunk_options| chunk_options.labels().collect::<Vec<_>>())
250 .unwrap_or_default()
251 .into_iter()
252 }
253
254 pub fn chunk_items(&self) -> impl Iterator<Item = ChunkInfoItem> {
255 self.chunk_options_node()
256 .map(|chunk_options| chunk_options.items().collect::<Vec<_>>())
257 .unwrap_or_default()
258 .into_iter()
259 }
260
261 pub fn chunk_options_node(&self) -> Option<ChunkOptions> {
262 self.0.children().find_map(ChunkOptions::cast)
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::options::{Flavor, ParserOptions};
270 use crate::parse;
271
272 #[test]
273 fn code_block_display_shortcut_wrapper() {
274 let tree = parse("```python\nprint('hi')\n```\n", None);
275 let block = tree
276 .descendants()
277 .find_map(CodeBlock::cast)
278 .expect("code block");
279
280 assert_eq!(block.language().as_deref(), Some("python"));
281 assert!(block.is_display_code_block());
282 assert!(!block.is_executable_chunk());
283 assert!(block.content_text().contains("print('hi')"));
284 }
285
286 #[test]
287 fn code_block_executable_chunk_wrapper() {
288 let config = ParserOptions {
289 flavor: Flavor::Quarto,
290 extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
291 ..Default::default()
292 };
293 let tree = parse("```{r, echo=FALSE}\nx <- 1\n```\n", Some(config));
294 let block = tree
295 .descendants()
296 .find_map(CodeBlock::cast)
297 .expect("code block");
298
299 assert_eq!(block.language().as_deref(), Some("r"));
300 assert!(block.is_executable_chunk());
301 assert!(!block.is_display_code_block());
302
303 let info = block.info().expect("code info");
304 let keys: Vec<String> = info.chunk_options().filter_map(|opt| opt.key()).collect();
305 assert!(keys.contains(&"echo".to_string()));
306 }
307
308 #[test]
309 fn code_block_hashpipe_preamble_wrapper() {
310 let config = ParserOptions {
311 flavor: Flavor::Quarto,
312 extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
313 ..Default::default()
314 };
315 let tree = parse(
316 "```{python}\n#| echo: false\nprint('hi')\n```\n",
317 Some(config),
318 );
319 let block = tree
320 .descendants()
321 .find_map(CodeBlock::cast)
322 .expect("code block");
323
324 assert!(block.hashpipe_yaml_preamble().is_some());
325 }
326
327 #[test]
328 fn code_block_collects_chunk_labels_and_options() {
329 let config = ParserOptions {
330 flavor: Flavor::Quarto,
331 extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
332 ..Default::default()
333 };
334 let tree = parse(
335 "```{r chunk_inline, echo=FALSE}\n#| label: chunk_hashpipe\n#| fig-cap: \"Caption\"\n1 + 1\n```\n",
336 Some(config),
337 );
338 let block = tree
339 .descendants()
340 .find_map(CodeBlock::cast)
341 .expect("code block");
342
343 let labels = block.chunk_labels();
344 assert!(labels.iter().any(|label| label == "chunk_inline"));
345 assert!(labels.iter().any(|label| label == "chunk_hashpipe"));
346 assert!(block.has_chunk_label());
347 assert!(block.has_chunk_option_key_with_nonempty_value("fig-cap"));
348 }
349
350 #[test]
351 fn merged_chunk_options_prefer_inline_over_hashpipe() {
352 let config = ParserOptions {
353 flavor: Flavor::Quarto,
354 extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
355 ..Default::default()
356 };
357 let tree = parse(
358 "```{r, label=inline, echo=true}\n#| label: hashpipe\n#| echo: false\n1 + 1\n```\n",
359 Some(config),
360 );
361 let block = tree
362 .descendants()
363 .find_map(CodeBlock::cast)
364 .expect("code block");
365
366 let merged = block.merged_chunk_option_entries();
367 let mut labels = merged
368 .iter()
369 .filter_map(|entry| {
370 let key = entry.key()?;
371 key.eq_ignore_ascii_case("label").then(|| {
372 (
373 entry.value().unwrap_or_default(),
374 entry.source() == ChunkOptionSource::InlineInfo,
375 )
376 })
377 })
378 .collect::<Vec<_>>();
379 labels.sort();
380 assert_eq!(labels, vec![("inline".to_string(), true)]);
381
382 let mut echoes = merged
383 .iter()
384 .filter_map(|entry| {
385 let key = entry.key()?;
386 key.eq_ignore_ascii_case("echo")
387 .then(|| entry.value().unwrap_or_default())
388 })
389 .collect::<Vec<_>>();
390 echoes.sort();
391 assert_eq!(echoes, vec!["true".to_string()]);
392 }
393
394 #[test]
395 fn chunk_label_entries_include_ranges() {
396 let config = ParserOptions {
397 flavor: Flavor::Quarto,
398 extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
399 ..Default::default()
400 };
401 let tree = parse("```{r chunk_a, label=chunk_b}\n1 + 1\n```\n", Some(config));
402 let block = tree
403 .descendants()
404 .find_map(CodeBlock::cast)
405 .expect("code block");
406
407 let labels = block.chunk_label_entries();
408 assert_eq!(labels.len(), 2);
409 assert!(labels.iter().any(|entry| {
410 entry.value() == "chunk_a"
411 && entry.source() == ChunkLabelSource::InlineLabel
412 && !entry.value_range().is_empty()
413 }));
414 assert!(labels.iter().any(|entry| {
415 entry.value() == "chunk_b"
416 && entry.source() == ChunkLabelSource::LabelOption
417 && !entry.value_range().is_empty()
418 }));
419 }
420}