use crate::{
checker::analyze,
error::{Error, SourceRange},
parser,
wiki::{DIRECTORY_LINK_PREFIX, FILE_LINK_PREFIX, Link, TextNode, Wiki},
};
use std::{
borrow::Cow,
collections::HashMap,
path::Path,
sync::{Arc, Mutex},
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, GotoDefinitionParams,
GotoDefinitionResponse, Hover, HoverContents, HoverParams, HoverProviderCapability,
InitializeParams, InitializeResult, InitializedParams, Location, LocationLink,
MarkupContent, MarkupKind, MessageType, OneOf, Position, PositionEncodingKind,
PrepareRenameResponse, Range, ReferenceParams, RenameOptions, RenameParams,
ServerCapabilities, ServerInfo, TextDocumentPositionParams, TextDocumentSyncCapability,
TextDocumentSyncKind, TextDocumentSyncOptions, TextEdit, Uri, WorkDoneProgressOptions,
WorkspaceEdit,
},
};
const CHECK_DELAY: Duration = Duration::from_millis(250);
#[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>>>,
}
impl Backend {
fn new(client: Client) -> Self {
Self {
client,
documents: Arc::new(Mutex::new(HashMap::new())),
}
}
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> {
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)),
rename_provider: Some(OneOf::Right(RenameOptions {
prepare_provider: Some(true),
work_done_progress_options: WorkDoneProgressOptions::default(),
})),
document_formatting_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, "Mull language server initialized.")
.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 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 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,
)))
}
}
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, target_source_range) =
completion_context(wiki_path.as_deref(), source_contents, byte_offset)?;
let replacement_range = lsp_range(source_contents, target_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,
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, target_source_range));
}
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) = previewed_node_at(&wiki, byte_offset)?;
Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: node.to_markdown(),
}),
range: Some(lsp_range(source_contents, source_range)),
})
}
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 title = referenced_title_at(&wiki, source_contents, byte_offset)?;
let node = wiki.text_nodes.get(title)?;
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<_>>();
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 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) = referenced_node_at(&wiki, source_contents, byte_offset)?;
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)) = referenced_node_at(&wiki, source_contents, byte_offset)
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))
}
fn previewed_node_at(wiki: &Wiki, byte_offset: usize) -> Option<(&TextNode, SourceRange)> {
if let Some(node) = wiki.text_nodes.values().find(|node| {
node.title_source_range.start <= byte_offset && byte_offset < node.title_source_range.end
}) {
return Some((node, node.title_source_range));
}
let (title, source_range) = text_link_at(wiki, byte_offset)?;
wiki.text_nodes.get(title).map(|node| (node, source_range))
}
fn referenced_node_at<'a>(
wiki: &'a Wiki,
source_contents: &str,
byte_offset: usize,
) -> Option<(&'a TextNode, SourceRange)> {
if let Some(node) = wiki.text_nodes.values().find(|node| {
node.title_source_range.start <= byte_offset && byte_offset < node.title_source_range.end
}) {
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 target_source_range = text_link_target_source_range(source_contents, source_range)?;
Some((node, target_source_range))
}
fn referenced_title_at<'a>(
wiki: &'a Wiki,
source_contents: &str,
byte_offset: usize,
) -> Option<&'a str> {
referenced_node_at(wiki, source_contents, byte_offset)
.map(|(node, _source_range)| node.title.as_str())
}
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, formatting_edit, hover_for_document, position,
prepare_rename_for_document, references_for_document, rename_for_document,
};
use crate::{error::SourceRange, parser};
use std::{
fs,
path::{Path, PathBuf},
process,
sync::atomic::{AtomicUsize, Ordering},
};
use tower_lsp_server::ls_types::{
CompletionTextEdit, DiagnosticSeverity, GotoDefinitionResponse, HoverContents, MarkupKind,
Position, PrepareRenameResponse, Range, 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 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, 3)),
);
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_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);
assert_eq!(
contents.value,
"# Greeting\n\nLiteral [brackets] and *[Home]*.",
);
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 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)),
);
}
}