diff --git a/docs/archive/2026-10-18-tight-item-lines/plan.md b/docs/archive/2026-10-18-tight-item-lines/plan.md new file mode 100644 index 0000000..bd79dcc --- /dev/null +++ b/docs/archive/2026-10-18-tight-item-lines/plan.md @@ -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 `
`-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. 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