diff --git a/docs/archive/2026-10-18-node-paths/plan.md b/docs/archive/2026-10-18-node-paths/plan.md new file mode 100644 index 0000000..765f1ac --- /dev/null +++ b/docs/archive/2026-10-18-node-paths/plan.md @@ -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` +- `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` 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. + diff --git a/docs/overview.md b/docs/overview.md index 74586c8..c78ab39 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -1,5 +1,5 @@ # Project overview -Updated 2026-10-14 +Updated 2026-10-18 ## What this is @@ -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 diff --git a/docs/specs/public-api.md b/docs/specs/public-api.md index 142f88c..0ee1093 100644 --- a/docs/specs/public-api.md +++ b/docs/specs/public-api.md @@ -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, message }`. +all report a single `Diagnostic { severity, code, span: Option, path: +Option, 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 @@ -87,6 +88,15 @@ type, `From<&str>` and `From` 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 @@ -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 diff --git a/docs/specs/validation.md b/docs/specs/validation.md index c3d2922..174d45a 100644 --- a/docs/specs/validation.md +++ b/docs/specs/validation.md @@ -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 diff --git a/docs/specs/wasm-binding.md b/docs/specs/wasm-binding.md index 31dc97e..7042b58 100644 --- a/docs/specs/wasm-binding.md +++ b/docs/specs/wasm-binding.md @@ -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 diff --git a/src/ast.rs b/src/ast.rs index 8d489b4..2c9a531 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -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 diff --git a/src/diagnostic.rs b/src/diagnostic.rs index ccc601a..daa19ff 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -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)] @@ -41,6 +41,9 @@ pub struct Diagnostic { pub code: DiagnosticCode, /// The source location, if known. pub span: Option, + /// The path of the node a validation diagnostic is about, in the document + /// validated; `None` for a parser diagnostic. + pub path: Option, /// A human-readable description. pub message: String, } @@ -57,6 +60,7 @@ impl Diagnostic { severity, code, span: Some(span), + path: None, message: message.into(), } } @@ -68,6 +72,7 @@ impl Diagnostic { severity: DiagnosticSeverity::Error, code: DiagnosticCode::InvalidDocument, span, + path: None, message: message.into(), } } diff --git a/src/validate.rs b/src/validate.rs index e77e4c0..df391f2 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -9,7 +9,7 @@ use crate::{ Alert, Block, CodeBlock, CodeBlockKind, CodeInline, ContainerDirective, Definition, DirectiveAttribute, Document, Escape, Frontmatter, Heading, HtmlContainer, HtmlContainerContent, Inline, LeafDirective, LineBreakKind, LinkDestinationKind, List, - ListItem, MathInline, MathInlineKind, ReferenceKind, Table, TextDirective, + ListItem, MathInline, MathInlineKind, NodePath, ReferenceKind, Table, TextDirective, }, diagnostic::Diagnostic, parse::{ @@ -29,9 +29,31 @@ impl Document { } pub(crate) fn validate_document(document: &Document) -> Vec { - let mut diagnostics = Vec::new(); - validate_blocks(&document.children, 0, &mut diagnostics); - diagnostics + let mut report = Report::default(); + validate_blocks(&document.children, 0, &mut report); + report.diagnostics +} + +/// The diagnostics found so far, and the path of the node being checked, +/// which each diagnostic pushed carries. +#[derive(Default)] +struct Report { + diagnostics: Vec, + path: Vec, +} + +impl Report { + fn push(&mut self, mut diagnostic: Diagnostic) { + diagnostic.path = Some(NodePath(self.path.clone())); + self.diagnostics.push(diagnostic); + } + + /// Runs `check` with the path at the child `index` of the current node. + fn at(&mut self, index: usize, check: impl FnOnce(&mut Self)) { + self.path.push(index); + check(self); + self.path.pop(); + } } /// A sequence of sibling blocks: each block, and two adjacent lists that one @@ -39,37 +61,39 @@ pub(crate) fn validate_document(document: &Document) -> Vec { /// 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 Vec) { +fn validate_blocks(blocks: &[Block], depth: usize, 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; - for block in blocks { - if let (Some(Block::List(before)), Block::List(list)) = (before, block) { - if written_marker(before) == written_marker(list) { - diagnostics.push(Diagnostic::invalid( - list.meta.span, - "adjacent lists cannot use the same marker", - )); + for (index, block) in blocks.iter().enumerate() { + diagnostics.at(index, |diagnostics| { + if let (Some(Block::List(before)), Block::List(list)) = (before, block) { + if written_marker(before) == written_marker(list) { + diagnostics.push(Diagnostic::invalid( + list.meta.span, + "adjacent lists cannot use the same marker", + )); + } } - } - 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 - // open containers first; neither starts a footnote definition. - let indented = match before { - Some(before) => matches!(before, Block::Definition(_)), - None => depth > 0, - }; - validate_definition(definition, depth, indented, diagnostics); - } - validate_block(block, depth, 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 + // open containers first; neither starts a footnote definition. + let indented = match before { + Some(before) => matches!(before, Block::Definition(_)), + None => depth > 0, + }; + validate_definition(definition, depth, indented, diagnostics); + } + validate_block(block, depth, diagnostics); + }); if !matches!(block, Block::BlankLine(_)) { before = Some(block); } } } -fn validate_block(block: &Block, depth: usize, diagnostics: &mut Vec) { +fn validate_block(block: &Block, depth: usize, diagnostics: &mut Report) { match block { Block::Paragraph(paragraph) => { // An empty paragraph writes nothing and reads back as no block. @@ -103,16 +127,18 @@ fn validate_block(block: &Block, depth: usize, diagnostics: &mut Vec ); // Its first line holds its label, and a blank line after its last // block belongs to the container around it. - let edges = [definition.children.first(), definition.children.last()]; - if let Some(Block::BlankLine(blank)) = edges + let last = definition.children.len().saturating_sub(1); + if let Some((at, Block::BlankLine(blank))) = [0, last] .into_iter() - .flatten() - .find(|block| matches!(block, Block::BlankLine(_))) + .filter_map(|at| definition.children.get(at).map(|block| (at, block))) + .find(|(_, block)| matches!(block, Block::BlankLine(_))) { - diagnostics.push(Diagnostic::invalid( - blank.meta.span, - "a footnote definition cannot open or end with a blank line", - )); + diagnostics.at(at, |diagnostics| { + diagnostics.push(Diagnostic::invalid( + blank.meta.span, + "a footnote definition cannot open or end with a blank line", + )) + }); } validate_blocks(&definition.children, depth + 1, diagnostics); } @@ -132,11 +158,7 @@ fn validate_block(block: &Block, depth: usize, diagnostics: &mut Vec } } -fn validate_html_container( - container: &HtmlContainer, - depth: usize, - diagnostics: &mut Vec, -) { +fn validate_html_container(container: &HtmlContainer, depth: usize, diagnostics: &mut Report) { if container.opening.name.is_empty() { diagnostics.push(Diagnostic::invalid( container.opening.meta.span, @@ -176,7 +198,7 @@ fn validate_html_container( } } -fn validate_heading(heading: &Heading, diagnostics: &mut Vec) { +fn validate_heading(heading: &Heading, diagnostics: &mut Report) { if heading.depth == 0 || heading.depth > 6 { diagnostics.push(Diagnostic::invalid( heading.meta.span, @@ -188,7 +210,7 @@ fn validate_heading(heading: &Heading, diagnostics: &mut Vec) { /// An alert's title is what the parser reads from its marker line: never /// empty, and without spaces or tabs around it. -fn validate_alert(alert: &Alert, depth: usize, diagnostics: &mut Vec) { +fn validate_alert(alert: &Alert, depth: usize, diagnostics: &mut Report) { if let Some(title) = &alert.title { if alert_title(title).as_deref() != Some(title.as_str()) { diagnostics.push(Diagnostic::invalid( @@ -200,7 +222,7 @@ fn validate_alert(alert: &Alert, depth: usize, diagnostics: &mut Vec validate_blocks(&alert.children, depth + 1, diagnostics); } -fn validate_list(list: &List, depth: usize, diagnostics: &mut Vec) { +fn validate_list(list: &List, depth: usize, diagnostics: &mut Report) { if list.children.is_empty() { diagnostics.push(Diagnostic::invalid( list.meta.span, @@ -223,19 +245,21 @@ fn validate_list(list: &List, depth: usize, diagnostics: &mut Vec) { )); } for (index, item) in list.children.iter().enumerate() { - 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); + diagnostics.at(index, |diagnostics| { + 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); + }); } } /// A task item's checkbox opens its first paragraph after the definitions it /// starts with; without one, `[ ]` or `[x]` reads as text. -fn validate_task_item(item: &ListItem, diagnostics: &mut Vec) { +fn validate_task_item(item: &ListItem, diagnostics: &mut Report) { if item.checked.is_none() { return; } @@ -253,21 +277,23 @@ fn validate_task_item(item: &ListItem, diagnostics: &mut Vec) { /// 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 Vec) { - for pair in item.children.windows(2) { +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.push(Diagnostic::invalid( - block.span(), - "in a tight list item, a block after a paragraph must be able to interrupt it", - )); + 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", + )) + }); } } } -fn validate_table(table: &Table, diagnostics: &mut Vec) { +fn validate_table(table: &Table, diagnostics: &mut Report) { if table.rows.is_empty() { diagnostics.push(Diagnostic::invalid( table.meta.span, @@ -291,22 +317,26 @@ fn validate_table(table: &Table, diagnostics: &mut Vec) { )); } - for row in &table.rows { - if row.cells.len() != width { - diagnostics.push(Diagnostic::invalid( - row.meta.span, - "table row width must match header width", - )); - } - for cell in &row.cells { - validate_inlines(&cell.children, diagnostics); - validate_cell_values(&cell.children, diagnostics); - validate_one_line( - &cell.children, - "a table cell cannot hold a line break", - diagnostics, - ); - } + for (index, row) in table.rows.iter().enumerate() { + diagnostics.at(index, |diagnostics| { + if row.cells.len() != width { + diagnostics.push(Diagnostic::invalid( + row.meta.span, + "table row width must match header width", + )); + } + for (index, cell) in row.cells.iter().enumerate() { + diagnostics.at(index, |diagnostics| { + validate_inlines(&cell.children, diagnostics); + validate_cell_values(&cell.children, diagnostics); + validate_one_line( + &cell.children, + "a table cell cannot hold a line break", + diagnostics, + ); + }); + } + }); } } @@ -314,7 +344,7 @@ fn validate_table(table: &Table, diagnostics: &mut Vec) { /// after no backslash or an even run of them, and the cell's inline input /// reads an odd run before a `|` with one backslash less, so no cell source /// gives a value that holds a `|` after an odd backslash run. -fn validate_cell_values(inlines: &[Inline], diagnostics: &mut Vec) { +fn validate_cell_values(inlines: &[Inline], diagnostics: &mut Report) { for inline in inlines { let unwritable = match inline { Inline::Code(node) => has_odd_escaped_pipe(&node.value), @@ -353,7 +383,7 @@ fn has_odd_escaped_pipe(value: &str) -> bool { /// Inline content written on one line, at any depth: a table cell, or a leaf /// or container directive's label on its opening line. -fn validate_one_line(inlines: &[Inline], message: &'static str, diagnostics: &mut Vec) { +fn validate_one_line(inlines: &[Inline], message: &'static str, diagnostics: &mut Report) { for inline in inlines { if matches!(inline, Inline::SoftBreak(_) | Inline::LineBreak(_)) { diagnostics.push(Diagnostic::invalid(inline.span(), message)); @@ -362,7 +392,7 @@ fn validate_one_line(inlines: &[Inline], message: &'static str, diagnostics: &mu } } -fn validate_leaf_directive(directive: &LeafDirective, diagnostics: &mut Vec) { +fn validate_leaf_directive(directive: &LeafDirective, diagnostics: &mut Report) { validate_directive_name(directive.meta.span, &directive.name, diagnostics); validate_directive_attributes(&directive.attributes, diagnostics); validate_inlines(&directive.label, diagnostics); @@ -376,7 +406,7 @@ fn validate_leaf_directive(directive: &LeafDirective, diagnostics: &mut Vec, + diagnostics: &mut Report, ) { validate_directive_name(directive.meta.span, &directive.name, diagnostics); validate_directive_attributes(&directive.attributes, diagnostics); @@ -398,7 +428,7 @@ fn validate_definition( definition: &Definition, depth: usize, indented: bool, - diagnostics: &mut Vec, + diagnostics: &mut Report, ) { if definition .identifier @@ -443,7 +473,7 @@ fn validate_reference_label( label: &str, identifier: &str, written: bool, - diagnostics: &mut Vec, + diagnostics: &mut Report, ) { if normalize_label(label) != identifier { diagnostics.push(Diagnostic::invalid( @@ -465,7 +495,7 @@ fn validate_footnote_label( span: Option, label: &str, identifier: &str, - diagnostics: &mut Vec, + diagnostics: &mut Report, ) { if normalize_label(label) != identifier { diagnostics.push(Diagnostic::invalid( @@ -487,7 +517,7 @@ fn validate_destination( span: Option, destination: &str, kind: LinkDestinationKind, - diagnostics: &mut Vec, + diagnostics: &mut Report, ) { if kind == LinkDestinationKind::Bare && destination.is_empty() { diagnostics.push(Diagnostic::invalid( @@ -500,7 +530,7 @@ fn validate_destination( /// A code block's info string is never empty, and an indented code block's /// value, which no fence bounds, starts and ends with a line that is not /// blank. -fn validate_code_block(code: &CodeBlock, diagnostics: &mut Vec) { +fn validate_code_block(code: &CodeBlock, diagnostics: &mut Report) { if code.info.as_deref() == Some("") { diagnostics.push(Diagnostic::invalid( code.meta.span, @@ -526,11 +556,7 @@ fn validate_code_block(code: &CodeBlock, diagnostics: &mut Vec) { /// Frontmatter opens only the document, and ends at the first line that is /// its fence. -fn validate_frontmatter( - frontmatter: &Frontmatter, - depth: usize, - diagnostics: &mut Vec, -) { +fn validate_frontmatter(frontmatter: &Frontmatter, depth: usize, diagnostics: &mut Report) { if depth > 0 { diagnostics.push(Diagnostic::invalid( frontmatter.meta.span, @@ -553,7 +579,7 @@ fn validate_frontmatter( /// Inline content whose container ends where its line does, or with a closing /// delimiter that a line break before it keeps from closing: a final hard line /// break would not survive serialization. -fn validate_inlines(inlines: &[Inline], diagnostics: &mut Vec) { +fn validate_inlines(inlines: &[Inline], diagnostics: &mut Report) { if let Some(Inline::LineBreak(node)) = inlines.last() { diagnostics.push(Diagnostic::invalid( node.meta.span, @@ -565,7 +591,7 @@ fn validate_inlines(inlines: &[Inline], diagnostics: &mut Vec) { /// Inline content closed by a `]`, which closes after a line break as well, so /// it may end with a hard line break. -fn validate_inline_nodes(inlines: &[Inline], diagnostics: &mut Vec) { +fn validate_inline_nodes(inlines: &[Inline], diagnostics: &mut Report) { for (index, inline) in inlines.iter().enumerate() { if let Some(before) = index.checked_sub(1).map(|before| &inlines[before]) { validate_siblings(before, inline, diagnostics); @@ -709,7 +735,7 @@ fn validate_inline_nodes(inlines: &[Inline], diagnostics: &mut Vec) /// on the chars around their joined run (`**:* ~**a**` parses as two /// emphasis spans), and two marks join into `====`, which reads as two marks /// again; neither is rejected. -fn validate_siblings(before: &Inline, inline: &Inline, diagnostics: &mut Vec) { +fn validate_siblings(before: &Inline, inline: &Inline, diagnostics: &mut Report) { let after_break = matches!(before, Inline::SoftBreak(_) | Inline::LineBreak(_)); let blank_line = match inline { Inline::SoftBreak(_) => true, @@ -737,7 +763,7 @@ fn validate_siblings(before: &Inline, inline: &Inline, diagnostics: &mut Vec) { +fn validate_emphasis_container(node: &Inline, diagnostics: &mut Report) { let children = node.children(); let span = node.span(); if children.iter().all(is_empty_text) { @@ -794,7 +820,7 @@ fn is_empty_text(inline: &Inline) -> bool { } /// Link text holds no link, at any depth. -fn validate_link_text(inlines: &[Inline], diagnostics: &mut Vec) { +fn validate_link_text(inlines: &[Inline], diagnostics: &mut Report) { for inline in inlines { match inline { Inline::Link(_) @@ -811,7 +837,7 @@ fn validate_link_text(inlines: &[Inline], diagnostics: &mut Vec) { } } -fn validate_escape(escape: &Escape, diagnostics: &mut Vec) { +fn validate_escape(escape: &Escape, diagnostics: &mut Report) { if !escape.value.is_ascii_punctuation() { diagnostics.push(Diagnostic::invalid( escape.meta.span, @@ -822,7 +848,7 @@ fn validate_escape(escape: &Escape, diagnostics: &mut Vec) { /// A code span's value is what the parser reads from one: never empty, and /// with its line endings read as spaces. -fn validate_code_inline(code: &CodeInline, diagnostics: &mut Vec) { +fn validate_code_inline(code: &CodeInline, diagnostics: &mut Report) { if code.value.is_empty() { diagnostics.push(Diagnostic::invalid( code.meta.span, @@ -840,7 +866,7 @@ fn validate_code_inline(code: &CodeInline, diagnostics: &mut Vec) { /// Inline math that its fence closes around: math needs a value (both `$$` /// and `` $`` `$ `` read back as other text), and code-form math ends at the /// first `` `$ ``, so its value cannot hold one. -fn validate_math_inline(math: &MathInline, diagnostics: &mut Vec) { +fn validate_math_inline(math: &MathInline, diagnostics: &mut Report) { if math.value.is_empty() { diagnostics.push(Diagnostic::invalid( math.meta.span, @@ -872,7 +898,7 @@ fn validate_math_inline(math: &MathInline, diagnostics: &mut Vec) { } } -fn validate_item_number(list: &List, item: &ListItem, diagnostics: &mut Vec) { +fn validate_item_number(list: &List, item: &ListItem, diagnostics: &mut Report) { let Some(number) = item.number else { return; }; @@ -892,12 +918,7 @@ fn validate_item_number(list: &List, item: &ListItem, diagnostics: &mut Vec, -) { +fn validate_item_blank_lines(list: &List, index: usize, item: &ListItem, diagnostics: &mut Report) { let last_item = index + 1 == list.children.len(); for (at, block) in item.children.iter().enumerate() { let Block::BlankLine(blank) = block else { @@ -912,17 +933,19 @@ fn validate_item_blank_lines( } else { continue; }; - diagnostics.push(Diagnostic::invalid(blank.meta.span, message)); + diagnostics.at(at, |diagnostics| { + diagnostics.push(Diagnostic::invalid(blank.meta.span, message)) + }); } } -fn validate_text_directive(directive: &TextDirective, diagnostics: &mut Vec) { +fn validate_text_directive(directive: &TextDirective, diagnostics: &mut Report) { validate_directive_name(directive.meta.span, &directive.name, diagnostics); validate_directive_attributes(&directive.attributes, diagnostics); validate_inline_nodes(&directive.label, diagnostics); } -fn validate_directive_name(span: Option, name: &str, diagnostics: &mut Vec) { +fn validate_directive_name(span: Option, name: &str, diagnostics: &mut Report) { if !is_directive_name(name) { diagnostics.push(Diagnostic::invalid( span, @@ -931,10 +954,7 @@ fn validate_directive_name(span: Option, name: &str, diagnostics: &mut Vec } } -fn validate_directive_attributes( - attributes: &[DirectiveAttribute], - diagnostics: &mut Vec, -) { +fn validate_directive_attributes(attributes: &[DirectiveAttribute], diagnostics: &mut Report) { for attribute in attributes { if !is_attribute_name(&attribute.name) { diagnostics.push(Diagnostic::invalid( diff --git a/tests/parse_block_regressions.rs b/tests/parse_block_regressions.rs index d8ee0c3..1797252 100644 --- a/tests/parse_block_regressions.rs +++ b/tests/parse_block_regressions.rs @@ -2537,3 +2537,50 @@ mod blank_lines_and_numbers { assert_eq!(list.children[0].number, None); } } + +mod children { + use markdown_syntax::*; + + fn kinds(blocks: &[Block]) -> Vec<&'static str> { + blocks + .iter() + .map(|block| match block { + Block::Paragraph(_) => "paragraph", + Block::List(_) => "list", + Block::BlankLine(_) => "blank", + _ => "other", + }) + .collect() + } + + // public-api: Children of a node + #[test] + fn a_container_gives_its_blocks_and_others_none() { + let source = "> a\n\n> [!NOTE]\n> b\n\n[^f]: c\n\n:::note\nd\n:::\n\n
\n\ne\n\n
\n\n- f\n\ng\n"; + let document = parse(source).document; + let children: Vec> = document + .children + .iter() + .filter(|block| !matches!(block, Block::BlankLine(_))) + .map(|block| kinds(block.children())) + .collect(); + assert_eq!( + children, + [ + vec!["paragraph"], + vec!["paragraph"], + vec!["paragraph"], + vec!["paragraph"], + vec!["blank", "paragraph"], + vec![], + vec![], + ], + "{:#?}", + document.children + ); + let Block::List(list) = &document.children[document.children.len() - 2] else { + panic!("{:#?}", document.children); + }; + assert_eq!(kinds(&list.children[0].children), ["paragraph"]); + } +} diff --git a/tests/parse_inline_regressions.rs b/tests/parse_inline_regressions.rs index e3f5260..ce4be43 100644 --- a/tests/parse_inline_regressions.rs +++ b/tests/parse_inline_regressions.rs @@ -1010,7 +1010,7 @@ mod gemoji_shortcodes { ); } - // public-api: Shortcode glyph + // public-api: Each written fact is stored once #[test] fn a_shortcode_gives_its_glyph() { let inlines = inlines(":tada:"); diff --git a/tests/validate_regressions.rs b/tests/validate_regressions.rs index a922034..b15954f 100644 --- a/tests/validate_regressions.rs +++ b/tests/validate_regressions.rs @@ -1604,3 +1604,125 @@ mod blank_lines_and_numbers { assert!(!document.validate().is_empty()); } } + +mod node_paths { + use markdown_syntax::*; + + fn paragraph(text: &str) -> Paragraph { + Paragraph::new([Text::from(text)]) + } + + fn paths(document: &Document) -> Vec> { + document.validate().into_iter().map(|d| d.path).collect() + } + + // validation: Validate reports invalid shapes + #[test] + fn a_block_a_tight_item_cannot_hold_names_that_block() { + let document = Document::new(vec![List::new([ListItem::new([ + paragraph("a"), + paragraph("b"), + ])]) + .into()]); + assert_eq!(paths(&document), [Some(NodePath::from([0, 0, 1]))]); + } + + // validation: Validate reports invalid shapes + #[test] + fn adjacent_lists_with_one_marker_name_the_later_list() { + let list = || Block::from(List::new([ListItem::new([paragraph("a")])])); + let document = Document::new(vec![ + paragraph("x").into(), + list(), + BlankLine::new().into(), + list(), + ]); + assert_eq!(paths(&document), [Some(NodePath::from([3]))]); + } + + // validation: Validate reports invalid shapes + #[test] + fn table_shapes_name_their_row_or_cell() { + let cell = |children: Vec| TableCell::new(children); + let document = Document::new(vec![BlockQuote { + meta: NodeMeta::default(), + children: vec![Table { + meta: NodeMeta::default(), + alignments: vec![TableAlignment::None, TableAlignment::None], + rows: vec![ + TableRow { + meta: NodeMeta::default(), + cells: vec![ + cell(vec![Text::from("a").into()]), + cell(vec![ + Text::from("b").into(), + LineBreak { + meta: NodeMeta::default(), + kind: LineBreakKind::Backslash, + } + .into(), + Text::from("c").into(), + ]), + ], + }, + TableRow { + meta: NodeMeta::default(), + cells: vec![cell(vec![Text::from("d").into()])], + }, + ], + } + .into()], + } + .into()]); + assert_eq!( + paths(&document), + [ + Some(NodePath::from([0, 0, 0, 1])), + Some(NodePath::from([0, 0, 1])), + ] + ); + } + + // validation: Validate reports invalid shapes + #[test] + fn inline_shapes_name_their_leaf_and_a_label_its_directive() { + let line_break = || { + Inline::from(LineBreak { + meta: NodeMeta::default(), + kind: LineBreakKind::Backslash, + }) + }; + let mut directive = LeafDirective::new("note"); + directive.label = vec![Text::from("a").into(), line_break(), Text::from("b").into()]; + let document = Document::new(vec![ + BlockQuote { + meta: NodeMeta::default(), + children: vec![Paragraph::new([Text::from("a").into(), line_break()]).into()], + } + .into(), + directive.into(), + ]); + assert_eq!( + paths(&document), + [Some(NodePath::from([0, 0])), Some(NodePath::from([1]))] + ); + } + + // public-api: One diagnostic type + #[test] + fn a_refused_write_carries_the_paths_and_parser_diagnostics_none() { + let document = Document::new(vec![List::new([ListItem::new([ + paragraph("a"), + paragraph("b"), + ])]) + .into()]); + let Err(SerializeError::InvalidDocument(diagnostics)) = document.to_markdown() else { + panic!("the tree is invalid"); + }; + assert_eq!(diagnostics[0].path, Some(NodePath::from([0, 0, 1]))); + + let parsed = parse(":::Bad name\n"); + assert!(!parsed.diagnostics.is_empty()); + assert!(parsed.diagnostics.iter().all(|d| d.path.is_none())); + } +} diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index c654cd6..a9ed24a 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -286,6 +286,8 @@ export interface Diagnostic { /** The Rust `DiagnosticCode` in camelCase, such as `invalidDocument`. */ code: string; span: Span; + /** The path of the node a validation diagnostic is about; `null` for a parser diagnostic. */ + path: number[] | null; message: string; } diff --git a/wasm/js/index.js b/wasm/js/index.js index ec0172b..389a667 100644 --- a/wasm/js/index.js +++ b/wasm/js/index.js @@ -328,6 +328,7 @@ class Reader { severity: camel(this.string()), code: camel(this.string()), span: this.span(), + path: this.word() === 1 ? this.list(() => this.word()) : null, message: this.string(), })); } diff --git a/wasm/js/test/binding.test.mjs b/wasm/js/test/binding.test.mjs index e997a6b..ff2dde4 100644 --- a/wasm/js/test/binding.test.mjs +++ b/wasm/js/test/binding.test.mjs @@ -331,6 +331,7 @@ describe("Write from JavaScript", () => { assert.equal(document.toMarkdown(tree), "\ufeff# a\n\nc\n"); }); + // wasm-binding: Write from JavaScript test("a refused tree returns the diagnostics", () => { const document = binding.parse("- a\n\np\n\n- b"); const tree = structuredClone(document.tree); @@ -339,6 +340,14 @@ describe("Write from JavaScript", () => { assert.equal(typeof result, "object"); assert.equal(result.diagnostics[0].code, "invalidDocument"); assert.equal(result.diagnostics[0].severity, "error"); + // The later of the two adjacent lists. + assert.deepEqual(result.diagnostics[0].path, [1]); + }); + + test("a parser diagnostic has no path", () => { + const { diagnostics } = binding.parse(":::Bad name\n"); + assert.ok(diagnostics.length > 0); + assert.ok(diagnostics.every((diagnostic) => diagnostic.path === null)); }); test("every value survives the round trip", () => { diff --git a/wasm/src/wire.rs b/wasm/src/wire.rs index e788e3d..2582c94 100644 --- a/wasm/src/wire.rs +++ b/wasm/src/wire.rs @@ -574,13 +574,21 @@ impl<'t> Writer<'t> { } } - /// Diagnostics, with their severity and code as their Rust names. + /// Diagnostics, with their severity and code as their Rust names, and + /// whether a path follows, then its indices. pub fn diagnostics(&mut self, diagnostics: &[Diagnostic]) { self.word(diagnostics.len() as u32); for diagnostic in diagnostics { self.str(&format!("{:?}", diagnostic.severity)); self.str(&format!("{:?}", diagnostic.code)); self.span(diagnostic.span); + self.bool(diagnostic.path.is_some()); + if let Some(path) = &diagnostic.path { + self.word(path.0.len() as u32); + for &index in &path.0 { + self.word(index as u32); + } + } self.str(&diagnostic.message); } }