feat(links): wire up nuwiki.links.generateForPath (scoped GenerateLinks)
`: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:
@@ -41,6 +41,7 @@ pub const COMMANDS: &[&str] = &[
|
|||||||
"nuwiki.heading.removeLevel",
|
"nuwiki.heading.removeLevel",
|
||||||
"nuwiki.toc.generate",
|
"nuwiki.toc.generate",
|
||||||
"nuwiki.links.generate",
|
"nuwiki.links.generate",
|
||||||
|
"nuwiki.links.generateForPath",
|
||||||
"nuwiki.workspace.checkLinks",
|
"nuwiki.workspace.checkLinks",
|
||||||
"nuwiki.workspace.findOrphans",
|
"nuwiki.workspace.findOrphans",
|
||||||
"nuwiki.diary.openToday",
|
"nuwiki.diary.openToday",
|
||||||
@@ -117,6 +118,9 @@ pub(crate) async fn execute(
|
|||||||
"nuwiki.links.generate" => {
|
"nuwiki.links.generate" => {
|
||||||
links_generate(backend, args).map(|o| o.map(CommandOutcome::Edit))
|
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" => {
|
"nuwiki.workspace.checkLinks" => {
|
||||||
workspace_check_links(backend, args).map(|o| o.map(CommandOutcome::Value))
|
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> {
|
fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
|
||||||
let (uri, cursor_line) = parse_uri_line_arg(args)?;
|
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,
|
Some(d) => d,
|
||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
|
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,
|
Some(w) => w,
|
||||||
None => return Ok(None),
|
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 (pages, captions) = {
|
||||||
let idx = wiki
|
let idx = wiki
|
||||||
.index
|
.index
|
||||||
@@ -391,12 +440,20 @@ fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<Workspac
|
|||||||
.config
|
.config
|
||||||
.generated_links_caption
|
.generated_links_caption
|
||||||
.then(|| ops::page_captions(&idx));
|
.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(
|
Ok(ops::links_edit(
|
||||||
&doc.text,
|
&doc.text,
|
||||||
&doc.ast,
|
&doc.ast,
|
||||||
&uri,
|
uri,
|
||||||
¤t_page,
|
¤t_page,
|
||||||
&pages,
|
&pages,
|
||||||
utf8,
|
utf8,
|
||||||
@@ -3941,3 +3998,33 @@ pub mod export_ops {
|
|||||||
out
|
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/*"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ fn escape_html(s: &str) -> String {
|
|||||||
/// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and
|
/// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and
|
||||||
/// literal characters. No character classes — those aren't needed for
|
/// literal characters. No character classes — those aren't needed for
|
||||||
/// "exclude foo/*.tmp" style patterns.
|
/// "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())
|
glob_impl(pattern.as_bytes(), text.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ fn commands_list_advertises_link_helpers() {
|
|||||||
"nuwiki.link.pasteWikilink",
|
"nuwiki.link.pasteWikilink",
|
||||||
"nuwiki.link.pasteUrl",
|
"nuwiki.link.pasteUrl",
|
||||||
"nuwiki.link.catUrl",
|
"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}");
|
assert!(names.contains(&name), "missing: {name}");
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -529,9 +529,11 @@ Page generation ~
|
|||||||
is inserted at the cursor line; an existing one is refreshed in place.
|
is inserted at the cursor line; an existing one is refreshed in place.
|
||||||
|
|
||||||
*:NuwikiGenerateLinks*
|
*:NuwikiGenerateLinks*
|
||||||
:NuwikiGenerateLinks
|
:NuwikiGenerateLinks [path]
|
||||||
Insert a flat list of every page in the wiki. A new section is inserted at
|
Insert a flat list of every page in the wiki. With a `path` argument, the
|
||||||
the cursor line; an existing one is refreshed in place.
|
list is scoped to that subtree (a wiki-root-relative directory, or a glob
|
||||||
|
pattern containing `*`/`?`). A new section is inserted at the cursor line;
|
||||||
|
an existing one is refreshed in place.
|
||||||
|
|
||||||
*:NuwikiCheckLinks*
|
*:NuwikiCheckLinks*
|
||||||
:[range]NuwikiCheckLinks
|
:[range]NuwikiCheckLinks
|
||||||
|
|||||||
Reference in New Issue
Block a user