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
+15
View File
@@ -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<WorkspaceEdit> {
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);
+18 -43
View File
@@ -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<String, String>,
/// 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<String, String>,
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<String>,
#[serde(default)]
diary_start_week_day: Option<String>,
#[serde(default)]
diary_caption_level: Option<u8>,
#[serde(default)]
diary_sort: Option<String>,
#[serde(default)]
diary_header: Option<String>,
#[serde(default)]
maxhi: Option<u8>,
#[serde(default)]
listsyms: Option<String>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
listsyms_propagate: Option<bool>,
@@ -549,8 +529,6 @@ struct RawWiki {
list_margin: Option<i32>,
#[serde(default)]
links_space_char: Option<String>,
#[serde(default)]
nested_syntaxes: Option<std::collections::HashMap<String, String>>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
auto_toc: Option<bool>,
}
@@ -603,16 +581,13 @@ impl From<RawWiki> 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,
}
+59 -11
View File
@@ -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<i32> = None;
let mut current_month: Option<u8> = 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 —
+2 -1
View File
@@ -200,6 +200,7 @@ pub fn render_page_html(
today: DiaryDate,
cfg: &HtmlConfig,
wiki_extension: Option<&str>,
list_margin: i32,
extra_vars: HashMap<String, String>,
) -> std::io::Result<String> {
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
+52 -6
View File
@@ -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(&params.settings);
{
let mut cfg = self.config.write().expect("config lock poisoned");
cfg.apply_change(&params.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<crate::wiki::WikiId> = 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;
}
+13
View File
@@ -35,6 +35,9 @@ pub(crate) async fn compute_rename(
) -> Option<WorkspaceEdit> {
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 `<root>/<new_name>(<ext>)` as a `file://` URL.
pub fn build_new_uri(root: &Path, new_name: &str, ext: &str) -> Option<Url> {
let mut path = root.to_path_buf();