Files
nuwiki/crates/nuwiki-lsp/src/config.rs
T
gffranco 66964be393
CI / cargo fmt --check (push) Successful in 11s
CI / cargo clippy (push) Successful in 14s
CI / cargo test (push) Successful in 21s
CI / editor keymaps (push) Successful in 1m10s
chore(fmt): apply cargo fmt to recent LSP changes
Pure rustfmt churn — the anchor-match, source-relative resolution,
and config-unwrap commits introduced lines that needed rustfmt
reformatting. CI's `cargo fmt --check` job was blocking on this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:34:31 -03:00

625 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Server-side runtime config.
//!
//! Populated from `InitializeParams.initialization_options` at boot and
//! refreshed via `workspace/didChangeConfiguration` (P18). v1.0 single-wiki
//! shapes are accepted and desugared into the v1.1 `wikis = [...]` form so
//! existing setups keep working (P16).
//!
//! Only the fields actually consumed in Phase 11 land here. Phases 1319
//! extend `WikiConfig` (diary path, html path, template options, …) and
//! the top-level `Config` (diagnostic severity, mappings opt-in, …) as
//! they need them.
use std::path::{Path, PathBuf};
use serde::Deserialize;
use tower_lsp::lsp_types::{InitializeParams, Url};
#[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. v1.1 ships `daily`; the others fall through
/// to vimwiki-style names but the commands are no-ops until
/// Cluster 4 lands.
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
/// index.
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"`.
pub listsyms: String,
/// When toggling a parent list item, also cascade to descendants.
pub listsyms_propagate: bool,
/// `-1` keeps tight lists; positive integers indent list items by
/// that many extra columns. Layout hint for the renderer.
pub list_margin: i32,
/// Character used in place of literal spaces inside wikilink
/// targets when writing to disk (`_` by default).
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,
/// Phase 17 HTML export options. See SPEC §12.8.
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 (SPEC §12.11). 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>,
}
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: std::collections::HashMap::new(),
}
}
}
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` per SPEC §12.8.
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 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_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(),
}
}
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_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,
}
}
/// 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)
}
}
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_start_week_day() -> String {
"monday".to_string()
}
fn default_diary_sort() -> String {
"desc".to_string()
}
fn default_diary_header() -> String {
"Diary".to_string()
}
fn default_listsyms() -> String {
" .oOX".to_string()
}
fn default_links_space_char() -> String {
" ".to_string()
}
/// Fill in the v1.1 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_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,
}
}
struct WikiDefaults {
index: String,
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,
}
impl Config {
/// Build a `Config` from `InitializeParams`.
///
/// Priority for the wikis list:
/// 1. `initialization_options.wikis = [...]` (v1.1 shape)
/// 2. `initialization_options.wiki_root + file_extension + syntax`
/// (v1.0 single-wiki shape, P16)
/// 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(root) = raw.wiki_root.as_deref().and_then(expand_path) {
let mut wc = WikiConfig::from_root(root);
if let Some(ext) = raw.file_extension {
wc.file_extension = ext;
}
if let Some(s) = raw.syntax {
wc.syntax = s;
}
vec![wc]
} else if let Some(folder) = first_workspace_folder(params) {
vec![WikiConfig::from_root(folder)]
} else {
Vec::new()
};
Config {
wikis,
diagnostic: DiagnosticConfig::default(),
}
}
/// 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.
let inner = value
.as_object()
.and_then(|m| {
if m.len() == 1 {
m.values().next()
} else {
None
}
})
.filter(|v| v.is_object())
.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(root) = raw.wiki_root.as_deref().and_then(expand_path) {
let mut wc = WikiConfig::from_root(root);
if let Some(ext) = raw.file_extension {
wc.file_extension = ext;
}
if let Some(s) = raw.syntax {
wc.syntax = s;
}
self.wikis = vec![wc];
}
}
}
// ===== 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}"
))),
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "snake_case")]
struct InitOptions {
wikis: Option<Vec<RawWiki>>,
// v1.0 single-wiki compat fields
wiki_root: Option<String>,
file_extension: Option<String>,
syntax: Option<String>,
log_level: Option<String>,
}
#[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>,
// Phase 17 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>>,
// v1.1 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_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>,
#[serde(default)]
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>,
}
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;
}
let d = wiki_defaults();
WikiConfig {
name: r.name.unwrap_or(derived_name),
root,
file_extension: r.file_extension.unwrap_or_else(|| ".wiki".into()),
syntax: r.syntax.unwrap_or_else(|| "vimwiki".into()),
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_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,
}
}
}
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
}
#[allow(dead_code)]
fn _ensure_url_is_used(_u: &Url) {}