use crate::{
analyzer::analyze,
cancellation::{CancellationFlag, Outcome},
error::{Error, Fix, SourceRange},
format::{CodePath, CodeStr},
parser,
spelled_path::{DirectoryListings, SpelledPath, WikiDirectory, entry_identity},
wiki::{
ContentText, FILESYSTEM_LINK_PREFIX, FilesystemTarget, HOME_TITLE, Link, TITLE_MARKER,
TITLE_PREFIX, TextNode, Wiki, unescaped_characters,
},
wiki_tree::{Visibility, visibility, wiki_tree_walker},
};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use std::{
borrow::Cow,
collections::{BTreeMap, HashMap},
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, DocumentLink, DocumentLinkOptions, DocumentLinkParams,
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 REVEAL_IN_EXPLORER_COMMAND: &str = "mull.revealInExplorer";
const TRIGGER_SUGGEST_COMMAND: &str = "editor.action.triggerSuggest";
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;
}
#[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,
}
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 shouldn't 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}."),
None,
)])
}) else {
return;
};
if documents
.lock()
.expect("The open-document mutex shouldn't 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 shouldn't 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 shouldn't 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 shouldn't 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()]),
..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)),
document_link_provider: Some(DocumentLinkOptions {
resolve_provider: None,
work_done_progress_options: WorkDoneProgressOptions::default(),
}),
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,
¶ms.context.diagnostics,
))
}
async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
let Some(contents) = self.document_contents(¶ms.text_document.uri) else {
return Ok(None);
};
Ok(document_link_for_document(
¶ms.text_document.uri,
&contents,
))
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
let document = self
.documents
.lock()
.expect("The open-document mutex shouldn't 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;
}
}
#[derive(Debug)]
struct OpenDocument {
contents: String,
version: i32,
generation: u64,
pending_check: Option<PendingCheck>,
}
#[derive(Debug)]
struct PendingCheck {
handle: JoinHandle<()>,
cancellation: CancellationFlag,
}
impl PendingCheck {
fn cancel(self) {
self.cancellation.cancel();
self.handle.abort();
}
}
fn diagnostics_for_document(
uri: &Uri,
source_contents: &str,
cancellation: &CancellationFlag,
) -> Option<Vec<Diagnostic>> {
let result = match analyze(local_path(uri).as_deref(), source_contents, cancellation) {
Outcome::Completed(result) => result,
Outcome::Cancelled => return None,
};
Some(result.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 {
diagnostic(
source_contents,
error.source_range(),
error.reason().map_or_else(
|| error.message().to_owned(),
|reason| format!("{}\n\nReason: {reason}", error.message()),
),
error.fix(),
)
}
fn diagnostic(
source_contents: &str,
source_range: Option<crate::error::SourceRange>,
message: String,
fix: Option<&Fix>,
) -> 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,
data: fix.map(|fix| serde_json::to_value(fix).expect("A fix should serialize to JSON.")),
..Diagnostic::default()
}
}
fn completion_for_document(
uri: &Uri,
source_contents: &str,
cursor: Position,
) -> Option<Vec<CompletionItem>> {
let wiki_path = local_path(uri);
let cursor_offset = byte_offset(source_contents, cursor)?;
if let Some(context) = filesystem_link_context(source_contents, cursor_offset) {
return Some(filesystem_link_completions(
wiki_path.as_deref()?,
source_contents,
&context,
));
}
let (wiki, replacement_source_range) =
text_link_context(wiki_path.as_deref(), source_contents, cursor_offset)?;
Some(text_link_completions(
&wiki,
source_contents,
replacement_source_range,
))
}
struct FilesystemLinkContext {
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;
for (index, character) in unescaped_characters(line) {
let offset = line_start + index;
if offset < cursor {
match character {
'[' => opening_delimiter = Some(offset),
']' => opening_delimiter = None,
_ => {}
}
} else if matches!(character, '[' | ']') {
closing_delimiter = (character == ']').then_some(offset);
break;
}
}
let typed_path = source_contents[opening_delimiter? + '['.len_utf8()..cursor].trim_start();
if !typed_path.starts_with(FILESYSTEM_LINK_PREFIX) {
return None;
}
let typed_directory = &typed_path[..typed_path.rfind('/').map_or(0, |index| index + 1)];
let mut directory = PathBuf::new();
for component in
Path::new(&ContentText::from_source(typed_directory.trim_start_matches('/')).unescape())
.components()
{
match component {
Component::Normal(component) => directory.push(component),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(FilesystemLinkContext {
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 Ok(wiki_directory) = WikiDirectory::new(wiki_path) else {
return Vec::new();
};
let mut walker_builder = wiki_tree_walker(wiki_directory.path());
walker_builder
.max_depth(Some(context.directory.components().count() + 1))
.filter_entry({
let wiki_directory = wiki_directory.clone();
let directory = context.directory.clone();
move |entry| {
let path = wiki_directory.entry_path(entry);
wiki_directory.wiki_path() != Some(&path)
&& (directory.starts_with(path.as_path())
|| path.as_path().parent() == Some(directory.as_path()))
}
});
let mut completions = Vec::new();
for entry in walker_builder.build().flatten() {
let path = wiki_directory.entry_path(&entry);
let file_type = entry
.file_type()
.expect("Only standard input lacks a file type.");
let Some(name) = entry.file_name().to_str() else {
continue;
};
if path.as_path().parent() != Some(context.directory.as_path()) {
continue;
}
if file_type.is_dir()
&& !matches!(
visibility(&wiki_directory, &path, &CancellationFlag::default()).assume_completed(),
Visibility::Visible,
)
{
continue;
}
let escaped_name = ContentText::escape(name).into_string();
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 {
(
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 = ContentText::escape(title).into_string();
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()
}
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_path = local_path(uri);
let wiki = parser::parse(wiki_path.as_deref(), source_contents).ok()?;
let (node, source_range) = node_at(
&wiki,
source_contents,
byte_offset(source_contents, cursor)?,
LinkExtent::Whole,
)?;
let wiki_directory = wiki_path
.as_deref()
.and_then(|wiki_path| WikiDirectory::new(wiki_path).ok());
let mut listings = DirectoryListings::new();
Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: node
.to_markdown(|link| match link {
Link::Text { title, .. } => Some(reveal_range_command_url(
uri,
source_contents,
wiki.text_nodes.get(title)?.title_source_range,
)),
Link::Filesystem { target, .. } => Some(
filesystem_link_target(wiki_directory.as_ref()?, target, &mut listings)?
.as_str()
.to_owned(),
),
})
.into_string(),
}),
range: Some(lsp_range(source_contents, source_range)),
})
}
fn reveal_range_command_url(uri: &Uri, source_contents: &str, source_range: SourceRange) -> String {
let range = lsp_range(source_contents, source_range);
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,
))
.expect("A string and numbers should serialize to JSON."),
NON_ALPHANUMERIC,
),
)
}
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 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::Filesystem { .. } => None,
})
.collect::<Vec<_>>();
source_ranges.sort_by_key(|source_range| (source_range.start, source_range.end));
source_ranges
}
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 Some(Link::Filesystem { target, .. }) = link_at(&wiki, byte_offset) else {
return None;
};
filesystem_link_source_ranges(&wiki, target)
.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 filesystem_link_source_ranges(wiki: &Wiki, target: &FilesystemTarget) -> Vec<SourceRange> {
wiki.text_nodes
.values()
.flat_map(|node| &node.links)
.filter_map(|link| match link {
Link::Filesystem {
target: link_target,
source_range,
} if link_target == target => Some(*source_range),
Link::Text { .. } | Link::Filesystem { .. } => None,
})
.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,
)? {
let path_source_range = filesystem_node.path_source_range;
let path_source = &source_contents[path_source_range.start..path_source_range.end];
let start_trimmed = path_source.trim_start_matches('/');
let trimmed = start_trimmed.trim_end_matches('/');
let start = path_source_range.end - start_trimmed.len();
return Ok(Some(PrepareRenameResponse::RangeWithPlaceholder {
range: lsp_range(
source_contents,
SourceRange {
start,
end: start + trimmed.len(),
},
),
placeholder: ContentText::from_source(trimmed).unescape(),
}));
}
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(),
}))
}
#[derive(Clone, Copy)]
struct FileOperationSupport {
rename: bool,
delete: bool,
}
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 can't contain a line break.".to_owned());
}
let new_title = new_name.trim();
if new_title.is_empty() {
return Err("A node title can't be empty.".to_owned());
}
if new_title.starts_with(FILESYSTEM_LINK_PREFIX) {
return Err(format!(
"A node title can't start with {}.",
FILESYSTEM_LINK_PREFIX.code_str(),
));
}
if new_title != node.title && wiki.text_nodes.contains_key(new_title) {
return Err(format!("Node {} already exists.", new_title.code_str()));
}
let mut edits = vec![(node.title_source_range, new_title.to_owned())];
let escaped_title = ContentText::escape(new_title).into_string();
for link in wiki.text_nodes.values().flat_map(|node| &node.links) {
if let Link::Text {
title,
source_range,
} = link
&& title == &node.title
{
edits.push((
text_link_target_source_range(source_contents, *source_range),
escaped_title.clone(),
));
}
}
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_target,
old_path,
..
}) = renamable_filesystem_node_at(
wiki,
uri,
source_contents,
cursor_offset,
file_operation_support.rename,
)?
else {
return Ok(None);
};
let is_directory = old_target.is_directory();
let new_target = FilesystemTarget::from_name(new_name.trim(), is_directory)?;
if new_target == old_target {
return Ok(Some(WorkspaceEdit::default()));
}
if new_target.is_wiki_directory() {
return Err("A file or directory can't be renamed to the wiki directory.".to_owned());
}
let (new_ancestor, new_suffix) = wiki_directory
.spell_existing_ancestor(&new_target)
.map_err(|error| error.message)?;
let old_absolute_path = wiki_directory.resolve(&old_path);
let new_absolute_path = wiki_directory.resolve(&new_ancestor).join(new_suffix);
let moves_into_itself = is_directory && new_ancestor.starts_with(&old_path);
if is_directory
&& !moves_into_itself
&& resolves_within(&wiki_directory.resolve(&new_ancestor), &old_absolute_path)
.unwrap_or(false)
{
return Err(format!(
"{} can't be moved into itself through a symlink.",
old_path.code_path(),
));
}
if !moves_into_itself {
check_rename_destination(
&wiki_directory,
&old_path,
new_target.path(),
&new_ancestor,
&new_absolute_path,
)?;
}
let edits = filesystem_rename_edits(wiki, source_contents, &old_target, &new_target);
let text_document_edit = 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(),
});
let mut operations = if moves_into_itself {
let temporary_path = unused_sibling_path(&old_absolute_path);
vec![
rename_operation(&old_absolute_path, &temporary_path),
text_document_edit,
rename_operation(&temporary_path, &new_absolute_path),
]
} else {
vec![
text_document_edit,
rename_operation(&old_absolute_path, &new_absolute_path),
]
};
if file_operation_support.delete
&& let Some(directory) =
outermost_directory_emptied_by_rename(&wiki_directory, &old_path, &new_ancestor)
{
operations.push(DocumentChangeOperation::Op(ResourceOp::Delete(
DeleteFile {
uri: Uri::from_file_path(wiki_directory.resolve(&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()
}))
}
struct RenamableFilesystemNode {
wiki_directory: WikiDirectory,
path_source_range: SourceRange,
old_target: FilesystemTarget,
old_path: SpelledPath,
}
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 Some(Link::Filesystem {
target: old_target,
source_range,
}) = link_at(wiki, cursor_offset)
else {
return Ok(None);
};
let (is_directory, old_path, source_range) =
(old_target.is_directory(), old_target.path(), *source_range);
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 doesn't support renaming files.".to_owned());
}
let wiki_directory = WikiDirectory::new(&wiki_path).map_err(|error| error.message)?;
if old_target.is_wiki_directory() {
return Err("The wiki directory can't be renamed.".to_owned());
}
let kind = if is_directory { "Directory" } else { "File" };
if !fs::metadata(wiki_directory.path().join(old_path))
.is_ok_and(|metadata| metadata.is_dir() == is_directory)
{
return Err(format!("{kind} {} not found.", old_path.code_path()));
}
let old_path = wiki_directory
.spell(old_target, &mut DirectoryListings::new())
.map_err(|error| error.message)?;
if wiki_directory.wiki_path() == Some(&old_path) {
return Err("The wiki can't be renamed through one of its own links.".to_owned());
}
Ok(Some(RenamableFilesystemNode {
wiki_directory,
path_source_range,
old_target: old_target.clone(),
old_path,
}))
}
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);
let target = &source_contents[target_source_range.start..target_source_range.end];
let start = target_source_range.start + (target.len() - target.trim_start().len());
SourceRange {
start,
end: start + target.trim().len(),
}
}
fn check_rename_destination(
wiki_directory: &WikiDirectory,
old_path: &SpelledPath,
new_path: &Path,
new_ancestor: &SpelledPath,
new_absolute_path: &Path,
) -> std::result::Result<(), String> {
if fs::symlink_metadata(new_absolute_path).is_ok() {
return Err(
if entry_identity(new_absolute_path)
== entry_identity(&wiki_directory.resolve(old_path))
{
format!(
"{} and {} differ only in case, which VS Code can't rename. Rename it in the \
explorer instead, then fix its links.",
old_path.code_path(),
new_path.code_path(),
)
} else {
format!("{} already exists.", new_path.code_path())
},
);
}
if !wiki_directory.resolve(new_ancestor).is_dir() {
return Err(format!(
"Path {} isn't a directory.",
new_ancestor.code_path(),
));
}
Ok(())
}
fn filesystem_rename_edits(
wiki: &Wiki,
source_contents: &str,
old_target: &FilesystemTarget,
new_target: &FilesystemTarget,
) -> Vec<(SourceRange, String)> {
let mut edits = Vec::new();
for link in wiki.text_nodes.values().flat_map(|node| &node.links) {
let Link::Filesystem {
target,
source_range,
} = link
else {
continue;
};
let moved_target = if old_target.is_directory() {
target.moved(old_target, new_target)
} else {
(target == old_target).then(|| new_target.clone())
};
if let Some(moved_target) = moved_target {
edits.push((
filesystem_link_path_source_range(source_contents, *source_range),
moved_target.text().into_string(),
));
}
}
edits.sort_by_key(|(source_range, _new_text)| (source_range.start, source_range.end));
edits
}
fn unused_sibling_path(path: &Path) -> PathBuf {
let name = path
.file_name()
.and_then(|name| name.to_str())
.expect("A renamed node should have a UTF-8 name.");
(1..=u64::MAX)
.map(|attempt| {
path.with_file_name(if attempt == 1 {
format!(".{name}.mull-rename")
} else {
format!(".{name}.mull-rename-{attempt}")
})
})
.find(|candidate| fs::symlink_metadata(candidate).is_err())
.expect("An unused temporary name should exist.")
}
fn rename_operation(old_path: &Path, new_path: &Path) -> DocumentChangeOperation {
DocumentChangeOperation::Op(ResourceOp::Rename(RenameFile {
old_uri: Uri::from_file_path(old_path)
.expect("A path within a saved wiki's directory should be absolute."),
new_uri: Uri::from_file_path(new_path)
.expect("A path within a saved wiki's directory should be absolute."),
options: None,
annotation_id: None,
}))
}
fn outermost_directory_emptied_by_rename(
wiki_directory: &WikiDirectory,
old_path: &SpelledPath,
new_ancestor: &SpelledPath,
) -> Option<SpelledPath> {
let new_ancestor_absolute_path = wiki_directory.resolve(new_ancestor);
let mut emptied_directory = None;
let mut removed_entry = old_path.clone();
while let Some(directory) = removed_entry
.parent()
.filter(|directory| !directory.is_wiki_directory())
{
let absolute_directory = wiki_directory.resolve(&directory);
if resolves_within(&new_ancestor_absolute_path, &absolute_directory).unwrap_or(true) {
break;
}
let Ok(mut entries) = fs::read_dir(absolute_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.clone());
removed_entry = directory;
}
emptied_directory
}
fn resolves_within(path: &Path, directory: &Path) -> Option<bool> {
Some(
fs::canonicalize(path)
.ok()?
.starts_with(fs::canonicalize(directory).ok()?),
)
}
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,
diagnostics: &[Diagnostic],
) -> Option<CodeActionResponse> {
let mut fixes = BTreeMap::<Fix, Vec<Diagnostic>>::new();
for diagnostic in diagnostics {
let Some(fix) = diagnostic
.data
.clone()
.and_then(|data| serde_json::from_value::<Fix>(data).ok())
else {
continue;
};
fixes.entry(fix).or_default().push(diagnostic.clone());
}
let wiki = parser::parse(local_path(uri).as_deref(), source_contents).ok()?;
let actions = fixes
.into_iter()
.filter_map(|(fix, diagnostics)| {
let (title, edit) = match fix {
Fix::CreateNode(title) if wiki.text_nodes.contains_key(&title) => return None,
Fix::CreateNode(title) => {
let linking_node = diagnostics
.iter()
.filter_map(|diagnostic| byte_offset(source_contents, diagnostic.range.end))
.min()
.and_then(|offset| {
wiki.text_nodes
.values()
.filter(|node| node.source_range.start < offset)
.max_by_key(|node| node.source_range.start)
});
let edit = if title == HOME_TITLE {
let separator = if source_contents.is_empty() { "" } else { "\n" };
TextEdit::new(
Range::new(Position::new(0, 0), Position::new(0, 0)),
format!("{TITLE_PREFIX}{title}\n{separator}"),
)
} else if let Some(node) = linking_node {
let end = lsp_position(source_contents, node.source_range.end);
TextEdit::new(Range::new(end, end), format!("\n\n{TITLE_PREFIX}{title}"))
} else {
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());
TextEdit::new(
Range::new(end, end),
format!("{separator}{TITLE_PREFIX}{title}\n"),
)
};
(format!("Create node {}", title.code_str()), edit)
}
};
Some(CodeActionOrCommand::CodeAction(CodeAction {
title,
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(diagnostics),
edit: Some(WorkspaceEdit {
changes: Some(HashMap::from([(uri.clone(), vec![edit])])),
..WorkspaceEdit::default()
}),
is_preferred: Some(true),
..CodeAction::default()
}))
})
.collect::<Vec<_>>();
(!actions.is_empty()).then_some(actions)
}
fn document_link_for_document(uri: &Uri, source_contents: &str) -> Option<Vec<DocumentLink>> {
let wiki_path = local_path(uri)?;
let wiki = parser::parse(Some(&wiki_path), source_contents).ok()?;
let wiki_directory = WikiDirectory::new(&wiki_path).ok()?;
let mut listings = DirectoryListings::new();
let mut document_links = wiki
.text_nodes
.values()
.flat_map(|node| &node.links)
.filter_map(|link| {
let Link::Filesystem {
target,
source_range,
} = link
else {
return None;
};
let tooltip = if target.is_directory() {
"Reveal in Explorer"
} else {
"Open file"
};
Some(DocumentLink {
range: lsp_range(source_contents, *source_range),
target: Some(filesystem_link_target(
&wiki_directory,
target,
&mut listings,
)?),
tooltip: Some(tooltip.to_owned()),
data: None,
})
})
.collect::<Vec<_>>();
document_links.sort_by_key(|document_link| {
(
document_link.range.start.line,
document_link.range.start.character,
)
});
Some(document_links)
}
fn filesystem_link_target(
wiki_directory: &WikiDirectory,
target: &FilesystemTarget,
listings: &mut DirectoryListings,
) -> Option<Uri> {
let is_directory = target.is_directory();
let target_path = wiki_directory.resolve(&wiki_directory.spell(target, listings).ok()?);
if !fs::metadata(&target_path).is_ok_and(|metadata| metadata.is_dir() == is_directory) {
return None;
}
let target_uri = Uri::from_file_path(&target_path)
.expect("A path within a saved wiki's directory should be absolute.");
Some(if is_directory {
reveal_in_explorer_command_url(&target_uri)
.parse()
.expect("A command URL should be a valid URI.")
} else {
target_uri
})
}
fn reveal_in_explorer_command_url(directory_uri: &Uri) -> String {
format!(
"command:{REVEAL_IN_EXPLORER_COMMAND}?{}",
utf8_percent_encode(
&serde_json::to_string(&[directory_uri.as_str()])
.expect("A list of strings should serialize to JSON."),
NON_ALPHANUMERIC,
),
)
}
#[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 source_range = link.source_range();
source_range.start <= byte_offset && byte_offset < source_range.end
})
})
}
fn text_link_target_source_range(source_contents: &str, source_range: SourceRange) -> SourceRange {
let target_source = source_contents
.get(source_range.start..source_range.end)
.and_then(|link_source| link_source.strip_prefix('['))
.and_then(|link_source| link_source.strip_suffix(']'))
.expect("A parsed link should be delimited by square brackets.");
let start = source_range.start + '['.len_utf8();
SourceRange {
start,
end: start + target_source.len(),
}
}
fn local_path(uri: &Uri) -> Option<Cow<'_, Path>> {
uri.scheme()
.as_str()
.eq_ignore_ascii_case("file")
.then(|| uri.to_file_path())
.flatten()
}
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;
}
}
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),
)
}
#[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_link_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::{Fix, SourceRange},
parser,
};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
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(0, 9)), Some(4));
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)), Some(source.len()));
assert_eq!(byte_offset(source, Position::new(2, 0)), None);
assert_eq!(byte_offset("ab\r\ncd", Position::new(0, 9)), Some(2));
}
#[test]
fn reveal_range_commands_encode_destinations() {
let source = "# Home";
let url =
reveal_range_command_url(&untitled_uri(), source, SourceRange { start: 2, end: 6 });
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 = "# Home\n[/notes.txt] [/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, 12)),
Range::new(Position::new(1, 13), Position::new(1, 23)),
],
);
}
#[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 = "# Home\n\nprose [/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 = "# Home\n\n[/]";
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, 2)).unwrap();
let empty_path = Range::new(Position::new(2, 2), Position::new(2, 2));
let closed_path = Range::new(Position::new(2, 2), Position::new(2, 3));
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_follow_prefix() {
let source = "# Home\n\n[/no";
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 completions = completion_for_document(&uri, source, Position::new(2, 4)).unwrap();
assert_eq!(
completion_edits(&completions),
vec![(
"notes.txt",
Range::new(Position::new(2, 2), Position::new(2, 4)),
"notes.txt]",
)],
);
}
#[test]
fn completions_list_nested_directories() {
let source = "# Home\n\n[/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/raw/large/photo.tiff"), "photo").unwrap();
fs::create_dir_all(directory.join("images/rejected/nested")).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, 10)).unwrap();
let typed_component = Range::new(Position::new(2, 9), Position::new(2, 10));
assert_eq!(
completion_edits(&completions),
vec![
("photo.jpg", typed_component, "photo.jpg]"),
("raw/", typed_component, "raw/"),
],
);
assert_eq!(completions[1].filter_text.as_deref(), Some("raw"));
}
#[test]
fn completions_escape_path_delimiters() {
let source = "# Home\n\n[/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, 2)).unwrap();
assert_eq!(
completion_edits(&completions),
vec![(
"a[b]/",
Range::new(Position::new(2, 2), Position::new(2, 9)),
"a\\[b\\]/",
)],
);
let completions = completion_for_document(&uri, source, Position::new(2, 9)).unwrap();
assert_eq!(
completion_edits(&completions),
vec![(
"c[d].txt",
Range::new(Position::new(2, 9), Position::new(2, 10)),
"c\\[d\\].txt]",
)],
);
}
#[test]
fn completions_ignore_invalid_filesystem_contexts() {
let source = "# [/\n\n[/../] \\[/";
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, 4),
Position::new(2, 5),
Position::new(2, 10),
] {
assert!(
completion_for_document(&uri, source, cursor)
.is_none_or(|completions| completions.is_empty()),
);
}
let source = "# Home\n\n[/]";
assert!(completion_for_document(&untitled_uri(), source, Position::new(2, 2)).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 });
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_link_filesystem_targets() {
let source = "# Home\n\n[Other]\n\n# Other\n\n[/notes.txt] [/images/] [/missing.txt]";
let wiki = TestWiki::new(source);
let directory = wiki.path().parent().unwrap();
fs::write(directory.join("notes.txt"), "notes").unwrap();
fs::create_dir(directory.join("images")).unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
let hover = hover_for_document(&uri, source, Position::new(2, 2)).unwrap();
let HoverContents::Markup(contents) = hover.contents else {
panic!("A node preview should use markup content.");
};
let file_uri = Uri::from_file_path(directory.join("notes.txt")).unwrap();
let directory_uri = Uri::from_file_path(directory.join("images")).unwrap();
let reveal_url = format!(
"command:mull.revealInExplorer?{}",
utf8_percent_encode(
&format!("[\"{}\"]", directory_uri.as_str()),
NON_ALPHANUMERIC,
),
);
assert_eq!(
contents.value,
format!(
"# Other\n\n[`[/notes.txt]`](<{}>) [`[/images/]`](<{reveal_url}>) \
`[/missing.txt]`",
file_uri.as_str(),
),
);
let hover = hover_for_document(&untitled_uri(), source, Position::new(2, 2)).unwrap();
let HoverContents::Markup(contents) = hover.contents else {
panic!("A node preview should use markup content.");
};
assert_eq!(
contents.value,
"# Other\n\n`[/notes.txt]` `[/images/]` `[/missing.txt]`",
);
}
#[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",
"[/foo] [/foo] [/bar/]\n\n",
"# Other\n\n",
"[/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, 15)).unwrap();
assert_eq!(
file_highlights,
vec![
DocumentHighlight {
range: Range::new(Position::new(2, 0), Position::new(2, 6)),
kind: Some(DocumentHighlightKind::READ),
},
DocumentHighlight {
range: Range::new(Position::new(2, 7), Position::new(2, 13)),
kind: Some(DocumentHighlightKind::READ),
},
],
);
assert_eq!(
directory_highlights,
vec![
DocumentHighlight {
range: Range::new(Position::new(2, 14), Position::new(2, 21)),
kind: Some(DocumentHighlightKind::READ),
},
DocumentHighlight {
range: Range::new(Position::new(6, 0), Position::new(6, 7)),
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 can't be empty.",
);
assert_eq!(
rename_for_document(
&untitled_uri(),
source,
7,
cursor,
"Hello\nworld",
ALL_FILE_OPERATIONS,
)
.unwrap_err(),
"A node title can't 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,
"/notes.txt",
ALL_FILE_OPERATIONS,
)
.unwrap_err(),
"A node title can't start with `/`.",
);
}
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 shouldn't 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 = "# Home\n\n[Home] [/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 = "# Home\n\n[ /a\\[1\\].txt ] [/images/raw/]";
let wiki = TestWiki::new(source);
let directory = wiki.path().parent().unwrap();
fs::write(directory.join("a[1].txt"), "a").unwrap();
fs::create_dir_all(directory.join("images/raw")).unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
let prepare = |character| {
prepare_rename_for_document(&uri, source, Position::new(2, character), true)
.unwrap()
.unwrap()
};
assert_eq!(
prepare(4),
PrepareRenameResponse::RangeWithPlaceholder {
range: Range::new(Position::new(2, 3), Position::new(2, 13)),
placeholder: "a[1].txt".to_owned(),
},
);
assert_eq!(
prepare(18),
PrepareRenameResponse::RangeWithPlaceholder {
range: Range::new(Position::new(2, 18), Position::new(2, 28)),
placeholder: "images/raw".to_owned(),
},
);
}
#[test]
fn rename_preparation_rejects_unrenamable_entries() {
let source = "# Home\n\n[/notes.txt] [/missing.txt] [/] [/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 doesn't support renaming files.",
);
assert_eq!(prepare(&uri, 14, true), "File `missing.txt` not found.");
assert_eq!(
prepare(&uri, 29, true),
"The wiki directory can't be renamed.",
);
assert_eq!(
prepare(&uri, 33, true),
"The wiki can't be renamed through one of its own links.",
);
}
#[test]
fn rename_moves_linked_files() {
let source = "# Home\n\n[/notes.txt] [/notes.txt] [/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),
(
"# Home\n\n[/notes/a\\[1\\].txt] [/notes/a\\[1\\].txt] [/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 = "# Home\n\n[/images/] [/images/raw/] [/images/photo.jpg] [/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),
(
"# Home\n\n[/photos/] [/photos/raw/] [/photos/photo.jpg] [/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 = "# Home\n\n[/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 = "# Home\n\n[/a/b/photo.jpg] [/c/d/e.txt] [/f/] [/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")],
);
assert_eq!(
deleted_uris(
2,
"photo.jpg",
FileOperationSupport {
rename: true,
delete: false,
},
),
Vec::<Uri>::new(),
);
}
#[cfg(unix)]
#[test]
fn rename_through_symlinks() {
use std::os::unix::fs::symlink;
let source = "# Home\n\n[/d/x.txt] [/d/] [/alias/]";
let wiki = TestWiki::new(source);
let directory = wiki.path().parent().unwrap();
fs::create_dir(directory.join("d")).unwrap();
fs::write(directory.join("d/x.txt"), "x").unwrap();
symlink("d", directory.join("alias")).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,
)
};
let (applied, _old_uri, _new_uri, deleted_uris) =
apply_filesystem_rename(source, rename(2, "alias/y.txt").unwrap().unwrap());
assert_eq!(applied, "# Home\n\n[/alias/y.txt] [/d/] [/alias/]");
assert_eq!(deleted_uris, Vec::<Uri>::new());
assert_eq!(
rename(13, "alias/sub").unwrap_err(),
"`d` can't be moved into itself through a symlink.",
);
}
#[test]
fn rename_moves_directories_into_themselves() {
let source = "# Home\n\n[/images/] [/images/photo.jpg] [/notes.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("notes.txt"), "notes").unwrap();
fs::create_dir(directory.join(".images.mull-rename")).unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
let workspace_edit = rename_for_document(
&uri,
source,
7,
Position::new(2, 2),
"images/raw",
ALL_FILE_OPERATIONS,
)
.unwrap()
.unwrap();
let Some(DocumentChanges::Operations(operations)) = workspace_edit.document_changes else {
panic!("A filesystem rename should consist of document change operations.");
};
let [
DocumentChangeOperation::Op(ResourceOp::Rename(to_temporary)),
DocumentChangeOperation::Edit(text_document_edit),
DocumentChangeOperation::Op(ResourceOp::Rename(from_temporary)),
] = operations.as_slice()
else {
panic!("The text edit should separate a rename to a temporary path from a rename out.");
};
let temporary_uri = Uri::from_file_path(directory.join(".images.mull-rename-2")).unwrap();
assert_eq!(
(&to_temporary.old_uri, &to_temporary.new_uri),
(
&Uri::from_file_path(directory.join("images")).unwrap(),
&temporary_uri,
),
);
assert_eq!(
(&from_temporary.old_uri, &from_temporary.new_uri),
(
&temporary_uri,
&Uri::from_file_path(directory.join("images/raw")).unwrap(),
),
);
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 shouldn't 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);
}
assert_eq!(
applied,
"# Home\n\n[/images/raw/] [/images/raw/photo.jpg] [/notes.txt]",
);
}
#[test]
fn rename_rejects_invalid_filesystem_renames() {
let source =
"# Home\n\n[/notes.txt] [/images/] [/missing.txt] [/] [/wiki.mull] [/NOTES.txt]";
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 doesn't 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 can't be renamed to the wiki directory.",
);
assert_eq!(
rename(&uri, 2, "other.txt", ALL_FILE_OPERATIONS),
"`other.txt` already exists.",
);
assert_eq!(
rename(&uri, 2, "notes.txt/inner.txt", ALL_FILE_OPERATIONS),
"Path `notes.txt` isn't a directory.",
);
assert_eq!(
rename(&uri, 25, "found.txt", ALL_FILE_OPERATIONS),
"File `missing.txt` not found.",
);
assert_eq!(
rename(&uri, 40, "elsewhere", ALL_FILE_OPERATIONS),
"The wiki directory can't be renamed.",
);
assert_eq!(
rename(&uri, 44, "renamed.mull", ALL_FILE_OPERATIONS),
"The wiki can't be renamed through one of its own links.",
);
if fs::metadata(directory.join("IMAGES")).is_ok() {
assert_eq!(
rename(&uri, 58, "renamed.txt", ALL_FILE_OPERATIONS),
"`NOTES.txt` doesn't match the spelling of any name on disk.",
);
assert_eq!(
rename(&uri, 2, "IMAGES/notes.txt", ALL_FILE_OPERATIONS),
"`IMAGES` doesn't match the spelling of any name on disk.",
);
assert_eq!(
rename(&uri, 15, "IMAGES/raw", ALL_FILE_OPERATIONS),
"`IMAGES` doesn't match the spelling of any name on disk.",
);
assert_eq!(
rename(
&Uri::from_file_path(directory.join("WIKI.mull")).unwrap(),
44,
"renamed.mull",
ALL_FILE_OPERATIONS,
),
"`WIKI.mull` doesn't match the spelling of any name on disk.",
);
assert_eq!(
rename(&uri, 2, "NOTES.txt", ALL_FILE_OPERATIONS),
"`notes.txt` and `NOTES.txt` differ only in case, which VS Code can't rename. \
Rename it in the explorer instead, then fix its links.",
);
assert_eq!(
rename(&uri, 15, "Images", ALL_FILE_OPERATIONS),
"`images` and `Images` differ only in case, which VS Code can't rename. Rename \
it in the explorer instead, then fix its links.",
);
}
}
#[test]
fn document_links_open_existing_targets() {
let source =
"# Home\n\n[Home] [/missing.txt] [/notes.txt]\n[/notes.txt/] [/images/] [/NOTES.txt]";
let wiki = TestWiki::new(source);
let directory = wiki.path().parent().unwrap();
fs::write(directory.join("notes.txt"), "notes").unwrap();
fs::create_dir(directory.join("images")).unwrap();
let uri = Uri::from_file_path(wiki.path()).unwrap();
let links = document_link_for_document(&uri, source).unwrap();
let [file_link, directory_link] = links.as_slice() else {
panic!("Only the two links with existing targets should be clickable.");
};
assert_eq!(
file_link.range,
Range::new(Position::new(2, 22), Position::new(2, 34)),
);
assert_eq!(
file_link.target,
Some(Uri::from_file_path(directory.join("notes.txt")).unwrap()),
);
assert_eq!(
directory_link.range,
Range::new(Position::new(3, 14), Position::new(3, 24)),
);
let directory_uri = Uri::from_file_path(directory.join("images")).unwrap();
assert_eq!(
directory_link.target.as_ref().map(|target| target.as_str()),
Some(
format!(
"command:mull.revealInExplorer?{}",
utf8_percent_encode(
&format!("[\"{}\"]", directory_uri.as_str()),
NON_ALPHANUMERIC,
),
)
.as_str(),
),
);
assert!(document_link_for_document(&untitled_uri(), source).is_none());
}
fn apply_code_action(uri: &Uri, source: &str, action: &CodeActionOrCommand) -> String {
let CodeActionOrCommand::CodeAction(action) = action else {
panic!("A code action shouldn't 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] [Greeting]";
let uri = untitled_uri();
let link_diagnostics = diagnostics(&uri, source);
assert_eq!(link_diagnostics.len(), 2);
let unrelated_diagnostic = Diagnostic {
data: None,
message: "Something else is wrong with this link.".to_owned(),
..link_diagnostics[0].clone()
};
let mut request_diagnostics = link_diagnostics.clone();
request_diagnostics.push(unrelated_diagnostic);
let actions = code_action_for_document(&uri, source, &request_diagnostics).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 shouldn't 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(link_diagnostics));
assert_eq!(code_action.is_preferred, Some(true));
let applied = apply_code_action(&uri, source, action);
assert_eq!(applied, "# Home\n\n[Greeting] [Greeting]\n\n# Greeting");
assert!(diagnostics(&uri, &applied).is_empty());
}
#[test]
fn code_actions_insert_after_linking_nodes() {
let source = "# Home\n\n[Other]\n\n# Other\n\n[Greeting]\n\n# Last\n\n[Greeting]\n";
let uri = untitled_uri();
let actions = code_action_for_document(&uri, source, &diagnostics(&uri, source)).unwrap();
assert_eq!(
apply_code_action(&uri, source, &actions[0]),
"# Home\n\n[Other]\n\n# Other\n\n[Greeting]\n\n# Greeting\n\n# Last\n\n[Greeting]\n",
);
}
#[test]
fn code_actions_insert_after_last_touched_nodes() {
let source = "# Home\n\n[First]\n\n# First\n\nText.\n\n# Second\n\nText.\n";
let uri = untitled_uri();
let fix_diagnostic = |start, end| Diagnostic {
range: Range::new(start, end),
data: Some(serde_json::to_value(Fix::CreateNode("New".to_owned())).unwrap()),
..Diagnostic::default()
};
let apply = |diagnostic| {
let actions = code_action_for_document(&uri, source, &[diagnostic]).unwrap();
apply_code_action(&uri, source, &actions[0])
};
assert_eq!(
apply(fix_diagnostic(Position::new(4, 0), Position::new(8, 3))),
"# Home\n\n[First]\n\n# First\n\nText.\n\n# Second\n\nText.\n\n# New\n",
);
assert_eq!(
apply(fix_diagnostic(Position::new(4, 0), Position::new(7, 0))),
"# Home\n\n[First]\n\n# First\n\nText.\n\n# New\n\n# Second\n\nText.\n",
);
}
#[test]
fn code_actions_append_without_linking_nodes() {
let uri = untitled_uri();
let stale_diagnostics = diagnostics(&uri, "# Home\n\nSome text first, then [Greeting]\n");
let source = "# Home\n";
let actions = code_action_for_document(&uri, source, &stale_diagnostics).unwrap();
assert_eq!(
apply_code_action(&uri, source, &actions[0]),
"# Home\n\n# Greeting\n",
);
}
#[test]
fn code_actions_create_missing_home_nodes() {
let uri = untitled_uri();
let home_diagnostic = diagnostics(&uri, "").remove(0);
let unrelated_diagnostic = Diagnostic {
data: None,
message: "File `notes.txt` isn't linked to.".to_owned(),
..home_diagnostic.clone()
};
let actions =
code_action_for_document(&uri, "", &[home_diagnostic.clone(), unrelated_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 shouldn't 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, &diagnostics(&uri, source)).unwrap();
assert_eq!(
apply_code_action(&uri, source, &actions[0]),
"# Home\n\n# Greeting\n",
);
}
#[test]
fn code_actions_skip_stale_fixes() {
let uri = untitled_uri();
let stale_diagnostics = diagnostics(&uri, "");
assert!(code_action_for_document(&uri, "# Home\n", &stale_diagnostics).is_none());
}
#[test]
fn code_actions_ignore_other_diagnostics() {
let source = "# Home\n\n[Home] [] [/notes.txt] prose";
let uri = untitled_uri();
let source_diagnostics = diagnostics(&uri, source);
assert!(!source_diagnostics.is_empty());
assert!(code_action_for_document(&uri, source, &source_diagnostics).is_none());
}
#[test]
fn navigation_ignores_filesystem_links() {
let source = "# Home\n\n[/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, 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,
None,
);
let diagnostic = diagnostic_from_error(source, &error);
assert_eq!(
diagnostic.range,
Range::new(Position::new(0, 0), Position::new(1, 2)),
);
}
}