From 4a717bcb318aef312989cf4985366158e9d318df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Wed, 24 Jun 2026 01:09:32 +0000 Subject: [PATCH] fix(parser): accept spaceless headings (==Heading==) like vimwiki (#7) The heading lexer required a space after the opening `=`s, so `==Heading==` fell through to a literal `

==Heading==

` instead of an `

`. vimwiki accepts the spaceless form, so a migrated page whose top heading was `==TODO==` (or `====Progress====`) rendered with no heading at all. Drop the space-after-marker requirement; the existing title trim already handles one optional space per side, so both `== H ==` and `==H==` work. Marker-only (`======`) and unbalanced (`==a==b`, `==> x`) lines still stay paragraphs (title-present + matching trailing-`=` checks). Regression tests added at the lexer and renderer levels. Co-Authored-By: Claude Opus 4.8 --- .../nuwiki-core/src/syntax/vimwiki/lexer.rs | 7 +++-- crates/nuwiki-core/tests/html_renderer.rs | 21 +++++++++++++++ crates/nuwiki-core/tests/lexer.rs | 26 +++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs index 12ba237..2d4b1af 100644 --- a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs +++ b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs @@ -526,10 +526,9 @@ impl<'src> LexState<'src> { if level == 0 { return false; } - // Must be followed by at least one space. - if leading_ws + level >= bytes.len() || bytes[leading_ws + level] != b' ' { - return false; - } + // vimwiki accepts both spaced (`== H ==`) and spaceless (`==H==`) + // headings, so we don't require a space after the opening `=`s. The + // title is still trimmed of one optional space per side below. // Trailing `=`s of the same level. let trimmed_end = line.trim_end_matches([' ', '\t']); diff --git a/crates/nuwiki-core/tests/html_renderer.rs b/crates/nuwiki-core/tests/html_renderer.rs index 7ab0598..713728e 100644 --- a/crates/nuwiki-core/tests/html_renderer.rs +++ b/crates/nuwiki-core/tests/html_renderer.rs @@ -77,6 +77,27 @@ fn centered_heading_gets_centered_class() { assert!(out.contains("

T

")); } +#[test] +fn spaceless_heading_renders_as_heading() { + // `==Heading==` (no surrounding spaces) is a heading, like vimwiki — not a + // literal `

==Heading==

`. + let out = render("==Plain==\n"); + assert!( + out.contains( + "

\ + Plain

" + ), + "got: {out}" + ); + // The spaceless + keyword combo from the report: a header, not a badge. + let out2 = render("==TODO==\n"); + assert!( + out2.contains("

"), + "got: {out2}" + ); + assert!(!out2.contains("

"), "not a paragraph: {out2}"); +} + #[test] fn heading_keyword_renders_as_plain_text_not_badge() { // `== TODO ==` is a section title, not an inline keyword: it must render as diff --git a/crates/nuwiki-core/tests/lexer.rs b/crates/nuwiki-core/tests/lexer.rs index 53a3fcb..b3aab42 100644 --- a/crates/nuwiki-core/tests/lexer.rs +++ b/crates/nuwiki-core/tests/lexer.rs @@ -85,6 +85,32 @@ fn centered_heading_is_marked_centered() { assert!(matches!(lexed[2], HeadingClose)); } +#[test] +fn spaceless_heading_is_a_heading() { + // vimwiki accepts `==Heading==` with no spaces around the text. + let lexed = lex("==Plain==\n"); + assert_eq!( + lexed, + vec![ + HeadingOpen { + level: 2, + centered: false + }, + text("Plain"), + HeadingClose, + Newline, + ] + ); +} + +#[test] +fn marker_only_or_unbalanced_lines_are_not_headings() { + // No title between markers, and trailing `=` not at line end → plain text. + assert!(!matches!(lex("======\n")[0], HeadingOpen { .. })); + assert!(!matches!(lex("==> arrow\n")[0], HeadingOpen { .. })); + assert!(!matches!(lex("==a==b\n")[0], HeadingOpen { .. })); +} + #[test] fn heading_with_inline_bold() { let lexed = lex("== Hello *world* ==\n");