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 <noreply@anthropic.com>
This commit is contained in:
@@ -304,6 +304,24 @@ fn parse_uri_arg(args: Vec<Value>) -> Result<UriArg, String> {
|
||||
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<Value>) -> Result<(Url, Option<u32>), String> {
|
||||
#[derive(Deserialize)]
|
||||
struct A {
|
||||
uri: Url,
|
||||
#[serde(default)]
|
||||
line: Option<u32>,
|
||||
}
|
||||
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<Value>) -> Result<OptionalUriArg, String> {
|
||||
}
|
||||
|
||||
fn toc_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
|
||||
#[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) {
|
||||
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<Value>) -> Result<Option<WorkspaceE
|
||||
Ok(ops::toc_edit(
|
||||
&doc.text,
|
||||
&doc.ast,
|
||||
&p.uri,
|
||||
&uri,
|
||||
utf8,
|
||||
&header,
|
||||
level,
|
||||
margin,
|
||||
link_format,
|
||||
p.line,
|
||||
cursor_line,
|
||||
))
|
||||
}
|
||||
|
||||
fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, 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<Value>) -> Result<Option<Workspac
|
||||
Ok(ops::links_edit(
|
||||
&doc.text,
|
||||
&doc.ast,
|
||||
&p.uri,
|
||||
&uri,
|
||||
¤t_page,
|
||||
&pages,
|
||||
utf8,
|
||||
@@ -396,6 +404,7 @@ fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<Workspac
|
||||
wiki.config.links_header_level,
|
||||
wiki.config.list_margin.max(0) as usize,
|
||||
captions.as_ref(),
|
||||
cursor_line,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -632,6 +641,9 @@ fn tags_generate_links(
|
||||
uri: Url,
|
||||
#[serde(default)]
|
||||
tag: Option<String>,
|
||||
/// 0-based cursor line; a fresh tags section is inserted here.
|
||||
#[serde(default)]
|
||||
line: Option<u32>,
|
||||
}
|
||||
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<u32>,
|
||||
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<String, String>>,
|
||||
insert_line: Option<u32>,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
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<String, String>>,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
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<u32>,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
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<WorkspaceEdit> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user