From 9441fe918c6dec197adefa7e5c255d5db71d2f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Tue, 2 Jun 2026 10:50:38 +0000 Subject: [PATCH] fix(parity): close the 2026-06-02 re-audit config/command findings Implements the remaining fourth-pass findings (gap doc updated): - list_margin: rework to upstream's buffer-side meaning. Drop the nuwiki-only HTML em-margin (renderer field/method + render_page_html param removed) and prepend max(0, list_margin) leading spaces to every generated bullet in build_toc_text / build_links_text / build_tag_links_text / diary::build_index_body; headings stay at col 0. From derives 0 for markdown wikis when unset. Negatives can't resolve 'shiftwidth' server-side, so they collapse to zero indent (documented divergence). - diary_months: per-wiki Vec (default 12 English names), threaded into the diary-index month labels; missing/empty slots fall back to the English name. - diary_caption_level: widen u8 -> i8 so vimwiki's -1 (min: -1) parses; build_index_body clamps < 0 to base tree level 0. - VimwikiRemoveDone: regain upstream's -range. All four defs are now -bang -range, dispatched via remove_done(bang, range, l1, l2) in both clients: ! -> whole buffer, explicit range -> new list_remove_done_range ({range:[l1-1,l2-1]}), else current list. Server remove_done_edit gained an Option<(u32,u32)> range that filters whole-doc victims by start line. markdown_header_style is deferred: the generators emit vimwiki syntax only (caption_line never writes markdown headers), so there's no markdown header to attach the style to. Logged as the "generated-content is vimwiki-only" intentional divergence pending a later markdown generated-content effort. Tests: list_margin indent, markdown list_margin-0 default, diary_months custom + fallback, negative caption_level clamp/parse, ranged remove-done (server + both keymap harnesses). 553 lsp/core tests pass; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- autoload/nuwiki/commands.vim | 20 ++++ crates/nuwiki-core/src/render/html.rs | 24 +--- crates/nuwiki-lsp/src/commands.rs | 88 +++++++++++--- crates/nuwiki-lsp/src/config.rs | 64 ++++++++-- crates/nuwiki-lsp/src/diary.rs | 36 ++++-- crates/nuwiki-lsp/src/export.rs | 3 +- crates/nuwiki-lsp/src/lib.rs | 3 + crates/nuwiki-lsp/tests/commands_coverage.rs | 37 ++++-- crates/nuwiki-lsp/tests/commands_lists.rs | 35 ++++-- crates/nuwiki-lsp/tests/commands_tags.rs | 23 ++-- crates/nuwiki-lsp/tests/diary.rs | 118 ++++++++++++++++++- crates/nuwiki-lsp/tests/html_export.rs | 52 -------- crates/nuwiki-lsp/tests/index_and_config.rs | 37 ++++++ crates/nuwiki-lsp/tests/link_health.rs | 90 ++++++++++---- development/tests/test-keymaps-vim.vim | 20 ++++ development/tests/test-keymaps.lua | 15 +++ development/vimwiki-gap.md | 113 ++++++++++++------ ftplugin/vimwiki.vim | 8 +- lua/nuwiki/commands.lua | 19 +++ 19 files changed, 603 insertions(+), 202 deletions(-) diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index 2810840..5a775f9 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -761,6 +761,26 @@ function! nuwiki#commands#list_remove_done_all() abort call s:exec('nuwiki.list.removeDone', l:args) endfunction +" `:'<,'>VimwikiRemoveDone` — remove done items only within the given line +" range (1-indexed, inclusive; converted to the server's 0-indexed range). +function! nuwiki#commands#list_remove_done_range(l1, l2) abort + let l:args = [{ 'uri': s:buf_uri(), 'range': [a:l1 - 1, a:l2 - 1] }] + call s:exec('nuwiki.list.removeDone', l:args) +endfunction + +" `:[range]VimwikiRemoveDone[!]` dispatcher. `!` → whole buffer; an explicit +" range (`a:range > 0`, e.g. `:'<,'>`) → that line span; otherwise the +" current list under the cursor. Mirrors upstream's `-bang -range` form. +function! nuwiki#commands#remove_done(bang, range, l1, l2) abort + if a:bang + call nuwiki#commands#list_remove_done_all() + elseif a:range > 0 + call nuwiki#commands#list_remove_done_range(a:l1, a:l2) + else + call nuwiki#commands#list_remove_done() + endif +endfunction + " `:VimwikiRemoveSingleCB` — strip the checkbox from the current item only. function! nuwiki#commands#list_remove_checkbox() abort let l:p = s:cursor_position() diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs index 69c9103..60ad350 100644 --- a/crates/nuwiki-core/src/render/html.rs +++ b/crates/nuwiki-core/src/render/html.rs @@ -46,10 +46,6 @@ pub struct HtmlRenderer { /// names fall through to the default `class="color-"` /// rendering. Matches vimwiki's `color_dic`. colors: HashMap, - /// vimwiki `list_margin`: left margin (in `em`) applied to the - /// outermost list. `-1` (the default) emits no inline style and - /// defers to the stylesheet; `>= 0` forces `margin-left:em`. - list_margin: i32, /// vimwiki `html_header_numbering`: the heading level at which automatic /// section numbering begins (`0` = off, the default). When `>= 1`, every /// heading at that level or deeper is prefixed with a dotted section @@ -73,7 +69,6 @@ impl HtmlRenderer { template: None, vars: HashMap::new(), colors: HashMap::new(), - list_margin: -1, header_numbering: 0, header_numbering_sym: String::new(), } @@ -110,13 +105,6 @@ impl HtmlRenderer { self } - /// Set the vimwiki `list_margin`. `-1` defers to the stylesheet; - /// `>= 0` forces a `margin-left:em` on the outermost list. - pub fn with_list_margin(mut self, margin: i32) -> Self { - self.list_margin = margin; - self - } - /// Supply a `color_dic`: vimwiki colour-tag names mapped to their /// CSS values. A name listed here is rendered as `style="color:V"`; /// missing names fall through to `class="color-"`. @@ -329,16 +317,8 @@ impl HtmlRenderer { } fn render_list(&self, n: &ListNode, w: &mut dyn Write) -> io::Result<()> { - self.render_list_nested(n, w, true) - } - - fn render_list_nested(&self, n: &ListNode, w: &mut dyn Write, top: bool) -> io::Result<()> { let tag = if n.ordered { "ol" } else { "ul" }; - if top && self.list_margin >= 0 { - writeln!(w, "<{tag} style=\"margin-left:{}em\">", self.list_margin)?; - } else { - writeln!(w, "<{tag}>")?; - } + writeln!(w, "<{tag}>")?; for item in &n.items { self.render_list_item(item, w)?; } @@ -368,7 +348,7 @@ impl HtmlRenderer { self.render_inlines(&n.children, w)?; if let Some(sublist) = &n.sublist { w.write_all(b"\n")?; - self.render_list_nested(sublist, w, false)?; + self.render_list(sublist, w)?; } w.write_all(b"\n") } diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index df8840f..5c22f48 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -334,8 +334,12 @@ fn toc_generate(backend: &Backend, args: Vec) -> Result) -> Result) -> Result, #[serde(default)] position: Option, + /// `[line1, line2]` (0-indexed, inclusive) for the ranged form. + #[serde(default)] + range: Option<[u32; 2]>, } let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; let uri = parsed @@ -899,12 +908,16 @@ fn list_remove_done(backend: &Backend, args: Vec) -> Result return Ok(None), }; let utf8 = backend.use_utf8.load(Ordering::Relaxed); + // A range takes precedence over a cursor position; the client sends at + // most one (range for `:'<,'>RemoveDone`, position for the current list). + let range = parsed.range.map(|[a, b]| (a.min(b), a.max(b))); + let pos = if range.is_some() { + None + } else { + parsed.position + }; Ok(ops::remove_done_edit( - &doc.text, - &doc.ast, - &uri, - parsed.position, - utf8, + &doc.text, &doc.ast, &uri, pos, range, utf8, )) } @@ -1814,14 +1827,21 @@ pub mod ops { /// Build a heading + nested list TOC for the current document. The TOC /// itself (any existing `= Contents =` heading on the page) is skipped /// so re-generation is idempotent. - pub fn build_toc_text(items: &[(u8, String, String)], heading_name: &str, level: u8) -> String { + pub fn build_toc_text( + items: &[(u8, String, String)], + heading_name: &str, + level: u8, + margin: usize, + ) -> String { let mut out = caption_line(heading_name, level); if items.is_empty() { return out; } + let pad = " ".repeat(margin); let min_level = items.iter().map(|(l, _, _)| *l).min().unwrap_or(1); for (level, title, anchor) in items { let depth = level.saturating_sub(min_level) as usize; + out.push_str(&pad); for _ in 0..depth { out.push_str(" "); } @@ -1841,12 +1861,15 @@ pub mod ops { heading_name: &str, level: u8, exclude: Option<&str>, + margin: usize, ) -> String { let mut out = caption_line(heading_name, level); + let pad = " ".repeat(margin); for name in pages { if Some(name.as_str()) == exclude { continue; } + out.push_str(&pad); out.push_str("- [["); out.push_str(name); out.push_str("]]\n"); @@ -1966,6 +1989,7 @@ pub mod ops { /// Produce a `WorkspaceEdit` that replaces or inserts the TOC for the /// given document. Returns `None` if the document has no headings at /// all (other than possibly a pre-existing TOC heading). + #[allow(clippy::too_many_arguments)] pub fn toc_edit( text: &str, ast: &DocumentNode, @@ -1973,6 +1997,7 @@ pub mod ops { utf8: bool, heading: &str, level: u8, + margin: usize, ) -> Option { let items = collect_toc_items(ast, heading); if items.is_empty() { @@ -1982,7 +2007,7 @@ pub mod ops { .into_iter() .map(|it| (it.level, it.title, it.anchor)) .collect(); - let new_text = build_toc_text(&triples, heading, level); + let new_text = build_toc_text(&triples, heading, level, margin); let mut b = WorkspaceEditBuilder::new(); match find_section_range(ast, heading) { Some((start, end)) => { @@ -2010,6 +2035,7 @@ pub mod ops { /// `= Contents =` section — used by the `auto_toc`-on-save hook so we /// rebuild an existing TOC without inserting one into pages that never /// had one. + #[allow(clippy::too_many_arguments)] pub fn toc_rebuild_edit( text: &str, ast: &DocumentNode, @@ -2017,9 +2043,10 @@ pub mod ops { utf8: bool, heading: &str, level: u8, + margin: usize, ) -> Option { find_section_range(ast, heading)?; - toc_edit(text, ast, uri, utf8, heading, level) + toc_edit(text, ast, uri, utf8, heading, level, margin) } /// Produce a `WorkspaceEdit` that replaces or inserts the @@ -2034,11 +2061,12 @@ pub mod ops { utf8: bool, heading: &str, level: u8, + margin: usize, ) -> Option { if all_pages.is_empty() { return None; } - let new_text = build_links_text(all_pages, heading, level, Some(current_page)); + let new_text = build_links_text(all_pages, heading, level, Some(current_page), margin); let mut b = WorkspaceEditBuilder::new(); match find_section_range(ast, heading) { Some((start, end)) => { @@ -2067,6 +2095,7 @@ pub mod ops { utf8: bool, heading: &str, level: u8, + margin: usize, ) -> Option { find_section_range(ast, heading)?; links_edit( @@ -2078,6 +2107,7 @@ pub mod ops { utf8, heading, level, + margin, ) } @@ -2291,13 +2321,17 @@ pub mod ops { /// Delete every checkbox item whose state is `Done` or `Rejected`, /// cascading into their sublists. When `pos` is `Some`, restrict to /// the contiguous list block containing `pos`'s line (the "current - /// list" — including its nested sublists); otherwise sweep the whole - /// doc. + /// list" — including its nested sublists). When `range` is `Some` + /// (`(line1, line2)`, 0-indexed inclusive), sweep the whole doc but keep + /// only items whose start line falls in that range — the vimwiki + /// `:'<,'>VimwikiRemoveDone` ranged form. With neither, sweep the whole + /// doc. `pos` and `range` are mutually exclusive (the client sets one). pub fn remove_done_edit( text: &str, ast: &DocumentNode, uri: &Url, pos: Option, + range: Option<(u32, u32)>, utf8: bool, ) -> Option { let mut victims: Vec = Vec::new(); @@ -2307,6 +2341,12 @@ pub mod ops { Some(nuwiki_core::ast::CheckboxState::Done) | Some(nuwiki_core::ast::CheckboxState::Rejected), ) { + if let Some((l1, l2)) = range { + let line = item.span.start.line; + if line < l1 || line > l2 { + return; + } + } victims.push(extend_to_line_end(text, item.span)); } }; @@ -3187,7 +3227,9 @@ pub mod ops { tag: Option<&str>, header: &str, level: u8, + margin: usize, ) -> Option { + let pad = " ".repeat(margin); match tag { Some(t) => { let pages = pages_by_tag.get(t)?; @@ -3196,7 +3238,7 @@ pub mod ops { } let mut out = caption_line(&format!("Tag: {t}"), level); for p in pages { - out.push_str(&format!("- [[{p}]]\n")); + out.push_str(&format!("{pad}- [[{p}]]\n")); } Some(out) } @@ -3210,7 +3252,7 @@ pub mod ops { out.push('\n'); out.push_str(&caption_line(name, sub)); for p in pages { - out.push_str(&format!("- [[{p}]]\n")); + out.push_str(&format!("{pad}- [[{p}]]\n")); } } Some(out) @@ -3242,8 +3284,9 @@ pub mod ops { utf8: bool, header: &str, level: u8, + margin: usize, ) -> Option { - let body = build_tag_links_text(pages_by_tag, tag, header, level)?; + let body = build_tag_links_text(pages_by_tag, tag, header, level, margin)?; let heading = tag_section_heading(tag, header); let mut b = WorkspaceEditBuilder::new(); match find_section_range(ast, &heading) { @@ -3263,6 +3306,7 @@ pub mod ops { /// Like [`tag_links_edit`] for the no-arg index, but only when a /// `Generated Tags` section already exists — the `auto_generate_tags` /// on-save hook. Never inserts one into a page that lacks it. + #[allow(clippy::too_many_arguments)] pub fn tag_links_rebuild_edit( text: &str, ast: &DocumentNode, @@ -3271,9 +3315,20 @@ pub mod ops { utf8: bool, header: &str, level: u8, + margin: usize, ) -> Option { find_section_range(ast, header)?; - tag_links_edit(text, ast, uri, None, pages_by_tag, utf8, header, level) + tag_links_edit( + text, + ast, + uri, + None, + pages_by_tag, + utf8, + header, + level, + margin, + ) } // Keep the import live for downstream use even when the local module @@ -3378,7 +3433,6 @@ pub mod export_ops { DiaryDate::today_utc(), &cfg.html, Some(cfg.file_extension.as_str()), - cfg.list_margin, std::collections::HashMap::new(), )?; let out_path = output_path_for(&cfg.html, name); diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs index 562b183..fdf7a93 100644 --- a/crates/nuwiki-lsp/src/config.rs +++ b/crates/nuwiki-lsp/src/config.rs @@ -49,13 +49,21 @@ pub struct WikiConfig { /// Weekday the diary week begins on (`monday`..`sunday`, vimwiki's /// `diary_start_week_day`). Used only when `diary_weekly_style = date`. pub diary_start_week_day: String, - /// Caption level for the diary index page headings (1 = h1). - pub diary_caption_level: u8, + /// Caption level for the diary index page headings (1 = h1). vimwiki's + /// `diary_caption_level` (`min: -1`). nuwiki uses it as the base level of + /// the year/month index tree; values `< 0` clamp to `0` (upstream's `-1` + /// "read no per-page captions" mode isn't modelled — nuwiki builds the + /// tree from dates, not page headers). + pub diary_caption_level: i8, /// Newest-first (`desc`) or oldest-first (`asc`) in the diary /// index. pub diary_sort: String, /// H1 text for the diary index page. pub diary_header: String, + /// vimwiki `diary_months`: month-number → display name for the diary + /// index tree. Twelve entries (January..December) by default; a custom + /// list falls back to the English name for any missing/out-of-range slot. + pub diary_months: Vec, /// Vimwiki's `g:vimwiki_listsyms`. A progression of checkbox glyphs /// from "empty" (first) to "done" (last); the lexer recognises these /// glyphs and the toggle/cycle commands walk them. Any length ≥ 2 is @@ -66,8 +74,12 @@ pub struct WikiConfig { pub listsym_rejected: String, /// When toggling a parent list item, also cascade to descendants. pub listsyms_propagate: bool, - /// `-1` keeps tight lists; positive integers indent list items by - /// that many extra columns. Layout hint for the renderer. + /// vimwiki `list_margin`: number of leading spaces before bullets in + /// **buffer-side generated lists** — `:VimwikiGenerateLinks`, the TOC, the + /// tags index, and the diary index. `>= 0` indents by that many spaces; + /// `< 0` means "no margin" (default `-1`; `0` for markdown wikis). + /// Divergence: upstream resolves `< 0` to the buffer's `'shiftwidth'`, + /// which the server can't observe, so negatives collapse to zero indent. pub list_margin: i32, /// Character used in place of literal spaces inside wikilink /// targets when writing to disk (a single space `" "` by default, @@ -229,6 +241,7 @@ impl WikiConfig { diary_caption_level: d.diary_caption_level, diary_sort: d.diary_sort, diary_header: d.diary_header, + diary_months: d.diary_months, listsyms: d.listsyms, listsym_rejected: d.listsym_rejected, listsyms_propagate: d.listsyms_propagate, @@ -268,6 +281,7 @@ impl WikiConfig { diary_caption_level: d.diary_caption_level, diary_sort: d.diary_sort, diary_header: d.diary_header, + diary_months: d.diary_months, listsyms: d.listsyms, listsym_rejected: d.listsym_rejected, listsyms_propagate: d.listsyms_propagate, @@ -371,6 +385,27 @@ fn default_diary_header() -> String { "Diary".to_string() } +/// vimwiki's default `diary_months` — the English month names, January..December. +fn default_diary_months() -> Vec { + [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ] + .iter() + .map(|s| s.to_string()) + .collect() +} + fn default_listsyms() -> String { " .oOX".to_string() } @@ -411,6 +446,7 @@ fn wiki_defaults() -> WikiDefaults { diary_caption_level: 0, diary_sort: default_diary_sort(), diary_header: default_diary_header(), + diary_months: default_diary_months(), listsyms: default_listsyms(), listsym_rejected: default_listsym_rejected(), listsyms_propagate: true, @@ -436,9 +472,10 @@ struct WikiDefaults { diary_frequency: String, diary_weekly_style: String, diary_start_week_day: String, - diary_caption_level: u8, + diary_caption_level: i8, diary_sort: String, diary_header: String, + diary_months: Vec, listsyms: String, listsym_rejected: String, listsyms_propagate: bool, @@ -673,12 +710,14 @@ struct RawWiki { #[serde(default)] diary_start_week_day: Option, #[serde(default)] - diary_caption_level: Option, + diary_caption_level: Option, #[serde(default)] diary_sort: Option, #[serde(default)] diary_header: Option, #[serde(default)] + diary_months: Option>, + #[serde(default)] listsyms: Option, #[serde(default)] listsym_rejected: Option, @@ -764,11 +803,19 @@ impl From for WikiConfig { html.html_header_numbering_sym = s; } let d = wiki_defaults(); + let syntax = r.syntax.unwrap_or_else(|| "vimwiki".into()); + // vimwiki derives `list_margin = 0` for markdown wikis when the key is + // unset (vars.vim:651); other syntaxes keep the `-1` default. + let list_margin = r.list_margin.unwrap_or(if syntax == "markdown" { + 0 + } else { + d.list_margin + }); WikiConfig { name: r.name.unwrap_or(derived_name), root, file_extension: r.file_extension.unwrap_or_else(|| ".wiki".into()), - syntax: r.syntax.unwrap_or_else(|| "vimwiki".into()), + syntax, index: r.index.unwrap_or(d.index), diary_rel_path: r.diary_rel_path.unwrap_or(d.diary_rel_path), diary_index: r.diary_index.unwrap_or(d.diary_index), @@ -778,10 +825,11 @@ impl From for WikiConfig { diary_caption_level: r.diary_caption_level.unwrap_or(d.diary_caption_level), diary_sort: r.diary_sort.unwrap_or(d.diary_sort), diary_header: r.diary_header.unwrap_or(d.diary_header), + diary_months: r.diary_months.unwrap_or(d.diary_months), listsyms: r.listsyms.unwrap_or(d.listsyms), listsym_rejected: r.listsym_rejected.unwrap_or(d.listsym_rejected), listsyms_propagate: r.listsyms_propagate.unwrap_or(d.listsyms_propagate), - list_margin: r.list_margin.unwrap_or(d.list_margin), + list_margin, links_space_char: r.links_space_char.unwrap_or(d.links_space_char), auto_toc: r.auto_toc.unwrap_or(d.auto_toc), auto_generate_links: r.auto_generate_links.unwrap_or(d.auto_generate_links), diff --git a/crates/nuwiki-lsp/src/diary.rs b/crates/nuwiki-lsp/src/diary.rs index c14a611..28aa04c 100644 --- a/crates/nuwiki-lsp/src/diary.rs +++ b/crates/nuwiki-lsp/src/diary.rs @@ -202,17 +202,27 @@ fn heading(level: u8, text: &str) -> String { /// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`. /// /// `caption_level` sets the level of the top caption; the year and month -/// subheadings nest one and two levels below it. `sort` controls whether -/// the flat list runs newest- or oldest-first. +/// subheadings nest one and two levels below it. A `caption_level < 0` +/// clamps to `0` (nuwiki builds the tree from dates, so upstream's `-1` +/// "no per-page captions" mode doesn't apply). `months` supplies the +/// month-number → display name table; an empty slice (or a short list) +/// falls back to the English `month_name` for any missing slot. `sort` +/// controls whether the flat list runs newest- or oldest-first. `margin` +/// is the number of leading spaces before each entry bullet (vimwiki's +/// `list_margin`). pub fn build_index_body( entries: &[DiaryEntry], diary_rel_path: &str, index_heading: &str, sort: DiarySort, - caption_level: u8, + caption_level: i8, + months: &[String], + margin: usize, ) -> String { + let base_level = caption_level.max(0) as u8; + let pad = " ".repeat(margin); let mut out = String::new(); - out.push_str(&heading(caption_level, index_heading)); + out.push_str(&heading(base_level, index_heading)); out.push('\n'); if entries.is_empty() { @@ -225,8 +235,8 @@ pub fn build_index_body( DiarySort::Asc => a.date.cmp(&b.date), }); - let year_level = caption_level.saturating_add(1); - let month_level = caption_level.saturating_add(2); + let year_level = base_level.saturating_add(1); + let month_level = base_level.saturating_add(2); let mut current_year: Option = None; let mut current_month: Option = None; for e in sorted { @@ -238,12 +248,17 @@ pub fn build_index_body( current_month = None; } if current_month != Some(e.date.month) { - out.push_str(&heading(month_level, month_name(e.date.month))); + let label = months + .get((e.date.month.saturating_sub(1)) as usize) + .map(String::as_str) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| month_name(e.date.month)); + out.push_str(&heading(month_level, label)); out.push('\n'); current_month = Some(e.date.month); } out.push_str(&format!( - "- [[{}/{}]]\n", + "{pad}- [[{}/{}]]\n", diary_rel_path.trim_end_matches('/'), e.date.format() )); @@ -253,7 +268,8 @@ pub fn build_index_body( /// Render the diary-index body for the given wiki's currently-indexed /// entries. Convenience wrapper around [`build_index_body`] that honours -/// the wiki's `diary_header`, `diary_sort`, and `diary_caption_level`. +/// the wiki's `diary_header`, `diary_sort`, `diary_caption_level`, and +/// `diary_months`. pub fn render_index_body(cfg: &WikiConfig, index: &WorkspaceIndex) -> String { build_index_body( &list_entries(index), @@ -261,6 +277,8 @@ pub fn render_index_body(cfg: &WikiConfig, index: &WorkspaceIndex) -> String { &cfg.diary_header, DiarySort::parse(&cfg.diary_sort), cfg.diary_caption_level, + &cfg.diary_months, + cfg.list_margin.max(0) as usize, ) } diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs index ed283dd..eac8dd3 100644 --- a/crates/nuwiki-lsp/src/export.rs +++ b/crates/nuwiki-lsp/src/export.rs @@ -203,7 +203,6 @@ pub fn render_page_html( today: DiaryDate, cfg: &HtmlConfig, wiki_extension: Option<&str>, - list_margin: i32, extra_vars: HashMap, ) -> std::io::Result { let date_str = match doc.metadata.date.as_deref() { @@ -228,7 +227,7 @@ pub fn render_page_html( vars.insert(k, v); } - let mut r = HtmlRenderer::new().with_list_margin(list_margin); + let mut r = HtmlRenderer::new(); if let Some(ext) = wiki_extension.filter(|e| !e.trim_start_matches('.').is_empty()) { // Strip the wiki extension from page links before the default resolver // turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index 7dd1d31..e715ec2 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -627,6 +627,7 @@ impl LanguageServer for Backend { self.use_utf8.load(Ordering::Relaxed), &wiki.config.toc_header, wiki.config.toc_header_level, + wiki.config.list_margin.max(0) as usize, ) }) }; @@ -656,6 +657,7 @@ impl LanguageServer for Backend { utf8, &wiki.config.links_header, wiki.config.links_header_level, + wiki.config.list_margin.max(0) as usize, ) }); if let Some(edit) = edit { @@ -680,6 +682,7 @@ impl LanguageServer for Backend { utf8, &wiki.config.tags_header, wiki.config.tags_header_level, + wiki.config.list_margin.max(0) as usize, ) }); if let Some(edit) = edit { diff --git a/crates/nuwiki-lsp/tests/commands_coverage.rs b/crates/nuwiki-lsp/tests/commands_coverage.rs index 46dd55c..8201e7b 100644 --- a/crates/nuwiki-lsp/tests/commands_coverage.rs +++ b/crates/nuwiki-lsp/tests/commands_coverage.rs @@ -262,7 +262,15 @@ fn nuwiki_diary_index_resolves_index_uri() { fn nuwiki_diary_generate_links_builds_grouped_body() { let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-04-30"]); let entries = diary::list_entries(&idx); - let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Desc, 1); + let body = diary::build_index_body( + &entries, + "diary", + "Diary", + diary::DiarySort::Desc, + 1, + &[], + 0, + ); assert!(body.starts_with("= Diary =")); assert!(body.contains("2026-05-12")); assert!(body.contains("2026-05-10")); @@ -298,7 +306,7 @@ fn nuwiki_toc_generates_nested_contents() { let src = "= Top =\n== Sub ==\ntext\n"; let doc = parse(src); let uri = Url::from_file_path("/wiki/Page.wiki").unwrap(); - let edit = ops::toc_edit(src, &doc, &uri, true, "Contents", 1).expect("toc edit produced"); + let edit = ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0).expect("toc edit produced"); assert!(edit.changes.is_some() || edit.document_changes.is_some()); let toc = ops::build_toc_text( @@ -308,6 +316,7 @@ fn nuwiki_toc_generates_nested_contents() { ], "Contents", 1, + 0, ); assert!(toc.contains("= Contents =")); assert!(toc.contains("- [[#top|Top]]")); @@ -318,7 +327,7 @@ fn nuwiki_toc_generates_nested_contents() { #[test] fn nuwiki_generate_links_lists_all_pages_excluding_current() { let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()]; - let text = ops::build_links_text(&pages, "Links", 1, Some("Home")); + let text = ops::build_links_text(&pages, "Links", 1, Some("Home"), 0); assert!(text.contains("= Links =")); assert!(text.contains("- [[About]]")); assert!(text.contains("- [[Notes]]")); @@ -327,7 +336,18 @@ fn nuwiki_generate_links_lists_all_pages_excluding_current() { let src = "= Existing =\n"; let doc = parse(src); let uri = Url::from_file_path("/wiki/Home.wiki").unwrap(); - assert!(ops::links_edit(src, &doc, &uri, "Home", &pages, true, "Generated Links", 1).is_some()); + assert!(ops::links_edit( + src, + &doc, + &uri, + "Home", + &pages, + true, + "Generated Links", + 1, + 0 + ) + .is_some()); } // :NuwikiCheckLinks — surface broken links across the workspace. @@ -384,13 +404,13 @@ fn nuwiki_generate_tag_links_builds_section() { ); let by_tag = ops::tag_pages_snapshot(&idx); - let single = ops::build_tag_links_text(&by_tag, Some("alpha"), "Generated Tags", 1).unwrap(); + let single = ops::build_tag_links_text(&by_tag, Some("alpha"), "Generated Tags", 1, 0).unwrap(); assert!(single.starts_with("= Tag: alpha =")); assert!(single.contains("- [[Home]]")); assert!(single.contains("- [[Work]]")); // No-arg variant emits a section per tag. - let all = ops::build_tag_links_text(&by_tag, None, "Generated Tags", 1).unwrap(); + let all = ops::build_tag_links_text(&by_tag, None, "Generated Tags", 1, 0).unwrap(); assert!(all.starts_with("= Generated Tags =")); assert!(all.contains("== alpha ==")); assert!(all.contains("== beta ==")); @@ -406,7 +426,8 @@ fn nuwiki_generate_tag_links_builds_section() { &by_tag, true, "Generated Tags", - 1 + 1, + 0 ) .is_some()); } @@ -442,7 +463,6 @@ fn nuwiki_2html_renders_page() { DiaryDate::from_ymd(2026, 5, 12).unwrap(), &cfg.html, Some(".wiki"), - -1, HashMap::new(), ) .unwrap(); @@ -464,7 +484,6 @@ fn nuwiki_2html_browse_shares_render_and_is_advertised() { DiaryDate::today_utc(), &cfg.html, Some(".wiki"), - -1, HashMap::new(), ) .unwrap(); diff --git a/crates/nuwiki-lsp/tests/commands_lists.rs b/crates/nuwiki-lsp/tests/commands_lists.rs index fcb176e..a30416a 100644 --- a/crates/nuwiki-lsp/tests/commands_lists.rs +++ b/crates/nuwiki-lsp/tests/commands_lists.rs @@ -61,7 +61,7 @@ fn render_marker_handles_each_symbol() { fn remove_done_strips_done_and_rejected_items() { let src = "- [ ] todo\n- [X] done\n- [-] rejected\n- [ ] also todo\n"; let ast = parse(src); - let edit = ops::remove_done_edit(src, &ast, &uri(), None, true).expect("edit"); + let edit = ops::remove_done_edit(src, &ast, &uri(), None, None, true).expect("edit"); let changes = edit.changes.expect("changes"); let edits = &changes[&uri()]; assert_eq!(edits.len(), 2, "expected 2 deletions"); @@ -75,14 +75,14 @@ fn remove_done_strips_done_and_rejected_items() { fn remove_done_leaves_open_items_alone() { let src = "- [ ] todo\n- [.] partial\n- [o] more\n"; let ast = parse(src); - assert!(ops::remove_done_edit(src, &ast, &uri(), None, true).is_none()); + assert!(ops::remove_done_edit(src, &ast, &uri(), None, None, true).is_none()); } #[test] fn remove_done_returns_none_when_no_lists() { let src = "= heading =\nparagraph\n"; let ast = parse(src); - assert!(ops::remove_done_edit(src, &ast, &uri(), None, true).is_none()); + assert!(ops::remove_done_edit(src, &ast, &uri(), None, None, true).is_none()); } #[test] @@ -96,7 +96,7 @@ fn remove_done_scoped_by_position_to_current_list() { line: 0, character: 0, }); - let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit"); + let edit = ops::remove_done_edit(src, &ast, &uri(), pos, None, true).expect("edit"); let edits = &edit.changes.unwrap()[&uri()]; assert_eq!(edits.len(), 2, "both done items in the current list match"); } @@ -112,7 +112,7 @@ fn remove_done_position_leaves_other_lists_untouched() { line: 0, character: 0, }); - let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit"); + let edit = ops::remove_done_edit(src, &ast, &uri(), pos, None, true).expect("edit"); let edits = &edit.changes.unwrap()[&uri()]; assert_eq!(edits.len(), 1, "only the first list's done item is removed"); } @@ -126,11 +126,32 @@ fn remove_done_position_cascades_into_sublists() { line: 0, character: 0, }); - let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit"); + let edit = ops::remove_done_edit(src, &ast, &uri(), pos, None, true).expect("edit"); let edits = &edit.changes.unwrap()[&uri()]; assert_eq!(edits.len(), 1, "the nested done child is removed"); } +#[test] +fn remove_done_ranged_restricts_to_selected_lines() { + // The `:'<,'>VimwikiRemoveDone` form: a 0-indexed inclusive line range + // sweeps the whole doc but only deletes done/rejected items inside it. + // Lines: 0 `[X]`, 1 `[ ]`, 2 `[X]`, 3 `[-]`. Range 0..=1 keeps only the + // line-0 done item; the line-2/3 done+rejected items survive. + let src = "- [X] a\n- [ ] b\n- [X] c\n- [-] d\n"; + let ast = parse(src); + let edit = ops::remove_done_edit(src, &ast, &uri(), None, Some((0, 1)), true).expect("edit"); + let edits = &edit.changes.unwrap()[&uri()]; + assert_eq!(edits.len(), 1, "only the done item on line 0 is in range"); +} + +#[test] +fn remove_done_ranged_returns_none_when_range_has_no_done() { + // Range covering only open items → nothing to delete. + let src = "- [X] a\n- [ ] b\n- [ ] c\n"; + let ast = parse(src); + assert!(ops::remove_done_edit(src, &ast, &uri(), None, Some((1, 2)), true).is_none()); +} + // ===== renumber ===== #[test] @@ -280,6 +301,6 @@ fn commands_list_includes_cluster_a() { fn one_edit_text_round_trip() { let src = "- [X] done\n"; let ast = parse(src); - let edit = ops::remove_done_edit(src, &ast, &uri(), None, true).unwrap(); + let edit = ops::remove_done_edit(src, &ast, &uri(), None, None, true).unwrap(); let _ = one_edit_text(edit); } diff --git a/crates/nuwiki-lsp/tests/commands_tags.rs b/crates/nuwiki-lsp/tests/commands_tags.rs index 120e86f..8ec9fad 100644 --- a/crates/nuwiki-lsp/tests/commands_tags.rs +++ b/crates/nuwiki-lsp/tests/commands_tags.rs @@ -203,7 +203,7 @@ fn tag_pages_snapshot_sorts_pages_alphabetically() { fn build_tag_links_for_single_tag() { let mut snap = BTreeMap::new(); snap.insert("release".to_string(), vec!["Alpha".into(), "Beta".into()]); - let out = ops::build_tag_links_text(&snap, Some("release"), "Generated Tags", 1).unwrap(); + let out = ops::build_tag_links_text(&snap, Some("release"), "Generated Tags", 1, 0).unwrap(); let lines: Vec<&str> = out.lines().collect(); assert_eq!(lines[0], "= Tag: release ="); assert_eq!(lines[1], "- [[Alpha]]"); @@ -213,7 +213,7 @@ fn build_tag_links_for_single_tag() { #[test] fn build_tag_links_for_missing_tag_returns_none() { let snap = BTreeMap::new(); - assert!(ops::build_tag_links_text(&snap, Some("ghost"), "Generated Tags", 1).is_none()); + assert!(ops::build_tag_links_text(&snap, Some("ghost"), "Generated Tags", 1, 0).is_none()); } #[test] @@ -221,7 +221,7 @@ fn build_tag_links_full_index_groups_by_tag() { let mut snap = BTreeMap::new(); snap.insert("a".to_string(), vec!["P1".into()]); snap.insert("b".to_string(), vec!["P2".into(), "P3".into()]); - let out = ops::build_tag_links_text(&snap, None, "Generated Tags", 1).unwrap(); + let out = ops::build_tag_links_text(&snap, None, "Generated Tags", 1, 0).unwrap(); assert!(out.starts_with("= Generated Tags =\n")); assert!(out.contains("== a ==")); assert!(out.contains("== b ==")); @@ -233,7 +233,7 @@ fn build_tag_links_full_index_groups_by_tag() { #[test] fn build_tag_links_full_index_returns_none_when_no_tags() { let snap = BTreeMap::new(); - assert!(ops::build_tag_links_text(&snap, None, "Generated Tags", 1).is_none()); + assert!(ops::build_tag_links_text(&snap, None, "Generated Tags", 1, 0).is_none()); } // ===== `nuwiki.tags.generateLinks` — tag_links_edit (in-buffer rewrite) ===== @@ -254,6 +254,7 @@ fn tag_links_edit_inserts_when_section_missing() { true, "Generated Tags", 1, + 0, ) .expect("got an edit"); let te = &edit.changes.unwrap()[&uri][0]; @@ -279,6 +280,7 @@ fn tag_links_edit_replaces_existing_section() { true, "Generated Tags", 1, + 0, ) .expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; @@ -294,8 +296,8 @@ fn tag_links_edit_full_index_replaces_existing_tags_section() { let uri = Url::parse("file:///tmp/p.wiki").unwrap(); let mut snap = BTreeMap::new(); snap.insert("alpha".to_string(), vec!["P".into()]); - let edit = - ops::tag_links_edit(src, &ast, &uri, None, &snap, true, "Generated Tags", 1).expect("edit"); + let edit = ops::tag_links_edit(src, &ast, &uri, None, &snap, true, "Generated Tags", 1, 0) + .expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; assert!(te.new_text.contains("== alpha ==")); assert!(!te.new_text.contains("[[old]]")); @@ -315,7 +317,8 @@ fn tag_links_rebuild_edit_only_acts_when_index_present() { &snap, true, "Generated Tags", - 1 + 1, + 0 ) .is_none(), "auto_generate_tags must not insert an index into a page that lacks one" @@ -328,7 +331,8 @@ fn tag_links_rebuild_edit_only_acts_when_index_present() { &snap, true, "Generated Tags", - 1 + 1, + 0 ) .is_some()); } @@ -347,7 +351,8 @@ fn tag_links_edit_returns_none_for_unknown_tag() { &snap, true, "Generated Tags", - 1 + 1, + 0 ) .is_none()); } diff --git a/crates/nuwiki-lsp/tests/diary.rs b/crates/nuwiki-lsp/tests/diary.rs index 9a8bfa0..5852f23 100644 --- a/crates/nuwiki-lsp/tests/diary.rs +++ b/crates/nuwiki-lsp/tests/diary.rs @@ -274,7 +274,15 @@ fn build_index_body_is_newest_first_grouped_by_year_month() { &["2025-12-31", "2026-05-11", "2026-05-12", "2026-04-30"], ); let entries = diary::list_entries(&idx); - let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Desc, 1); + let body = diary::build_index_body( + &entries, + "diary", + "Diary", + diary::DiarySort::Desc, + 1, + &[], + 0, + ); let lines: Vec<&str> = body.lines().collect(); assert_eq!(lines[0], "= Diary ="); // First date in output should be the latest: 2026-05-12 @@ -292,7 +300,7 @@ fn build_index_body_is_newest_first_grouped_by_year_month() { #[test] fn build_index_body_empty_when_no_entries() { - let body = diary::build_index_body(&[], "diary", "Diary", diary::DiarySort::Desc, 1); + let body = diary::build_index_body(&[], "diary", "Diary", diary::DiarySort::Desc, 1, &[], 0); assert_eq!(body, "= Diary =\n"); } @@ -300,7 +308,8 @@ fn build_index_body_empty_when_no_entries() { fn build_index_body_ascending_sort_lists_oldest_first() { let idx = build_index_with_entries("/tmp/diarySort", &["2026-05-11", "2026-05-12"]); let entries = diary::list_entries(&idx); - let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Asc, 1); + let body = + diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Asc, 1, &[], 0); let older = body.find("2026-05-11").unwrap(); let newer = body.find("2026-05-12").unwrap(); assert!(older < newer, "ascending sort lists oldest first"); @@ -310,7 +319,15 @@ fn build_index_body_ascending_sort_lists_oldest_first() { fn build_index_body_caption_level_shifts_all_headings() { let idx = build_index_with_entries("/tmp/diaryCap", &["2026-05-11"]); let entries = diary::list_entries(&idx); - let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Desc, 2); + let body = diary::build_index_body( + &entries, + "diary", + "Diary", + diary::DiarySort::Desc, + 2, + &[], + 0, + ); assert!(body.contains("== Diary =="), "caption at level 2"); assert!( body.contains("=== 2026 ==="), @@ -327,11 +344,102 @@ fn build_index_body_clamps_caption_level_to_six() { let idx = build_index_with_entries("/tmp/diaryClamp", &["2026-05-11"]); let entries = diary::list_entries(&idx); // caption_level 6 → year/month would be 7/8 but clamp to 6. - let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Desc, 6); + let body = diary::build_index_body( + &entries, + "diary", + "Diary", + diary::DiarySort::Desc, + 6, + &[], + 0, + ); assert!(body.contains("====== Diary ======")); assert!(!body.contains("======="), "no heading exceeds level 6"); } +#[test] +fn build_index_body_uses_custom_diary_months() { + let idx = build_index_with_entries("/tmp/diaryMonths", &["2026-05-11", "2026-12-01"]); + let entries = diary::list_entries(&idx); + // A localized month table (Portuguese) replaces the English labels. + let months: Vec = [ + "Janeiro", + "Fevereiro", + "Março", + "Abril", + "Maio", + "Junho", + "Julho", + "Agosto", + "Setembro", + "Outubro", + "Novembro", + "Dezembro", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + let body = diary::build_index_body( + &entries, + "diary", + "Diary", + diary::DiarySort::Desc, + 1, + &months, + 0, + ); + assert!(body.contains("=== Maio ==="), "May → Maio: {body}"); + assert!( + body.contains("=== Dezembro ==="), + "December → Dezembro: {body}" + ); + assert!(!body.contains("May"), "English label leaked: {body}"); +} + +#[test] +fn build_index_body_falls_back_per_missing_month_slot() { + let idx = build_index_with_entries("/tmp/diaryShort", &["2026-05-11"]); + let entries = diary::list_entries(&idx); + // A too-short table only covers Jan–Mar; May falls back to English. + let months: Vec = ["Jan", "Feb", "Mar"] + .iter() + .map(|s| s.to_string()) + .collect(); + let body = diary::build_index_body( + &entries, + "diary", + "Diary", + diary::DiarySort::Desc, + 1, + &months, + 0, + ); + assert!( + body.contains("=== May ==="), + "missing slot → English: {body}" + ); +} + +#[test] +fn build_index_body_clamps_negative_caption_level_to_zero() { + let idx = build_index_with_entries("/tmp/diaryNeg", &["2026-05-11"]); + let entries = diary::list_entries(&idx); + // vimwiki allows diary_caption_level = -1; nuwiki clamps it to 0 (same as + // a caption_level of 0: caption h1, year h1, month h2). + let body = diary::build_index_body( + &entries, + "diary", + "Diary", + diary::DiarySort::Desc, + -1, + &[], + 0, + ); + assert!(body.contains("= Diary ="), "caption at level 1: {body}"); + assert!(body.contains("= 2026 ="), "year at level 1: {body}"); + assert!(body.contains("== May =="), "month at level 2: {body}"); +} + #[test] fn render_index_body_round_trip() { let cfg = wiki_cfg("/tmp/diaryA"); diff --git a/crates/nuwiki-lsp/tests/html_export.rs b/crates/nuwiki-lsp/tests/html_export.rs index 441d302..e8c628f 100644 --- a/crates/nuwiki-lsp/tests/html_export.rs +++ b/crates/nuwiki-lsp/tests/html_export.rs @@ -245,7 +245,6 @@ fn render_page_html_substitutes_title_content_and_extras() { DiaryDate::from_ymd(2026, 5, 11).unwrap(), &c.html, None, - -1, extras, ) .unwrap(); @@ -265,7 +264,6 @@ fn render_page_html_uses_metadata_date_when_set() { DiaryDate::from_ymd(2026, 5, 11).unwrap(), &c.html, None, - -1, HashMap::new(), ) .unwrap(); @@ -285,7 +283,6 @@ fn render_page_html_numbers_headers_when_enabled() { DiaryDate::from_ymd(2026, 5, 11).unwrap(), &c.html, None, - -1, HashMap::new(), ) .unwrap(); @@ -308,7 +305,6 @@ fn render_page_html_numbering_skips_headers_above_start_level() { DiaryDate::from_ymd(2026, 5, 11).unwrap(), &c.html, None, - -1, HashMap::new(), ) .unwrap(); @@ -331,7 +327,6 @@ fn render_page_html_no_header_numbering_by_default() { DiaryDate::from_ymd(2026, 5, 11).unwrap(), &c.html, None, - -1, HashMap::new(), ) .unwrap(); @@ -350,7 +345,6 @@ fn render_page_html_root_path_for_nested_pages() { DiaryDate::today_utc(), &c.html, None, - -1, HashMap::new(), ) .unwrap(); @@ -370,7 +364,6 @@ fn render_page_html_extra_var_overrides_default() { DiaryDate::today_utc(), &c.html, None, - -1, extras, ) .unwrap(); @@ -393,7 +386,6 @@ fn render_page_html_substitutes_vimwiki_percent_template() { DiaryDate::from_ymd(2026, 5, 11).unwrap(), &c.html, None, - -1, HashMap::new(), ) .unwrap(); @@ -420,7 +412,6 @@ fn render_page_html_strips_wiki_extension_from_links() { DiaryDate::today_utc(), &c.html, Some(".wiki"), - -1, HashMap::new(), ) .unwrap(); @@ -430,49 +421,6 @@ fn render_page_html_strips_wiki_extension_from_links() { assert!(html.contains("href=\"posts/index.html\""), "plain: {html}"); } -#[test] -fn render_page_html_list_margin_only_styles_top_level_list() { - // `list_margin >= 0` forces a `margin-left:em` on the outermost - // list; nested lists and the default of `-1` stay unstyled. - let ast = parse("- a\n - b\n"); - let c = cfg("/tmp/x"); - - let styled = export::render_page_html( - &ast, - Some("{{content}}".into()), - "index", - DiaryDate::today_utc(), - &c.html, - None, - 2, - HashMap::new(), - ) - .unwrap(); - assert!( - styled.contains("
    "), - "top-level margin: {styled}" - ); - // Exactly one styled list — the nested `
      ` is plain. - assert_eq!( - styled.matches("margin-left").count(), - 1, - "only top: {styled}" - ); - - let plain = export::render_page_html( - &ast, - Some("{{content}}".into()), - "index", - DiaryDate::today_utc(), - &c.html, - None, - -1, - HashMap::new(), - ) - .unwrap(); - assert!(!plain.contains("margin-left"), "default unstyled: {plain}"); -} - // ===== fallback_template + DEFAULT_CSS ===== #[test] diff --git a/crates/nuwiki-lsp/tests/index_and_config.rs b/crates/nuwiki-lsp/tests/index_and_config.rs index 1e4d1be..6eb51ac 100644 --- a/crates/nuwiki-lsp/tests/index_and_config.rs +++ b/crates/nuwiki-lsp/tests/index_and_config.rs @@ -270,6 +270,10 @@ fn defaults_carry_vimwiki_per_wiki_keys() { assert_eq!(cfg.diary_caption_level, 0); // upstream default assert_eq!(cfg.diary_sort, "desc"); assert_eq!(cfg.diary_header, "Diary"); + // diary_months defaults to the 12 English month names. + assert_eq!(cfg.diary_months.len(), 12); + assert_eq!(cfg.diary_months[0], "January"); + assert_eq!(cfg.diary_months[11], "December"); assert_eq!(cfg.listsyms, " .oOX"); assert!(cfg.listsyms_propagate); assert_eq!(cfg.list_margin, -1); @@ -305,6 +309,7 @@ fn raw_wiki_parses_every_new_key() { "diary_caption_level": 2, "diary_sort": "asc", "diary_header": "Journal", + "diary_months": ["Jan","Feb","Mar","Apr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"], "listsyms": "abcde", "listsym_rejected": "✗", "listsyms_propagate": false, @@ -334,6 +339,8 @@ fn raw_wiki_parses_every_new_key() { assert_eq!(w.diary_caption_level, 2); assert_eq!(w.diary_sort, "asc"); assert_eq!(w.diary_header, "Journal"); + assert_eq!(w.diary_months[4], "Mai"); + assert_eq!(w.diary_months[11], "Dez"); assert_eq!(w.listsyms, "abcde"); assert_eq!(w.listsym_rejected, "✗"); assert_eq!(w.list_syms().rejected_glyph(), '✗'); @@ -369,6 +376,36 @@ fn raw_wiki_parses_every_new_key() { ); } +#[test] +fn markdown_wiki_derives_list_margin_zero_when_unset() { + // vimwiki sets list_margin = 0 for markdown wikis (vs -1 elsewhere) when + // the key is absent; an explicit value still wins. + let cfg = config_from_json(serde_json::json!({ + "wikis": [{ "root": "/tmp/md", "syntax": "markdown" }], + })); + assert_eq!(cfg.wikis[0].list_margin, 0); + + let vw = config_from_json(serde_json::json!({ + "wikis": [{ "root": "/tmp/vw", "syntax": "vimwiki" }], + })); + assert_eq!(vw.wikis[0].list_margin, -1); + + let explicit = config_from_json(serde_json::json!({ + "wikis": [{ "root": "/tmp/md2", "syntax": "markdown", "list_margin": 4 }], + })); + assert_eq!(explicit.wikis[0].list_margin, 4); +} + +#[test] +fn diary_caption_level_accepts_negative() { + // vimwiki allows diary_caption_level = -1 (min: -1); the field is i8 so it + // parses instead of failing deserialization. + let cfg = config_from_json(serde_json::json!({ + "wikis": [{ "root": "/tmp/w", "diary_caption_level": -1 }], + })); + assert_eq!(cfg.wikis[0].diary_caption_level, -1); +} + #[test] fn diary_calendar_defaults_to_iso_weeks() { let cfg = WikiConfig::empty(); diff --git a/crates/nuwiki-lsp/tests/link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs index 3acb366..76738b9 100644 --- a/crates/nuwiki-lsp/tests/link_health.rs +++ b/crates/nuwiki-lsp/tests/link_health.rs @@ -290,7 +290,7 @@ fn build_toc_text_nests_by_level() { (2u8, "Sub".into(), "sub".into()), (1u8, "Other".into(), "other".into()), ]; - let out = ops::build_toc_text(&items, "Contents", 1); + let out = ops::build_toc_text(&items, "Contents", 1, 0); assert!(out.starts_with("= Contents =\n")); let lines: Vec<&str> = out.lines().collect(); assert_eq!(lines[0], "= Contents ="); @@ -301,7 +301,7 @@ fn build_toc_text_nests_by_level() { #[test] fn build_toc_text_with_no_headings_is_just_the_heading() { - let out = ops::build_toc_text(&[], "Contents", 1); + let out = ops::build_toc_text(&[], "Contents", 1, 0); assert_eq!(out, "= Contents =\n"); } @@ -319,7 +319,8 @@ fn links_rebuild_edit_only_acts_when_section_present() { &pages, true, "Generated Links", - 1 + 1, + 0 ) .is_none(), "must not insert a links section into a page that lacks one" @@ -333,7 +334,8 @@ fn links_rebuild_edit_only_acts_when_section_present() { &pages, true, "Generated Links", - 1 + 1, + 0 ) .is_some()); } @@ -358,15 +360,42 @@ fn find_section_range_stops_at_same_level_sibling() { #[test] fn captions_honour_custom_header_and_level() { // toc_header="Table of Contents", level 2 → `== Table of Contents ==`. - let out = ops::build_toc_text(&[], "Table of Contents", 2); + let out = ops::build_toc_text(&[], "Table of Contents", 2, 0); assert_eq!(out, "== Table of Contents ==\n"); // links_header at level 3. let pages = vec!["A".to_string(), "B".to_string()]; - let links = ops::build_links_text(&pages, "All Pages", 3, None); + let links = ops::build_links_text(&pages, "All Pages", 3, None, 0); assert!(links.starts_with("=== All Pages ===\n")); // level clamps to 1..=6. - assert!(ops::build_toc_text(&[], "X", 9).starts_with("====== X ======")); - assert!(ops::build_toc_text(&[], "X", 0).starts_with("= X =")); + assert!(ops::build_toc_text(&[], "X", 9, 0).starts_with("====== X ======")); + assert!(ops::build_toc_text(&[], "X", 0, 0).starts_with("= X =")); +} + +#[test] +fn list_margin_indents_generated_bullets_not_headings() { + // vimwiki `list_margin` = leading spaces before each generated bullet. + // The heading stays at column 0; bullets get `margin` spaces (TOC keeps + // its per-depth nesting *after* the base margin). + let pages = vec!["A".to_string(), "B".to_string()]; + let links = ops::build_links_text(&pages, "Generated Links", 1, None, 2); + assert!(links.contains("\n - [[A]]\n"), "2-space margin: {links:?}"); + assert!( + links.starts_with("= Generated Links =\n"), + "heading unindented: {links:?}" + ); + + let toc = ops::build_toc_text( + &[ + (1, "Top".into(), "top".into()), + (2, "Sub".into(), "sub".into()), + ], + "Contents", + 1, + 2, + ); + // Top bullet: 2-space margin; Sub bullet: margin + one nesting level. + assert!(toc.contains("\n - [[#top|Top]]\n"), "top margin: {toc:?}"); + assert!(toc.contains("\n - [[#sub|Sub]]\n"), "nested: {toc:?}"); } // ===== Links text generation ===== @@ -374,7 +403,7 @@ fn captions_honour_custom_header_and_level() { #[test] fn build_links_text_excludes_current_page() { let pages = vec!["A".to_string(), "Home".to_string(), "B".to_string()]; - let out = ops::build_links_text(&pages, "Generated Links", 1, Some("Home")); + let out = ops::build_links_text(&pages, "Generated Links", 1, Some("Home"), 0); let lines: Vec<&str> = out.lines().collect(); assert_eq!(lines[0], "= Generated Links ="); assert!(lines.contains(&"- [[A]]")); @@ -385,7 +414,7 @@ fn build_links_text_excludes_current_page() { #[test] fn build_links_text_no_excludes_includes_all() { let pages = vec!["A".to_string(), "B".to_string()]; - let out = ops::build_links_text(&pages, "Generated Links", 1, None); + let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0); assert!(out.contains("[[A]]")); assert!(out.contains("[[B]]")); } @@ -423,7 +452,7 @@ fn toc_edit_inserts_at_top_when_no_existing_toc() { let src = "= One =\n== Two ==\n"; let ast = parse(src); let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1).expect("got an edit"); + let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0).expect("got an edit"); let changes = edit.changes.expect("changes map"); let edits = &changes[&uri]; assert_eq!(edits.len(), 1); @@ -439,7 +468,7 @@ fn toc_edit_replaces_existing_toc() { let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n"; let ast = parse(src); let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1).expect("got an edit"); + let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0).expect("got an edit"); let changes = edit.changes.expect("changes map"); let edits = &changes[&uri]; let te = &edits[0]; @@ -454,7 +483,7 @@ fn toc_edit_returns_none_for_empty_doc() { let src = ""; let ast = parse(src); let uri = Url::parse("file:///tmp/empty.wiki").unwrap(); - assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1).is_none()); + assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0).is_none()); } #[test] @@ -465,14 +494,15 @@ fn toc_rebuild_edit_only_acts_when_toc_already_present() { let without = "= One =\n== Two ==\n"; let ast = parse(without); assert!( - ops::toc_rebuild_edit(without, &ast, &uri, true, "Contents", 1).is_none(), + ops::toc_rebuild_edit(without, &ast, &uri, true, "Contents", 1, 0).is_none(), "auto_toc should not insert a TOC where none existed" ); // Existing TOC → rebuild refreshes it. let with = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n"; let ast = parse(with); - let edit = ops::toc_rebuild_edit(with, &ast, &uri, true, "Contents", 1).expect("rebuild edit"); + let edit = + ops::toc_rebuild_edit(with, &ast, &uri, true, "Contents", 1, 0).expect("rebuild edit"); let te = &edit.changes.unwrap()[&uri][0]; assert!(te.new_text.contains("[[#real|Real]]")); assert!(!te.new_text.contains("Stale")); @@ -486,8 +516,18 @@ fn links_edit_inserts_when_section_absent() { let ast = parse(src); let uri = Url::parse("file:///tmp/page.wiki").unwrap(); let pages = vec!["A".into(), "B".into(), "Home".into()]; - let edit = - ops::links_edit(src, &ast, &uri, "Home", &pages, true, "Generated Links", 1).expect("edit"); + let edit = ops::links_edit( + src, + &ast, + &uri, + "Home", + &pages, + true, + "Generated Links", + 1, + 0, + ) + .expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; assert!(te.new_text.contains("[[A]]")); assert!(te.new_text.contains("[[B]]")); @@ -500,8 +540,18 @@ fn links_edit_replaces_when_section_present() { let ast = parse(src); let uri = Url::parse("file:///tmp/page.wiki").unwrap(); let pages = vec!["Fresh".into()]; - let edit = - ops::links_edit(src, &ast, &uri, "Home", &pages, true, "Generated Links", 1).expect("edit"); + let edit = ops::links_edit( + src, + &ast, + &uri, + "Home", + &pages, + true, + "Generated Links", + 1, + 0, + ) + .expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; assert!(te.new_text.contains("[[Fresh]]")); assert!(!te.new_text.contains("Stale")); @@ -513,7 +563,7 @@ fn links_edit_returns_none_for_empty_page_list() { let src = "Hi\n"; let ast = parse(src); let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - assert!(ops::links_edit(src, &ast, &uri, "Home", &[], true, "Generated Links", 1).is_none()); + assert!(ops::links_edit(src, &ast, &uri, "Home", &[], true, "Generated Links", 1, 0).is_none()); } // ===== find_orphans ===== diff --git a/development/tests/test-keymaps-vim.vim b/development/tests/test-keymaps-vim.vim index 9475498..ce7ff62 100644 --- a/development/tests/test-keymaps-vim.vim +++ b/development/tests/test-keymaps-vim.vim @@ -669,6 +669,26 @@ call s:record( \ 'cmd.VimwikiListChangeLvl_accepts_range_and_args', \ 'err=' . s:lcl_err) +" ===== :VimwikiRemoveDone -bang -range ===== +" Upstream is `-range`; nuwiki had `-bang` only, so `:'<,'>VimwikiRemoveDone` +" raised E481. Now `-bang -range`: a ranged invocation must parse (no E481), +" and the `!` whole-buffer form must still work. The removal is an LSP +" roundtrip (no server here), so assert only the attribute parse. +call s:set_buf(['- [X] a', '- [ ] b', '- [X] c']) +let s:rd_err = '' +try + silent 1,2VimwikiRemoveDone + silent VimwikiRemoveDone! +catch /E481/ + let s:rd_err = v:exception +catch + " non-E481 (e.g. no LSP attached) is expected and fine here. +endtry +call s:record( + \ s:rd_err ==# '' ? 1 : 0, + \ 'cmd.VimwikiRemoveDone_accepts_range_and_bang', + \ 'err=' . s:rd_err) + " ===== :VimwikiToggleRejectedListItem -range ===== " Was bare (range-less): `:1,3VimwikiToggleRejectedListItem` raised E481. The " reject is an LSP roundtrip (no server here), so assert only the attribute diff --git a/development/tests/test-keymaps.lua b/development/tests/test-keymaps.lua index 789b578..3969c37 100644 --- a/development/tests/test-keymaps.lua +++ b/development/tests/test-keymaps.lua @@ -824,6 +824,21 @@ vim.defer_fn(function() error('range reject incomplete: ' .. vim.inspect(vim.api.nvim_buf_get_lines(0, 0, -1, false))) end end) + -- `:1,2VimwikiRemoveDone` — now -range (was -bang only → E481 on a ranged + -- invocation). Only done items within the range are removed; line-2 `[X]` + -- (0-indexed) outside [0,1] survives. + tobj_case('cmd.remove_done_range', function() + set_buf({ '- [X] a', '- [ ] b', '- [X] c' }) + vim.cmd('1,2VimwikiRemoveDone') + local done = vim.wait(2000, function() + local ls = vim.api.nvim_buf_get_lines(0, 0, -1, false) + -- line-0 done item gone (2 lines left), line-2 done item still present. + return #ls == 2 and ls[#ls]:match('%[X%] c') + end, 30) + if not done then + error('ranged remove-done wrong: ' .. vim.inspect(vim.api.nvim_buf_get_lines(0, 0, -1, false))) + end + end) -- `:VimwikiTable {cols} [rows]` — now -nargs=* (was -nargs=1, which dropped -- the args entirely). A 4-col table has 5 pipes per row; 3 data rows give -- header + separator + 3 = 5 table lines. diff --git a/development/vimwiki-gap.md b/development/vimwiki-gap.md index 43f21f1..e1cfdeb 100644 --- a/development/vimwiki-gap.md +++ b/development/vimwiki-gap.md @@ -330,14 +330,20 @@ fix site. `gln`/`glp` siblings (the keymap-range question is a separate, family-wide item). Covered by `cmd.reject_list_item_range` (`test-keymaps.lua`) + `cmd.VimwikiToggleRejectedListItem_accepts_range` (`test-keymaps-vim.vim`). -- [ ] **`VimwikiRemoveDone` dropped upstream's `-range`** _(new, 2026-06-02 - re-audit)_ — upstream is `-range` (`ftplugin/vimwiki.vim:362`, "remove done - items in the selection"); nuwiki repurposed it to `-bang` only (the documented - redesign at the top of this doc), so `:'<,'>VimwikiRemoveDone` raises E481. The - `-bang` whole-buffer sweep is intentional; the lost `-range`/E481 is an - undocumented side effect. Low priority — decide whether to also accept a range - (filter the sweep to selected lines) or just note it as part of the documented - divergence. +- [x] **`VimwikiRemoveDone` regained upstream's `-range`** _(new, 2026-06-02 + re-audit; fixed 2026-06-02)_ — upstream is `-range` (`ftplugin/vimwiki.vim:362`, + "remove done items in the selection"); nuwiki had repurposed it to `-bang` only, + so `:'<,'>VimwikiRemoveDone` raised E481. _Fix:_ all four defs are now + `-bang -range`, dispatched by `nuwiki#commands#remove_done` / + `commands.remove_done(bang, range, l1, l2)` (both clients): `!` → whole-buffer + sweep (unchanged); an explicit range (` > 0`, e.g. `:'<,'>`) → a new + `list_remove_done_range` sending `{range:[l1-1,l2-1]}`; otherwise the + cursor's current list. Server `remove_done_edit` gained an `Option<(u32,u32)>` + range that filters whole-doc victims by start line (`commands.rs`). Tests: + `remove_done_ranged_restricts_to_selected_lines` + + `remove_done_ranged_returns_none_when_range_has_no_done` (`commands_lists.rs`), + `cmd.remove_done_range` (`test-keymaps.lua`), + `cmd.VimwikiRemoveDone_accepts_range_and_bang` (`test-keymaps-vim.vim`). - [x] **`-complete=` specs** _(new; done 2026-05-31 command-attribute pass)_ — upstream attaches command-line completion to several commands; nuwiki had none, so `` at the `:` line never completed page / tag / colour names. @@ -382,36 +388,50 @@ fix site. `cmd.VimwikiSplitLink_accepts_args` (`test-keymaps-vim.vim`, no E488). ### Config -- [ ] **`list_margin` — wrong semantics + wrong markdown default** _(new, - 2026-06-02 re-audit)_ — upstream (`vars.vim:516`, doc `vimwiki.txt:2667`): - left-margin **width in spaces for buffer-side generated lists** — generated - links (`:VimwikiGenerateLinks`), the TOC, and the list-manipulation commands — - default `-1` (⇒ use `'shiftwidth'`) but **`0` for markdown** wikis - (`vars.vim:651`). nuwiki instead applies it **only** as an HTML CSS - `margin-left:em` on the outermost `
        /
          ` (`html.rs:337`) and never - indents the generated buffer content; it also defaults `-1` for all syntaxes - (`config.rs:417`), so markdown wikis get the wrong default. Two parts: - (a) the documented primary effect (buffer indentation in `build_links_text` / - TOC / `diary::build_index_body`) is absent; (b) derive `0` when - `syntax == "markdown"` and the key is unset. _(The HTML `em` reinterpretation - may have been deliberate — decide: implement buffer-side indent + markdown-0, - or keep the HTML meaning and document the divergence.)_ -- [ ] **`diary_months` — absent (month names hardcoded)** _(new, 2026-06-02 - re-audit)_ — upstream global (`vars.vim:141`) maps month numbers → names for - the generated diary-index tree; nuwiki hardcodes English in `diary.rs` - `month_name()`. i18n only — low. _Fix:_ optional `diary_months` map on - `RawWiki`/`WikiConfig`, threaded into `build_index_body`'s month labels. -- [ ] **`markdown_header_style` — absent** _(new, 2026-06-02 re-audit)_ — - upstream global (`vars.vim:174`, default `1`): number of blank lines inserted - after a **generated** header in markdown syntax (generated links, TOC, tags, - diary index). nuwiki's `caption_line` (`commands.rs`) emits no configurable - trailing blank line. Cosmetic, markdown-only. -- [ ] **`diary_caption_level = -1` not representable** _(new, 2026-06-02 - re-audit)_ — upstream allows `-1` ("read no headers from diary pages", - `vars.vim:507` `min: -1`); nuwiki types the field as `u8` (`config.rs`, - `RawWiki`), so a `-1` config fails deserialization and silently falls back to - `0`. The *default* (`0`) is correct — only the `-1` value is lost. _Fix:_ widen - to `i8` (and honour `-1` if/when caption-from-page-headers lands). Edge case. +- [x] **`list_margin` — buffer-side parity + markdown default** _(new, + 2026-06-02 re-audit; fixed 2026-06-02)_ — upstream (`vars.vim:516`, doc + `vimwiki.txt:2667`): left-margin **width in spaces for buffer-side generated + lists** — generated links (`:VimwikiGenerateLinks`), the TOC, the tags index, + and the diary index — default `-1` but **`0` for markdown** wikis + (`vars.vim:651`). nuwiki had instead applied it **only** as an HTML CSS + `margin-left:em`. _Fix (true-parity, chosen over keeping the HTML meaning):_ + dropped the HTML `em`-margin entirely (renderer `list_margin` field/method + + the `render_page_html` param removed) and now prepend `max(0, list_margin)` + leading spaces to every generated bullet in `build_toc_text` / + `build_links_text` / `build_tag_links_text` / `diary::build_index_body` (the + TOC keeps its per-depth nesting after the base margin); headings stay at + column 0. `From` derives `0` for markdown wikis when the key is unset. + **Divergence (documented in the field doc):** upstream resolves a negative + value to the buffer's `'shiftwidth'`, which the server can't observe, so + negatives collapse to zero indent (matches nuwiki's prior default output). + Tests: `list_margin_indents_generated_bullets_not_headings` (`link_health.rs`), + `markdown_wiki_derives_list_margin_zero_when_unset` (`index_and_config.rs`). +- [x] **`diary_months`** _(new, 2026-06-02 re-audit; fixed 2026-06-02)_ — + upstream global (`vars.vim:141`) maps month numbers → names for the generated + diary-index tree; nuwiki hardcoded English. _Fix:_ per-wiki `diary_months: + Vec` (default the 12 English names) on `WikiConfig`/`RawWiki`, threaded + into `build_index_body`; a missing/empty slot falls back to the English + `month_name`. Tests: `build_index_body_uses_custom_diary_months` + + `…_falls_back_per_missing_month_slot` (`diary.rs`), config round-trip + (`index_and_config.rs`). +- [ ] **`markdown_header_style`** _(new, 2026-06-02 re-audit; deferred)_ — + upstream global (`vars.vim:174`, default `1`): blank lines after a **generated** + header in markdown syntax. **Intentionally deferred:** nuwiki's generators + emit vimwiki syntax only (`caption_line` always writes `= H =`, never `# H`), + so there's no markdown header to attach the style to yet. This is part of a + broader "markdown generated-content" gap — markdown-style heading generation + comes first, then this blank-line control on top. See the divergence note + below. +- [x] **`diary_caption_level = -1` now representable** _(new, 2026-06-02 + re-audit; fixed 2026-06-02)_ — upstream allows `-1` (`vars.vim:507` `min: -1`); + nuwiki had typed the field as `u8`, so a `-1` config failed deserialization + and silently fell back to `0`. _Fix:_ widened to `i8` across + `WikiConfig`/`RawWiki`/`WikiDefaults`; `build_index_body` clamps `< 0` to a + base tree level of `0`. (nuwiki builds the index tree from dates, so upstream's + `-1` = "no per-page captions" semantic doesn't apply — it just clamps; noted in + the field doc.) Tests: `diary_caption_level_accepts_negative` + (`index_and_config.rs`), `build_index_body_clamps_negative_caption_level_to_zero` + (`diary.rs`). - [ ] `auto_header` — auto H1-from-filename on new page (server-side). - [ ] `create_link` — toggle to suppress link-target creation. - [ ] `dir_link` — index file to open when following a directory link. @@ -521,6 +541,16 @@ audits don't re-flag them. → `diary` vs upstream `diary/` (trailing slash only — paths are joined identically, so behaviour matches). Listed so future audits stop re-flagging them; users can still set any of these explicitly. +- **Generated-content syntax is vimwiki-only (for now)** _(noted 2026-06-02 + re-audit)_ — the buffer-side generators (TOC, generated links, tags index, + diary index) always emit vimwiki markup (`= H =` headings via `caption_line`), + regardless of the wiki's `syntax`. Consequently the markdown-specific + `markdown_header_style` (blank lines after a generated *markdown* header) has + nothing to attach to and is deferred. This is a deliberate scoping choice, not + an oversight: markdown generated-content (markdown-style headings, then + `markdown_header_style`, `markdown_link_ext` in generated links) is planned as + a single later effort. Until then, markdown wikis get vimwiki-syntax generated + sections. --- @@ -558,3 +588,10 @@ audits don't re-flag them. default output-location derivations (`path_html`/`template_path`/ `diary_rel_path`) recorded as intentional divergences. No previously-closed item regressed. +- Fixed the fourth-pass findings (2026-06-02), in order: `VimwikiRemoveDone` + `-range`; `list_margin` reworked to upstream's buffer-side meaning (HTML + `em`-margin dropped) + markdown-`0` default; `diary_months`; + `diary_caption_level` widened to `i8`. `markdown_header_style` deferred as part + of the documented "generated-content is vimwiki-only" divergence. All five + P1-blocking sections remain clear; only niche P3 items (and the deferred + markdown work) are left. diff --git a/ftplugin/vimwiki.vim b/ftplugin/vimwiki.vim index ddafe19..35968a1 100644 --- a/ftplugin/vimwiki.vim +++ b/ftplugin/vimwiki.vim @@ -84,7 +84,7 @@ if !has('nvim') command! -buffer -range -nargs=1 VimwikiChangeSymbolTo call nuwiki#commands#list_change_symbol_range(, , ) command! -buffer -range -nargs=1 VimwikiListChangeSymbolI call nuwiki#commands#list_change_symbol_range(, , ) command! -buffer -nargs=1 VimwikiChangeSymbolInListTo call nuwiki#commands#list_change_symbol(, 1) - command! -buffer -bang VimwikiRemoveDone if 0 | call nuwiki#commands#list_remove_done_all() | else | call nuwiki#commands#list_remove_done() | endif + command! -buffer -bang -range VimwikiRemoveDone call nuwiki#commands#remove_done(0, , , ) command! -buffer -range VimwikiRemoveSingleCB call nuwiki#commands#list_remove_checkbox() command! -buffer VimwikiRemoveCBInList call nuwiki#commands#list_remove_checkbox_in_list() command! -buffer -range -nargs=+ VimwikiListChangeLvl call nuwiki#commands#list_change_lvl(, , ) @@ -170,7 +170,7 @@ if !has('nvim') command! -buffer Nuwiki2HTMLBrowse call nuwiki#commands#export_browse() command! -buffer -bang NuwikiAll2HTML if 0 | call nuwiki#commands#export_all_force() | else | call nuwiki#commands#export_all() | endif - command! -buffer -bang NuwikiRemoveDone if 0 | call nuwiki#commands#list_remove_done_all() | else | call nuwiki#commands#list_remove_done() | endif + command! -buffer -bang -range NuwikiRemoveDone call nuwiki#commands#remove_done(0, , , ) command! -buffer -range NuwikiRemoveCheckbox call nuwiki#commands#list_remove_checkbox() command! -buffer NuwikiRemoveCheckboxInList call nuwiki#commands#list_remove_checkbox_in_list() command! -buffer -range -nargs=+ NuwikiListChangeLvl call nuwiki#commands#list_change_lvl(, , ) @@ -433,7 +433,7 @@ command! -buffer -range VimwikiDecrementListItem lua require('nuwiki.c command! -buffer -range -nargs=1 VimwikiChangeSymbolTo lua require('nuwiki.commands').list_change_symbol_range(, , ) command! -buffer -range -nargs=1 VimwikiListChangeSymbolI lua require('nuwiki.commands').list_change_symbol_range(, , ) command! -buffer -nargs=1 VimwikiChangeSymbolInListTo lua require('nuwiki.commands').list_change_symbol(, true) -command! -buffer -bang VimwikiRemoveDone lua require('nuwiki.commands')[('' == '!') and 'list_remove_done_all' or 'list_remove_done']() +command! -buffer -bang -range VimwikiRemoveDone lua require('nuwiki.commands').remove_done('' == '!', , , ) command! -buffer -range VimwikiRemoveSingleCB lua require('nuwiki.commands').list_remove_checkbox() command! -buffer VimwikiRemoveCBInList lua require('nuwiki.commands').list_remove_checkbox_in_list() command! -buffer -range -nargs=+ VimwikiListChangeLvl lua require('nuwiki.commands').list_change_lvl(, , ) @@ -519,7 +519,7 @@ command! -buffer -nargs=* NuwikiSplitLink split | lua require('nuwiki.c command! -buffer -nargs=* NuwikiVSplitLink vsplit | lua require('nuwiki.commands').follow_link_or_create() command! -buffer NuwikiTabnewLink tabnew | lua require('nuwiki.commands').follow_link_or_create() command! -buffer NuwikiTabDropLink lua require('nuwiki.commands').follow_link_drop() -command! -buffer -bang NuwikiRemoveDone lua require('nuwiki.commands')[('' == '!') and 'list_remove_done_all' or 'list_remove_done']() +command! -buffer -bang -range NuwikiRemoveDone lua require('nuwiki.commands').remove_done('' == '!', , , ) command! -buffer -range NuwikiRemoveCheckbox lua require('nuwiki.commands').list_remove_checkbox() command! -buffer NuwikiRemoveCheckboxInList lua require('nuwiki.commands').list_remove_checkbox_in_list() command! -buffer -range -nargs=+ NuwikiListChangeLvl lua require('nuwiki.commands').list_change_lvl(, , ) diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua index 689c7bd..6756228 100644 --- a/lua/nuwiki/commands.lua +++ b/lua/nuwiki/commands.lua @@ -566,6 +566,25 @@ function M.list_remove_done_all() exec('nuwiki.list.removeDone', uri_args()) end +-- `:'<,'>VimwikiRemoveDone` — remove done items only within the given line +-- range (1-indexed, inclusive; converted to the server's 0-indexed range). +function M.list_remove_done_range(l1, l2) + exec('nuwiki.list.removeDone', { { uri = buf_uri(), range = { l1 - 1, l2 - 1 } } }) +end + +-- `:[range]VimwikiRemoveDone[!]` dispatcher. `!` → whole buffer; an explicit +-- range (`range > 0`, e.g. `:'<,'>`) → that line span; otherwise the current +-- list under the cursor. Mirrors upstream's `-bang -range` form. +function M.remove_done(bang, range, l1, l2) + if bang then + M.list_remove_done_all() + elseif range and range > 0 then + M.list_remove_done_range(l1, l2) + else + M.list_remove_done() + end +end + -- `:VimwikiRemoveSingleCB` — strip the checkbox from the current item only. function M.list_remove_checkbox() exec('nuwiki.list.removeCheckbox', pos_args())