From 00105f80c86d2250228218e490109c6564ecec83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:16:44 +0000 Subject: [PATCH 1/2] fix(validate): refuse a block the block before it takes when written A tight item writes each block on the line after the one before. A block after a nested list, quote, or footnote ending in a paragraph was read back as that paragraph's lazy line, and one after a table, a blank-line HTML block, a definition, or a quote was read back inside it. Indented code after indented code or a footnote definition was read back inside it in any container. Validation now refuses these shapes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Kf5KPnmXitGzqA1hs1txs4 --- docs/plans/tight-item-lines/plan.md | 137 +++++++++++++ .../tight-item-lines/specs/validation.md | 134 +++++++++++++ src/parse.rs | 2 +- src/parse/blocks.rs | 21 ++ src/serialize.rs | 10 + src/validate.rs | 156 ++++++++++++--- tests/validate_regressions.rs | 180 ++++++++++++++++++ 7 files changed, 611 insertions(+), 29 deletions(-) create mode 100644 docs/plans/tight-item-lines/plan.md create mode 100644 docs/plans/tight-item-lines/specs/validation.md diff --git a/docs/plans/tight-item-lines/plan.md b/docs/plans/tight-item-lines/plan.md new file mode 100644 index 0000000..dda44b7 --- /dev/null +++ b/docs/plans/tight-item-lines/plan.md @@ -0,0 +1,137 @@ +# Blocks the block before them takes + +## Why + +A tight item writes each block on the line after the one before, and +validation checks only a block right after a paragraph. A block after a +nested list, a quote, or a footnote that ends in a paragraph is read back as +that paragraph's lazy line, and a block after a table, a `
`-style HTML +block, a definition, or a quote is read back inside it: `to_markdown` +returns Markdown that reads as another tree, and Settle cannot tell that it +should write the list loose. + +- Reported by Settle: a tight item holding a paragraph `a`, a tight list of + an item `b`, and a paragraph `c` is written `"- a\n - b\n c\n"`, where + `c` reads back as `b`'s lazy line. +- Scope: every pair of adjacent blocks in a tight item that the line join + merges, and indented code after indented code or a footnote definition in + any container, which no source spells either. Validation refuses them; the + writer is unchanged. + +## What changes + +### Validation +- `validation` › Shapes that cannot be written: modified — in a tight list + item, a block is refused after any block that takes the line written + after it, not only after a paragraph; in any container, an indented code + block is refused after an indented code block or a footnote definition + +### Outside the specs +- Settle (another repository), once this ships in a published release: bump + `markdown-syntax`; `save()` already writes loose the tight list a refusal's + path falls in, so a refused item is saved loose with no Settle code change. + +Specs: +- `validation` (modified) + +## Out of scope +- A block after a list whose first line reaches the list's last item's + content column (indented code, or an HTML block opening with spaces), in a + tight item or across a blank line — the source's marker width sets that + column, so parsed documents hold such pairs (`" - one\n\n two"`), + and refusing them would refuse documents the parser produces; it needs a + writer change, in its own plan. +- A paragraph whose text reads as syntax where it is written, such as `(t)` + after a definition without a title — text the builder answers for, by the + rule closing `validation` › Shapes that cannot be written. +- Writing such an item loose instead of refusing it — the serializer only + renders (`serialization` › Rendering only), and `tight` is part of the + tree a read-back compares. + +## Design + +### The rule + +For each pair of adjacent blocks `left`, `right` in a container (blank +lines skipped, as for adjacent lists), `right` is refused when `left` takes +its first line. Which lines a block takes is read from its kind and its last +descendants, by the parser's own checks. In any container: + +- an indented code block after an indented code block (which it continues) + or a footnote definition (whose content is indented four columns). + +In a tight item, which joins blocks by one line ending: + +| `left` | `right` refused when | +|---|---| +| `Paragraph` | it does not interrupt a paragraph as written (the current rule) | +| `Definition` | the same, except a paragraph, a definition, and a setext heading, which read the same after a definition's lines | +| `Table` | its first line starts no block where no paragraph is open: a paragraph, a definition, a table, a setext heading, an HTML block opening none; the line is read as a row | +| `HtmlBlock` that a blank line ends (block-level or lone tag) | always | +| `BlockQuote` or `Alert` | `right` is a `BlockQuote` or `Alert`: the `>` lines continue it | +| a container whose last line, through the last child of each container and the last item of each list, is a paragraph's or a definition's, or is an alert without blocks | the table's set, and indented code, which cannot start while the paragraph is open: the line is the paragraph's lazy line; except after a quote or alert on the way, a block opening with a backtick or a tilde, which ends the quote's paragraph (GH-19) | + +The parser checks block starts on a line after a nested container against +the matched container (the outer item), not the open paragraph, so an +ordered list from 2, an empty item, and a lone-tag HTML block start there: +the lazy and table rows use that check, not "interrupts a paragraph". + +Every other left block ends where its own syntax or line does. The descent +stops at a last `BlankLine` (a closed paragraph), at a nested empty item +(the line is written short of its content column), and at fenced code, +headings, directives, and HTML containers. + +The diagnostic's path is `right`'s, as for every shape among siblings, so +Settle's `save()` writes loose the innermost tight list around it, the +item's own list, which then writes the pair with a blank line between. + +### Architecture + +`validate_blocks` takes whether its container is a tight item and checks +each pair through `validate_taken_block`, which replaces +`validate_tight_item`. The tight table lives in `takes_next_line`, with +`starts_outside_paragraph` for the matched-container start check and +`ends_in_open_paragraph` for the descent. The descent follows only last +children, and a block reached on one left block's descent is a last child +and so is never another left block, so the walks stay linear in the tree. + +Parser helpers give the HTML kinds (`html_block_opens`, +`html_block_ends_at_blank_line`); the serializer gives +`written_opens_fence_like`, which writes the right block once. + +### Decisions +- Refuse in validation — because validation is the only check that refuses + (`serialization` › Invalid documents are rejected) and Settle already + writes a refused tight list loose. Turned down: writing the item loose, + which changes `tight` silently. +- A rule by kind, not a probe parse of the two blocks as written — because + validation judges shapes, and a probe writes and parses each pair at every + nesting level (quadratic in depth on untrusted trees, decision 0011). + Turned down: the source writer's probe (`starts_after_leaf`), which reads + the source's markers and needs the written first line. +- Refuse only shapes no source spells — because validation runs before the + source writer too, and refusing a parsed document would leave an editor + unable to save a note it opened. Turned down: refusing indented code after + a list, which wide source markers spell. + +### Test scope + +Hand-built tight items for each row, each refused tight and read back when +written loose; the blocks that start their own line after each row; and +parsed tight items holding those shapes (an ordered list from 2, a lone tag, +an empty item, indented code after a table, a fence-like paragraph after a +quote, a quote ending in a blank line). The fixtures and generated read-back +tests keep checking that parsed documents still write. + +## Tasks + +Release: the change ships as `fix` (a hand-built tree written wrongly is +now refused). The Settle bump follows once a published release holds it. + +### 1. Validation +- [x] 1.1 Each pair of the rule is refused and the nearby shapes accepted; verified by `tests/validate_regressions.rs` › `written_shapes` › `a_block_in_a_tight_item_is_not_taken_by_the_block_before_it` and `indented_code_is_not_taken_by_the_block_before_it`, naming `validation: Shapes that cannot be written`. + - Follows Design › The rule. +- [x] 1.2 The reported tree is refused with the path of its last paragraph; verified by `node_paths` › `a_paragraph_after_a_nested_list_in_a_tight_item_names_the_paragraph`, the spec change's scenario. + +### 2. Integration +- [x] 2.1 Every CI command in `CLAUDE.md` › Commands passes locally. diff --git a/docs/plans/tight-item-lines/specs/validation.md b/docs/plans/tight-item-lines/specs/validation.md new file mode 100644 index 0000000..3f747e6 --- /dev/null +++ b/docs/plans/tight-item-lines/specs/validation.md @@ -0,0 +1,134 @@ +# Validation — spec changes + +## MODIFIED Requirements + +### Requirement: Shapes that cannot be written +Validation SHALL reject exactly these shapes, each visible from the node, its +children, its sibling blocks and the last blocks they end in, how many +containers enclose it, or whether it sits in a table cell or a directive +label: + +- Block sequences: two adjacent `List`s in the same container written with + the same marker char; a list recording a delimiter of the other list kind is + written with `-` when unordered and `.` when ordered. An indented + `CodeBlock` after an indented `CodeBlock` or a `FootnoteDefinition` in the + same container, blank lines between them or not. +- In a tight list item, which writes each block on the line after the one + before, a block that the block before it takes as that line: + - after a `Paragraph`, a block that cannot interrupt it, by the parser's + own checks: a `Paragraph`, a `Definition`, an indented `CodeBlock`, a + `Frontmatter`, a `Heading` written as a setext heading, an ordered + `List` whose first item's number is not 1, a `List` whose first item has + no blocks or that the serializer starts on a line after its bullet (a + thematic break of the bullet's char, or content opening with one to + three spaces or a tab), or an `HtmlBlock` whose first line opens no HTML + block or opens one that only a blank line ends (a lone tag such as + ``); + - after a `Definition`, those blocks other than a `Paragraph`, a + `Definition`, and a setext `Heading`; + - after a `Table`, a block whose first line starts no block where no + paragraph is open: a `Paragraph`, a `Definition`, a `Table`, a `Heading` + written as a setext heading, or an `HtmlBlock` whose first line opens no + HTML block; + - after an `HtmlBlock` whose first line opens one that a blank line ends + (a block-level tag such as `
`, or a lone tag), any block; + - after a `BlockQuote` or an `Alert`, a `BlockQuote` or an `Alert`; + - after a `BlockQuote`, an `Alert`, a `FootnoteDefinition`, or a `List` + whose last line, through the last block of each and the last item of + each list, is a `Paragraph`'s or a `Definition`'s, or is an `Alert` + holding no blocks: the blocks after a `Table` and an indented + `CodeBlock`, which that paragraph takes as a lazy line, except, where a + `BlockQuote` or an `Alert` is passed on the way, a block written opening + with a backtick or a tilde, which ends the quote's paragraph. +- `Paragraph`: no children other than empty `Text`. +- `Heading`: a depth outside 1–6. +- `Table`: no rows; a header row with no cells; an alignment count other than + the header row's width; a row whose width differs from the header row's. +- `List`: no items; a loose list of one item holding at most one block. +- `ListItem`: a number beyond the parser's 9-digit marker limit; a number in + a bullet list. +- `BlankLine`: first or last in a footnote definition; first in a list item, + or last in a list's last item; in a tight list. +- `ListItem`: a task item (`checked` set) whose first block after its leading + `Definition`s is not a `Paragraph` with children. +- `Alert`: a title that is empty or starts or ends with a space or a tab. +- `CodeBlock`: an info string that is `Some("")`; an indented block whose + value is empty or whose first or last line is blank (only spaces and + tabs), where one line ending at the value's end closes its last line. +- `Frontmatter`: inside any container; a value holding a line that is its own + fence (`---` for YAML, `+++` for TOML, spaces and tabs after it aside). +- `Definition`: an identifier that is empty or holds only spaces, tabs, and + line endings; a label that is `^` followed by a footnote label, unless the + definition directly follows another `Definition`, is the first block of a + container, or sits 32 or more containers deep (block quotes, alerts, list + items, footnote definitions, HTML containers, and container directives). +- `Definition`, and a `Full` `LinkReference` or `ImageReference`: a label + that `[label]` does not read back as: one holding an unescaped `[` or `]`, + ending in a backslash that escapes its `]`, holding a blank line between + its first and last line, longer than 999 chars, or blank. +- `Definition`, `LinkReference`, and `ImageReference`: an identifier other + than the parser's normalization of the label. +- `FootnoteDefinition`, `FootnoteReference`, `LinkReference`, and + `ImageReference`: an empty identifier. +- `FootnoteDefinition` and `FootnoteReference`: an identifier other than the + parser's normalization of the label; a label that is not a footnote label + (empty, holding a space, a tab, a line ending, or an unescaped `[` or `]`, + or longer than 999 chars) or that ends in a backslash escaping its `]`. +- `Link`, `Image`, and `Definition`: an empty bare destination. +- `HtmlContainer`: an empty opening or closing tag name, opening and closing + tag names that differ, or an empty opening or closing tag source. +- `LeafDirective`, `ContainerDirective`, and `TextDirective`: a name that is + not one or more runs of ASCII letters joined by single `-` chars; an + attribute name that does not start with an ASCII letter, `_`, or `-`, or + holds a char other than ASCII letters, digits, `_`, `-`, and `:`. +- Inline content of a paragraph, a heading, a table cell, an HTML container, + a leaf or container directive label, or an emphasis-like container: a + `LineBreak` as its last inline. Link text, image alt text, an inline + footnote, and a text directive label close with a `]` and may end with one. +- Inline content: a `Text` holding a line ending; a `SoftBreak`, or a + `LineBreak` of trailing spaces, right after a `SoftBreak` or a `LineBreak`; + two adjacent `Delete`s. +- `Emphasis`, `Strong`, `Delete`, and `Mark`: no children other than empty + `Text`; content that, past empty `Text`, starts with a Unicode whitespace + char, a `SoftBreak`, or a `LineBreak` of trailing spaces, or ends with a + Unicode whitespace char, a `SoftBreak`, or a `LineBreak`; an + `Emphasis` or a `Strong` whose only child is an `Emphasis` with the same + delimiter, a `Delete` whose only child is a `Delete`, and a `Mark` whose + only child is a `Mark`. +- `InlineFootnote`: no children. +- The text of a `Link` or `LinkReference`: a `Link`, `Autolink`, + `LinkReference`, or `WikiLink` at any depth. +- `Autolink`: a text that is not exactly one autolink of its form, so that + `Autolink::destination()` returns `None`. +- `Escape`: a value that is not an ASCII punctuation char. +- `CharacterReference`: a reference that is not exactly one character + reference. +- `Shortcode`: a name not in the crate's pinned gemoji table. +- `WikiLink`: an empty target. +- `Tag`: a name that is not one Unicode letter followed by Unicode letters, + Unicode numbers, `/`, `_`, or `-`. +- `CodeInline`: an empty value, or a value holding a line ending. +- `MathInline`: an empty value; a dollar fence of length 0; code-form math + whose value holds a backtick followed by `$`; dollar math that, written + alone behind its fence, the parser does not read back as the same value and + fence: a fence of three or more, a `$` that closes it early, whitespace at + an edge of single-`$` math, or a line ending. +- In a table cell, or in the label of a `LeafDirective` or a + `ContainerDirective`, at any depth: a `SoftBreak` or a `LineBreak`. +- In a table cell, at any depth: a `CodeInline`, `MathInline`, or `Html` + value, an `Autolink` text, a `LinkReference`, `ImageReference`, or + `FootnoteReference` label, or a `WikiLink` target or label that holds a `|` + right after an odd run of backslashes. + +Any other shape is not checked, including one whose spelling depends on the +characters around it or that only the builder can know is meant literally +(punctuation meant literally is built as `Escape` nodes), and a block after a +`List` whose first line, written four columns in or opening with spaces, +reaches the content column of the list's last item, which in a parsed +document the source's markers set: the builder answers +for it, and the serializer writes it as it is even when the output reads back +differently. + +#### Scenario: A paragraph after a nested list in a tight item +- **WHEN** a hand-built document holds a tight list whose item holds a paragraph `a`, a tight list of one item holding a paragraph `b`, and a paragraph `c`, and `validate()` runs +- **THEN** it returns one diagnostic whose `path` is `[0, 0, 2]`, the paragraph `c`, which `"- a\n - b\n c\n"` would read as `b`'s lazy line diff --git a/src/parse.rs b/src/parse.rs index 436ba86..2b762b0 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -28,7 +28,7 @@ mod source_map; mod tags; mod tokens; -pub(crate) use blocks::interrupts_paragraph; +pub(crate) use blocks::{html_block_ends_at_blank_line, html_block_opens, interrupts_paragraph}; use source_map::{DerivedText, Segment, SourceMap}; /// The largest ordered-list item number: a marker holds at most 9 digits. diff --git a/src/parse/blocks.rs b/src/parse/blocks.rs index 3b81d4f..d4e015a 100644 --- a/src/parse/blocks.rs +++ b/src/parse/blocks.rs @@ -2178,6 +2178,27 @@ pub(crate) fn interrupts_paragraph(block: &Block) -> bool { } } +/// The kind of HTML block the first line of `node`'s value opens, if any. +fn written_html_block_kind(node: &HtmlBlock) -> Option { + let first_line = node.value.split(['\n', '\r']).next().unwrap_or_default(); + html_block_start(trim_ascii_start(first_line)) +} + +/// Whether the first line of `node`'s value opens an HTML block. +pub(crate) fn html_block_opens(node: &HtmlBlock) -> bool { + written_html_block_kind(node).is_some() +} + +/// Whether an HTML block, written as `node`'s value, takes every line after +/// it up to a blank line: one its first line opens with a block-level tag or +/// a lone tag. +pub(crate) fn html_block_ends_at_blank_line(node: &HtmlBlock) -> bool { + matches!( + written_html_block_kind(node), + Some(HtmlBlockKind::BlockTag | HtmlBlockKind::UntilBlank) + ) +} + /// The delimiter and width of the list item marker `rest` opens with, when /// a space, a tab, or the line's end follows it. pub(super) fn list_marker_head(rest: &str) -> Option<(ListDelimiter, usize)> { diff --git a/src/serialize.rs b/src/serialize.rs index 0d328ad..d421362 100644 --- a/src/serialize.rs +++ b/src/serialize.rs @@ -1169,6 +1169,16 @@ fn item_number(item: &ListItem, previous: &mut Option) -> u64 { number } +/// Whether `block`, as the serializer writes it with default options, opens +/// with a backtick or a tilde after fewer than four spaces: a line the +/// parser does not take as a quote's paragraph's lazy line. +pub(crate) fn written_opens_fence_like(block: &Block) -> bool { + write_block(block, &SerializeOptions::default()).is_ok_and(|written| { + let content = written.trim_start_matches(' '); + written.len() - content.len() < 4 && content.starts_with(['`', '~']) + }) +} + /// One item of `list`, written with `marker_char`; `previous` is the number /// the item before it was written with. fn write_item( diff --git a/src/validate.rs b/src/validate.rs index df391f2..2d13d5e 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -13,10 +13,14 @@ use crate::{ }, diagnostic::Diagnostic, parse::{ - alert_title, frontmatter_fence_kind, is_blank, is_footnote_label, is_reference_label, - is_written_footnote_label, normalize_label, reads_as_inline_math, MAX_BLOCK_NESTING, + alert_title, frontmatter_fence_kind, html_block_ends_at_blank_line, html_block_opens, + is_blank, is_footnote_label, is_reference_label, is_written_footnote_label, + normalize_label, reads_as_inline_math, MAX_BLOCK_NESTING, + }, + serialize::{ + write_inline_math, writes_setext, written_after_paragraph_interrupts, written_marker, + written_opens_fence_like, }, - serialize::{write_inline_math, written_after_paragraph_interrupts, written_marker}, span::Span, }; @@ -30,7 +34,7 @@ impl Document { pub(crate) fn validate_document(document: &Document) -> Vec { let mut report = Report::default(); - validate_blocks(&document.children, 0, &mut report); + validate_blocks(&document.children, 0, false, &mut report); report.diagnostics } @@ -61,7 +65,7 @@ impl Report { /// blocks as the parser's nesting limit counts them: block quotes and alerts, /// list items, footnote definitions, HTML containers, and container /// directives. -fn validate_blocks(blocks: &[Block], depth: usize, diagnostics: &mut Report) { +fn validate_blocks(blocks: &[Block], depth: usize, tight: bool, diagnostics: &mut Report) { // Blank lines beyond the separator change how no block reads, so the // block before each one is the last one before them. let mut before: Option<&Block> = None; @@ -75,6 +79,9 @@ fn validate_blocks(blocks: &[Block], depth: usize, diagnostics: &mut Report) { )); } } + if let Some(before) = before { + validate_taken_block(before, block, tight, diagnostics); + } if let Block::Definition(definition) = block { // A line indented four columns or more continues a paragraph, // and starts one on the line after an alert's marker, which may @@ -107,7 +114,7 @@ fn validate_block(block: &Block, depth: usize, diagnostics: &mut Report) { } Block::Heading(heading) => validate_heading(heading, diagnostics), Block::BlockQuote(block_quote) => { - validate_blocks(&block_quote.children, depth + 1, diagnostics); + validate_blocks(&block_quote.children, depth + 1, false, diagnostics); } Block::Alert(alert) => validate_alert(alert, depth, diagnostics), Block::List(list) => validate_list(list, depth, diagnostics), @@ -140,7 +147,7 @@ fn validate_block(block: &Block, depth: usize, diagnostics: &mut Report) { )) }); } - validate_blocks(&definition.children, depth + 1, diagnostics); + validate_blocks(&definition.children, depth + 1, false, diagnostics); } // Checked with its siblings in `validate_blocks`. Block::Definition(_) => {} @@ -192,7 +199,7 @@ fn validate_html_container(container: &HtmlContainer, depth: usize, diagnostics: match &container.content { HtmlContainerContent::Blocks(children) => { - validate_blocks(children, depth + 1, diagnostics); + validate_blocks(children, depth + 1, false, diagnostics); } HtmlContainerContent::Inlines(children) => validate_inlines(children, diagnostics), } @@ -219,7 +226,7 @@ fn validate_alert(alert: &Alert, depth: usize, diagnostics: &mut Report) { )); } } - validate_blocks(&alert.children, depth + 1, diagnostics); + validate_blocks(&alert.children, depth + 1, false, diagnostics); } fn validate_list(list: &List, depth: usize, diagnostics: &mut Report) { @@ -249,10 +256,7 @@ fn validate_list(list: &List, depth: usize, diagnostics: &mut Report) { validate_item_number(list, item, diagnostics); validate_item_blank_lines(list, index, item, diagnostics); validate_task_item(item, diagnostics); - if list.tight { - validate_tight_item(item, diagnostics); - } - validate_blocks(&item.children, depth + 1, diagnostics); + validate_blocks(&item.children, depth + 1, list.tight, diagnostics); }); } } @@ -275,21 +279,117 @@ fn validate_task_item(item: &ListItem, diagnostics: &mut Report) { } } -/// A tight item writes each block on the line after the one before; after a -/// paragraph's line, a block that cannot interrupt the paragraph continues it. -fn validate_tight_item(item: &ListItem, diagnostics: &mut Report) { - for (at, pair) in item.children.windows(2).enumerate() { - let [Block::Paragraph(_), block] = pair else { - continue; - }; - if !written_after_paragraph_interrupts(block) { - diagnostics.at(at + 1, |diagnostics| { - diagnostics.push(Diagnostic::invalid( - block.span(), - "in a tight list item, a block after a paragraph must be able to interrupt it", - )) - }); +/// Refuses `right` where `left`, the block before it, takes its first line: +/// an indented code block is written four columns in, which some blocks take +/// across a blank line (see [`takes_indented_code`]), and a tight item writes +/// each block on the line after the one before (see [`takes_next_line`]). +fn validate_taken_block(left: &Block, right: &Block, tight: bool, diagnostics: &mut Report) { + let message = if matches!(right, Block::CodeBlock(code) if code.kind == CodeBlockKind::Indented) + && takes_indented_code(left) + { + "an indented code block must not be read as part of the block before it" + } else if tight && matches!(left, Block::Paragraph(_)) && takes_next_line(left, right) { + "in a tight list item, a block after a paragraph must be able to interrupt it" + } else if tight && takes_next_line(left, right) { + "in a tight list item, a block must not be read as part of the block before it" + } else { + return; + }; + diagnostics.push(Diagnostic::invalid(right.span(), message)); +} + +/// Whether `left` takes the lines of an indented code block written after +/// it, on the next line or after blank lines: an indented code block's, which +/// they continue, and a footnote definition's, whose content is indented four +/// columns. A list's last item takes them only where its content starts at +/// most four columns in, which the source's marker sets, so a parsed list +/// before indented code is written as it reads only from the source. +fn takes_indented_code(left: &Block) -> bool { + match left { + Block::CodeBlock(code) => code.kind == CodeBlockKind::Indented, + Block::FootnoteDefinition(_) => true, + _ => false, + } +} + +/// Whether `left`, written on the line before `right` in the same +/// container, takes `right`'s first line, by the parser's own checks: a +/// paragraph takes a line that does not interrupt it; a table, and a +/// container whose last line, through its last block and last item, is a +/// paragraph's (which takes it lazily), take a line that starts no block +/// where no paragraph is the line's container; an HTML block that a blank +/// line ends takes every line; and a quote's `>` lines continue a quote +/// before it. Every other block ends where its own syntax or line does. +fn takes_next_line(left: &Block, right: &Block) -> bool { + match left { + Block::Paragraph(_) => !written_after_paragraph_interrupts(right), + // A definition's lines are a paragraph's until it is read, so a + // line that cannot interrupt one continues them; only a paragraph, + // a definition, and a setext heading read the same after it. + Block::Definition(_) => { + !written_after_paragraph_interrupts(right) + && !matches!(right, Block::Paragraph(_) | Block::Definition(_)) + && !matches!(right, Block::Heading(heading) if writes_setext(heading)) + } + Block::Table(_) => !starts_outside_paragraph(right, false), + Block::HtmlBlock(node) => html_block_ends_at_blank_line(node), + Block::BlockQuote(_) | Block::Alert(_) + if matches!(right, Block::BlockQuote(_) | Block::Alert(_)) => + { + true } + _ => match ends_in_open_paragraph(left) { + // A line opening like a fence ends a quote's paragraph instead of + // continuing it. + Some(through_quote) => { + !starts_outside_paragraph(right, true) + && !(through_quote && written_opens_fence_like(right)) + } + None => false, + }, + } +} + +/// Whether `block`'s first line, written where the line's container is not +/// a paragraph, starts a block: a paragraph's, a definition's, a setext +/// heading's, and a table's first line are text, and an HTML block's starts +/// one only where it opens one. With `lazy`, a paragraph (or an alert's +/// marker line) is still the last open block, where no indented code +/// starts. +fn starts_outside_paragraph(block: &Block, lazy: bool) -> bool { + match block { + Block::Paragraph(_) | Block::Definition(_) | Block::Table(_) => false, + Block::Heading(heading) => !writes_setext(heading), + Block::HtmlBlock(node) => html_block_opens(node), + Block::CodeBlock(code) => !(lazy && code.kind == CodeBlockKind::Indented), + _ => true, + } +} + +/// Whether `block` is a container whose last line, through the last block +/// of each container and the last item of each list, is a paragraph's or a +/// definition's, or is the marker line of an alert holding no blocks: a +/// line after it that starts no block continues that paragraph lazily. When +/// it is, whether a block quote or an alert is passed on the way. Only last +/// children are visited, which no other pair of a container starts from, so +/// the walks over a tree stay linear. +fn ends_in_open_paragraph(block: &Block) -> Option { + let mut block = block; + let mut nested = false; + let mut through_quote = false; + loop { + let children = match block { + Block::Paragraph(_) | Block::Definition(_) => return nested.then_some(through_quote), + Block::Alert(alert) if alert.children.is_empty() => return Some(true), + Block::BlockQuote(quote) => "e.children, + Block::Alert(alert) => &alert.children, + Block::FootnoteDefinition(footnote) => &footnote.children, + Block::List(list) => &list.children.last()?.children, + _ => return None, + }; + through_quote |= matches!(block, Block::BlockQuote(_) | Block::Alert(_)); + block = children.last()?; + nested = true; } } @@ -416,7 +516,7 @@ fn validate_container_directive( "a container directive label cannot hold a line break", diagnostics, ); - validate_blocks(&directive.children, depth + 1, diagnostics); + validate_blocks(&directive.children, depth + 1, false, diagnostics); } /// A definition's label is written as a reference label, and its identifier diff --git a/tests/validate_regressions.rs b/tests/validate_regressions.rs index b15954f..45b8095 100644 --- a/tests/validate_regressions.rs +++ b/tests/validate_regressions.rs @@ -1311,6 +1311,174 @@ mod written_shapes { assert!(valid(&parsed("- a\n # h\n 1. b\n
"))); } + /// The first block `source` parses as, without its span. + fn block(source: &str) -> Block { + parse(source).document.without_source().children.remove(0) + } + + /// A tight or loose item holding `blocks`. + fn holding(tight: bool, blocks: Vec) -> Document { + document(vec![list(tight, vec![item(None, blocks)])]) + } + + /// Whether `document` writes Markdown that reads back as it. + fn reads_back(document: &Document) -> bool { + let written = document.to_markdown().expect("document serializes"); + super::normalize::normalized(&parse(&written).document.children) + == super::normalize::normalized(&document.children) + } + + /// A tight item writes each block on the line after the one before, + /// which a table, an HTML block a blank line ends, a quote before a + /// quote, a definition, and a container ending in a paragraph (lazily) + /// can take. Each refused pair is written loose instead and reads back. + // validation: Shapes that cannot be written + #[test] + fn a_block_in_a_tight_item_is_not_taken_by_the_block_before_it() { + let mut setext = Heading::new(1, [Text::from("h")]); + setext.kind = HeadingKind::Setext; + let setext = Block::from(setext); + let lazy_lefts = [ + "- b", + "1. b", + "- b\n\n c", + "> b", + "> [!NOTE]", + "> [!NOTE]\n> b", + "[^1]: b", + "> [x]: /u", + "- > - [^1]: > b", + ]; + let mut refused: Vec<(&str, Block)> = Vec::new(); + for left in lazy_lefts { + for right in [ + paragraph(vec![text("c")]), + definition("y", "y"), + block("| x |\n| - |"), + setext.clone(), + html_block("x"), + indented_code("z\n"), + ] { + refused.push((left, right)); + } + } + for right in [ + paragraph(vec![text("c")]), + definition("y", "y"), + block("| x |\n| - |"), + setext.clone(), + ] { + refused.push(("| a |\n| - |", right)); + } + for right in [ + paragraph(vec![text("c")]), + block("# h"), + block("> q"), + block("```\nz\n```"), + ] { + refused.push(("
", right.clone())); + refused.push(("", right)); + } + for left in ["> b", "> [!NOTE]\n> b", "> - b"] { + refused.push((left, block("> q"))); + refused.push((left, block("> [!TIP]\n> q"))); + } + for right in [ + html_block(""), + indented_code("z\n"), + ordered(2, vec![item(None, vec![paragraph(vec![text("x")])])]), + list(true, vec![item(None, vec![])]), + ] { + refused.push(("[x]: /u", right)); + } + for (left, right) in refused { + let blocks = vec![paragraph(vec![text("a")]), block(left), right]; + assert!( + invalid(&holding(true, blocks.clone())), + "{left:?} {:?}", + blocks[2] + ); + // Indented code reaches some blocks across a blank line too + // (see the next test, and lists, which validation does not + // judge), and an HTML block that opens none reads as a + // paragraph anywhere. + match &blocks[2] { + Block::CodeBlock(code) if code.kind == CodeBlockKind::Indented => continue, + _ => {} + } + let loose_differs = matches!(&blocks[2], Block::HtmlBlock(_)); + let loose = holding(false, blocks); + assert!( + valid(&loose) && (loose_differs || reads_back(&loose)), + "{left:?} {:?} {:?}", + loose.validate(), + loose.to_markdown() + ); + } + // Blocks that start their own line after each of those. + for (left, right) in [ + ("- b", block("2. x")), + ("- b", block("* x")), + ("- b", html_block("")), + ("- b", block("# h")), + ("- b", block("***")), + ("- b", block("```\nz\n```")), + ("> b", block("- x")), + ("> b", block("[^2]: c")), + ("> [!NOTE]", block("$$\nm\n$$")), + ("| a |\n| - |", indented_code("z\n")), + ("| a |\n| - |", html_block("")), + ("| a |\n| - |", list(true, vec![item(None, vec![])])), + ("", paragraph(vec![text("c")])), + ("[x]: /u", paragraph(vec![text("c")])), + ("[x]: /u", setext.clone()), + ("[^1]: b", block("[^2]: c")), + ("- ```\n b\n ```", paragraph(vec![text("c")])), + ("> b\n>", paragraph(vec![text("c")])), + ("> b", paragraph(vec![text("``` a`b")])), + ("> b", paragraph(vec![Inline::Code(CodeInline::new("c"))])), + (":::d\nb\n:::", paragraph(vec![text("c")])), + ] { + let document = holding(true, vec![block(left), right]); + assert!(valid(&document) && reads_back(&document), "{left:?}"); + } + // Parsed tight items holding those shapes. + for source in [ + "- a\n - b\n 2. c\n", + "- a\n - b\n \n", + "- a\n > b\n -\n", + "- a\n | x |\n | - |\n code\n", + "- a\n > b\n ``` a`b\n", + "- a\n - > b\n `c`\n", + "- a\n > b\n >\n c\n", + "- [x]: /u\n c\n", + ] { + assert!(valid(&parsed(source)), "{source:?}"); + } + } + + /// An indented code block continues one before it, and is a footnote + /// definition's content, across blank lines too. + // validation: Shapes that cannot be written + #[test] + fn indented_code_is_not_taken_by_the_block_before_it() { + for left in [indented_code("a\n"), block("[^1]: b"), block("[^1]:")] { + for blocks in [ + vec![left.clone(), indented_code("z\n")], + vec![ + left.clone(), + Block::BlankLine(BlankLine::new()), + indented_code("z\n"), + ], + ] { + assert!(invalid(&document(blocks.clone())), "{blocks:?}"); + assert!(invalid(&document(vec![quoted(blocks)]))); + } + } + assert!(valid(&parsed(" a\n\n```\nz\n```"))); + assert!(valid(&parsed("[^1]: b\n\n z"))); + } + #[test] fn a_list_holds_an_item() { assert!(invalid(&document(vec![list(true, vec![])]))); @@ -1627,6 +1795,18 @@ mod node_paths { assert_eq!(paths(&document), [Some(NodePath::from([0, 0, 1]))]); } + // validation: Shapes that cannot be written + #[test] + fn a_paragraph_after_a_nested_list_in_a_tight_item_names_the_paragraph() { + let document = Document::new(vec![List::new([ListItem::new([ + Block::from(paragraph("a")), + List::new([ListItem::new([paragraph("b")])]).into(), + paragraph("c").into(), + ])]) + .into()]); + assert_eq!(paths(&document), [Some(NodePath::from([0, 0, 2]))]); + } + // validation: Validate reports invalid shapes #[test] fn adjacent_lists_with_one_marker_name_the_later_list() { From e31b7c495606afb7020faf16487074d818f04e28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:31:39 +0000 Subject: [PATCH 2/2] fix(validate): refuse any block after an HTML block its value leaves open, and archive the plan Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Kf5KPnmXitGzqA1hs1txs4 --- .../2026-10-18-tight-item-lines}/plan.md | 53 ++++--- .../tight-item-lines/specs/validation.md | 134 ------------------ docs/specs/validation.md | 58 ++++++-- src/parse.rs | 4 +- src/parse/blocks.rs | 15 ++ src/validate.rs | 13 +- tests/validate_regressions.rs | 36 +++++ 7 files changed, 132 insertions(+), 181 deletions(-) rename docs/{plans/tight-item-lines => archive/2026-10-18-tight-item-lines}/plan.md (71%) delete mode 100644 docs/plans/tight-item-lines/specs/validation.md diff --git a/docs/plans/tight-item-lines/plan.md b/docs/archive/2026-10-18-tight-item-lines/plan.md similarity index 71% rename from docs/plans/tight-item-lines/plan.md rename to docs/archive/2026-10-18-tight-item-lines/plan.md index dda44b7..bd79dcc 100644 --- a/docs/plans/tight-item-lines/plan.md +++ b/docs/archive/2026-10-18-tight-item-lines/plan.md @@ -14,8 +14,9 @@ should write the list loose. an item `b`, and a paragraph `c` is written `"- a\n - b\n c\n"`, where `c` reads back as `b`'s lazy line. - Scope: every pair of adjacent blocks in a tight item that the line join - merges, and indented code after indented code or a footnote definition in - any container, which no source spells either. Validation refuses them; the + merges, and, in any container, indented code after indented code or a + footnote definition and any block after an HTML block its value leaves + open, which no source spells either. Validation refuses them; the writer is unchanged. ## What changes @@ -24,7 +25,8 @@ should write the list loose. - `validation` › Shapes that cannot be written: modified — in a tight list item, a block is refused after any block that takes the line written after it, not only after a paragraph; in any container, an indented code - block is refused after an indented code block or a footnote definition + block is refused after an indented code block or a footnote definition, + and any block after an HTML block its value leaves open ### Outside the specs - Settle (another repository), once this ships in a published release: bump @@ -52,29 +54,24 @@ Specs: ### The rule -For each pair of adjacent blocks `left`, `right` in a container (blank -lines skipped, as for adjacent lists), `right` is refused when `left` takes -its first line. Which lines a block takes is read from its kind and its last -descendants, by the parser's own checks. In any container: - -- an indented code block after an indented code block (which it continues) - or a footnote definition (whose content is indented four columns). - -In a tight item, which joins blocks by one line ending: - -| `left` | `right` refused when | -|---|---| -| `Paragraph` | it does not interrupt a paragraph as written (the current rule) | -| `Definition` | the same, except a paragraph, a definition, and a setext heading, which read the same after a definition's lines | -| `Table` | its first line starts no block where no paragraph is open: a paragraph, a definition, a table, a setext heading, an HTML block opening none; the line is read as a row | -| `HtmlBlock` that a blank line ends (block-level or lone tag) | always | -| `BlockQuote` or `Alert` | `right` is a `BlockQuote` or `Alert`: the `>` lines continue it | -| a container whose last line, through the last child of each container and the last item of each list, is a paragraph's or a definition's, or is an alert without blocks | the table's set, and indented code, which cannot start while the paragraph is open: the line is the paragraph's lazy line; except after a quote or alert on the way, a block opening with a backtick or a tilde, which ends the quote's paragraph (GH-19) | - -The parser checks block starts on a line after a nested container against -the matched container (the outer item), not the open paragraph, so an -ordered list from 2, an empty item, and a lone-tag HTML block start there: -the lazy and table rows use that check, not "interrupts a paragraph". +The shapes are `validation` › Shapes that cannot be written, as the spec +change states them: for each pair of adjacent blocks `left`, `right` in a +container (blank lines skipped, as for adjacent lists), `right` is refused +when `left` takes its first line, read from `left`'s kind and its last +descendants by the parser's own checks. Three of those checks are not the +obvious ones: + +- On a line after a nested container, the parser checks block starts + against the matched container (the outer item), not the open paragraph, + so an ordered list from 2, an empty item, and a lone-tag HTML block start + there: after a table and after a container ending in a paragraph, the + test is "starts no block where no paragraph is open", not "interrupts a + paragraph". +- A line opening with a backtick or a tilde ends a quote's paragraph + instead of continuing it lazily (GH-19), so the descent records whether it + passed a quote or an alert. +- An HTML block of an end-marker kind left open by its value runs to its + container's end, so in a parse it is always the last sibling. Every other left block ends where its own syntax or line does. The descent stops at a last `BlankLine` (a closed paragraph), at a nested empty item @@ -96,7 +93,7 @@ children, and a block reached on one left block's descent is a last child and so is never another left block, so the walks stay linear in the tree. Parser helpers give the HTML kinds (`html_block_opens`, -`html_block_ends_at_blank_line`); the serializer gives +`html_block_ends_at_blank_line`, `html_block_left_open`); the serializer gives `written_opens_fence_like`, which writes the right block once. ### Decisions @@ -129,7 +126,7 @@ Release: the change ships as `fix` (a hand-built tree written wrongly is now refused). The Settle bump follows once a published release holds it. ### 1. Validation -- [x] 1.1 Each pair of the rule is refused and the nearby shapes accepted; verified by `tests/validate_regressions.rs` › `written_shapes` › `a_block_in_a_tight_item_is_not_taken_by_the_block_before_it` and `indented_code_is_not_taken_by_the_block_before_it`, naming `validation: Shapes that cannot be written`. +- [x] 1.1 Each pair of the rule is refused and the nearby shapes accepted; verified by `tests/validate_regressions.rs` › `written_shapes` › `a_block_in_a_tight_item_is_not_taken_by_the_block_before_it`, `indented_code_is_not_taken_by_the_block_before_it`, and `no_block_follows_an_html_block_left_open`, naming `validation: Shapes that cannot be written`. - Follows Design › The rule. - [x] 1.2 The reported tree is refused with the path of its last paragraph; verified by `node_paths` › `a_paragraph_after_a_nested_list_in_a_tight_item_names_the_paragraph`, the spec change's scenario. diff --git a/docs/plans/tight-item-lines/specs/validation.md b/docs/plans/tight-item-lines/specs/validation.md deleted file mode 100644 index 3f747e6..0000000 --- a/docs/plans/tight-item-lines/specs/validation.md +++ /dev/null @@ -1,134 +0,0 @@ -# Validation — spec changes - -## MODIFIED Requirements - -### Requirement: Shapes that cannot be written -Validation SHALL reject exactly these shapes, each visible from the node, its -children, its sibling blocks and the last blocks they end in, how many -containers enclose it, or whether it sits in a table cell or a directive -label: - -- Block sequences: two adjacent `List`s in the same container written with - the same marker char; a list recording a delimiter of the other list kind is - written with `-` when unordered and `.` when ordered. An indented - `CodeBlock` after an indented `CodeBlock` or a `FootnoteDefinition` in the - same container, blank lines between them or not. -- In a tight list item, which writes each block on the line after the one - before, a block that the block before it takes as that line: - - after a `Paragraph`, a block that cannot interrupt it, by the parser's - own checks: a `Paragraph`, a `Definition`, an indented `CodeBlock`, a - `Frontmatter`, a `Heading` written as a setext heading, an ordered - `List` whose first item's number is not 1, a `List` whose first item has - no blocks or that the serializer starts on a line after its bullet (a - thematic break of the bullet's char, or content opening with one to - three spaces or a tab), or an `HtmlBlock` whose first line opens no HTML - block or opens one that only a blank line ends (a lone tag such as - ``); - - after a `Definition`, those blocks other than a `Paragraph`, a - `Definition`, and a setext `Heading`; - - after a `Table`, a block whose first line starts no block where no - paragraph is open: a `Paragraph`, a `Definition`, a `Table`, a `Heading` - written as a setext heading, or an `HtmlBlock` whose first line opens no - HTML block; - - after an `HtmlBlock` whose first line opens one that a blank line ends - (a block-level tag such as `
`, or a lone tag), any block; - - after a `BlockQuote` or an `Alert`, a `BlockQuote` or an `Alert`; - - after a `BlockQuote`, an `Alert`, a `FootnoteDefinition`, or a `List` - whose last line, through the last block of each and the last item of - each list, is a `Paragraph`'s or a `Definition`'s, or is an `Alert` - holding no blocks: the blocks after a `Table` and an indented - `CodeBlock`, which that paragraph takes as a lazy line, except, where a - `BlockQuote` or an `Alert` is passed on the way, a block written opening - with a backtick or a tilde, which ends the quote's paragraph. -- `Paragraph`: no children other than empty `Text`. -- `Heading`: a depth outside 1–6. -- `Table`: no rows; a header row with no cells; an alignment count other than - the header row's width; a row whose width differs from the header row's. -- `List`: no items; a loose list of one item holding at most one block. -- `ListItem`: a number beyond the parser's 9-digit marker limit; a number in - a bullet list. -- `BlankLine`: first or last in a footnote definition; first in a list item, - or last in a list's last item; in a tight list. -- `ListItem`: a task item (`checked` set) whose first block after its leading - `Definition`s is not a `Paragraph` with children. -- `Alert`: a title that is empty or starts or ends with a space or a tab. -- `CodeBlock`: an info string that is `Some("")`; an indented block whose - value is empty or whose first or last line is blank (only spaces and - tabs), where one line ending at the value's end closes its last line. -- `Frontmatter`: inside any container; a value holding a line that is its own - fence (`---` for YAML, `+++` for TOML, spaces and tabs after it aside). -- `Definition`: an identifier that is empty or holds only spaces, tabs, and - line endings; a label that is `^` followed by a footnote label, unless the - definition directly follows another `Definition`, is the first block of a - container, or sits 32 or more containers deep (block quotes, alerts, list - items, footnote definitions, HTML containers, and container directives). -- `Definition`, and a `Full` `LinkReference` or `ImageReference`: a label - that `[label]` does not read back as: one holding an unescaped `[` or `]`, - ending in a backslash that escapes its `]`, holding a blank line between - its first and last line, longer than 999 chars, or blank. -- `Definition`, `LinkReference`, and `ImageReference`: an identifier other - than the parser's normalization of the label. -- `FootnoteDefinition`, `FootnoteReference`, `LinkReference`, and - `ImageReference`: an empty identifier. -- `FootnoteDefinition` and `FootnoteReference`: an identifier other than the - parser's normalization of the label; a label that is not a footnote label - (empty, holding a space, a tab, a line ending, or an unescaped `[` or `]`, - or longer than 999 chars) or that ends in a backslash escaping its `]`. -- `Link`, `Image`, and `Definition`: an empty bare destination. -- `HtmlContainer`: an empty opening or closing tag name, opening and closing - tag names that differ, or an empty opening or closing tag source. -- `LeafDirective`, `ContainerDirective`, and `TextDirective`: a name that is - not one or more runs of ASCII letters joined by single `-` chars; an - attribute name that does not start with an ASCII letter, `_`, or `-`, or - holds a char other than ASCII letters, digits, `_`, `-`, and `:`. -- Inline content of a paragraph, a heading, a table cell, an HTML container, - a leaf or container directive label, or an emphasis-like container: a - `LineBreak` as its last inline. Link text, image alt text, an inline - footnote, and a text directive label close with a `]` and may end with one. -- Inline content: a `Text` holding a line ending; a `SoftBreak`, or a - `LineBreak` of trailing spaces, right after a `SoftBreak` or a `LineBreak`; - two adjacent `Delete`s. -- `Emphasis`, `Strong`, `Delete`, and `Mark`: no children other than empty - `Text`; content that, past empty `Text`, starts with a Unicode whitespace - char, a `SoftBreak`, or a `LineBreak` of trailing spaces, or ends with a - Unicode whitespace char, a `SoftBreak`, or a `LineBreak`; an - `Emphasis` or a `Strong` whose only child is an `Emphasis` with the same - delimiter, a `Delete` whose only child is a `Delete`, and a `Mark` whose - only child is a `Mark`. -- `InlineFootnote`: no children. -- The text of a `Link` or `LinkReference`: a `Link`, `Autolink`, - `LinkReference`, or `WikiLink` at any depth. -- `Autolink`: a text that is not exactly one autolink of its form, so that - `Autolink::destination()` returns `None`. -- `Escape`: a value that is not an ASCII punctuation char. -- `CharacterReference`: a reference that is not exactly one character - reference. -- `Shortcode`: a name not in the crate's pinned gemoji table. -- `WikiLink`: an empty target. -- `Tag`: a name that is not one Unicode letter followed by Unicode letters, - Unicode numbers, `/`, `_`, or `-`. -- `CodeInline`: an empty value, or a value holding a line ending. -- `MathInline`: an empty value; a dollar fence of length 0; code-form math - whose value holds a backtick followed by `$`; dollar math that, written - alone behind its fence, the parser does not read back as the same value and - fence: a fence of three or more, a `$` that closes it early, whitespace at - an edge of single-`$` math, or a line ending. -- In a table cell, or in the label of a `LeafDirective` or a - `ContainerDirective`, at any depth: a `SoftBreak` or a `LineBreak`. -- In a table cell, at any depth: a `CodeInline`, `MathInline`, or `Html` - value, an `Autolink` text, a `LinkReference`, `ImageReference`, or - `FootnoteReference` label, or a `WikiLink` target or label that holds a `|` - right after an odd run of backslashes. - -Any other shape is not checked, including one whose spelling depends on the -characters around it or that only the builder can know is meant literally -(punctuation meant literally is built as `Escape` nodes), and a block after a -`List` whose first line, written four columns in or opening with spaces, -reaches the content column of the list's last item, which in a parsed -document the source's markers set: the builder answers -for it, and the serializer writes it as it is even when the output reads back -differently. - -#### Scenario: A paragraph after a nested list in a tight item -- **WHEN** a hand-built document holds a tight list whose item holds a paragraph `a`, a tight list of one item holding a paragraph `b`, and a paragraph `c`, and `validate()` runs -- **THEN** it returns one diagnostic whose `path` is `[0, 0, 2]`, the paragraph `c`, which `"- a\n - b\n c\n"` would read as `b`'s lazy line diff --git a/docs/specs/validation.md b/docs/specs/validation.md index 174d45a..ef23d07 100644 --- a/docs/specs/validation.md +++ b/docs/specs/validation.md @@ -23,21 +23,45 @@ directive's. ### Requirement: Shapes that cannot be written Validation SHALL reject exactly these shapes, each visible from the node, its -children, its sibling blocks, how many containers enclose it, or whether it -sits in a table cell or a directive label: +children, its sibling blocks and the last blocks they end in, how many +containers enclose it, or whether it sits in a table cell or a directive +label: - Block sequences: two adjacent `List`s in the same container written with the same marker char; a list recording a delimiter of the other list kind is - written with `-` when unordered and `.` when ordered. -- In a tight list item, a block right after a `Paragraph` that cannot - interrupt it, by the parser's own checks: a `Paragraph`, a `Definition`, an - indented `CodeBlock`, a `Frontmatter`, a `Heading` written as a setext - heading, an ordered `List` whose first item's number is not 1, a `List` whose first item - has no blocks or that the serializer starts on a line after its bullet (a - thematic break of the bullet's char, or content opening with one to three - spaces or a tab), or an - `HtmlBlock` whose first line opens no HTML block or opens one that only a - blank line ends (a lone tag such as ``). + written with `-` when unordered and `.` when ordered. An indented + `CodeBlock` after an indented `CodeBlock` or a `FootnoteDefinition` in the + same container, blank lines between them or not; any block after an + `HtmlBlock` whose first line opens one that an end marker closes (a + comment, a raw tag such as `", "", ""] { + let document = document(vec![html_block(closed), paragraph(vec![text("a")])]); + assert!(valid(&document) && reads_back(&document), "{closed:?}"); + } + assert!(valid(&parsed("- a\n\n