From 94cb58064d070995a5e54e668a44e48c3e70eff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Fri, 5 Jun 2026 00:51:40 +0000 Subject: [PATCH] feat(toc): insert a fresh TOC at the cursor line :NuwikiTOC inserted a new table of contents at the top of the file. It now inserts at the cursor line instead, so you can place the TOC where you want it (a blank line separates it from preceding text). An existing TOC is still refreshed in place, and auto_toc-on-save is unaffected. The Vim/Lua clients send the 0-based cursor line with nuwiki.toc.generate; the server threads it into toc_edit (clamped to the document) and falls back to the top of the file when absent. toc_rebuild_edit passes None. Tests: toc_edit_inserts_at_cursor_line + toc_edit_cursor_line_clamped_and_existing_replaced_in_place. 575 passed, clippy clean, keymap harnesses green. Doc updated. Co-Authored-By: Claude Opus 4.8 --- autoload/nuwiki/commands.vim | 4 +- crates/nuwiki-lsp/src/commands.rs | 54 ++++++++++++++++---- crates/nuwiki-lsp/tests/commands_coverage.rs | 2 +- crates/nuwiki-lsp/tests/link_health.rs | 42 +++++++++++++-- doc/nuwiki.txt | 3 +- lua/nuwiki/commands.lua | 6 ++- 6 files changed, 94 insertions(+), 17 deletions(-) diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index d0cade0..22205a2 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -725,7 +725,9 @@ endfunction " ===== Generation + workspace ===== function! nuwiki#commands#toc_generate() abort - call s:exec('nuwiki.toc.generate', [{'uri': s:buf_uri()}]) + " Send the cursor line (0-based) so a fresh TOC is inserted there. + call s:exec('nuwiki.toc.generate', + \ [{'uri': s:buf_uri(), 'line': line('.') - 1}]) endfunction " `:VimwikiGenerateLinks [rel_path]` — with no arg, list every page; with a " rel-path arg, scope the generated links to that subtree (server-side diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 76e574f..7f4f8f0 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -323,7 +323,17 @@ fn parse_optional_uri_arg(args: Vec) -> Result { } fn toc_generate(backend: &Backend, args: Vec) -> Result, String> { - let p = parse_uri_arg(args)?; + #[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) { Some(d) => d, None => return Ok(None), @@ -348,6 +358,7 @@ fn toc_generate(backend: &Backend, args: Vec) -> Result, ) -> Option { let items = collect_toc_items(ast, heading); if items.is_empty() { @@ -2087,16 +2104,21 @@ pub mod ops { b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8)); } None => { - let with_sep = format!("{new_text}\n"); + // 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") + }; b.edit( uri.clone(), - text_edit_insert( - LspPosition { - line: 0, - character: 0, - }, - with_sep, - ), + text_edit_insert(LspPosition { line, character: 0 }, block), ); } } @@ -2119,7 +2141,19 @@ pub mod ops { link_format: u8, ) -> Option { find_section_range(ast, heading)?; - toc_edit(text, ast, uri, utf8, heading, level, margin, link_format) + // Rebuild-on-save only ever replaces an existing TOC in place, so the + // insert line is irrelevant. + toc_edit( + text, + ast, + uri, + utf8, + heading, + level, + margin, + link_format, + None, + ) } /// Produce a `WorkspaceEdit` that replaces or inserts the diff --git a/crates/nuwiki-lsp/tests/commands_coverage.rs b/crates/nuwiki-lsp/tests/commands_coverage.rs index 086a00d..2f01e4a 100644 --- a/crates/nuwiki-lsp/tests/commands_coverage.rs +++ b/crates/nuwiki-lsp/tests/commands_coverage.rs @@ -307,7 +307,7 @@ fn nuwiki_toc_generates_nested_contents() { 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, 0, 0).expect("toc edit produced"); + ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0, 0, None).expect("toc edit produced"); assert!(edit.changes.is_some() || edit.document_changes.is_some()); let toc = ops::build_toc_text( diff --git a/crates/nuwiki-lsp/tests/link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs index ae4b9cf..ed0dc65 100644 --- a/crates/nuwiki-lsp/tests/link_health.rs +++ b/crates/nuwiki-lsp/tests/link_health.rs @@ -484,7 +484,8 @@ 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, 0, 0).expect("got an edit"); + let edit = + ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).expect("got an edit"); let changes = edit.changes.expect("changes map"); let edits = &changes[&uri]; assert_eq!(edits.len(), 1); @@ -497,12 +498,47 @@ fn toc_edit_inserts_at_top_when_no_existing_toc() { assert!(te.new_text.contains("[[#One|One]]")); } +#[test] +fn toc_edit_inserts_at_cursor_line() { + // A fresh TOC goes at the cursor line (0-based) the client sends. + let src = "= One =\nbody text\n== Two ==\nmore\n"; + let ast = parse(src); + let uri = Url::parse("file:///tmp/page.wiki").unwrap(); + // Cursor on line 1 ("body text"). + let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, Some(1)).expect("edit"); + let te = &edit.changes.unwrap()[&uri][0]; + // Zero-width insert at line 1, column 0. + assert_eq!(te.range.start, te.range.end); + assert_eq!(te.range.start.line, 1); + assert_eq!(te.range.start.character, 0); + // A leading blank separates it from the preceding text. + assert!( + te.new_text.starts_with("\n= Contents ="), + "got: {:?}", + te.new_text + ); +} + +#[test] +fn toc_edit_cursor_line_clamped_and_existing_replaced_in_place() { + // An existing TOC is replaced in place regardless of the cursor line. + 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, 0, 0, Some(99)).expect("edit"); + let te = &edit.changes.unwrap()[&uri][0]; + // Replacement starts at line 0 (the existing section), not the cursor. + assert_eq!(te.range.start.line, 0); + assert!(te.new_text.contains("[[#Real|Real]]")); +} + #[test] 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, 0, 0).expect("got an edit"); + let edit = + ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).expect("got an edit"); let changes = edit.changes.expect("changes map"); let edits = &changes[&uri]; let te = &edits[0]; @@ -517,7 +553,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, 0, 0).is_none()); + assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).is_none()); } #[test] diff --git a/doc/nuwiki.txt b/doc/nuwiki.txt index b47a5cd..afc0eb0 100644 --- a/doc/nuwiki.txt +++ b/doc/nuwiki.txt @@ -525,7 +525,8 @@ Page generation ~ *:NuwikiTOC* :NuwikiTOC - Generate or refresh the table of contents on the current page. + Generate or refresh the table of contents on the current page. A new TOC + is inserted at the cursor line; an existing one is refreshed in place. *:NuwikiGenerateLinks* :NuwikiGenerateLinks diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua index 16674ed..8d80707 100644 --- a/lua/nuwiki/commands.lua +++ b/lua/nuwiki/commands.lua @@ -513,7 +513,11 @@ end -- ===== Generation + workspace ===== -M.toc_generate = function() exec('nuwiki.toc.generate', uri_args()) end +M.toc_generate = function() + -- Send the cursor line (0-based) so a fresh TOC is inserted there. + local line = vim.api.nvim_win_get_cursor(0)[1] - 1 + exec('nuwiki.toc.generate', { { uri = buf_uri(), line = line } }) +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)