feat: initial nuwiki Rust workspace (core, lsp, ls)
CI / cargo fmt --check (push) Successful in 59s
CI / cargo clippy (push) Successful in 1m20s
CI / cargo test (push) Successful in 1m29s
CI / editor keymaps (push) Failing after 33s

This commit is contained in:
gffranco
2026-06-24 00:01:59 +00:00
commit 29a4efb6bc
70 changed files with 26264 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
//! Cluster 4 — diary frequency / period primitives.
//! Tests the date math for ISO weeks, monthly, yearly periods.
use nuwiki_core::date::{
DiaryCalendar, DiaryDate, DiaryFrequency, DiaryPeriod, WeekStart, WeeklyStyle,
};
#[test]
fn frequency_parses_canonical_strings() {
assert_eq!(DiaryFrequency::parse("daily"), DiaryFrequency::Daily);
assert_eq!(DiaryFrequency::parse("weekly"), DiaryFrequency::Weekly);
assert_eq!(DiaryFrequency::parse("monthly"), DiaryFrequency::Monthly);
assert_eq!(DiaryFrequency::parse("yearly"), DiaryFrequency::Yearly);
assert_eq!(DiaryFrequency::parse("WeEkLy"), DiaryFrequency::Weekly);
// Unknown falls back to Daily (mirrors vimwiki's permissive behaviour).
assert_eq!(DiaryFrequency::parse("hourly"), DiaryFrequency::Daily);
assert_eq!(DiaryFrequency::parse(""), DiaryFrequency::Daily);
}
#[test]
fn period_format_round_trips() {
let cases = [
(
"2026-05-12",
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
),
(
"2026-W19",
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
},
),
(
"2026-05",
DiaryPeriod::Month {
year: 2026,
month: 5,
},
),
("2026", DiaryPeriod::Year { year: 2026 }),
];
for (s, want) in cases {
let parsed = DiaryPeriod::parse(s).unwrap_or_else(|| panic!("parse {s}"));
assert_eq!(parsed, want, "parse({s})");
assert_eq!(parsed.format(), s, "format({s})");
}
}
#[test]
fn period_parse_rejects_garbage() {
assert!(DiaryPeriod::parse("garbage").is_none());
assert!(DiaryPeriod::parse("2026-W00").is_none());
assert!(DiaryPeriod::parse("2026-13").is_none()); // month 13
assert!(DiaryPeriod::parse("2026-").is_none());
}
#[test]
fn iso_week_jan_1_can_belong_to_prior_year() {
// 2021-01-01 was a Friday → ISO week 53 of 2020.
let d = DiaryDate::from_ymd(2021, 1, 1).unwrap();
let p = DiaryPeriod::week_containing(d);
assert_eq!(
p,
DiaryPeriod::Week {
iso_year: 2020,
iso_week: 53
}
);
}
#[test]
fn iso_week_dec_31_can_belong_to_next_year() {
// 2024-12-30 (Monday) is in ISO week 1 of 2025.
let d = DiaryDate::from_ymd(2024, 12, 30).unwrap();
let p = DiaryPeriod::week_containing(d);
assert_eq!(
p,
DiaryPeriod::Week {
iso_year: 2025,
iso_week: 1
}
);
}
#[test]
fn iso_week_mid_year_is_consistent() {
// 2026-05-12 is a Tuesday in ISO week 20.
let d = DiaryDate::from_ymd(2026, 5, 12).unwrap();
let p = DiaryPeriod::week_containing(d);
assert_eq!(
p,
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 20
}
);
}
#[test]
fn week_next_and_prev_step_by_seven_days() {
let p = DiaryPeriod::Week {
iso_year: 2026,
iso_week: 20,
};
assert_eq!(
p.next(),
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 21
}
);
assert_eq!(
p.prev(),
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19
}
);
}
#[test]
fn week_next_crosses_year_boundary() {
// Week 53 of 2020 → week 53 ends; next should be week 1 of 2021.
let p = DiaryPeriod::Week {
iso_year: 2020,
iso_week: 53,
};
assert_eq!(
p.next(),
DiaryPeriod::Week {
iso_year: 2021,
iso_week: 1
}
);
}
#[test]
fn month_next_wraps_to_january() {
let dec = DiaryPeriod::Month {
year: 2026,
month: 12,
};
assert_eq!(
dec.next(),
DiaryPeriod::Month {
year: 2027,
month: 1
}
);
}
#[test]
fn month_prev_wraps_to_december() {
let jan = DiaryPeriod::Month {
year: 2026,
month: 1,
};
assert_eq!(
jan.prev(),
DiaryPeriod::Month {
year: 2025,
month: 12
}
);
}
#[test]
fn year_next_and_prev_increment_by_one() {
let y = DiaryPeriod::Year { year: 2026 };
assert_eq!(y.next(), DiaryPeriod::Year { year: 2027 });
assert_eq!(y.prev(), DiaryPeriod::Year { year: 2025 });
}
#[test]
fn day_next_and_prev_still_work() {
let d = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap());
assert_eq!(
d.next(),
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 13).unwrap())
);
assert_eq!(
d.prev(),
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 11).unwrap())
);
}
// ===== DiaryCalendar — weekly naming styles + week-start =====
// 2026-05-25 is a Monday (so 2026-05-27 is a Wednesday, 2026-05-24 a Sunday).
fn day(y: i32, m: u8, d: u8) -> DiaryPeriod {
DiaryPeriod::Day(DiaryDate::from_ymd(y, m, d).unwrap())
}
#[test]
fn weekly_style_and_week_start_parse() {
assert_eq!(WeeklyStyle::parse("date"), WeeklyStyle::Date);
assert_eq!(WeeklyStyle::parse("vimwiki"), WeeklyStyle::Date);
assert_eq!(WeeklyStyle::parse("iso"), WeeklyStyle::Iso);
assert_eq!(WeeklyStyle::parse(""), WeeklyStyle::Iso); // default
assert_eq!(WeeklyStyle::parse("WEEK"), WeeklyStyle::Iso);
// WeekStart::parse is exercised via the calendar tests below; unknown
// names fall back to Monday.
assert_eq!(WeekStart::parse("sunday"), WeekStart::parse("SUNDAY"));
assert_eq!(WeekStart::parse("nonsense"), WeekStart::monday());
}
#[test]
fn date_mode_weekly_today_is_a_day() {
let cal = DiaryCalendar::new(
DiaryFrequency::Weekly,
WeeklyStyle::Date,
WeekStart::monday(),
);
assert!(matches!(cal.today(), DiaryPeriod::Day(_)));
}
#[test]
fn iso_mode_weekly_today_is_a_week() {
let cal = DiaryCalendar::new(
DiaryFrequency::Weekly,
WeeklyStyle::Iso,
WeekStart::monday(),
);
assert!(matches!(cal.today(), DiaryPeriod::Week { .. }));
}
#[test]
fn date_mode_weekly_steps_seven_days_from_the_week_start_monday() {
let cal = DiaryCalendar::new(
DiaryFrequency::Weekly,
WeeklyStyle::Date,
WeekStart::monday(),
);
// From a Wednesday: snap back to Mon 05-25, then ±7.
assert_eq!(cal.next(day(2026, 5, 27)), day(2026, 6, 1));
assert_eq!(cal.prev(day(2026, 5, 27)), day(2026, 5, 18));
// From the Monday itself: no snap, just ±7.
assert_eq!(cal.next(day(2026, 5, 25)), day(2026, 6, 1));
assert_eq!(cal.prev(day(2026, 5, 25)), day(2026, 5, 18));
// Stem is the plain date, like upstream.
assert_eq!(cal.next(day(2026, 5, 27)).format(), "2026-06-01");
}
#[test]
fn date_mode_weekly_honours_a_sunday_week_start() {
let cal = DiaryCalendar::new(
DiaryFrequency::Weekly,
WeeklyStyle::Date,
WeekStart::parse("sunday"),
);
// Wed 05-27 snaps back to Sun 05-24, +7 = Sun 05-31.
assert_eq!(cal.next(day(2026, 5, 27)), day(2026, 5, 31));
assert_eq!(cal.prev(day(2026, 5, 27)), day(2026, 5, 17));
}
#[test]
fn iso_mode_weekly_delegates_to_iso_week_stepping() {
let cal = DiaryCalendar::new(
DiaryFrequency::Weekly,
WeeklyStyle::Iso,
WeekStart::monday(),
);
let wk = DiaryPeriod::week_containing(DiaryDate::from_ymd(2026, 5, 27).unwrap());
assert_eq!(cal.next(wk), wk.next());
assert_eq!(cal.prev(wk), wk.prev());
}
#[test]
fn daily_calendar_steps_one_day_regardless_of_weekly_fields() {
let cal = DiaryCalendar::new(
DiaryFrequency::Daily,
WeeklyStyle::Date,
WeekStart::parse("sunday"),
);
assert_eq!(cal.next(day(2026, 5, 27)), day(2026, 5, 28));
assert_eq!(cal.prev(day(2026, 5, 27)), day(2026, 5, 26));
}
+532
View File
@@ -0,0 +1,532 @@
//! End-to-end tests for the HTML renderer.
//!
//! Pipeline: source text → `VimwikiSyntax::parse` → `HtmlRenderer::render`.
use nuwiki_core::ast::{
BlockNode, DocumentNode, InlineNode, LinkTarget, Span, TableCellNode, TableNode, TableRowNode,
TextNode,
};
use nuwiki_core::render::{HtmlRenderer, Renderer};
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
fn render(src: &str) -> String {
let doc = VimwikiSyntax::new().parse(src);
HtmlRenderer::new().render_to_string(&doc).unwrap()
}
fn span_cell(text: &str, col_span: bool, row_span: bool) -> TableCellNode {
let s = Span::default();
TableCellNode {
span: s,
children: vec![InlineNode::Text(TextNode {
span: s,
content: text.into(),
})],
col_span,
row_span,
}
}
fn span_table(rows: Vec<Vec<TableCellNode>>, has_header: bool) -> DocumentNode {
let span = Span::default();
let rows: Vec<TableRowNode> = rows
.into_iter()
.enumerate()
.map(|(i, cells)| TableRowNode {
span,
cells,
is_header: has_header && i == 0,
})
.collect();
DocumentNode {
span,
metadata: Default::default(),
children: vec![BlockNode::Table(TableNode {
span,
rows,
has_header,
alignments: Vec::new(),
})],
}
}
// ===== Block elements =====
#[test]
fn empty_document_renders_to_empty_string() {
assert_eq!(render(""), "");
}
#[test]
fn heading_levels() {
for level in 1..=6 {
let bars = "=".repeat(level);
let out = render(&format!("{bars} Title {bars}\n"));
assert!(out.starts_with(&format!("<h{level} id=\"Title\">Title</h{level}>")));
}
}
#[test]
fn centered_heading_gets_centered_class() {
let out = render(" = T =\n");
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
}
#[test]
fn paragraph_wraps_inline_content() {
let out = render("Hello world\n");
assert_eq!(out.trim(), "<p>Hello world</p>");
}
#[test]
fn horizontal_rule() {
let out = render("----\n");
assert!(out.contains("<hr>"));
}
#[test]
fn blockquote_lines_become_paragraphs() {
let out = render("> first\n> second\n");
assert!(out.contains("<blockquote>"));
assert!(out.contains("<p>first</p>"));
assert!(out.contains("<p>second</p>"));
}
#[test]
fn preformatted_block_emits_pre_code_with_language_class() {
let out = render("{{{rust\nfn main() {}\n}}}\n");
assert!(out.contains("<pre><code class=\"language-rust\">"));
assert!(out.contains("fn main() {}"));
assert!(out.contains("</code></pre>"));
}
#[test]
fn math_block_emits_div() {
let out = render("{{$\nx=1\n}}$\n");
assert!(out.contains("<div class=\"math-block\">"));
assert!(out.contains("x=1"));
}
#[test]
fn unordered_list_with_checkbox() {
let out = render("- [X] done\n- not done\n");
assert!(out.contains("<ul>"));
// vimwiki-compatible: class on the <li>, no <input> (the stylesheet draws it).
assert!(out.contains("<li class=\"done4\">done</li>"), "{out}");
assert!(out.contains("<li>not done</li>"), "{out}");
assert!(!out.contains("<input"), "no input element: {out}");
}
#[test]
fn checkbox_states_use_vimwiki_done_classes() {
let out = render("- [ ] a\n- [.] b\n- [o] c\n- [O] d\n- [X] e\n- [-] f\n");
assert!(out.contains("<li class=\"done0\">a</li>"), "{out}");
assert!(out.contains("<li class=\"done1\">b</li>"), "{out}");
assert!(out.contains("<li class=\"done2\">c</li>"), "{out}");
assert!(out.contains("<li class=\"done3\">d</li>"), "{out}");
assert!(out.contains("<li class=\"done4\">e</li>"), "{out}");
assert!(out.contains("<li class=\"rejected\">f</li>"), "{out}");
assert!(!out.contains("task-"), "no legacy task-* classes: {out}");
assert!(!out.contains("<input"), "no input elements: {out}");
}
#[test]
fn ordered_list() {
let out = render("1. first\n");
assert!(out.contains("<ol>"));
assert!(out.contains("<li>first</li>"));
}
#[test]
fn nested_list_renders_recursively() {
let out = render("- top\n - nested\n");
assert!(out.contains("<ul>"));
let inner_idx = out
.find("<ul>\n<li>top")
.map(|i| i + "<ul>\n<li>top".len())
.unwrap_or(0);
assert!(out[inner_idx..].contains("<ul>"));
assert!(out.contains("nested"));
}
#[test]
fn definition_list() {
let out = render("Term:: Defn\n");
assert!(out.contains("<dl>"));
assert!(out.contains("<dt>Term</dt>"));
assert!(out.contains("<dd>Defn</dd>"));
}
#[test]
fn table_with_header_separator() {
let out = render("| a | b |\n|---|---|\n| 1 | 2 |\n");
assert!(out.contains("<table>"));
assert!(out.contains("<thead>"));
assert!(out.contains("<th> a </th>"));
assert!(out.contains("<tbody>"));
assert!(out.contains("<td> 1 </td>"));
}
#[test]
fn single_comment_becomes_html_comment() {
let out = render("%% hidden\n");
assert!(out.contains("<!--"));
assert!(out.contains("hidden"));
assert!(out.contains("-->"));
}
// ===== Inline =====
#[test]
fn bold_italic_strike_super_sub() {
let out = render("*b* _i_ ~~s~~ ^sup^ ,,sub,,\n");
assert!(out.contains("<strong>b</strong>"));
assert!(out.contains("<em>i</em>"));
assert!(out.contains("<del>s</del>"));
assert!(out.contains("<sup>sup</sup>"));
assert!(out.contains("<sub>sub</sub>"));
}
#[test]
fn inline_code_is_escaped() {
let out = render("Use `Vec<u32>` here\n");
assert!(out.contains("<code>Vec&lt;u32&gt;</code>"));
}
#[test]
fn keyword_spans() {
// One class per keyword so a stylesheet can colour groups differently.
// TODO keeps the vimwiki `todo` class.
let cases = [
("TODO x\n", "<span class=\"todo\">TODO</span>"),
("DONE x\n", "<span class=\"done\">DONE</span>"),
("STARTED x\n", "<span class=\"started\">STARTED</span>"),
("FIXME x\n", "<span class=\"fixme\">FIXME</span>"),
("FIXED x\n", "<span class=\"fixed\">FIXED</span>"),
("XXX x\n", "<span class=\"xxx\">XXX</span>"),
("STOPPED x\n", "<span class=\"stopped\">STOPPED</span>"),
];
for (src, expected) in cases {
let out = render(src);
assert!(out.contains(expected), "src {src:?} -> {out}");
}
}
#[test]
fn raw_url_link() {
let out = render("See https://example.com here\n");
assert!(out.contains("<a href=\"https://example.com\">https://example.com</a>"));
}
#[test]
fn wikilink_with_default_resolver() {
let out = render("[[Page]]\n");
assert!(out.contains("<a href=\"Page.html\">Page</a>"));
}
#[test]
fn wikilink_with_anchor_default_resolver() {
let out = render("[[Page#Section]]\n");
assert!(out.contains("<a href=\"Page.html#Section\">"));
}
#[test]
fn anchor_only_link() {
let out = render("[[#Section]]\n");
assert!(out.contains("<a href=\"#Section\">"));
}
#[test]
fn heading_id_matches_anchor_link_href() {
// The heading carries an `id` equal to its plain text, so a `#anchor`
// link (TOC or cross-reference) actually lands on it. Regression guard
// for "exported HTML anchor links don't work".
let out = render("= My Section =\n\n[[#My Section]]\n");
assert!(
out.contains("<h1 id=\"My Section\">My Section</h1>"),
"heading id: {out}"
);
assert!(
out.contains("<a href=\"#My Section\">"),
"anchor href: {out}"
);
}
#[test]
fn toc_header_heading_wrapped_in_div_toc() {
// The TOC section heading gets a `<div class="toc">` wrapper (vimwiki
// scheme) so `.toc` stylesheet rules apply. The class is on the div, not
// the heading.
let doc = VimwikiSyntax::new().parse("== Contents ==\n");
let out = HtmlRenderer::new()
.with_toc_header("Contents")
.render_to_string(&doc)
.unwrap();
assert!(
out.contains("<div class=\"toc\"><h2 id=\"Contents\">Contents</h2></div>"),
"got: {out}"
);
}
#[test]
fn non_toc_heading_not_wrapped() {
let doc = VimwikiSyntax::new().parse("== Intro ==\n");
let out = HtmlRenderer::new()
.with_toc_header("Contents")
.render_to_string(&doc)
.unwrap();
assert!(out.contains("<h2 id=\"Intro\">Intro</h2>"), "got: {out}");
assert!(!out.contains("class=\"toc\""), "got: {out}");
}
#[test]
fn interwiki_numbered_link() {
let out = render("[[wiki1:Page]]\n");
assert!(out.contains("<a href=\"../wiki1/Page.html\">"));
}
#[test]
fn interwiki_named_link() {
let out = render("[[wn.Notes:Page]]\n");
assert!(out.contains("<a href=\"../wn-Notes/Page.html\">"));
}
#[test]
fn diary_link() {
let out = render("[[diary:2026-05-10]]\n");
assert!(out.contains("<a href=\"diary/2026-05-10.html\">"));
}
#[test]
fn external_link_routes_to_external_link_node() {
let out = render("[[https://example.com|docs]]\n");
assert!(out.contains("<a href=\"https://example.com\">docs</a>"));
}
#[test]
fn transclusion_becomes_img() {
let out = render("{{img.png|cat photo|class=hi}}\n");
assert!(out.contains("<img src=\"img.png\""));
assert!(out.contains("alt=\"cat photo\""));
assert!(out.contains("class=\"hi\""));
}
// ===== HTML escaping =====
#[test]
fn html_special_chars_in_text_are_escaped() {
let out = render("a < b > c & d \"e\"\n");
assert!(out.contains("a &lt; b &gt; c &amp; d &quot;e&quot;"));
}
// ===== Custom link resolver =====
#[test]
fn custom_link_resolver_overrides_href() {
let doc = VimwikiSyntax::new().parse("[[Page]]\n");
let renderer = HtmlRenderer::new()
.with_link_resolver(|t: &LinkTarget| format!("/wiki/{}", t.path.as_deref().unwrap_or("")));
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<a href=\"/wiki/Page\">"));
}
// ===== Template =====
#[test]
fn template_substitutes_content_and_title() {
let doc = VimwikiSyntax::new().parse("%title My Page\n= Hello =\n");
let renderer = HtmlRenderer::new().with_template(
"<!doctype html><html><head><title>{{title}}</title></head>\
<body>{{content}}</body></html>",
);
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title>My Page</title>"));
assert!(out.contains("<body><h1 id=\"Hello\">Hello</h1>"));
}
#[test]
fn template_with_missing_title_substitutes_empty_string() {
let doc = VimwikiSyntax::new().parse("= Heading =\n");
let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}");
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title></title>"));
assert!(out.contains("<h1 id=\"Heading\">Heading</h1>"));
}
#[test]
fn template_substitutes_vimwiki_percent_placeholders() {
// Stock vimwiki templates use `%title%` / `%content%` / `%root_path%`
// rather than nuwiki's `{{…}}`. Both must resolve.
let doc = VimwikiSyntax::new().parse("%title My Page\n= Hello =\n");
let renderer = HtmlRenderer::new()
.with_template(
"<title>%title%</title><link href=\"%root_path%style.css\"><body>%content%</body>",
)
.with_var("root_path", "../");
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title>My Page</title>"), "title: {out}");
assert!(
out.contains("<body><h1 id=\"Hello\">Hello</h1>"),
"content: {out}"
);
assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
assert!(!out.contains('%'), "no placeholder left: {out}");
}
// ===== Colour spans =====
// `ColorNode` is an extension point the vimwiki parser never emits, so build
// a document around one by hand and render it directly.
fn doc_with_color(color: &str) -> DocumentNode {
use nuwiki_core::ast::{ColorNode, ParagraphNode};
let color_node = InlineNode::Color(ColorNode {
span: Span::default(),
color: color.to_string(),
children: vec![InlineNode::Text(TextNode {
span: Span::default(),
content: "hi".to_string(),
})],
});
DocumentNode {
children: vec![BlockNode::Paragraph(ParagraphNode {
span: Span::default(),
children: vec![color_node],
})],
..DocumentNode::default()
}
}
#[test]
fn color_dic_name_uses_inline_style_via_default_template() {
let doc = doc_with_color("red");
let renderer =
HtmlRenderer::new().with_colors([("red".to_string(), "crimson".to_string())].into());
let out = renderer.render_to_string(&doc).unwrap();
assert!(
out.contains("<span style=\"color:crimson\">hi</span>"),
"got: {out}"
);
}
#[test]
fn color_tag_template_override_is_honored() {
let doc = doc_with_color("red");
let renderer = HtmlRenderer::new()
.with_colors([("red".to_string(), "crimson".to_string())].into())
.with_color_template("<em data-style=\"__STYLE__\">__CONTENT__</em>");
let out = renderer.render_to_string(&doc).unwrap();
assert!(
out.contains("<em data-style=\"color:crimson\">hi</em>"),
"got: {out}"
);
}
#[test]
fn unknown_color_name_falls_back_to_class() {
let doc = doc_with_color("plaid");
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(
out.contains("<span class=\"color-plaid\">hi</span>"),
"got: {out}"
);
}
// ===== End-to-end smoke =====
#[test]
fn full_document_round_trip() {
let src = "\
%title Smoke
= Heading =
Paragraph with *bold* and _italic_ and `code`.
- one
- two
> quoted
----
| a | b |
|---|---|
| 1 | 2 |
{{{rust
fn x() {}
}}}
";
let doc = VimwikiSyntax::new().parse(src);
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
// A few sanity checks; the full output is exercised piece-by-piece above.
for needle in [
"<h1 id=\"Heading\">Heading</h1>",
"<strong>bold</strong>",
"<em>italic</em>",
"<code>code</code>",
"<ul>",
"<blockquote>",
"<hr>",
"<table>",
"<pre><code class=\"language-rust\">",
] {
assert!(
out.contains(needle),
"expected {needle:?} in output:\n{out}"
);
}
}
// ===== Table colspan / rowspan =====
#[test]
fn colspan_marker_folds_into_lead_cell() {
// | a | b | c |
// | d | > | e | → second row has b spanning 2 cols (cell index 1 is >)
let doc = span_table(
vec![
vec![
span_cell("a", false, false),
span_cell("b", false, false),
span_cell("c", false, false),
],
vec![
span_cell("d", false, false),
span_cell(">", true, false),
span_cell("e", false, false),
],
],
false,
);
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(out.contains(r#"<td colspan="2">d</td>"#), "got: {out}");
assert!(!out.contains(r#"class="col-span""#));
}
#[test]
fn rowspan_marker_folds_into_lead_cell_above() {
let doc = span_table(
vec![
vec![span_cell("a", false, false), span_cell("b", false, false)],
vec![span_cell("c", false, false), span_cell("\\/", false, true)],
],
false,
);
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(out.contains(r#"<td rowspan="2">b</td>"#), "got: {out}");
}
#[test]
fn no_spans_emits_no_table_attrs() {
let doc = span_table(
vec![
vec![span_cell("a", false, false), span_cell("b", false, false)],
vec![span_cell("c", false, false), span_cell("d", false, false)],
],
false,
);
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(!out.contains("colspan="));
assert!(!out.contains("rowspan="));
}
+639
View File
@@ -0,0 +1,639 @@
//! End-to-end tests for the vimwiki lexer.
//!
//! Each test feeds a small fragment to `VimwikiLexer::lex` and asserts the
//! exact token-kind sequence. A separate `spans_are_byte_accurate` test
//! pins down position semantics.
use std::collections::HashMap;
use nuwiki_core::ast::{CheckboxState, Keyword, ListSymbol, Position};
use nuwiki_core::syntax::vimwiki::{VimwikiLexer, VimwikiTokenKind};
use nuwiki_core::syntax::Lexer;
use VimwikiTokenKind::*;
fn lex(text: &str) -> Vec<VimwikiTokenKind> {
VimwikiLexer::new()
.lex(text)
.into_iter()
.map(|t| t.kind)
.collect()
}
fn text(s: &str) -> VimwikiTokenKind {
Text(s.into())
}
// ===== Empty / whitespace =====
#[test]
fn empty_input_produces_no_tokens() {
assert!(lex("").is_empty());
}
#[test]
fn lone_newline_is_a_blank_line() {
assert_eq!(lex("\n"), vec![BlankLine]);
}
#[test]
fn whitespace_only_line_is_a_blank_line() {
assert_eq!(lex(" \n"), vec![BlankLine]);
}
#[test]
fn blank_lines_separate_paragraphs() {
assert_eq!(
lex("hello\n\nworld\n"),
vec![text("hello"), Newline, BlankLine, text("world"), Newline]
);
}
// ===== Headings =====
#[test]
fn headings_at_all_six_levels() {
let mut tokens = Vec::new();
for level in 1..=6 {
let line = format!(
"{} L{} {}\n",
"=".repeat(level as usize),
level,
"=".repeat(level as usize)
);
let lexed = lex(&line);
assert!(matches!(
lexed[0],
HeadingOpen { level: l, centered: false } if l == level
));
tokens.extend(lexed);
}
assert!(tokens.iter().any(|t| matches!(t, HeadingClose)));
}
#[test]
fn centered_heading_is_marked_centered() {
let lexed = lex(" = title =\n");
assert!(matches!(
lexed[0],
HeadingOpen {
level: 1,
centered: true
}
));
assert_eq!(lexed[1], text("title"));
assert!(matches!(lexed[2], HeadingClose));
}
#[test]
fn heading_with_inline_bold() {
let lexed = lex("== Hello *world* ==\n");
assert_eq!(
lexed,
vec![
HeadingOpen {
level: 2,
centered: false
},
text("Hello "),
BoldDelim,
text("world"),
BoldDelim,
HeadingClose,
Newline,
]
);
}
// ===== Horizontal rule =====
#[test]
fn horizontal_rule_requires_four_or_more_dashes() {
assert_eq!(lex("----\n"), vec![HorizontalRule, Newline]);
assert_eq!(lex("--------\n"), vec![HorizontalRule, Newline]);
// Three dashes: not a rule, parsed as text.
assert_eq!(lex("---\n"), vec![text("---"), Newline]);
}
// ===== Comments =====
#[test]
fn single_line_comment() {
assert_eq!(
lex("%% hello\n"),
vec![CommentLine(" hello".into()), Newline]
);
}
#[test]
fn multiline_comment_spans_multiple_lines() {
assert_eq!(
lex("%%+ a\nb\n+%%\n"),
vec![
CommentMultiOpen,
CommentMultiLine(" a".into()),
Newline,
CommentMultiLine("b".into()),
Newline,
CommentMultiClose,
Newline,
]
);
}
#[test]
fn multiline_comment_one_liner() {
assert_eq!(
lex("%%+ inline +%%\n"),
vec![
CommentMultiOpen,
CommentMultiLine(" inline ".into()),
CommentMultiClose,
Newline,
]
);
}
// ===== Preformatted =====
#[test]
fn preformatted_block_captures_lines_verbatim() {
let lexed = lex("{{{\nfn main() {}\n}}}\n");
assert!(matches!(lexed[0], PreformattedOpen { .. }));
assert_eq!(lexed[1], Newline);
assert_eq!(lexed[2], PreformattedLine("fn main() {}".into()));
assert_eq!(lexed[3], Newline);
assert_eq!(lexed[4], PreformattedClose);
assert_eq!(lexed[5], Newline);
}
#[test]
fn preformatted_block_parses_language() {
let lexed = lex("{{{rust\nx\n}}}\n");
let PreformattedOpen { language, .. } = &lexed[0] else {
panic!("expected PreformattedOpen");
};
assert_eq!(language.as_deref(), Some("rust"));
}
#[test]
fn preformatted_block_parses_attrs() {
let lexed = lex("{{{rust class=\"hl\" key=val\nx\n}}}\n");
let PreformattedOpen { language, attrs } = &lexed[0] else {
panic!("expected PreformattedOpen");
};
assert_eq!(language.as_deref(), Some("rust"));
let mut expected = HashMap::new();
expected.insert("class".into(), "hl".into());
expected.insert("key".into(), "val".into());
assert_eq!(attrs, &expected);
}
// ===== Math block =====
#[test]
fn math_block_captures_environment() {
let lexed = lex("{{$%align%\nx = 1\n}}$\n");
assert_eq!(
lexed[0],
MathBlockOpen {
environment: Some("align".into())
}
);
assert_eq!(lexed[2], MathBlockLine("x = 1".into()));
assert_eq!(lexed[4], MathBlockClose);
}
// ===== Placeholders =====
#[test]
fn all_four_placeholders() {
assert_eq!(
lex("%title My Page\n"),
vec![PlaceholderTitle(Some("My Page".into())), Newline]
);
assert_eq!(lex("%nohtml\n"), vec![PlaceholderNohtml, Newline]);
assert_eq!(
lex("%template fancy\n"),
vec![PlaceholderTemplate(Some("fancy".into())), Newline]
);
assert_eq!(
lex("%date 2026-05-10\n"),
vec![PlaceholderDate(Some("2026-05-10".into())), Newline]
);
}
// ===== Lists =====
#[test]
fn list_marker_dash() {
let lexed = lex("- item\n");
assert_eq!(
lexed,
vec![
ListMarker {
symbol: ListSymbol::Dash,
indent: 0
},
text("item"),
Newline,
]
);
}
#[test]
fn list_marker_star_with_space_is_a_list_not_bold() {
let lexed = lex("* item\n");
assert_eq!(
lexed[0],
ListMarker {
symbol: ListSymbol::Star,
indent: 0
}
);
assert_eq!(lexed[1], text("item"));
}
#[test]
fn list_marker_kinds() {
let cases: &[(&str, ListSymbol)] = &[
("- a\n", ListSymbol::Dash),
("* a\n", ListSymbol::Star),
("# a\n", ListSymbol::Hash),
("1. a\n", ListSymbol::Numeric),
("1) a\n", ListSymbol::NumericParen),
("a) a\n", ListSymbol::AlphaParen),
("A) a\n", ListSymbol::AlphaUpperParen),
("i) a\n", ListSymbol::RomanParen),
("I) a\n", ListSymbol::RomanUpperParen),
];
for (line, expected) in cases {
let lexed = lex(line);
assert!(
matches!(lexed[0], ListMarker { symbol, .. } if symbol == *expected),
"input {line:?} expected {expected:?}, got {:?}",
lexed[0]
);
}
}
#[test]
fn list_marker_at_indent() {
let lexed = lex(" - nested\n");
assert_eq!(
lexed[0],
ListMarker {
symbol: ListSymbol::Dash,
indent: 4
}
);
}
#[test]
fn checkbox_states() {
let cases: &[(&str, CheckboxState)] = &[
("- [ ] a\n", CheckboxState::Empty),
("- [.] a\n", CheckboxState::Quarter),
("- [o] a\n", CheckboxState::Half),
("- [O] a\n", CheckboxState::ThreeQuarters),
("- [X] a\n", CheckboxState::Done),
("- [-] a\n", CheckboxState::Rejected),
];
for (line, expected) in cases {
let lexed = lex(line);
assert!(
matches!(lexed[1], Checkbox(s) if s == *expected),
"input {line:?} expected {expected:?}, got {:?}",
lexed[1]
);
}
}
#[test]
fn checkbox_states_honor_custom_palette() {
use nuwiki_core::listsyms::ListSyms;
let lex_with = |text: &str, syms: ListSyms| -> Vec<VimwikiTokenKind> {
VimwikiLexer::new()
.with_listsyms(syms)
.lex(text)
.into_iter()
.map(|t| t.kind)
.collect()
};
// 3-glyph palette ` x✓`: empty / half / done, plus the fixed rejected `-`.
let syms = ListSyms::new(" x✓");
let cases: &[(&str, CheckboxState)] = &[
("- [ ] a\n", CheckboxState::Empty),
("- [x] a\n", CheckboxState::Half),
("- [✓] a\n", CheckboxState::Done),
("- [-] a\n", CheckboxState::Rejected),
];
for (line, expected) in cases {
let lexed = lex_with(line, syms.clone());
assert!(
matches!(lexed[1], Checkbox(s) if s == *expected),
"input {line:?} expected {expected:?}, got {:?}",
lexed[1]
);
}
// Default-palette glyphs are not checkboxes under this palette: `[o]`
// is plain inline text, so no Checkbox token is emitted.
let lexed = lex_with("- [o] a\n", syms);
assert!(
!lexed.iter().any(|k| matches!(k, Checkbox(_))),
"expected no checkbox token, got {lexed:?}"
);
}
// ===== Blockquotes =====
#[test]
fn angle_blockquote() {
assert_eq!(
lex("> quoted\n"),
vec![BlockquoteMarker, text("quoted"), Newline]
);
}
#[test]
fn indent_blockquote() {
assert_eq!(
lex(" quoted\n"),
vec![BlockquoteIndent, text("quoted"), Newline]
);
}
#[test]
fn indent_with_list_marker_is_a_list_not_blockquote() {
let lexed = lex(" - item\n");
assert!(matches!(lexed[0], ListMarker { indent: 4, .. }));
}
// ===== Tables =====
#[test]
fn simple_table_row() {
assert_eq!(
lex("| a | b |\n"),
vec![
TableSep,
text(" a "),
TableSep,
text(" b "),
TableSep,
Newline,
]
);
}
#[test]
fn table_header_separator_row() {
use nuwiki_core::ast::TableAlign;
assert_eq!(
lex("|---|---|\n"),
vec![
TableHeaderRow(vec![TableAlign::Default, TableAlign::Default]),
Newline
]
);
}
#[test]
fn table_col_and_row_span_cells() {
let lexed = lex("| a | > | \\/ |\n");
// sep, " a ", sep, ColSpan, sep, RowSpan, sep, newline
assert_eq!(
lexed,
vec![
TableSep,
text(" a "),
TableSep,
TableColSpan,
TableSep,
TableRowSpan,
TableSep,
Newline,
]
);
}
// ===== Definition list =====
#[test]
fn definition_term_with_inline_definition() {
let lexed = lex("Term:: Definition\n");
assert_eq!(
lexed,
vec![
text("Term"),
DefinitionTermMarker,
text("Definition"),
Newline,
]
);
}
#[test]
fn definition_term_alone() {
let lexed = lex("Term::\n");
assert_eq!(lexed, vec![text("Term"), DefinitionTermMarker, Newline]);
}
// ===== Inline typefaces =====
#[test]
fn inline_bold_italic_strike_super_sub() {
assert_eq!(
lex("*b* _i_ ~~s~~ ^sup^ ,,sub,,\n"),
vec![
BoldDelim,
text("b"),
BoldDelim,
text(" "),
ItalicDelim,
text("i"),
ItalicDelim,
text(" "),
StrikethroughDelim,
text("s"),
StrikethroughDelim,
text(" "),
SuperscriptDelim,
text("sup"),
SuperscriptDelim,
text(" "),
SubscriptDelim,
text("sub"),
SubscriptDelim,
Newline,
]
);
}
#[test]
fn inline_code_captures_content() {
assert_eq!(lex("`x = 1`\n"), vec![Code("x = 1".into()), Newline]);
}
#[test]
fn inline_math_captures_content() {
assert_eq!(
lex("$E = mc^2$\n"),
vec![MathInline("E = mc^2".into()), Newline]
);
}
// ===== Keywords =====
#[test]
fn all_keywords() {
let cases: &[(&str, Keyword)] = &[
("TODO", Keyword::Todo),
("DONE", Keyword::Done),
("STARTED", Keyword::Started),
("FIXME", Keyword::Fixme),
("FIXED", Keyword::Fixed),
("XXX", Keyword::Xxx),
("STOPPED", Keyword::Stopped),
];
for (s, expected) in cases {
let line = format!("{s} stuff\n");
let lexed = lex(&line);
assert!(
matches!(lexed[0], Keyword(k) if k == *expected),
"input {s:?} expected {expected:?}, got {:?}",
lexed[0]
);
}
}
#[test]
fn keyword_only_at_word_boundary() {
// Embedded TODO inside a longer word should NOT match as a keyword.
let lexed = lex("aTODOb\n");
assert_eq!(lexed, vec![text("aTODOb"), Newline]);
}
// ===== Wikilinks / transclusions / raw URLs =====
#[test]
fn bare_wikilink() {
assert_eq!(
lex("[[Page]]\n"),
vec![WikiLinkOpen, text("Page"), WikiLinkClose, Newline]
);
}
#[test]
fn described_wikilink() {
assert_eq!(
lex("[[Page|Description]]\n"),
vec![
WikiLinkOpen,
text("Page"),
WikiLinkSep,
text("Description"),
WikiLinkClose,
Newline,
]
);
}
#[test]
fn wikilink_with_anchor_is_lexed_as_text() {
// The lexer doesn't carve out anchors; the parser does.
let lexed = lex("[[Page#Anchor]]\n");
assert_eq!(
lexed,
vec![WikiLinkOpen, text("Page#Anchor"), WikiLinkClose, Newline,]
);
}
#[test]
fn transclusion_with_alt_and_attrs() {
assert_eq!(
lex("{{img.png|alt|class=hi}}\n"),
vec![
TransclusionOpen,
text("img.png"),
TransclusionSep,
text("alt"),
TransclusionSep,
text("class=hi"),
TransclusionClose,
Newline,
]
);
}
#[test]
fn raw_urls_for_each_supported_scheme() {
for scheme in &[
"https://example.com",
"http://example.com/path?q=1",
"ftp://archive.example.com/file.tar.gz",
"mailto:hi@example.com",
"file:///tmp/file.txt",
] {
let line = format!("see {scheme} now\n");
let lexed = lex(&line);
assert!(
lexed.iter().any(|t| matches!(t, RawUrl(u) if u == scheme)),
"scheme {scheme:?} not lexed; got {lexed:?}"
);
}
}
#[test]
fn raw_url_drops_trailing_sentence_punctuation() {
let lexed = lex("Visit https://example.com.\n");
assert!(
lexed
.iter()
.any(|t| matches!(t, RawUrl(u) if u == "https://example.com")),
"got {lexed:?}"
);
}
// ===== Span correctness =====
#[test]
fn spans_are_byte_accurate() {
// Input layout:
// Line 0: "= H ="
// Line 1: "" (blank line)
// Line 2: "*bold*"
let src = "= H =\n\n*bold*\n";
let tokens = VimwikiLexer::new().lex(src).into_vec();
// First token: HeadingOpen at line 0, col 0..1
assert_eq!(
tokens[0].span.start,
Position {
line: 0,
column: 0,
offset: 0
}
);
assert_eq!(
tokens[0].span.end,
Position {
line: 0,
column: 1,
offset: 1
}
);
// Find the BoldDelim opening on line 2.
let bold_open = tokens
.iter()
.find(|t| matches!(t.kind, BoldDelim))
.expect("bold delim");
assert_eq!(bold_open.span.start.line, 2);
assert_eq!(bold_open.span.start.column, 0);
// "= H =\n" = 6 bytes, "\n" = 1, so line 2 starts at offset 7.
assert_eq!(bold_open.span.start.offset, 7);
}
+108
View File
@@ -0,0 +1,108 @@
//! Cluster 8b — multi-line list-item continuation.
//!
//! Lines indented strictly past the item's marker attach to the item
//! rather than becoming a sibling paragraph. Mirrors upstream vimwiki
//! + Markdown.
use nuwiki_core::ast::{BlockNode, InlineNode};
use nuwiki_core::render::{HtmlRenderer, Renderer};
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn single_list(doc: &nuwiki_core::ast::DocumentNode) -> &nuwiki_core::ast::ListNode {
match &doc.children[0] {
BlockNode::List(l) => l,
other => panic!("expected list, got {other:?}"),
}
}
fn text_of(inlines: &[InlineNode]) -> String {
let mut out = String::new();
for n in inlines {
match n {
InlineNode::Text(t) => out.push_str(&t.content),
InlineNode::Bold(b) => out.push_str(&text_of(&b.children)),
InlineNode::Italic(i) => out.push_str(&text_of(&i.children)),
// Soft breaks join continuation lines (formerly a Text(" ")).
InlineNode::SoftBreak(_) => out.push(' '),
_ => {}
}
}
out
}
#[test]
fn indented_line_after_marker_continues_the_item() {
let doc = parse("- first item\n continuing here\n- second\n");
assert_eq!(doc.children.len(), 1, "should be a single list block");
let list = single_list(&doc);
assert_eq!(list.items.len(), 2);
let first = &list.items[0];
let joined = text_of(&first.children);
assert_eq!(joined, "first item continuing here");
let second = &list.items[1];
assert_eq!(text_of(&second.children), "second");
}
#[test]
fn two_continuation_lines_both_attach() {
let doc = parse("- top\n middle\n bottom\n");
let list = single_list(&doc);
assert_eq!(list.items.len(), 1);
let joined = text_of(&list.items[0].children);
assert_eq!(joined, "top middle bottom");
}
#[test]
fn unindented_line_breaks_the_list() {
let doc = parse("- item\nflush against margin\n");
// Two blocks: a list (just `- item`) and a paragraph.
assert!(
doc.children.len() >= 2,
"want list + paragraph, got {}",
doc.children.len()
);
match &doc.children[0] {
BlockNode::List(l) => {
assert_eq!(text_of(&l.items[0].children), "item");
}
other => panic!("expected list first, got {other:?}"),
}
}
#[test]
fn blank_line_breaks_the_list_item() {
// A blank line ends the current list (paragraph semantics).
let doc = parse("- item\n\n orphaned\n");
let list = single_list(&doc);
assert_eq!(list.items.len(), 1);
assert_eq!(text_of(&list.items[0].children), "item");
}
#[test]
fn continuation_works_for_nested_lists_too() {
let doc = parse("- parent\n - child\n continued child\n- sibling\n");
let list = single_list(&doc);
assert_eq!(list.items.len(), 2);
let parent = &list.items[0];
let sublist = parent.sublist.as_ref().expect("nested list");
assert_eq!(sublist.items.len(), 1);
let child = &sublist.items[0];
assert_eq!(text_of(&child.children), "child continued child");
}
#[test]
fn html_renderer_emits_a_single_li_per_item() {
let doc = parse("- first item\n continuing here\n- second\n");
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
// Single <ul>, two <li> entries — the bug we're fixing produced
// two adjacent <ul> elements with an interleaved <p>.
assert_eq!(out.matches("<ul>").count(), 1, "got: {out}");
assert_eq!(out.matches("<li>").count(), 2, "got: {out}");
assert!(!out.contains("<p>"), "no orphan paragraph; got: {out}");
assert!(out.contains("first item continuing here"), "got: {out}");
}
+592
View File
@@ -0,0 +1,592 @@
//! End-to-end tests for the vimwiki parser. Each test runs source text
//! through the lexer + parser and asserts on the resulting AST.
use nuwiki_core::ast::{BlockNode, CheckboxState, InlineNode, Keyword, LinkKind, ListSymbol};
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
fn parse(text: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(text)
}
// ===== Document scaffolding =====
#[test]
fn empty_input_yields_empty_document() {
let doc = parse("");
assert!(doc.children.is_empty());
assert_eq!(doc.metadata.title, None);
}
#[test]
fn placeholders_populate_metadata() {
let doc = parse("%title My Page\n%nohtml\n%template fancy\n%date 2026-05-10\n");
assert_eq!(doc.metadata.title.as_deref(), Some("My Page"));
assert!(doc.metadata.nohtml);
assert_eq!(doc.metadata.template.as_deref(), Some("fancy"));
assert_eq!(doc.metadata.date.as_deref(), Some("2026-05-10"));
// No body blocks.
assert!(doc.children.is_empty());
}
// ===== Headings =====
#[test]
fn heading_each_level() {
for level in 1..=6 {
let line = format!(
"{} L{} {}\n",
"=".repeat(level as usize),
level,
"=".repeat(level as usize)
);
let doc = parse(&line);
let BlockNode::Heading(h) = &doc.children[0] else {
panic!("expected heading at level {level}");
};
assert_eq!(h.level, level);
assert!(!h.centered);
}
}
#[test]
fn centered_heading_flag() {
let doc = parse(" = title =\n");
let BlockNode::Heading(h) = &doc.children[0] else {
panic!("expected heading");
};
assert!(h.centered);
}
#[test]
fn heading_inline_bold() {
let doc = parse("== Hello *world* ==\n");
let BlockNode::Heading(h) = &doc.children[0] else {
panic!("expected heading");
};
// Children: Text("Hello "), Bold(Text("world"))
assert_eq!(h.children.len(), 2);
assert!(matches!(&h.children[0], InlineNode::Text(t) if t.content == "Hello "));
let InlineNode::Bold(b) = &h.children[1] else {
panic!("expected Bold");
};
assert!(matches!(&b.children[0], InlineNode::Text(t) if t.content == "world"));
}
// ===== Paragraphs =====
#[test]
fn paragraph_simple_text() {
let doc = parse("Hello world\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
assert_eq!(p.children.len(), 1);
assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == "Hello world"));
}
#[test]
fn paragraph_spans_multiple_lines() {
let doc = parse("Hello\nworld\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
// Contents: Text("Hello"), SoftBreak (from soft newline), Text("world").
let text_concat: String = p
.children
.iter()
.filter_map(|n| match n {
InlineNode::Text(t) => Some(t.content.clone()),
InlineNode::SoftBreak(_) => Some(" ".to_string()),
_ => None,
})
.collect();
assert_eq!(text_concat, "Hello world");
}
#[test]
fn blank_line_breaks_paragraph() {
let doc = parse("first\n\nsecond\n");
assert_eq!(doc.children.len(), 2);
assert!(matches!(doc.children[0], BlockNode::Paragraph(_)));
assert!(matches!(doc.children[1], BlockNode::Paragraph(_)));
}
// ===== Horizontal rule =====
#[test]
fn horizontal_rule() {
let doc = parse("----\n");
assert!(matches!(doc.children[0], BlockNode::HorizontalRule(_)));
}
// ===== Lists =====
#[test]
fn flat_unordered_list() {
let doc = parse("- one\n- two\n- three\n");
let BlockNode::List(list) = &doc.children[0] else {
panic!("expected list");
};
assert!(!list.ordered);
assert_eq!(list.symbol, ListSymbol::Dash);
assert_eq!(list.items.len(), 3);
for (item, expected) in list.items.iter().zip(["one", "two", "three"]) {
assert!(matches!(&item.children[0], InlineNode::Text(t) if t.content == expected));
}
}
#[test]
fn ordered_list_each_marker_kind() {
let cases: &[(&str, ListSymbol, bool)] = &[
("- a\n", ListSymbol::Dash, false),
("* a\n", ListSymbol::Star, false),
("# a\n", ListSymbol::Hash, false),
("1. a\n", ListSymbol::Numeric, true),
("1) a\n", ListSymbol::NumericParen, true),
("a) a\n", ListSymbol::AlphaParen, true),
("A) a\n", ListSymbol::AlphaUpperParen, true),
("i) a\n", ListSymbol::RomanParen, true),
("I) a\n", ListSymbol::RomanUpperParen, true),
];
for (src, sym, ordered) in cases {
let doc = parse(src);
let BlockNode::List(list) = &doc.children[0] else {
panic!("expected list for {src:?}");
};
assert_eq!(list.symbol, *sym);
assert_eq!(list.ordered, *ordered);
}
}
#[test]
fn nested_list_via_indent() {
let doc = parse("- top\n - nested\n");
let BlockNode::List(list) = &doc.children[0] else {
panic!("expected list");
};
let item = &list.items[0];
let sublist = item.sublist.as_ref().expect("sublist on nested item");
assert_eq!(sublist.items.len(), 1);
assert!(matches!(&sublist.items[0].children[0], InlineNode::Text(t) if t.content == "nested"));
}
#[test]
fn list_item_with_checkbox() {
let doc = parse("- [X] done\n");
let BlockNode::List(list) = &doc.children[0] else {
panic!("expected list");
};
let item = &list.items[0];
assert_eq!(item.checkbox, Some(CheckboxState::Done));
assert!(matches!(&item.children[0], InlineNode::Text(t) if t.content == "done"));
}
// ===== Blockquotes =====
#[test]
fn angle_blockquote_two_lines() {
let doc = parse("> first\n> second\n");
let BlockNode::Blockquote(bq) = &doc.children[0] else {
panic!("expected blockquote");
};
assert_eq!(bq.children.len(), 2);
for (i, expected) in ["first", "second"].iter().enumerate() {
let BlockNode::Paragraph(p) = &bq.children[i] else {
panic!("blockquote child should be a paragraph");
};
assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == *expected));
}
}
#[test]
fn indent_blockquote() {
let doc = parse(" quoted\n");
let BlockNode::Blockquote(bq) = &doc.children[0] else {
panic!("expected blockquote");
};
assert_eq!(bq.children.len(), 1);
}
// ===== Tables =====
#[test]
fn simple_table_row() {
let doc = parse("| a | b |\n");
let BlockNode::Table(t) = &doc.children[0] else {
panic!("expected table");
};
assert_eq!(t.rows.len(), 1);
let row = &t.rows[0];
assert_eq!(row.cells.len(), 2);
}
#[test]
fn table_with_header_separator() {
let doc = parse("| a | b |\n|---|---|\n| 1 | 2 |\n");
let BlockNode::Table(t) = &doc.children[0] else {
panic!("expected table");
};
assert!(t.has_header);
assert!(t.rows[0].is_header);
assert!(!t.rows[1].is_header);
}
#[test]
fn table_col_and_row_span_cells() {
let doc = parse("| a | > | \\/ |\n");
let BlockNode::Table(t) = &doc.children[0] else {
panic!("expected table");
};
let cells = &t.rows[0].cells;
assert!(!cells[0].col_span && !cells[0].row_span);
assert!(cells[1].col_span);
assert!(cells[2].row_span);
}
// ===== Definition list =====
#[test]
fn definition_term_with_inline_definition() {
let doc = parse("Term:: Definition\n");
let BlockNode::DefinitionList(dl) = &doc.children[0] else {
panic!("expected definition list");
};
assert_eq!(dl.items.len(), 1);
let item = &dl.items[0];
let term = item.term.as_ref().expect("term");
assert!(matches!(&term[0], InlineNode::Text(t) if t.content == "Term"));
assert_eq!(item.definitions.len(), 1);
let def = &item.definitions[0];
assert!(matches!(&def[0], InlineNode::Text(t) if t.content == "Definition"));
}
#[test]
fn multiple_definition_items() {
let doc = parse("A:: 1\nB:: 2\n");
let BlockNode::DefinitionList(dl) = &doc.children[0] else {
panic!("expected definition list");
};
assert_eq!(dl.items.len(), 2);
}
// ===== Preformatted / Math =====
#[test]
fn preformatted_block_captures_content_and_language() {
let doc = parse("{{{rust\nfn main() {}\n}}}\n");
let BlockNode::Preformatted(p) = &doc.children[0] else {
panic!("expected preformatted");
};
assert_eq!(p.content, "fn main() {}");
assert_eq!(p.language.as_deref(), Some("rust"));
}
#[test]
fn math_block_captures_content_and_environment() {
let doc = parse("{{$%align%\nx = 1\n}}$\n");
let BlockNode::MathBlock(m) = &doc.children[0] else {
panic!("expected math block");
};
assert_eq!(m.content, "x = 1");
assert_eq!(m.environment.as_deref(), Some("align"));
}
// ===== Comments =====
#[test]
fn single_line_comment_block() {
let doc = parse("%% hidden\n");
let BlockNode::Comment(c) = &doc.children[0] else {
panic!("expected comment");
};
assert_eq!(c.content, " hidden");
}
#[test]
fn multi_line_comment_block() {
let doc = parse("%%+ a\nb\n+%%\n");
let BlockNode::Comment(c) = &doc.children[0] else {
panic!("expected comment");
};
assert_eq!(c.content, " a\nb");
}
// ===== Inline =====
#[test]
fn inline_bold_italic_strike_super_sub_code_math() {
let doc = parse("*b* _i_ ~~s~~ ^sup^ ,,sub,, `c` $m$\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
let kinds: Vec<&str> = p
.children
.iter()
.map(|n| match n {
InlineNode::Bold(_) => "Bold",
InlineNode::Italic(_) => "Italic",
InlineNode::Strikethrough(_) => "Strike",
InlineNode::Superscript(_) => "Sup",
InlineNode::Subscript(_) => "Sub",
InlineNode::Code(_) => "Code",
InlineNode::MathInline(_) => "Math",
InlineNode::Text(_) => "Text",
_ => "Other",
})
.collect();
assert!(kinds.contains(&"Bold"));
assert!(kinds.contains(&"Italic"));
assert!(kinds.contains(&"Strike"));
assert!(kinds.contains(&"Sup"));
assert!(kinds.contains(&"Sub"));
assert!(kinds.contains(&"Code"));
assert!(kinds.contains(&"Math"));
}
#[test]
fn unmatched_bold_falls_back_to_text() {
let doc = parse("2 * 3 = 6\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
// No Bold node should appear; the orphan `*` becomes literal text.
assert!(!p.children.iter().any(|n| matches!(n, InlineNode::Bold(_))));
}
#[test]
fn nested_italic_around_bold() {
let doc = parse("_*x*_\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
let InlineNode::Italic(it) = &p.children[0] else {
panic!("expected italic outer");
};
let InlineNode::Bold(b) = &it.children[0] else {
panic!("expected bold inside italic");
};
assert!(matches!(&b.children[0], InlineNode::Text(t) if t.content == "x"));
}
#[test]
fn keywords_become_keyword_nodes() {
let cases = [
("TODO", Keyword::Todo),
("DONE", Keyword::Done),
("STARTED", Keyword::Started),
("FIXME", Keyword::Fixme),
("FIXED", Keyword::Fixed),
("XXX", Keyword::Xxx),
("STOPPED", Keyword::Stopped),
];
for (src, expected) in cases {
let doc = parse(&format!("{src} stuff\n"));
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
let InlineNode::Keyword(k) = &p.children[0] else {
panic!("expected keyword for {src}");
};
assert_eq!(k.keyword, expected);
}
}
#[test]
fn raw_url_inline() {
let doc = parse("see https://example.com here\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
assert!(p
.children
.iter()
.any(|n| matches!(n, InlineNode::RawUrl(u) if u.url == "https://example.com")));
}
// ===== Wikilinks / external links / transclusions =====
#[test]
fn bare_wikilink() {
let doc = parse("[[Page]]\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
let InlineNode::WikiLink(w) = &p.children[0] else {
panic!("expected wikilink");
};
assert_eq!(w.target.kind, LinkKind::Wiki);
assert_eq!(w.target.path.as_deref(), Some("Page"));
assert!(w.description.is_none());
}
#[test]
fn wikilink_with_anchor() {
let doc = parse("[[Page#Section]]\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
let InlineNode::WikiLink(w) = &p.children[0] else {
panic!("expected wikilink");
};
assert_eq!(w.target.path.as_deref(), Some("Page"));
assert_eq!(w.target.anchor.as_deref(), Some("Section"));
}
#[test]
fn anchor_only_wikilink() {
let doc = parse("[[#Section]]\n");
let InlineNode::WikiLink(w) = &if let BlockNode::Paragraph(p) = &doc.children[0] {
p
} else {
panic!()
}
.children[0] else {
panic!("expected wikilink");
};
assert_eq!(w.target.kind, LinkKind::AnchorOnly);
assert_eq!(w.target.anchor.as_deref(), Some("Section"));
}
#[test]
fn root_relative_and_filesystem_absolute_wikilinks() {
let doc = parse("[[/Page]] [[//etc/hosts]]\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
let mut links = p.children.iter().filter_map(|n| match n {
InlineNode::WikiLink(w) => Some(w),
_ => None,
});
let root = links.next().expect("root-relative");
assert_eq!(root.target.kind, LinkKind::Wiki);
assert!(root.target.is_absolute);
assert_eq!(root.target.path.as_deref(), Some("Page"));
let local = links.next().expect("filesystem-absolute");
assert_eq!(local.target.kind, LinkKind::Local);
assert!(local.target.is_absolute);
assert_eq!(local.target.path.as_deref(), Some("etc/hosts"));
}
#[test]
fn directory_wikilink() {
let doc = parse("[[dir/]]\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
let InlineNode::WikiLink(w) = &p.children[0] else {
panic!("expected wikilink");
};
assert!(w.target.is_directory);
assert_eq!(w.target.path.as_deref(), Some("dir"));
}
#[test]
fn interwiki_links_numbered_and_named() {
let doc = parse("[[wiki1:Page]] [[wn.Notes:Page]]\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
let mut links = p.children.iter().filter_map(|n| match n {
InlineNode::WikiLink(w) => Some(w),
_ => None,
});
let numbered = links.next().expect("numbered");
assert_eq!(numbered.target.kind, LinkKind::Interwiki);
assert_eq!(numbered.target.wiki_index, Some(1));
assert_eq!(numbered.target.path.as_deref(), Some("Page"));
let named = links.next().expect("named");
assert_eq!(named.target.kind, LinkKind::Interwiki);
assert_eq!(named.target.wiki_name.as_deref(), Some("Notes"));
assert_eq!(named.target.path.as_deref(), Some("Page"));
}
#[test]
fn diary_and_file_and_local_kinds() {
for (src, expected) in [
("[[diary:2026-05-10]]", LinkKind::Diary),
("[[file:/tmp/note.txt]]", LinkKind::File),
("[[local:rel/path]]", LinkKind::Local),
] {
let doc = parse(&format!("{src}\n"));
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
let InlineNode::WikiLink(w) = &p.children[0] else {
panic!("expected wikilink for {src}");
};
assert_eq!(w.target.kind, expected, "src {src}");
}
}
#[test]
fn url_inside_brackets_is_external_link() {
let doc = parse("[[https://example.com|docs]]\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
let InlineNode::ExternalLink(e) = &p.children[0] else {
panic!("expected external link");
};
assert_eq!(e.url, "https://example.com");
let desc = e.description.as_ref().expect("description");
assert!(matches!(&desc[0], InlineNode::Text(t) if t.content == "docs"));
}
#[test]
fn transclusion_with_alt() {
let doc = parse("{{img.png|alt text}}\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
let InlineNode::Transclusion(t) = &p.children[0] else {
panic!("expected transclusion");
};
assert_eq!(t.url, "img.png");
assert_eq!(t.alt.as_deref(), Some("alt text"));
}
#[test]
fn transclusion_with_alt_and_attrs() {
let doc = parse("{{img.png|alt|class=hi|width=200}}\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
let InlineNode::Transclusion(t) = &p.children[0] else {
panic!("expected transclusion");
};
assert_eq!(t.url, "img.png");
assert_eq!(t.alt.as_deref(), Some("alt"));
assert_eq!(t.attrs.get("class").map(String::as_str), Some("hi"));
assert_eq!(t.attrs.get("width").map(String::as_str), Some("200"));
}
// ===== Resilience =====
#[test]
fn unterminated_wikilink_falls_back_to_text() {
let doc = parse("[[Unterminated\n");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected paragraph");
};
// First child should be literal "[[" text.
assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == "[["));
}
// ===== End-to-end through the registry =====
#[test]
fn vimwiki_syntax_registers_and_dispatches() {
use nuwiki_core::syntax::SyntaxRegistry;
let mut reg = SyntaxRegistry::new();
reg.register(VimwikiSyntax::new());
let plugin = reg.detect_from_extension(".wiki").expect("plugin by ext");
assert_eq!(plugin.id(), "vimwiki");
let doc = plugin.parse("= Hello =\n");
assert!(matches!(doc.children[0], BlockNode::Heading(_)));
}
+144
View File
@@ -0,0 +1,144 @@
//! Integration tests for the syntax plugin interface.
use nuwiki_core::ast::{
inline::InlineNode, BlockNode, DocumentNode, ParagraphNode, Span, TextNode,
};
use nuwiki_core::syntax::{Lexer, Parser, SyntaxPlugin, SyntaxRegistry, TokenStream};
// --- A minimal mock plugin: tokenises the input on whitespace, then wraps
// the joined text in a single Paragraph. Just enough surface to exercise
// every trait + the registry.
#[derive(Debug, Clone, PartialEq, Eq)]
struct MockToken(String);
struct MockLexer;
impl Lexer for MockLexer {
type Token = MockToken;
fn lex(&self, text: &str) -> TokenStream<MockToken> {
TokenStream::from_vec(
text.split_whitespace()
.map(|w| MockToken(w.to_owned()))
.collect(),
)
}
}
struct MockParser;
impl Parser for MockParser {
type Token = MockToken;
fn parse(&self, tokens: TokenStream<MockToken>) -> DocumentNode {
let joined = tokens
.iter()
.map(|t| t.0.as_str())
.collect::<Vec<_>>()
.join(" ");
let paragraph = BlockNode::Paragraph(ParagraphNode {
span: Span::default(),
children: vec![InlineNode::Text(TextNode {
span: Span::default(),
content: joined,
})],
});
DocumentNode {
span: Span::default(),
children: vec![paragraph],
metadata: Default::default(),
}
}
}
struct MockPlugin;
impl SyntaxPlugin for MockPlugin {
fn id(&self) -> &str {
"mock"
}
fn display_name(&self) -> &str {
"Mock Syntax"
}
fn file_extensions(&self) -> &[&str] {
&[".mock", ".mk"]
}
fn parse(&self, text: &str) -> DocumentNode {
MockParser.parse(MockLexer.lex(text))
}
}
#[test]
fn token_stream_roundtrip() {
let mut s = TokenStream::<u32>::new();
assert!(s.is_empty());
s.push(1);
s.push(2);
s.push(3);
assert_eq!(s.len(), 3);
assert_eq!(s.as_slice(), &[1, 2, 3]);
assert_eq!(s.iter().sum::<u32>(), 6);
assert_eq!((&s).into_iter().sum::<u32>(), 6);
assert_eq!(s.into_vec(), vec![1, 2, 3]);
let from_vec: TokenStream<&str> = vec!["a", "b"].into();
assert_eq!(from_vec.len(), 2);
let collected: Vec<&str> = from_vec.into_iter().collect();
assert_eq!(collected, vec!["a", "b"]);
}
#[test]
fn lexer_and_parser_chain_through_plugin() {
let plugin = MockPlugin;
let doc = plugin.parse(" hello world ");
let BlockNode::Paragraph(p) = &doc.children[0] else {
panic!("expected a paragraph, got {:?}", doc.children[0]);
};
let InlineNode::Text(t) = &p.children[0] else {
panic!("expected a text node");
};
assert_eq!(t.content, "hello world");
}
#[test]
fn registry_lookup_by_id_and_extension() {
let mut registry = SyntaxRegistry::new();
assert!(registry.is_empty());
registry.register(MockPlugin);
assert_eq!(registry.len(), 1);
let by_id = registry
.get("mock")
.expect("plugin should be findable by id");
assert_eq!(by_id.display_name(), "Mock Syntax");
let by_ext = registry
.detect_from_extension(".mk")
.expect("plugin should be findable by secondary extension");
assert_eq!(by_ext.id(), "mock");
assert!(registry.get("nonexistent").is_none());
assert!(registry.detect_from_extension(".unknown").is_none());
// Iteration order matches registration order.
let ids: Vec<&str> = registry.iter().map(|p| p.id()).collect();
assert_eq!(ids, vec!["mock"]);
}
#[test]
fn registry_can_parse_through_trait_object() {
let mut registry = SyntaxRegistry::new();
registry.register(MockPlugin);
let plugin = registry.detect_from_extension(".mock").unwrap();
let doc = plugin.parse("a b c");
assert_eq!(doc.children.len(), 1);
}
/// Compile-time check: SyntaxRegistry must be Send + Sync so the LSP server
/// can share it across handler tasks.
#[test]
fn registry_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<SyntaxRegistry>();
}
@@ -0,0 +1,57 @@
//! Cluster 7 — Markdown-style alignment markers on the header
//! separator row (`|:--|--:|:--:|`) propagate to the AST and HTML.
use nuwiki_core::ast::{BlockNode, TableAlign};
use nuwiki_core::render::{HtmlRenderer, Renderer};
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
#[test]
fn separator_row_with_no_anchors_yields_defaults() {
let doc = parse("| h1 | h2 |\n|----|----|\n| a | b |\n");
let table = match &doc.children[0] {
BlockNode::Table(t) => t,
_ => panic!("expected table"),
};
assert_eq!(
table.alignments,
vec![TableAlign::Default, TableAlign::Default]
);
}
#[test]
fn left_right_center_anchors_parsed() {
let doc = parse("| a | b | c |\n|:---|---:|:---:|\n| 1 | 2 | 3 |\n");
let table = match &doc.children[0] {
BlockNode::Table(t) => t,
_ => panic!("expected table"),
};
assert_eq!(
table.alignments,
vec![TableAlign::Left, TableAlign::Right, TableAlign::Center]
);
}
#[test]
fn html_renderer_emits_text_align_style() {
let doc = parse("| a | b | c |\n|:---|---:|:---:|\n| 1 | 2 | 3 |\n");
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(
out.contains(r#"<th style="text-align: left;"> a </th>"#),
"got: {out}"
);
assert!(out.contains(r#"<th style="text-align: right;"> b </th>"#));
assert!(out.contains(r#"<th style="text-align: center;"> c </th>"#));
assert!(out.contains(r#"<td style="text-align: left;"> 1 </td>"#));
}
#[test]
fn render_without_alignment_omits_style_attr() {
let doc = parse("| a | b |\n|---|---|\n| 1 | 2 |\n");
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(!out.contains("text-align"));
}
+204
View File
@@ -0,0 +1,204 @@
//! Tag tests: lexer + parser + renderer behaviour on
//! `:tag:` lines, plus the scope-detection rules.
use nuwiki_core::ast::{BlockNode, TagScope};
use nuwiki_core::render::{HtmlRenderer, Renderer};
use nuwiki_core::syntax::vimwiki::{VimwikiLexer, VimwikiSyntax, VimwikiTokenKind};
use nuwiki_core::syntax::Lexer;
use nuwiki_core::syntax::SyntaxPlugin;
fn lex(src: &str) -> Vec<VimwikiTokenKind> {
VimwikiLexer::new()
.lex(src)
.into_iter()
.map(|t| t.kind)
.collect()
}
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn render(src: &str) -> String {
let doc = parse(src);
HtmlRenderer::new().render_to_string(&doc).unwrap()
}
// ===== Lexer =====
#[test]
fn lexer_emits_tag_for_single_tag_line() {
let toks = lex(":todo:\n");
assert!(toks
.iter()
.any(|t| matches!(t, VimwikiTokenKind::Tag(v) if v == &vec!["todo".to_string()])));
}
#[test]
fn lexer_emits_tag_for_multiple_chained_tags() {
let toks = lex(":one:two:three:\n");
let names = toks
.iter()
.find_map(|t| match t {
VimwikiTokenKind::Tag(v) => Some(v.clone()),
_ => None,
})
.expect("tag token");
assert_eq!(names, vec!["one", "two", "three"]);
}
#[test]
fn lexer_rejects_empty_segment() {
// `::tag::` — empty leading segment between the doubled colons.
let toks = lex("::tag::\n");
assert!(!toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
}
#[test]
fn lexer_rejects_tag_with_whitespace() {
let toks = lex(":one tag:other:\n");
assert!(!toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
}
#[test]
fn lexer_does_not_collide_with_definition_term() {
// `Term::` is a definition-term marker (text *before* the `::`); a
// tag line has nothing before its leading `:`. The two patterns must
// not steal each other's lines.
let toks = lex("Term:: Definition\n:foo:\n");
assert!(toks
.iter()
.any(|t| matches!(t, VimwikiTokenKind::DefinitionTermMarker)));
assert!(toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
}
#[test]
fn lexer_accepts_indented_tag_line() {
let toks = lex(" :indented:\n");
assert!(toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
}
// ===== Parser scope rules =====
#[test]
fn scope_is_file_when_tag_is_on_line_0_or_1() {
let doc = parse(":todo:other:\n= Heading =\n");
let BlockNode::Tag(t) = &doc.children[0] else {
panic!(
"expected first block to be a Tag, got {:?}",
doc.children[0]
);
};
assert_eq!(t.scope, TagScope::File);
assert_eq!(t.tags, vec!["todo", "other"]);
assert_eq!(doc.metadata.tags, vec!["todo", "other"]);
}
#[test]
fn scope_is_file_for_tag_on_line_1() {
let doc = parse("a paragraph\n:todo:\n");
// Tag is on line 1, so still file-level.
let tag = doc
.children
.iter()
.find_map(|b| match b {
BlockNode::Tag(t) => Some(t),
_ => None,
})
.expect("tag block");
assert_eq!(tag.scope, TagScope::File);
assert!(doc.metadata.tags.contains(&"todo".to_string()));
}
#[test]
fn scope_is_heading_when_tag_is_one_or_two_lines_below_heading() {
// Heading at line 0; tag at line 1 (one below) → Heading(0).
let doc = parse("= H1 =\n:hot:\n");
let tag = doc
.children
.iter()
.find_map(|b| match b {
BlockNode::Tag(t) => Some(t),
_ => None,
})
.expect("tag block");
// Tag at line 1 is *also* line ≤ 1 → file rule wins (matches vimwiki:
// file rule applies to lines 0-1 regardless of preceding heading).
assert_eq!(tag.scope, TagScope::File);
}
#[test]
fn scope_is_heading_for_tag_two_lines_below_a_deeper_heading() {
// Heading at line 3; tag at line 4 (one below).
let src = "first\n\n\n= Section =\n:scoped:\n";
let doc = parse(src);
let tag = doc
.children
.iter()
.find_map(|b| match b {
BlockNode::Tag(t) => Some(t),
_ => None,
})
.expect("tag block");
// Heading is the only one seen; index 0.
assert_eq!(tag.scope, TagScope::Heading(0));
// Heading-scope tags do NOT contribute to file-level metadata.
assert!(doc.metadata.tags.is_empty());
}
#[test]
fn scope_is_standalone_for_distant_tag() {
// Heading at line 3; tag at line 6 → 3 lines below → out of the
// heading window (>2), so standalone.
let src = "x\n\n\n= H =\n\n\n:wandering:\n";
let doc = parse(src);
let tag = doc
.children
.iter()
.find_map(|b| match b {
BlockNode::Tag(t) => Some(t),
_ => None,
})
.expect("tag block");
assert_eq!(tag.scope, TagScope::Standalone);
}
#[test]
fn scope_is_standalone_when_no_heading_precedes() {
// No heading at all and not on lines 0-1.
let doc = parse("a\nb\nc\n:lonely:\n");
let tag = doc
.children
.iter()
.find_map(|b| match b {
BlockNode::Tag(t) => Some(t),
_ => None,
})
.expect("tag block");
assert_eq!(tag.scope, TagScope::Standalone);
}
#[test]
fn file_tags_are_deduped_in_metadata() {
let doc = parse(":a:b:\n:b:c:\n");
assert_eq!(doc.metadata.tags, vec!["a", "b", "c"]);
}
// ===== Renderer =====
#[test]
fn renderer_emits_div_with_tag_spans() {
let html = render(":todo:done:\n");
assert!(html.contains("<div class=\"tags\">"));
assert!(html.contains("<span class=\"tag\" id=\"tag-todo\">todo</span>"));
assert!(html.contains("<span class=\"tag\" id=\"tag-done\">done</span>"));
}
#[test]
fn renderer_escapes_html_inside_tag_names() {
// Tags shouldn't contain `<`/`>` per the lexer rule (whitespace
// forbidden, but `<` isn't), so this checks the renderer's escaping
// fires anyway as defence-in-depth.
let html = render(":a<b:\n");
assert!(html.contains("a&lt;b"));
}
+60
View File
@@ -0,0 +1,60 @@
//! Cluster 8 — transclusion attribute parsing. The basic
//! `{{url|alt|key=val}}` shape was already wired; this test pins
//! down quote-stripping on quoted values (`key="quoted value"`)
//! so an HTML renderer doesn't double-emit `&quot;` artefacts.
use nuwiki_core::ast::{BlockNode, InlineNode};
use nuwiki_core::render::{HtmlRenderer, Renderer};
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn first_transclusion(doc: &nuwiki_core::ast::DocumentNode) -> &nuwiki_core::ast::TransclusionNode {
let para = match &doc.children[0] {
BlockNode::Paragraph(p) => p,
_ => panic!("expected paragraph"),
};
for child in &para.children {
if let InlineNode::Transclusion(t) = child {
return t;
}
}
panic!("no transclusion in paragraph");
}
#[test]
fn transclusion_attrs_strip_double_quotes() {
let doc = parse(r#"{{cat.png|cat|style="border: 1px"}}"#);
let t = first_transclusion(&doc);
assert_eq!(t.url, "cat.png");
assert_eq!(t.alt.as_deref(), Some("cat"));
assert_eq!(
t.attrs.get("style").map(String::as_str),
Some("border: 1px")
);
}
#[test]
fn transclusion_attrs_strip_single_quotes() {
let doc = parse("{{cat.png|cat|class='thumb'}}");
let t = first_transclusion(&doc);
assert_eq!(t.attrs.get("class").map(String::as_str), Some("thumb"));
}
#[test]
fn transclusion_attrs_leave_unquoted_values_alone() {
let doc = parse("{{cat.png|cat|width=200}}");
let t = first_transclusion(&doc);
assert_eq!(t.attrs.get("width").map(String::as_str), Some("200"));
}
#[test]
fn transclusion_renders_clean_html_with_quoted_attr() {
let doc = parse(r#"{{cat.png|cat|style="border: 1px"}}"#);
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(out.contains(r#"style="border: 1px""#), "got: {out}");
assert!(!out.contains("&quot;"));
}
+215
View File
@@ -0,0 +1,215 @@
//! 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,
alignments: Vec::new(),
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);
}