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

385 lines
13 KiB
Rust
Raw Normal View History

//! 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,
/// 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,
/// 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 {
Self {
name: String::new(),
root: PathBuf::new(),
file_extension: ".wiki".into(),
syntax: "vimwiki".into(),
diary_rel_path: default_diary_rel_path(),
diary_index: default_diary_index(),
html: HtmlConfig::default(),
}
}
pub fn from_root(root: PathBuf) -> Self {
let html = HtmlConfig::from_root(&root);
Self {
name: root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(),
root,
file_extension: ".wiki".into(),
syntax: "vimwiki".into(),
diary_rel_path: default_diary_rel_path(),
diary_index: default_diary_index(),
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
}
}
fn default_diary_rel_path() -> String {
"diary".to_string()
}
fn default_diary_index() -> String {
"diary".to_string()
}
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 html = HtmlConfig::from_root(&root);
vec![WikiConfig {
name: root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(),
root,
file_extension: raw.file_extension.unwrap_or_else(|| ".wiki".into()),
syntax: raw.syntax.unwrap_or_else(|| "vimwiki".into()),
diary_rel_path: default_diary_rel_path(),
diary_index: default_diary_index(),
html,
}]
} 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.
pub fn apply_change(&mut self, value: &serde_json::Value) {
let raw: InitOptions = match serde_json::from_value(value.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 =====
#[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)]
auto_export: Option<bool>,
#[serde(default)]
html_filename_parameterization: Option<bool>,
#[serde(default)]
exclude_files: Option<Vec<String>>,
#[serde(default)]
color_dic: Option<std::collections::HashMap<String, String>>,
}
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;
}
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()),
diary_rel_path: r.diary_rel_path.unwrap_or_else(default_diary_rel_path),
diary_index: r.diary_index.unwrap_or_else(default_diary_index),
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) {}