Skip to main content

panache_parser/syntax/
chunk_options.rs

1//! Chunk option AST wrappers for type-safe access to chunk options in executable code blocks.
2
3use crate::syntax::ast::support;
4use crate::syntax::{AstNode, PanacheLanguage, SyntaxKind, SyntaxNode};
5
6/// A chunk option in an executable code block (e.g., `echo=TRUE` or `fig.cap="text"`).
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
8pub struct ChunkOption(SyntaxNode);
9
10impl AstNode for ChunkOption {
11    type Language = PanacheLanguage;
12
13    fn can_cast(kind: SyntaxKind) -> bool {
14        kind == SyntaxKind::CHUNK_OPTION
15    }
16
17    fn cast(node: SyntaxNode) -> Option<Self> {
18        Self::can_cast(node.kind()).then(|| ChunkOption(node))
19    }
20
21    fn syntax(&self) -> &SyntaxNode {
22        &self.0
23    }
24}
25
26impl ChunkOption {
27    /// Get the option key (e.g., "echo", "fig.cap").
28    pub fn key(&self) -> Option<String> {
29        self.0.children_with_tokens().find_map(|child| {
30            if let rowan::NodeOrToken::Token(token) = child
31                && token.kind() == SyntaxKind::CHUNK_OPTION_KEY
32            {
33                return Some(token.text().to_string());
34            }
35            None
36        })
37    }
38
39    /// Get the option value (e.g., "TRUE", "A nice plot").
40    /// Returns None for options without values.
41    pub fn value(&self) -> Option<String> {
42        self.0.children_with_tokens().find_map(|child| {
43            if let rowan::NodeOrToken::Token(token) = child
44                && token.kind() == SyntaxKind::CHUNK_OPTION_VALUE
45            {
46                return Some(token.text().to_string());
47            }
48            None
49        })
50    }
51
52    /// Get the value token range, if present.
53    pub fn value_range(&self) -> Option<rowan::TextRange> {
54        self.0.children_with_tokens().find_map(|child| {
55            if let rowan::NodeOrToken::Token(token) = child
56                && token.kind() == SyntaxKind::CHUNK_OPTION_VALUE
57            {
58                return Some(token.text_range());
59            }
60            None
61        })
62    }
63
64    /// Get the key token range, if present.
65    pub fn key_range(&self) -> Option<rowan::TextRange> {
66        self.0.children_with_tokens().find_map(|child| {
67            if let rowan::NodeOrToken::Token(token) = child
68                && token.kind() == SyntaxKind::CHUNK_OPTION_KEY
69            {
70                return Some(token.text_range());
71            }
72            None
73        })
74    }
75
76    /// Check if the value is quoted (has CHUNK_OPTION_QUOTE nodes).
77    pub fn is_quoted(&self) -> bool {
78        self.0.children_with_tokens().any(|child| {
79            if let rowan::NodeOrToken::Token(token) = child {
80                token.kind() == SyntaxKind::CHUNK_OPTION_QUOTE
81            } else {
82                false
83            }
84        })
85    }
86
87    /// Get the quote character if the value is quoted.
88    pub fn quote_char(&self) -> Option<char> {
89        self.0.children_with_tokens().find_map(|child| {
90            if let rowan::NodeOrToken::Token(token) = child
91                && token.kind() == SyntaxKind::CHUNK_OPTION_QUOTE
92            {
93                return token.text().chars().next();
94            }
95            None
96        })
97    }
98}
99
100/// A chunk label in an executable code block (e.g., `mylabel` in `{r mylabel}`).
101#[derive(Debug, Clone, PartialEq, Eq, Hash)]
102pub struct ChunkLabel(SyntaxNode);
103
104impl AstNode for ChunkLabel {
105    type Language = PanacheLanguage;
106
107    fn can_cast(kind: SyntaxKind) -> bool {
108        kind == SyntaxKind::CHUNK_LABEL
109    }
110
111    fn cast(node: SyntaxNode) -> Option<Self> {
112        Self::can_cast(node.kind()).then(|| ChunkLabel(node))
113    }
114
115    fn syntax(&self) -> &SyntaxNode {
116        &self.0
117    }
118}
119
120impl ChunkLabel {
121    /// Get the label text.
122    pub fn text(&self) -> String {
123        self.0.text().to_string()
124    }
125
126    /// Get the label text range.
127    pub fn range(&self) -> rowan::TextRange {
128        self.0.text_range()
129    }
130}
131
132/// A class attribute in an executable code block (e.g., `.marimo` in
133/// `{python .marimo}`). The text accessor includes the leading `.`.
134#[derive(Debug, Clone, PartialEq, Eq, Hash)]
135pub struct ChunkClass(SyntaxNode);
136
137impl AstNode for ChunkClass {
138    type Language = PanacheLanguage;
139
140    fn can_cast(kind: SyntaxKind) -> bool {
141        kind == SyntaxKind::ATTR_CLASS
142    }
143
144    fn cast(node: SyntaxNode) -> Option<Self> {
145        Self::can_cast(node.kind()).then(|| ChunkClass(node))
146    }
147
148    fn syntax(&self) -> &SyntaxNode {
149        &self.0
150    }
151}
152
153impl ChunkClass {
154    pub fn text(&self) -> String {
155        self.0.text().to_string()
156    }
157
158    pub fn range(&self) -> rowan::TextRange {
159        self.0.text_range()
160    }
161}
162
163/// An id attribute in an executable code block (e.g., `#setup` in
164/// `{r #setup}`). The text accessor includes the leading `#`.
165#[derive(Debug, Clone, PartialEq, Eq, Hash)]
166pub struct ChunkId(SyntaxNode);
167
168impl AstNode for ChunkId {
169    type Language = PanacheLanguage;
170
171    fn can_cast(kind: SyntaxKind) -> bool {
172        kind == SyntaxKind::ATTR_ID
173    }
174
175    fn cast(node: SyntaxNode) -> Option<Self> {
176        Self::can_cast(node.kind()).then(|| ChunkId(node))
177    }
178
179    fn syntax(&self) -> &SyntaxNode {
180        &self.0
181    }
182}
183
184impl ChunkId {
185    pub fn text(&self) -> String {
186        self.0.text().to_string()
187    }
188
189    pub fn range(&self) -> rowan::TextRange {
190        self.0.text_range()
191    }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
195pub enum ChunkOptionSource {
196    InlineInfo,
197    HashpipeYaml,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Hash)]
201pub struct ChunkOptionEntry {
202    option: ChunkOption,
203    source: ChunkOptionSource,
204}
205
206impl ChunkOptionEntry {
207    pub fn new(option: ChunkOption, source: ChunkOptionSource) -> Self {
208        Self { option, source }
209    }
210
211    pub fn option(&self) -> &ChunkOption {
212        &self.option
213    }
214
215    pub fn into_option(self) -> ChunkOption {
216        self.option
217    }
218
219    pub fn source(&self) -> ChunkOptionSource {
220        self.source
221    }
222
223    pub fn key(&self) -> Option<String> {
224        self.option.key()
225    }
226
227    pub fn key_range(&self) -> Option<rowan::TextRange> {
228        self.option.key_range()
229    }
230
231    pub fn value(&self) -> Option<String> {
232        self.option.value()
233    }
234
235    pub fn value_range(&self) -> Option<rowan::TextRange> {
236        self.option.value_range()
237    }
238
239    pub fn is_quoted(&self) -> bool {
240        self.option.is_quoted()
241    }
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
245pub enum ChunkLabelSource {
246    InlineLabel,
247    LabelOption,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Hash)]
251pub struct ChunkLabelEntry {
252    value: String,
253    declaration_range: rowan::TextRange,
254    value_range: rowan::TextRange,
255    source: ChunkLabelSource,
256}
257
258impl ChunkLabelEntry {
259    pub fn new(
260        value: String,
261        declaration_range: rowan::TextRange,
262        value_range: rowan::TextRange,
263        source: ChunkLabelSource,
264    ) -> Self {
265        Self {
266            value,
267            declaration_range,
268            value_range,
269            source,
270        }
271    }
272
273    pub fn value(&self) -> &str {
274        &self.value
275    }
276
277    pub fn source(&self) -> ChunkLabelSource {
278        self.source
279    }
280
281    pub fn declaration_range(&self) -> rowan::TextRange {
282        self.declaration_range
283    }
284
285    pub fn value_range(&self) -> rowan::TextRange {
286        self.value_range
287    }
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Hash)]
291pub enum ChunkInfoItem {
292    Label(ChunkLabel),
293    Class(ChunkClass),
294    Id(ChunkId),
295    Option(ChunkOption),
296}
297
298pub struct ChunkOptions(SyntaxNode);
299
300impl AstNode for ChunkOptions {
301    type Language = PanacheLanguage;
302
303    fn can_cast(kind: SyntaxKind) -> bool {
304        kind == SyntaxKind::CHUNK_OPTIONS
305    }
306
307    fn cast(syntax: SyntaxNode) -> Option<Self> {
308        if Self::can_cast(syntax.kind()) {
309            Some(Self(syntax))
310        } else {
311            None
312        }
313    }
314
315    fn syntax(&self) -> &SyntaxNode {
316        &self.0
317    }
318}
319
320impl ChunkOptions {
321    pub fn options(&self) -> impl Iterator<Item = ChunkOption> {
322        support::children(&self.0)
323    }
324
325    pub fn labels(&self) -> impl Iterator<Item = ChunkLabel> {
326        self.0.children().filter_map(ChunkLabel::cast)
327    }
328
329    pub fn items(&self) -> impl Iterator<Item = ChunkInfoItem> {
330        self.0.children().filter_map(|child| {
331            if let Some(label) = ChunkLabel::cast(child.clone()) {
332                return Some(ChunkInfoItem::Label(label));
333            }
334            if let Some(class) = ChunkClass::cast(child.clone()) {
335                return Some(ChunkInfoItem::Class(class));
336            }
337            if let Some(id) = ChunkId::cast(child.clone()) {
338                return Some(ChunkInfoItem::Id(id));
339            }
340            ChunkOption::cast(child).map(ChunkInfoItem::Option)
341        })
342    }
343
344    pub fn option_entries(&self, source: ChunkOptionSource) -> Vec<ChunkOptionEntry> {
345        self.options()
346            .map(|option| ChunkOptionEntry::new(option, source))
347            .collect()
348    }
349}
350
351pub fn collect_option_entries_from_descendants(
352    root: &SyntaxNode,
353    source: ChunkOptionSource,
354) -> Vec<ChunkOptionEntry> {
355    root.descendants()
356        .filter_map(ChunkOption::cast)
357        .map(|option| ChunkOptionEntry::new(option, source))
358        .collect()
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::options::{Flavor, ParserOptions};
365    use crate::parse;
366
367    #[test]
368    fn test_chunk_option_quoted() {
369        let config = ParserOptions {
370            flavor: Flavor::Quarto,
371            extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
372            ..Default::default()
373        };
374        let tree = parse(
375            r#"```{r, fig.cap="A nice plot"}
376x <- 1
377```"#,
378            Some(config),
379        );
380
381        let option = tree
382            .descendants()
383            .find_map(ChunkOption::cast)
384            .expect("Should find chunk option");
385
386        assert_eq!(option.key(), Some("fig.cap".to_string()));
387        assert_eq!(option.value(), Some("A nice plot".to_string()));
388        assert!(option.key_range().is_some());
389        assert!(option.value_range().is_some());
390        assert!(option.is_quoted());
391        assert_eq!(option.quote_char(), Some('"'));
392    }
393
394    #[test]
395    fn test_chunk_option_unquoted() {
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, echo=TRUE}\nx <- 1\n```", Some(config));
402
403        let option = tree
404            .descendants()
405            .find_map(ChunkOption::cast)
406            .expect("Should find chunk option");
407
408        assert_eq!(option.key(), Some("echo".to_string()));
409        assert_eq!(option.value(), Some("TRUE".to_string()));
410        assert!(option.key_range().is_some());
411        assert!(option.value_range().is_some());
412        assert!(!option.is_quoted());
413    }
414
415    #[test]
416    fn test_chunk_label() {
417        let config = ParserOptions {
418            flavor: Flavor::Quarto,
419            extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
420            ..Default::default()
421        };
422        let tree = parse("```{r mylabel}\nx <- 1\n```", Some(config));
423
424        let label = tree
425            .descendants()
426            .find_map(ChunkLabel::cast)
427            .expect("Should find chunk label");
428
429        assert_eq!(label.text(), "mylabel");
430        assert!(!label.range().is_empty());
431    }
432}