//! `workspace/executeCommand` dispatcher. //! //! Phase 13 introduced the router + `nuwiki.file.delete`. Phase 14 added //! the cheap surgical edits — checkbox toggle/cycle/reject, heading //! promote/demote, "next task" navigation. Phase 15 layers on the //! generation + workspace-query commands: `toc.generate`, //! `links.generate`, `workspace.checkLinks`, `workspace.findOrphans`. //! //! Every command resolves to either a `WorkspaceEdit` (the server then //! calls `apply_edit`) or a JSON `Value` (returned to the client) — see //! `CommandOutcome` below. `ops::*` are the pure functions the dispatcher //! wraps; they're `pub` so tests and future tooling can drive them //! without spinning up a Backend. use serde::Deserialize; use serde_json::Value; use std::sync::atomic::Ordering; use tower_lsp::lsp_types::{Position as LspPosition, Url, WorkspaceEdit}; use crate::edits::{op_delete, WorkspaceEditBuilder}; use crate::nav; use crate::Backend; /// Result of a single executeCommand dispatch. pub enum CommandOutcome { /// Push to the client via `workspace/applyEdit`. Edit(WorkspaceEdit), /// Return verbatim as the executeCommand response. Value(Value), } /// All commands the server advertises in `ExecuteCommandOptions.commands`. /// Keep in lock-step with the match arms in `execute`. pub const COMMANDS: &[&str] = &[ "nuwiki.file.delete", "nuwiki.list.toggleCheckbox", "nuwiki.list.cycleCheckbox", "nuwiki.list.rejectCheckbox", "nuwiki.list.nextTask", "nuwiki.heading.addLevel", "nuwiki.heading.removeLevel", "nuwiki.toc.generate", "nuwiki.links.generate", "nuwiki.workspace.checkLinks", "nuwiki.workspace.findOrphans", "nuwiki.diary.openToday", "nuwiki.diary.openYesterday", "nuwiki.diary.openTomorrow", "nuwiki.diary.openIndex", "nuwiki.diary.generateIndex", "nuwiki.diary.next", "nuwiki.diary.prev", "nuwiki.diary.listEntries", "nuwiki.diary.openForDate", "nuwiki.tags.search", "nuwiki.tags.generateLinks", "nuwiki.tags.rebuild", "nuwiki.export.currentToHtml", "nuwiki.export.allToHtml", "nuwiki.export.allToHtmlForce", "nuwiki.export.browse", "nuwiki.export.rss", "nuwiki.wiki.listAll", "nuwiki.wiki.select", "nuwiki.wiki.openIndex", "nuwiki.wiki.tabOpenIndex", "nuwiki.wiki.gotoPage", ]; pub(crate) async fn execute( backend: &Backend, command: &str, args: Vec, ) -> Result, String> { match command { "nuwiki.file.delete" => file_delete(args).map(|opt| opt.map(CommandOutcome::Edit)), "nuwiki.list.toggleCheckbox" => { Ok(list_checkbox(backend, args, ops::toggle_state)?.map(CommandOutcome::Edit)) } "nuwiki.list.cycleCheckbox" => { Ok(list_checkbox(backend, args, ops::cycle_state)?.map(CommandOutcome::Edit)) } "nuwiki.list.rejectCheckbox" => { Ok(list_checkbox(backend, args, ops::reject_state)?.map(CommandOutcome::Edit)) } "nuwiki.list.nextTask" => { list_next_task(backend, args).map(|opt| opt.map(CommandOutcome::Value)) } "nuwiki.heading.addLevel" => { Ok(heading_change_level(backend, args, 1)?.map(CommandOutcome::Edit)) } "nuwiki.heading.removeLevel" => { Ok(heading_change_level(backend, args, -1)?.map(CommandOutcome::Edit)) } "nuwiki.toc.generate" => toc_generate(backend, args).map(|o| o.map(CommandOutcome::Edit)), "nuwiki.links.generate" => { links_generate(backend, args).map(|o| o.map(CommandOutcome::Edit)) } "nuwiki.workspace.checkLinks" => { workspace_check_links(backend, args).map(|o| o.map(CommandOutcome::Value)) } "nuwiki.workspace.findOrphans" => { workspace_find_orphans(backend, args).map(|o| o.map(CommandOutcome::Value)) } "nuwiki.diary.openToday" => diary_open_relative(backend, args, RelativeDay::Today) .map(|o| o.map(CommandOutcome::Value)), "nuwiki.diary.openYesterday" => diary_open_relative(backend, args, RelativeDay::Yesterday) .map(|o| o.map(CommandOutcome::Value)), "nuwiki.diary.openTomorrow" => diary_open_relative(backend, args, RelativeDay::Tomorrow) .map(|o| o.map(CommandOutcome::Value)), "nuwiki.diary.openIndex" => { diary_open_index(backend, args).map(|o| o.map(CommandOutcome::Value)) } "nuwiki.diary.generateIndex" => { diary_generate_index(backend, args).map(|o| o.map(CommandOutcome::Edit)) } "nuwiki.diary.next" => { diary_step(backend, args, true).map(|o| o.map(CommandOutcome::Value)) } "nuwiki.diary.prev" => { diary_step(backend, args, false).map(|o| o.map(CommandOutcome::Value)) } "nuwiki.diary.listEntries" => { diary_list(backend, args).map(|o| o.map(CommandOutcome::Value)) } "nuwiki.diary.openForDate" => { diary_open_for_date(backend, args).map(|o| o.map(CommandOutcome::Value)) } "nuwiki.tags.search" => tags_search(backend, args).map(|o| o.map(CommandOutcome::Value)), "nuwiki.tags.generateLinks" => { tags_generate_links(backend, args).map(|o| o.map(CommandOutcome::Edit)) } "nuwiki.tags.rebuild" => tags_rebuild(backend, args).map(|o| o.map(CommandOutcome::Value)), "nuwiki.export.currentToHtml" => { export_current(backend, args, false).map(|o| o.map(CommandOutcome::Value)) } "nuwiki.export.allToHtml" => { export_all(backend, args, false).map(|o| o.map(CommandOutcome::Value)) } "nuwiki.export.allToHtmlForce" => { export_all(backend, args, true).map(|o| o.map(CommandOutcome::Value)) } "nuwiki.export.browse" => { export_current(backend, args, true).map(|o| o.map(CommandOutcome::Value)) } "nuwiki.export.rss" => export_rss(backend, args).map(|o| o.map(CommandOutcome::Value)), "nuwiki.wiki.listAll" => wiki_list_all(backend).map(|o| o.map(CommandOutcome::Value)), "nuwiki.wiki.select" => wiki_list_all(backend).map(|o| o.map(CommandOutcome::Value)), "nuwiki.wiki.openIndex" => { wiki_open_index(backend, args, false).map(|o| o.map(CommandOutcome::Value)) } "nuwiki.wiki.tabOpenIndex" => { wiki_open_index(backend, args, true).map(|o| o.map(CommandOutcome::Value)) } "nuwiki.wiki.gotoPage" => { wiki_goto_page(backend, args).map(|o| o.map(CommandOutcome::Value)) } other => Err(format!("unknown nuwiki command: {other}")), } } #[derive(Deserialize)] struct PosArgs { uri: Url, position: LspPosition, } fn parse_pos(args: Vec) -> Result { let raw = args .into_iter() .next() .ok_or_else(|| "missing { uri, position } argument".to_string())?; serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}")) } fn file_delete(args: Vec) -> Result, String> { #[derive(Deserialize)] struct Args { uri: Url, } let raw = args .into_iter() .next() .ok_or_else(|| "nuwiki.file.delete: missing arguments".to_string())?; let parsed: Args = serde_json::from_value(raw) .map_err(|e| format!("nuwiki.file.delete: invalid args — {e}"))?; let mut builder = WorkspaceEditBuilder::new(); builder.file_op(op_delete(parsed.uri)); Ok(Some(builder.build())) } fn list_checkbox( backend: &Backend, args: Vec, mutate: fn(&str) -> Option<&'static str>, ) -> Result, String> { let p = parse_pos(args)?; let doc = match backend.documents.get(&p.uri) { Some(d) => d, None => return Ok(None), }; let utf8 = backend.use_utf8.load(Ordering::Relaxed); let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8); Ok(ops::checkbox_edit( &doc.text, &doc.ast, &p.uri, line, col, utf8, mutate, )) } fn list_next_task(backend: &Backend, args: Vec) -> Result, String> { let p = parse_pos(args)?; let doc = match backend.documents.get(&p.uri) { Some(d) => d, None => return Ok(None), }; let utf8 = backend.use_utf8.load(Ordering::Relaxed); let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8); let Some(loc) = ops::next_task(&doc.ast, &p.uri, &doc.text, line, col, utf8) else { return Ok(None); }; serde_json::to_value(loc) .map(Some) .map_err(|e| format!("nuwiki.list.nextTask: serialization failed — {e}")) } fn heading_change_level( backend: &Backend, args: Vec, delta: i32, ) -> Result, String> { let p = parse_pos(args)?; let doc = match backend.documents.get(&p.uri) { Some(d) => d, None => return Ok(None), }; let utf8 = backend.use_utf8.load(Ordering::Relaxed); let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8); Ok(ops::heading_change_level( &doc.text, &doc.ast, &p.uri, line, col, utf8, delta, )) } #[derive(Deserialize)] struct UriArg { uri: Url, } fn parse_uri_arg(args: Vec) -> Result { let raw = args .into_iter() .next() .ok_or_else(|| "missing { uri } argument".to_string())?; serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}")) } #[derive(Deserialize, Default)] struct OptionalUriArg { #[serde(default)] uri: Option, } fn parse_optional_uri_arg(args: Vec) -> Result { match args.into_iter().next() { Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}")), None => Ok(OptionalUriArg::default()), } } fn toc_generate(backend: &Backend, args: Vec) -> Result, String> { let p = parse_uri_arg(args)?; let doc = match backend.documents.get(&p.uri) { Some(d) => d, None => return Ok(None), }; let utf8 = backend.use_utf8.load(Ordering::Relaxed); Ok(ops::toc_edit(&doc.text, &doc.ast, &p.uri, utf8)) } fn links_generate(backend: &Backend, args: Vec) -> Result, String> { let p = parse_uri_arg(args)?; let doc = match backend.documents.get(&p.uri) { Some(d) => d, None => return Ok(None), }; let utf8 = backend.use_utf8.load(Ordering::Relaxed); let wiki = match backend.wiki_for_uri(&p.uri) { Some(w) => w, None => return Ok(None), }; let current_page = crate::index::page_name_from_uri(&p.uri, Some(&wiki.config.root)); let pages = { let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; idx.page_names() }; Ok(ops::links_edit( &doc.text, &doc.ast, &p.uri, ¤t_page, &pages, utf8, )) } fn workspace_check_links(backend: &Backend, args: Vec) -> Result, String> { let p = parse_optional_uri_arg(args)?; let wiki = match p.uri.as_ref().and_then(|u| backend.wiki_for_uri(u)) { Some(w) => w, None => match backend.default_wiki() { Some(w) => w, None => return Ok(Some(serde_json::json!([]))), }, }; let utf8 = backend.use_utf8.load(Ordering::Relaxed); let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; let broken = ops::collect_workspace_broken_links(backend, &idx, utf8); Ok(Some(serde_json::to_value(broken).map_err(|e| { format!("nuwiki.workspace.checkLinks: serialization failed — {e}") })?)) } #[derive(Clone, Copy)] enum RelativeDay { Today, Yesterday, Tomorrow, } fn resolve_diary_wiki(backend: &Backend, uri: Option<&Url>) -> Option { if let Some(u) = uri { if let Some(w) = backend.wiki_for_uri(u) { return Some(w); } } backend.default_wiki() } fn diary_open_relative( backend: &Backend, args: Vec, rel: RelativeDay, ) -> Result, String> { let p = parse_optional_uri_arg(args)?; let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else { return Ok(None); }; let today = nuwiki_core::date::DiaryDate::today_utc(); let date = match rel { RelativeDay::Today => today, RelativeDay::Yesterday => today.prev_day(), RelativeDay::Tomorrow => today.next_day(), }; let Some(uri) = crate::diary::uri_for_date(&wiki.config, &date) else { return Ok(None); }; Ok(Some(serde_json::json!({ "uri": uri, "date": date.format(), }))) } fn diary_open_index(backend: &Backend, args: Vec) -> Result, String> { let p = parse_optional_uri_arg(args)?; let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else { return Ok(None); }; let Some(uri) = crate::diary::index_uri(&wiki.config) else { return Ok(None); }; Ok(Some(serde_json::json!({ "uri": uri }))) } fn diary_generate_index( backend: &Backend, args: Vec, ) -> Result, String> { let p = parse_optional_uri_arg(args)?; let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else { return Ok(None); }; let utf8 = backend.use_utf8.load(Ordering::Relaxed); let Some(index_uri) = crate::diary::index_uri(&wiki.config) else { return Ok(None); }; let body = { let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; crate::diary::render_index_body(&wiki.config, &idx) }; Ok(Some(ops::diary_generate_index_edit( backend, &index_uri, &body, utf8, ))) } fn diary_step(backend: &Backend, args: Vec, forward: bool) -> Result, String> { let p = parse_uri_arg(args)?; let Some(wiki) = backend.wiki_for_uri(&p.uri) else { return Ok(None); }; // Pivot date: the current page's `diary_date` when it's an entry, // otherwise today — matches vimwiki's "navigate from whatever date // I'm currently viewing". let pivot = { let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; idx.page(&p.uri) .and_then(|page| page.diary_date) .unwrap_or_else(nuwiki_core::date::DiaryDate::today_utc) }; let next = { let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; if forward { crate::diary::next_entry(&idx, &pivot) } else { crate::diary::prev_entry(&idx, &pivot) } }; match next { None => Ok(None), Some(entry) => Ok(Some(serde_json::to_value(&entry).map_err(|e| { format!("nuwiki.diary.next/prev: serialization failed — {e}") })?)), } } fn diary_list(backend: &Backend, args: Vec) -> Result, String> { #[derive(Deserialize, Default)] struct Args { #[serde(default)] uri: Option, #[serde(default)] year: Option, #[serde(default)] month: Option, } let parsed: Args = match args.into_iter().next() { Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}"))?, None => Args::default(), }; let Some(wiki) = resolve_diary_wiki(backend, parsed.uri.as_ref()) else { return Ok(Some(serde_json::json!([]))); }; let entries = { let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; crate::diary::list_entries_filtered(&idx, parsed.year, parsed.month) }; Ok(Some(serde_json::to_value(&entries).map_err(|e| { format!("nuwiki.diary.listEntries: serialization failed — {e}") })?)) } fn diary_open_for_date(backend: &Backend, args: Vec) -> Result, String> { #[derive(Deserialize)] struct Args { date: String, #[serde(default)] uri: Option, } let raw = args .into_iter() .next() .ok_or_else(|| "nuwiki.diary.openForDate: missing { date } argument".to_string())?; let parsed: Args = serde_json::from_value(raw) .map_err(|e| format!("nuwiki.diary.openForDate: invalid args — {e}"))?; let Some(date) = nuwiki_core::date::DiaryDate::parse(&parsed.date) else { return Err(format!( "nuwiki.diary.openForDate: '{}' is not a YYYY-MM-DD date", parsed.date )); }; let Some(wiki) = resolve_diary_wiki(backend, parsed.uri.as_ref()) else { return Ok(None); }; let Some(uri) = crate::diary::uri_for_date(&wiki.config, &date) else { return Ok(None); }; Ok(Some( serde_json::json!({ "uri": uri, "date": date.format() }), )) } fn tags_search(backend: &Backend, args: Vec) -> Result, String> { #[derive(Deserialize, Default)] struct Args { #[serde(default)] uri: Option, #[serde(default)] query: Option, } let parsed: Args = match args.into_iter().next() { Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}"))?, None => Args::default(), }; let Some(wiki) = resolve_diary_wiki(backend, parsed.uri.as_ref()) else { return Ok(Some(serde_json::json!([]))); }; let hits = { let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; ops::tag_hits(&idx, parsed.query.as_deref().unwrap_or("")) }; Ok(Some(serde_json::to_value(&hits).map_err(|e| { format!("nuwiki.tags.search: serialization failed — {e}") })?)) } fn tags_generate_links( backend: &Backend, args: Vec, ) -> Result, String> { #[derive(Deserialize)] struct Args { uri: Url, #[serde(default)] tag: Option, } let raw = args .into_iter() .next() .ok_or_else(|| "nuwiki.tags.generateLinks: missing { uri } argument".to_string())?; let parsed: Args = serde_json::from_value(raw) .map_err(|e| format!("nuwiki.tags.generateLinks: invalid args — {e}"))?; let doc = match backend.documents.get(&parsed.uri) { Some(d) => d, None => return Ok(None), }; let utf8 = backend.use_utf8.load(Ordering::Relaxed); let Some(wiki) = backend.wiki_for_uri(&parsed.uri) else { return Ok(None); }; let snapshot = { let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; ops::tag_pages_snapshot(&idx) }; Ok(ops::tag_links_edit( &doc.text, &doc.ast, &parsed.uri, parsed.tag.as_deref(), &snapshot, utf8, )) } fn tags_rebuild(backend: &Backend, args: Vec) -> Result, String> { let p = parse_optional_uri_arg(args)?; let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else { return Ok(None); }; let registry = std::sync::Arc::clone(&backend.registry); let root = wiki.config.root.clone(); if root.as_os_str().is_empty() { return Ok(Some( serde_json::json!({ "pages": 0, "reason": "no wiki root configured" }), )); } let Some(plugin) = registry.get("vimwiki") else { return Err("vimwiki syntax plugin missing".to_string()); }; let files = crate::index::walk_wiki_files(&root); let total = files.len(); let mut indexed = 0usize; let mut idx = wiki .index .write() .map_err(|_| "index lock poisoned".to_string())?; // Drop pages whose files no longer exist. Collect first to avoid mutating // during iteration. let stale: Vec = idx .pages_by_uri .keys() .filter(|u| match u.to_file_path() { Ok(p) => !p.exists(), Err(_) => true, }) .cloned() .collect(); for u in &stale { idx.remove(u); } for path in files { let Ok(text) = std::fs::read_to_string(&path) else { continue; }; let ast = plugin.parse(&text); let Ok(uri) = Url::from_file_path(&path) else { continue; }; idx.upsert(uri, &ast); indexed += 1; } Ok(Some(serde_json::json!({ "pages": indexed, "files_seen": total, "stale_removed": stale.len(), }))) } // ===== Phase 18: multi-wiki picker commands ===== fn wiki_list_all(backend: &Backend) -> Result, String> { let wikis = backend.wikis_snapshot(); let rows: Vec = wikis .iter() .enumerate() .map(|(i, w)| { serde_json::json!({ "id": i, "name": w.config.name, "root": w.config.root, "syntax": w.config.syntax, "file_extension": w.config.file_extension, }) }) .collect(); Ok(Some(serde_json::json!(rows))) } fn resolve_wiki_selector(backend: &Backend, sel: Option<&Value>) -> Option { let wikis = backend.wikis_snapshot(); let Some(sel) = sel else { return wikis.into_iter().next(); }; if let Some(idx) = sel.as_u64() { return wikis.into_iter().nth(idx as usize); } if let Some(name) = sel.as_str() { // Numeric strings are also accepted so the picker UI can pass // back whatever it received without coercing. if let Ok(idx) = name.parse::() { if let Some(w) = wikis.get(idx).cloned() { return Some(w); } } return wikis.into_iter().find(|w| w.config.name == name); } None } fn wiki_open_index( backend: &Backend, args: Vec, tab: bool, ) -> Result, String> { #[derive(Deserialize, Default)] struct Args { #[serde(default)] wiki: Option, } let parsed: Args = match args.into_iter().next() { Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}"))?, None => Args::default(), }; let Some(wiki) = resolve_wiki_selector(backend, parsed.wiki.as_ref()) else { return Ok(None); }; let index_path = wiki .config .root .join(format!("index{}", wiki.config.file_extension)); let Ok(uri) = Url::from_file_path(&index_path) else { return Ok(None); }; Ok(Some(serde_json::json!({ "uri": uri, "name": wiki.config.name, "tab": tab, }))) } fn wiki_goto_page(backend: &Backend, args: Vec) -> Result, String> { #[derive(Deserialize)] struct Args { page: String, #[serde(default)] wiki: Option, } let raw = args .into_iter() .next() .ok_or_else(|| "nuwiki.wiki.gotoPage: missing { page } argument".to_string())?; let parsed: Args = serde_json::from_value(raw) .map_err(|e| format!("nuwiki.wiki.gotoPage: invalid args — {e}"))?; let Some(wiki) = resolve_wiki_selector(backend, parsed.wiki.as_ref()) else { return Ok(None); }; // Prefer an indexed page first (canonical URI from the workspace // index), then fall back to building the path from the page name. let uri = { let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; idx.pages_by_name.get(&parsed.page).cloned() }; let uri = uri.unwrap_or_else(|| { // Build `/.wiki` directly. `page` may contain // forward-slashes for subdirs. let mut path = wiki.config.root.clone(); for seg in parsed.page.split('/') { if !seg.is_empty() { path.push(seg); } } let path = path.with_extension(wiki.config.file_extension.trim_start_matches('.')); Url::from_file_path(path) .unwrap_or_else(|_| Url::parse("file:///nonexistent").expect("constant url")) }); Ok(Some(serde_json::json!({ "uri": uri, "page": parsed.page, "wiki": wiki.config.name, }))) } // ===== Phase 17: HTML export commands ===== fn export_current( backend: &Backend, args: Vec, return_open_url: bool, ) -> Result, String> { let p = parse_uri_arg(args)?; let doc = match backend.documents.get(&p.uri) { Some(d) => d, None => return Ok(None), }; let Some(wiki) = backend.wiki_for_uri(&p.uri) else { return Ok(None); }; let cfg = &wiki.config; if doc.ast.metadata.nohtml { return Ok(Some(serde_json::json!({ "exported": [], "skipped": [{"uri": p.uri, "reason": "nohtml"}], }))); } let name = crate::index::page_name_from_uri(&p.uri, Some(&cfg.root)); let outcome = match crate::commands::export_ops::write_page(&doc.ast, &name, cfg) { Ok(o) => o, Err(e) => return Err(format!("nuwiki.export.currentToHtml: {e}")), }; let css = crate::commands::export_ops::ensure_css(cfg).map_err(|e| e.to_string())?; let mut body = serde_json::json!({ "exported": [outcome.to_json()], "css_path": css.path, "css_created": css.created, }); if return_open_url { if let Ok(url) = Url::from_file_path(&outcome.output_path) { body["browse"] = serde_json::json!(url); } } Ok(Some(body)) } fn export_all(backend: &Backend, args: Vec, force: bool) -> Result, String> { let p = parse_optional_uri_arg(args)?; let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else { return Ok(None); }; let cfg = wiki.config.clone(); if cfg.root.as_os_str().is_empty() { return Ok(Some(serde_json::json!({ "exported": [], "reason": "no wiki root configured", }))); } let registry = std::sync::Arc::clone(&backend.registry); let Some(plugin) = registry.get("vimwiki") else { return Err("vimwiki syntax plugin missing".to_string()); }; let files = crate::index::walk_wiki_files(&cfg.root); let mut exported: Vec = Vec::new(); let mut skipped: Vec = Vec::new(); for path in &files { let Ok(uri) = Url::from_file_path(path) else { continue; }; let name = crate::index::page_name_from_uri(&uri, Some(&cfg.root)); if crate::export::is_excluded(&cfg.html.exclude_files, &name) { skipped.push(serde_json::json!({"uri": uri, "reason": "excluded"})); continue; } let (text, ast) = match backend.documents.get(&uri) { Some(d) => (d.text.clone(), d.ast.clone()), None => { let Ok(text) = std::fs::read_to_string(path) else { continue; }; let ast = plugin.parse(&text); (text, ast) } }; let _ = text; if ast.metadata.nohtml { skipped.push(serde_json::json!({"uri": uri, "reason": "nohtml"})); continue; } let out_path = crate::export::output_path_for(&cfg.html, &name); if !force { // Compare modification times. Skip when the HTML is newer than // the source. Cheap and matches vimwiki's incremental behaviour. if let (Ok(src_meta), Ok(html_meta)) = (std::fs::metadata(path), std::fs::metadata(&out_path)) { let src_mtime = src_meta.modified().ok(); let out_mtime = html_meta.modified().ok(); if let (Some(s), Some(o)) = (src_mtime, out_mtime) { if o >= s { skipped.push(serde_json::json!({"uri": uri, "reason": "up-to-date"})); continue; } } } } match crate::commands::export_ops::write_page(&ast, &name, &cfg) { Ok(outcome) => exported.push(outcome.to_json()), Err(e) => skipped.push(serde_json::json!({"uri": uri, "reason": e.to_string()})), } } let css = crate::commands::export_ops::ensure_css(&cfg).map_err(|e| e.to_string())?; Ok(Some(serde_json::json!({ "exported": exported, "skipped": skipped, "files_seen": files.len(), "css_path": css.path, "css_created": css.created, "forced": force, }))) } fn export_rss(backend: &Backend, args: Vec) -> Result, String> { let p = parse_optional_uri_arg(args)?; let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else { return Ok(None); }; let entries = { let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; crate::diary::list_entries(&idx) }; let out_path = crate::commands::export_ops::write_rss(&wiki.config, &entries) .map_err(|e| format!("nuwiki.export.rss: {e}"))?; Ok(Some(serde_json::json!({ "rss_path": out_path, "entries": entries.len(), }))) } fn workspace_find_orphans(backend: &Backend, args: Vec) -> Result, String> { let p = parse_optional_uri_arg(args)?; let wiki = match p.uri.as_ref().and_then(|u| backend.wiki_for_uri(u)) { Some(w) => w, None => match backend.default_wiki() { Some(w) => w, None => return Ok(Some(serde_json::json!([]))), }, }; let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; let orphans = ops::find_orphans(&idx); Ok(Some(serde_json::to_value(orphans).map_err(|e| { format!("nuwiki.workspace.findOrphans: serialization failed — {e}") })?)) } // ===== Pure operations ===== pub mod ops { use super::*; use crate::edits::text_edit_replace; use nuwiki_core::ast::{ BlockNode, BlockquoteNode, CheckboxState, DocumentNode, HeadingNode, ListItemNode, ListNode, Span, }; use tower_lsp::lsp_types::Location; /// Toggle ` ↔ X. Mid-states (`.`, `o`, `O`) snap forward to `X`. /// Rejected (`-`) reverts to empty. pub fn toggle_state(current: &str) -> Option<&'static str> { match current { "[ ]" => Some("[X]"), "[X]" => Some("[ ]"), "[.]" | "[o]" | "[O]" => Some("[X]"), "[-]" => Some("[ ]"), _ => None, } } /// `[ ]` → `[.]` → `[o]` → `[O]` → `[X]` → `[ ]`. `[-]` → `[ ]`. pub fn cycle_state(current: &str) -> Option<&'static str> { match current { "[ ]" => Some("[.]"), "[.]" => Some("[o]"), "[o]" => Some("[O]"), "[O]" => Some("[X]"), "[X]" => Some("[ ]"), "[-]" => Some("[ ]"), _ => None, } } /// Toggle the `[-]` "rejected" state. pub fn reject_state(current: &str) -> Option<&'static str> { match current { "[-]" => Some("[ ]"), _ => Some("[-]"), } } pub fn checkbox_edit( text: &str, ast: &DocumentNode, uri: &Url, line: u32, col: u32, utf8: bool, mutate: fn(&str) -> Option<&'static str>, ) -> Option { let item = find_list_item_at(ast, line, col)?; let cb_span = find_checkbox_span(text, item.span)?; let cur = &text[cb_span.start.offset..cb_span.end.offset]; let new = mutate(cur)?; let mut b = WorkspaceEditBuilder::new(); b.edit( uri.clone(), text_edit_replace(cb_span, new.to_string(), text, utf8), ); Some(b.build()) } pub fn next_task( ast: &DocumentNode, uri: &Url, text: &str, line: u32, _col: u32, utf8: bool, ) -> Option { // Walk in source order; first task at line > cursor wins. Wraps to // the start of the document if nothing found after cursor — matches // vimwiki's `gnt`. let mut items: Vec<&ListItemNode> = Vec::new(); for block in &ast.children { collect_list_items(block, &mut items); } let is_unfinished = |it: &&ListItemNode| match it.checkbox { Some(CheckboxState::Done) | Some(CheckboxState::Rejected) => false, Some(_) | None => it.checkbox.is_some(), }; let after = items .iter() .find(|it| it.span.start.line > line && is_unfinished(it)); let chosen = after.or_else(|| items.iter().find(|it| is_unfinished(it)))?; let span = chosen.span; Some(Location { uri: uri.clone(), range: crate::edits::text_edit_replace(span, String::new(), text, utf8).range, }) } fn collect_list_items<'a>(block: &'a BlockNode, out: &mut Vec<&'a ListItemNode>) { match block { BlockNode::List(ListNode { items, .. }) => { for it in items { out.push(it); if let Some(sub) = &it.sublist { for sub_it in &sub.items { collect_list_items_from_item(sub_it, out); } } } } BlockNode::Blockquote(BlockquoteNode { children, .. }) => { for c in children { collect_list_items(c, out); } } _ => {} } } fn collect_list_items_from_item<'a>(item: &'a ListItemNode, out: &mut Vec<&'a ListItemNode>) { out.push(item); if let Some(sub) = &item.sublist { for sub_it in &sub.items { collect_list_items_from_item(sub_it, out); } } } pub fn heading_change_level( text: &str, ast: &DocumentNode, uri: &Url, line: u32, _col: u32, utf8: bool, delta: i32, ) -> Option { let heading = find_heading_at(ast, line)?; let current = heading.level as i32; let new_level = (current + delta).clamp(1, 6) as u8; if new_level == heading.level { return None; } let new_text = rewrite_heading_with_level(text, heading.span, new_level)?; let mut b = WorkspaceEditBuilder::new(); b.edit( uri.clone(), text_edit_replace(heading.span, new_text, text, utf8), ); Some(b.build()) } fn find_heading_at(ast: &DocumentNode, line: u32) -> Option<&HeadingNode> { for block in &ast.children { if let BlockNode::Heading(h) = block { if h.span.start.line == line { return Some(h); } } } None } /// Rewrite a heading's source so it has `new_level` `=`s on each side. /// Returns `None` if the source doesn't have a balanced `=` ... `=` /// shape (defensive — the lexer should guarantee this, but we don't /// crash if the buffer was edited mid-flight). pub fn rewrite_heading_with_level(text: &str, span: Span, new_level: u8) -> Option { if !(1..=6).contains(&new_level) { return None; } let segment = &text[span.start.offset..span.end.offset]; let leading_ws = segment .bytes() .take_while(|b| *b == b' ' || *b == b'\t') .count(); let after_ws = &segment[leading_ws..]; let opening = after_ws.bytes().take_while(|b| *b == b'=').count(); let trimmed_end = after_ws.trim_end_matches([' ', '\t']).len(); let trailing = after_ws[..trimmed_end] .bytes() .rev() .take_while(|b| *b == b'=') .count(); if opening == 0 || trailing == 0 || opening != trailing { return None; } let middle = &after_ws[opening..trimmed_end - trailing]; let trailing_ws = &after_ws[trimmed_end..]; let mut out = String::with_capacity(segment.len() + 2); out.push_str(&segment[..leading_ws]); for _ in 0..new_level { out.push('='); } out.push_str(middle); for _ in 0..new_level { out.push('='); } out.push_str(trailing_ws); Some(out) } /// Find the `[X]` (or other checkbox) substring within a list item's /// source span. Returns a span covering exactly the 3-byte run. pub fn find_checkbox_span(text: &str, item_span: Span) -> Option { let start = item_span.start.offset; let end = item_span.end.offset.min(text.len()); if start >= end { return None; } let segment = &text[start..end]; let bytes = segment.as_bytes(); let mut i = 0; while i + 2 < bytes.len() { if bytes[i] == b'[' && bytes[i + 2] == b']' && matches!(bytes[i + 1], b' ' | b'.' | b'o' | b'O' | b'X' | b'-') { let abs_start = start + i; let cb_start = nuwiki_core::ast::Position { line: item_span.start.line, column: item_span.start.column + i as u32, offset: abs_start, }; let cb_end = nuwiki_core::ast::Position { line: item_span.start.line, column: item_span.start.column + (i + 3) as u32, offset: abs_start + 3, }; return Some(Span::new(cb_start, cb_end)); } i += 1; } None } /// Find the list item whose source line contains `line`. Descends into /// sublists; returns the deepest match so editing the inner item works /// when the user's cursor sits there. pub fn find_list_item_at(ast: &DocumentNode, line: u32, col: u32) -> Option<&ListItemNode> { for block in &ast.children { if let Some(it) = item_in_block(block, line, col) { return Some(it); } } None } fn item_in_block(block: &BlockNode, line: u32, col: u32) -> Option<&ListItemNode> { match block { BlockNode::List(l) => l.items.iter().find_map(|it| item_in_list(it, line, col)), BlockNode::Blockquote(BlockquoteNode { children, .. }) => { children.iter().find_map(|c| item_in_block(c, line, col)) } _ => None, } } fn item_in_list(item: &ListItemNode, line: u32, col: u32) -> Option<&ListItemNode> { // Prefer the deepest match: try sublists first. if let Some(sub) = &item.sublist { for sub_it in &sub.items { if let Some(found) = item_in_list(sub_it, line, col) { return Some(found); } } } if (item.span.start.line..=item.span.end.line).contains(&line) { return Some(item); } let _ = col; None } // ===== Phase 15: TOC + Links generation, workspace queries ===== use serde::Serialize; pub const TOC_HEADING: &str = "Contents"; pub const LINKS_HEADING: &str = "Generated Links"; use crate::diagnostics::classify_outgoing; use crate::edits::text_edit_insert; use crate::index::WorkspaceIndex; use nuwiki_core::ast::Position as AstPosition; /// One heading entry used to render the TOC. struct TocItem { level: u8, title: String, anchor: String, } /// Build a heading + nested list TOC for the current document. The TOC /// itself (any existing `= Contents =` heading on the page) is skipped /// so re-generation is idempotent. pub fn build_toc_text(items: &[(u8, String, String)], heading_name: &str) -> String { let mut out = String::new(); out.push_str("= "); out.push_str(heading_name); out.push_str(" =\n"); if items.is_empty() { return out; } let min_level = items.iter().map(|(l, _, _)| *l).min().unwrap_or(1); for (level, title, anchor) in items { let depth = level.saturating_sub(min_level) as usize; for _ in 0..depth { out.push_str(" "); } out.push_str("- [[#"); out.push_str(anchor); out.push('|'); out.push_str(title); out.push_str("]]\n"); } out } /// Build a flat list of wikilinks to every page (sorted), optionally /// excluding `current_page`. pub fn build_links_text(pages: &[String], heading_name: &str, exclude: Option<&str>) -> String { let mut out = String::new(); out.push_str("= "); out.push_str(heading_name); out.push_str(" =\n"); for name in pages { if Some(name.as_str()) == exclude { continue; } out.push_str("- [["); out.push_str(name); out.push_str("]]\n"); } out } /// Locate an existing section whose level-1 heading matches /// `heading_name` case-insensitively. Returns the offset range covering /// `[heading start .. (immediately-following list end | heading end)]` /// plus the start `AstPosition` for emitting an LSP range from a /// synthesised span. pub fn find_section_range( ast: &DocumentNode, heading_name: &str, ) -> Option<(AstPosition, AstPosition)> { let needle = heading_name.to_ascii_lowercase(); for (i, block) in ast.children.iter().enumerate() { let BlockNode::Heading(h) = block else { continue; }; if h.level != 1 { continue; } let title = heading_title(h).to_ascii_lowercase(); if title != needle { continue; } let start = h.span.start; let end = match ast.children.get(i + 1) { Some(BlockNode::List(list)) => list.span.end, _ => h.span.end, }; return Some((start, end)); } None } fn heading_title(h: &nuwiki_core::ast::HeadingNode) -> String { crate::diagnostics::heading_text(&h.children) } fn collect_toc_items(ast: &DocumentNode, toc_heading: &str) -> Vec { let mut out = Vec::new(); let needle = toc_heading.to_ascii_lowercase(); for block in &ast.children { let BlockNode::Heading(h) = block else { continue; }; let title = heading_title(h); if h.level == 1 && title.to_ascii_lowercase() == needle { // skip the TOC's own heading continue; } let anchor = crate::index::slugify(&title); out.push(TocItem { level: h.level, title, anchor, }); } out } /// Produce a `WorkspaceEdit` that replaces or inserts the TOC for the /// given document. Returns `None` if the document has no headings at /// all (other than possibly a pre-existing TOC heading). pub fn toc_edit( text: &str, ast: &DocumentNode, uri: &Url, utf8: bool, ) -> Option { let items = collect_toc_items(ast, TOC_HEADING); if items.is_empty() { return None; } let triples: Vec<(u8, String, String)> = items .into_iter() .map(|it| (it.level, it.title, it.anchor)) .collect(); let new_text = build_toc_text(&triples, TOC_HEADING); let mut b = WorkspaceEditBuilder::new(); match find_section_range(ast, TOC_HEADING) { Some((start, end)) => { let span = Span::new(start, end); b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8)); } None => { let with_sep = format!("{new_text}\n"); b.edit( uri.clone(), text_edit_insert( LspPosition { line: 0, character: 0, }, with_sep, ), ); } } Some(b.build()) } /// Produce a `WorkspaceEdit` that replaces or inserts the /// auto-generated links section for the given document. pub fn links_edit( text: &str, ast: &DocumentNode, uri: &Url, current_page: &str, all_pages: &[String], utf8: bool, ) -> Option { if all_pages.is_empty() { return None; } let new_text = build_links_text(all_pages, LINKS_HEADING, Some(current_page)); let mut b = WorkspaceEditBuilder::new(); match find_section_range(ast, LINKS_HEADING) { Some((start, end)) => { let span = Span::new(start, end); b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8)); } None => { let with_sep = format!("{new_text}\n"); b.edit( uri.clone(), text_edit_insert( LspPosition { line: 0, character: 0, }, with_sep, ), ); } } Some(b.build()) } // ===== Workspace queries ===== #[derive(Debug, Serialize)] pub struct BrokenLinkEntry { pub uri: Url, pub range: tower_lsp::lsp_types::Range, pub kind: &'static str, pub message: String, } /// Walk every indexed page and classify each outgoing link against the /// workspace index. Open documents (held in `backend.documents`) use /// live text for LSP range conversion; closed pages fall back to the /// stored span coordinates verbatim (they're already in line/column form). pub(crate) fn collect_workspace_broken_links( backend: &Backend, index: &WorkspaceIndex, utf8: bool, ) -> Vec { let mut out = Vec::new(); for page in index.pages_by_uri.values() { for link in &page.outgoing { let Some(broken) = classify_outgoing(link, &page.uri, index, &page.name) else { continue; }; let range = match backend.documents.get(&page.uri) { Some(doc) => crate::to_lsp_range(&link.span, &doc.text, utf8), None => no_text_range(&link.span), }; out.push(BrokenLinkEntry { uri: page.uri.clone(), range, kind: broken.tag(), message: broken.message(), }); } } out.sort_by(|a, b| { (a.uri.as_str(), a.range.start.line, a.range.start.character).cmp(&( b.uri.as_str(), b.range.start.line, b.range.start.character, )) }); out } #[derive(Debug, Serialize)] pub struct OrphanEntry { pub uri: Url, pub name: String, } /// A page is an orphan when no other indexed page links to it. The /// current convention is to surface every orphan and let the client /// filter out the index page if it wants to. pub fn find_orphans(index: &WorkspaceIndex) -> Vec { let mut out: Vec = index .pages_by_uri .values() .filter(|p| index.backlinks_for(&p.name).is_empty()) .map(|p| OrphanEntry { uri: p.uri.clone(), name: p.name.clone(), }) .collect(); out.sort_by(|a, b| a.name.cmp(&b.name)); out } fn no_text_range(span: &Span) -> tower_lsp::lsp_types::Range { tower_lsp::lsp_types::Range { start: LspPosition { line: span.start.line, character: span.start.column, }, end: LspPosition { line: span.end.line, character: span.end.column, }, } } /// Re-exported AST link walker so tests can drive it without depending /// on the diagnostics module's internals. pub use crate::diagnostics::collect_wiki_links; // ===== Phase 16: diary helpers ===== use crate::edits::op_create; /// Build the `WorkspaceEdit` that materialises the diary index page. /// /// Decision tree: /// - If the index URI is open in `backend.documents`, the live text is /// the source of truth: emit a single full-document `TextEdit` /// replacing it with `body`. /// - If the index file exists on disk but isn't open: same full-doc /// replace, span computed from the on-disk bytes. /// - If neither: a `CreateFile` op + an insert at `0:0`. pub(crate) fn diary_generate_index_edit( backend: &Backend, index_uri: &Url, body: &str, utf8: bool, ) -> WorkspaceEdit { let mut b = WorkspaceEditBuilder::new(); if let Some(doc) = backend.documents.get(index_uri) { let span = whole_doc_span(&doc.text); b.edit( index_uri.clone(), text_edit_replace(span, body.to_string(), &doc.text, utf8), ); return b.build(); } if let Ok(path) = index_uri.to_file_path() { if let Ok(existing) = std::fs::read_to_string(&path) { let span = whole_doc_span(&existing); b.edit( index_uri.clone(), text_edit_replace(span, body.to_string(), &existing, utf8), ); return b.build(); } } // File does not exist — create it and insert the body. The text // edit applies *after* CreateFile via the builder's ordering. b.file_op(op_create(index_uri.clone())); b.edit( index_uri.clone(), crate::edits::text_edit_insert( LspPosition { line: 0, character: 0, }, body.to_string(), ), ); b.build() } // ===== Tag ops (Phase 12 commands) ===== // // SPEC §12.3 calls for three tag-driven commands beyond the indexing // that Phase 12 already shipped. `tag_hits` powers `nuwiki.tags.search`; // `tag_links_edit` powers `nuwiki.tags.generateLinks`; the rebuild // command is a direct re-walk of the wiki root in the dispatcher. use crate::index::TagOccurrence; use nuwiki_core::ast::TagScope; /// One row returned by `nuwiki.tags.search`. Serialised as /// `{ name, uri, range, scope, page }` so editors can render either a /// flat hit list or a per-tag tree. #[derive(Debug, Clone, serde::Serialize)] pub struct TagHit { pub name: String, pub uri: Url, pub range: tower_lsp::lsp_types::Range, pub scope: &'static str, pub page: String, } fn scope_tag(s: TagScope) -> &'static str { match s { TagScope::File => "file", TagScope::Heading(_) => "heading", TagScope::Standalone => "standalone", } } /// Filter every indexed tag by a case-insensitive substring match. An /// empty query returns everything (sorted by name, then by page). pub fn tag_hits(index: &WorkspaceIndex, query: &str) -> Vec { let needle = query.to_ascii_lowercase(); let mut out: Vec = Vec::new(); for (name, occs) in &index.tags_by_name { if !needle.is_empty() && !name.to_ascii_lowercase().contains(&needle) { continue; } for occ in occs { let page = index .pages_by_uri .get(&occ.uri) .map(|p| p.name.clone()) .unwrap_or_default(); out.push(TagHit { name: name.clone(), uri: occ.uri.clone(), range: no_text_range(&occ.span), scope: scope_tag(occ.scope), page, }); } } out.sort_by(|a, b| { a.name .cmp(&b.name) .then_with(|| a.page.cmp(&b.page)) .then_with(|| a.range.start.line.cmp(&b.range.start.line)) }); out } /// Snapshot of `tag → sorted [page names]` taken under the index read /// lock so the dispatcher can release the lock before computing the /// edit. pub fn tag_pages_snapshot( index: &WorkspaceIndex, ) -> std::collections::BTreeMap> { let mut by_tag: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for (name, occs) in &index.tags_by_name { for occ in occs { if let Some(page) = index.pages_by_uri.get(&occ.uri) { by_tag .entry(name.clone()) .or_default() .insert(page.name.clone()); } } } by_tag .into_iter() .map(|(k, v)| (k, v.into_iter().collect())) .collect() } /// Render a `= Tag: =` section listing every page tagged with /// `name`. When `tag` is `None` the section becomes a "Tag Index" /// covering every tag, headed `== ==` per group — matches /// `:VimwikiGenerateTagLinks` (no arg) variant. pub fn build_tag_links_text( pages_by_tag: &std::collections::BTreeMap>, tag: Option<&str>, ) -> Option { match tag { Some(t) => { let pages = pages_by_tag.get(t)?; if pages.is_empty() { return None; } let mut out = format!("= Tag: {t} =\n"); for p in pages { out.push_str(&format!("- [[{p}]]\n")); } Some(out) } None => { if pages_by_tag.is_empty() { return None; } let mut out = String::from("= Tags =\n"); for (name, pages) in pages_by_tag { out.push_str(&format!("\n== {name} ==\n")); for p in pages { out.push_str(&format!("- [[{p}]]\n")); } } Some(out) } } } /// Heading name used to locate an existing tag-links section so /// re-generation is idempotent. fn tag_section_heading(tag: Option<&str>) -> String { match tag { Some(t) => format!("Tag: {t}"), None => "Tags".to_string(), } } /// Build a `WorkspaceEdit` that replaces an existing `Tag: ` /// section (case-insensitive match on the h1 title) or inserts at the /// top of the document when no such section exists. Returns `None` /// when there are no matching tagged pages. pub fn tag_links_edit( text: &str, ast: &DocumentNode, uri: &Url, tag: Option<&str>, pages_by_tag: &std::collections::BTreeMap>, utf8: bool, ) -> Option { let body = build_tag_links_text(pages_by_tag, tag)?; let heading = tag_section_heading(tag); let mut b = WorkspaceEditBuilder::new(); match find_section_range(ast, &heading) { Some((start, end)) => { let span = Span::new(start, end); b.edit(uri.clone(), text_edit_replace(span, body, text, utf8)); } None => { let with_sep = format!("{body}\n"); b.edit( uri.clone(), text_edit_insert( LspPosition { line: 0, character: 0, }, with_sep, ), ); } } Some(b.build()) } // Keep the import live for downstream use even when the local module // doesn't reference it directly. #[allow(dead_code)] fn _retain_tag_occurrence_import(_o: &TagOccurrence) {} fn whole_doc_span(text: &str) -> Span { let bytes = text.as_bytes(); let mut line = 0u32; let mut last_nl = 0usize; for (i, b) in bytes.iter().enumerate() { if *b == b'\n' { line += 1; last_nl = i + 1; } } let column = (bytes.len() - last_nl) as u32; Span::new( AstPosition { line: 0, column: 0, offset: 0, }, AstPosition { line, column, offset: bytes.len(), }, ) } } /// Disk-touching helpers for the Phase 17 `nuwiki.export.*` commands. /// Kept out of `ops` because everything here is side-effecting — pure /// rendering / templating lives in [`crate::export`]. pub mod export_ops { use std::path::{Path, PathBuf}; use nuwiki_core::ast::DocumentNode; use nuwiki_core::date::DiaryDate; use crate::config::WikiConfig; use crate::diary::DiaryEntry; use crate::export::{ css_path, fallback_template, is_excluded, output_path_for, render_page_html, template_for, DEFAULT_CSS, }; /// Per-page result of `write_page`. Serialised back to the client as /// `{ uri, output_path, page }`. pub struct PageExport { pub page: String, pub output_path: PathBuf, pub source_uri: Option, } impl PageExport { pub fn to_json(&self) -> serde_json::Value { serde_json::json!({ "page": self.page, "output_path": self.output_path, "uri": self.source_uri, }) } } /// CSS write outcome — `created = true` when we just put a default /// stylesheet on disk; false when one already existed. pub struct CssOutcome { pub path: PathBuf, pub created: bool, } /// Render `doc` and write the result to `/.html`. /// Reads the template from disk (primary → fallback → built-in /// fallback). Creates any missing parent directories. pub fn write_page( doc: &DocumentNode, name: &str, cfg: &WikiConfig, ) -> std::io::Result { if is_excluded(&cfg.html.exclude_files, name) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("excluded by exclude_files: {name}"), )); } let template_source = template_for(&cfg.html, doc); let template = load_template(&template_source) .unwrap_or_else(|| fallback_template(&cfg.html.css_name)); let html = render_page_html( doc, Some(template), name, DiaryDate::today_utc(), &cfg.html, std::collections::HashMap::new(), )?; let out_path = output_path_for(&cfg.html, name); if let Some(parent) = out_path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&out_path, html)?; let source_uri = tower_lsp::lsp_types::Url::from_file_path( cfg.root.join(format!("{name}{}", cfg.file_extension)), ) .ok(); Ok(PageExport { page: name.to_string(), output_path: out_path, source_uri, }) } /// Two-tier template load. Returns the resolved template body, or /// `None` if neither file exists — caller falls back to the built-in /// template. fn load_template(src: &crate::export::TemplateSource) -> Option { if let Some(p) = src.primary.as_ref() { if let Ok(text) = std::fs::read_to_string(p) { return Some(text); } } std::fs::read_to_string(&src.fallback).ok() } /// Ensure `/` exists. Writes the built-in /// [`DEFAULT_CSS`] when missing. No-op when it's already there. pub fn ensure_css(cfg: &WikiConfig) -> std::io::Result { let path = css_path(&cfg.html); if path.exists() { return Ok(CssOutcome { path, created: false, }); } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&path, DEFAULT_CSS)?; Ok(CssOutcome { path, created: true, }) } /// Write `/rss.xml` listing the wiki's diary entries /// newest-first. The feed is intentionally minimal — just enough to /// validate against RSS 2.0 parsers — since the diary URLs are file: /// scheme and won't survive remote consumption anyway. pub fn write_rss(cfg: &WikiConfig, entries: &[DiaryEntry]) -> std::io::Result { let mut sorted: Vec<&DiaryEntry> = entries.iter().collect(); sorted.sort_by(|a, b| b.date.cmp(&a.date)); let mut xml = String::from( r#" "#, ); xml.push_str(&format!("{}\n", xml_escape(&cfg.name))); xml.push_str("nuwiki diary\n"); for e in sorted { xml.push_str(""); xml.push_str(&format!("{}", e.date.format())); xml.push_str(&format!("{}", e.uri.as_str())); xml.push_str(&format!("{}", e.uri.as_str())); xml.push_str("\n"); } xml.push_str("\n"); let path = rss_path(cfg); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&path, xml)?; Ok(path) } fn rss_path(cfg: &WikiConfig) -> PathBuf { cfg.html.html_path.join("rss.xml") } fn xml_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for ch in s.chars() { match ch { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), _ => out.push(ch), } } out } /// Re-export so the dispatcher doesn't have to know about the export /// module's path manipulation primitives. #[allow(dead_code)] pub fn _silence_unused(_: &Path) {} }