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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
133pub enum ChunkOptionSource {
134    InlineInfo,
135    HashpipeYaml,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Hash)]
139pub struct ChunkOptionEntry {
140    option: ChunkOption,
141    source: ChunkOptionSource,
142}
143
144impl ChunkOptionEntry {
145    pub fn new(option: ChunkOption, source: ChunkOptionSource) -> Self {
146        Self { option, source }
147    }
148
149    pub fn option(&self) -> &ChunkOption {
150        &self.option
151    }
152
153    pub fn into_option(self) -> ChunkOption {
154        self.option
155    }
156
157    pub fn source(&self) -> ChunkOptionSource {
158        self.source
159    }
160
161    pub fn key(&self) -> Option<String> {
162        self.option.key()
163    }
164
165    pub fn key_range(&self) -> Option<rowan::TextRange> {
166        self.option.key_range()
167    }
168
169    pub fn value(&self) -> Option<String> {
170        self.option.value()
171    }
172
173    pub fn value_range(&self) -> Option<rowan::TextRange> {
174        self.option.value_range()
175    }
176
177    pub fn is_quoted(&self) -> bool {
178        self.option.is_quoted()
179    }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
183pub enum ChunkLabelSource {
184    InlineLabel,
185    LabelOption,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Hash)]
189pub struct ChunkLabelEntry {
190    value: String,
191    declaration_range: rowan::TextRange,
192    value_range: rowan::TextRange,
193    source: ChunkLabelSource,
194}
195
196impl ChunkLabelEntry {
197    pub fn new(
198        value: String,
199        declaration_range: rowan::TextRange,
200        value_range: rowan::TextRange,
201        source: ChunkLabelSource,
202    ) -> Self {
203        Self {
204            value,
205            declaration_range,
206            value_range,
207            source,
208        }
209    }
210
211    pub fn value(&self) -> &str {
212        &self.value
213    }
214
215    pub fn source(&self) -> ChunkLabelSource {
216        self.source
217    }
218
219    pub fn declaration_range(&self) -> rowan::TextRange {
220        self.declaration_range
221    }
222
223    pub fn value_range(&self) -> rowan::TextRange {
224        self.value_range
225    }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Hash)]
229pub enum ChunkInfoItem {
230    Label(ChunkLabel),
231    Option(ChunkOption),
232}
233
234pub struct ChunkOptions(SyntaxNode);
235
236impl AstNode for ChunkOptions {
237    type Language = PanacheLanguage;
238
239    fn can_cast(kind: SyntaxKind) -> bool {
240        kind == SyntaxKind::CHUNK_OPTIONS
241    }
242
243    fn cast(syntax: SyntaxNode) -> Option<Self> {
244        if Self::can_cast(syntax.kind()) {
245            Some(Self(syntax))
246        } else {
247            None
248        }
249    }
250
251    fn syntax(&self) -> &SyntaxNode {
252        &self.0
253    }
254}
255
256impl ChunkOptions {
257    pub fn options(&self) -> impl Iterator<Item = ChunkOption> {
258        support::children(&self.0)
259    }
260
261    pub fn labels(&self) -> impl Iterator<Item = ChunkLabel> {
262        self.0.children().filter_map(ChunkLabel::cast)
263    }
264
265    pub fn items(&self) -> impl Iterator<Item = ChunkInfoItem> {
266        self.0.children().filter_map(|child| {
267            if let Some(label) = ChunkLabel::cast(child.clone()) {
268                return Some(ChunkInfoItem::Label(label));
269            }
270            ChunkOption::cast(child).map(ChunkInfoItem::Option)
271        })
272    }
273
274    pub fn option_entries(&self, source: ChunkOptionSource) -> Vec<ChunkOptionEntry> {
275        self.options()
276            .map(|option| ChunkOptionEntry::new(option, source))
277            .collect()
278    }
279}
280
281pub fn collect_option_entries_from_descendants(
282    root: &SyntaxNode,
283    source: ChunkOptionSource,
284) -> Vec<ChunkOptionEntry> {
285    root.descendants()
286        .filter_map(ChunkOption::cast)
287        .map(|option| ChunkOptionEntry::new(option, source))
288        .collect()
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::options::{Flavor, ParserOptions};
295    use crate::parse;
296
297    #[test]
298    fn test_chunk_option_quoted() {
299        let config = ParserOptions {
300            flavor: Flavor::Quarto,
301            extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
302            ..Default::default()
303        };
304        let tree = parse(
305            r#"```{r, fig.cap="A nice plot"}
306x <- 1
307```"#,
308            Some(config),
309        );
310
311        let option = tree
312            .descendants()
313            .find_map(ChunkOption::cast)
314            .expect("Should find chunk option");
315
316        assert_eq!(option.key(), Some("fig.cap".to_string()));
317        assert_eq!(option.value(), Some("A nice plot".to_string()));
318        assert!(option.key_range().is_some());
319        assert!(option.value_range().is_some());
320        assert!(option.is_quoted());
321        assert_eq!(option.quote_char(), Some('"'));
322    }
323
324    #[test]
325    fn test_chunk_option_unquoted() {
326        let config = ParserOptions {
327            flavor: Flavor::Quarto,
328            extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
329            ..Default::default()
330        };
331        let tree = parse("```{r, echo=TRUE}\nx <- 1\n```", Some(config));
332
333        let option = tree
334            .descendants()
335            .find_map(ChunkOption::cast)
336            .expect("Should find chunk option");
337
338        assert_eq!(option.key(), Some("echo".to_string()));
339        assert_eq!(option.value(), Some("TRUE".to_string()));
340        assert!(option.key_range().is_some());
341        assert!(option.value_range().is_some());
342        assert!(!option.is_quoted());
343    }
344
345    #[test]
346    fn test_chunk_label() {
347        let config = ParserOptions {
348            flavor: Flavor::Quarto,
349            extensions: crate::options::Extensions::for_flavor(Flavor::Quarto),
350            ..Default::default()
351        };
352        let tree = parse("```{r mylabel}\nx <- 1\n```", Some(config));
353
354        let label = tree
355            .descendants()
356            .find_map(ChunkLabel::cast)
357            .expect("Should find chunk label");
358
359        assert_eq!(label.text(), "mylabel");
360        assert!(!label.range().is_empty());
361    }
362}