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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user