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:
@@ -480,9 +480,25 @@ endfunction
|
|||||||
function! nuwiki#commands#colorize(color) abort
|
function! nuwiki#commands#colorize(color) abort
|
||||||
call s:notify_deferred(':VimwikiColorize')
|
call s:notify_deferred(':VimwikiColorize')
|
||||||
endfunction
|
endfunction
|
||||||
|
" §13.1 Cluster C — link helpers.
|
||||||
|
|
||||||
function! nuwiki#commands#paste_link() abort
|
function! nuwiki#commands#paste_link() abort
|
||||||
call s:notify_deferred(':VimwikiPasteLink')
|
let l:p = s:cursor_position()
|
||||||
|
let l:args = [{ 'uri': l:p['textDocument']['uri'], 'position': l:p['position'] }]
|
||||||
|
call s:exec('nuwiki.link.pasteWikilink', l:args)
|
||||||
endfunction
|
endfunction
|
||||||
|
|
||||||
function! nuwiki#commands#paste_url() abort
|
function! nuwiki#commands#paste_url() abort
|
||||||
call s:notify_deferred(':VimwikiPasteUrl')
|
let l:p = s:cursor_position()
|
||||||
|
let l:args = [{ 'uri': l:p['textDocument']['uri'], 'position': l:p['position'] }]
|
||||||
|
call s:exec('nuwiki.link.pasteUrl', l:args)
|
||||||
|
endfunction
|
||||||
|
|
||||||
|
" `:VimwikiNormalizeLink` — wrap the word at cursor as `[[word]]`
|
||||||
|
" without following. Pure-VimL.
|
||||||
|
function! nuwiki#commands#normalize_link() abort
|
||||||
|
if s:cursor_inside_wikilink()
|
||||||
|
return
|
||||||
|
endif
|
||||||
|
call s:wrap_cword_as_wikilink()
|
||||||
endfunction
|
endfunction
|
||||||
@@ -65,6 +65,8 @@ pub const COMMANDS: &[&str] = &[
|
|||||||
"nuwiki.wiki.openIndex",
|
"nuwiki.wiki.openIndex",
|
||||||
"nuwiki.wiki.tabOpenIndex",
|
"nuwiki.wiki.tabOpenIndex",
|
||||||
"nuwiki.wiki.gotoPage",
|
"nuwiki.wiki.gotoPage",
|
||||||
|
"nuwiki.link.pasteWikilink",
|
||||||
|
"nuwiki.link.pasteUrl",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub(crate) async fn execute(
|
pub(crate) async fn execute(
|
||||||
@@ -155,6 +157,12 @@ pub(crate) async fn execute(
|
|||||||
"nuwiki.wiki.gotoPage" => {
|
"nuwiki.wiki.gotoPage" => {
|
||||||
wiki_goto_page(backend, args).map(|o| o.map(CommandOutcome::Value))
|
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}")),
|
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> {
|
fn wiki_goto_page(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Args {
|
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]]");
|
||||||
|
}
|
||||||
@@ -129,8 +129,8 @@ if !has('nvim')
|
|||||||
nnoremap <silent><buffer> <BS> <C-o>
|
nnoremap <silent><buffer> <BS> <C-o>
|
||||||
nnoremap <silent><buffer> <Tab> /\[\[<CR>:nohlsearch<CR>
|
nnoremap <silent><buffer> <Tab> /\[\[<CR>:nohlsearch<CR>
|
||||||
nnoremap <silent><buffer> <S-Tab> ?\[\[<CR>:nohlsearch<CR>
|
nnoremap <silent><buffer> <S-Tab> ?\[\[<CR>:nohlsearch<CR>
|
||||||
nnoremap <silent><buffer> + :echohl WarningMsg<bar>echom 'nuwiki: :VimwikiNormalizeLink not yet implemented — see SPEC §13.1'<bar>echohl None<CR>
|
nnoremap <silent><buffer> + :call nuwiki#commands#normalize_link()<CR>
|
||||||
xnoremap <silent><buffer> + :<C-u>echohl WarningMsg<bar>echom 'nuwiki: :VimwikiNormalizeLink not yet implemented — see SPEC §13.1'<bar>echohl None<CR>
|
xnoremap <silent><buffer> + :<C-u>call nuwiki#commands#normalize_link()<CR>
|
||||||
nnoremap <silent><buffer> <Leader>wn :call nuwiki#commands#wiki_goto_page('')<CR>
|
nnoremap <silent><buffer> <Leader>wn :call nuwiki#commands#wiki_goto_page('')<CR>
|
||||||
nnoremap <silent><buffer> <Leader>wd :call nuwiki#commands#delete_file()<CR>
|
nnoremap <silent><buffer> <Leader>wd :call nuwiki#commands#delete_file()<CR>
|
||||||
nnoremap <silent><buffer> <Leader>wr :call nuwiki#commands#rename_file()<CR>
|
nnoremap <silent><buffer> <Leader>wr :call nuwiki#commands#rename_file()<CR>
|
||||||
|
|||||||
+19
-2
@@ -444,7 +444,24 @@ M.table_insert = _not_yet(':VimwikiTable')
|
|||||||
M.table_move_column_left = _not_yet(':VimwikiTableMoveColumnLeft')
|
M.table_move_column_left = _not_yet(':VimwikiTableMoveColumnLeft')
|
||||||
M.table_move_column_right = _not_yet(':VimwikiTableMoveColumnRight')
|
M.table_move_column_right = _not_yet(':VimwikiTableMoveColumnRight')
|
||||||
M.colorize = _not_yet(':VimwikiColorize')
|
M.colorize = _not_yet(':VimwikiColorize')
|
||||||
M.paste_link = _not_yet(':VimwikiPasteLink')
|
|
||||||
M.paste_url = _not_yet(':VimwikiPasteUrl')
|
-- §13.1 Cluster C — link helpers.
|
||||||
|
|
||||||
|
function M.paste_link()
|
||||||
|
exec('nuwiki.link.pasteWikilink', pos_args())
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.paste_url()
|
||||||
|
exec('nuwiki.link.pasteUrl', pos_args())
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Vimwiki `:VimwikiNormalizeLink` — wrap the word at cursor as
|
||||||
|
--- `[[word]]` without following. The `<CR>` two-step does the same
|
||||||
|
--- thing under the hood; expose it as a standalone command + keymap
|
||||||
|
--- for users who want to bind it to `+` per vimwiki defaults.
|
||||||
|
function M.normalize_link()
|
||||||
|
if cursor_inside_wikilink() then return end
|
||||||
|
wrap_cword_as_wikilink()
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ function M.attach(bufnr, mappings)
|
|||||||
map('n', '<BS>', '<C-o>', { desc = 'nuwiki: go back', remap = true }, bufnr)
|
map('n', '<BS>', '<C-o>', { desc = 'nuwiki: go back', remap = true }, bufnr)
|
||||||
map('n', '<Tab>', link.next, { desc = 'nuwiki: next wikilink' }, bufnr)
|
map('n', '<Tab>', link.next, { desc = 'nuwiki: next wikilink' }, bufnr)
|
||||||
map('n', '<S-Tab>', link.prev, { desc = 'nuwiki: prev wikilink' }, bufnr)
|
map('n', '<S-Tab>', link.prev, { desc = 'nuwiki: prev wikilink' }, bufnr)
|
||||||
map('n', '+', deferred(':VimwikiNormalizeLink'), { desc = 'nuwiki: normalize link (deferred)' }, bufnr)
|
map('n', '+', cmd.normalize_link, { desc = 'nuwiki: wrap word as wikilink' }, bufnr)
|
||||||
map('x', '+', deferred(':VimwikiNormalizeLink'), { desc = 'nuwiki: normalize link (deferred)' }, bufnr)
|
map('x', '+', cmd.normalize_link, { desc = 'nuwiki: wrap word as wikilink' }, bufnr)
|
||||||
map('n', '<Leader>wn', cmd.wiki_goto_page, { desc = 'nuwiki: goto page' }, bufnr)
|
map('n', '<Leader>wn', cmd.wiki_goto_page, { desc = 'nuwiki: goto page' }, bufnr)
|
||||||
map('n', '<Leader>wd', cmd.delete_file, { desc = 'nuwiki: delete page' }, bufnr)
|
map('n', '<Leader>wd', cmd.delete_file, { desc = 'nuwiki: delete page' }, bufnr)
|
||||||
map('n', '<Leader>wr', cmd.rename_file, { desc = 'nuwiki: rename page' }, bufnr)
|
map('n', '<Leader>wr', cmd.rename_file, { desc = 'nuwiki: rename page' }, bufnr)
|
||||||
|
|||||||
@@ -237,6 +237,17 @@ vim.defer_fn(function()
|
|||||||
wait_ms = 200,
|
wait_ms = 200,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- ===== Link helpers (§13.1 Cluster C) =====
|
||||||
|
|
||||||
|
run('links.normalize_via_+', {
|
||||||
|
lines = { 'MyPage rest' },
|
||||||
|
cursor = { 1, 3 },
|
||||||
|
keys = '+',
|
||||||
|
expect_line = '[[MyPage]] rest',
|
||||||
|
expect_at = 1,
|
||||||
|
wait_ms = 200,
|
||||||
|
})
|
||||||
|
|
||||||
-- ===== Bullet continuation (o/O) =====
|
-- ===== Bullet continuation (o/O) =====
|
||||||
|
|
||||||
run('lists.o_continues_list_marker', {
|
run('lists.o_continues_list_marker', {
|
||||||
|
|||||||
Reference in New Issue
Block a user