feat(config): P3 config batch — group 1 (clean config + plumbing)

Config plumbing for the whole config batch (HtmlConfig + WikiConfig fields,
RawWiki deserialization, From/defaults) plus these behaviors:

- rss_name / rss_max_items: HtmlConfig fields (wired into write_rss in the RSS
  group).
- toc_link_format: build_toc_text emits [[#anchor]] (no description) for 1,
  [[#anchor|title]] for 0.
- generated_links_caption: build_links_text emits [[page|FirstHeading]] from a
  page->heading map (ops::page_captions) when enabled.
- table_reduce_last_col: column_widths clamps the last column to width 1 when
  set; threaded through table_align_edit/table_move_column_edit + commands.
- color_dic now ships a populated default palette (red/green/blue/...).
- commentstring aligned to upstream's no-space `%%%s`.
- auto_chdir (default off): :lcd into the owning wiki root on buffer enter;
  both clients (ftplugin.lua setup_auto_chdir + Vim NuwikiAutoChdir augroup),
  with config.wiki_root_for / nuwiki#commands#wiki_root_for resolvers.

Also seeded HtmlConfig fields for later groups (valid_html_tags,
list/text_ignore_newline, emoji_enable, user_htmls, color_tag_template) and
WikiConfig (create_link, dir_link, bullet_types, cycle_bullets).

Tests: toc_link_format, generated_links_caption, table_reduce_last_col,
config defaults + JSON round-trip for every new key. Full rust suite +
both keymap harnesses green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 12:04:36 +00:00
parent 03005d0931
commit b2f2fc88bd
11 changed files with 522 additions and 48 deletions
+104 -19
View File
@@ -338,8 +338,16 @@ fn toc_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceE
.as_ref()
.map(|w| w.config.list_margin.max(0) as usize)
.unwrap_or(0);
let link_format = wiki.as_ref().map(|w| w.config.toc_link_format).unwrap_or(0);
Ok(ops::toc_edit(
&doc.text, &doc.ast, &p.uri, utf8, &header, level, margin,
&doc.text,
&doc.ast,
&p.uri,
utf8,
&header,
level,
margin,
link_format,
))
}
@@ -355,12 +363,16 @@ fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<Workspac
None => return Ok(None),
};
let current_page = crate::index::page_name_from_uri(&p.uri, Some(&wiki.config.root));
let pages = {
let (pages, captions) = {
let idx = wiki
.index
.read()
.map_err(|_| "index lock poisoned".to_string())?;
idx.page_names()
let captions = wiki
.config
.generated_links_caption
.then(|| ops::page_captions(&idx));
(idx.page_names(), captions)
};
Ok(ops::links_edit(
&doc.text,
@@ -372,6 +384,7 @@ fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<Workspac
&wiki.config.links_header,
wiki.config.links_header_level,
wiki.config.list_margin.max(0) as usize,
captions.as_ref(),
))
}
@@ -1105,8 +1118,12 @@ fn table_align(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEd
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let (line, _) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8);
let reduce = backend
.wiki_for_uri(&p.uri)
.map(|w| w.config.table_reduce_last_col)
.unwrap_or(false);
Ok(ops::table_align_edit(
&doc.text, &doc.ast, &p.uri, line, utf8,
&doc.text, &doc.ast, &p.uri, line, utf8, reduce,
))
}
@@ -1135,6 +1152,10 @@ fn table_move_column(backend: &Backend, args: Vec<Value>) -> Result<Option<Works
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let (line, col) = nav::lsp_to_byte_pos(parsed.position, &doc.text, utf8);
let reduce = backend
.wiki_for_uri(&parsed.uri)
.map(|w| w.config.table_reduce_last_col)
.unwrap_or(false);
Ok(ops::table_move_column_edit(
&doc.text,
&doc.ast,
@@ -1143,6 +1164,7 @@ fn table_move_column(backend: &Backend, args: Vec<Value>) -> Result<Option<Works
col,
delta,
utf8,
reduce,
))
}
@@ -1832,6 +1854,7 @@ pub mod ops {
heading_name: &str,
level: u8,
margin: usize,
link_format: u8,
) -> String {
let mut out = caption_line(heading_name, level);
if items.is_empty() {
@@ -1845,23 +1868,47 @@ pub mod ops {
for _ in 0..depth {
out.push_str(" ");
}
out.push_str("- [[#");
out.push_str(anchor);
out.push('|');
out.push_str(title);
out.push_str("]]\n");
// vimwiki `toc_link_format`: 0 = `[[#anchor|title]]` (with
// description), 1 = `[[#anchor]]` (anchor only).
if link_format == 1 {
out.push_str("- [[#");
out.push_str(anchor);
out.push_str("]]\n");
} else {
out.push_str("- [[#");
out.push_str(anchor);
out.push('|');
out.push_str(title);
out.push_str("]]\n");
}
}
out
}
/// Build a flat list of wikilinks to every page (sorted), optionally
/// excluding `current_page`.
/// Map each indexed page name → its first heading's title (vimwiki's
/// caption for `generated_links_caption`). Pages with no heading are
/// omitted, so `build_links_text` falls back to `[[page]]` for them.
pub fn page_captions(
index: &crate::index::WorkspaceIndex,
) -> std::collections::BTreeMap<String, String> {
let mut map = std::collections::BTreeMap::new();
for name in index.page_names() {
if let Some(h) = index.page_by_name(&name).and_then(|p| p.headings.first()) {
map.insert(name, h.title.clone());
}
}
map
}
pub fn build_links_text(
pages: &[String],
heading_name: &str,
level: u8,
exclude: Option<&str>,
margin: usize,
captions: Option<&std::collections::BTreeMap<String, String>>,
) -> String {
let mut out = caption_line(heading_name, level);
let pad = " ".repeat(margin);
@@ -1872,6 +1919,12 @@ pub mod ops {
out.push_str(&pad);
out.push_str("- [[");
out.push_str(name);
// vimwiki `generated_links_caption`: when a caption map is supplied
// and the page has a first-heading, emit `[[page|Heading]]`.
if let Some(cap) = captions.and_then(|c| c.get(name)).filter(|c| !c.is_empty()) {
out.push('|');
out.push_str(cap);
}
out.push_str("]]\n");
}
out
@@ -1998,6 +2051,7 @@ pub mod ops {
heading: &str,
level: u8,
margin: usize,
link_format: u8,
) -> Option<WorkspaceEdit> {
let items = collect_toc_items(ast, heading);
if items.is_empty() {
@@ -2007,7 +2061,7 @@ pub mod ops {
.into_iter()
.map(|it| (it.level, it.title, it.anchor))
.collect();
let new_text = build_toc_text(&triples, heading, level, margin);
let new_text = build_toc_text(&triples, heading, level, margin, link_format);
let mut b = WorkspaceEditBuilder::new();
match find_section_range(ast, heading) {
Some((start, end)) => {
@@ -2044,9 +2098,10 @@ pub mod ops {
heading: &str,
level: u8,
margin: usize,
link_format: u8,
) -> Option<WorkspaceEdit> {
find_section_range(ast, heading)?;
toc_edit(text, ast, uri, utf8, heading, level, margin)
toc_edit(text, ast, uri, utf8, heading, level, margin, link_format)
}
/// Produce a `WorkspaceEdit` that replaces or inserts the
@@ -2062,11 +2117,19 @@ pub mod ops {
heading: &str,
level: u8,
margin: usize,
captions: Option<&std::collections::BTreeMap<String, String>>,
) -> Option<WorkspaceEdit> {
if all_pages.is_empty() {
return None;
}
let new_text = build_links_text(all_pages, heading, level, Some(current_page), margin);
let new_text = build_links_text(
all_pages,
heading,
level,
Some(current_page),
margin,
captions,
);
let mut b = WorkspaceEditBuilder::new();
match find_section_range(ast, heading) {
Some((start, end)) => {
@@ -2096,6 +2159,7 @@ pub mod ops {
heading: &str,
level: u8,
margin: usize,
captions: Option<&std::collections::BTreeMap<String, String>>,
) -> Option<WorkspaceEdit> {
find_section_range(ast, heading)?;
links_edit(
@@ -2108,6 +2172,7 @@ pub mod ops {
heading,
level,
margin,
captions,
)
}
@@ -2863,9 +2928,10 @@ pub mod ops {
uri: &Url,
line: u32,
utf8: bool,
reduce_last_col: bool,
) -> Option<WorkspaceEdit> {
let table = find_table_at_line(ast, line)?;
let rendered = render_aligned_table(table, text)?;
let rendered = render_aligned_table(table, text, reduce_last_col)?;
let span = table.span;
let mut b = WorkspaceEditBuilder::new();
b.edit(uri.clone(), text_edit_replace(span, rendered, text, utf8));
@@ -2874,6 +2940,7 @@ pub mod ops {
/// Swap the column under cursor with its left (`delta = -1`) or
/// right (`delta = +1`) neighbour across every row of the table.
#[allow(clippy::too_many_arguments)]
pub fn table_move_column_edit(
text: &str,
ast: &DocumentNode,
@@ -2882,6 +2949,7 @@ pub mod ops {
col: u32,
delta: i32,
utf8: bool,
reduce_last_col: bool,
) -> Option<WorkspaceEdit> {
let table = find_table_at_line(ast, line)?;
let cur_col = cell_column_at(table, text, line, col)?;
@@ -2895,7 +2963,7 @@ pub mod ops {
if other >= max_cols {
return None;
}
let rendered = render_swapped_table(table, text, cur_col, other)?;
let rendered = render_swapped_table(table, text, cur_col, other, reduce_last_col)?;
let mut b = WorkspaceEditBuilder::new();
b.edit(
uri.clone(),
@@ -2966,7 +3034,7 @@ pub mod ops {
}
/// Compute column widths (max content length across rows).
fn column_widths(table: &TableNode, text: &str) -> Vec<usize> {
fn column_widths(table: &TableNode, text: &str, reduce_last_col: bool) -> Vec<usize> {
let max_cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
let mut widths = vec![1usize; max_cols];
for row in &table.rows {
@@ -2977,6 +3045,13 @@ pub mod ops {
}
}
}
// vimwiki `table_reduce_last_col`: keep the last column at its minimum
// so it isn't padded out to fill.
if reduce_last_col {
if let Some(last) = widths.last_mut() {
*last = 1;
}
}
widths
}
@@ -2987,8 +3062,12 @@ pub mod ops {
.all(|c| c == '-' || c == ':' || c.is_whitespace())
}
fn render_aligned_table(table: &TableNode, text: &str) -> Option<String> {
let widths = column_widths(table, text);
fn render_aligned_table(
table: &TableNode,
text: &str,
reduce_last_col: bool,
) -> Option<String> {
let widths = column_widths(table, text, reduce_last_col);
let mut out = String::new();
for (i, row) in table.rows.iter().enumerate() {
render_row(row, text, &widths, &mut out);
@@ -3037,10 +3116,16 @@ pub mod ops {
out.push('\n');
}
fn render_swapped_table(table: &TableNode, text: &str, a: usize, b: usize) -> Option<String> {
fn render_swapped_table(
table: &TableNode,
text: &str,
a: usize,
b: usize,
reduce_last_col: bool,
) -> Option<String> {
// Swap the column-width slots first so the rendered table
// remains visually aligned after the move.
let mut widths = column_widths(table, text);
let mut widths = column_widths(table, text, reduce_last_col);
if a < widths.len() && b < widths.len() {
widths.swap(a, b);
}
+188 -1
View File
@@ -108,6 +108,28 @@ pub struct WikiConfig {
/// (`tags_header` / `tags_header_level`).
pub tags_header: String,
pub tags_header_level: u8,
/// vimwiki `create_link` (default `true`): when following a link to a
/// missing page, create it. When `false`, missing-link follow is a no-op.
pub create_link: bool,
/// vimwiki `dir_link`: index file opened when following a link to a
/// directory (e.g. `index` → `dir/index.wiki`). Empty = open the directory.
pub dir_link: String,
/// vimwiki `bullet_types`: ordered list of unordered-bullet glyphs for
/// this wiki's syntax (default `-`, `*`, `#`); drives `cycle_bullets`.
pub bullet_types: Vec<String>,
/// vimwiki `cycle_bullets` (default `false`): rotate an unordered item's
/// glyph through `bullet_types` as it is indented/dedented.
pub cycle_bullets: bool,
/// vimwiki `generated_links_caption` (default `false`): emit
/// `[[page|Heading]]` (first heading as caption) from `:VimwikiGenerateLinks`.
pub generated_links_caption: bool,
/// vimwiki `toc_link_format` (default `0`): `0` = `[[#anchor|title]]`
/// (leaf anchor, with description); `1` = `[[#parent#child]]` (full path,
/// no description).
pub toc_link_format: u8,
/// vimwiki `table_reduce_last_col` (default `false`): don't pad the last
/// table column to fill — keep it at its minimum width.
pub table_reduce_last_col: bool,
/// HTML export options.
pub html: HtmlConfig,
}
@@ -155,6 +177,32 @@ pub struct HtmlConfig {
/// vimwiki `html_header_numbering_sym`: symbol appended after the
/// section number (e.g. `.` → `1.2. Heading`). Empty by default.
pub html_header_numbering_sym: String,
/// vimwiki `rss_name`: filename of the generated RSS feed (default
/// `rss.xml`), relative to `html_path`.
pub rss_name: String,
/// vimwiki `rss_max_items`: cap on the number of diary items in the RSS
/// feed (default `10`; `0` = unlimited-but-empty per upstream's min).
pub rss_max_items: usize,
/// vimwiki `valid_html_tags`: inline HTML tag names that pass through to
/// HTML export unescaped (everything else has `<`/`>` escaped). Default
/// `b,i,s,u,sub,sup,kbd,br,hr,div,center,strong,em`.
pub valid_html_tags: Vec<String>,
/// vimwiki `list_ignore_newline` (default `true`): a single newline inside
/// a list item is a space in HTML export; when `false` it becomes `<br>`.
pub list_ignore_newline: bool,
/// vimwiki `text_ignore_newline` (default `true`): a single newline inside
/// a paragraph is a space in HTML export; when `false` it becomes `<br>`.
pub text_ignore_newline: bool,
/// vimwiki `emoji_enable` (default `true`): substitute `:alias:` emoji
/// shortcodes with their glyph during HTML export.
pub emoji_enable: bool,
/// vimwiki `user_htmls`: basenames of HTML files with no wiki source that
/// `allToHtml` must not prune. Empty by default.
pub user_htmls: Vec<String>,
/// vimwiki `color_tag_template`: HTML template for a colour span. The
/// `__COLORFG__` / `__COLORBG__` / `__CONTENT__` placeholders are filled
/// from `color_dic`. Default matches upstream's `<span style=…>` template.
pub color_tag_template: String,
}
impl Default for HtmlConfig {
@@ -169,16 +217,56 @@ impl Default for HtmlConfig {
auto_export: false,
html_filename_parameterization: false,
exclude_files: Vec::new(),
color_dic: std::collections::HashMap::new(),
color_dic: default_color_dic(),
custom_wiki2html: String::new(),
custom_wiki2html_args: String::new(),
base_url: String::new(),
html_header_numbering: 0,
html_header_numbering_sym: String::new(),
rss_name: "rss.xml".into(),
rss_max_items: 10,
valid_html_tags: default_valid_html_tags(),
list_ignore_newline: true,
text_ignore_newline: true,
emoji_enable: true,
user_htmls: Vec::new(),
color_tag_template: default_color_tag_template(),
}
}
}
/// vimwiki's default `valid_html_tags` — inline tags allowed through export.
fn default_valid_html_tags() -> Vec<String> {
"b,i,s,u,sub,sup,kbd,br,hr,div,center,strong,em"
.split(',')
.map(|s| s.to_string())
.collect()
}
/// vimwiki's default `color_tag_template`.
fn default_color_tag_template() -> String {
"<span style=\"__STYLE__\">__CONTENT__</span>".to_string()
}
/// A populated default `color_dic` (vimwiki ships a palette rather than the
/// empty map nuwiki previously defaulted to). Names → CSS colour value.
fn default_color_dic() -> std::collections::HashMap<String, String> {
[
("default", "inherit"),
("red", "red"),
("green", "green"),
("blue", "blue"),
("yellow", "#b8860b"),
("magenta", "magenta"),
("cyan", "darkcyan"),
("gray", "gray"),
("grey", "gray"),
]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
impl HtmlConfig {
/// Synthesise default paths under `root` when the user hasn't
/// specified explicit ones. `html_path` defaults to `<root>/_html`
@@ -257,6 +345,13 @@ impl WikiConfig {
links_header_level: d.links_header_level,
tags_header: d.tags_header,
tags_header_level: d.tags_header_level,
create_link: d.create_link,
dir_link: d.dir_link,
bullet_types: d.bullet_types,
cycle_bullets: d.cycle_bullets,
generated_links_caption: d.generated_links_caption,
toc_link_format: d.toc_link_format,
table_reduce_last_col: d.table_reduce_last_col,
html: HtmlConfig::default(),
}
}
@@ -297,6 +392,13 @@ impl WikiConfig {
links_header_level: d.links_header_level,
tags_header: d.tags_header,
tags_header_level: d.tags_header_level,
create_link: d.create_link,
dir_link: d.dir_link,
bullet_types: d.bullet_types,
cycle_bullets: d.cycle_bullets,
generated_links_caption: d.generated_links_caption,
toc_link_format: d.toc_link_format,
table_reduce_last_col: d.table_reduce_last_col,
html,
}
}
@@ -462,9 +564,21 @@ fn wiki_defaults() -> WikiDefaults {
links_header_level: 1,
tags_header: default_tags_header(),
tags_header_level: 1,
create_link: true,
dir_link: String::new(),
bullet_types: default_bullet_types(),
cycle_bullets: false,
generated_links_caption: false,
toc_link_format: 0,
table_reduce_last_col: false,
}
}
/// vimwiki's default `bullet_types` for the `vimwiki` syntax.
fn default_bullet_types() -> Vec<String> {
["-", "*", "#"].iter().map(|s| s.to_string()).collect()
}
struct WikiDefaults {
index: String,
diary_rel_path: String,
@@ -491,6 +605,13 @@ struct WikiDefaults {
links_header_level: u8,
tags_header: String,
tags_header_level: u8,
create_link: bool,
dir_link: String,
bullet_types: Vec<String>,
cycle_bullets: bool,
generated_links_caption: bool,
toc_link_format: u8,
table_reduce_last_col: bool,
}
impl Config {
@@ -699,6 +820,22 @@ struct RawWiki {
html_header_numbering: Option<u8>,
#[serde(default)]
html_header_numbering_sym: Option<String>,
#[serde(default)]
rss_name: Option<String>,
#[serde(default)]
rss_max_items: Option<usize>,
#[serde(default)]
valid_html_tags: Option<String>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
list_ignore_newline: Option<bool>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
text_ignore_newline: Option<bool>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
emoji_enable: Option<bool>,
#[serde(default)]
user_htmls: Option<Vec<String>>,
#[serde(default)]
color_tag_template: Option<String>,
// per-wiki keys mirroring vimwiki globals. All optional so
// existing single-wiki configs migrate without touching anything.
#[serde(default)]
@@ -747,6 +884,20 @@ struct RawWiki {
tags_header: Option<String>,
#[serde(default)]
tags_header_level: Option<u8>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
create_link: Option<bool>,
#[serde(default)]
dir_link: Option<String>,
#[serde(default)]
bullet_types: Option<Vec<String>>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
cycle_bullets: Option<bool>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
generated_links_caption: Option<bool>,
#[serde(default)]
toc_link_format: Option<u8>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
table_reduce_last_col: Option<bool>,
}
impl From<RawWiki> for WikiConfig {
@@ -802,6 +953,30 @@ impl From<RawWiki> for WikiConfig {
if let Some(s) = r.html_header_numbering_sym {
html.html_header_numbering_sym = s;
}
if let Some(s) = r.rss_name {
html.rss_name = s;
}
if let Some(n) = r.rss_max_items {
html.rss_max_items = n;
}
if let Some(s) = r.valid_html_tags {
html.valid_html_tags = s.split(',').map(|t| t.trim().to_string()).collect();
}
if let Some(b) = r.list_ignore_newline {
html.list_ignore_newline = b;
}
if let Some(b) = r.text_ignore_newline {
html.text_ignore_newline = b;
}
if let Some(b) = r.emoji_enable {
html.emoji_enable = b;
}
if let Some(v) = r.user_htmls {
html.user_htmls = v;
}
if let Some(s) = r.color_tag_template {
html.color_tag_template = s;
}
let d = wiki_defaults();
let syntax = r.syntax.unwrap_or_else(|| "vimwiki".into());
// vimwiki derives `list_margin = 0` for markdown wikis when the key is
@@ -841,6 +1016,18 @@ impl From<RawWiki> for WikiConfig {
links_header_level: r.links_header_level.unwrap_or(d.links_header_level),
tags_header: r.tags_header.unwrap_or(d.tags_header),
tags_header_level: r.tags_header_level.unwrap_or(d.tags_header_level),
create_link: r.create_link.unwrap_or(d.create_link),
dir_link: r.dir_link.unwrap_or(d.dir_link),
bullet_types: r
.bullet_types
.filter(|v| !v.is_empty())
.unwrap_or(d.bullet_types),
cycle_bullets: r.cycle_bullets.unwrap_or(d.cycle_bullets),
generated_links_caption: r
.generated_links_caption
.unwrap_or(d.generated_links_caption),
toc_link_format: r.toc_link_format.unwrap_or(d.toc_link_format),
table_reduce_last_col: r.table_reduce_last_col.unwrap_or(d.table_reduce_last_col),
html,
}
}
+10 -2
View File
@@ -628,6 +628,7 @@ impl LanguageServer for Backend {
&wiki.config.toc_header,
wiki.config.toc_header_level,
wiki.config.list_margin.max(0) as usize,
wiki.config.toc_link_format,
)
})
};
@@ -642,11 +643,17 @@ impl LanguageServer for Backend {
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
let (pages, captions) = wiki
.index
.read()
.ok()
.map(|i| i.page_names())
.map(|i| {
let caps = wiki
.config
.generated_links_caption
.then(|| commands::ops::page_captions(&i));
(i.page_names(), caps)
})
.unwrap_or_default();
commands::ops::links_rebuild_edit(
&doc.text,
@@ -658,6 +665,7 @@ impl LanguageServer for Backend {
&wiki.config.links_header,
wiki.config.links_header_level,
wiki.config.list_margin.max(0) as usize,
captions.as_ref(),
)
});
if let Some(edit) = edit {