Files
nuwiki/crates/nuwiki-lsp/src/config.rs
T

1099 lines
42 KiB
Rust
Raw Normal View History

//! 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<WikiConfig>,
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"`). `<root>/<index><file_extension>` 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 `<root>/<diary_rel_path>/<diary_index>.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<String>,
/// 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<String>,
/// 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: `<root>/_html`.
pub html_path: PathBuf,
/// Template search root. Default: `<root>/_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<String>,
/// `color_dic`: vimwiki colour-tag name → CSS value mapping used
/// by the HTML renderer. Empty by default; the
/// renderer falls back to `class="color-<name>"` when a name
/// isn't in the dict.
pub color_dic: std::collections::HashMap<String, String>,
/// 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<String>,
/// vimwiki `list_ignore_newline` (default `true`): a single newline inside
/// a list item is a space in HTML export; when `false` it becomes `<br>`.
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 `<br>`.
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<String>,
/// vimwiki `color_tag_template`: HTML template for a colour span whose name
/// resolves in `color_dic`. `__STYLE__` expands to the inline style
/// (`color:<css>`) and `__CONTENT__` to the rendered inner HTML. Default
/// matches upstream's `<span style="…">…</span>` 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<String> {
"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 {
"<span style=\"__STYLE__\">__CONTENT__</span>".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<String, String> {
[
("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 `<root>/_html`
/// and `template_path` to `<root>/_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<Self> {
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<String> {
[
"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<RawWiki>` 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<String> {
["-", "*", "#"].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<String>,
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<String>,
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::<InitOptions>(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<bool>` 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<Option<bool>, D::Error>
where
D: Deserializer<'de>,
{
let v = Option::<serde_json::Value>::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<Vec<RawWiki>>,
// legacy single-wiki compat fields
wiki_root: Option<String>,
file_extension: Option<String>,
syntax: Option<String>,
log_level: Option<String>,
diagnostic: Option<RawDiagnostic>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct RawDiagnostic {
#[serde(default)]
link_severity: Option<String>,
}
/// Build a [`DiagnosticConfig`] from the raw client payload, falling back
/// to defaults for absent or unrecognised values.
fn diagnostic_from_raw(raw: &Option<RawDiagnostic>) -> 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<String>,
root: String,
#[serde(default)]
file_extension: Option<String>,
#[serde(default)]
syntax: Option<String>,
#[serde(default)]
diary_rel_path: Option<String>,
#[serde(default)]
diary_index: Option<String>,
// HTML keys — all optional, fall back to per-root defaults.
#[serde(default)]
html_path: Option<String>,
#[serde(default)]
template_path: Option<String>,
#[serde(default)]
template_default: Option<String>,
#[serde(default)]
template_ext: Option<String>,
#[serde(default)]
template_date_format: Option<String>,
#[serde(default)]
css_name: Option<String>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
auto_export: Option<bool>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
html_filename_parameterization: Option<bool>,
#[serde(default)]
exclude_files: Option<Vec<String>>,
#[serde(default)]
color_dic: Option<std::collections::HashMap<String, String>>,
#[serde(default)]
custom_wiki2html: Option<String>,
#[serde(default)]
custom_wiki2html_args: Option<String>,
#[serde(default)]
base_url: Option<String>,
#[serde(default)]
html_header_numbering: Option<u8>,
#[serde(default)]
html_header_numbering_sym: Option<String>,
#[serde(default)]
rss_name: Option<String>,
#[serde(default)]
rss_max_items: Option<usize>,
#[serde(default)]
valid_html_tags: Option<String>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
list_ignore_newline: Option<bool>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
text_ignore_newline: Option<bool>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
emoji_enable: Option<bool>,
#[serde(default)]
user_htmls: Option<Vec<String>>,
#[serde(default)]
color_tag_template: Option<String>,
// per-wiki keys mirroring vimwiki globals. All optional so
// existing single-wiki configs migrate without touching anything.
#[serde(default)]
index: Option<String>,
#[serde(default)]
diary_frequency: Option<String>,
#[serde(default)]
diary_weekly_style: Option<String>,
#[serde(default)]
diary_start_week_day: Option<String>,
#[serde(default)]
diary_caption_level: Option<i8>,
#[serde(default)]
diary_sort: Option<String>,
#[serde(default)]
diary_header: Option<String>,
#[serde(default)]
diary_months: Option<Vec<String>>,
#[serde(default)]
listsyms: Option<String>,
#[serde(default)]
listsym_rejected: Option<String>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
listsyms_propagate: Option<bool>,
#[serde(default)]
list_margin: Option<i32>,
#[serde(default)]
links_space_char: Option<String>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
auto_toc: Option<bool>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
auto_generate_links: Option<bool>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
auto_generate_tags: Option<bool>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
auto_diary_index: Option<bool>,
#[serde(default)]
toc_header: Option<String>,
#[serde(default)]
toc_header_level: Option<u8>,
#[serde(default)]
links_header: Option<String>,
#[serde(default)]
links_header_level: Option<u8>,
#[serde(default)]
tags_header: Option<String>,
#[serde(default)]
tags_header_level: Option<u8>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
create_link: Option<bool>,
#[serde(default)]
dir_link: Option<String>,
#[serde(default)]
bullet_types: Option<Vec<String>>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
cycle_bullets: Option<bool>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
generated_links_caption: Option<bool>,
#[serde(default)]
toc_link_format: Option<u8>,
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
table_reduce_last_col: Option<bool>,
}
impl From<RawWiki> 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<WikiConfig> {
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<PathBuf> {
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<PathBuf> {
if let Some(folders) = &params.workspace_folders {
if let Some(first) = folders.first() {
return first.uri.to_file_path().ok();
}
}
#[allow(deprecated)]
if let Some(uri) = &params.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
}