2026-05-11 16:27:39 +00:00
|
|
|
//! `workspace/executeCommand` dispatcher.
|
|
|
|
|
//!
|
2026-05-11 20:49:32 +00:00
|
|
|
//! Phase 13 introduced the router + `nuwiki.file.delete`. Phase 14 added
|
2026-05-11 17:41:11 +00:00
|
|
|
//! 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`.
|
2026-05-11 16:27:39 +00:00
|
|
|
//!
|
2026-05-11 17:41:11 +00:00
|
|
|
//! 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.
|
2026-05-11 16:27:39 +00:00
|
|
|
|
|
|
|
|
use serde::Deserialize;
|
2026-05-11 17:41:11 +00:00
|
|
|
use serde_json::Value;
|
|
|
|
|
use std::sync::atomic::Ordering;
|
|
|
|
|
use tower_lsp::lsp_types::{Position as LspPosition, Url, WorkspaceEdit};
|
2026-05-11 16:27:39 +00:00
|
|
|
|
|
|
|
|
use crate::edits::{op_delete, WorkspaceEditBuilder};
|
2026-05-11 17:41:11 +00:00
|
|
|
use crate::nav;
|
2026-05-11 16:27:39 +00:00
|
|
|
use crate::Backend;
|
|
|
|
|
|
2026-05-11 17:41:11 +00:00
|
|
|
/// 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),
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 16:27:39 +00:00
|
|
|
/// All commands the server advertises in `ExecuteCommandOptions.commands`.
|
2026-05-11 17:41:11 +00:00
|
|
|
/// 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",
|
2026-05-11 21:04:55 +00:00
|
|
|
"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",
|
2026-05-11 21:17:04 +00:00
|
|
|
"nuwiki.tags.search",
|
|
|
|
|
"nuwiki.tags.generateLinks",
|
|
|
|
|
"nuwiki.tags.rebuild",
|
2026-05-11 21:37:36 +00:00
|
|
|
"nuwiki.export.currentToHtml",
|
|
|
|
|
"nuwiki.export.allToHtml",
|
|
|
|
|
"nuwiki.export.allToHtmlForce",
|
|
|
|
|
"nuwiki.export.browse",
|
|
|
|
|
"nuwiki.export.rss",
|
2026-05-11 21:46:33 +00:00
|
|
|
"nuwiki.wiki.listAll",
|
|
|
|
|
"nuwiki.wiki.select",
|
|
|
|
|
"nuwiki.wiki.openIndex",
|
|
|
|
|
"nuwiki.wiki.tabOpenIndex",
|
|
|
|
|
"nuwiki.wiki.gotoPage",
|
2026-05-12 14:32:35 +00:00
|
|
|
"nuwiki.link.pasteWikilink",
|
|
|
|
|
"nuwiki.link.pasteUrl",
|
2026-05-12 14:37:11 +00:00
|
|
|
"nuwiki.list.removeDone",
|
|
|
|
|
"nuwiki.list.renumber",
|
|
|
|
|
"nuwiki.list.changeSymbol",
|
|
|
|
|
"nuwiki.list.changeLevel",
|
2026-05-12 14:41:51 +00:00
|
|
|
"nuwiki.table.insert",
|
|
|
|
|
"nuwiki.table.align",
|
|
|
|
|
"nuwiki.table.moveColumn",
|
2026-05-11 17:41:11 +00:00
|
|
|
];
|
|
|
|
|
|
2026-05-11 16:27:39 +00:00
|
|
|
pub(crate) async fn execute(
|
2026-05-11 17:41:11 +00:00
|
|
|
backend: &Backend,
|
2026-05-11 16:27:39 +00:00
|
|
|
command: &str,
|
2026-05-11 17:41:11 +00:00
|
|
|
args: Vec<Value>,
|
|
|
|
|
) -> Result<Option<CommandOutcome>, String> {
|
2026-05-11 16:27:39 +00:00
|
|
|
match command {
|
2026-05-11 17:41:11 +00:00
|
|
|
"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))
|
|
|
|
|
}
|
2026-05-11 21:04:55 +00:00
|
|
|
"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))
|
|
|
|
|
}
|
2026-05-11 21:17:04 +00:00
|
|
|
"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)),
|
2026-05-11 21:37:36 +00:00
|
|
|
"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)),
|
2026-05-11 21:46:33 +00:00
|
|
|
"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))
|
|
|
|
|
}
|
2026-05-12 14:32:35 +00:00
|
|
|
"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))
|
|
|
|
|
}
|
2026-05-12 14:37:11 +00:00
|
|
|
"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))
|
|
|
|
|
}
|
2026-05-12 14:41:51 +00:00
|
|
|
"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))
|
|
|
|
|
}
|
2026-05-11 16:27:39 +00:00
|
|
|
other => Err(format!("unknown nuwiki command: {other}")),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 17:41:11 +00:00
|
|
|
#[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> {
|
2026-05-11 16:27:39 +00:00
|
|
|
#[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()))
|
|
|
|
|
}
|
2026-05-11 17:41:11 +00:00
|
|
|
|
|
|
|
|
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);
|
2026-05-14 15:00:46 +00:00
|
|
|
let propagate = backend
|
|
|
|
|
.wiki_for_uri(&p.uri)
|
|
|
|
|
.map(|w| w.config.listsyms_propagate)
|
|
|
|
|
.unwrap_or(true);
|
2026-05-11 17:41:11 +00:00
|
|
|
Ok(ops::checkbox_edit(
|
2026-05-14 15:00:46 +00:00
|
|
|
&doc.text, &doc.ast, &p.uri, line, col, utf8, mutate, propagate,
|
2026-05-11 17:41:11 +00:00
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
¤t_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}")
|
|
|
|
|
})?))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:04:55 +00:00
|
|
|
#[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);
|
|
|
|
|
};
|
2026-05-12 22:09:14 +00:00
|
|
|
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(),
|
2026-05-11 21:04:55 +00:00
|
|
|
};
|
2026-05-12 22:09:14 +00:00
|
|
|
let Some(uri) = crate::diary::uri_for_period(&wiki.config, &period) else {
|
2026-05-11 21:04:55 +00:00
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
Ok(Some(serde_json::json!({
|
|
|
|
|
"uri": uri,
|
2026-05-12 22:09:14 +00:00
|
|
|
"date": period.format(),
|
|
|
|
|
"frequency": freq.as_str(),
|
2026-05-11 21:04:55 +00:00
|
|
|
})))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
};
|
2026-05-12 22:09:14 +00:00
|
|
|
// 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 = {
|
2026-05-11 21:04:55 +00:00
|
|
|
let idx = wiki
|
|
|
|
|
.index
|
|
|
|
|
.read()
|
|
|
|
|
.map_err(|_| "index lock poisoned".to_string())?;
|
|
|
|
|
idx.page(&p.uri)
|
2026-05-12 22:09:14 +00:00
|
|
|
.and_then(|page| page.diary_period)
|
|
|
|
|
.unwrap_or_else(|| nuwiki_core::date::DiaryPeriod::today_utc(wiki.config.frequency()))
|
2026-05-11 21:04:55 +00:00
|
|
|
};
|
|
|
|
|
let next = {
|
|
|
|
|
let idx = wiki
|
|
|
|
|
.index
|
|
|
|
|
.read()
|
|
|
|
|
.map_err(|_| "index lock poisoned".to_string())?;
|
|
|
|
|
if forward {
|
2026-05-12 22:09:14 +00:00
|
|
|
crate::diary::next_period(&idx, &pivot)
|
2026-05-11 21:04:55 +00:00
|
|
|
} else {
|
2026-05-12 22:09:14 +00:00
|
|
|
crate::diary::prev_period(&idx, &pivot)
|
2026-05-11 21:04:55 +00:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
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() }),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:17:04 +00:00
|
|
|
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(),
|
|
|
|
|
})))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:46:33 +00:00
|
|
|
// ===== 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);
|
|
|
|
|
};
|
2026-05-12 15:10:27 +00:00
|
|
|
let index_path = wiki.config.root.join(format!(
|
|
|
|
|
"{}{}",
|
|
|
|
|
wiki.config.index, wiki.config.file_extension
|
|
|
|
|
));
|
2026-05-11 21:46:33 +00:00
|
|
|
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,
|
|
|
|
|
})))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 14:32:35 +00:00
|
|
|
#[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()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:46:33 +00:00
|
|
|
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,
|
|
|
|
|
})))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 14:37:11 +00:00
|
|
|
// ===== §13.1 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,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 14:41:51 +00:00
|
|
|
// ===== §13.1 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,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:37:36 +00:00
|
|
|
// ===== 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}")
|
|
|
|
|
})?))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 17:41:11 +00:00
|
|
|
// ===== 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("[-]"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:00:46 +00:00
|
|
|
#[allow(clippy::too_many_arguments)]
|
2026-05-11 17:41:11 +00:00
|
|
|
pub fn checkbox_edit(
|
|
|
|
|
text: &str,
|
|
|
|
|
ast: &DocumentNode,
|
|
|
|
|
uri: &Url,
|
|
|
|
|
line: u32,
|
|
|
|
|
col: u32,
|
|
|
|
|
utf8: bool,
|
|
|
|
|
mutate: fn(&str) -> Option<&'static str>,
|
2026-05-14 15:00:46 +00:00
|
|
|
propagate: bool,
|
2026-05-11 17:41:11 +00:00
|
|
|
) -> Option<WorkspaceEdit> {
|
2026-05-14 15:00:46 +00:00
|
|
|
let path = find_path_to_item(ast, line, col)?;
|
|
|
|
|
let leaf = *path.last()?;
|
|
|
|
|
let cb_span = find_checkbox_span(text, leaf.span)?;
|
2026-05-11 17:41:11 +00:00
|
|
|
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),
|
|
|
|
|
);
|
2026-05-14 15:00:46 +00:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 17:41:11 +00:00
|
|
|
Some(b.build())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:00:46 +00:00
|
|
|
/// 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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 17:41:11 +00:00
|
|
|
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.
|
2026-05-14 15:00:46 +00:00
|
|
|
/// 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.
|
2026-05-11 17:41:11 +00:00
|
|
|
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();
|
2026-05-14 15:24:35 +00:00
|
|
|
let line_end = bytes
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|&b| b == b'\n')
|
|
|
|
|
.unwrap_or(bytes.len());
|
2026-05-14 15:00:46 +00:00
|
|
|
let bytes = &bytes[..line_end];
|
2026-05-11 17:41:11 +00:00
|
|
|
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
|
|
|
|
2026-05-14 15:00:46 +00:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
2026-05-11 21:04:55 +00:00
|
|
|
|
2026-05-12 14:37:11 +00:00
|
|
|
// ===== §13.1 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 list containing `pos`'s line; otherwise sweep the whole doc.
|
|
|
|
|
pub fn remove_done_edit(
|
|
|
|
|
text: &str,
|
|
|
|
|
ast: &DocumentNode,
|
|
|
|
|
uri: &Url,
|
|
|
|
|
pos: Option<LspPosition>,
|
|
|
|
|
utf8: bool,
|
|
|
|
|
) -> Option<WorkspaceEdit> {
|
|
|
|
|
let restrict_line = pos.map(|p| p.line);
|
|
|
|
|
let mut victims: Vec<Span> = Vec::new();
|
|
|
|
|
walk_list_items(&ast.children, &mut |item| {
|
|
|
|
|
let matches_scope = match restrict_line {
|
|
|
|
|
None => true,
|
|
|
|
|
Some(line) => (item.span.start.line..=item.span.end.line).contains(&line),
|
|
|
|
|
};
|
|
|
|
|
if !matches_scope {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 14:41:51 +00:00
|
|
|
// ===== §13.1 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:04:55 +00:00
|
|
|
// ===== 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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:17:04 +00:00
|
|
|
// ===== 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) {}
|
|
|
|
|
|
2026-05-11 21:04:55 +00:00
|
|
|
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(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-05-11 17:41:11 +00:00
|
|
|
}
|
2026-05-11 21:37:36 +00:00
|
|
|
|
|
|
|
|
/// 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("&"),
|
|
|
|
|
'<' => out.push_str("<"),
|
|
|
|
|
'>' => out.push_str(">"),
|
|
|
|
|
'"' => out.push_str("""),
|
|
|
|
|
_ => out.push(ch),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Re-export so the dispatcher doesn't have to know about the export
|
|
|
|
|
/// module's path manipulation primitives.
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
pub fn _silence_unused(_: &Path) {}
|
|
|
|
|
}
|