Skip to main content

panache_parser/parser/blocks/
code_blocks.rs

1//! Fenced code block parsing utilities.
2
3use 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
18// Container-prefix primitives live in `container_prefix.rs` (the lower
19// layer that hosts `StrippedLines`); re-export so existing call sites in
20// this module, `tables.rs`, `line_blocks.rs`, and `block_dispatcher.rs`
21// keep their `code_blocks::…` import paths working.
22pub(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/// Represents the type of code block based on its info string syntax.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum CodeBlockType {
33    /// Display-only block with shortcut syntax: ```python
34    DisplayShortcut { language: String },
35    /// Display-only block with explicit Pandoc syntax: ```{.python}
36    DisplayExplicit { classes: Vec<String> },
37    /// Executable chunk (Quarto/RMarkdown): ```{python}
38    Executable { language: String },
39    /// Raw block for specific output format: ```{=html}
40    Raw { format: String },
41    /// No language specified: ```
42    Plain,
43}
44
45/// Parsed attributes from a code block info string.
46#[derive(Debug, Clone, PartialEq)]
47pub struct InfoString {
48    pub raw: String,
49    pub block_type: CodeBlockType,
50    pub attributes: Vec<(String, Option<String>)>, // key-value pairs
51}
52
53impl InfoString {
54    /// Parse an info string into structured attributes.
55    ///
56    /// Brace-delimited info strings (`{...}`) carry Pandoc attribute semantics
57    /// (executable chunks, raw blocks, attribute lists). In the CommonMark
58    /// dialect they have no special meaning — the info string is opaque and the
59    /// first word is just the language class — so callers in that dialect should
60    /// use [`InfoString::parse_with_dialect`]. Plain [`parse`](Self::parse)
61    /// retains the Pandoc interpretation for backward compatibility.
62    pub fn parse(raw: &str) -> Self {
63        Self::parse_with_dialect(raw, crate::options::Dialect::Pandoc)
64    }
65
66    /// Parse an info string, honoring dialect-specific brace semantics.
67    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        // In the CommonMark dialect braces are not attributes: the entire info
79        // string is opaque and the first word is the language class.
80        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        // Check if it starts with '{' - explicit attribute block
92        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        // Check for mixed form: python {.numberLines}
99        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        // Otherwise, it's a shortcut form (just the language name)
119        // Only take the first word as language
120        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        // Check for raw attribute FIRST: {=format}
132        // The content should start with '=' and have only alphanumeric chars after
133        let trimmed_content = content.trim();
134        if let Some(format_name) = trimmed_content.strip_prefix('=') {
135            // Validate format name: alphanumeric only, no spaces
136            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        // First, do a preliminary parse to determine block type
151        // Use chunk options parser (comma-aware) for initial detection
152        let prelim_attrs = Self::parse_chunk_options(content);
153
154        // First non-ID, non-attribute token determines if it's executable or display
155        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            // Display block: {.python} or {.haskell .numberLines}
167            // Re-parse with Pandoc-style parser (space-delimited)
168            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            // Executable chunk: {python} or {r}
188            // Use chunk options parser (comma-delimited)
189            let attrs = Self::parse_chunk_options(content);
190            let lang_index = attrs.iter().position(|(k, _)| k == first_token).unwrap();
191
192            // Check if there's a second bareword (implicit label in R/Quarto chunks)
193            // Pattern: {r mylabel} is equivalent to {r, label=mylabel}.
194            // Skip tokens that are actually class (`.foo`) or id (`#foo`)
195            // attributes — those are not labels.
196            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                    // Remove language token
214                    if *i == lang_index {
215                        return false;
216                    }
217                    // Remove implicit label token (will be added back explicitly)
218                    if has_implicit_label && *i == lang_index + 1 {
219                        return false;
220                    }
221                    true
222                })
223                .map(|(_, attr)| attr)
224                .collect();
225
226            // Add explicit label if we found an implicit one
227            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            // Just attributes, no language - use Pandoc parser
240            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    /// Parse Pandoc-style attributes for display blocks: {.class #id key="value"}
250    /// Spaces are the primary delimiter. Pandoc spec prefers explicit quoting.
251    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            // Skip whitespace
257            while matches!(chars.peek(), Some(&' ') | Some(&'\t')) {
258                chars.next();
259            }
260
261            if chars.peek().is_none() {
262                break;
263            }
264
265            // Read key
266            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            // Skip whitespace
280            while matches!(chars.peek(), Some(&' ') | Some(&'\t')) {
281                chars.next();
282            }
283
284            // Check for value
285            if chars.peek() == Some(&'=') {
286                chars.next(); // consume '='
287
288                // Skip whitespace after '='
289                while matches!(chars.peek(), Some(&' ') | Some(&'\t')) {
290                    chars.next();
291                }
292
293                // Read value (might be quoted)
294                let value = if chars.peek() == Some(&'"') {
295                    chars.next(); // consume opening quote
296                    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                    // Unquoted value - read until space
314                    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    /// Parse Quarto/RMarkdown chunk options: {language, option=value, option2=value2}
335    /// Commas are the primary delimiter (R CSV style). Supports unquoted barewords.
336    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            // Skip whitespace and commas
342            while matches!(chars.peek(), Some(&' ') | Some(&'\t') | Some(&',')) {
343                chars.next();
344            }
345
346            if chars.peek().is_none() {
347                break;
348            }
349
350            // Read key
351            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            // Skip whitespace and commas
365            while matches!(chars.peek(), Some(&' ') | Some(&'\t') | Some(&',')) {
366                chars.next();
367            }
368
369            // Check for value
370            if chars.peek() == Some(&'=') {
371                chars.next(); // consume '='
372
373                // Skip whitespace and commas after '='
374                while matches!(chars.peek(), Some(&' ') | Some(&'\t') | Some(&',')) {
375                    chars.next();
376                }
377
378                // Read value (might be quoted)
379                let value = if chars.peek() == Some(&'"') {
380                    chars.next(); // consume opening quote
381                    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                    // Unquoted value - read until comma, space, or tab at depth 0
399                    // Track nesting depth for (), [], {} and quote state
400                    let mut val = String::new();
401                    let mut depth = 0; // Track parentheses/brackets/braces depth
402                    let mut in_quote: Option<char> = None; // Track if inside ' or "
403                    let mut escaped = false; // Track if previous char was backslash
404
405                    while let Some(&ch) = chars.peek() {
406                        // Handle escape sequences
407                        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                        // Handle quotes
422                        if let Some(quote_char) = in_quote {
423                            val.push(ch);
424                            chars.next();
425                            if ch == quote_char {
426                                in_quote = None; // Close quote
427                            }
428                            continue;
429                        }
430
431                        // Not in a quote - check for quote start
432                        if ch == '"' || ch == '\'' {
433                            in_quote = Some(ch);
434                            val.push(ch);
435                            chars.next();
436                            continue;
437                        }
438
439                        // Track nesting depth (only when not in quotes)
440                        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                        // Check for delimiters - only break at depth 0
455                        if depth == 0 && (ch == ' ' || ch == '\t' || ch == ',') {
456                            break;
457                        }
458
459                        // Regular character
460                        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    /// Legacy function - kept for backward compatibility in mixed-form parsing
476    /// For new code, use parse_pandoc_attributes or parse_chunk_options
477    fn parse_attributes(content: &str) -> Vec<(String, Option<String>)> {
478        // Default to chunk options parsing (comma-aware)
479        Self::parse_chunk_options(content)
480    }
481}
482
483/// Information about a detected code fence opening.
484#[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
495/// Try to detect a fenced code block opening from content.
496/// Returns fence info if this is a valid opening fence.
497pub(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    // Check for fence opening (``` or ~~~)
504    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    // Strip trailing newline (LF or CRLF) and at most one leading space
520    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    // Backtick-fenced blocks cannot have backticks in the info string.
528    if fence_char == '`' && info_string.contains('`') {
529        return None;
530    }
531
532    // In Pandoc-markdown, a fence info string is valid only as one of:
533    //   `lang`            a single bare language word,
534    //   `{attrs}`         a brace-delimited attribute block, or
535    //   `lang {attrs}`    a single language word plus an attribute block,
536    // with nothing trailing after the attribute block. Anything else — a
537    // multi-word bare info string (```` ```haskell foo ````), a word before
538    // the brace (```` ```a b {.x} ````), or content after the closing brace
539    // (```` ```{.x} foo ````) — is not a code fence: pandoc reads the backtick
540    // run as an inline code span (and a tilde run as plain inline text).
541    // CommonMark and GFM instead take the first word as the language class and
542    // accept the rest, so this restriction is gated to the Pandoc dialect.
543    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    // Strip the active container prefix on line 0 in container-stack
577    // order. Bq markers are always upstream-emitted by the blockquote
578    // dispatch and silently consumed here. The list_content_col indent
579    // is upstream-emitted only on a marker-line dispatch
580    // (`list_marker_consumed_on_line_0=true`); on continuation-line
581    // dispatch it must be emitted here as WHITESPACE. Adjacent
582    // WHITESPACE emissions are coalesced into one token for
583    // byte-range-equivalent CST stability.
584    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        // On a marker-line dispatch (`suppress_list=true`), the list
623        // marker bytes have already been emitted upstream and may not
624        // be whitespace (e.g. `- > ```` has a leading `-`). Use
625        // `advance_columns` which counts columns through any char.
626        // On continuation lines, the leading bytes ARE whitespace
627        // (the list-content-indent) so use the whitespace-only
628        // `strip_list_indent` to stop at non-whitespace.
629        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    // content_indent (footnote/definition) — always emit as WHITESPACE.
663    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        // A blank `#|` line continues the preamble only when followed by another
740        // prefixed line — i.e. it is a blank interior line of a block scalar
741        // (issue_201). A trailing blank `#|` before body code ends the preamble.
742        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
755/// Compute the composite per-line prefix marker for a hashpipe preamble:
756/// the uniform container prefix (blockquote markers / list indent /
757/// content indent) plus any leading whitespace up to and including the
758/// hashpipe comment marker (`prefix`), taken from the first preamble line.
759///
760/// Within a preamble the container prefix is uniform per line, so matching
761/// this composite marker via `strip_prefix` lets the prefix-aware YAML
762/// parser splice a nested (list-/blockquote-indented) cell exactly as a
763/// top-level one, peeling the whole prefix into one `YAML_LINE_PREFIX`
764/// leaf. A non-uniform preamble fails validation and falls back to opaque
765/// tokens.
766fn 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
819/// A bare/blank hashpipe line — the marker followed only by optional whitespace
820/// (e.g. `#|`). Such a line is a valid blank *inside* a block scalar (the
821/// `issue_201` literal-with-blank-line case) or a trailing blank in the preamble,
822/// so it continues the preamble rather than ending it. Without this, the
823/// preamble scan stops at the blank and the parser truncates the block scalar,
824/// embedding only the lines before it.
825fn 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
833/// Check if a line is a valid closing fence for the given fence info.
834pub(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    // Rest of line must be empty
851    trimmed[closing_count..].trim().is_empty()
852}
853
854/// Emit chunk options as structured CST nodes while preserving all bytes.
855/// This parses {r, echo=TRUE, fig.cap="text"} into CHUNK_OPTIONS with individual CHUNK_OPTION nodes.
856fn 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        // Emit leading whitespace/commas as TEXT
869        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        // Check if this is a closing brace
886        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        // Read key
896        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            // No key found, emit rest as TEXT
907            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        // Check for whitespace before '='
916        let ws_before_eq_start = pos;
917        while pos < bytes.len() && matches!(bytes[pos] as char, ' ' | '\t') {
918            pos += 1;
919        }
920
921        // Check if there's a value (=)
922        if pos < bytes.len() && bytes[pos] as char == '=' {
923            // Has value - emit as CHUNK_OPTION
924            builder.start_node(SyntaxKind::CHUNK_OPTION.into());
925            builder.token(SyntaxKind::CHUNK_OPTION_KEY.into(), key);
926
927            // Emit whitespace before '=' if any
928            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; // consume '='
934
935            // Emit whitespace after '='
936            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            // Parse value (might be quoted)
945            if pos < bytes.len() {
946                let quote_char = bytes[pos] as char;
947                if quote_char == '"' || quote_char == '\'' {
948                    // Quoted value
949                    builder.token(
950                        SyntaxKind::CHUNK_OPTION_QUOTE.into(),
951                        &content[pos..pos + 1],
952                    );
953                    pos += 1; // consume opening quote
954
955                    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                    // Emit closing quote
974                    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                    // Unquoted value - read until comma, space, closing brace, or balanced delimiter
983                    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; // End of chunk options
1002                                }
1003                            }
1004                            ',' if depth == 0 => {
1005                                break; // Next option
1006                            }
1007                            ' ' | '\t' if depth == 0 => {
1008                                break; // Space separator
1009                            }
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(); // CHUNK_OPTION
1025        } else {
1026            // No '=' - classify by prefix: '.foo' is a class, '#foo' is an id,
1027            // anything else is a chunk label (e.g. `{r mylabel}`).
1028            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(); // CHUNK_OPTIONS
1043}
1044
1045/// Helper to parse info string and emit CodeInfo node with parsed components.
1046/// This breaks down the info string into its logical parts while preserving all bytes.
1047fn 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            // Simple case: python or python {.class}
1059            builder.token(SyntaxKind::CODE_LANGUAGE.into(), language);
1060
1061            // Structure a trailing `{...}` attribute block (the language is
1062            // already emitted, so no carve). Falls back to one opaque TEXT token
1063            // for unrecognized remainders, preserving the prior shape.
1064            let after_lang = &info_string[language.len()..];
1065            if !after_lang.is_empty()
1066                && !emit_code_info_attrs(builder, after_lang, /* carve */ false)
1067            {
1068                builder.token(SyntaxKind::TEXT.into(), after_lang);
1069            }
1070        }
1071        CodeBlockType::Executable { language } => {
1072            // Quarto: {r} or {r my-label, echo=FALSE}
1073            builder.token(SyntaxKind::TEXT.into(), "{");
1074            builder.token(SyntaxKind::CODE_LANGUAGE.into(), language);
1075
1076            // Parse and emit chunk options
1077            let start_offset = 1 + language.len(); // Skip "{r"
1078            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            // Pandoc: `{.python}` or `{#id .haskell .numberLines startFrom="10"}`.
1085            // Structure the `{...}` body into ATTR_* children, carving the first
1086            // `.class` out as the CODE_LANGUAGE token (language-first semantics).
1087            // Falls back to one opaque TEXT token when the body is unrecognized,
1088            // preserving the prior shape.
1089            if !emit_code_info_attrs(builder, info_string, /* carve */ true) {
1090                builder.token(SyntaxKind::TEXT.into(), info_string);
1091            }
1092        }
1093        CodeBlockType::Raw { .. } | CodeBlockType::Plain => {
1094            // No language, just emit as TEXT
1095            builder.token(SyntaxKind::TEXT.into(), info_string);
1096        }
1097    }
1098
1099    builder.finish_node(); // CodeInfo
1100}
1101
1102/// Parse a fenced code block, consuming lines from the parser.
1103/// Parse a fenced code block, consuming lines from the parser.
1104/// Returns the new position after the code block.
1105///
1106/// All container geometry (blockquote depth, list-item indent,
1107/// footnote/definition base indent, and the bq-vs-list strip order) is
1108/// derived from `window.prefix()`; detection scans and the open-fence
1109/// emitter read those derived scalars, and content/closing-fence lines
1110/// re-emit their container prefix via [`StrippedLines::emit_prefix_at`].
1111pub(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    // Start code block
1129    builder.start_node(SyntaxKind::CODE_BLOCK.into());
1130
1131    // Opening fence
1132    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    // Emit any space between fence and info string (for losslessness)
1150    let after_fence = &first_trimmed[fence.fence_count..];
1151    if let Some(_space_stripped) = after_fence.strip_prefix(' ') {
1152        // There was a space - emit it as WHITESPACE
1153        builder.token(SyntaxKind::WHITESPACE.into(), " ");
1154        // Parse and emit the info string as a structured node
1155        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        // No space - parse and emit info_string as a structured node
1160        emit_code_info_node(builder, &fence.info_string, Dialect::for_flavor(flavor));
1161    }
1162
1163    // Extract and emit the actual newline from the opening fence line
1164    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(); // CodeFenceOpen
1169
1170    let mut current_pos = start_pos + 1;
1171    let mut content_lines: Vec<&str> = Vec::new(); // Store original lines for lossless parsing
1172    let mut found_closing = false;
1173
1174    while current_pos < lines.len() {
1175        let line = lines[current_pos];
1176
1177        // Count blockquote markers to detect leaving the surrounding
1178        // blockquote. For bq_outer=true probe the raw line (bq markers
1179        // lead); for bq_outer=false strip the list indent first, then
1180        // probe the post-list slice. This forward-scan termination has no
1181        // `StrippedLines` equivalent, so it stays inline.
1182        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        // Detection only (emits nothing): the same 2-bucket container
1193        // strip the emission path applies via `emit_content_line_prefixes`
1194        // / `emit_prefix_at`, kept here rather than `strip_at` (a per-op
1195        // walk) to stay byte-identical in interleaved nesting.
1196        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    // Add content
1210    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                // Exact host bytes of the preamble region: the lines retain
1232                // their trailing LF/CRLF, so concatenation rebuilds the
1233                // source between the open fence and the body exactly.
1234                let content: String = content_lines[..prepared_hashpipe_lines].concat();
1235                // Composite per-line marker (container prefix + `#|`). Uniform
1236                // across the preamble, so a nested cell splices as a top-level
1237                // one (see `hashpipe_composite_marker`).
1238                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                    // Malformed hashpipe YAML: record the syntax error at its
1252                    // host position — the parser already computed the verdict,
1253                    // so it surfaces the diagnostic here instead of discarding
1254                    // it (the linter would otherwise re-parse to recover it).
1255                    // `content` is `content_lines[..n]` concatenated and those
1256                    // lines are subslices of the host input, so the preamble's
1257                    // host start is their pointer offset from line 0.
1258                    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                    // Fall back to opaque line tokens (container prefix + TEXT +
1269                    // NEWLINE), preserving the bytes without imposing a
1270                    // structure that didn't parse.
1271                    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                    // Valid: splice the prefix-aware YAML subtree. Token ranges
1284                    // are host ranges directly, the composite prefix peeled into
1285                    // `YAML_LINE_PREFIX` trivia. Mirrors the frontmatter
1286                    // `emit_yaml_block` validate→splice→fallback pattern.
1287                    let stream = parse_stream_with_prefix(&content, marker)
1288                        .green()
1289                        .into_owned();
1290                    copy_green_children(builder, &stream);
1291                }
1292                // Whether spliced or fallback, the preamble lines are consumed.
1293                line_idx = prepared_hashpipe_lines;
1294
1295                builder.finish_node(); // HASHPIPE_YAML_CONTENT
1296                builder.finish_node(); // HASHPIPE_YAML_PREAMBLE
1297            }
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(); // CodeContent
1313    }
1314
1315    // Closing fence (if found)
1316    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(); // CodeFenceClose
1345    }
1346
1347    builder.finish_node(); // CodeBlock
1348
1349    current_pos
1350}
1351
1352/// Parse a GFM math fence (``` math ... ```) as DISPLAY_MATH while preserving bytes.
1353///
1354/// Container geometry is derived from `window.prefix()`, mirroring
1355/// [`parse_fenced_code_block`].
1356pub(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        // Forward-scan termination on blockquote depth — stays inline (no
1400        // `StrippedLines` equivalent), mirroring `parse_fenced_code_block`.
1401        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        // Detection only (emits nothing): same 2-bucket strip as emission.
1412        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(); // DisplayMath
1466    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        // ```haskell foo => inline code span in pandoc-markdown, not a fence.
1486        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        // A single bare word (with surrounding space) is still a valid fence.
1490        assert!(try_parse_fence_open("```haskell ", Dialect::Pandoc).is_some());
1491        assert!(try_parse_fence_open("``` haskell", Dialect::Pandoc).is_some());
1492        // Braced attribute forms carry their own whitespace and stay valid.
1493        assert!(try_parse_fence_open("```{.haskell .foo}", Dialect::Pandoc).is_some());
1494        // Mixed `lang {attrs}` form (e.g. Quarto's `bash {filename="..."}`)
1495        // is valid; extra words or trailing content after the brace are not.
1496        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        // CommonMark/GFM take the first word as the language class and keep
1506        // the rest of the info string, so the fence is still recognized.
1507        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        // A blank `#|` line inside a literal block scalar must stay in the
1515        // preamble (issue_201) — otherwise the scalar is truncated.
1516        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        // R-style with commas and spaces
1701        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        // Non-class attributes
1759        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        // If there are other attributes with =format, it should not be treated as raw
1827        let info = InfoString::parse("{=html .class}");
1828        // This should NOT be parsed as raw because there's more than one attribute
1829        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        // Pandoc display blocks use spaces as delimiters
1840        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        // Commas in Pandoc attributes should be treated as part of the value
1850        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        // Quarto/RMarkdown chunks use commas as delimiters
1860        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        // Should handle comma-separated without spaces
1870        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        // Handle both commas and spaces
1880        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        // R function calls with nested commas should be treated as single value
1890        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        // Function call with spaces inside
1906        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        // Multiple levels of nesting
1921        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        // Test all bracket types
1936        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        // Parentheses inside quoted strings shouldn't affect depth tracking
1952        // Note: The parser strips outer quotes from quoted values
1953        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        // Escaped quotes inside string values
1966        // Note: The parser strips outer quotes and processes escapes
1967        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        // Display block should use Pandoc parser (spaces)
1982        let info1 = InfoString::parse("{.python .numberLines startFrom=\"10\"}");
1983        assert!(matches!(
1984            info1.block_type,
1985            CodeBlockType::DisplayExplicit { .. }
1986        ));
1987
1988        // Executable chunk should use chunk options parser (commas)
1989        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        // {r mylabel} should parse as label=mylabel
1997        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        // {r mylabel, echo=FALSE} should parse as label=mylabel, echo=FALSE
2012        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}