parity(8b): multi-line list item continuation

Lines indented strictly past a list item's marker now attach to that
item rather than becoming a sibling paragraph. Matches upstream
vimwiki + Markdown convention.

Before:
  - first item
    continuing here          ←  rendered as a separate <p>
  - second

Now:
  - first item continuing here  ← one <li>, joined with a space
  - second

The detection lives in `parse_list_item`: after the item's first
line, we loop over subsequent lines and treat each as continuation
when its leading whitespace exceeds the marker's column. Two
flavours need handling because the lexer emits different tokens
based on indent width:

  * 1..3 leading spaces → `Text(s)` with the whitespace embedded;
    we trim it off the first text token before re-running
    `parse_inline_seq`.
  * 4+ leading spaces  → `BlockquoteIndent` + bare `Text`; we
    advance past the indent token and use the text as-is.

Continuation also works inside nested lists (`level + 1` threshold
adapts to the marker's own indent) and is bounded by blank lines /
unindented content / sibling-or-shallower list markers.

Tests: 6 new in `crates/nuwiki-core/tests/list_continuation.rs`
covering single-line continuation, multi-line continuation, blank-
line termination, unindented termination, nested-list continuation,
and the HTML-renderer regression (single `<ul>` with two `<li>`s,
no orphan `<p>`).

Gates: 456 Rust / 39 Neovim / 12 Vim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 22:15:58 +00:00
parent d8fa59a63a
commit c8a6fc1172
2 changed files with 190 additions and 1 deletions
@@ -0,0 +1,106 @@
//! 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)),
_ => {}
}
}
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}");
}