use crate::{
checker::analyze,
error::{Error, SourceRange},
parser,
wiki::{DIRECTORY_LINK_PREFIX, FILE_LINK_PREFIX, Link, TextNode, Wiki},
};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use std::{
borrow::Cow,
collections::HashMap,
path::Path,
sync::{Arc, Mutex, atomic::AtomicBool, atomic::Ordering},
time::Duration,
};
use tokio::task::JoinHandle;
use tower_lsp_server::{
Client, LanguageServer, LspService, Server,
jsonrpc::{Error as JsonRpcError, Result},
ls_types::{
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams,
CompletionResponse, CompletionTextEdit, Diagnostic, DiagnosticSeverity,
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
DidSaveTextDocumentParams, DocumentFormattingParams, DocumentHighlight,
DocumentHighlightKind, DocumentHighlightParams, DocumentSymbol, DocumentSymbolParams,
DocumentSymbolResponse, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents,
HoverParams, HoverProviderCapability, InitializeParams, InitializeResult,
InitializedParams, Location, LocationLink, MarkupContent, MarkupKind, MessageType, OneOf,
Position, PositionEncodingKind, PrepareRenameResponse, Range, ReferenceParams,
RenameOptions, RenameParams, ServerCapabilities, ServerInfo, SymbolInformation, SymbolKind,
TextDocumentPositionParams, TextDocumentSyncCapability, TextDocumentSyncKind,
TextDocumentSyncOptions, TextEdit, Uri, WorkDoneProgressOptions, WorkspaceEdit,
},
};
const CHECK_DELAY: Duration = Duration::from_millis(250);
const REVEAL_RANGE_COMMAND: &str = "mull.revealRange";
#[derive(Debug)]
struct OpenDocument {
contents: String,
version: i32,
generation: u64,
pending_check: Option<JoinHandle<()>>,
}
#[derive(Debug)]
struct Backend {
client: Client,
documents: Arc<Mutex<HashMap<Uri, OpenDocument>>>,
supports_hierarchical_document_symbols: AtomicBool,
}
impl Backend {
fn new(client: Client) -> Self {
Self {
client,
documents: Arc::new(Mutex::new(HashMap::new())),
supports_hierarchical_document_symbols: AtomicBool::new(false),
}
}
fn update_document(&self, uri: Uri, contents: String, version: i32, delay: Duration) {
let client = self.client.clone();
let documents = Arc::clone(&self.documents);
let diagnostic_uri = uri.clone();
let diagnostic_contents = contents.clone();
let mut open_documents = self
.documents
.lock()
.expect("the open-document mutex should not be poisoned");
let document = open_documents.entry(uri).or_insert_with(|| OpenDocument {
contents: String::new(),
version,
generation: 0,
pending_check: None,
});
if let Some(pending_check) = document.pending_check.take() {
pending_check.abort();
}
document.contents = contents;
document.version = version;
document.generation = document
.generation
.checked_add(1)
.expect("a document generation should fit in a u64");
let generation = document.generation;
document.pending_check = Some(tokio::spawn(async move {
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
let check_uri = diagnostic_uri.clone();
let fallback_contents = diagnostic_contents.clone();
let diagnostics = tokio::task::spawn_blocking(move || {
diagnostics_for_document(&check_uri, &diagnostic_contents)
})
.await
.unwrap_or_else(|error| {
vec![diagnostic(
&fallback_contents,
None,
format!("Mull was unable to check the wiki: {error}."),
)]
});
let is_current = documents
.lock()
.expect("the open-document mutex should not be poisoned")
.get(&diagnostic_uri)
.is_some_and(|document| {
document.version == version && document.generation == generation
});
if is_current {
client
.publish_diagnostics(diagnostic_uri, diagnostics, Some(version))
.await;
}
}));
}
fn save_document(&self, uri: Uri, contents: Option<String>) {
let snapshot = {
let mut open_documents = self
.documents
.lock()
.expect("the open-document mutex should not be poisoned");
open_documents.get_mut(&uri).map(|document| {
if let Some(contents) = contents {
document.contents = contents;
}
(document.contents.clone(), document.version)
})
};
if let Some((contents, version)) = snapshot {
self.update_document(uri, contents, version, Duration::ZERO);
}
}
fn document_contents(&self, uri: &Uri) -> Option<String> {
self.documents
.lock()
.expect("the open-document mutex should not be poisoned")
.get(uri)
.map(|document| document.contents.clone())
}
}
#[allow(
clippy::unused_async_trait_impl,
reason = "Some methods mirror the asynchronous language-server interface without awaiting."
)]
impl LanguageServer for Backend {
async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
let supports_hierarchical_document_symbols = params
.capabilities
.text_document
.as_ref()
.and_then(|capabilities| capabilities.document_symbol.as_ref())
.and_then(|capabilities| capabilities.hierarchical_document_symbol_support)
.unwrap_or(false);
self.supports_hierarchical_document_symbols
.store(supports_hierarchical_document_symbols, Ordering::Relaxed);
Ok(InitializeResult {
capabilities: ServerCapabilities {
completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec!["[".to_owned()]),
..CompletionOptions::default()
}),
definition_provider: Some(OneOf::Left(true)),
hover_provider: Some(HoverProviderCapability::Simple(true)),
position_encoding: Some(PositionEncodingKind::UTF16),
references_provider: Some(OneOf::Left(true)),
document_highlight_provider: Some(OneOf::Left(true)),
rename_provider: Some(OneOf::Right(RenameOptions {
prepare_provider: Some(true),
work_done_progress_options: WorkDoneProgressOptions::default(),
})),
document_formatting_provider: Some(OneOf::Left(true)),
document_symbol_provider: Some(OneOf::Left(true)),
text_document_sync: Some(TextDocumentSyncCapability::Options(
TextDocumentSyncOptions {
open_close: Some(true),
change: Some(TextDocumentSyncKind::FULL),
save: Some(true.into()),
..TextDocumentSyncOptions::default()
},
)),
..ServerCapabilities::default()
},
server_info: Some(ServerInfo {
name: env!("CARGO_PKG_NAME").to_owned(),
version: Some(env!("CARGO_PKG_VERSION").to_owned()),
}),
..InitializeResult::default()
})
}
async fn initialized(&self, _params: InitializedParams) {
self.client
.log_message(
MessageType::INFO,
format!(
"Mull {} language server initialized.",
env!("CARGO_PKG_VERSION"),
),
)
.await;
}
async fn shutdown(&self) -> Result<()> {
self.client
.log_message(MessageType::INFO, "Mull language server shutting down.")
.await;
Ok(())
}
async fn did_open(&self, params: DidOpenTextDocumentParams) {
let document = params.text_document;
self.update_document(
document.uri,
document.text,
document.version,
Duration::ZERO,
);
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
if let Some(change) = params.content_changes.into_iter().next_back() {
self.update_document(
params.text_document.uri,
change.text,
params.text_document.version,
CHECK_DELAY,
);
}
}
async fn did_save(&self, params: DidSaveTextDocumentParams) {
self.save_document(params.text_document.uri, params.text);
}
async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
let position_params = params.text_document_position;
let uri = position_params.text_document.uri;
let Some(contents) = self.document_contents(&uri) else {
return Ok(None);
};
Ok(
completions_for_document(&uri, &contents, position_params.position)
.map(CompletionResponse::Array),
)
}
async fn goto_definition(
&self,
params: GotoDefinitionParams,
) -> Result<Option<GotoDefinitionResponse>> {
let position_params = params.text_document_position_params;
let uri = position_params.text_document.uri;
let Some(contents) = self.document_contents(&uri) else {
return Ok(None);
};
Ok(definition_for_document(
&uri,
&contents,
position_params.position,
))
}
async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
let position_params = params.text_document_position_params;
let uri = position_params.text_document.uri;
let Some(contents) = self.document_contents(&uri) else {
return Ok(None);
};
Ok(hover_for_document(
&uri,
&contents,
position_params.position,
))
}
async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
let position_params = params.text_document_position;
let uri = position_params.text_document.uri;
let Some(contents) = self.document_contents(&uri) else {
return Ok(None);
};
Ok(references_for_document(
&uri,
&contents,
position_params.position,
params.context.include_declaration,
))
}
async fn document_highlight(
&self,
params: DocumentHighlightParams,
) -> Result<Option<Vec<DocumentHighlight>>> {
let position_params = params.text_document_position_params;
let uri = position_params.text_document.uri;
let Some(contents) = self.document_contents(&uri) else {
return Ok(None);
};
Ok(document_highlights_for_document(
&uri,
&contents,
position_params.position,
))
}
async fn prepare_rename(
&self,
params: TextDocumentPositionParams,
) -> Result<Option<PrepareRenameResponse>> {
let uri = params.text_document.uri;
let Some(contents) = self.document_contents(&uri) else {
return Ok(None);
};
Ok(prepare_rename_for_document(
&uri,
&contents,
params.position,
))
}
async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
let position_params = params.text_document_position;
let uri = position_params.text_document.uri;
let Some(contents) = self.document_contents(&uri) else {
return Ok(None);
};
rename_for_document(&uri, &contents, position_params.position, ¶ms.new_name)
.map_err(JsonRpcError::invalid_params)
}
async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
let uri = params.text_document.uri;
let snapshot = self
.documents
.lock()
.expect("the open-document mutex should not be poisoned")
.get(&uri)
.map(|document| (document.contents.clone(), document.generation));
let Some((contents, generation)) = snapshot else {
return Ok(None);
};
let wiki_path = local_path(&uri).map(Cow::into_owned);
let formatting_result = tokio::task::spawn_blocking(move || {
formatting_edit(wiki_path.as_deref(), &contents).map_err(|_errors| ())
})
.await;
let Ok(Ok(edit)) = formatting_result else {
return Ok(None);
};
let is_current = self
.documents
.lock()
.expect("the open-document mutex should not be poisoned")
.get(&uri)
.is_some_and(|document| document.generation == generation);
if !is_current {
return Ok(None);
}
Ok(Some(edit.into_iter().collect()))
}
async fn document_symbol(
&self,
params: DocumentSymbolParams,
) -> Result<Option<DocumentSymbolResponse>> {
let uri = params.text_document.uri;
let Some(contents) = self.document_contents(&uri) else {
return Ok(None);
};
Ok(document_symbols_for_document(
&uri,
&contents,
self.supports_hierarchical_document_symbols
.load(Ordering::Relaxed),
))
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
let uri = params.text_document.uri;
let document = self
.documents
.lock()
.expect("the open-document mutex should not be poisoned")
.remove(&uri);
if let Some(pending_check) = document.and_then(|document| document.pending_check) {
pending_check.abort();
}
self.client.publish_diagnostics(uri, Vec::new(), None).await;
}
}
fn local_path(uri: &Uri) -> Option<Cow<'_, Path>> {
uri.scheme()
.as_str()
.eq_ignore_ascii_case("file")
.then(|| uri.to_file_path())
.flatten()
}
fn formatting_edit(
source_path: Option<&Path>,
source_contents: &str,
) -> std::result::Result<Option<TextEdit>, Vec<Error>> {
let rendered_wiki = analyze(source_path, source_contents)?.to_string();
if source_contents == rendered_wiki {
Ok(None)
} else {
Ok(Some(TextEdit::new(
Range::new(
Position::new(0, 0),
position(source_contents, source_contents.len()),
),
rendered_wiki,
)))
}
}
#[allow(
deprecated,
reason = "The protocol's DocumentSymbol type retains a required legacy field."
)]
fn document_symbols_for_document(
uri: &Uri,
source_contents: &str,
supports_hierarchy: bool,
) -> Option<DocumentSymbolResponse> {
let wiki_path = local_path(uri);
let wiki = parser::parse(wiki_path.as_deref(), source_contents).ok()?;
let mut nodes = wiki.text_nodes.values().collect::<Vec<_>>();
nodes.sort_by_key(|node| node.source_range.start);
let symbols = nodes
.into_iter()
.map(|node| DocumentSymbol {
name: node.title.clone(),
detail: None,
kind: SymbolKind::OBJECT,
tags: None,
deprecated: None,
range: lsp_range(source_contents, node.source_range),
selection_range: lsp_range(source_contents, node.title_source_range),
children: None,
})
.collect::<Vec<_>>();
if supports_hierarchy {
Some(DocumentSymbolResponse::Nested(symbols))
} else {
Some(DocumentSymbolResponse::Flat(
symbols
.into_iter()
.map(|symbol| SymbolInformation {
name: symbol.name,
kind: symbol.kind,
tags: symbol.tags,
deprecated: None,
location: Location::new(uri.clone(), symbol.selection_range),
container_name: None,
})
.collect(),
))
}
}
fn completions_for_document(
uri: &Uri,
source_contents: &str,
cursor: Position,
) -> Option<Vec<CompletionItem>> {
let byte_offset = byte_offset(source_contents, cursor)?;
let wiki_path = local_path(uri);
let (wiki, replacement_source_range) =
completion_context(wiki_path.as_deref(), source_contents, byte_offset)?;
let replacement_range = lsp_range(source_contents, replacement_source_range);
let mut titles = wiki
.text_nodes
.keys()
.filter(|title| is_text_link_title(title))
.collect::<Vec<_>>();
titles.sort();
Some(
titles
.into_iter()
.map(|title| {
let escaped_title = escape_text_link_title(title);
CompletionItem {
label: title.clone(),
kind: Some(CompletionItemKind::REFERENCE),
filter_text: Some(escaped_title.clone()),
text_edit: Some(CompletionTextEdit::Edit(TextEdit::new(
replacement_range,
format!("{escaped_title}]"),
))),
..CompletionItem::default()
}
})
.collect(),
)
}
fn completion_context(
source_path: Option<&Path>,
source_contents: &str,
byte_offset: usize,
) -> Option<(Wiki, SourceRange)> {
if let Ok(wiki) = parser::parse(source_path, source_contents)
&& let Some(target_source_range) = text_link_target_at(&wiki, source_contents, byte_offset)
{
return Some((
wiki,
SourceRange {
start: target_source_range.start,
end: target_source_range.end + ']'.len_utf8(),
},
));
}
let mut completed_source = source_contents.to_owned();
completed_source.insert(byte_offset, ']');
let wiki = parser::parse(source_path, &completed_source).ok()?;
let target_source_range = text_link_target_at(&wiki, &completed_source, byte_offset)?;
Some((wiki, target_source_range))
}
fn text_link_target_at(
wiki: &Wiki,
source_contents: &str,
byte_offset: usize,
) -> Option<SourceRange> {
let (_title, link_source_range) = text_link_at(wiki, byte_offset)?;
let target_source_range = text_link_target_source_range(source_contents, link_source_range)?;
(target_source_range.start <= byte_offset && byte_offset <= target_source_range.end)
.then_some(target_source_range)
}
fn definition_for_document(
uri: &Uri,
source_contents: &str,
cursor: Position,
) -> Option<GotoDefinitionResponse> {
let wiki_path = local_path(uri);
let wiki = parser::parse(wiki_path.as_deref(), source_contents).ok()?;
let (node, link_source_range) = linked_node_at(&wiki, source_contents, cursor)?;
Some(GotoDefinitionResponse::Link(vec![LocationLink {
origin_selection_range: Some(lsp_range(source_contents, link_source_range)),
target_uri: uri.clone(),
target_range: lsp_range(source_contents, node.source_range),
target_selection_range: lsp_range(source_contents, node.title_source_range),
}]))
}
fn hover_for_document(uri: &Uri, source_contents: &str, cursor: Position) -> Option<Hover> {
let wiki_path = local_path(uri);
let wiki = parser::parse(wiki_path.as_deref(), source_contents).ok()?;
let byte_offset = byte_offset(source_contents, cursor)?;
let (node, source_range) = node_at(&wiki, source_contents, byte_offset, LinkExtent::Whole)?;
let markdown = node.to_markdown(|title| {
let target = wiki.text_nodes.get(title)?;
reveal_range_command_url(uri, source_contents, target.title_source_range)
});
Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: markdown,
}),
range: Some(lsp_range(source_contents, source_range)),
})
}
fn reveal_range_command_url(
uri: &Uri,
source_contents: &str,
source_range: SourceRange,
) -> Option<String> {
let range = lsp_range(source_contents, source_range);
let arguments = serde_json::to_string(&(
uri.as_str(),
range.start.line,
range.start.character,
range.end.line,
range.end.character,
))
.ok()?;
Some(format!(
"command:{REVEAL_RANGE_COMMAND}?{}",
utf8_percent_encode(&arguments, NON_ALPHANUMERIC),
))
}
fn references_for_document(
uri: &Uri,
source_contents: &str,
cursor: Position,
include_declaration: bool,
) -> Option<Vec<Location>> {
let wiki_path = local_path(uri);
let wiki = parser::parse(wiki_path.as_deref(), source_contents).ok()?;
let byte_offset = byte_offset(source_contents, cursor)?;
let (node, _source_range) = node_at(&wiki, source_contents, byte_offset, LinkExtent::Whole)?;
let mut source_ranges = text_link_source_ranges(&wiki, &node.title);
if include_declaration {
source_ranges.push(node.title_source_range);
}
source_ranges.sort_by_key(|source_range| (source_range.start, source_range.end));
Some(
source_ranges
.into_iter()
.map(|source_range| {
Location::new(uri.clone(), lsp_range(source_contents, source_range))
})
.collect(),
)
}
fn document_highlights_for_document(
uri: &Uri,
source_contents: &str,
cursor: Position,
) -> Option<Vec<DocumentHighlight>> {
let wiki_path = local_path(uri);
let wiki = parser::parse(wiki_path.as_deref(), source_contents).ok()?;
let byte_offset = byte_offset(source_contents, cursor)?;
let mut highlights = if let Some((node, _source_range)) =
node_at(&wiki, source_contents, byte_offset, LinkExtent::Whole)
{
let mut highlights = text_link_source_ranges(&wiki, &node.title)
.into_iter()
.map(|source_range| (source_range, DocumentHighlightKind::READ))
.collect::<Vec<_>>();
highlights.push((node.title_source_range, DocumentHighlightKind::WRITE));
highlights
} else {
let link = filesystem_link_at(&wiki, byte_offset)?;
filesystem_link_source_ranges(&wiki, link)
.into_iter()
.map(|source_range| (source_range, DocumentHighlightKind::READ))
.collect()
};
highlights.sort_by_key(|(source_range, _kind)| (source_range.start, source_range.end));
Some(
highlights
.into_iter()
.map(|(source_range, kind)| DocumentHighlight {
range: lsp_range(source_contents, source_range),
kind: Some(kind),
})
.collect(),
)
}
fn text_link_source_ranges(wiki: &Wiki, title: &str) -> Vec<SourceRange> {
let mut source_ranges = wiki
.text_nodes
.values()
.flat_map(|node| &node.links)
.filter_map(|link| match link {
Link::Text {
title: link_title,
source_range,
} if link_title == title => Some(*source_range),
Link::Text { .. } | Link::File { .. } | Link::Directory { .. } => None,
})
.collect::<Vec<_>>();
source_ranges.sort_by_key(|source_range| (source_range.start, source_range.end));
source_ranges
}
fn filesystem_link_at(wiki: &Wiki, byte_offset: usize) -> Option<&Link> {
wiki.text_nodes.values().find_map(|node| {
node.links.iter().find(|link| match link {
Link::File { source_range, .. } | Link::Directory { source_range, .. } => {
source_range.start <= byte_offset && byte_offset < source_range.end
}
Link::Text { .. } => false,
})
})
}
fn filesystem_link_source_ranges(wiki: &Wiki, target: &Link) -> Vec<SourceRange> {
wiki.text_nodes
.values()
.flat_map(|node| &node.links)
.filter_map(|link| match (link, target) {
(
Link::File { path, source_range },
Link::File {
path: target_path, ..
},
)
| (
Link::Directory { path, source_range },
Link::Directory {
path: target_path, ..
},
) if path == target_path => Some(*source_range),
(
Link::Text { .. } | Link::File { .. } | Link::Directory { .. },
Link::Text { .. } | Link::File { .. } | Link::Directory { .. },
) => None,
})
.collect()
}
fn prepare_rename_for_document(
uri: &Uri,
source_contents: &str,
cursor: Position,
) -> Option<PrepareRenameResponse> {
let wiki_path = local_path(uri);
let wiki = parser::parse(wiki_path.as_deref(), source_contents).ok()?;
let byte_offset = byte_offset(source_contents, cursor)?;
let (node, source_range) = node_at(&wiki, source_contents, byte_offset, LinkExtent::Target)?;
Some(PrepareRenameResponse::RangeWithPlaceholder {
range: lsp_range(source_contents, source_range),
placeholder: node.title.clone(),
})
}
fn rename_for_document(
uri: &Uri,
source_contents: &str,
cursor: Position,
new_name: &str,
) -> std::result::Result<Option<WorkspaceEdit>, String> {
let wiki_path = local_path(uri);
let Some(wiki) = parser::parse(wiki_path.as_deref(), source_contents).ok() else {
return Ok(None);
};
let Some(byte_offset) = byte_offset(source_contents, cursor) else {
return Ok(None);
};
let Some((node, _source_range)) =
node_at(&wiki, source_contents, byte_offset, LinkExtent::Target)
else {
return Ok(None);
};
if new_name
.chars()
.any(|character| matches!(character, '\r' | '\n'))
{
return Err("A node title cannot contain a line break.".to_owned());
}
let new_title = new_name.trim();
if new_title.is_empty() {
return Err("A node title cannot be empty.".to_owned());
}
if !is_text_link_title(new_title) {
return Err(format!(
"A text-linked node title cannot start with `{FILE_LINK_PREFIX}` or \
`{DIRECTORY_LINK_PREFIX}`.",
));
}
if new_title != node.title && wiki.text_nodes.contains_key(new_title) {
return Err(format!("Node `{new_title}` already exists."));
}
let mut edits = vec![(node.title_source_range, new_title.to_owned())];
for link in wiki.text_nodes.values().flat_map(|node| &node.links) {
if let Link::Text {
title,
source_range,
} = link
&& title == &node.title
&& let Some(target_source_range) =
text_link_target_source_range(source_contents, *source_range)
{
edits.push((target_source_range, escape_text_link_title(new_title)));
}
}
edits.sort_by_key(|(source_range, _new_text)| (source_range.start, source_range.end));
let edits = edits
.into_iter()
.map(|(source_range, new_text)| {
TextEdit::new(lsp_range(source_contents, source_range), new_text)
})
.collect();
Ok(Some(WorkspaceEdit {
changes: Some(HashMap::from([(uri.clone(), edits)])),
..WorkspaceEdit::default()
}))
}
fn escape_text_link_title(title: &str) -> String {
title.replace('[', "\\[").replace(']', "\\]")
}
fn is_text_link_title(title: &str) -> bool {
!title.starts_with(FILE_LINK_PREFIX) && !title.starts_with(DIRECTORY_LINK_PREFIX)
}
fn linked_node_at<'a>(
wiki: &'a Wiki,
source_contents: &str,
cursor: Position,
) -> Option<(&'a TextNode, SourceRange)> {
let byte_offset = byte_offset(source_contents, cursor)?;
let (title, source_range) = text_link_at(wiki, byte_offset)?;
wiki.text_nodes.get(title).map(|node| (node, source_range))
}
#[derive(Clone, Copy)]
enum LinkExtent {
Whole,
Target,
}
fn node_at<'a>(
wiki: &'a Wiki,
source_contents: &str,
byte_offset: usize,
link_extent: LinkExtent,
) -> Option<(&'a TextNode, SourceRange)> {
if let Some(node) = declaration_at(wiki, byte_offset) {
return Some((node, node.title_source_range));
}
let (title, source_range) = text_link_at(wiki, byte_offset)?;
let node = wiki.text_nodes.get(title)?;
let source_range = match link_extent {
LinkExtent::Whole => source_range,
LinkExtent::Target => text_link_target_source_range(source_contents, source_range)?,
};
Some((node, source_range))
}
fn declaration_at(wiki: &Wiki, byte_offset: usize) -> Option<&TextNode> {
wiki.text_nodes.values().find(|node| {
node.title_source_range.start <= byte_offset && byte_offset < node.title_source_range.end
})
}
fn text_link_at(wiki: &Wiki, byte_offset: usize) -> Option<(&str, SourceRange)> {
wiki.text_nodes.values().find_map(|node| {
node.links.iter().find_map(|link| match link {
Link::Text {
title,
source_range,
} if source_range.start <= byte_offset && byte_offset < source_range.end => {
Some((title.as_str(), *source_range))
}
Link::Text { .. } | Link::File { .. } | Link::Directory { .. } => None,
})
})
}
fn text_link_target_source_range(
source_contents: &str,
source_range: SourceRange,
) -> Option<SourceRange> {
let link_source = source_contents.get(source_range.start..source_range.end)?;
let target_source = link_source.strip_prefix('[')?.strip_suffix(']')?;
let start = source_range.start + '['.len_utf8();
Some(SourceRange {
start,
end: start + target_source.len(),
})
}
fn diagnostics_for_document(uri: &Uri, source_contents: &str) -> Vec<Diagnostic> {
let wiki_path = local_path(uri);
analyze(wiki_path.as_deref(), source_contents).map_or_else(
|errors| {
errors
.iter()
.map(|error| diagnostic_from_error(source_contents, error))
.collect()
},
|_wiki| Vec::new(),
)
}
fn diagnostic_from_error(source_contents: &str, error: &Error) -> Diagnostic {
let message = error.reason().map_or_else(
|| error.message().to_owned(),
|reason| format!("{}\n\nReason: {reason}", error.message()),
);
diagnostic(source_contents, error.source_range(), message)
}
fn diagnostic(
source_contents: &str,
source_range: Option<crate::error::SourceRange>,
message: String,
) -> Diagnostic {
let source_range = source_range.unwrap_or(crate::error::SourceRange { start: 0, end: 0 });
Diagnostic {
range: lsp_range(source_contents, source_range),
severity: Some(DiagnosticSeverity::ERROR),
source: Some(env!("CARGO_PKG_NAME").to_owned()),
message,
..Diagnostic::default()
}
}
fn lsp_range(source_contents: &str, source_range: SourceRange) -> Range {
Range::new(
position(source_contents, source_range.start),
position(source_contents, source_range.end),
)
}
fn byte_offset(source_contents: &str, position: Position) -> Option<usize> {
let mut line_start = 0;
for _ in 0..position.line {
let line_break = source_contents[line_start..].find('\n')?;
line_start += line_break + '\n'.len_utf8();
}
let line_end = source_contents[line_start..]
.find('\n')
.map_or(source_contents.len(), |index| line_start + index);
let content_end = if line_end > line_start
&& source_contents.as_bytes()[line_end - 1] == b'\r'
&& source_contents.as_bytes().get(line_end) == Some(&b'\n')
{
line_end - '\r'.len_utf8()
} else {
line_end
};
let requested_character = usize::try_from(position.character).ok()?;
let mut utf16_character = 0;
for (index, character) in source_contents[line_start..content_end].char_indices() {
if utf16_character == requested_character {
return Some(line_start + index);
}
utf16_character += character.len_utf16();
if utf16_character > requested_character {
return None;
}
}
(utf16_character == requested_character).then_some(content_end)
}
fn position(source_contents: &str, byte_offset: usize) -> Position {
let byte_offset = byte_offset.min(source_contents.len());
let prefix = source_contents
.get(..byte_offset)
.expect("source ranges should end on UTF-8 character boundaries");
let line_start = prefix
.rfind('\n')
.map_or(0, |index| index + '\n'.len_utf8());
let line = prefix.bytes().filter(|byte| *byte == b'\n').count();
let character = source_contents[line_start..byte_offset]
.encode_utf16()
.count();
Position::new(
u32::try_from(line).unwrap_or(u32::MAX),
u32::try_from(character).unwrap_or(u32::MAX),
)
}
pub async fn run() {
colored::control::set_override(false);
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::new(Backend::new);
Server::new(stdin, stdout, socket).serve(service).await;
}
#[cfg(test)]
mod tests {
use super::{
byte_offset, completions_for_document, definition_for_document, diagnostic_from_error,
diagnostics_for_document, document_highlights_for_document, document_symbols_for_document,
formatting_edit, hover_for_document, position, prepare_rename_for_document,
references_for_document, rename_for_document, reveal_range_command_url,
};
use crate::{error::SourceRange, parser};
use std::{
fs,
path::{Path, PathBuf},
process,
sync::atomic::{AtomicUsize, Ordering},
};
use tower_lsp_server::ls_types::{
CompletionTextEdit, DiagnosticSeverity, DocumentHighlight, DocumentHighlightKind,
DocumentSymbolResponse, GotoDefinitionResponse, HoverContents, MarkupKind, Position,
PrepareRenameResponse, Range, SymbolKind, Uri,
};
static NEXT_DIRECTORY: AtomicUsize = AtomicUsize::new(0);
struct TestWiki(PathBuf);
impl TestWiki {
fn new(source_contents: &str) -> Self {
let sequence = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
let directory = std::env::temp_dir()
.join(format!("mull-language-server-{}-{sequence}", process::id()));
fs::create_dir(&directory).unwrap();
let wiki_path = directory.join("wiki.mull");
fs::write(&wiki_path, source_contents).unwrap();
Self(wiki_path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestWiki {
fn drop(&mut self) {
fs::remove_dir_all(self.0.parent().unwrap()).unwrap();
}
}
fn untitled_uri() -> Uri {
"untitled:Untitled-1".parse().unwrap()
}
#[test]
fn positions_use_utf16_code_units() {
let source = "zero\n😀 café";
assert_eq!(position(source, 0), Position::new(0, 0));
assert_eq!(position(source, 5), Position::new(1, 0));
assert_eq!(position(source, 9), Position::new(1, 2));
assert_eq!(position(source, source.len()), Position::new(1, 7));
}
#[test]
fn byte_offsets_use_utf16_code_units() {
let source = "zero\n😀 café";
assert_eq!(byte_offset(source, Position::new(0, 0)), Some(0));
assert_eq!(byte_offset(source, Position::new(1, 0)), Some(5));
assert_eq!(byte_offset(source, Position::new(1, 1)), None);
assert_eq!(byte_offset(source, Position::new(1, 2)), Some(9));
assert_eq!(byte_offset(source, Position::new(1, 7)), Some(source.len()));
assert_eq!(byte_offset(source, Position::new(1, 8)), None);
assert_eq!(byte_offset(source, Position::new(2, 0)), None);
}
#[test]
fn reveal_range_commands_encode_destinations() {
let source = "# Home";
let url =
reveal_range_command_url(&untitled_uri(), source, SourceRange { start: 2, end: 6 })
.unwrap();
assert_eq!(
url,
concat!(
"command:mull.revealRange?",
"%5B%22untitled%3AUntitled%2D1%22%2C0%2C2%2C0%2C6%5D",
),
);
}
#[test]
fn document_symbols_describe_text_nodes() {
let source = "# Zebra\n\nFirst\n\n# Alpha\n\nSecond";
let wiki = TestWiki::new(source);
let uris = [untitled_uri(), Uri::from_file_path(wiki.path()).unwrap()];
for uri in uris {
let response = document_symbols_for_document(&uri, source, true).unwrap();
let DocumentSymbolResponse::Nested(symbols) = response else {
panic!("text nodes should be represented as nested document symbols");
};
assert_eq!(
symbols
.iter()
.map(|symbol| symbol.name.as_str())
.collect::<Vec<_>>(),
vec!["Zebra", "Alpha"],
);
assert!(symbols.iter().all(|symbol| {
symbol.kind == SymbolKind::OBJECT
&& symbol.detail.is_none()
&& symbol.tags.is_none()
&& symbol.children.is_none()
}));
assert_eq!(
symbols[0].range,
Range::new(Position::new(0, 0), Position::new(4, 0)),
);
assert_eq!(
symbols[0].selection_range,
Range::new(Position::new(0, 2), Position::new(0, 7)),
);
assert_eq!(
symbols[1].range,
Range::new(Position::new(4, 0), Position::new(6, 6)),
);
assert_eq!(
symbols[1].selection_range,
Range::new(Position::new(4, 2), Position::new(4, 7)),
);
let response = document_symbols_for_document(&uri, source, false).unwrap();
let DocumentSymbolResponse::Flat(symbols) = response else {
panic!("clients without hierarchy support should receive flat symbols");
};
assert_eq!(
symbols
.iter()
.map(|symbol| symbol.name.as_str())
.collect::<Vec<_>>(),
vec!["Zebra", "Alpha"],
);
assert_eq!(
symbols[0].location.range,
Range::new(Position::new(0, 2), Position::new(0, 7)),
);
assert_eq!(
symbols[1].location.range,
Range::new(Position::new(4, 2), Position::new(4, 7)),
);
}
}
#[test]
fn untitled_text_only_wikis_receive_diagnostics() {
let source = "# Home\nSee [Missing].";
let diagnostics = diagnostics_for_document(&untitled_uri(), source);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].message, "Node `Missing` not found.");
assert_eq!(
diagnostics[0].range,
Range::new(Position::new(1, 4), Position::new(1, 13)),
);
}
#[test]
fn untitled_syntax_errors_receive_diagnostics() {
let source = "# Home\nUnexpected]";
let diagnostics = diagnostics_for_document(&untitled_uri(), source);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].message, "Unexpected closing link delimiter.");
assert_eq!(
diagnostics[0].range,
Range::new(Position::new(1, 10), Position::new(1, 11)),
);
}
#[test]
fn untitled_filesystem_links_receive_diagnostics() {
let source = concat!("# Home\n[", "file:notes.txt] [", "dir:images]");
let diagnostics = diagnostics_for_document(&untitled_uri(), source);
assert_eq!(diagnostics.len(), 2);
assert!(diagnostics.iter().all(|diagnostic| {
diagnostic.message == "Save the wiki to validate this filesystem link."
}));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.range)
.collect::<Vec<_>>(),
vec![
Range::new(Position::new(1, 0), Position::new(1, 16)),
Range::new(Position::new(1, 17), Position::new(1, 29)),
],
);
}
#[test]
fn untitled_wikis_support_navigation() {
let source = "# Home\n\n[Greeting]\n\n# Greeting";
assert!(definition_for_document(&untitled_uri(), source, Position::new(2, 4)).is_some());
assert!(hover_for_document(&untitled_uri(), source, Position::new(2, 4)).is_some());
assert!(
references_for_document(&untitled_uri(), source, Position::new(2, 4), false).is_some(),
);
}
#[test]
fn completions_replace_closed_link_targets() {
let source = "# Home\n\n[Gr]\n\n# Greeting\n\n# Other";
let completions =
completions_for_document(&untitled_uri(), source, Position::new(2, 3)).unwrap();
assert_eq!(
completions
.iter()
.map(|completion| completion.label.as_str())
.collect::<Vec<_>>(),
vec!["Greeting", "Home", "Other"],
);
let Some(CompletionTextEdit::Edit(edit)) = &completions[0].text_edit else {
panic!("a completion should replace the link target");
};
assert_eq!(
edit.range,
Range::new(Position::new(2, 1), Position::new(2, 4)),
);
assert_eq!(edit.new_text, "Greeting]");
}
#[test]
fn completions_support_unfinished_links() {
let source = "# Home\n\n[Gre\n\n# Greeting";
let completions =
completions_for_document(&untitled_uri(), source, Position::new(2, 4)).unwrap();
let greeting = completions
.iter()
.find(|completion| completion.label == "Greeting")
.unwrap();
let Some(CompletionTextEdit::Edit(edit)) = &greeting.text_edit else {
panic!("a completion should replace the unfinished target");
};
assert_eq!(
edit.range,
Range::new(Position::new(2, 1), Position::new(2, 4)),
);
assert_eq!(edit.new_text, "Greeting]");
}
#[test]
fn completions_do_not_duplicate_closing_delimiters() {
let source = "# Home\n\n[Gr]\n\n# Greeting";
let completions =
completions_for_document(&untitled_uri(), source, Position::new(2, 3)).unwrap();
let greeting = completions
.iter()
.find(|completion| completion.label == "Greeting")
.unwrap();
let Some(CompletionTextEdit::Edit(edit)) = &greeting.text_edit else {
panic!("a completion should replace the link target");
};
let start = byte_offset(source, edit.range.start).unwrap();
let end = byte_offset(source, edit.range.end).unwrap();
let mut applied = source.to_owned();
applied.replace_range(start..end, &edit.new_text);
assert_eq!(applied, "# Home\n\n[Greeting]\n\n# Greeting");
}
#[test]
fn completions_escape_title_delimiters() {
let source = "# Home\n\n[]\n\n# A[B]";
let completions =
completions_for_document(&untitled_uri(), source, Position::new(2, 1)).unwrap();
let bracketed = completions
.iter()
.find(|completion| completion.label == "A[B]")
.unwrap();
let Some(CompletionTextEdit::Edit(edit)) = &bracketed.text_edit else {
panic!("a completion should encode the title as a text link");
};
assert_eq!(bracketed.filter_text.as_deref(), Some("A\\[B\\]"));
assert_eq!(edit.new_text, "A\\[B\\]]");
}
#[test]
fn completions_ignore_other_contexts() {
let source = concat!("# Home\n\nprose [", "file:notes.txt]");
assert!(completions_for_document(&untitled_uri(), source, Position::new(2, 2)).is_none());
assert!(completions_for_document(&untitled_uri(), source, Position::new(2, 10)).is_none());
assert!(completions_for_document(&untitled_uri(), source, Position::new(0, 3)).is_none());
}
#[test]
fn completions_omit_filesystem_link_titles() {
let source = "# Home\n\n[]\n\n# file:notes.txt\n\n# dir:images\n\n# Other";
let completions =
completions_for_document(&untitled_uri(), source, Position::new(2, 1)).unwrap();
assert_eq!(
completions
.iter()
.map(|completion| completion.label.as_str())
.collect::<Vec<_>>(),
vec!["Home", "Other"],
);
}
#[test]
fn definitions_target_node_titles() {
let source = "# Home\n\n😀 [Greeting]\n\n# Greeting\n\nHello!";
let wiki = TestWiki::new(source);
let uri = Uri::from_file_path(wiki.path()).unwrap();
let definition = definition_for_document(&uri, source, Position::new(2, 5)).unwrap();
let GotoDefinitionResponse::Link(links) = definition else {
panic!("a text link should have one definition");
};
let [link] = links.as_slice() else {
panic!("a text link should have exactly one definition");
};
assert_eq!(link.target_uri, uri);
assert_eq!(
link.origin_selection_range,
Some(Range::new(Position::new(2, 3), Position::new(2, 13))),
);
assert_eq!(
link.target_range,
Range::new(Position::new(4, 0), Position::new(6, 6)),
);
assert_eq!(
link.target_selection_range,
Range::new(Position::new(4, 2), Position::new(4, 10)),
);
}
#[test]
fn hovers_preview_nodes() {
let source = "# Home\n\n😀 [Greeting]\n\n# Greeting\n\nLiteral \\[brackets\\] and [Home].";
let wiki = TestWiki::new(source);
let uri = Uri::from_file_path(wiki.path()).unwrap();
let hover = hover_for_document(&uri, source, Position::new(2, 5)).unwrap();
let HoverContents::Markup(contents) = hover.contents else {
panic!("a node preview should use markup content");
};
assert_eq!(contents.kind, MarkupKind::Markdown);
let home_url =
reveal_range_command_url(&uri, source, SourceRange { start: 2, end: 6 }).unwrap();
assert_eq!(
contents.value,
format!(
"# Greeting\n\nLiteral [brackets] and \
[[Home]]({home_url}).",
),
);
assert_eq!(
hover.range,
Some(Range::new(Position::new(2, 3), Position::new(2, 13))),
);
}
#[test]
fn hovers_preview_node_titles() {
let source = "# Home\n\n[Greeting]\n\n# Greeting\n\nHello!";
let wiki = TestWiki::new(source);
let uri = Uri::from_file_path(wiki.path()).unwrap();
let hover = hover_for_document(&uri, source, Position::new(4, 4)).unwrap();
let HoverContents::Markup(contents) = hover.contents else {
panic!("a node preview should use markup content");
};
assert_eq!(contents.kind, MarkupKind::Markdown);
assert_eq!(contents.value, "# Greeting\n\nHello!");
assert_eq!(
hover.range,
Some(Range::new(Position::new(4, 2), Position::new(4, 10))),
);
}
#[test]
fn references_find_text_links() {
let source = concat!(
"# Home\n\n",
"[Greeting] and [Greeting].\n\n",
"# Other\n\n",
"[Greeting]\n\n",
"# Greeting",
);
let wiki = TestWiki::new(source);
let uri = Uri::from_file_path(wiki.path()).unwrap();
let from_title = references_for_document(&uri, source, Position::new(8, 3), false).unwrap();
let from_link = references_for_document(&uri, source, Position::new(2, 3), false).unwrap();
assert_eq!(from_title, from_link);
assert!(from_title.iter().all(|location| location.uri == uri));
assert_eq!(
from_title
.iter()
.map(|location| location.range)
.collect::<Vec<_>>(),
vec![
Range::new(Position::new(2, 0), Position::new(2, 10)),
Range::new(Position::new(2, 15), Position::new(2, 25)),
Range::new(Position::new(6, 0), Position::new(6, 10)),
],
);
}
#[test]
fn references_optionally_include_declarations() {
let source = "# Home\n\n[Greeting]\n\n# Greeting";
let wiki = TestWiki::new(source);
let uri = Uri::from_file_path(wiki.path()).unwrap();
let locations = references_for_document(&uri, source, Position::new(4, 3), true).unwrap();
assert_eq!(
locations
.iter()
.map(|location| location.range)
.collect::<Vec<_>>(),
vec![
Range::new(Position::new(2, 0), Position::new(2, 10)),
Range::new(Position::new(4, 2), Position::new(4, 10)),
],
);
}
#[test]
fn references_can_be_empty() {
let source = "# Home";
let wiki = TestWiki::new(source);
let uri = Uri::from_file_path(wiki.path()).unwrap();
assert_eq!(
references_for_document(&uri, source, Position::new(0, 3), false),
Some(Vec::new()),
);
assert!(references_for_document(&uri, source, Position::new(0, 0), false).is_none());
}
#[test]
fn document_highlights_find_node_occurrences() {
let source = concat!(
"# Home\n\n",
"[Greeting] and [Greeting].\n\n",
"# Other\n\n",
"[Greeting]\n\n",
"# Greeting",
);
let uri = untitled_uri();
let from_title =
document_highlights_for_document(&uri, source, Position::new(8, 3)).unwrap();
let from_link =
document_highlights_for_document(&uri, source, Position::new(2, 3)).unwrap();
let expected = vec![
DocumentHighlight {
range: Range::new(Position::new(2, 0), Position::new(2, 10)),
kind: Some(DocumentHighlightKind::READ),
},
DocumentHighlight {
range: Range::new(Position::new(2, 15), Position::new(2, 25)),
kind: Some(DocumentHighlightKind::READ),
},
DocumentHighlight {
range: Range::new(Position::new(6, 0), Position::new(6, 10)),
kind: Some(DocumentHighlightKind::READ),
},
DocumentHighlight {
range: Range::new(Position::new(8, 2), Position::new(8, 10)),
kind: Some(DocumentHighlightKind::WRITE),
},
];
assert_eq!(from_title, expected);
assert_eq!(from_link, expected);
}
#[test]
fn document_highlights_distinguish_unreferenced_nodes() {
let source = "# Home";
let uri = untitled_uri();
assert_eq!(
document_highlights_for_document(&uri, source, Position::new(0, 3)),
Some(vec![DocumentHighlight {
range: Range::new(Position::new(0, 2), Position::new(0, 6)),
kind: Some(DocumentHighlightKind::WRITE),
}]),
);
assert!(document_highlights_for_document(&uri, source, Position::new(0, 0)).is_none());
}
#[test]
fn document_highlights_find_filesystem_links() {
let source = concat!(
"# Home\n\n",
"[",
"file:foo] [",
"file:foo] [",
"dir:bar]\n\n",
"# Other\n\n",
"[",
"dir:bar]",
);
let uri = untitled_uri();
let file_highlights =
document_highlights_for_document(&uri, source, Position::new(2, 3)).unwrap();
let directory_highlights =
document_highlights_for_document(&uri, source, Position::new(2, 25)).unwrap();
assert_eq!(
file_highlights,
vec![
DocumentHighlight {
range: Range::new(Position::new(2, 0), Position::new(2, 10)),
kind: Some(DocumentHighlightKind::READ),
},
DocumentHighlight {
range: Range::new(Position::new(2, 11), Position::new(2, 21)),
kind: Some(DocumentHighlightKind::READ),
},
],
);
assert_eq!(
directory_highlights,
vec![
DocumentHighlight {
range: Range::new(Position::new(2, 22), Position::new(2, 31)),
kind: Some(DocumentHighlightKind::READ),
},
DocumentHighlight {
range: Range::new(Position::new(6, 0), Position::new(6, 9)),
kind: Some(DocumentHighlightKind::READ),
},
],
);
}
#[test]
fn rename_preparation_selects_title_text() {
let source = "# Home\n\n[Greeting]\n\n# Greeting";
let from_title =
prepare_rename_for_document(&untitled_uri(), source, Position::new(4, 3)).unwrap();
let from_link =
prepare_rename_for_document(&untitled_uri(), source, Position::new(2, 4)).unwrap();
assert_eq!(
from_title,
PrepareRenameResponse::RangeWithPlaceholder {
range: Range::new(Position::new(4, 2), Position::new(4, 10)),
placeholder: "Greeting".to_owned(),
},
);
assert_eq!(
from_link,
PrepareRenameResponse::RangeWithPlaceholder {
range: Range::new(Position::new(2, 1), Position::new(2, 9)),
placeholder: "Greeting".to_owned(),
},
);
}
#[test]
fn rename_updates_every_occurrence() {
let source = "# Home\n\n[Greeting] and [Greeting]\n\n# Greeting";
let workspace_edit = rename_for_document(
&untitled_uri(),
source,
Position::new(4, 3),
" Salutation\t",
)
.unwrap()
.unwrap();
let edits = &workspace_edit.changes.unwrap()[&untitled_uri()];
assert_eq!(edits.len(), 3);
assert_eq!(edits[0].new_text, "Salutation");
assert_eq!(edits[0].range.start, Position::new(2, 1));
assert_eq!(edits[1].new_text, "Salutation");
assert_eq!(edits[1].range.start, Position::new(2, 16));
assert_eq!(edits[2].new_text, "Salutation");
assert_eq!(edits[2].range.start, Position::new(4, 2));
}
#[test]
fn rename_escapes_link_delimiters() {
let source = "# Home\n\n[Greeting]\n\n# Greeting";
let workspace_edit =
rename_for_document(&untitled_uri(), source, Position::new(2, 4), "A[B]")
.unwrap()
.unwrap();
let edits = &workspace_edit.changes.unwrap()[&untitled_uri()];
assert_eq!(edits[0].new_text, "A\\[B\\]");
assert_eq!(edits[1].new_text, "A[B]");
}
#[test]
fn rename_allows_home() {
let source = "# Home";
let workspace_edit =
rename_for_document(&untitled_uri(), source, Position::new(0, 3), "Start")
.unwrap()
.unwrap();
let edits = &workspace_edit.changes.unwrap()[&untitled_uri()];
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "Start");
}
#[test]
fn rename_rejects_invalid_titles() {
let source = "# Home\n\n[Greeting]\n\n# Greeting";
let cursor = Position::new(4, 3);
assert_eq!(
rename_for_document(&untitled_uri(), source, cursor, " \t").unwrap_err(),
"A node title cannot be empty.",
);
assert_eq!(
rename_for_document(&untitled_uri(), source, cursor, "Hello\nworld").unwrap_err(),
"A node title cannot contain a line break.",
);
assert_eq!(
rename_for_document(&untitled_uri(), source, cursor, "Home").unwrap_err(),
"Node `Home` already exists.",
);
assert_eq!(
rename_for_document(&untitled_uri(), source, cursor, "file:notes.txt").unwrap_err(),
"A text-linked node title cannot start with `file:` or `dir:`.",
);
}
#[test]
fn navigation_ignores_filesystem_links() {
let source = concat!("# Home\n\n[", "file:notes.txt]");
let wiki = TestWiki::new(source);
let uri = Uri::from_file_path(wiki.path()).unwrap();
assert!(definition_for_document(&uri, source, Position::new(2, 4)).is_none());
assert!(hover_for_document(&uri, source, Position::new(2, 4)).is_none());
assert!(references_for_document(&uri, source, Position::new(2, 4), false).is_none());
}
#[test]
fn navigation_requires_parseable_source() {
let source = "# Home\n\n[Greeting]\n\n# Greeting\n\nUnexpected]";
let wiki = TestWiki::new(source);
let uri = Uri::from_file_path(wiki.path()).unwrap();
assert!(definition_for_document(&uri, source, Position::new(2, 4)).is_none());
assert!(hover_for_document(&uri, source, Position::new(2, 4)).is_none());
assert!(references_for_document(&uri, source, Position::new(2, 4), false).is_none());
}
#[test]
fn formatting_replaces_noncanonical_source() {
let source = "# Zulu\n\n😀\n\n# Home\n\n[Zulu]";
let wiki = TestWiki::new(source);
let edit = formatting_edit(Some(wiki.path()), source).unwrap().unwrap();
assert_eq!(
edit.range,
Range::new(Position::new(0, 0), Position::new(6, 6)),
);
assert_eq!(edit.new_text, "# Home\n\n[Zulu]\n\n# Zulu\n\n😀\n");
}
#[test]
fn formatting_omits_edits_for_canonical_source() {
let source = "# Home\n";
let wiki = TestWiki::new(source);
assert!(
formatting_edit(Some(wiki.path()), source)
.unwrap()
.is_none(),
);
}
#[test]
fn formatting_rejects_invalid_source() {
let source = "# Elsewhere\n";
let wiki = TestWiki::new(source);
assert!(formatting_edit(Some(wiki.path()), source).is_err());
}
#[test]
fn formatting_supports_untitled_wikis() {
let source = "# Zulu\n\n# Home\n\n[Zulu]";
let edit = formatting_edit(None, source).unwrap().unwrap();
assert_eq!(edit.new_text, "# Home\n\n[Zulu]\n\n# Zulu\n");
}
#[test]
fn formatting_differences_are_not_diagnostics() {
let source = "# Zulu\n\n# Home\n\n[Zulu]";
let wiki = TestWiki::new(source);
let uri = Uri::from_file_path(wiki.path()).unwrap();
assert!(diagnostics_for_document(&uri, source).is_empty());
}
#[test]
fn source_errors_become_precise_diagnostics() {
let source = "# Home\n😀 ]";
let error = parser::parse(Some(Path::new("wiki.mull")), source)
.unwrap_err()
.into_iter()
.next()
.unwrap();
let diagnostic = diagnostic_from_error(source, &error);
assert_eq!(
diagnostic.range,
Range::new(Position::new(1, 3), Position::new(1, 4)),
);
assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
assert_eq!(diagnostic.source.as_deref(), Some("mull"));
assert_eq!(diagnostic.message, "Unexpected closing link delimiter.");
}
#[test]
fn errors_without_ranges_point_to_document_start() {
let error = crate::error::Error::new("Something went wrong.", None, None, None);
let diagnostic = diagnostic_from_error("# Home\n", &error);
assert_eq!(
diagnostic.range,
Range::new(Position::new(0, 0), Position::new(0, 0)),
);
}
#[test]
fn ranges_can_span_windows_line_endings() {
let source = "first\r\nsecond";
let error = crate::error::Error::new(
"Something went wrong.",
Some(Path::new("wiki.mull")),
Some((source, SourceRange { start: 0, end: 9 })),
None,
);
let diagnostic = diagnostic_from_error(source, &error);
assert_eq!(
diagnostic.range,
Range::new(Position::new(0, 0), Position::new(1, 2)),
);
}
}