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:
@@ -532,9 +532,86 @@ impl<'a> ParseState<'a> {
|
||||
}
|
||||
}
|
||||
let inline_end = self.pos;
|
||||
let children = parse_inline_seq(&self.toks[inline_start..inline_end]);
|
||||
let mut children = parse_inline_seq(&self.toks[inline_start..inline_end]);
|
||||
self.eat_newline();
|
||||
|
||||
// Continuation lines (vimwiki parity): lines indented strictly past
|
||||
// the marker's column attach to this item rather than becoming a
|
||||
// sibling paragraph. The lexer doesn't model list state, so we
|
||||
// detect this here by inspecting whatever the next line opens
|
||||
// with: either a `Text(s)` whose leading whitespace exceeds the
|
||||
// marker's column, or a `BlockquoteIndent` token (the lexer emits
|
||||
// it for 4+ leading spaces, swallowing them from the following
|
||||
// Text). We accept either as a continuation when the implied
|
||||
// indent is `> level`.
|
||||
let continuation_threshold = level + 1;
|
||||
loop {
|
||||
let Some(first) = self.peek() else { break };
|
||||
let mut consume_first = false;
|
||||
let is_continuation = match &first.kind {
|
||||
K::Text(s) => leading_ws_count(s) >= continuation_threshold,
|
||||
K::BlockquoteIndent => {
|
||||
// `BlockquoteIndent`'s span covers the leading-whitespace
|
||||
// run on the current line. Width ≥ threshold means this
|
||||
// is a continuation, not a blockquote.
|
||||
let width = (first.span.end.column - first.span.start.column) as usize;
|
||||
if width >= continuation_threshold {
|
||||
consume_first = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !is_continuation {
|
||||
break;
|
||||
}
|
||||
// Eat the synthetic BlockquoteIndent if present — the lexer
|
||||
// already stripped the whitespace from the following Text so
|
||||
// we don't want to also emit a leading space ourselves.
|
||||
if consume_first {
|
||||
self.advance();
|
||||
}
|
||||
let cont_start = self.pos;
|
||||
while let Some(t) = self.peek() {
|
||||
match &t.kind {
|
||||
K::Newline | K::BlankLine => break,
|
||||
_ => {
|
||||
span_end = t.span.end;
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
let cont_end = self.pos;
|
||||
// Drop the leading whitespace on the first text token (only
|
||||
// present when we came via the Text branch — the
|
||||
// BlockquoteIndent path already stripped it).
|
||||
let mut buf: Vec<VimwikiToken> = self.toks[cont_start..cont_end].to_vec();
|
||||
if !consume_first {
|
||||
if let Some(t0) = buf.first_mut() {
|
||||
if let K::Text(text) = &t0.kind {
|
||||
let trimmed = text.trim_start().to_owned();
|
||||
t0.kind = K::Text(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Insert a synthetic " " between the previous content and
|
||||
// the continuation so the rendered text flows naturally.
|
||||
if !children.is_empty() {
|
||||
let span = first.span;
|
||||
children.push(InlineNode::Text(TextNode {
|
||||
span,
|
||||
content: " ".into(),
|
||||
}));
|
||||
}
|
||||
let cont = parse_inline_seq(&buf);
|
||||
children.extend(cont);
|
||||
if !self.eat_newline() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Sublist: subsequent ListMarker tokens with deeper indent.
|
||||
let sublist = if let Some(K::ListMarker { indent, .. }) = self.peek_kind() {
|
||||
if (*indent as usize) > level {
|
||||
@@ -1206,6 +1283,12 @@ fn parse_transclusion(toks: &[VimwikiToken], open_idx: usize) -> (InlineNode, us
|
||||
)
|
||||
}
|
||||
|
||||
/// Count leading space / tab characters at the start of `s`. Used by
|
||||
/// `parse_list_item` to detect continuation lines.
|
||||
fn leading_ws_count(s: &str) -> usize {
|
||||
s.chars().take_while(|c| *c == ' ' || *c == '\t').count()
|
||||
}
|
||||
|
||||
/// Split a `key=value` attribute segment, strip surrounding quotes from
|
||||
/// the value, and insert into `attrs`. Empty keys are skipped.
|
||||
fn insert_attr(segment: &str, attrs: &mut std::collections::HashMap<String, String>) {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user