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