//! Server-side runtime config. //! //! Populated from `InitializeParams.initialization_options` at boot and //! refreshed via `workspace/didChangeConfiguration`. Legacy single-wiki //! shapes are accepted and desugared into the `wikis = [...]` form so //! existing setups keep working. //! //! `WikiConfig` (diary path, html path, template options, …) and the //! top-level `Config` (diagnostic severity, mappings opt-in, …) carry the //! fields actually consumed by the server. use std::path::{Path, PathBuf}; use serde::Deserialize; use tower_lsp::lsp_types::InitializeParams; #[derive(Debug, Clone, Default)] pub struct Config { pub wikis: Vec, pub diagnostic: DiagnosticConfig, } #[derive(Debug, Clone)] pub struct WikiConfig { pub name: String, pub root: PathBuf, pub file_extension: String, pub syntax: String, /// Stem of the wiki's index page (vimwiki's `index`, default /// `"index"`). `/` is what /// `:VimwikiIndex` opens. pub index: String, /// Subdirectory under `root` where diary entries live. Matches /// vimwiki's `g:vimwiki_diary_rel_path` (default `"diary"`). pub diary_rel_path: String, /// Stem of the diary index page within the diary subdirectory. /// Matches vimwiki's `g:vimwiki_diary_index` (default `"diary"`). /// The full path is `//.wiki`. pub diary_index: String, /// `daily`/`weekly`/`monthly`/`yearly`. Picks the date format the /// 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, /// Weekly-diary naming: `iso` (nuwiki default — `YYYY-Www` labels, /// always Monday-based) or `date`/`vimwiki` (the week-start day's date /// `YYYY-MM-DD`, honouring `diary_start_week_day`). Only affects /// `diary_frequency = weekly`. pub diary_weekly_style: String, /// Weekday the diary week begins on (`monday`..`sunday`, vimwiki's /// `diary_start_week_day`). Used only when `diary_weekly_style = date`. pub diary_start_week_day: String, /// Caption level for the diary index page headings (1 = h1). vimwiki's /// `diary_caption_level` (`min: -1`). nuwiki uses it as the base level of /// the year/month index tree; values `< 0` clamp to `0` (upstream's `-1` /// "read no per-page captions" mode isn't modelled — nuwiki builds the /// tree from dates, not page headers). pub diary_caption_level: i8, /// Newest-first (`desc`) or oldest-first (`asc`) in the diary /// index. pub diary_sort: String, /// H1 text for the diary index page. pub diary_header: String, /// vimwiki `diary_months`: month-number → display name for the diary /// index tree. Twelve entries (January..December) by default; a custom /// list falls back to the English name for any missing/out-of-range slot. pub diary_months: Vec, /// Vimwiki's `g:vimwiki_listsyms`. A progression of checkbox glyphs /// from "empty" (first) to "done" (last); the lexer recognises these /// glyphs and the toggle/cycle commands walk them. Any length ≥ 2 is /// honoured. Default: `" .oOX"`. pub listsyms: String, /// Vimwiki's `g:vimwiki_listsym_rejected` — the glyph for a cancelled /// checkbox (`[-]` by default). First character is used. pub listsym_rejected: String, /// When toggling a parent list item, also cascade to descendants. pub listsyms_propagate: bool, /// vimwiki `list_margin`: number of leading spaces before bullets in /// **buffer-side generated lists** — `:VimwikiGenerateLinks`, the TOC, the /// tags index, and the diary index. `>= 0` indents by that many spaces; /// `< 0` means "no margin" (default `-1`; `0` for markdown wikis). /// Divergence: upstream resolves `< 0` to the buffer's `'shiftwidth'`, /// which the server can't observe, so negatives collapse to zero indent. pub list_margin: i32, /// Character used in place of literal spaces inside wikilink /// targets when writing to disk (a single space `" "` by default, /// i.e. spaces are kept verbatim). pub links_space_char: String, /// Rebuild the page's TOC on save when set. pub auto_toc: bool, /// On save, regenerate an existing `Generated Links` section /// (vimwiki's `auto_generate_links`). pub auto_generate_links: bool, /// On save, regenerate an existing `Generated Tags` index section /// (vimwiki's `auto_generate_tags`). pub auto_generate_tags: bool, /// On save of a diary entry, regenerate the diary index page /// (vimwiki's `auto_diary_index`). pub auto_diary_index: bool, /// Heading text + level for the generated `:VimwikiTOC` section /// (vimwiki's `toc_header` / `toc_header_level`). pub toc_header: String, pub toc_header_level: u8, /// Heading text + level for `:VimwikiGenerateLinks` (`links_header` / /// `links_header_level`). pub links_header: String, pub links_header_level: u8, /// Heading text + level for the no-arg `:VimwikiGenerateTagLinks` index /// (`tags_header` / `tags_header_level`). pub tags_header: String, pub tags_header_level: u8, /// vimwiki `create_link` (default `true`): when following a link to a /// missing page, create it. When `false`, missing-link follow is a no-op. pub create_link: bool, /// vimwiki `dir_link`: index file opened when following a link to a /// directory (e.g. `index` → `dir/index.wiki`). Empty = open the directory. pub dir_link: String, /// vimwiki `bullet_types`: ordered list of unordered-bullet glyphs for /// this wiki's syntax (default `-`, `*`, `#`); drives `cycle_bullets`. pub bullet_types: Vec, /// vimwiki `cycle_bullets` (default `false`): rotate an unordered item's /// glyph through `bullet_types` as it is indented/dedented. pub cycle_bullets: bool, /// vimwiki `generated_links_caption` (default `false`): emit /// `[[page|Heading]]` (first heading as caption) from `:VimwikiGenerateLinks`. pub generated_links_caption: bool, /// vimwiki `toc_link_format` (default `0`): `0` = `[[#anchor|title]]` /// (with description); `1` = `[[#anchor]]` (anchor only, no description). pub toc_link_format: u8, /// vimwiki `table_reduce_last_col` (default `false`): don't pad the last /// table column to fill — keep it at its minimum width. pub table_reduce_last_col: bool, /// HTML export options. pub html: HtmlConfig, } /// Per-wiki HTML export settings. All paths are absolute (relative /// paths from `initializationOptions` are resolved against the wiki /// root at config-load time). #[derive(Debug, Clone)] pub struct HtmlConfig { /// Output directory for `nuwiki.export.*` commands. /// Default: `/_html`. pub html_path: PathBuf, /// Template search root. Default: `/_templates`. pub template_path: PathBuf, /// Template name used when a page has no `%template` directive. pub template_default: String, /// Template file extension appended when looking up a template. pub template_ext: String, /// `%Y`/`%m`/`%d` format string passed to `{{date}}`. pub template_date_format: String, /// CSS file name relative to `html_path` (copied/created if missing). pub css_name: String, /// Run `currentToHtml` automatically on `textDocument/didSave`. pub auto_export: bool, /// When true, the output filename for a page is URL-safe-slugified. pub html_filename_parameterization: bool, /// Glob patterns (matched against the page name relative to root) /// to skip during `allToHtml*`. pub exclude_files: Vec, /// `color_dic`: vimwiki colour-tag name → CSS value mapping used /// by the HTML renderer. Empty by default; the /// renderer falls back to `class="color-"` when a name /// isn't in the dict. pub color_dic: std::collections::HashMap, /// External wiki→HTML converter (vimwiki's `custom_wiki2html`). When /// non-empty, export shells out to it instead of the built-in renderer. pub custom_wiki2html: String, /// Extra args appended to the `custom_wiki2html` invocation. pub custom_wiki2html_args: String, /// URL prefix prepended to RSS feed / diary item links (`base_url`). pub base_url: String, /// vimwiki `html_header_numbering`: heading level at which automatic /// section numbering starts in HTML export (`0` = off, the default). pub html_header_numbering: u8, /// vimwiki `html_header_numbering_sym`: symbol appended after the /// section number (e.g. `.` → `1.2. Heading`). Empty by default. pub html_header_numbering_sym: String, /// vimwiki `toc_header`: the heading text that identifies the TOC /// section. When a heading matches this value (case-insensitive), it /// gets `class="toc"` in HTML export. pub toc_header: String, /// vimwiki `rss_name`: filename of the generated RSS feed (default /// `rss.xml`), relative to `html_path`. pub rss_name: String, /// vimwiki `rss_max_items`: cap on the number of diary items in the RSS /// feed (default `10`; `0` = unlimited-but-empty per upstream's min). pub rss_max_items: usize, /// vimwiki `valid_html_tags`: inline HTML tag names that pass through to /// HTML export unescaped (everything else has `<`/`>` escaped). Default /// `b,i,s,u,sub,sup,kbd,br,hr,div,center,strong,em`. pub valid_html_tags: Vec, /// vimwiki `list_ignore_newline` (default `true`): a single newline inside /// a list item is a space in HTML export; when `false` it becomes `
`. pub list_ignore_newline: bool, /// vimwiki `text_ignore_newline` (default `true`): a single newline inside /// a paragraph is a space in HTML export; when `false` it becomes `
`. pub text_ignore_newline: bool, /// vimwiki `emoji_enable` (default `true`): substitute `:alias:` emoji /// shortcodes with their glyph during HTML export. pub emoji_enable: bool, /// vimwiki `user_htmls`: basenames of HTML files with no wiki source that /// `allToHtml` must not prune. Empty by default. pub user_htmls: Vec, /// vimwiki `color_tag_template`: HTML template for a colour span whose name /// resolves in `color_dic`. `__STYLE__` expands to the inline style /// (`color:`) and `__CONTENT__` to the rendered inner HTML. Default /// matches upstream's `` template. Consumed by /// `HtmlRenderer::render_color`. pub color_tag_template: String, } impl Default for HtmlConfig { fn default() -> Self { Self { html_path: PathBuf::new(), template_path: PathBuf::new(), template_default: "default".into(), template_ext: ".tpl".into(), template_date_format: "%Y-%m-%d".into(), css_name: "style.css".into(), auto_export: false, html_filename_parameterization: false, exclude_files: Vec::new(), color_dic: default_color_dic(), custom_wiki2html: String::new(), custom_wiki2html_args: String::new(), base_url: String::new(), html_header_numbering: 0, html_header_numbering_sym: String::new(), toc_header: default_toc_header(), rss_name: "rss.xml".into(), rss_max_items: 10, valid_html_tags: default_valid_html_tags(), list_ignore_newline: true, text_ignore_newline: true, emoji_enable: true, user_htmls: Vec::new(), color_tag_template: default_color_tag_template(), } } } /// vimwiki's default `valid_html_tags` — inline tags allowed through export. fn default_valid_html_tags() -> Vec { "b,i,s,u,sub,sup,kbd,br,hr,div,center,strong,em" .split(',') .map(|s| s.to_string()) .collect() } /// vimwiki's default `color_tag_template`. fn default_color_tag_template() -> String { "__CONTENT__".to_string() } /// A populated default `color_dic` (vimwiki ships a palette rather than the /// empty map nuwiki previously defaulted to). Names → CSS colour value. fn default_color_dic() -> std::collections::HashMap { [ ("default", "inherit"), ("red", "red"), ("green", "green"), ("blue", "blue"), ("yellow", "#b8860b"), ("magenta", "magenta"), ("cyan", "darkcyan"), ("gray", "gray"), ("grey", "gray"), ] .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect() } impl HtmlConfig { /// Synthesise default paths under `root` when the user hasn't /// specified explicit ones. `html_path` defaults to `/_html` /// and `template_path` to `/_templates`. pub fn from_root(root: &Path) -> Self { Self { html_path: root.join("_html"), template_path: root.join("_templates"), ..Self::default() } } } #[derive(Debug, Clone, Default)] pub struct DiagnosticConfig { pub link_severity: LinkSeverity, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum LinkSeverity { Off, Hint, #[default] Warning, Error, } impl LinkSeverity { /// Parse a client-supplied severity string. Case-insensitive; /// accepts both `warn` and `warning`. Returns `None` for unknown /// values so callers can fall back to the default. pub fn parse(s: &str) -> Option { match s.trim().to_ascii_lowercase().as_str() { "off" => Some(Self::Off), "hint" => Some(Self::Hint), "warn" | "warning" => Some(Self::Warning), "error" => Some(Self::Error), _ => None, } } } impl WikiConfig { /// Synthetic default used when the client supplies neither /// `initializationOptions` nor a workspace root. `name` is empty; /// callers shouldn't depend on it for routing. pub fn empty() -> Self { let d = wiki_defaults(); Self { name: String::new(), root: PathBuf::new(), file_extension: ".wiki".into(), syntax: "vimwiki".into(), index: d.index, diary_rel_path: d.diary_rel_path, diary_index: d.diary_index, diary_frequency: d.diary_frequency, diary_weekly_style: d.diary_weekly_style, 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, diary_months: d.diary_months, listsyms: d.listsyms, listsym_rejected: d.listsym_rejected, listsyms_propagate: d.listsyms_propagate, list_margin: d.list_margin, links_space_char: d.links_space_char, auto_toc: d.auto_toc, auto_generate_links: d.auto_generate_links, auto_generate_tags: d.auto_generate_tags, auto_diary_index: d.auto_diary_index, toc_header: d.toc_header, toc_header_level: d.toc_header_level, links_header: d.links_header, links_header_level: d.links_header_level, tags_header: d.tags_header, tags_header_level: d.tags_header_level, create_link: d.create_link, dir_link: d.dir_link, bullet_types: d.bullet_types, cycle_bullets: d.cycle_bullets, generated_links_caption: d.generated_links_caption, toc_link_format: d.toc_link_format, table_reduce_last_col: d.table_reduce_last_col, html: HtmlConfig::default(), } } pub fn from_root(root: PathBuf) -> Self { let html = HtmlConfig::from_root(&root); let d = wiki_defaults(); Self { name: root .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(), root, file_extension: ".wiki".into(), syntax: "vimwiki".into(), index: d.index, diary_rel_path: d.diary_rel_path, diary_index: d.diary_index, diary_frequency: d.diary_frequency, diary_weekly_style: d.diary_weekly_style, 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, diary_months: d.diary_months, listsyms: d.listsyms, listsym_rejected: d.listsym_rejected, listsyms_propagate: d.listsyms_propagate, list_margin: d.list_margin, links_space_char: d.links_space_char, auto_toc: d.auto_toc, auto_generate_links: d.auto_generate_links, auto_generate_tags: d.auto_generate_tags, auto_diary_index: d.auto_diary_index, toc_header: d.toc_header, toc_header_level: d.toc_header_level, links_header: d.links_header, links_header_level: d.links_header_level, tags_header: d.tags_header, tags_header_level: d.tags_header_level, create_link: d.create_link, dir_link: d.dir_link, bullet_types: d.bullet_types, cycle_bullets: d.cycle_bullets, generated_links_caption: d.generated_links_caption, toc_link_format: d.toc_link_format, table_reduce_last_col: d.table_reduce_last_col, html, } } /// Absolute path to the diary subdirectory under this wiki's root. pub fn diary_dir(&self) -> PathBuf { self.root.join(&self.diary_rel_path) } /// Absolute path to this wiki's diary index page. pub fn diary_index_path(&self) -> PathBuf { let mut p = self.diary_dir(); p.push(format!("{}{}", self.diary_index, self.file_extension)); p } /// Absolute path for a diary entry on `date`. pub fn diary_path_for(&self, date: &nuwiki_core::date::DiaryDate) -> PathBuf { let mut p = self.diary_dir(); p.push(format!("{}{}", date.format(), self.file_extension)); p } /// Absolute path for a diary entry covering `period`. Honours the /// wiki's `diary_frequency` indirectly — callers compute the period /// at the right cadence and we just pick the stem from its format. pub fn diary_path_for_period(&self, period: &nuwiki_core::date::DiaryPeriod) -> PathBuf { let mut p = self.diary_dir(); p.push(format!("{}{}", period.format(), self.file_extension)); p } /// `DiaryFrequency` parsed from this wiki's `diary_frequency` field. pub fn frequency(&self) -> nuwiki_core::date::DiaryFrequency { nuwiki_core::date::DiaryFrequency::parse(&self.diary_frequency) } /// The checkbox palette for this wiki — the `listsyms` progression plus /// the configured `listsym_rejected` glyph (default `-`). pub fn list_syms(&self) -> nuwiki_core::listsyms::ListSyms { let rejected = self.listsym_rejected.chars().next().unwrap_or('-'); nuwiki_core::listsyms::ListSyms::new_with_rejected(&self.listsyms, rejected) } /// The diary navigation policy (frequency + weekly naming/week-start), /// used by the diary commands to compute today / next / prev. pub fn diary_calendar(&self) -> nuwiki_core::date::DiaryCalendar { nuwiki_core::date::DiaryCalendar::new( self.frequency(), nuwiki_core::date::WeeklyStyle::parse(&self.diary_weekly_style), nuwiki_core::date::WeekStart::parse(&self.diary_start_week_day), ) } } fn default_diary_rel_path() -> String { "diary".to_string() } fn default_diary_index() -> String { "diary".to_string() } fn default_index() -> String { "index".to_string() } fn default_diary_frequency() -> String { "daily".to_string() } fn default_diary_weekly_style() -> String { // nuwiki's original ISO-week labels; opt into `date` for vimwiki parity. "iso".to_string() } fn default_diary_start_week_day() -> String { "monday".to_string() } fn default_diary_sort() -> String { "desc".to_string() } fn default_diary_header() -> String { "Diary".to_string() } /// vimwiki's default `diary_months` — the English month names, January..December. fn default_diary_months() -> Vec { [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ] .iter() .map(|s| s.to_string()) .collect() } fn default_listsyms() -> String { " .oOX".to_string() } fn default_listsym_rejected() -> String { "-".to_string() } fn default_toc_header() -> String { "Contents".to_string() } fn default_links_header() -> String { "Generated Links".to_string() } fn default_tags_header() -> String { "Generated Tags".to_string() } fn default_links_space_char() -> String { " ".to_string() } /// Fill in the per-wiki keys that none of the constructors care /// about. Centralising the defaults here keeps `empty()`, `from_root()`, /// the legacy single-wiki path, and `From` in sync. fn wiki_defaults() -> WikiDefaults { WikiDefaults { index: default_index(), diary_rel_path: default_diary_rel_path(), diary_index: default_diary_index(), diary_frequency: default_diary_frequency(), diary_weekly_style: default_diary_weekly_style(), diary_start_week_day: default_diary_start_week_day(), // Match upstream vimwiki's `diary_caption_level` default of 0 (year // captions at the top level, months one below). Users override per-wiki. diary_caption_level: 0, diary_sort: default_diary_sort(), diary_header: default_diary_header(), diary_months: default_diary_months(), listsyms: default_listsyms(), listsym_rejected: default_listsym_rejected(), listsyms_propagate: true, list_margin: -1, links_space_char: default_links_space_char(), auto_toc: false, auto_generate_links: false, auto_generate_tags: false, auto_diary_index: false, toc_header: default_toc_header(), toc_header_level: 1, links_header: default_links_header(), links_header_level: 1, tags_header: default_tags_header(), tags_header_level: 1, create_link: true, dir_link: String::new(), bullet_types: default_bullet_types(), cycle_bullets: false, generated_links_caption: false, toc_link_format: 0, table_reduce_last_col: false, } } /// vimwiki's default `bullet_types` for the `vimwiki` syntax. fn default_bullet_types() -> Vec { ["-", "*", "#"].iter().map(|s| s.to_string()).collect() } struct WikiDefaults { index: String, diary_rel_path: String, diary_index: String, diary_frequency: String, diary_weekly_style: String, diary_start_week_day: String, diary_caption_level: i8, diary_sort: String, diary_header: String, diary_months: Vec, listsyms: String, listsym_rejected: String, listsyms_propagate: bool, list_margin: i32, links_space_char: String, auto_toc: bool, auto_generate_links: bool, auto_generate_tags: bool, auto_diary_index: bool, toc_header: String, toc_header_level: u8, links_header: String, links_header_level: u8, tags_header: String, tags_header_level: u8, create_link: bool, dir_link: String, bullet_types: Vec, cycle_bullets: bool, generated_links_caption: bool, toc_link_format: u8, table_reduce_last_col: bool, } impl Config { /// Build a `Config` from `InitializeParams`. /// /// Priority for the wikis list: /// 1. `initialization_options.wikis = [...]` (multi-wiki shape) /// 2. `initialization_options.wiki_root + file_extension + syntax` /// (legacy single-wiki shape) /// 3. `workspace_folders[0]` if present /// 4. deprecated `root_uri` if present /// 5. empty list (server stays alive but won't index anything) pub fn from_init_params(params: &InitializeParams) -> Self { let raw = params .initialization_options .as_ref() .and_then(|v| serde_json::from_value::(v.clone()).ok()) .unwrap_or_default(); let wikis = if let Some(list) = raw.wikis { list.into_iter().map(WikiConfig::from).collect() } else if let Some(wc) = params .initialization_options .as_ref() .and_then(single_wiki_from_value) { vec![wc] } else if let Some(folder) = first_workspace_folder(params) { vec![WikiConfig::from_root(folder)] } else { Vec::new() }; Config { wikis, diagnostic: diagnostic_from_raw(&raw.diagnostic), } } /// Re-apply settings received via `workspace/didChangeConfiguration`. /// On parse failure the existing config is returned unchanged. /// /// Accepts both the flat shape (`{ "wikis": [...] }`) produced by the /// VimL client, **and** the Neovim-style wrapper (`{ "nuwiki": { ... } }`) /// that `server_settings()` in `lua/nuwiki/lsp.lua` produces. Any other /// single-key object whose value is an object is also unwrapped so /// 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.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()) { Ok(r) => r, Err(_) => return, }; if let Some(list) = raw.wikis { self.wikis = list.into_iter().map(WikiConfig::from).collect(); } else if let Some(wc) = single_wiki_from_value(inner) { self.wikis = vec![wc]; } // Only touch diagnostic settings when the payload carries them, so a // partial `didChangeConfiguration` doesn't reset severity to default. if raw.diagnostic.is_some() { self.diagnostic = diagnostic_from_raw(&raw.diagnostic); } } } // ===== Wire schema ===== /// Custom serde helper: accept JSON `true`/`false` **or** integers `0`/`1` /// for `Option` fields. VimL dicts serialise `auto_export = 1` as /// the JSON number `1`, not `true`, which would otherwise silently break /// deserialisation of the entire `RawWiki` and cause the server to fall /// back to `wiki_root` (ignoring the wikis list and marking every link /// broken). mod opt_bool_or_int { use serde::{Deserialize, Deserializer}; pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let v = Option::::deserialize(deserializer)?; match v { None | Some(serde_json::Value::Null) => Ok(None), Some(serde_json::Value::Bool(b)) => Ok(Some(b)), Some(serde_json::Value::Number(ref n)) => { if let Some(i) = n.as_i64() { Ok(Some(i != 0)) } else { Err(serde::de::Error::custom( "expected bool or 0/1 integer for bool field", )) } } Some(other) => Err(serde::de::Error::custom(format!( "expected bool or 0/1 integer, got {other}" ))), } } } /// 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 { wikis: Option>, // legacy single-wiki compat fields wiki_root: Option, file_extension: Option, syntax: Option, log_level: Option, diagnostic: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "snake_case")] struct RawDiagnostic { #[serde(default)] link_severity: Option, } /// Build a [`DiagnosticConfig`] from the raw client payload, falling back /// to defaults for absent or unrecognised values. fn diagnostic_from_raw(raw: &Option) -> DiagnosticConfig { let link_severity = raw .as_ref() .and_then(|d| d.link_severity.as_deref()) .and_then(LinkSeverity::parse) .unwrap_or_default(); DiagnosticConfig { link_severity } } #[derive(Debug, Deserialize)] #[serde(rename_all = "snake_case")] struct RawWiki { #[serde(default)] name: Option, root: String, #[serde(default)] file_extension: Option, #[serde(default)] syntax: Option, #[serde(default)] diary_rel_path: Option, #[serde(default)] diary_index: Option, // HTML keys — all optional, fall back to per-root defaults. #[serde(default)] html_path: Option, #[serde(default)] template_path: Option, #[serde(default)] template_default: Option, #[serde(default)] template_ext: Option, #[serde(default)] template_date_format: Option, #[serde(default)] css_name: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] auto_export: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] html_filename_parameterization: Option, #[serde(default)] exclude_files: Option>, #[serde(default)] color_dic: Option>, #[serde(default)] custom_wiki2html: Option, #[serde(default)] custom_wiki2html_args: Option, #[serde(default)] base_url: Option, #[serde(default)] html_header_numbering: Option, #[serde(default)] html_header_numbering_sym: Option, #[serde(default)] rss_name: Option, #[serde(default)] rss_max_items: Option, #[serde(default)] valid_html_tags: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] list_ignore_newline: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] text_ignore_newline: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] emoji_enable: Option, #[serde(default)] user_htmls: Option>, #[serde(default)] color_tag_template: Option, // per-wiki keys mirroring vimwiki globals. All optional so // existing single-wiki configs migrate without touching anything. #[serde(default)] index: Option, #[serde(default)] diary_frequency: Option, #[serde(default)] diary_weekly_style: 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)] diary_months: Option>, #[serde(default)] listsyms: Option, #[serde(default)] listsym_rejected: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] listsyms_propagate: Option, #[serde(default)] list_margin: Option, #[serde(default)] links_space_char: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] auto_toc: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] auto_generate_links: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] auto_generate_tags: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] auto_diary_index: Option, #[serde(default)] toc_header: Option, #[serde(default)] toc_header_level: Option, #[serde(default)] links_header: Option, #[serde(default)] links_header_level: Option, #[serde(default)] tags_header: Option, #[serde(default)] tags_header_level: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] create_link: Option, #[serde(default)] dir_link: Option, #[serde(default)] bullet_types: Option>, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] cycle_bullets: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] generated_links_caption: Option, #[serde(default)] toc_link_format: Option, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] table_reduce_last_col: Option, } impl From for WikiConfig { fn from(r: RawWiki) -> Self { let root = expand_path(&r.root).unwrap_or_default(); let derived_name = root .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); let mut html = HtmlConfig::from_root(&root); if let Some(p) = r.html_path.as_deref().and_then(expand_path) { html.html_path = p; } if let Some(p) = r.template_path.as_deref().and_then(expand_path) { html.template_path = p; } if let Some(s) = r.template_default { html.template_default = s; } if let Some(s) = r.template_ext { html.template_ext = s; } if let Some(s) = r.template_date_format { html.template_date_format = s; } if let Some(s) = r.css_name { html.css_name = s; } if let Some(b) = r.auto_export { html.auto_export = b; } if let Some(b) = r.html_filename_parameterization { html.html_filename_parameterization = b; } if let Some(v) = r.exclude_files { html.exclude_files = v; } if let Some(v) = r.color_dic { html.color_dic = v; } if let Some(s) = r.custom_wiki2html { html.custom_wiki2html = s; } if let Some(s) = r.custom_wiki2html_args { html.custom_wiki2html_args = s; } if let Some(s) = r.base_url { html.base_url = s; } if let Some(n) = r.html_header_numbering { html.html_header_numbering = n; } if let Some(s) = r.html_header_numbering_sym { html.html_header_numbering_sym = s; } if let Some(ref s) = r.toc_header { html.toc_header = s.clone(); } if let Some(s) = r.rss_name { html.rss_name = s; } if let Some(n) = r.rss_max_items { html.rss_max_items = n; } if let Some(s) = r.valid_html_tags { html.valid_html_tags = s.split(',').map(|t| t.trim().to_string()).collect(); } if let Some(b) = r.list_ignore_newline { html.list_ignore_newline = b; } if let Some(b) = r.text_ignore_newline { html.text_ignore_newline = b; } if let Some(b) = r.emoji_enable { html.emoji_enable = b; } if let Some(v) = r.user_htmls { html.user_htmls = v; } if let Some(s) = r.color_tag_template { html.color_tag_template = s; } let d = wiki_defaults(); let syntax = r.syntax.unwrap_or_else(|| "vimwiki".into()); // vimwiki derives `list_margin = 0` for markdown wikis when the key is // unset (vars.vim:651); other syntaxes keep the `-1` default. let list_margin = r.list_margin.unwrap_or(if syntax == "markdown" { 0 } else { d.list_margin }); WikiConfig { name: r.name.unwrap_or(derived_name), root, file_extension: r.file_extension.unwrap_or_else(|| ".wiki".into()), syntax, index: r.index.unwrap_or(d.index), 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_weekly_style: r.diary_weekly_style.unwrap_or(d.diary_weekly_style), 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), diary_months: r.diary_months.unwrap_or(d.diary_months), listsyms: r.listsyms.unwrap_or(d.listsyms), listsym_rejected: r.listsym_rejected.unwrap_or(d.listsym_rejected), listsyms_propagate: r.listsyms_propagate.unwrap_or(d.listsyms_propagate), list_margin, links_space_char: r.links_space_char.unwrap_or(d.links_space_char), auto_toc: r.auto_toc.unwrap_or(d.auto_toc), auto_generate_links: r.auto_generate_links.unwrap_or(d.auto_generate_links), auto_generate_tags: r.auto_generate_tags.unwrap_or(d.auto_generate_tags), auto_diary_index: r.auto_diary_index.unwrap_or(d.auto_diary_index), toc_header: r.toc_header.unwrap_or(d.toc_header), toc_header_level: r.toc_header_level.unwrap_or(d.toc_header_level), links_header: r.links_header.unwrap_or(d.links_header), links_header_level: r.links_header_level.unwrap_or(d.links_header_level), tags_header: r.tags_header.unwrap_or(d.tags_header), tags_header_level: r.tags_header_level.unwrap_or(d.tags_header_level), create_link: r.create_link.unwrap_or(d.create_link), dir_link: r.dir_link.unwrap_or(d.dir_link), bullet_types: r .bullet_types .filter(|v| !v.is_empty()) .unwrap_or(d.bullet_types), cycle_bullets: r.cycle_bullets.unwrap_or(d.cycle_bullets), generated_links_caption: r .generated_links_caption .unwrap_or(d.generated_links_caption), toc_link_format: r.toc_link_format.unwrap_or(d.toc_link_format), table_reduce_last_col: r.table_reduce_last_col.unwrap_or(d.table_reduce_last_col), html, } } } /// Build the single-wiki [`WikiConfig`] from the flat top-level options object /// (single-wiki shorthand: a `wiki_root` plus any per-wiki keys set alongside /// it). Returns `None` when there's no expandable `wiki_root` (so callers fall /// through to the workspace-folder default) or a `wikis` list is present. /// /// Re-uses the full [`RawWiki`] schema so **every** per-wiki key set at the top /// level — `toc_header`/`toc_header_level`, `links_header`, `html_path`, /// `auto_export`, … — is honoured, not just `file_extension`/`syntax`. The /// shorthand keys on `wiki_root`; `RawWiki` keys on `root`, so we inject it. fn single_wiki_from_value(value: &serde_json::Value) -> Option { let obj = value.as_object()?; if obj.contains_key("wikis") { return None; } let wiki_root = obj.get("wiki_root").and_then(|v| v.as_str())?; // Bail (→ workspace-folder fallback) when the root can't be expanded. expand_path(wiki_root)?; let mut wiki_obj = obj.clone(); wiki_obj.insert( "root".to_string(), serde_json::Value::String(wiki_root.to_string()), ); let raw: RawWiki = serde_json::from_value(serde_json::Value::Object(wiki_obj)).ok()?; Some(WikiConfig::from(raw)) } fn expand_path(s: &str) -> Option { let trimmed = s.trim(); if trimmed.is_empty() { return None; } // Best-effort `~` expansion. Avoids pulling in `dirs` or `home`. if let Some(rest) = trimmed.strip_prefix("~/") { if let Some(home) = std::env::var_os("HOME") { return Some(Path::new(&home).join(rest)); } } else if trimmed == "~" { if let Some(home) = std::env::var_os("HOME") { return Some(PathBuf::from(home)); } } Some(PathBuf::from(trimmed)) } fn first_workspace_folder(params: &InitializeParams) -> Option { if let Some(folders) = ¶ms.workspace_folders { if let Some(first) = folders.first() { return first.uri.to_file_path().ok(); } } #[allow(deprecated)] if let Some(uri) = ¶ms.root_uri { return uri.to_file_path().ok(); } None } /// Test helper: build a `Config` from a JSON value (mimics what arrives /// in `initializationOptions`). pub fn config_from_json(value: serde_json::Value) -> Config { let mut cfg = Config::default(); cfg.apply_change(&value); cfg }