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.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,
|
||||
¤t_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/*"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user