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 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 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);
|
|
|
|
|
Ok(ops::checkbox_edit(
|
|
|
|
|
&doc.text, &doc.ast, &p.uri, line, col, utf8, mutate,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn list_next_task(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
|
|
|
|
|
let p = parse_pos(args)?;
|
|
|
|
|
let doc = match backend.documents.get(&p.uri) {
|
|
|
|
|
Some(d) => d,
|
|
|
|
|
None => return Ok(None),
|
|
|
|
|
};
|
|
|
|
|
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8);
|
|
|
|
|
let Some(loc) = ops::next_task(&doc.ast, &p.uri, &doc.text, line, col, utf8) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
serde_json::to_value(loc)
|
|
|
|
|
.map(Some)
|
|
|
|
|
.map_err(|e| format!("nuwiki.list.nextTask: serialization failed — {e}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn heading_change_level(
|
|
|
|
|
backend: &Backend,
|
|
|
|
|
args: Vec<Value>,
|
|
|
|
|
delta: i32,
|
|
|
|
|
) -> Result<Option<WorkspaceEdit>, String> {
|
|
|
|
|
let p = parse_pos(args)?;
|
|
|
|
|
let doc = match backend.documents.get(&p.uri) {
|
|
|
|
|
Some(d) => d,
|
|
|
|
|
None => return Ok(None),
|
|
|
|
|
};
|
|
|
|
|
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8);
|
|
|
|
|
Ok(ops::heading_change_level(
|
|
|
|
|
&doc.text, &doc.ast, &p.uri, line, col, utf8, delta,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 20:49:32 +00:00
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct UriArg {
|
|
|
|
|
uri: Url,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_uri_arg(args: Vec<Value>) -> Result<UriArg, String> {
|
|
|
|
|
let raw = args
|
|
|
|
|
.into_iter()
|
|
|
|
|
.next()
|
|
|
|
|
.ok_or_else(|| "missing { uri } argument".to_string())?;
|
|
|
|
|
serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, Default)]
|
|
|
|
|
struct OptionalUriArg {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
uri: Option<Url>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_optional_uri_arg(args: Vec<Value>) -> Result<OptionalUriArg, String> {
|
|
|
|
|
match args.into_iter().next() {
|
|
|
|
|
Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}")),
|
|
|
|
|
None => Ok(OptionalUriArg::default()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn toc_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
|
|
|
|
|
let p = parse_uri_arg(args)?;
|
|
|
|
|
let doc = match backend.documents.get(&p.uri) {
|
|
|
|
|
Some(d) => d,
|
|
|
|
|
None => return Ok(None),
|
|
|
|
|
};
|
|
|
|
|
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
Ok(ops::toc_edit(&doc.text, &doc.ast, &p.uri, utf8))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
|
|
|
|
|
let p = parse_uri_arg(args)?;
|
|
|
|
|
let doc = match backend.documents.get(&p.uri) {
|
|
|
|
|
Some(d) => d,
|
|
|
|
|
None => return Ok(None),
|
|
|
|
|
};
|
|
|
|
|
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
let wiki = match backend.wiki_for_uri(&p.uri) {
|
|
|
|
|
Some(w) => w,
|
|
|
|
|
None => return Ok(None),
|
|
|
|
|
};
|
|
|
|
|
let current_page = crate::index::page_name_from_uri(&p.uri, Some(&wiki.config.root));
|
|
|
|
|
let pages = {
|
|
|
|
|
let idx = wiki
|
|
|
|
|
.index
|
|
|
|
|
.read()
|
|
|
|
|
.map_err(|_| "index lock poisoned".to_string())?;
|
|
|
|
|
idx.page_names()
|
|
|
|
|
};
|
|
|
|
|
Ok(ops::links_edit(
|
|
|
|
|
&doc.text,
|
|
|
|
|
&doc.ast,
|
|
|
|
|
&p.uri,
|
|
|
|
|
¤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}")
|
|
|
|
|
})?))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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("[-]"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn checkbox_edit(
|
|
|
|
|
text: &str,
|
|
|
|
|
ast: &DocumentNode,
|
|
|
|
|
uri: &Url,
|
|
|
|
|
line: u32,
|
|
|
|
|
col: u32,
|
|
|
|
|
utf8: bool,
|
|
|
|
|
mutate: fn(&str) -> Option<&'static str>,
|
|
|
|
|
) -> Option<WorkspaceEdit> {
|
|
|
|
|
let item = find_list_item_at(ast, line, col)?;
|
|
|
|
|
let cb_span = find_checkbox_span(text, item.span)?;
|
|
|
|
|
let cur = &text[cb_span.start.offset..cb_span.end.offset];
|
|
|
|
|
let new = mutate(cur)?;
|
|
|
|
|
let mut b = WorkspaceEditBuilder::new();
|
|
|
|
|
b.edit(
|
|
|
|
|
uri.clone(),
|
|
|
|
|
text_edit_replace(cb_span, new.to_string(), text, utf8),
|
|
|
|
|
);
|
|
|
|
|
Some(b.build())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn next_task(
|
|
|
|
|
ast: &DocumentNode,
|
|
|
|
|
uri: &Url,
|
|
|
|
|
text: &str,
|
|
|
|
|
line: u32,
|
|
|
|
|
_col: u32,
|
|
|
|
|
utf8: bool,
|
|
|
|
|
) -> Option<Location> {
|
|
|
|
|
// Walk in source order; first task at line > cursor wins. Wraps to
|
|
|
|
|
// the start of the document if nothing found after cursor — matches
|
|
|
|
|
// vimwiki's `gnt`.
|
|
|
|
|
let mut items: Vec<&ListItemNode> = Vec::new();
|
|
|
|
|
for block in &ast.children {
|
|
|
|
|
collect_list_items(block, &mut items);
|
|
|
|
|
}
|
|
|
|
|
let is_unfinished = |it: &&ListItemNode| match it.checkbox {
|
|
|
|
|
Some(CheckboxState::Done) | Some(CheckboxState::Rejected) => false,
|
|
|
|
|
Some(_) | None => it.checkbox.is_some(),
|
|
|
|
|
};
|
|
|
|
|
let after = items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|it| it.span.start.line > line && is_unfinished(it));
|
|
|
|
|
let chosen = after.or_else(|| items.iter().find(|it| is_unfinished(it)))?;
|
|
|
|
|
let span = chosen.span;
|
|
|
|
|
Some(Location {
|
|
|
|
|
uri: uri.clone(),
|
|
|
|
|
range: crate::edits::text_edit_replace(span, String::new(), text, utf8).range,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_list_items<'a>(block: &'a BlockNode, out: &mut Vec<&'a ListItemNode>) {
|
|
|
|
|
match block {
|
|
|
|
|
BlockNode::List(ListNode { items, .. }) => {
|
|
|
|
|
for it in items {
|
|
|
|
|
out.push(it);
|
|
|
|
|
if let Some(sub) = &it.sublist {
|
|
|
|
|
for sub_it in &sub.items {
|
|
|
|
|
collect_list_items_from_item(sub_it, out);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
|
|
|
|
for c in children {
|
|
|
|
|
collect_list_items(c, out);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_list_items_from_item<'a>(item: &'a ListItemNode, out: &mut Vec<&'a ListItemNode>) {
|
|
|
|
|
out.push(item);
|
|
|
|
|
if let Some(sub) = &item.sublist {
|
|
|
|
|
for sub_it in &sub.items {
|
|
|
|
|
collect_list_items_from_item(sub_it, out);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn heading_change_level(
|
|
|
|
|
text: &str,
|
|
|
|
|
ast: &DocumentNode,
|
|
|
|
|
uri: &Url,
|
|
|
|
|
line: u32,
|
|
|
|
|
_col: u32,
|
|
|
|
|
utf8: bool,
|
|
|
|
|
delta: i32,
|
|
|
|
|
) -> Option<WorkspaceEdit> {
|
|
|
|
|
let heading = find_heading_at(ast, line)?;
|
|
|
|
|
let current = heading.level as i32;
|
|
|
|
|
let new_level = (current + delta).clamp(1, 6) as u8;
|
|
|
|
|
if new_level == heading.level {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let new_text = rewrite_heading_with_level(text, heading.span, new_level)?;
|
|
|
|
|
let mut b = WorkspaceEditBuilder::new();
|
|
|
|
|
b.edit(
|
|
|
|
|
uri.clone(),
|
|
|
|
|
text_edit_replace(heading.span, new_text, text, utf8),
|
|
|
|
|
);
|
|
|
|
|
Some(b.build())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn find_heading_at(ast: &DocumentNode, line: u32) -> Option<&HeadingNode> {
|
|
|
|
|
for block in &ast.children {
|
|
|
|
|
if let BlockNode::Heading(h) = block {
|
|
|
|
|
if h.span.start.line == line {
|
|
|
|
|
return Some(h);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Rewrite a heading's source so it has `new_level` `=`s on each side.
|
|
|
|
|
/// Returns `None` if the source doesn't have a balanced `=` ... `=`
|
|
|
|
|
/// shape (defensive — the lexer should guarantee this, but we don't
|
|
|
|
|
/// crash if the buffer was edited mid-flight).
|
|
|
|
|
pub fn rewrite_heading_with_level(text: &str, span: Span, new_level: u8) -> Option<String> {
|
|
|
|
|
if !(1..=6).contains(&new_level) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let segment = &text[span.start.offset..span.end.offset];
|
|
|
|
|
let leading_ws = segment
|
|
|
|
|
.bytes()
|
|
|
|
|
.take_while(|b| *b == b' ' || *b == b'\t')
|
|
|
|
|
.count();
|
|
|
|
|
let after_ws = &segment[leading_ws..];
|
|
|
|
|
let opening = after_ws.bytes().take_while(|b| *b == b'=').count();
|
|
|
|
|
let trimmed_end = after_ws.trim_end_matches([' ', '\t']).len();
|
|
|
|
|
let trailing = after_ws[..trimmed_end]
|
|
|
|
|
.bytes()
|
|
|
|
|
.rev()
|
|
|
|
|
.take_while(|b| *b == b'=')
|
|
|
|
|
.count();
|
|
|
|
|
if opening == 0 || trailing == 0 || opening != trailing {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let middle = &after_ws[opening..trimmed_end - trailing];
|
|
|
|
|
let trailing_ws = &after_ws[trimmed_end..];
|
|
|
|
|
|
|
|
|
|
let mut out = String::with_capacity(segment.len() + 2);
|
|
|
|
|
out.push_str(&segment[..leading_ws]);
|
|
|
|
|
for _ in 0..new_level {
|
|
|
|
|
out.push('=');
|
|
|
|
|
}
|
|
|
|
|
out.push_str(middle);
|
|
|
|
|
for _ in 0..new_level {
|
|
|
|
|
out.push('=');
|
|
|
|
|
}
|
|
|
|
|
out.push_str(trailing_ws);
|
|
|
|
|
Some(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Find the `[X]` (or other checkbox) substring within a list item's
|
|
|
|
|
/// source span. Returns a span covering exactly the 3-byte run.
|
|
|
|
|
pub fn find_checkbox_span(text: &str, item_span: Span) -> Option<Span> {
|
|
|
|
|
let start = item_span.start.offset;
|
|
|
|
|
let end = item_span.end.offset.min(text.len());
|
|
|
|
|
if start >= end {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let segment = &text[start..end];
|
|
|
|
|
let bytes = segment.as_bytes();
|
|
|
|
|
let mut i = 0;
|
|
|
|
|
while i + 2 < bytes.len() {
|
|
|
|
|
if bytes[i] == b'['
|
|
|
|
|
&& bytes[i + 2] == b']'
|
|
|
|
|
&& matches!(bytes[i + 1], b' ' | b'.' | b'o' | b'O' | b'X' | b'-')
|
|
|
|
|
{
|
|
|
|
|
let abs_start = start + i;
|
|
|
|
|
let cb_start = nuwiki_core::ast::Position {
|
|
|
|
|
line: item_span.start.line,
|
|
|
|
|
column: item_span.start.column + i as u32,
|
|
|
|
|
offset: abs_start,
|
|
|
|
|
};
|
|
|
|
|
let cb_end = nuwiki_core::ast::Position {
|
|
|
|
|
line: item_span.start.line,
|
|
|
|
|
column: item_span.start.column + (i + 3) as u32,
|
|
|
|
|
offset: abs_start + 3,
|
|
|
|
|
};
|
|
|
|
|
return Some(Span::new(cb_start, cb_end));
|
|
|
|
|
}
|
|
|
|
|
i += 1;
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Find the list item whose source line contains `line`. Descends into
|
|
|
|
|
/// sublists; returns the deepest match so editing the inner item works
|
|
|
|
|
/// when the user's cursor sits there.
|
|
|
|
|
pub fn find_list_item_at(ast: &DocumentNode, line: u32, col: u32) -> Option<&ListItemNode> {
|
|
|
|
|
for block in &ast.children {
|
|
|
|
|
if let Some(it) = item_in_block(block, line, col) {
|
|
|
|
|
return Some(it);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn item_in_block(block: &BlockNode, line: u32, col: u32) -> Option<&ListItemNode> {
|
|
|
|
|
match block {
|
|
|
|
|
BlockNode::List(l) => l.items.iter().find_map(|it| item_in_list(it, line, col)),
|
|
|
|
|
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
|
|
|
|
children.iter().find_map(|c| item_in_block(c, line, col))
|
|
|
|
|
}
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn item_in_list(item: &ListItemNode, line: u32, col: u32) -> Option<&ListItemNode> {
|
|
|
|
|
// Prefer the deepest match: try sublists first.
|
|
|
|
|
if let Some(sub) = &item.sublist {
|
|
|
|
|
for sub_it in &sub.items {
|
|
|
|
|
if let Some(found) = item_in_list(sub_it, line, col) {
|
|
|
|
|
return Some(found);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (item.span.start.line..=item.span.end.line).contains(&line) {
|
|
|
|
|
return Some(item);
|
|
|
|
|
}
|
|
|
|
|
let _ = col;
|
|
|
|
|
None
|
|
|
|
|
}
|
2026-05-11 20:49:32 +00:00
|
|
|
|
|
|
|
|
// ===== Phase 15: TOC + Links generation, workspace queries =====
|
|
|
|
|
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
|
|
|
|
|
pub const TOC_HEADING: &str = "Contents";
|
|
|
|
|
pub const LINKS_HEADING: &str = "Generated Links";
|
|
|
|
|
|
|
|
|
|
use crate::diagnostics::classify_outgoing;
|
|
|
|
|
use crate::edits::text_edit_insert;
|
|
|
|
|
use crate::index::WorkspaceIndex;
|
|
|
|
|
use nuwiki_core::ast::Position as AstPosition;
|
|
|
|
|
|
|
|
|
|
/// One heading entry used to render the TOC.
|
|
|
|
|
struct TocItem {
|
|
|
|
|
level: u8,
|
|
|
|
|
title: String,
|
|
|
|
|
anchor: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build a heading + nested list TOC for the current document. The TOC
|
|
|
|
|
/// itself (any existing `= Contents =` heading on the page) is skipped
|
|
|
|
|
/// so re-generation is idempotent.
|
|
|
|
|
pub fn build_toc_text(items: &[(u8, String, String)], heading_name: &str) -> String {
|
|
|
|
|
let mut out = String::new();
|
|
|
|
|
out.push_str("= ");
|
|
|
|
|
out.push_str(heading_name);
|
|
|
|
|
out.push_str(" =\n");
|
|
|
|
|
if items.is_empty() {
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
let min_level = items.iter().map(|(l, _, _)| *l).min().unwrap_or(1);
|
|
|
|
|
for (level, title, anchor) in items {
|
|
|
|
|
let depth = level.saturating_sub(min_level) as usize;
|
|
|
|
|
for _ in 0..depth {
|
|
|
|
|
out.push_str(" ");
|
|
|
|
|
}
|
|
|
|
|
out.push_str("- [[#");
|
|
|
|
|
out.push_str(anchor);
|
|
|
|
|
out.push('|');
|
|
|
|
|
out.push_str(title);
|
|
|
|
|
out.push_str("]]\n");
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build a flat list of wikilinks to every page (sorted), optionally
|
|
|
|
|
/// excluding `current_page`.
|
|
|
|
|
pub fn build_links_text(pages: &[String], heading_name: &str, exclude: Option<&str>) -> String {
|
|
|
|
|
let mut out = String::new();
|
|
|
|
|
out.push_str("= ");
|
|
|
|
|
out.push_str(heading_name);
|
|
|
|
|
out.push_str(" =\n");
|
|
|
|
|
for name in pages {
|
|
|
|
|
if Some(name.as_str()) == exclude {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
out.push_str("- [[");
|
|
|
|
|
out.push_str(name);
|
|
|
|
|
out.push_str("]]\n");
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Locate an existing section whose level-1 heading matches
|
|
|
|
|
/// `heading_name` case-insensitively. Returns the offset range covering
|
|
|
|
|
/// `[heading start .. (immediately-following list end | heading end)]`
|
|
|
|
|
/// plus the start `AstPosition` for emitting an LSP range from a
|
|
|
|
|
/// synthesised span.
|
|
|
|
|
pub fn find_section_range(
|
|
|
|
|
ast: &DocumentNode,
|
|
|
|
|
heading_name: &str,
|
|
|
|
|
) -> Option<(AstPosition, AstPosition)> {
|
|
|
|
|
let needle = heading_name.to_ascii_lowercase();
|
|
|
|
|
for (i, block) in ast.children.iter().enumerate() {
|
|
|
|
|
let BlockNode::Heading(h) = block else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if h.level != 1 {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let title = heading_title(h).to_ascii_lowercase();
|
|
|
|
|
if title != needle {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let start = h.span.start;
|
|
|
|
|
let end = match ast.children.get(i + 1) {
|
|
|
|
|
Some(BlockNode::List(list)) => list.span.end,
|
|
|
|
|
_ => h.span.end,
|
|
|
|
|
};
|
|
|
|
|
return Some((start, end));
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn heading_title(h: &nuwiki_core::ast::HeadingNode) -> String {
|
|
|
|
|
crate::diagnostics::heading_text(&h.children)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_toc_items(ast: &DocumentNode, toc_heading: &str) -> Vec<TocItem> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
let needle = toc_heading.to_ascii_lowercase();
|
|
|
|
|
for block in &ast.children {
|
|
|
|
|
let BlockNode::Heading(h) = block else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let title = heading_title(h);
|
|
|
|
|
if h.level == 1 && title.to_ascii_lowercase() == needle {
|
|
|
|
|
// skip the TOC's own heading
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let anchor = crate::index::slugify(&title);
|
|
|
|
|
out.push(TocItem {
|
|
|
|
|
level: h.level,
|
|
|
|
|
title,
|
|
|
|
|
anchor,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Produce a `WorkspaceEdit` that replaces or inserts the TOC for the
|
|
|
|
|
/// given document. Returns `None` if the document has no headings at
|
|
|
|
|
/// all (other than possibly a pre-existing TOC heading).
|
|
|
|
|
pub fn toc_edit(
|
|
|
|
|
text: &str,
|
|
|
|
|
ast: &DocumentNode,
|
|
|
|
|
uri: &Url,
|
|
|
|
|
utf8: bool,
|
|
|
|
|
) -> Option<WorkspaceEdit> {
|
|
|
|
|
let items = collect_toc_items(ast, TOC_HEADING);
|
|
|
|
|
if items.is_empty() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let triples: Vec<(u8, String, String)> = items
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|it| (it.level, it.title, it.anchor))
|
|
|
|
|
.collect();
|
|
|
|
|
let new_text = build_toc_text(&triples, TOC_HEADING);
|
|
|
|
|
let mut b = WorkspaceEditBuilder::new();
|
|
|
|
|
match find_section_range(ast, TOC_HEADING) {
|
|
|
|
|
Some((start, end)) => {
|
|
|
|
|
let span = Span::new(start, end);
|
|
|
|
|
b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8));
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
let with_sep = format!("{new_text}\n");
|
|
|
|
|
b.edit(
|
|
|
|
|
uri.clone(),
|
|
|
|
|
text_edit_insert(
|
|
|
|
|
LspPosition {
|
|
|
|
|
line: 0,
|
|
|
|
|
character: 0,
|
|
|
|
|
},
|
|
|
|
|
with_sep,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Some(b.build())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Produce a `WorkspaceEdit` that replaces or inserts the
|
|
|
|
|
/// auto-generated links section for the given document.
|
|
|
|
|
pub fn links_edit(
|
|
|
|
|
text: &str,
|
|
|
|
|
ast: &DocumentNode,
|
|
|
|
|
uri: &Url,
|
|
|
|
|
current_page: &str,
|
|
|
|
|
all_pages: &[String],
|
|
|
|
|
utf8: bool,
|
|
|
|
|
) -> Option<WorkspaceEdit> {
|
|
|
|
|
if all_pages.is_empty() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let new_text = build_links_text(all_pages, LINKS_HEADING, Some(current_page));
|
|
|
|
|
let mut b = WorkspaceEditBuilder::new();
|
|
|
|
|
match find_section_range(ast, LINKS_HEADING) {
|
|
|
|
|
Some((start, end)) => {
|
|
|
|
|
let span = Span::new(start, end);
|
|
|
|
|
b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8));
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
let with_sep = format!("{new_text}\n");
|
|
|
|
|
b.edit(
|
|
|
|
|
uri.clone(),
|
|
|
|
|
text_edit_insert(
|
|
|
|
|
LspPosition {
|
|
|
|
|
line: 0,
|
|
|
|
|
character: 0,
|
|
|
|
|
},
|
|
|
|
|
with_sep,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Some(b.build())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Workspace queries =====
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
pub struct BrokenLinkEntry {
|
|
|
|
|
pub uri: Url,
|
|
|
|
|
pub range: tower_lsp::lsp_types::Range,
|
|
|
|
|
pub kind: &'static str,
|
|
|
|
|
pub message: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Walk every indexed page and classify each outgoing link against the
|
|
|
|
|
/// workspace index. Open documents (held in `backend.documents`) use
|
|
|
|
|
/// live text for LSP range conversion; closed pages fall back to the
|
|
|
|
|
/// stored span coordinates verbatim (they're already in line/column form).
|
|
|
|
|
pub(crate) fn collect_workspace_broken_links(
|
|
|
|
|
backend: &Backend,
|
|
|
|
|
index: &WorkspaceIndex,
|
|
|
|
|
utf8: bool,
|
|
|
|
|
) -> Vec<BrokenLinkEntry> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
for page in index.pages_by_uri.values() {
|
|
|
|
|
for link in &page.outgoing {
|
|
|
|
|
let Some(broken) = classify_outgoing(link, &page.uri, index, &page.name) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let range = match backend.documents.get(&page.uri) {
|
|
|
|
|
Some(doc) => crate::to_lsp_range(&link.span, &doc.text, utf8),
|
|
|
|
|
None => no_text_range(&link.span),
|
|
|
|
|
};
|
|
|
|
|
out.push(BrokenLinkEntry {
|
|
|
|
|
uri: page.uri.clone(),
|
|
|
|
|
range,
|
|
|
|
|
kind: broken.tag(),
|
|
|
|
|
message: broken.message(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out.sort_by(|a, b| {
|
|
|
|
|
(a.uri.as_str(), a.range.start.line, a.range.start.character).cmp(&(
|
|
|
|
|
b.uri.as_str(),
|
|
|
|
|
b.range.start.line,
|
|
|
|
|
b.range.start.character,
|
|
|
|
|
))
|
|
|
|
|
});
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
pub struct OrphanEntry {
|
|
|
|
|
pub uri: Url,
|
|
|
|
|
pub name: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A page is an orphan when no other indexed page links to it. The
|
|
|
|
|
/// current convention is to surface every orphan and let the client
|
|
|
|
|
/// filter out the index page if it wants to.
|
|
|
|
|
pub fn find_orphans(index: &WorkspaceIndex) -> Vec<OrphanEntry> {
|
|
|
|
|
let mut out: Vec<OrphanEntry> = index
|
|
|
|
|
.pages_by_uri
|
|
|
|
|
.values()
|
|
|
|
|
.filter(|p| index.backlinks_for(&p.name).is_empty())
|
|
|
|
|
.map(|p| OrphanEntry {
|
|
|
|
|
uri: p.uri.clone(),
|
|
|
|
|
name: p.name.clone(),
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
out.sort_by(|a, b| a.name.cmp(&b.name));
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn no_text_range(span: &Span) -> tower_lsp::lsp_types::Range {
|
|
|
|
|
tower_lsp::lsp_types::Range {
|
|
|
|
|
start: LspPosition {
|
|
|
|
|
line: span.start.line,
|
|
|
|
|
character: span.start.column,
|
|
|
|
|
},
|
|
|
|
|
end: LspPosition {
|
|
|
|
|
line: span.end.line,
|
|
|
|
|
character: span.end.column,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Re-exported AST link walker so tests can drive it without depending
|
|
|
|
|
/// on the diagnostics module's internals.
|
|
|
|
|
pub use crate::diagnostics::collect_wiki_links;
|
2026-05-11 17:41:11 +00:00
|
|
|
}
|