From c63ec679aeb5ccac153cfe999effb163ed0578be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Sat, 30 May 2026 23:12:08 -0300 Subject: [PATCH] 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 --- crates/nuwiki-lsp/src/commands.rs | 15 ++++ crates/nuwiki-lsp/src/config.rs | 61 +++++--------- crates/nuwiki-lsp/src/diary.rs | 70 ++++++++++++++--- crates/nuwiki-lsp/src/export.rs | 3 +- crates/nuwiki-lsp/src/lib.rs | 58 ++++++++++++-- crates/nuwiki-lsp/src/rename.rs | 13 +++ crates/nuwiki-lsp/tests/commands_coverage.rs | 30 ++++++- crates/nuwiki-lsp/tests/diary.rs | 57 +++++++++++++- crates/nuwiki-lsp/tests/html_export.rs | 49 ++++++++++++ crates/nuwiki-lsp/tests/index_and_config.rs | 83 +++++++++++++++++--- crates/nuwiki-lsp/tests/link_health.rs | 21 +++++ 11 files changed, 383 insertions(+), 77 deletions(-) diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 2df2290..e3335b4 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -1936,6 +1936,20 @@ pub mod ops { Some(b.build()) } + /// Like [`toc_edit`], but only when the page *already* has a + /// `= Contents =` section — used by the `auto_toc`-on-save hook so we + /// rebuild an existing TOC without inserting one into pages that never + /// had one. + pub fn toc_rebuild_edit( + text: &str, + ast: &DocumentNode, + uri: &Url, + utf8: bool, + ) -> Option { + find_section_range(ast, TOC_HEADING)?; + toc_edit(text, ast, uri, utf8) + } + /// Produce a `WorkspaceEdit` that replaces or inserts the /// auto-generated links section for the given document. pub fn links_edit( @@ -3232,6 +3246,7 @@ pub mod export_ops { DiaryDate::today_utc(), &cfg.html, Some(cfg.file_extension.as_str()), + cfg.list_margin, std::collections::HashMap::new(), )?; let out_path = output_path_for(&cfg.html, name); diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs index 186dd60..1ce9213 100644 --- a/crates/nuwiki-lsp/src/config.rs +++ b/crates/nuwiki-lsp/src/config.rs @@ -41,9 +41,6 @@ pub struct WikiConfig { /// diary commands use. `daily` is supported; the others fall through /// to vimwiki-style names but the commands are no-ops for them. pub diary_frequency: String, - /// First day of the week (`monday`..`sunday`). Used by weekly - /// diary entry naming. - pub diary_start_week_day: String, /// Caption level for the diary index page headings (1 = h1). pub diary_caption_level: u8, /// Newest-first (`desc`) or oldest-first (`asc`) in the diary @@ -51,10 +48,6 @@ pub struct WikiConfig { pub diary_sort: String, /// H1 text for the diary index page. pub diary_header: String, - /// Maximum heading level treated as a *navigation* anchor — beyond - /// it the LSP doesn't synthesise an anchor symbol. Matches - /// vimwiki's `g:vimwiki_maxhi` (default `1`). - pub maxhi: u8, /// Vimwiki's `g:vimwiki_listsyms`. The 5-character string maps /// fractional progress states for checkboxes; the i-th char (0..=4) /// is the rendered marker for state `i / 4`. Default: `" .oOX"`. @@ -68,9 +61,6 @@ pub struct WikiConfig { /// targets when writing to disk (a single space `" "` by default, /// i.e. spaces are kept verbatim). pub links_space_char: String, - /// `nested_syntaxes` — code-block language → editor filetype map - /// used for syntax-highlighting fenced blocks. Empty by default. - pub nested_syntaxes: std::collections::HashMap, /// Rebuild the page's TOC on save when set. pub auto_toc: bool, /// HTML export options. @@ -183,16 +173,13 @@ impl WikiConfig { diary_rel_path: d.diary_rel_path, diary_index: d.diary_index, diary_frequency: d.diary_frequency, - diary_start_week_day: d.diary_start_week_day, diary_caption_level: d.diary_caption_level, diary_sort: d.diary_sort, diary_header: d.diary_header, - maxhi: d.maxhi, listsyms: d.listsyms, listsyms_propagate: d.listsyms_propagate, list_margin: d.list_margin, links_space_char: d.links_space_char, - nested_syntaxes: d.nested_syntaxes, auto_toc: d.auto_toc, html: HtmlConfig::default(), } @@ -213,16 +200,13 @@ impl WikiConfig { diary_rel_path: d.diary_rel_path, diary_index: d.diary_index, diary_frequency: d.diary_frequency, - diary_start_week_day: d.diary_start_week_day, diary_caption_level: d.diary_caption_level, diary_sort: d.diary_sort, diary_header: d.diary_header, - maxhi: d.maxhi, listsyms: d.listsyms, listsyms_propagate: d.listsyms_propagate, list_margin: d.list_margin, links_space_char: d.links_space_char, - nested_syntaxes: d.nested_syntaxes, auto_toc: d.auto_toc, html, } @@ -278,10 +262,6 @@ fn default_diary_frequency() -> String { "daily".to_string() } -fn default_diary_start_week_day() -> String { - "monday".to_string() -} - fn default_diary_sort() -> String { "desc".to_string() } @@ -307,16 +287,13 @@ fn wiki_defaults() -> WikiDefaults { diary_rel_path: default_diary_rel_path(), diary_index: default_diary_index(), diary_frequency: default_diary_frequency(), - diary_start_week_day: default_diary_start_week_day(), diary_caption_level: 1, diary_sort: default_diary_sort(), diary_header: default_diary_header(), - maxhi: 1, listsyms: default_listsyms(), listsyms_propagate: true, list_margin: -1, links_space_char: default_links_space_char(), - nested_syntaxes: std::collections::HashMap::new(), auto_toc: false, } } @@ -326,16 +303,13 @@ struct WikiDefaults { diary_rel_path: String, diary_index: String, diary_frequency: String, - diary_start_week_day: String, diary_caption_level: u8, diary_sort: String, diary_header: String, - maxhi: u8, listsyms: String, listsyms_propagate: bool, list_margin: i32, links_space_char: String, - nested_syntaxes: std::collections::HashMap, auto_toc: bool, } @@ -389,16 +363,15 @@ impl Config { /// editors that namespace settings by server name continue to work. pub fn apply_change(&mut self, value: &serde_json::Value) { // Unwrap { "nuwiki": { ... } } → { ... } so both clients work. + // Only unwrap when the lone key isn't itself a recognised top-level + // setting — otherwise a minimal payload like + // `{ "diagnostic": { ... } }` would be mistaken for a namespace + // wrapper and its contents silently dropped. let inner = value .as_object() - .and_then(|m| { - if m.len() == 1 { - m.values().next() - } else { - None - } - }) - .filter(|v| v.is_object()) + .and_then(|m| if m.len() == 1 { m.iter().next() } else { None }) + .filter(|(k, v)| v.is_object() && !is_init_option_key(k)) + .map(|(_, v)| v) .unwrap_or(value); let raw: InitOptions = match serde_json::from_value(inner.clone()) { @@ -460,6 +433,17 @@ mod opt_bool_or_int { } } +/// Recognised top-level keys in the flat `initializationOptions` shape. +/// Used to tell a real single-key payload (e.g. `{ "diagnostic": {…} }`) +/// apart from an editor namespace wrapper (e.g. `{ "nuwiki": {…} }`) in +/// [`Config::apply_change`]. +fn is_init_option_key(key: &str) -> bool { + matches!( + key, + "wikis" | "wiki_root" | "file_extension" | "syntax" | "log_level" | "diagnostic" + ) +} + #[derive(Debug, Default, Deserialize)] #[serde(default, rename_all = "snake_case")] struct InitOptions { @@ -532,16 +516,12 @@ struct RawWiki { #[serde(default)] diary_frequency: Option, #[serde(default)] - diary_start_week_day: Option, - #[serde(default)] diary_caption_level: Option, #[serde(default)] diary_sort: Option, #[serde(default)] diary_header: Option, #[serde(default)] - maxhi: Option, - #[serde(default)] listsyms: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] listsyms_propagate: Option, @@ -549,8 +529,6 @@ struct RawWiki { list_margin: Option, #[serde(default)] links_space_char: Option, - #[serde(default)] - nested_syntaxes: Option>, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] auto_toc: Option, } @@ -603,16 +581,13 @@ impl From for WikiConfig { diary_rel_path: r.diary_rel_path.unwrap_or(d.diary_rel_path), diary_index: r.diary_index.unwrap_or(d.diary_index), diary_frequency: r.diary_frequency.unwrap_or(d.diary_frequency), - diary_start_week_day: r.diary_start_week_day.unwrap_or(d.diary_start_week_day), diary_caption_level: r.diary_caption_level.unwrap_or(d.diary_caption_level), diary_sort: r.diary_sort.unwrap_or(d.diary_sort), diary_header: r.diary_header.unwrap_or(d.diary_header), - maxhi: r.maxhi.unwrap_or(d.maxhi), listsyms: r.listsyms.unwrap_or(d.listsyms), listsyms_propagate: r.listsyms_propagate.unwrap_or(d.listsyms_propagate), list_margin: r.list_margin.unwrap_or(d.list_margin), links_space_char: r.links_space_char.unwrap_or(d.links_space_char), - nested_syntaxes: r.nested_syntaxes.unwrap_or(d.nested_syntaxes), auto_toc: r.auto_toc.unwrap_or(d.auto_toc), html, } diff --git a/crates/nuwiki-lsp/src/diary.rs b/crates/nuwiki-lsp/src/diary.rs index ed291f1..c14a611 100644 --- a/crates/nuwiki-lsp/src/diary.rs +++ b/crates/nuwiki-lsp/src/diary.rs @@ -168,37 +168,78 @@ fn period_first_day(p: &DiaryPeriod) -> DiaryDate { p.first_day() } -/// Generate the body of the diary index page — a flat newest-first list -/// of `[[diary/YYYY-MM-DD]]` wikilinks, grouped under year and month +/// Ordering of entries in the diary index page. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiarySort { + /// Newest first (vimwiki default). + Desc, + /// Oldest first. + Asc, +} + +impl DiarySort { + /// Parse a wiki's `diary_sort` field. Anything other than `asc` + /// (case-insensitive) is treated as the default `desc`. + pub fn parse(s: &str) -> Self { + if s.trim().eq_ignore_ascii_case("asc") { + Self::Asc + } else { + Self::Desc + } + } +} + +/// Render a heading line at `level` (1 = `= … =`). Clamped to vimwiki's +/// 1..=6 range so a stray config value can't emit a 200-equals heading. +fn heading(level: u8, text: &str) -> String { + let n = level.clamp(1, 6) as usize; + let eq = "=".repeat(n); + format!("{eq} {text} {eq}") +} + +/// Generate the body of the diary index page — a flat list of +/// `[[diary/YYYY-MM-DD]]` wikilinks, grouped under year and month /// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`. +/// +/// `caption_level` sets the level of the top caption; the year and month +/// subheadings nest one and two levels below it. `sort` controls whether +/// the flat list runs newest- or oldest-first. pub fn build_index_body( entries: &[DiaryEntry], diary_rel_path: &str, index_heading: &str, + sort: DiarySort, + caption_level: u8, ) -> String { let mut out = String::new(); - out.push_str("= "); - out.push_str(index_heading); - out.push_str(" =\n"); + out.push_str(&heading(caption_level, index_heading)); + out.push('\n'); if entries.is_empty() { return out; } - // Sort descending by date — newest first matches vimwiki. let mut sorted: Vec<&DiaryEntry> = entries.iter().collect(); - sorted.sort_by(|a, b| b.date.cmp(&a.date)); + sorted.sort_by(|a, b| match sort { + DiarySort::Desc => b.date.cmp(&a.date), + DiarySort::Asc => a.date.cmp(&b.date), + }); + let year_level = caption_level.saturating_add(1); + let month_level = caption_level.saturating_add(2); let mut current_year: Option = None; let mut current_month: Option = None; for e in sorted { if current_year != Some(e.date.year) { - out.push_str(&format!("\n== {} ==\n", e.date.year)); + out.push('\n'); + out.push_str(&heading(year_level, &e.date.year.to_string())); + out.push('\n'); current_year = Some(e.date.year); current_month = None; } if current_month != Some(e.date.month) { - out.push_str(&format!("=== {} ===\n", month_name(e.date.month))); + out.push_str(&heading(month_level, month_name(e.date.month))); + out.push('\n'); current_month = Some(e.date.month); } out.push_str(&format!( @@ -211,9 +252,16 @@ pub fn build_index_body( } /// Render the diary-index body for the given wiki's currently-indexed -/// entries. Convenience wrapper around [`build_index_body`]. +/// entries. Convenience wrapper around [`build_index_body`] that honours +/// the wiki's `diary_header`, `diary_sort`, and `diary_caption_level`. pub fn render_index_body(cfg: &WikiConfig, index: &WorkspaceIndex) -> String { - build_index_body(&list_entries(index), &cfg.diary_rel_path, "Diary") + build_index_body( + &list_entries(index), + &cfg.diary_rel_path, + &cfg.diary_header, + DiarySort::parse(&cfg.diary_sort), + cfg.diary_caption_level, + ) } /// Cheap "is this path under the diary subdir of this wiki" check — diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs index 88de5a8..ac7a545 100644 --- a/crates/nuwiki-lsp/src/export.rs +++ b/crates/nuwiki-lsp/src/export.rs @@ -200,6 +200,7 @@ pub fn render_page_html( today: DiaryDate, cfg: &HtmlConfig, wiki_extension: Option<&str>, + list_margin: i32, extra_vars: HashMap, ) -> std::io::Result { let date_str = match doc.metadata.date.as_deref() { @@ -224,7 +225,7 @@ pub fn render_page_html( vars.insert(k, v); } - let mut r = HtmlRenderer::new(); + let mut r = HtmlRenderer::new().with_list_margin(list_margin); if let Some(ext) = wiki_extension.filter(|e| !e.trim_start_matches('.').is_empty()) { // Strip the wiki extension from page links before the default resolver // turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index b9356a3..939a432 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -504,8 +504,33 @@ impl LanguageServer for Backend { // is out of scope here; for now we just accept the new // values so later commands see the right diagnostic settings etc. // Multi-wiki support revisits this with proper rebuild semantics. - let mut cfg = self.config.write().expect("config lock poisoned"); - cfg.apply_change(¶ms.settings); + { + let mut cfg = self.config.write().expect("config lock poisoned"); + cfg.apply_change(¶ms.settings); + } + + // A severity change (e.g. diagnostic.link_severity) only affects + // already-published diagnostics once we re-run link-health checks for + // open docs — otherwise the editor keeps showing the old severity until + // the next edit. Refresh every wiki's open docs against the new config. + let wiki_ids: Vec = self + .wikis + .read() + .expect("wikis lock poisoned") + .iter() + .map(|w| w.id) + .collect(); + for wiki_id in wiki_ids { + refresh_open_diagnostics_for_wiki( + wiki_id, + Arc::clone(&self.documents), + Arc::clone(&self.wikis), + Arc::clone(&self.config), + self.client.clone(), + Arc::clone(&self.use_utf8), + ) + .await; + } } async fn shutdown(&self) -> LspResult<()> { @@ -575,15 +600,36 @@ impl LanguageServer for Backend { } async fn did_save(&self, params: DidSaveTextDocumentParams) { - // When `auto_export` is set on the resolved wiki, run - // the equivalent of `nuwiki.export.currentToHtml` after the file - // hits disk. Best-effort — failures are logged but do not bubble - // to the client. Skips pages with the `%nohtml` directive. let uri = params.text_document.uri.clone(); let wiki = match self.wiki_for_uri(&uri) { Some(w) => w, None => return, }; + + // When `auto_toc` is set, rebuild an existing `= Contents =` section + // on save and push the change back to the buffer via + // `workspace/applyEdit`. Only rebuilds a TOC that's already there — + // it never inserts one into a page that didn't have it. + if wiki.config.auto_toc { + let edit = { + self.documents.get(&uri).and_then(|doc| { + commands::ops::toc_rebuild_edit( + &doc.text, + &doc.ast, + &uri, + self.use_utf8.load(Ordering::Relaxed), + ) + }) + }; + if let Some(edit) = edit { + let _ = self.client.apply_edit(edit).await; + } + } + + // When `auto_export` is set on the resolved wiki, run + // the equivalent of `nuwiki.export.currentToHtml` after the file + // hits disk. Best-effort — failures are logged but do not bubble + // to the client. Skips pages with the `%nohtml` directive. if !wiki.config.html.auto_export { return; } diff --git a/crates/nuwiki-lsp/src/rename.rs b/crates/nuwiki-lsp/src/rename.rs index a2be058..c582a78 100644 --- a/crates/nuwiki-lsp/src/rename.rs +++ b/crates/nuwiki-lsp/src/rename.rs @@ -35,6 +35,9 @@ pub(crate) async fn compute_rename( ) -> Option { let wiki = backend.wiki_for_uri(&target_uri)?; let old_name = page_name_from_uri(&target_uri, Some(&wiki.config.root)); + // Spaces in the link *target* (and the file on disk) are replaced with + // the wiki's `links_space_char`; descriptions keep their original text. + let new_name = apply_links_space_char(&new_name, &wiki.config.links_space_char); let new_uri = build_new_uri(&wiki.config.root, &new_name, &wiki.config.file_extension)?; if new_uri == target_uri { return None; @@ -73,6 +76,16 @@ pub(crate) async fn compute_rename( } } +/// Replace spaces in a link path with the configured `links_space_char`. +/// The default `" "` is an identity transform (spaces kept verbatim). +pub fn apply_links_space_char(name: &str, space_char: &str) -> String { + if space_char == " " { + name.to_string() + } else { + name.replace(' ', space_char) + } +} + /// Build `/()` as a `file://` URL. pub fn build_new_uri(root: &Path, new_name: &str, ext: &str) -> Option { let mut path = root.to_path_buf(); diff --git a/crates/nuwiki-lsp/tests/commands_coverage.rs b/crates/nuwiki-lsp/tests/commands_coverage.rs index 6aca03a..620612f 100644 --- a/crates/nuwiki-lsp/tests/commands_coverage.rs +++ b/crates/nuwiki-lsp/tests/commands_coverage.rs @@ -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(); diff --git a/crates/nuwiki-lsp/tests/diary.rs b/crates/nuwiki-lsp/tests/diary.rs index ae60cd0..9a8bfa0 100644 --- a/crates/nuwiki-lsp/tests/diary.rs +++ b/crates/nuwiki-lsp/tests/diary.rs @@ -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] diff --git a/crates/nuwiki-lsp/tests/html_export.rs b/crates/nuwiki-lsp/tests/html_export.rs index ed1a517..7a729f2 100644 --- a/crates/nuwiki-lsp/tests/html_export.rs +++ b/crates/nuwiki-lsp/tests/html_export.rs @@ -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: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("
    "), + "top-level margin: {styled}" + ); + // Exactly one styled list — the nested `
      ` 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] diff --git a/crates/nuwiki-lsp/tests/index_and_config.rs b/crates/nuwiki-lsp/tests/index_and_config.rs index 36fe0df..6530e46 100644 --- a/crates/nuwiki-lsp/tests/index_and_config.rs +++ b/crates/nuwiki-lsp/tests/index_and_config.rs @@ -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: diff --git a/crates/nuwiki-lsp/tests/link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs index 5c24a8d..0872aac 100644 --- a/crates/nuwiki-lsp/tests/link_health.rs +++ b/crates/nuwiki-lsp/tests/link_health.rs @@ -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]