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, YamlDocument, YamlScalarStyle,
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 inline_chunk_option_entries(&self) -> Vec<ChunkOptionEntry> {
70        self.info()
71            .map(|info| {
72                info.chunk_options()
73                    .map(|option| {
74                        ChunkOptionEntry::from_inline_option(&option, ChunkOptionSource::InlineInfo)
75                    })
76                    .collect()
77            })
78            .unwrap_or_default()
79    }
80
81    /// Chunk options from the embedded hashpipe YAML block map. The preamble's
82    /// `HASHPIPE_YAML_CONTENT` carries a spliced YAML document (host-aligned
83    /// ranges), so each top-level `key: value` entry becomes one option:
84    /// cooked value, host-range spans, and a quoted flag from the scalar
85    /// style. Non-scalar values (e.g. a `fig-subcap:` sequence) yield an entry
86    /// with no value, matching the legacy option-line behavior.
87    pub fn hashpipe_chunk_option_entries(&self) -> Vec<ChunkOptionEntry> {
88        let Some(map) = self
89            .hashpipe_yaml_preamble()
90            .and_then(|preamble| {
91                preamble
92                    .syntax()
93                    .children()
94                    .find(|n| n.kind() == SyntaxKind::HASHPIPE_YAML_CONTENT)
95            })
96            .and_then(|content| content.children().find_map(YamlDocument::cast))
97            .and_then(|doc| doc.block_map())
98        else {
99            return Vec::new();
100        };
101
102        map.entries()
103            .map(|entry| {
104                let key_scalar = entry.key().and_then(|key| key.scalar());
105                let value_scalar = entry.value().and_then(|value| value.as_scalar());
106                let is_quoted = value_scalar.as_ref().is_some_and(|scalar| {
107                    matches!(
108                        scalar.style(),
109                        YamlScalarStyle::SingleQuoted | YamlScalarStyle::DoubleQuoted
110                    )
111                });
112                ChunkOptionEntry::new(
113                    entry.key_text(),
114                    value_scalar.as_ref().map(|scalar| scalar.value()),
115                    key_scalar.map(|scalar| scalar.text_range()),
116                    value_scalar.as_ref().map(|scalar| scalar.text_range()),
117                    is_quoted,
118                    entry.syntax().text_range(),
119                    ChunkOptionSource::HashpipeYaml,
120                )
121            })
122            .collect()
123    }
124
125    pub fn merged_chunk_option_entries(&self) -> Vec<ChunkOptionEntry> {
126        fn normalized_key(entry: &ChunkOptionEntry) -> Option<String> {
127            entry.key().map(|key| key.trim().to_ascii_lowercase())
128        }
129
130        let mut seen_inline_keys = std::collections::HashSet::new();
131        let mut merged = self.inline_chunk_option_entries();
132        for entry in &merged {
133            if let Some(key) = normalized_key(entry) {
134                seen_inline_keys.insert(key);
135            }
136        }
137
138        for entry in self.hashpipe_chunk_option_entries() {
139            if normalized_key(&entry).is_some_and(|key| seen_inline_keys.contains(&key)) {
140                continue;
141            }
142            merged.push(entry);
143        }
144
145        merged
146    }
147
148    pub fn inline_chunk_options_node(&self) -> Option<ChunkOptions> {
149        self.info().and_then(|info| info.chunk_options_node())
150    }
151
152    pub fn chunk_label_entries(&self) -> Vec<ChunkLabelEntry> {
153        let mut labels = Vec::new();
154
155        if let Some(info) = self.info() {
156            for label in info.chunk_labels() {
157                let text = label.text();
158                if text.is_empty() {
159                    continue;
160                }
161                let range = label.syntax().text_range();
162                labels.push(ChunkLabelEntry::new(
163                    text,
164                    range,
165                    range,
166                    ChunkLabelSource::InlineLabel,
167                ));
168            }
169        }
170
171        for entry in self.merged_chunk_option_entries() {
172            let Some(key) = entry.key() else {
173                continue;
174            };
175            if !key.eq_ignore_ascii_case("label") {
176                continue;
177            }
178            let Some(value) = entry.value() else {
179                continue;
180            };
181            if value.is_empty() {
182                continue;
183            }
184            let value_range = entry
185                .value_range()
186                .unwrap_or_else(|| entry.declaration_range());
187            labels.push(ChunkLabelEntry::new(
188                value,
189                entry.declaration_range(),
190                value_range,
191                ChunkLabelSource::LabelOption,
192            ));
193        }
194
195        labels
196    }
197
198    pub fn chunk_labels(&self) -> Vec<String> {
199        self.chunk_label_entries()
200            .into_iter()
201            .map(|entry| entry.value().to_string())
202            .collect()
203    }
204
205    pub fn has_chunk_option_key_with_nonempty_value(&self, key_name: &str) -> bool {
206        self.merged_chunk_option_entries().into_iter().any(|entry| {
207            entry
208                .key()
209                .is_some_and(|key| key.eq_ignore_ascii_case(key_name))
210                && entry.value().is_some_and(|value| !value.is_empty())
211        })
212    }
213
214    pub fn has_chunk_label(&self) -> bool {
215        !self.chunk_labels().is_empty()
216    }
217}
218
219pub struct CodeInfo(SyntaxNode);
220
221impl AstNode for CodeInfo {
222    type Language = PanacheLanguage;
223
224    fn can_cast(kind: SyntaxKind) -> bool {
225        kind == SyntaxKind::CODE_INFO
226    }
227
228    fn cast(syntax: SyntaxNode) -> Option<Self> {
229        if Self::can_cast(syntax.kind()) {
230            Some(Self(syntax))
231        } else {
232            None
233        }
234    }
235
236    fn syntax(&self) -> &SyntaxNode {
237        &self.0
238    }
239}
240
241impl CodeInfo {
242    pub fn language(&self) -> Option<String> {
243        self.0.children_with_tokens().find_map(|child| {
244            child.into_token().and_then(|token| {
245                (token.kind() == SyntaxKind::CODE_LANGUAGE).then(|| token.text().to_string())
246            })
247        })
248    }
249
250    pub fn is_executable(&self) -> bool {
251        self.chunk_options_node().is_some()
252    }
253
254    pub fn chunk_options(&self) -> impl Iterator<Item = ChunkOption> {
255        self.chunk_options_node()
256            .map(|chunk_options| chunk_options.options().collect::<Vec<_>>())
257            .unwrap_or_default()
258            .into_iter()
259    }
260
261    pub fn chunk_labels(&self) -> impl Iterator<Item = ChunkLabel> {
262        self.chunk_options_node()
263            .map(|chunk_options| chunk_options.labels().collect::<Vec<_>>())
264            .unwrap_or_default()
265            .into_iter()
266    }
267
268    pub fn chunk_items(&self) -> impl Iterator<Item = ChunkInfoItem> {
269        self.chunk_options_node()
270            .map(|chunk_options| chunk_options.items().collect::<Vec<_>>())
271            .unwrap_or_default()
272            .into_iter()
273    }
274
275    pub fn chunk_options_node(&self) -> Option<ChunkOptions> {
276        self.0.children().find_map(ChunkOptions::cast)
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::options::{Flavor, ParserOptions};
284    use crate::parse;
285
286    #[test]
287    fn code_block_display_shortcut_wrapper() {
288        let tree = parse("```python\nprint('hi')\n```\n", None);
289        let block = tree
290            .descendants()
291            .find_map(CodeBlock::cast)
292            .expect("code block");
293
294        assert_eq!(block.language().as_deref(), Some("python"));
295        assert!(block.is_display_code_block());
296        assert!(!block.is_executable_chunk());
297        assert!(block.content_text().contains("print('hi')"));
298    }
299
300    #[test]
301    fn code_block_executable_chunk_wrapper() {
302        let config = ParserOptions {
303            flavor: Flavor::Quarto,
304            extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
305            ..Default::default()
306        };
307        let tree = parse("```{r, echo=FALSE}\nx <- 1\n```\n", Some(config));
308        let block = tree
309            .descendants()
310            .find_map(CodeBlock::cast)
311            .expect("code block");
312
313        assert_eq!(block.language().as_deref(), Some("r"));
314        assert!(block.is_executable_chunk());
315        assert!(!block.is_display_code_block());
316
317        let info = block.info().expect("code info");
318        let keys: Vec<String> = info.chunk_options().filter_map(|opt| opt.key()).collect();
319        assert!(keys.contains(&"echo".to_string()));
320    }
321
322    #[test]
323    fn code_block_hashpipe_preamble_wrapper() {
324        let config = ParserOptions {
325            flavor: Flavor::Quarto,
326            extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
327            ..Default::default()
328        };
329        let tree = parse(
330            "```{python}\n#| echo: false\nprint('hi')\n```\n",
331            Some(config),
332        );
333        let block = tree
334            .descendants()
335            .find_map(CodeBlock::cast)
336            .expect("code block");
337
338        assert!(block.hashpipe_yaml_preamble().is_some());
339    }
340
341    #[test]
342    fn code_block_collects_chunk_labels_and_options() {
343        let config = ParserOptions {
344            flavor: Flavor::Quarto,
345            extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
346            ..Default::default()
347        };
348        let tree = parse(
349            "```{r chunk_inline, echo=FALSE}\n#| label: chunk_hashpipe\n#| fig-cap: \"Caption\"\n1 + 1\n```\n",
350            Some(config),
351        );
352        let block = tree
353            .descendants()
354            .find_map(CodeBlock::cast)
355            .expect("code block");
356
357        let labels = block.chunk_labels();
358        assert!(labels.iter().any(|label| label == "chunk_inline"));
359        assert!(labels.iter().any(|label| label == "chunk_hashpipe"));
360        assert!(block.has_chunk_label());
361        assert!(block.has_chunk_option_key_with_nonempty_value("fig-cap"));
362    }
363
364    #[test]
365    fn merged_chunk_options_prefer_inline_over_hashpipe() {
366        let config = ParserOptions {
367            flavor: Flavor::Quarto,
368            extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
369            ..Default::default()
370        };
371        let tree = parse(
372            "```{r, label=inline, echo=true}\n#| label: hashpipe\n#| echo: false\n1 + 1\n```\n",
373            Some(config),
374        );
375        let block = tree
376            .descendants()
377            .find_map(CodeBlock::cast)
378            .expect("code block");
379
380        let merged = block.merged_chunk_option_entries();
381        let mut labels = merged
382            .iter()
383            .filter_map(|entry| {
384                let key = entry.key()?;
385                key.eq_ignore_ascii_case("label").then(|| {
386                    (
387                        entry.value().unwrap_or_default(),
388                        entry.source() == ChunkOptionSource::InlineInfo,
389                    )
390                })
391            })
392            .collect::<Vec<_>>();
393        labels.sort();
394        assert_eq!(labels, vec![("inline".to_string(), true)]);
395
396        let mut echoes = merged
397            .iter()
398            .filter_map(|entry| {
399                let key = entry.key()?;
400                key.eq_ignore_ascii_case("echo")
401                    .then(|| entry.value().unwrap_or_default())
402            })
403            .collect::<Vec<_>>();
404        echoes.sort();
405        assert_eq!(echoes, vec!["true".to_string()]);
406    }
407
408    #[test]
409    fn chunk_label_entries_include_ranges() {
410        let config = ParserOptions {
411            flavor: Flavor::Quarto,
412            extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
413            ..Default::default()
414        };
415        let tree = parse("```{r chunk_a, label=chunk_b}\n1 + 1\n```\n", Some(config));
416        let block = tree
417            .descendants()
418            .find_map(CodeBlock::cast)
419            .expect("code block");
420
421        let labels = block.chunk_label_entries();
422        assert_eq!(labels.len(), 2);
423        assert!(labels.iter().any(|entry| {
424            entry.value() == "chunk_a"
425                && entry.source() == ChunkLabelSource::InlineLabel
426                && !entry.value_range().is_empty()
427        }));
428        assert!(labels.iter().any(|entry| {
429            entry.value() == "chunk_b"
430                && entry.source() == ChunkLabelSource::LabelOption
431                && !entry.value_range().is_empty()
432        }));
433    }
434}