2026-05-10 16:01:58 +00:00
|
|
|
//! LSP protocol bridge for nuwiki.
|
|
|
|
|
//!
|
2026-05-30 18:35:40 +00:00
|
|
|
//! `tower-lsp` server that maintains a `DocumentStore` plus a
|
|
|
|
|
//! workspace-wide index and exposes every LSP method:
|
2026-05-10 19:44:44 +00:00
|
|
|
//!
|
|
|
|
|
//! - `initialize` / `initialized` / `shutdown`
|
|
|
|
|
//! - `textDocument/didOpen`, `didChange` (full sync), `didClose`
|
|
|
|
|
//! - `textDocument/publishDiagnostics` from `BlockNode::Error` nodes
|
|
|
|
|
//! - `textDocument/documentSymbol` — nested outline from headings
|
2026-05-11 00:27:06 +00:00
|
|
|
//! - `textDocument/semanticTokens/full` + `/range`
|
|
|
|
|
//! - `textDocument/definition`, `references`, `hover`
|
|
|
|
|
//! - `textDocument/completion` (trigger: `[`)
|
|
|
|
|
//! - `workspace/symbol` — search across indexed headings
|
2026-05-10 19:44:44 +00:00
|
|
|
//!
|
|
|
|
|
//! Position encoding is negotiated as UTF-8 when the client supports LSP
|
2026-05-30 18:35:40 +00:00
|
|
|
//! 3.17+ encodings. When the client only supports the legacy
|
2026-05-10 19:44:44 +00:00
|
|
|
//! UTF-16 default, positions get translated through a per-line lookup.
|
|
|
|
|
//!
|
2026-05-30 18:35:40 +00:00
|
|
|
//! Re-parses are full per change (no incremental parsing).
|
2026-05-11 00:27:06 +00:00
|
|
|
//! The workspace scan runs in a background tokio task spawned from
|
2026-05-30 18:35:40 +00:00
|
|
|
//! `initialize` with `window/workDoneProgress` begin/end events.
|
2026-05-10 19:44:44 +00:00
|
|
|
|
2026-05-11 16:27:39 +00:00
|
|
|
pub mod commands;
|
2026-05-11 14:32:22 +00:00
|
|
|
pub mod config;
|
2026-05-11 20:49:32 +00:00
|
|
|
pub mod diagnostics;
|
2026-05-11 21:04:55 +00:00
|
|
|
pub mod diary;
|
2026-05-11 14:32:22 +00:00
|
|
|
pub mod edits;
|
2026-05-11 21:37:36 +00:00
|
|
|
pub mod export;
|
2026-05-11 21:57:05 +00:00
|
|
|
pub mod folding;
|
2026-05-11 00:27:06 +00:00
|
|
|
pub mod index;
|
|
|
|
|
pub mod nav;
|
2026-05-11 16:27:39 +00:00
|
|
|
pub mod rename;
|
2026-05-10 21:14:01 +00:00
|
|
|
pub mod semantic_tokens;
|
2026-05-11 14:32:22 +00:00
|
|
|
pub mod wiki;
|
2026-05-10 21:14:01 +00:00
|
|
|
|
2026-05-10 19:44:44 +00:00
|
|
|
use std::io;
|
2026-05-11 00:27:06 +00:00
|
|
|
use std::path::PathBuf;
|
2026-05-10 19:44:44 +00:00
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
2026-05-11 00:27:06 +00:00
|
|
|
use std::sync::{Arc, RwLock};
|
2026-05-10 19:44:44 +00:00
|
|
|
|
|
|
|
|
use dashmap::DashMap;
|
|
|
|
|
use tokio::io::{AsyncRead, AsyncWrite};
|
|
|
|
|
use tower_lsp::jsonrpc::Result as LspResult;
|
|
|
|
|
use tower_lsp::lsp_types::{
|
2026-05-11 00:27:06 +00:00
|
|
|
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
|
2026-05-11 21:17:04 +00:00
|
|
|
DeleteFilesParams, Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams,
|
|
|
|
|
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
|
2026-05-11 21:37:36 +00:00
|
|
|
DidSaveTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
|
|
|
|
|
ExecuteCommandOptions, ExecuteCommandParams, FileOperationFilter, FileOperationPattern,
|
2026-05-11 21:57:05 +00:00
|
|
|
FileOperationPatternKind, FileOperationRegistrationOptions, FoldingRange, FoldingRangeParams,
|
|
|
|
|
FoldingRangeProviderCapability, GotoDefinitionParams, GotoDefinitionResponse, Hover,
|
|
|
|
|
HoverContents, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult,
|
|
|
|
|
InitializedParams, Location, MarkupContent, MarkupKind, MessageType, OneOf,
|
|
|
|
|
PositionEncodingKind, ProgressParams, ProgressParamsValue, ProgressToken, Range,
|
|
|
|
|
ReferenceParams, RenameFilesParams, RenameParams, SaveOptions, SemanticTokens,
|
2026-05-11 21:37:36 +00:00
|
|
|
SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams,
|
|
|
|
|
SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult,
|
|
|
|
|
SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo,
|
|
|
|
|
SymbolInformation as LspSymbolInformation, SymbolKind, TextDocumentSyncCapability,
|
|
|
|
|
TextDocumentSyncKind, TextDocumentSyncOptions, TextDocumentSyncSaveOptions, Url,
|
|
|
|
|
WorkDoneProgress, WorkDoneProgressBegin, WorkDoneProgressEnd, WorkDoneProgressOptions,
|
|
|
|
|
WorkspaceEdit, WorkspaceFileOperationsServerCapabilities, WorkspaceServerCapabilities,
|
|
|
|
|
WorkspaceSymbolParams,
|
2026-05-10 19:44:44 +00:00
|
|
|
};
|
|
|
|
|
use tower_lsp::{Client, LanguageServer, LspService, Server};
|
|
|
|
|
|
|
|
|
|
use nuwiki_core::ast::{
|
|
|
|
|
BlockNode, BlockquoteNode, DocumentNode, ErrorNode, HeadingNode, InlineNode, ListItemNode,
|
|
|
|
|
ListNode, Span,
|
|
|
|
|
};
|
|
|
|
|
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
|
|
|
use nuwiki_core::syntax::SyntaxRegistry;
|
|
|
|
|
|
2026-05-11 14:32:22 +00:00
|
|
|
use crate::config::{Config, DiagnosticConfig};
|
2026-05-11 00:27:06 +00:00
|
|
|
use crate::index::{IndexedPage, WorkspaceIndex};
|
2026-05-11 14:32:22 +00:00
|
|
|
use crate::wiki::{build_wikis, resolve_uri_to_wiki, Wiki, WikiId};
|
|
|
|
|
|
|
|
|
|
/// Snapshot of a document — either live (held in the documents map) or
|
|
|
|
|
/// read from disk on demand. Used by cross-document operations
|
|
|
|
|
/// (`workspace/rename`, link health checks for closed pages, …) so
|
|
|
|
|
/// callers don't trust potentially stale index spans.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub struct DocSnapshot {
|
|
|
|
|
pub text: String,
|
|
|
|
|
pub ast: DocumentNode,
|
|
|
|
|
pub in_memory: bool,
|
|
|
|
|
}
|
2026-05-11 00:27:06 +00:00
|
|
|
|
2026-05-10 19:44:44 +00:00
|
|
|
/// Run the LSP server over the given async reader/writer pair. The binary
|
|
|
|
|
/// (`nuwiki-ls`) just calls this with stdin/stdout.
|
|
|
|
|
pub async fn run<I, O>(reader: I, writer: O)
|
|
|
|
|
where
|
|
|
|
|
I: AsyncRead + Unpin,
|
|
|
|
|
O: AsyncWrite + Unpin,
|
|
|
|
|
{
|
|
|
|
|
let (service, socket) = LspService::new(Backend::new);
|
|
|
|
|
Server::new(reader, writer, socket).serve(service).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Convenience: run on stdin/stdout. Fails only if stdio handles are
|
|
|
|
|
/// unavailable.
|
|
|
|
|
pub async fn run_stdio() -> io::Result<()> {
|
|
|
|
|
let stdin = tokio::io::stdin();
|
|
|
|
|
let stdout = tokio::io::stdout();
|
|
|
|
|
run(stdin, stdout).await;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
struct DocumentState {
|
|
|
|
|
text: String,
|
|
|
|
|
ast: DocumentNode,
|
2026-05-30 18:35:40 +00:00
|
|
|
/// Last version we observed from the client. Held even
|
|
|
|
|
/// though nothing reads it back yet.
|
2026-05-10 19:44:44 +00:00
|
|
|
#[allow(dead_code)]
|
|
|
|
|
version: i32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Backend {
|
|
|
|
|
client: Client,
|
|
|
|
|
documents: Arc<DashMap<Url, DocumentState>>,
|
|
|
|
|
registry: Arc<SyntaxRegistry>,
|
|
|
|
|
/// True after `initialize` if the client opted into UTF-8 encoding.
|
|
|
|
|
/// Otherwise we keep the LSP 3.16 default of UTF-16.
|
|
|
|
|
use_utf8: Arc<AtomicBool>,
|
2026-05-30 18:35:40 +00:00
|
|
|
/// Per-wiki state. Vector holds one entry until multi-wiki
|
|
|
|
|
/// support lets users add more.
|
2026-05-11 14:32:22 +00:00
|
|
|
wikis: Arc<RwLock<Vec<Wiki>>>,
|
|
|
|
|
/// Server config received via `initializationOptions` and refreshed
|
|
|
|
|
/// via `workspace/didChangeConfiguration` (P18).
|
|
|
|
|
config: Arc<RwLock<Config>>,
|
2026-05-10 19:44:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Backend {
|
|
|
|
|
fn new(client: Client) -> Self {
|
|
|
|
|
let mut registry = SyntaxRegistry::new();
|
|
|
|
|
registry.register(VimwikiSyntax::new());
|
|
|
|
|
Self {
|
|
|
|
|
client,
|
|
|
|
|
documents: Arc::new(DashMap::new()),
|
|
|
|
|
registry: Arc::new(registry),
|
|
|
|
|
use_utf8: Arc::new(AtomicBool::new(false)),
|
2026-05-11 14:32:22 +00:00
|
|
|
wikis: Arc::new(RwLock::new(Vec::new())),
|
|
|
|
|
config: Arc::new(RwLock::new(Config::default())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn wiki(&self, id: WikiId) -> Option<Wiki> {
|
|
|
|
|
let wikis = self.wikis.read().ok()?;
|
|
|
|
|
wikis.iter().find(|w| w.id == id).cloned()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 18:35:40 +00:00
|
|
|
/// Used by workspace-level commands (`checkLinks`, `findOrphans`)
|
2026-05-11 20:49:32 +00:00
|
|
|
/// when no URI is supplied — they fall back to the first registered wiki.
|
2026-05-11 14:32:22 +00:00
|
|
|
fn default_wiki(&self) -> Option<Wiki> {
|
|
|
|
|
let wikis = self.wikis.read().ok()?;
|
|
|
|
|
wikis.first().cloned()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 16:27:39 +00:00
|
|
|
/// If the cursor sits on a wikilink, return the linked page's URI so
|
|
|
|
|
/// `rename` operates on the *target* (matches vimwiki's behaviour).
|
|
|
|
|
/// Otherwise return `None` and the caller falls back to the buffer's
|
|
|
|
|
/// own URI.
|
|
|
|
|
fn rename_target_uri(&self, uri: &Url, pos: tower_lsp::lsp_types::Position) -> Option<Url> {
|
|
|
|
|
let doc = self.documents.get(uri)?;
|
|
|
|
|
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
let (line, col) = nav::lsp_to_byte_pos(pos, &doc.text, utf8);
|
|
|
|
|
let node = nav::find_inline_at(&doc.ast, line, col)?;
|
|
|
|
|
let wikilink = match node {
|
|
|
|
|
InlineNode::WikiLink(w) => w,
|
|
|
|
|
_ => return None,
|
|
|
|
|
};
|
|
|
|
|
let wiki = self.wiki_for_uri(uri)?;
|
|
|
|
|
let source_page = index::page_name_from_uri(uri, Some(&wiki.config.root));
|
|
|
|
|
let idx = wiki.index.read().ok()?;
|
|
|
|
|
idx.resolve(&wikilink.target, &source_page).cloned()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 14:32:22 +00:00
|
|
|
/// Resolve `uri` to the wiki whose root is the longest prefix of it.
|
|
|
|
|
/// Falls back to the default wiki when no root matches — that's the
|
|
|
|
|
/// usual case for ad-hoc `.wiki` files outside any registered wiki.
|
|
|
|
|
fn wiki_for_uri(&self, uri: &Url) -> Option<Wiki> {
|
|
|
|
|
let id_or_default = {
|
|
|
|
|
let wikis = self.wikis.read().ok()?;
|
|
|
|
|
resolve_uri_to_wiki(&wikis, uri).or_else(|| wikis.first().map(|w| w.id))
|
|
|
|
|
};
|
|
|
|
|
id_or_default.and_then(|id| self.wiki(id))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 23:32:15 -03:00
|
|
|
/// Parse `text` as vimwiki, honouring the checkbox palette
|
|
|
|
|
/// (`listsyms`) of the wiki owning `uri`. Falls back to the default
|
|
|
|
|
/// palette when no wiki matches.
|
|
|
|
|
fn parse_for_uri(&self, uri: &Url, text: &str) -> nuwiki_core::ast::DocumentNode {
|
|
|
|
|
let syms = self
|
|
|
|
|
.wiki_for_uri(uri)
|
2026-05-31 18:33:25 +00:00
|
|
|
.map(|w| w.config.list_syms())
|
2026-05-30 23:32:15 -03:00
|
|
|
.unwrap_or_default();
|
|
|
|
|
nuwiki_core::syntax::vimwiki::VimwikiSyntax::new().parse_with_listsyms(text, &syms)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:46:33 +00:00
|
|
|
/// Snapshot of every registered wiki — held by value so callers can
|
|
|
|
|
/// drop the lock before awaiting or building responses.
|
|
|
|
|
pub(crate) fn wikis_snapshot(&self) -> Vec<Wiki> {
|
|
|
|
|
self.wikis.read().map(|g| g.clone()).unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Look up a wiki by its position in the `wikis` Vec (used for the
|
|
|
|
|
/// `wiki<N>:` interwiki prefix).
|
|
|
|
|
fn wiki_by_index(&self, idx: usize) -> Option<Wiki> {
|
|
|
|
|
let wikis = self.wikis.read().ok()?;
|
|
|
|
|
wikis.get(idx).cloned()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Look up a wiki by its configured `name` (used for the
|
|
|
|
|
/// `wn.<Name>:` interwiki prefix). Match is exact, case-sensitive.
|
|
|
|
|
fn wiki_by_name(&self, name: &str) -> Option<Wiki> {
|
|
|
|
|
let wikis = self.wikis.read().ok()?;
|
|
|
|
|
wikis.iter().find(|w| w.config.name == name).cloned()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolve a `LinkTarget` to the destination page's URI.
|
|
|
|
|
///
|
2026-05-12 01:22:35 +00:00
|
|
|
/// - `LinkKind::Wiki` — looks the page up in the source wiki's
|
|
|
|
|
/// index first; if it isn't indexed yet (the user is following
|
|
|
|
|
/// a link to a page that doesn't exist on disk), synthesises the
|
|
|
|
|
/// URI from the wiki root + path + file extension so the editor
|
|
|
|
|
/// can open a fresh buffer for it. Matches vimwiki's
|
|
|
|
|
/// "follow-link creates the page on save" behaviour.
|
|
|
|
|
/// - `LinkKind::AnchorOnly` — same source-wiki lookup; returns
|
|
|
|
|
/// `None` when the source page isn't indexed.
|
2026-05-11 21:46:33 +00:00
|
|
|
/// - `LinkKind::Interwiki` — routes to the wiki referenced by
|
2026-05-12 01:22:35 +00:00
|
|
|
/// `wiki_index` or `wiki_name`. Falls back to a synthesised URI
|
|
|
|
|
/// in the target wiki's root when the page isn't indexed.
|
|
|
|
|
/// - Other kinds (`File`/`Local`/`Diary`/`Raw`) — no resolution
|
|
|
|
|
/// here; the LSP handler builds those URIs separately.
|
2026-05-11 21:46:33 +00:00
|
|
|
fn resolve_target_uri(
|
|
|
|
|
&self,
|
|
|
|
|
target: &nuwiki_core::ast::LinkTarget,
|
|
|
|
|
source_uri: &Url,
|
|
|
|
|
) -> Option<Url> {
|
|
|
|
|
use nuwiki_core::ast::LinkKind;
|
|
|
|
|
match target.kind {
|
|
|
|
|
LinkKind::Interwiki => {
|
|
|
|
|
let target_wiki = if let Some(idx) = target.wiki_index {
|
|
|
|
|
self.wiki_by_index(idx)?
|
|
|
|
|
} else if let Some(name) = target.wiki_name.as_deref() {
|
|
|
|
|
self.wiki_by_name(name)?
|
|
|
|
|
} else {
|
|
|
|
|
return None;
|
|
|
|
|
};
|
|
|
|
|
let path = target.path.as_deref()?;
|
2026-05-12 01:22:35 +00:00
|
|
|
if let Ok(idx) = target_wiki.index.read() {
|
2026-05-26 22:00:03 -03:00
|
|
|
let normalized = idx.strip_link_extension(path);
|
|
|
|
|
if let Some(uri) = idx.pages_by_name.get(normalized).cloned() {
|
2026-05-12 01:22:35 +00:00
|
|
|
return Some(uri);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
synthesise_page_uri(&target_wiki.config, path)
|
|
|
|
|
}
|
|
|
|
|
LinkKind::Wiki => {
|
2026-05-12 01:44:26 +00:00
|
|
|
if let Some(source_wiki) = self.wiki_for_uri(source_uri) {
|
|
|
|
|
let source_page = index::page_name_from_uri(
|
|
|
|
|
source_uri,
|
|
|
|
|
Some(source_wiki.config.root.as_path()),
|
|
|
|
|
);
|
|
|
|
|
if let Ok(idx) = source_wiki.index.read() {
|
|
|
|
|
if let Some(uri) = idx.resolve(target, &source_page).cloned() {
|
|
|
|
|
return Some(uri);
|
|
|
|
|
}
|
2026-05-12 01:22:35 +00:00
|
|
|
}
|
2026-05-12 01:44:26 +00:00
|
|
|
let path = target.path.as_deref()?;
|
|
|
|
|
return synthesise_page_uri(&source_wiki.config, path);
|
2026-05-12 01:22:35 +00:00
|
|
|
}
|
2026-05-12 01:44:26 +00:00
|
|
|
// No wiki registered (no `initializationOptions`, no
|
|
|
|
|
// workspace folder). Fall back to the source buffer's
|
|
|
|
|
// parent directory so `<CR>` on `[[NewPage]]` still
|
|
|
|
|
// opens (and on save creates) the future page next to
|
|
|
|
|
// the current file.
|
2026-05-12 01:22:35 +00:00
|
|
|
let path = target.path.as_deref()?;
|
2026-05-12 01:44:26 +00:00
|
|
|
synthesise_page_uri_next_to(source_uri, path)
|
2026-05-11 21:46:33 +00:00
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
let source_wiki = self.wiki_for_uri(source_uri)?;
|
|
|
|
|
let source_page =
|
|
|
|
|
index::page_name_from_uri(source_uri, Some(source_wiki.config.root.as_path()));
|
|
|
|
|
let idx = source_wiki.index.read().ok()?;
|
|
|
|
|
idx.resolve(target, &source_page).cloned()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Compute the LSP `Range` for an anchor (heading or tag) on the
|
|
|
|
|
/// page at `target_uri`. Resolves the *target* wiki, so interwiki
|
|
|
|
|
/// anchors land at the correct heading in the cross-wiki page.
|
|
|
|
|
fn heading_range_for(&self, target_uri: &Url, anchor: &str, utf8: bool) -> Option<Range> {
|
|
|
|
|
let wiki = self.wiki_for_uri(target_uri)?;
|
|
|
|
|
let idx = wiki.index.read().ok()?;
|
|
|
|
|
heading_range_in(&idx, target_uri, anchor, utf8)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 14:32:22 +00:00
|
|
|
/// Return live state if `uri` is open in the editor, otherwise read
|
|
|
|
|
/// the file from disk and re-parse. The closed-doc path is used by
|
2026-05-30 18:35:40 +00:00
|
|
|
/// cross-document operations (`workspace/rename` and link
|
2026-05-11 14:32:22 +00:00
|
|
|
/// health) that need accurate spans + text without trusting cached
|
|
|
|
|
/// `WorkspaceIndex` data.
|
|
|
|
|
pub async fn read_or_open(&self, uri: &Url) -> io::Result<DocSnapshot> {
|
|
|
|
|
if let Some(doc) = self.documents.get(uri) {
|
|
|
|
|
return Ok(DocSnapshot {
|
|
|
|
|
text: doc.text.clone(),
|
|
|
|
|
ast: doc.ast.clone(),
|
|
|
|
|
in_memory: true,
|
|
|
|
|
});
|
2026-05-10 19:44:44 +00:00
|
|
|
}
|
2026-05-11 14:32:22 +00:00
|
|
|
let path = uri
|
|
|
|
|
.to_file_path()
|
|
|
|
|
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "non-file URI"))?;
|
|
|
|
|
let text = tokio::fs::read_to_string(&path).await?;
|
2026-05-30 23:32:15 -03:00
|
|
|
if self.registry.get("vimwiki").is_none() {
|
|
|
|
|
return Err(io::Error::other("vimwiki syntax plugin missing"));
|
|
|
|
|
}
|
|
|
|
|
let ast = self.parse_for_uri(uri, &text);
|
2026-05-11 14:32:22 +00:00
|
|
|
Ok(DocSnapshot {
|
|
|
|
|
text,
|
|
|
|
|
ast,
|
|
|
|
|
in_memory: false,
|
|
|
|
|
})
|
2026-05-10 19:44:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn update_document(&self, uri: Url, text: String, version: i32) {
|
2026-05-30 18:35:40 +00:00
|
|
|
// We always treat unknown buffers as vimwiki — if/when
|
2026-05-10 19:44:44 +00:00
|
|
|
// multi-syntax dispatch lands the pick should follow the URI's ext.
|
2026-05-30 23:32:15 -03:00
|
|
|
if self.registry.get("vimwiki").is_none() {
|
|
|
|
|
self.client
|
|
|
|
|
.log_message(MessageType::ERROR, "vimwiki plugin missing")
|
|
|
|
|
.await;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let ast = self.parse_for_uri(&uri, &text);
|
2026-05-11 14:32:22 +00:00
|
|
|
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
|
|
|
|
|
// Compute diagnostics + update the matching wiki's index. All locks
|
|
|
|
|
// released before the `await` below.
|
|
|
|
|
let diagnostics = {
|
|
|
|
|
let wiki = self.wiki_for_uri(&uri);
|
|
|
|
|
let cfg = self.config.read().expect("config lock poisoned");
|
|
|
|
|
let page_name = wiki
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|w| crate::index::page_name_from_uri(&uri, Some(&w.config.root)));
|
|
|
|
|
let diags = if let Some(w) = wiki.as_ref() {
|
|
|
|
|
let idx_guard = w.index.read().expect("index lock poisoned");
|
|
|
|
|
collect_diagnostics(
|
|
|
|
|
&ast,
|
|
|
|
|
&text,
|
2026-05-11 20:49:32 +00:00
|
|
|
Some(&uri),
|
2026-05-11 14:32:22 +00:00
|
|
|
Some(&idx_guard),
|
|
|
|
|
page_name.as_deref(),
|
|
|
|
|
&cfg.diagnostic,
|
|
|
|
|
utf8,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
2026-05-11 20:49:32 +00:00
|
|
|
collect_diagnostics(&ast, &text, Some(&uri), None, None, &cfg.diagnostic, utf8)
|
2026-05-11 14:32:22 +00:00
|
|
|
};
|
|
|
|
|
diags
|
|
|
|
|
};
|
2026-05-11 00:27:06 +00:00
|
|
|
|
2026-05-11 14:32:22 +00:00
|
|
|
if let Some(wiki) = self.wiki_for_uri(&uri) {
|
|
|
|
|
let mut idx = wiki.index.write().expect("index lock poisoned");
|
2026-05-11 00:27:06 +00:00
|
|
|
idx.upsert(uri.clone(), &ast);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 19:44:44 +00:00
|
|
|
self.documents
|
|
|
|
|
.insert(uri.clone(), DocumentState { text, ast, version });
|
|
|
|
|
self.client
|
|
|
|
|
.publish_diagnostics(uri, diagnostics, Some(version))
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tower_lsp::async_trait]
|
|
|
|
|
impl LanguageServer for Backend {
|
|
|
|
|
async fn initialize(&self, params: InitializeParams) -> LspResult<InitializeResult> {
|
2026-05-30 18:35:40 +00:00
|
|
|
// Prefer UTF-8 if the client advertises support.
|
2026-05-10 19:44:44 +00:00
|
|
|
let supports_utf8 = params
|
|
|
|
|
.capabilities
|
|
|
|
|
.general
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|g| g.position_encodings.as_ref())
|
|
|
|
|
.map(|encs| encs.iter().any(|e| *e == PositionEncodingKind::UTF8))
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
self.use_utf8.store(supports_utf8, Ordering::Relaxed);
|
|
|
|
|
|
|
|
|
|
let position_encoding = if supports_utf8 {
|
|
|
|
|
Some(PositionEncodingKind::UTF8)
|
|
|
|
|
} else {
|
|
|
|
|
None // server defaults to UTF-16 per LSP 3.16.
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-11 14:32:22 +00:00
|
|
|
// Build server config from initializationOptions (P18) and the
|
|
|
|
|
// workspace folders, then materialise the `Wiki` aggregate (P11).
|
|
|
|
|
let cfg = Config::from_init_params(¶ms);
|
|
|
|
|
*self.wikis.write().expect("wikis lock poisoned") = build_wikis(&cfg.wikis);
|
|
|
|
|
*self.config.write().expect("config lock poisoned") = cfg;
|
2026-05-11 00:27:06 +00:00
|
|
|
|
2026-05-10 19:44:44 +00:00
|
|
|
Ok(InitializeResult {
|
|
|
|
|
capabilities: ServerCapabilities {
|
|
|
|
|
position_encoding,
|
2026-05-11 21:37:36 +00:00
|
|
|
text_document_sync: Some(TextDocumentSyncCapability::Options(
|
|
|
|
|
TextDocumentSyncOptions {
|
|
|
|
|
open_close: Some(true),
|
|
|
|
|
change: Some(TextDocumentSyncKind::FULL),
|
|
|
|
|
will_save: None,
|
|
|
|
|
will_save_wait_until: None,
|
2026-05-30 18:35:40 +00:00
|
|
|
// `auto_export` listens on `did_save`. Asking
|
2026-05-11 21:37:36 +00:00
|
|
|
// for `include_text: false` since the document store
|
|
|
|
|
// already mirrors the live buffer.
|
|
|
|
|
save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
|
|
|
|
|
include_text: Some(false),
|
|
|
|
|
})),
|
|
|
|
|
},
|
2026-05-10 19:44:44 +00:00
|
|
|
)),
|
|
|
|
|
document_symbol_provider: Some(OneOf::Left(true)),
|
2026-05-11 00:27:06 +00:00
|
|
|
workspace_symbol_provider: Some(OneOf::Left(true)),
|
|
|
|
|
definition_provider: Some(OneOf::Left(true)),
|
|
|
|
|
references_provider: Some(OneOf::Left(true)),
|
2026-05-11 16:27:39 +00:00
|
|
|
rename_provider: Some(OneOf::Left(true)),
|
|
|
|
|
execute_command_provider: Some(ExecuteCommandOptions {
|
|
|
|
|
commands: commands::COMMANDS
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|s| (*s).to_string())
|
|
|
|
|
.collect(),
|
|
|
|
|
work_done_progress_options: WorkDoneProgressOptions::default(),
|
|
|
|
|
}),
|
2026-05-11 00:27:06 +00:00
|
|
|
hover_provider: Some(HoverProviderCapability::Simple(true)),
|
|
|
|
|
completion_provider: Some(CompletionOptions {
|
|
|
|
|
trigger_characters: Some(vec!["[".into()]),
|
|
|
|
|
..CompletionOptions::default()
|
|
|
|
|
}),
|
2026-05-10 21:14:01 +00:00
|
|
|
semantic_tokens_provider: Some(
|
|
|
|
|
SemanticTokensServerCapabilities::SemanticTokensOptions(
|
|
|
|
|
SemanticTokensOptions {
|
|
|
|
|
work_done_progress_options: WorkDoneProgressOptions::default(),
|
|
|
|
|
legend: semantic_tokens::legend(),
|
|
|
|
|
range: Some(true),
|
|
|
|
|
full: Some(SemanticTokensFullOptions::Bool(true)),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-05-11 21:17:04 +00:00
|
|
|
workspace: Some(WorkspaceServerCapabilities {
|
|
|
|
|
workspace_folders: None,
|
|
|
|
|
file_operations: Some(wiki_file_operations()),
|
|
|
|
|
}),
|
2026-05-11 21:57:05 +00:00
|
|
|
folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
|
2026-05-10 19:44:44 +00:00
|
|
|
..ServerCapabilities::default()
|
|
|
|
|
},
|
|
|
|
|
server_info: Some(ServerInfo {
|
|
|
|
|
name: env!("CARGO_PKG_NAME").into(),
|
|
|
|
|
version: Some(env!("CARGO_PKG_VERSION").into()),
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn initialized(&self, _params: InitializedParams) {
|
|
|
|
|
let enc = if self.use_utf8.load(Ordering::Relaxed) {
|
|
|
|
|
"utf-8"
|
|
|
|
|
} else {
|
|
|
|
|
"utf-16"
|
|
|
|
|
};
|
|
|
|
|
self.client
|
|
|
|
|
.log_message(
|
|
|
|
|
MessageType::INFO,
|
|
|
|
|
format!("nuwiki-lsp initialized (positionEncoding={enc})"),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
2026-05-11 00:27:06 +00:00
|
|
|
|
2026-05-11 14:32:22 +00:00
|
|
|
// Kick off the background workspace scan (P8) — one task per wiki.
|
2026-05-26 22:47:46 -03:00
|
|
|
// When each scan finishes, refresh diagnostics for any docs that were
|
|
|
|
|
// opened while the index was still empty (their link-health checks
|
|
|
|
|
// would have flagged every link as missing-page).
|
2026-05-11 14:32:22 +00:00
|
|
|
let wikis = self.wikis.read().expect("wikis lock poisoned").clone();
|
|
|
|
|
for wiki in wikis {
|
|
|
|
|
if wiki.config.root.as_os_str().is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-05-26 22:47:46 -03:00
|
|
|
let scan_client = self.client.clone();
|
|
|
|
|
let refresh_client = self.client.clone();
|
2026-05-11 00:27:06 +00:00
|
|
|
let registry = Arc::clone(&self.registry);
|
2026-05-11 14:32:22 +00:00
|
|
|
let index = Arc::clone(&wiki.index);
|
|
|
|
|
let root = wiki.config.root.clone();
|
2026-05-26 22:47:46 -03:00
|
|
|
let documents = Arc::clone(&self.documents);
|
|
|
|
|
let wikis_arc = Arc::clone(&self.wikis);
|
|
|
|
|
let config = Arc::clone(&self.config);
|
|
|
|
|
let use_utf8 = Arc::clone(&self.use_utf8);
|
|
|
|
|
let wiki_id = wiki.id;
|
2026-05-11 00:27:06 +00:00
|
|
|
tokio::spawn(async move {
|
2026-05-26 22:47:46 -03:00
|
|
|
index_workspace(root, index, scan_client, registry).await;
|
|
|
|
|
refresh_open_diagnostics_for_wiki(
|
|
|
|
|
wiki_id,
|
|
|
|
|
documents,
|
|
|
|
|
wikis_arc,
|
|
|
|
|
config,
|
|
|
|
|
refresh_client,
|
|
|
|
|
use_utf8,
|
|
|
|
|
)
|
|
|
|
|
.await;
|
2026-05-11 00:27:06 +00:00
|
|
|
});
|
|
|
|
|
}
|
2026-05-10 19:44:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-11 14:32:22 +00:00
|
|
|
async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
|
|
|
|
|
// Apply the new settings to `Config`. Re-indexing on root changes
|
2026-05-30 18:35:40 +00:00
|
|
|
// is out of scope here; for now we just accept the new
|
2026-05-11 14:32:22 +00:00
|
|
|
// values so later commands see the right diagnostic settings etc.
|
2026-05-30 18:35:40 +00:00
|
|
|
// Multi-wiki support revisits this with proper rebuild semantics.
|
2026-05-30 23:12:08 -03:00
|
|
|
{
|
|
|
|
|
let mut cfg = self.config.write().expect("config lock poisoned");
|
|
|
|
|
cfg.apply_change(¶ms.settings);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A severity change (e.g. diagnostic.link_severity) only affects
|
|
|
|
|
// already-published diagnostics once we re-run link-health checks for
|
|
|
|
|
// open docs — otherwise the editor keeps showing the old severity until
|
|
|
|
|
// the next edit. Refresh every wiki's open docs against the new config.
|
|
|
|
|
let wiki_ids: Vec<crate::wiki::WikiId> = self
|
|
|
|
|
.wikis
|
|
|
|
|
.read()
|
|
|
|
|
.expect("wikis lock poisoned")
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|w| w.id)
|
|
|
|
|
.collect();
|
|
|
|
|
for wiki_id in wiki_ids {
|
|
|
|
|
refresh_open_diagnostics_for_wiki(
|
|
|
|
|
wiki_id,
|
|
|
|
|
Arc::clone(&self.documents),
|
|
|
|
|
Arc::clone(&self.wikis),
|
|
|
|
|
Arc::clone(&self.config),
|
|
|
|
|
self.client.clone(),
|
|
|
|
|
Arc::clone(&self.use_utf8),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
}
|
2026-05-11 14:32:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 19:44:44 +00:00
|
|
|
async fn shutdown(&self) -> LspResult<()> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn did_open(&self, params: DidOpenTextDocumentParams) {
|
|
|
|
|
let uri = params.text_document.uri;
|
|
|
|
|
let text = params.text_document.text;
|
|
|
|
|
let version = params.text_document.version;
|
|
|
|
|
self.update_document(uri, text, version).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn did_change(&self, params: DidChangeTextDocumentParams) {
|
|
|
|
|
let uri = params.text_document.uri;
|
|
|
|
|
let version = params.text_document.version;
|
|
|
|
|
// Full sync: the *last* content change carries the whole document.
|
|
|
|
|
if let Some(change) = params.content_changes.into_iter().last() {
|
|
|
|
|
self.update_document(uri, change.text, version).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:17:04 +00:00
|
|
|
async fn did_rename_files(&self, params: RenameFilesParams) {
|
2026-05-30 18:35:40 +00:00
|
|
|
// We advertise this capability so the editor pushes
|
2026-05-11 21:17:04 +00:00
|
|
|
// out-of-band renames (file explorer drags, `:VimwikiRenameFile`
|
|
|
|
|
// when the client opts to surface the new URI). Update the index
|
|
|
|
|
// so backlinks/headings track the new URI without waiting for
|
|
|
|
|
// didOpen on the renamed buffer.
|
|
|
|
|
for fr in ¶ms.files {
|
|
|
|
|
let Ok(old_uri) = Url::parse(&fr.old_uri) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let Ok(new_uri) = Url::parse(&fr.new_uri) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
// Pull the live document forward if it was open under the old
|
|
|
|
|
// URI. Otherwise read the new path from disk and parse.
|
|
|
|
|
if let Some((_, state)) = self.documents.remove(&old_uri) {
|
|
|
|
|
self.documents.insert(new_uri.clone(), state);
|
|
|
|
|
}
|
|
|
|
|
if let Some(wiki) = self.wiki_for_uri(&new_uri) {
|
|
|
|
|
let mut idx = wiki.index.write().expect("index lock poisoned");
|
|
|
|
|
idx.remove(&old_uri);
|
|
|
|
|
}
|
|
|
|
|
if let Ok(snapshot) = self.read_or_open(&new_uri).await {
|
|
|
|
|
if let Some(wiki) = self.wiki_for_uri(&new_uri) {
|
|
|
|
|
let mut idx = wiki.index.write().expect("index lock poisoned");
|
|
|
|
|
idx.upsert(new_uri.clone(), &snapshot.ast);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn did_delete_files(&self, params: DeleteFilesParams) {
|
|
|
|
|
// Mirror of did_rename: drop the index entry so backlink lookups
|
|
|
|
|
// stop returning the now-missing source URI.
|
|
|
|
|
for f in ¶ms.files {
|
|
|
|
|
let Ok(uri) = Url::parse(&f.uri) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
self.documents.remove(&uri);
|
|
|
|
|
if let Some(wiki) = self.wiki_for_uri(&uri) {
|
|
|
|
|
let mut idx = wiki.index.write().expect("index lock poisoned");
|
|
|
|
|
idx.remove(&uri);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:37:36 +00:00
|
|
|
async fn did_save(&self, params: DidSaveTextDocumentParams) {
|
|
|
|
|
let uri = params.text_document.uri.clone();
|
|
|
|
|
let wiki = match self.wiki_for_uri(&uri) {
|
|
|
|
|
Some(w) => w,
|
|
|
|
|
None => return,
|
|
|
|
|
};
|
2026-05-30 23:12:08 -03:00
|
|
|
|
|
|
|
|
// When `auto_toc` is set, rebuild an existing `= Contents =` section
|
|
|
|
|
// on save and push the change back to the buffer via
|
|
|
|
|
// `workspace/applyEdit`. Only rebuilds a TOC that's already there —
|
|
|
|
|
// it never inserts one into a page that didn't have it.
|
|
|
|
|
if wiki.config.auto_toc {
|
|
|
|
|
let edit = {
|
|
|
|
|
self.documents.get(&uri).and_then(|doc| {
|
|
|
|
|
commands::ops::toc_rebuild_edit(
|
|
|
|
|
&doc.text,
|
|
|
|
|
&doc.ast,
|
|
|
|
|
&uri,
|
|
|
|
|
self.use_utf8.load(Ordering::Relaxed),
|
2026-05-31 18:44:35 +00:00
|
|
|
&wiki.config.toc_header,
|
|
|
|
|
wiki.config.toc_header_level,
|
2026-05-30 23:12:08 -03:00
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
};
|
|
|
|
|
if let Some(edit) = edit {
|
|
|
|
|
let _ = self.client.apply_edit(edit).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 18:55:51 +00:00
|
|
|
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
|
|
|
|
|
// `auto_generate_links`: rebuild an existing `Generated Links` section.
|
|
|
|
|
if wiki.config.auto_generate_links {
|
|
|
|
|
let edit = self.documents.get(&uri).and_then(|doc| {
|
|
|
|
|
let current = index::page_name_from_uri(&uri, Some(&wiki.config.root));
|
|
|
|
|
let pages = wiki
|
|
|
|
|
.index
|
|
|
|
|
.read()
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|i| i.page_names())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
commands::ops::links_rebuild_edit(
|
|
|
|
|
&doc.text,
|
|
|
|
|
&doc.ast,
|
|
|
|
|
&uri,
|
|
|
|
|
¤t,
|
|
|
|
|
&pages,
|
|
|
|
|
utf8,
|
|
|
|
|
&wiki.config.links_header,
|
|
|
|
|
wiki.config.links_header_level,
|
|
|
|
|
)
|
|
|
|
|
});
|
|
|
|
|
if let Some(edit) = edit {
|
|
|
|
|
let _ = self.client.apply_edit(edit).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// `auto_generate_tags`: rebuild an existing `Generated Tags` index.
|
|
|
|
|
if wiki.config.auto_generate_tags {
|
|
|
|
|
let edit = self.documents.get(&uri).and_then(|doc| {
|
|
|
|
|
let snap = wiki
|
|
|
|
|
.index
|
|
|
|
|
.read()
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|i| commands::ops::tag_pages_snapshot(&i))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
commands::ops::tag_links_rebuild_edit(
|
|
|
|
|
&doc.text,
|
|
|
|
|
&doc.ast,
|
|
|
|
|
&uri,
|
|
|
|
|
&snap,
|
|
|
|
|
utf8,
|
|
|
|
|
&wiki.config.tags_header,
|
|
|
|
|
wiki.config.tags_header_level,
|
|
|
|
|
)
|
|
|
|
|
});
|
|
|
|
|
if let Some(edit) = edit {
|
|
|
|
|
let _ = self.client.apply_edit(edit).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// `auto_diary_index`: when a diary *entry* (a dated page, not the
|
|
|
|
|
// index itself) is saved, regenerate the diary index page.
|
|
|
|
|
if wiki.config.auto_diary_index {
|
|
|
|
|
if let Some(index_uri) = crate::diary::index_uri(&wiki.config) {
|
|
|
|
|
let is_diary_entry = uri != index_uri
|
|
|
|
|
&& wiki
|
|
|
|
|
.index
|
|
|
|
|
.read()
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|i| i.page(&uri).and_then(|p| p.diary_period))
|
|
|
|
|
.is_some();
|
|
|
|
|
if is_diary_entry {
|
|
|
|
|
let body = wiki
|
|
|
|
|
.index
|
|
|
|
|
.read()
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|i| crate::diary::render_index_body(&wiki.config, &i));
|
|
|
|
|
if let Some(body) = body {
|
|
|
|
|
let edit =
|
|
|
|
|
commands::ops::diary_generate_index_edit(self, &index_uri, &body, utf8);
|
|
|
|
|
let _ = self.client.apply_edit(edit).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 23:12:08 -03:00
|
|
|
// When `auto_export` is set on the resolved wiki, run
|
|
|
|
|
// the equivalent of `nuwiki.export.currentToHtml` after the file
|
|
|
|
|
// hits disk. Best-effort — failures are logged but do not bubble
|
|
|
|
|
// to the client. Skips pages with the `%nohtml` directive.
|
2026-05-11 21:37:36 +00:00
|
|
|
if !wiki.config.html.auto_export {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let doc = match self.documents.get(&uri) {
|
|
|
|
|
Some(d) => d,
|
|
|
|
|
None => return,
|
|
|
|
|
};
|
|
|
|
|
if doc.ast.metadata.nohtml {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let name = index::page_name_from_uri(&uri, Some(&wiki.config.root));
|
2026-05-31 19:06:42 +00:00
|
|
|
let outcome = commands::export_ops::write_page(&doc.ast, &name, &wiki.config, false);
|
2026-05-11 21:37:36 +00:00
|
|
|
drop(doc);
|
|
|
|
|
match outcome {
|
|
|
|
|
Ok(out) => {
|
|
|
|
|
let _ = commands::export_ops::ensure_css(&wiki.config);
|
|
|
|
|
self.client
|
|
|
|
|
.log_message(
|
|
|
|
|
MessageType::INFO,
|
|
|
|
|
format!(
|
|
|
|
|
"nuwiki: auto-exported {} → {}",
|
|
|
|
|
name,
|
|
|
|
|
out.output_path.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
self.client
|
|
|
|
|
.log_message(
|
|
|
|
|
MessageType::ERROR,
|
|
|
|
|
format!("nuwiki: auto-export of {name} failed — {e}"),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 19:44:44 +00:00
|
|
|
async fn did_close(&self, params: DidCloseTextDocumentParams) {
|
2026-05-11 00:27:06 +00:00
|
|
|
let uri = ¶ms.text_document.uri;
|
|
|
|
|
self.documents.remove(uri);
|
|
|
|
|
// Keep the index entry — the file may still exist on disk and other
|
|
|
|
|
// pages can still link to it. We only drop the live document state.
|
2026-05-10 19:44:44 +00:00
|
|
|
// Clear lingering diagnostics so closed buffers don't keep red squiggles.
|
|
|
|
|
self.client
|
2026-05-11 00:27:06 +00:00
|
|
|
.publish_diagnostics(params.text_document.uri.clone(), Vec::new(), None)
|
2026-05-10 19:44:44 +00:00
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn document_symbol(
|
|
|
|
|
&self,
|
|
|
|
|
params: DocumentSymbolParams,
|
|
|
|
|
) -> LspResult<Option<DocumentSymbolResponse>> {
|
|
|
|
|
let uri = ¶ms.text_document.uri;
|
|
|
|
|
let Some(doc) = self.documents.get(uri) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let symbols =
|
|
|
|
|
headings_to_symbols(&doc.ast, &doc.text, self.use_utf8.load(Ordering::Relaxed));
|
|
|
|
|
Ok(Some(DocumentSymbolResponse::Nested(symbols)))
|
|
|
|
|
}
|
2026-05-10 21:14:01 +00:00
|
|
|
|
|
|
|
|
async fn semantic_tokens_full(
|
|
|
|
|
&self,
|
|
|
|
|
params: SemanticTokensParams,
|
|
|
|
|
) -> LspResult<Option<SemanticTokensResult>> {
|
|
|
|
|
let uri = ¶ms.text_document.uri;
|
|
|
|
|
let Some(doc) = self.documents.get(uri) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
let data = semantic_tokens::build_data(&doc.ast, &doc.text, utf8, None);
|
|
|
|
|
Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
|
|
|
|
|
result_id: None,
|
|
|
|
|
data: pack_data(&data),
|
|
|
|
|
})))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn semantic_tokens_range(
|
|
|
|
|
&self,
|
|
|
|
|
params: SemanticTokensRangeParams,
|
|
|
|
|
) -> LspResult<Option<SemanticTokensRangeResult>> {
|
|
|
|
|
let uri = ¶ms.text_document.uri;
|
|
|
|
|
let Some(doc) = self.documents.get(uri) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
let data = semantic_tokens::build_data(&doc.ast, &doc.text, utf8, Some(params.range));
|
|
|
|
|
Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
|
|
|
|
|
result_id: None,
|
|
|
|
|
data: pack_data(&data),
|
|
|
|
|
})))
|
|
|
|
|
}
|
2026-05-11 00:27:06 +00:00
|
|
|
|
|
|
|
|
async fn goto_definition(
|
|
|
|
|
&self,
|
|
|
|
|
params: GotoDefinitionParams,
|
|
|
|
|
) -> LspResult<Option<GotoDefinitionResponse>> {
|
|
|
|
|
let pos = params.text_document_position_params.position;
|
|
|
|
|
let uri = ¶ms.text_document_position_params.text_document.uri;
|
|
|
|
|
let Some(doc) = self.documents.get(uri) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
let (line, col) = nav::lsp_to_byte_pos(pos, &doc.text, utf8);
|
|
|
|
|
let Some(node) = nav::find_inline_at(&doc.ast, line, col) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let location = match node {
|
2026-05-11 21:46:33 +00:00
|
|
|
InlineNode::WikiLink(w) => self.resolve_target_uri(&w.target, uri).map(|target| {
|
|
|
|
|
let target_range = w
|
|
|
|
|
.target
|
|
|
|
|
.anchor
|
|
|
|
|
.as_deref()
|
|
|
|
|
.and_then(|a| self.heading_range_for(&target, a, utf8))
|
|
|
|
|
.unwrap_or_else(zero_range);
|
|
|
|
|
Location {
|
|
|
|
|
uri: target,
|
|
|
|
|
range: target_range,
|
|
|
|
|
}
|
|
|
|
|
}),
|
2026-05-11 00:27:06 +00:00
|
|
|
InlineNode::ExternalLink(_) | InlineNode::RawUrl(_) => None,
|
|
|
|
|
_ => None,
|
|
|
|
|
};
|
|
|
|
|
Ok(location.map(GotoDefinitionResponse::Scalar))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn references(&self, params: ReferenceParams) -> LspResult<Option<Vec<Location>>> {
|
|
|
|
|
let pos = params.text_document_position.position;
|
|
|
|
|
let uri = ¶ms.text_document_position.text_document.uri;
|
|
|
|
|
let Some(doc) = self.documents.get(uri) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
let (line, col) = nav::lsp_to_byte_pos(pos, &doc.text, utf8);
|
|
|
|
|
|
|
|
|
|
// Identify the page (and optional anchor) the cursor is "asking
|
|
|
|
|
// about". Two cases:
|
|
|
|
|
// - cursor is on a wikilink → references to the linked page
|
|
|
|
|
// - cursor isn't on a link → references to the *current* page
|
2026-05-11 14:32:22 +00:00
|
|
|
let wiki = self.wiki_for_uri(uri);
|
|
|
|
|
let source_page =
|
|
|
|
|
index::page_name_from_uri(uri, wiki.as_ref().map(|w| w.config.root.as_path()));
|
2026-05-11 00:27:06 +00:00
|
|
|
let target_page = match nav::find_inline_at(&doc.ast, line, col) {
|
|
|
|
|
Some(InlineNode::WikiLink(w)) => match w.target.kind {
|
2026-05-26 22:00:03 -03:00
|
|
|
nuwiki_core::ast::LinkKind::Wiki => w.target.path.as_ref().map(|p| {
|
|
|
|
|
let ext = wiki.as_ref().map(|w| w.config.file_extension.as_str());
|
|
|
|
|
index::strip_wiki_extension(p, ext).to_string()
|
|
|
|
|
}),
|
2026-05-11 00:27:06 +00:00
|
|
|
nuwiki_core::ast::LinkKind::AnchorOnly => Some(source_page.clone()),
|
|
|
|
|
_ => None,
|
|
|
|
|
},
|
|
|
|
|
_ => Some(source_page),
|
|
|
|
|
};
|
|
|
|
|
let Some(page) = target_page else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
2026-05-11 14:32:22 +00:00
|
|
|
let Some(wiki) = wiki else {
|
|
|
|
|
return Ok(Some(Vec::new()));
|
|
|
|
|
};
|
|
|
|
|
let idx = wiki.index.read().expect("index lock poisoned");
|
2026-05-11 00:27:06 +00:00
|
|
|
let locations: Vec<Location> = idx
|
|
|
|
|
.backlinks_for(&page)
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|b| {
|
|
|
|
|
self.documents.get(&b.source_uri).map(|src| Location {
|
|
|
|
|
uri: b.source_uri.clone(),
|
|
|
|
|
range: span_to_lsp_range(&b.source_span, &src.text, utf8),
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
if locations.is_empty() {
|
|
|
|
|
// Even without open docs we can still produce LSP ranges from
|
|
|
|
|
// the stored span; the editor opens the file to display it.
|
|
|
|
|
let locations: Vec<Location> = idx
|
|
|
|
|
.backlinks_for(&page)
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|b| Location {
|
|
|
|
|
uri: b.source_uri.clone(),
|
|
|
|
|
range: span_to_lsp_range_no_text(&b.source_span),
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
return Ok(Some(locations));
|
|
|
|
|
}
|
|
|
|
|
Ok(Some(locations))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn hover(&self, params: HoverParams) -> LspResult<Option<Hover>> {
|
|
|
|
|
let pos = params.text_document_position_params.position;
|
|
|
|
|
let uri = ¶ms.text_document_position_params.text_document.uri;
|
|
|
|
|
let Some(doc) = self.documents.get(uri) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
let (line, col) = nav::lsp_to_byte_pos(pos, &doc.text, utf8);
|
|
|
|
|
let Some(node) = nav::find_inline_at(&doc.ast, line, col) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let (markdown, hover_span) = match node {
|
|
|
|
|
InlineNode::WikiLink(w) => {
|
2026-05-11 21:46:33 +00:00
|
|
|
let body = match self.resolve_target_uri(&w.target, uri) {
|
|
|
|
|
Some(target_uri) => {
|
|
|
|
|
// Look up the page in whichever wiki owns the
|
|
|
|
|
// resolved URI — for interwiki targets this is
|
|
|
|
|
// the destination wiki, not the source.
|
|
|
|
|
let wiki = self.wiki_for_uri(&target_uri);
|
|
|
|
|
match wiki.as_ref() {
|
|
|
|
|
Some(wiki) => {
|
|
|
|
|
let idx = wiki.index.read().expect("index lock poisoned");
|
|
|
|
|
match idx.page(&target_uri) {
|
|
|
|
|
Some(page) => preview_for(page, w.target.anchor.as_deref()),
|
|
|
|
|
None => match &w.target.path {
|
|
|
|
|
Some(p) => format!("**{p}** — page not in index"),
|
|
|
|
|
None => "(unresolved link)".into(),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None => "(no wiki configured)".into(),
|
|
|
|
|
}
|
2026-05-11 14:32:22 +00:00
|
|
|
}
|
2026-05-11 21:46:33 +00:00
|
|
|
None => match &w.target.path {
|
|
|
|
|
Some(p) => format!("**{p}** — page not in index"),
|
|
|
|
|
None => "(unresolved link)".into(),
|
|
|
|
|
},
|
2026-05-11 00:27:06 +00:00
|
|
|
};
|
|
|
|
|
(body, w.span)
|
|
|
|
|
}
|
|
|
|
|
InlineNode::ExternalLink(e) => (format!("[{0}]({0})", e.url), e.span),
|
|
|
|
|
InlineNode::RawUrl(u) => (format!("<{0}>", u.url), u.span),
|
|
|
|
|
_ => return Ok(None),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(Some(Hover {
|
|
|
|
|
contents: HoverContents::Markup(MarkupContent {
|
|
|
|
|
kind: MarkupKind::Markdown,
|
|
|
|
|
value: markdown,
|
|
|
|
|
}),
|
|
|
|
|
range: Some(span_to_lsp_range(&hover_span, &doc.text, utf8)),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn completion(&self, params: CompletionParams) -> LspResult<Option<CompletionResponse>> {
|
|
|
|
|
let uri = ¶ms.text_document_position.text_document.uri;
|
|
|
|
|
let Some(doc) = self.documents.get(uri) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
let (line, col) =
|
|
|
|
|
nav::lsp_to_byte_pos(params.text_document_position.position, &doc.text, utf8);
|
|
|
|
|
// Only fire completion inside an unterminated `[[ ... ` run.
|
|
|
|
|
if !is_inside_wikilink(&doc.text, line, col) {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
2026-05-11 14:32:22 +00:00
|
|
|
let Some(wiki) = self.wiki_for_uri(uri) else {
|
|
|
|
|
return Ok(Some(CompletionResponse::Array(Vec::new())));
|
|
|
|
|
};
|
|
|
|
|
let idx = wiki.index.read().expect("index lock poisoned");
|
2026-05-11 00:27:06 +00:00
|
|
|
let items: Vec<CompletionItem> = idx
|
|
|
|
|
.page_names()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|name| CompletionItem {
|
|
|
|
|
label: name.clone(),
|
|
|
|
|
kind: Some(CompletionItemKind::FILE),
|
|
|
|
|
insert_text: Some(name),
|
|
|
|
|
..CompletionItem::default()
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
Ok(Some(CompletionResponse::Array(items)))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 16:27:39 +00:00
|
|
|
async fn rename(&self, params: RenameParams) -> LspResult<Option<WorkspaceEdit>> {
|
|
|
|
|
let uri = params.text_document_position.text_document.uri;
|
|
|
|
|
let pos = params.text_document_position.position;
|
|
|
|
|
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
|
|
|
|
|
// If the cursor sits on a wikilink, rename the *linked* page;
|
|
|
|
|
// otherwise rename the buffer's own file. Closed-doc reads happen
|
|
|
|
|
// inside `compute_rename` via `read_or_open`.
|
|
|
|
|
let target_uri = self.rename_target_uri(&uri, pos).unwrap_or(uri);
|
|
|
|
|
let edit = rename::compute_rename(self, target_uri, params.new_name, utf8).await;
|
|
|
|
|
Ok(edit)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn execute_command(
|
|
|
|
|
&self,
|
|
|
|
|
params: ExecuteCommandParams,
|
|
|
|
|
) -> LspResult<Option<serde_json::Value>> {
|
|
|
|
|
match commands::execute(self, ¶ms.command, params.arguments).await {
|
2026-05-11 17:41:11 +00:00
|
|
|
Ok(Some(commands::CommandOutcome::Edit(edit))) => {
|
2026-05-11 16:27:39 +00:00
|
|
|
if let Err(e) = self.client.apply_edit(edit).await {
|
|
|
|
|
self.client
|
|
|
|
|
.log_message(
|
|
|
|
|
MessageType::ERROR,
|
|
|
|
|
format!("nuwiki: apply_edit failed — {e}"),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
2026-05-11 17:41:11 +00:00
|
|
|
Ok(Some(commands::CommandOutcome::Value(v))) => Ok(Some(v)),
|
2026-05-11 16:27:39 +00:00
|
|
|
Ok(None) => Ok(None),
|
|
|
|
|
Err(msg) => {
|
|
|
|
|
self.client
|
|
|
|
|
.log_message(MessageType::ERROR, format!("nuwiki: {msg}"))
|
|
|
|
|
.await;
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:57:05 +00:00
|
|
|
async fn folding_range(
|
|
|
|
|
&self,
|
|
|
|
|
params: FoldingRangeParams,
|
|
|
|
|
) -> LspResult<Option<Vec<FoldingRange>>> {
|
|
|
|
|
let uri = ¶ms.text_document.uri;
|
|
|
|
|
let Some(doc) = self.documents.get(uri) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let total = folding::line_count(&doc.text);
|
|
|
|
|
Ok(Some(folding::folding_ranges(&doc.ast, total)))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 00:27:06 +00:00
|
|
|
async fn symbol(
|
|
|
|
|
&self,
|
|
|
|
|
params: WorkspaceSymbolParams,
|
|
|
|
|
) -> LspResult<Option<Vec<LspSymbolInformation>>> {
|
|
|
|
|
let q = params.query.to_lowercase();
|
2026-05-11 14:32:22 +00:00
|
|
|
let wikis = self.wikis.read().expect("wikis lock poisoned").clone();
|
2026-05-11 00:27:06 +00:00
|
|
|
let mut out = Vec::new();
|
2026-05-11 14:32:22 +00:00
|
|
|
for wiki in &wikis {
|
|
|
|
|
let idx = wiki.index.read().expect("index lock poisoned");
|
|
|
|
|
for page in idx.pages_by_uri.values() {
|
|
|
|
|
for h in &page.headings {
|
|
|
|
|
if q.is_empty() || h.title.to_lowercase().contains(&q) {
|
|
|
|
|
#[allow(deprecated)]
|
|
|
|
|
out.push(LspSymbolInformation {
|
|
|
|
|
name: h.title.clone(),
|
|
|
|
|
kind: SymbolKind::STRING,
|
|
|
|
|
tags: None,
|
|
|
|
|
deprecated: None,
|
|
|
|
|
location: Location {
|
|
|
|
|
uri: page.uri.clone(),
|
|
|
|
|
range: span_to_lsp_range_no_text(&h.span),
|
|
|
|
|
},
|
|
|
|
|
container_name: Some(page.name.clone()),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-11 00:27:06 +00:00
|
|
|
}
|
2026-05-30 18:35:40 +00:00
|
|
|
// Surface tags too, named as `:tag:` so editors
|
2026-05-11 14:54:55 +00:00
|
|
|
// can distinguish them from headings at a glance.
|
|
|
|
|
for t in &page.tags {
|
|
|
|
|
if q.is_empty() || t.name.to_lowercase().contains(&q) {
|
|
|
|
|
#[allow(deprecated)]
|
|
|
|
|
out.push(LspSymbolInformation {
|
|
|
|
|
name: format!(":{}:", t.name),
|
|
|
|
|
kind: SymbolKind::PROPERTY,
|
|
|
|
|
tags: None,
|
|
|
|
|
deprecated: None,
|
|
|
|
|
location: Location {
|
|
|
|
|
uri: page.uri.clone(),
|
|
|
|
|
range: span_to_lsp_range_no_text(&t.span),
|
|
|
|
|
},
|
|
|
|
|
container_name: Some(page.name.clone()),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-11 00:27:06 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(Some(out))
|
|
|
|
|
}
|
2026-05-10 21:14:01 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-12 01:44:26 +00:00
|
|
|
/// Same as `synthesise_page_uri` but anchored on `source_uri`'s parent
|
|
|
|
|
/// directory and the conventional `.wiki` extension. Used when no
|
|
|
|
|
/// `Wiki` is registered (server received no `initializationOptions`
|
|
|
|
|
/// and no `workspace_folders`) — `<CR>` on a wikilink still opens a
|
|
|
|
|
/// future page next to the current file.
|
|
|
|
|
fn synthesise_page_uri_next_to(source_uri: &Url, path: &str) -> Option<Url> {
|
|
|
|
|
let source_path = source_uri.to_file_path().ok()?;
|
|
|
|
|
let dir = source_path.parent()?;
|
|
|
|
|
let mut p = dir.to_path_buf();
|
|
|
|
|
for segment in path.split('/') {
|
|
|
|
|
if !segment.is_empty() {
|
|
|
|
|
p.push(segment);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let stem = p
|
|
|
|
|
.file_name()
|
|
|
|
|
.map(|s| s.to_string_lossy().into_owned())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
if !stem.is_empty() && !stem.contains('.') {
|
|
|
|
|
p.set_file_name(format!("{stem}.wiki"));
|
|
|
|
|
}
|
|
|
|
|
Url::from_file_path(p).ok()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 01:22:35 +00:00
|
|
|
/// Build a `<wiki_root>/<page><file_extension>` URI for a wikilink that
|
|
|
|
|
/// doesn't (yet) resolve to an indexed page. Used by
|
|
|
|
|
/// `Backend::resolve_target_uri` so `<CR>` on `[[Newish]]` opens the
|
|
|
|
|
/// future page in the editor — saving creates it. Slashes inside `path`
|
|
|
|
|
/// are honoured for subdirectory pages.
|
|
|
|
|
fn synthesise_page_uri(cfg: &crate::config::WikiConfig, path: &str) -> Option<Url> {
|
|
|
|
|
if cfg.root.as_os_str().is_empty() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let mut p = cfg.root.clone();
|
|
|
|
|
for segment in path.split('/') {
|
|
|
|
|
if !segment.is_empty() {
|
|
|
|
|
p.push(segment);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let ext = cfg.file_extension.trim_start_matches('.');
|
|
|
|
|
if !ext.is_empty() {
|
2026-05-26 22:00:03 -03:00
|
|
|
let current_ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
|
|
|
|
|
if current_ext != ext {
|
|
|
|
|
let stem = p
|
|
|
|
|
.file_name()
|
|
|
|
|
.map(|s| s.to_string_lossy().into_owned())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
if !stem.is_empty() {
|
|
|
|
|
p.set_file_name(format!("{stem}.{ext}"));
|
|
|
|
|
}
|
2026-05-12 01:22:35 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Url::from_file_path(p).ok()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:17:04 +00:00
|
|
|
/// File-operation capability for `.wiki` files. Tells the client to
|
|
|
|
|
/// notify us before/after the user renames or deletes files via the file
|
|
|
|
|
/// explorer (or any other out-of-buffer trigger). Filters scope these
|
|
|
|
|
/// notifications to wiki sources so we don't see every C++ rename in a
|
|
|
|
|
/// polyglot workspace.
|
|
|
|
|
fn wiki_file_operations() -> WorkspaceFileOperationsServerCapabilities {
|
|
|
|
|
let filters = vec![
|
|
|
|
|
FileOperationFilter {
|
|
|
|
|
scheme: Some("file".into()),
|
|
|
|
|
pattern: FileOperationPattern {
|
|
|
|
|
glob: "**/*.wiki".into(),
|
|
|
|
|
matches: Some(FileOperationPatternKind::File),
|
|
|
|
|
options: None,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
FileOperationFilter {
|
|
|
|
|
scheme: Some("file".into()),
|
|
|
|
|
pattern: FileOperationPattern {
|
|
|
|
|
glob: "**/*.md".into(),
|
|
|
|
|
matches: Some(FileOperationPatternKind::File),
|
|
|
|
|
options: None,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
let opts = || FileOperationRegistrationOptions {
|
|
|
|
|
filters: filters.clone(),
|
|
|
|
|
};
|
|
|
|
|
WorkspaceFileOperationsServerCapabilities {
|
|
|
|
|
did_create: None,
|
|
|
|
|
will_create: None,
|
|
|
|
|
did_rename: Some(opts()),
|
|
|
|
|
will_rename: None,
|
|
|
|
|
did_delete: Some(opts()),
|
|
|
|
|
will_delete: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 21:14:01 +00:00
|
|
|
fn pack_data(flat: &[u32]) -> Vec<tower_lsp::lsp_types::SemanticToken> {
|
|
|
|
|
flat.chunks_exact(5)
|
|
|
|
|
.map(|c| tower_lsp::lsp_types::SemanticToken {
|
|
|
|
|
delta_line: c[0],
|
|
|
|
|
delta_start: c[1],
|
|
|
|
|
length: c[2],
|
|
|
|
|
token_type: c[3],
|
|
|
|
|
token_modifiers_bitset: c[4],
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
2026-05-10 19:44:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Pure helpers =====
|
|
|
|
|
|
|
|
|
|
/// Convert one of our `Position` values into an LSP `Position`.
|
|
|
|
|
///
|
|
|
|
|
/// In UTF-8 encoding mode (negotiated during `initialize`) the byte column
|
|
|
|
|
/// is the LSP `character`. In UTF-16 mode the LSP `character` is a UTF-16
|
|
|
|
|
/// code-unit offset, so we walk the source line to translate.
|
|
|
|
|
pub fn to_lsp_position(
|
|
|
|
|
pos: &nuwiki_core::ast::Position,
|
|
|
|
|
text: &str,
|
|
|
|
|
utf8: bool,
|
|
|
|
|
) -> tower_lsp::lsp_types::Position {
|
|
|
|
|
let character = if utf8 {
|
|
|
|
|
pos.column
|
|
|
|
|
} else {
|
|
|
|
|
utf16_column(text, pos.line, pos.column)
|
|
|
|
|
};
|
|
|
|
|
tower_lsp::lsp_types::Position {
|
|
|
|
|
line: pos.line,
|
|
|
|
|
character,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn to_lsp_range(span: &Span, text: &str, utf8: bool) -> tower_lsp::lsp_types::Range {
|
|
|
|
|
tower_lsp::lsp_types::Range {
|
|
|
|
|
start: to_lsp_position(&span.start, text, utf8),
|
|
|
|
|
end: to_lsp_position(&span.end, text, utf8),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Convert a byte-offset column on `line` into the corresponding UTF-16
|
|
|
|
|
/// code-unit count from the start of that line. Out-of-range inputs are
|
|
|
|
|
/// clamped to the line length, matching the LSP spec's "if the character
|
|
|
|
|
/// value is greater than the length of the line it is clipped to the
|
|
|
|
|
/// length".
|
|
|
|
|
fn utf16_column(text: &str, line: u32, byte_col: u32) -> u32 {
|
|
|
|
|
let mut current_line = 0u32;
|
|
|
|
|
let mut line_start = 0usize;
|
|
|
|
|
let bytes = text.as_bytes();
|
|
|
|
|
while current_line < line && line_start < bytes.len() {
|
|
|
|
|
if let Some(nl) = bytes[line_start..].iter().position(|b| *b == b'\n') {
|
|
|
|
|
line_start += nl + 1;
|
|
|
|
|
current_line += 1;
|
|
|
|
|
} else {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let line_end = bytes[line_start..]
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|b| *b == b'\n')
|
|
|
|
|
.map(|i| line_start + i)
|
|
|
|
|
.unwrap_or(bytes.len());
|
|
|
|
|
let target = (line_start + byte_col as usize).min(line_end);
|
|
|
|
|
text[line_start..target]
|
|
|
|
|
.chars()
|
|
|
|
|
.map(char::len_utf16)
|
|
|
|
|
.sum::<usize>() as u32
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 18:35:40 +00:00
|
|
|
/// Backward-compat wrapper kept for the legacy test surface. New call sites
|
2026-05-11 14:32:22 +00:00
|
|
|
/// should use `collect_diagnostics` so they can opt into link-health
|
2026-05-30 18:35:40 +00:00
|
|
|
/// and other sources.
|
2026-05-10 19:44:44 +00:00
|
|
|
pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec<Diagnostic> {
|
2026-05-11 20:49:32 +00:00
|
|
|
collect_diagnostics(
|
|
|
|
|
ast,
|
|
|
|
|
text,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
&DiagnosticConfig::default(),
|
|
|
|
|
utf8,
|
|
|
|
|
)
|
2026-05-11 14:32:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 18:35:40 +00:00
|
|
|
/// Composable diagnostic collector.
|
2026-05-11 14:32:22 +00:00
|
|
|
///
|
|
|
|
|
/// Sources, each gated by `cfg`:
|
|
|
|
|
///
|
|
|
|
|
/// 1. Parse errors (always on).
|
2026-05-30 18:35:40 +00:00
|
|
|
/// 2. Broken-link warnings — emitted when an index, source
|
2026-05-11 20:49:32 +00:00
|
|
|
/// page name, and `link_severity != Off` are all available.
|
2026-05-11 14:32:22 +00:00
|
|
|
pub fn collect_diagnostics(
|
|
|
|
|
ast: &DocumentNode,
|
|
|
|
|
text: &str,
|
2026-05-11 20:49:32 +00:00
|
|
|
uri: Option<&Url>,
|
2026-05-11 14:32:22 +00:00
|
|
|
index: Option<&WorkspaceIndex>,
|
|
|
|
|
page_name: Option<&str>,
|
|
|
|
|
cfg: &DiagnosticConfig,
|
|
|
|
|
utf8: bool,
|
|
|
|
|
) -> Vec<Diagnostic> {
|
2026-05-10 19:44:44 +00:00
|
|
|
let mut out = Vec::new();
|
|
|
|
|
walk_blocks_for_errors(&ast.children, text, utf8, &mut out);
|
2026-05-11 20:49:32 +00:00
|
|
|
if let (Some(idx), Some(name)) = (index, page_name) {
|
|
|
|
|
out.extend(diagnostics::link_health(
|
|
|
|
|
ast,
|
|
|
|
|
text,
|
|
|
|
|
uri,
|
|
|
|
|
idx,
|
|
|
|
|
name,
|
|
|
|
|
cfg.link_severity,
|
|
|
|
|
utf8,
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-05-10 19:44:44 +00:00
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn walk_blocks_for_errors(blocks: &[BlockNode], text: &str, utf8: bool, out: &mut Vec<Diagnostic>) {
|
|
|
|
|
for block in blocks {
|
|
|
|
|
match block {
|
|
|
|
|
BlockNode::Error(err) => out.push(error_to_diagnostic(err, text, utf8)),
|
|
|
|
|
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
|
|
|
|
walk_blocks_for_errors(children, text, utf8, out);
|
|
|
|
|
}
|
|
|
|
|
BlockNode::List(ListNode { items, .. }) => {
|
|
|
|
|
for item in items {
|
|
|
|
|
walk_list_item_for_errors(item, text, utf8, out);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn walk_list_item_for_errors(
|
|
|
|
|
item: &ListItemNode,
|
|
|
|
|
text: &str,
|
|
|
|
|
utf8: bool,
|
|
|
|
|
out: &mut Vec<Diagnostic>,
|
|
|
|
|
) {
|
|
|
|
|
if let Some(sub) = &item.sublist {
|
|
|
|
|
for sub_item in &sub.items {
|
|
|
|
|
walk_list_item_for_errors(sub_item, text, utf8, out);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let _ = (text, utf8, out);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn error_to_diagnostic(err: &ErrorNode, text: &str, utf8: bool) -> Diagnostic {
|
|
|
|
|
Diagnostic {
|
|
|
|
|
range: to_lsp_range(&err.span, text, utf8),
|
|
|
|
|
severity: Some(DiagnosticSeverity::ERROR),
|
|
|
|
|
message: err.message.clone(),
|
|
|
|
|
source: Some("nuwiki".into()),
|
|
|
|
|
..Diagnostic::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn headings_to_symbols(ast: &DocumentNode, text: &str, utf8: bool) -> Vec<DocumentSymbol> {
|
|
|
|
|
let headings: Vec<&HeadingNode> = ast
|
|
|
|
|
.children
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|b| match b {
|
|
|
|
|
BlockNode::Heading(h) => Some(h),
|
|
|
|
|
_ => None,
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
#[allow(deprecated)]
|
|
|
|
|
fn build<'a>(
|
|
|
|
|
headings: &mut std::slice::Iter<'a, &'a HeadingNode>,
|
|
|
|
|
text: &str,
|
|
|
|
|
utf8: bool,
|
|
|
|
|
parent_level: u8,
|
|
|
|
|
peeked: &mut Option<&'a HeadingNode>,
|
|
|
|
|
) -> Vec<DocumentSymbol> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
loop {
|
|
|
|
|
let h = match peeked.take().or_else(|| headings.next().copied()) {
|
|
|
|
|
Some(h) => h,
|
|
|
|
|
None => return out,
|
|
|
|
|
};
|
|
|
|
|
if h.level <= parent_level {
|
|
|
|
|
*peeked = Some(h);
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
let children = build(headings, text, utf8, h.level, peeked);
|
|
|
|
|
let title = inline_to_text(&h.children);
|
|
|
|
|
out.push(DocumentSymbol {
|
|
|
|
|
name: if title.is_empty() {
|
|
|
|
|
"(empty heading)".into()
|
|
|
|
|
} else {
|
|
|
|
|
title
|
|
|
|
|
},
|
|
|
|
|
detail: None,
|
|
|
|
|
kind: SymbolKind::STRING,
|
|
|
|
|
tags: None,
|
|
|
|
|
deprecated: None,
|
|
|
|
|
range: to_lsp_range(&h.span, text, utf8),
|
|
|
|
|
selection_range: to_lsp_range(&h.span, text, utf8),
|
|
|
|
|
children: if children.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(children)
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut iter = headings.iter();
|
|
|
|
|
let mut peeked: Option<&HeadingNode> = None;
|
|
|
|
|
build(&mut iter, text, utf8, 0, &mut peeked)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn inline_to_text(inlines: &[InlineNode]) -> String {
|
|
|
|
|
let mut out = String::new();
|
|
|
|
|
inline_to_text_into(inlines, &mut out);
|
|
|
|
|
out.trim().to_string()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn inline_to_text_into(inlines: &[InlineNode], out: &mut String) {
|
|
|
|
|
for n in inlines {
|
|
|
|
|
match n {
|
|
|
|
|
InlineNode::Text(t) => out.push_str(&t.content),
|
|
|
|
|
InlineNode::Bold(b) => inline_to_text_into(&b.children, out),
|
|
|
|
|
InlineNode::Italic(i) => inline_to_text_into(&i.children, out),
|
|
|
|
|
InlineNode::BoldItalic(bi) => inline_to_text_into(&bi.children, out),
|
|
|
|
|
InlineNode::Strikethrough(s) => inline_to_text_into(&s.children, out),
|
|
|
|
|
InlineNode::Code(c) => out.push_str(&c.content),
|
|
|
|
|
InlineNode::Superscript(s) => inline_to_text_into(&s.children, out),
|
|
|
|
|
InlineNode::Subscript(s) => inline_to_text_into(&s.children, out),
|
|
|
|
|
InlineNode::MathInline(m) => out.push_str(&m.content),
|
|
|
|
|
InlineNode::Keyword(k) => out.push_str(keyword_str(k.keyword)),
|
|
|
|
|
InlineNode::Color(c) => inline_to_text_into(&c.children, out),
|
|
|
|
|
InlineNode::WikiLink(w) => match &w.description {
|
|
|
|
|
Some(d) => inline_to_text_into(d, out),
|
|
|
|
|
None => {
|
|
|
|
|
if let Some(p) = &w.target.path {
|
|
|
|
|
out.push_str(p);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
InlineNode::ExternalLink(e) => match &e.description {
|
|
|
|
|
Some(d) => inline_to_text_into(d, out),
|
|
|
|
|
None => out.push_str(&e.url),
|
|
|
|
|
},
|
|
|
|
|
InlineNode::Transclusion(t) => out.push_str(t.alt.as_deref().unwrap_or(&t.url)),
|
|
|
|
|
InlineNode::RawUrl(r) => out.push_str(&r.url),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn keyword_str(k: nuwiki_core::ast::Keyword) -> &'static str {
|
|
|
|
|
match k {
|
|
|
|
|
nuwiki_core::ast::Keyword::Todo => "TODO",
|
|
|
|
|
nuwiki_core::ast::Keyword::Done => "DONE",
|
|
|
|
|
nuwiki_core::ast::Keyword::Started => "STARTED",
|
|
|
|
|
nuwiki_core::ast::Keyword::Fixme => "FIXME",
|
|
|
|
|
nuwiki_core::ast::Keyword::Fixed => "FIXED",
|
|
|
|
|
nuwiki_core::ast::Keyword::Xxx => "XXX",
|
2026-05-28 22:05:23 -03:00
|
|
|
nuwiki_core::ast::Keyword::Stopped => "STOPPED",
|
2026-05-10 19:44:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-11 00:27:06 +00:00
|
|
|
|
|
|
|
|
// ===== Workspace + navigation helpers =====
|
2026-05-11 14:32:22 +00:00
|
|
|
//
|
2026-05-30 18:35:40 +00:00
|
|
|
// `workspace_root_from_params` logic now lives in
|
|
|
|
|
// `Config::from_init_params` (see `config.rs`) so workspace
|
2026-05-11 14:32:22 +00:00
|
|
|
// folders and `root_uri` are handled alongside `initializationOptions`.
|
2026-05-11 00:27:06 +00:00
|
|
|
|
|
|
|
|
/// Background workspace scan (P8). Walks every `.wiki` file under `root`,
|
|
|
|
|
/// parses it, and feeds it into the shared index. Wraps the work in a
|
|
|
|
|
/// `window/workDoneProgress` begin/end pair so editors can show progress.
|
|
|
|
|
async fn index_workspace(
|
|
|
|
|
root: PathBuf,
|
|
|
|
|
index: Arc<RwLock<WorkspaceIndex>>,
|
|
|
|
|
client: Client,
|
|
|
|
|
registry: Arc<SyntaxRegistry>,
|
|
|
|
|
) {
|
|
|
|
|
let token = ProgressToken::String("nuwiki-index".into());
|
|
|
|
|
// Best-effort: if the client doesn't pre-create progress tokens we just
|
|
|
|
|
// send the begin/end directly. tower-lsp permits this.
|
|
|
|
|
let _ = client
|
|
|
|
|
.send_notification::<tower_lsp::lsp_types::notification::Progress>(ProgressParams {
|
|
|
|
|
token: token.clone(),
|
|
|
|
|
value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(WorkDoneProgressBegin {
|
|
|
|
|
title: "Indexing wiki".into(),
|
|
|
|
|
cancellable: Some(false),
|
|
|
|
|
message: None,
|
|
|
|
|
percentage: None,
|
|
|
|
|
})),
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
let plugin = registry.get("vimwiki");
|
|
|
|
|
let files = index::walk_wiki_files(&root);
|
|
|
|
|
for path in files {
|
|
|
|
|
let Ok(text) = std::fs::read_to_string(&path) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let Some(plugin) = plugin else { continue };
|
|
|
|
|
let ast = plugin.parse(&text);
|
|
|
|
|
let Ok(uri) = Url::from_file_path(&path) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if let Ok(mut idx) = index.write() {
|
|
|
|
|
idx.upsert(uri, &ast);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let _ = client
|
|
|
|
|
.send_notification::<tower_lsp::lsp_types::notification::Progress>(ProgressParams {
|
|
|
|
|
token,
|
|
|
|
|
value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd {
|
|
|
|
|
message: Some("done".into()),
|
|
|
|
|
})),
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 22:47:46 -03:00
|
|
|
/// After the initial workspace scan for `wiki_id` completes, recompute and
|
|
|
|
|
/// re-publish diagnostics for every open document that lives in that wiki.
|
|
|
|
|
/// Documents opened before the index was populated would otherwise show
|
|
|
|
|
/// stale link-health diagnostics flagging every cross-page link as
|
|
|
|
|
/// missing-page (the lookups happened against a near-empty index).
|
|
|
|
|
async fn refresh_open_diagnostics_for_wiki(
|
|
|
|
|
wiki_id: crate::wiki::WikiId,
|
|
|
|
|
documents: Arc<DashMap<Url, DocumentState>>,
|
|
|
|
|
wikis: Arc<RwLock<Vec<Wiki>>>,
|
|
|
|
|
config: Arc<RwLock<Config>>,
|
|
|
|
|
client: Client,
|
|
|
|
|
use_utf8: Arc<AtomicBool>,
|
|
|
|
|
) {
|
|
|
|
|
// Snapshot the open URIs first so we don't hold a DashMap iterator
|
|
|
|
|
// across the await below.
|
|
|
|
|
let uris: Vec<Url> = documents.iter().map(|e| e.key().clone()).collect();
|
|
|
|
|
|
|
|
|
|
for uri in uris {
|
|
|
|
|
// Re-check wiki ownership inside the loop — a workspace/configuration
|
|
|
|
|
// change could re-shape the wikis list mid-refresh.
|
|
|
|
|
let Some(wiki) = ({
|
|
|
|
|
let snapshot = wikis.read().ok();
|
|
|
|
|
snapshot.and_then(|w| w.iter().find(|w| w.id == wiki_id).cloned())
|
|
|
|
|
}) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
if !wiki.contains(&uri) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Take a snapshot of the live doc state. If it's gone (closed mid-scan)
|
|
|
|
|
// skip it.
|
|
|
|
|
let Some((ast, text, version)) = ({
|
|
|
|
|
documents
|
|
|
|
|
.get(&uri)
|
|
|
|
|
.map(|d| (d.ast.clone(), d.text.clone(), d.version))
|
|
|
|
|
}) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let utf8 = use_utf8.load(Ordering::Relaxed);
|
|
|
|
|
let diagnostics = {
|
|
|
|
|
let cfg = config.read().expect("config lock poisoned");
|
|
|
|
|
let page_name = index::page_name_from_uri(&uri, Some(&wiki.config.root));
|
|
|
|
|
let idx_guard = wiki.index.read().expect("index lock poisoned");
|
|
|
|
|
collect_diagnostics(
|
|
|
|
|
&ast,
|
|
|
|
|
&text,
|
|
|
|
|
Some(&uri),
|
|
|
|
|
Some(&idx_guard),
|
|
|
|
|
Some(&page_name),
|
|
|
|
|
&cfg.diagnostic,
|
|
|
|
|
utf8,
|
|
|
|
|
)
|
|
|
|
|
};
|
|
|
|
|
client
|
|
|
|
|
.publish_diagnostics(uri, diagnostics, Some(version))
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 00:27:06 +00:00
|
|
|
fn span_to_lsp_range(span: &Span, text: &str, utf8: bool) -> Range {
|
|
|
|
|
to_lsp_range(span, text, utf8)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn span_to_lsp_range_no_text(span: &Span) -> Range {
|
|
|
|
|
Range {
|
|
|
|
|
start: tower_lsp::lsp_types::Position {
|
|
|
|
|
line: span.start.line,
|
|
|
|
|
character: span.start.column,
|
|
|
|
|
},
|
|
|
|
|
end: tower_lsp::lsp_types::Position {
|
|
|
|
|
line: span.end.line,
|
|
|
|
|
character: span.end.column,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn zero_range() -> Range {
|
|
|
|
|
Range {
|
|
|
|
|
start: tower_lsp::lsp_types::Position {
|
|
|
|
|
line: 0,
|
|
|
|
|
character: 0,
|
|
|
|
|
},
|
|
|
|
|
end: tower_lsp::lsp_types::Position {
|
|
|
|
|
line: 0,
|
|
|
|
|
character: 0,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn heading_range_in(idx: &WorkspaceIndex, uri: &Url, anchor: &str, _utf8: bool) -> Option<Range> {
|
|
|
|
|
let page = idx.page(uri)?;
|
2026-05-11 14:54:55 +00:00
|
|
|
// Headings win over tags when both match — same precedence as
|
|
|
|
|
// vimwiki: heading anchors are the "canonical" target shape.
|
2026-05-26 22:29:30 -03:00
|
|
|
if let Some(h) = page.find_heading_by_anchor(anchor) {
|
2026-05-11 14:54:55 +00:00
|
|
|
return Some(span_to_lsp_range_no_text(&h.span));
|
|
|
|
|
}
|
2026-05-30 18:35:40 +00:00
|
|
|
// Tags-as-anchors. `[[Page#tag-name]]` lands on the
|
2026-05-11 14:54:55 +00:00
|
|
|
// matching `TagNode` on the target page.
|
|
|
|
|
let t = page.tags.iter().find(|t| t.name == anchor)?;
|
|
|
|
|
Some(span_to_lsp_range_no_text(&t.span))
|
2026-05-11 00:27:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_inside_wikilink(text: &str, line: u32, byte_col: u32) -> bool {
|
|
|
|
|
// Scan the current line for the most recent unclosed `[[` before the
|
|
|
|
|
// cursor.
|
|
|
|
|
let mut cur = 0u32;
|
|
|
|
|
let mut current_line = 0u32;
|
|
|
|
|
let bytes = text.as_bytes();
|
|
|
|
|
while current_line < line && cur < bytes.len() as u32 {
|
|
|
|
|
if bytes[cur as usize] == b'\n' {
|
|
|
|
|
current_line += 1;
|
|
|
|
|
}
|
|
|
|
|
cur += 1;
|
|
|
|
|
}
|
|
|
|
|
let line_start = cur as usize;
|
|
|
|
|
let limit = (line_start + byte_col as usize).min(bytes.len());
|
|
|
|
|
let segment = &bytes[line_start..limit];
|
|
|
|
|
// Find last "[[" and check no "]]" closes it before cursor.
|
|
|
|
|
let mut open_at: Option<usize> = None;
|
|
|
|
|
let mut i = 0;
|
|
|
|
|
while i + 1 < segment.len() {
|
|
|
|
|
if segment[i] == b'[' && segment[i + 1] == b'[' {
|
|
|
|
|
open_at = Some(i);
|
|
|
|
|
i += 2;
|
|
|
|
|
} else if segment[i] == b']' && segment[i + 1] == b']' {
|
|
|
|
|
open_at = None;
|
|
|
|
|
i += 2;
|
|
|
|
|
} else {
|
|
|
|
|
i += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
open_at.is_some()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn preview_for(page: &IndexedPage, anchor: Option<&str>) -> String {
|
|
|
|
|
let mut buf = String::new();
|
|
|
|
|
let title = page.title.as_deref().unwrap_or(&page.name);
|
|
|
|
|
buf.push_str("**");
|
|
|
|
|
buf.push_str(title);
|
|
|
|
|
buf.push_str("**\n\n");
|
|
|
|
|
if let Some(anchor) = anchor {
|
2026-05-26 22:29:30 -03:00
|
|
|
if let Some(h) = page.find_heading_by_anchor(anchor) {
|
2026-05-11 00:27:06 +00:00
|
|
|
buf.push_str(&format!("§ {}\n", h.title));
|
|
|
|
|
} else {
|
|
|
|
|
buf.push_str(&format!("(anchor `{anchor}` not found)\n"));
|
|
|
|
|
}
|
|
|
|
|
} else if let Some(h) = page.headings.first() {
|
|
|
|
|
buf.push_str(&format!("§ {}\n", h.title));
|
|
|
|
|
}
|
|
|
|
|
if !page.headings.is_empty() {
|
|
|
|
|
buf.push_str("\nOutline:\n");
|
|
|
|
|
for h in page.headings.iter().take(8) {
|
|
|
|
|
let indent = " ".repeat((h.level.saturating_sub(1)) as usize);
|
|
|
|
|
buf.push_str(&format!("{indent}- {}\n", h.title));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
buf
|
|
|
|
|
}
|