Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions docs/archive/2026-10-18-tight-item-lines/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# 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 `<div>`-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, 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

### 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,
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
`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

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
(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`, `html_block_left_open`); 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`, `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.

### 2. Integration
- [x] 2.1 Every CI command in `CLAUDE.md` › Commands passes locally.
58 changes: 45 additions & 13 deletions docs/specs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<span>`).
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 `<script>`, a processing instruction, a
declaration, or CDATA) and whose value holds no line that closes it.
- 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
`<span>`);
- 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 `<div>`, 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
Expand Down Expand Up @@ -120,6 +144,14 @@ sits in a table cell or a directive label:

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): the builder answers
(punctuation meant literally is built as `Escape` nodes), and a block after a
`List` whose last line is not a paragraph's or a definition's, where the
block's 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
4 changes: 3 additions & 1 deletion src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ 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_left_open, html_block_opens, interrupts_paragraph,
};
use source_map::{DerivedText, Segment, SourceMap};

/// The largest ordered-list item number: a marker holds at most 9 digits.
Expand Down
36 changes: 36 additions & 0 deletions src/parse/blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2178,6 +2178,42 @@ 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<HtmlBlockKind> {
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, opens a kind that an
/// end marker closes and holds no line that closes it: it takes every line
/// after it to its container's end.
pub(crate) fn html_block_left_open(node: &HtmlBlock) -> bool {
match written_html_block_kind(node) {
Some(HtmlBlockKind::RawTag) => !node.value.split(['\n', '\r']).any(|line| {
["script", "pre", "style", "textarea"]
.iter()
.any(|tag| line_contains_raw_closing_tag(line, tag))
}),
Some(HtmlBlockKind::Until(end)) => !node.value.contains(end),
_ => false,
}
}

/// 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)> {
Expand Down
10 changes: 10 additions & 0 deletions src/serialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1169,6 +1169,16 @@ fn item_number(item: &ListItem, previous: &mut Option<u64>) -> 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(
Expand Down
Loading
Loading