use std::collections::{VecDeque, HashMap};
use std::ops::ControlFlow;
use std::path::Path;
use async_lsp::lsp_types::{
ClientCapabilities, CompletionClientCapabilities, CompletionItemCapability, CompletionParams,
CompletionResponse, CompletionContext, CompletionTriggerKind,
DidChangeTextDocumentParams, DidOpenTextDocumentParams, GotoCapability,
GotoDefinitionParams, GotoDefinitionResponse, HoverClientCapabilities, HoverContents,
HoverParams, HoverProviderCapability, InitializeParams, InitializedParams,
MarkedString, PublishDiagnosticsClientCapabilities, RenameParams, TextDocumentClientCapabilities,
TextDocumentContentChangeEvent, TextDocumentIdentifier, TextDocumentItem,
TextDocumentPositionParams, TextEdit, Url, VersionedTextDocumentIdentifier,
notification, request, Range, Position,
TextDocumentSyncKind, TextDocumentSyncCapability,
WorkspaceEdit,
};
use async_lsp::router::Router;
use async_lsp::{MainLoop, ServerSocket};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::mpsc;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use crate::operation::{LspCompletionItem, LspFileEdit, LspLocation, LspOp, LspRenameOp, LspTextEdit, Operation};
pub(crate) struct LspClient {
cmd_tx: mpsc::UnboundedSender<LspCommand>,
_child: Child,
mainloop_handle: Option<tokio::task::JoinHandle<()>>,
actor_handle: Option<tokio::task::JoinHandle<()>>,
}
impl Drop for LspClient {
fn drop(&mut self) {
if let Some(handle) = self.actor_handle.take() {
handle.abort();
}
if let Some(handle) = self.mainloop_handle.take() {
handle.abort();
}
}
}
impl LspClient {
pub(crate) async fn spawn(
cmd: &str,
args: &[String],
op_tx: mpsc::UnboundedSender<Vec<Operation>>,
server_name: impl Into<String>,
) -> anyhow::Result<Self> {
let server_name = server_name.into();
let mut child = Command::new(cmd)
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()?;
let stdin = child.stdin.take().expect("stdin piped");
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
tokio::spawn(async move {
let mut reader = BufReader::new(stderr);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => log::error!("lsp stderr: {}", line.trim_end()),
Err(e) => {
log::error!("lsp stderr read error: {e}");
break;
}
}
}
});
let (mainloop, server) = MainLoop::new_client(|_server_socket| {
let mut router = Router::new(OoClientState {
op_tx: op_tx.clone(),
server_name: server_name.clone(),
});
router
.notification::<notification::PublishDiagnostics>(|st, params| {
handle_publish_diagnostics(&st.op_tx, &st.server_name, params);
ControlFlow::Continue(())
})
.notification::<notification::ShowMessage>(|_st, _| ControlFlow::Continue(()))
.notification::<notification::LogMessage>(|_st, _| ControlFlow::Continue(()))
.notification::<notification::Progress>(|_st, _| ControlFlow::Continue(()))
.unhandled_notification(|_st, _| ControlFlow::Continue(()));
router
});
let stdin_compat = stdin.compat_write();
let stdout_compat = stdout.compat();
let mainloop_handle = tokio::spawn(async move {
if let Err(e) = mainloop.run_buffered(stdout_compat, stdin_compat).await {
log::debug!("lsp mainloop exited: {e}");
}
});
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<LspCommand>();
let actor_handle = tokio::spawn(lsp_actor_loop(LspActor {
server,
op_tx,
cmd_rx,
initialized: false,
queued_cmds: VecDeque::new(),
use_incremental: false,
supports_hover: false,
supports_rename: false,
supports_prepare_rename: false,
doc_states: HashMap::new(),
trigger_chars: HashMap::new(),
}));
Ok(Self {
cmd_tx,
_child: child,
mainloop_handle: Some(mainloop_handle),
actor_handle: Some(actor_handle),
})
}
pub(crate) fn initialize(&self, root_uri: &str) {
let _ = self.cmd_tx.send(LspCommand::Initialize {
root_uri: root_uri.into(),
});
}
#[allow(dead_code)]
pub(crate) fn shutdown(&self) {
let _ = self.cmd_tx.send(LspCommand::Shutdown);
}
pub(crate) fn did_open(&self, uri: &str, lang_id: &str, text: &str) {
let _ = self.cmd_tx.send(LspCommand::DidOpen {
uri: uri.into(),
lang_id: lang_id.into(),
text: text.into(),
});
}
pub(crate) fn did_change(&self, uri: &str, version: i32, text: &str) {
let _ = self.cmd_tx.send(LspCommand::DidChange {
uri: uri.into(),
version,
text: text.into(),
});
}
pub(crate) fn request_completion(&self, uri: &str, row: u32, col: u32, trigger_char: Option<String>) {
let _ = self.cmd_tx.send(LspCommand::RequestCompletion {
uri: uri.into(),
row,
col,
trigger_char,
});
}
pub(crate) fn request_hover(&self, uri: &str, row: u32, col: u32) {
let _ = self.cmd_tx.send(LspCommand::RequestHover {
uri: uri.into(),
row,
col,
});
}
#[allow(dead_code)]
pub(crate) fn shutdown_notify(&self) {
let _ = self.cmd_tx.send(LspCommand::Shutdown);
}
pub(crate) fn request_definition(&self, uri: &str, row: u32, col: u32) {
let _ = self.cmd_tx.send(LspCommand::RequestDefinition {
uri: uri.into(),
row,
col,
});
}
pub(crate) fn request_prepare_rename(&self, uri: &str, row: u32, col: u32, fallback_name: String) {
let _ = self.cmd_tx.send(LspCommand::RequestPrepareRename {
uri: uri.into(),
row,
col,
fallback_name,
});
}
pub(crate) fn request_rename(&self, uri: &str, row: u32, col: u32, new_name: String) {
let _ = self.cmd_tx.send(LspCommand::RequestRename {
uri: uri.into(),
row,
col,
new_name,
});
}
}
pub(crate) fn path_to_uri(path: &Path) -> String {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let s = canonical.to_string_lossy();
if cfg!(windows) {
let stripped = s.strip_prefix(r"\\?\").unwrap_or(&s);
format!("file:///{}", stripped.replace('\\', "/"))
} else {
format!("file://{}", s)
}
}
pub(crate) fn lang_id_for_path(path: &Path) -> &'static str {
crate::language::detect_language(path)
.and_then(|id| match id.0 {
std::borrow::Cow::Borrowed(s) => Some(s),
std::borrow::Cow::Owned(_) => None,
})
.unwrap_or("plaintext")
}
fn uri_to_path(uri: &str) -> std::path::PathBuf {
let path = uri.strip_prefix("file://").unwrap_or(uri);
let path = if cfg!(windows) {
path.strip_prefix('/').unwrap_or(path).replace('/', "\\")
} else {
path.to_owned()
};
std::path::PathBuf::from(path)
}
enum LspCommand {
Initialize { root_uri: String },
DidOpen { uri: String, lang_id: String, text: String },
DidChange { uri: String, version: i32, text: String },
RequestCompletion { uri: String, row: u32, col: u32, trigger_char: Option<String> },
RequestHover { uri: String, row: u32, col: u32 },
RequestDefinition { uri: String, row: u32, col: u32 },
RequestPrepareRename { uri: String, row: u32, col: u32, fallback_name: String },
RequestRename { uri: String, row: u32, col: u32, new_name: String },
Shutdown,
}
struct OoClientState {
op_tx: mpsc::UnboundedSender<Vec<Operation>>,
server_name: String,
}
struct DocumentState {
version: i32,
text: String,
}
struct LspActor {
server: ServerSocket,
op_tx: mpsc::UnboundedSender<Vec<Operation>>,
cmd_rx: mpsc::UnboundedReceiver<LspCommand>,
initialized: bool,
queued_cmds: VecDeque<LspCommand>,
use_incremental: bool,
supports_hover: bool,
supports_rename: bool,
supports_prepare_rename: bool,
doc_states: HashMap<String, DocumentState>,
trigger_chars: HashMap<String, Vec<String>>,
}
async fn lsp_actor_loop(mut actor: LspActor) {
log::debug!("lsp actor: started");
loop {
while actor.initialized {
let Some(cmd) = actor.queued_cmds.pop_front() else { break };
execute_command(&mut actor, cmd).await;
}
let Some(cmd) = actor.cmd_rx.recv().await else {
log::debug!("lsp actor: cmd_rx closed, exiting");
break;
};
execute_command(&mut actor, cmd).await;
}
}
async fn execute_command(actor: &mut LspActor, cmd: LspCommand) {
if !actor.initialized && !matches!(cmd, LspCommand::Initialize { .. }) {
log::debug!("lsp actor: queuing command until initialized");
actor.queued_cmds.push_back(cmd);
return;
}
match cmd {
LspCommand::Initialize { root_uri } => {
log::debug!("lsp actor: initialize root={root_uri}");
let params = {
#[allow(deprecated)]
InitializeParams {
root_uri: Url::parse(&root_uri).ok(),
capabilities: build_client_capabilities(),
..Default::default()
}
};
match actor.server.request::<request::Initialize>(params).await {
Ok(res) => {
let supports_incremental = match res.capabilities.text_document_sync {
Some(tds) => match tds {
TextDocumentSyncCapability::Kind(kind) => {
matches!(kind, TextDocumentSyncKind::INCREMENTAL)
}
TextDocumentSyncCapability::Options(opts) => {
matches!(opts.change, Some(TextDocumentSyncKind::INCREMENTAL))
}
},
None => false,
};
actor.use_incremental = supports_incremental;
if let Some(completion_provider) = &res.capabilities.completion_provider
&& let Some(triggers) = &completion_provider.trigger_characters {
let chars: Vec<String> = triggers.iter().map(|s| s.to_string()).collect();
if !chars.is_empty() {
log::debug!("lsp actor: server trigger characters: {:?}", chars);
actor.trigger_chars.insert("default".to_string(), chars);
}
}
actor.supports_hover = matches!(
res.capabilities.hover_provider,
Some(HoverProviderCapability::Simple(true))
| Some(HoverProviderCapability::Options(_))
);
log::debug!("lsp actor: supports_hover={}", actor.supports_hover);
match &res.capabilities.rename_provider {
Some(async_lsp::lsp_types::OneOf::Left(true)) => {
actor.supports_rename = true;
actor.supports_prepare_rename = false;
}
Some(async_lsp::lsp_types::OneOf::Right(opts)) => {
actor.supports_rename = true;
actor.supports_prepare_rename = opts.prepare_provider.unwrap_or(false);
}
_ => {
actor.supports_rename = false;
actor.supports_prepare_rename = false;
}
}
log::debug!("lsp actor: supports_rename={} supports_prepare_rename={}",
actor.supports_rename, actor.supports_prepare_rename);
let _ = actor.server.notify::<notification::Initialized>(InitializedParams {});
actor.initialized = true;
let default_chars: Vec<String> = Vec::new();
let chars = actor.trigger_chars.get("default").unwrap_or(&default_chars).clone();
let _ = actor.op_tx.send(vec![Operation::LspTriggerCharactersReceived {
lang_id: "default".to_string(),
trigger_chars: chars,
}]);
log::debug!("lsp actor: initialized; incremental={}", actor.use_incremental);
}
Err(e) => log::warn!("[lsp] initialize failed: {e}"),
}
}
LspCommand::DidOpen { uri, lang_id, text } => {
let text_clone = text.clone();
let lang_clone = lang_id.clone();
let uri_parsed = Url::parse(&uri).unwrap_or_else(|_| Url::parse("file:///unknown").unwrap());
let _ = actor.server.notify::<notification::DidOpenTextDocument>(
DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri_parsed,
language_id: lang_clone,
version: 0,
text: text_clone.clone(),
},
},
);
actor.doc_states.insert(
uri.clone(),
DocumentState {
version: 0,
text: text_clone,
},
);
}
LspCommand::DidChange { uri, version, text } => {
let uri_parsed = Url::parse(&uri).unwrap_or_else(|_| Url::parse("file:///unknown").unwrap());
if actor.use_incremental {
if let Some(state) = actor.doc_states.get_mut(&uri) {
if state.text == text {
state.version = version;
} else {
let (old_start, old_end, new_start, new_end) =
compute_change_range(&state.text, &text);
let (start_line, start_char) = byte_index_to_position(&state.text, old_start);
let (end_line, end_char) = byte_index_to_position(&state.text, old_end);
let range = Range {
start: Position { line: start_line, character: start_char },
end: Position { line: end_line, character: end_char },
};
let changed_text = &text[new_start..new_end];
let _ = actor.server.notify::<notification::DidChangeTextDocument>(
DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri_parsed.clone(),
version,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: Some(range),
range_length: None,
text: changed_text.to_string(),
}],
},
);
state.text = text;
state.version = version;
}
} else {
let _ = actor.server.notify::<notification::DidChangeTextDocument>(
DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri_parsed.clone(),
version,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: text.clone(),
}],
},
);
actor.doc_states.insert(
uri.clone(),
DocumentState { version, text },
);
}
} else {
let _ = actor.server.notify::<notification::DidChangeTextDocument>(
DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri_parsed.clone(),
version,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: text.clone(),
}],
},
);
if let Some(state) = actor.doc_states.get_mut(&uri) {
state.text = text;
state.version = version;
}
}
}
LspCommand::RequestCompletion { uri, row, col, trigger_char } => {
log::debug!("lsp actor: request_completion {uri} {row}:{col}, trigger_char: {:?}", trigger_char);
let server = actor.server.clone();
let op_tx = actor.op_tx.clone();
tokio::spawn(async move {
let context = trigger_char.map(|c| CompletionContext {
trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: Some(c),
});
let params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: Url::parse(&uri).unwrap_or_else(|_| {
Url::parse("file:///unknown").unwrap()
}),
},
position: async_lsp::lsp_types::Position {
line: row,
character: col,
},
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
context,
};
match server.request::<request::Completion>(params).await {
Ok(Some(result)) => {
let items = match result {
CompletionResponse::Array(items) => items,
CompletionResponse::List(list) => list.items,
};
let parsed: Vec<LspCompletionItem> =
items.iter().map(completion_item_to_lsp).collect();
log::debug!("lsp: completion response — {} items", parsed.len());
let trigger = Some(crate::editor::position::Position::new(
row as usize,
col as usize,
));
let _ = op_tx.send(vec![Operation::LspLocal(LspOp::CompletionResponse {
items: parsed,
trigger,
version: None,
})]);
}
Ok(None) => {}
Err(e) => log::debug!("[lsp] completion error: {e}"),
}
});
}
LspCommand::RequestHover { uri, row, col } => {
if !actor.supports_hover {
log::debug!("lsp actor: server does not support hover, skipping");
return;
}
log::debug!("lsp actor: request_hover {uri} {row}:{col}");
let server = actor.server.clone();
let op_tx = actor.op_tx.clone();
tokio::spawn(async move {
let params = HoverParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: Url::parse(&uri).unwrap_or_else(|_| {
Url::parse("file:///unknown").unwrap()
}),
},
position: async_lsp::lsp_types::Position {
line: row,
character: col,
},
},
work_done_progress_params: Default::default(),
};
match server.request::<request::HoverRequest>(params).await {
Ok(Some(hover)) => {
let text = hover_to_string(hover);
let _ = op_tx
.send(vec![Operation::LspLocal(LspOp::HoverResponse(Some(text)))]);
}
Ok(None) => {
let _ = op_tx
.send(vec![Operation::LspLocal(LspOp::HoverResponse(None))]);
}
Err(e) => log::debug!("[lsp] hover error: {e}"),
}
});
}
LspCommand::RequestDefinition { uri, row, col } => {
log::debug!("lsp actor: request_definition {uri} {row}:{col}");
let server = actor.server.clone();
let op_tx = actor.op_tx.clone();
tokio::spawn(async move {
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: Url::parse(&uri).unwrap_or_else(|_| {
Url::parse("file:///unknown").unwrap()
}),
},
position: async_lsp::lsp_types::Position {
line: row,
character: col,
},
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
match server.request::<request::GotoDefinition>(params).await {
Ok(Some(result)) => {
let locations = goto_to_locations(result);
let _ = op_tx.send(vec![Operation::LspGoToDefinitionResult(locations)]);
}
Ok(None) => {}
Err(e) => log::debug!("[lsp] definition error: {e}"),
}
});
}
LspCommand::RequestPrepareRename { uri, row, col, fallback_name } => {
if !actor.supports_rename {
log::debug!("lsp actor: server does not support rename, skipping");
let _ = actor.op_tx.send(vec![Operation::LspRenameLocal(
LspRenameOp::OpenPrompt { current_name: fallback_name },
)]);
return;
}
if !actor.supports_prepare_rename {
let _ = actor.op_tx.send(vec![Operation::LspRenameLocal(
LspRenameOp::OpenPrompt { current_name: fallback_name },
)]);
return;
}
log::debug!("lsp actor: request_prepare_rename {uri} {row}:{col}");
let server = actor.server.clone();
let op_tx = actor.op_tx.clone();
tokio::spawn(async move {
let params = TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: Url::parse(&uri).unwrap_or_else(|_| Url::parse("file:///unknown").unwrap()),
},
position: Position { line: row, character: col },
};
match server.request::<request::PrepareRenameRequest>(params).await {
Ok(Some(result)) => {
use async_lsp::lsp_types::PrepareRenameResponse;
let current_name = match result {
PrepareRenameResponse::Range(_) => fallback_name,
PrepareRenameResponse::RangeWithPlaceholder { placeholder, .. } => placeholder,
PrepareRenameResponse::DefaultBehavior { .. } => fallback_name,
};
let _ = op_tx.send(vec![Operation::LspRenameLocal(
LspRenameOp::OpenPrompt { current_name },
)]);
}
Ok(None) => {
let _ = op_tx.send(vec![Operation::LspRenameLocal(
LspRenameOp::OpenPrompt { current_name: fallback_name },
)]);
}
Err(e) => {
log::debug!("[lsp] prepareRename error: {e}");
let _ = op_tx.send(vec![Operation::LspRenameLocal(
LspRenameOp::OpenPrompt { current_name: fallback_name },
)]);
}
}
});
}
LspCommand::RequestRename { uri, row, col, new_name } => {
if !actor.supports_rename {
log::debug!("lsp actor: server does not support rename, skipping");
return;
}
log::debug!("lsp actor: request_rename {uri} {row}:{col} -> {new_name:?}");
let server = actor.server.clone();
let op_tx = actor.op_tx.clone();
tokio::spawn(async move {
let params = RenameParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: Url::parse(&uri).unwrap_or_else(|_| Url::parse("file:///unknown").unwrap()),
},
position: Position { line: row, character: col },
},
new_name,
work_done_progress_params: Default::default(),
};
match server.request::<request::Rename>(params).await {
Ok(Some(workspace_edit)) => {
let file_edits = workspace_edit_to_file_edits(workspace_edit);
let _ = op_tx.send(vec![Operation::LspRenameResult(file_edits)]);
}
Ok(None) => {
log::debug!("[lsp] rename: server returned no edits");
}
Err(e) => log::debug!("[lsp] rename error: {e}"),
}
});
}
LspCommand::Shutdown => {
log::debug!("lsp actor: shutdown requested");
let _ = actor.server.request::<request::Shutdown>(()).await;
let _ = actor.server.notify::<notification::Exit>(());
}
}
}
fn handle_publish_diagnostics(
op_tx: &mpsc::UnboundedSender<Vec<Operation>>,
server_name: &str,
params: async_lsp::lsp_types::PublishDiagnosticsParams,
) {
let uri_str = params.uri.to_string();
let marker = format!("lsp:{uri_str}");
let path = uri_to_path(&uri_str);
let mut ops = vec![Operation::ClearIssuesByMarker { marker: marker.clone() }];
for diag in ¶ms.diagnostics {
let severity = lsp_severity_to_issue(diag.severity);
let range = lsp_range_to_position_range(&diag.range);
ops.push(Operation::AddIssue {
issue: crate::issue_registry::NewIssue {
marker: Some(marker.clone()),
source: server_name.to_owned(),
path: Some(path.clone()),
range: Some(range),
message: diag.message.clone(),
severity,
},
});
}
let _ = op_tx.send(ops);
}
fn lsp_severity_to_issue(
sev: Option<async_lsp::lsp_types::DiagnosticSeverity>,
) -> crate::issue_registry::Severity {
use async_lsp::lsp_types::DiagnosticSeverity;
use crate::issue_registry::Severity;
match sev {
Some(DiagnosticSeverity::ERROR) => Severity::Error,
Some(DiagnosticSeverity::WARNING) => Severity::Warning,
Some(DiagnosticSeverity::INFORMATION) => Severity::Info,
_ => Severity::Info,
}
}
fn lsp_range_to_position_range(
range: &async_lsp::lsp_types::Range,
) -> (crate::editor::position::Position, crate::editor::position::Position) {
let start = crate::editor::position::Position::new(
range.start.line as usize,
range.start.character as usize,
);
let end = crate::editor::position::Position::new(
range.end.line as usize,
range.end.character as usize,
);
(start, end)
}
fn completion_item_to_lsp(item: &async_lsp::lsp_types::CompletionItem) -> LspCompletionItem {
LspCompletionItem {
label: item.label.clone(),
kind: item.kind.and_then(|k| {
serde_json::to_value(k).ok().and_then(|v| v.as_u64()).map(|n| n as u8)
}),
detail: item.detail.clone(),
insert_text: item.insert_text.clone(),
}
}
fn hover_to_string(hover: async_lsp::lsp_types::Hover) -> String {
match hover.contents {
HoverContents::Scalar(MarkedString::String(s)) => s,
HoverContents::Scalar(MarkedString::LanguageString(ls)) => ls.value,
HoverContents::Array(parts) => parts
.into_iter()
.map(|p| match p {
MarkedString::String(s) => s,
MarkedString::LanguageString(ls) => ls.value,
})
.collect::<Vec<_>>()
.join("\n\n"),
HoverContents::Markup(m) => m.value,
}
}
fn goto_to_locations(result: GotoDefinitionResponse) -> Vec<LspLocation> {
let locs = match result {
GotoDefinitionResponse::Scalar(loc) => vec![loc],
GotoDefinitionResponse::Array(locs) => locs,
GotoDefinitionResponse::Link(links) => links
.into_iter()
.map(|l| async_lsp::lsp_types::Location {
uri: l.target_uri,
range: l.target_range,
})
.collect(),
};
locs.into_iter()
.map(|loc| LspLocation {
path: uri_to_path(loc.uri.as_str()),
row: loc.range.start.line as usize,
col: loc.range.start.character as usize,
})
.collect()
}
fn build_client_capabilities() -> ClientCapabilities {
ClientCapabilities {
text_document: Some(TextDocumentClientCapabilities {
completion: Some(CompletionClientCapabilities {
completion_item: Some(CompletionItemCapability {
snippet_support: Some(false),
..Default::default()
}),
..Default::default()
}),
hover: Some(HoverClientCapabilities::default()),
definition: Some(GotoCapability::default()),
publish_diagnostics: Some(PublishDiagnosticsClientCapabilities::default()),
..Default::default()
}),
..Default::default()
}
}
fn compute_change_range(old: &str, new: &str) -> (usize, usize, usize, usize) {
if old == new {
return (0, 0, 0, 0);
}
let old_chars: Vec<(usize, char)> = old.char_indices().collect();
let new_chars: Vec<(usize, char)> = new.char_indices().collect();
let mut i = 0usize;
while i < old_chars.len() && i < new_chars.len() && old_chars[i].1 == new_chars[i].1 {
i += 1;
}
let old_prefix_byte = if i == 0 {
0
} else {
let (b, c) = old_chars[i - 1];
b + c.len_utf8()
};
let new_prefix_byte = if i == 0 {
0
} else {
let (b, c) = new_chars[i - 1];
b + c.len_utf8()
};
let mut suffix = 0usize;
while suffix < old_chars.len() - i && suffix < new_chars.len() - i
&& old_chars[old_chars.len() - 1 - suffix].1
== new_chars[new_chars.len() - 1 - suffix].1
{
suffix += 1;
}
let old_end_byte = if suffix == 0 {
old.len()
} else {
old_chars[old_chars.len() - suffix].0
};
let new_end_byte = if suffix == 0 {
new.len()
} else {
new_chars[new_chars.len() - suffix].0
};
(old_prefix_byte, old_end_byte, new_prefix_byte, new_end_byte)
}
fn byte_index_to_position(text: &str, byte_idx: usize) -> (u32, u32) {
let prefix = &text[..byte_idx.min(text.len())];
let line = prefix.matches('\n').count() as u32;
let last_newline = prefix.rfind('\n').map(|i| i + 1).unwrap_or(0);
let character = prefix[last_newline..].chars().count() as u32;
(line, character)
}
fn workspace_edit_to_file_edits(edit: WorkspaceEdit) -> Vec<LspFileEdit> {
let mut map: std::collections::HashMap<std::path::PathBuf, Vec<LspTextEdit>> =
std::collections::HashMap::new();
if let Some(changes) = edit.changes {
for (uri, text_edits) in changes {
let path = uri_to_path(uri.as_str());
let entry = map.entry(path).or_default();
for te in text_edits {
entry.push(lsp_text_edit_to_ours(te));
}
}
}
if let Some(doc_changes) = edit.document_changes {
map.clear();
use async_lsp::lsp_types::DocumentChanges;
match doc_changes {
DocumentChanges::Edits(edits) => {
for te in edits {
let path = uri_to_path(te.text_document.uri.as_str());
let entry = map.entry(path).or_default();
for e in te.edits {
use async_lsp::lsp_types::OneOf;
match e {
OneOf::Left(text_edit) => {
entry.push(lsp_text_edit_to_ours(text_edit));
}
OneOf::Right(annotated) => {
entry.push(lsp_text_edit_to_ours(annotated.text_edit));
}
}
}
}
}
DocumentChanges::Operations(_) => {
}
}
}
map.into_iter()
.map(|(path, edits)| LspFileEdit { path, edits })
.collect()
}
fn lsp_text_edit_to_ours(te: TextEdit) -> LspTextEdit {
LspTextEdit {
start_line: te.range.start.line,
start_char: te.range.start.character,
end_line: te.range.end.line,
end_char: te.range.end.character,
new_text: te.new_text,
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_lsp::lsp_types::*;
#[test]
fn lsp_severity_mapping() {
use crate::issue_registry::Severity;
assert_eq!(lsp_severity_to_issue(Some(DiagnosticSeverity::ERROR)), Severity::Error);
assert_eq!(lsp_severity_to_issue(Some(DiagnosticSeverity::WARNING)), Severity::Warning);
assert_eq!(lsp_severity_to_issue(Some(DiagnosticSeverity::INFORMATION)), Severity::Info);
assert_eq!(lsp_severity_to_issue(Some(DiagnosticSeverity::HINT)), Severity::Info);
assert_eq!(lsp_severity_to_issue(None), Severity::Info);
}
#[test]
fn completion_item_mapping() {
let item = CompletionItem {
label: "println!".to_string(),
kind: Some(CompletionItemKind::FUNCTION),
detail: Some("fn println(...)".to_string()),
insert_text: Some("println!($0)".to_string()),
..Default::default()
};
let lsp_item = completion_item_to_lsp(&item);
assert_eq!(lsp_item.label, "println!");
assert_eq!(lsp_item.detail.as_deref(), Some("fn println(...)"));
}
#[tokio::test]
async fn publish_diagnostics_emits_operations() {
use tokio::sync::mpsc;
let (tx, mut rx) = mpsc::unbounded_channel::<Vec<crate::operation::Operation>>();
let params = PublishDiagnosticsParams {
uri: Url::parse("file:///project/src/main.rs").unwrap(),
version: None,
diagnostics: vec![Diagnostic {
range: Range {
start: Position { line: 5, character: 4 },
end: Position { line: 5, character: 10 },
},
severity: Some(DiagnosticSeverity::ERROR),
message: "cannot find value `foo`".to_string(),
..Default::default()
}],
};
handle_publish_diagnostics(&tx, "rust", params);
let ops = rx.recv().await.unwrap();
assert!(
matches!(&ops[0], crate::operation::Operation::ClearIssuesByMarker { marker }
if marker.starts_with("lsp:")),
"first op should be ClearIssuesByMarker"
);
if let crate::operation::Operation::AddIssue { issue } = &ops[1] {
assert_eq!(issue.severity, crate::issue_registry::Severity::Error);
assert_eq!(issue.message, "cannot find value `foo`");
assert_eq!(issue.source, "rust", "source should equal server_name");
} else {
panic!("expected AddIssue at ops[1]");
}
}
#[tokio::test]
async fn publish_diagnostics_hint_maps_to_info() {
use tokio::sync::mpsc;
let (tx, mut rx) = mpsc::unbounded_channel::<Vec<crate::operation::Operation>>();
let params = PublishDiagnosticsParams {
uri: Url::parse("file:///project/src/lib.rs").unwrap(),
version: None,
diagnostics: vec![Diagnostic {
range: Range {
start: Position { line: 0, character: 0 },
end: Position { line: 0, character: 1 },
},
severity: Some(DiagnosticSeverity::HINT),
message: "consider using a semicolon".to_string(),
..Default::default()
}],
};
handle_publish_diagnostics(&tx, "typescript", params);
let ops = rx.recv().await.unwrap();
if let crate::operation::Operation::AddIssue { issue } = &ops[1] {
assert_eq!(issue.severity, crate::issue_registry::Severity::Info,
"HINT should map to Info, not Todo");
assert_eq!(issue.source, "typescript");
} else {
panic!("expected AddIssue at ops[1]");
}
}
#[test]
fn hover_to_string_markup() {
let hover = Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: "**Description**\n\nSome docs".to_string(),
}),
range: None,
};
let s = hover_to_string(hover);
assert_eq!(s, "**Description**\n\nSome docs");
}
#[test]
fn hover_to_string_scalar_string() {
let hover = Hover {
contents: HoverContents::Scalar(MarkedString::String("plain text".to_string())),
range: None,
};
assert_eq!(hover_to_string(hover), "plain text");
}
#[test]
fn uri_to_path_posix() {
if !cfg!(windows) {
let p = uri_to_path("file:///home/user/foo.rs");
assert_eq!(p, std::path::PathBuf::from("/home/user/foo.rs"));
}
}
}