1use crate::parser::diagnostics::{Diagnostics, SyntaxError, SyntaxErrorSource};
4use crate::parser::utils::attributes::emit_code_info_attrs;
5use crate::parser::utils::chunk_options::hashpipe_comment_prefix;
6use crate::syntax::SyntaxKind;
7use rowan::{GreenNodeBuilder, TextRange};
8
9use super::blockquotes::{count_blockquote_markers, strip_n_blockquote_markers};
10use super::container_prefix::{StrippedLines, advance_columns};
11use crate::options::{Dialect, Flavor};
12use crate::parser::utils::container_stack::byte_index_at_column;
13use crate::parser::utils::tree_copy::copy_green_children;
14use crate::parser::yaml::{
15 YamlValidationContext, locate_yaml_diagnostic_ctx, parse_stream_with_prefix,
16};
17
18pub(crate) use super::container_prefix::{
23 bq_outer_of_list, emit_blockquote_prefix_tokens, strip_list_indent,
24};
25
26use crate::parser::utils::helpers::{
27 strip_leading_spaces, strip_newline, trim_end_spaces_tabs, trim_start_spaces_tabs,
28};
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum CodeBlockType {
33 DisplayShortcut { language: String },
35 DisplayExplicit { classes: Vec<String> },
37 Executable { language: String },
39 Raw { format: String },
41 Plain,
43}
44
45#[derive(Debug, Clone, PartialEq)]
47pub struct InfoString {
48 pub raw: String,
49 pub block_type: CodeBlockType,
50 pub attributes: Vec<(String, Option<String>)>, }
52
53impl InfoString {
54 pub fn parse(raw: &str) -> Self {
63 Self::parse_with_dialect(raw, crate::options::Dialect::Pandoc)
64 }
65
66 pub fn parse_with_dialect(raw: &str, dialect: crate::options::Dialect) -> Self {
68 let trimmed = raw.trim();
69
70 if trimmed.is_empty() {
71 return InfoString {
72 raw: raw.to_string(),
73 block_type: CodeBlockType::Plain,
74 attributes: Vec::new(),
75 };
76 }
77
78 if dialect != crate::options::Dialect::Pandoc {
81 let language = trimmed.split_whitespace().next().unwrap_or(trimmed);
82 return InfoString {
83 raw: raw.to_string(),
84 block_type: CodeBlockType::DisplayShortcut {
85 language: language.to_string(),
86 },
87 attributes: Vec::new(),
88 };
89 }
90
91 if let Some(stripped) = trimmed.strip_prefix('{')
93 && let Some(content) = stripped.strip_suffix('}')
94 {
95 return Self::parse_explicit(raw, content);
96 }
97
98 if let Some(brace_start) = trimmed.find('{') {
100 let language = trimmed[..brace_start].trim();
101 if !language.is_empty() && !language.contains(char::is_whitespace) {
102 let attr_part = &trimmed[brace_start..];
103 if let Some(stripped) = attr_part.strip_prefix('{')
104 && let Some(content) = stripped.strip_suffix('}')
105 {
106 let attrs = Self::parse_attributes(content);
107 return InfoString {
108 raw: raw.to_string(),
109 block_type: CodeBlockType::DisplayShortcut {
110 language: language.to_string(),
111 },
112 attributes: attrs,
113 };
114 }
115 }
116 }
117
118 let language = trimmed.split_whitespace().next().unwrap_or(trimmed);
121 InfoString {
122 raw: raw.to_string(),
123 block_type: CodeBlockType::DisplayShortcut {
124 language: language.to_string(),
125 },
126 attributes: Vec::new(),
127 }
128 }
129
130 fn parse_explicit(raw: &str, content: &str) -> Self {
131 let trimmed_content = content.trim();
134 if let Some(format_name) = trimmed_content.strip_prefix('=') {
135 if !format_name.is_empty()
137 && format_name.chars().all(|c| c.is_alphanumeric())
138 && !format_name.contains(char::is_whitespace)
139 {
140 return InfoString {
141 raw: raw.to_string(),
142 block_type: CodeBlockType::Raw {
143 format: format_name.to_string(),
144 },
145 attributes: Vec::new(),
146 };
147 }
148 }
149
150 let prelim_attrs = Self::parse_chunk_options(content);
153
154 let mut first_lang_token = None;
156 for (key, val) in prelim_attrs.iter() {
157 if val.is_none() && !key.starts_with('#') {
158 first_lang_token = Some(key.as_str());
159 break;
160 }
161 }
162
163 let first_token = first_lang_token.unwrap_or("");
164
165 if first_token.starts_with('.') {
166 let attrs = Self::parse_pandoc_attributes(content);
169
170 let classes: Vec<String> = attrs
171 .iter()
172 .filter(|(k, v)| k.starts_with('.') && v.is_none())
173 .map(|(k, _)| k[1..].to_string())
174 .collect();
175
176 let non_class_attrs: Vec<(String, Option<String>)> = attrs
177 .into_iter()
178 .filter(|(k, _)| !k.starts_with('.') || k.contains('='))
179 .collect();
180
181 InfoString {
182 raw: raw.to_string(),
183 block_type: CodeBlockType::DisplayExplicit { classes },
184 attributes: non_class_attrs,
185 }
186 } else if !first_token.is_empty() && !first_token.starts_with('#') {
187 let attrs = Self::parse_chunk_options(content);
190 let lang_index = attrs.iter().position(|(k, _)| k == first_token).unwrap();
191
192 let mut has_implicit_label = false;
197 let implicit_label_value = if lang_index + 1 < attrs.len() {
198 let (label_key, val) = &attrs[lang_index + 1];
199 if val.is_none() && !label_key.starts_with('.') && !label_key.starts_with('#') {
200 has_implicit_label = true;
201 Some(label_key.clone())
202 } else {
203 None
204 }
205 } else {
206 None
207 };
208
209 let mut final_attrs: Vec<(String, Option<String>)> = attrs
210 .into_iter()
211 .enumerate()
212 .filter(|(i, _)| {
213 if *i == lang_index {
215 return false;
216 }
217 if has_implicit_label && *i == lang_index + 1 {
219 return false;
220 }
221 true
222 })
223 .map(|(_, attr)| attr)
224 .collect();
225
226 if let Some(label_val) = implicit_label_value {
228 final_attrs.insert(0, ("label".to_string(), Some(label_val)));
229 }
230
231 InfoString {
232 raw: raw.to_string(),
233 block_type: CodeBlockType::Executable {
234 language: first_token.to_string(),
235 },
236 attributes: final_attrs,
237 }
238 } else {
239 let attrs = Self::parse_pandoc_attributes(content);
241 InfoString {
242 raw: raw.to_string(),
243 block_type: CodeBlockType::Plain,
244 attributes: attrs,
245 }
246 }
247 }
248
249 fn parse_pandoc_attributes(content: &str) -> Vec<(String, Option<String>)> {
252 let mut attrs = Vec::new();
253 let mut chars = content.chars().peekable();
254
255 while chars.peek().is_some() {
256 while matches!(chars.peek(), Some(&' ') | Some(&'\t')) {
258 chars.next();
259 }
260
261 if chars.peek().is_none() {
262 break;
263 }
264
265 let mut key = String::new();
267 while let Some(&ch) = chars.peek() {
268 if ch == '=' || ch == ' ' || ch == '\t' {
269 break;
270 }
271 key.push(ch);
272 chars.next();
273 }
274
275 if key.is_empty() {
276 break;
277 }
278
279 while matches!(chars.peek(), Some(&' ') | Some(&'\t')) {
281 chars.next();
282 }
283
284 if chars.peek() == Some(&'=') {
286 chars.next(); while matches!(chars.peek(), Some(&' ') | Some(&'\t')) {
290 chars.next();
291 }
292
293 let value = if chars.peek() == Some(&'"') {
295 chars.next(); let mut val = String::new();
297 while let Some(&ch) = chars.peek() {
298 chars.next();
299 if ch == '"' {
300 break;
301 }
302 if ch == '\\' {
303 if let Some(&next_ch) = chars.peek() {
304 chars.next();
305 val.push(next_ch);
306 }
307 } else {
308 val.push(ch);
309 }
310 }
311 val
312 } else {
313 let mut val = String::new();
315 while let Some(&ch) = chars.peek() {
316 if ch == ' ' || ch == '\t' {
317 break;
318 }
319 val.push(ch);
320 chars.next();
321 }
322 val
323 };
324
325 attrs.push((key, Some(value)));
326 } else {
327 attrs.push((key, None));
328 }
329 }
330
331 attrs
332 }
333
334 fn parse_chunk_options(content: &str) -> Vec<(String, Option<String>)> {
337 let mut attrs = Vec::new();
338 let mut chars = content.chars().peekable();
339
340 while chars.peek().is_some() {
341 while matches!(chars.peek(), Some(&' ') | Some(&'\t') | Some(&',')) {
343 chars.next();
344 }
345
346 if chars.peek().is_none() {
347 break;
348 }
349
350 let mut key = String::new();
352 while let Some(&ch) = chars.peek() {
353 if ch == '=' || ch == ' ' || ch == '\t' || ch == ',' {
354 break;
355 }
356 key.push(ch);
357 chars.next();
358 }
359
360 if key.is_empty() {
361 break;
362 }
363
364 while matches!(chars.peek(), Some(&' ') | Some(&'\t') | Some(&',')) {
366 chars.next();
367 }
368
369 if chars.peek() == Some(&'=') {
371 chars.next(); while matches!(chars.peek(), Some(&' ') | Some(&'\t') | Some(&',')) {
375 chars.next();
376 }
377
378 let value = if chars.peek() == Some(&'"') {
380 chars.next(); let mut val = String::new();
382 while let Some(&ch) = chars.peek() {
383 chars.next();
384 if ch == '"' {
385 break;
386 }
387 if ch == '\\' {
388 if let Some(&next_ch) = chars.peek() {
389 chars.next();
390 val.push(next_ch);
391 }
392 } else {
393 val.push(ch);
394 }
395 }
396 val
397 } else {
398 let mut val = String::new();
401 let mut depth = 0; let mut in_quote: Option<char> = None; let mut escaped = false; while let Some(&ch) = chars.peek() {
406 if escaped {
408 val.push(ch);
409 chars.next();
410 escaped = false;
411 continue;
412 }
413
414 if ch == '\\' {
415 val.push(ch);
416 chars.next();
417 escaped = true;
418 continue;
419 }
420
421 if let Some(quote_char) = in_quote {
423 val.push(ch);
424 chars.next();
425 if ch == quote_char {
426 in_quote = None; }
428 continue;
429 }
430
431 if ch == '"' || ch == '\'' {
433 in_quote = Some(ch);
434 val.push(ch);
435 chars.next();
436 continue;
437 }
438
439 if ch == '(' || ch == '[' || ch == '{' {
441 depth += 1;
442 val.push(ch);
443 chars.next();
444 continue;
445 }
446
447 if ch == ')' || ch == ']' || ch == '}' {
448 depth -= 1;
449 val.push(ch);
450 chars.next();
451 continue;
452 }
453
454 if depth == 0 && (ch == ' ' || ch == '\t' || ch == ',') {
456 break;
457 }
458
459 val.push(ch);
461 chars.next();
462 }
463 val
464 };
465
466 attrs.push((key, Some(value)));
467 } else {
468 attrs.push((key, None));
469 }
470 }
471
472 attrs
473 }
474
475 fn parse_attributes(content: &str) -> Vec<(String, Option<String>)> {
478 Self::parse_chunk_options(content)
480 }
481}
482
483#[derive(Debug, Clone)]
485pub(crate) struct FenceInfo {
486 pub fence_char: char,
487 pub fence_count: usize,
488 pub info_string: String,
489}
490
491pub(crate) fn is_gfm_math_fence(fence: &FenceInfo) -> bool {
492 fence.info_string.trim() == "math"
493}
494
495pub(crate) fn try_parse_fence_open(
498 content: &str,
499 dialect: crate::options::Dialect,
500) -> Option<FenceInfo> {
501 let trimmed = strip_leading_spaces(content);
502
503 let (fence_char, fence_count) = if trimmed.starts_with('`') {
505 let count = trimmed.chars().take_while(|&c| c == '`').count();
506 ('`', count)
507 } else if trimmed.starts_with('~') {
508 let count = trimmed.chars().take_while(|&c| c == '~').count();
509 ('~', count)
510 } else {
511 return None;
512 };
513
514 if fence_count < 3 {
515 return None;
516 }
517
518 let info_string_raw = &trimmed[fence_count..];
519 let (info_string_trimmed, _) = strip_newline(info_string_raw);
521 let info_string = if let Some(stripped) = info_string_trimmed.strip_prefix(' ') {
522 stripped.to_string()
523 } else {
524 info_string_trimmed.to_string()
525 };
526
527 if fence_char == '`' && info_string.contains('`') {
529 return None;
530 }
531
532 if dialect == crate::options::Dialect::Pandoc {
544 let bare = info_string.trim();
545 if !bare.is_empty() {
546 let is_valid = if let Some(brace_start) = bare.find('{') {
547 let before = bare[..brace_start].trim();
548 !before.contains(char::is_whitespace) && bare.ends_with('}')
549 } else {
550 bare.split_whitespace().nth(1).is_none()
551 };
552 if !is_valid {
553 return None;
554 }
555 }
556 }
557
558 Some(FenceInfo {
559 fence_char,
560 fence_count,
561 info_string,
562 })
563}
564
565#[allow(clippy::too_many_arguments)]
566fn prepare_fence_open_line<'a>(
567 builder: &mut GreenNodeBuilder<'static>,
568 source_line: &'a str,
569 first_line_override: Option<&'a str>,
570 bq_depth: usize,
571 list_content_col: usize,
572 list_marker_consumed_on_line_0: bool,
573 bq_outer: bool,
574 content_indent: usize,
575) -> (&'a str, &'a str) {
576 if let Some(first_line) = first_line_override {
585 if bq_depth > 0 && source_line != first_line {
586 let stripped = strip_n_blockquote_markers(source_line, bq_depth);
587 let prefix_len = source_line.len().saturating_sub(stripped.len());
588 if prefix_len > 0 {
589 emit_blockquote_prefix_tokens(builder, &source_line[..prefix_len]);
590 }
591 }
592 let first_trimmed = strip_leading_spaces(first_line);
593 let leading_ws_len = first_line.len().saturating_sub(first_trimmed.len());
594 if leading_ws_len > 0 {
595 builder.token(SyntaxKind::WHITESPACE.into(), &first_line[..leading_ws_len]);
596 }
597 return (first_trimmed, first_line);
598 }
599
600 let mut s: &'a str = source_line;
601 let mut pending_ws_start: Option<usize> = None;
602 let suppress_list = list_marker_consumed_on_line_0;
603
604 let flush_ws = |builder: &mut GreenNodeBuilder<'static>,
605 pending: &mut Option<usize>,
606 current_offset: usize| {
607 if let Some(start) = *pending
608 && current_offset > start
609 {
610 builder.token(
611 SyntaxKind::WHITESPACE.into(),
612 &source_line[start..current_offset],
613 );
614 }
615 *pending = None;
616 };
617
618 let do_strip_list = |s: &mut &'a str, pending: &mut Option<usize>| {
619 if list_content_col == 0 {
620 return;
621 }
622 let stripped = if suppress_list {
630 advance_columns(s, list_content_col)
631 } else {
632 strip_list_indent(s, list_content_col)
633 };
634 let consumed = s.len() - stripped.len();
635 if consumed > 0 {
636 let start = source_line.len() - s.len();
637 if !suppress_list && pending.is_none() {
638 *pending = Some(start);
639 }
640 *s = stripped;
641 }
642 };
643
644 let do_strip_bq =
645 |builder: &mut GreenNodeBuilder<'static>, s: &mut &'a str, pending: &mut Option<usize>| {
646 if bq_depth == 0 {
647 return;
648 }
649 let current_offset = source_line.len() - s.len();
650 flush_ws(builder, pending, current_offset);
651 *s = strip_n_blockquote_markers(s, bq_depth);
652 };
653
654 if bq_outer {
655 do_strip_bq(builder, &mut s, &mut pending_ws_start);
656 do_strip_list(&mut s, &mut pending_ws_start);
657 } else {
658 do_strip_list(&mut s, &mut pending_ws_start);
659 do_strip_bq(builder, &mut s, &mut pending_ws_start);
660 }
661
662 if content_indent > 0 {
664 let indent_bytes = byte_index_at_column(s, content_indent);
665 if s.len() >= indent_bytes && indent_bytes > 0 {
666 let start = source_line.len() - s.len();
667 if pending_ws_start.is_none() {
668 pending_ws_start = Some(start);
669 }
670 s = &s[indent_bytes..];
671 }
672 }
673
674 let final_offset = source_line.len() - s.len();
675 flush_ws(builder, &mut pending_ws_start, final_offset);
676
677 let first_trimmed = strip_leading_spaces(s);
678 let leading_ws_len = s.len().saturating_sub(first_trimmed.len());
679 if leading_ws_len > 0 {
680 builder.token(SyntaxKind::WHITESPACE.into(), &s[..leading_ws_len]);
681 }
682 (first_trimmed, s)
683}
684
685fn strip_content_line_prefixes(
686 content_line: &str,
687 bq_depth: usize,
688 list_content_col: usize,
689 bq_outer: bool,
690 content_indent: usize,
691) -> &str {
692 let after_bq_and_list = if bq_outer {
693 let after_bq = if bq_depth > 0 {
694 strip_n_blockquote_markers(content_line, bq_depth)
695 } else {
696 content_line
697 };
698 strip_list_indent(after_bq, list_content_col)
699 } else {
700 let after_list = strip_list_indent(content_line, list_content_col);
701 if bq_depth > 0 {
702 strip_n_blockquote_markers(after_list, bq_depth)
703 } else {
704 after_list
705 }
706 };
707
708 let indent_bytes = byte_index_at_column(after_bq_and_list, content_indent);
709 if content_indent > 0 && after_bq_and_list.len() >= indent_bytes {
710 &after_bq_and_list[indent_bytes..]
711 } else {
712 after_bq_and_list
713 }
714}
715
716pub(crate) fn compute_hashpipe_preamble_line_count(
717 content_lines: &[&str],
718 prefix: &str,
719 bq_depth: usize,
720 list_content_col: usize,
721 bq_outer: bool,
722 content_indent: usize,
723) -> usize {
724 let preview = |idx: usize| -> Option<&str> {
725 let line = content_lines.get(idx)?;
726 let after_indent =
727 strip_content_line_prefixes(line, bq_depth, list_content_col, bq_outer, content_indent);
728 Some(strip_newline(after_indent).0)
729 };
730
731 let mut line_idx = 0usize;
732 while let Some(preview_without_newline) = preview(line_idx) {
733 if is_hashpipe_option_line(preview_without_newline, prefix)
734 || is_hashpipe_continuation_line(preview_without_newline, prefix)
735 {
736 line_idx += 1;
737 continue;
738 }
739 if is_hashpipe_blank_line(preview_without_newline, prefix)
743 && preview(line_idx + 1)
744 .is_some_and(|next| trim_start_spaces_tabs(next).starts_with(prefix))
745 {
746 line_idx += 1;
747 continue;
748 }
749 break;
750 }
751
752 line_idx
753}
754
755fn hashpipe_composite_marker<'a>(
767 first_line: &'a str,
768 prefix: &str,
769 bq_depth: usize,
770 list_content_col: usize,
771 bq_outer: bool,
772 content_indent: usize,
773) -> &'a str {
774 let after_container = strip_content_line_prefixes(
775 first_line,
776 bq_depth,
777 list_content_col,
778 bq_outer,
779 content_indent,
780 );
781 let container_len = first_line.len() - after_container.len();
782 let ws_before = after_container.len() - trim_start_spaces_tabs(after_container).len();
783 let marker_len = (container_len + ws_before + prefix.len()).min(first_line.len());
784 &first_line[..marker_len]
785}
786
787fn is_hashpipe_option_line(line_without_newline: &str, prefix: &str) -> bool {
788 let trimmed_start = trim_start_spaces_tabs(line_without_newline);
789 if !trimmed_start.starts_with(prefix) {
790 return false;
791 }
792 let after_prefix = &trimmed_start[prefix.len()..];
793 let rest = trim_start_spaces_tabs(after_prefix);
794 let Some(colon_idx) = rest.find(':') else {
795 return false;
796 };
797 let key = trim_end_spaces_tabs(&rest[..colon_idx]);
798 if key.is_empty() {
799 return false;
800 }
801 true
802}
803
804fn is_hashpipe_continuation_line(line_without_newline: &str, prefix: &str) -> bool {
805 let trimmed_start = trim_start_spaces_tabs(line_without_newline);
806 if !trimmed_start.starts_with(prefix) {
807 return false;
808 }
809 let after_prefix = &trimmed_start[prefix.len()..];
810 let Some(first) = after_prefix.chars().next() else {
811 return false;
812 };
813 if first != ' ' && first != '\t' {
814 return false;
815 }
816 !trim_start_spaces_tabs(after_prefix).is_empty()
817}
818
819fn is_hashpipe_blank_line(line_without_newline: &str, prefix: &str) -> bool {
826 let trimmed_start = trim_start_spaces_tabs(line_without_newline);
827 let Some(after_prefix) = trimmed_start.strip_prefix(prefix) else {
828 return false;
829 };
830 trim_start_spaces_tabs(after_prefix).is_empty()
831}
832
833pub(crate) fn is_closing_fence(content: &str, fence: &FenceInfo) -> bool {
835 let trimmed = strip_leading_spaces(content);
836
837 if !trimmed.starts_with(fence.fence_char) {
838 return false;
839 }
840
841 let closing_count = trimmed
842 .chars()
843 .take_while(|&c| c == fence.fence_char)
844 .count();
845
846 if closing_count < fence.fence_count {
847 return false;
848 }
849
850 trimmed[closing_count..].trim().is_empty()
852}
853
854fn emit_chunk_options(builder: &mut GreenNodeBuilder<'static>, content: &str) {
857 if content.trim().is_empty() {
858 builder.token(SyntaxKind::TEXT.into(), content);
859 return;
860 }
861
862 builder.start_node(SyntaxKind::CHUNK_OPTIONS.into());
863
864 let mut pos = 0;
865 let bytes = content.as_bytes();
866
867 while pos < bytes.len() {
868 let ws_start = pos;
870 while pos < bytes.len() {
871 let ch = bytes[pos] as char;
872 if ch != ' ' && ch != '\t' && ch != ',' {
873 break;
874 }
875 pos += 1;
876 }
877 if pos > ws_start {
878 builder.token(SyntaxKind::TEXT.into(), &content[ws_start..pos]);
879 }
880
881 if pos >= bytes.len() {
882 break;
883 }
884
885 if bytes[pos] as char == '}' {
887 builder.token(SyntaxKind::TEXT.into(), &content[pos..pos + 1]);
888 pos += 1;
889 if pos < bytes.len() {
890 builder.token(SyntaxKind::TEXT.into(), &content[pos..]);
891 }
892 break;
893 }
894
895 let key_start = pos;
897 while pos < bytes.len() {
898 let ch = bytes[pos] as char;
899 if ch == '=' || ch == ' ' || ch == '\t' || ch == ',' || ch == '}' {
900 break;
901 }
902 pos += 1;
903 }
904
905 if pos == key_start {
906 if pos < bytes.len() {
908 builder.token(SyntaxKind::TEXT.into(), &content[pos..]);
909 }
910 break;
911 }
912
913 let key = &content[key_start..pos];
914
915 let ws_before_eq_start = pos;
917 while pos < bytes.len() && matches!(bytes[pos] as char, ' ' | '\t') {
918 pos += 1;
919 }
920
921 if pos < bytes.len() && bytes[pos] as char == '=' {
923 builder.start_node(SyntaxKind::CHUNK_OPTION.into());
925 builder.token(SyntaxKind::CHUNK_OPTION_KEY.into(), key);
926
927 if pos > ws_before_eq_start {
929 builder.token(SyntaxKind::TEXT.into(), &content[ws_before_eq_start..pos]);
930 }
931
932 builder.token(SyntaxKind::TEXT.into(), "=");
933 pos += 1; let ws_after_eq_start = pos;
937 while pos < bytes.len() && matches!(bytes[pos] as char, ' ' | '\t') {
938 pos += 1;
939 }
940 if pos > ws_after_eq_start {
941 builder.token(SyntaxKind::TEXT.into(), &content[ws_after_eq_start..pos]);
942 }
943
944 if pos < bytes.len() {
946 let quote_char = bytes[pos] as char;
947 if quote_char == '"' || quote_char == '\'' {
948 builder.token(
950 SyntaxKind::CHUNK_OPTION_QUOTE.into(),
951 &content[pos..pos + 1],
952 );
953 pos += 1; let val_start = pos;
956 let mut escaped = false;
957 while pos < bytes.len() {
958 let ch = bytes[pos] as char;
959 if !escaped && ch == quote_char {
960 break;
961 }
962 escaped = !escaped && ch == '\\';
963 pos += 1;
964 }
965
966 if pos > val_start {
967 builder.token(
968 SyntaxKind::CHUNK_OPTION_VALUE.into(),
969 &content[val_start..pos],
970 );
971 }
972
973 if pos < bytes.len() && bytes[pos] as char == quote_char {
975 builder.token(
976 SyntaxKind::CHUNK_OPTION_QUOTE.into(),
977 &content[pos..pos + 1],
978 );
979 pos += 1;
980 }
981 } else {
982 let val_start = pos;
984 let mut depth = 0;
985
986 while pos < bytes.len() {
987 let ch = bytes[pos] as char;
988 match ch {
989 '(' | '[' | '{' => depth += 1,
990 ')' | ']' => {
991 if depth > 0 {
992 depth -= 1;
993 } else {
994 break;
995 }
996 }
997 '}' => {
998 if depth > 0 {
999 depth -= 1;
1000 } else {
1001 break; }
1003 }
1004 ',' if depth == 0 => {
1005 break; }
1007 ' ' | '\t' if depth == 0 => {
1008 break; }
1010 _ => {}
1011 }
1012 pos += 1;
1013 }
1014
1015 if pos > val_start {
1016 builder.token(
1017 SyntaxKind::CHUNK_OPTION_VALUE.into(),
1018 &content[val_start..pos],
1019 );
1020 }
1021 }
1022 }
1023
1024 builder.finish_node(); } else {
1026 let kind = match key.as_bytes().first() {
1029 Some(b'.') => SyntaxKind::ATTR_CLASS,
1030 Some(b'#') => SyntaxKind::ATTR_ID,
1031 _ => SyntaxKind::CHUNK_LABEL,
1032 };
1033 builder.start_node(kind.into());
1034 builder.token(SyntaxKind::TEXT.into(), key);
1035 builder.finish_node();
1036 if pos > ws_before_eq_start {
1037 builder.token(SyntaxKind::TEXT.into(), &content[ws_before_eq_start..pos]);
1038 }
1039 }
1040 }
1041
1042 builder.finish_node(); }
1044
1045fn emit_code_info_node(
1048 builder: &mut GreenNodeBuilder<'static>,
1049 info_string: &str,
1050 dialect: crate::options::Dialect,
1051) {
1052 builder.start_node(SyntaxKind::CODE_INFO.into());
1053
1054 let info = InfoString::parse_with_dialect(info_string, dialect);
1055
1056 match &info.block_type {
1057 CodeBlockType::DisplayShortcut { language } => {
1058 builder.token(SyntaxKind::CODE_LANGUAGE.into(), language);
1060
1061 let after_lang = &info_string[language.len()..];
1065 if !after_lang.is_empty()
1066 && !emit_code_info_attrs(builder, after_lang, false)
1067 {
1068 builder.token(SyntaxKind::TEXT.into(), after_lang);
1069 }
1070 }
1071 CodeBlockType::Executable { language } => {
1072 builder.token(SyntaxKind::TEXT.into(), "{");
1074 builder.token(SyntaxKind::CODE_LANGUAGE.into(), language);
1075
1076 let start_offset = 1 + language.len(); if start_offset < info_string.len() {
1079 let rest = &info_string[start_offset..];
1080 emit_chunk_options(builder, rest);
1081 }
1082 }
1083 CodeBlockType::DisplayExplicit { .. } => {
1084 if !emit_code_info_attrs(builder, info_string, true) {
1090 builder.token(SyntaxKind::TEXT.into(), info_string);
1091 }
1092 }
1093 CodeBlockType::Raw { .. } | CodeBlockType::Plain => {
1094 builder.token(SyntaxKind::TEXT.into(), info_string);
1096 }
1097 }
1098
1099 builder.finish_node(); }
1101
1102pub(crate) fn parse_fenced_code_block(
1112 builder: &mut GreenNodeBuilder<'static>,
1113 window: &StrippedLines<'_, '_>,
1114 fence: FenceInfo,
1115 first_line_override: Option<&str>,
1116 diags: &Diagnostics,
1117 flavor: Flavor,
1118) -> usize {
1119 let lines = window.raw();
1120 let start_pos = window.pos();
1121 let prefix = window.prefix();
1122 let bq_depth = prefix.bq_depth();
1123 let list_content_col = prefix.list_content_col();
1124 let list_marker_consumed_on_line_0 = prefix.list_marker_consumed_on_line_0;
1125 let bq_outer = bq_outer_of_list(prefix);
1126 let content_indent = prefix.content_indent();
1127
1128 builder.start_node(SyntaxKind::CODE_BLOCK.into());
1130
1131 let (first_trimmed, _first_inner) = prepare_fence_open_line(
1133 builder,
1134 lines[start_pos],
1135 first_line_override,
1136 bq_depth,
1137 list_content_col,
1138 list_marker_consumed_on_line_0,
1139 bq_outer,
1140 content_indent,
1141 );
1142
1143 builder.start_node(SyntaxKind::CODE_FENCE_OPEN.into());
1144 builder.token(
1145 SyntaxKind::CODE_FENCE_MARKER.into(),
1146 &first_trimmed[..fence.fence_count],
1147 );
1148
1149 let after_fence = &first_trimmed[fence.fence_count..];
1151 if let Some(_space_stripped) = after_fence.strip_prefix(' ') {
1152 builder.token(SyntaxKind::WHITESPACE.into(), " ");
1154 if !fence.info_string.is_empty() {
1156 emit_code_info_node(builder, &fence.info_string, Dialect::for_flavor(flavor));
1157 }
1158 } else if !fence.info_string.is_empty() {
1159 emit_code_info_node(builder, &fence.info_string, Dialect::for_flavor(flavor));
1161 }
1162
1163 let (_, newline_str) = strip_newline(first_trimmed);
1165 if !newline_str.is_empty() {
1166 builder.token(SyntaxKind::NEWLINE.into(), newline_str);
1167 }
1168 builder.finish_node(); let mut current_pos = start_pos + 1;
1171 let mut content_lines: Vec<&str> = Vec::new(); let mut found_closing = false;
1173
1174 while current_pos < lines.len() {
1175 let line = lines[current_pos];
1176
1177 let probe = if bq_outer {
1183 line
1184 } else {
1185 strip_list_indent(line, list_content_col)
1186 };
1187 let (line_bq_depth, _) = count_blockquote_markers(probe);
1188 if line_bq_depth < bq_depth {
1189 break;
1190 }
1191
1192 let inner_stripped =
1197 strip_content_line_prefixes(line, bq_depth, list_content_col, bq_outer, content_indent);
1198
1199 if is_closing_fence(inner_stripped, &fence) {
1200 found_closing = true;
1201 current_pos += 1;
1202 break;
1203 }
1204
1205 content_lines.push(line);
1206 current_pos += 1;
1207 }
1208
1209 if !content_lines.is_empty() {
1211 builder.start_node(SyntaxKind::CODE_CONTENT.into());
1212 let hashpipe_prefix = match InfoString::parse(&fence.info_string).block_type {
1213 CodeBlockType::Executable { language } => hashpipe_comment_prefix(&language),
1214 _ => None,
1215 };
1216
1217 let mut line_idx = 0usize;
1218 if let Some(prefix) = hashpipe_prefix {
1219 let prepared_hashpipe_lines = compute_hashpipe_preamble_line_count(
1220 &content_lines,
1221 prefix,
1222 bq_depth,
1223 list_content_col,
1224 bq_outer,
1225 content_indent,
1226 );
1227 if prepared_hashpipe_lines > 0 {
1228 builder.start_node(SyntaxKind::HASHPIPE_YAML_PREAMBLE.into());
1229 builder.start_node(SyntaxKind::HASHPIPE_YAML_CONTENT.into());
1230
1231 let content: String = content_lines[..prepared_hashpipe_lines].concat();
1235 let marker = hashpipe_composite_marker(
1239 content_lines[0],
1240 prefix,
1241 bq_depth,
1242 list_content_col,
1243 bq_outer,
1244 content_indent,
1245 );
1246
1247 let yaml_ctx = YamlValidationContext::hashpipe(flavor);
1248 if let Some((diag, start_off, end_off)) =
1249 locate_yaml_diagnostic_ctx(&content, marker, yaml_ctx)
1250 {
1251 let host_start =
1259 content_lines[0].as_ptr() as usize - lines[0].as_ptr() as usize;
1260 diags.push(SyntaxError {
1261 range: TextRange::new(
1262 ((host_start + start_off) as u32).into(),
1263 ((host_start + end_off) as u32).into(),
1264 ),
1265 message: diag.message.to_string(),
1266 source: SyntaxErrorSource::Yaml,
1267 });
1268 while line_idx < prepared_hashpipe_lines {
1272 let after_indent = window.emit_prefix_at(builder, start_pos + 1 + line_idx);
1273 let (line_without_newline, newline_str) = strip_newline(after_indent);
1274 if !line_without_newline.is_empty() {
1275 builder.token(SyntaxKind::TEXT.into(), line_without_newline);
1276 }
1277 if !newline_str.is_empty() {
1278 builder.token(SyntaxKind::NEWLINE.into(), newline_str);
1279 }
1280 line_idx += 1;
1281 }
1282 } else {
1283 let stream = parse_stream_with_prefix(&content, marker)
1288 .green()
1289 .into_owned();
1290 copy_green_children(builder, &stream);
1291 }
1292 line_idx = prepared_hashpipe_lines;
1294
1295 builder.finish_node(); builder.finish_node(); }
1298 }
1299
1300 for k in line_idx..content_lines.len() {
1301 let after_indent = window.emit_prefix_at(builder, start_pos + 1 + k);
1302 let (line_without_newline, newline_str) = strip_newline(after_indent);
1303
1304 if !line_without_newline.is_empty() {
1305 builder.token(SyntaxKind::TEXT.into(), line_without_newline);
1306 }
1307
1308 if !newline_str.is_empty() {
1309 builder.token(SyntaxKind::NEWLINE.into(), newline_str);
1310 }
1311 }
1312 builder.finish_node(); }
1314
1315 if found_closing {
1317 let closing_stripped = window.emit_prefix_at(builder, current_pos - 1);
1318 let (closing_without_newline, newline_str) = strip_newline(closing_stripped);
1319 let closing_trimmed_start = strip_leading_spaces(closing_without_newline);
1320 let leading_ws_len = closing_without_newline.len() - closing_trimmed_start.len();
1321 let closing_count = closing_trimmed_start
1322 .chars()
1323 .take_while(|&c| c == fence.fence_char)
1324 .count();
1325 let trailing_after_marker = &closing_trimmed_start[closing_count..];
1326
1327 builder.start_node(SyntaxKind::CODE_FENCE_CLOSE.into());
1328 if leading_ws_len > 0 {
1329 builder.token(
1330 SyntaxKind::WHITESPACE.into(),
1331 &closing_without_newline[..leading_ws_len],
1332 );
1333 }
1334 builder.token(
1335 SyntaxKind::CODE_FENCE_MARKER.into(),
1336 &closing_trimmed_start[..closing_count],
1337 );
1338 if !trailing_after_marker.is_empty() {
1339 builder.token(SyntaxKind::WHITESPACE.into(), trailing_after_marker);
1340 }
1341 if !newline_str.is_empty() {
1342 builder.token(SyntaxKind::NEWLINE.into(), newline_str);
1343 }
1344 builder.finish_node(); }
1346
1347 builder.finish_node(); current_pos
1350}
1351
1352pub(crate) fn parse_fenced_math_block(
1357 builder: &mut GreenNodeBuilder<'static>,
1358 window: &StrippedLines<'_, '_>,
1359 fence: FenceInfo,
1360 first_line_override: Option<&str>,
1361) -> usize {
1362 let lines = window.raw();
1363 let start_pos = window.pos();
1364 let prefix = window.prefix();
1365 let bq_depth = prefix.bq_depth();
1366 let list_content_col = prefix.list_content_col();
1367 let list_marker_consumed_on_line_0 = prefix.list_marker_consumed_on_line_0;
1368 let bq_outer = bq_outer_of_list(prefix);
1369 let content_indent = prefix.content_indent();
1370
1371 builder.start_node(SyntaxKind::DISPLAY_MATH.into());
1372
1373 let (first_trimmed, _first_inner) = prepare_fence_open_line(
1374 builder,
1375 lines[start_pos],
1376 first_line_override,
1377 bq_depth,
1378 list_content_col,
1379 list_marker_consumed_on_line_0,
1380 bq_outer,
1381 content_indent,
1382 );
1383 let (opening_without_newline, opening_newline) = strip_newline(first_trimmed);
1384 builder.token(
1385 SyntaxKind::DISPLAY_MATH_MARKER.into(),
1386 opening_without_newline,
1387 );
1388 if !opening_newline.is_empty() {
1389 builder.token(SyntaxKind::NEWLINE.into(), opening_newline);
1390 }
1391
1392 let mut current_pos = start_pos + 1;
1393 let mut content_lines: Vec<&str> = Vec::new();
1394 let mut found_closing = false;
1395
1396 while current_pos < lines.len() {
1397 let line = lines[current_pos];
1398
1399 let probe = if bq_outer {
1402 line
1403 } else {
1404 strip_list_indent(line, list_content_col)
1405 };
1406 let (line_bq_depth, _) = count_blockquote_markers(probe);
1407 if line_bq_depth < bq_depth {
1408 break;
1409 }
1410
1411 let inner_stripped =
1413 strip_content_line_prefixes(line, bq_depth, list_content_col, bq_outer, content_indent);
1414
1415 if is_closing_fence(inner_stripped, &fence) {
1416 found_closing = true;
1417 current_pos += 1;
1418 break;
1419 }
1420
1421 content_lines.push(line);
1422 current_pos += 1;
1423 }
1424
1425 if !content_lines.is_empty() {
1426 let mut content = String::new();
1427 for k in 0..content_lines.len() {
1428 let after_indent = window.emit_prefix_at(builder, start_pos + 1 + k);
1429 let (line_without_newline, newline_str) = strip_newline(after_indent);
1430 content.push_str(line_without_newline);
1431 content.push_str(newline_str);
1432 }
1433 builder.token(SyntaxKind::TEXT.into(), &content);
1434 }
1435
1436 if found_closing {
1437 let closing_stripped = window.emit_prefix_at(builder, current_pos - 1);
1438 let (closing_without_newline, newline_str) = strip_newline(closing_stripped);
1439 let closing_trimmed_start = strip_leading_spaces(closing_without_newline);
1440 let leading_ws_len = closing_without_newline.len() - closing_trimmed_start.len();
1441 let closing_count = closing_trimmed_start
1442 .chars()
1443 .take_while(|&c| c == fence.fence_char)
1444 .count();
1445 let trailing_after_marker = &closing_trimmed_start[closing_count..];
1446
1447 if leading_ws_len > 0 {
1448 builder.token(
1449 SyntaxKind::WHITESPACE.into(),
1450 &closing_without_newline[..leading_ws_len],
1451 );
1452 }
1453 builder.token(
1454 SyntaxKind::DISPLAY_MATH_MARKER.into(),
1455 &closing_trimmed_start[..closing_count],
1456 );
1457 if !trailing_after_marker.is_empty() {
1458 builder.token(SyntaxKind::WHITESPACE.into(), trailing_after_marker);
1459 }
1460 if !newline_str.is_empty() {
1461 builder.token(SyntaxKind::NEWLINE.into(), newline_str);
1462 }
1463 }
1464
1465 builder.finish_node(); current_pos
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471 use super::*;
1472
1473 use crate::options::Dialect;
1474
1475 #[test]
1476 fn test_backtick_fence() {
1477 let fence = try_parse_fence_open("```python", Dialect::Pandoc).unwrap();
1478 assert_eq!(fence.fence_char, '`');
1479 assert_eq!(fence.fence_count, 3);
1480 assert_eq!(fence.info_string, "python");
1481 }
1482
1483 #[test]
1484 fn multiword_bare_info_is_not_a_fence_in_pandoc() {
1485 assert!(try_parse_fence_open("```haskell foo", Dialect::Pandoc).is_none());
1487 assert!(try_parse_fence_open("~~~haskell foo", Dialect::Pandoc).is_none());
1488 assert!(try_parse_fence_open("```@example foo bar", Dialect::Pandoc).is_none());
1489 assert!(try_parse_fence_open("```haskell ", Dialect::Pandoc).is_some());
1491 assert!(try_parse_fence_open("``` haskell", Dialect::Pandoc).is_some());
1492 assert!(try_parse_fence_open("```{.haskell .foo}", Dialect::Pandoc).is_some());
1494 assert!(try_parse_fence_open("```bash {filename=\"Terminal\"}", Dialect::Pandoc).is_some());
1497 assert!(try_parse_fence_open("```haskell {.numberLines}", Dialect::Pandoc).is_some());
1498 assert!(try_parse_fence_open("```haskell {.numberLines} foo", Dialect::Pandoc).is_none());
1499 assert!(try_parse_fence_open("```haskell foo {.x}", Dialect::Pandoc).is_none());
1500 assert!(try_parse_fence_open("```{.x} foo", Dialect::Pandoc).is_none());
1501 }
1502
1503 #[test]
1504 fn multiword_bare_info_is_a_fence_in_commonmark() {
1505 let fence = try_parse_fence_open("```haskell foo", Dialect::CommonMark).unwrap();
1508 assert_eq!(fence.info_string, "haskell foo");
1509 assert!(try_parse_fence_open("~~~haskell foo", Dialect::CommonMark).is_some());
1510 }
1511
1512 #[test]
1513 fn hashpipe_preamble_includes_blank_line_in_block_scalar() {
1514 let lines = [
1517 "#| fig-alt: |\n",
1518 "#| First paragraph.\n",
1519 "#|\n",
1520 "#| Second paragraph.\n",
1521 "plot(1)\n",
1522 ];
1523 assert_eq!(
1524 compute_hashpipe_preamble_line_count(&lines, "#|", 0, 0, false, 0),
1525 4
1526 );
1527 }
1528
1529 #[test]
1530 fn hashpipe_blank_line_predicate() {
1531 assert!(is_hashpipe_blank_line("#|", "#|"));
1532 assert!(is_hashpipe_blank_line("#| ", "#|"));
1533 assert!(!is_hashpipe_blank_line("#| key: v", "#|"));
1534 assert!(!is_hashpipe_blank_line("plot(1)", "#|"));
1535 }
1536
1537 #[test]
1538 fn test_tilde_fence() {
1539 let fence = try_parse_fence_open("~~~", Dialect::Pandoc).unwrap();
1540 assert_eq!(fence.fence_char, '~');
1541 assert_eq!(fence.fence_count, 3);
1542 assert_eq!(fence.info_string, "");
1543 }
1544
1545 #[test]
1546 fn test_long_fence() {
1547 let fence = try_parse_fence_open("`````", Dialect::Pandoc).unwrap();
1548 assert_eq!(fence.fence_count, 5);
1549 }
1550
1551 #[test]
1552 fn test_two_backticks_invalid() {
1553 assert!(try_parse_fence_open("``", Dialect::Pandoc).is_none());
1554 }
1555
1556 #[test]
1557 fn test_backtick_fence_with_backtick_in_info_is_invalid() {
1558 assert!(try_parse_fence_open("`````hi````there`````", Dialect::Pandoc).is_none());
1559 }
1560
1561 #[test]
1562 fn test_closing_fence() {
1563 let fence = FenceInfo {
1564 fence_char: '`',
1565 fence_count: 3,
1566 info_string: String::new(),
1567 };
1568 assert!(is_closing_fence("```", &fence));
1569 assert!(is_closing_fence("````", &fence));
1570 assert!(!is_closing_fence("``", &fence));
1571 assert!(!is_closing_fence("~~~", &fence));
1572 }
1573
1574 #[test]
1575 fn test_fenced_code_preserves_leading_gt() {
1576 let input = "```\n> foo\n```\n";
1577 let tree = crate::parse(input, None);
1578 assert_eq!(tree.text().to_string(), input);
1579 }
1580
1581 #[test]
1582 fn test_fenced_code_in_blockquote_preserves_opening_fence_marker() {
1583 let input = "> ```\n> code\n> ```\n";
1584 let tree = crate::parse(input, None);
1585 assert_eq!(tree.text().to_string(), input);
1586 }
1587
1588 #[test]
1589 fn test_fenced_code_in_definition_list_with_unicode_content_does_not_panic() {
1590 let input = "Term\n: ```\n├── pyproject.toml\n```\n";
1591 let tree = crate::parse(input, None);
1592 assert_eq!(tree.text().to_string(), input);
1593 }
1594
1595 #[test]
1596 fn test_info_string_plain() {
1597 let info = InfoString::parse("");
1598 assert_eq!(info.block_type, CodeBlockType::Plain);
1599 assert!(info.attributes.is_empty());
1600 }
1601
1602 #[test]
1603 fn test_info_string_shortcut() {
1604 let info = InfoString::parse("python");
1605 assert_eq!(
1606 info.block_type,
1607 CodeBlockType::DisplayShortcut {
1608 language: "python".to_string()
1609 }
1610 );
1611 assert!(info.attributes.is_empty());
1612 }
1613
1614 #[test]
1615 fn test_info_string_shortcut_with_trailing() {
1616 let info = InfoString::parse("python extra stuff");
1617 assert_eq!(
1618 info.block_type,
1619 CodeBlockType::DisplayShortcut {
1620 language: "python".to_string()
1621 }
1622 );
1623 }
1624
1625 #[test]
1626 fn test_info_string_display_explicit() {
1627 let info = InfoString::parse("{.python}");
1628 assert_eq!(
1629 info.block_type,
1630 CodeBlockType::DisplayExplicit {
1631 classes: vec!["python".to_string()]
1632 }
1633 );
1634 }
1635
1636 #[test]
1637 fn test_info_string_display_explicit_multiple() {
1638 let info = InfoString::parse("{.python .numberLines}");
1639 assert_eq!(
1640 info.block_type,
1641 CodeBlockType::DisplayExplicit {
1642 classes: vec!["python".to_string(), "numberLines".to_string()]
1643 }
1644 );
1645 }
1646
1647 #[test]
1648 fn test_info_string_executable() {
1649 let info = InfoString::parse("{python}");
1650 assert_eq!(
1651 info.block_type,
1652 CodeBlockType::Executable {
1653 language: "python".to_string()
1654 }
1655 );
1656 }
1657
1658 #[test]
1659 fn test_info_string_executable_with_options() {
1660 let info = InfoString::parse("{python echo=false warning=true}");
1661 assert_eq!(
1662 info.block_type,
1663 CodeBlockType::Executable {
1664 language: "python".to_string()
1665 }
1666 );
1667 assert_eq!(info.attributes.len(), 2);
1668 assert_eq!(
1669 info.attributes[0],
1670 ("echo".to_string(), Some("false".to_string()))
1671 );
1672 assert_eq!(
1673 info.attributes[1],
1674 ("warning".to_string(), Some("true".to_string()))
1675 );
1676 }
1677
1678 #[test]
1679 fn test_info_string_executable_with_commas() {
1680 let info = InfoString::parse("{r, echo=FALSE, warning=TRUE}");
1681 assert_eq!(
1682 info.block_type,
1683 CodeBlockType::Executable {
1684 language: "r".to_string()
1685 }
1686 );
1687 assert_eq!(info.attributes.len(), 2);
1688 assert_eq!(
1689 info.attributes[0],
1690 ("echo".to_string(), Some("FALSE".to_string()))
1691 );
1692 assert_eq!(
1693 info.attributes[1],
1694 ("warning".to_string(), Some("TRUE".to_string()))
1695 );
1696 }
1697
1698 #[test]
1699 fn test_info_string_executable_mixed_commas_spaces() {
1700 let info = InfoString::parse("{r, echo=FALSE, label=\"my chunk\"}");
1702 assert_eq!(
1703 info.block_type,
1704 CodeBlockType::Executable {
1705 language: "r".to_string()
1706 }
1707 );
1708 assert_eq!(info.attributes.len(), 2);
1709 assert_eq!(
1710 info.attributes[0],
1711 ("echo".to_string(), Some("FALSE".to_string()))
1712 );
1713 assert_eq!(
1714 info.attributes[1],
1715 ("label".to_string(), Some("my chunk".to_string()))
1716 );
1717 }
1718
1719 #[test]
1720 fn test_info_string_mixed_shortcut_and_attrs() {
1721 let info = InfoString::parse("python {.numberLines}");
1722 assert_eq!(
1723 info.block_type,
1724 CodeBlockType::DisplayShortcut {
1725 language: "python".to_string()
1726 }
1727 );
1728 assert_eq!(info.attributes.len(), 1);
1729 assert_eq!(info.attributes[0], (".numberLines".to_string(), None));
1730 }
1731
1732 #[test]
1733 fn test_info_string_mixed_with_key_value() {
1734 let info = InfoString::parse("python {.numberLines startFrom=\"100\"}");
1735 assert_eq!(
1736 info.block_type,
1737 CodeBlockType::DisplayShortcut {
1738 language: "python".to_string()
1739 }
1740 );
1741 assert_eq!(info.attributes.len(), 2);
1742 assert_eq!(info.attributes[0], (".numberLines".to_string(), None));
1743 assert_eq!(
1744 info.attributes[1],
1745 ("startFrom".to_string(), Some("100".to_string()))
1746 );
1747 }
1748
1749 #[test]
1750 fn test_info_string_explicit_with_id_and_classes() {
1751 let info = InfoString::parse("{#mycode .haskell .numberLines startFrom=\"100\"}");
1752 assert_eq!(
1753 info.block_type,
1754 CodeBlockType::DisplayExplicit {
1755 classes: vec!["haskell".to_string(), "numberLines".to_string()]
1756 }
1757 );
1758 let has_id = info.attributes.iter().any(|(k, _)| k == "#mycode");
1760 let has_start = info
1761 .attributes
1762 .iter()
1763 .any(|(k, v)| k == "startFrom" && v == &Some("100".to_string()));
1764 assert!(has_id);
1765 assert!(has_start);
1766 }
1767
1768 #[test]
1769 fn test_info_string_raw_html() {
1770 let info = InfoString::parse("{=html}");
1771 assert_eq!(
1772 info.block_type,
1773 CodeBlockType::Raw {
1774 format: "html".to_string()
1775 }
1776 );
1777 assert!(info.attributes.is_empty());
1778 }
1779
1780 #[test]
1781 fn test_info_string_raw_latex() {
1782 let info = InfoString::parse("{=latex}");
1783 assert_eq!(
1784 info.block_type,
1785 CodeBlockType::Raw {
1786 format: "latex".to_string()
1787 }
1788 );
1789 }
1790
1791 #[test]
1792 fn test_info_string_raw_openxml() {
1793 let info = InfoString::parse("{=openxml}");
1794 assert_eq!(
1795 info.block_type,
1796 CodeBlockType::Raw {
1797 format: "openxml".to_string()
1798 }
1799 );
1800 }
1801
1802 #[test]
1803 fn test_info_string_raw_ms() {
1804 let info = InfoString::parse("{=ms}");
1805 assert_eq!(
1806 info.block_type,
1807 CodeBlockType::Raw {
1808 format: "ms".to_string()
1809 }
1810 );
1811 }
1812
1813 #[test]
1814 fn test_info_string_raw_html5() {
1815 let info = InfoString::parse("{=html5}");
1816 assert_eq!(
1817 info.block_type,
1818 CodeBlockType::Raw {
1819 format: "html5".to_string()
1820 }
1821 );
1822 }
1823
1824 #[test]
1825 fn test_info_string_raw_not_combined_with_attrs() {
1826 let info = InfoString::parse("{=html .class}");
1828 assert_ne!(
1830 info.block_type,
1831 CodeBlockType::Raw {
1832 format: "html".to_string()
1833 }
1834 );
1835 }
1836
1837 #[test]
1838 fn test_parse_pandoc_attributes_spaces() {
1839 let attrs = InfoString::parse_pandoc_attributes(".python .numberLines startFrom=\"10\"");
1841 assert_eq!(attrs.len(), 3);
1842 assert_eq!(attrs[0], (".python".to_string(), None));
1843 assert_eq!(attrs[1], (".numberLines".to_string(), None));
1844 assert_eq!(attrs[2], ("startFrom".to_string(), Some("10".to_string())));
1845 }
1846
1847 #[test]
1848 fn test_parse_pandoc_attributes_no_commas() {
1849 let attrs = InfoString::parse_pandoc_attributes("#id .class key=value");
1851 assert_eq!(attrs.len(), 3);
1852 assert_eq!(attrs[0], ("#id".to_string(), None));
1853 assert_eq!(attrs[1], (".class".to_string(), None));
1854 assert_eq!(attrs[2], ("key".to_string(), Some("value".to_string())));
1855 }
1856
1857 #[test]
1858 fn test_parse_chunk_options_commas() {
1859 let attrs = InfoString::parse_chunk_options("r, echo=FALSE, warning=TRUE");
1861 assert_eq!(attrs.len(), 3);
1862 assert_eq!(attrs[0], ("r".to_string(), None));
1863 assert_eq!(attrs[1], ("echo".to_string(), Some("FALSE".to_string())));
1864 assert_eq!(attrs[2], ("warning".to_string(), Some("TRUE".to_string())));
1865 }
1866
1867 #[test]
1868 fn test_parse_chunk_options_no_spaces() {
1869 let attrs = InfoString::parse_chunk_options("r,echo=FALSE,warning=TRUE");
1871 assert_eq!(attrs.len(), 3);
1872 assert_eq!(attrs[0], ("r".to_string(), None));
1873 assert_eq!(attrs[1], ("echo".to_string(), Some("FALSE".to_string())));
1874 assert_eq!(attrs[2], ("warning".to_string(), Some("TRUE".to_string())));
1875 }
1876
1877 #[test]
1878 fn test_parse_chunk_options_mixed() {
1879 let attrs = InfoString::parse_chunk_options("python echo=False, warning=True");
1881 assert_eq!(attrs.len(), 3);
1882 assert_eq!(attrs[0], ("python".to_string(), None));
1883 assert_eq!(attrs[1], ("echo".to_string(), Some("False".to_string())));
1884 assert_eq!(attrs[2], ("warning".to_string(), Some("True".to_string())));
1885 }
1886
1887 #[test]
1888 fn test_parse_chunk_options_nested_function_call() {
1889 let attrs = InfoString::parse_chunk_options(r#"r pep-cg, dependson=c("foo", "bar")"#);
1891 assert_eq!(attrs.len(), 3);
1892 assert_eq!(attrs[0], ("r".to_string(), None));
1893 assert_eq!(attrs[1], ("pep-cg".to_string(), None));
1894 assert_eq!(
1895 attrs[2],
1896 (
1897 "dependson".to_string(),
1898 Some(r#"c("foo", "bar")"#.to_string())
1899 )
1900 );
1901 }
1902
1903 #[test]
1904 fn test_parse_chunk_options_nested_with_spaces() {
1905 let attrs = InfoString::parse_chunk_options(r#"r, cache.path=file.path("cache", "dir")"#);
1907 assert_eq!(attrs.len(), 2);
1908 assert_eq!(attrs[0], ("r".to_string(), None));
1909 assert_eq!(
1910 attrs[1],
1911 (
1912 "cache.path".to_string(),
1913 Some(r#"file.path("cache", "dir")"#.to_string())
1914 )
1915 );
1916 }
1917
1918 #[test]
1919 fn test_parse_chunk_options_deeply_nested() {
1920 let attrs = InfoString::parse_chunk_options(r#"r, x=list(a=c(1,2), b=c(3,4))"#);
1922 assert_eq!(attrs.len(), 2);
1923 assert_eq!(attrs[0], ("r".to_string(), None));
1924 assert_eq!(
1925 attrs[1],
1926 (
1927 "x".to_string(),
1928 Some(r#"list(a=c(1,2), b=c(3,4))"#.to_string())
1929 )
1930 );
1931 }
1932
1933 #[test]
1934 fn test_parse_chunk_options_brackets_and_braces() {
1935 let attrs = InfoString::parse_chunk_options(r#"r, data=df[rows, cols], config={a:1, b:2}"#);
1937 assert_eq!(attrs.len(), 3);
1938 assert_eq!(attrs[0], ("r".to_string(), None));
1939 assert_eq!(
1940 attrs[1],
1941 ("data".to_string(), Some("df[rows, cols]".to_string()))
1942 );
1943 assert_eq!(
1944 attrs[2],
1945 ("config".to_string(), Some("{a:1, b:2}".to_string()))
1946 );
1947 }
1948
1949 #[test]
1950 fn test_parse_chunk_options_quotes_with_parens() {
1951 let attrs = InfoString::parse_chunk_options(r#"r, label="test (with parens)", echo=TRUE"#);
1954 assert_eq!(attrs.len(), 3);
1955 assert_eq!(attrs[0], ("r".to_string(), None));
1956 assert_eq!(
1957 attrs[1],
1958 ("label".to_string(), Some("test (with parens)".to_string()))
1959 );
1960 assert_eq!(attrs[2], ("echo".to_string(), Some("TRUE".to_string())));
1961 }
1962
1963 #[test]
1964 fn test_parse_chunk_options_escaped_quotes() {
1965 let attrs = InfoString::parse_chunk_options(r#"r, label="has \"quoted\" text""#);
1968 assert_eq!(attrs.len(), 2);
1969 assert_eq!(attrs[0], ("r".to_string(), None));
1970 assert_eq!(
1971 attrs[1],
1972 (
1973 "label".to_string(),
1974 Some(r#"has "quoted" text"#.to_string())
1975 )
1976 );
1977 }
1978
1979 #[test]
1980 fn test_display_vs_executable_parsing() {
1981 let info1 = InfoString::parse("{.python .numberLines startFrom=\"10\"}");
1983 assert!(matches!(
1984 info1.block_type,
1985 CodeBlockType::DisplayExplicit { .. }
1986 ));
1987
1988 let info2 = InfoString::parse("{r, echo=FALSE, warning=TRUE}");
1990 assert!(matches!(info2.block_type, CodeBlockType::Executable { .. }));
1991 assert_eq!(info2.attributes.len(), 2);
1992 }
1993
1994 #[test]
1995 fn test_info_string_executable_implicit_label() {
1996 let info = InfoString::parse("{r mylabel}");
1998 assert!(matches!(
1999 info.block_type,
2000 CodeBlockType::Executable { ref language } if language == "r"
2001 ));
2002 assert_eq!(info.attributes.len(), 1);
2003 assert_eq!(
2004 info.attributes[0],
2005 ("label".to_string(), Some("mylabel".to_string()))
2006 );
2007 }
2008
2009 #[test]
2010 fn test_info_string_executable_implicit_label_with_options() {
2011 let info = InfoString::parse("{r mylabel, echo=FALSE}");
2013 assert!(matches!(
2014 info.block_type,
2015 CodeBlockType::Executable { ref language } if language == "r"
2016 ));
2017 assert_eq!(info.attributes.len(), 2);
2018 assert_eq!(
2019 info.attributes[0],
2020 ("label".to_string(), Some("mylabel".to_string()))
2021 );
2022 assert_eq!(
2023 info.attributes[1],
2024 ("echo".to_string(), Some("FALSE".to_string()))
2025 );
2026 }
2027
2028 #[test]
2029 fn test_compute_hashpipe_preamble_line_count_for_block_scalar() {
2030 let content_lines = vec![
2031 "#| fig-cap: |\n",
2032 "#| A caption\n",
2033 "#| spanning lines\n",
2034 "a <- 1\n",
2035 ];
2036 let count = compute_hashpipe_preamble_line_count(&content_lines, "#|", 0, 0, false, 0);
2037 assert_eq!(count, 3);
2038 }
2039
2040 #[test]
2041 fn test_compute_hashpipe_preamble_line_count_stops_at_non_option() {
2042 let content_lines = vec!["#| label: fig-plot\n", "plot(1:10)\n", "#| echo: false\n"];
2043 let count = compute_hashpipe_preamble_line_count(&content_lines, "#|", 0, 0, false, 0);
2044 assert_eq!(count, 1);
2045 }
2046
2047 #[test]
2048 fn test_compute_hashpipe_preamble_line_count_stops_at_standalone_prefix() {
2049 let content_lines = vec!["#| label: fig-plot\n", "#|\n", "plot(1:10)\n"];
2050 let count = compute_hashpipe_preamble_line_count(&content_lines, "#|", 0, 0, false, 0);
2051 assert_eq!(count, 1);
2052 }
2053}