Skip to main content

surf_parse/
parse.rs

1use crate::attrs::parse_attrs;
2use crate::error::{Diagnostic, Severity};
3use crate::types::{Attrs, Block, FrontMatter, Span, SurfDoc};
4
5/// Result of parsing a SurfDoc.
6#[derive(Debug, Clone)]
7pub struct ParseResult {
8    /// The parsed document.
9    pub doc: SurfDoc,
10    /// Non-fatal diagnostics collected during parsing.
11    pub diagnostics: Vec<Diagnostic>,
12}
13
14/// Parse a SurfDoc string into a `ParseResult`.
15///
16/// This function never panics. Malformed input produces diagnostics and a
17/// best-effort `SurfDoc`.
18pub fn parse(input: &str) -> ParseResult {
19    let mut diagnostics = Vec::new();
20
21    // Normalise CRLF → LF.
22    let normalised = input.replace("\r\n", "\n");
23    let lines: Vec<&str> = normalised.split('\n').collect();
24
25    // ---------------------------------------------------------------
26    // Pass 1a: Front matter extraction.
27    // ---------------------------------------------------------------
28    let (front_matter, body_start_line) = extract_front_matter(&lines, &normalised, &mut diagnostics);
29
30    // ---------------------------------------------------------------
31    // Pass 1b: Line-by-line block directive scan.
32    // ---------------------------------------------------------------
33    let blocks = scan_blocks(&lines, body_start_line, &normalised, &mut diagnostics);
34
35    // ---------------------------------------------------------------
36    // Pass 2: Type resolution — convert Unknown blocks to typed variants.
37    // ---------------------------------------------------------------
38    let blocks = blocks
39        .into_iter()
40        .map(|block| match block {
41            Block::Unknown { .. } => crate::blocks::resolve_block(block),
42            other => other,
43        })
44        .collect();
45
46    ParseResult {
47        doc: SurfDoc {
48            front_matter,
49            blocks,
50            source: normalised,
51        },
52        diagnostics,
53    }
54}
55
56// ------------------------------------------------------------------
57// Front matter
58// ------------------------------------------------------------------
59
60/// Try to extract YAML front matter from the beginning of the document.
61///
62/// Returns `(Option<FrontMatter>, first_body_line_index)`.
63fn extract_front_matter(
64    lines: &[&str],
65    source: &str,
66    diagnostics: &mut Vec<Diagnostic>,
67) -> (Option<FrontMatter>, usize) {
68    if lines.is_empty() || lines[0].trim() != "---" {
69        return (None, 0);
70    }
71
72    // Find the closing `---`.
73    let mut end_idx = None;
74    for (i, line) in lines.iter().enumerate().skip(1) {
75        if line.trim() == "---" {
76            end_idx = Some(i);
77            break;
78        }
79    }
80
81    let end_idx = match end_idx {
82        Some(i) => i,
83        None => {
84            // No closing `---` — treat the whole thing as body text.
85            diagnostics.push(Diagnostic {
86                severity: Severity::Error,
87                message: "Front matter opened with `---` but never closed".into(),
88                span: Some(line_span(0, 0, source)),
89                code: Some("P002".into()),
90                fix: None,
91            });
92            return (None, 0);
93        }
94    };
95
96    let yaml_str: String = lines[1..end_idx].join("\n");
97    let fm_span = Span {
98        start_line: 1,
99        end_line: end_idx + 1,
100        start_offset: 0,
101        end_offset: byte_offset_end_of_line(end_idx, source),
102    };
103
104    match serde_yaml::from_str::<FrontMatter>(&yaml_str) {
105        Ok(fm) => (Some(fm), end_idx + 1),
106        Err(e) => {
107            // Distinguish genuinely broken YAML (P002, error) from valid YAML
108            // whose values fail the closed schema enums, e.g. `type:
109            // checkpoint` (P005, warning). Recovery is identical either way:
110            // no front matter, body starts after the closing `---` — the
111            // SurfDoc produced does not depend on which code fired.
112            let p005 = serde_yaml::from_str::<serde_yaml::Value>(&yaml_str)
113                .ok()
114                .as_ref()
115                .and_then(offending_front_matter_field);
116            let diagnostic = match p005 {
117                Some((field, value)) => Diagnostic {
118                    severity: Severity::Warning,
119                    message: p005_message(&field, &value),
120                    span: Some(fm_span),
121                    code: Some("P005".into()),
122                    fix: None,
123                },
124                None => Diagnostic {
125                    severity: Severity::Error,
126                    message: format!("Failed to parse front matter YAML: {e}"),
127                    span: Some(fm_span),
128                    code: Some("P002".into()),
129                    fix: None,
130                },
131            };
132            diagnostics.push(diagnostic);
133            (None, end_idx + 1)
134        }
135    }
136}
137
138/// Message for a P005 diagnostic (front matter value outside the closed
139/// schema enums). Shared with the lint layer, which reconstructs it verbatim
140/// to suppress values allowed via `LintConfig::extra_frontmatter_values`.
141pub(crate) fn p005_message(field: &str, value: &str) -> String {
142    format!("Front matter field '{field}' has unrecognized value '{value}'")
143}
144
145/// First front matter field whose value fails the typed [`FrontMatter`]
146/// schema, found by probing one `key: value` pair at a time. `None` when the
147/// failure cannot be pinned to a single field (caller falls back to P002).
148fn offending_front_matter_field(value: &serde_yaml::Value) -> Option<(String, String)> {
149    let map = value.as_mapping()?;
150    for (k, v) in map {
151        let Some(key) = k.as_str() else { continue };
152        let mut single = serde_yaml::Mapping::new();
153        single.insert(k.clone(), v.clone());
154        if serde_yaml::from_value::<FrontMatter>(serde_yaml::Value::Mapping(single)).is_err() {
155            return Some((key.to_string(), render_yaml_value(v)));
156        }
157    }
158    None
159}
160
161/// Compact single-line rendering of a YAML value for diagnostics.
162fn render_yaml_value(v: &serde_yaml::Value) -> String {
163    match v {
164        serde_yaml::Value::String(s) => s.clone(),
165        other => serde_yaml::to_string(other)
166            .unwrap_or_default()
167            .trim_end()
168            .replace('\n', " "),
169    }
170}
171
172// ------------------------------------------------------------------
173// Block scanning
174// ------------------------------------------------------------------
175
176/// State for an in-progress block directive on the nesting stack.
177struct OpenBlock {
178    name: String,
179    attrs: Attrs,
180    depth: usize, // number of leading colons (2 = top-level, 3 = nested, …)
181    start_line: usize, // 1-based
182    start_offset: usize,
183    content_start_offset: usize, // byte offset right after the opening line
184}
185
186/// Scan the body lines for block directives, producing `Block` items.
187///
188/// Design: only **top-level** blocks (those opened when the stack is empty) are
189/// emitted as `Block::Unknown`. Nested directives are tracked for depth
190/// matching but their content stays inside the parent block's raw content string.
191fn scan_blocks(
192    lines: &[&str],
193    body_start: usize,
194    source: &str,
195    diagnostics: &mut Vec<Diagnostic>,
196) -> Vec<Block> {
197    let mut blocks: Vec<Block> = Vec::new();
198    let mut stack: Vec<OpenBlock> = Vec::new();
199
200    // Track the start of the current "gap" of plain markdown between directives.
201    // We only collect markdown gaps at the top nesting level (stack is empty).
202    let mut md_start_line: Option<usize> = None; // 0-based index into `lines`
203    let mut md_start_offset: Option<usize> = None;
204
205    // Markdown code-fence state (``` / ~~~), mirroring lint.rs::scan_lines
206    // literal semantics. Guards ONLY the newer tolerant behaviors below
207    // (space-separated openers, orphan-closer consumption) so fenced SurfDoc
208    // examples in prose stay literal; the pre-existing strict-opener path is
209    // deliberately left non-fence-aware to avoid behavior churn.
210    let mut in_fence = false;
211
212    for (idx, &line) in lines.iter().enumerate().skip(body_start) {
213        let trimmed = line.trim();
214        let line_offset = byte_offset_start_of_line(idx, source);
215
216        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
217            in_fence = !in_fence;
218        }
219
220        // Check for closing directive: a line that is *only* colons.
221        if let Some(close_depth) = closing_directive_depth(trimmed) {
222            // Find the innermost matching open block.
223            if let Some(pos) = stack.iter().rposition(|b| b.depth == close_depth) {
224                // If there are unclosed blocks deeper than `pos`, warn about them.
225                while stack.len() > pos + 1 {
226                    let orphan = stack.pop().unwrap();
227                    diagnostics.push(Diagnostic {
228                        severity: Severity::Warning,
229                        message: format!(
230                            "Unclosed block directive '{}' opened at line {}",
231                            orphan.name, orphan.start_line
232                        ),
233                        span: Some(Span {
234                            start_line: orphan.start_line,
235                            end_line: idx + 1,
236                            start_offset: orphan.start_offset,
237                            end_offset: line_offset + line.len(),
238                        }),
239                        code: Some("P001".into()),
240                        fix: None,
241                    });
242                }
243
244                let open = stack.pop().unwrap(); // this is the one at `pos`
245
246                // Only emit a block if the stack is now empty (this was top-level).
247                if stack.is_empty() {
248                    let content = &source[open.content_start_offset..line_offset];
249                    let content = content.strip_suffix('\n').unwrap_or(content);
250
251                    blocks.push(Block::Unknown {
252                        name: open.name,
253                        attrs: open.attrs,
254                        content: content.to_string(),
255                        span: Span {
256                            start_line: open.start_line,
257                            end_line: idx + 1,
258                            start_offset: open.start_offset,
259                            end_offset: line_offset + line.len(),
260                        },
261                    });
262
263                    md_start_line = None;
264                    md_start_offset = None;
265                }
266                // If stack is not empty, this was a nested close — just pop, no emit.
267                continue;
268            }
269            // No matching open block. Outside fences, at top level, consume
270            // the orphan closer with a P006 warning instead of leaking a
271            // literal `::` paragraph into rendered output (mirrors the
272            // orphan-closer skip in blocks.rs::parse_page_children). Inside a
273            // fence it stays literal markdown; inside an open block it stays
274            // part of the parent's raw content.
275            if !in_fence && stack.is_empty() {
276                // Close out any pending markdown gap first so the consumed
277                // line can't be swept into a later gap's span.
278                flush_markdown(
279                    &mut blocks,
280                    &mut md_start_line,
281                    &mut md_start_offset,
282                    idx,
283                    source,
284                );
285                diagnostics.push(Diagnostic {
286                    severity: Severity::Warning,
287                    message: format!("Closing directive with no open block at line {}", idx + 1),
288                    span: Some(line_span(idx, idx, source)),
289                    code: Some("P006".into()),
290                    fix: None,
291                });
292                continue;
293            }
294            // Fall through and treat as markdown.
295        }
296
297        // Check for opening directive: `::name[attrs]`. The tolerant
298        // space-separated form (`:: name`) is ignored inside fences so code
299        // samples keep their literal lines; the strict form keeps its
300        // pre-existing non-fence-aware behavior.
301        let opener = opening_directive(trimmed).filter(|(depth, _, _)| {
302            !in_fence || directive_name_start(trimmed, *depth) == *depth
303        });
304        if let Some((depth, name, attrs_str)) = opener {
305            // Greedy page boundary: when a ::page or ::footer arrives and the
306            // stack bottom is a ::page at the same depth, force-close the
307            // current page.  Leaf directives inside pages (::hero-image, ::cta,
308            // ::embed, etc.) don't have closers and steal the page's own `::`
309            // markers via depth matching.  This ensures each ::page is emitted
310            // as a top-level block regardless of inner leaf nesting.
311            if !stack.is_empty()
312                && (name == "page" || name == "footer")
313                && stack[0].depth == depth
314                && stack[0].name == "page"
315            {
316                // Discard unclosed nested blocks — they are raw content.
317                while stack.len() > 1 {
318                    stack.pop();
319                }
320                let open = stack.pop().unwrap();
321
322                let content = &source[open.content_start_offset..line_offset];
323                let content = content.strip_suffix('\n').unwrap_or(content);
324
325                blocks.push(Block::Unknown {
326                    name: open.name,
327                    attrs: open.attrs,
328                    content: content.to_string(),
329                    span: Span {
330                        start_line: open.start_line,
331                        end_line: idx + 1,
332                        start_offset: open.start_offset,
333                        end_offset: line_offset,
334                    },
335                });
336
337                md_start_line = None;
338                md_start_offset = None;
339            }
340
341            // If we're at top level, flush any accumulated markdown.
342            if stack.is_empty() {
343                flush_markdown(
344                    &mut blocks,
345                    &mut md_start_line,
346                    &mut md_start_offset,
347                    idx,
348                    source,
349                );
350
351                let attrs = match parse_attrs(&attrs_str) {
352                    Ok(a) => a,
353                    Err(e) => {
354                        diagnostics.push(Diagnostic {
355                            severity: Severity::Warning,
356                            message: format!("Invalid attributes on '::{}': {}", name, e),
357                            span: Some(line_span(idx, idx, source)),
358                            code: Some("P003".into()),
359                            fix: None,
360                        });
361                        Attrs::new()
362                    }
363                };
364
365                // Leaf-aware: a closer-less top-level directive (e.g. ::metric,
366                // ::badge, ::cta) has no matching `::`. If we pushed it onto the
367                // stack it would steal the closer meant for a following sibling
368                // block and swallow the rest of the document. Detect this by
369                // looking ahead: if a same-depth sibling opening directive
370                // appears before any matching closer, emit immediately as a leaf
371                // and do NOT push. (At EOF with neither closer nor sibling we
372                // keep the old behavior: push + force-close as an unclosed
373                // container with a P001 diagnostic.)
374                //
375                // ::page / ::footer are excluded: they are containers whose
376                // child leaf directives (::hero-image, ::cta, …) sit at the SAME
377                // depth as the page itself, so generic sibling detection would
378                // misclassify a page as a leaf. The dedicated greedy page-boundary
379                // logic above handles their closing instead.
380                let is_page_family = name == "page" || name == "footer";
381                if !is_page_family && is_leaf_before_sibling(lines, idx + 1, depth) {
382                    blocks.push(Block::Unknown {
383                        name,
384                        attrs,
385                        content: String::new(),
386                        span: line_span(idx, idx, source),
387                    });
388
389                    md_start_line = None;
390                    md_start_offset = None;
391                    continue;
392                }
393
394                let content_start = line_offset + line.len() + 1; // +1 for the newline
395                let content_start = content_start.min(source.len());
396
397                stack.push(OpenBlock {
398                    name,
399                    attrs,
400                    depth,
401                    start_line: idx + 1,
402                    start_offset: line_offset,
403                    content_start_offset: content_start,
404                });
405            } else {
406                // Inside an existing block — push a nesting tracker.
407                // We don't parse attrs for nested blocks in Chunk 1; they stay
408                // as raw content of the parent.
409                stack.push(OpenBlock {
410                    name,
411                    attrs: Attrs::new(),
412                    depth,
413                    start_line: idx + 1,
414                    start_offset: line_offset,
415                    content_start_offset: 0, // unused for nested
416                });
417            }
418            continue;
419        }
420
421        // Regular line — track markdown gap if at top level.
422        if stack.is_empty() && md_start_line.is_none() {
423            md_start_line = Some(idx);
424            md_start_offset = Some(line_offset);
425        }
426    }
427
428    // Flush any remaining markdown.
429    flush_markdown(
430        &mut blocks,
431        &mut md_start_line,
432        &mut md_start_offset,
433        lines.len(),
434        source,
435    );
436
437    // Force-close any remaining open blocks (unclosed at EOF).
438    // Only the outermost (bottom of stack) gets emitted; inner ones just get diagnostics.
439    while let Some(open) = stack.pop() {
440        let eof_offset = source.len();
441        let eof_line = lines.len();
442
443        diagnostics.push(Diagnostic {
444            severity: Severity::Warning,
445            message: format!(
446                "Unclosed block directive '{}' opened at line {}",
447                open.name, open.start_line
448            ),
449            span: Some(Span {
450                start_line: open.start_line,
451                end_line: eof_line,
452                start_offset: open.start_offset,
453                end_offset: eof_offset,
454            }),
455            code: Some("P001".into()),
456            fix: None,
457        });
458
459        // Only emit for the outermost block (stack now empty).
460        if stack.is_empty() {
461            let content = if open.content_start_offset <= eof_offset {
462                &source[open.content_start_offset..eof_offset]
463            } else {
464                ""
465            };
466            let content = content.strip_suffix('\n').unwrap_or(content);
467
468            blocks.push(Block::Unknown {
469                name: open.name,
470                attrs: open.attrs,
471                content: content.to_string(),
472                span: Span {
473                    start_line: open.start_line,
474                    end_line: eof_line,
475                    start_offset: open.start_offset,
476                    end_offset: eof_offset,
477                },
478            });
479        }
480    }
481
482    blocks
483}
484
485/// Flush accumulated markdown lines into a `Block::Markdown`.
486fn flush_markdown(
487    blocks: &mut Vec<Block>,
488    md_start_line: &mut Option<usize>,
489    md_start_offset: &mut Option<usize>,
490    current_idx: usize,
491    source: &str,
492) {
493    if let (Some(start_idx), Some(start_off)) = (*md_start_line, *md_start_offset) {
494        let mut end_idx = current_idx.saturating_sub(1);
495
496        // Walk backwards past trailing empty lines so spans are tight.
497        let source_lines: Vec<&str> = source.split('\n').collect();
498        while end_idx > start_idx && source_lines.get(end_idx).is_some_and(|l| l.trim().is_empty())
499        {
500            end_idx -= 1;
501        }
502
503        let end_offset = byte_offset_end_of_line(end_idx, source);
504        let content = &source[start_off..end_offset];
505
506        // Only emit if there's actual content (not just whitespace).
507        let trimmed = content.trim();
508        if !trimmed.is_empty() {
509            blocks.push(Block::Markdown {
510                content: content.to_string(),
511                span: Span {
512                    start_line: start_idx + 1,
513                    end_line: end_idx + 1,
514                    start_offset: start_off,
515                    end_offset,
516                },
517            });
518        }
519
520        *md_start_line = None;
521        *md_start_offset = None;
522    }
523}
524
525// ------------------------------------------------------------------
526// Line classification helpers
527// ------------------------------------------------------------------
528
529/// Returns true if a top-level opening directive at `depth` (whose line is at
530/// `after_idx - 1`) is a LEAF: a same-depth sibling opening directive appears
531/// before any matching closer. Returns false if its own closer comes first
532/// (container) OR if neither is found before EOF (preserve existing unclosed-
533/// container force-close behavior at EOF).
534///
535/// Mirrors the sibling-detection semantics of
536/// `crate::blocks::scan_container_close`.
537fn is_leaf_before_sibling(lines: &[&str], after_idx: usize, depth: usize) -> bool {
538    let mut nesting = 0usize;
539    for line in lines.iter().skip(after_idx) {
540        let trimmed = line.trim();
541        if let Some(close_depth) = closing_directive_depth(trimmed) {
542            if nesting == 0 && close_depth == depth {
543                return false; // own closer first → container
544            }
545            if nesting > 0 {
546                nesting -= 1;
547            }
548            continue;
549        }
550        if let Some((nd, _, _)) = opening_directive(trimmed) {
551            if nd == depth && nesting == 0 {
552                return true; // sibling first → leaf
553            }
554            if nd > depth {
555                nesting += 1;
556            }
557        }
558    }
559    false // EOF, no closer, no sibling → keep old behavior (push; force-closed at EOF)
560}
561
562/// If the line is a closing directive (`::`, `:::`, …), return the depth (colon count).
563///
564/// Public since 0.10.0: source-scanning consumers (e.g. span re-scan passes
565/// in downstream tooling) pair this with [`opening_directive`].
566pub fn closing_directive_depth(trimmed: &str) -> Option<usize> {
567    if trimmed.is_empty() {
568        return None;
569    }
570    // Must be only colons.
571    if trimmed.chars().all(|c| c == ':') && trimmed.len() >= 2 {
572        Some(trimmed.len())
573    } else {
574        None
575    }
576}
577
578/// If the line is an opening directive (`::name[attrs]`), return `(depth, name, attrs_str)`.
579///
580/// Tolerates spaces/tabs between the colons and the block name (`:: hero[...]`)
581/// — a common generated-markup slip that previously leaked the whole directive
582/// line into rendered output as literal text. Callers that do offset math on
583/// the line must use [`directive_name_start`] rather than assuming the name
584/// begins at `depth`.
585pub fn opening_directive(trimmed: &str) -> Option<(usize, String, String)> {
586    if !trimmed.starts_with("::") {
587        return None;
588    }
589
590    // Count leading colons.
591    let depth = trimmed.chars().take_while(|&c| c == ':').count();
592    if depth < 2 {
593        return None;
594    }
595
596    let rest = trimmed[depth..].trim_start_matches([' ', '\t']);
597    if rest.is_empty() {
598        // This is a closing directive, not an opening one.
599        return None;
600    }
601
602    // The next character must be alphabetic (block name start).
603    let first_char = rest.chars().next()?;
604    if !first_char.is_alphabetic() {
605        return None;
606    }
607
608    // Scan block name.
609    let name_end = rest
610        .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
611        .unwrap_or(rest.len());
612    let name = rest[..name_end].to_string();
613    let remainder = &rest[name_end..];
614
615    // Extract attrs if present. Find the closing bracket quote- and depth-aware
616    // so a `]` inside a quoted attribute value (e.g. a leftover `[[wikilink]]`
617    // in a figure caption) doesn't truncate the block — which previously made
618    // `parse_attrs` fail and silently drop every attribute (notably `src`,
619    // leaving images blank).
620    let attrs_str = if remainder.starts_with('[') {
621        if let Some(close) = find_attr_close(remainder) {
622            remainder[..=close].to_string()
623        } else {
624            // Unclosed bracket — take everything.
625            remainder.to_string()
626        }
627    } else {
628        String::new()
629    };
630
631    Some((depth, name, attrs_str))
632}
633
634/// Byte offset (within `trimmed`) at which an opening directive's block name
635/// starts: `depth` plus any tolerated spaces/tabs between the colons and the
636/// name. Lint rules that slice the line relative to the directive must use
637/// this instead of assuming the name begins at `depth`.
638pub(crate) fn directive_name_start(trimmed: &str, depth: usize) -> usize {
639    let after = &trimmed[depth..];
640    depth + (after.len() - after.trim_start_matches([' ', '\t']).len())
641}
642
643/// Find the byte index of the `]` that closes the attribute block opened by the
644/// leading `[` of `s`. Quote-aware (a `]` inside `"..."` is ignored, honoring
645/// `\"` escapes) and depth-aware (balances nested `[...]`, e.g. an unquoted
646/// `[[wikilink]]`). Returns `None` if no balanced close exists.
647pub(crate) fn find_attr_close(s: &str) -> Option<usize> {
648    let mut depth: i32 = 0;
649    let mut in_quote = false;
650    let mut escaped = false;
651    for (i, b) in s.bytes().enumerate() {
652        if in_quote {
653            if escaped {
654                escaped = false;
655            } else if b == b'\\' {
656                escaped = true;
657            } else if b == b'"' {
658                in_quote = false;
659            }
660            continue;
661        }
662        match b {
663            b'"' => in_quote = true,
664            b'[' => depth += 1,
665            b']' => {
666                depth -= 1;
667                if depth == 0 {
668                    return Some(i);
669                }
670            }
671            _ => {}
672        }
673    }
674    None
675}
676
677// ------------------------------------------------------------------
678// Byte offset helpers
679// ------------------------------------------------------------------
680
681/// Byte offset of the start of line `idx` (0-based) within `source`.
682fn byte_offset_start_of_line(idx: usize, source: &str) -> usize {
683    let mut offset = 0;
684    for (i, line) in source.split('\n').enumerate() {
685        if i == idx {
686            return offset;
687        }
688        offset += line.len() + 1; // +1 for '\n'
689    }
690    source.len()
691}
692
693/// Byte offset of the end (exclusive) of line `idx` (0-based) within `source`.
694fn byte_offset_end_of_line(idx: usize, source: &str) -> usize {
695    let mut offset = 0;
696    for (i, line) in source.split('\n').enumerate() {
697        offset += line.len();
698        if i == idx {
699            return offset;
700        }
701        offset += 1; // '\n'
702    }
703    source.len()
704}
705
706/// Build a `Span` covering lines `start_idx..=end_idx` (0-based).
707fn line_span(start_idx: usize, end_idx: usize, source: &str) -> Span {
708    Span {
709        start_line: start_idx + 1,
710        end_line: end_idx + 1,
711        start_offset: byte_offset_start_of_line(start_idx, source),
712        end_offset: byte_offset_end_of_line(end_idx, source),
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use pretty_assertions::assert_eq;
720
721    #[test]
722    fn parse_empty_input() {
723        let result = parse("");
724        assert!(result.doc.front_matter.is_none());
725        assert!(result.doc.blocks.is_empty());
726        assert!(result.diagnostics.is_empty());
727    }
728
729    #[test]
730    fn find_attr_close_is_quote_and_depth_aware() {
731        // Plain.
732        assert_eq!(find_attr_close("[a=b]"), Some(4));
733        // `]` inside a quoted value must be ignored.
734        let s = r#"[src="u" caption="x ] y"]"#;
735        assert_eq!(find_attr_close(s), Some(s.len() - 1));
736        // Nested unquoted brackets (leftover [[wikilink]]) are balanced.
737        let s2 = r#"[src="u" caption="HQ [[Mountain View]] 2009"]"#;
738        assert_eq!(find_attr_close(s2), Some(s2.len() - 1));
739        // Escaped quote inside a value doesn't end the quote early.
740        let s3 = r#"[k="say \"hi\" ]now"]"#;
741        assert_eq!(find_attr_close(s3), Some(s3.len() - 1));
742        // Unterminated.
743        assert_eq!(find_attr_close("[a=b"), None);
744    }
745
746    #[test]
747    fn opening_directive_keeps_attrs_with_bracket_in_caption() {
748        // Regression: a `]` inside the caption used to truncate the block, so
749        // parse_attrs failed and dropped `src`, blanking the image.
750        let line = r#"::figure[src="https://x/y.jpg" caption="HQ in [[Mountain View, California]], 2009"]"#;
751        let (_, name, attrs_str) = opening_directive(line).expect("directive parses");
752        assert_eq!(name, "figure");
753        let attrs = parse_attrs(&attrs_str).expect("attrs parse");
754        let str_attr = |k: &str| match attrs.get(k) {
755            Some(crate::types::AttrValue::String(s)) => Some(s.clone()),
756            _ => None,
757        };
758        assert_eq!(str_attr("src").as_deref(), Some("https://x/y.jpg"));
759        assert!(
760            str_attr("caption").unwrap_or_default().contains("Mountain View"),
761            "caption preserved"
762        );
763    }
764
765    #[test]
766    fn figure_with_wikilink_caption_renders_img_src() {
767        // End-to-end: the rendered fragment must carry the real image src.
768        let src = "::figure[src=\"https://upload.wikimedia.org/x.jpg\" caption=\"HQ in [[Mountain View, California]], 2009\"]\n";
769        let frag = parse(src).doc.to_html_fragment();
770        assert!(
771            frag.contains(r#"src="https://upload.wikimedia.org/x.jpg""#),
772            "img src present in fragment: {frag}"
773        );
774    }
775
776    #[test]
777    fn parse_plain_markdown() {
778        let input = "# Hello\n\nSome text here.\n";
779        let result = parse(input);
780        assert!(result.doc.front_matter.is_none());
781        assert_eq!(result.doc.blocks.len(), 1);
782        match &result.doc.blocks[0] {
783            Block::Markdown { content, .. } => {
784                assert!(content.contains("# Hello"));
785                assert!(content.contains("Some text here."));
786            }
787            _ => panic!("Expected Markdown block"),
788        }
789    }
790
791    #[test]
792    fn parse_front_matter() {
793        let input = "---\ntitle: Test\n---\n# Hello\n";
794        let result = parse(input);
795        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
796        let fm = result.doc.front_matter.as_ref().unwrap();
797        assert_eq!(fm.title.as_deref(), Some("Test"));
798        assert_eq!(result.doc.blocks.len(), 1);
799        match &result.doc.blocks[0] {
800            Block::Markdown { content, .. } => {
801                assert!(content.contains("# Hello"));
802            }
803            _ => panic!("Expected Markdown block"),
804        }
805    }
806
807    #[test]
808    fn parse_single_block() {
809        let input = "::callout[type=warning]\nDanger!\n::\n";
810        let result = parse(input);
811        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
812        assert_eq!(result.doc.blocks.len(), 1);
813        match &result.doc.blocks[0] {
814            Block::Callout {
815                callout_type,
816                content,
817                span,
818                ..
819            } => {
820                assert_eq!(*callout_type, crate::types::CalloutType::Warning);
821                assert_eq!(content, "Danger!");
822                assert_eq!(span.start_line, 1);
823                assert_eq!(span.end_line, 3);
824            }
825            other => panic!("Expected Callout block, got {other:?}"),
826        }
827    }
828
829    #[test]
830    fn parse_two_blocks() {
831        let input = "::callout[type=info]\nFirst\n::\n\nSome markdown.\n\n::data[format=json]\n{}\n::\n";
832        let result = parse(input);
833        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
834        assert_eq!(result.doc.blocks.len(), 3);
835
836        assert!(matches!(&result.doc.blocks[0], Block::Callout { .. }));
837        assert!(matches!(&result.doc.blocks[1], Block::Markdown { .. }));
838        assert!(matches!(&result.doc.blocks[2], Block::Data { .. }));
839    }
840
841    #[test]
842    fn parse_nested_blocks() {
843        let input = "::columns\n:::column\nLeft text.\n:::\n:::column\nRight text.\n:::\n::\n";
844        let result = parse(input);
845        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
846        assert_eq!(result.doc.blocks.len(), 1);
847        match &result.doc.blocks[0] {
848            Block::Columns { columns, .. } => {
849                assert_eq!(columns.len(), 2);
850                assert!(columns[0].content.contains("Left text."));
851                assert!(columns[1].content.contains("Right text."));
852            }
853            other => panic!("Expected Columns block, got {other:?}"),
854        }
855    }
856
857    #[test]
858    fn parse_unclosed_block() {
859        let input = "::callout[type=warning]\nNo closing marker";
860        let result = parse(input);
861        assert!(!result.diagnostics.is_empty(), "Expected a diagnostic for unclosed block");
862        assert_eq!(result.doc.blocks.len(), 1);
863        match &result.doc.blocks[0] {
864            Block::Callout { content, .. } => {
865                assert!(content.contains("No closing marker"));
866            }
867            other => panic!("Expected Callout block, got {other:?}"),
868        }
869    }
870
871    #[test]
872    fn parse_leaf_directive() {
873        let input = "# Title\n\n::metric[label=\"MRR\" value=\"$2K\"]\n\n## More\n";
874        let result = parse(input);
875        // A leaf directive is a single-line directive with no explicit closing.
876        // It should be treated as an unclosed block that captures no content.
877        // But actually the parser should detect it as a block that's implicitly
878        // closed by the next block or end-of-gap.
879        //
880        // With our design, the `::metric` opens a block on the stack.
881        // The next lines are not closers, so at EOF the block is force-closed.
882        // The diagnostic is expected.
883        assert_eq!(
884            result.doc.blocks.len(),
885            2, // Markdown "# Title\n", then Metric (force-closed)
886            "blocks: {:#?}", result.doc.blocks
887        );
888        let has_metric = result.doc.blocks.iter().any(|b| matches!(b, Block::Metric { .. }));
889        assert!(has_metric, "Should contain a metric block");
890    }
891
892    #[test]
893    fn parse_block_spans() {
894        let input = "# Title\n::callout\nInside\n::\n# After\n";
895        let result = parse(input);
896        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
897
898        // First block: markdown "# Title\n" → line 1
899        match &result.doc.blocks[0] {
900            Block::Markdown { span, .. } => {
901                assert_eq!(span.start_line, 1);
902                assert_eq!(span.end_line, 1);
903            }
904            _ => panic!("Expected Markdown"),
905        }
906
907        // Second block: callout → lines 2-4
908        match &result.doc.blocks[1] {
909            Block::Callout { span, .. } => {
910                assert_eq!(span.start_line, 2);
911                assert_eq!(span.end_line, 4);
912            }
913            other => panic!("Expected Callout, got {other:?}"),
914        }
915
916        // Third block: markdown "# After\n" → line 5
917        match &result.doc.blocks[2] {
918            Block::Markdown { span, .. } => {
919                assert_eq!(span.start_line, 5);
920                assert_eq!(span.end_line, 5);
921            }
922            _ => panic!("Expected Markdown"),
923        }
924    }
925
926    #[test]
927    fn parse_front_matter_all_fields() {
928        let input = r#"---
929title: "Full Document"
930type: plan
931status: active
932scope: workspace
933tags: [rust, parser]
934created: "2026-02-10"
935updated: "2026-02-10"
936author: "Brady Davis"
937confidence: high
938version: 2
939workspace: cloudsurf
940decision: "Use Rust"
941related:
942  - path: plans/example.md
943    relationship: references
944---
945Body.
946"#;
947        let result = parse(input);
948        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
949        let fm = result.doc.front_matter.as_ref().unwrap();
950        assert_eq!(fm.title.as_deref(), Some("Full Document"));
951        assert_eq!(fm.doc_type, Some(crate::types::DocType::Plan));
952        assert_eq!(fm.status, Some(crate::types::DocStatus::Active));
953        assert_eq!(fm.scope, Some(crate::types::Scope::Workspace));
954        assert_eq!(fm.tags.as_deref(), Some(&["rust".to_string(), "parser".to_string()][..]));
955        assert_eq!(fm.created.as_deref(), Some("2026-02-10"));
956        assert_eq!(fm.updated.as_deref(), Some("2026-02-10"));
957        assert_eq!(fm.author.as_deref(), Some("Brady Davis"));
958        assert_eq!(fm.confidence, Some(crate::types::Confidence::High));
959        assert_eq!(fm.version, Some(2));
960        assert_eq!(fm.workspace.as_deref(), Some("cloudsurf"));
961        assert_eq!(fm.decision.as_deref(), Some("Use Rust"));
962        let related = fm.related.as_ref().unwrap();
963        assert_eq!(related.len(), 1);
964        assert_eq!(related[0].path, "plans/example.md");
965    }
966
967    #[test]
968    fn p005_recovery_is_identical_to_p002_recovery() {
969        // The P002/P005 split changes ONLY the diagnostic. Both subcases must
970        // recover exactly as before: no front matter, body starts after the
971        // closing `---`.
972        let p005_input = "---\ntitle: T\ntype: checkpoint\n---\n# Heading\n\nBody.\n";
973        let p002_input = "---\ntitle: [broken\n---\n# Heading\n\nBody.\n";
974        for (input, code) in [(p005_input, "P005"), (p002_input, "P002")] {
975            let result = parse(input);
976            assert!(result.doc.front_matter.is_none(), "{code}: fm stays None");
977            assert_eq!(result.diagnostics.len(), 1, "{code}: one diagnostic");
978            assert_eq!(result.diagnostics[0].code.as_deref(), Some(code));
979            assert_eq!(result.doc.blocks.len(), 1, "{code}: body parsed");
980            match &result.doc.blocks[0] {
981                Block::Markdown { content, .. } => {
982                    assert!(content.contains("# Heading"), "{code}");
983                    assert!(content.contains("Body."), "{code}");
984                }
985                other => panic!("{code}: expected Markdown block, got {other:?}"),
986            }
987        }
988        // Severity split: P005 warning, P002 error.
989        assert_eq!(parse(p005_input).diagnostics[0].severity, Severity::Warning);
990        assert_eq!(parse(p002_input).diagnostics[0].severity, Severity::Error);
991    }
992
993    #[test]
994    fn p005_names_the_offending_field_and_value() {
995        let result = parse("---\ntitle: T\nstatus: superseded\n---\nBody.\n");
996        assert_eq!(result.diagnostics.len(), 1);
997        assert_eq!(result.diagnostics[0].code.as_deref(), Some("P005"));
998        assert_eq!(
999            result.diagnostics[0].message,
1000            "Front matter field 'status' has unrecognized value 'superseded'"
1001        );
1002    }
1003
1004    #[test]
1005    fn parse_unknown_front_matter_fields() {
1006        let input = "---\ntitle: Test\ncustom_field: hello\nanother: 42\n---\n";
1007        let result = parse(input);
1008        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1009        let fm = result.doc.front_matter.as_ref().unwrap();
1010        assert_eq!(fm.title.as_deref(), Some("Test"));
1011        assert!(fm.extra.contains_key("custom_field"), "extra should contain custom_field");
1012        assert!(fm.extra.contains_key("another"), "extra should contain another");
1013    }
1014
1015    // ------------------------------------------------------------------
1016    // Chunk 2 integration tests — end-to-end through Pass 2 resolution.
1017    // ------------------------------------------------------------------
1018
1019    #[test]
1020    fn parse_callout_end_to_end() {
1021        let input = "::callout[type=warning]\nWatch out for sharp edges.\n::\n";
1022        let result = parse(input);
1023        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1024        assert_eq!(result.doc.blocks.len(), 1);
1025        match &result.doc.blocks[0] {
1026            Block::Callout {
1027                callout_type,
1028                content,
1029                span,
1030                ..
1031            } => {
1032                assert_eq!(*callout_type, crate::types::CalloutType::Warning);
1033                assert_eq!(content, "Watch out for sharp edges.");
1034                assert_eq!(span.start_line, 1);
1035                assert_eq!(span.end_line, 3);
1036            }
1037            other => panic!("Expected Callout block, got {other:?}"),
1038        }
1039    }
1040
1041    #[test]
1042    fn parse_metric_end_to_end() {
1043        let input = "::metric[label=\"MRR\" value=\"$2K\"]\n::\n";
1044        let result = parse(input);
1045        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1046        assert_eq!(result.doc.blocks.len(), 1);
1047        match &result.doc.blocks[0] {
1048            Block::Metric {
1049                label,
1050                value,
1051                trend,
1052                ..
1053            } => {
1054                assert_eq!(label, "MRR");
1055                assert_eq!(value, "$2K");
1056                assert!(trend.is_none());
1057            }
1058            other => panic!("Expected Metric block, got {other:?}"),
1059        }
1060    }
1061
1062    #[test]
1063    fn parse_mixed_typed_blocks() {
1064        let input = concat!(
1065            "::callout[type=info]\nFYI\n::\n",
1066            "\n# Some Markdown\n\n",
1067            "::data[format=csv]\nA, B\n1, 2\n::\n",
1068        );
1069        let result = parse(input);
1070        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1071        assert_eq!(result.doc.blocks.len(), 3, "blocks: {:#?}", result.doc.blocks);
1072
1073        assert!(matches!(&result.doc.blocks[0], Block::Callout { .. }));
1074        assert!(matches!(&result.doc.blocks[1], Block::Markdown { .. }));
1075        match &result.doc.blocks[2] {
1076            Block::Data {
1077                format,
1078                headers,
1079                rows,
1080                ..
1081            } => {
1082                assert_eq!(*format, crate::types::DataFormat::Csv);
1083                assert_eq!(headers, &["A", "B"]);
1084                assert_eq!(rows.len(), 1);
1085            }
1086            other => panic!("Expected Data block, got {other:?}"),
1087        }
1088    }
1089
1090    // ------------------------------------------------------------------
1091    // Multipage parsing tests — greedy page boundary.
1092    // ------------------------------------------------------------------
1093
1094    #[test]
1095    fn parse_multipage_with_leaf_directives() {
1096        // Pages contain leaf directives (::hero-image, ::cta) that don't have
1097        // closers.  The parser must still emit each page as a separate block.
1098        let input = concat!(
1099            "::site\nname: Test\n::\n",
1100            "::page[route=\"/\" title=\"Home\"]\n",
1101            "# Welcome\n",
1102            "::hero-image[src=\"bg.jpg\"]\n",
1103            "::cta[label=\"Go\" href=\"/about\"]\n",
1104            "::\n",
1105            "::page[route=\"/about\" title=\"About\"]\n",
1106            "# About Us\n",
1107            "::\n",
1108            "::footer\n(c) 2026\n::\n",
1109        );
1110        let result = parse(input);
1111        let pages: Vec<_> = result
1112            .doc
1113            .blocks
1114            .iter()
1115            .filter(|b| matches!(b, Block::Page { .. }))
1116            .collect();
1117        assert_eq!(pages.len(), 2, "Expected 2 pages, got {} — blocks: {:#?}", pages.len(), result.doc.blocks);
1118        match &pages[0] {
1119            Block::Page { route, title, .. } => {
1120                assert_eq!(route, "/");
1121                assert_eq!(title.as_deref(), Some("Home"));
1122            }
1123            _ => unreachable!(),
1124        }
1125        match &pages[1] {
1126            Block::Page { route, title, .. } => {
1127                assert_eq!(route, "/about");
1128                assert_eq!(title.as_deref(), Some("About"));
1129            }
1130            _ => unreachable!(),
1131        }
1132    }
1133
1134    #[test]
1135    fn parse_multipage_children_resolved() {
1136        // Verify that children inside pages are correctly resolved even when
1137        // leaf directives are present.
1138        let input = concat!(
1139            "::page[route=\"/\"]\n",
1140            "::hero-image[src=\"bg.jpg\"]\n",
1141            "::columns\n:::column\nLeft\n:::\n:::column\nRight\n:::\n::\n",
1142            "::cta[label=\"Click\"]\n",
1143            "::\n",
1144            "::page[route=\"/next\"]\n",
1145            "# Next Page\n",
1146            "::\n",
1147        );
1148        let result = parse(input);
1149        let pages: Vec<_> = result
1150            .doc
1151            .blocks
1152            .iter()
1153            .filter(|b| matches!(b, Block::Page { .. }))
1154            .collect();
1155        assert_eq!(pages.len(), 2, "blocks: {:#?}", result.doc.blocks);
1156
1157        // First page should have children: hero-image, columns, cta
1158        if let Block::Page { children, .. } = &pages[0] {
1159            let has_columns = children.iter().any(|b| matches!(b, Block::Columns { .. }));
1160            assert!(has_columns, "First page should contain a Columns block, got: {:#?}", children);
1161        }
1162    }
1163
1164    #[test]
1165    fn parse_multipage_footer_closes_last_page() {
1166        // A ::footer should force-close the preceding ::page.
1167        let input = concat!(
1168            "::page[route=\"/\"]\n",
1169            "# Home\n",
1170            "::hero-image[src=\"bg.jpg\"]\n",
1171            "::\n",
1172            "::footer\n(c) 2026\n::\n",
1173        );
1174        let result = parse(input);
1175        let page_count = result.doc.blocks.iter().filter(|b| matches!(b, Block::Page { .. })).count();
1176        let footer_count = result.doc.blocks.iter().filter(|b| matches!(b, Block::Footer { .. })).count();
1177        assert_eq!(page_count, 1, "blocks: {:#?}", result.doc.blocks);
1178        assert_eq!(footer_count, 1, "blocks: {:#?}", result.doc.blocks);
1179    }
1180
1181    #[test]
1182    fn parse_five_page_site() {
1183        // Regression test modelled on the Bowties Tuxedo .surf file.
1184        let input = concat!(
1185            "::site\nname: Bowties\n::\n",
1186            "::nav[logo=\"Bowties\"]\n- [Home](/)\n- [Gallery](/gallery)\n::\n",
1187            "::page[route=\"/\" title=\"Home\"]\n",
1188            "::hero-image[src=\"hero.jpg\"]\n",
1189            "::cta[label=\"View\" href=\"/gallery\"]\n",
1190            "::columns\n:::column\nA\n:::\n:::column\nB\n:::\n::\n",
1191            "::testimonial[author=\"Review\"]\nGreat!\n::\n",
1192            "::cta[label=\"Go\" href=\"/contact\"]\n",
1193            "::\n",
1194            "::page[route=\"/gallery\" title=\"Gallery\"]\n",
1195            "# Gallery\n",
1196            "::gallery\n![A](a.jpg) Caption A\n::\n",
1197            "::cta[label=\"Book\" href=\"/contact\"]\n",
1198            "::\n",
1199            "::page[route=\"/services\" title=\"Services\"]\n",
1200            "# Services\n",
1201            "::\n",
1202            "::page[route=\"/measurements\" title=\"Measurements\"]\n",
1203            "::form[submit=\"Submit\"]\n- Name (text) *\n::\n",
1204            "::\n",
1205            "::page[route=\"/contact\" title=\"Contact\"]\n",
1206            "# Contact\n",
1207            "::embed[src=\"https://maps.google.com\"]\n",
1208            "::form[submit=\"Send\"]\n- Name (text) *\n::\n",
1209            "::\n",
1210            "::footer\n## Links\n- [Home](/)\n::\n",
1211        );
1212        let result = parse(input);
1213        let pages: Vec<_> = result
1214            .doc
1215            .blocks
1216            .iter()
1217            .filter_map(|b| match b {
1218                Block::Page { route, .. } => Some(route.as_str()),
1219                _ => None,
1220            })
1221            .collect();
1222        assert_eq!(
1223            pages,
1224            vec!["/", "/gallery", "/services", "/measurements", "/contact"],
1225            "Expected 5 pages, got {:?} — full blocks: {:#?}",
1226            pages,
1227            result.doc.blocks
1228        );
1229    }
1230
1231    #[test]
1232    fn parse_utf8_content_no_panic() {
1233        // Regression: verify byte offset calculation handles multibyte UTF-8
1234        let input = "# Café ☕\n\n::callout[type=info]\nBienvenue à notre café!\n::\n\n日本語テスト\n";
1235        let result = parse(input);
1236        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1237        // Verify the markdown block captured the UTF-8 heading
1238        let md = result.doc.blocks.iter().find(|b| matches!(b, Block::Markdown { .. }));
1239        assert!(md.is_some(), "Expected markdown block with UTF-8 heading");
1240        if let Some(Block::Markdown { content, .. }) = md {
1241            assert!(content.contains("Café ☕"), "UTF-8 heading not captured");
1242        }
1243    }
1244
1245    #[test]
1246    fn parse_utf8_in_block_directives() {
1247        // Test non-ASCII in block attributes and content
1248        let input = "::callout[type=info]\nDas ist ein Prüfung mit Ümlauten: äöüß\n::\n";
1249        let result = parse(input);
1250        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1251        assert_eq!(result.doc.blocks.len(), 1);
1252    }
1253
1254    #[test]
1255    fn parse_utf8_in_page_routes_and_titles() {
1256        let input = concat!(
1257            "::site\nname = \"カフェ\"\n::\n",
1258            "::page[route=\"/\" title=\"ホーム\"]\n# ホーム\n::\n",
1259            "::page[route=\"/über-uns\"]\n# Über Uns\n::\n",
1260        );
1261        let result = parse(input);
1262        let pages: Vec<_> = result
1263            .doc
1264            .blocks
1265            .iter()
1266            .filter_map(|b| match b {
1267                Block::Page { route, .. } => Some(route.as_str()),
1268                _ => None,
1269            })
1270            .collect();
1271        assert_eq!(pages, vec!["/", "/über-uns"]);
1272    }
1273
1274    #[test]
1275    fn parse_emoji_heavy_content() {
1276        // Stress test: every line has multibyte chars
1277        let input = "# 🚀 Launch Day\n\n::callout[type=tip]\n💡 Remember: 大事なこと\n::\n\n🎉 We did it! 🎊\n";
1278        let result = parse(input);
1279        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1280        assert!(result.doc.blocks.len() >= 2, "Expected at least 2 blocks");
1281    }
1282
1283    #[test]
1284    fn parse_duplicate_page_routes() {
1285        // Two pages with identical routes — second should still parse
1286        // (validation should catch this, not the parser)
1287        let input = concat!(
1288            "::site\nname = \"Test\"\n::\n",
1289            "::page[route=\"/\"]\n# Home v1\n::\n",
1290            "::page[route=\"/\"]\n# Home v2\n::\n",
1291        );
1292        let result = parse(input);
1293        let pages: Vec<_> = result
1294            .doc
1295            .blocks
1296            .iter()
1297            .filter_map(|b| match b {
1298                Block::Page { route, .. } => Some(route.as_str()),
1299                _ => None,
1300            })
1301            .collect();
1302        // Parser emits both — validation should flag the duplicate
1303        assert_eq!(pages.len(), 2, "Parser should emit both duplicate pages");
1304    }
1305
1306    // ----------------------------------------------------------------
1307    // Regression: leaf directives must not swallow following siblings.
1308    // ----------------------------------------------------------------
1309
1310    #[test]
1311    fn leaf_then_container_then_leaf_all_parse() {
1312        // Bug repro: a closer-less leaf (::metric) followed by a real
1313        // container (::decision) and another leaf (::tasks) used to swallow
1314        // everything after the metric. All three must now appear in order.
1315        let input = concat!(
1316            "::metric[label=\"x\" value=\"42K\"]\n",
1317            "\n",
1318            "::decision[status=accepted]\n",
1319            "x\n",
1320            "::\n",
1321            "\n",
1322            "::tasks\n",
1323        );
1324        let result = parse(input);
1325        let names: Vec<&str> = result
1326            .doc
1327            .blocks
1328            .iter()
1329            .map(|b| match b {
1330                Block::Metric { .. } => "metric",
1331                Block::Decision { .. } => "decision",
1332                Block::Tasks { .. } => "tasks",
1333                _ => "other",
1334            })
1335            .collect();
1336        assert_eq!(
1337            names,
1338            vec!["metric", "decision", "tasks"],
1339            "blocks: {:?}",
1340            result.doc.blocks
1341        );
1342    }
1343
1344    #[test]
1345    fn consecutive_leaves_then_container_all_parse() {
1346        // A run of consecutive closer-less leaf directives followed by a real
1347        // container — every block must be present.
1348        let input = concat!(
1349            "::badge[value=\"New\"]\n",
1350            "\n",
1351            "::divider[label=\"sec\"]\n",
1352            "\n",
1353            "::cta[label=\"y\"]\n",
1354            "\n",
1355            "::callout[type=info]\n",
1356            "body text\n",
1357            "::\n",
1358        );
1359        let result = parse(input);
1360        assert!(
1361            result.doc.blocks.iter().any(|b| matches!(b, Block::Badge { .. })),
1362            "missing Badge; blocks: {:?}",
1363            result.doc.blocks
1364        );
1365        assert!(
1366            result.doc.blocks.iter().any(|b| matches!(b, Block::Divider { .. })),
1367            "missing Divider; blocks: {:?}",
1368            result.doc.blocks
1369        );
1370        assert!(
1371            result.doc.blocks.iter().any(|b| matches!(b, Block::Cta { .. })),
1372            "missing Cta; blocks: {:?}",
1373            result.doc.blocks
1374        );
1375        match result.doc.blocks.iter().find(|b| matches!(b, Block::Callout { .. })) {
1376            Some(Block::Callout { content, .. }) => {
1377                assert!(content.contains("body text"), "callout body lost: {content:?}");
1378            }
1379            _ => panic!("missing Callout; blocks: {:?}", result.doc.blocks),
1380        }
1381    }
1382
1383    #[test]
1384    fn normal_container_keeps_body_intact() {
1385        // Guard against over-eager leaf classification: a genuine container
1386        // with a body must still parse with its content intact.
1387        let input = "::callout[type=warning]\nbody\n::\n";
1388        let result = parse(input);
1389        assert!(result.diagnostics.is_empty(), "diagnostics: {:?}", result.diagnostics);
1390        assert_eq!(result.doc.blocks.len(), 1);
1391        match &result.doc.blocks[0] {
1392            Block::Callout { content, .. } => assert_eq!(content, "body"),
1393            other => panic!("Expected Callout, got {other:?}"),
1394        }
1395    }
1396
1397    #[test]
1398    #[cfg(feature = "native")]
1399    fn gfm_pipe_table_in_markdown_becomes_data_table() {
1400        // End-to-end: a GFM pipe table embedded in plain markdown parses as a
1401        // Markdown block, then expands to a native DataTable on conversion.
1402        let input = "Lead text.\n\n| Col A | Col B |\n|-------|-------|\n| 1 | 2 |\n";
1403        let doc = parse(input).doc;
1404        // The parser keeps it as a single Markdown block (tables are markdown).
1405        assert!(doc
1406            .blocks
1407            .iter()
1408            .any(|b| matches!(b, Block::Markdown { .. })));
1409        // The native conversion lifts the table out.
1410        let native = crate::render_native::to_native_blocks(&doc);
1411        let table = native.iter().find_map(|b| match b {
1412            crate::render_native::NativeBlock::DataTable { headers, rows, .. } => {
1413                Some((headers.clone(), rows.clone()))
1414            }
1415            _ => None,
1416        });
1417        let (headers, rows) = table.expect("expected a DataTable from the pipe table");
1418        assert_eq!(headers, vec!["Col A".to_string(), "Col B".to_string()]);
1419        assert_eq!(rows, vec![vec!["1".to_string(), "2".to_string()]]);
1420    }
1421
1422    #[test]
1423    #[cfg(feature = "native")]
1424    fn pipes_without_delimiter_stay_paragraph() {
1425        // A paragraph containing pipes but no delimiter row must NOT become a
1426        // table — it stays plain markdown.
1427        let input = "Run `a | b | c` in the shell.\n";
1428        let doc = parse(input).doc;
1429        let native = crate::render_native::to_native_blocks(&doc);
1430        assert!(!native
1431            .iter()
1432            .any(|b| matches!(b, crate::render_native::NativeBlock::DataTable { .. })));
1433        assert!(native
1434            .iter()
1435            .any(|b| matches!(b, crate::render_native::NativeBlock::Markdown { .. })));
1436    }
1437
1438    #[test]
1439    fn leaf_directive_as_final_block_at_eof_emits() {
1440        // A leaf with no closer and no following sibling at EOF still emits.
1441        let input = "# Heading\n\n::metric[label=\"x\" value=\"42K\"]\n";
1442        let result = parse(input);
1443        assert!(
1444            result.doc.blocks.iter().any(|b| matches!(b, Block::Metric { .. })),
1445            "missing Metric at EOF; blocks: {:?}",
1446            result.doc.blocks
1447        );
1448    }
1449
1450    // ----------------------------------------------------------------
1451    // p3-copy-sweep: tolerant openers + orphan-closer consumption.
1452    // ----------------------------------------------------------------
1453
1454    #[test]
1455    fn opening_directive_tolerates_space_after_colons() {
1456        // `:: hero[title="X"]` (space between colons and name) opens a hero
1457        // block instead of leaking the directive line as body text.
1458        let input = ":: hero[title=\"About Us\" align=center]\n# Welcome\n::\n";
1459        let result = parse(input);
1460        assert_eq!(result.doc.blocks.len(), 1, "blocks: {:#?}", result.doc.blocks);
1461        match &result.doc.blocks[0] {
1462            Block::Hero { headline, .. } => {
1463                assert!(
1464                    headline.as_deref().unwrap_or_default().contains("Welcome"),
1465                    "headline: {headline:?}"
1466                );
1467            }
1468            other => panic!("Expected Hero block, got {other:?}"),
1469        }
1470        // Unit-level: the helper itself.
1471        let (depth, name, attrs) = opening_directive(":: hero[title=\"X\"]").unwrap();
1472        assert_eq!((depth, name.as_str(), attrs.as_str()), (2, "hero", "[title=\"X\"]"));
1473        assert_eq!(directive_name_start(":: hero", 2), 3);
1474        assert_eq!(directive_name_start("::hero", 2), 2);
1475
1476        // Inside a markdown fence the tolerant form stays literal.
1477        let fenced = "```\n:: hero\n```\n";
1478        let result = parse(fenced);
1479        assert!(
1480            !result.doc.blocks.iter().any(|b| matches!(b, Block::Hero { .. })),
1481            "fenced tolerant opener must not open a block: {:#?}",
1482            result.doc.blocks
1483        );
1484        match &result.doc.blocks[0] {
1485            Block::Markdown { content, .. } => assert!(content.contains(":: hero")),
1486            other => panic!("Expected Markdown block, got {other:?}"),
1487        }
1488    }
1489
1490    #[test]
1491    fn orphan_top_level_closer_consumed_p006() {
1492        // A stray top-level `::` (double-close / trailing closer) is consumed
1493        // with a P006 warning instead of rendering as a literal `::` paragraph.
1494        let input = "::callout[type=info]\nHi\n::\n::\n\nAfter text.\n";
1495        let result = parse(input);
1496        let p006: Vec<_> = result
1497            .diagnostics
1498            .iter()
1499            .filter(|d| d.code.as_deref() == Some("P006"))
1500            .collect();
1501        assert_eq!(p006.len(), 1, "diagnostics: {:?}", result.diagnostics);
1502        assert_eq!(p006[0].severity, Severity::Warning);
1503        for block in &result.doc.blocks {
1504            if let Block::Markdown { content, .. } = block {
1505                assert!(
1506                    !content.lines().any(|l| l.trim() == "::"),
1507                    "orphan closer leaked into markdown: {content:?}"
1508                );
1509                assert!(content.contains("After text."), "copy survives");
1510            }
1511        }
1512        // Inside a markdown fence the `::` line survives verbatim (code sample).
1513        let fenced = "```\n::\n```\n";
1514        let result = parse(fenced);
1515        assert!(
1516            result.diagnostics.iter().all(|d| d.code.as_deref() != Some("P006")),
1517            "no P006 for a fenced closer: {:?}",
1518            result.diagnostics
1519        );
1520        match &result.doc.blocks[0] {
1521            Block::Markdown { content, .. } => assert!(content.contains("::")),
1522            other => panic!("Expected Markdown block, got {other:?}"),
1523        }
1524    }
1525}