From f0c51fbfcc13ecb6d0ae2b407df42ebf037ac83f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Fri, 5 Jun 2026 01:07:18 +0000 Subject: [PATCH] feat(generate): insert links/tags sections at the cursor line too Extend the cursor-line insertion from :NuwikiTOC to the other buffer list generators: :NuwikiGenerateLinks and :NuwikiGenerateTagLinks. They appended at end-of-file; a fresh section now goes at the cursor line (with a leading blank), matching :NuwikiTOC. An existing section is still refreshed in place, and the auto_generate_*-on-save hooks are unaffected (they only ever replace). Shared section_insert() helper computes the insert position/block for all three (cursor line clamped to the document, else the per-command fallback: top of file for TOC, EOF for links/tags). Clients send the 0-based cursor line; handlers parse it via parse_uri_line_arg and thread it through links_edit / tag_links_edit. Rebuild variants pass None. Tests: links_edit_inserts_at_cursor_line + tag_links_edit_inserts_at_cursor_line. 577 passed, clippy clean, keymap harnesses green. Docs updated. Co-Authored-By: Claude Opus 4.8 --- autoload/nuwiki/commands.vim | 10 +- crates/nuwiki-lsp/src/commands.rs | 118 ++++++++++++------- crates/nuwiki-lsp/tests/commands_coverage.rs | 4 +- crates/nuwiki-lsp/tests/commands_tags.rs | 51 +++++++- crates/nuwiki-lsp/tests/link_health.rs | 34 ++++++ doc/nuwiki.txt | 7 +- lua/nuwiki/commands.lua | 11 +- 7 files changed, 180 insertions(+), 55 deletions(-) diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index 22205a2..01b1367 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -734,11 +734,14 @@ endfunction " `generateForPath`). Matches upstream's optional path argument. function! nuwiki#commands#links_generate(...) abort let l:path = a:0 >= 1 ? a:1 : '' + " Cursor line (0-based) so a fresh section is inserted there. + let l:line = line('.') - 1 if l:path !=# '' call s:exec('nuwiki.links.generateForPath', - \ [{'uri': s:buf_uri(), 'path': l:path}]) + \ [{'uri': s:buf_uri(), 'path': l:path, 'line': l:line}]) else - call s:exec('nuwiki.links.generate', [{'uri': s:buf_uri()}]) + call s:exec('nuwiki.links.generate', + \ [{'uri': s:buf_uri(), 'line': l:line}]) endif endfunction function! nuwiki#commands#check_links(...) abort @@ -800,7 +803,8 @@ function! nuwiki#commands#tags_search(query) abort endfunction function! nuwiki#commands#tags_generate_links(tag) abort - let l:args = { 'uri': s:buf_uri() } + " Cursor line (0-based) so a fresh section is inserted there. + let l:args = { 'uri': s:buf_uri(), 'line': line('.') - 1 } if a:tag !=# '' let l:args['tag'] = a:tag endif diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 7f4f8f0..e2959ee 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -304,6 +304,24 @@ fn parse_uri_arg(args: Vec) -> Result { serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}")) } +/// Parse `{ uri, line? }` for the section generators (TOC / links / tags). The +/// optional 0-based `line` is the cursor line the client sends, where a fresh +/// section is inserted. +fn parse_uri_line_arg(args: Vec) -> Result<(Url, Option), String> { + #[derive(Deserialize)] + struct A { + uri: Url, + #[serde(default)] + line: Option, + } + let raw = args + .into_iter() + .next() + .ok_or_else(|| "missing { uri } argument".to_string())?; + let a: A = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; + Ok((a.uri, a.line)) +} + #[derive(Deserialize, Default)] struct OptionalUriArg { #[serde(default)] @@ -323,23 +341,13 @@ fn parse_optional_uri_arg(args: Vec) -> Result { } fn toc_generate(backend: &Backend, args: Vec) -> Result, String> { - #[derive(Deserialize)] - struct TocArg { - uri: Url, - /// 0-based cursor line; a fresh TOC is inserted here. - #[serde(default)] - line: Option, - } - let p: TocArg = match args.into_iter().next() { - Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}"))?, - None => return Err("missing { uri } argument".to_string()), - }; - let doc = match backend.documents.get(&p.uri) { + let (uri, cursor_line) = parse_uri_line_arg(args)?; + let doc = match backend.documents.get(&uri) { Some(d) => d, None => return Ok(None), }; let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let wiki = backend.wiki_for_uri(&p.uri); + let wiki = backend.wiki_for_uri(&uri); let (header, level) = wiki .as_ref() .map(|w| (w.config.toc_header.clone(), w.config.toc_header_level)) @@ -352,28 +360,28 @@ fn toc_generate(backend: &Backend, args: Vec) -> Result) -> Result, String> { - let p = parse_uri_arg(args)?; - let doc = match backend.documents.get(&p.uri) { + let (uri, cursor_line) = parse_uri_line_arg(args)?; + let doc = match backend.documents.get(&uri) { Some(d) => d, None => return Ok(None), }; let utf8 = backend.use_utf8.load(Ordering::Relaxed); - let wiki = match backend.wiki_for_uri(&p.uri) { + let wiki = match backend.wiki_for_uri(&uri) { Some(w) => w, None => return Ok(None), }; - let current_page = crate::index::page_name_from_uri(&p.uri, Some(&wiki.config.root)); + let current_page = crate::index::page_name_from_uri(&uri, Some(&wiki.config.root)); let (pages, captions) = { let idx = wiki .index @@ -388,7 +396,7 @@ fn links_generate(backend: &Backend, args: Vec) -> Result) -> Result, + /// 0-based cursor line; a fresh tags section is inserted here. + #[serde(default)] + line: Option, } let raw = args .into_iter() @@ -664,6 +676,7 @@ fn tags_generate_links( &wiki.config.tags_header, wiki.config.tags_header_level, wiki.config.list_margin.max(0) as usize, + parsed.line, )) } @@ -1370,6 +1383,32 @@ fn eof_position(text: &str) -> LspPosition { } } +/// Decide where to insert a generated section (TOC / generated links / tags +/// index) and the text block to write. When the client passed a cursor +/// `insert_line`, insert at that line (clamped to the document; a leading blank +/// keeps the heading off the preceding line, except at the top of the file). +/// Otherwise fall back to `fallback` — top of file for the TOC, end of file for +/// the links/tags sections. +fn section_insert( + text: &str, + insert_line: Option, + fallback: LspPosition, + new_text: &str, +) -> (LspPosition, String) { + match insert_line { + Some(l) => { + let line = l.min(text.matches('\n').count() as u32); + let block = if line == 0 { + format!("{new_text}\n") + } else { + format!("\n{new_text}\n") + }; + (LspPosition { line, character: 0 }, block) + } + None => (fallback, format!("{new_text}\n")), + } +} + // ===== Pure operations ===== pub mod ops { @@ -2104,22 +2143,13 @@ pub mod ops { b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8)); } None => { - // Insert at the cursor line (clamped to the document), or the - // top of the file when the client didn't send one. - let line = insert_line - .unwrap_or(0) - .min(text.matches('\n').count() as u32); - // A blank line keeps the TOC heading from abutting preceding - // text — not needed at the very top of the file. - let block = if line == 0 { - format!("{new_text}\n") - } else { - format!("\n{new_text}\n") + // Fresh TOC: at the cursor line, else the top of the file. + let top = LspPosition { + line: 0, + character: 0, }; - b.edit( - uri.clone(), - text_edit_insert(LspPosition { line, character: 0 }, block), - ); + let (pos, block) = section_insert(text, insert_line, top, &new_text); + b.edit(uri.clone(), text_edit_insert(pos, block)); } } Some(b.build()) @@ -2170,6 +2200,7 @@ pub mod ops { level: u8, margin: usize, captions: Option<&std::collections::BTreeMap>, + insert_line: Option, ) -> Option { if all_pages.is_empty() { return None; @@ -2189,9 +2220,9 @@ pub mod ops { b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8)); } None => { - let with_sep = format!("{new_text}\n"); - let insert_pos = eof_position(text); - b.edit(uri.clone(), text_edit_insert(insert_pos, with_sep)); + // Fresh section: at the cursor line, else the end of the file. + let (pos, block) = section_insert(text, insert_line, eof_position(text), &new_text); + b.edit(uri.clone(), text_edit_insert(pos, block)); } } Some(b.build()) @@ -2214,6 +2245,7 @@ pub mod ops { captions: Option<&std::collections::BTreeMap>, ) -> Option { find_section_range(ast, heading)?; + // Rebuild-on-save only ever replaces an existing section in place. links_edit( text, ast, @@ -2225,6 +2257,7 @@ pub mod ops { level, margin, captions, + None, ) } @@ -3447,6 +3480,7 @@ pub mod ops { header: &str, level: u8, margin: usize, + insert_line: Option, ) -> Option { let body = build_tag_links_text(pages_by_tag, tag, header, level, margin)?; let heading = tag_section_heading(tag, header); @@ -3457,9 +3491,9 @@ pub mod ops { b.edit(uri.clone(), text_edit_replace(span, body, text, utf8)); } None => { - let with_sep = format!("{body}\n"); - let insert_pos = eof_position(text); - b.edit(uri.clone(), text_edit_insert(insert_pos, with_sep)); + // Fresh section: at the cursor line, else the end of the file. + let (pos, block) = section_insert(text, insert_line, eof_position(text), &body); + b.edit(uri.clone(), text_edit_insert(pos, block)); } } Some(b.build()) @@ -3480,6 +3514,7 @@ pub mod ops { margin: usize, ) -> Option { find_section_range(ast, header)?; + // Rebuild-on-save only ever replaces an existing section in place. tag_links_edit( text, ast, @@ -3490,6 +3525,7 @@ pub mod ops { header, level, margin, + None, ) } diff --git a/crates/nuwiki-lsp/tests/commands_coverage.rs b/crates/nuwiki-lsp/tests/commands_coverage.rs index 2f01e4a..f2e30ea 100644 --- a/crates/nuwiki-lsp/tests/commands_coverage.rs +++ b/crates/nuwiki-lsp/tests/commands_coverage.rs @@ -348,6 +348,7 @@ fn nuwiki_generate_links_lists_all_pages_excluding_current() { "Generated Links", 1, 0, + None, None ) .is_some()); @@ -430,7 +431,8 @@ fn nuwiki_generate_tag_links_builds_section() { true, "Generated Tags", 1, - 0 + 0, + None ) .is_some()); } diff --git a/crates/nuwiki-lsp/tests/commands_tags.rs b/crates/nuwiki-lsp/tests/commands_tags.rs index 8ec9fad..81057d7 100644 --- a/crates/nuwiki-lsp/tests/commands_tags.rs +++ b/crates/nuwiki-lsp/tests/commands_tags.rs @@ -255,6 +255,7 @@ fn tag_links_edit_inserts_when_section_missing() { "Generated Tags", 1, 0, + None, ) .expect("got an edit"); let te = &edit.changes.unwrap()[&uri][0]; @@ -264,6 +265,37 @@ fn tag_links_edit_inserts_when_section_missing() { assert!(te.new_text.contains("[[Alpha]]")); } +#[test] +fn tag_links_edit_inserts_at_cursor_line() { + // A fresh tags section goes at the cursor line, not the EOF. + let src = "= Notes =\nbody\nmore\n"; + let ast = parse(src); + let uri = Url::parse("file:///tmp/p.wiki").unwrap(); + let mut snap = BTreeMap::new(); + snap.insert("release".to_string(), vec!["Alpha".into()]); + let edit = ops::tag_links_edit( + src, + &ast, + &uri, + Some("release"), + &snap, + true, + "Generated Tags", + 1, + 0, + Some(1), + ) + .expect("edit"); + let te = &edit.changes.unwrap()[&uri][0]; + assert_eq!(te.range.start.line, 1); + assert_eq!(te.range.start.character, 0); + assert!( + te.new_text.starts_with("\n= Tag: release ="), + "got: {:?}", + te.new_text + ); +} + #[test] fn tag_links_edit_replaces_existing_section() { let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\n"; @@ -281,6 +313,7 @@ fn tag_links_edit_replaces_existing_section() { "Generated Tags", 1, 0, + None, ) .expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; @@ -296,8 +329,19 @@ 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, 0) - .expect("edit"); + let edit = ops::tag_links_edit( + src, + &ast, + &uri, + None, + &snap, + true, + "Generated Tags", + 1, + 0, + None, + ) + .expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; assert!(te.new_text.contains("== alpha ==")); assert!(!te.new_text.contains("[[old]]")); @@ -352,7 +396,8 @@ fn tag_links_edit_returns_none_for_unknown_tag() { true, "Generated Tags", 1, - 0 + 0, + None ) .is_none()); } diff --git a/crates/nuwiki-lsp/tests/link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs index ed0dc65..2871685 100644 --- a/crates/nuwiki-lsp/tests/link_health.rs +++ b/crates/nuwiki-lsp/tests/link_health.rs @@ -597,6 +597,7 @@ fn links_edit_inserts_when_section_absent() { 1, 0, None, + None, ) .expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; @@ -605,6 +606,37 @@ fn links_edit_inserts_when_section_absent() { assert!(!te.new_text.contains("[[Home]]")); } +#[test] +fn links_edit_inserts_at_cursor_line() { + // A fresh Generated Links section goes at the cursor line, not the EOF. + let src = "= Home =\nbody\nmore\n"; + let ast = parse(src); + let uri = Url::parse("file:///tmp/page.wiki").unwrap(); + let pages = vec!["A".into()]; + let edit = ops::links_edit( + src, + &ast, + &uri, + "Home", + &pages, + true, + "Generated Links", + 1, + 0, + None, + Some(1), + ) + .expect("edit"); + let te = &edit.changes.unwrap()[&uri][0]; + assert_eq!(te.range.start.line, 1); + assert_eq!(te.range.start.character, 0); + assert!( + te.new_text.starts_with("\n= Generated Links ="), + "got: {:?}", + te.new_text + ); +} + #[test] fn links_edit_replaces_when_section_present() { let src = "= Generated Links =\n- [[Stale]]\n\n= Real =\n"; @@ -622,6 +654,7 @@ fn links_edit_replaces_when_section_present() { 1, 0, None, + None, ) .expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; @@ -645,6 +678,7 @@ fn links_edit_returns_none_for_empty_page_list() { "Generated Links", 1, 0, + None, None ) .is_none()); diff --git a/doc/nuwiki.txt b/doc/nuwiki.txt index afc0eb0..2c12dda 100644 --- a/doc/nuwiki.txt +++ b/doc/nuwiki.txt @@ -530,7 +530,8 @@ Page generation ~ *:NuwikiGenerateLinks* :NuwikiGenerateLinks - Insert a flat list of every page in the wiki under the cursor. + Insert a flat list of every page in the wiki. A new section is inserted at + the cursor line; an existing one is refreshed in place. *:NuwikiCheckLinks* :[range]NuwikiCheckLinks @@ -556,7 +557,9 @@ Tags ~ *:NuwikiGenerateTagLinks* :NuwikiGenerateTagLinks [tag] Insert a section linking every page that has ``. With no - argument, generates a section per tag found in the workspace. + argument, generates a section per tag found in the workspace. A new + section is inserted at the cursor line; an existing one is refreshed in + place. *:NuwikiRebuildTags* :NuwikiRebuildTags[!] diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua index 8d80707..a238157 100644 --- a/lua/nuwiki/commands.lua +++ b/lua/nuwiki/commands.lua @@ -521,12 +521,12 @@ end -- `:VimwikiGenerateLinks [rel_path]` — optional path scopes the links to a -- subtree via the server's generateForPath; no arg = every page. M.links_generate = function(path) + -- Cursor line (0-based) so a fresh section is inserted there. + local line = vim.api.nvim_win_get_cursor(0)[1] - 1 if path and path ~= '' then - local args = uri_args()[1] - args.path = path - exec('nuwiki.links.generateForPath', { args }) + exec('nuwiki.links.generateForPath', { { uri = buf_uri(), path = path, line = line } }) else - exec('nuwiki.links.generate', uri_args()) + exec('nuwiki.links.generate', { { uri = buf_uri(), line = line } }) end end @@ -606,7 +606,8 @@ function M.tags_search(query) end function M.tags_generate_links(tag) - local args = { uri = buf_uri() } + -- Cursor line (0-based) so a fresh section is inserted there. + local args = { uri = buf_uri(), line = vim.api.nvim_win_get_cursor(0)[1] - 1 } if tag and tag ~= '' then args.tag = tag end