use std::path::PathBuf;
use std::sync::Arc;
use dashmap::DashMap;
use tower_lsp::jsonrpc::Result as LspResult;
use tower_lsp::lsp_types::{
DiagnosticOptions, DiagnosticServerCapabilities, DidChangeTextDocumentParams,
DidCloseTextDocumentParams, DidOpenTextDocumentParams, GotoDefinitionParams,
GotoDefinitionResponse, Hover, HoverContents, HoverParams, HoverProviderCapability,
InitializeParams, InitializeResult, InitializedParams, Location, MarkupContent, MarkupKind,
MessageType, OneOf, ServerCapabilities, ServerInfo, TextDocumentSyncCapability,
TextDocumentSyncKind, Url, WorkDoneProgressOptions,
};
use tower_lsp::{Client, LanguageServer};
use crate::diagnostics::{span_to_range, to_lsp_diagnostic};
use crate::goto_definition::find_definition;
use crate::hover::hover;
use crate::pipeline::check_document;
#[derive(Debug)]
pub struct BockLanguageServer {
client: Client,
documents: Arc<DashMap<Url, String>>,
}
impl BockLanguageServer {
#[must_use]
pub fn new(client: Client) -> Self {
Self {
client,
documents: Arc::new(DashMap::new()),
}
}
fn server_capabilities() -> ServerCapabilities {
ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
hover_provider: Some(HoverProviderCapability::Simple(true)),
definition_provider: Some(OneOf::Left(true)),
diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
identifier: Some("bock".to_string()),
inter_file_dependencies: true,
workspace_diagnostics: false,
work_done_progress_options: WorkDoneProgressOptions::default(),
})),
..ServerCapabilities::default()
}
}
async fn publish(&self, uri: Url, version: Option<i32>) {
let Some(content) = self.documents.get(&uri).map(|e| e.value().clone()) else {
return;
};
let path = url_to_path(&uri);
let uri_for_task = uri.clone();
let result =
tokio::task::spawn_blocking(move || check_document(path, content)).await;
let result = match result {
Ok(r) => r,
Err(err) => {
self.client
.log_message(MessageType::ERROR, format!("check pipeline panicked: {err}"))
.await;
return;
}
};
let source_file = result.source_map.get_file(result.file_id);
let lsp_diags: Vec<_> = result
.diagnostics
.iter()
.map(|d| to_lsp_diagnostic(d, &uri_for_task, source_file))
.collect();
self.client
.publish_diagnostics(uri_for_task, lsp_diags, version)
.await;
}
}
fn url_to_path(uri: &Url) -> PathBuf {
uri.to_file_path()
.unwrap_or_else(|_| PathBuf::from(uri.path()))
}
#[tower_lsp::async_trait]
impl LanguageServer for BockLanguageServer {
async fn initialize(&self, _params: InitializeParams) -> LspResult<InitializeResult> {
Ok(InitializeResult {
capabilities: Self::server_capabilities(),
server_info: Some(ServerInfo {
name: "bock-lsp".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}),
})
}
async fn initialized(&self, _: InitializedParams) {
self.client
.log_message(MessageType::INFO, "Bock LSP ready")
.await;
}
async fn shutdown(&self) -> LspResult<()> {
Ok(())
}
async fn did_open(&self, params: DidOpenTextDocumentParams) {
let doc = params.text_document;
self.documents.insert(doc.uri.clone(), doc.text);
self.publish(doc.uri, Some(doc.version)).await;
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
let uri = params.text_document.uri;
if let Some(change) = params.content_changes.into_iter().last() {
self.documents.insert(uri.clone(), change.text);
}
self.publish(uri, Some(params.text_document.version)).await;
}
async fn goto_definition(
&self,
params: GotoDefinitionParams,
) -> LspResult<Option<GotoDefinitionResponse>> {
let uri = params.text_document_position_params.text_document.uri;
let pos = params.text_document_position_params.position;
let Some(content) = self.documents.get(&uri).map(|e| e.value().clone()) else {
return Ok(None);
};
let path = url_to_path(&uri);
let result = tokio::task::spawn_blocking(move || {
find_definition(path, content, pos.line, pos.character)
})
.await;
let result = match result {
Ok(r) => r,
Err(err) => {
self.client
.log_message(
MessageType::ERROR,
format!("goto_definition panicked: {err}"),
)
.await;
return Ok(None);
}
};
let Some(def) = result else { return Ok(None) };
let source_file = def.source_map.get_file(def.file_id);
let range = span_to_range(def.target, source_file);
Ok(Some(GotoDefinitionResponse::Scalar(Location {
uri,
range,
})))
}
async fn hover(&self, params: HoverParams) -> LspResult<Option<Hover>> {
let uri = params.text_document_position_params.text_document.uri;
let pos = params.text_document_position_params.position;
let Some(content) = self.documents.get(&uri).map(|e| e.value().clone()) else {
return Ok(None);
};
let path = url_to_path(&uri);
let result =
tokio::task::spawn_blocking(move || hover(path, content, pos.line, pos.character))
.await;
let result = match result {
Ok(r) => r,
Err(err) => {
self.client
.log_message(MessageType::ERROR, format!("hover panicked: {err}"))
.await;
return Ok(None);
}
};
let Some(info) = result else { return Ok(None) };
let source_file = info.source_map.get_file(info.file_id);
let range = span_to_range(info.span, source_file);
Ok(Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: info.contents,
}),
range: Some(range),
}))
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
let uri = params.text_document.uri;
self.documents.remove(&uri);
self.client.publish_diagnostics(uri, Vec::new(), None).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capabilities_declare_required_providers() {
let caps = BockLanguageServer::server_capabilities();
match caps.text_document_sync {
Some(TextDocumentSyncCapability::Kind(kind)) => {
assert_eq!(kind, TextDocumentSyncKind::FULL);
}
_ => panic!("expected Full text document sync"),
}
assert!(
matches!(caps.hover_provider, Some(HoverProviderCapability::Simple(true))),
"hover provider must be enabled",
);
assert!(
matches!(caps.definition_provider, Some(OneOf::Left(true))),
"definition provider must be enabled",
);
assert!(
caps.diagnostic_provider.is_some(),
"diagnostic provider must be declared for F.1.2",
);
}
#[test]
fn url_to_path_handles_file_uri() {
let url = Url::parse("file:///tmp/foo.bock").unwrap();
assert_eq!(url_to_path(&url), PathBuf::from("/tmp/foo.bock"));
}
#[test]
fn url_to_path_falls_back_for_non_file_scheme() {
let url = Url::parse("untitled:Untitled-1").unwrap();
let path = url_to_path(&url);
assert!(!path.as_os_str().is_empty());
}
}