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
112 changes: 112 additions & 0 deletions docs/archive/2026-10-18-node-paths/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Node paths for diagnostics, and block children

## Why

An editor that builds a tree and is refused cannot tell which node a
validation diagnostic is about when that node has no span, so Settle retries
the write with each tight list made loose until fewer diagnostics come back.
And every caller that walks blocks writes its own match over the container
kinds, which silently skips a container kind added later.

- Scope: a `path` on every diagnostic validation reports, carried by the wasm
binding; `Block::children()`.
- Breaking: `Diagnostic` gains a public field, and it is not
`#[non_exhaustive]`, so a struct literal of it no longer compiles.

## What changes

### Diagnostics
- `public-api` › One diagnostic type: modified — `path: Option<NodePath>`
- `validation` › Validate reports invalid shapes: modified — each diagnostic
carries the path of the node whose shape it rejects
- `wasm-binding` › Write from JavaScript: modified — a refusal's diagnostics
carry their paths

### Tree walks
- `public-api` › Children of a node: added — `Inline::children()` and
`Block::children()`
- `public-api` › Shortcode glyph: removed — merged into "Each written fact is
stored once", which already holds the other values derived from a node's
fields, to keep the spec within its budget
- `public-api` › Each written fact is stored once: modified — gains
`Shortcode::glyph()`

### Outside the specs
- Settle (another repository), once this ships in a published release:
`save()` writes loose exactly the tight lists a refusal's paths fall in,
replacing its retry loop; the two `child_blocks` helpers in its core crate
call `Block::children()`.

Specs:
- `public-api` (modified)
- `validation` (modified)
- `wasm-binding` (modified)

## Out of scope
- Settle's `inline_runs` — it reads each leaf's inlines, including an HTML
container's, which `leaf_sources` (text, and no HTML container) does not
replace; it stays in Settle.
- Paths on parser diagnostics — each already carries a span into the source
it was read from, which addresses it.

## Design

### Which node a path names

A validation diagnostic's `path` is the `NodePath` of the innermost node a
path can name that the rejected shape belongs to:

- a shape of a block, list item, table row, or table cell: that node, a
misplaced `BlankLine` included;
- a shape among sibling blocks (two adjacent lists with the same marker, a
block that cannot follow a paragraph in a tight item): the later block;
- a shape in inline content: the leaf holding it, and for a directive label
the directive, as `inline_source` addresses leaves.

A caller finds the enclosing list by walking up the path.

### Architecture

- `Diagnostic` gains `path: Option<NodePath>` after `span`. Parser
diagnostics and `Diagnostic::new` / `Diagnostic::invalid` leave it `None`;
the validator sets it. Serializer and HTML pre-validation already return
the validator's diagnostics, so they carry paths with no change of their
own.
- The validator keeps the current path while it descends and attaches it
to each diagnostic it pushes.
- `Block::children(&self) -> &[Block]`: the blocks directly inside a block
quote, an alert, a footnote definition, a container directive, or an HTML
container holding blocks; an empty slice for every other block. A list's
blocks are in its items (`ListItem::children`), and a table holds rows, as
`NodePath` counts them.
- wasm: the diagnostic record on the wire gains its path; the JS
`Diagnostic` gains `path: number[] | null`.

