feat(toc): insert a fresh TOC at the cursor line
CI / cargo fmt --check (push) Successful in 35s
CI / cargo clippy (push) Successful in 39s
CI / cargo test (push) Successful in 50s
CI / editor keymaps (push) Successful in 1m25s

: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 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 00:51:40 +00:00
parent 9d6d28a1b6
commit 94cb58064d
6 changed files with 94 additions and 17 deletions
+3 -1
View File
@@ -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
+44 -10
View File
@@ -323,7 +323,17 @@ fn parse_optional_uri_arg(args: Vec<Value>) -> Result<OptionalUriArg, String> {
}
fn toc_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, 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<u32>,
}
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<Value>) -> Result<Option<WorkspaceE
level,
margin,
link_format,
p.line,
))
}
@@ -2060,6 +2071,11 @@ 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).
///
/// When there's no existing TOC section, a fresh one is inserted at
/// `insert_line` (0-based) — the cursor line passed by the client — or at
/// the top of the file when `None`. An existing TOC is always replaced in
/// place, regardless of the cursor.
#[allow(clippy::too_many_arguments)]
pub fn toc_edit(
text: &str,
@@ -2070,6 +2086,7 @@ pub mod ops {
level: u8,
margin: usize,
link_format: u8,
insert_line: Option<u32>,
) -> Option<WorkspaceEdit> {
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<WorkspaceEdit> {
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
+1 -1
View File
@@ -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(
+39 -3
View File
@@ -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]
+2 -1
View File
@@ -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
+5 -1
View File
@@ -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)