diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index c698e61..1b9a9ff 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -1176,9 +1176,27 @@ fn workspace_find_orphans(backend: &Backend, args: Vec) -> Result LspPosition { + let mut line = 0u32; + let mut last_nl_col = 0u32; + let mut col = 0u32; + for (i, b) in text.as_bytes().iter().copied().enumerate() { + if b == b'\n' { + line += 1; + last_nl_col = (i + 1) as u32; + col = 0; + } else { + col = (i + 1 - last_nl_col as usize) as u32; + } + } + LspPosition { line, character: col } + } -pub mod ops { + // ===== Pure operations ===== + + pub mod ops { use super::*; use crate::edits::text_edit_replace; use nuwiki_core::ast::{ @@ -1700,21 +1718,70 @@ pub mod ops { ) -> Option<(AstPosition, AstPosition)> { let needle = heading_name.to_ascii_lowercase(); for (i, block) in ast.children.iter().enumerate() { - let BlockNode::Heading(h) = block else { - continue; - }; - if h.level != 1 { - continue; - } + let BlockNode::Heading(h) = block else { + continue; + }; + // Accept any heading level for section replacement. Previously only + // level‑1 headings were considered, which caused `VimwikiGenerateTagLinks` + // to miss existing tag sections if they were not level‑1 (e.g. `== Tag:`). + // Removing the level check makes the edit idempotent across heading + // levels. + // NOTE: All callers (TOC, Links, Tag links) expect a top‑level heading, + // but supporting any level is safe because headings are unique by name + // within a page and the operation replaces the whole section. + // If multiple headings share the same title at different levels, the + // first encountered will be replaced – this matches prior behaviour for + // level‑1. + // The level check is therefore removed. + // if h.level != 1 { + // continue; + // } + let title = heading_title(h).to_ascii_lowercase(); if title != needle { continue; } let start = h.span.start; - let end = match ast.children.get(i + 1) { - Some(BlockNode::List(list)) => list.span.end, - _ => h.span.end, - }; + // Determine the end of the section. Historically we only extended + // to the next immediate list block, which worked for simple tag + // sections like `= Tag: foo =` that contain a single list. For the + // full‑tags index (`= Tags =`) the section contains multiple + // sub‑headings and lists, so the next block after the heading is a + // level‑2 heading, not a list. The old logic therefore stopped at + // the heading line, causing only the heading to be replaced and + // the existing content to remain, resulting in duplicated links + // on subsequent runs. + // + // New logic walks forward until it finds the next *level‑1* + // heading (the start of the next top‑level section) or reaches the + // end of the document. The span end is set to the start of that + // next heading (exclusive) or to the end of the last block. + let mut end = h.span.end; + for next_block in ast.children.iter().skip(i + 1) { + // If we encounter another level‑1 heading, the current section + // ends just before it. + if let BlockNode::Heading(next_h) = next_block { + if next_h.level == 1 { + end = next_h.span.start; + break; + } + } + // Otherwise extend the end to the end of this block. + end = match next_block { + BlockNode::Heading(h) => h.span.end, + BlockNode::Paragraph(p) => p.span.end, + BlockNode::HorizontalRule(hr) => hr.span.end, + BlockNode::Blockquote(bq) => bq.span.end, + BlockNode::Preformatted(pf) => pf.span.end, + BlockNode::MathBlock(mb) => mb.span.end, + BlockNode::List(list) => list.span.end, + BlockNode::DefinitionList(dl) => dl.span.end, + BlockNode::Table(t) => t.span.end, + BlockNode::Comment(c) => c.span.end, + BlockNode::Tag(tag) => tag.span.end, + BlockNode::Error(err) => err.span.end, + }; + } return Some((start, end)); } None @@ -1809,15 +1876,10 @@ pub mod ops { } None => { let with_sep = format!("{new_text}\n"); + let insert_pos = eof_position(text); b.edit( uri.clone(), - text_edit_insert( - LspPosition { - line: 0, - character: 0, - }, - with_sep, - ), + text_edit_insert(insert_pos, with_sep), ); } } @@ -2884,15 +2946,10 @@ pub mod ops { } None => { let with_sep = format!("{body}\n"); + let insert_pos = eof_position(text); b.edit( uri.clone(), - text_edit_insert( - LspPosition { - line: 0, - character: 0, - }, - with_sep, - ), + text_edit_insert(insert_pos, with_sep), ); } }