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)