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:
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user