feat(links): wire up nuwiki.links.generateForPath (scoped GenerateLinks)
CI / cargo fmt --check (push) Successful in 24s
CI / cargo clippy (push) Successful in 30s
CI / cargo test (push) Successful in 40s
CI / editor keymaps (push) Successful in 1m22s

`:VimwikiGenerateLinks <path>` sent nuwiki.links.generateForPath, but the
server had no handler and didn't advertise it — so the scoped form was
silently dropped (only the no-arg form worked). Register and implement it.

The page list is scoped via page_in_scope: a plain `path` is a directory
subtree (the page itself or anything under `<path>/`); a `path` with `*`/`?`
is a glob pattern (upstream's behaviour), matched with export::glob_match
(now pub(crate)). links_generate and the scoped form share
links_generate_scoped, so cursor-line insertion + captions + config all
apply uniformly.

Tests: page_in_scope unit tests (subtree, glob, prefix-sibling, trailing
slash) + COMMANDS advertises both generate forms. 580 passed, clippy
clean, harnesses green. Doc updated with the [path] argument.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 01:23:42 +00:00
parent f0c51fbfcc
commit daee0f1902
4 changed files with 102 additions and 9 deletions
+92 -5
View File
@@ -41,6 +41,7 @@ pub const COMMANDS: &[&str] = &[
"nuwiki.heading.removeLevel",
"nuwiki.toc.generate",
"nuwiki.links.generate",
"nuwiki.links.generateForPath",
"nuwiki.workspace.checkLinks",
"nuwiki.workspace.findOrphans",
"nuwiki.diary.openToday",
@@ -117,6 +118,9 @@ pub(crate) async fn execute(
"nuwiki.links.generate" => {
links_generate(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.links.generateForPath" => {
links_generate_for_path(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.workspace.checkLinks" => {
workspace_check_links(backend, args).map(|o| o.map(CommandOutcome::Value))
}
@@ -372,16 +376,61 @@ fn toc_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceE
fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
let (uri, cursor_line) = parse_uri_line_arg(args)?;
let doc = match backend.documents.get(&uri) {
links_generate_scoped(backend, &uri, cursor_line, None)
}
/// `:VimwikiGenerateLinks <path>` — scope the generated links to a subtree.
fn links_generate_for_path(
backend: &Backend,
args: Vec<Value>,
) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
path: String,
#[serde(default)]
line: Option<u32>,
}
let raw = args.into_iter().next().ok_or_else(|| {
"nuwiki.links.generateForPath: missing { uri, path } argument".to_string()
})?;
let parsed: Args = serde_json::from_value(raw)
.map_err(|e| format!("nuwiki.links.generateForPath: invalid args — {e}"))?;
links_generate_scoped(backend, &parsed.uri, parsed.line, Some(&parsed.path))
}
/// True when page `name` falls within `scope`. A scope containing `*`/`?` is a
/// glob (upstream's pattern arg); otherwise it's a directory subtree — the page
/// itself or anything under `<scope>/`. Page names are wiki-root-relative with
/// `/` separators (e.g. `projects/api`).
fn page_in_scope(name: &str, scope: &str) -> bool {
let scope = scope.trim_matches('/');
if scope.is_empty() {
return true;
}
if scope.contains('*') || scope.contains('?') {
crate::export::glob_match(scope, name)
} else {
name == scope || name.starts_with(&format!("{scope}/"))
}
}
fn links_generate_scoped(
backend: &Backend,
uri: &Url,
cursor_line: Option<u32>,
scope: Option<&str>,
) -> Result<Option<WorkspaceEdit>, String> {
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(&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(&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
@@ -391,12 +440,20 @@ fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<Workspac
.config
.generated_links_caption
.then(|| ops::page_captions(&idx));
(idx.page_names(), captions)
let names: Vec<String> = match scope {
Some(s) => idx
.page_names()
.into_iter()
.filter(|n| page_in_scope(n, s))
.collect(),
None => idx.page_names(),
};
(names, captions)
};
Ok(ops::links_edit(
&doc.text,
&doc.ast,
&uri,
uri,
&current_page,
&pages,
utf8,
@@ -3941,3 +3998,33 @@ pub mod export_ops {
out
}
}
#[cfg(test)]
mod scope_tests {
use super::page_in_scope;
#[test]
fn empty_scope_matches_everything() {
assert!(page_in_scope("anything", ""));
assert!(page_in_scope("a/b/c", "/"));
}
#[test]
fn subtree_matches_self_and_descendants() {
assert!(page_in_scope("projects", "projects"));
assert!(page_in_scope("projects/api", "projects"));
assert!(page_in_scope("projects/api/v2", "projects"));
// Not a sibling that merely shares a prefix.
assert!(!page_in_scope("projectsX", "projects"));
assert!(!page_in_scope("Home", "projects"));
// Trailing slash on the scope is tolerated.
assert!(page_in_scope("projects/api", "projects/"));
}
#[test]
fn glob_scope_uses_pattern_match() {
assert!(page_in_scope("diary/2026-05-30", "diary/*"));
assert!(page_in_scope("notes-2026", "notes-*"));
assert!(!page_in_scope("Home", "diary/*"));
}
}
+1 -1
View File
@@ -329,7 +329,7 @@ fn escape_html(s: &str) -> String {
/// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and
/// literal characters. No character classes — those aren't needed for
/// "exclude foo/*.tmp" style patterns.
fn glob_match(pattern: &str, text: &str) -> bool {
pub(crate) fn glob_match(pattern: &str, text: &str) -> bool {
glob_impl(pattern.as_bytes(), text.as_bytes())
}
@@ -32,6 +32,10 @@ fn commands_list_advertises_link_helpers() {
"nuwiki.link.pasteWikilink",
"nuwiki.link.pasteUrl",
"nuwiki.link.catUrl",
// Both GenerateLinks forms must be advertised so coc/vim-lsp accept
// them (the scoped form was previously sent but unregistered).
"nuwiki.links.generate",
"nuwiki.links.generateForPath",
] {
assert!(names.contains(&name), "missing: {name}");
}