Files
nuwiki/crates/nuwiki-lsp/src/lib.rs
T
gffranco 21b485c91b Remove stale phase/spec references from code comments
Strip "Phase N", "SPEC §X.Y", and "v1.x" planning labels from comments
across the Rust crates and editor layers now that those tracking
artifacts are gone. Comments only — no logic, strings, or test names
touched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:25:51 -03:00

1533 lines
58 KiB
Rust

//! LSP protocol bridge for nuwiki.
//!
//! `tower-lsp` server that maintains a `DocumentStore` plus a
//! workspace-wide index and exposes every LSP method:
//!
//! - `initialize` / `initialized` / `shutdown`
//! - `textDocument/didOpen`, `didChange` (full sync), `didClose`
//! - `textDocument/publishDiagnostics` from `BlockNode::Error` nodes
//! - `textDocument/documentSymbol` — nested outline from headings
//! - `textDocument/semanticTokens/full` + `/range`
//! - `textDocument/definition`, `references`, `hover`
//! - `textDocument/completion` (trigger: `[`)
//! - `workspace/symbol` — search across indexed headings
//!
//! Position encoding is negotiated as UTF-8 when the client supports LSP
//! 3.17+ encodings. When the client only supports the legacy
//! UTF-16 default, positions get translated through a per-line lookup.
//!
//! Re-parses are full per change (no incremental parsing).
//! The workspace scan runs in a background tokio task spawned from
//! `initialize` with `window/workDoneProgress` begin/end events.
pub mod commands;
pub mod config;
pub mod diagnostics;
pub mod diary;
pub mod edits;
pub mod export;
pub mod folding;
pub mod index;
pub mod nav;
pub mod rename;
pub mod semantic_tokens;
pub mod wiki;
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use dashmap::DashMap;
use tokio::io::{AsyncRead, AsyncWrite};
use tower_lsp::jsonrpc::Result as LspResult;
use tower_lsp::lsp_types::{
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
DeleteFilesParams, Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams,
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
DidSaveTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
ExecuteCommandOptions, ExecuteCommandParams, FileOperationFilter, FileOperationPattern,
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,
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,
};
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;
use crate::config::{Config, DiagnosticConfig};
use crate::index::{IndexedPage, WorkspaceIndex};
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,
}
/// 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,
/// Last version we observed from the client. Held even
/// though nothing reads it back yet.
#[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>,
/// Per-wiki state. Vector holds one entry until multi-wiki
/// support lets users add more.
wikis: Arc<RwLock<Vec<Wiki>>>,
/// Server config received via `initializationOptions` and refreshed
/// via `workspace/didChangeConfiguration` (P18).
config: Arc<RwLock<Config>>,
}
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)),
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()
}
/// Used by workspace-level commands (`checkLinks`, `findOrphans`)
/// when no URI is supplied — they fall back to the first registered wiki.
fn default_wiki(&self) -> Option<Wiki> {
let wikis = self.wikis.read().ok()?;
wikis.first().cloned()
}
/// 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()
}
/// 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))
}
/// 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.
///
/// - `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.
/// - `LinkKind::Interwiki` — routes to the wiki referenced by
/// `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.
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()?;
if let Ok(idx) = target_wiki.index.read() {
let normalized = idx.strip_link_extension(path);
if let Some(uri) = idx.pages_by_name.get(normalized).cloned() {
return Some(uri);
}
}
synthesise_page_uri(&target_wiki.config, path)
}
LinkKind::Wiki => {
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);
}
}
let path = target.path.as_deref()?;
return synthesise_page_uri(&source_wiki.config, path);
}
// 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.
let path = target.path.as_deref()?;
synthesise_page_uri_next_to(source_uri, path)
}
_ => {
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)
}
/// 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
/// cross-document operations (`workspace/rename` and link
/// 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,
});
}
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?;
let plugin = self
.registry
.get("vimwiki")
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "vimwiki syntax plugin missing"))?;
let ast = plugin.parse(&text);
Ok(DocSnapshot {
text,
ast,
in_memory: false,
})
}
async fn update_document(&self, uri: Url, text: String, version: i32) {
// We always treat unknown buffers as vimwiki — if/when
// multi-syntax dispatch lands the pick should follow the URI's ext.
let plugin = match self.registry.get("vimwiki") {
Some(p) => p,
None => {
self.client
.log_message(MessageType::ERROR, "vimwiki plugin missing")
.await;
return;
}
};
let ast = plugin.parse(&text);
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,
Some(&uri),
Some(&idx_guard),
page_name.as_deref(),
&cfg.diagnostic,
utf8,
)
} else {
collect_diagnostics(&ast, &text, Some(&uri), None, None, &cfg.diagnostic, utf8)
};
diags
};
if let Some(wiki) = self.wiki_for_uri(&uri) {
let mut idx = wiki.index.write().expect("index lock poisoned");
idx.upsert(uri.clone(), &ast);
}
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> {
// Prefer UTF-8 if the client advertises support.
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.
};
// Build server config from initializationOptions (P18) and the
// workspace folders, then materialise the `Wiki` aggregate (P11).
let cfg = Config::from_init_params(&params);
*self.wikis.write().expect("wikis lock poisoned") = build_wikis(&cfg.wikis);
*self.config.write().expect("config lock poisoned") = cfg;
Ok(InitializeResult {
capabilities: ServerCapabilities {
position_encoding,
text_document_sync: Some(TextDocumentSyncCapability::Options(
TextDocumentSyncOptions {
open_close: Some(true),
change: Some(TextDocumentSyncKind::FULL),
will_save: None,
will_save_wait_until: None,
// `auto_export` listens on `did_save`. Asking
// for `include_text: false` since the document store
// already mirrors the live buffer.
save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
include_text: Some(false),
})),
},
)),
document_symbol_provider: Some(OneOf::Left(true)),
workspace_symbol_provider: Some(OneOf::Left(true)),
definition_provider: Some(OneOf::Left(true)),
references_provider: Some(OneOf::Left(true)),
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(),
}),
hover_provider: Some(HoverProviderCapability::Simple(true)),
completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec!["[".into()]),
..CompletionOptions::default()
}),
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)),
},
),
),
workspace: Some(WorkspaceServerCapabilities {
workspace_folders: None,
file_operations: Some(wiki_file_operations()),
}),
folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
..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;
// Kick off the background workspace scan (P8) — one task per wiki.
// 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).
let wikis = self.wikis.read().expect("wikis lock poisoned").clone();
for wiki in wikis {
if wiki.config.root.as_os_str().is_empty() {
continue;
}
let scan_client = self.client.clone();
let refresh_client = self.client.clone();
let registry = Arc::clone(&self.registry);
let index = Arc::clone(&wiki.index);
let root = wiki.config.root.clone();
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;
tokio::spawn(async move {
index_workspace(root, index, scan_client, registry).await;
refresh_open_diagnostics_for_wiki(
wiki_id,
documents,
wikis_arc,
config,
refresh_client,
use_utf8,
)
.await;
});
}
}
async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
// Apply the new settings to `Config`. Re-indexing on root changes
// is out of scope here; for now we just accept the new
// values so later commands see the right diagnostic settings etc.
// Multi-wiki support revisits this with proper rebuild semantics.
let mut cfg = self.config.write().expect("config lock poisoned");
cfg.apply_change(&params.settings);
}
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;
}
}
async fn did_rename_files(&self, params: RenameFilesParams) {
// We advertise this capability so the editor pushes
// 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 &params.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 &params.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);
}
}
}
async fn did_save(&self, params: DidSaveTextDocumentParams) {
// 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.
let uri = params.text_document.uri.clone();
let wiki = match self.wiki_for_uri(&uri) {
Some(w) => w,
None => return,
};
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));
let outcome = commands::export_ops::write_page(&doc.ast, &name, &wiki.config);
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;
}
}
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
let uri = &params.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.
// Clear lingering diagnostics so closed buffers don't keep red squiggles.
self.client
.publish_diagnostics(params.text_document.uri.clone(), Vec::new(), None)
.await;
}
async fn document_symbol(
&self,
params: DocumentSymbolParams,
) -> LspResult<Option<DocumentSymbolResponse>> {
let uri = &params.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)))
}
async fn semantic_tokens_full(
&self,
params: SemanticTokensParams,
) -> LspResult<Option<SemanticTokensResult>> {
let uri = &params.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 = &params.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),
})))
}
async fn goto_definition(
&self,
params: GotoDefinitionParams,
) -> LspResult<Option<GotoDefinitionResponse>> {
let pos = params.text_document_position_params.position;
let uri = &params.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 {
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,
}
}),
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 = &params.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
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()));
let target_page = match nav::find_inline_at(&doc.ast, line, col) {
Some(InlineNode::WikiLink(w)) => match w.target.kind {
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()
}),
nuwiki_core::ast::LinkKind::AnchorOnly => Some(source_page.clone()),
_ => None,
},
_ => Some(source_page),
};
let Some(page) = target_page else {
return Ok(None);
};
let Some(wiki) = wiki else {
return Ok(Some(Vec::new()));
};
let idx = wiki.index.read().expect("index lock poisoned");
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 = &params.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) => {
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(),
}
}
None => match &w.target.path {
Some(p) => format!("**{p}** — page not in index"),
None => "(unresolved link)".into(),
},
};
(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 = &params.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);
}
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");
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)))
}
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, &params.command, params.arguments).await {
Ok(Some(commands::CommandOutcome::Edit(edit))) => {
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)
}
Ok(Some(commands::CommandOutcome::Value(v))) => Ok(Some(v)),
Ok(None) => Ok(None),
Err(msg) => {
self.client
.log_message(MessageType::ERROR, format!("nuwiki: {msg}"))
.await;
Ok(None)
}
}
}
async fn folding_range(
&self,
params: FoldingRangeParams,
) -> LspResult<Option<Vec<FoldingRange>>> {
let uri = &params.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)))
}
async fn symbol(
&self,
params: WorkspaceSymbolParams,
) -> LspResult<Option<Vec<LspSymbolInformation>>> {
let q = params.query.to_lowercase();
let wikis = self.wikis.read().expect("wikis lock poisoned").clone();
let mut out = Vec::new();
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()),
});
}
}
// Surface tags too, named as `:tag:` so editors
// 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()),
});
}
}
}
}
Ok(Some(out))
}
}
/// 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()
}
/// 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() {
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}"));
}
}
}
Url::from_file_path(p).ok()
}
/// 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,
}
}
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()
}
// ===== 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
}
/// Backward-compat wrapper kept for the legacy test surface. New call sites
/// should use `collect_diagnostics` so they can opt into link-health
/// and other sources.
pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec<Diagnostic> {
collect_diagnostics(
ast,
text,
None,
None,
None,
&DiagnosticConfig::default(),
utf8,
)
}
/// Composable diagnostic collector.
///
/// Sources, each gated by `cfg`:
///
/// 1. Parse errors (always on).
/// 2. Broken-link warnings — emitted when an index, source
/// page name, and `link_severity != Off` are all available.
pub fn collect_diagnostics(
ast: &DocumentNode,
text: &str,
uri: Option<&Url>,
index: Option<&WorkspaceIndex>,
page_name: Option<&str>,
cfg: &DiagnosticConfig,
utf8: bool,
) -> Vec<Diagnostic> {
let mut out = Vec::new();
walk_blocks_for_errors(&ast.children, text, utf8, &mut out);
if let (Some(idx), Some(name)) = (index, page_name) {
out.extend(diagnostics::link_health(
ast,
text,
uri,
idx,
name,
cfg.link_severity,
utf8,
));
}
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",
nuwiki_core::ast::Keyword::Stopped => "STOPPED",
}
}
// ===== Workspace + navigation helpers =====
//
// `workspace_root_from_params` logic now lives in
// `Config::from_init_params` (see `config.rs`) so workspace
// folders and `root_uri` are handled alongside `initializationOptions`.
/// 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;
}
/// 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;
}
}
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)?;
// Headings win over tags when both match — same precedence as
// vimwiki: heading anchors are the "canonical" target shape.
if let Some(h) = page.find_heading_by_anchor(anchor) {
return Some(span_to_lsp_range_no_text(&h.span));
}
// Tags-as-anchors. `[[Page#tag-name]]` lands on the
// 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))
}
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 {
if let Some(h) = page.find_heading_by_anchor(anchor) {
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
}