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/// A single chunk option, decoupled from its source CST shape. Inline
201/// options come from `CHUNK_OPTION` nodes on the fence info line; hashpipe
202/// options come from the embedded YAML block map under
203/// `HASHPIPE_YAML_CONTENT`. Both project to the same key/value/range view so
204/// `CodeBlock::merged_chunk_option_entries` can mix them.
205#[derive(Debug, Clone, PartialEq, Eq, Hash)]
206pub struct ChunkOptionEntry {
207    key: Option<String>,
208    value: Option<String>,
209    key_range: Option<rowan::TextRange>,
210    value_range: Option<rowan::TextRange>,
211    is_quoted: bool,
212    declaration_range: rowan::TextRange,
213    source: ChunkOptionSource,
214}
215
216impl ChunkOptionEntry {
217    #[allow(clippy::too_many_arguments)]
218    pub fn new(
219        key: Option<String>,
220        value: Option<String>,
221        key_range: Option<rowan::TextRange>,
222        value_range: Option<rowan::TextRange>,
223        is_quoted: bool,
224        declaration_range: rowan::TextRange,
225        source: ChunkOptionSource,
226    ) -> Self {
227        Self {
228            key,
229            value,
230            key_range,
231            value_range,
232            is_quoted,
233            declaration_range,
234            source,
235        }
236    }
237
238    /// Build an entry from an inline `CHUNK_OPTION` node (fence info line).
239    pub fn from_inline_option(option: &ChunkOption, source: ChunkOptionSource) -> Self {
240        Self::new(
241            option.key(),
242            option.value(),
243            option.key_range(),
244            option.value_range(),
245            option.is_quoted(),
246            option.syntax().text_range(),
247            source,
248        )
249    }
250
251    pub fn source(&self) -> ChunkOptionSource {
252        self.source
253    }
254
255    pub fn key(&self) -> Option<String> {
256        self.key.clone()
257    }
258
259    pub fn key_range(&self) -> Option<rowan::TextRange> {
260        self.key_range
261    }
262
263    pub fn value(&self) -> Option<String> {
264        self.value.clone()
265    }
266
267    pub fn value_range(&self) -> Option<rowan::TextRange> {
268        self.value_range
269    }
270
271    pub fn is_quoted(&self) -> bool {
272        self.is_quoted
273    }
274
275    /// The full source range of the option declaration (the `CHUNK_OPTION`
276    /// node for inline options, the `YAML_BLOCK_MAP_ENTRY` for hashpipe).
277    pub fn declaration_range(&self) -> rowan::TextRange {
278        self.declaration_range
279    }
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
283pub enum ChunkLabelSource {
284    InlineLabel,
285    LabelOption,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Hash)]
289pub struct ChunkLabelEntry {
290    value: String,
291    declaration_range: rowan::TextRange,
292    value_range: rowan::TextRange,
293    source: ChunkLabelSource,
294}
295
296impl ChunkLabelEntry {
297    pub fn new(
298        value: String,
299        declaration_range: rowan::TextRange,
300        value_range: rowan::TextRange,
301        source: ChunkLabelSource,
302    ) -> Self {
303        Self {
304            value,
305            declaration_range,
306            value_range,
307            source,
308        }
309    }
310
311    pub fn value(&self) -> &str {
312        &self.value
313    }
314
315    pub fn source(&self) -> ChunkLabelSource {
316        self.source
317    }
318
319    pub fn declaration_range(&self) -> rowan::TextRange {
320        self.declaration_range
321    }
322
323    pub fn value_range(&self) -> rowan::TextRange {
324        self.value_range
325    }
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Hash)]
329pub enum ChunkInfoItem {
330    Label(ChunkLabel),
331    Class(ChunkClass),
332    Id(ChunkId),
333    Option(ChunkOption),
334}
335
336pub struct ChunkOptions(SyntaxNode);
337
338impl AstNode for ChunkOptions {
339    type Language = PanacheLanguage;
340
341    fn can_cast(kind: SyntaxKind) -> bool {
342        kind == SyntaxKind::CHUNK_OPTIONS
343    }
344
345    fn cast(syntax: SyntaxNode) -> Option<Self> {
346        if Self::can_cast(syntax.kind()) {
347            Some(Self(syntax))
348        } else {
349            None
350        }
351    }
352
353    fn syntax(&self) -> &SyntaxNode {
354        &self.0
355    }
356}
357
358impl ChunkOptions {
359    pub fn options(&self) -> impl Iterator<Item = ChunkOption> {
360        support::children(&self.0)
361    }
362
363    pub fn labels(&self) -> impl Iterator<Item = ChunkLabel> {
364        self.0.children().filter_map(ChunkLabel::cast)
365    }
366
367    pub fn items(&self) -> impl Iterator<Item = ChunkInfoItem> {
368        self.0.children().filter_map(|child| {
369            if let Some(label) = ChunkLabel::cast(child.clone()) {
370                return Some(ChunkInfoItem::Label(label));
371            }
372            if let Some(class) = ChunkClass::cast(child.clone()) {
373                return Some(ChunkInfoItem::Class(class));
374            }
375            if let Some(id) = ChunkId::cast(child.clone()) {
376                return Some(ChunkInfoItem::Id(id));
377            }
378            ChunkOption::cast(child).map(ChunkInfoItem::Option)
379        })
380    }
381
382    pub fn option_entries(&self, source: ChunkOptionSource) -> Vec<ChunkOptionEntry> {
383        self.options()
384            .map(|option| ChunkOptionEntry::from_inline_option(&option, source))
385            .collect()
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::options::{Flavor, ParserOptions};
393    use crate::parse;
394
395    #[test]
396    fn test_chunk_option_quoted() {
397        let config = ParserOptions {
398            flavor: Flavor::Quarto,
399            extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
400            ..Default::default()
401        };
402        let tree = parse(
403            r#"```{r, fig.cap="A nice plot"}
404x <- 1
405```"#,
406            Some(config),
407        );
408
409        let option = tree
410            .descendants()
411            .find_map(ChunkOption::cast)
412            .expect("Should find chunk option");
413
414        assert_eq!(option.key(), Some("fig.cap".to_string()));
415        assert_eq!(option.value(), Some("A nice plot".to_string()));
416        assert!(option.key_range().is_some());
417        assert!(option.value_range().is_some());
418        assert!(option.is_quoted());
419        assert_eq!(option.quote_char(), Some('"'));
420    }
421
422    #[test]
423    fn test_chunk_option_unquoted() {
424        let config = ParserOptions {
425            flavor: Flavor::Quarto,
426            extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
427            ..Default::default()
428        };
429        let tree = parse("```{r, echo=TRUE}\nx <- 1\n```", Some(config));
430
431        let option = tree
432            .descendants()
433            .find_map(ChunkOption::cast)
434            .expect("Should find chunk option");
435
436        assert_eq!(option.key(), Some("echo".to_string()));
437        assert_eq!(option.value(), Some("TRUE".to_string()));
438        assert!(option.key_range().is_some());
439        assert!(option.value_range().is_some());
440        assert!(!option.is_quoted());
441    }
442
443    #[test]
444    fn test_chunk_label() {
445        let config = ParserOptions {
446            flavor: Flavor::Quarto,
447            extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
448            ..Default::default()
449        };
450        let tree = parse("```{r mylabel}\nx <- 1\n```", Some(config));
451
452        let label = tree
453            .descendants()
454            .find_map(ChunkLabel::cast)
455            .expect("Should find chunk label");
456
457        assert_eq!(label.text(), "mylabel");
458        assert!(!label.range().is_empty());
459    }
460}