feat(config): on-save autoregen family — links/tags/diary-index (P2)
CI / cargo fmt --check (push) Successful in 17s
CI / cargo clippy (push) Successful in 38s
CI / cargo test (push) Successful in 41s
CI / editor keymaps (push) Successful in 1m25s

Added per-wiki auto_generate_links, auto_generate_tags, auto_diary_index
(default false, like upstream), wired into the did_save hook mirroring
auto_toc:

- auto_generate_links / auto_generate_tags rebuild the Generated Links /
  Generated Tags section ONLY when it already exists (new
  links_rebuild_edit / tag_links_rebuild_edit, like toc_rebuild_edit) — never
  inserts one into a page that lacks it.
- auto_diary_index regenerates the diary index page when a dated diary entry
  (not the index itself) is saved, via diary_generate_index_edit (handles the
  index file open / on-disk / absent).

auto_tags is recorded as an intentional divergence: nuwiki re-indexes tags on
every change, so the tag metadata is always fresh (no on-disk file to update).

Tests: links_rebuild_edit_only_acts_when_section_present,
tag_links_rebuild_edit_only_acts_when_index_present, config round-trip +
defaults. README/doc/lua comment updated. fmt/clippy clean; 0 Rust failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 18:55:51 +00:00
parent 3fd04b0efa
commit 09b5a2bd46
10 changed files with 255 additions and 3 deletions
+6
View File
@@ -231,6 +231,9 @@ require('nuwiki').setup({
css_name = 'style.css', css_name = 'style.css',
auto_export = false, -- export on save auto_export = false, -- export on save
auto_toc = false, -- auto-refresh TOC on save auto_toc = false, -- auto-refresh TOC on save
auto_generate_links = false, -- regen Generated Links on save
auto_generate_tags = false, -- regen Generated Tags index on save
auto_diary_index = false, -- regen diary index on diary-entry save
toc_header = 'Contents', -- :VimwikiTOC caption (+ toc_header_level = 1) toc_header = 'Contents', -- :VimwikiTOC caption (+ toc_header_level = 1)
links_header = 'Generated Links', links_header = 'Generated Links',
tags_header = 'Generated Tags', tags_header = 'Generated Tags',
@@ -336,6 +339,9 @@ Each is a boolean. All default to `true` except `mouse`.
| `list_margin` | int | `-1` | `-1` (auto) or `≥ 0` | | `list_margin` | int | `-1` | `-1` (auto) or `≥ 0` |
| `links_space_char` | string | `' '` | char that replaces spaces in synthesised link paths | | `links_space_char` | string | `' '` | char that replaces spaces in synthesised link paths |
| `auto_toc` | bool | `false` | `true` \| `false` | | `auto_toc` | bool | `false` | `true` \| `false` |
| `auto_generate_links` | bool | `false` | regenerate an existing Generated Links section on save |
| `auto_generate_tags` | bool | `false` | regenerate an existing Generated Tags index on save |
| `auto_diary_index` | bool | `false` | regenerate the diary index when a diary entry is saved |
| `toc_header` / `toc_header_level` | string / int | `'Contents'` / `1` | `:VimwikiTOC` caption + heading level | | `toc_header` / `toc_header_level` | string / int | `'Contents'` / `1` | `:VimwikiTOC` caption + heading level |
| `links_header` / `links_header_level` | string / int | `'Generated Links'` / `1` | `:VimwikiGenerateLinks` caption + level | | `links_header` / `links_header_level` | string / int | `'Generated Links'` / `1` | `:VimwikiGenerateLinks` caption + level |
| `tags_header` / `tags_header_level` | string / int | `'Generated Tags'` / `1` | `:VimwikiGenerateTagLinks` index caption + level | | `tags_header` / `tags_header_level` | string / int | `'Generated Tags'` / `1` | `:VimwikiGenerateTagLinks` index caption + level |
+43
View File
@@ -2041,6 +2041,33 @@ pub mod ops {
Some(b.build()) Some(b.build())
} }
/// Like [`links_edit`], but only when a `Generated Links` section already
/// exists — the `auto_generate_links`-on-save hook, mirroring
/// [`toc_rebuild_edit`]. Never inserts one into a page that lacks it.
#[allow(clippy::too_many_arguments)]
pub fn links_rebuild_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
current_page: &str,
all_pages: &[String],
utf8: bool,
heading: &str,
level: u8,
) -> Option<WorkspaceEdit> {
find_section_range(ast, heading)?;
links_edit(
text,
ast,
uri,
current_page,
all_pages,
utf8,
heading,
level,
)
}
// ===== Workspace queries ===== // ===== Workspace queries =====
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -3220,6 +3247,22 @@ pub mod ops {
Some(b.build()) Some(b.build())
} }
/// Like [`tag_links_edit`] for the no-arg index, but only when a
/// `Generated Tags` section already exists — the `auto_generate_tags`
/// on-save hook. Never inserts one into a page that lacks it.
pub fn tag_links_rebuild_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
pages_by_tag: &std::collections::BTreeMap<String, Vec<String>>,
utf8: bool,
header: &str,
level: u8,
) -> Option<WorkspaceEdit> {
find_section_range(ast, header)?;
tag_links_edit(text, ast, uri, None, pages_by_tag, utf8, header, level)
}
// Keep the import live for downstream use even when the local module // Keep the import live for downstream use even when the local module
// doesn't reference it directly. // doesn't reference it directly.
#[allow(dead_code)] #[allow(dead_code)]
+30
View File
@@ -75,6 +75,15 @@ pub struct WikiConfig {
pub links_space_char: String, pub links_space_char: String,
/// Rebuild the page's TOC on save when set. /// Rebuild the page's TOC on save when set.
pub auto_toc: bool, pub auto_toc: bool,
/// On save, regenerate an existing `Generated Links` section
/// (vimwiki's `auto_generate_links`).
pub auto_generate_links: bool,
/// On save, regenerate an existing `Generated Tags` index section
/// (vimwiki's `auto_generate_tags`).
pub auto_generate_tags: bool,
/// On save of a diary entry, regenerate the diary index page
/// (vimwiki's `auto_diary_index`).
pub auto_diary_index: bool,
/// Heading text + level for the generated `:VimwikiTOC` section /// Heading text + level for the generated `:VimwikiTOC` section
/// (vimwiki's `toc_header` / `toc_header_level`). /// (vimwiki's `toc_header` / `toc_header_level`).
pub toc_header: String, pub toc_header: String,
@@ -208,6 +217,9 @@ impl WikiConfig {
list_margin: d.list_margin, list_margin: d.list_margin,
links_space_char: d.links_space_char, links_space_char: d.links_space_char,
auto_toc: d.auto_toc, auto_toc: d.auto_toc,
auto_generate_links: d.auto_generate_links,
auto_generate_tags: d.auto_generate_tags,
auto_diary_index: d.auto_diary_index,
toc_header: d.toc_header, toc_header: d.toc_header,
toc_header_level: d.toc_header_level, toc_header_level: d.toc_header_level,
links_header: d.links_header, links_header: d.links_header,
@@ -244,6 +256,9 @@ impl WikiConfig {
list_margin: d.list_margin, list_margin: d.list_margin,
links_space_char: d.links_space_char, links_space_char: d.links_space_char,
auto_toc: d.auto_toc, auto_toc: d.auto_toc,
auto_generate_links: d.auto_generate_links,
auto_generate_tags: d.auto_generate_tags,
auto_diary_index: d.auto_diary_index,
toc_header: d.toc_header, toc_header: d.toc_header,
toc_header_level: d.toc_header_level, toc_header_level: d.toc_header_level,
links_header: d.links_header, links_header: d.links_header,
@@ -384,6 +399,9 @@ fn wiki_defaults() -> WikiDefaults {
list_margin: -1, list_margin: -1,
links_space_char: default_links_space_char(), links_space_char: default_links_space_char(),
auto_toc: false, auto_toc: false,
auto_generate_links: false,
auto_generate_tags: false,
auto_diary_index: false,
toc_header: default_toc_header(), toc_header: default_toc_header(),
toc_header_level: 1, toc_header_level: 1,
links_header: default_links_header(), links_header: default_links_header(),
@@ -409,6 +427,9 @@ struct WikiDefaults {
list_margin: i32, list_margin: i32,
links_space_char: String, links_space_char: String,
auto_toc: bool, auto_toc: bool,
auto_generate_links: bool,
auto_generate_tags: bool,
auto_diary_index: bool,
toc_header: String, toc_header: String,
toc_header_level: u8, toc_header_level: u8,
links_header: String, links_header: String,
@@ -641,6 +662,12 @@ struct RawWiki {
links_space_char: Option<String>, links_space_char: Option<String>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
auto_toc: Option<bool>, auto_toc: Option<bool>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
auto_generate_links: Option<bool>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
auto_generate_tags: Option<bool>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
auto_diary_index: Option<bool>,
#[serde(default)] #[serde(default)]
toc_header: Option<String>, toc_header: Option<String>,
#[serde(default)] #[serde(default)]
@@ -714,6 +741,9 @@ impl From<RawWiki> for WikiConfig {
list_margin: r.list_margin.unwrap_or(d.list_margin), list_margin: r.list_margin.unwrap_or(d.list_margin),
links_space_char: r.links_space_char.unwrap_or(d.links_space_char), links_space_char: r.links_space_char.unwrap_or(d.links_space_char),
auto_toc: r.auto_toc.unwrap_or(d.auto_toc), auto_toc: r.auto_toc.unwrap_or(d.auto_toc),
auto_generate_links: r.auto_generate_links.unwrap_or(d.auto_generate_links),
auto_generate_tags: r.auto_generate_tags.unwrap_or(d.auto_generate_tags),
auto_diary_index: r.auto_diary_index.unwrap_or(d.auto_diary_index),
toc_header: r.toc_header.unwrap_or(d.toc_header), toc_header: r.toc_header.unwrap_or(d.toc_header),
toc_header_level: r.toc_header_level.unwrap_or(d.toc_header_level), toc_header_level: r.toc_header_level.unwrap_or(d.toc_header_level),
links_header: r.links_header.unwrap_or(d.links_header), links_header: r.links_header.unwrap_or(d.links_header),
+78
View File
@@ -635,6 +635,84 @@ impl LanguageServer for Backend {
} }
} }
let utf8 = self.use_utf8.load(Ordering::Relaxed);
// `auto_generate_links`: rebuild an existing `Generated Links` section.
if wiki.config.auto_generate_links {
let edit = self.documents.get(&uri).and_then(|doc| {
let current = index::page_name_from_uri(&uri, Some(&wiki.config.root));
let pages = wiki
.index
.read()
.ok()
.map(|i| i.page_names())
.unwrap_or_default();
commands::ops::links_rebuild_edit(
&doc.text,
&doc.ast,
&uri,
&current,
&pages,
utf8,
&wiki.config.links_header,
wiki.config.links_header_level,
)
});
if let Some(edit) = edit {
let _ = self.client.apply_edit(edit).await;
}
}
// `auto_generate_tags`: rebuild an existing `Generated Tags` index.
if wiki.config.auto_generate_tags {
let edit = self.documents.get(&uri).and_then(|doc| {
let snap = wiki
.index
.read()
.ok()
.map(|i| commands::ops::tag_pages_snapshot(&i))
.unwrap_or_default();
commands::ops::tag_links_rebuild_edit(
&doc.text,
&doc.ast,
&uri,
&snap,
utf8,
&wiki.config.tags_header,
wiki.config.tags_header_level,
)
});
if let Some(edit) = edit {
let _ = self.client.apply_edit(edit).await;
}
}
// `auto_diary_index`: when a diary *entry* (a dated page, not the
// index itself) is saved, regenerate the diary index page.
if wiki.config.auto_diary_index {
if let Some(index_uri) = crate::diary::index_uri(&wiki.config) {
let is_diary_entry = uri != index_uri
&& wiki
.index
.read()
.ok()
.and_then(|i| i.page(&uri).and_then(|p| p.diary_period))
.is_some();
if is_diary_entry {
let body = wiki
.index
.read()
.ok()
.map(|i| crate::diary::render_index_body(&wiki.config, &i));
if let Some(body) = body {
let edit =
commands::ops::diary_generate_index_edit(self, &index_uri, &body, utf8);
let _ = self.client.apply_edit(edit).await;
}
}
}
}
// When `auto_export` is set on the resolved wiki, run // When `auto_export` is set on the resolved wiki, run
// the equivalent of `nuwiki.export.currentToHtml` after the file // the equivalent of `nuwiki.export.currentToHtml` after the file
// hits disk. Best-effort — failures are logged but do not bubble // hits disk. Best-effort — failures are logged but do not bubble
+32
View File
@@ -301,6 +301,38 @@ fn tag_links_edit_full_index_replaces_existing_tags_section() {
assert!(!te.new_text.contains("[[old]]")); assert!(!te.new_text.contains("[[old]]"));
} }
#[test]
fn tag_links_rebuild_edit_only_acts_when_index_present() {
let mut snap = BTreeMap::new();
snap.insert("a".to_string(), vec!["P".into()]);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let without = "= Home =\nbody\n";
assert!(
ops::tag_links_rebuild_edit(
without,
&parse(without),
&uri,
&snap,
true,
"Generated Tags",
1
)
.is_none(),
"auto_generate_tags must not insert an index into a page that lacks one"
);
let with = "= Generated Tags =\n- [[old]]\n";
assert!(ops::tag_links_rebuild_edit(
with,
&parse(with),
&uri,
&snap,
true,
"Generated Tags",
1
)
.is_some());
}
#[test] #[test]
fn tag_links_edit_returns_none_for_unknown_tag() { fn tag_links_edit_returns_none_for_unknown_tag() {
let src = "hi\n"; let src = "hi\n";
@@ -275,6 +275,9 @@ fn defaults_carry_vimwiki_per_wiki_keys() {
assert_eq!(cfg.list_margin, -1); assert_eq!(cfg.list_margin, -1);
assert_eq!(cfg.links_space_char, " "); assert_eq!(cfg.links_space_char, " ");
assert!(!cfg.auto_toc); assert!(!cfg.auto_toc);
assert!(!cfg.auto_generate_links);
assert!(!cfg.auto_generate_tags);
assert!(!cfg.auto_diary_index);
// Generated-section captions match upstream defaults. // Generated-section captions match upstream defaults.
assert_eq!(cfg.toc_header, "Contents"); assert_eq!(cfg.toc_header, "Contents");
assert_eq!(cfg.toc_header_level, 1); assert_eq!(cfg.toc_header_level, 1);
@@ -301,6 +304,9 @@ fn raw_wiki_parses_every_new_key() {
"list_margin": 2, "list_margin": 2,
"links_space_char": "_", "links_space_char": "_",
"auto_toc": true, "auto_toc": true,
"auto_generate_links": true,
"auto_generate_tags": 1,
"auto_diary_index": true,
"toc_header": "Table of Contents", "toc_header": "Table of Contents",
"toc_header_level": 2, "toc_header_level": 2,
"links_header": "All Pages", "links_header": "All Pages",
@@ -323,6 +329,9 @@ fn raw_wiki_parses_every_new_key() {
assert_eq!(w.list_margin, 2); assert_eq!(w.list_margin, 2);
assert_eq!(w.links_space_char, "_"); assert_eq!(w.links_space_char, "_");
assert!(w.auto_toc); assert!(w.auto_toc);
assert!(w.auto_generate_links);
assert!(w.auto_generate_tags); // int 1 → true
assert!(w.auto_diary_index);
assert_eq!(w.toc_header, "Table of Contents"); assert_eq!(w.toc_header, "Table of Contents");
assert_eq!(w.toc_header_level, 2); assert_eq!(w.toc_header_level, 2);
assert_eq!(w.links_header, "All Pages"); assert_eq!(w.links_header, "All Pages");
+33
View File
@@ -305,6 +305,39 @@ fn build_toc_text_with_no_headings_is_just_the_heading() {
assert_eq!(out, "= Contents =\n"); assert_eq!(out, "= Contents =\n");
} }
#[test]
fn links_rebuild_edit_only_acts_when_section_present() {
let pages = vec!["Home".to_string(), "About".to_string()];
let uri = Url::from_file_path("/w/Home.wiki").unwrap();
let without = "= Home =\nbody\n";
assert!(
ops::links_rebuild_edit(
without,
&parse(without),
&uri,
"Home",
&pages,
true,
"Generated Links",
1
)
.is_none(),
"must not insert a links section into a page that lacks one"
);
let with = "= Generated Links =\n- [[old]]\n";
assert!(ops::links_rebuild_edit(
with,
&parse(with),
&uri,
"Home",
&pages,
true,
"Generated Links",
1
)
.is_some());
}
#[test] #[test]
fn find_section_range_stops_at_same_level_sibling() { fn find_section_range_stops_at_same_level_sibling() {
// A non-level-1 generated section (e.g. a level-2 tags index) must not // A non-level-1 generated section (e.g. a level-2 tags index) must not
+17 -3
View File
@@ -98,9 +98,18 @@ fix site.
auto_toc save hook) and `find_section_range` matches the configured text so auto_toc save hook) and `find_section_range` matches the configured text so
regen stays idempotent. Tests: `captions_honour_custom_header_and_level` regen stays idempotent. Tests: `captions_honour_custom_header_and_level`
(`link_health.rs`) + config round-trip in `index_and_config.rs`. (`link_health.rs`) + config round-trip in `index_and_config.rs`.
- [ ] **On-save autoregen family**`auto_diary_index`, `auto_generate_links`, - [x] **On-save autoregen family**added per-wiki `auto_generate_links`,
`auto_generate_tags`, `auto_tags` (only `auto_toc` / `auto_export` exist). `auto_generate_tags`, `auto_diary_index` (all default `false`, like upstream),
_Fix:_ `config.rs` `RawWiki`/`WikiConfig` + the `didSave` path. wired into the `did_save` hook (`crates/nuwiki-lsp/src/lib.rs`) mirroring
`auto_toc`: regen runs only when the section already exists
(`links_rebuild_edit` / `tag_links_rebuild_edit`), and `auto_diary_index`
regenerates the diary index when a dated diary *entry* is saved
(`diary_generate_index_edit`, which handles an open/closed/absent index file).
`auto_tags` is an **intentional divergence** (see below): nuwiki re-indexes on
every change, so tag metadata is always fresh — there's no on-disk metadata
file to update on save. Tests: `links_rebuild_edit_only_acts_when_section_present`
(`link_health.rs`), `tag_links_rebuild_edit_only_acts_when_index_present`
(`commands_tags.rs`), config round-trip + defaults (`index_and_config.rs`).
- [x] **`listsym_rejected`** — the rejected glyph was a hardcoded `const '-'`. - [x] **`listsym_rejected`** — the rejected glyph was a hardcoded `const '-'`.
_Fix:_ `ListSyms` gained a `rejected` field + `new_with_rejected()` _Fix:_ `ListSyms` gained a `rejected` field + `new_with_rejected()`
(`crates/nuwiki-core/src/listsyms.rs`); per-wiki `listsym_rejected` key (default (`crates/nuwiki-core/src/listsyms.rs`); per-wiki `listsym_rejected` key (default
@@ -276,6 +285,11 @@ audits don't re-flag them.
- `key_mappings` (dict) — replaced by Lua `mappings.<group>` + the - `key_mappings` (dict) — replaced by Lua `mappings.<group>` + the
`g:nuwiki_no_<group>_mappings` globals. `g:nuwiki_no_<group>_mappings` globals.
- `use_calendar` — no calendar.vim integration. - `use_calendar` — no calendar.vim integration.
- `auto_tags` (upstream `0`) — upstream auto-updates an on-disk tag *metadata*
file on save. nuwiki has no such file: the LSP re-indexes tags on every
`didChange`, so tag search/jump/completion are always fresh. The setting's
intent is therefore always satisfied (the `auto_generate_tags` regen of the
in-buffer links section is the configurable on-save piece).
- Mouse maps (`<2-LeftMouse>`, `<MiddleMouse>`, …) — upstream binds them - Mouse maps (`<2-LeftMouse>`, `<MiddleMouse>`, …) — upstream binds them
unconditionally; nuwiki ships them **opt-in** (`mappings.mouse` / unconditionally; nuwiki ships them **opt-in** (`mappings.mouse` /
`g:nuwiki_mouse_mappings`). Deliberate, so they're not always-on. `g:nuwiki_mouse_mappings`). Deliberate, so they're not always-on.
+6
View File
@@ -196,6 +196,12 @@ match upstream vimwiki.
`css_name` `'style.css'` `css_name` `'style.css'`
`auto_export` `false` — export to HTML on save. `auto_export` `false` — export to HTML on save.
`auto_toc` `false` — refresh TOC on save. `auto_toc` `false` — refresh TOC on save.
`auto_generate_links` `false` — regen an existing Generated Links section
on save.
`auto_generate_tags` `false` — regen an existing Generated Tags index on
save.
`auto_diary_index` `false` — regen the diary index when a diary entry is
saved.
`toc_header` `'Contents'` (+ `toc_header_level` `1`) — :VimwikiTOC. `toc_header` `'Contents'` (+ `toc_header_level` `1`) — :VimwikiTOC.
`links_header` `'Generated Links'` (+ `links_header_level` `1`). `links_header` `'Generated Links'` (+ `links_header_level` `1`).
`tags_header` `'Generated Tags'` (+ `tags_header_level` `1`). `tags_header` `'Generated Tags'` (+ `tags_header_level` `1`).
+1
View File
@@ -25,6 +25,7 @@ M.defaults = {
-- diary_caption_level, diary_sort, diary_header -- diary_caption_level, diary_sort, diary_header
-- html_path, template_path, template_default, template_ext, -- html_path, template_path, template_default, template_ext,
-- template_date_format, css_name, auto_export, auto_toc, -- template_date_format, css_name, auto_export, auto_toc,
-- auto_generate_links, auto_generate_tags, auto_diary_index,
-- html_filename_parameterization, exclude_files, color_dic -- html_filename_parameterization, exclude_files, color_dic
-- toc_header, toc_header_level, links_header, links_header_level, -- toc_header, toc_header_level, links_header, links_header_level,
-- tags_header, tags_header_level, -- tags_header, tags_header_level,