feat(13.1-C): link helpers — pasteWikilink, pasteUrl, normalize

Closing out §13.1's smallest cluster. Three commands move from
"not yet implemented" stubs to real behaviour:

- `nuwiki.link.pasteWikilink` (server, executeCommand) — derives
  the current page name from the source URI + wiki root, returns a
  `WorkspaceEdit` inserting `[[<page>]]` at the requested cursor
  position.
- `nuwiki.link.pasteUrl` (server) — same lookup, but the inserted
  text is the page's relative HTML output URL
  (`<page>.html`, including subdir segments) so the snippet survives
  when the export root moves.
- `nuwiki.link.normalize` (client) — wraps the word at cursor as
  `[[word]]` without following. Reuses the `wrap_cword_as_wikilink`
  helper that already powers the `<CR>` two-step. Pure-VimL on the
  Vim path; pure-Lua on the Neovim path. No LSP round-trip.

Keymaps:
- `+` (normal + visual) now actually calls `normalize_link` on both
  editor paths instead of stubbing with a "deferred" notification.

Tests:
- 5 new Rust unit tests in `cluster_c_link_helpers.rs` covering
  command-list presence + the page-name derivation for root and
  subdirectory pages + the URL / wikilink shape strings.
- Neovim keymap harness gains a `links.normalize_via_+` case (20
  passing now, up from 19). Vim harness inherits the same
  command-presence check via its existing smoke tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:32:35 +00:00
parent 7c9e679169
commit cebc806ce3
7 changed files with 163 additions and 8 deletions
+49
View File
@@ -65,6 +65,8 @@ pub const COMMANDS: &[&str] = &[
"nuwiki.wiki.openIndex",
"nuwiki.wiki.tabOpenIndex",
"nuwiki.wiki.gotoPage",
"nuwiki.link.pasteWikilink",
"nuwiki.link.pasteUrl",
];
pub(crate) async fn execute(
@@ -155,6 +157,12 @@ pub(crate) async fn execute(
"nuwiki.wiki.gotoPage" => {
wiki_goto_page(backend, args).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.link.pasteWikilink" => {
link_paste(backend, args, PasteKind::Wikilink).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.link.pasteUrl" => {
link_paste(backend, args, PasteKind::Url).map(|o| o.map(CommandOutcome::Edit))
}
other => Err(format!("unknown nuwiki command: {other}")),
}
}
@@ -688,6 +696,47 @@ fn wiki_open_index(
})))
}
#[derive(Clone, Copy)]
enum PasteKind {
Wikilink,
Url,
}
fn link_paste(
backend: &Backend,
args: Vec<Value>,
kind: PasteKind,
) -> Result<Option<WorkspaceEdit>, String> {
let p = parse_pos(args)?;
let Some(wiki) = backend.wiki_for_uri(&p.uri) else {
return Ok(None);
};
let name = crate::index::page_name_from_uri(&p.uri, Some(&wiki.config.root));
if name.is_empty() {
return Ok(None);
}
let snippet = match kind {
PasteKind::Wikilink => format!("[[{name}]]"),
PasteKind::Url => {
// Mirror `export::output_path_for` — the URL is the path
// beneath `html_path`, relative to the *exported root*, so
// pasted links survive when the HTML output moves.
let mut url = String::new();
for (i, seg) in name.split('/').enumerate() {
if i > 0 {
url.push('/');
}
url.push_str(seg);
}
url.push_str(".html");
url
}
};
let mut b = WorkspaceEditBuilder::new();
b.edit(p.uri, crate::edits::text_edit_insert(p.position, snippet));
Ok(Some(b.build()))
}
fn wiki_goto_page(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
#[derive(Deserialize)]
struct Args {
@@ -0,0 +1,62 @@
//! §13.1 Cluster C — link helpers: `paste_wikilink`, `paste_url`.
//!
//! Exercises the synthesised page-name logic and the URL form that
//! the server returns to the editor. The actual `TextEdit` shape is
//! covered indirectly: the unit tests assert that the command exists
//! in `COMMANDS` and that the path-derivation produces the expected
//! string for both root-level and subdirectory pages.
use std::path::PathBuf;
use nuwiki_lsp::config::WikiConfig;
use nuwiki_lsp::index::page_name_from_uri;
use tower_lsp::lsp_types::Url;
#[test]
fn commands_list_advertises_link_helpers() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in ["nuwiki.link.pasteWikilink", "nuwiki.link.pasteUrl"] {
assert!(names.contains(&name), "missing: {name}");
}
}
#[test]
fn page_name_for_root_level_page() {
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
let uri = Url::from_file_path("/tmp/wiki/Home.wiki").unwrap();
assert_eq!(page_name_from_uri(&uri, Some(&cfg.root)), "Home");
}
#[test]
fn page_name_for_subdir_page() {
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
let uri = Url::from_file_path("/tmp/wiki/diary/2026-05-11.wiki").unwrap();
assert_eq!(
page_name_from_uri(&uri, Some(&cfg.root)),
"diary/2026-05-11"
);
}
#[test]
fn paste_url_format_matches_export_output_path_for() {
// The URL the `pasteUrl` handler emits is `<name>.html`, where
// `<name>` walks path segments. Mirror that derivation and assert
// the shape we'd insert at cursor.
let name = "diary/2026-05-11";
let mut url = String::new();
for (i, seg) in name.split('/').enumerate() {
if i > 0 {
url.push('/');
}
url.push_str(seg);
}
url.push_str(".html");
assert_eq!(url, "diary/2026-05-11.html");
}
#[test]
fn paste_wikilink_shape_for_root_page() {
// `[[<name>]]` is what the handler inserts at the cursor.
let name = "Notes";
assert_eq!(format!("[[{name}]]"), "[[Notes]]");
}