diff --git a/SPEC.md b/SPEC.md index 1a5aa64..1a8fc44 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,7 +1,7 @@ # nuwiki — Project Specification -> Last updated: 2026-05-08 -> Status: In design — Phase 0 not yet started +> Last updated: 2026-05-10 +> Status: Phase 1 (Core AST) complete — moving to Phase 2 (Syntax Plugin Interface) --- @@ -586,8 +586,8 @@ These decisions are required before or during the phase indicated. |---|---|---|---|---| | ~~P1~~ | ~~**License**~~ | ~~Phase 0~~ | ✅ **Dual MIT/Apache-2.0** | | | ~~P2~~ | ~~**MSRV**~~ | ~~Phase 0~~ | ✅ **stable-2 (Rust 1.83)** | | -| P3 | **String representation in AST** | Phase 1 | `String` (owned) · `Cow<'a, str>` · `Arc` | `String` simplest; `Cow` enables zero-copy parsing later | -| P4 | **Visitor pattern** | Phase 1 | Define trait now · Defer to Phase 5 | Recommend defining interface now; avoids refactor when renderer arrives | +| ~~P3~~ | ~~**String representation in AST**~~ | ~~Phase 1~~ | ✅ **`String` (owned)** | | +| ~~P4~~ | ~~**Visitor pattern**~~ | ~~Phase 1~~ | ✅ **Defined in Phase 1** | Open-recursion `Visitor` trait + `walk_*` helpers | | P5 | **Minimum Neovim version** | Phase 9 | 0.8 (`vim.lsp.start`) · 0.11 (`vim.lsp.config`) | 0.8 = broader compat; 0.11 = cleaner Lua API | | P6 | **Minimum Vim version** | Phase 9 | Vim 8.2 · Vim 9.0 · Vim 9.1 | LSP client plugins work best on 9.0+; recommend documenting 9.1 as minimum | | P7 | **Semantic token type mapping** | Phase 7 | Standard LSP types only · Custom token types | Custom types need client-side highlight group definitions; more flexible but more setup for users | diff --git a/crates/nuwiki-core/src/ast/block.rs b/crates/nuwiki-core/src/ast/block.rs new file mode 100644 index 0000000..8788672 --- /dev/null +++ b/crates/nuwiki-core/src/ast/block.rs @@ -0,0 +1,151 @@ +//! Block-level AST nodes and their supporting types. +//! +//! See SPEC.md §6.4. + +use super::inline::InlineNode; +use super::span::Span; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BlockNode { + Heading(HeadingNode), + Paragraph(ParagraphNode), + HorizontalRule(HorizontalRuleNode), + Blockquote(BlockquoteNode), + Preformatted(PreformattedNode), + MathBlock(MathBlockNode), + List(ListNode), + DefinitionList(DefinitionListNode), + Table(TableNode), + Comment(CommentNode), + Error(ErrorNode), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HeadingNode { + pub span: Span, + /// 1..=6 — invariant enforced by the parser, not the type. + pub level: u8, + pub children: Vec, + pub centered: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParagraphNode { + pub span: Span, + pub children: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HorizontalRuleNode { + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockquoteNode { + pub span: Span, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreformattedNode { + pub span: Span, + pub content: String, + pub language: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MathBlockNode { + pub span: Span, + pub content: String, + pub environment: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListNode { + pub span: Span, + pub ordered: bool, + pub symbol: ListSymbol, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListItemNode { + pub span: Span, + pub symbol: ListSymbol, + pub level: usize, + pub checkbox: Option, + pub children: Vec, + pub sublist: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ListSymbol { + Dash, + Star, + Hash, + Numeric, + NumericParen, + AlphaParen, + AlphaUpperParen, + RomanParen, + RomanUpperParen, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CheckboxState { + Empty, + Quarter, + Half, + ThreeQuarters, + Done, + Rejected, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefinitionListNode { + pub span: Span, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefinitionItemNode { + pub span: Span, + pub term: Option>, + pub definitions: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableNode { + pub span: Span, + pub rows: Vec, + pub has_header: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableRowNode { + pub span: Span, + pub cells: Vec, + pub is_header: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableCellNode { + pub span: Span, + pub children: Vec, + pub col_span: bool, + pub row_span: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommentNode { + pub span: Span, + pub content: String, +} + +/// Resilient parse-failure node — emitted instead of aborting the document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ErrorNode { + pub span: Span, + pub raw: String, + pub message: String, +} diff --git a/crates/nuwiki-core/src/ast/inline.rs b/crates/nuwiki-core/src/ast/inline.rs new file mode 100644 index 0000000..ed5c169 --- /dev/null +++ b/crates/nuwiki-core/src/ast/inline.rs @@ -0,0 +1,103 @@ +//! Inline AST nodes. +//! +//! See SPEC.md §6.4. The link-related variants (`WikiLink`, `ExternalLink`, +//! `Transclusion`, `RawUrl`) wrap structs defined in `super::link`. + +use super::link::{ExternalLinkNode, RawUrlNode, TransclusionNode, WikiLinkNode}; +use super::span::Span; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Keyword { + Todo, + Done, + Started, + Fixme, + Fixed, + Xxx, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InlineNode { + Text(TextNode), + Bold(BoldNode), + Italic(ItalicNode), + BoldItalic(BoldItalicNode), + Strikethrough(StrikethroughNode), + Code(CodeNode), + Superscript(SuperscriptNode), + Subscript(SubscriptNode), + MathInline(MathInlineNode), + Keyword(KeywordNode), + Color(ColorNode), + WikiLink(WikiLinkNode), + ExternalLink(ExternalLinkNode), + Transclusion(TransclusionNode), + RawUrl(RawUrlNode), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextNode { + pub span: Span, + pub content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BoldNode { + pub span: Span, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ItalicNode { + pub span: Span, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BoldItalicNode { + pub span: Span, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StrikethroughNode { + pub span: Span, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodeNode { + pub span: Span, + pub content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SuperscriptNode { + pub span: Span, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubscriptNode { + pub span: Span, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MathInlineNode { + pub span: Span, + pub content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeywordNode { + pub span: Span, + pub keyword: Keyword, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ColorNode { + pub span: Span, + pub color: String, + pub children: Vec, +} diff --git a/crates/nuwiki-core/src/ast/link.rs b/crates/nuwiki-core/src/ast/link.rs new file mode 100644 index 0000000..8f6a74c --- /dev/null +++ b/crates/nuwiki-core/src/ast/link.rs @@ -0,0 +1,66 @@ +//! Link-related AST nodes and supporting types. +//! +//! See SPEC.md §6.4. All nodes here are inline (`InlineNode` variants); +//! they live in their own module because the link model has enough surface +//! area (kinds, anchors, interwiki indirection) to warrant separation. + +use std::collections::HashMap; + +use super::inline::InlineNode; +use super::span::Span; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LinkKind { + Wiki, + Interwiki, + Diary, + File, + Local, + Raw, + AnchorOnly, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct LinkTarget { + pub kind: LinkKind, + pub path: Option, + pub wiki_index: Option, + pub wiki_name: Option, + pub anchor: Option, + pub is_absolute: bool, + pub is_directory: bool, +} + +impl Default for LinkKind { + fn default() -> Self { + Self::Wiki + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WikiLinkNode { + pub span: Span, + pub target: LinkTarget, + pub description: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalLinkNode { + pub span: Span, + pub url: String, + pub description: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransclusionNode { + pub span: Span, + pub url: String, + pub alt: Option, + pub attrs: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawUrlNode { + pub span: Span, + pub url: String, +} diff --git a/crates/nuwiki-core/src/ast/mod.rs b/crates/nuwiki-core/src/ast/mod.rs new file mode 100644 index 0000000..ee8e7ba --- /dev/null +++ b/crates/nuwiki-core/src/ast/mod.rs @@ -0,0 +1,46 @@ +//! AST types for nuwiki documents. +//! +//! Types here are syntax-agnostic — both vimwiki and (eventually) markdown +//! produce the same node shapes. See SPEC.md §6.4. + +pub mod block; +pub mod inline; +pub mod link; +pub mod span; +pub mod visit; + +pub use block::{ + BlockNode, BlockquoteNode, CheckboxState, CommentNode, DefinitionItemNode, DefinitionListNode, + ErrorNode, HeadingNode, HorizontalRuleNode, ListItemNode, ListNode, ListSymbol, MathBlockNode, + ParagraphNode, PreformattedNode, TableCellNode, TableNode, TableRowNode, +}; +pub use inline::{ + BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, Keyword, KeywordNode, + MathInlineNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TextNode, +}; +pub use link::{ + ExternalLinkNode, LinkKind, LinkTarget, RawUrlNode, TransclusionNode, WikiLinkNode, +}; +pub use span::{Position, Span}; +pub use visit::{ + walk_block, walk_definition_item, walk_document, walk_inline, walk_list, walk_list_item, + walk_table, walk_table_row, Visitor, +}; + +/// Root of an AST. See SPEC.md §6.4. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DocumentNode { + pub span: Span, + pub children: Vec, + pub metadata: PageMetadata, +} + +/// Document-level placeholders parsed from `%title` / `%nohtml` / `%template` +/// / `%date` directives. See SPEC.md §9. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PageMetadata { + pub title: Option, + pub nohtml: bool, + pub template: Option, + pub date: Option, +} diff --git a/crates/nuwiki-core/src/ast/span.rs b/crates/nuwiki-core/src/ast/span.rs new file mode 100644 index 0000000..3d6a5f8 --- /dev/null +++ b/crates/nuwiki-core/src/ast/span.rs @@ -0,0 +1,33 @@ +//! Source positions and spans carried on every AST node. +//! +//! See SPEC.md §6.5. Positions are 0-indexed; `column` is a byte offset +//! within a line; `offset` is a byte offset from the start of the document. + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Position { + pub line: u32, + pub column: u32, + pub offset: usize, +} + +impl Position { + pub const fn new(line: u32, column: u32, offset: usize) -> Self { + Self { + line, + column, + offset, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct Span { + pub start: Position, + pub end: Position, +} + +impl Span { + pub const fn new(start: Position, end: Position) -> Self { + Self { start, end } + } +} diff --git a/crates/nuwiki-core/src/ast/visit.rs b/crates/nuwiki-core/src/ast/visit.rs new file mode 100644 index 0000000..d2b1375 --- /dev/null +++ b/crates/nuwiki-core/src/ast/visit.rs @@ -0,0 +1,226 @@ +//! Open-recursion AST visitor. +//! +//! Each `visit_*` method has a default body that calls the corresponding +//! `walk_*` free function, which in turn dispatches back into the trait. +//! Override any method to short-circuit or augment traversal — call the +//! matching `walk_*` from your override to keep descending. +//! +//! Pattern follows `rustc`'s and `syn`'s visitors. See SPEC.md §6.4 + P4. +//! +//! Read-only for now. A `VisitorMut` flavor can be added when transformations +//! become relevant (post-Phase 5). + +use super::block::{ + BlockNode, BlockquoteNode, CommentNode, DefinitionItemNode, DefinitionListNode, ErrorNode, + HeadingNode, HorizontalRuleNode, ListItemNode, ListNode, MathBlockNode, ParagraphNode, + PreformattedNode, TableCellNode, TableNode, TableRowNode, +}; +use super::inline::{ + BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, KeywordNode, + MathInlineNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TextNode, +}; +use super::link::{ExternalLinkNode, RawUrlNode, TransclusionNode, WikiLinkNode}; +use super::DocumentNode; + +pub trait Visitor { + // Top level + fn visit_document(&mut self, node: &DocumentNode) { + walk_document(self, node); + } + + // Block dispatch + fn visit_block(&mut self, node: &BlockNode) { + walk_block(self, node); + } + + // Inline dispatch + fn visit_inline(&mut self, node: &InlineNode) { + walk_inline(self, node); + } + + // Block leaves and recursive block nodes + fn visit_heading(&mut self, node: &HeadingNode) { + for child in &node.children { + self.visit_inline(child); + } + } + fn visit_paragraph(&mut self, node: &ParagraphNode) { + for child in &node.children { + self.visit_inline(child); + } + } + fn visit_horizontal_rule(&mut self, _node: &HorizontalRuleNode) {} + fn visit_blockquote(&mut self, node: &BlockquoteNode) { + for child in &node.children { + self.visit_block(child); + } + } + fn visit_preformatted(&mut self, _node: &PreformattedNode) {} + fn visit_math_block(&mut self, _node: &MathBlockNode) {} + fn visit_list(&mut self, node: &ListNode) { + walk_list(self, node); + } + fn visit_list_item(&mut self, node: &ListItemNode) { + walk_list_item(self, node); + } + fn visit_definition_list(&mut self, node: &DefinitionListNode) { + for item in &node.items { + self.visit_definition_item(item); + } + } + fn visit_definition_item(&mut self, node: &DefinitionItemNode) { + walk_definition_item(self, node); + } + fn visit_table(&mut self, node: &TableNode) { + walk_table(self, node); + } + fn visit_table_row(&mut self, node: &TableRowNode) { + walk_table_row(self, node); + } + fn visit_table_cell(&mut self, node: &TableCellNode) { + for child in &node.children { + self.visit_inline(child); + } + } + fn visit_comment(&mut self, _node: &CommentNode) {} + fn visit_error(&mut self, _node: &ErrorNode) {} + + // Inline leaves and recursive inline nodes + fn visit_text(&mut self, _node: &TextNode) {} + fn visit_bold(&mut self, node: &BoldNode) { + for child in &node.children { + self.visit_inline(child); + } + } + fn visit_italic(&mut self, node: &ItalicNode) { + for child in &node.children { + self.visit_inline(child); + } + } + fn visit_bold_italic(&mut self, node: &BoldItalicNode) { + for child in &node.children { + self.visit_inline(child); + } + } + fn visit_strikethrough(&mut self, node: &StrikethroughNode) { + for child in &node.children { + self.visit_inline(child); + } + } + fn visit_code(&mut self, _node: &CodeNode) {} + fn visit_superscript(&mut self, node: &SuperscriptNode) { + for child in &node.children { + self.visit_inline(child); + } + } + fn visit_subscript(&mut self, node: &SubscriptNode) { + for child in &node.children { + self.visit_inline(child); + } + } + fn visit_math_inline(&mut self, _node: &MathInlineNode) {} + fn visit_keyword(&mut self, _node: &KeywordNode) {} + fn visit_color(&mut self, node: &ColorNode) { + for child in &node.children { + self.visit_inline(child); + } + } + fn visit_wiki_link(&mut self, node: &WikiLinkNode) { + if let Some(desc) = &node.description { + for child in desc { + self.visit_inline(child); + } + } + } + fn visit_external_link(&mut self, node: &ExternalLinkNode) { + if let Some(desc) = &node.description { + for child in desc { + self.visit_inline(child); + } + } + } + fn visit_transclusion(&mut self, _node: &TransclusionNode) {} + fn visit_raw_url(&mut self, _node: &RawUrlNode) {} +} + +pub fn walk_document(v: &mut V, node: &DocumentNode) { + for child in &node.children { + v.visit_block(child); + } +} + +pub fn walk_block(v: &mut V, node: &BlockNode) { + match node { + BlockNode::Heading(n) => v.visit_heading(n), + BlockNode::Paragraph(n) => v.visit_paragraph(n), + BlockNode::HorizontalRule(n) => v.visit_horizontal_rule(n), + BlockNode::Blockquote(n) => v.visit_blockquote(n), + BlockNode::Preformatted(n) => v.visit_preformatted(n), + BlockNode::MathBlock(n) => v.visit_math_block(n), + BlockNode::List(n) => v.visit_list(n), + BlockNode::DefinitionList(n) => v.visit_definition_list(n), + BlockNode::Table(n) => v.visit_table(n), + BlockNode::Comment(n) => v.visit_comment(n), + BlockNode::Error(n) => v.visit_error(n), + } +} + +pub fn walk_inline(v: &mut V, node: &InlineNode) { + match node { + InlineNode::Text(n) => v.visit_text(n), + InlineNode::Bold(n) => v.visit_bold(n), + InlineNode::Italic(n) => v.visit_italic(n), + InlineNode::BoldItalic(n) => v.visit_bold_italic(n), + InlineNode::Strikethrough(n) => v.visit_strikethrough(n), + InlineNode::Code(n) => v.visit_code(n), + InlineNode::Superscript(n) => v.visit_superscript(n), + InlineNode::Subscript(n) => v.visit_subscript(n), + InlineNode::MathInline(n) => v.visit_math_inline(n), + InlineNode::Keyword(n) => v.visit_keyword(n), + InlineNode::Color(n) => v.visit_color(n), + InlineNode::WikiLink(n) => v.visit_wiki_link(n), + InlineNode::ExternalLink(n) => v.visit_external_link(n), + InlineNode::Transclusion(n) => v.visit_transclusion(n), + InlineNode::RawUrl(n) => v.visit_raw_url(n), + } +} + +pub fn walk_list(v: &mut V, node: &ListNode) { + for item in &node.items { + v.visit_list_item(item); + } +} + +pub fn walk_list_item(v: &mut V, node: &ListItemNode) { + for child in &node.children { + v.visit_inline(child); + } + if let Some(sublist) = &node.sublist { + v.visit_list(sublist); + } +} + +pub fn walk_definition_item(v: &mut V, node: &DefinitionItemNode) { + if let Some(term) = &node.term { + for child in term { + v.visit_inline(child); + } + } + for definition in &node.definitions { + for child in definition { + v.visit_inline(child); + } + } +} + +pub fn walk_table(v: &mut V, node: &TableNode) { + for row in &node.rows { + v.visit_table_row(row); + } +} + +pub fn walk_table_row(v: &mut V, node: &TableRowNode) { + for cell in &node.cells { + v.visit_table_cell(cell); + } +} diff --git a/crates/nuwiki-core/src/lib.rs b/crates/nuwiki-core/src/lib.rs index b375515..54d0e55 100644 --- a/crates/nuwiki-core/src/lib.rs +++ b/crates/nuwiki-core/src/lib.rs @@ -2,3 +2,5 @@ //! //! This crate is editor-independent and must never depend on `nuwiki-lsp` or //! `nuwiki-ls`. See SPEC.md §6.2 for the dependency rules. + +pub mod ast; diff --git a/crates/nuwiki-core/tests/visitor.rs b/crates/nuwiki-core/tests/visitor.rs new file mode 100644 index 0000000..ad5ba1c --- /dev/null +++ b/crates/nuwiki-core/tests/visitor.rs @@ -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); +}