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
+6 -3
View File
@@ -306,7 +306,8 @@ fn nuwiki_toc_generates_nested_contents() {
let src = "= Top =\n== Sub ==\ntext\n";
let doc = parse(src);
let uri = Url::from_file_path("/wiki/Page.wiki").unwrap();
let edit = ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0).expect("toc edit produced");
let edit =
ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0, 0).expect("toc edit produced");
assert!(edit.changes.is_some() || edit.document_changes.is_some());
let toc = ops::build_toc_text(
@@ -317,6 +318,7 @@ fn nuwiki_toc_generates_nested_contents() {
"Contents",
1,
0,
0,
);
assert!(toc.contains("= Contents ="));
assert!(toc.contains("- [[#top|Top]]"));
@@ -327,7 +329,7 @@ fn nuwiki_toc_generates_nested_contents() {
#[test]
fn nuwiki_generate_links_lists_all_pages_excluding_current() {
let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()];
let text = ops::build_links_text(&pages, "Links", 1, Some("Home"), 0);
let text = ops::build_links_text(&pages, "Links", 1, Some("Home"), 0, None);
assert!(text.contains("= Links ="));
assert!(text.contains("- [[About]]"));
assert!(text.contains("- [[Notes]]"));
@@ -345,7 +347,8 @@ fn nuwiki_generate_links_lists_all_pages_excluding_current() {
true,
"Generated Links",
1,
0
0,
None
)
.is_some());
}
+21 -5
View File
@@ -45,7 +45,7 @@ fn blank_table_handles_single_cell_grid() {
fn align_pads_short_cells_to_widest() {
let src = "|a|bbb|c|\n|--|--|--|\n|1|2|three|\n";
let ast = parse(src);
let edit = ops::table_align_edit(src, &ast, &uri(), 0, true).expect("edit");
let edit = ops::table_align_edit(src, &ast, &uri(), 0, true, false).expect("edit");
let body = one_text(edit);
let lines: Vec<&str> = body.lines().collect();
eprintln!("rendered table:");
@@ -64,7 +64,23 @@ fn align_pads_short_cells_to_widest() {
fn align_returns_none_when_cursor_off_table() {
let src = "paragraph\n";
let ast = parse(src);
assert!(ops::table_align_edit(src, &ast, &uri(), 0, true).is_none());
assert!(ops::table_align_edit(src, &ast, &uri(), 0, true, false).is_none());
}
#[test]
fn table_reduce_last_col_keeps_last_column_minimal() {
// With reduce_last_col=true, the last column isn't padded to its content
// width — it stays at the minimum (1), so `three` is not surrounded by
// alignment padding on the right edge.
let src = "|a|bbb|c|\n|--|--|--|\n|1|2|three|\n";
let ast = parse(src);
let edit = ops::table_align_edit(src, &ast, &uri(), 0, true, true).expect("edit");
let body = one_text(edit);
let lines: Vec<&str> = body.lines().collect();
// First two columns still align; the last column stays at width 1 (not 5),
// so the header's last cell renders `| c |` not `| c |`.
assert_eq!(lines[0], "| a | bbb | c |", "got: {:?}", lines[0]);
assert_eq!(lines[2], "| 1 | 2 | three |", "got: {:?}", lines[2]);
}
// ===== moveColumn =====
@@ -74,7 +90,7 @@ fn move_column_right_swaps_with_neighbour() {
let src = "|a|b|c|\n|--|--|--|\n|1|2|3|\n";
let ast = parse(src);
// Cursor on first column → swap right.
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, 1, true).expect("edit");
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, 1, true, false).expect("edit");
let body = one_text(edit);
let lines: Vec<&str> = body.lines().collect();
assert!(lines[0].contains("b") && lines[0].contains("a"));
@@ -90,7 +106,7 @@ fn move_column_left_at_zero_is_noop() {
let src = "|a|b|\n|--|--|\n|1|2|\n";
let ast = parse(src);
// Cursor in column 0; left would be -1 → returns None.
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, -1, true);
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, -1, true, false);
assert!(edit.is_none());
}
@@ -98,7 +114,7 @@ fn move_column_left_at_zero_is_noop() {
fn move_column_returns_none_off_table() {
let src = "paragraph\n";
let ast = parse(src);
assert!(ops::table_move_column_edit(src, &ast, &uri(), 0, 0, 1, true).is_none());
assert!(ops::table_move_column_edit(src, &ast, &uri(), 0, 0, 1, true, false).is_none());
}
// ===== COMMANDS list =====
@@ -295,6 +295,63 @@ fn defaults_carry_vimwiki_per_wiki_keys() {
// HTML section numbering is off by default (vimwiki's `0`).
assert_eq!(cfg.html.html_header_numbering, 0);
assert_eq!(cfg.html.html_header_numbering_sym, "");
// New config defaults match upstream.
assert!(cfg.create_link);
assert_eq!(cfg.dir_link, "");
assert_eq!(cfg.bullet_types, vec!["-", "*", "#"]);
assert!(!cfg.cycle_bullets);
assert!(!cfg.generated_links_caption);
assert_eq!(cfg.toc_link_format, 0);
assert!(!cfg.table_reduce_last_col);
assert_eq!(cfg.html.rss_name, "rss.xml");
assert_eq!(cfg.html.rss_max_items, 10);
assert!(cfg.html.list_ignore_newline);
assert!(cfg.html.text_ignore_newline);
assert!(cfg.html.emoji_enable);
assert!(cfg.html.user_htmls.is_empty());
assert!(cfg.html.valid_html_tags.contains(&"sub".to_string()));
// color_dic ships a populated palette (vimwiki seeds one).
assert!(cfg.html.color_dic.contains_key("red"));
}
#[test]
fn raw_wiki_parses_config_batch_keys() {
let cfg = config_from_json(serde_json::json!({
"wikis": [{
"root": "/tmp/w",
"create_link": false,
"dir_link": "index",
"bullet_types": ["+", "-"],
"cycle_bullets": true,
"generated_links_caption": 1,
"toc_link_format": 1,
"table_reduce_last_col": true,
"rss_name": "feed.xml",
"rss_max_items": 5,
"valid_html_tags": "b,mark",
"list_ignore_newline": 0,
"text_ignore_newline": false,
"emoji_enable": 0,
"user_htmls": ["404.html"],
"color_tag_template": "<u>__CONTENT__</u>",
}],
}));
let w = &cfg.wikis[0];
assert!(!w.create_link);
assert_eq!(w.dir_link, "index");
assert_eq!(w.bullet_types, vec!["+", "-"]);
assert!(w.cycle_bullets);
assert!(w.generated_links_caption);
assert_eq!(w.toc_link_format, 1);
assert!(w.table_reduce_last_col);
assert_eq!(w.html.rss_name, "feed.xml");
assert_eq!(w.html.rss_max_items, 5);
assert_eq!(w.html.valid_html_tags, vec!["b", "mark"]);
assert!(!w.html.list_ignore_newline);
assert!(!w.html.text_ignore_newline);
assert!(!w.html.emoji_enable);
assert_eq!(w.html.user_htmls, vec!["404.html"]);
assert_eq!(w.html.color_tag_template, "<u>__CONTENT__</u>");
}
#[test]
+60 -17
View File
@@ -290,7 +290,7 @@ fn build_toc_text_nests_by_level() {
(2u8, "Sub".into(), "sub".into()),
(1u8, "Other".into(), "other".into()),
];
let out = ops::build_toc_text(&items, "Contents", 1, 0);
let out = ops::build_toc_text(&items, "Contents", 1, 0, 0);
assert!(out.starts_with("= Contents =\n"));
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "= Contents =");
@@ -301,10 +301,36 @@ fn build_toc_text_nests_by_level() {
#[test]
fn build_toc_text_with_no_headings_is_just_the_heading() {
let out = ops::build_toc_text(&[], "Contents", 1, 0);
let out = ops::build_toc_text(&[], "Contents", 1, 0, 0);
assert_eq!(out, "= Contents =\n");
}
#[test]
fn toc_link_format_1_drops_the_description() {
// vimwiki toc_link_format: 1 = `[[#anchor]]` (no `|title`); 0 = with title.
let items = vec![(1u8, "Top".into(), "top".into())];
let f0 = ops::build_toc_text(&items, "Contents", 1, 0, 0);
assert!(f0.contains("- [[#top|Top]]"), "format 0: {f0}");
let f1 = ops::build_toc_text(&items, "Contents", 1, 0, 1);
assert!(f1.contains("- [[#top]]"), "format 1: {f1}");
assert!(!f1.contains("|Top"), "format 1 has no description: {f1}");
}
#[test]
fn generated_links_caption_emits_first_heading() {
use std::collections::BTreeMap;
let pages = vec!["About".to_string(), "Notes".to_string()];
let mut caps = BTreeMap::new();
caps.insert("About".to_string(), "About Us".to_string());
// Notes has no caption → falls back to bare [[Notes]].
let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0, Some(&caps));
assert!(out.contains("- [[About|About Us]]"), "captioned: {out}");
assert!(out.contains("- [[Notes]]"), "fallback: {out}");
// Without the caption map, both are bare.
let bare = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None);
assert!(bare.contains("- [[About]]") && !bare.contains("About Us"), "bare: {bare}");
}
#[test]
fn links_rebuild_edit_only_acts_when_section_present() {
let pages = vec!["Home".to_string(), "About".to_string()];
@@ -320,7 +346,8 @@ fn links_rebuild_edit_only_acts_when_section_present() {
true,
"Generated Links",
1,
0
0,
None
)
.is_none(),
"must not insert a links section into a page that lacks one"
@@ -335,7 +362,8 @@ fn links_rebuild_edit_only_acts_when_section_present() {
true,
"Generated Links",
1,
0
0,
None
)
.is_some());
}
@@ -360,15 +388,15 @@ fn find_section_range_stops_at_same_level_sibling() {
#[test]
fn captions_honour_custom_header_and_level() {
// toc_header="Table of Contents", level 2 → `== Table of Contents ==`.
let out = ops::build_toc_text(&[], "Table of Contents", 2, 0);
let out = ops::build_toc_text(&[], "Table of Contents", 2, 0, 0);
assert_eq!(out, "== Table of Contents ==\n");
// links_header at level 3.
let pages = vec!["A".to_string(), "B".to_string()];
let links = ops::build_links_text(&pages, "All Pages", 3, None, 0);
let links = ops::build_links_text(&pages, "All Pages", 3, None, 0, None);
assert!(links.starts_with("=== All Pages ===\n"));
// level clamps to 1..=6.
assert!(ops::build_toc_text(&[], "X", 9, 0).starts_with("====== X ======"));
assert!(ops::build_toc_text(&[], "X", 0, 0).starts_with("= X ="));
assert!(ops::build_toc_text(&[], "X", 9, 0, 0).starts_with("====== X ======"));
assert!(ops::build_toc_text(&[], "X", 0, 0, 0).starts_with("= X ="));
}
#[test]
@@ -377,7 +405,7 @@ fn list_margin_indents_generated_bullets_not_headings() {
// The heading stays at column 0; bullets get `margin` spaces (TOC keeps
// its per-depth nesting *after* the base margin).
let pages = vec!["A".to_string(), "B".to_string()];
let links = ops::build_links_text(&pages, "Generated Links", 1, None, 2);
let links = ops::build_links_text(&pages, "Generated Links", 1, None, 2, None);
assert!(links.contains("\n - [[A]]\n"), "2-space margin: {links:?}");
assert!(
links.starts_with("= Generated Links =\n"),
@@ -392,6 +420,7 @@ fn list_margin_indents_generated_bullets_not_headings() {
"Contents",
1,
2,
0,
);
// Top bullet: 2-space margin; Sub bullet: margin + one nesting level.
assert!(toc.contains("\n - [[#top|Top]]\n"), "top margin: {toc:?}");
@@ -403,7 +432,7 @@ fn list_margin_indents_generated_bullets_not_headings() {
#[test]
fn build_links_text_excludes_current_page() {
let pages = vec!["A".to_string(), "Home".to_string(), "B".to_string()];
let out = ops::build_links_text(&pages, "Generated Links", 1, Some("Home"), 0);
let out = ops::build_links_text(&pages, "Generated Links", 1, Some("Home"), 0, None);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "= Generated Links =");
assert!(lines.contains(&"- [[A]]"));
@@ -414,7 +443,7 @@ fn build_links_text_excludes_current_page() {
#[test]
fn build_links_text_no_excludes_includes_all() {
let pages = vec!["A".to_string(), "B".to_string()];
let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0);
let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None);
assert!(out.contains("[[A]]"));
assert!(out.contains("[[B]]"));
}
@@ -452,7 +481,7 @@ fn toc_edit_inserts_at_top_when_no_existing_toc() {
let src = "= One =\n== Two ==\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0).expect("got an edit");
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).expect("got an edit");
let changes = edit.changes.expect("changes map");
let edits = &changes[&uri];
assert_eq!(edits.len(), 1);
@@ -468,7 +497,7 @@ fn toc_edit_replaces_existing_toc() {
let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0).expect("got an edit");
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).expect("got an edit");
let changes = edit.changes.expect("changes map");
let edits = &changes[&uri];
let te = &edits[0];
@@ -483,7 +512,7 @@ fn toc_edit_returns_none_for_empty_doc() {
let src = "";
let ast = parse(src);
let uri = Url::parse("file:///tmp/empty.wiki").unwrap();
assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0).is_none());
assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).is_none());
}
#[test]
@@ -494,7 +523,7 @@ fn toc_rebuild_edit_only_acts_when_toc_already_present() {
let without = "= One =\n== Two ==\n";
let ast = parse(without);
assert!(
ops::toc_rebuild_edit(without, &ast, &uri, true, "Contents", 1, 0).is_none(),
ops::toc_rebuild_edit(without, &ast, &uri, true, "Contents", 1, 0, 0).is_none(),
"auto_toc should not insert a TOC where none existed"
);
@@ -502,7 +531,7 @@ fn toc_rebuild_edit_only_acts_when_toc_already_present() {
let with = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
let ast = parse(with);
let edit =
ops::toc_rebuild_edit(with, &ast, &uri, true, "Contents", 1, 0).expect("rebuild edit");
ops::toc_rebuild_edit(with, &ast, &uri, true, "Contents", 1, 0, 0).expect("rebuild edit");
let te = &edit.changes.unwrap()[&uri][0];
assert!(te.new_text.contains("[[#real|Real]]"));
assert!(!te.new_text.contains("Stale"));
@@ -526,6 +555,7 @@ fn links_edit_inserts_when_section_absent() {
"Generated Links",
1,
0,
None,
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
@@ -550,6 +580,7 @@ fn links_edit_replaces_when_section_present() {
"Generated Links",
1,
0,
None,
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
@@ -563,7 +594,19 @@ fn links_edit_returns_none_for_empty_page_list() {
let src = "Hi\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
assert!(ops::links_edit(src, &ast, &uri, "Home", &[], true, "Generated Links", 1, 0).is_none());
assert!(ops::links_edit(
src,
&ast,
&uri,
"Home",
&[],
true,
"Generated Links",
1,
0,
None
)
.is_none());
}
// ===== find_orphans =====