Skip to main content

docutils_core/
parser.rs

1//! Block-level state machine: mirrors `docutils.parsers.rst.states`
2//! (`Body`, `BulletList`, `EnumeratedList`, `DefinitionList`, `FieldList`,
3//! `BlockQuote`, `LineBlock`, `TableParser`, `ExplicitMarkup`) with a
4//! line-oriented recursive-descent machine over dedented line runs.
5//!
6//! Entry points: [`parse`] (plain) and [`Parser::parse_with_base`] (enables
7//! `.. include::` relative to a base directory).
8
9use std::path::PathBuf;
10
11use crate::inline::parse_inline;
12use crate::nodes::*;
13
14/// One source line: text (tab-expanded, newline stripped), indent width,
15/// and 1-based source line number.
16#[derive(Debug, Clone)]
17struct Line {
18    text: String,
19    indent: usize,
20    num: usize,
21}
22
23fn expand_tabs(s: &str) -> String {
24    let mut out = String::with_capacity(s.len());
25    let mut col = 0;
26    for ch in s.chars() {
27        if ch == '\t' {
28            let n = 8 - (col % 8);
29            for _ in 0..n {
30                out.push(' ');
31            }
32            col += n;
33        } else {
34            out.push(ch);
35            col += 1;
36        }
37    }
38    out
39}
40
41fn split_lines(text: &str) -> Vec<Line> {
42    text.lines()
43        .enumerate()
44        .map(|(i, raw)| {
45            let expanded = expand_tabs(raw);
46            let trimmed_end = expanded.trim_end().to_string();
47            let indent = trimmed_end.len() - trimmed_end.trim_start().len();
48            Line {
49                text: trimmed_end,
50                indent,
51                num: i + 1,
52            }
53        })
54        .collect()
55}
56
57/// Parse `text` into a [`Document`].
58pub fn parse(text: &str) -> Document {
59    Parser::new(text, None).run()
60}
61
62pub struct Parser {
63    lines: Vec<Line>,
64    pos: usize,
65    /// Adornment styles in order of appearance: (char, overline?).
66    adorns: Vec<(char, bool)>,
67    messages: Vec<SystemMessage>,
68    default_role: Option<String>,
69    title_override: Option<Vec<Inline>>,
70    sectnum: bool,
71    sectnum_depth: Option<u32>,
72    meta: Vec<(String, String)>,
73    base_dir: Option<PathBuf>,
74    /// `.. class::` pending wrapper for the next block.
75    pending_classes: Vec<String>,
76    /// Blocks queued ahead of the next parsed block: literal-block lead
77    /// paragraphs and messages that precede their construct (title
78    /// problems, unknown-directive info). Drained before every push.
79    pending_blocks: Vec<Block>,
80    /// Parser messages that follow their construct (the oracle appends
81    /// messages after the current node). Drained after every push.
82    msg_blocks: Vec<Block>,
83    /// Named hyperlink targets seen (duplicate detection).
84    target_names: std::collections::HashSet<String>,
85    /// Next free linkage number for message/problematic ids.
86    next_id: usize,
87}
88
89impl Parser {
90    pub fn new(text: &str, base_dir: Option<PathBuf>) -> Self {
91        Self {
92            lines: split_lines(text),
93            pos: 0,
94            adorns: Vec::new(),
95            messages: Vec::new(),
96            default_role: None,
97            title_override: None,
98            sectnum: false,
99            sectnum_depth: None,
100            meta: Vec::new(),
101            base_dir,
102            pending_classes: Vec::new(),
103            pending_blocks: Vec::new(),
104            msg_blocks: Vec::new(),
105            target_names: std::collections::HashSet::new(),
106            next_id: 1,
107        }
108    }
109
110    pub fn parse_with_base(text: &str, base_dir: PathBuf) -> Document {
111        Self::new(text, Some(base_dir)).run()
112    }
113
114    pub fn run(mut self) -> Document {
115        let children = self.parse_container(0, 0);
116        let mut doc = Document {
117            children,
118            title: None,
119            subtitle: None,
120            header: Vec::new(),
121            footer: Vec::new(),
122            next_id: self.next_id,
123            messages: std::mem::take(&mut self.messages),
124            meta: std::mem::take(&mut self.meta),
125        };
126        // `.. header::` / `.. footer::` topics were parked as leading
127        // children by the directive parser; hoist them into decoration.
128        hoist_decoration(&mut doc);
129        promote_doc_title(&mut doc);
130        // `.. title::` wins over a promoted section title.
131        if let Some(t) = self.title_override.take() {
132            doc.title = Some(t);
133        }
134        if self.sectnum {
135            apply_sectnum(&mut doc, self.sectnum_depth);
136        }
137        doc
138    }
139
140    // -- infrastructure -------------------------------------------------
141
142    /// Record an error and queue its in-tree node after the current block.
143    fn error(&mut self, text: impl Into<String>, line: Option<usize>) {
144        self.msg(3, text, line);
145    }
146
147    /// Record a warning and queue its in-tree node after the current block.
148    fn warn(&mut self, text: impl Into<String>, line: Option<usize>) {
149        self.msg(2, text, line);
150    }
151
152    /// Record an info and queue its in-tree node after the current block.
153    fn info(&mut self, text: impl Into<String>, line: Option<usize>) {
154        self.msg(1, text, line);
155    }
156
157    fn msg(&mut self, level: u8, text: impl Into<String>, line: Option<usize>) {
158        let text = text.into();
159        self.messages.push(SystemMessage {
160            level,
161            text: text.clone(),
162            line,
163            id: None,
164        });
165        self.msg_blocks.push(Block::SystemMessage(SystemMessage {
166            level,
167            text,
168            line,
169            id: None,
170        }));
171    }
172
173    /// Record a message with an in-tree node placed ahead of the next block
174    /// (title problems, unknown-directive info).
175    fn msg_before(&mut self, level: u8, text: impl Into<String>, line: Option<usize>) {
176        let text = text.into();
177        self.messages.push(SystemMessage {
178            level,
179            text: text.clone(),
180            line,
181            id: None,
182        });
183        self.pending_blocks
184            .push(Block::SystemMessage(SystemMessage {
185                level,
186                text,
187                line,
188                id: None,
189            }));
190    }
191
192    /// Record a message without a tree node (for callers that return the
193    /// node themselves).
194    fn record(&mut self, level: u8, text: impl Into<String>, line: Option<usize>) {
195        self.messages.push(SystemMessage {
196            level,
197            text: text.into(),
198            line,
199            id: None,
200        });
201    }
202
203    fn drain_pending(&mut self, out: &mut Vec<Block>) {
204        if !self.pending_blocks.is_empty() {
205            out.extend(std::mem::take(&mut self.pending_blocks));
206        }
207    }
208
209    fn drain_messages(&mut self, out: &mut Vec<Block>) {
210        if !self.msg_blocks.is_empty() {
211            out.extend(std::mem::take(&mut self.msg_blocks));
212        }
213    }
214
215    fn inline(&mut self, text: &str, line: usize) -> Vec<Inline> {
216        let (mut nodes, errs) = parse_inline(text);
217        // Pair problematics with messages for backlink ids: every
218        // non-INFO message links (INFOs never do, matching the oracle).
219        // Pairing is positional within the inline run.
220        let mut err_ids: Vec<Option<usize>> = Vec::with_capacity(errs.len());
221        for e in &errs {
222            if e.level != 1 {
223                let n = self.next_id;
224                self.next_id += 1;
225                err_ids.push(Some(n));
226            } else {
227                err_ids.push(None);
228            }
229        }
230        {
231            let mut pairing = err_ids.iter().filter_map(|id| *id);
232            fn link(nodes: &mut [Inline], pairing: &mut impl Iterator<Item = usize>) {
233                for n in nodes.iter_mut() {
234                    match n {
235                        Inline::Problematic(p) => {
236                            if p.id.is_none() {
237                                p.id = pairing.next();
238                            }
239                        }
240                        Inline::Emphasis(c) | Inline::Strong(c) | Inline::Cite(c) => {
241                            link(c, pairing);
242                        }
243                        Inline::Reference(_, c) => link(c, pairing),
244                        Inline::PendingRef(p) => link(&mut p.text, pairing),
245                        _ => {}
246                    }
247                }
248            }
249            link(&mut nodes, &mut pairing);
250        }
251        for (e, id) in errs.into_iter().zip(err_ids) {
252            self.messages.push(SystemMessage {
253                level: e.level,
254                text: e.message.clone(),
255                line: Some(line),
256                id,
257            });
258            self.msg_blocks.push(Block::SystemMessage(SystemMessage {
259                level: e.level,
260                text: e.message,
261                line: Some(line),
262                id,
263            }));
264        }
265        // `.. default-role::` applies to bare interpreted text (Cite nodes
266        // produced without an explicit role).
267        match &self.default_role {
268            None => nodes,
269            Some(role) => nodes
270                .into_iter()
271                .map(|n| match n {
272                    Inline::Cite(inner) => match role.as_str() {
273                        "emphasis" => Inline::Emphasis(inner),
274                        "strong" => Inline::Strong(inner),
275                        "literal" => Inline::Literal(inline_text(&inner)),
276                        "code" => Inline::Code(inline_text(&inner)),
277                        _ => Inline::Cite(inner),
278                    },
279                    other => other,
280                })
281                .collect(),
282        }
283    }
284
285    fn skip_blanks(&mut self) {
286        while self.pos < self.lines.len() && self.lines[self.pos].text.trim().is_empty() {
287            self.pos += 1;
288        }
289    }
290
291    fn peek(&mut self) -> Option<Line> {
292        self.skip_blanks();
293        self.lines.get(self.pos).cloned()
294    }
295
296    /// Parse blocks until dedent below `min_indent` or a section header at
297    /// or above `level`. `level` is the current section depth (0=document).
298    fn parse_container(&mut self, min_indent: usize, level: u8) -> Vec<Block> {
299        let mut out = Vec::new();
300        loop {
301            let line = match self.peek() {
302                None => break,
303                Some(l) => l,
304            };
305            if line.indent < min_indent {
306                break;
307            }
308            // Section header (only at the container's own indent).
309            if line.indent == min_indent {
310                let save = self.pos;
311                if let Some(h) = self.try_section_header(min_indent) {
312                    let (title_text, hlevel, tline) = h;
313                    if hlevel <= level {
314                        self.pos = save;
315                        break; // belongs to an ancestor
316                    }
317                    let hlevel = if hlevel > level + 1 {
318                        self.msg_before(
319                            3,
320                            format!(
321                                "title level inconsistent (jump to level {hlevel}); treated as {}",
322                                level + 1
323                            ),
324                            Some(tline),
325                        );
326                        level + 1
327                    } else {
328                        hlevel
329                    };
330                    let title = self.inline(&title_text, tline);
331                    // DocTitle/DocSubtitle promotion happens in run() as a
332                    // post-pass; here every header becomes a section.
333                    let children = self.parse_container(min_indent, hlevel);
334                    let ids = vec![slugify(&inline_text(&title))];
335                    self.drain_pending(&mut out);
336                    out.push(Block::Section(Section {
337                        title,
338                        level: hlevel,
339                        ids,
340                        children,
341                        classes: Vec::new(),
342                    }));
343                    self.drain_messages(&mut out);
344                    continue;
345                }
346            }
347            match self.parse_block(min_indent, level) {
348                Some(b) => {
349                    self.drain_pending(&mut out);
350                    if !self.pending_classes.is_empty() && !matches!(b, Block::SystemMessage(_)) {
351                        let classes = std::mem::take(&mut self.pending_classes);
352                        out.push(Block::Container(Container {
353                            classes,
354                            children: vec![b],
355                        }));
356                    } else {
357                        out.push(b);
358                    }
359                    self.drain_messages(&mut out);
360                }
361                None => break,
362            }
363        }
364        self.drain_pending(&mut out);
365        self.drain_messages(&mut out);
366        out
367    }
368
369    /// Try a section header at `pos` (after blanks skipped). Returns
370    /// (title_text, level, title_line). Consumes the header lines.
371    fn try_section_header(&mut self, min_indent: usize) -> Option<(String, u8, usize)> {
372        // Overline form: adorn / text / adorn.
373        if self.pos + 2 < self.lines.len()
374            && is_adornment(&self.lines[self.pos].text)
375            && !self.lines[self.pos + 1].text.trim().is_empty()
376            && is_adornment(&self.lines[self.pos + 2].text)
377            && self.lines[self.pos].indent == min_indent
378            && self.lines[self.pos + 1].indent == min_indent
379            && self.lines[self.pos + 2].indent == min_indent
380        {
381            let (a, b, c) = (
382                self.lines[self.pos].clone(),
383                self.lines[self.pos + 1].clone(),
384                self.lines[self.pos + 2].clone(),
385            );
386            if adorn_char(&a.text) != adorn_char(&c.text) {
387                // Oracle: error, lines consumed without a section.
388                self.pos += 3;
389                self.msg_before(3, "Title overline & underline mismatch.", Some(b.num));
390                return None;
391            }
392            if b.text.trim().len() > a.text.trim().len() {
393                if a.text.trim().len() < 4 {
394                    self.msg_before(
395                        1,
396                        "Possible incomplete section title.\nTreating the overline as ordinary text because it's so short.",
397                        Some(a.num),
398                    );
399                    return None;
400                }
401                self.msg_before(2, "Title overline too short.", Some(b.num));
402            }
403            let ch = adorn_char(&a.text);
404            let title = b.text.trim().to_string();
405            let tline = b.num;
406            self.pos += 3;
407            let level = self.adorn_level(ch, true);
408            return Some((title, level, tline));
409        }
410        // Underline form: text / adorn.
411        if self.pos + 1 < self.lines.len() {
412            let (a, b) = (
413                self.lines[self.pos].clone(),
414                self.lines[self.pos + 1].clone(),
415            );
416            let ok = !a.text.trim().is_empty()
417                && is_adornment(&b.text)
418                && a.indent == min_indent
419                && b.indent == min_indent
420                && !is_list_marker(a.text.trim_start());
421            if ok {
422                // Oracle rule (states.Body.underline): a short underline is
423                // still a title (warning), unless shorter than 4 chars, in
424                // which case both lines stay ordinary text (info).
425                if b.text.trim().len() < a.text.trim().len() {
426                    if b.text.trim().len() < 4 {
427                        self.msg_before(
428                            1,
429                            "Possible title underline, too short for the title.\nTreating it as ordinary text because it's so short.",
430                            Some(b.num),
431                        );
432                        return None;
433                    }
434                    self.msg_before(2, "Title underline too short.", Some(b.num));
435                }
436                let ch = adorn_char(&b.text);
437                let tline = a.num;
438                let title = a.text.trim().to_string();
439                self.pos += 2;
440                let level = self.adorn_level(ch, false);
441                return Some((title, level, tline));
442            }
443        }
444        None
445    }
446
447    fn adorn_level(&mut self, ch: char, over: bool) -> u8 {
448        if let Some(i) = self.adorns.iter().position(|&(c, o)| c == ch && o == over) {
449            return (i + 1) as u8;
450        }
451        self.adorns.push((ch, over));
452        self.adorns.len() as u8
453    }
454
455    // -- block dispatch -------------------------------------------------
456
457    /// Parse one block at `min_indent`. Returns None on EOF/dedent.
458    fn parse_block(&mut self, min_indent: usize, level: u8) -> Option<Block> {
459        let line = self.peek()?;
460        if line.indent < min_indent {
461            return None;
462        }
463        let text = line.text.clone();
464        let stripped: String = text.trim_start().to_string();
465
466        // Explicit markup.
467        if stripped.starts_with("..")
468            && (stripped.len() == 2 || stripped[2..].starts_with([' ', '\t']))
469        {
470            // `...` transition? `...` is not explicit markup; docutils treats
471            // `...` as... a transition needs 4+. `..` alone is a comment.
472            return Some(self.parse_explicit(min_indent, level));
473        }
474        // Transition (needs blanks around; container guarantees preceding
475        // blank or start; require following blank/EOF... be lenient).
476        if is_transition(&stripped) && line.indent == min_indent {
477            self.pos += 1;
478            return Some(Block::Transition);
479        }
480        // Grid table.
481        if is_grid_border(&stripped) {
482            return Some(self.parse_grid_table(line.num));
483        }
484        // Simple table.
485        if is_simple_header(&stripped) && self.pos + 1 < self.lines.len() {
486            return Some(self.parse_simple_table());
487        }
488        // Line block.
489        if stripped.starts_with('|') && (stripped.len() == 1 || stripped[1..].starts_with(' ')) {
490            return Some(self.parse_line_block(min_indent));
491        }
492        // Bullet list.
493        if let Some(marker) = bullet_marker(&stripped) {
494            let _ = marker;
495            return Some(self.parse_bullet_list(min_indent, level));
496        }
497        // Enumerated list.
498        if enum_marker(&stripped).is_some() {
499            return Some(self.parse_enum_list(min_indent, level));
500        }
501        // Doctest block.
502        if stripped.starts_with(">>> ") || stripped == ">>>" {
503            return Some(self.parse_doctest_block(min_indent));
504        }
505        // Field list.
506        if field_start(&stripped).is_some() {
507            return Some(self.parse_field_list(min_indent, level));
508        }
509        // Option list (`-f, --flag  description`).
510        if option_start(&stripped).is_some() {
511            return Some(self.parse_option_list(min_indent, level));
512        }
513        // Paragraph / definition list / literal block / blockquote.
514        if line.indent > min_indent {
515            return Some(self.parse_blockquote(min_indent, level, line.num));
516        }
517        Some(self.parse_para_or_deflist_or_literal(min_indent, level))
518    }
519
520    /// Gather paragraph lines: consecutive non-blank lines at `indent`
521    /// that do not start a new construct.
522    fn gather_para_lines(&mut self, indent: usize) -> Vec<Line> {
523        let mut lines = Vec::new();
524        while self.pos < self.lines.len() {
525            let l = &self.lines[self.pos];
526            if l.text.trim().is_empty() || l.indent != indent {
527                break;
528            }
529            let s = l.text.trim_start();
530            let is_first = lines.is_empty();
531            if !is_first
532                && (s.starts_with("..") && (s.len() == 2 || s[2..].starts_with([' ', '\t']))
533                    || s.starts_with('|') && (s.len() == 1 || s[1..].starts_with(' '))
534                    || s.starts_with(">>> ")
535                    || bullet_marker(s).is_some()
536                    || enum_marker(s).is_some()
537                    || field_start(s).is_some()
538                    || option_start(s).is_some()
539                    || is_grid_border(s)
540                    || is_transition(s))
541            {
542                break;
543            }
544            // A text+adornment pair starts a section, not a paragraph --
545            // unless the underline is too short to be a title (< 4 chars:
546            // the oracle treats both lines as ordinary text, INFO only).
547            if self.pos + 1 < self.lines.len() {
548                let n = &self.lines[self.pos + 1];
549                if !n.text.trim().is_empty()
550                    && is_adornment(&n.text)
551                    && n.indent == indent
552                    && n.text.trim().len() >= 4
553                {
554                    break;
555                }
556            }
557            lines.push(l.clone());
558            self.pos += 1;
559        }
560        lines
561    }
562
563    fn parse_para_or_deflist_or_literal(&mut self, _min_indent: usize, level: u8) -> Block {
564        let first = self.lines[self.pos].clone();
565        let indent = first.indent;
566        let para = self.gather_para_lines(indent);
567        if para.is_empty() {
568            // Failed section-header lookahead (e.g. too-short underline):
569            // keep the line as ordinary text instead of dropping it.
570            let l = self.lines[self.pos].clone();
571            self.pos += 1;
572            return Block::Paragraph(self.inline(l.text.trim(), l.num));
573        }
574        let last_text = para.last().unwrap().text.clone();
575        let ends_literal = last_text.trim_end().ends_with("::");
576        // Lookahead: next non-blank line.
577        let mut j = self.pos;
578        while j < self.lines.len() && self.lines[j].text.trim().is_empty() {
579            j += 1;
580        }
581        let next_indented = j < self.lines.len() && self.lines[j].indent > indent;
582
583        // Literal block after `::`. The marker loses one colon in all cases
584        // (oracle parity: `::` -> `:` even when no block follows).
585        let mut ptext: Vec<String> = para.iter().map(|l| l.text.clone()).collect();
586        if ends_literal {
587            strip_literal_marker(&mut ptext);
588        }
589        if ends_literal && next_indented {
590            let lit_indent = self.lines[j].indent;
591            let lit_lines = self.take_indented(lit_indent);
592            let dedented = dedent(&lit_lines, lit_indent);
593            let para_block = if ptext.iter().any(|l| !l.trim().is_empty()) {
594                let joined = ptext.join("\n");
595                Some(Block::Paragraph(self.inline(joined.trim(), para[0].num)))
596            } else {
597                None
598            };
599            let lit = Block::LiteralBlock(LiteralBlock {
600                lines: dedented,
601                language: None,
602            });
603            // Two blocks: stash the paragraph ahead of the literal.
604            if let Some(p) = para_block {
605                self.pending_blocks.push(p);
606            }
607            return lit;
608        }
609        if ends_literal && !next_indented {
610            // Oracle reports at the line where the block was expected.
611            let at = self.lines.get(j).map(|l| l.num).unwrap_or(para[0].num);
612            self.warn("Literal block expected; none found.", Some(at));
613        }
614        // Definition list: single-line term + indented definition.
615        if para.len() == 1 && next_indented && !ends_literal {
616            return self.parse_deflist_from_term(para.into_iter().next().unwrap(), level);
617        }
618        // An indented block directly after paragraph text (no blank line)
619        // is unexpected (oracle parity); the blockquote still parses next.
620        if next_indented && j == self.pos {
621            self.error("Unexpected indentation.", Some(self.lines[j].num));
622        }
623        // Plain paragraph (possibly multi-line, wrapped).
624        let joined = if ends_literal {
625            ptext.join("\n")
626        } else {
627            para.iter()
628                .map(|l| l.text.clone())
629                .collect::<Vec<_>>()
630                .join("\n")
631        };
632        Block::Paragraph(self.inline(joined.trim(), para[0].num))
633    }
634
635    fn parse_deflist_from_term(&mut self, term_line: Line, level: u8) -> Block {
636        // The oracle takes a single term line per item (a multi-line "term"
637        // is a paragraph); consecutive term+definition pairs form one list.
638        let term_indent = term_line.indent;
639        let mut items = Vec::new();
640        let mut pending: Option<Line> = Some(term_line);
641        loop {
642            let tl = match pending.take() {
643                Some(t) => t,
644                None => {
645                    // Peek the next pair without consuming on failure.
646                    self.skip_blanks();
647                    if self.pos >= self.lines.len() || self.lines[self.pos].indent != term_indent {
648                        break;
649                    }
650                    let save = self.pos;
651                    let cand = self.gather_para_lines(term_indent);
652                    let mut k = self.pos;
653                    while k < self.lines.len() && self.lines[k].text.trim().is_empty() {
654                        k += 1;
655                    }
656                    let ok = cand.len() == 1
657                        && k < self.lines.len()
658                        && self.lines[k].indent > term_indent
659                        && !cand[0].text.trim_end().ends_with("::")
660                        && !is_block_start(cand[0].text.trim_start());
661                    if ok {
662                        cand.into_iter().next().unwrap()
663                    } else {
664                        self.pos = save;
665                        break;
666                    }
667                }
668            };
669            let term = self.inline(tl.text.trim(), tl.num);
670            let mut j = self.pos;
671            while j < self.lines.len() && self.lines[j].text.trim().is_empty() {
672                j += 1;
673            }
674            if j >= self.lines.len() || self.lines[j].indent <= term_indent {
675                if items.is_empty() {
676                    // No definition: degenerate; emit term as paragraph.
677                    return Block::Paragraph(term);
678                }
679                // Trailing term without a definition: leave it for the
680                // outer container (rewind to the term line).
681                // NOTE: `tl` was already consumed; this only happens for
682                // peeked terms, whose start we can restore approximately
683                // by ending the list here and letting the container
684                // re-parse from... (handled by rewinding to `tl`).
685                self.pos = tl.num.saturating_sub(1).min(self.pos);
686                break;
687            }
688            let def_indent = self.lines[j].indent;
689            let def_lines = self.take_indented(def_indent);
690            let definition = self.parse_sub_lines(def_lines, level);
691            items.push(DefItem { term, definition });
692        }
693        Block::DefinitionList(items)
694    }
695
696    fn parse_blockquote(&mut self, min_indent: usize, level: u8, _ln: usize) -> Block {
697        let indent = self.lines[self.pos].indent;
698        debug_assert!(indent > min_indent);
699        let quote_lines = self.take_indented(indent);
700        let children = self.parse_sub_lines(quote_lines, level);
701        // Attribution: trailing `-- name` / `--- name` paragraph.
702        let mut children = children;
703        let mut attribution = None;
704        if let Some(Block::Paragraph(inlines)) = children.last() {
705            let t = inline_text(inlines);
706            let s = t.trim_start();
707            if (s.starts_with("-- ") || s.starts_with("--- ")) && s.len() > 3 {
708                let name = s.trim_start_matches('-').trim().to_string();
709                if let Some(Block::Paragraph(_)) = children.pop() {
710                    let (nodes, errs) = parse_inline(&name);
711                    for e in errs {
712                        self.messages.push(SystemMessage {
713                            level: e.level,
714                            text: e.message.clone(),
715                            line: None,
716                            id: None,
717                        });
718                        self.msg_blocks.push(Block::SystemMessage(SystemMessage {
719                            level: e.level,
720                            text: e.message,
721                            line: None,
722                            id: None,
723                        }));
724                    }
725                    attribution = Some(nodes);
726                }
727            }
728        }
729        Block::BlockQuote(BlockQuote {
730            children,
731            attribution,
732        })
733    }
734
735    /// Consume all lines with indent >= `indent` (plus trailing blanks).
736    fn take_indented(&mut self, indent: usize) -> Vec<Line> {
737        let mut out = Vec::new();
738        while self.pos < self.lines.len() {
739            let l = &self.lines[self.pos];
740            if l.text.trim().is_empty() {
741                out.push(l.clone());
742                self.pos += 1;
743                continue;
744            }
745            if l.indent < indent {
746                break;
747            }
748            out.push(l.clone());
749            self.pos += 1;
750        }
751        // Trim trailing blank lines (they belong to the parent).
752        while out
753            .last()
754            .map(|l| l.text.trim().is_empty())
755            .unwrap_or(false)
756        {
757            out.pop();
758            self.pos -= 1;
759        }
760        out
761    }
762
763    // -- lists ----------------------------------------------------------
764
765    fn parse_bullet_list(&mut self, min_indent: usize, level: u8) -> Block {
766        let bullet = bullet_marker(self.lines[self.pos].text.trim_start()).unwrap();
767        let mut items = Vec::new();
768        loop {
769            self.skip_blanks();
770            if self.pos >= self.lines.len() {
771                break;
772            }
773            let l = &self.lines[self.pos];
774            if l.indent != min_indent || bullet_marker(l.text.trim_start()) != Some(bullet) {
775                break;
776            }
777            // Content indent: first non-space after `X `.
778            let stripped = l.text.trim_start();
779            let after = stripped[1..].trim_start_matches(' ');
780            let content_indent = if after.is_empty() {
781                // Empty item: content on following lines or empty.
782                let mut j = self.pos + 1;
783                while j < self.lines.len() && self.lines[j].text.trim().is_empty() {
784                    j += 1;
785                }
786                if j < self.lines.len() && self.lines[j].indent > min_indent {
787                    self.lines[j].indent
788                } else {
789                    min_indent + 2
790                }
791            } else {
792                l.text.len() - l.text.trim_start().len() + (stripped.len() - after.len())
793            };
794            self.pos += 1;
795            // First-line rest belongs to the item: inject as a virtual line.
796            let mut item_lines: Vec<Line> = Vec::new();
797            if !after.is_empty() {
798                item_lines.push(Line {
799                    text: format!("{}{}", " ".repeat(content_indent), after),
800                    indent: content_indent,
801                    num: l.num,
802                });
803            }
804            // Following lines with indent >= content_indent.
805            while self.pos < self.lines.len() {
806                let n = &self.lines[self.pos];
807                if n.text.trim().is_empty() {
808                    // Blank: keep if followed by indent >= content_indent.
809                    let mut j = self.pos + 1;
810                    while j < self.lines.len() && self.lines[j].text.trim().is_empty() {
811                        j += 1;
812                    }
813                    if j < self.lines.len() && self.lines[j].indent >= content_indent {
814                        item_lines.push(n.clone());
815                        self.pos += 1;
816                        continue;
817                    }
818                    break;
819                }
820                if n.indent < content_indent {
821                    break;
822                }
823                item_lines.push(n.clone());
824                self.pos += 1;
825            }
826            items.push(self.parse_sub_lines(item_lines, level));
827        }
828        Block::BulletList(BulletList { bullet, items })
829    }
830
831    fn parse_enum_list(&mut self, min_indent: usize, level: u8) -> Block {
832        let first = enum_marker(self.lines[self.pos].text.trim_start()).unwrap();
833        let format = first.0;
834        if first.1 != 1 {
835            // Oracle: starting value other than ordinal-1 is an info.
836            let tok: String = self.lines[self.pos]
837                .text
838                .trim_start()
839                .chars()
840                .take_while(|c| !c.is_whitespace())
841                .collect();
842            let core = tok.trim_start_matches('(').trim_end_matches(['.', ')']);
843            self.info(
844                format!(
845                    "Enumerated list start value not ordinal-1: \"{core}\" (ordinal {})",
846                    first.1
847                ),
848                Some(self.lines[self.pos].num),
849            );
850        }
851        let mut items = Vec::new();
852        let mut expected = first.1;
853        loop {
854            self.skip_blanks();
855            if self.pos >= self.lines.len() {
856                break;
857            }
858            let l = &self.lines[self.pos];
859            if l.indent != min_indent {
860                break;
861            }
862            let m = match enum_marker(l.text.trim_start()) {
863                Some(m) => m,
864                None => break,
865            };
866            // A different explicit format starts a new list; auto `#`
867            // markers continue any list (oracle parity). A marker that
868            // also parses under the current format with the expected
869            // value continues too (`iv.` + `v.` in a roman list).
870            if m.0 != format && m.0 != EnumFormat::Auto {
871                let tok: String = l
872                    .text
873                    .trim_start()
874                    .chars()
875                    .take_while(|c| !c.is_whitespace())
876                    .collect();
877                match enum_value_in_format(enum_core(&tok), format) {
878                    Some(v) if v == expected => {}
879                    _ => break,
880                }
881            }
882            let stripped = l.text.trim_start();
883            let base = l.text.len() - stripped.len();
884            let token_len = stripped.find(char::is_whitespace).unwrap_or(stripped.len());
885            let after_ws = stripped[token_len..].trim_start_matches([' ', '\t']);
886            let content_indent = if after_ws.trim().is_empty() {
887                min_indent + 2
888            } else {
889                base + (stripped.len() - after_ws.len())
890            };
891            let after = after_ws;
892            let _ = expected;
893            expected += 1;
894            let _ = m;
895            self.pos += 1;
896            let mut item_lines: Vec<Line> = Vec::new();
897            if !after.trim().is_empty() {
898                item_lines.push(Line {
899                    text: format!("{}{}", " ".repeat(content_indent), after.trim_start()),
900                    indent: content_indent,
901                    num: l.num,
902                });
903            }
904            while self.pos < self.lines.len() {
905                let n = &self.lines[self.pos];
906                if n.text.trim().is_empty() {
907                    let mut j = self.pos + 1;
908                    while j < self.lines.len() && self.lines[j].text.trim().is_empty() {
909                        j += 1;
910                    }
911                    if j < self.lines.len() && self.lines[j].indent >= content_indent {
912                        item_lines.push(n.clone());
913                        self.pos += 1;
914                        continue;
915                    }
916                    break;
917                }
918                if n.indent < content_indent {
919                    break;
920                }
921                item_lines.push(n.clone());
922                self.pos += 1;
923            }
924            items.push(self.parse_sub_lines(item_lines, level));
925        }
926        Block::EnumList(EnumList {
927            format,
928            start: first.1,
929            items,
930        })
931    }
932
933    /// Parse a spliced run of lines with a fresh sub-parser sharing
934    /// adornment state (sections inside are downgraded to paragraphs by
935    /// using level 255... simpler: sections at any header unwind only to
936    /// sub-level 0, producing real sections — acceptable).
937    fn parse_sub_lines(&mut self, lines: Vec<Line>, level: u8) -> Vec<Block> {
938        if lines.iter().all(|l| l.text.trim().is_empty()) {
939            return Vec::new();
940        }
941        let min = lines
942            .iter()
943            .filter(|l| !l.text.trim().is_empty())
944            .map(|l| l.indent)
945            .min()
946            .unwrap_or(0);
947        // Dedent to the container indent (the oracle strips block
948        // indentation from content); relative structure is preserved.
949        let lines: Vec<Line> = lines
950            .into_iter()
951            .map(|mut l| {
952                if !l.text.trim().is_empty() && l.indent >= min {
953                    l.text = l.text.get(min..).unwrap_or(l.text.trim_start()).to_string();
954                    l.indent -= min;
955                }
956                l
957            })
958            .collect();
959        let saved_lines = std::mem::take(&mut self.lines);
960        let saved_pos = self.pos;
961        // Isolate message queues: nested containers must not drain
962        // messages queued by the outer context (e.g. list-start info).
963        let saved_pending = std::mem::take(&mut self.pending_blocks);
964        let saved_msgs = std::mem::take(&mut self.msg_blocks);
965        self.lines = lines;
966        self.pos = 0;
967        let mut blocks = self.parse_container(0, level);
968        // Leftovers (shouldn't happen: containers end-drain) stay nested.
969        let mut leftover_p = std::mem::take(&mut self.pending_blocks);
970        let mut leftover_m = std::mem::take(&mut self.msg_blocks);
971        if !leftover_p.is_empty() || !leftover_m.is_empty() {
972            leftover_p.append(&mut leftover_m);
973            leftover_p.append(&mut blocks);
974            blocks = leftover_p;
975        }
976        self.pending_blocks = saved_pending;
977        self.msg_blocks = saved_msgs;
978        self.lines = saved_lines;
979        self.pos = saved_pos;
980        blocks
981    }
982
983    fn parse_field_list(&mut self, min_indent: usize, level: u8) -> Block {
984        let mut fields = Vec::new();
985        loop {
986            self.skip_blanks();
987            if self.pos >= self.lines.len() {
988                break;
989            }
990            let l = &self.lines[self.pos];
991            if l.indent != min_indent {
992                break;
993            }
994            let (name, rest) = match field_start(l.text.trim_start()) {
995                Some(x) => x,
996                None => break,
997            };
998            let lnum = l.num;
999            self.pos += 1;
1000            // Body: same-line rest plus following indented lines. The rest
1001            // is the body's first paragraph (at the marker indent); following
1002            // lines dedent by their own minimum (oracle parity: indented
1003            // body paragraphs stay paragraphs, deeper ones quote).
1004            let has_rest = !rest.trim().is_empty();
1005            let mut body_lines: Vec<Line> = Vec::new();
1006            if has_rest {
1007                body_lines.push(Line {
1008                    text: format!("{}{}", " ".repeat(l.indent), rest.trim_start()),
1009                    indent: l.indent,
1010                    num: lnum,
1011                });
1012            }
1013            loop {
1014                if self.pos >= self.lines.len() {
1015                    break;
1016                }
1017                let n = &self.lines[self.pos];
1018                if n.text.trim().is_empty() {
1019                    let mut j = self.pos + 1;
1020                    while j < self.lines.len() && self.lines[j].text.trim().is_empty() {
1021                        j += 1;
1022                    }
1023                    if j < self.lines.len() && self.lines[j].indent > min_indent {
1024                        body_lines.push(n.clone());
1025                        self.pos += 1;
1026                        continue;
1027                    }
1028                    break;
1029                }
1030                if n.indent <= min_indent {
1031                    break;
1032                }
1033                body_lines.push(n.clone());
1034                self.pos += 1;
1035            }
1036            // Dedent the collected (non-rest) lines by their own minimum so
1037            // the sub-parse sees body-relative indents.
1038            let skip = usize::from(has_rest);
1039            let base = body_lines[skip..]
1040                .iter()
1041                .filter(|l| !l.text.trim().is_empty())
1042                .map(|l| l.indent)
1043                .min();
1044            if let Some(base) = base {
1045                for l in body_lines.iter_mut().skip(skip) {
1046                    if l.text.trim().is_empty() {
1047                        continue;
1048                    }
1049                    let cut = base.min(l.indent).min(leading_spaces(&l.text));
1050                    l.text = l.text[cut..].to_string();
1051                    l.indent -= cut;
1052                }
1053            }
1054            let name_inlines = self.inline(&name, lnum);
1055            let body = if body_lines.iter().all(|l| l.text.trim().is_empty()) {
1056                Vec::new()
1057            } else {
1058                self.parse_sub_lines(body_lines, level)
1059            };
1060            fields.push(Field {
1061                name: name_inlines,
1062                body,
1063            });
1064        }
1065        Block::FieldList(fields)
1066    }
1067    fn parse_option_list(&mut self, min_indent: usize, level: u8) -> Block {
1068        let mut groups = Vec::new();
1069        loop {
1070            self.skip_blanks();
1071            if self.pos >= self.lines.len() {
1072                break;
1073            }
1074            let l = &self.lines[self.pos];
1075            if l.indent != min_indent {
1076                break;
1077            }
1078            let (opts, rest) = match option_start(l.text.trim_start()) {
1079                Some(x) => x,
1080                None => break,
1081            };
1082            let lnum = l.num;
1083            self.pos += 1;
1084            // Body: same-line rest plus following indented lines.
1085            let mut body_lines: Vec<Line> = Vec::new();
1086            if !rest.trim().is_empty() {
1087                body_lines.push(Line {
1088                    text: format!("{}{}", " ".repeat(l.indent + 1), rest.trim_start()),
1089                    indent: l.indent + 1,
1090                    num: lnum,
1091                });
1092            }
1093            loop {
1094                if self.pos >= self.lines.len() {
1095                    break;
1096                }
1097                let n = &self.lines[self.pos];
1098                if n.text.trim().is_empty() {
1099                    let mut j = self.pos + 1;
1100                    while j < self.lines.len() && self.lines[j].text.trim().is_empty() {
1101                        j += 1;
1102                    }
1103                    if j < self.lines.len() && self.lines[j].indent > min_indent {
1104                        body_lines.push(n.clone());
1105                        self.pos += 1;
1106                        continue;
1107                    }
1108                    break;
1109                }
1110                if n.indent <= min_indent {
1111                    break;
1112                }
1113                body_lines.push(n.clone());
1114                self.pos += 1;
1115            }
1116            let body = if body_lines.iter().all(|l| l.text.trim().is_empty()) {
1117                Vec::new()
1118            } else {
1119                self.parse_sub_lines(body_lines, level)
1120            };
1121            groups.push(OptGroup { opts, body });
1122        }
1123        Block::OptionList(groups)
1124    }
1125
1126    fn parse_line_block(&mut self, min_indent: usize) -> Block {
1127        // Collect (content-indent, text) rows; a blank line or non-`|`
1128        // line ends the block.
1129        let mut raw: Vec<(usize, String, usize)> = Vec::new();
1130        loop {
1131            if self.pos >= self.lines.len() || self.lines[self.pos].text.trim().is_empty() {
1132                break;
1133            }
1134            let l = self.lines[self.pos].clone();
1135            if l.indent != min_indent {
1136                break;
1137            }
1138            let s = l.text.trim_start().to_string();
1139            if !(s.starts_with('|') && (s.len() == 1 || s[1..].starts_with(' '))) {
1140                break;
1141            }
1142            let after = &s[1..];
1143            let cindent = after.len() - after.trim_start_matches(' ').len();
1144            raw.push((cindent, after.trim_start().to_string(), l.num));
1145            self.pos += 1;
1146        }
1147        if raw.is_empty() {
1148            return Block::LineBlock(Vec::new());
1149        }
1150        let base = raw[0].0;
1151        let (mut rows, mut i) = self.build_line_rows(&raw, 0, base);
1152        // Shallower-than-first rows (unseen shape): keep as flat lines
1153        // rather than dropping content.
1154        while i < raw.len() {
1155            let (_, text, lnum) = &raw[i];
1156            rows.push(LineRow::Line(self.inline(text, *lnum)));
1157            i += 1;
1158        }
1159        Block::LineBlock(rows)
1160    }
1161
1162    /// Group line-block rows: runs deeper than `base` nest recursively;
1163    /// a row shallower than `base` ends this level.
1164    fn build_line_rows(
1165        &mut self,
1166        raw: &[(usize, String, usize)],
1167        start: usize,
1168        base: usize,
1169    ) -> (Vec<LineRow>, usize) {
1170        let mut rows = Vec::new();
1171        let mut i = start;
1172        while i < raw.len() {
1173            let (ci, text, lnum) = &raw[i];
1174            if *ci < base {
1175                break;
1176            }
1177            if *ci == base {
1178                rows.push(LineRow::Line(self.inline(text, *lnum)));
1179                i += 1;
1180            } else {
1181                let (nested, next) = self.build_line_rows(raw, i, *ci);
1182                rows.push(LineRow::Nested(nested));
1183                i = next;
1184            }
1185        }
1186        (rows, i)
1187    }
1188
1189    fn parse_doctest_block(&mut self, min_indent: usize) -> Block {
1190        let mut out = Vec::new();
1191        while self.pos < self.lines.len() {
1192            let l = &self.lines[self.pos];
1193            if l.text.trim().is_empty() || l.indent < min_indent {
1194                break;
1195            }
1196            out.push(l.text.clone());
1197            self.pos += 1;
1198        }
1199        // Rendered as <pre> like LiteralBlock (node classes differ upstream).
1200        Block::LiteralBlock(LiteralBlock {
1201            lines: dedent(
1202                &out.iter()
1203                    .map(|t| Line {
1204                        text: t.clone(),
1205                        indent: min_indent,
1206                        num: 0,
1207                    })
1208                    .collect::<Vec<_>>(),
1209                min_indent,
1210            ),
1211            language: None,
1212        })
1213    }
1214
1215    // -- tables ----------------------------------------------------------
1216
1217    fn parse_grid_table(&mut self, _ln: usize) -> Block {
1218        // Collect border + content lines.
1219        let mut raw: Vec<String> = Vec::new();
1220        while self.pos < self.lines.len() {
1221            let t = self.lines[self.pos].text.trim().to_string();
1222            if t.is_empty() {
1223                break;
1224            }
1225            if !(t.starts_with('+') || t.starts_with('|')) {
1226                break;
1227            }
1228            raw.push(t);
1229            self.pos += 1;
1230        }
1231        build_grid_table(raw, &mut self.messages, &mut self.pending_blocks)
1232    }
1233
1234    fn parse_simple_table(&mut self) -> Block {
1235        let header = self.lines[self.pos].text.trim().to_string();
1236        let cols = simple_columns(&header);
1237        if cols.is_empty() {
1238            let l = self.lines[self.pos].clone();
1239            self.pos += 1;
1240            return Block::Paragraph(self.inline(l.text.trim(), l.num));
1241        }
1242        let lnum = self.lines[self.pos].num;
1243        self.pos += 1;
1244        // Rows before the mid border are the head only when a mid border
1245        // exists; otherwise everything is body (oracle parity).
1246        let mut pre: Vec<Vec<String>> = Vec::new();
1247        let mut post: Vec<Vec<String>> = Vec::new();
1248        let mut saw_mid = false;
1249        let mut ended_clean = false;
1250        while self.pos < self.lines.len() {
1251            let t = self.lines[self.pos].text.trim_end().to_string();
1252            if t.trim().is_empty() {
1253                self.pos += 1;
1254                ended_clean = true;
1255                break;
1256            }
1257            if is_simple_header(&t) {
1258                if saw_mid {
1259                    // Bottom border ends the table cleanly.
1260                    self.pos += 1;
1261                    ended_clean = true;
1262                    break;
1263                }
1264                saw_mid = true;
1265                self.pos += 1;
1266                continue;
1267            }
1268            if !simple_row_fits(&t, &cols) {
1269                // Not a table row: end the table, leave the line.
1270                ended_clean = true;
1271                break;
1272            }
1273            let cells = slice_simple_row(&t, &cols);
1274            if saw_mid {
1275                post.push(cells);
1276            } else {
1277                pre.push(cells);
1278            }
1279            self.pos += 1;
1280        }
1281        if !ended_clean && self.pos >= self.lines.len() {
1282            // EOF right after a data row: malformed (oracle parity).
1283            self.error(
1284                "Malformed table.\nNo bottom table border found or no blank line after table.",
1285                Some(lnum),
1286            );
1287            self.warn("Blank line required after table.", Some(lnum));
1288            return Block::Comment(String::new());
1289        }
1290        let (head_rows, body_rows) = if saw_mid {
1291            (pre, post)
1292        } else {
1293            (Vec::new(), pre)
1294        };
1295        build_simple_table(cols.len(), head_rows, body_rows, &mut self.messages)
1296    }
1297
1298    // -- explicit markup --------------------------------------------------
1299
1300    fn parse_explicit(&mut self, _min_indent: usize, level: u8) -> Block {
1301        let line = self.lines[self.pos].clone();
1302        let indent = line.indent;
1303        let after = line.text[indent + 2..].trim_start().to_string();
1304        let lnum = line.num;
1305        self.pos += 1;
1306
1307        // `..` alone: comment; slurp indented continuation.
1308        if after.is_empty() {
1309            let extra = self.take_indented(indent + 1);
1310            let mut text = String::new();
1311            for l in extra {
1312                text.push_str(l.text.trim());
1313                text.push('\n');
1314            }
1315            return Block::Comment(text);
1316        }
1317        // Anonymous target `.. __: uri`.
1318        if after.starts_with("__:") || after == "__" {
1319            let uri = after["__".len()..]
1320                .trim_start_matches(':')
1321                .trim()
1322                .to_string();
1323            return Block::HyperlinkTarget(HyperlinkTarget {
1324                name: String::new(),
1325                uri,
1326                anonymous: true,
1327            });
1328        }
1329        // Named target `.. _name: uri` / `.. _`quoted`: uri`.
1330        if let Some(rest) = after.strip_prefix('_') {
1331            if let Some((name, uri)) = split_target(rest) {
1332                let norm = norm_name(&name);
1333                if !self.target_names.insert(norm.clone()) {
1334                    self.warn(
1335                        format!("Duplicate explicit target name: \"{norm}\"."),
1336                        Some(lnum),
1337                    );
1338                }
1339                return Block::HyperlinkTarget(HyperlinkTarget {
1340                    name: norm,
1341                    uri: uri.trim().to_string(),
1342                    anonymous: false,
1343                });
1344            }
1345            // `.. _comment` without colon: a comment.
1346            let extra = self.take_indented(indent + 1);
1347            let mut text = after;
1348            for l in extra {
1349                text.push('\n');
1350                text.push_str(&l.text);
1351            }
1352            return Block::Comment(text);
1353        }
1354        // Footnote / citation `.. [label] ...`.
1355        if let Some(b) = self.parse_bracket_def(&after, indent, lnum, level) {
1356            return b;
1357        }
1358        // Substitution `.. |name| directive:: ...`.
1359        if let Some(b) = self.parse_substitution_def(&after, indent, lnum, level) {
1360            return b;
1361        }
1362        // Directive `.. name:: args`.
1363        if let Some((dname, args)) = split_directive(&after) {
1364            let (children, node) = self.parse_directive(&dname, &args, indent, lnum, level, false);
1365            if let Some(n) = node {
1366                return n;
1367            }
1368            // Directives yielding blocks without a single node (`.. include::`
1369            // splices file content here); the rest only touch messages/meta.
1370            self.pending_blocks.extend(children);
1371            return Block::Comment(String::new());
1372        }
1373        // Plain comment: first line + indented continuation.
1374        let extra = self.take_indented(indent + 1);
1375        let mut text = after;
1376        for l in extra {
1377            if !l.text.trim().is_empty() {
1378                text.push('\n');
1379                text.push_str(l.text.trim());
1380            }
1381        }
1382        Block::Comment(text)
1383    }
1384
1385    /// Footnote / citation definition `.. [label] body`. None when `after`
1386    /// is not bracket-led (caller falls through to directives/comments).
1387    fn parse_bracket_def(
1388        &mut self,
1389        after: &str,
1390        indent: usize,
1391        lnum: usize,
1392        level: u8,
1393    ) -> Option<Block> {
1394        let bracket = after.strip_prefix('[')?;
1395        let end = bracket.find(']')?;
1396        let label = bracket[..end].trim().to_string();
1397        let rest = bracket[end + 1..].trim().to_string();
1398        let body_indent = indent + 3; // `.. ` width
1399        let mut body_lines: Vec<Line> = Vec::new();
1400        if !rest.is_empty() {
1401            body_lines.push(Line {
1402                text: format!("{}{}", " ".repeat(body_indent), rest),
1403                indent: body_indent,
1404                num: lnum,
1405            });
1406        }
1407        let extra = self.take_indented(indent + 1);
1408        body_lines.extend(extra);
1409        let children = if body_lines.iter().all(|l| l.text.trim().is_empty()) {
1410            Vec::new()
1411        } else {
1412            self.parse_sub_lines(body_lines, level)
1413        };
1414        if label == "*" || label == "#" || label.starts_with('#') || is_digits_str(&label) {
1415            Some(Block::Footnote(Footnote {
1416                name: label.to_lowercase(),
1417                auto_numbered: label == "#" || label.starts_with('#') || label == "*",
1418                symbol: label == "*",
1419                number: None,
1420                children,
1421            }))
1422        } else {
1423            Some(Block::Citation(Citation {
1424                name: label.to_lowercase(),
1425                label,
1426                children,
1427            }))
1428        }
1429    }
1430
1431    /// Substitution definition `.. |name| directive:: ...`. None when
1432    /// `after` is not pipe-led (caller falls through).
1433    fn parse_substitution_def(
1434        &mut self,
1435        after: &str,
1436        indent: usize,
1437        lnum: usize,
1438        level: u8,
1439    ) -> Option<Block> {
1440        let rest = after.strip_prefix('|')?;
1441        let end = rest.find('|')?;
1442        let name = rest[..end].trim().to_lowercase();
1443        let tail = rest[end + 1..].trim_start().to_string();
1444        // tail must be `directive:: args`.
1445        if let Some((dname, args)) = split_directive(&tail) {
1446            let (children, node) = self.parse_directive(&dname, &args, indent, lnum, level, true);
1447            // Directives like `image::` yield a node, not children.
1448            let replacement = match node {
1449                Some(n) => vec![n],
1450                None => children,
1451            };
1452            return Some(Block::SubstitutionDef(SubstitutionDef {
1453                name,
1454                replacement,
1455            }));
1456        }
1457        self.record(
1458            3,
1459            format!("malformed substitution definition |{name}|"),
1460            Some(lnum),
1461        );
1462        let _ = self.take_indented(indent + 1);
1463        Some(Block::SystemMessage(SystemMessage {
1464            level: 3,
1465            text: format!("malformed substitution definition |{name}|"),
1466            line: Some(lnum),
1467            id: None,
1468        }))
1469    }
1470
1471    /// Parse a directive after its `.. name:: args` line (already consumed).
1472    /// Returns (child_blocks, optional_single_node).
1473    fn parse_directive(
1474        &mut self,
1475        name: &str,
1476        args: &str,
1477        indent: usize,
1478        lnum: usize,
1479        level: u8,
1480        in_substitution: bool,
1481    ) -> (Vec<Block>, Option<Block>) {
1482        // Options `:opt: val`.
1483        let mut options: Vec<(String, String)> = Vec::new();
1484        loop {
1485            self.skip_blanks();
1486            if self.pos >= self.lines.len() {
1487                break;
1488            }
1489            let l = &self.lines[self.pos];
1490            if l.indent <= indent {
1491                break;
1492            }
1493            match field_start(l.text.trim_start()) {
1494                Some((k, v)) => {
1495                    options.push((k.to_lowercase(), v.trim().to_string()));
1496                    self.pos += 1;
1497                }
1498                None => break,
1499            }
1500        }
1501        // Body: lines indented deeper than the directive.
1502        self.skip_blanks();
1503        let body_indent = if self.pos < self.lines.len() && self.lines[self.pos].indent > indent {
1504            self.lines[self.pos].indent
1505        } else {
1506            indent + 1
1507        };
1508        let body_lines = self.take_indented(body_indent);
1509        let has_body = body_lines.iter().any(|l| !l.text.trim().is_empty());
1510        let opt = |k: &str| {
1511            options
1512                .iter()
1513                .find(|(ok, _)| ok == k)
1514                .map(|(_, v)| v.clone())
1515        };
1516        let _ = level;
1517
1518        // Admonitions.
1519        if let Some(kind) = AdmonKind::from_directive(name) {
1520            // The oracle stores no default title (the writer generates it
1521            // from the class). Only generic `.. admonition::` takes its
1522            // title from the argument (required); specific admonitions
1523            // treat a same-line argument as body text.
1524            let mut children = Vec::new();
1525            let title = if name == "admonition" {
1526                if args.trim().is_empty() {
1527                    self.error("admonition requires a title argument", Some(lnum));
1528                    None
1529                } else {
1530                    Some(self.inline(args.trim(), lnum))
1531                }
1532            } else {
1533                if !args.trim().is_empty() {
1534                    children.push(Block::Paragraph(self.inline(args.trim(), lnum)));
1535                }
1536                None
1537            };
1538            children.extend(self.parse_sub_lines(body_lines, 0));
1539            return (
1540                vec![],
1541                Some(Block::Admonition(Admonition {
1542                    kind,
1543                    title,
1544                    children,
1545                })),
1546            );
1547        }
1548        match name {
1549            "code" => {
1550                let ded = dedent(&body_lines, body_indent);
1551                let language = if args.trim().is_empty() {
1552                    None
1553                } else {
1554                    Some(args.trim().to_string())
1555                };
1556                (
1557                    vec![],
1558                    Some(Block::LiteralBlock(LiteralBlock {
1559                        lines: ded,
1560                        language,
1561                    })),
1562                )
1563            }
1564            "contents" => {
1565                let depth = opt("depth").and_then(|d| d.parse().ok());
1566                let title = if args.trim().is_empty() {
1567                    None
1568                } else {
1569                    Some(self.inline(args.trim(), lnum))
1570                };
1571                (vec![], Some(Block::PendingContents { depth, title }))
1572            }
1573            "topic" => {
1574                let title = self.inline(args.trim(), lnum);
1575                let children = self.parse_sub_lines(body_lines, 0);
1576                (
1577                    vec![],
1578                    Some(Block::Topic(Topic {
1579                        title,
1580                        children,
1581                        classes: vec!["topic".into()],
1582                    })),
1583                )
1584            }
1585            "sidebar" => {
1586                let title = self.inline(args.trim(), lnum);
1587                let children = self.parse_sub_lines(body_lines, 0);
1588                (
1589                    vec![],
1590                    Some(Block::Topic(Topic {
1591                        title,
1592                        children,
1593                        classes: vec!["sidebar".into()],
1594                    })),
1595                )
1596            }
1597            "rubric" => (vec![], Some(Block::Rubric(self.inline(args.trim(), lnum)))),
1598            "compound" => {
1599                let children = self.parse_sub_lines(body_lines, 0);
1600                (
1601                    vec![],
1602                    Some(Block::Container(Container {
1603                        classes: vec!["compound".into()],
1604                        children,
1605                    })),
1606                )
1607            }
1608            "container" => {
1609                let classes: Vec<String> = args.split_whitespace().map(|s| s.to_string()).collect();
1610                let children = self.parse_sub_lines(body_lines, 0);
1611                (
1612                    vec![],
1613                    Some(Block::Container(Container { classes, children })),
1614                )
1615            }
1616            "class" => {
1617                let classes: Vec<String> = args.split_whitespace().map(|s| s.to_string()).collect();
1618                self.pending_classes = classes;
1619                // Applies to the NEXT block, not a body.
1620                (vec![], None)
1621            }
1622            "image" => {
1623                let uri = args.trim().to_string();
1624                if uri.is_empty() {
1625                    self.error("image directive requires a URI argument", Some(lnum));
1626                }
1627                (
1628                    vec![],
1629                    Some(Block::Image(Image {
1630                        uri,
1631                        alt: opt("alt"),
1632                    })),
1633                )
1634            }
1635            "figure" => {
1636                let uri = args.trim().to_string();
1637                let caption = self.parse_sub_lines(body_lines, 0);
1638                (
1639                    vec![],
1640                    Some(Block::Figure(Figure {
1641                        uri,
1642                        alt: opt("alt"),
1643                        caption,
1644                    })),
1645                )
1646            }
1647            "raw" => {
1648                let text = dedent(&body_lines, body_indent).join("\n");
1649                (
1650                    vec![],
1651                    Some(Block::Raw(Raw {
1652                        format: args.trim().to_lowercase(),
1653                        text,
1654                    })),
1655                )
1656            }
1657            "include" => {
1658                let path = args.trim();
1659                let content = self.read_include(path, lnum);
1660                match content {
1661                    Some(text) => {
1662                        let sub = Self::new(&text, self.base_dir.clone());
1663                        // Share-nothing except messages/adorns? Fresh parse
1664                        // would restart section levels; acceptable for v1.
1665                        let mut sub = sub;
1666                        let blocks = sub.parse_container(0, 0);
1667                        self.messages.extend(sub.messages);
1668                        (blocks, None)
1669                    }
1670                    None => (vec![], None),
1671                }
1672            }
1673            "replace" => {
1674                if in_substitution {
1675                    let inlines = self.inline(args, lnum);
1676                    (vec![Block::Paragraph(inlines)], None)
1677                } else {
1678                    // Oracle: error whose literal child is the directive
1679                    // line (no separate paragraph).
1680                    self.msg_before(
1681                        3,
1682                        format!(
1683                            "Invalid context: the \"replace\" directive can only be used within a substitution definition.\n\n.. replace:: {args}"
1684                        ),
1685                        Some(lnum),
1686                    );
1687                    (vec![], None)
1688                }
1689            }
1690            "unicode" => {
1691                if in_substitution {
1692                    let text = parse_unicode_spec(args);
1693                    (vec![Block::Paragraph(vec![Inline::Text(text)])], None)
1694                } else {
1695                    self.msg_before(
1696                        3,
1697                        format!(
1698                            "Invalid context: the \"unicode\" directive can only be used within a substitution definition.\n\n.. unicode:: {args}"
1699                        ),
1700                        Some(lnum),
1701                    );
1702                    (vec![], None)
1703                }
1704            }
1705            "date" => {
1706                if in_substitution {
1707                    let text = format_date(args.trim());
1708                    (vec![Block::Paragraph(vec![Inline::Text(text)])], None)
1709                } else {
1710                    self.msg_before(
1711                        3,
1712                        format!(
1713                            "Invalid context: the \"date\" directive can only be used within a substitution definition.\n\n.. date:: {args}"
1714                        ),
1715                        Some(lnum),
1716                    );
1717                    (vec![], None)
1718                }
1719            }
1720            "header" | "footer" => {
1721                // The oracle renders the argument as content too.
1722                let mut children = Vec::new();
1723                if !args.trim().is_empty() {
1724                    children.push(Block::Paragraph(self.inline(args.trim(), lnum)));
1725                }
1726                children.extend(self.parse_sub_lines(body_lines, 0));
1727                (
1728                    vec![],
1729                    Some(Block::Topic(Topic {
1730                        title: vec![],
1731                        children,
1732                        classes: vec![name.to_string()],
1733                    })),
1734                )
1735            }
1736            "parsed-literal" => {
1737                let ded = dedent(&body_lines, body_indent);
1738                let mut rows = Vec::new();
1739                for l in ded {
1740                    rows.push(self.inline(&l, lnum));
1741                }
1742                (vec![], Some(Block::ParsedLiteral(rows)))
1743            }
1744            "line-block" => {
1745                // Body lines are literal line-block lines; no `|` strip
1746                // (the oracle treats bars here as text).
1747                let ded = dedent(&body_lines, body_indent);
1748                let mut rows = Vec::new();
1749                for l in ded {
1750                    rows.push(LineRow::Line(self.inline(l.trim(), lnum)));
1751                }
1752                (vec![], Some(Block::LineBlock(rows)))
1753            }
1754            "list-table" => {
1755                let node = self.build_list_table(args, body_lines, body_indent, &options, lnum);
1756                (vec![], node)
1757            }
1758            "table" => {
1759                let text = dedent(&body_lines, body_indent).join("\n");
1760                let mut sub = Self::new(&text, self.base_dir.clone());
1761                let inner = sub.parse_container(0, 0);
1762                self.messages.extend(sub.messages);
1763                let table = inner.into_iter().find_map(|b| match b {
1764                    Block::Table(t) => Some(t),
1765                    _ => None,
1766                });
1767                match table {
1768                    Some(mut t) => {
1769                        if !args.trim().is_empty() {
1770                            t.caption = self.inline(args.trim(), lnum);
1771                        }
1772                        (vec![], Some(Block::Table(t)))
1773                    }
1774                    None => {
1775                        self.error("table directive body contains no table", Some(lnum));
1776                        (vec![], None)
1777                    }
1778                }
1779            }
1780            "csv-table" => {
1781                let node = self.build_csv_table(args, body_lines, body_indent, &options, lnum);
1782                (vec![], node)
1783            }
1784            "target-notes" => {
1785                // Replaced with target footnotes by the transform, in
1786                // place (the oracle inserts at the directive position).
1787                (
1788                    vec![],
1789                    Some(Block::Topic(Topic {
1790                        title: vec![],
1791                        children: vec![],
1792                        classes: vec!["__target_notes__".into()],
1793                    })),
1794                )
1795            }
1796            "math" => {
1797                let ded = dedent(&body_lines, body_indent);
1798                (
1799                    vec![],
1800                    Some(Block::LiteralBlock(LiteralBlock {
1801                        lines: ded,
1802                        language: None,
1803                    })),
1804                )
1805            }
1806            "meta" => {
1807                for (k, v) in options {
1808                    // `:name: content` or http-equiv forms; store raw.
1809                    self.meta.push((k, v));
1810                }
1811                if has_body {
1812                    self.error("meta directive takes no body; body ignored", Some(lnum));
1813                }
1814                (vec![], None)
1815            }
1816            "title" => {
1817                self.title_override = Some(self.inline(args.trim(), lnum));
1818                (vec![], None)
1819            }
1820            "sectnum" => {
1821                self.sectnum = true;
1822                self.sectnum_depth = opt("depth").and_then(|d| d.parse().ok());
1823                (vec![], None)
1824            }
1825            "role" => {
1826                // Custom role registration: names are accepted; uses resolve
1827                // only for built-in roles (documented coverage boundary).
1828                (vec![], None)
1829            }
1830            "default-role" => {
1831                let r = args.trim().to_lowercase();
1832                match r.as_str() {
1833                    "emphasis" | "strong" | "literal" | "code" | "title-reference" => {
1834                        self.default_role = Some(r);
1835                    }
1836                    _ => self.error(format!("unknown role for default-role: {r}"), Some(lnum)),
1837                }
1838                (vec![], None)
1839            }
1840            _ => {
1841                self.msg_before(
1842                    1,
1843                    format!(
1844                        "No directive entry for \"{name}\" in module \"docutils.parsers.rst.languages.en\".\nTrying \"{name}\" as canonical directive name."
1845                    ),
1846                    Some(lnum),
1847                );
1848                let text = format!("Unknown directive type \"{name}\".\n\n.. {name}:: {args}");
1849                self.record(3, text.clone(), Some(lnum));
1850                (
1851                    vec![],
1852                    Some(Block::SystemMessage(SystemMessage {
1853                        level: 3,
1854                        text,
1855                        line: Some(lnum),
1856                        id: None,
1857                    })),
1858                )
1859            }
1860        }
1861    }
1862
1863    fn read_include(&mut self, path: &str, lnum: usize) -> Option<String> {
1864        let base = match &self.base_dir {
1865            Some(b) => b.clone(),
1866            None => {
1867                self.error(
1868                    "include directive needs a base directory (none given)",
1869                    Some(lnum),
1870                );
1871                return None;
1872            }
1873        };
1874        let full = base.join(path);
1875        match std::fs::read_to_string(&full) {
1876            Ok(s) => Some(s),
1877            Err(e) => {
1878                self.error(format!("include file not found: {path} ({e})"), Some(lnum));
1879                None
1880            }
1881        }
1882    }
1883
1884    fn build_list_table(
1885        &mut self,
1886        args: &str,
1887        body_lines: Vec<Line>,
1888        body_indent: usize,
1889        options: &[(String, String)],
1890        lnum: usize,
1891    ) -> Option<Block> {
1892        let text = dedent(&body_lines, body_indent).join("\n");
1893        let mut sub = Self::new(&text, self.base_dir.clone());
1894        let inner = sub.parse_container(0, 0);
1895        self.messages.extend(sub.messages);
1896        let rows_list = inner.into_iter().find_map(|b| match b {
1897            Block::BulletList(l) => Some(l.items),
1898            _ => None,
1899        });
1900        let rows_list = match rows_list {
1901            Some(r) => r,
1902            None => {
1903                self.error("list-table body must be a bullet list of rows", Some(lnum));
1904                return None;
1905            }
1906        };
1907        let header_rows: usize = options
1908            .iter()
1909            .find(|(k, _)| k == "header-rows")
1910            .and_then(|(_, v)| v.parse().ok())
1911            .unwrap_or(0);
1912        let mut head = Vec::new();
1913        let mut body = Vec::new();
1914        for (ri, row_blocks) in rows_list.into_iter().enumerate() {
1915            // Each row: a bullet list of cells, or plain blocks.
1916            let cells: Vec<TableCell> = match row_blocks.as_slice() {
1917                [Block::BulletList(cells)] => cells
1918                    .items
1919                    .iter()
1920                    .map(|c| TableCell {
1921                        blocks: c.clone(),
1922                        colspan: 1,
1923                    })
1924                    .collect(),
1925                other => other
1926                    .iter()
1927                    .map(|b| TableCell {
1928                        blocks: vec![b.clone()],
1929                        colspan: 1,
1930                    })
1931                    .collect(),
1932            };
1933            if ri < header_rows {
1934                head.push(cells);
1935            } else {
1936                body.push(cells);
1937            }
1938        }
1939        let widths = body.first().map(|r| vec![0; r.len()]).unwrap_or_default();
1940        let caption = if args.trim().is_empty() {
1941            vec![]
1942        } else {
1943            self.inline(args.trim(), lnum)
1944        };
1945        Some(Block::Table(Table {
1946            head,
1947            body,
1948            widths,
1949            caption,
1950        }))
1951    }
1952
1953    fn build_csv_table(
1954        &mut self,
1955        args: &str,
1956        body_lines: Vec<Line>,
1957        body_indent: usize,
1958        options: &[(String, String)],
1959        lnum: usize,
1960    ) -> Option<Block> {
1961        let header_opt = options
1962            .iter()
1963            .find(|(k, _)| k == "header")
1964            .map(|(_, v)| v.clone());
1965        let mut header_rows: usize = options
1966            .iter()
1967            .find(|(k, _)| k == "header-rows")
1968            .and_then(|(_, v)| v.parse().ok())
1969            .unwrap_or(0);
1970        // `:header:` implies one header row (oracle parity).
1971        if header_opt.is_some() {
1972            header_rows = header_rows.max(1);
1973        }
1974        let mut rows: Vec<Vec<String>> = Vec::new();
1975        if let Some(h) = header_opt {
1976            rows.push(parse_csv_line(&h));
1977        }
1978        for l in dedent(&body_lines, body_indent) {
1979            if l.trim().is_empty() {
1980                continue;
1981            }
1982            rows.push(parse_csv_line(&l));
1983        }
1984        if rows.is_empty() {
1985            self.error("csv-table has no rows", Some(lnum));
1986            return None;
1987        }
1988        let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
1989        let mut head = Vec::new();
1990        let mut body = Vec::new();
1991        for (ri, row) in rows.into_iter().enumerate() {
1992            let cells: Vec<TableCell> = row
1993                .into_iter()
1994                .map(|c| {
1995                    let (nodes, errs) = parse_inline(c.trim());
1996                    for e in errs {
1997                        self.messages.push(SystemMessage {
1998                            level: e.level,
1999                            text: e.message.clone(),
2000                            line: Some(lnum),
2001                            id: None,
2002                        });
2003                        self.msg_blocks.push(Block::SystemMessage(SystemMessage {
2004                            level: e.level,
2005                            text: e.message,
2006                            line: Some(lnum),
2007                            id: None,
2008                        }));
2009                    }
2010                    TableCell {
2011                        blocks: vec![Block::Paragraph(nodes)],
2012                        colspan: 1,
2013                    }
2014                })
2015                .collect();
2016            // Pad short rows.
2017            let mut cells = cells;
2018            while cells.len() < ncols {
2019                cells.push(TableCell {
2020                    blocks: vec![],
2021                    colspan: 1,
2022                });
2023            }
2024            if ri < header_rows {
2025                head.push(cells);
2026            } else {
2027                body.push(cells);
2028            }
2029        }
2030        let caption = if args.trim().is_empty() {
2031            vec![]
2032        } else {
2033            self.inline(args.trim(), lnum)
2034        };
2035        Some(Block::Table(Table {
2036            head,
2037            body,
2038            widths: vec![0; ncols],
2039            caption,
2040        }))
2041    }
2042}
2043
2044// -- section helpers ----------------------------------------------------
2045
2046/// Section adornment / transition punctuation: the oracle `line` pattern
2047/// (`nonalphanum7bit` repeated): any ASCII punctuation run.
2048fn is_nonalphanum7bit(c: char) -> bool {
2049    matches!(c, '!'..='/' | ':'..='@' | '['..='`' | '{'..='~')
2050}
2051
2052fn is_adornment(s: &str) -> bool {
2053    let t = s.trim();
2054    if t.len() < 2 {
2055        return false;
2056    }
2057    let mut chars = t.chars();
2058    let first = chars.next().unwrap();
2059    if !is_nonalphanum7bit(first) {
2060        return false;
2061    }
2062    chars.all(|c| c == first)
2063}
2064
2065fn adorn_char(s: &str) -> char {
2066    s.trim().chars().next().unwrap_or('=')
2067}
2068
2069fn is_transition(s: &str) -> bool {
2070    let t = s.trim();
2071    t.len() >= 4 && is_adornment(t)
2072}
2073
2074fn is_list_marker(s: &str) -> bool {
2075    bullet_marker(s).is_some() || enum_marker(s).is_some()
2076}
2077/// A line that starts its own block construct (never a definition term).
2078fn is_block_start(s: &str) -> bool {
2079    (s.starts_with("..") && (s.len() == 2 || s[2..].starts_with([' ', '\t'])))
2080        || (s.starts_with('|') && (s.len() == 1 || s[1..].starts_with(' ')))
2081        || s.starts_with(">>> ")
2082        || bullet_marker(s).is_some()
2083        || enum_marker(s).is_some()
2084        || field_start(s).is_some()
2085        || option_start(s).is_some()
2086        || is_grid_border(s)
2087        || is_transition(s)
2088        || is_simple_header(s)
2089}
2090
2091fn bullet_marker(s: &str) -> Option<char> {
2092    let mut chars = s.chars();
2093    let m = chars.next()?;
2094    if !"-*+".contains(m) {
2095        return None;
2096    }
2097    match chars.next() {
2098        Some(c) if c == ' ' || c == '\t' => Some(m),
2099        None => Some(m),
2100        _ => None,
2101    }
2102}
2103
2104/// Parse an enumerated-list marker. Returns (format, value).
2105fn enum_marker(s: &str) -> Option<(EnumFormat, u32)> {
2106    let token: String = s.chars().take_while(|c| !c.is_whitespace()).collect();
2107    if token.is_empty() {
2108        return None;
2109    }
2110    // Parenthesized `(1)` / `(a)` / `(iv)`.
2111    if token.starts_with('(') && token.ends_with(')') && token.len() > 2 {
2112        return enum_value(&token[1..token.len() - 1]);
2113    }
2114    // Suffixed `1.` / `a)` / `#.` ...
2115    let core = token.strip_suffix(['.', ')'])?;
2116    if core.is_empty() {
2117        return None;
2118    }
2119    enum_value(core)
2120}
2121
2122fn enum_value(core: &str) -> Option<(EnumFormat, u32)> {
2123    if core == "#" {
2124        return Some((EnumFormat::Auto, 1));
2125    }
2126    if core.chars().all(|c| c.is_ascii_digit()) {
2127        return core.parse::<u32>().ok().map(|v| (EnumFormat::Arabic, v));
2128    }
2129    if core.len() == 1 {
2130        let c = core.chars().next().unwrap();
2131        // `i`/`I` are roman numerals; every other single letter is alpha.
2132        if c == 'i' {
2133            return Some((EnumFormat::LowerRoman, 1));
2134        }
2135        if c == 'I' {
2136            return Some((EnumFormat::UpperRoman, 1));
2137        }
2138        if c.is_ascii_lowercase() {
2139            return Some((EnumFormat::LowerAlpha, (c as u32) - ('a' as u32) + 1));
2140        }
2141        if c.is_ascii_uppercase() {
2142            return Some((EnumFormat::UpperAlpha, (c as u32) - ('A' as u32) + 1));
2143        }
2144    }
2145    if let Some(v) = roman_value(&core.to_uppercase()) {
2146        let fmt = if core.chars().next().unwrap().is_lowercase() {
2147            EnumFormat::LowerRoman
2148        } else {
2149            EnumFormat::UpperRoman
2150        };
2151        return Some((fmt, v));
2152    }
2153    None
2154}
2155
2156/// Marker core without affixes (`1.` -> `1`, `(iv)` -> `iv`).
2157fn enum_core(token: &str) -> &str {
2158    let t = token.strip_prefix('(').unwrap_or(token);
2159    let t = t.strip_suffix(')').unwrap_or(t);
2160    t.strip_suffix('.').unwrap_or(t)
2161}
2162
2163/// Value of `core` under an established list `format` (for continuation
2164/// checks like `iv.` + `v.` in a roman list).
2165fn enum_value_in_format(core: &str, format: EnumFormat) -> Option<u32> {
2166    match format {
2167        EnumFormat::Arabic => {
2168            if core.chars().all(|c| c.is_ascii_digit()) {
2169                core.parse::<u32>().ok()
2170            } else {
2171                None
2172            }
2173        }
2174        EnumFormat::LowerAlpha => {
2175            if core.len() == 1 {
2176                let c = core.chars().next().unwrap();
2177                if c.is_ascii_lowercase() && c != 'i' {
2178                    Some((c as u32) - ('a' as u32) + 1)
2179                } else {
2180                    None
2181                }
2182            } else {
2183                None
2184            }
2185        }
2186        EnumFormat::UpperAlpha => {
2187            if core.len() == 1 {
2188                let c = core.chars().next().unwrap();
2189                if c.is_ascii_uppercase() && c != 'I' {
2190                    Some((c as u32) - ('A' as u32) + 1)
2191                } else {
2192                    None
2193                }
2194            } else {
2195                None
2196            }
2197        }
2198        EnumFormat::LowerRoman | EnumFormat::UpperRoman => roman_value(&core.to_uppercase()),
2199        EnumFormat::Auto => None,
2200    }
2201}
2202
2203fn roman_value(s: &str) -> Option<u32> {
2204    if s.is_empty() || !s.chars().all(|c| "IVXLCDM".contains(c)) {
2205        return None;
2206    }
2207    let val = |c: char| match c {
2208        'I' => 1,
2209        'V' => 5,
2210        'X' => 10,
2211        'L' => 50,
2212        'C' => 100,
2213        'D' => 500,
2214        'M' => 1000,
2215        _ => 0,
2216    };
2217    let chars: Vec<char> = s.chars().collect();
2218    let mut total = 0;
2219    let mut prev = 0;
2220    for &c in chars.iter().rev() {
2221        let v = val(c);
2222        if v < prev {
2223            total -= v;
2224        } else {
2225            total += v;
2226        }
2227        prev = v;
2228    }
2229    // Validate canonical form by round-tripping (rejects `IIII`, `VV`...).
2230    if total == 0 || total > 3999 {
2231        return None;
2232    }
2233    if roman_canonical(total) == s {
2234        Some(total)
2235    } else {
2236        None
2237    }
2238}
2239
2240fn roman_canonical(mut v: u32) -> String {
2241    let table = [
2242        (1000, "M"),
2243        (900, "CM"),
2244        (500, "D"),
2245        (400, "CD"),
2246        (100, "C"),
2247        (90, "XC"),
2248        (50, "L"),
2249        (40, "XL"),
2250        (10, "X"),
2251        (9, "IX"),
2252        (5, "V"),
2253        (4, "IV"),
2254        (1, "I"),
2255    ];
2256    let mut out = String::new();
2257    for (n, sym) in table {
2258        while v >= n {
2259            out.push_str(sym);
2260            v -= n;
2261        }
2262    }
2263    out
2264}
2265
2266/// `:name: rest` at line start. Returns (name, rest). Mirrors the oracle
2267/// `field_marker` pattern: the closing colon must be followed by whitespace
2268/// or end-of-line (so `:emphasis:`x`` is a paragraph, not a field).
2269fn field_start(s: &str) -> Option<(String, String)> {
2270    if !s.starts_with(':') || s[1..].starts_with([':', ' ']) {
2271        return None;
2272    }
2273    // Find a `:` followed by whitespace or EOL; names may contain `:`.
2274    let bytes = s.as_bytes();
2275    let mut i = 1;
2276    while i < bytes.len() {
2277        if bytes[i] == b':' {
2278            let after = &s[i + 1..];
2279            if after.is_empty() || after.starts_with([' ', '\t']) {
2280                let name = s[1..i].trim();
2281                if name.is_empty() {
2282                    return None;
2283                }
2284                return Some((name.to_string(), after.to_string()));
2285            }
2286        }
2287        i += 1;
2288    }
2289    None
2290}
2291/// Option group marker: `opt(, opt)*` followed by 2+ spaces or EOL.
2292/// Mirrors the oracle `option_marker` pattern loosely: short `-x`/`+x`,
2293/// long `--name`/`/name`, each with an optional `=ARG` or ` ARG` argument.
2294fn option_start(s: &str) -> Option<(Vec<OptName>, String)> {
2295    let mut rest = s;
2296    let mut opts = Vec::new();
2297    loop {
2298        let (opt, tail) = option_one(rest)?;
2299        opts.push(opt);
2300        // Another option after `, `, else the description separator.
2301        if let Some(comma) = tail.strip_prefix(", ") {
2302            rest = comma;
2303            continue;
2304        }
2305        if tail.is_empty() {
2306            return Some((opts, String::new()));
2307        }
2308        // Two or more spaces (or tab) before the description.
2309        let spaces = tail.chars().take_while(|c| *c == ' ' || *c == '\t').count();
2310        if spaces >= 2 || (spaces == 1 && tail[1..].trim_start().is_empty()) {
2311            return Some((opts, tail[spaces..].to_string()));
2312        }
2313        // Single space then EOL counts (`-f ` with nothing after).
2314        if tail.trim().is_empty() {
2315            return Some((opts, String::new()));
2316        }
2317        return None;
2318    }
2319}
2320
2321/// Parse one `-x`, `+x`, `--name`, or `/name` with optional argument.
2322fn option_one(s: &str) -> Option<(OptName, &str)> {
2323    let (prefix_len, long) = if s.starts_with("--") {
2324        (2, true)
2325    } else if s.starts_with('/') {
2326        (1, true)
2327    } else if s.starts_with('-') || s.starts_with('+') {
2328        (1, false)
2329    } else {
2330        return None;
2331    };
2332    let after_prefix = &s[prefix_len..];
2333    let name_len: usize = if long {
2334        after_prefix
2335            .chars()
2336            .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || *c == '.')
2337            .map(|c| c.len_utf8())
2338            .sum()
2339    } else {
2340        after_prefix
2341            .chars()
2342            .next()
2343            .filter(|c| c.is_alphanumeric())
2344            .map(|c| c.len_utf8())
2345            .unwrap_or(0)
2346    };
2347    if name_len == 0 {
2348        return None;
2349    }
2350    let name = format!("{}{}", &s[..prefix_len], &after_prefix[..name_len]);
2351    let mut tail = &s[prefix_len + name_len..];
2352    // Optional `=ARG` or ` ARG` argument.
2353    let mut arg = None;
2354    let mut sep = None;
2355    if let Some(eq) = tail.strip_prefix('=') {
2356        let n: usize = eq
2357            .chars()
2358            .take_while(|c| !c.is_whitespace() && *c != ',')
2359            .map(|c| c.len_utf8())
2360            .sum();
2361        if n == 0 {
2362            return None;
2363        }
2364        arg = Some(eq[..n].to_string());
2365        sep = Some('=');
2366        tail = &eq[n..];
2367    } else if let Some(sp) = tail.strip_prefix(' ') {
2368        // Space-separated argument only when followed by more spaces/EOL
2369        // (else the space starts the description).
2370        let n: usize = sp
2371            .chars()
2372            .take_while(|c| !c.is_whitespace() && *c != ',')
2373            .map(|c| c.len_utf8())
2374            .sum();
2375        let after_arg = &sp[n..];
2376        if n > 0 && (after_arg.is_empty() || after_arg.starts_with([' ', '\t'])) {
2377            arg = Some(sp[..n].to_string());
2378            sep = Some(' ');
2379            tail = after_arg;
2380        }
2381    }
2382    Some((OptName { name, arg, sep }, tail))
2383}
2384
2385// -- tables ---------------------------------------------------------------
2386
2387fn is_grid_border(s: &str) -> bool {
2388    let t = s.trim();
2389    t.len() >= 2 && t.starts_with('+') && t.chars().all(|c| "+-=".contains(c))
2390}
2391
2392fn build_grid_table(
2393    raw: Vec<String>,
2394    messages: &mut Vec<SystemMessage>,
2395    pending: &mut Vec<Block>,
2396) -> Block {
2397    if raw.len() < 3 {
2398        let text = "malformed grid table (fewer than 3 lines)".to_string();
2399        messages.push(SystemMessage {
2400            level: 3,
2401            text: text.clone(),
2402            line: None,
2403            id: None,
2404        });
2405        pending.push(Block::SystemMessage(SystemMessage {
2406            level: 3,
2407            text,
2408            line: None,
2409            id: None,
2410        }));
2411        return Block::Paragraph(vec![Inline::Text(raw.join("\n"))]);
2412    }
2413    // Column boundaries (char indices of `+`) from the first border.
2414    let first = &raw[0];
2415    let bounds: Vec<usize> = first
2416        .chars()
2417        .enumerate()
2418        .filter(|(_, c)| *c == '+')
2419        .map(|(i, _)| i)
2420        .collect();
2421    if bounds.len() < 2 {
2422        return Block::Paragraph(vec![Inline::Text(raw.join("\n"))]);
2423    }
2424    let ncols = bounds.len() - 1;
2425    let mut head: Vec<Vec<TableCell>> = Vec::new();
2426    let mut body: Vec<Vec<TableCell>> = Vec::new();
2427    let mut pending: Vec<Vec<TableCell>> = Vec::new();
2428    let mut current: Vec<Vec<String>> = vec![vec![String::new()]; ncols];
2429    let mut dirty = false;
2430    let mut head_sep_seen = false;
2431    for line in raw.iter().skip(1) {
2432        if is_grid_border(line) {
2433            if dirty {
2434                pending.push(mkrow(&current, messages));
2435                current = vec![vec![String::new()]; ncols];
2436                dirty = false;
2437            }
2438            // Rows completed before the first `=` border are the head.
2439            if line.contains('=') && !head_sep_seen {
2440                head_sep_seen = true;
2441                head.append(&mut pending);
2442            }
2443            continue;
2444        }
2445        // Content line: slice by boundaries.
2446        let chars: Vec<char> = line.chars().collect();
2447        for ci in 0..ncols {
2448            let (a, b) = (bounds[ci], bounds[ci + 1]);
2449            let slice: String = chars
2450                .get(a + 1..b.min(chars.len()))
2451                .unwrap_or(&[])
2452                .iter()
2453                .collect();
2454            current[ci].push(slice.trim_end().to_string());
2455        }
2456        dirty = true;
2457    }
2458    // Trailing row without a closing border still counts (lenient).
2459    if dirty {
2460        pending.push(mkrow(&current, messages));
2461    }
2462    body.append(&mut pending);
2463    let widths: Vec<u32> = bounds
2464        .windows(2)
2465        .map(|w| (w[1] - w[0]).saturating_sub(1) as u32)
2466        .collect();
2467    Block::Table(Table {
2468        head,
2469        body,
2470        widths,
2471        caption: Vec::new(),
2472    })
2473}
2474
2475fn mkrow(current: &[Vec<String>], messages: &mut Vec<SystemMessage>) -> Vec<TableCell> {
2476    current
2477        .iter()
2478        .map(|cell_lines| {
2479            let text = cell_lines.join("\n");
2480            if text.trim().is_empty() {
2481                return TableCell {
2482                    blocks: vec![],
2483                    colspan: 1,
2484                };
2485            }
2486            let (blocks, mut sub_messages) = Parser::new(text.trim(), None).parse_cell();
2487            messages.append(&mut sub_messages);
2488            TableCell { blocks, colspan: 1 }
2489        })
2490        .collect()
2491}
2492
2493fn is_simple_header(s: &str) -> bool {
2494    let t = s.trim();
2495    if t.len() < 3 || !t.chars().all(|c| c == '=' || c == ' ') {
2496        return false;
2497    }
2498    // At least two `=` groups separated by spaces.
2499    t.split(' ').filter(|w| !w.is_empty()).count() >= 2
2500        && t.split(' ')
2501            .filter(|w| !w.is_empty())
2502            .all(|w| w.chars().all(|c| c == '='))
2503}
2504
2505/// Column (start,end) char ranges from a `=== ===` line.
2506fn simple_columns(header: &str) -> Vec<(usize, usize)> {
2507    let bytes = header.as_bytes();
2508    let mut cols = Vec::new();
2509    let mut i = 0;
2510    while i < bytes.len() {
2511        if bytes[i] == b'=' {
2512            let start = i;
2513            while i < bytes.len() && bytes[i] == b'=' {
2514                i += 1;
2515            }
2516            cols.push((start, i));
2517        } else {
2518            i += 1;
2519        }
2520    }
2521    cols
2522}
2523/// A line fits a simple table when it has no text beyond the last column.
2524fn simple_row_fits(line: &str, cols: &[(usize, usize)]) -> bool {
2525    let last_end = cols.last().map(|(_, e)| *e).unwrap_or(0);
2526    line.chars().skip(last_end).all(|c| c.is_whitespace())
2527}
2528
2529fn slice_simple_row(line: &str, cols: &[(usize, usize)]) -> Vec<String> {
2530    cols.iter()
2531        .map(|&(a, b)| {
2532            line.get(a..b.min(line.len().max(a)))
2533                .unwrap_or("")
2534                .trim()
2535                .to_string()
2536        })
2537        .collect()
2538}
2539
2540fn build_simple_table(
2541    ncols: usize,
2542    head_rows: Vec<Vec<String>>,
2543    body_rows: Vec<Vec<String>>,
2544    messages: &mut Vec<SystemMessage>,
2545) -> Block {
2546    let mk = |rows: Vec<Vec<String>>, messages: &mut Vec<SystemMessage>| -> Vec<Vec<TableCell>> {
2547        rows.into_iter()
2548            .map(|r| {
2549                let mut cells: Vec<TableCell> = r
2550                    .into_iter()
2551                    .map(|c| {
2552                        if c.trim().is_empty() {
2553                            return TableCell {
2554                                blocks: vec![],
2555                                colspan: 1,
2556                            };
2557                        }
2558                        let (blocks, mut sub) = Parser::new(&c, None).parse_cell();
2559                        messages.append(&mut sub);
2560                        TableCell { blocks, colspan: 1 }
2561                    })
2562                    .collect();
2563                while cells.len() < ncols {
2564                    cells.push(TableCell {
2565                        blocks: vec![],
2566                        colspan: 1,
2567                    });
2568                }
2569                cells.truncate(ncols);
2570                cells
2571            })
2572            .collect()
2573    };
2574    Block::Table(Table {
2575        head: mk(head_rows, messages),
2576        body: mk(body_rows, messages),
2577        widths: vec![0; ncols],
2578        caption: Vec::new(),
2579    })
2580}
2581
2582// -- small helpers ----------------------------------------------------------
2583
2584/// Count leading ASCII spaces of a line's text.
2585fn leading_spaces(s: &str) -> usize {
2586    s.len() - s.trim_start_matches(' ').len()
2587}
2588fn dedent(lines: &[Line], indent: usize) -> Vec<String> {
2589    lines
2590        .iter()
2591        .map(|l| {
2592            if l.text.trim().is_empty() {
2593                String::new()
2594            } else {
2595                l.text
2596                    .get(indent..)
2597                    .unwrap_or(l.text.trim_start())
2598                    .to_string()
2599            }
2600        })
2601        .collect()
2602}
2603/// Strip one trailing colon from a `::` literal marker: a lone `::` line is
2604/// dropped, otherwise the last line loses one `:` (`::` -> `:`).
2605fn strip_literal_marker(ptext: &mut Vec<String>) {
2606    let Some(last) = ptext.last() else { return };
2607    if last.trim() == "::" {
2608        ptext.pop();
2609        return;
2610    }
2611    let t = last.trim_end();
2612    if t.ends_with("::") {
2613        let lead: String = last.chars().take_while(|c| c.is_whitespace()).collect();
2614        let stripped = t[..t.len() - 1].to_string();
2615        *ptext.last_mut().unwrap() = format!("{lead}{stripped}");
2616    }
2617}
2618
2619/// Split `name: uri` target rest. Handles backquoted names.
2620fn split_target(rest: &str) -> Option<(String, String)> {
2621    let rest = rest.trim_start();
2622    if let Some(q) = rest.strip_prefix('`') {
2623        let end = q.find('`')?;
2624        let name = q[..end].to_string();
2625        let tail = q[end + 1..].trim_start();
2626        let uri = tail.strip_prefix(':')?.trim().to_string();
2627        return Some((name, uri));
2628    }
2629    let end = rest.find(':')?;
2630    let name = rest[..end].trim().to_string();
2631    if name.is_empty() || name.contains(char::is_whitespace) && name.split_whitespace().count() > 6
2632    {
2633        return None;
2634    }
2635    let uri = rest[end + 1..].trim().to_string();
2636    Some((name, uri))
2637}
2638
2639/// Split `name:: args` directive heads.
2640fn split_directive(s: &str) -> Option<(String, String)> {
2641    let idx = s.find("::")?;
2642    let name = s[..idx].trim().to_lowercase();
2643    if name.is_empty() || name.contains(char::is_whitespace) {
2644        return None;
2645    }
2646    Some((name, s[idx + 2..].trim().to_string()))
2647}
2648
2649/// Lowercase + collapse whitespace (target/reference name matching).
2650pub fn norm_name(s: &str) -> String {
2651    s.split_whitespace()
2652        .collect::<Vec<_>>()
2653        .join(" ")
2654        .to_lowercase()
2655}
2656
2657fn is_digits_str(s: &str) -> bool {
2658    !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
2659}
2660
2661/// `.. |x| unicode:: U+0041 ...` -> decoded text (best effort).
2662fn parse_unicode_spec(args: &str) -> String {
2663    let mut out = String::new();
2664    for token in args.split_whitespace() {
2665        if token.eq_ignore_ascii_case(":trim:")
2666            || token.eq_ignore_ascii_case(":ltrim:")
2667            || token.eq_ignore_ascii_case(":rtrim:")
2668        {
2669            continue;
2670        }
2671        let hex = token
2672            .strip_prefix("U+")
2673            .or_else(|| token.strip_prefix("u+"))
2674            .unwrap_or(token);
2675        if let Ok(cp) = u32::from_str_radix(hex.trim_end_matches(','), 16)
2676            && let Some(ch) = char::from_u32(cp)
2677        {
2678            out.push(ch);
2679            continue;
2680        }
2681        out.push_str(token);
2682        out.push(' ');
2683    }
2684    out
2685}
2686
2687fn parse_csv_line(line: &str) -> Vec<String> {
2688    let mut fields = Vec::new();
2689    let mut cur = String::new();
2690    let mut chars = line.chars().peekable();
2691    let mut in_quotes = false;
2692    while let Some(c) = chars.next() {
2693        if in_quotes {
2694            if c == '"' {
2695                if chars.peek() == Some(&'"') {
2696                    cur.push('"');
2697                    chars.next();
2698                } else {
2699                    in_quotes = false;
2700                }
2701            } else {
2702                cur.push(c);
2703            }
2704        } else if c == '"' && cur.trim().is_empty() {
2705            cur.clear();
2706            in_quotes = true;
2707        } else if c == ',' {
2708            fields.push(std::mem::take(&mut cur));
2709        } else {
2710            cur.push(c);
2711        }
2712    }
2713    fields.push(cur);
2714    fields
2715}
2716
2717// -- dates ------------------------------------------------------------------
2718
2719fn today_ymd() -> (i32, u32, u32) {
2720    use std::time::{SystemTime, UNIX_EPOCH};
2721    let days = SystemTime::now()
2722        .duration_since(UNIX_EPOCH)
2723        .map(|d| (d.as_secs() / 86400) as i64)
2724        .unwrap_or(0);
2725    // Howard Hinnant's civil_from_days.
2726    let z = days + 719468;
2727    let era = z.div_euclid(146097);
2728    let doe = (z - era * 146097) as u64;
2729    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
2730    let y = yoe as i64 + era * 400;
2731    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2732    let mp = (5 * doy + 2) / 153;
2733    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
2734    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
2735    ((if m <= 2 { y + 1 } else { y }) as i32, m, d)
2736}
2737
2738fn format_date(fmt: &str) -> String {
2739    let (y, m, d) = today_ymd();
2740    const MONTHS: [&str; 12] = [
2741        "January",
2742        "February",
2743        "March",
2744        "April",
2745        "May",
2746        "June",
2747        "July",
2748        "August",
2749        "September",
2750        "October",
2751        "November",
2752        "December",
2753    ];
2754    let fmt = if fmt.is_empty() { "%Y-%m-%d" } else { fmt };
2755    let mut out = String::new();
2756    let mut chars = fmt.chars();
2757    while let Some(c) = chars.next() {
2758        if c != '%' {
2759            out.push(c);
2760            continue;
2761        }
2762        match chars.next() {
2763            Some('Y') => out.push_str(&format!("{y:04}")),
2764            Some('y') => out.push_str(&format!("{:02}", y % 100)),
2765            Some('m') => out.push_str(&format!("{m:02}")),
2766            Some('d') => out.push_str(&format!("{d:02}")),
2767            Some('B') => out.push_str(MONTHS[(m - 1) as usize]),
2768            Some('b') => out.push_str(&MONTHS[(m - 1) as usize][..3]),
2769            Some('%') => out.push('%'),
2770            Some(other) => {
2771                out.push('%');
2772                out.push(other);
2773            }
2774            None => out.push('%'),
2775        }
2776    }
2777    out
2778}
2779// -- section post-passes ------------------------------------------------------
2780
2781/// Move `.. header::` / `.. footer::` topics into the document decoration
2782/// slots (the oracle renders them around `<main>`). Topics nest inside the
2783/// title section, so hoist recursively in document order.
2784fn hoist_decoration(doc: &mut Document) {
2785    fn walk(blocks: &mut Vec<Block>, header: &mut Vec<Block>, footer: &mut Vec<Block>) {
2786        let mut rest = Vec::with_capacity(blocks.len());
2787        for b in blocks.drain(..) {
2788            match b {
2789                Block::Topic(mut t) if t.classes.iter().any(|c| c == "header") => {
2790                    header.append(&mut t.children);
2791                }
2792                Block::Topic(mut t) if t.classes.iter().any(|c| c == "footer") => {
2793                    footer.append(&mut t.children);
2794                }
2795                Block::Section(mut s) => {
2796                    walk(&mut s.children, header, footer);
2797                    rest.push(Block::Section(s));
2798                }
2799                Block::Container(mut c) => {
2800                    walk(&mut c.children, header, footer);
2801                    rest.push(Block::Container(c));
2802                }
2803                other => rest.push(other),
2804            }
2805        }
2806        *blocks = rest;
2807    }
2808    let mut header = Vec::new();
2809    let mut footer = Vec::new();
2810    walk(&mut doc.children, &mut header, &mut footer);
2811    doc.header = header;
2812    doc.footer = footer;
2813}
2814
2815/// DocTitle/DocSubtitle promotion (mirrors
2816/// `docutils.transforms.frontmatter.DocTitle`): a leading level-1 section
2817/// becomes the document title (its children splice into the document);
2818/// a second leading level-1 section becomes the subtitle.
2819fn promote_doc_title(doc: &mut Document) {
2820    let first_is_l1 = matches!(doc.children.first(), Some(Block::Section(s)) if s.level == 1);
2821    if !first_is_l1 {
2822        return;
2823    }
2824    let s = match doc.children.remove(0) {
2825        Block::Section(s) => s,
2826        _ => unreachable!("leading block checked above"),
2827    };
2828    let title_had_children = !s.children.is_empty();
2829    let mut rest = s.children;
2830    // The title owned level 1; its children shift up one level so
2831    // top-level sections render as h2, matching the oracle.
2832    renumber_sections(&mut rest, 1);
2833    rest.append(&mut doc.children);
2834    doc.children = rest;
2835    doc.title = Some(s.title);
2836    // A subtitle is a second level-1 section only when it directly
2837    // follows the title (the title section was empty).
2838    if !title_had_children {
2839        let sub_is_l1 = matches!(doc.children.first(), Some(Block::Section(x)) if x.level == 1);
2840        if sub_is_l1 {
2841            let s2 = match doc.children.remove(0) {
2842                Block::Section(s) => s,
2843                _ => unreachable!("leading block checked above"),
2844            };
2845            let mut rest2 = s2.children;
2846            renumber_sections(&mut rest2, 1);
2847            rest2.append(&mut doc.children);
2848            doc.children = rest2;
2849            doc.subtitle = Some(s2.title);
2850        }
2851    }
2852}
2853
2854/// Shift section levels under a promoted title/subtitle down by `by`.
2855fn renumber_sections(blocks: &mut [Block], by: u8) {
2856    for b in blocks {
2857        match b {
2858            Block::Section(s) => {
2859                s.level = s.level.saturating_sub(by).max(1);
2860                renumber_sections(&mut s.children, by);
2861            }
2862            Block::Topic(t) => renumber_sections(&mut t.children, by),
2863            Block::Container(c) => renumber_sections(&mut c.children, by),
2864            _ => {}
2865        }
2866    }
2867}
2868
2869/// `.. sectnum::`: prepend hierarchical numbers to section titles.
2870fn apply_sectnum(doc: &mut Document, depth: Option<u32>) {
2871    let mut counters: Vec<u32> = Vec::new();
2872    fn walk(blocks: &mut [Block], level: u32, counters: &mut Vec<u32>, depth: Option<u32>) {
2873        for b in blocks {
2874            if let Block::Section(s) = b {
2875                while counters.len() < level as usize {
2876                    counters.push(0);
2877                }
2878                counters.truncate(level as usize);
2879                if let Some(last) = counters.last_mut() {
2880                    *last += 1;
2881                }
2882                if depth.map(|d| level <= d).unwrap_or(true) {
2883                    let num = counters
2884                        .iter()
2885                        .map(|n| n.to_string())
2886                        .collect::<Vec<_>>()
2887                        .join(".");
2888                    // Oracle separator: number + three non-breaking spaces.
2889                    s.title
2890                        .insert(0, Inline::Text(format!("{num}\u{a0}\u{a0}\u{a0}")));
2891                }
2892                walk(&mut s.children, level + 1, counters, depth);
2893            } else {
2894                // Recurse into containers for sections inside topics etc.
2895                match b {
2896                    Block::Topic(t) => walk(&mut t.children, level, counters, depth),
2897                    Block::Container(c) => walk(&mut c.children, level, counters, depth),
2898                    _ => {}
2899                }
2900            }
2901        }
2902    }
2903    walk(&mut doc.children, 1, &mut counters, depth);
2904}
2905
2906// -- cell sub-parsing -----------------------------------------------------------
2907
2908impl Parser {
2909    /// Parse short cell/table text as blocks (no sections expected).
2910    fn parse_cell(mut self) -> (Vec<Block>, Vec<SystemMessage>) {
2911        self.adorns = Vec::new();
2912        let blocks = self.parse_container(0, 0);
2913        let messages = std::mem::take(&mut self.messages);
2914        (blocks, messages)
2915    }
2916}
2917
2918#[cfg(test)]
2919mod tests {
2920    use super::*;
2921
2922    fn para_text(doc: &Document) -> String {
2923        doc.astext()
2924    }
2925
2926    #[test]
2927    fn title_and_sections() {
2928        let doc = parse("Title\n=====\n\nSection\n-------\n\nBody text.\n");
2929        // Leading h1 promotes to document title; the h2 stays a section.
2930        assert_eq!(
2931            doc.title.as_ref().map(|t| inline_text(t)),
2932            Some("Title".into())
2933        );
2934        assert!(matches!(doc.children[0], Block::Section(_)));
2935        assert!(para_text(&doc).contains("Body text."));
2936    }
2937
2938    #[test]
2939    fn bullet_list() {
2940        let doc = parse("- a\n- b\n\nAfter.\n");
2941        assert!(matches!(doc.children[0], Block::BulletList(_)));
2942    }
2943
2944    #[test]
2945    fn literal_block() {
2946        let doc = parse("Intro::\n\n    code here\n");
2947        assert!(matches!(doc.children[1], Block::LiteralBlock(_)));
2948    }
2949
2950    #[test]
2951    fn grid_table() {
2952        let doc = parse("+---+---+\n| a | b |\n+===+===+\n| c | d |\n+---+---+\n");
2953        assert!(matches!(doc.children[0], Block::Table(_)));
2954    }
2955
2956    #[test]
2957    fn unknown_directive_errors() {
2958        let doc = parse(".. frobnicate:: x\n");
2959        assert!(!doc.messages.is_empty());
2960    }
2961
2962    #[test]
2963    fn field_body_dedents_to_paragraphs() {
2964        let doc = parse(":Multi: first line\n\n   continuation line\n");
2965        let Block::FieldList(fields) = &doc.children[0] else {
2966            panic!("expected field list: {:?}", doc.children);
2967        };
2968        assert!(
2969            fields[0]
2970                .body
2971                .iter()
2972                .all(|b| matches!(b, Block::Paragraph(_)))
2973        );
2974        assert_eq!(fields[0].body.len(), 2);
2975    }
2976
2977    #[test]
2978    fn line_block_nests_extra_indent() {
2979        let doc = parse("| one\n|   nested\n");
2980        let Block::LineBlock(rows) = &doc.children[0] else {
2981            panic!("expected line block: {:?}", doc.children);
2982        };
2983        assert!(matches!(rows[0], LineRow::Line(_)));
2984        assert!(matches!(rows[1], LineRow::Nested(_)));
2985    }
2986
2987    #[test]
2988    fn short_underline_stays_one_paragraph() {
2989        let doc = parse("Bad\n--\n");
2990        let paras: Vec<_> = doc
2991            .children
2992            .iter()
2993            .filter(|b| matches!(b, Block::Paragraph(_)))
2994            .collect();
2995        assert_eq!(paras.len(), 1);
2996        assert!(doc.messages.iter().any(|m| m.level == 1));
2997    }
2998}