Skip to main content

brief/
parser.rs

1use crate::ast::{Block, CodeAttrs, Document, Inline, ListItem, Row, ShortArgs, TaskState};
2use crate::diag::{Code, Diagnostic};
3use crate::inline::{parse_args, parse_inline};
4use crate::span::{SourceMap, Span};
5use crate::token::{Token, TokenKind};
6
7pub fn parse(tokens: Vec<Token>, src: &SourceMap) -> (Document, Vec<Diagnostic>) {
8    let (metadata, fm_consumed, fm_diags) = parse_frontmatter(&tokens, src);
9    let mut p = Parser {
10        src,
11        toks: tokens,
12        pos: fm_consumed,
13        diags: fm_diags,
14        depth: 0,
15    };
16    let mut blocks = p.parse_blocks(0, None);
17    // Anything left after a top-level parse must be a stray `@end`.
18    while !p.at_eof() {
19        let tok = *p.peek();
20        match tok.kind {
21            TokenKind::Eof => break,
22            TokenKind::Blank => {
23                p.pos += 1;
24            }
25            TokenKind::Line if p.line(&tok).trim() == "@end" => {
26                p.diags.push(Diagnostic::new(Code::StrayEnd, tok.span));
27                p.pos += 1;
28            }
29            _ => {
30                blocks.append(&mut p.parse_blocks(0, None));
31            }
32        }
33    }
34    (
35        Document {
36            blocks,
37            metadata,
38            resolved_refs: Default::default(),
39        },
40        p.diags,
41    )
42}
43
44fn parse_frontmatter(
45    toks: &[Token],
46    src: &SourceMap,
47) -> (Option<toml::Table>, usize, Vec<Diagnostic>) {
48    // Returns (metadata, tokens_consumed, diagnostics).
49    //
50    // Frontmatter must be the very first content. We detect it by checking
51    // that the first token is `Line("+++")` at byte offset 0 with indent 0.
52    // If anything else comes first (a Blank, a comment, an indented `+++`,
53    // or a non-`+++` line), there is no frontmatter and we return
54    // (None, 0, empty).
55    if toks.is_empty() {
56        return (None, 0, Vec::new());
57    }
58    let first = &toks[0];
59    let opens = match first.kind {
60        TokenKind::Line => {
61            first.indent == 0 && first.span.start == 0 && &src.source[first.span.range()] == "+++"
62        }
63        _ => false,
64    };
65    if !opens {
66        return (None, 0, Vec::new());
67    }
68
69    // Body starts at the first byte after `+++\n` (or `+++\r\n`).
70    // The lexer's next token's `span.start` is exactly that byte.
71    let mut idx = 1usize;
72    let body_start = toks
73        .get(idx)
74        .map(|t| t.span.start as usize)
75        .unwrap_or(src.source.len());
76
77    let mut diags = Vec::new();
78    while idx < toks.len() {
79        match toks[idx].kind {
80            TokenKind::Eof => {
81                diags.push(
82                    Diagnostic::new(Code::UnterminatedFrontmatter, first.span)
83                        .label("frontmatter opened with `+++` is never closed"),
84                );
85                return (None, idx, diags);
86            }
87            TokenKind::Line
88                if toks[idx].indent == 0 && &src.source[toks[idx].span.range()] == "+++" =>
89            {
90                let close = &toks[idx];
91                let body_end = close.span.start as usize;
92                let body = &src.source[body_start..body_end];
93                idx += 1; // consume the closing `+++`
94                match toml::from_str::<toml::Table>(body) {
95                    Ok(t) => return (Some(t), idx, diags),
96                    Err(e) => {
97                        let (off, len) = match e.span() {
98                            Some(r) => (body_start + r.start, r.end - r.start),
99                            None => (body_start, body.len()),
100                        };
101                        // `.max(1)` keeps the diagnostic caret renderable; a
102                        // zero-length span produces no caret in the error UI.
103                        let span = Span::new(off, len.max(1));
104                        diags.push(
105                            Diagnostic::new(Code::FrontmatterToml, span).label(e.to_string()),
106                        );
107                        return (None, idx, diags);
108                    }
109                }
110            }
111            _ => {
112                idx += 1;
113            }
114        }
115    }
116    diags.push(
117        Diagnostic::new(Code::UnterminatedFrontmatter, first.span)
118            .label("frontmatter opened with `+++` is never closed"),
119    );
120    (None, idx, diags)
121}
122
123/// Blocks may nest via list children, block shortcodes, and blockquote
124/// markers. Each level recurses on the native stack, so nesting must be
125/// bounded or hostile/degenerate input aborts the process with a stack
126/// overflow instead of a diagnostic.
127const MAX_NESTING_DEPTH: usize = 64;
128
129struct Parser<'a> {
130    src: &'a SourceMap,
131    toks: Vec<Token>,
132    pos: usize,
133    diags: Vec<Diagnostic>,
134    depth: usize,
135}
136
137impl<'a> Parser<'a> {
138    fn peek(&self) -> &Token {
139        &self.toks[self.pos]
140    }
141    /// Text of a `Line` token, sliced from the source. The returned slice
142    /// borrows from the `SourceMap` (lifetime `'a`), not from `self`, so it
143    /// stays valid across `&mut self` calls.
144    fn line(&self, tok: &Token) -> &'a str {
145        &self.src.source[tok.span.range()]
146    }
147    fn at_eof(&self) -> bool {
148        matches!(self.peek().kind, TokenKind::Eof)
149    }
150
151    fn parse_blocks(&mut self, base_indent: u16, end_at_indent_below: Option<u16>) -> Vec<Block> {
152        if self.depth >= MAX_NESTING_DEPTH {
153            self.diags.push(
154                Diagnostic::new(Code::NestingTooDeep, self.peek().span).label(format!(
155                    "blocks nest deeper than {} levels",
156                    MAX_NESTING_DEPTH
157                )),
158            );
159            self.skip_overdeep_region(base_indent, end_at_indent_below);
160            return Vec::new();
161        }
162        self.depth += 1;
163        let out = self.parse_blocks_inner(base_indent, end_at_indent_below);
164        self.depth -= 1;
165        out
166    }
167
168    /// Consume the tokens a `parse_blocks` call would have owned, without
169    /// recursing, so callers past the depth ceiling still make progress.
170    fn skip_overdeep_region(&mut self, base_indent: u16, end_at_indent_below: Option<u16>) {
171        while !self.at_eof() {
172            let tok = *self.peek();
173            match tok.kind {
174                TokenKind::Eof => break,
175                TokenKind::Blank => {
176                    self.pos += 1;
177                }
178                TokenKind::Line => {
179                    let indent = tok.indent;
180                    if indent < base_indent {
181                        break;
182                    }
183                    if let Some(min) = end_at_indent_below {
184                        if indent < min {
185                            break;
186                        }
187                    }
188                    if self.line(&tok)[indent as usize..].trim() == "@end" {
189                        break;
190                    }
191                    self.pos += 1;
192                }
193            }
194        }
195    }
196
197    fn parse_blocks_inner(
198        &mut self,
199        base_indent: u16,
200        end_at_indent_below: Option<u16>,
201    ) -> Vec<Block> {
202        let mut out = Vec::new();
203        loop {
204            if self.at_eof() {
205                break;
206            }
207            match &self.peek().kind {
208                TokenKind::Eof => break,
209                TokenKind::Blank => {
210                    self.pos += 1;
211                    continue;
212                }
213                TokenKind::Line => {
214                    let tok = *self.peek();
215                    let indent = tok.indent;
216                    if indent < base_indent {
217                        break;
218                    }
219                    if let Some(min) = end_at_indent_below {
220                        if indent < min {
221                            break;
222                        }
223                    }
224                    let trimmed = &self.line(&tok)[indent as usize..];
225
226                    // `@end` is always a terminator for the parent block-shortcode;
227                    // stop here so the caller can consume it. Stray @ends are
228                    // surfaced by the top-level driver in `parse()`.
229                    if trimmed.trim() == "@end" {
230                        break;
231                    }
232                    if trimmed.starts_with("//") {
233                        self.pos += 1;
234                        continue;
235                    }
236                    if trimmed.starts_with("/*") {
237                        self.consume_block_comment(trimmed);
238                        continue;
239                    }
240                    if let Some(b) = self.try_block_at(trimmed, indent) {
241                        out.push(b);
242                    } else {
243                        out.push(self.parse_paragraph(indent));
244                    }
245                }
246            }
247        }
248        out
249    }
250
251    fn try_block_at(&mut self, trimmed: &str, indent: u16) -> Option<Block> {
252        if trimmed.starts_with('#') {
253            return Some(self.parse_heading());
254        }
255        if trimmed == "---" {
256            return Some(self.parse_hr());
257        }
258        // A dash-only line that isn't exactly three dashes is a malformed
259        // horizontal rule, not prose — silently reading it as a paragraph
260        // would hide the typo.
261        if trimmed.len() >= 2 && trimmed.bytes().all(|b| b == b'-') {
262            let span = self.peek().span;
263            self.diags.push(
264                Diagnostic::new(Code::BadHorizontalRule, span).label(format!(
265                    "horizontal rule is exactly `---`, got {} dashes",
266                    trimmed.len()
267                )),
268            );
269            self.pos += 1;
270            return Some(Block::Paragraph {
271                content: vec![],
272                span,
273            });
274        }
275        if trimmed.starts_with("```") {
276            return Some(self.parse_code_fence());
277        }
278        if trimmed.starts_with("- ") {
279            return Some(self.parse_unordered_list(indent));
280        }
281        if leading_ordered_marker(trimmed).is_some() {
282            return Some(self.parse_ordered_list(indent));
283        }
284        if trimmed.starts_with('>') {
285            return Some(self.parse_blockquote(indent));
286        }
287        if trimmed == "@t" || trimmed.starts_with("@t ") || trimmed.starts_with("@t(") {
288            return Some(self.parse_table(indent));
289        }
290        if trimmed == "@dl" || trimmed.starts_with("@dl ") || trimmed.starts_with("@dl(") {
291            return Some(self.parse_definition_list(indent));
292        }
293        if trimmed.starts_with('@') {
294            return self.parse_block_shortcode_or_inline(indent);
295        }
296        if trimmed.starts_with('|') {
297            let span = self.peek().span;
298            self.diags.push(
299                Diagnostic::new(Code::StrayContent, span)
300                    .label("`|` only appears inside a `@t` table"),
301            );
302            self.pos += 1;
303            return Some(Block::Paragraph {
304                content: vec![],
305                span,
306            });
307        }
308        None
309    }
310
311    fn consume_block_comment(&mut self, trimmed: &str) {
312        if trimmed.ends_with("*/") && trimmed.len() >= 4 {
313            self.pos += 1;
314            return;
315        }
316        self.pos += 1;
317        loop {
318            let tok = *self.peek();
319            match tok.kind {
320                TokenKind::Eof => {
321                    self.diags.push(
322                        Diagnostic::new(Code::UnterminatedBlock, tok.span)
323                            .label("unterminated /* */ comment"),
324                    );
325                    return;
326                }
327                TokenKind::Blank => {
328                    self.pos += 1;
329                }
330                TokenKind::Line => {
331                    let s = self.line(&tok);
332                    self.pos += 1;
333                    if s.trim_end().ends_with("*/") {
334                        return;
335                    }
336                }
337            }
338        }
339    }
340
341    fn parse_heading(&mut self) -> Block {
342        let tok = *self.peek();
343        let line = self.line(&tok);
344        self.pos += 1;
345        let indent = tok.indent as usize;
346        let s = &line[indent..];
347        let mut level = 0u8;
348        let bytes = s.as_bytes();
349        while (level as usize) < bytes.len() && bytes[level as usize] == b'#' {
350            level += 1;
351            if level > 6 {
352                break;
353            }
354        }
355        let mut hash_count = level as usize;
356        while hash_count < bytes.len() && bytes[hash_count] == b'#' {
357            hash_count += 1;
358        }
359        if hash_count > 6 {
360            let span = Span::new(tok.span.start as usize + indent, hash_count);
361            self.diags.push(
362                Diagnostic::new(Code::HeadingTooDeep, span)
363                    .label("Brief supports heading levels 1-6 only"),
364            );
365            return Block::Paragraph {
366                content: vec![],
367                span: tok.span,
368            };
369        }
370        if bytes.get(level as usize) != Some(&b' ') {
371            self.diags.push(
372                Diagnostic::new(Code::HeadingNoSpace, tok.span)
373                    .help("write `# heading` with exactly one space after the `#`s"),
374            );
375            return Block::Paragraph {
376                content: vec![],
377                span: tok.span,
378            };
379        }
380        if bytes.get(level as usize + 1) == Some(&b' ') {
381            self.diags.push(
382                Diagnostic::new(Code::HeadingNoSpace, tok.span)
383                    .label("multiple spaces after heading marker"),
384            );
385        }
386        let text_offset = indent + level as usize + 1;
387        let raw_text = &line[text_offset..];
388
389        // Anchor detection: look for a trailing `{#name}` block.
390        // Only triggers when the line ends with `}` and contains `{#`.
391        let (heading_text, anchor) = parse_heading_anchor(
392            raw_text,
393            tok.span.start + text_offset as u32,
394            &mut self.diags,
395        );
396
397        let (content, idiags) = parse_inline(heading_text, tok.span.start + text_offset as u32);
398        self.diags.extend(idiags);
399        Block::Heading {
400            level,
401            content,
402            anchor,
403            span: tok.span,
404        }
405    }
406
407    fn parse_paragraph(&mut self, indent: u16) -> Block {
408        let first = *self.peek();
409        let mut span = first.span;
410        let mut text = String::new();
411        // Fast path: most paragraphs are a single line with no hard break.
412        // Hold the first line as a source slice and only spill into the
413        // owned `text` accumulator when a second line (or a hard break)
414        // makes joining unavoidable.
415        let mut single: Option<&'a str> = None;
416        let mut hard_break_indices: Vec<usize> = Vec::new();
417        let mut first_line = true;
418        loop {
419            let tok = *self.peek();
420            match tok.kind {
421                TokenKind::Line => {
422                    if tok.indent != indent {
423                        break;
424                    }
425                    let trimmed = &self.line(&tok)[indent as usize..];
426                    // The first paragraph line is always consumed: the dispatcher
427                    // already decided this isn't a block. Subsequent continuation
428                    // lines stop at any block-starting sigil.
429                    if !first_line && leading_block_sigil(trimmed) {
430                        break;
431                    }
432                    let (line_text, hard) = match trimmed.strip_suffix('\\') {
433                        Some(rest) => (rest, true),
434                        None => (trimmed, false),
435                    };
436                    if first_line && !hard {
437                        single = Some(line_text);
438                    } else {
439                        if let Some(prev) = single.take() {
440                            text.push_str(prev);
441                        }
442                        if !text.is_empty() {
443                            text.push(' ');
444                        }
445                        if hard {
446                            hard_break_indices.push(text.len() + line_text.len());
447                        }
448                        text.push_str(line_text);
449                    }
450                    first_line = false;
451                    span = span.join(tok.span);
452                    self.pos += 1;
453                }
454                _ => break,
455            }
456        }
457        if let Some(s) = single {
458            let base = first.span.start + first.indent as u32;
459            let (content, d) = parse_inline(s, base);
460            self.diags.extend(d);
461            return Block::Paragraph { content, span };
462        }
463        let mut content: Vec<Inline> = Vec::new();
464        let mut cursor = 0usize;
465        let base = first.span.start + first.indent as u32;
466        for hb in &hard_break_indices {
467            let chunk = &text[cursor..*hb];
468            let (mut inl, d) = parse_inline(chunk, base + cursor as u32);
469            self.diags.extend(d);
470            content.append(&mut inl);
471            content.push(Inline::HardBreak {
472                span: Span::new(base as usize + *hb, 1),
473            });
474            cursor = *hb;
475        }
476        let chunk = &text[cursor..];
477        let (mut inl, d) = parse_inline(chunk, base + cursor as u32);
478        self.diags.extend(d);
479        content.append(&mut inl);
480        Block::Paragraph { content, span }
481    }
482
483    fn parse_hr(&mut self) -> Block {
484        let tok = *self.peek();
485        self.pos += 1;
486        Block::HorizontalRule { span: tok.span }
487    }
488
489    fn parse_code_fence(&mut self) -> Block {
490        let open = *self.peek();
491        let line = self.line(&open);
492        self.pos += 1;
493        let indent = open.indent as usize;
494        let after = &line[indent + 3..];
495        if after.starts_with('`') {
496            self.diags.push(
497                Diagnostic::new(Code::UnterminatedFence, open.span)
498                    .label("opening fence must be exactly three backticks"),
499            );
500        }
501        let info_offset = open.span.start as usize + indent + 3;
502        let (lang, attrs) = parse_fence_info(after, info_offset as u32, open.span, &mut self.diags);
503        let mut body = String::new();
504        let mut span = open.span;
505        loop {
506            let tok = *self.peek();
507            match tok.kind {
508                TokenKind::Eof => {
509                    self.diags.push(
510                        Diagnostic::new(Code::UnterminatedFence, open.span)
511                            .label("fence opened here is never closed"),
512                    );
513                    break;
514                }
515                TokenKind::Blank => {
516                    body.push('\n');
517                    span = span.join(tok.span);
518                    self.pos += 1;
519                }
520                TokenKind::Line => {
521                    let s = self.line(&tok);
522                    if s.trim() == "```" {
523                        span = span.join(tok.span);
524                        self.pos += 1;
525                        break;
526                    }
527                    body.push_str(s);
528                    body.push('\n');
529                    span = span.join(tok.span);
530                    self.pos += 1;
531                }
532            }
533        }
534        if body.ends_with('\n') {
535            body.pop();
536        }
537        Block::CodeBlock {
538            lang,
539            body,
540            attrs,
541            span,
542        }
543    }
544
545    fn parse_unordered_list(&mut self, indent: u16) -> Block {
546        let start_span = self.peek().span;
547        let mut items: Vec<ListItem> = Vec::new();
548        loop {
549            if self.at_eof() {
550                break;
551            }
552            let tok = *self.peek();
553            if !matches!(tok.kind, TokenKind::Line) {
554                break;
555            }
556            if tok.indent != indent {
557                break;
558            }
559            let trimmed = &self.line(&tok)[indent as usize..];
560            if !trimmed.starts_with("- ") {
561                break;
562            }
563            let after_marker = &trimmed[2..];
564            // Task-list modifier: exactly `[x] ` (Done) or `[ ] ` (Todo) at
565            // the start of item content. Lowercase `x` only; one space; one
566            // marker length only. Anything else is plain inline content.
567            let (task, item_text, content_offset) =
568                if let Some(rest) = after_marker.strip_prefix("[x] ") {
569                    (Some(TaskState::Done), rest, 4u32)
570                } else if let Some(rest) = after_marker.strip_prefix("[ ] ") {
571                    (Some(TaskState::Todo), rest, 4u32)
572                } else {
573                    (None, after_marker, 0u32)
574                };
575            let (content, d) = parse_inline(
576                item_text,
577                tok.span.start + indent as u32 + 2 + content_offset,
578            );
579            self.diags.extend(d);
580            self.pos += 1;
581            let mut children: Vec<Block> = Vec::new();
582            self.skip_blanks();
583            if matches!(self.peek().kind, TokenKind::Line) && self.peek().indent >= indent + 2 {
584                children = self.parse_blocks(indent + 2, Some(indent + 2));
585            }
586            items.push(ListItem {
587                content,
588                children,
589                task,
590                span: tok.span,
591            });
592        }
593        let span = items.iter().fold(start_span, |a, it| a.join(it.span));
594        Block::List {
595            ordered: false,
596            items,
597            span,
598        }
599    }
600
601    fn parse_ordered_list(&mut self, indent: u16) -> Block {
602        let start_span = self.peek().span;
603        let mut items: Vec<ListItem> = Vec::new();
604        let mut expected: u32 = 1;
605        loop {
606            if self.at_eof() {
607                break;
608            }
609            let tok = *self.peek();
610            if !matches!(tok.kind, TokenKind::Line) {
611                break;
612            }
613            if tok.indent != indent {
614                break;
615            }
616            let trimmed = &self.line(&tok)[indent as usize..];
617            let Some((num, marker_len)) = leading_ordered_marker(trimmed) else {
618                break;
619            };
620            if num != expected {
621                let span = Span::new(tok.span.start as usize + indent as usize, marker_len);
622                self.diags.push(
623                    Diagnostic::new(Code::OrderedListSequence, span)
624                        .label(format!("got `{}.`, expected `{}.`", num, expected))
625                        .help("ordered lists must number sequentially starting from 1"),
626                );
627            }
628            expected = expected.saturating_add(1);
629            let after_marker = &trimmed[marker_len..];
630            // Task-list modifier: exactly `[x] ` (Done) or `[ ] ` (Todo) at
631            // the start of item content. Lowercase `x` only; one space; one
632            // marker length only. Anything else is plain inline content.
633            let (task, item_text, content_offset) =
634                if let Some(rest) = after_marker.strip_prefix("[x] ") {
635                    (Some(TaskState::Done), rest, 4u32)
636                } else if let Some(rest) = after_marker.strip_prefix("[ ] ") {
637                    (Some(TaskState::Todo), rest, 4u32)
638                } else {
639                    (None, after_marker, 0u32)
640                };
641            let (content, d) = parse_inline(
642                item_text,
643                tok.span.start + indent as u32 + marker_len as u32 + content_offset,
644            );
645            self.diags.extend(d);
646            self.pos += 1;
647            let mut children: Vec<Block> = Vec::new();
648            self.skip_blanks();
649            if matches!(self.peek().kind, TokenKind::Line) && self.peek().indent >= indent + 2 {
650                children = self.parse_blocks(indent + 2, Some(indent + 2));
651            }
652            items.push(ListItem {
653                content,
654                children,
655                task,
656                span: tok.span,
657            });
658        }
659        let span = items.iter().fold(start_span, |a, it| a.join(it.span));
660        Block::List {
661            ordered: true,
662            items,
663            span,
664        }
665    }
666
667    fn parse_blockquote(&mut self, indent: u16) -> Block {
668        let mut lines: Vec<(usize, &'a str, Span)> = Vec::new();
669        let start = self.peek().span;
670        loop {
671            if self.at_eof() {
672                break;
673            }
674            let tok = *self.peek();
675            if !matches!(tok.kind, TokenKind::Line) {
676                break;
677            }
678            if tok.indent != indent {
679                break;
680            }
681            let trimmed = &self.line(&tok)[indent as usize..];
682            let mut depth: usize = 0;
683            let mut idx = 0usize;
684            let bytes = trimmed.as_bytes();
685            while idx < bytes.len() && bytes[idx] == b'>' {
686                depth += 1;
687                idx += 1;
688            }
689            if depth == 0 {
690                break;
691            }
692            if depth > MAX_NESTING_DEPTH {
693                self.diags.push(
694                    Diagnostic::new(Code::NestingTooDeep, tok.span).label(format!(
695                        "blockquote nests deeper than {} levels",
696                        MAX_NESTING_DEPTH
697                    )),
698                );
699                self.pos += 1;
700                break;
701            }
702            if bytes.get(idx) != Some(&b' ') {
703                self.diags.push(
704                    Diagnostic::new(Code::BadBlockquote, tok.span)
705                        .label("expected one space after `>`"),
706                );
707                self.pos += 1;
708                break;
709            }
710            let body = &trimmed[idx + 1..];
711            lines.push((depth, body, tok.span));
712            self.pos += 1;
713        }
714        let (children, span) = build_blockquote(&lines, 1);
715        Block::Blockquote {
716            children,
717            span: if span == Span::DUMMY { start } else { span },
718        }
719    }
720
721    fn parse_table(&mut self, indent: u16) -> Block {
722        let directive = *self.peek();
723        let line = self.line(&directive);
724        self.pos += 1;
725        let trimmed = &line[indent as usize..];
726        let mut cursor = 2usize;
727        let args = if trimmed.as_bytes().get(cursor) == Some(&b'(') {
728            match parse_args(trimmed, &mut cursor) {
729                Ok(a) => a,
730                Err(d) => {
731                    self.diags.push(d);
732                    ShortArgs::default()
733                }
734            }
735        } else {
736            ShortArgs::default()
737        };
738        let mut rows: Vec<Row> = Vec::new();
739        loop {
740            if self.at_eof() {
741                break;
742            }
743            let tok = *self.peek();
744            if !matches!(tok.kind, TokenKind::Line) {
745                break;
746            }
747            let row_line = self.line(&tok);
748            let trimmed = row_line.trim_start();
749            if !trimmed.starts_with('|') {
750                break;
751            }
752            let split = split_cells(trimmed);
753            if let Some(rel) = split.unclosed_backtick_at {
754                // `rel` is relative to `trimmed`. The token spans `row_line`, which
755                // includes leading whitespace; account for that when computing the
756                // absolute offset into the SourceMap.
757                let leading_ws = row_line.len() - trimmed.len();
758                debug_assert!(
759                    rel < trimmed.len(),
760                    "unclosed_backtick_at {} out of bounds for trimmed (len {})",
761                    rel,
762                    trimmed.len()
763                );
764                let abs = tok.span.start as usize + leading_ws + rel;
765                self.diags.push(
766                    Diagnostic::new(Code::UnterminatedCode, Span::new(abs, 1))
767                        .label("inline code span never closed inside a table row"),
768                );
769                self.pos += 1;
770                continue;
771            }
772            let cells = split.cells;
773            let mut parsed_cells: Vec<Vec<Inline>> = Vec::new();
774            for c in cells {
775                let (inl, d) = parse_inline(c.trim(), tok.span.start);
776                self.diags.extend(d);
777                parsed_cells.push(inl);
778            }
779            rows.push(Row {
780                cells: parsed_cells,
781                span: tok.span,
782            });
783            self.pos += 1;
784        }
785        if rows.is_empty() {
786            self.diags.push(
787                Diagnostic::new(Code::StrayContent, directive.span)
788                    .label("`@t` must be followed by at least a header row"),
789            );
790            return Block::Paragraph {
791                content: vec![],
792                span: directive.span,
793            };
794        }
795        let header = rows.remove(0);
796        let cols = header.cells.len();
797        for r in &rows {
798            if r.cells.len() != cols {
799                self.diags.push(
800                    Diagnostic::new(Code::TableColumnMismatch, r.span).label(format!(
801                        "table row has {} cells, expected {}",
802                        r.cells.len(),
803                        cols
804                    )),
805                );
806            }
807        }
808        if let Some(crate::shortcode::ArgValue::Array(a)) = args.keyword.get("align") {
809            if a.len() != cols {
810                self.diags.push(
811                    Diagnostic::new(Code::AlignArrayLength, directive.span).label(format!(
812                        "`align` has {} entries but table has {} columns",
813                        a.len(),
814                        cols
815                    )),
816                );
817            }
818            // `@t` becomes a `Block::Table` before the resolver sees it, so
819            // the enum check must live here. The emitter writes these values
820            // into a style attribute; anything outside the allowed set is
821            // both a user error and an injection vector.
822            for v in a {
823                let ok = matches!(v.as_str(), Some("left") | Some("right") | Some("center"));
824                if !ok {
825                    self.diags
826                        .push(
827                            Diagnostic::new(Code::BadEnumValue, directive.span).label(format!(
828                                "`align` entries must be `left`, `right`, or `center`, got `{}`",
829                                v.as_str().unwrap_or(v.type_name())
830                            )),
831                        );
832                }
833            }
834        }
835        let span = rows
836            .iter()
837            .fold(directive.span.join(header.span), |a, r| a.join(r.span));
838        Block::Table {
839            args,
840            header,
841            rows,
842            span,
843        }
844    }
845
846    fn parse_definition_list(&mut self, indent: u16) -> Block {
847        use crate::ast::DefinitionItem;
848        let directive = *self.peek();
849        let line = self.line(&directive);
850        self.pos += 1;
851        let trimmed = &line[indent as usize..];
852        let mut cursor = 3usize;
853        let args = if trimmed.as_bytes().get(cursor) == Some(&b'(') {
854            match parse_args(trimmed, &mut cursor) {
855                Ok(a) => a,
856                Err(d) => {
857                    self.diags.push(d);
858                    ShortArgs::default()
859                }
860            }
861        } else {
862            ShortArgs::default()
863        };
864
865        let mut items: Vec<DefinitionItem> = Vec::new();
866        let mut pending_term: Option<(Vec<Inline>, Span)> = None;
867        // (text accumulator, base offset of first line, span covering the
868        // definition's lines).
869        let mut pending_def: Option<(String, u32, Span)> = None;
870        let cont_indent = indent + 2;
871        let mut end_span = directive.span;
872
873        // Closes any open definition into items, paired with the pending term.
874        let finalize_def = |items: &mut Vec<DefinitionItem>,
875                            pending_term: &mut Option<(Vec<Inline>, Span)>,
876                            pending_def: &mut Option<(String, u32, Span)>,
877                            diags: &mut Vec<Diagnostic>| {
878            if let Some((text, base, span)) = pending_def.take() {
879                let term_pair = pending_term.take();
880                let (def_inl, dd) = parse_inline(&text, base);
881                diags.extend(dd);
882                if let Some((term, t_span)) = term_pair {
883                    let pair_span = t_span.join(span);
884                    items.push(DefinitionItem {
885                        term,
886                        definition: def_inl,
887                        span: pair_span,
888                    });
889                } else {
890                    // Should not happen — a definition without a term is
891                    // caught at the `: ` line itself.
892                }
893            }
894        };
895
896        loop {
897            if self.at_eof() {
898                self.diags.push(
899                    Diagnostic::new(Code::UnterminatedBlock, directive.span)
900                        .label("`@dl` block was never closed with `@end`"),
901                );
902                break;
903            }
904            let tok = *self.peek();
905            match tok.kind {
906                TokenKind::Eof => {
907                    self.diags.push(
908                        Diagnostic::new(Code::UnterminatedBlock, directive.span)
909                            .label("`@dl` block was never closed with `@end`"),
910                    );
911                    break;
912                }
913                TokenKind::Blank => {
914                    finalize_def(
915                        &mut items,
916                        &mut pending_term,
917                        &mut pending_def,
918                        &mut self.diags,
919                    );
920                    self.pos += 1;
921                    continue;
922                }
923                TokenKind::Line => {
924                    let s = self.line(&tok);
925                    if let Some(pd) = pending_def.as_mut()
926                        && tok.indent == cont_indent
927                    {
928                        // Continuation of the active definition.
929                        let body = &s[cont_indent as usize..];
930                        pd.0.push(' ');
931                        pd.0.push_str(body);
932                        pd.2 = pd.2.join(tok.span);
933                        self.pos += 1;
934                        continue;
935                    }
936                    if tok.indent != indent {
937                        // Anything else not at @dl's indent inside a `@dl`
938                        // body is unexpected — skip it; subsequent tasks
939                        // refine error reporting.
940                        self.pos += 1;
941                        continue;
942                    }
943                    let body = &s[indent as usize..];
944                    if body.trim() == "@end" {
945                        finalize_def(
946                            &mut items,
947                            &mut pending_term,
948                            &mut pending_def,
949                            &mut self.diags,
950                        );
951                        end_span = tok.span;
952                        self.pos += 1;
953                        break;
954                    }
955                    if let Some(rest) = body.strip_prefix(": ") {
956                        // The marker is exactly `: ` — colon, one space.
957                        // Extra spaces would silently become part of the
958                        // definition text, so they're rejected as documented.
959                        if rest.starts_with(' ') {
960                            self.diags.push(
961                                Diagnostic::new(Code::BadDefinitionList, tok.span)
962                                    .label("`:` must be followed by exactly one space"),
963                            );
964                            self.pos += 1;
965                            continue;
966                        }
967                        if pending_term.is_none() && pending_def.is_none() {
968                            self.diags.push(
969                                Diagnostic::new(Code::BadDefinitionList, tok.span)
970                                    .label("definition without a term"),
971                            );
972                            self.pos += 1;
973                            continue;
974                        }
975                        if pending_def.is_some() {
976                            self.diags.push(
977                                Diagnostic::new(Code::BadDefinitionList, tok.span).label(
978                                    "multiple definitions per term are not supported in v0.4",
979                                ),
980                            );
981                            // Drop the duplicate definition: do not consume
982                            // pending_term, do not start a new pending_def.
983                            self.pos += 1;
984                            continue;
985                        }
986                        let base = tok.span.start + indent as u32 + 2;
987                        pending_def = Some((rest.to_string(), base, tok.span));
988                        self.pos += 1;
989                    } else if body.starts_with(':') {
990                        self.diags.push(
991                            Diagnostic::new(Code::BadDefinitionList, tok.span)
992                                .label("`:` must be followed by exactly one space"),
993                        );
994                        self.pos += 1;
995                    } else {
996                        // Term line.
997                        finalize_def(
998                            &mut items,
999                            &mut pending_term,
1000                            &mut pending_def,
1001                            &mut self.diags,
1002                        );
1003                        if let Some((_t, t_span)) = pending_term.take() {
1004                            self.diags.push(
1005                                Diagnostic::new(Code::BadDefinitionList, t_span)
1006                                    .label("term without a definition"),
1007                            );
1008                        }
1009                        let base = tok.span.start + indent as u32;
1010                        let (term, td) = parse_inline(body, base);
1011                        self.diags.extend(td);
1012                        pending_term = Some((term, tok.span));
1013                        self.pos += 1;
1014                    }
1015                }
1016            }
1017        }
1018
1019        // Final flush after EOF or @end.
1020        finalize_def(
1021            &mut items,
1022            &mut pending_term,
1023            &mut pending_def,
1024            &mut self.diags,
1025        );
1026        if let Some((_t, t_span)) = pending_term {
1027            self.diags.push(
1028                Diagnostic::new(Code::BadDefinitionList, t_span).label("term without a definition"),
1029            );
1030        }
1031        if items.is_empty() && !self.diags.iter().any(|d| d.code == Code::BadDefinitionList) {
1032            self.diags.push(
1033                Diagnostic::new(Code::BadDefinitionList, directive.span)
1034                    .label("`@dl` must contain at least one term/definition pair"),
1035            );
1036        }
1037
1038        Block::DefinitionList {
1039            args,
1040            items,
1041            span: directive.span.join(end_span),
1042        }
1043    }
1044
1045    fn parse_block_shortcode_or_inline(&mut self, indent: u16) -> Option<Block> {
1046        let tok = *self.peek();
1047        if !matches!(tok.kind, TokenKind::Line) {
1048            return None;
1049        }
1050        let trimmed = &self.line(&tok)[indent as usize..];
1051        let mut cursor = 1usize;
1052        let bytes = trimmed.as_bytes();
1053        if cursor >= bytes.len() || !bytes[cursor].is_ascii_alphabetic() {
1054            return None;
1055        }
1056        let name_start = cursor;
1057        while cursor < bytes.len()
1058            && (bytes[cursor].is_ascii_alphanumeric() || bytes[cursor] == b'-')
1059        {
1060            cursor += 1;
1061        }
1062        let name = trimmed[name_start..cursor].to_string();
1063        let mut args = ShortArgs::default();
1064        if bytes.get(cursor) == Some(&b'(') {
1065            match parse_args(trimmed, &mut cursor) {
1066                Ok(a) => args = a,
1067                Err(d) => self.diags.push(d),
1068            }
1069        }
1070        if !trimmed[cursor..].trim().is_empty() {
1071            return None;
1072        }
1073        self.pos += 1;
1074        if name == "code" {
1075            return Some(self.parse_code_shortcode_body(indent, &tok, args));
1076        }
1077        let children = self.parse_blocks(indent, Some(indent));
1078        let mut end_span = tok.span;
1079        let end_tok = *self.peek();
1080        match end_tok.kind {
1081            TokenKind::Line if end_tok.indent == indent && self.line(&end_tok).trim() == "@end" => {
1082                end_span = end_tok.span;
1083                self.pos += 1;
1084            }
1085            _ => {
1086                self.diags.push(
1087                    Diagnostic::new(Code::UnterminatedBlock, tok.span)
1088                        .label(format!("`@{}` block was never closed with `@end`", name)),
1089                );
1090            }
1091        }
1092        Some(Block::BlockShortcode {
1093            name,
1094            args,
1095            children,
1096            span: tok.span.join(end_span),
1097        })
1098    }
1099
1100    /// `@code` bodies are verbatim, exactly like a triple-backtick fence —
1101    /// the shortcode exists so fence syntax itself can appear inside a code
1102    /// block. The body must never go through `parse_blocks`/`parse_inline`;
1103    /// that would reject ordinary code (`*ptr` reads as emphasis) and
1104    /// double-escape the rest.
1105    fn parse_code_shortcode_body(&mut self, indent: u16, open: &Token, args: ShortArgs) -> Block {
1106        let lang = self.code_shortcode_lang(args, open.span);
1107        let mut body = String::new();
1108        let mut span = open.span;
1109        loop {
1110            let tok = *self.peek();
1111            match tok.kind {
1112                TokenKind::Eof => {
1113                    self.diags.push(
1114                        Diagnostic::new(Code::UnterminatedBlock, open.span)
1115                            .label("`@code` block was never closed with `@end`"),
1116                    );
1117                    break;
1118                }
1119                TokenKind::Blank => {
1120                    body.push('\n');
1121                    span = span.join(tok.span);
1122                    self.pos += 1;
1123                }
1124                TokenKind::Line => {
1125                    let s = self.line(&tok);
1126                    if s.trim() == "@end" && tok.indent == indent {
1127                        span = span.join(tok.span);
1128                        self.pos += 1;
1129                        break;
1130                    }
1131                    body.push_str(s);
1132                    body.push('\n');
1133                    span = span.join(tok.span);
1134                    self.pos += 1;
1135                }
1136            }
1137        }
1138        if body.ends_with('\n') {
1139            body.pop();
1140        }
1141        Block::CodeBlock {
1142            lang,
1143            body,
1144            attrs: CodeAttrs::default(),
1145            span,
1146        }
1147    }
1148
1149    /// `@code` becomes a `CodeBlock` before the resolver ever sees it, so its
1150    /// arguments must be checked here: one optional `lang`, positional or
1151    /// keyword, string or ident.
1152    fn code_shortcode_lang(&mut self, args: ShortArgs, span: Span) -> Option<String> {
1153        let mut lang: Option<String> = None;
1154        for (i, v) in args.positional.iter().enumerate() {
1155            if i > 0 {
1156                self.diags.push(
1157                    Diagnostic::new(Code::BadArgSyntax, span)
1158                        .label("`@code` takes at most one positional argument (the language)"),
1159                );
1160                break;
1161            }
1162            match v.as_str() {
1163                Some(s) => lang = Some(s.to_string()),
1164                None => {
1165                    self.diags
1166                        .push(Diagnostic::new(Code::ArgTypeMismatch, span).label(format!(
1167                            "argument `lang` has type {} but expected String",
1168                            v.type_name()
1169                        )));
1170                }
1171            }
1172        }
1173        for (kw, v) in &args.keyword {
1174            if kw != "lang" {
1175                self.diags.push(
1176                    Diagnostic::new(Code::BadArgSyntax, span)
1177                        .label(format!("`@code` has no argument named `{}`", kw)),
1178                );
1179                continue;
1180            }
1181            if lang.is_some() {
1182                self.diags.push(
1183                    Diagnostic::new(Code::DuplicateKwarg, span)
1184                        .label("`lang` given both positionally and by keyword"),
1185                );
1186                continue;
1187            }
1188            match v.as_str() {
1189                Some(s) => lang = Some(s.to_string()),
1190                None => {
1191                    self.diags
1192                        .push(Diagnostic::new(Code::ArgTypeMismatch, span).label(format!(
1193                            "argument `lang` has type {} but expected String",
1194                            v.type_name()
1195                        )));
1196                }
1197            }
1198        }
1199        lang
1200    }
1201
1202    fn skip_blanks(&mut self) {
1203        while matches!(self.peek().kind, TokenKind::Blank) {
1204            self.pos += 1;
1205        }
1206    }
1207}
1208
1209fn build_blockquote(items: &[(usize, &str, Span)], depth: usize) -> (Vec<Block>, Span) {
1210    let mut paras: Vec<Block> = Vec::new();
1211    let mut full_span = Span::DUMMY;
1212    let mut i = 0;
1213    while i < items.len() {
1214        let (d, body, span) = &items[i];
1215        if *d < depth {
1216            break;
1217        }
1218        full_span = if full_span == Span::DUMMY {
1219            *span
1220        } else {
1221            full_span.join(*span)
1222        };
1223        if *d == depth {
1224            let (content, _) = parse_inline(body, span.start);
1225            paras.push(Block::Paragraph {
1226                content,
1227                span: *span,
1228            });
1229            i += 1;
1230        } else {
1231            let mut j = i;
1232            while j < items.len() && items[j].0 > depth {
1233                j += 1;
1234            }
1235            let (children, child_span) = build_blockquote(&items[i..j], depth + 1);
1236            paras.push(Block::Blockquote {
1237                children,
1238                span: child_span,
1239            });
1240            i = j;
1241        }
1242    }
1243    (paras, full_span)
1244}
1245
1246fn parse_fence_info(
1247    after: &str,
1248    base: u32,
1249    fence_span: Span,
1250    diags: &mut Vec<Diagnostic>,
1251) -> (Option<String>, CodeAttrs) {
1252    // The info string follows the opening ```; the language tag is the first
1253    // whitespace-separated token, and any subsequent `@<ident>` tokens are
1254    // fence attributes (v0.2: `@nominify`, `@minify`).
1255    let mut attrs = CodeAttrs::default();
1256    let bytes = after.as_bytes();
1257    let mut i = 0usize;
1258    while i < bytes.len() && bytes[i] == b' ' {
1259        i += 1;
1260    }
1261    if i == bytes.len() {
1262        return (None, attrs);
1263    }
1264    let lang_start = i;
1265    while i < bytes.len() && bytes[i] != b' ' {
1266        i += 1;
1267    }
1268    let lang_tok = &after[lang_start..i];
1269    let lang = if lang_tok.is_empty() {
1270        None
1271    } else if lang_tok.starts_with('@') {
1272        // The first non-whitespace token is an attribute, not a language.
1273        i = lang_start;
1274        None
1275    } else {
1276        Some(lang_tok.to_string())
1277    };
1278
1279    while i < bytes.len() {
1280        while i < bytes.len() && bytes[i] == b' ' {
1281            i += 1;
1282        }
1283        if i >= bytes.len() {
1284            break;
1285        }
1286        let tok_start = i;
1287        while i < bytes.len() && bytes[i] != b' ' {
1288            i += 1;
1289        }
1290        let tok = &after[tok_start..i];
1291        if tok.is_empty() {
1292            continue;
1293        }
1294        let tok_span = Span::new(base as usize + tok_start, tok.len());
1295        if !tok.starts_with('@') {
1296            diags.push(
1297                Diagnostic::new(Code::UnknownCodeAttribute, tok_span)
1298                    .label(format!("`{}` is not a valid code-fence attribute", tok))
1299                    .help("attributes must be `@`-prefixed identifiers (e.g. `@nominify`)"),
1300            );
1301            continue;
1302        }
1303        let name = &tok[1..];
1304        match name {
1305            "nominify" => {
1306                if attrs.minify || attrs.keep_comments {
1307                    diags.push(
1308                        Diagnostic::new(Code::ConflictingCodeAttributes, fence_span)
1309                            .label("`@nominify` conflicts with `@minify`/`@minify-keep-comments`"),
1310                    );
1311                }
1312                attrs.nominify = true;
1313            }
1314            "minify" => {
1315                if attrs.nominify {
1316                    diags.push(
1317                        Diagnostic::new(Code::ConflictingCodeAttributes, fence_span)
1318                            .label("`@nominify` and `@minify` cannot both be set"),
1319                    );
1320                }
1321                attrs.minify = true;
1322            }
1323            "minify-keep-comments" => {
1324                if attrs.nominify {
1325                    diags.push(
1326                        Diagnostic::new(Code::ConflictingCodeAttributes, fence_span)
1327                            .label("`@nominify` and `@minify-keep-comments` cannot both be set"),
1328                    );
1329                }
1330                attrs.keep_comments = true;
1331            }
1332            _ => {
1333                diags.push(
1334                    Diagnostic::new(Code::UnknownCodeAttribute, tok_span)
1335                        .label(format!("unknown code-fence attribute `{}`", tok))
1336                        .help("v0.4 supports `@nominify`, `@minify`, `@minify-keep-comments`"),
1337                );
1338            }
1339        }
1340    }
1341    (lang, attrs)
1342}
1343
1344/// Parse a trailing `{#anchor}` block from heading text.
1345///
1346/// Returns `(text_to_use_for_inline_parse, anchor_name)`. If the anchor
1347/// block is present but malformed, a diagnostic is pushed and `anchor` is
1348/// `None`, but the malformed block is still stripped from `text_to_use`
1349/// where possible.
1350///
1351/// Anchor syntax is triggered whenever `{#` appears in the heading text.
1352/// If `{#` is present but the format does not match the strict form
1353/// (` {#name}` at end of line, name `[a-z0-9-]+`), it is a `BadHeadingAnchor`
1354/// error.
1355///
1356/// Exception: `{#` in the middle of text with NO closing `}` at
1357/// end-of-line is treated as plain text — it's clearly not intended as
1358/// anchor syntax.
1359///
1360/// `base` is the byte offset of `text` in the source, used for diagnostics.
1361fn parse_heading_anchor<'a>(
1362    text: &'a str,
1363    base: u32,
1364    diags: &mut Vec<Diagnostic>,
1365) -> (&'a str, Option<String>) {
1366    // Quick exit: if there's no `{#` anywhere, no anchor syntax is possible.
1367    if !text.contains("{#") {
1368        return (text, None);
1369    }
1370
1371    // Find the last `{#` occurrence (there can be at most one anchor block).
1372    let hash_open = match text.rfind("{#") {
1373        Some(i) => i,
1374        None => return (text, None),
1375    };
1376
1377    // Check if the `}` at end-of-line closes this `{#`.
1378    // Case A: `{#name}` at end of line (the only valid form).
1379    // Case B: `{#name}` NOT at end of line (content after `}`) → malformed.
1380    // Case C: no `}` after the `{#` at all → the `{#` is inside text, leave alone.
1381
1382    let after_hash = &text[hash_open..];
1383    let rbrace = match after_hash.find('}') {
1384        Some(i) => i,
1385        None => {
1386            // `{#` with no closing `}` anywhere → plain text, no error.
1387            return (text, None);
1388        }
1389    };
1390
1391    let candidate = &after_hash[..rbrace + 1]; // e.g. `{#abc}`
1392    let after_candidate = &after_hash[rbrace + 1..]; // what comes after `}`
1393
1394    // If there is content after the `}`, the anchor block is NOT at end of line.
1395    // This is a BadHeadingAnchor (rule: no content after `}`).
1396    if !after_candidate.is_empty() {
1397        let anchor_span = Span::new(base as usize + hash_open, candidate.len());
1398        diags.push(
1399            Diagnostic::new(Code::BadHeadingAnchor, anchor_span)
1400                .label("anchor block must be `{#anchor}` with exactly one space before `{` and no content after `}`"),
1401        );
1402        // Leave the heading text alone (don't strip anything).
1403        return (text, None);
1404    }
1405
1406    // The candidate `{#...}` IS at end of line.
1407    // Validate: exactly one space before `{`.
1408    let before = &text[..hash_open];
1409
1410    let malformed = if before.is_empty() {
1411        // No content before `{#...}` → no space before `{`.
1412        true
1413    } else {
1414        let last_ch = before.chars().last().unwrap();
1415        if last_ch != ' ' {
1416            // No space before `{`
1417            true
1418        } else {
1419            // Check for double space (before ends with "  ")
1420            let before_trim = &before[..before.len() - 1];
1421            before_trim.ends_with(' ')
1422        }
1423    };
1424
1425    // Extract the name (everything between `{#` and `}`).
1426    let name_part = &candidate[2..candidate.len() - 1]; // strip `{#` and `}`
1427
1428    let anchor_span_start = base as usize + hash_open;
1429    let anchor_span = Span::new(anchor_span_start, candidate.len());
1430
1431    if malformed {
1432        diags.push(
1433            Diagnostic::new(Code::BadHeadingAnchor, anchor_span)
1434                .label("anchor block must be `{#anchor}` with exactly one space before `{` and no content after `}`"),
1435        );
1436        // Strip the malformed block from text.
1437        return (&text[..hash_open], None);
1438    }
1439
1440    // Validate the name: `[a-z0-9-]+`, non-empty.
1441    let name_is_valid = !name_part.is_empty()
1442        && name_part
1443            .bytes()
1444            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
1445
1446    // Strip ` {#name}` from the text (one space + anchor block).
1447    // `hash_open - 1` skips the single space before `{`.
1448    let stripped = &text[..hash_open - 1];
1449
1450    if !name_is_valid {
1451        let name_span = Span::new(anchor_span_start + 2, name_part.len().max(1));
1452        diags.push(
1453            Diagnostic::new(Code::BadHeadingAnchor, name_span)
1454                .label("anchor must match `[a-z0-9-]+`")
1455                .help("use lowercase letters, digits, and hyphens only"),
1456        );
1457        return (stripped, None);
1458    }
1459
1460    (stripped, Some(name_part.to_string()))
1461}
1462
1463fn leading_block_sigil(s: &str) -> bool {
1464    if s.is_empty() {
1465        return false;
1466    }
1467    let b = s.as_bytes()[0];
1468    if b == b'#' || b == b'>' || b == b'|' || b == b'`' {
1469        return true;
1470    }
1471    if s == "---" {
1472        return true;
1473    }
1474    if s.starts_with("- ") {
1475        return true;
1476    }
1477    if leading_ordered_marker(s).is_some() {
1478        return true;
1479    }
1480    if s.starts_with("//") || s.starts_with("/*") {
1481        return true;
1482    }
1483    if s == "@end" || s.starts_with("@end ") {
1484        return true;
1485    }
1486    if b == b'@' {
1487        // a leading directive starts a block; non-directive @ inside text wouldn't appear at line start
1488        return true;
1489    }
1490    false
1491}
1492
1493fn leading_ordered_marker(s: &str) -> Option<(u32, usize)> {
1494    let bytes = s.as_bytes();
1495    let mut i = 0;
1496    while i < bytes.len() && bytes[i].is_ascii_digit() {
1497        i += 1;
1498    }
1499    if i == 0 {
1500        return None;
1501    }
1502    if bytes.get(i) != Some(&b'.') {
1503        return None;
1504    }
1505    if bytes.get(i + 1) != Some(&b' ') {
1506        return None;
1507    }
1508    let n: u32 = s[..i].parse().ok()?;
1509    Some((n, i + 2))
1510}
1511
1512/// Outcome of splitting a table row.
1513///
1514/// `cells` borrow from the slice that was passed in (the `line` argument
1515/// to `split_cells`). The caller is responsible for providing the line
1516/// in whatever frame it wants `unclosed_backtick_at` to be relative to —
1517/// see the doc comment on that field.
1518#[derive(Debug)]
1519struct RowSplit<'a> {
1520    cells: Vec<&'a str>,
1521    /// `Some(byte_offset)` if a backtick code span opened in this row
1522    /// and never closed. The offset is relative to **the input slice
1523    /// passed to `split_cells`** (i.e. the same `&str` whose address
1524    /// the cell slices are also relative to). Callers that need an
1525    /// absolute span into the source must add the offset of `line` from
1526    /// the source start themselves.
1527    unclosed_backtick_at: Option<usize>,
1528}
1529
1530fn split_cells(line: &str) -> RowSplit<'_> {
1531    let bytes = line.as_bytes();
1532    // The leading `|` is the row-opener, not a cell separator.
1533    let body_start = if bytes.first() == Some(&b'|') { 1 } else { 0 };
1534    let body = &line[body_start..];
1535    let body_bytes = body.as_bytes();
1536    let mut cells: Vec<&str> = Vec::new();
1537    let mut cell_start = 0usize;
1538    let mut i = 0usize;
1539    // Offset is relative to `line` (the parameter) — not to `body` —
1540    // so callers can add `tok.span.start + leading_ws` and get an
1541    // absolute byte offset into the source map.
1542    let mut unclosed: Option<usize> = None;
1543    while i < body_bytes.len() {
1544        let b = body_bytes[i];
1545        if b == b'\\' {
1546            // Escape: skip the backslash and one following byte. The
1547            // body is later passed to parse_inline which handles UTF-8;
1548            // here we just need to *not* re-trigger on the escaped byte.
1549            i += 1;
1550            if i < body_bytes.len() {
1551                i += 1;
1552            }
1553            continue;
1554        }
1555        if b == b'`' {
1556            // Span length: 1 or 2 backticks. Matches the inline parser
1557            // (see `inline.rs::parse_code`).
1558            let ticks = if body_bytes.get(i + 1) == Some(&b'`') {
1559                2
1560            } else {
1561                1
1562            };
1563            let span_open = i;
1564            let needle: &[u8] = if ticks == 2 { b"``" } else { b"`" };
1565            let mut j = i + ticks;
1566            let mut closed = false;
1567            while j + ticks <= body_bytes.len() {
1568                if &body_bytes[j..j + ticks] == needle {
1569                    j += ticks;
1570                    closed = true;
1571                    break;
1572                }
1573                j += 1;
1574            }
1575            if !closed {
1576                // Record the unclosed-backtick offset *relative to
1577                // `line`* (so we add `body_start`, since `span_open` is
1578                // relative to `body`). Stop splitting; the caller will
1579                // emit a single Code::UnterminatedCode diagnostic.
1580                unclosed = Some(body_start + span_open);
1581                break;
1582            }
1583            i = j;
1584            continue;
1585        }
1586        if b == b'|' {
1587            cells.push(&body[cell_start..i]);
1588            cell_start = i + 1;
1589            i += 1;
1590            continue;
1591        }
1592        i += 1;
1593    }
1594    if unclosed.is_none() {
1595        // Push the final cell. Strip a trailing empty cell only when the
1596        // *source* used a `|` as a row-closer (cell_start lands right
1597        // after the trailing `|`, leaving an empty slice).
1598        let last = &body[cell_start..];
1599        if !(last.is_empty() && cell_start > 0 && body_bytes[cell_start - 1] == b'|') {
1600            cells.push(last);
1601        }
1602    }
1603    let trimmed: Vec<&str> = cells.into_iter().map(str::trim).collect();
1604    RowSplit {
1605        cells: trimmed,
1606        unclosed_backtick_at: unclosed,
1607    }
1608}
1609
1610#[cfg(test)]
1611mod tests {
1612    use super::*;
1613    use crate::lexer::lex;
1614
1615    fn p(s: &str) -> (Document, Vec<Diagnostic>) {
1616        let src = SourceMap::new("d.brf", s);
1617        let toks = lex(&src).unwrap();
1618        parse(toks, &src)
1619    }
1620
1621    #[test]
1622    fn heading_levels() {
1623        let (doc, d) = p("# A\n## B\n");
1624        assert!(d.is_empty(), "{:?}", d);
1625        assert_eq!(doc.blocks.len(), 2);
1626        if let Block::Heading { level, .. } = doc.blocks[0] {
1627            assert_eq!(level, 1);
1628        }
1629        if let Block::Heading { level, .. } = doc.blocks[1] {
1630            assert_eq!(level, 2);
1631        }
1632    }
1633
1634    #[test]
1635    fn heading_too_deep() {
1636        let (_, d) = p("####### x\n");
1637        assert!(d.iter().any(|x| x.code == Code::HeadingTooDeep));
1638    }
1639
1640    #[test]
1641    fn ordered_sequence() {
1642        let (_, d) = p("1. one\n3. three\n");
1643        assert!(d.iter().any(|x| x.code == Code::OrderedListSequence));
1644    }
1645
1646    #[test]
1647    fn ordered_ok() {
1648        let (doc, d) = p("1. one\n2. two\n");
1649        assert!(d.is_empty(), "{:?}", d);
1650        assert!(matches!(doc.blocks[0], Block::List { ordered: true, .. }));
1651    }
1652
1653    #[test]
1654    fn unordered_nested() {
1655        let (doc, d) = p("- a\n  - a1\n- b\n");
1656        assert!(d.is_empty(), "{:?}", d);
1657        if let Block::List { items, .. } = &doc.blocks[0] {
1658            assert_eq!(items.len(), 2);
1659            assert_eq!(items[0].children.len(), 1);
1660        } else {
1661            panic!();
1662        }
1663    }
1664
1665    #[test]
1666    fn paragraph_join() {
1667        let (doc, d) = p("one\ntwo\n");
1668        assert!(d.is_empty());
1669        if let Block::Paragraph { content, .. } = &doc.blocks[0] {
1670            if let Inline::Text { value, .. } = &content[0] {
1671                assert_eq!(value, "one two");
1672            }
1673        }
1674    }
1675
1676    #[test]
1677    fn code_block() {
1678        let (doc, d) = p("```rust\nfn x() {}\n```\n");
1679        assert!(d.is_empty(), "{:?}", d);
1680        if let Block::CodeBlock {
1681            lang, body, attrs, ..
1682        } = &doc.blocks[0]
1683        {
1684            assert_eq!(lang.as_deref(), Some("rust"));
1685            assert_eq!(body, "fn x() {}");
1686            assert_eq!(*attrs, CodeAttrs::default());
1687        } else {
1688            panic!();
1689        }
1690    }
1691
1692    #[test]
1693    fn code_fence_nominify_attr() {
1694        let (doc, d) = p("```json @nominify\n{\"a\":1}\n```\n");
1695        assert!(d.is_empty(), "{:?}", d);
1696        if let Block::CodeBlock { lang, attrs, .. } = &doc.blocks[0] {
1697            assert_eq!(lang.as_deref(), Some("json"));
1698            assert!(attrs.nominify);
1699            assert!(!attrs.minify);
1700        } else {
1701            panic!();
1702        }
1703    }
1704
1705    #[test]
1706    fn code_fence_minify_attr() {
1707        let (doc, d) = p("```rust @minify\nfn x() {}\n```\n");
1708        assert!(d.is_empty(), "{:?}", d);
1709        if let Block::CodeBlock { lang, attrs, .. } = &doc.blocks[0] {
1710            assert_eq!(lang.as_deref(), Some("rust"));
1711            assert!(attrs.minify);
1712        } else {
1713            panic!();
1714        }
1715    }
1716
1717    #[test]
1718    fn code_fence_unknown_attr_errors() {
1719        let (_, d) = p("```json @bogus\n{}\n```\n");
1720        assert!(
1721            d.iter().any(|x| x.code == Code::UnknownCodeAttribute),
1722            "{:?}",
1723            d
1724        );
1725    }
1726
1727    #[test]
1728    fn code_fence_attr_without_at_sigil_errors() {
1729        let (_, d) = p("```json bogus\n{}\n```\n");
1730        assert!(
1731            d.iter().any(|x| x.code == Code::UnknownCodeAttribute),
1732            "{:?}",
1733            d
1734        );
1735    }
1736
1737    #[test]
1738    fn code_fence_conflicting_attrs() {
1739        let (_, d) = p("```json @nominify @minify\n{}\n```\n");
1740        assert!(
1741            d.iter().any(|x| x.code == Code::ConflictingCodeAttributes),
1742            "{:?}",
1743            d
1744        );
1745    }
1746
1747    #[test]
1748    fn code_fence_attr_only_no_lang() {
1749        // An attribute on the first non-whitespace position means the block
1750        // has no language; the attribute still parses.
1751        let (doc, d) = p("``` @nominify\nbody\n```\n");
1752        assert!(d.is_empty(), "{:?}", d);
1753        if let Block::CodeBlock { lang, attrs, .. } = &doc.blocks[0] {
1754            assert!(lang.is_none());
1755            assert!(attrs.nominify);
1756        } else {
1757            panic!();
1758        }
1759    }
1760
1761    #[test]
1762    fn table_basic() {
1763        let (doc, d) = p("@t\n| A | B\n| 1 | 2\n");
1764        assert!(d.is_empty(), "{:?}", d);
1765        if let Block::Table { rows, .. } = &doc.blocks[0] {
1766            assert_eq!(rows.len(), 1);
1767        } else {
1768            panic!("{:?}", doc.blocks);
1769        }
1770    }
1771
1772    #[test]
1773    fn table_column_mismatch() {
1774        let (_, d) = p("@t\n| A | B | C\n| 1 | 2\n");
1775        assert!(d.iter().any(|x| x.code == Code::TableColumnMismatch));
1776    }
1777
1778    #[test]
1779    fn table_pipe_inside_inline_code_span_is_not_a_separator() {
1780        // Production report issue #2: documenting `|>` should not blow up
1781        // the row's column count.
1782        let (doc, d) = p("@t\n| Op | Meaning\n| `|>` | pipeline\n");
1783        assert!(d.is_empty(), "{:?}", d);
1784        if let crate::ast::Block::Table { rows, .. } = &doc.blocks[0] {
1785            assert_eq!(rows.len(), 1, "{:?}", rows);
1786            assert_eq!(rows[0].cells.len(), 2);
1787        } else {
1788            panic!("expected table");
1789        }
1790    }
1791
1792    #[test]
1793    fn table_pipe_inside_double_backtick_span_is_not_a_separator() {
1794        let (doc, d) = p("@t\n| A | B\n| ``a ` b | c`` | d\n");
1795        assert!(d.is_empty(), "{:?}", d);
1796        if let crate::ast::Block::Table { rows, .. } = &doc.blocks[0] {
1797            assert_eq!(rows.len(), 1);
1798            assert_eq!(rows[0].cells.len(), 2);
1799        } else {
1800            panic!();
1801        }
1802    }
1803
1804    #[test]
1805    fn table_unclosed_backtick_in_row_reports_unterminated_code_not_column_mismatch() {
1806        let (_doc, d) = p("@t\n| A | B\n| `oops | c\n");
1807        assert!(
1808            d.iter().any(|x| x.code == Code::UnterminatedCode),
1809            "{:?}",
1810            d
1811        );
1812        assert!(
1813            !d.iter().any(|x| x.code == Code::TableColumnMismatch),
1814            "{:?}",
1815            d
1816        );
1817    }
1818
1819    #[test]
1820    fn table_unclosed_backtick_with_indented_row_diagnostic_anchors_correctly() {
1821        // Two-space-indented `@t` (legal — tables can appear inside
1822        // indented contexts). Diagnostic offset must include the leading
1823        // whitespace, not just the post-trim column.
1824        let (_doc, d) = p("  @t\n  | A | B\n  | `oops | c\n");
1825        let unterm: Vec<_> = d
1826            .iter()
1827            .filter(|x| x.code == Code::UnterminatedCode)
1828            .collect();
1829        assert_eq!(unterm.len(), 1, "{:?}", d);
1830    }
1831
1832    #[test]
1833    fn block_shortcode() {
1834        let (doc, d) = p("@callout(kind: warning)\nbody\n@end\n");
1835        assert!(d.is_empty(), "{:?}", d);
1836        assert!(matches!(doc.blocks[0], Block::BlockShortcode { .. }));
1837    }
1838
1839    #[test]
1840    fn hr() {
1841        let (doc, _) = p("---\n");
1842        assert!(matches!(doc.blocks[0], Block::HorizontalRule { .. }));
1843    }
1844
1845    #[test]
1846    fn frontmatter_basic() {
1847        let input = "+++\ntitle = \"hi\"\nn = 3\n+++\n# Doc\n";
1848        let (doc, d) = p(input);
1849        assert!(d.is_empty(), "{:?}", d);
1850        let meta = doc.metadata.as_ref().expect("metadata present");
1851        assert_eq!(meta.get("title").and_then(|v| v.as_str()), Some("hi"));
1852        assert_eq!(meta.get("n").and_then(|v| v.as_integer()), Some(3));
1853        assert_eq!(doc.blocks.len(), 1);
1854        assert!(matches!(doc.blocks[0], Block::Heading { level: 1, .. }));
1855    }
1856
1857    #[test]
1858    fn frontmatter_empty_table() {
1859        let (doc, d) = p("+++\n+++\n");
1860        assert!(d.is_empty(), "{:?}", d);
1861        let meta = doc.metadata.as_ref().expect("metadata present");
1862        assert!(meta.is_empty());
1863        assert!(doc.blocks.is_empty());
1864    }
1865
1866    #[test]
1867    fn frontmatter_unterminated() {
1868        let (_, d) = p("+++\nfoo = 1\n");
1869        assert!(
1870            d.iter().any(|x| x.code == Code::UnterminatedFrontmatter),
1871            "{:?}",
1872            d
1873        );
1874    }
1875
1876    #[test]
1877    fn frontmatter_bad_toml() {
1878        let (_, d) = p("+++\nfoo === 1\n+++\n");
1879        assert!(d.iter().any(|x| x.code == Code::FrontmatterToml), "{:?}", d);
1880    }
1881
1882    #[test]
1883    fn frontmatter_only_first_line() {
1884        // Leading blank line means the document does not start with `+++`,
1885        // so this is a stray paragraph, not frontmatter.
1886        let (doc, _d) = p("\n+++\nfoo = 1\n+++\n");
1887        assert!(doc.metadata.is_none());
1888    }
1889
1890    #[test]
1891    fn frontmatter_indented_is_not_frontmatter() {
1892        // Leading spaces on the opening line mean it's not a delimiter.
1893        let (doc, _d) = p("  +++\nfoo = 1\n+++\n");
1894        assert!(doc.metadata.is_none());
1895    }
1896
1897    #[test]
1898    fn frontmatter_no_open_means_none() {
1899        let (doc, _d) = p("# Heading\n");
1900        assert!(doc.metadata.is_none());
1901    }
1902
1903    #[test]
1904    fn frontmatter_crlf() {
1905        let input = "+++\r\ntitle = \"hi\"\r\n+++\r\n# Doc\r\n";
1906        let (doc, d) = p(input);
1907        assert!(d.is_empty(), "{:?}", d);
1908        let meta = doc.metadata.as_ref().expect("metadata present");
1909        assert_eq!(meta.get("title").and_then(|v| v.as_str()), Some("hi"));
1910    }
1911
1912    #[test]
1913    fn dl_basic_two_pairs() {
1914        let (doc, d) = p("@dl\nTerm 1\n: Definition 1.\nTerm 2\n: Definition 2.\n@end\n");
1915        assert!(d.is_empty(), "{:?}", d);
1916        let dl = match &doc.blocks[0] {
1917            Block::DefinitionList { items, .. } => items,
1918            other => panic!("expected DefinitionList, got {:?}", other),
1919        };
1920        assert_eq!(dl.len(), 2);
1921        let term0 = match &dl[0].term[0] {
1922            Inline::Text { value, .. } => value.as_str(),
1923            _ => panic!("expected Text in term"),
1924        };
1925        let def0 = match &dl[0].definition[0] {
1926            Inline::Text { value, .. } => value.as_str(),
1927            _ => panic!("expected Text in definition"),
1928        };
1929        assert_eq!(term0, "Term 1");
1930        assert_eq!(def0, "Definition 1.");
1931        let term1 = match &dl[1].term[0] {
1932            Inline::Text { value, .. } => value.as_str(),
1933            _ => panic!("expected Text in term"),
1934        };
1935        assert_eq!(term1, "Term 2");
1936    }
1937
1938    #[test]
1939    fn dl_continuation_joins_with_space() {
1940        let input = "@dl\nTerm\n: Definition that\n  spans two lines.\n@end\n";
1941        let (doc, d) = p(input);
1942        assert!(d.is_empty(), "{:?}", d);
1943        let items = match &doc.blocks[0] {
1944            Block::DefinitionList { items, .. } => items,
1945            other => panic!("expected DefinitionList, got {:?}", other),
1946        };
1947        assert_eq!(items.len(), 1);
1948        let def_text = match &items[0].definition[0] {
1949            Inline::Text { value, .. } => value.as_str(),
1950            _ => panic!("expected Text"),
1951        };
1952        assert_eq!(def_text, "Definition that spans two lines.");
1953    }
1954
1955    #[test]
1956    fn dl_definition_without_term_is_b0505() {
1957        let (_, d) = p("@dl\n: Stray definition.\nTerm\n: Def.\n@end\n");
1958        assert!(
1959            d.iter().any(|x| x.code == Code::BadDefinitionList),
1960            "{:?}",
1961            d
1962        );
1963    }
1964
1965    #[test]
1966    fn dl_term_without_definition_is_b0505() {
1967        let (_, d) = p("@dl\nLonely term\n@end\n");
1968        assert!(
1969            d.iter().any(|x| x.code == Code::BadDefinitionList),
1970            "{:?}",
1971            d
1972        );
1973    }
1974
1975    #[test]
1976    fn dl_multiple_definitions_per_term_is_b0505() {
1977        let (_, d) = p("@dl\nTerm\n: First def.\n: Second def.\n@end\n");
1978        assert!(
1979            d.iter().any(|x| x.code == Code::BadDefinitionList),
1980            "{:?}",
1981            d
1982        );
1983    }
1984
1985    #[test]
1986    fn dl_empty_body_is_b0505() {
1987        let (_, d) = p("@dl\n@end\n");
1988        assert!(
1989            d.iter().any(|x| x.code == Code::BadDefinitionList),
1990            "{:?}",
1991            d
1992        );
1993    }
1994
1995    #[test]
1996    fn dl_unterminated_is_b0306() {
1997        let (_, d) = p("@dl\nTerm\n: Def.\n");
1998        assert!(
1999            d.iter().any(|x| x.code == Code::UnterminatedBlock),
2000            "{:?}",
2001            d
2002        );
2003    }
2004
2005    #[test]
2006    fn deep_list_nesting_is_b0318_not_crash() {
2007        let src: String = (0..6000)
2008            .map(|i| format!("{}- x\n", "  ".repeat(i)))
2009            .collect();
2010        let (_, d) = p(&src);
2011        assert!(d.iter().any(|x| x.code == Code::NestingTooDeep), "{:?}", d);
2012    }
2013
2014    #[test]
2015    fn deep_shortcode_nesting_is_b0318_not_crash() {
2016        let src = format!("{}x\n{}", "@details\n".repeat(6000), "@end\n".repeat(6000));
2017        let (_, d) = p(&src);
2018        assert!(d.iter().any(|x| x.code == Code::NestingTooDeep), "{:?}", d);
2019    }
2020
2021    #[test]
2022    fn deep_blockquote_markers_is_b0318_not_crash() {
2023        let src = format!("{} hi\n", ">".repeat(50_000));
2024        let (_, d) = p(&src);
2025        assert!(d.iter().any(|x| x.code == Code::NestingTooDeep), "{:?}", d);
2026    }
2027
2028    #[test]
2029    fn code_shortcode_body_is_verbatim() {
2030        let (doc, d) = p("@code(lang: rust)\nlet x = *ptr;\n@end\n");
2031        assert!(d.is_empty(), "{:?}", d);
2032        match &doc.blocks[0] {
2033            Block::CodeBlock { lang, body, .. } => {
2034                assert_eq!(lang.as_deref(), Some("rust"));
2035                assert_eq!(body, "let x = *ptr;");
2036            }
2037            b => panic!("expected CodeBlock, got {:?}", b),
2038        }
2039    }
2040
2041    #[test]
2042    fn code_shortcode_positional_lang_and_nested_fence() {
2043        let (doc, d) = p("@code(brief)\n```rust\nfn main() {}\n```\n@end\n");
2044        assert!(d.is_empty(), "{:?}", d);
2045        match &doc.blocks[0] {
2046            Block::CodeBlock { lang, body, .. } => {
2047                assert_eq!(lang.as_deref(), Some("brief"));
2048                assert_eq!(body, "```rust\nfn main() {}\n```");
2049            }
2050            b => panic!("expected CodeBlock, got {:?}", b),
2051        }
2052    }
2053
2054    #[test]
2055    fn code_shortcode_unknown_arg_is_b0406() {
2056        let (_, d) = p("@code(bogus: 1)\nx\n@end\n");
2057        assert!(d.iter().any(|x| x.code == Code::BadArgSyntax), "{:?}", d);
2058    }
2059
2060    #[test]
2061    fn code_shortcode_unterminated_is_b0306() {
2062        let (_, d) = p("@code\nx\n");
2063        assert!(
2064            d.iter().any(|x| x.code == Code::UnterminatedBlock),
2065            "{:?}",
2066            d
2067        );
2068    }
2069
2070    #[test]
2071    fn table_align_value_outside_enum_is_b0404() {
2072        let (_, d) = p("@t(align: [\"left\\\"><script>x</script>\", right])\n| A | B\n| 1 | 2\n");
2073        assert!(d.iter().any(|x| x.code == Code::BadEnumValue), "{:?}", d);
2074    }
2075
2076    #[test]
2077    fn table_align_valid_values_ok() {
2078        let (_, d) = p("@t(align: [left, right, center])\n| A | B | C\n| 1 | 2 | 3\n");
2079        assert!(d.is_empty(), "{:?}", d);
2080    }
2081
2082    #[test]
2083    fn four_dash_rule_is_b0304() {
2084        let (_, d) = p("----\n");
2085        assert!(
2086            d.iter().any(|x| x.code == Code::BadHorizontalRule),
2087            "{:?}",
2088            d
2089        );
2090    }
2091
2092    #[test]
2093    fn two_dash_rule_is_b0304() {
2094        let (_, d) = p("--\n");
2095        assert!(
2096            d.iter().any(|x| x.code == Code::BadHorizontalRule),
2097            "{:?}",
2098            d
2099        );
2100    }
2101
2102    #[test]
2103    fn three_dash_rule_is_ok() {
2104        let (doc, d) = p("---\n");
2105        assert!(d.is_empty(), "{:?}", d);
2106        assert!(matches!(doc.blocks[0], Block::HorizontalRule { .. }));
2107    }
2108
2109    #[test]
2110    fn dl_extra_space_after_colon_is_b0505() {
2111        let (_, d) = p("@dl\nTerm\n:  two spaces\n@end\n");
2112        assert!(
2113            d.iter().any(|x| x.code == Code::BadDefinitionList),
2114            "{:?}",
2115            d
2116        );
2117    }
2118
2119    #[test]
2120    fn dl_no_space_after_colon_is_b0505() {
2121        let (_, d) = p("@dl\nTerm\n:nospace\n@end\n");
2122        assert!(
2123            d.iter().any(|x| x.code == Code::BadDefinitionList),
2124            "{:?}",
2125            d
2126        );
2127    }
2128
2129    #[test]
2130    fn nesting_at_limit_is_ok() {
2131        let src: String = (0..MAX_NESTING_DEPTH)
2132            .map(|i| format!("{}- x\n", "  ".repeat(i)))
2133            .collect();
2134        let (_, d) = p(&src);
2135        assert!(d.is_empty(), "{:?}", d);
2136    }
2137}