Skip to main content

panache_parser/syntax/
code_blocks.rs

1//! Code block and chunk AST node wrappers.
2
3use 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}