diff --git a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs index e9d2176..95179d7 100644 --- a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs +++ b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs @@ -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 = 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) { diff --git a/crates/nuwiki-core/tests/list_continuation.rs b/crates/nuwiki-core/tests/list_continuation.rs new file mode 100644 index 0000000..77b6e10 --- /dev/null +++ b/crates/nuwiki-core/tests/list_continuation.rs @@ -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