Files
nuwiki/crates/nuwiki-lsp/src/commands.rs
T
gffranco 8ab6015405
CI / cargo fmt --check (push) Successful in 33s
CI / cargo clippy (push) Successful in 23s
CI / cargo test (push) Successful in 33s
CI / editor keymaps (push) Successful in 1m25s
Major clean up and improvements to vimwiki compatibility and documentation.
Reviewed-on: #1
2026-05-30 18:35:40 +00:00

3220 lines
110 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.
//! `workspace/executeCommand` dispatcher.
//!
//! The router handles `nuwiki.file.delete`, the cheap surgical edits —
//! checkbox toggle/cycle/reject, heading promote/demote, "next task"
//! navigation — and 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",
"nuwiki.link.pasteWikilink",
"nuwiki.link.pasteUrl",
"nuwiki.list.removeDone",
"nuwiki.list.renumber",
"nuwiki.list.changeSymbol",
"nuwiki.list.changeLevel",
"nuwiki.table.insert",
"nuwiki.table.align",
"nuwiki.table.moveColumn",
];
pub(crate) async fn execute(
backend: &Backend,
command: &str,
args: Vec<Value>,
) -> Result<Option<CommandOutcome>, 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" => {
let reverse = args
.first()
.and_then(|v| v.get("reverse"))
.and_then(Value::as_bool)
.unwrap_or(false);
let mutate = if reverse {
ops::cycle_state_back
} else {
ops::cycle_state
};
Ok(list_checkbox(backend, args, mutate)?.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))
}
"nuwiki.link.pasteWikilink" => {
link_paste(backend, args, PasteKind::Wikilink).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.link.pasteUrl" => {
link_paste(backend, args, PasteKind::Url).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.list.removeDone" => {
list_remove_done(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.list.renumber" => list_renumber(backend, args).map(|o| o.map(CommandOutcome::Edit)),
"nuwiki.list.changeSymbol" => {
list_change_symbol(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.list.changeLevel" => {
list_change_level(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.table.insert" => table_insert(backend, args).map(|o| o.map(CommandOutcome::Edit)),
"nuwiki.table.align" => table_align(backend, args).map(|o| o.map(CommandOutcome::Edit)),
"nuwiki.table.moveColumn" => {
table_move_column(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
other => Err(format!("unknown nuwiki command: {other}")),
}
}
#[derive(Deserialize)]
struct PosArgs {
uri: Url,
position: LspPosition,
}
fn parse_pos(args: Vec<Value>) -> Result<PosArgs, String> {
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<Value>) -> Result<Option<WorkspaceEdit>, 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<Value>,
mutate: fn(&str) -> Option<&'static str>,
) -> Result<Option<WorkspaceEdit>, 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 propagate = backend
.wiki_for_uri(&p.uri)
.map(|w| w.config.listsyms_propagate)
.unwrap_or(true);
Ok(ops::checkbox_edit(
&doc.text, &doc.ast, &p.uri, line, col, utf8, mutate, propagate,
))
}
fn list_next_task(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, 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<Value>,
delta: i32,
) -> Result<Option<WorkspaceEdit>, 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<Value>) -> Result<UriArg, String> {
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<Url>,
}
fn parse_optional_uri_arg(args: Vec<Value>) -> Result<OptionalUriArg, String> {
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<Value>) -> Result<Option<WorkspaceEdit>, 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<Value>) -> Result<Option<WorkspaceEdit>, 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,
&current_page,
&pages,
utf8,
))
}
fn workspace_check_links(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, 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<crate::wiki::Wiki> {
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<Value>,
rel: RelativeDay,
) -> Result<Option<Value>, String> {
let p = parse_optional_uri_arg(args)?;
let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else {
return Ok(None);
};
let freq = wiki.config.frequency();
let today_period = nuwiki_core::date::DiaryPeriod::today_utc(freq);
let period = match rel {
RelativeDay::Today => today_period,
RelativeDay::Yesterday => today_period.prev(),
RelativeDay::Tomorrow => today_period.next(),
};
let Some(uri) = crate::diary::uri_for_period(&wiki.config, &period) else {
return Ok(None);
};
Ok(Some(serde_json::json!({
"uri": uri,
"date": period.format(),
"frequency": freq.as_str(),
})))
}
fn diary_open_index(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, 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<Value>,
) -> Result<Option<WorkspaceEdit>, 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<Value>, forward: bool) -> Result<Option<Value>, String> {
let p = parse_uri_arg(args)?;
let Some(wiki) = backend.wiki_for_uri(&p.uri) else {
return Ok(None);
};
// Pivot period: prefer the current page's own period (any flavour),
// falling back to the wiki's configured frequency for "today" when
// the cursor isn't on a diary page. This makes <C-Down> on a weekly
// entry jump to the next weekly entry, not the next daily one.
let pivot: nuwiki_core::date::DiaryPeriod = {
let idx = wiki
.index
.read()
.map_err(|_| "index lock poisoned".to_string())?;
idx.page(&p.uri)
.and_then(|page| page.diary_period)
.unwrap_or_else(|| nuwiki_core::date::DiaryPeriod::today_utc(wiki.config.frequency()))
};
let next = {
let idx = wiki
.index
.read()
.map_err(|_| "index lock poisoned".to_string())?;
if forward {
crate::diary::next_period(&idx, &pivot)
} else {
crate::diary::prev_period(&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<Value>) -> Result<Option<Value>, String> {
#[derive(Deserialize, Default)]
struct Args {
#[serde(default)]
uri: Option<Url>,
#[serde(default)]
year: Option<i32>,
#[serde(default)]
month: Option<u8>,
}
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<Value>) -> Result<Option<Value>, String> {
#[derive(Deserialize)]
struct Args {
date: String,
#[serde(default)]
uri: Option<Url>,
}
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<Value>) -> Result<Option<Value>, String> {
#[derive(Deserialize, Default)]
struct Args {
#[serde(default)]
uri: Option<Url>,
#[serde(default)]
query: Option<String>,
}
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<Value>,
) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
#[serde(default)]
tag: Option<String>,
}
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<Value>) -> Result<Option<Value>, 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<Url> = 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(),
})))
}
// ===== multi-wiki picker commands =====
fn wiki_list_all(backend: &Backend) -> Result<Option<Value>, String> {
let wikis = backend.wikis_snapshot();
let rows: Vec<Value> = 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<crate::wiki::Wiki> {
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::<usize>() {
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<Value>,
tab: bool,
) -> Result<Option<Value>, String> {
#[derive(Deserialize, Default)]
struct Args {
#[serde(default)]
wiki: Option<Value>,
}
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!(
"{}{}",
wiki.config.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,
})))
}
#[derive(Clone, Copy)]
enum PasteKind {
Wikilink,
Url,
}
fn link_paste(
backend: &Backend,
args: Vec<Value>,
kind: PasteKind,
) -> Result<Option<WorkspaceEdit>, String> {
let p = parse_pos(args)?;
let Some(wiki) = backend.wiki_for_uri(&p.uri) else {
return Ok(None);
};
let name = crate::index::page_name_from_uri(&p.uri, Some(&wiki.config.root));
if name.is_empty() {
return Ok(None);
}
let snippet = match kind {
PasteKind::Wikilink => format!("[[{name}]]"),
PasteKind::Url => {
// Mirror `export::output_path_for` — the URL is the path
// beneath `html_path`, relative to the *exported root*, so
// pasted links survive when the HTML output moves.
let mut url = String::new();
for (i, seg) in name.split('/').enumerate() {
if i > 0 {
url.push('/');
}
url.push_str(seg);
}
url.push_str(".html");
url
}
};
let mut b = WorkspaceEditBuilder::new();
b.edit(p.uri, crate::edits::text_edit_insert(p.position, snippet));
Ok(Some(b.build()))
}
fn wiki_goto_page(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
#[derive(Deserialize)]
struct Args {
page: String,
#[serde(default)]
wiki: Option<Value>,
}
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 `<root>/<page>.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,
})))
}
// ===== Cluster A: list rewriters =====
fn list_remove_done(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
let p = parse_optional_uri_arg(args.clone())?;
// Accept both `{ uri }` (whole doc) and `{ uri, position }` (item under
// cursor + its descendants). Position is optional — when absent, we
// strip every done/rejected item in the doc.
let raw = args.into_iter().next().unwrap_or(serde_json::json!({}));
#[derive(Deserialize, Default)]
struct Args {
uri: Option<Url>,
#[serde(default)]
position: Option<LspPosition>,
}
let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?;
let uri = parsed
.uri
.or(p.uri)
.ok_or_else(|| "missing uri".to_string())?;
let doc = match backend.documents.get(&uri) {
Some(d) => d,
None => return Ok(None),
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
Ok(ops::remove_done_edit(
&doc.text,
&doc.ast,
&uri,
parsed.position,
utf8,
))
}
fn list_renumber(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
#[serde(default)]
position: Option<LspPosition>,
#[serde(default)]
whole_file: bool,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "missing { uri } argument".to_string())?;
let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("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 (line, _) = match parsed.position {
Some(pos) => nav::lsp_to_byte_pos(pos, &doc.text, utf8),
None => (0, 0),
};
Ok(ops::renumber_edit(
&doc.text,
&doc.ast,
&parsed.uri,
line,
parsed.whole_file,
utf8,
))
}
fn list_change_symbol(
backend: &Backend,
args: Vec<Value>,
) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
position: LspPosition,
symbol: String,
#[serde(default)]
whole_list: bool,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "missing { uri, position, symbol } argument".to_string())?;
let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?;
let target = ops::parse_symbol(&parsed.symbol)
.ok_or_else(|| format!("unknown list symbol: {}", parsed.symbol))?;
let doc = match backend.documents.get(&parsed.uri) {
Some(d) => d,
None => return Ok(None),
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let (line, _) = nav::lsp_to_byte_pos(parsed.position, &doc.text, utf8);
Ok(ops::change_symbol_edit(
&doc.text,
&doc.ast,
&parsed.uri,
line,
target,
parsed.whole_list,
utf8,
))
}
fn list_change_level(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
position: LspPosition,
delta: i32,
#[serde(default)]
whole_subtree: bool,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "missing { uri, position, delta } argument".to_string())?;
let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("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 (line, _) = nav::lsp_to_byte_pos(parsed.position, &doc.text, utf8);
Ok(ops::change_level_edit(
&doc.text,
&doc.ast,
&parsed.uri,
line,
parsed.delta,
parsed.whole_subtree,
utf8,
))
}
// ===== Cluster B: table rewriters =====
fn table_insert(_backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
position: LspPosition,
cols: usize,
rows: usize,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "missing { uri, position, cols, rows }".to_string())?;
let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?;
if parsed.cols == 0 || parsed.rows == 0 {
return Ok(None);
}
let text = ops::render_blank_table(parsed.cols, parsed.rows);
let mut b = WorkspaceEditBuilder::new();
b.edit(
parsed.uri,
crate::edits::text_edit_insert(parsed.position, text),
);
Ok(Some(b.build()))
}
fn table_align(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, 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, _) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8);
Ok(ops::table_align_edit(
&doc.text, &doc.ast, &p.uri, line, utf8,
))
}
fn table_move_column(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
position: LspPosition,
#[serde(default)]
dir: Option<String>,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "missing { uri, position, dir }".to_string())?;
let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?;
let dir = parsed.dir.as_deref().unwrap_or("right");
let delta = match dir {
"left" => -1i32,
"right" => 1,
other => return Err(format!("unknown direction: {other}")),
};
let doc = match backend.documents.get(&parsed.uri) {
Some(d) => d,
None => return Ok(None),
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let (line, col) = nav::lsp_to_byte_pos(parsed.position, &doc.text, utf8);
Ok(ops::table_move_column_edit(
&doc.text,
&doc.ast,
&parsed.uri,
line,
col,
delta,
utf8,
))
}
// ===== HTML export commands =====
fn export_current(
backend: &Backend,
args: Vec<Value>,
return_open_url: bool,
) -> Result<Option<Value>, 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<Value>, force: bool) -> Result<Option<Value>, 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<serde_json::Value> = Vec::new();
let mut skipped: Vec<serde_json::Value> = 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<Value>) -> Result<Option<Value>, 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<Value>) -> Result<Option<Value>, 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}")
})?))
}
/// Return the end-of-file `LspPosition` (1-based lines; 0-based
/// character column) for the given text, suitable for appending.
fn eof_position(text: &str) -> LspPosition {
let mut line = 0u32;
let mut last_nl_col = 0u32;
let mut col = 0u32;
for (i, b) in text.as_bytes().iter().copied().enumerate() {
if b == b'\n' {
line += 1;
last_nl_col = (i + 1) as u32;
col = 0;
} else {
col = (i + 1 - last_nl_col as usize) as u32;
}
}
LspPosition {
line,
character: col,
}
}
// ===== 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,
}
}
/// Exact inverse of [`cycle_state`] — used by the `glp` mapping
/// ("cycle the checkbox state backward").
pub fn cycle_state_back(current: &str) -> Option<&'static str> {
match current {
"[ ]" => Some("[X]"),
"[X]" => Some("[O]"),
"[O]" => Some("[o]"),
"[o]" => Some("[.]"),
"[.]" => Some("[ ]"),
"[-]" => Some("[ ]"),
_ => None,
}
}
/// Toggle the `[-]` "rejected" state.
pub fn reject_state(current: &str) -> Option<&'static str> {
match current {
"[-]" => Some("[ ]"),
_ => Some("[-]"),
}
}
#[allow(clippy::too_many_arguments)]
pub fn checkbox_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
line: u32,
col: u32,
utf8: bool,
mutate: fn(&str) -> Option<&'static str>,
propagate: bool,
) -> Option<WorkspaceEdit> {
let path = find_path_to_item(ast, line, col)?;
let leaf = *path.last()?;
let cb_span = find_checkbox_span(text, leaf.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),
);
if propagate && path.len() > 1 {
// Vimwiki's listsyms_propagate: walk up the chain, recomputing
// each ancestor's marker from the average rate of its immediate
// children (using the modified child's *new* state). Stops at
// the first ancestor that has no checkbox.
let mut child_node: &ListItemNode = leaf;
let mut child_marker: &str = new;
for parent in path.iter().rev().skip(1) {
let parent_cb_span = match find_checkbox_span(text, parent.span) {
Some(s) => s,
None => break,
};
let parent_cur = &text[parent_cb_span.start.offset..parent_cb_span.end.offset];
let new_parent = match compute_parent_marker(parent, child_node, child_marker) {
Some(m) => m,
None => break,
};
if new_parent == parent_cur {
break;
}
b.edit(
uri.clone(),
text_edit_replace(parent_cb_span, new_parent.to_string(), text, utf8),
);
child_node = parent;
child_marker = new_parent;
}
}
Some(b.build())
}
/// Convert a marker string like `"[X]"` to its progress rate. `-1`
/// signals rejected (handled specially when averaging). Returns
/// `None` for unrecognised markers.
fn marker_to_rate(marker: &str) -> Option<i32> {
match marker {
"[ ]" => Some(0),
"[.]" => Some(25),
"[o]" => Some(50),
"[O]" => Some(75),
"[X]" => Some(100),
"[-]" => Some(-1),
_ => None,
}
}
fn checkbox_state_to_rate(state: CheckboxState) -> i32 {
match state {
CheckboxState::Empty => 0,
CheckboxState::Quarter => 25,
CheckboxState::Half => 50,
CheckboxState::ThreeQuarters => 75,
CheckboxState::Done => 100,
CheckboxState::Rejected => -1,
}
}
/// Inverse of `marker_to_rate`. `rate` is 0..=100 or `-1` (rejected).
/// Mirrors vimwiki's `s:rate_to_state` for the standard 5-symbol
/// `' .oOX'` palette.
fn rate_to_marker(rate: i32) -> &'static str {
if rate == -1 {
return "[-]";
}
if rate <= 0 {
return "[ ]";
}
if rate >= 100 {
return "[X]";
}
// n=5 symbols, so n-2=3. ceil(rate/100 * 3) ∈ {1,2,3}
let idx = ((rate as f64) / 100.0 * 3.0).ceil() as i32;
match idx {
1 => "[.]",
2 => "[o]",
_ => "[O]",
}
}
/// Compute the new marker for `parent` given that `modified_child`
/// (one of its immediate sublist items) just became
/// `modified_marker`. Returns `None` if the parent has no children
/// with checkboxes — there's nothing to average from.
///
/// Mirrors vimwiki's `s:update_state` averaging: rejected children
/// count as 100% for the average, but if *every* child is rejected
/// the parent itself becomes rejected.
fn compute_parent_marker(
parent: &ListItemNode,
modified_child: &ListItemNode,
modified_marker: &str,
) -> Option<&'static str> {
let sub = parent.sublist.as_ref()?;
let mut sum: i32 = 0;
let mut count_with_cb: i32 = 0;
let mut count_rejected: i32 = 0;
for item in &sub.items {
let rate = if std::ptr::eq(item, modified_child) {
marker_to_rate(modified_marker)?
} else {
match item.checkbox {
Some(state) => checkbox_state_to_rate(state),
None => continue,
}
};
count_with_cb += 1;
if rate == -1 {
count_rejected += 1;
sum += 100;
} else {
sum += rate;
}
}
if count_with_cb == 0 {
return None;
}
let new_rate = if count_rejected == count_with_cb {
-1
} else {
sum / count_with_cb
};
Some(rate_to_marker(new_rate))
}
pub fn next_task(
ast: &DocumentNode,
uri: &Url,
text: &str,
line: u32,
_col: u32,
utf8: bool,
) -> Option<Location> {
// 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<WorkspaceEdit> {
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<String> {
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.
/// Searches only the marker line — a parent item's span covers its
/// nested sublist too, and we don't want a child's checkbox to look
/// like the parent's.
pub fn find_checkbox_span(text: &str, item_span: Span) -> Option<Span> {
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 line_end = bytes
.iter()
.position(|&b| b == b'\n')
.unwrap_or(bytes.len());
let bytes = &bytes[..line_end];
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
}
/// Return the chain of list items from outermost ancestor to the
/// deepest item containing `line`. Used by checkbox propagation so
/// it can update each ancestor in turn.
pub fn find_path_to_item(
ast: &DocumentNode,
line: u32,
col: u32,
) -> Option<Vec<&ListItemNode>> {
for block in &ast.children {
if let Some(path) = path_in_block(block, line, col) {
return Some(path);
}
}
None
}
fn path_in_block(block: &BlockNode, line: u32, col: u32) -> Option<Vec<&ListItemNode>> {
match block {
BlockNode::List(l) => l.items.iter().find_map(|it| path_in_item(it, line, col)),
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
children.iter().find_map(|c| path_in_block(c, line, col))
}
_ => None,
}
}
fn path_in_item(item: &ListItemNode, line: u32, col: u32) -> Option<Vec<&ListItemNode>> {
if let Some(sub) = &item.sublist {
for sub_it in &sub.items {
if let Some(mut deeper) = path_in_item(sub_it, line, col) {
let mut path = Vec::with_capacity(deeper.len() + 1);
path.push(item);
path.append(&mut deeper);
return Some(path);
}
}
}
if (item.span.start.line..=item.span.end.line).contains(&line) {
return Some(vec![item]);
}
let _ = col;
None
}
// ===== 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;
};
// Accept any heading level for section replacement. Previously only
// level1 headings were considered, which caused `VimwikiGenerateTagLinks`
// to miss existing tag sections if they were not level1 (e.g. `== Tag:`).
// Removing the level check makes the edit idempotent across heading
// levels.
// NOTE: All callers (TOC, Links, Tag links) expect a toplevel heading,
// but supporting any level is safe because headings are unique by name
// within a page and the operation replaces the whole section.
// If multiple headings share the same title at different levels, the
// first encountered will be replaced this matches prior behaviour for
// level1.
// The level check is therefore removed.
// if h.level != 1 {
// continue;
// }
let title = heading_title(h).to_ascii_lowercase();
if title != needle {
continue;
}
let start = h.span.start;
// Determine the end of the section. Historically we only extended
// to the next immediate list block, which worked for simple tag
// sections like `= Tag: foo =` that contain a single list. For the
// fulltags index (`= Tags =`) the section contains multiple
// subheadings and lists, so the next block after the heading is a
// level2 heading, not a list. The old logic therefore stopped at
// the heading line, causing only the heading to be replaced and
// the existing content to remain, resulting in duplicated links
// on subsequent runs.
//
// New logic walks forward until it finds the next *level1*
// heading (the start of the next toplevel section) or reaches the
// end of the document. The span end is set to the start of that
// next heading (exclusive) or to the end of the last block.
let mut end = h.span.end;
for next_block in ast.children.iter().skip(i + 1) {
// If we encounter another level1 heading, the current section
// ends just before it.
if let BlockNode::Heading(next_h) = next_block {
if next_h.level == 1 {
end = next_h.span.start;
break;
}
}
// Otherwise extend the end to the end of this block.
end = match next_block {
BlockNode::Heading(h) => h.span.end,
BlockNode::Paragraph(p) => p.span.end,
BlockNode::HorizontalRule(hr) => hr.span.end,
BlockNode::Blockquote(bq) => bq.span.end,
BlockNode::Preformatted(pf) => pf.span.end,
BlockNode::MathBlock(mb) => mb.span.end,
BlockNode::List(list) => list.span.end,
BlockNode::DefinitionList(dl) => dl.span.end,
BlockNode::Table(t) => t.span.end,
BlockNode::Comment(c) => c.span.end,
BlockNode::Tag(tag) => tag.span.end,
BlockNode::Error(err) => err.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<TocItem> {
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<WorkspaceEdit> {
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<WorkspaceEdit> {
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");
let insert_pos = eof_position(text);
b.edit(uri.clone(), text_edit_insert(insert_pos, 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<BrokenLinkEntry> {
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<OrphanEntry> {
let mut out: Vec<OrphanEntry> = 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;
// ===== Cluster A: list rewriters =====
use crate::edits::text_edit_delete;
use nuwiki_core::ast::ListSymbol;
/// Parse a `:VimwikiListChangeSymbol` argument into a `ListSymbol`.
/// Accepts both the short markers (`-`, `*`, `#`, `1.`, …) and the
/// long variant names so wrappers can pass either shape.
pub fn parse_symbol(name: &str) -> Option<ListSymbol> {
match name {
"-" | "Dash" | "dash" => Some(ListSymbol::Dash),
"*" | "Star" | "star" => Some(ListSymbol::Star),
"#" | "Hash" | "hash" => Some(ListSymbol::Hash),
"1." | "Numeric" | "numeric" => Some(ListSymbol::Numeric),
"1)" | "NumericParen" | "numeric_paren" => Some(ListSymbol::NumericParen),
"a)" | "AlphaParen" | "alpha_paren" => Some(ListSymbol::AlphaParen),
"A)" | "AlphaUpperParen" | "alpha_upper_paren" => Some(ListSymbol::AlphaUpperParen),
"i)" | "RomanParen" | "roman_paren" => Some(ListSymbol::RomanParen),
"I)" | "RomanUpperParen" | "roman_upper_paren" => Some(ListSymbol::RomanUpperParen),
_ => None,
}
}
/// Render a list marker for the given symbol + 1-based sequence index.
/// Indices only matter for ordered variants (`Numeric`, `AlphaParen`, …).
pub fn render_marker(symbol: ListSymbol, idx: usize) -> String {
match symbol {
ListSymbol::Dash => "-".to_string(),
ListSymbol::Star => "*".to_string(),
ListSymbol::Hash => "#".to_string(),
ListSymbol::Numeric => format!("{idx}."),
ListSymbol::NumericParen => format!("{idx})"),
ListSymbol::AlphaParen => alpha_marker(idx, false),
ListSymbol::AlphaUpperParen => alpha_marker(idx, true),
ListSymbol::RomanParen => format!("{})", roman_marker(idx, false)),
ListSymbol::RomanUpperParen => format!("{})", roman_marker(idx, true)),
}
}
fn alpha_marker(idx: usize, upper: bool) -> String {
// Spreadsheet-style: 1 → a, 2 → b, …, 26 → z, 27 → aa.
let mut n = idx;
let mut buf = Vec::new();
let base = if upper { b'A' } else { b'a' };
while n > 0 {
let rem = (n - 1) % 26;
buf.push(base + rem as u8);
n = (n - 1) / 26;
}
let mut s: String = buf.iter().rev().map(|b| *b as char).collect();
s.push(')');
s
}
fn roman_marker(idx: usize, upper: bool) -> String {
let map: [(usize, &str); 13] = [
(1000, "m"),
(900, "cm"),
(500, "d"),
(400, "cd"),
(100, "c"),
(90, "xc"),
(50, "l"),
(40, "xl"),
(10, "x"),
(9, "ix"),
(5, "v"),
(4, "iv"),
(1, "i"),
];
let mut n = idx;
let mut out = String::new();
for (val, sym) in map {
while n >= val {
out.push_str(sym);
n -= val;
}
}
if upper {
out.to_ascii_uppercase()
} else {
out
}
}
/// Walk every list block at top level (recursing into blockquotes
/// and sublists) and call `f` with each item.
fn walk_list_items<'a, F: FnMut(&'a ListItemNode)>(blocks: &'a [BlockNode], f: &mut F) {
for block in blocks {
walk_list_items_block(block, f);
}
}
fn walk_list_items_block<'a, F: FnMut(&'a ListItemNode)>(block: &'a BlockNode, f: &mut F) {
match block {
BlockNode::List(ListNode { items, .. }) => {
for item in items {
walk_list_items_in_item(item, f);
}
}
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
for c in children {
walk_list_items_block(c, f);
}
}
_ => {}
}
}
fn walk_list_items_in_item<'a, F: FnMut(&'a ListItemNode)>(item: &'a ListItemNode, f: &mut F) {
f(item);
if let Some(sub) = &item.sublist {
for sub_it in &sub.items {
walk_list_items_in_item(sub_it, f);
}
}
}
// ----- removeDone -----
/// Delete every checkbox item whose state is `Done` or `Rejected`,
/// cascading into their sublists. When `pos` is `Some`, restrict to
/// the contiguous list block containing `pos`'s line (the "current
/// list" — including its nested sublists); otherwise sweep the whole
/// doc.
pub fn remove_done_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
pos: Option<LspPosition>,
utf8: bool,
) -> Option<WorkspaceEdit> {
let mut victims: Vec<Span> = Vec::new();
let mut collect = |item: &ListItemNode| {
if matches!(
item.checkbox,
Some(nuwiki_core::ast::CheckboxState::Done)
| Some(nuwiki_core::ast::CheckboxState::Rejected),
) {
victims.push(extend_to_line_end(text, item.span));
}
};
match pos {
None => walk_list_items(&ast.children, &mut collect),
Some(p) => {
// "Current list" = the top-level contiguous list block the
// cursor sits in. Walk just that block (and its sublists),
// leaving done items in sibling lists untouched.
let list = find_top_list_at_line(ast, p.line)?;
for item in &list.items {
walk_list_items_in_item(item, &mut collect);
}
}
}
if victims.is_empty() {
return None;
}
let mut b = WorkspaceEditBuilder::new();
for span in victims {
b.edit(uri.clone(), text_edit_delete(span, text, utf8));
}
Some(b.build())
}
/// Find the top-level (contiguous) list block whose span contains
/// `line`. Unlike [`find_list_at_line`], this does not descend into
/// sublists — it returns the outermost list so callers operate on the
/// whole list block the cursor belongs to.
fn find_top_list_at_line(ast: &DocumentNode, line: u32) -> Option<&ListNode> {
for block in &ast.children {
if let Some(list) = find_top_list_in_block(block, line) {
return Some(list);
}
}
None
}
fn find_top_list_in_block(block: &BlockNode, line: u32) -> Option<&ListNode> {
match block {
BlockNode::List(list) => {
if (list.span.start.line..=list.span.end.line).contains(&line) {
Some(list)
} else {
None
}
}
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
for c in children {
if let Some(l) = find_top_list_in_block(c, line) {
return Some(l);
}
}
None
}
_ => None,
}
}
/// Extend a span to include the trailing newline (so deletion removes
/// the empty line that would otherwise be left behind).
fn extend_to_line_end(text: &str, span: Span) -> Span {
let bytes = text.as_bytes();
let mut end_off = span.end.offset.min(bytes.len());
let mut end_line = span.end.line;
let mut end_col = span.end.column;
if end_off < bytes.len() && bytes[end_off] == b'\n' {
end_off += 1;
end_line += 1;
end_col = 0;
}
Span::new(
span.start,
AstPosition {
line: end_line,
column: end_col,
offset: end_off,
},
)
}
// ----- renumber -----
/// Re-sequence numeric markers in the list containing `line`, or in
/// every list when `whole_file` is set.
pub fn renumber_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
line: u32,
whole_file: bool,
utf8: bool,
) -> Option<WorkspaceEdit> {
let mut edits: Vec<(Span, String)> = Vec::new();
if whole_file {
for block in &ast.children {
renumber_in_block(block, text, &mut edits);
}
} else {
let list = find_list_at_line(ast, line)?;
renumber_in_list(list, text, &mut edits);
}
if edits.is_empty() {
return None;
}
let mut b = WorkspaceEditBuilder::new();
for (span, new) in edits {
b.edit(uri.clone(), text_edit_replace(span, new, text, utf8));
}
Some(b.build())
}
fn renumber_in_block(block: &BlockNode, text: &str, edits: &mut Vec<(Span, String)>) {
match block {
BlockNode::List(list) => renumber_in_list(list, text, edits),
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
for c in children {
renumber_in_block(c, text, edits);
}
}
_ => {}
}
}
fn renumber_in_list(list: &ListNode, text: &str, edits: &mut Vec<(Span, String)>) {
for (idx, item) in list.items.iter().enumerate() {
if is_ordered_symbol(item.symbol) {
if let Some((span, _, _)) = find_marker_span(text, item.span) {
let new = render_marker(item.symbol, idx + 1);
edits.push((span, new));
}
}
if let Some(sub) = &item.sublist {
renumber_in_list(sub, text, edits);
}
}
}
fn is_ordered_symbol(s: ListSymbol) -> bool {
matches!(
s,
ListSymbol::Numeric
| ListSymbol::NumericParen
| ListSymbol::AlphaParen
| ListSymbol::AlphaUpperParen
| ListSymbol::RomanParen
| ListSymbol::RomanUpperParen
)
}
// ----- changeSymbol -----
/// Rewrite the leading marker on each list item. When `whole_list`
/// is true, every item in the same list (recursively through
/// sublists) gets the new symbol; otherwise only the item at `line`
/// is touched.
pub fn change_symbol_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
line: u32,
target: ListSymbol,
whole_list: bool,
utf8: bool,
) -> Option<WorkspaceEdit> {
let mut edits: Vec<(Span, String)> = Vec::new();
if whole_list {
let list = find_list_at_line(ast, line)?;
rewrite_list_symbols(list, target, text, &mut edits);
} else {
let item = find_list_item_at(ast, line, 0)?;
if let Some((span, _, _)) = find_marker_span(text, item.span) {
edits.push((span, render_marker(target, 1)));
}
}
if edits.is_empty() {
return None;
}
let mut b = WorkspaceEditBuilder::new();
for (span, new) in edits {
b.edit(uri.clone(), text_edit_replace(span, new, text, utf8));
}
Some(b.build())
}
fn rewrite_list_symbols(
list: &ListNode,
target: ListSymbol,
text: &str,
edits: &mut Vec<(Span, String)>,
) {
for (idx, item) in list.items.iter().enumerate() {
if let Some((span, _, _)) = find_marker_span(text, item.span) {
edits.push((span, render_marker(target, idx + 1)));
}
if let Some(sub) = &item.sublist {
rewrite_list_symbols(sub, target, text, edits);
}
}
}
// ----- changeLevel -----
/// Indent (`delta > 0`) or dedent (`delta < 0`) the item at `line`.
/// When `whole_subtree` is set, all descendants get the same shift
/// so the visual tree stays consistent.
pub fn change_level_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
line: u32,
delta: i32,
whole_subtree: bool,
utf8: bool,
) -> Option<WorkspaceEdit> {
let item = find_list_item_at(ast, line, 0)?;
let mut edits: Vec<(Span, String)> = Vec::new();
if whole_subtree {
collect_level_edits(item, text, delta, &mut edits);
} else {
// Only the first line of the item — the marker line —
// changes indentation. Sublists keep their own indentation
// (a partial shift would break the tree).
push_level_edit_for_line(item.span.start.line, text, delta, &mut edits);
}
if edits.is_empty() {
return None;
}
let mut b = WorkspaceEditBuilder::new();
for (span, new) in edits {
b.edit(uri.clone(), text_edit_replace(span, new, text, utf8));
}
Some(b.build())
}
fn collect_level_edits(
item: &ListItemNode,
text: &str,
delta: i32,
edits: &mut Vec<(Span, String)>,
) {
for line in item.span.start.line..=item.span.end.line {
push_level_edit_for_line(line, text, delta, edits);
}
if let Some(sub) = &item.sublist {
for sub_it in &sub.items {
// Already covered by the line range, but recurse so we
// don't miss items past the parent's span.end (resilient
// to off-by-one quirks in the lexer).
collect_level_edits(sub_it, text, delta, edits);
}
}
}
fn push_level_edit_for_line(
line_num: u32,
text: &str,
delta: i32,
edits: &mut Vec<(Span, String)>,
) {
let bytes = text.as_bytes();
// Locate the start offset of `line_num`.
let mut off = 0usize;
let mut cur_line: u32 = 0;
while cur_line < line_num && off < bytes.len() {
if bytes[off] == b'\n' {
cur_line += 1;
}
off += 1;
}
if cur_line != line_num {
return;
}
let mut ws_end = off;
while ws_end < bytes.len() && (bytes[ws_end] == b' ' || bytes[ws_end] == b'\t') {
ws_end += 1;
}
let current_ws = ws_end - off;
let new_ws = if delta < 0 {
current_ws.saturating_sub((-delta * 2) as usize)
} else {
current_ws + (delta * 2) as usize
};
if new_ws == current_ws {
return;
}
let span = Span::new(
AstPosition {
line: line_num,
column: 0,
offset: off,
},
AstPosition {
line: line_num,
column: current_ws as u32,
offset: ws_end,
},
);
edits.push((span, " ".repeat(new_ws)));
}
// ----- shared helpers -----
/// Find the deepest list containing `line` (an item's line range).
/// Top-level lists win over nested ones unless the cursor is on a
/// sublist item.
pub fn find_list_at_line(ast: &DocumentNode, line: u32) -> Option<&ListNode> {
for block in &ast.children {
if let Some(list) = find_list_in_block(block, line) {
return Some(list);
}
}
None
}
fn find_list_in_block(block: &BlockNode, line: u32) -> Option<&ListNode> {
match block {
BlockNode::List(list) => {
// First check sublists for a tighter match.
for item in &list.items {
if let Some(sub) = &item.sublist {
if (sub.span.start.line..=sub.span.end.line).contains(&line) {
if let Some(nested) = find_list_in_sublist(sub, line) {
return Some(nested);
}
return Some(sub);
}
}
}
if (list.span.start.line..=list.span.end.line).contains(&line) {
Some(list)
} else {
None
}
}
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
for c in children {
if let Some(l) = find_list_in_block(c, line) {
return Some(l);
}
}
None
}
_ => None,
}
}
fn find_list_in_sublist(list: &ListNode, line: u32) -> Option<&ListNode> {
for item in &list.items {
if let Some(sub) = &item.sublist {
if (sub.span.start.line..=sub.span.end.line).contains(&line) {
return find_list_in_sublist(sub, line).or(Some(sub));
}
}
}
None
}
/// Find the leading marker substring (e.g. `"-"`, `"1."`, `"a)"`)
/// within the first line of an item's source span. Returns the
/// span covering only the marker characters, the marker text, and
/// the byte offset right after the marker (caller uses this to
/// extend edits if needed).
pub fn find_marker_span(text: &str, item_span: Span) -> Option<(Span, &str, usize)> {
let start = item_span.start.offset;
let end = item_span.end.offset.min(text.len());
if start >= end {
return None;
}
let bytes = text.as_bytes();
// Skip leading whitespace.
let mut i = start;
while i < end && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
let marker_start = i;
// Marker = run of non-space chars before the first whitespace.
// For `-`, `*`, `#`: single char. For `1.`, `1)`, `a)`, …: 2+.
while i < end && !(bytes[i] as char).is_whitespace() {
i += 1;
}
if marker_start == i {
return None;
}
let marker_str = std::str::from_utf8(&bytes[marker_start..i]).ok()?;
// Sanity: only accept characters that could form a list marker.
if !marker_str
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '-' | '*' | '#' | '.' | ')'))
{
return None;
}
let col_offset = marker_start - start;
let span = Span::new(
AstPosition {
line: item_span.start.line,
column: item_span.start.column + col_offset as u32,
offset: marker_start,
},
AstPosition {
line: item_span.start.line,
column: item_span.start.column + (i - start) as u32,
offset: i,
},
);
Some((span, marker_str, i))
}
// ===== Cluster B: table rewriters =====
use nuwiki_core::ast::{TableNode, TableRowNode};
/// Plain-text body of a freshly-inserted vimwiki table with `cols`
/// columns and `rows` data rows (header + separator + rows).
pub fn render_blank_table(cols: usize, rows: usize) -> String {
let cell = " ";
let mut out = String::new();
// Header row.
out.push('|');
for _ in 0..cols {
out.push_str(cell);
out.push('|');
}
out.push('\n');
// Separator row.
out.push('|');
for _ in 0..cols {
out.push_str("--");
out.push('|');
}
out.push('\n');
// Data rows.
for _ in 0..rows {
out.push('|');
for _ in 0..cols {
out.push_str(cell);
out.push('|');
}
out.push('\n');
}
out
}
/// Reformat the table containing `line` so every column shares the
/// same width as its widest cell. Header-separator rows get
/// repadded with `-` runs so the table still parses cleanly.
pub fn table_align_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
line: u32,
utf8: bool,
) -> Option<WorkspaceEdit> {
let table = find_table_at_line(ast, line)?;
let rendered = render_aligned_table(table, text)?;
let span = table.span;
let mut b = WorkspaceEditBuilder::new();
b.edit(uri.clone(), text_edit_replace(span, rendered, text, utf8));
Some(b.build())
}
/// Swap the column under cursor with its left (`delta = -1`) or
/// right (`delta = +1`) neighbour across every row of the table.
pub fn table_move_column_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
line: u32,
col: u32,
delta: i32,
utf8: bool,
) -> Option<WorkspaceEdit> {
let table = find_table_at_line(ast, line)?;
let cur_col = cell_column_at(table, text, line, col)?;
let other = cur_col as i32 + delta;
if other < 0 {
return None;
}
let other = other as usize;
// All rows must have both columns to make the swap meaningful.
let max_cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
if other >= max_cols {
return None;
}
let rendered = render_swapped_table(table, text, cur_col, other)?;
let mut b = WorkspaceEditBuilder::new();
b.edit(
uri.clone(),
text_edit_replace(table.span, rendered, text, utf8),
);
Some(b.build())
}
fn find_table_at_line(ast: &DocumentNode, line: u32) -> Option<&TableNode> {
for block in &ast.children {
if let BlockNode::Table(t) = block {
if (t.span.start.line..=t.span.end.line).contains(&line) {
return Some(t);
}
}
// Tables inside blockquotes are uncommon but possible.
if let BlockNode::Blockquote(BlockquoteNode { children, .. }) = block {
for c in children {
if let BlockNode::Table(t) = c {
if (t.span.start.line..=t.span.end.line).contains(&line) {
return Some(t);
}
}
}
}
}
None
}
/// Locate the column index under (`line`, `col`) by walking the
/// row that contains `line` and counting pipe separators before
/// the cursor.
fn cell_column_at(table: &TableNode, text: &str, line: u32, col: u32) -> Option<usize> {
let row = table
.rows
.iter()
.find(|r| (r.span.start.line..=r.span.end.line).contains(&line))?;
let row_src = source_slice(text, row.span);
let bytes = row_src.as_bytes();
// `col` is the cursor's byte column on `line`. Re-base it
// against the row's start column on that line.
let row_start_col = row.span.start.column;
if col < row_start_col {
return None;
}
let mut rel = (col - row_start_col) as usize;
rel = rel.min(bytes.len());
let mut pipes_before = 0usize;
for (i, b) in bytes.iter().enumerate() {
if i >= rel {
break;
}
if *b == b'|' {
pipes_before += 1;
}
}
// First pipe opens the row, so column index is `pipes - 1`.
if pipes_before == 0 {
return Some(0);
}
Some(pipes_before - 1)
}
fn source_slice(text: &str, span: Span) -> &str {
let start = span.start.offset.min(text.len());
let end = span.end.offset.min(text.len());
&text[start..end]
}
/// Compute column widths (max content length across rows).
fn column_widths(table: &TableNode, text: &str) -> Vec<usize> {
let max_cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
let mut widths = vec![1usize; max_cols];
for row in &table.rows {
for (i, cell) in row.cells.iter().enumerate() {
let content = source_slice(text, cell.span).trim();
if !is_separator_cell(content) {
widths[i] = widths[i].max(content.chars().count());
}
}
}
widths
}
fn is_separator_cell(content: &str) -> bool {
!content.is_empty()
&& content
.chars()
.all(|c| c == '-' || c == ':' || c.is_whitespace())
}
fn render_aligned_table(table: &TableNode, text: &str) -> Option<String> {
let widths = column_widths(table, text);
let mut out = String::new();
for (i, row) in table.rows.iter().enumerate() {
render_row(row, text, &widths, &mut out);
// The lexer dropped the `|---|---|` separator row, so
// re-emit one after the header when `has_header` is set.
if i == 0 && table.has_header {
emit_separator_row(&widths, &mut out);
}
}
Some(out)
}
fn emit_separator_row(widths: &[usize], out: &mut String) {
out.push('|');
for w in widths {
out.push_str(&"-".repeat(w + 2));
out.push('|');
}
out.push('\n');
}
fn render_row(row: &TableRowNode, text: &str, widths: &[usize], out: &mut String) {
let mut all_separator = !row.cells.is_empty();
for cell in &row.cells {
let content = source_slice(text, cell.span).trim();
if !is_separator_cell(content) {
all_separator = false;
break;
}
}
out.push('|');
for (i, _cell) in row.cells.iter().enumerate() {
let width = *widths.get(i).unwrap_or(&1);
if all_separator {
out.push_str(&"-".repeat(width + 2));
} else {
let content = source_slice(text, row.cells[i].span).trim();
let pad = width.saturating_sub(content.chars().count());
out.push(' ');
out.push_str(content);
out.push_str(&" ".repeat(pad));
out.push(' ');
}
out.push('|');
}
out.push('\n');
}
fn render_swapped_table(table: &TableNode, text: &str, a: usize, b: usize) -> Option<String> {
// Swap the column-width slots first so the rendered table
// remains visually aligned after the move.
let mut widths = column_widths(table, text);
if a < widths.len() && b < widths.len() {
widths.swap(a, b);
}
let mut out = String::new();
for (row_idx, row) in table.rows.iter().enumerate() {
out.push('|');
for i in 0..row.cells.len() {
let src_col = if i == a {
b
} else if i == b {
a
} else {
i
};
let width = *widths.get(i).unwrap_or(&1);
let content = if src_col < row.cells.len() {
source_slice(text, row.cells[src_col].span).trim()
} else {
""
};
let pad = width.saturating_sub(content.chars().count());
out.push(' ');
out.push_str(content);
out.push_str(&" ".repeat(pad));
out.push(' ');
out.push('|');
}
out.push('\n');
// Re-emit the dropped separator row after the header.
if row_idx == 0 && table.has_header {
emit_separator_row(&widths, &mut out);
}
}
Some(out)
}
// ===== 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 =====
//
// Three tag-driven commands beyond the indexing itself.
// `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<TagHit> {
let needle = query.to_ascii_lowercase();
let mut out: Vec<TagHit> = 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<String, Vec<String>> {
let mut by_tag: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
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: <name> =` section listing every page tagged with
/// `name`. When `tag` is `None` the section becomes a "Tag Index"
/// covering every tag, headed `== <tag> ==` per group — matches
/// `:VimwikiGenerateTagLinks` (no arg) variant.
pub fn build_tag_links_text(
pages_by_tag: &std::collections::BTreeMap<String, Vec<String>>,
tag: Option<&str>,
) -> Option<String> {
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: <name>`
/// 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<String, Vec<String>>,
utf8: bool,
) -> Option<WorkspaceEdit> {
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");
let insert_pos = eof_position(text);
b.edit(uri.clone(), text_edit_insert(insert_pos, 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 `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<tower_lsp::lsp_types::Url>,
}
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_path>/<name>.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<PageExport> {
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,
Some(cfg.file_extension.as_str()),
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<String> {
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 `<html_path>/<css_name>` 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<CssOutcome> {
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 `<html_path>/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<PathBuf> {
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 version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
"#,
);
xml.push_str(&format!("<title>{}</title>\n", xml_escape(&cfg.name)));
xml.push_str("<description>nuwiki diary</description>\n");
for e in sorted {
xml.push_str("<item>");
xml.push_str(&format!("<title>{}</title>", e.date.format()));
xml.push_str(&format!("<link>{}</link>", e.uri.as_str()));
xml.push_str(&format!("<guid>{}</guid>", e.uri.as_str()));
xml.push_str("</item>\n");
}
xml.push_str("</channel></rss>\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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => 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) {}
}