Skip to main content

memstead_base/
section_format.rs

1//! Section-body reduction — the markdown half of the section-format
2//! vocabulary (agent-toolbox plan 08).
3//!
4//! Reduces a section body to its **top-level block sequence** with a
5//! real CommonMark parser (`pulldown-cmark`, no default features,
6//! GFM tables via the runtime `Options` flag). A line-scanner
7//! disagrees with CommonMark on exactly the constructs agents produce
8//! — lazy continuation lines, mixed bullet markers (`-` then `*` is
9//! *two* lists), indented code blocks containing `- `, GFM tables
10//! degrading to paragraphs on a malformed delimiter row — and a
11//! validator that disagrees with the renderer every agent uses sends
12//! repair loops that cannot converge. The parser is the referee.
13//!
14//! The reduction carries, per block kind, exactly the material the
15//! declaration surface checks: list items (text a renderer shows —
16//! lazy continuations joined by a single space), paragraph source
17//! lines, table header + row cells. The expression matching itself
18//! lives in `memstead_schema::content_expr` — this module only
19//! observes.
20
21use memstead_schema::content_expr::ObservedBlock;
22use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
23
24/// One top-level block of a section body: what it is, where it
25/// starts, and the per-kind material the format checks consume.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ReducedBlock {
28    pub observed: ObservedBlock,
29    /// 1-based source line of the block's first byte.
30    pub line: usize,
31    pub detail: BlockDetail,
32}
33
34/// Per-kind check material.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum BlockDetail {
37    None,
38    /// Each item: `(line, text)` — the item's inline text with lazy
39    /// continuation / soft breaks joined by a single space (the text
40    /// a renderer shows; continuation never changes an `item_pattern`
41    /// match). Nested blocks inside an item are not part of its text.
42    List {
43        items: Vec<(usize, String)>,
44    },
45    /// Each source line of the paragraph: `(line, text)`.
46    Paragraph {
47        lines: Vec<(usize, String)>,
48    },
49    /// Header cell texts plus each row's entry. `cells` come from the
50    /// parser (already padded/truncated to header width — GFM
51    /// normalizes silently); `raw_cell_count` is the REAL
52    /// pipe-delimited cell count of the source row line, so the
53    /// column contract can refuse what GFM would paper over.
54    Table {
55        header: Vec<String>,
56        rows: Vec<TableRow>,
57    },
58}
59
60/// One body row of a reduced table.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct TableRow {
63    pub line: usize,
64    /// Cell texts as the parser emits them (padded/truncated to the
65    /// header width).
66    pub cells: Vec<String>,
67    /// The source row's real pipe-delimited cell count.
68    pub raw_cell_count: usize,
69}
70
71/// A setext heading of depth 1–2 found anywhere in the body. The
72/// byte-class line guard (`^# ` / `^## `) cannot see these — only the
73/// real parser can — so the reduction reports them for the
74/// format-checked-section refusal.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct SetextReservedHeading {
77    pub line: usize,
78    pub depth: u8,
79}
80
81/// The reduced view of one section body.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ReducedSection {
84    pub blocks: Vec<ReducedBlock>,
85    /// Setext h1/h2 occurrences (ATX `#`/`##` are already refused by
86    /// the byte-class guard before any reduction runs).
87    pub setext_reserved: Vec<SetextReservedHeading>,
88}
89
90impl ReducedSection {
91    /// The observed block sequence, for expression matching.
92    pub fn observed(&self) -> Vec<ObservedBlock> {
93        self.blocks.iter().map(|b| b.observed.clone()).collect()
94    }
95}
96
97/// 1-based line number of a byte offset.
98fn line_of(source: &str, offset: usize) -> usize {
99    source.as_bytes()[..offset.min(source.len())]
100        .iter()
101        .filter(|&&b| b == b'\n')
102        .count()
103        + 1
104}
105
106fn heading_depth(level: HeadingLevel) -> u8 {
107    match level {
108        HeadingLevel::H1 => 1,
109        HeadingLevel::H2 => 2,
110        HeadingLevel::H3 => 3,
111        HeadingLevel::H4 => 4,
112        HeadingLevel::H5 => 5,
113        HeadingLevel::H6 => 6,
114    }
115}
116
117/// Reduce a section body to its top-level block sequence.
118pub fn reduce_section(source: &str) -> ReducedSection {
119    let mut options = Options::empty();
120    options.insert(Options::ENABLE_TABLES);
121
122    let mut blocks: Vec<ReducedBlock> = Vec::new();
123    let mut setext_reserved: Vec<SetextReservedHeading> = Vec::new();
124    // Nesting depth of container tags; a Start at depth 0 opens a
125    // top-level block.
126    let mut depth: usize = 0;
127    // Collector state for the currently-open top-level block.
128    let mut current: Option<ReducedBlock> = None;
129    // While inside a top-level list item: the item's start line, the
130    // merged source range of its own inline content (markers
131    // preserved — item_patterns are written against source shapes),
132    // how many block-level containers are open inside it, and whether
133    // its own (first) paragraph was seen.
134    struct ItemState {
135        line: usize,
136        span: Option<(usize, usize)>,
137        nested: usize,
138        own_paragraph_seen: bool,
139    }
140    let mut item: Option<ItemState> = None;
141    // While inside a table: header-cell mode, current row state.
142    let mut in_table_head = false;
143    let mut current_cell: Option<String> = None;
144    let mut current_row: Option<(usize, Vec<String>)> = None;
145    // Paragraph capture is source-based: remember the range start.
146    let mut para_range_start: usize = 0;
147
148    /// Is this tag a BLOCK container? Inline containers (emphasis,
149    /// links, …) are transparent for item-text collection.
150    fn is_block_tag(tag: &Tag) -> bool {
151        matches!(
152            tag,
153            Tag::Paragraph
154                | Tag::List(_)
155                | Tag::Item
156                | Tag::Table(_)
157                | Tag::CodeBlock(_)
158                | Tag::BlockQuote(_)
159                | Tag::Heading { .. }
160                | Tag::HtmlBlock
161                | Tag::FootnoteDefinition(_)
162        )
163    }
164
165    for (event, range) in Parser::new_ext(source, options).into_offset_iter() {
166        match event {
167            Event::Start(tag) => {
168                let line = line_of(source, range.start);
169                if depth == 0 {
170                    let observed = match &tag {
171                        Tag::Paragraph => {
172                            para_range_start = range.start;
173                            Some(ObservedBlock::Paragraph)
174                        }
175                        Tag::List(ordering) => Some(ObservedBlock::List {
176                            ordered: ordering.is_some(),
177                        }),
178                        Tag::Table(_) => Some(ObservedBlock::Table),
179                        Tag::CodeBlock(kind) => Some(ObservedBlock::Code {
180                            lang: match kind {
181                                CodeBlockKind::Fenced(info) => {
182                                    info.split_whitespace().next().unwrap_or("").to_string()
183                                }
184                                CodeBlockKind::Indented => String::new(),
185                            },
186                        }),
187                        Tag::BlockQuote(_) => Some(ObservedBlock::Blockquote),
188                        Tag::Heading { level, .. } => Some(ObservedBlock::Heading {
189                            depth: heading_depth(*level),
190                        }),
191                        Tag::HtmlBlock => Some(ObservedBlock::Html),
192                        _ => None,
193                    };
194                    if let Some(observed) = observed {
195                        let detail = match &observed {
196                            ObservedBlock::List { .. } => BlockDetail::List { items: Vec::new() },
197                            ObservedBlock::Paragraph => {
198                                BlockDetail::Paragraph { lines: Vec::new() }
199                            }
200                            ObservedBlock::Table => BlockDetail::Table {
201                                header: Vec::new(),
202                                rows: Vec::new(),
203                            },
204                            _ => BlockDetail::None,
205                        };
206                        current = Some(ReducedBlock {
207                            observed,
208                            line,
209                            detail,
210                        });
211                    }
212                }
213                // Setext h1/h2 detection anywhere in the body: a
214                // setext heading's source slice does not start with
215                // '#'.
216                if let Tag::Heading { level, .. } = &tag {
217                    let d = heading_depth(*level);
218                    if d <= 2 {
219                        let slice = &source[range.start..range.end.min(source.len())];
220                        if !slice.trim_start().starts_with('#') {
221                            setext_reserved.push(SetextReservedHeading { line, depth: d });
222                        }
223                    }
224                }
225                match &tag {
226                    Tag::Item if depth == 1 => {
227                        item = Some(ItemState {
228                            line,
229                            span: None,
230                            nested: 0,
231                            own_paragraph_seen: false,
232                        });
233                    }
234                    Tag::TableHead if depth == 1 => in_table_head = true,
235                    Tag::TableRow if depth == 1 => {
236                        current_row = Some((line, Vec::new()));
237                    }
238                    Tag::TableCell => current_cell = Some(String::new()),
239                    _ => {
240                        if let Some(st) = item.as_mut()
241                            && !is_block_tag(&tag)
242                            && st.nested == 0
243                            && current_cell.is_none()
244                        {
245                            // Inline container (emphasis, link, …):
246                            // its range covers marker + content, so
247                            // merging keeps the source markers in the
248                            // item text.
249                            merge_span(&mut st.span, range.start, range.end);
250                        }
251                        if let Some(st) = item.as_mut()
252                            && is_block_tag(&tag)
253                        {
254                            // The item's own first paragraph is
255                            // transparent; every other block-level
256                            // start inside the item is nesting.
257                            if matches!(tag, Tag::Paragraph)
258                                && st.nested == 0
259                                && !st.own_paragraph_seen
260                            {
261                                st.own_paragraph_seen = true;
262                            } else {
263                                st.nested += 1;
264                            }
265                        }
266                    }
267                }
268                depth += 1;
269            }
270            Event::End(tag_end) => {
271                depth -= 1;
272                match tag_end {
273                    TagEnd::Item if depth == 1 => {
274                        if let (Some(st), Some(block)) = (item.take(), current.as_mut())
275                            && let BlockDetail::List { items } = &mut block.detail
276                        {
277                            let text = st
278                                .span
279                                .map(|(a, b)| {
280                                    source[a..b.min(source.len())]
281                                        .lines()
282                                        .map(str::trim)
283                                        .filter(|l| !l.is_empty())
284                                        .collect::<Vec<_>>()
285                                        .join(" ")
286                                })
287                                .unwrap_or_default();
288                            items.push((st.line, text));
289                        }
290                    }
291                    TagEnd::TableHead if depth == 1 => in_table_head = false,
292                    TagEnd::TableRow if depth == 1 => {
293                        if let (Some((line, cells)), Some(block)) =
294                            (current_row.take(), current.as_mut())
295                            && let BlockDetail::Table { rows, .. } = &mut block.detail
296                        {
297                            let raw = raw_cell_count(source, line);
298                            rows.push(TableRow {
299                                line,
300                                cells,
301                                raw_cell_count: raw,
302                            });
303                        }
304                    }
305                    TagEnd::TableCell => {
306                        if let Some(cell) = current_cell.take() {
307                            let cell = cell.trim().to_string();
308                            if in_table_head {
309                                if let Some(block) = current.as_mut()
310                                    && let BlockDetail::Table { header, .. } = &mut block.detail
311                                {
312                                    header.push(cell);
313                                }
314                            } else if let Some((_, cells)) = current_row.as_mut() {
315                                cells.push(cell);
316                            }
317                        }
318                    }
319                    TagEnd::Paragraph => {
320                        if depth == 0
321                            && let Some(block) = current.as_mut()
322                            && let BlockDetail::Paragraph { lines } = &mut block.detail
323                        {
324                            let end = range.end.min(source.len());
325                            let slice = &source[para_range_start..end];
326                            let first_line = line_of(source, para_range_start);
327                            for (line_no, l) in (first_line..).zip(slice.lines()) {
328                                let t = l.trim();
329                                if !t.is_empty() {
330                                    lines.push((line_no, t.to_string()));
331                                }
332                            }
333                        }
334                        if let Some(st) = item.as_mut()
335                            && st.nested > 0
336                        {
337                            st.nested -= 1;
338                        }
339                    }
340                    TagEnd::List(_)
341                    | TagEnd::Item
342                    | TagEnd::Table
343                    | TagEnd::CodeBlock
344                    | TagEnd::BlockQuote(_)
345                    | TagEnd::Heading(_)
346                    | TagEnd::HtmlBlock
347                    | TagEnd::FootnoteDefinition => {
348                        if let Some(st) = item.as_mut()
349                            && st.nested > 0
350                        {
351                            st.nested -= 1;
352                        }
353                    }
354                    _ => {}
355                }
356                if depth == 0
357                    && let Some(block) = current.take()
358                {
359                    blocks.push(block);
360                }
361            }
362            Event::Rule if depth == 0 => {
363                blocks.push(ReducedBlock {
364                    observed: ObservedBlock::ThematicBreak,
365                    line: line_of(source, range.start),
366                    detail: BlockDetail::None,
367                });
368            }
369            Event::Text(t) | Event::Code(t) | Event::InlineHtml(t) => {
370                if let Some(cell) = current_cell.as_mut() {
371                    cell.push_str(&t);
372                } else if let Some(st) = item.as_mut()
373                    && st.nested == 0
374                {
375                    merge_span(&mut st.span, range.start, range.end);
376                }
377            }
378            Event::SoftBreak | Event::HardBreak => {
379                if let Some(cell) = current_cell.as_mut() {
380                    cell.push(' ');
381                } else if let Some(st) = item.as_mut()
382                    && st.nested == 0
383                {
384                    merge_span(&mut st.span, range.start, range.end);
385                }
386            }
387            _ => {}
388        }
389    }
390
391    ReducedSection {
392        blocks,
393        setext_reserved,
394    }
395}
396
397/// Merge an event's byte range into the item's inline span. The span
398/// is later sliced from the SOURCE, so inline markers (`**`, `` ` ``,
399/// `[…](…)`) survive exactly as the author wrote them — item patterns
400/// are written against source shapes. Inline-container markers sit
401/// between their children's ranges, so min/max merging covers them.
402fn merge_span(span: &mut Option<(usize, usize)>, start: usize, end: usize) {
403    *span = Some(match span {
404        None => (start, end),
405        Some((a, b)) => ((*a).min(start), (*b).max(end)),
406    });
407}
408
409/// The real pipe-delimited cell count of a table row's source line —
410/// GFM pads/truncates silently at parse time, so the parser's cell
411/// events cannot answer "did the author write the right number of
412/// cells". Counts unescaped `|` delimiters on the 1-based `line`.
413fn raw_cell_count(source: &str, line: usize) -> usize {
414    let Some(row) = source.lines().nth(line.saturating_sub(1)) else {
415        return 0;
416    };
417    let trimmed = row.trim();
418    let inner = trimmed
419        .strip_prefix('|')
420        .unwrap_or(trimmed)
421        .strip_suffix('|')
422        .unwrap_or(trimmed);
423    let mut count = 1;
424    let mut escaped = false;
425    for c in inner.chars() {
426        match c {
427            '\\' if !escaped => escaped = true,
428            '|' if !escaped => {
429                count += 1;
430                escaped = false;
431            }
432            _ => escaped = false,
433        }
434    }
435    count
436}
437
438// ---------------------------------------------------------------------------
439// Format evaluation
440// ---------------------------------------------------------------------------
441
442/// One violation of a section's declared format. The serde shape is
443/// the wire `details` payload of the corresponding refusal code —
444/// `SECTION_CONTENT_MISMATCH` / `SECTION_ITEM_PATTERN_MISMATCH` /
445/// `INVALID_TABLE_COLUMNS` — plus the reserved-setext case, which
446/// rides the pre-existing `SECTION_CONTENT_INVALID` family.
447#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
448#[serde(tag = "kind", rename_all = "snake_case")]
449pub enum SectionFormatViolation {
450    ContentMismatch {
451        section: String,
452        /// The declared expression, verbatim.
453        expected: String,
454        /// The observed top-level block sequence (display forms).
455        found: Vec<String>,
456        /// 1-based source line of the offending block (the line after
457        /// the last block when the body ended too early).
458        failed_at: usize,
459        /// Display forms of the terminals legal at that position.
460        expected_next: Vec<String>,
461        #[serde(skip_serializing_if = "Option::is_none")]
462        example: Option<String>,
463    },
464    ItemPatternMismatch {
465        section: String,
466        /// 0-based index of the offending unit (list item / paragraph
467        /// line) within its kind.
468        item_index: usize,
469        /// 1-based source line of the unit.
470        line: usize,
471        /// The unit's text as matched (items: lazy continuation
472        /// joined; paragraphs: the source line).
473        text: String,
474        /// The declared pattern, verbatim (anchoring is implicit).
475        pattern: String,
476        /// The pattern's named capture groups — the parts a
477        /// conforming unit would carry.
478        groups: Vec<String>,
479        #[serde(skip_serializing_if = "Option::is_none")]
480        example: Option<String>,
481    },
482    TableColumns {
483        section: String,
484        /// What went wrong: `header` (names/order mismatch),
485        /// `cell_count` (row width vs declared columns), or
486        /// `cell_pattern` (a cell failing its column's regex).
487        reason: String,
488        expected_columns: Vec<String>,
489        #[serde(skip_serializing_if = "Vec::is_empty")]
490        found_columns: Vec<String>,
491        #[serde(skip_serializing_if = "Option::is_none")]
492        row_line: Option<usize>,
493        #[serde(skip_serializing_if = "Option::is_none")]
494        expected_cells: Option<usize>,
495        #[serde(skip_serializing_if = "Option::is_none")]
496        found_cells: Option<usize>,
497        #[serde(skip_serializing_if = "Option::is_none")]
498        column: Option<String>,
499        #[serde(skip_serializing_if = "Option::is_none")]
500        pattern: Option<String>,
501        #[serde(skip_serializing_if = "Option::is_none")]
502        cell: Option<String>,
503        #[serde(skip_serializing_if = "Option::is_none")]
504        example: Option<String>,
505    },
506    /// A setext h1/h2 inside a format-checked section — the reserved
507    /// levels the byte-class line guard cannot see.
508    SetextReserved {
509        section: String,
510        line: usize,
511        depth: u8,
512    },
513}
514
515impl SectionFormatViolation {
516    /// The wire code of this violation's refusal.
517    pub fn code(&self) -> &'static str {
518        match self {
519            Self::ContentMismatch { .. } => "SECTION_CONTENT_MISMATCH",
520            Self::ItemPatternMismatch { .. } => "SECTION_ITEM_PATTERN_MISMATCH",
521            Self::TableColumns { .. } => "INVALID_TABLE_COLUMNS",
522            Self::SetextReserved { .. } => "SECTION_CONTENT_INVALID",
523        }
524    }
525
526    /// The declared conforming example, when the violation carries one.
527    pub fn example(&self) -> Option<&str> {
528        match self {
529            Self::ContentMismatch { example, .. }
530            | Self::ItemPatternMismatch { example, .. }
531            | Self::TableColumns { example, .. } => example.as_deref(),
532            Self::SetextReserved { .. } => None,
533        }
534    }
535
536    /// One-line human rendering for refusal message text.
537    pub fn describe(&self) -> String {
538        match self {
539            Self::ContentMismatch {
540                section,
541                expected,
542                found,
543                failed_at,
544                expected_next,
545                ..
546            } => format!(
547                "section '{section}' does not match its declared shape `{expected}` — found [{}], expected {} at line {failed_at}",
548                found.join(", "),
549                if expected_next.is_empty() {
550                    "end of section".to_string()
551                } else {
552                    expected_next.join(" | ")
553                },
554            ),
555            Self::ItemPatternMismatch {
556                section,
557                line,
558                text,
559                pattern,
560                ..
561            } => format!(
562                "section '{section}' line {line} does not match the declared item pattern `{pattern}`: {text}"
563            ),
564            Self::TableColumns {
565                section, reason, ..
566            } => format!("section '{section}' violates its table contract ({reason})"),
567            Self::SetextReserved {
568                section,
569                line,
570                depth,
571            } => format!(
572                "section '{section}' line {line} is a setext h{depth} heading — h1/h2 are the entity's own levels"
573            ),
574        }
575    }
576}
577
578/// Evaluate one section body against its declared format. Returns
579/// every violation in document order (the write path refuses with the
580/// first; health reports all). A section declaring no `content` — or
581/// one whose expression failed to compile, which the loader refuses
582/// anyway — produces no violations (free-form).
583pub fn check_section_format(
584    def: &memstead_schema::SectionDef,
585    body: &str,
586) -> Vec<SectionFormatViolation> {
587    let Some(expr) = def.compiled_content.as_ref() else {
588        return Vec::new();
589    };
590    let section = def.key.as_str();
591    let reduced = reduce_section(body);
592    let mut out: Vec<SectionFormatViolation> = Vec::new();
593
594    for setext in &reduced.setext_reserved {
595        out.push(SectionFormatViolation::SetextReserved {
596            section: section.to_string(),
597            line: setext.line,
598            depth: setext.depth,
599        });
600    }
601
602    let observed = reduced.observed();
603    if let Err(failure) = expr.match_blocks(&observed) {
604        let failed_at_line = reduced
605            .blocks
606            .get(failure.failed_at)
607            .map(|b| b.line)
608            .unwrap_or_else(|| reduced.blocks.last().map(|b| b.line + 1).unwrap_or(1));
609        out.push(SectionFormatViolation::ContentMismatch {
610            section: section.to_string(),
611            expected: expr.source().to_string(),
612            found: observed.iter().map(|b| b.display()).collect(),
613            failed_at: failed_at_line,
614            expected_next: failure.expected_next,
615            example: def.example.clone(),
616        });
617    }
618
619    if let Some(pattern_src) = &def.item_pattern
620        // The loader guarantees the pattern compiles and the content
621        // expression names exactly one of list/paragraph.
622        && let Ok(pattern) = regex::Regex::new(&format!("^(?:{pattern_src})$"))
623    {
624        let groups: Vec<String> = pattern
625            .capture_names()
626            .flatten()
627            .map(str::to_string)
628            .collect();
629        let targets_lists = expr.mentioned_names().contains(&"list");
630        let mut unit_index = 0usize;
631        for block in &reduced.blocks {
632            match &block.detail {
633                BlockDetail::List { items } if targets_lists => {
634                    for (line, text) in items {
635                        if !pattern.is_match(text) {
636                            out.push(SectionFormatViolation::ItemPatternMismatch {
637                                section: section.to_string(),
638                                item_index: unit_index,
639                                line: *line,
640                                text: text.clone(),
641                                pattern: pattern_src.clone(),
642                                groups: groups.clone(),
643                                example: def.example.clone(),
644                            });
645                        }
646                        unit_index += 1;
647                    }
648                }
649                BlockDetail::Paragraph { lines } if !targets_lists => {
650                    for (line, text) in lines {
651                        if !pattern.is_match(text) {
652                            out.push(SectionFormatViolation::ItemPatternMismatch {
653                                section: section.to_string(),
654                                item_index: unit_index,
655                                line: *line,
656                                text: text.clone(),
657                                pattern: pattern_src.clone(),
658                                groups: groups.clone(),
659                                example: def.example.clone(),
660                            });
661                        }
662                        unit_index += 1;
663                    }
664                }
665                _ => {}
666            }
667        }
668    }
669
670    if let Some(table_format) = &def.table {
671        for block in &reduced.blocks {
672            let BlockDetail::Table { header, rows } = &block.detail else {
673                continue;
674            };
675            if header != &table_format.columns {
676                out.push(SectionFormatViolation::TableColumns {
677                    section: section.to_string(),
678                    reason: "header".to_string(),
679                    expected_columns: table_format.columns.clone(),
680                    found_columns: header.clone(),
681                    row_line: None,
682                    expected_cells: None,
683                    found_cells: None,
684                    column: None,
685                    pattern: None,
686                    cell: None,
687                    example: def.example.clone(),
688                });
689                // A wrong header makes per-cell checks noise.
690                continue;
691            }
692            for row in rows {
693                if row.raw_cell_count != table_format.columns.len() {
694                    out.push(SectionFormatViolation::TableColumns {
695                        section: section.to_string(),
696                        reason: "cell_count".to_string(),
697                        expected_columns: table_format.columns.clone(),
698                        found_columns: Vec::new(),
699                        row_line: Some(row.line),
700                        expected_cells: Some(table_format.columns.len()),
701                        found_cells: Some(row.raw_cell_count),
702                        column: None,
703                        pattern: None,
704                        cell: None,
705                        example: def.example.clone(),
706                    });
707                    continue;
708                }
709                for (column, pattern_src) in &table_format.column_patterns {
710                    let Some(col_idx) = table_format.columns.iter().position(|c| c == column)
711                    else {
712                        continue;
713                    };
714                    let Some(cell) = row.cells.get(col_idx) else {
715                        continue;
716                    };
717                    let Ok(pattern) = regex::Regex::new(&format!("^(?:{pattern_src})$")) else {
718                        continue;
719                    };
720                    if !pattern.is_match(cell) {
721                        out.push(SectionFormatViolation::TableColumns {
722                            section: section.to_string(),
723                            reason: "cell_pattern".to_string(),
724                            expected_columns: table_format.columns.clone(),
725                            found_columns: Vec::new(),
726                            row_line: Some(row.line),
727                            expected_cells: None,
728                            found_cells: None,
729                            column: Some(column.clone()),
730                            pattern: Some(pattern_src.clone()),
731                            cell: Some(cell.clone()),
732                            example: def.example.clone(),
733                        });
734                    }
735                }
736            }
737        }
738    }
739
740    out.sort_by_key(|v| match v {
741        SectionFormatViolation::ContentMismatch { failed_at, .. } => *failed_at,
742        SectionFormatViolation::ItemPatternMismatch { line, .. } => *line,
743        SectionFormatViolation::TableColumns { row_line, .. } => row_line.unwrap_or(0),
744        SectionFormatViolation::SetextReserved { line, .. } => *line,
745    });
746    out
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752
753    fn observed(source: &str) -> Vec<ObservedBlock> {
754        reduce_section(source).observed()
755    }
756
757    #[test]
758    fn simple_bullet_list_is_one_block() {
759        assert_eq!(
760            observed("- one\n- two\n"),
761            vec![ObservedBlock::List { ordered: false }]
762        );
763        assert_eq!(
764            observed("1. one\n2. two\n"),
765            vec![ObservedBlock::List { ordered: true }]
766        );
767    }
768
769    /// Divergence pin (plan criterion 3): a lazy-continuation list is
770    /// ONE list, and the continuation joins the item text with a
771    /// single space.
772    #[test]
773    fn lazy_continuation_stays_one_list_and_joins_item_text() {
774        let src = "- **Kickoff** — Projektstart\n  mit allen Beteiligten — 2026-09-01\n- **Zwei** — kurz — 2026-09-02\n";
775        let reduced = reduce_section(src);
776        assert_eq!(
777            reduced.observed(),
778            vec![ObservedBlock::List { ordered: false }]
779        );
780        let BlockDetail::List { items } = &reduced.blocks[0].detail else {
781            panic!("list detail");
782        };
783        assert_eq!(items.len(), 2);
784        assert_eq!(
785            items[0].1,
786            "**Kickoff** — Projektstart mit allen Beteiligten — 2026-09-01",
787        );
788        assert_eq!(items[0].0, 1, "item line");
789        assert_eq!(items[1].0, 3);
790
791        // Truly lazy continuation (no indent) — same result.
792        let lazy = "- alpha\nbeta\n";
793        let reduced = reduce_section(lazy);
794        let BlockDetail::List { items } = &reduced.blocks[0].detail else {
795            panic!("list detail");
796        };
797        assert_eq!(items[0].1, "alpha beta");
798    }
799
800    /// Divergence pin: mixed `-` / `*` markers are TWO lists per
801    /// CommonMark — a scanner would see one.
802    #[test]
803    fn mixed_markers_are_two_lists() {
804        assert_eq!(
805            observed("- one\n* two\n"),
806            vec![
807                ObservedBlock::List { ordered: false },
808                ObservedBlock::List { ordered: false },
809            ]
810        );
811    }
812
813    /// Divergence pin: fenced or indented code containing `- ` lines
814    /// is code, not a list.
815    #[test]
816    fn code_blocks_containing_dashes_are_not_lists() {
817        assert_eq!(
818            observed("```\n- not a list\n```\n"),
819            vec![ObservedBlock::Code {
820                lang: String::new()
821            }]
822        );
823        assert_eq!(
824            observed("    - not a list\n"),
825            vec![ObservedBlock::Code {
826                lang: String::new()
827            }]
828        );
829        assert_eq!(
830            observed("```rust\nfn x() {}\n```\n"),
831            vec![ObservedBlock::Code {
832                lang: "rust".to_string()
833            }]
834        );
835    }
836
837    /// Divergence pin: a malformed GFM delimiter row degrades the
838    /// table to paragraphs — the parser decides, not a `|` scan.
839    #[test]
840    fn malformed_delimiter_row_is_not_a_table() {
841        let good = "| Name | Datum |\n| --- | --- |\n| a | b |\n";
842        assert_eq!(observed(good), vec![ObservedBlock::Table]);
843
844        let bad = "| Name | Datum |\n| -x- | --- |\n| a | b |\n";
845        assert!(
846            !observed(bad).contains(&ObservedBlock::Table),
847            "malformed delimiter row must not parse as a table: {:?}",
848            observed(bad)
849        );
850    }
851
852    #[test]
853    fn table_reduction_carries_header_and_row_cells() {
854        let src = "| Name | Beschreibung | Datum |\n| --- | --- | --- |\n| Kickoff | Start | 2026-09-01 |\n| Zwei | Kurz | 2026-09-02 |\n";
855        let reduced = reduce_section(src);
856        let BlockDetail::Table { header, rows } = &reduced.blocks[0].detail else {
857            panic!("table detail");
858        };
859        assert_eq!(header, &["Name", "Beschreibung", "Datum"]);
860        assert_eq!(rows.len(), 2);
861        assert_eq!(rows[0].cells, vec!["Kickoff", "Start", "2026-09-01"]);
862        assert_eq!(rows[0].raw_cell_count, 3);
863        assert_eq!(rows[1].line, 4, "row line number");
864    }
865
866    /// GFM pads/truncates mismatched row widths silently — the
867    /// reduction must preserve the REAL cell count so the column
868    /// contract can refuse it.
869    #[test]
870    fn table_rows_keep_their_real_cell_count() {
871        let src = "| A | B |\n| --- | --- |\n| only |\n| x | y | z |\n";
872        let reduced = reduce_section(src);
873        let BlockDetail::Table { rows, .. } = &reduced.blocks[0].detail else {
874            panic!("table detail");
875        };
876        // The parser pads/truncates to header width; the RAW counts
877        // preserve what the author actually wrote.
878        assert_eq!(rows[0].raw_cell_count, 1, "short row really has 1 cell");
879        assert_eq!(rows[1].raw_cell_count, 3, "long row really has 3 cells");
880    }
881
882    #[test]
883    fn paragraph_lines_carry_source_lines() {
884        let src = "erste zeile\nzweite zeile\n\nnächster absatz\n";
885        let reduced = reduce_section(src);
886        assert_eq!(
887            reduced.observed(),
888            vec![ObservedBlock::Paragraph, ObservedBlock::Paragraph]
889        );
890        let BlockDetail::Paragraph { lines } = &reduced.blocks[0].detail else {
891            panic!("paragraph detail");
892        };
893        assert_eq!(
894            lines,
895            &[
896                (1, "erste zeile".to_string()),
897                (2, "zweite zeile".to_string())
898            ]
899        );
900        let BlockDetail::Paragraph { lines } = &reduced.blocks[1].detail else {
901            panic!("paragraph detail");
902        };
903        assert_eq!(lines, &[(4, "nächster absatz".to_string())]);
904    }
905
906    #[test]
907    fn setext_headings_are_reported() {
908        let src = "Titel\n=====\n\ntext\n\nUnter\n-----\n";
909        let reduced = reduce_section(src);
910        assert_eq!(
911            reduced.setext_reserved,
912            vec![
913                SetextReservedHeading { line: 1, depth: 1 },
914                SetextReservedHeading { line: 6, depth: 2 },
915            ]
916        );
917        // ATX h3 is a heading block, not a setext report.
918        let reduced = reduce_section("### Phase 1\n- x\n");
919        assert!(reduced.setext_reserved.is_empty());
920        assert_eq!(
921            reduced.observed(),
922            vec![
923                ObservedBlock::Heading { depth: 3 },
924                ObservedBlock::List { ordered: false },
925            ]
926        );
927    }
928
929    #[test]
930    fn nested_list_text_stays_out_of_parent_item() {
931        let src = "- parent\n  - child\n- second\n";
932        let reduced = reduce_section(src);
933        assert_eq!(
934            reduced.observed(),
935            vec![ObservedBlock::List { ordered: false }]
936        );
937        let BlockDetail::List { items } = &reduced.blocks[0].detail else {
938            panic!("list detail");
939        };
940        assert_eq!(
941            items.iter().map(|(_, t)| t.as_str()).collect::<Vec<_>>(),
942            vec!["parent", "second"]
943        );
944    }
945
946    #[test]
947    fn blockquote_html_and_rule_reduce() {
948        assert_eq!(observed("> quoted\n"), vec![ObservedBlock::Blockquote]);
949        assert_eq!(observed("---\n"), vec![ObservedBlock::ThematicBreak]);
950        assert_eq!(observed("<div>\nx\n</div>\n"), vec![ObservedBlock::Html]);
951    }
952
953    /// End-to-end with the expression layer: the plan's own example
954    /// declaration accepts its own example snippet.
955    #[test]
956    fn plan_example_roundtrip() {
957        use memstead_schema::content_expr::ContentExpr;
958        let expr = ContentExpr::parse("(heading(3) list(bullet))+").unwrap();
959        let body = "### Phase 1\n- **Kickoff** — Projektstart mit allen Beteiligten — 2026-09-01\n";
960        assert!(expr.match_blocks(&reduce_section(body).observed()).is_ok());
961
962        let wrong = "### Phase 1\n\nkein listenpunkt\n";
963        let err = expr
964            .match_blocks(&reduce_section(wrong).observed())
965            .unwrap_err();
966        assert_eq!(err.failed_at, 1);
967        assert_eq!(err.expected_next, vec!["list(bullet)".to_string()]);
968    }
969}
970
971#[cfg(test)]
972mod check_tests {
973    use super::*;
974    use memstead_schema::{ConstraintSeverity, SectionDef, TableFormat};
975
976    fn def(
977        content: &str,
978        item_pattern: Option<&str>,
979        table: Option<TableFormat>,
980        example: Option<&str>,
981    ) -> SectionDef {
982        SectionDef {
983            key: "body".to_string(),
984            heading: "Body".to_string(),
985            required: true,
986            search_weight: 1.0,
987            catch_all: true,
988            write_rules: vec![],
989            description: None,
990            content: Some(content.to_string()),
991            item_pattern: item_pattern.map(str::to_string),
992            table,
993            example: example.map(str::to_string),
994            format_severity: ConstraintSeverity::Block,
995            compiled_content: Some(
996                memstead_schema::content_expr::ContentExpr::parse(content).unwrap(),
997            ),
998            format_problems: Vec::new(),
999        }
1000    }
1001
1002    #[test]
1003    fn content_mismatch_carries_position_expectation_and_example() {
1004        let d = def(
1005            "(heading(3) list(bullet))+",
1006            None,
1007            None,
1008            Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
1009        );
1010        let violations = check_section_format(&d, "### Phase 1\n\nprose statt liste\n");
1011        assert_eq!(violations.len(), 1);
1012        let SectionFormatViolation::ContentMismatch {
1013            failed_at,
1014            expected_next,
1015            found,
1016            example,
1017            ..
1018        } = &violations[0]
1019        else {
1020            panic!("expected content mismatch: {violations:?}");
1021        };
1022        assert_eq!(*failed_at, 3, "line of the offending paragraph");
1023        assert_eq!(expected_next, &vec!["list(bullet)".to_string()]);
1024        assert_eq!(
1025            found,
1026            &vec!["heading(3)".to_string(), "paragraph".to_string()]
1027        );
1028        assert!(example.as_deref().unwrap().contains("Kickoff"));
1029        assert_eq!(violations[0].code(), "SECTION_CONTENT_MISMATCH");
1030
1031        // Conforming body: no violations.
1032        assert!(check_section_format(&d, "### Phase 1\n- **Kickoff** — 2026-09-01\n").is_empty());
1033    }
1034
1035    #[test]
1036    fn item_pattern_flags_each_nonconforming_item_with_groups() {
1037        let d = def(
1038            "list(bullet)",
1039            Some(r"\*\*(?<name>[^*]+)\*\* — (?<datum>\d{4}-\d{2}-\d{2})"),
1040            None,
1041            None,
1042        );
1043        let ok = "- **Kickoff** — 2026-09-01\n- **Zwei** — 2026-09-02\n";
1044        assert!(check_section_format(&d, ok).is_empty());
1045
1046        // Lazy continuation still matches (joined by a single space).
1047        let lazy = "- **Kickoff** —\n  2026-09-01\n";
1048        assert!(
1049            check_section_format(&d, lazy).is_empty(),
1050            "continuation never changes the match: {:?}",
1051            check_section_format(&d, lazy)
1052        );
1053
1054        let bad = "- **Kickoff** — 2026-09-01\n- kein format\n";
1055        let violations = check_section_format(&d, bad);
1056        assert_eq!(violations.len(), 1);
1057        let SectionFormatViolation::ItemPatternMismatch {
1058            item_index,
1059            line,
1060            text,
1061            groups,
1062            ..
1063        } = &violations[0]
1064        else {
1065            panic!("expected item mismatch: {violations:?}");
1066        };
1067        assert_eq!(*item_index, 1);
1068        assert_eq!(*line, 2);
1069        assert_eq!(text, "kein format");
1070        assert_eq!(groups, &vec!["name".to_string(), "datum".to_string()]);
1071        assert_eq!(violations[0].code(), "SECTION_ITEM_PATTERN_MISMATCH");
1072    }
1073
1074    #[test]
1075    fn paragraph_pattern_checks_each_source_line() {
1076        // The anker two-halves citation shape — the left half may
1077        // contain spaces (the `bverfg:1 BvR 2649/21` case must pass).
1078        let d = def(
1079            "paragraph+",
1080            Some(r"(?<quelle>\S[^|]*?) \| (?<aussage>.+)"),
1081            None,
1082            None,
1083        );
1084        let ok = "bverfg:1 BvR 2649/21 Rn. 183 | Der Staat schuldet Schutz.\ngg:art-20a | Schutzauftrag.\n";
1085        assert!(
1086            check_section_format(&d, ok).is_empty(),
1087            "{:?}",
1088            check_section_format(&d, ok)
1089        );
1090        // The observed silent-deviation shape: missing ` | ` separator.
1091        let bad = "bverfg:1 BvR 2649/21 Rn. 183 — Der Staat schuldet Schutz.\n";
1092        let violations = check_section_format(&d, bad);
1093        assert_eq!(violations.len(), 1);
1094        assert!(matches!(
1095            &violations[0],
1096            SectionFormatViolation::ItemPatternMismatch { line: 1, .. }
1097        ));
1098    }
1099
1100    #[test]
1101    fn table_contract_enforces_columns_counts_and_cell_patterns() {
1102        let table = TableFormat {
1103            columns: vec!["Name".into(), "Datum".into()],
1104            column_patterns: [("Datum".to_string(), r"\d{4}-\d{2}-\d{2}".to_string())]
1105                .into_iter()
1106                .collect(),
1107        };
1108        let d = def("table", None, Some(table), None);
1109
1110        let ok = "| Name | Datum |\n| --- | --- |\n| Kickoff | 2026-09-01 |\n";
1111        assert!(check_section_format(&d, ok).is_empty());
1112
1113        // Wrong header order.
1114        let wrong_header = "| Datum | Name |\n| --- | --- |\n| 2026-09-01 | Kickoff |\n";
1115        let violations = check_section_format(&d, wrong_header);
1116        assert!(matches!(
1117            &violations[0],
1118            SectionFormatViolation::TableColumns { reason, .. } if reason == "header"
1119        ));
1120
1121        // Short row — GFM would silently pad it.
1122        let short = "| Name | Datum |\n| --- | --- |\n| nur-eine |\n";
1123        let violations = check_section_format(&d, short);
1124        let SectionFormatViolation::TableColumns {
1125            reason,
1126            expected_cells,
1127            found_cells,
1128            row_line,
1129            ..
1130        } = &violations[0]
1131        else {
1132            panic!("expected table violation: {violations:?}");
1133        };
1134        assert_eq!(reason, "cell_count");
1135        assert_eq!(*expected_cells, Some(2));
1136        assert_eq!(*found_cells, Some(1));
1137        assert_eq!(*row_line, Some(3));
1138        assert_eq!(violations[0].code(), "INVALID_TABLE_COLUMNS");
1139
1140        // Cell pattern violation names column, row, pattern.
1141        let bad_cell = "| Name | Datum |\n| --- | --- |\n| Kickoff | morgen |\n";
1142        let violations = check_section_format(&d, bad_cell);
1143        let SectionFormatViolation::TableColumns {
1144            reason,
1145            column,
1146            pattern,
1147            cell,
1148            row_line,
1149            ..
1150        } = &violations[0]
1151        else {
1152            panic!("expected cell violation: {violations:?}");
1153        };
1154        assert_eq!(reason, "cell_pattern");
1155        assert_eq!(column.as_deref(), Some("Datum"));
1156        assert!(pattern.as_deref().unwrap().contains("d{4}"));
1157        assert_eq!(cell.as_deref(), Some("morgen"));
1158        assert_eq!(*row_line, Some(3));
1159    }
1160
1161    /// The plenum coordinate grammar (plan criterion 9): the
1162    /// seven-times-duplicated two-halves Belegzeile — machine
1163    /// coordinate `<quelle>:<dokument>:<von>-<bis>:<hash12>:<hash12>`,
1164    /// ` | `, then the public Fundstelle — expressed as a declaration,
1165    /// without a line of project Python.
1166    #[test]
1167    fn plenum_coordinate_grammar_is_declarable() {
1168        let d = def(
1169            "paragraph+",
1170            Some(
1171                r"(?<quelle>[a-z]+):(?<dokument>[^:|]+):(?<von>\d+)-(?<bis>\d+):(?<dokument_hash>[0-9a-f]{12}):(?<span_hash>[0-9a-f]{12}) \| (?<fundstelle>.+)",
1172            ),
1173            None,
1174            None,
1175        );
1176        let ok = "btp:20/13/073:4559-4985:09b80726ef42:0a582b1c5530 | 2022-01-26 · Tino Chrupalla · https://dserver.bundestag.de/btp/20/20013.pdf
1177";
1178        assert!(
1179            check_section_format(&d, ok).is_empty(),
1180            "{:?}",
1181            check_section_format(&d, ok)
1182        );
1183        // Missing span hash — the checker's regex class, declared.
1184        let bad =
1185            "btp:20/13/073:4559-4985:09b80726ef42 | 2022-01-26 · Chrupalla · https://example.org
1186";
1187        assert_eq!(check_section_format(&d, bad).len(), 1);
1188        // Missing the two-halves separator.
1189        let bad = "btp:20/13/073:4559-4985:09b80726ef42:0a582b1c5530 2022-01-26
1190";
1191        assert_eq!(check_section_format(&d, bad).len(), 1);
1192    }
1193
1194    #[test]
1195    fn setext_reserved_headings_refuse_in_checked_sections() {
1196        let d = def("paragraph+", None, None, None);
1197        let violations = check_section_format(&d, "Titel\n=====\n\ntext\n");
1198        assert!(
1199            violations
1200                .iter()
1201                .any(|v| matches!(v, SectionFormatViolation::SetextReserved { depth: 1, .. })),
1202            "{violations:?}"
1203        );
1204        assert_eq!(
1205            violations
1206                .iter()
1207                .find(|v| matches!(v, SectionFormatViolation::SetextReserved { .. }))
1208                .unwrap()
1209                .code(),
1210            "SECTION_CONTENT_INVALID"
1211        );
1212    }
1213
1214    #[test]
1215    fn free_form_section_produces_no_violations() {
1216        let d = SectionDef {
1217            key: "body".to_string(),
1218            heading: "Body".to_string(),
1219            required: true,
1220            search_weight: 1.0,
1221            catch_all: true,
1222            write_rules: vec![],
1223            description: None,
1224            content: None,
1225            item_pattern: None,
1226            table: None,
1227            example: None,
1228            format_severity: ConstraintSeverity::Block,
1229            compiled_content: None,
1230            format_problems: Vec::new(),
1231        };
1232        assert!(check_section_format(&d, "anything\n=====\n\n- mixed\n* markers\n").is_empty());
1233    }
1234}