use crate::{
analyzer::analyze,
cancellation::{CancellationFlag, Outcome},
error::{Error, SourceRange},
format::CodePath,
parser::{self, normalize_filesystem_path},
path_util::relative_path,
wiki::{
DIRECTORY_LINK_PREFIX, FILE_LINK_PREFIX, HOME_TITLE, Link, TITLE_MARKER, TITLE_PREFIX,
TextNode, Wiki,
},
wiki_tree::wiki_tree_walker,
};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use std::{
borrow::Cow,
collections::{HashMap, HashSet},
fs,
path::{Component, Path, PathBuf},
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::{
CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams,
CodeActionProviderCapability, CodeActionResponse, Command, CompletionItem,
CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
CompletionTextEdit, DeleteFile, DeleteFileOptions, Diagnostic, DiagnosticSeverity,
DidChangeTextDocumentParams, DidChangeWatchedFilesParams,
DidChangeWatchedFilesRegistrationOptions, DidCloseTextDocumentParams,
DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentChangeOperation,
DocumentChanges, DocumentFormattingParams, DocumentHighlight, DocumentHighlightKind,
DocumentHighlightParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
FileSystemWatcher, GlobPattern, GotoDefinitionParams, GotoDefinitionResponse, Hover,
HoverContents, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult,
InitializedParams, Location, LocationLink, MarkupContent, MarkupKind, MessageType, OneOf,
OptionalVersionedTextDocumentIdentifier, Position, PositionEncodingKind,
PrepareRenameResponse, Range, ReferenceParams, Registration, RenameFile, RenameOptions,
RenameParams, ResourceOp, ResourceOperationKind, ServerCapabilities, ServerInfo,
SymbolInformation, SymbolKind, TextDocumentEdit, TextDocumentPositionParams,
TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions, TextEdit, Uri,
WorkDoneProgressOptions, WorkspaceEdit,
},
};
const CHECK_DELAY: Duration = Duration::from_millis(250);
const REVEAL_RANGE_COMMAND: &str = "mull.revealRange";
const TRIGGER_SUGGEST_COMMAND: &str = "editor.action.triggerSuggest";
#[derive(Debug)]
struct PendingCheck {
handle: JoinHandle<()>,
cancellation: CancellationFlag,
}
impl PendingCheck {
fn cancel(self) {
self.cancellation.cancel();
self.handle.abort();
}
}
#[derive(Debug)]
struct OpenDocument {
contents: String,
version: i32,
generation: u64,
pending_check: Option<PendingCheck>,
}
#[derive(Debug)]
struct Backend {
client: Client,
documents: Arc<Mutex<HashMap<Uri, OpenDocument>>>,
supports_file_deletes: AtomicBool,
supports_file_renames: AtomicBool,
supports_hierarchical_document_symbols: AtomicBool,
supports_watched_file_registration: AtomicBool,
}
#[derive(Clone, Copy)]
struct FileOperationSupport {
rename: bool,
delete: bool,
}
impl Backend {
fn new(client: Client) -> Self {
Self {
client,
documents: Arc::new(Mutex::new(HashMap::new())),
supports_file_deletes: AtomicBool::new(false),
supports_file_renames: AtomicBool::new(false),
supports_hierarchical_document_symbols: AtomicBool::new(false),
supports_watched_file_registration: AtomicBool::new(false),
}
}
fn store_and_check_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 cancellation = CancellationFlag::default();
let check_cancellation = cancellation.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.cancel();
}
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(PendingCheck {
handle: 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 Some(diagnostics) = tokio::task::spawn_blocking(move || {
diagnostics_for_document(&check_uri, &diagnostic_contents, &check_cancellation)
})
.await
.unwrap_or_else(|error| {
Some(vec![diagnostic(
&fallback_contents,
None,
format!("Mull was unable to check the wiki: {error}."),
)])
}) else {
return;
};
if documents
.lock()
.expect("the open-document mutex should not be poisoned")
.get(&diagnostic_uri)
.is_some_and(|document| document.generation == generation)
{
client
.publish_diagnostics(diagnostic_uri, diagnostics, Some(version))
.await;
}
}),
cancellation,
});
}
fn recheck_saved_document(&self, uri: Uri, contents: Option<String>) {
let snapshot = self
.documents
.lock()
.expect("the open-document mutex should not be poisoned")
.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.store_and_check_document(uri, contents, version, Duration::ZERO);
}
}
fn recheck_open_documents(&self, changed_uris: &[&Uri]) {
let snapshots = self
.documents
.lock()
.expect("the open-document mutex should not be poisoned")
.iter()
.filter(|(uri, _document)| !changed_uris.contains(uri))
.map(|(uri, document)| (uri.clone(), document.contents.clone(), document.version))
.collect::<Vec<_>>();
for (uri, contents, version) in snapshots {
self.store_and_check_document(uri, contents, version, CHECK_DELAY);
}
}
fn document_contents(&self, uri: &Uri) -> Option<String> {
self.document_snapshot(uri)
.map(|(contents, _version)| contents)
}
fn document_snapshot(&self, uri: &Uri) -> Option<(String, i32)> {
self.documents
.lock()
.expect("the open-document mutex should not be poisoned")
.get(uri)
.map(|document| (document.contents.clone(), document.version))
}
}
#[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> {
self.supports_hierarchical_document_symbols.store(
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),
Ordering::Relaxed,
);
let resource_operations = params
.capabilities
.workspace
.as_ref()
.and_then(|capabilities| capabilities.workspace_edit.as_ref())
.filter(|capabilities| capabilities.document_changes == Some(true))
.and_then(|capabilities| capabilities.resource_operations.as_deref())
.unwrap_or_default();
self.supports_file_renames.store(
resource_operations.contains(&ResourceOperationKind::Rename),
Ordering::Relaxed,
);
self.supports_file_deletes.store(
resource_operations.contains(&ResourceOperationKind::Delete),
Ordering::Relaxed,
);
self.supports_watched_file_registration.store(
params
.capabilities
.workspace
.as_ref()
.and_then(|capabilities| capabilities.did_change_watched_files.as_ref())
.and_then(|capabilities| capabilities.dynamic_registration)
.unwrap_or(false),
Ordering::Relaxed,
);
Ok(InitializeResult {
capabilities: ServerCapabilities {
completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec!["[".to_owned(), ":".to_owned(), "/".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)),
code_action_provider: Some(CodeActionProviderCapability::Simple(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;
if self
.supports_watched_file_registration
.load(Ordering::Relaxed)
&& let Err(error) = self
.client
.register_capability(vec![Registration {
id: "mull-watched-files".to_owned(),
method: "workspace/didChangeWatchedFiles".to_owned(),
register_options: serde_json::to_value(
DidChangeWatchedFilesRegistrationOptions {
watchers: vec![FileSystemWatcher {
glob_pattern: GlobPattern::String("**/*".to_owned()),
kind: None,
}],
},
)
.ok(),
}])
.await
{
self.client
.log_message(
MessageType::WARNING,
format!("Mull was unable to watch for filesystem changes: {error}."),
)
.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) {
self.store_and_check_document(
params.text_document.uri,
params.text_document.text,
params.text_document.version,
Duration::ZERO,
);
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
if let Some(change) = params.content_changes.into_iter().next_back() {
self.store_and_check_document(
params.text_document.uri,
change.text,
params.text_document.version,
CHECK_DELAY,
);
}
}
async fn did_save(&self, params: DidSaveTextDocumentParams) {
self.recheck_saved_document(params.text_document.uri, params.text);
}
async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
self.recheck_open_documents(
¶ms
.changes
.iter()
.map(|change| &change.uri)
.collect::<Vec<_>>(),
);
}
async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
let Some(contents) =
self.document_contents(¶ms.text_document_position.text_document.uri)
else {
return Ok(None);
};
Ok(completion_for_document(
¶ms.text_document_position.text_document.uri,
&contents,
params.text_document_position.position,
)
.map(CompletionResponse::Array))
}
async fn goto_definition(
&self,
params: GotoDefinitionParams,
) -> Result<Option<GotoDefinitionResponse>> {
let Some(contents) =
self.document_contents(¶ms.text_document_position_params.text_document.uri)
else {
return Ok(None);
};
Ok(goto_definition_for_document(
¶ms.text_document_position_params.text_document.uri,
&contents,
params.text_document_position_params.position,
))
}
async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
let Some(contents) =
self.document_contents(¶ms.text_document_position_params.text_document.uri)
else {
return Ok(None);
};
Ok(hover_for_document(
¶ms.text_document_position_params.text_document.uri,
&contents,
params.text_document_position_params.position,
))
}
async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
let Some(contents) =
self.document_contents(¶ms.text_document_position.text_document.uri)
else {
return Ok(None);
};
Ok(references_for_document(
¶ms.text_document_position.text_document.uri,
&contents,
params.text_document_position.position,
params.context.include_declaration,
))
}
async fn document_highlight(
&self,
params: DocumentHighlightParams,
) -> Result<Option<Vec<DocumentHighlight>>> {
let Some(contents) =
self.document_contents(¶ms.text_document_position_params.text_document.uri)
else {
return Ok(None);
};
Ok(document_highlight_for_document(
¶ms.text_document_position_params.text_document.uri,
&contents,
params.text_document_position_params.position,
))
}
async fn prepare_rename(
&self,
params: TextDocumentPositionParams,
) -> Result<Option<PrepareRenameResponse>> {
let Some(contents) = self.document_contents(¶ms.text_document.uri) else {
return Ok(None);
};
prepare_rename_for_document(
¶ms.text_document.uri,
&contents,
params.position,
self.supports_file_renames.load(Ordering::Relaxed),
)
.map_err(JsonRpcError::invalid_params)
}
async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
let uri = ¶ms.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let Some((contents, version)) = self.document_snapshot(uri) else {
return Ok(None);
};
rename_for_document(
uri,
&contents,
version,
position,
¶ms.new_name,
FileOperationSupport {
rename: self.supports_file_renames.load(Ordering::Relaxed),
delete: self.supports_file_deletes.load(Ordering::Relaxed),
},
)
.map_err(JsonRpcError::invalid_params)
}
async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
let Some(contents) = self.document_contents(¶ms.text_document.uri) else {
return Ok(None);
};
Ok(formatting_for_document(
¶ms.text_document.uri,
&contents,
))
}
async fn document_symbol(
&self,
params: DocumentSymbolParams,
) -> Result<Option<DocumentSymbolResponse>> {
let Some(contents) = self.document_contents(¶ms.text_document.uri) else {
return Ok(None);
};
Ok(document_symbol_for_document(
¶ms.text_document.uri,
&contents,
self.supports_hierarchical_document_symbols
.load(Ordering::Relaxed),
))
}
async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
let Some(contents) = self.document_contents(¶ms.text_document.uri) else {
return Ok(None);
};
Ok(code_action_for_document(
¶ms.text_document.uri,
&contents,
params.range,
¶ms.context.diagnostics,
))
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
let document = self
.documents
.lock()
.expect("the open-document mutex should not be poisoned")
.remove(¶ms.text_document.uri);
if let Some(pending_check) = document.and_then(|document| document.pending_check) {
pending_check.cancel();
}
self.client
.publish_diagnostics(params.text_document.uri, Vec::new(), None)
.await;
}
}
fn diagnostics_for_document(
uri: &Uri,
source_contents: &str,
cancellation: &CancellationFlag,
) -> Option<Vec<Diagnostic>> {
let Outcome::Completed(result) =
analyze(local_path(uri).as_deref(), source_contents, cancellation)
else {
return None;
};
Some(result.map_or_else(
|errors| {
errors
.iter()
.map(|error| diagnostic_from_error(source_contents, error))
.collect()
},
|_wiki| Vec::new(),
))
}
fn completion_for_document(
uri: &Uri,
source_contents: &str,
cursor: Position,
) -> Option<Vec<CompletionItem>> {
let cursor_offset = byte_offset(source_contents, cursor)?;
if let Some(context) = filesystem_link_context(source_contents, cursor_offset) {
return Some(filesystem_link_completions(
&local_path(uri)?,
source_contents,
&context,
));
}
let (wiki, replacement_source_range) =
text_link_context(local_path(uri).as_deref(), source_contents, cursor_offset)?;
Some(text_link_completions(
&wiki,
source_contents,
replacement_source_range,
))
}
fn goto_definition_for_document(
uri: &Uri,
source_contents: &str,
cursor: Position,
) -> Option<GotoDefinitionResponse> {
let wiki = parser::parse(local_path(uri).as_deref(), source_contents).ok()?;
let (node, origin_source_range) = node_at(
&wiki,
source_contents,
byte_offset(source_contents, cursor)?,
LinkExtent::Whole,
)?;
Some(GotoDefinitionResponse::Link(vec![LocationLink {
origin_selection_range: Some(lsp_range(source_contents, origin_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 = parser::parse(local_path(uri).as_deref(), source_contents).ok()?;
let (node, source_range) = node_at(
&wiki,
source_contents,
byte_offset(source_contents, cursor)?,
LinkExtent::Whole,
)?;
Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: node.to_markdown(|title| {
reveal_range_command_url(
uri,
source_contents,
wiki.text_nodes.get(title)?.title_source_range,
)
}),
}),
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 = parser::parse(local_path(uri).as_deref(), source_contents).ok()?;
let (node, _source_range) = node_at(
&wiki,
source_contents,
byte_offset(source_contents, cursor)?,
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_highlight_for_document(
uri: &Uri,
source_contents: &str,
cursor: Position,
) -> Option<Vec<DocumentHighlight>> {
let wiki = parser::parse(local_path(uri).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 = link_at(&wiki, byte_offset).filter(|link| !matches!(link, Link::Text { .. }))?;
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 prepare_rename_for_document(
uri: &Uri,
source_contents: &str,
cursor: Position,
supports_file_renames: bool,
) -> std::result::Result<Option<PrepareRenameResponse>, String> {
let Ok(wiki) = parser::parse(local_path(uri).as_deref(), source_contents) else {
return Ok(None);
};
let Some(cursor_offset) = byte_offset(source_contents, cursor) else {
return Ok(None);
};
if let Some(filesystem_node) = renamable_filesystem_node_at(
&wiki,
uri,
source_contents,
cursor_offset,
supports_file_renames,
)? {
return Ok(Some(PrepareRenameResponse::RangeWithPlaceholder {
range: lsp_range(source_contents, filesystem_node.path_source_range),
placeholder: unescape_link_delimiters(
&source_contents[filesystem_node.path_source_range.start
..filesystem_node.path_source_range.end],
),
}));
}
let Some((node, source_range)) =
node_at(&wiki, source_contents, cursor_offset, LinkExtent::Target)
else {
return Ok(None);
};
Ok(Some(PrepareRenameResponse::RangeWithPlaceholder {
range: lsp_range(source_contents, source_range),
placeholder: node.title.clone(),
}))
}
fn rename_for_document(
uri: &Uri,
source_contents: &str,
version: i32,
cursor: Position,
new_name: &str,
file_operation_support: FileOperationSupport,
) -> std::result::Result<Option<WorkspaceEdit>, String> {
let Ok(wiki) = parser::parse(local_path(uri).as_deref(), source_contents) else {
return Ok(None);
};
let Some(cursor_offset) = byte_offset(source_contents, cursor) else {
return Ok(None);
};
match rename_filesystem_node_for_document(
&wiki,
uri,
source_contents,
version,
cursor_offset,
new_name,
file_operation_support,
) {
Ok(None) => {
rename_text_node_for_document(&wiki, uri, source_contents, cursor_offset, new_name)
}
result => result,
}
}
fn rename_text_node_for_document(
wiki: &Wiki,
uri: &Uri,
source_contents: &str,
cursor_offset: usize,
new_name: &str,
) -> std::result::Result<Option<WorkspaceEdit>, String> {
let Some((node, _source_range)) =
node_at(wiki, source_contents, cursor_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 new_title.starts_with(FILE_LINK_PREFIX) || new_title.starts_with(DIRECTORY_LINK_PREFIX) {
return Err(format!(
"A 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_link_delimiters(new_title)));
}
}
edits.sort_by_key(|(source_range, _new_text)| (source_range.start, source_range.end));
Ok(Some(WorkspaceEdit {
changes: Some(HashMap::from([(
uri.clone(),
edits
.into_iter()
.map(|(source_range, new_text)| {
TextEdit::new(lsp_range(source_contents, source_range), new_text)
})
.collect(),
)])),
..WorkspaceEdit::default()
}))
}
fn rename_filesystem_node_for_document(
wiki: &Wiki,
uri: &Uri,
source_contents: &str,
version: i32,
cursor_offset: usize,
new_name: &str,
file_operation_support: FileOperationSupport,
) -> std::result::Result<Option<WorkspaceEdit>, String> {
let Some(RenamableFilesystemNode {
wiki_directory,
old_path,
is_directory,
..
}) = renamable_filesystem_node_at(
wiki,
uri,
source_contents,
cursor_offset,
file_operation_support.rename,
)?
else {
return Ok(None);
};
let wiki_directory = wiki_directory.as_path();
let old_path = old_path.as_path();
let new_path = normalize_filesystem_path(new_name.trim())?;
if new_path == old_path {
return Ok(Some(WorkspaceEdit::default()));
}
if new_path.as_os_str().is_empty() {
return Err("A file or directory cannot be renamed to the wiki directory.".to_owned());
}
if is_directory && new_path.starts_with(old_path) {
return Err(format!(
"Directory {} cannot be moved into itself.",
old_path.code_path(),
));
}
check_rename_destination(wiki_directory, old_path, &new_path)?;
let old_absolute_path = wiki_directory.join(old_path);
let new_absolute_path = wiki_directory.join(&new_path);
let edits = filesystem_rename_edits(wiki, source_contents, old_path, &new_path, is_directory);
let old_uri = Uri::from_file_path(&old_absolute_path)
.expect("a path within a saved wiki's directory should be absolute");
let new_uri = Uri::from_file_path(&new_absolute_path)
.expect("a path within a saved wiki's directory should be absolute");
let mut operations = vec![
DocumentChangeOperation::Edit(TextDocumentEdit {
text_document: OptionalVersionedTextDocumentIdentifier {
uri: uri.clone(),
version: Some(version),
},
edits: edits
.into_iter()
.map(|(source_range, new_text)| {
OneOf::Left(TextEdit::new(
lsp_range(source_contents, source_range),
new_text,
))
})
.collect(),
}),
DocumentChangeOperation::Op(ResourceOp::Rename(RenameFile {
old_uri,
new_uri,
options: None,
annotation_id: None,
})),
];
if file_operation_support.delete
&& let Some(directory) =
outermost_directory_emptied_by_rename(wiki, wiki_directory, old_path, &new_path)
{
operations.push(DocumentChangeOperation::Op(ResourceOp::Delete(
DeleteFile {
uri: Uri::from_file_path(wiki_directory.join(directory))
.expect("a path within a saved wiki's directory should be absolute"),
options: Some(DeleteFileOptions {
recursive: Some(true),
ignore_if_not_exists: Some(true),
}),
annotation_id: None,
},
)));
}
Ok(Some(WorkspaceEdit {
document_changes: Some(DocumentChanges::Operations(operations)),
..WorkspaceEdit::default()
}))
}
fn formatting_for_document(uri: &Uri, source_contents: &str) -> Option<Vec<TextEdit>> {
let rendered_wiki = parser::parse(local_path(uri).as_deref(), source_contents)
.ok()?
.to_string();
if source_contents == rendered_wiki {
Some(Vec::new())
} else {
Some(vec![TextEdit::new(
Range::new(
Position::new(0, 0),
lsp_position(source_contents, source_contents.len()),
),
rendered_wiki,
)])
}
}
#[allow(
deprecated,
reason = "The protocol's DocumentSymbol type retains a required legacy field."
)]
fn document_symbol_for_document(
uri: &Uri,
source_contents: &str,
supports_hierarchy: bool,
) -> Option<DocumentSymbolResponse> {
let wiki = parser::parse(local_path(uri).as_deref(), source_contents).ok()?;
let mut nodes = wiki.text_nodes.values().collect::<Vec<_>>();
nodes.sort_by_key(|node| node.source_range.start);
Some(if supports_hierarchy {
DocumentSymbolResponse::Nested(
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(),
)
} else {
DocumentSymbolResponse::Flat(
nodes
.into_iter()
.map(|node| SymbolInformation {
name: node.title.clone(),
kind: SymbolKind::OBJECT,
tags: None,
deprecated: None,
location: Location::new(
uri.clone(),
lsp_range(source_contents, node.title_source_range),
),
container_name: None,
})
.collect(),
)
})
}
fn code_action_for_document(
uri: &Uri,
source_contents: &str,
range: Range,
diagnostics: &[Diagnostic],
) -> Option<CodeActionResponse> {
let wiki = parser::parse(local_path(uri).as_deref(), source_contents).ok()?;
let mut actions = Vec::new();
let document_start = Range::new(Position::new(0, 0), Position::new(0, 0));
if range.start == document_start.start && !wiki.text_nodes.contains_key(HOME_TITLE) {
let separator = if source_contents.is_empty() { "" } else { "\n" };
actions.push(create_node_action(
uri,
HOME_TITLE,
TextEdit::new(
document_start,
format!("{TITLE_PREFIX}{HOME_TITLE}\n{separator}"),
),
diagnostics,
document_start,
));
}
if let Some(byte_offset) = byte_offset(source_contents, range.start)
&& let Some(Link::Text {
title,
source_range,
}) = link_at(&wiki, byte_offset)
&& !title.is_empty()
&& !wiki.text_nodes.contains_key(title)
{
let separator = if source_contents.ends_with("\n\n") {
""
} else if source_contents.ends_with('\n') {
"\n"
} else {
"\n\n"
};
let end = lsp_position(source_contents, source_contents.len());
actions.push(create_node_action(
uri,
title,
TextEdit::new(
Range::new(end, end),
format!("{separator}{TITLE_PREFIX}{title}\n"),
),
diagnostics,
lsp_range(source_contents, *source_range),
));
}
(!actions.is_empty()).then_some(actions)
}
struct FilesystemLinkContext {
is_directory_link: bool,
directory: PathBuf, segment_start: usize, cursor: usize,
closing_delimiter: Option<usize>,
}
fn filesystem_link_context(source_contents: &str, cursor: usize) -> Option<FilesystemLinkContext> {
let line_start = source_contents[..cursor]
.rfind('\n')
.map_or(0, |index| index + '\n'.len_utf8());
let line_end = source_contents[cursor..]
.find('\n')
.map_or(source_contents.len(), |index| cursor + index);
let line = &source_contents[line_start..line_end];
let line = line.strip_suffix('\r').unwrap_or(line);
if line == TITLE_MARKER || line.starts_with(TITLE_PREFIX) {
return None;
}
let mut opening_delimiter = None;
let mut closing_delimiter = None;
let mut previous_was_backslash = false;
for (index, character) in line.char_indices() {
let offset = line_start + index;
let is_escaped_delimiter = previous_was_backslash && matches!(character, '[' | ']');
previous_was_backslash = character == '\\';
if is_escaped_delimiter {
continue;
}
if offset < cursor {
match character {
'[' => opening_delimiter = Some(offset),
']' => opening_delimiter = None,
_ => {}
}
} else if matches!(character, '[' | ']') {
closing_delimiter = (character == ']').then_some(offset);
break;
}
}
let target = source_contents[opening_delimiter? + '['.len_utf8()..cursor].trim_start();
let (is_directory_link, typed_path) = match target.strip_prefix(FILE_LINK_PREFIX) {
Some(typed_path) => (false, typed_path),
None => (true, target.strip_prefix(DIRECTORY_LINK_PREFIX)?),
};
let typed_directory = &typed_path[..typed_path.rfind('/').map_or(0, |index| index + 1)];
let mut directory = PathBuf::new();
for component in
Path::new(&typed_directory.replace("\\[", "[").replace("\\]", "]")).components()
{
match component {
Component::Normal(component) => directory.push(component),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(FilesystemLinkContext {
is_directory_link,
directory,
segment_start: cursor - (typed_path.len() - typed_directory.len()),
cursor,
closing_delimiter,
})
}
fn filesystem_link_completions(
wiki_path: &Path,
source_contents: &str,
context: &FilesystemLinkContext,
) -> Vec<CompletionItem> {
let wiki_directory = wiki_path
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let Ok(mut walker_builder) = wiki_tree_walker(wiki_directory) else {
return Vec::new();
};
walker_builder
.max_depth(Some(context.directory.components().count() + 1))
.filter_entry({
let wiki_directory = wiki_directory.to_owned();
let relative_wiki_path = relative_path(&wiki_directory, wiki_path).to_owned();
let directory = context.directory.clone();
move |entry| {
let path = relative_path(&wiki_directory, entry.path());
path != relative_wiki_path
&& (directory.starts_with(path) || path.parent() == Some(directory.as_path()))
}
});
let mut completions = Vec::new();
for entry in walker_builder.build().flatten() {
let path = relative_path(wiki_directory, entry.path());
let (Some(file_type), Some(name)) = (entry.file_type(), entry.file_name().to_str()) else {
continue;
};
if path.parent() != Some(context.directory.as_path()) {
continue;
}
let escaped_name = escape_link_delimiters(name);
let (label, kind, new_text, replacement_end, command) = if file_type.is_dir() {
(
format!("{name}/"),
CompletionItemKind::FOLDER,
format!("{escaped_name}/"),
context.closing_delimiter.unwrap_or(context.cursor),
Some(Command {
title: "Suggest".to_owned(),
command: TRIGGER_SUGGEST_COMMAND.to_owned(),
arguments: None,
}),
)
} else if context.is_directory_link {
continue;
} else {
(
name.to_owned(),
CompletionItemKind::FILE,
format!("{escaped_name}]"),
context
.closing_delimiter
.map_or(context.cursor, |offset| offset + ']'.len_utf8()),
None,
)
};
let replacement_range = lsp_range(
source_contents,
SourceRange {
start: context.segment_start,
end: replacement_end,
},
);
completions.push(CompletionItem {
label,
kind: Some(kind),
filter_text: Some(escaped_name),
text_edit: Some(CompletionTextEdit::Edit(TextEdit::new(
replacement_range,
new_text,
))),
command,
..CompletionItem::default()
});
}
completions.sort_by(|a, b| a.label.cmp(&b.label));
completions
}
fn text_link_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(Link::Text { source_range, .. }) = link_at(&wiki, byte_offset)
{
let source_range = *source_range;
return Some((wiki, 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 Some(Link::Text { source_range, .. }) = link_at(&wiki, byte_offset) else {
return None;
};
let source_range = SourceRange {
start: source_range.start,
end: source_range.end - ']'.len_utf8(),
};
Some((wiki, source_range))
}
fn text_link_completions(
wiki: &Wiki,
source_contents: &str,
replacement_source_range: SourceRange,
) -> Vec<CompletionItem> {
let replacement_range = lsp_range(source_contents, replacement_source_range);
let mut titles = wiki.text_nodes.keys().collect::<Vec<_>>();
titles.sort();
titles
.into_iter()
.map(|title| {
let escaped_title = escape_link_delimiters(title);
CompletionItem {
label: title.clone(),
kind: Some(CompletionItemKind::REFERENCE),
filter_text: Some(format!("[{escaped_title}")),
text_edit: Some(CompletionTextEdit::Edit(TextEdit::new(
replacement_range,
format!("[{escaped_title}]"),
))),
..CompletionItem::default()
}
})
.collect()
}
struct RenamableFilesystemNode {
wiki_directory: PathBuf,
path_source_range: SourceRange,
old_path: PathBuf,
is_directory: bool,
}
fn renamable_filesystem_node_at(
wiki: &Wiki,
uri: &Uri,
source_contents: &str,
cursor_offset: usize,
supports_file_renames: bool,
) -> std::result::Result<Option<RenamableFilesystemNode>, String> {
let (is_directory, old_path, source_range) = match link_at(wiki, cursor_offset) {
Some(Link::File { path, source_range }) => (false, path.clone(), *source_range),
Some(Link::Directory { path, source_range }) => (true, path.clone(), *source_range),
Some(Link::Text { .. }) | None => return Ok(None),
};
let path_source_range = filesystem_link_path_source_range(source_contents, source_range);
let Some(wiki_path) = local_path(uri) else {
return Err("Save the wiki before renaming the files it links to.".to_owned());
};
if !supports_file_renames {
return Err("This editor does not support renaming files.".to_owned());
}
let wiki_directory = wiki_path
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
if old_path.as_os_str().is_empty() {
return Err("The wiki directory cannot be renamed.".to_owned());
}
let kind = if is_directory { "Directory" } else { "File" };
if !fs::metadata(wiki_directory.join(&old_path))
.is_ok_and(|metadata| metadata.is_dir() == is_directory)
{
return Err(format!("{kind} {} does not exist.", old_path.code_path()));
}
if old_path == relative_path(wiki_directory, &wiki_path) {
return Err("The wiki cannot be renamed through one of its own links.".to_owned());
}
Ok(Some(RenamableFilesystemNode {
wiki_directory: wiki_directory.to_owned(),
path_source_range,
old_path,
is_directory,
}))
}
fn check_rename_destination(
wiki_directory: &Path,
old_path: &Path,
new_path: &Path,
) -> std::result::Result<(), String> {
let new_absolute_path = wiki_directory.join(new_path);
if fs::symlink_metadata(&new_absolute_path).is_ok()
&& fs::canonicalize(&new_absolute_path).ok()
!= fs::canonicalize(wiki_directory.join(old_path)).ok()
{
return Err(format!("Path {} already exists.", new_path.code_path()));
}
if let Some(ancestor) = new_path
.ancestors()
.skip(1)
.find(|ancestor| wiki_directory.join(ancestor).exists())
&& !wiki_directory.join(ancestor).is_dir()
{
return Err(format!("Path {} is not a directory.", ancestor.code_path()));
}
Ok(())
}
fn filesystem_rename_edits(
wiki: &Wiki,
source_contents: &str,
old_path: &Path,
new_path: &Path,
is_directory: bool,
) -> Vec<(SourceRange, String)> {
let mut edits = Vec::new();
for link in wiki.text_nodes.values().flat_map(|node| &node.links) {
let (Link::File { path, source_range } | Link::Directory { path, source_range }) = link
else {
continue;
};
let Ok(suffix) = path.strip_prefix(old_path) else {
continue;
};
if !is_directory && (matches!(link, Link::Directory { .. }) || path != old_path) {
continue;
}
let path_source_range = filesystem_link_path_source_range(source_contents, *source_range);
edits.push((
path_source_range,
render_link_path(
&source_contents[path_source_range.start..path_source_range.end],
&new_path.join(suffix),
),
));
}
edits.sort_by_key(|(source_range, _new_text)| (source_range.start, source_range.end));
edits
}
fn render_link_path(old_source: &str, path: &Path) -> String {
let mut rendered = path
.components()
.map(|component| {
component
.as_os_str()
.to_str()
.expect("link paths should come from UTF-8 text")
})
.collect::<Vec<_>>()
.join("/");
if old_source.starts_with("./") {
rendered.insert_str(0, "./");
}
if old_source.len() > 1 && old_source.ends_with('/') {
rendered.push('/');
}
escape_link_delimiters(&rendered)
}
fn outermost_directory_emptied_by_rename(
wiki: &Wiki,
wiki_directory: &Path,
old_path: &Path,
new_path: &Path,
) -> Option<PathBuf> {
let linked_directories = wiki
.text_nodes
.values()
.flat_map(|node| &node.links)
.filter_map(|link| match link {
Link::Directory { path, .. } => Some(path.as_path()),
Link::Text { .. } | Link::File { .. } => None,
})
.collect::<HashSet<_>>();
let mut emptied_directory = None;
let mut removed_entry = old_path;
while let Some(directory) = removed_entry
.parent()
.filter(|directory| !directory.as_os_str().is_empty())
{
if new_path.starts_with(directory) || linked_directories.contains(directory) {
break;
}
let Ok(mut entries) = fs::read_dir(wiki_directory.join(directory)) else {
break;
};
let contains_only_removed_entry = entries
.next()
.and_then(std::result::Result::ok)
.is_some_and(|entry| Some(entry.file_name().as_os_str()) == removed_entry.file_name())
&& entries.next().is_none();
if !contains_only_removed_entry {
break;
}
emptied_directory = Some(directory.to_owned());
removed_entry = directory;
}
emptied_directory
}
fn create_node_action(
uri: &Uri,
title: &str,
edit: TextEdit,
diagnostics: &[Diagnostic],
diagnostic_range: Range,
) -> CodeActionOrCommand {
CodeActionOrCommand::CodeAction(CodeAction {
title: format!("Create node `{title}`"),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(
diagnostics
.iter()
.filter(|diagnostic| diagnostic.range == diagnostic_range)
.cloned()
.collect(),
),
edit: Some(WorkspaceEdit {
changes: Some(HashMap::from([(uri.clone(), vec![edit])])),
..WorkspaceEdit::default()
}),
is_preferred: Some(true),
..CodeAction::default()
})
}
#[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) = 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 Link::Text {
title,
source_range,
} = link_at(wiki, byte_offset)?
else {
return None;
};
let source_range = *source_range;
Some((
wiki.text_nodes.get(title)?,
match link_extent {
LinkExtent::Whole => source_range,
LinkExtent::Target => text_link_target_source_range(source_contents, source_range)?,
},
))
}
fn link_at(wiki: &Wiki, byte_offset: usize) -> Option<&Link> {
wiki.text_nodes.values().find_map(|node| {
node.links.iter().find(|link| {
let (Link::Text { source_range, .. }
| Link::File { source_range, .. }
| Link::Directory { source_range, .. }) = link;
source_range.start <= byte_offset && byte_offset < source_range.end
})
})
}
fn text_link_target_source_range(
source_contents: &str,
source_range: SourceRange,
) -> Option<SourceRange> {
let target_source = source_contents
.get(source_range.start..source_range.end)?
.strip_prefix('[')?
.strip_suffix(']')?;
let start = source_range.start + '['.len_utf8();
Some(SourceRange {
start,
end: start + target_source.len(),
})
}
fn filesystem_link_path_source_range(
source_contents: &str,
source_range: SourceRange,
) -> SourceRange {
let target_source_range = text_link_target_source_range(source_contents, source_range)
.expect("a parsed link should be delimited by square brackets");
let target = &source_contents[target_source_range.start..target_source_range.end];
let trimmed_target = target.trim();
let path = trimmed_target
.strip_prefix(FILE_LINK_PREFIX)
.or_else(|| trimmed_target.strip_prefix(DIRECTORY_LINK_PREFIX))
.expect("a parsed filesystem link should start with a filesystem-link prefix");
let start = target_source_range.start
+ (target.len() - target.trim_start().len())
+ (trimmed_target.len() - path.len());
SourceRange {
start,
end: start + path.len(),
}
}
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_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 byte_offset(source_contents: &str, position: Position) -> Option<usize> {
let mut line_start = 0;
for _ in 0..position.line {
line_start += source_contents[line_start..].find('\n')? + '\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 lsp_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");
Position::new(
u32::try_from(prefix.bytes().filter(|byte| *byte == b'\n').count()).unwrap_or(u32::MAX),
u32::try_from(
source_contents[prefix
.rfind('\n')
.map_or(0, |index| index + '\n'.len_utf8())
..byte_offset]
.encode_utf16()
.count(),
)
.unwrap_or(u32::MAX),
)
}
fn lsp_range(source_contents: &str, source_range: SourceRange) -> Range {
Range::new(
lsp_position(source_contents, source_range.start),
lsp_position(source_contents, source_range.end),
)
}
fn reveal_range_command_url(
uri: &Uri,
source_contents: &str,
source_range: SourceRange,
) -> Option<String> {
let range = lsp_range(source_contents, source_range);
Some(format!(
"command:{REVEAL_RANGE_COMMAND}?{}",
utf8_percent_encode(
&serde_json::to_string(&(
uri.as_str(),
range.start.line,
range.start.character,
range.end.line,
range.end.character,
))
.ok()?,
NON_ALPHANUMERIC,
),
))
}
fn escape_link_delimiters(title: &str) -> String {
title.replace('[', "\\[").replace(']', "\\]")
}
fn unescape_link_delimiters(source: &str) -> String {
source.replace("\\[", "[").replace("\\]", "]")
}
fn diagnostic_from_error(source_contents: &str, error: &Error) -> Diagnostic {
diagnostic(
source_contents,
error.source_range(),
error.reason().map_or_else(
|| error.message().to_owned(),
|reason| format!("{}\n\nReason: {reason}", error.message()),
),
)
}
fn diagnostic(
source_contents: &str,
source_range: Option<crate::error::SourceRange>,
message: String,
) -> Diagnostic {
Diagnostic {
range: lsp_range(
source_contents,
source_range.unwrap_or(crate::error::SourceRange { start: 0, end: 0 }),
),
severity: Some(DiagnosticSeverity::ERROR),
source: Some(env!("CARGO_PKG_NAME").to_owned()),
message,
..Diagnostic::default()
}
}
fn local_path(uri: &Uri) -> Option<Cow<'_, Path>> {
uri.scheme()
.as_str()
.eq_ignore_ascii_case("file")
.then(|| uri.to_file_path())
.flatten()
}
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::{
FileOperationSupport, byte_offset, code_action_for_document, completion_for_document,
diagnostic_from_error, diagnostics_for_document, document_highlight_for_document,
document_symbol_for_document, formatting_for_document, goto_definition_for_document,
hover_for_document, lsp_position, prepare_rename_for_document, references_for_document,
rename_for_document, reveal_range_command_url,
};
use crate::{cancellation::CancellationFlag, error::SourceRange, parser};
use std::{
fs,
path::{Path, PathBuf},
process,
sync::atomic::{AtomicUsize, Ordering},
};
use tower_lsp_server::ls_types::{
CodeActionKind, CodeActionOrCommand, CompletionItem, CompletionItemKind,
CompletionTextEdit, Diagnostic, DiagnosticSeverity, DocumentChangeOperation,
DocumentChanges, DocumentHighlight, DocumentHighlightKind, DocumentSymbolResponse,
GotoDefinitionResponse, HoverContents, MarkupKind, OneOf, Position, PrepareRenameResponse,
Range, ResourceOp, SymbolKind, Uri, WorkspaceEdit,
};
static NEXT_DIRECTORY: AtomicUsize = AtomicUsize::new(0);
fn diagnostics(uri: &Uri, source_contents: &str) -> Vec<Diagnostic> {
diagnostics_for_document(uri, source_contents, &CancellationFlag::default())
.expect("a check without cancellation should complete")
}
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!(lsp_position(source, 0), Position::new(0, 0));
assert_eq!(lsp_position(source, 5), Position::new(1, 0));
assert_eq!(lsp_position(source, 9), Position::new(1, 2));
assert_eq!(lsp_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_symbol_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(2, 5)),
);
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_symbol_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(&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(&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(&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!(
goto_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_links() {
let source = "# Home\n\n[Gr]\n\n# Greeting\n\n# Other";
let completions =
completion_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");
};
let link_range = Range::new(Position::new(2, 0), Position::new(2, 4));
assert_eq!(edit.range, link_range);
assert_eq!(edit.new_text, "[Greeting]");
let completions =
completion_for_document(&untitled_uri(), source, Position::new(2, 0)).unwrap();
let Some(CompletionTextEdit::Edit(edit)) = &completions[0].text_edit else {
panic!("a completion should replace the link");
};
assert_eq!(edit.range, link_range);
}
#[test]
fn completions_support_unfinished_links() {
let source = "# Home\n\n[Gre\n\n# Greeting";
let completions =
completion_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 link");
};
assert_eq!(
edit.range,
Range::new(Position::new(2, 0), 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 =
completion_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");
};
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 =
completion_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!(completion_for_document(&untitled_uri(), source, Position::new(2, 2)).is_none());
assert!(completion_for_document(&untitled_uri(), source, Position::new(2, 10)).is_none());
assert!(completion_for_document(&untitled_uri(), source, Position::new(0, 3)).is_none());
}
fn completion_edits(completions: &[CompletionItem]) -> Vec<(&str, Range, &str)> {
completions
.iter()
.map(|completion| {
let Some(CompletionTextEdit::Edit(edit)) = &completion.text_edit else {
panic!("a completion should replace part of the link");
};
(
completion.label.as_str(),
edit.range,
edit.new_text.as_str(),
)
})
.collect()
}
#[test]
fn completions_list_filesystem_entries() {
let source = concat!("# Home\n\n[", "file:]");
let wiki = TestWiki::new(source);
let directory = wiki.path().parent().unwrap();
fs::write(directory.join(".gitignore"), "ignored.txt\n").unwrap();
fs::write(directory.join("ignored.txt"), "ignored").unwrap();
fs::write(directory.join("notes.txt"), "notes").unwrap();
fs::create_dir(directory.join("images")).unwrap();
fs::write(directory.join("images/photo.jpg"), "photo").unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
let completions = completion_for_document(&uri, source, Position::new(2, 6)).unwrap();
let empty_path = Range::new(Position::new(2, 6), Position::new(2, 6));
let closed_path = Range::new(Position::new(2, 6), Position::new(2, 7));
assert_eq!(
completion_edits(&completions),
vec![
(".gitignore", closed_path, ".gitignore]"),
("images/", empty_path, "images/"),
("notes.txt", closed_path, "notes.txt]"),
],
);
assert_eq!(completions[0].kind, Some(CompletionItemKind::FILE));
assert_eq!(completions[1].kind, Some(CompletionItemKind::FOLDER));
assert!(completions[0].command.is_none());
assert_eq!(
completions[1]
.command
.as_ref()
.map(|command| command.command.as_str()),
Some("editor.action.triggerSuggest"),
);
}
#[test]
fn completions_list_nested_directories() {
let source = concat!("# Home\n\n[", "dir:./images/r");
let wiki = TestWiki::new(source);
let directory = wiki.path().parent().unwrap();
fs::create_dir_all(directory.join("images/raw/large")).unwrap();
fs::write(directory.join("images/photo.jpg"), "photo").unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
let completions = completion_for_document(&uri, source, Position::new(2, 15)).unwrap();
assert_eq!(
completion_edits(&completions),
vec![(
"raw/",
Range::new(Position::new(2, 14), Position::new(2, 15)),
"raw/",
)],
);
assert_eq!(completions[0].filter_text.as_deref(), Some("raw"));
}
#[test]
fn completions_escape_path_delimiters() {
let source = concat!("# Home\n\n[", "file:a\\[b\\]/]");
let wiki = TestWiki::new(source);
let directory = wiki.path().parent().unwrap();
fs::create_dir_all(directory.join("a[b]")).unwrap();
fs::write(directory.join("a[b]/c[d].txt"), "content").unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
let completions = completion_for_document(&uri, source, Position::new(2, 6)).unwrap();
assert_eq!(
completion_edits(&completions),
vec![(
"a[b]/",
Range::new(Position::new(2, 6), Position::new(2, 13)),
"a\\[b\\]/",
)],
);
let completions = completion_for_document(&uri, source, Position::new(2, 13)).unwrap();
assert_eq!(
completion_edits(&completions),
vec![(
"c[d].txt",
Range::new(Position::new(2, 13), Position::new(2, 14)),
"c\\[d\\].txt]",
)],
);
}
#[test]
fn completions_ignore_invalid_filesystem_contexts() {
let source = concat!("# [", "file:\n\n[", "file:../] [", "dir:/] \\[", "file:");
let wiki = TestWiki::new(source);
fs::write(wiki.path().parent().unwrap().join("notes.txt"), "notes").unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
for cursor in [
Position::new(0, 8),
Position::new(2, 9),
Position::new(2, 17),
Position::new(2, 26),
] {
assert!(
completion_for_document(&uri, source, cursor)
.is_none_or(|completions| completions.is_empty()),
);
}
let source = concat!("# Home\n\n[", "file:]");
assert!(completion_for_document(&untitled_uri(), source, Position::new(2, 6)).is_none());
}
#[test]
fn definitions_of_titles_target_themselves() {
let source = "# Home\n\n[Home]";
let definition =
goto_definition_for_document(&untitled_uri(), source, Position::new(0, 3)).unwrap();
let GotoDefinitionResponse::Link(links) = definition else {
panic!("a title should have one definition");
};
let [link] = links.as_slice() else {
panic!("a title should have exactly one definition");
};
let title_range = Range::new(Position::new(0, 2), Position::new(0, 6));
assert_eq!(link.origin_selection_range, Some(title_range));
assert_eq!(link.target_selection_range, title_range);
}
#[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 = goto_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_highlight_for_document(&uri, source, Position::new(8, 3)).unwrap();
let from_link = document_highlight_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_highlight_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_highlight_for_document(&uri, source, Position::new(0, 0)).is_none());
}
#[test]
fn document_highlights_ignore_unresolved_text_links() {
let source = "# Home\n\n[Missing]";
assert!(
document_highlight_for_document(&untitled_uri(), source, Position::new(2, 3)).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_highlight_for_document(&uri, source, Position::new(2, 3)).unwrap();
let directory_highlights =
document_highlight_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), false)
.unwrap()
.unwrap();
let from_link =
prepare_rename_for_document(&untitled_uri(), source, Position::new(2, 4), false)
.unwrap()
.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,
7,
Position::new(4, 3),
" Salutation\t",
ALL_FILE_OPERATIONS,
)
.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,
7,
Position::new(2, 4),
"A[B]",
ALL_FILE_OPERATIONS,
)
.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,
7,
Position::new(0, 3),
"Start",
ALL_FILE_OPERATIONS,
)
.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,
7,
cursor,
" \t",
ALL_FILE_OPERATIONS,
)
.unwrap_err(),
"A node title cannot be empty.",
);
assert_eq!(
rename_for_document(
&untitled_uri(),
source,
7,
cursor,
"Hello\nworld",
ALL_FILE_OPERATIONS,
)
.unwrap_err(),
"A node title cannot contain a line break.",
);
assert_eq!(
rename_for_document(
&untitled_uri(),
source,
7,
cursor,
"Home",
ALL_FILE_OPERATIONS,
)
.unwrap_err(),
"Node `Home` already exists.",
);
assert_eq!(
rename_for_document(
&untitled_uri(),
source,
7,
cursor,
"file:notes.txt",
ALL_FILE_OPERATIONS,
)
.unwrap_err(),
"A node title cannot start with `file:` or `dir:`.",
);
}
const ALL_FILE_OPERATIONS: FileOperationSupport = FileOperationSupport {
rename: true,
delete: true,
};
fn apply_filesystem_rename(
source: &str,
workspace_edit: WorkspaceEdit,
) -> (String, Uri, Uri, Vec<Uri>) {
let Some(DocumentChanges::Operations(operations)) = workspace_edit.document_changes else {
panic!("a filesystem rename should consist of document change operations");
};
let [
DocumentChangeOperation::Edit(text_document_edit),
DocumentChangeOperation::Op(ResourceOp::Rename(rename)),
deletions @ ..,
] = operations.as_slice()
else {
panic!("a filesystem rename should edit the wiki and then rename one node");
};
assert_eq!(text_document_edit.text_document.version, Some(7_i32));
let mut applied = source.to_owned();
for edit in text_document_edit.edits.iter().rev() {
let OneOf::Left(edit) = edit else {
panic!("a filesystem rename should not annotate its edits");
};
let start = byte_offset(source, edit.range.start).unwrap();
let end = byte_offset(source, edit.range.end).unwrap();
applied.replace_range(start..end, &edit.new_text);
}
let deleted_uris = deletions
.iter()
.map(|operation| {
let DocumentChangeOperation::Op(ResourceOp::Delete(deletion)) = operation else {
panic!(
"a filesystem rename should only delete directories after renaming a node",
);
};
assert_eq!(
deletion
.options
.as_ref()
.and_then(|options| options.recursive),
Some(true),
);
deletion.uri.clone()
})
.collect();
(
applied,
rename.old_uri.clone(),
rename.new_uri.clone(),
deleted_uris,
)
}
#[test]
fn rename_dispatches_by_node_kind() {
let source = concat!("# Home\n\n[Home] [", "file:notes.txt]");
let wiki = TestWiki::new(source);
fs::write(wiki.path().parent().unwrap().join("notes.txt"), "notes").unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
let rename = |character, new_name| {
rename_for_document(
&uri,
source,
7,
Position::new(2, character),
new_name,
ALL_FILE_OPERATIONS,
)
.unwrap()
};
let text_node_edit = rename(2, "Start").unwrap();
assert!(text_node_edit.changes.is_some() && text_node_edit.document_changes.is_none());
let filesystem_node_edit = rename(10, "renamed.txt").unwrap();
assert!(
filesystem_node_edit.changes.is_none()
&& filesystem_node_edit.document_changes.is_some(),
);
assert!(rename(6, "Start").is_none());
}
#[test]
fn rename_preparation_selects_filesystem_paths() {
let source = concat!("# Home\n\n[ ", "file:./a\\[1\\].txt ]");
let wiki = TestWiki::new(source);
fs::write(wiki.path().parent().unwrap().join("a[1].txt"), "a").unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
assert_eq!(
prepare_rename_for_document(&uri, source, Position::new(2, 4), true)
.unwrap()
.unwrap(),
PrepareRenameResponse::RangeWithPlaceholder {
range: Range::new(Position::new(2, 7), Position::new(2, 19)),
placeholder: "./a[1].txt".to_owned(),
},
);
}
#[test]
fn rename_preparation_rejects_unrenamable_entries() {
let source = concat!(
"# Home\n\n[",
"file:notes.txt] [",
"file:missing.txt] [",
"dir:.] [",
"file:wiki.mull]",
);
let wiki = TestWiki::new(source);
fs::write(wiki.path().parent().unwrap().join("notes.txt"), "notes").unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
let prepare = |uri: &Uri, character, supports_file_renames| {
prepare_rename_for_document(
uri,
source,
Position::new(2, character),
supports_file_renames,
)
.unwrap_err()
};
assert_eq!(
prepare(&untitled_uri(), 2, true),
"Save the wiki before renaming the files it links to.",
);
assert_eq!(
prepare(&uri, 2, false),
"This editor does not support renaming files.",
);
assert_eq!(
prepare(&uri, 18, true),
"File `missing.txt` does not exist.",
);
assert_eq!(
prepare(&uri, 37, true),
"The wiki directory cannot be renamed.",
);
assert_eq!(
prepare(&uri, 45, true),
"The wiki cannot be renamed through one of its own links.",
);
}
#[test]
fn rename_moves_linked_files() {
let source = concat!(
"# Home\n\n[",
"file:./notes.txt] [",
"file:notes.txt] [",
"dir:notes]",
);
let wiki = TestWiki::new(source);
let directory = wiki.path().parent().unwrap();
fs::write(directory.join("notes.txt"), "notes").unwrap();
fs::create_dir(directory.join("notes")).unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
let workspace_edit = rename_for_document(
&uri,
source,
7,
Position::new(2, 4),
" notes/a[1].txt ",
ALL_FILE_OPERATIONS,
)
.unwrap()
.unwrap();
assert_eq!(
apply_filesystem_rename(source, workspace_edit),
(
concat!(
"# Home\n\n[",
"file:./notes/a\\[1\\].txt] [",
"file:notes/a\\[1\\].txt] [",
"dir:notes]",
)
.to_owned(),
Uri::from_file_path(directory.join("notes.txt")).unwrap(),
Uri::from_file_path(directory.join("notes/a[1].txt")).unwrap(),
Vec::new(),
),
);
}
#[test]
fn rename_moves_linked_directories() {
let source = concat!(
"# Home\n\n[",
"dir:images/] [",
"dir:./images/raw] [",
"file:images/photo.jpg] [",
"file:images.txt]",
);
let wiki = TestWiki::new(source);
let directory = wiki.path().parent().unwrap();
fs::create_dir_all(directory.join("images/raw")).unwrap();
fs::write(directory.join("images/photo.jpg"), "photo").unwrap();
fs::write(directory.join("images.txt"), "images").unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
let workspace_edit = rename_for_document(
&uri,
source,
7,
Position::new(2, 4),
"photos",
ALL_FILE_OPERATIONS,
)
.unwrap()
.unwrap();
assert_eq!(
apply_filesystem_rename(source, workspace_edit),
(
concat!(
"# Home\n\n[",
"dir:photos/] [",
"dir:./photos/raw] [",
"file:photos/photo.jpg] [",
"file:images.txt]",
)
.to_owned(),
Uri::from_file_path(directory.join("images")).unwrap(),
Uri::from_file_path(directory.join("photos")).unwrap(),
Vec::new(),
),
);
}
#[test]
fn rename_to_same_path_does_nothing() {
let source = concat!("# Home\n\n[", "file:notes.txt]");
let wiki = TestWiki::new(source);
fs::write(wiki.path().parent().unwrap().join("notes.txt"), "notes").unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
assert_eq!(
rename_for_document(
&uri,
source,
7,
Position::new(2, 3),
"./notes.txt",
ALL_FILE_OPERATIONS,
)
.unwrap(),
Some(WorkspaceEdit::default()),
);
}
#[test]
fn rename_creates_and_deletes_directories() {
let source = concat!(
"# Home\n\n[",
"file:a/b/photo.jpg] [",
"file:c/d/e.txt] [",
"dir:f] [",
"file:f/g/h.txt]",
);
let wiki = TestWiki::new(source);
let directory = wiki.path().parent().unwrap();
fs::create_dir_all(directory.join("a/b")).unwrap();
fs::write(directory.join("a/b/photo.jpg"), "photo").unwrap();
fs::create_dir_all(directory.join("c/d")).unwrap();
fs::write(directory.join("c/d/e.txt"), "e").unwrap();
fs::write(directory.join("c/sibling.txt"), "sibling").unwrap();
fs::create_dir_all(directory.join("f/g")).unwrap();
fs::write(directory.join("f/g/h.txt"), "h").unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
let deleted_uris = |character, new_name, file_operation_support| {
let workspace_edit = rename_for_document(
&uri,
source,
7,
Position::new(2, character),
new_name,
file_operation_support,
)
.unwrap()
.unwrap();
apply_filesystem_rename(source, workspace_edit).3
};
let directory_uri = |path| Uri::from_file_path(directory.join(path)).unwrap();
assert_eq!(
deleted_uris(2, "new/photos/photo.jpg", ALL_FILE_OPERATIONS),
vec![directory_uri("a")],
);
assert_eq!(
deleted_uris(22, "e.txt", ALL_FILE_OPERATIONS),
vec![directory_uri("c/d")],
);
assert_eq!(
deleted_uris(2, "a/photo.jpg", ALL_FILE_OPERATIONS),
vec![directory_uri("a/b")],
);
assert_eq!(
deleted_uris(46, "h.txt", ALL_FILE_OPERATIONS),
vec![directory_uri("f/g")],
);
assert_eq!(
deleted_uris(
2,
"photo.jpg",
FileOperationSupport {
rename: true,
delete: false,
},
),
Vec::<Uri>::new(),
);
}
#[test]
fn rename_rejects_invalid_filesystem_renames() {
let source = concat!(
"# Home\n\n[",
"file:notes.txt] [",
"dir:images] [",
"file:missing.txt] [",
"dir:.] [",
"file:wiki.mull]",
);
let wiki = TestWiki::new(source);
let directory = wiki.path().parent().unwrap();
fs::write(directory.join("notes.txt"), "notes").unwrap();
fs::write(directory.join("other.txt"), "other").unwrap();
fs::create_dir(directory.join("images")).unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
let rename = |uri: &Uri, character, new_name, file_operation_support| {
rename_for_document(
uri,
source,
7,
Position::new(2, character),
new_name,
file_operation_support,
)
.unwrap_err()
};
assert_eq!(
rename(&untitled_uri(), 2, "other.txt", ALL_FILE_OPERATIONS),
"Save the wiki before renaming the files it links to.",
);
assert_eq!(
rename(
&uri,
2,
"renamed.txt",
FileOperationSupport {
rename: false,
delete: true,
},
),
"This editor does not support renaming files.",
);
assert_eq!(
rename(&uri, 2, "../notes.txt", ALL_FILE_OPERATIONS),
"Path `../notes.txt` must not contain `..`.",
);
assert_eq!(
rename(&uri, 2, "./", ALL_FILE_OPERATIONS),
"A file or directory cannot be renamed to the wiki directory.",
);
assert_eq!(
rename(&uri, 2, "other.txt", ALL_FILE_OPERATIONS),
"Path `other.txt` already exists.",
);
assert_eq!(
rename(&uri, 2, "notes.txt/inner.txt", ALL_FILE_OPERATIONS),
"Path `notes.txt` is not a directory.",
);
assert_eq!(
rename(&uri, 18, "images/raw", ALL_FILE_OPERATIONS),
"Directory `images` cannot be moved into itself.",
);
assert_eq!(
rename(&uri, 31, "found.txt", ALL_FILE_OPERATIONS),
"File `missing.txt` does not exist.",
);
assert_eq!(
rename(&uri, 50, "elsewhere", ALL_FILE_OPERATIONS),
"The wiki directory cannot be renamed.",
);
assert_eq!(
rename(&uri, 58, "renamed.mull", ALL_FILE_OPERATIONS),
"The wiki cannot be renamed through one of its own links.",
);
}
fn apply_code_action(uri: &Uri, source: &str, action: &CodeActionOrCommand) -> String {
let CodeActionOrCommand::CodeAction(action) = action else {
panic!("a code action should not be a bare command");
};
let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
let [edit] = changes[uri].as_slice() else {
panic!("a code action should make exactly one edit");
};
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);
applied
}
#[test]
fn code_actions_create_missing_nodes() {
let source = "# Home\n\n[Greeting]";
let uri = untitled_uri();
let link_range = Range::new(Position::new(2, 0), Position::new(2, 10));
let link_diagnostic = diagnostics(&uri, source).remove(0);
let other_diagnostic = Diagnostic {
range: Range::new(Position::new(0, 0), Position::new(0, 1)),
..link_diagnostic.clone()
};
assert_eq!(link_diagnostic.range, link_range);
let actions = code_action_for_document(
&uri,
source,
Range::new(Position::new(2, 3), Position::new(2, 3)),
&[other_diagnostic, link_diagnostic.clone()],
)
.unwrap();
let [action] = actions.as_slice() else {
panic!("a missing destination should have exactly one code action");
};
let CodeActionOrCommand::CodeAction(code_action) = action else {
panic!("a code action should not be a bare command");
};
assert_eq!(code_action.title, "Create node `Greeting`");
assert_eq!(code_action.kind, Some(CodeActionKind::QUICKFIX));
assert_eq!(code_action.diagnostics, Some(vec![link_diagnostic]));
assert_eq!(code_action.is_preferred, Some(true));
let applied = apply_code_action(&uri, source, action);
assert_eq!(applied, "# Home\n\n[Greeting]\n\n# Greeting\n");
assert!(diagnostics(&uri, &applied).is_empty());
}
#[test]
fn code_actions_reuse_trailing_line_breaks() {
let source = "# Home\n\n[Greeting]\n";
let uri = untitled_uri();
let actions = code_action_for_document(
&uri,
source,
Range::new(Position::new(2, 1), Position::new(2, 1)),
&[],
)
.unwrap();
assert_eq!(
apply_code_action(&uri, source, &actions[0]),
"# Home\n\n[Greeting]\n\n# Greeting\n",
);
}
#[test]
fn code_actions_create_missing_home_nodes() {
let uri = untitled_uri();
let document_start = Range::new(Position::new(0, 0), Position::new(0, 0));
let home_diagnostic = diagnostics(&uri, "").remove(0);
assert_eq!(home_diagnostic.range, document_start);
let actions = code_action_for_document(
&uri,
"",
document_start,
std::slice::from_ref(&home_diagnostic),
)
.unwrap();
let [action] = actions.as_slice() else {
panic!("a missing home node should have exactly one code action");
};
let CodeActionOrCommand::CodeAction(code_action) = action else {
panic!("a code action should not be a bare command");
};
assert_eq!(code_action.title, "Create node `Home`");
assert_eq!(code_action.diagnostics, Some(vec![home_diagnostic]));
let applied = apply_code_action(&uri, "", action);
assert_eq!(applied, "# Home\n");
assert!(diagnostics(&uri, &applied).is_empty());
let source = "# Greeting\n";
let actions = code_action_for_document(&uri, source, document_start, &[]).unwrap();
assert_eq!(
apply_code_action(&uri, source, &actions[0]),
"# Home\n\n# Greeting\n",
);
}
#[test]
fn code_actions_omit_unneeded_home_nodes() {
let uri = untitled_uri();
let at = |line, character| {
let position = Position::new(line, character);
Range::new(position, position)
};
assert!(code_action_for_document(&uri, "# Home\n", at(0, 0), &[]).is_none());
assert!(code_action_for_document(&uri, "# Greeting\n", at(0, 3), &[]).is_none());
}
#[test]
fn code_actions_ignore_other_contexts() {
let source = concat!("# Home\n\n[Home] [] [", "file:notes.txt] prose");
let uri = untitled_uri();
let actions_at = |character| {
let position = Position::new(2, character);
code_action_for_document(&uri, source, Range::new(position, position), &[])
};
assert!(actions_at(1).is_none());
assert!(actions_at(8).is_none());
assert!(actions_at(14).is_none());
assert!(actions_at(29).is_none());
}
#[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!(goto_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!(goto_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 uri = Uri::from_file_path(wiki.path()).unwrap();
let edits = formatting_for_document(&uri, source).unwrap();
assert_eq!(edits.len(), 1);
let edit = &edits[0];
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);
let uri = Uri::from_file_path(wiki.path()).unwrap();
assert!(formatting_for_document(&uri, source).unwrap().is_empty());
}
#[test]
fn formatting_rejects_unparsable_source() {
let source = "# Home\n😀 ]";
let wiki = TestWiki::new(source);
let uri = Uri::from_file_path(wiki.path()).unwrap();
assert!(formatting_for_document(&uri, source).is_none());
}
#[test]
fn formatting_supports_invalid_wikis() {
let source = "# Zulu\n\n# Elsewhere";
let wiki = TestWiki::new(source);
let uri = Uri::from_file_path(wiki.path()).unwrap();
let edits = formatting_for_document(&uri, source).unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "# Elsewhere\n\n# Zulu\n");
}
#[test]
fn formatting_supports_untitled_wikis() {
let source = "# Zulu\n\n# Home\n\n[Zulu]";
let edits = formatting_for_document(&untitled_uri(), source).unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].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(&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)),
);
}
}