phase 1: core AST, spans, and Visitor
CI / cargo fmt --check (push) Successful in 30s
CI / cargo clippy (push) Successful in 56s
CI / cargo test (push) Successful in 52s

Define every node type in SPEC.md §6.4 — Document, 11 block variants,
15 inline variants (including the 4 link-related ones), plus
ListItem / DefinitionItem / TableRow / TableCell, ListSymbol,
CheckboxState, Keyword, LinkTarget, LinkKind. Every node carries a
`Span` (line + column + byte offset, 0-indexed) per §6.5.

Visitor: open-recursion trait with default `visit_*` bodies that
delegate to `walk_*` free functions. Override at any granularity;
call `walk_*` from your override to keep descending. P4 resolved by
defining the trait now so Phase 5 (renderer) and Phase 7 (semantic
tokens) can plug in without refactoring traversal.

P3 resolved: AST string fields are owned `String`. Cow / Arc<str> can
be revisited if the parser grows a zero-copy mode.

Tests: visitor smoke test builds a heading + paragraph + list + table
by hand and asserts exact visit counts (4 blocks, 12 inlines, 9 text
leaves, 1 bold, 1 external link, 1 transclusion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 16:25:24 +00:00
parent cf336ee839
commit dc2a952e67
9 changed files with 845 additions and 4 deletions
+214
View File
@@ -0,0 +1,214 @@
//! Smoke test for the AST visitor: build a small document by hand and
//! confirm a Visitor descends into every nested node exactly once.
use std::collections::HashMap;
use nuwiki_core::ast::{
BlockNode, BoldNode, DocumentNode, ExternalLinkNode, HeadingNode, InlineNode, ListItemNode,
ListNode, ListSymbol, ParagraphNode, Span, TableCellNode, TableNode, TableRowNode, TextNode,
TransclusionNode, Visitor,
};
#[derive(Default)]
struct Counter {
headings: usize,
paragraphs: usize,
text: usize,
bold: usize,
list_items: usize,
table_cells: usize,
external_links: usize,
transclusions: usize,
blocks: usize,
inlines: usize,
}
impl Visitor for Counter {
fn visit_block(&mut self, node: &BlockNode) {
self.blocks += 1;
nuwiki_core::ast::walk_block(self, node);
}
fn visit_inline(&mut self, node: &InlineNode) {
self.inlines += 1;
nuwiki_core::ast::walk_inline(self, node);
}
fn visit_heading(&mut self, node: &HeadingNode) {
self.headings += 1;
for child in &node.children {
self.visit_inline(child);
}
}
fn visit_paragraph(&mut self, node: &ParagraphNode) {
self.paragraphs += 1;
for child in &node.children {
self.visit_inline(child);
}
}
fn visit_text(&mut self, _: &TextNode) {
self.text += 1;
}
fn visit_bold(&mut self, node: &BoldNode) {
self.bold += 1;
for child in &node.children {
self.visit_inline(child);
}
}
fn visit_list_item(&mut self, node: &ListItemNode) {
self.list_items += 1;
nuwiki_core::ast::walk_list_item(self, node);
}
fn visit_table_cell(&mut self, node: &TableCellNode) {
self.table_cells += 1;
for child in &node.children {
self.visit_inline(child);
}
}
fn visit_external_link(&mut self, node: &ExternalLinkNode) {
self.external_links += 1;
if let Some(desc) = &node.description {
for child in desc {
self.visit_inline(child);
}
}
}
fn visit_transclusion(&mut self, _: &TransclusionNode) {
self.transclusions += 1;
}
}
fn text(s: &str) -> InlineNode {
InlineNode::Text(TextNode {
span: Span::default(),
content: s.into(),
})
}
#[test]
fn visitor_descends_into_every_node() {
// = Title with *bold* word =
let heading = BlockNode::Heading(HeadingNode {
span: Span::default(),
level: 1,
centered: false,
children: vec![
text("Title with "),
InlineNode::Bold(BoldNode {
span: Span::default(),
children: vec![text("bold")],
}),
text(" word"),
],
});
// A paragraph containing an external link with two text children in its
// description, and a transclusion sibling.
let paragraph = BlockNode::Paragraph(ParagraphNode {
span: Span::default(),
children: vec![
InlineNode::ExternalLink(ExternalLinkNode {
span: Span::default(),
url: "https://example.com".into(),
description: Some(vec![text("see "), text("docs")]),
}),
InlineNode::Transclusion(TransclusionNode {
span: Span::default(),
url: "image.png".into(),
alt: None,
attrs: HashMap::new(),
}),
],
});
// - one
// - two
let list = BlockNode::List(ListNode {
span: Span::default(),
ordered: false,
symbol: ListSymbol::Dash,
items: vec![
ListItemNode {
span: Span::default(),
symbol: ListSymbol::Dash,
level: 0,
checkbox: None,
children: vec![text("one")],
sublist: None,
},
ListItemNode {
span: Span::default(),
symbol: ListSymbol::Dash,
level: 0,
checkbox: None,
children: vec![text("two")],
sublist: None,
},
],
});
// | a | b |
let table = BlockNode::Table(TableNode {
span: Span::default(),
has_header: false,
rows: vec![TableRowNode {
span: Span::default(),
is_header: false,
cells: vec![
TableCellNode {
span: Span::default(),
children: vec![text("a")],
col_span: false,
row_span: false,
},
TableCellNode {
span: Span::default(),
children: vec![text("b")],
col_span: false,
row_span: false,
},
],
}],
});
let doc = DocumentNode {
span: Span::default(),
metadata: Default::default(),
children: vec![heading, paragraph, list, table],
};
let mut counter = Counter::default();
counter.visit_document(&doc);
// Block-level: 4 top-level + 2 list items aren't blocks
assert_eq!(counter.blocks, 4, "top-level blocks");
assert_eq!(counter.headings, 1);
assert_eq!(counter.paragraphs, 1);
assert_eq!(counter.list_items, 2);
// 1 cell row with 2 cells
assert_eq!(counter.table_cells, 2);
// Inlines:
// heading: 3 ("Title with ", Bold, " word") + 1 (bold child) = 4
// paragraph: 2 (ExternalLink, Transclusion) + 2 (link desc) = 4
// list items: 2 (one each)
// table cells: 2 (one each)
// total: 12
assert_eq!(counter.inlines, 12);
// Text leaves: heading 3, link description 2, list items 2, table cells 2 = 9
assert_eq!(counter.text, 9, "Text leaves");
assert_eq!(counter.bold, 1);
assert_eq!(counter.external_links, 1);
assert_eq!(counter.transclusions, 1);
}
#[test]
fn document_default_is_empty() {
let doc = DocumentNode::default();
assert!(doc.children.is_empty());
assert_eq!(doc.metadata.title, None);
assert!(!doc.metadata.nohtml);
let mut counter = Counter::default();
counter.visit_document(&doc);
assert_eq!(counter.blocks, 0);
assert_eq!(counter.inlines, 0);
}