diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index e2959ee..c5a3eab 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -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) -> Result) -> Result, 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 ` — scope the generated links to a subtree. +fn links_generate_for_path( + backend: &Backend, + args: Vec, +) -> Result, String> { + #[derive(Deserialize)] + struct Args { + uri: Url, + path: String, + #[serde(default)] + line: Option, + } + 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 `/`. 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, + scope: Option<&str>, +) -> Result, 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) -> Result = 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/*")); + } +} diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs index 4d9ba68..a8f8049 100644 --- a/crates/nuwiki-lsp/src/export.rs +++ b/crates/nuwiki-lsp/src/export.rs @@ -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()) } diff --git a/crates/nuwiki-lsp/tests/commands_links.rs b/crates/nuwiki-lsp/tests/commands_links.rs index 68e2c91..50b9265 100644 --- a/crates/nuwiki-lsp/tests/commands_links.rs +++ b/crates/nuwiki-lsp/tests/commands_links.rs @@ -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}"); } diff --git a/doc/nuwiki.txt b/doc/nuwiki.txt index 2c12dda..68b65f5 100644 --- a/doc/nuwiki.txt +++ b/doc/nuwiki.txt @@ -529,9 +529,11 @@ Page generation ~ is inserted at the cursor line; an existing one is refreshed in place. *:NuwikiGenerateLinks* -:NuwikiGenerateLinks - Insert a flat list of every page in the wiki. A new section is inserted at - the cursor line; an existing one is refreshed in place. +:NuwikiGenerateLinks [path] + Insert a flat list of every page in the wiki. With a `path` argument, the + 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* :[range]NuwikiCheckLinks