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

1871 lines
63 KiB
Rust
Raw Normal View History

//! `workspace/executeCommand` dispatcher.
//!
2026-05-11 20:49:32 +00:00
//! Phase 13 introduced the router + `nuwiki.file.delete`. Phase 14 added
//! the cheap surgical edits — checkbox toggle/cycle/reject, heading
2026-05-11 20:49:32 +00:00
//! 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",
2026-05-11 20:49:32 +00:00
"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<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" => {
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))
}
2026-05-11 20:49:32 +00:00
"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<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);
Ok(ops::checkbox_edit(
&doc.text, &doc.ast, &p.uri, line, col, utf8, mutate,
))
}
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,
))
}
2026-05-11 20:49:32 +00:00
#[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 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<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 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<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(),
})))
}
// ===== Phase 18: 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!("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<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,
})))
}
// ===== Phase 17: 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(),
})))
}
2026-05-11 20:49:32 +00:00
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}")
})?))
}
// ===== 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<WorkspaceEdit> {
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<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.
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 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
}
2026-05-11 20:49:32 +00:00
// ===== 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<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");
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<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;
// ===== 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<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");
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<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,
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) {}
}