### Decisions
- The path rides on the diagnostic — because every place diagnostics flow
(`SerializeError`, `HtmlError`, the wasm wire) then carries it unchanged.
Turned down: a separate list of paths beside the diagnostics, which every
carrier would have to keep in step.
- `Block::children()` returns block slices only — because callers walking
blocks treat items and rows apart anyway (Settle's both do). Turned down:
a node enum over blocks, items, rows, and cells, a new type for one
walk shape.

## Tasks

Release: the change ships as `feat!`. The Settle change in What changes
follows in Settle once a published release contains this one.

### 1. Core crate
- [x] 1.1 `Diagnostic` holds `path`, and every validation diagnostic carries the path its shape belongs to; verified by `tests/validate_regressions.rs` cases naming `validation: Validate reports invalid shapes`: a hand-built tight item with two paragraphs (path of the second), two adjacent same-marker lists (path of the second), a table row of the wrong width, a `LineBreak` last in a nested paragraph (path of the paragraph), and a directive label (path of the directive).
- Follows Design › Which node a path names.
- [x] 1.2 `to_markdown` refusals carry the same paths, and parser diagnostics none; verified by a `validate_regressions` case naming `public-api: One diagnostic type` with a span-less tree.
- [x] 1.3 `Block::children()` exists; verified by a `parse_block_regressions` test naming `public-api: Children of a node` over each container kind, a list, and a leaf.
- [x] 1.4 The test comment naming `public-api: Shortcode glyph` names `public-api: Each written fact is stored once`; verified by `grep`.

### 2. wasm binding
- [x] 2.1 The wire and the JS wrapper carry `path`, and `index.d.ts` declares it; verified by a `binding.test.mjs` case where `toMarkdown` refuses a hand-built tree and the diagnostic's `path` names the node.

### 3. Integration
- [x] 3.1 Every CI command in `CLAUDE.md` › Commands passes locally.

14 changes: 7 additions & 7 deletions docs/overview.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Project overview
Updated 2026-10-14
Updated 2026-10-18

## What this is

Expand Down Expand Up @@ -31,12 +31,12 @@ Shipped features are described in `docs/specs/`.

## Current focus

The second batch for the editor. Blank lines are nodes, ordered items keep
their numbers, every leaf's inline source comes in one pass, a leaf built
from its text is written as that text, the source is reused per node and
per node's own syntax down to inlines, and an inline parse marks each run
of its text with the node holding it and the part of that node it is, for
the editor's marks. Lists stay nested; the editor flattens them.
Serving Settle's editor and index. The second batch for the editor has
shipped: blank lines as nodes, item numbers, leaves written as their text,
source reuse per node down to inlines, and inline syntax tokens for the
editor's marks. Validation diagnostics name the node they are about, so an
editor can fix a refused tree without guessing, and `Block::children()`
walks block containers. Lists stay nested; the editor flattens them.

## Non-goals

Expand Down
20 changes: 14 additions & 6 deletions docs/specs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ prelude.

### Requirement: One diagnostic type
Parser diagnostics, AST validation, and serializer and HTML pre-validation SHALL
all report a single `Diagnostic { severity, code, span: Option<Span>, message }`.
all report a single `Diagnostic { severity, code, span: Option<Span>, path:
Option<NodePath>, message }`; `path` SHALL be `None` on parser diagnostics.

### Requirement: Source spans
Every parsed node SHALL carry an absolute, half-open UTF-8 byte range into the
Expand Down Expand Up @@ -87,6 +88,15 @@ type, `From<&str>` and `From<String>` for `Text`, and `new` constructors for
list item's number to `None`, and a directive's label and attributes to none;
`markdown_syntax::prelude::*` SHALL import this surface.

### Requirement: Children of a node
`Inline::children()` SHALL return the inlines directly inside an inline (an
emphasis-like container's, a link's or reference's text, an image's alt, an
inline footnote's, a text directive's label) and an empty slice for every
other inline. `Block::children()` SHALL return the blocks directly inside a
block quote, an alert, a footnote definition, a container directive, or an
HTML container holding blocks, and an empty slice for every other block; a
list's blocks are its items' `children`.

### Requirement: Byte order mark and NUL
The parser SHALL ignore one leading U+FEFF and SHALL treat every U+0000 as
U+FFFD, both when recognizing structure and in every node value, while all spans
Expand All @@ -111,16 +121,14 @@ not exactly one autolink of its form.
A `Link`, an `Image`, and a `Definition` SHALL hold a title, when they have
one, as one `Title` holding its value and the quotes it is written in, so a
title without quotes or quotes without a title cannot be built.
A `Shortcode` SHALL hold only its name, and `Shortcode::glyph()` SHALL return
the emoji that the crate's pinned gemoji table gives that name, and `None`
for a name the table does not hold.

#### Scenario: Text the parser would trim
- **WHEN** `destination()` is called on a hand-built literal `Autolink` holding `"http://a.b."`
- **THEN** it returns `None`

### Requirement: Shortcode glyph
`Shortcode::glyph()` SHALL return the emoji that the crate's pinned gemoji
table gives the shortcode's name, and `None` for a name the table does not
hold.

### Requirement: Wiki link decoding
`WikiLink::decoded_target()` and `WikiLink::decoded_label()` SHALL return the
target and the label with each backslash escape of an ASCII punctuation
Expand Down
9 changes: 9 additions & 0 deletions docs/specs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ and renderer can write faithfully. Owned by the validator.
### Requirement: Validate reports invalid shapes
`Document::validate` SHALL return one error-severity `InvalidDocument`
diagnostic per invalid shape it finds, and an empty list for a valid document.
Each diagnostic's `path` SHALL be the `NodePath`, in the document validated,
of the node the shape belongs to: the block, list item, table row, or table
cell; for a shape among sibling blocks, the later block; for a shape in
inline content, the leaf holding it, a directive label's being its
directive's.

#### Scenario: A block a tight item cannot hold
- **WHEN** a hand-built document holds a tight list whose first item holds two paragraphs, and `validate()` runs
- **THEN** it returns one diagnostic whose `path` is `[0, 0, 1]`, the second paragraph

### Requirement: Shapes that cannot be written
Validation SHALL reject exactly these shapes, each visible from the node, its
Expand Down
10 changes: 5 additions & 5 deletions docs/specs/wasm-binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ of the document's definitions.
### Requirement: Write from JavaScript
`parsedDocument.toMarkdown(tree, options)` SHALL return the text the core
crate's `to_markdown_with` writes for `tree` given this document's source, or
SHALL return the validation diagnostics when it refuses the tree. A
paragraph, heading, table cell, or directive in `tree` MAY carry `source: string`
in place of its inline content (for a directive, its label's); the binding
SHALL build it with the core crate's `with_source` and the definitions of
`tree`.
SHALL return the validation diagnostics when it refuses the tree, each with
its `path` as an array of indices. A paragraph, heading, table cell, or
directive in `tree` MAY carry `source: string` in place of its inline
content (for a directive, its label's); the binding SHALL build it with the
core crate's `with_source` and the definitions of `tree`.

#### Scenario: Untouched tree
- **WHEN** `doc.toMarkdown(doc.tree)` runs
Expand Down
32 changes: 32 additions & 0 deletions src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,38 @@ impl_from_variants!(Inline {
TextDirective(TextDirective), Tag(Tag),
});

impl Block {
/// The blocks directly inside this block: a block quote's, an alert's, a
/// footnote definition's, a container directive's, or an HTML container's
/// that holds blocks; an empty slice for every other block. A list's
/// blocks are in its items ([`ListItem::children`]), as [`NodePath`]
/// counts them, so a walker over blocks steps into a list's items itself.
pub fn children(&self) -> &[Block] {
match self {
Block::BlockQuote(n) => &n.children,
Block::Alert(n) => &n.children,
Block::FootnoteDefinition(n) => &n.children,
Block::ContainerDirective(n) => &n.children,
Block::HtmlContainer(n) => match &n.content {
HtmlContainerContent::Blocks(blocks) => blocks,
HtmlContainerContent::Inlines(_) => &[],
},
Block::Paragraph(_)
| Block::Heading(_)
| Block::ThematicBreak(_)
| Block::List(_)
| Block::CodeBlock(_)
| Block::HtmlBlock(_)
| Block::Definition(_)
| Block::Table(_)
| Block::MathBlock(_)
| Block::Frontmatter(_)
| Block::LeafDirective(_)
| Block::BlankLine(_) => &[],
}
}
}

impl Inline {
/// The inline subtree of this node, or an empty slice for a leaf. Covers the
/// `alt`/`label` fields uniformly, so a generic walker never silently skips
Expand Down
7 changes: 6 additions & 1 deletion src/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

use alloc::string::String;

use crate::span::Span;
use crate::{ast::NodePath, span::Span};

/// How serious a [`Diagnostic`] is.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -41,6 +41,9 @@ pub struct Diagnostic {
pub code: DiagnosticCode,
/// The source location, if known.
pub span: Option<Span>,
/// The path of the node a validation diagnostic is about, in the document
/// validated; `None` for a parser diagnostic.
pub path: Option<NodePath>,
/// A human-readable description.
pub message: String,
}
Expand All @@ -57,6 +60,7 @@ impl Diagnostic {
severity,
code,
span: Some(span),
path: None,
message: message.into(),
}
}
Expand All @@ -68,6 +72,7 @@ impl Diagnostic {
severity: DiagnosticSeverity::Error,
code: DiagnosticCode::InvalidDocument,
span,
path: None,
message: message.into(),
}
}
Expand Down
Loading
Loading