panache_parser/syntax/
code_blocks.rs1use 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 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}