feat(lsp): wire diary/auto_toc/links_space_char/list_margin; drop client-side keys

Consume several per-wiki config keys that the server deserialized but
ignored, and remove keys whose effect is purely client-side:

- diagnostics: re-publish open-doc diagnostics on
  didChangeConfiguration so a link_severity change takes effect
  immediately; fix the single-key unwrap in apply_change so a minimal
  `{ diagnostic: {...} }` payload isn't mistaken for a namespace wrapper.
- auto_toc: rebuild an existing TOC section on save (new
  ops::toc_rebuild_edit, no-op when the page has no TOC).
- diary index: honour diary_header, diary_sort and diary_caption_level
  when rendering the diary index body.
- links_space_char: apply on rename so spaces in the link target and
  the on-disk path become the configured glyph (default " " = verbatim).
- list_margin: thread the per-wiki value into render_page_html.
- remove nested_syntaxes, maxhi and diary_start_week_day from the server
  config: nested-syntax and heading highlighting are client-side, and
  the weekly diary is ISO-week based so a custom week start has no clean
  server-side meaning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 23:12:08 -03:00
parent 0741c349db
commit c63ec679ae
11 changed files with 383 additions and 77 deletions
+28 -2
View File
@@ -21,7 +21,7 @@ use nuwiki_lsp::commands::{export_ops, ops, COMMANDS};
use nuwiki_lsp::config::WikiConfig;
use nuwiki_lsp::index::WorkspaceIndex;
use nuwiki_lsp::nav::find_inline_at;
use nuwiki_lsp::rename::{build_new_uri, rewrite_wikilink_target};
use nuwiki_lsp::rename::{apply_links_space_char, build_new_uri, rewrite_wikilink_target};
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki};
use nuwiki_lsp::{diary, export};
use tower_lsp::lsp_types::Url;
@@ -177,6 +177,30 @@ fn nuwiki_rename_file_builds_uri_and_rewrites_links() {
);
}
// links_space_char rewrites spaces in the link target / on-disk path while
// the default of a single space keeps the name verbatim.
#[test]
fn links_space_char_replaces_spaces_in_target_path() {
// Default " " — identity, spaces preserved.
assert_eq!(apply_links_space_char("My Page", " "), "My Page");
// Custom char — spaces become the configured glyph.
assert_eq!(apply_links_space_char("My Page", "_"), "My_Page");
// Subdir separators are untouched; only spaces within segments change.
assert_eq!(
apply_links_space_char("sub dir/My Page", "_"),
"sub_dir/My_Page"
);
// The transformed name flows into both the new URI and the rewritten link.
let renamed = apply_links_space_char("New Page", "-");
let new_uri = build_new_uri(&PathBuf::from("/wiki"), &renamed, ".wiki").unwrap();
assert!(new_uri.as_str().ends_with("/wiki/New-Page.wiki"));
assert_eq!(
rewrite_wikilink_target("[[Old Name|caption]]", &renamed),
Some("[[New-Page|caption]]".to_string())
);
}
// :NuwikiDeleteFile — delete the current page and its on-disk file.
#[test]
fn nuwiki_delete_file_emits_delete_workspace_edit() {
@@ -238,7 +262,7 @@ fn nuwiki_diary_index_resolves_index_uri() {
fn nuwiki_diary_generate_links_builds_grouped_body() {
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-04-30"]);
let entries = diary::list_entries(&idx);
let body = diary::build_index_body(&entries, "diary", "Diary");
let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Desc, 1);
assert!(body.starts_with("= Diary ="));
assert!(body.contains("2026-05-12"));
assert!(body.contains("2026-05-10"));
@@ -407,6 +431,7 @@ fn nuwiki_2html_renders_page() {
DiaryDate::from_ymd(2026, 5, 12).unwrap(),
&cfg.html,
Some(".wiki"),
-1,
HashMap::new(),
)
.unwrap();
@@ -428,6 +453,7 @@ fn nuwiki_2html_browse_shares_render_and_is_advertised() {
DiaryDate::today_utc(),
&cfg.html,
Some(".wiki"),
-1,
HashMap::new(),
)
.unwrap();
+55 -2
View File
@@ -274,7 +274,7 @@ fn build_index_body_is_newest_first_grouped_by_year_month() {
&["2025-12-31", "2026-05-11", "2026-05-12", "2026-04-30"],
);
let entries = diary::list_entries(&idx);
let body = diary::build_index_body(&entries, "diary", "Diary");
let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Desc, 1);
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines[0], "= Diary =");
// First date in output should be the latest: 2026-05-12
@@ -292,10 +292,46 @@ fn build_index_body_is_newest_first_grouped_by_year_month() {
#[test]
fn build_index_body_empty_when_no_entries() {
let body = diary::build_index_body(&[], "diary", "Diary");
let body = diary::build_index_body(&[], "diary", "Diary", diary::DiarySort::Desc, 1);
assert_eq!(body, "= Diary =\n");
}
#[test]
fn build_index_body_ascending_sort_lists_oldest_first() {
let idx = build_index_with_entries("/tmp/diarySort", &["2026-05-11", "2026-05-12"]);
let entries = diary::list_entries(&idx);
let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Asc, 1);
let older = body.find("2026-05-11").unwrap();
let newer = body.find("2026-05-12").unwrap();
assert!(older < newer, "ascending sort lists oldest first");
}
#[test]
fn build_index_body_caption_level_shifts_all_headings() {
let idx = build_index_with_entries("/tmp/diaryCap", &["2026-05-11"]);
let entries = diary::list_entries(&idx);
let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Desc, 2);
assert!(body.contains("== Diary =="), "caption at level 2");
assert!(
body.contains("=== 2026 ==="),
"year nests one below caption"
);
assert!(
body.contains("==== May ===="),
"month nests two below caption"
);
}
#[test]
fn build_index_body_clamps_caption_level_to_six() {
let idx = build_index_with_entries("/tmp/diaryClamp", &["2026-05-11"]);
let entries = diary::list_entries(&idx);
// caption_level 6 → year/month would be 7/8 but clamp to 6.
let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Desc, 6);
assert!(body.contains("====== Diary ======"));
assert!(!body.contains("======="), "no heading exceeds level 6");
}
#[test]
fn render_index_body_round_trip() {
let cfg = wiki_cfg("/tmp/diaryA");
@@ -305,6 +341,23 @@ fn render_index_body_round_trip() {
assert!(body.contains("[[diary/2026-05-11]]"));
}
#[test]
fn render_index_body_honours_custom_header_sort_and_caption() {
let mut cfg = wiki_cfg("/tmp/diaryCustom");
cfg.diary_header = "Journal".into();
cfg.diary_sort = "asc".into();
cfg.diary_caption_level = 2;
let idx = build_index_with_entries("/tmp/diaryCustom", &["2026-05-11", "2026-05-12"]);
let body = diary::render_index_body(&cfg, &idx);
assert!(
body.contains("== Journal =="),
"custom header at caption level 2"
);
let older = body.find("2026-05-11").unwrap();
let newer = body.find("2026-05-12").unwrap();
assert!(older < newer, "asc sort honoured");
}
// ===== is_in_diary =====
#[test]
+49
View File
@@ -243,6 +243,7 @@ fn render_page_html_substitutes_title_content_and_extras() {
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
-1,
extras,
)
.unwrap();
@@ -262,6 +263,7 @@ fn render_page_html_uses_metadata_date_when_set() {
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
-1,
HashMap::new(),
)
.unwrap();
@@ -279,6 +281,7 @@ fn render_page_html_root_path_for_nested_pages() {
DiaryDate::today_utc(),
&c.html,
None,
-1,
HashMap::new(),
)
.unwrap();
@@ -298,6 +301,7 @@ fn render_page_html_extra_var_overrides_default() {
DiaryDate::today_utc(),
&c.html,
None,
-1,
extras,
)
.unwrap();
@@ -320,6 +324,7 @@ fn render_page_html_substitutes_vimwiki_percent_template() {
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
-1,
HashMap::new(),
)
.unwrap();
@@ -346,6 +351,7 @@ fn render_page_html_strips_wiki_extension_from_links() {
DiaryDate::today_utc(),
&c.html,
Some(".wiki"),
-1,
HashMap::new(),
)
.unwrap();
@@ -355,6 +361,49 @@ fn render_page_html_strips_wiki_extension_from_links() {
assert!(html.contains("href=\"posts/index.html\""), "plain: {html}");
}
#[test]
fn render_page_html_list_margin_only_styles_top_level_list() {
// `list_margin >= 0` forces a `margin-left:<n>em` on the outermost
// list; nested lists and the default of `-1` stay unstyled.
let ast = parse("- a\n - b\n");
let c = cfg("/tmp/x");
let styled = export::render_page_html(
&ast,
Some("{{content}}".into()),
"index",
DiaryDate::today_utc(),
&c.html,
None,
2,
HashMap::new(),
)
.unwrap();
assert!(
styled.contains("<ul style=\"margin-left:2em\">"),
"top-level margin: {styled}"
);
// Exactly one styled list — the nested `<ul>` is plain.
assert_eq!(
styled.matches("margin-left").count(),
1,
"only top: {styled}"
);
let plain = export::render_page_html(
&ast,
Some("{{content}}".into()),
"index",
DiaryDate::today_utc(),
&c.html,
None,
-1,
HashMap::new(),
)
.unwrap();
assert!(!plain.contains("margin-left"), "default unstyled: {plain}");
}
// ===== fallback_template + DEFAULT_CSS =====
#[test]
+71 -12
View File
@@ -267,16 +267,13 @@ fn defaults_carry_vimwiki_per_wiki_keys() {
let cfg = WikiConfig::empty();
assert_eq!(cfg.index, "index");
assert_eq!(cfg.diary_frequency, "daily");
assert_eq!(cfg.diary_start_week_day, "monday");
assert_eq!(cfg.diary_caption_level, 1);
assert_eq!(cfg.diary_sort, "desc");
assert_eq!(cfg.diary_header, "Diary");
assert_eq!(cfg.maxhi, 1);
assert_eq!(cfg.listsyms, " .oOX");
assert!(cfg.listsyms_propagate);
assert_eq!(cfg.list_margin, -1);
assert_eq!(cfg.links_space_char, " ");
assert!(cfg.nested_syntaxes.is_empty());
assert!(!cfg.auto_toc);
}
@@ -287,35 +284,26 @@ fn raw_wiki_parses_every_new_key() {
"root": "/tmp/w",
"index": "home",
"diary_frequency": "weekly",
"diary_start_week_day": "sunday",
"diary_caption_level": 2,
"diary_sort": "asc",
"diary_header": "Journal",
"maxhi": 6,
"listsyms": "abcde",
"listsyms_propagate": false,
"list_margin": 2,
"links_space_char": "_",
"nested_syntaxes": { "python": "python", "ruby": "ruby" },
"auto_toc": true,
}],
}));
let w = &cfg.wikis[0];
assert_eq!(w.index, "home");
assert_eq!(w.diary_frequency, "weekly");
assert_eq!(w.diary_start_week_day, "sunday");
assert_eq!(w.diary_caption_level, 2);
assert_eq!(w.diary_sort, "asc");
assert_eq!(w.diary_header, "Journal");
assert_eq!(w.maxhi, 6);
assert_eq!(w.listsyms, "abcde");
assert!(!w.listsyms_propagate);
assert_eq!(w.list_margin, 2);
assert_eq!(w.links_space_char, "_");
assert_eq!(
w.nested_syntaxes.get("python").map(String::as_str),
Some("python")
);
assert!(w.auto_toc);
}
@@ -367,6 +355,77 @@ fn apply_change_preserves_severity_on_partial_update() {
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error);
}
#[test]
fn apply_change_minimal_diagnostic_payload_is_not_unwrapped() {
// A lone `{ "diagnostic": { … } }` is a real flat payload, not a
// `{ "nuwiki": { … } }` namespace wrapper. The single-key unwrap must
// skip it (it's a recognised top-level key) so the severity applies.
let mut cfg = config_from_json(json!({ "wiki_root": "/tmp/w" }));
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Warning);
cfg.apply_change(&json!({ "diagnostic": { "link_severity": "error" } }));
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error);
// The Neovim namespace wrapper still unwraps correctly.
cfg.apply_change(&json!({ "nuwiki": { "diagnostic": { "link_severity": "hint" } } }));
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Hint);
}
#[test]
fn severity_change_via_apply_change_is_observable_in_collected_diagnostics() {
// Contract behind `did_change_configuration`'s diagnostic re-publish:
// once `apply_change` updates the severity, re-running `collect_diagnostics`
// (what the handler does for every open doc) must emit broken-link
// diagnostics at the *new* severity. Guards the data path the handler
// depends on — apply_change writing the severity AND collect_diagnostics
// reading it.
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::index::WorkspaceIndex;
use tower_lsp::lsp_types::DiagnosticSeverity;
let root = "/tmp/cfgchange";
let src = "[[GhostPage]]\n";
let ast = VimwikiSyntax::new().parse(src);
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
let uri = Url::from_file_path(format!("{root}/Home.wiki")).unwrap();
idx.upsert(uri.clone(), &ast);
let mut cfg = config_from_json(json!({
"wiki_root": root,
"diagnostic": { "link_severity": "warn" },
}));
let warn = collect_diagnostics(
&ast,
src,
Some(&uri),
Some(&idx),
Some("Home"),
&cfg.diagnostic,
true,
);
assert_eq!(warn.len(), 1, "broken link should warn");
assert_eq!(warn[0].severity, Some(DiagnosticSeverity::WARNING));
// Simulate the client raising severity via didChangeConfiguration.
cfg.apply_change(&json!({ "diagnostic": { "link_severity": "error" } }));
let err = collect_diagnostics(
&ast,
src,
Some(&uri),
Some(&idx),
Some("Home"),
&cfg.diagnostic,
true,
);
assert_eq!(err.len(), 1);
assert_eq!(
err[0].severity,
Some(DiagnosticSeverity::ERROR),
"re-collected diagnostics must reflect the new severity"
);
}
#[test]
fn vim_flat_and_neovim_wrapped_payloads_desugar_identically() {
// Parity guard for the two client shapes:
+21
View File
@@ -393,6 +393,27 @@ fn toc_edit_returns_none_for_empty_doc() {
assert!(ops::toc_edit(src, &ast, &uri, true).is_none());
}
#[test]
fn toc_rebuild_edit_only_acts_when_toc_already_present() {
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
// No existing TOC → rebuild-on-save must not insert one.
let without = "= One =\n== Two ==\n";
let ast = parse(without);
assert!(
ops::toc_rebuild_edit(without, &ast, &uri, true).is_none(),
"auto_toc should not insert a TOC where none existed"
);
// Existing TOC → rebuild refreshes it.
let with = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
let ast = parse(with);
let edit = ops::toc_rebuild_edit(with, &ast, &uri, true).expect("rebuild edit");
let te = &edit.changes.unwrap()[&uri][0];
assert!(te.new_text.contains("[[#real|Real]]"));
assert!(!te.new_text.contains("Stale"));
}
// ===== links_edit =====
#[test]