Add host-configurable LSP server
This commit is contained in:
@@ -0,0 +1,807 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fs,
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
|
||||
use decodal::{
|
||||
Diagnostic as DecodalDiagnostic, DiagnosticKind, HostEnvironment, ImportLoader, LoadedImport,
|
||||
LoadedSource, SourceId, Span,
|
||||
};
|
||||
use decodal_language_service::{LanguageService, SemanticAnalysis};
|
||||
use decodal_language_tools::format_source;
|
||||
use lsp_server::{Connection, ErrorCode, Message, Notification, Request, Response};
|
||||
pub use lsp_types::InitializeParams;
|
||||
use lsp_types::{
|
||||
Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DidChangeTextDocumentParams,
|
||||
DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams,
|
||||
DocumentFormattingParams, InitializeResult, Location, NumberOrString, OneOf, Position,
|
||||
PositionEncodingKind, PublishDiagnosticsParams, Range, SaveOptions, ServerCapabilities,
|
||||
ServerInfo, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
|
||||
TextDocumentSyncSaveOptions, TextEdit, Uri,
|
||||
};
|
||||
|
||||
pub type ServerResult<T = ()> = Result<T, Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// Editor lifecycle hooks layered on top of a production host environment.
|
||||
///
|
||||
/// An implementation can store unsaved documents in the same state consulted
|
||||
/// by its import loader. The default methods are sufficient when imports do
|
||||
/// not need editor overlays.
|
||||
pub trait LspEnvironment: HostEnvironment {
|
||||
/// Updates host-owned editor overlays when a document is opened.
|
||||
fn open_document(&mut self, _key: &str, _source: &str) {}
|
||||
|
||||
/// Updates host-owned editor overlays when a document changes.
|
||||
fn change_document(&mut self, _key: &str, _source: &str) {}
|
||||
|
||||
/// Removes a document from host-owned editor overlays when it closes.
|
||||
fn close_document(&mut self, _key: &str) {}
|
||||
|
||||
/// Selects documents that should be evaluated as Decodal roots.
|
||||
///
|
||||
/// Other synchronized documents are still passed to the lifecycle hooks,
|
||||
/// so a host loader can consume unsaved Markdown or other external files.
|
||||
fn is_decodal_document(&self, key: &str, language_id: &str) -> bool {
|
||||
language_id == "decodal"
|
||||
|| Path::new(key)
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "dcdl")
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a stdio language server using an environment built from the client's
|
||||
/// initialization parameters.
|
||||
pub fn run_stdio<E, F>(create_environment: F) -> ServerResult
|
||||
where
|
||||
E: LspEnvironment,
|
||||
F: FnOnce(&InitializeParams) -> ServerResult<E>,
|
||||
{
|
||||
let (connection, io_threads) = Connection::stdio();
|
||||
run_connection(connection, create_environment)?;
|
||||
io_threads.join()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs a language server on an existing transport.
|
||||
///
|
||||
/// The environment factory is called exactly once after receiving and parsing
|
||||
/// the client's `initialize` request and before returning server capabilities.
|
||||
pub fn run_connection<E, F>(connection: Connection, create_environment: F) -> ServerResult
|
||||
where
|
||||
E: LspEnvironment,
|
||||
F: FnOnce(&InitializeParams) -> ServerResult<E>,
|
||||
{
|
||||
let (initialize_id, initialize_params) = connection.initialize_start()?;
|
||||
let initialize_params: InitializeParams = match serde_json::from_value(initialize_params) {
|
||||
Ok(params) => params,
|
||||
Err(error) => {
|
||||
connection.sender.send(Message::Response(Response::new_err(
|
||||
initialize_id,
|
||||
ErrorCode::InvalidParams as i32,
|
||||
error.to_string(),
|
||||
)))?;
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
let environment = match create_environment(&initialize_params) {
|
||||
Ok(environment) => environment,
|
||||
Err(error) => {
|
||||
connection.sender.send(Message::Response(Response::new_err(
|
||||
initialize_id,
|
||||
ErrorCode::InternalError as i32,
|
||||
error.to_string(),
|
||||
)))?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let initialize_result = InitializeResult {
|
||||
capabilities: server_capabilities(),
|
||||
server_info: Some(ServerInfo {
|
||||
name: String::from("decodal-lsp"),
|
||||
version: Some(String::from(env!("CARGO_PKG_VERSION"))),
|
||||
}),
|
||||
};
|
||||
connection.initialize_finish(initialize_id, serde_json::to_value(initialize_result)?)?;
|
||||
|
||||
let mut server = Server::new(connection, environment);
|
||||
server.run()
|
||||
}
|
||||
|
||||
fn server_capabilities() -> ServerCapabilities {
|
||||
ServerCapabilities {
|
||||
position_encoding: Some(PositionEncodingKind::UTF16),
|
||||
text_document_sync: Some(TextDocumentSyncCapability::Options(
|
||||
TextDocumentSyncOptions {
|
||||
open_close: Some(true),
|
||||
change: Some(TextDocumentSyncKind::FULL),
|
||||
will_save: None,
|
||||
will_save_wait_until: None,
|
||||
save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
|
||||
include_text: Some(true),
|
||||
})),
|
||||
},
|
||||
)),
|
||||
document_formatting_provider: Some(OneOf::Left(true)),
|
||||
..ServerCapabilities::default()
|
||||
}
|
||||
}
|
||||
|
||||
struct Server<E> {
|
||||
connection: Connection,
|
||||
service: LanguageService<E>,
|
||||
documents: HashMap<String, Document>,
|
||||
}
|
||||
|
||||
impl<E: LspEnvironment> Server<E> {
|
||||
fn new(connection: Connection, environment: E) -> Self {
|
||||
Self {
|
||||
connection,
|
||||
service: LanguageService::new(environment),
|
||||
documents: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self) -> ServerResult {
|
||||
while let Ok(message) = self.connection.receiver.recv() {
|
||||
match message {
|
||||
Message::Request(request) => {
|
||||
if self.connection.handle_shutdown(&request)? {
|
||||
break;
|
||||
}
|
||||
self.handle_request(request)?;
|
||||
}
|
||||
Message::Notification(notification) => {
|
||||
if notification.method == "exit" {
|
||||
break;
|
||||
}
|
||||
self.handle_notification(notification)?;
|
||||
}
|
||||
Message::Response(_) => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_request(&self, request: Request) -> ServerResult {
|
||||
match request.method.as_str() {
|
||||
"textDocument/formatting" => self.format_document(request),
|
||||
_ => self.reject_unknown_request(request),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_document(&self, request: Request) -> ServerResult {
|
||||
let id = request.id;
|
||||
let params: DocumentFormattingParams = match serde_json::from_value(request.params) {
|
||||
Ok(params) => params,
|
||||
Err(error) => {
|
||||
self.connection
|
||||
.sender
|
||||
.send(Message::Response(Response::new_err(
|
||||
id,
|
||||
ErrorCode::InvalidParams as i32,
|
||||
error.to_string(),
|
||||
)))?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let document_id = params.text_document.uri.as_str();
|
||||
let edits = self.documents.get(document_id).and_then(|document| {
|
||||
format_source(&document.source).ok().map(|formatted| {
|
||||
if formatted == document.source {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![TextEdit::new(
|
||||
Range::new(
|
||||
Position::new(0, 0),
|
||||
byte_offset_to_position(&document.source, document.source.len()),
|
||||
),
|
||||
formatted,
|
||||
)]
|
||||
}
|
||||
})
|
||||
});
|
||||
self.connection
|
||||
.sender
|
||||
.send(Message::Response(Response::new_ok(id, edits)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_unknown_request(&self, request: Request) -> ServerResult {
|
||||
self.connection
|
||||
.sender
|
||||
.send(Message::Response(Response::new_err(
|
||||
request.id,
|
||||
ErrorCode::MethodNotFound as i32,
|
||||
format!("unsupported request `{}`", request.method),
|
||||
)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_notification(&mut self, notification: Notification) -> ServerResult {
|
||||
match notification.method.as_str() {
|
||||
"textDocument/didOpen" => {
|
||||
let params: DidOpenTextDocumentParams =
|
||||
serde_json::from_value(notification.params)?;
|
||||
self.open_document(params)
|
||||
}
|
||||
"textDocument/didChange" => {
|
||||
let params: DidChangeTextDocumentParams =
|
||||
serde_json::from_value(notification.params)?;
|
||||
self.change_document(params)
|
||||
}
|
||||
"textDocument/didSave" => {
|
||||
let params: DidSaveTextDocumentParams =
|
||||
serde_json::from_value(notification.params)?;
|
||||
self.save_document(params)
|
||||
}
|
||||
"textDocument/didClose" => {
|
||||
let params: DidCloseTextDocumentParams =
|
||||
serde_json::from_value(notification.params)?;
|
||||
self.close_document(params)
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_document(&mut self, params: DidOpenTextDocumentParams) -> ServerResult {
|
||||
let item = params.text_document;
|
||||
let id = item.uri.as_str().to_owned();
|
||||
let key = document_key(&item.uri);
|
||||
let is_decodal = self
|
||||
.service
|
||||
.environment()
|
||||
.is_decodal_document(&key, &item.language_id);
|
||||
let document = Document {
|
||||
uri: item.uri,
|
||||
key,
|
||||
source: item.text,
|
||||
version: Some(item.version),
|
||||
is_decodal,
|
||||
};
|
||||
self.service
|
||||
.environment_mut()
|
||||
.open_document(&document.key, &document.source);
|
||||
self.documents.insert(id, document);
|
||||
self.publish_all_diagnostics()
|
||||
}
|
||||
|
||||
fn change_document(&mut self, params: DidChangeTextDocumentParams) -> ServerResult {
|
||||
let id = params.text_document.uri.as_str().to_owned();
|
||||
let Some(document) = self.documents.get_mut(&id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(change) = params.content_changes.last() else {
|
||||
return Ok(());
|
||||
};
|
||||
if change.range.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
document.source.clone_from(&change.text);
|
||||
document.version = Some(params.text_document.version);
|
||||
self.service
|
||||
.environment_mut()
|
||||
.change_document(&document.key, &document.source);
|
||||
self.publish_all_diagnostics()
|
||||
}
|
||||
|
||||
fn save_document(&mut self, params: DidSaveTextDocumentParams) -> ServerResult {
|
||||
let id = params.text_document.uri.as_str().to_owned();
|
||||
let Some(document) = self.documents.get_mut(&id) else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(source) = params.text {
|
||||
document.source = source;
|
||||
self.service
|
||||
.environment_mut()
|
||||
.change_document(&document.key, &document.source);
|
||||
}
|
||||
self.publish_all_diagnostics()
|
||||
}
|
||||
|
||||
fn close_document(&mut self, params: DidCloseTextDocumentParams) -> ServerResult {
|
||||
let id = params.text_document.uri.as_str().to_owned();
|
||||
if let Some(document) = self.documents.remove(&id) {
|
||||
self.service.environment_mut().close_document(&document.key);
|
||||
self.send_diagnostics(document.uri, Vec::new(), None)?;
|
||||
self.publish_all_diagnostics()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish_all_diagnostics(&self) -> ServerResult {
|
||||
for id in self
|
||||
.documents
|
||||
.iter()
|
||||
.filter_map(|(id, document)| document.is_decodal.then_some(id))
|
||||
{
|
||||
self.publish_diagnostics(id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish_diagnostics(&self, id: &str) -> ServerResult {
|
||||
let Some(document) = self.documents.get(id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let analysis =
|
||||
self.service
|
||||
.analyze(document.key.clone(), document.key.clone(), &document.source);
|
||||
let diagnostics = analysis_to_lsp(&analysis, &document.uri, &document.source);
|
||||
self.send_diagnostics(document.uri.clone(), diagnostics, document.version)
|
||||
}
|
||||
|
||||
fn send_diagnostics(
|
||||
&self,
|
||||
uri: Uri,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
version: Option<i32>,
|
||||
) -> ServerResult {
|
||||
let params = PublishDiagnosticsParams {
|
||||
uri,
|
||||
diagnostics,
|
||||
version,
|
||||
};
|
||||
self.connection
|
||||
.sender
|
||||
.send(Message::Notification(Notification::new(
|
||||
String::from("textDocument/publishDiagnostics"),
|
||||
params,
|
||||
)))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Document {
|
||||
uri: Uri,
|
||||
key: String,
|
||||
source: String,
|
||||
version: Option<i32>,
|
||||
is_decodal: bool,
|
||||
}
|
||||
|
||||
fn analysis_to_lsp(analysis: &SemanticAnalysis, uri: &Uri, source: &str) -> Vec<Diagnostic> {
|
||||
analysis
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| diagnostic_to_lsp(diagnostic, uri, source))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn diagnostic_to_lsp(diagnostic: &DecodalDiagnostic, uri: &Uri, source: &str) -> Diagnostic {
|
||||
let range = if diagnostic.span.source == SourceId(0) {
|
||||
span_to_range(source, diagnostic.span)
|
||||
} else {
|
||||
Range::default()
|
||||
};
|
||||
let related_information = diagnostic
|
||||
.labels
|
||||
.iter()
|
||||
.filter(|label| label.span.source == SourceId(0))
|
||||
.map(|label| DiagnosticRelatedInformation {
|
||||
location: Location {
|
||||
uri: uri.clone(),
|
||||
range: span_to_range(source, label.span),
|
||||
},
|
||||
message: label.message.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut message = diagnostic.message.clone();
|
||||
for note in &diagnostic.notes {
|
||||
message.push('\n');
|
||||
message.push_str(note);
|
||||
}
|
||||
Diagnostic {
|
||||
range,
|
||||
severity: Some(DiagnosticSeverity::ERROR),
|
||||
code: Some(NumberOrString::String(format!("{:?}", diagnostic.kind))),
|
||||
code_description: None,
|
||||
source: Some(String::from("decodal")),
|
||||
message,
|
||||
related_information: (!related_information.is_empty()).then_some(related_information),
|
||||
tags: None,
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn span_to_range(source: &str, span: Span) -> Range {
|
||||
Range {
|
||||
start: byte_offset_to_position(source, span.start as usize),
|
||||
end: byte_offset_to_position(source, span.end as usize),
|
||||
}
|
||||
}
|
||||
|
||||
fn byte_offset_to_position(source: &str, offset: usize) -> Position {
|
||||
let mut offset = offset.min(source.len());
|
||||
while !source.is_char_boundary(offset) {
|
||||
offset = offset.saturating_sub(1);
|
||||
}
|
||||
let mut line = 0u32;
|
||||
let mut character = 0u32;
|
||||
for ch in source[..offset].chars() {
|
||||
if ch == '\n' {
|
||||
line += 1;
|
||||
character = 0;
|
||||
} else {
|
||||
character += ch.len_utf16() as u32;
|
||||
}
|
||||
}
|
||||
Position { line, character }
|
||||
}
|
||||
|
||||
fn document_key(uri: &Uri) -> String {
|
||||
if uri
|
||||
.scheme()
|
||||
.is_some_and(|scheme| scheme.eq_lowercase("file"))
|
||||
{
|
||||
if let Ok(path) = uri.path().as_estr().decode().into_string() {
|
||||
return normalize_path(file_uri_path(path.into_owned()));
|
||||
}
|
||||
}
|
||||
uri.as_str().to_owned()
|
||||
}
|
||||
|
||||
fn file_uri_path(path: String) -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
let path = path
|
||||
.strip_prefix('/')
|
||||
.filter(|path| path.as_bytes().get(1) == Some(&b':'))
|
||||
.unwrap_or(&path)
|
||||
.to_owned();
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
fn normalize_path(path: PathBuf) -> String {
|
||||
path.canonicalize()
|
||||
.unwrap_or_else(|_| normalize_lexically(&path))
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn normalize_lexically(path: &Path) -> PathBuf {
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
component => normalized.push(component.as_os_str()),
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FileSystemEnvironment {
|
||||
overlays: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl HostEnvironment for FileSystemEnvironment {
|
||||
type Loader = FileSystemLoader;
|
||||
|
||||
fn create_loader(&self) -> Self::Loader {
|
||||
FileSystemLoader {
|
||||
overlays: self.overlays.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LspEnvironment for FileSystemEnvironment {
|
||||
fn open_document(&mut self, key: &str, source: &str) {
|
||||
self.overlays.insert(key.to_owned(), source.to_owned());
|
||||
}
|
||||
|
||||
fn change_document(&mut self, key: &str, source: &str) {
|
||||
self.overlays.insert(key.to_owned(), source.to_owned());
|
||||
}
|
||||
|
||||
fn close_document(&mut self, key: &str) {
|
||||
self.overlays.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileSystemLoader {
|
||||
overlays: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ImportLoader for FileSystemLoader {
|
||||
fn load(
|
||||
&mut self,
|
||||
current_key: Option<&str>,
|
||||
specifier: &str,
|
||||
) -> decodal::Result<LoadedImport> {
|
||||
let path = Path::new(specifier);
|
||||
let path = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else if let Some(current_key) = current_key {
|
||||
Path::new(current_key)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(path)
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
let key = normalize_path(path.clone());
|
||||
let source = if let Some(source) = self.overlays.get(&key) {
|
||||
source.clone()
|
||||
} else {
|
||||
fs::read_to_string(&path).map_err(|error| {
|
||||
DecodalDiagnostic::new(
|
||||
DiagnosticKind::Import,
|
||||
Span::default(),
|
||||
format!("failed to read `{}`: {error}", path.display()),
|
||||
)
|
||||
})?
|
||||
};
|
||||
Ok(LoadedImport::Source(LoadedSource {
|
||||
key: key.clone(),
|
||||
name: key,
|
||||
source,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
str::FromStr,
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use decodal::{Engine, HostValue};
|
||||
use lsp_server::RequestId;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestEnvironment {
|
||||
overlays: Arc<Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
struct TestLoader {
|
||||
overlays: Arc<Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
impl ImportLoader for TestLoader {
|
||||
fn load(
|
||||
&mut self,
|
||||
_current_key: Option<&str>,
|
||||
_specifier: &str,
|
||||
) -> decodal::Result<LoadedImport> {
|
||||
let draft = self
|
||||
.overlays
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get("/tmp/post.md")
|
||||
.is_some_and(|source| source == "draft: false");
|
||||
Ok(LoadedImport::value(
|
||||
"content/post.md",
|
||||
HostValue::object([(
|
||||
"draft",
|
||||
if draft {
|
||||
HostValue::bool(false)
|
||||
} else {
|
||||
HostValue::string("maybe")
|
||||
},
|
||||
)]),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl HostEnvironment for TestEnvironment {
|
||||
type Loader = TestLoader;
|
||||
|
||||
fn create_loader(&self) -> Self::Loader {
|
||||
TestLoader {
|
||||
overlays: Arc::clone(&self.overlays),
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_engine(&self, engine: &mut Engine<Self::Loader>) -> decodal::Result<()> {
|
||||
engine.bind_global(
|
||||
"Post",
|
||||
HostValue::object([("draft", HostValue::bool_type())]),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl LspEnvironment for TestEnvironment {
|
||||
fn open_document(&mut self, key: &str, source: &str) {
|
||||
self.overlays
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key.to_owned(), source.to_owned());
|
||||
}
|
||||
|
||||
fn change_document(&mut self, key: &str, source: &str) {
|
||||
self.open_document(key, source);
|
||||
}
|
||||
|
||||
fn close_document(&mut self, key: &str) {
|
||||
self.overlays.lock().unwrap().remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_byte_offsets_to_utf16_positions() {
|
||||
let source = "a😀\nβ";
|
||||
assert_eq!(byte_offset_to_position(source, 0), Position::new(0, 0));
|
||||
assert_eq!(byte_offset_to_position(source, 1), Position::new(0, 1));
|
||||
assert_eq!(byte_offset_to_position(source, 5), Position::new(0, 3));
|
||||
assert_eq!(byte_offset_to_position(source, 6), Position::new(1, 0));
|
||||
assert_eq!(
|
||||
byte_offset_to_position(source, source.len()),
|
||||
Position::new(1, 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_percent_encoded_file_uris_to_document_keys() {
|
||||
let uri = Uri::from_str("file:///tmp/hello%20world.dcdl").unwrap();
|
||||
assert_eq!(document_key(&uri), "/tmp/hello world.dcdl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_host_environment_diagnostics_over_lsp() {
|
||||
let (server_connection, client_connection) = Connection::memory();
|
||||
let initialized_workspace = Arc::new(Mutex::new(None));
|
||||
let server_workspace = Arc::clone(&initialized_workspace);
|
||||
let server = thread::spawn(move || {
|
||||
run_connection(server_connection, move |initialize| {
|
||||
let workspace = initialize
|
||||
.workspace_folders
|
||||
.as_ref()
|
||||
.and_then(|folders| folders.first())
|
||||
.map(|folder| folder.uri.as_str().to_owned());
|
||||
*server_workspace.lock().unwrap() = workspace;
|
||||
Ok(TestEnvironment::default())
|
||||
})
|
||||
});
|
||||
|
||||
client_connection
|
||||
.sender
|
||||
.send(Message::Request(Request {
|
||||
id: RequestId::from(1),
|
||||
method: String::from("initialize"),
|
||||
params: json!({
|
||||
"processId": null,
|
||||
"capabilities": {},
|
||||
"workspaceFolders": [
|
||||
{ "uri": "file:///tmp", "name": "tmp" }
|
||||
]
|
||||
}),
|
||||
}))
|
||||
.unwrap();
|
||||
let initialized = client_connection
|
||||
.receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap();
|
||||
let Message::Response(initialized) = initialized else {
|
||||
panic!("expected initialize response")
|
||||
};
|
||||
assert!(initialized.response_result.is_ok());
|
||||
assert_eq!(
|
||||
initialized_workspace.lock().unwrap().as_deref(),
|
||||
Some("file:///tmp")
|
||||
);
|
||||
client_connection
|
||||
.sender
|
||||
.send(Message::Notification(Notification::new(
|
||||
String::from("initialized"),
|
||||
json!({}),
|
||||
)))
|
||||
.unwrap();
|
||||
|
||||
let uri = Uri::from_str("file:///tmp/main.dcdl").unwrap();
|
||||
client_connection
|
||||
.sender
|
||||
.send(Message::Notification(Notification::new(
|
||||
String::from("textDocument/didOpen"),
|
||||
DidOpenTextDocumentParams {
|
||||
text_document: lsp_types::TextDocumentItem {
|
||||
uri: uri.clone(),
|
||||
language_id: String::from("decodal"),
|
||||
version: 1,
|
||||
text: String::from(r#"Post & import "./post.md""#),
|
||||
},
|
||||
},
|
||||
)))
|
||||
.unwrap();
|
||||
let published = client_connection
|
||||
.receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap();
|
||||
let Message::Notification(published) = published else {
|
||||
panic!("expected diagnostics notification")
|
||||
};
|
||||
let params: PublishDiagnosticsParams = serde_json::from_value(published.params).unwrap();
|
||||
assert_eq!(params.uri, uri);
|
||||
assert_eq!(params.version, Some(1));
|
||||
assert_eq!(params.diagnostics.len(), 1);
|
||||
assert!(params.diagnostics[0].message.contains("content/post.md"));
|
||||
assert!(params.diagnostics[0].message.contains("draft"));
|
||||
|
||||
let markdown_uri = Uri::from_str("file:///tmp/post.md").unwrap();
|
||||
client_connection
|
||||
.sender
|
||||
.send(Message::Notification(Notification::new(
|
||||
String::from("textDocument/didOpen"),
|
||||
DidOpenTextDocumentParams {
|
||||
text_document: lsp_types::TextDocumentItem {
|
||||
uri: markdown_uri,
|
||||
language_id: String::from("markdown"),
|
||||
version: 1,
|
||||
text: String::from("draft: false"),
|
||||
},
|
||||
},
|
||||
)))
|
||||
.unwrap();
|
||||
let published = client_connection
|
||||
.receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap();
|
||||
let Message::Notification(published) = published else {
|
||||
panic!("expected diagnostics notification")
|
||||
};
|
||||
let params: PublishDiagnosticsParams = serde_json::from_value(published.params).unwrap();
|
||||
assert_eq!(params.uri, uri);
|
||||
assert!(params.diagnostics.is_empty());
|
||||
|
||||
client_connection
|
||||
.sender
|
||||
.send(Message::Request(Request {
|
||||
id: RequestId::from(2),
|
||||
method: String::from("textDocument/formatting"),
|
||||
params: json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"options": { "tabSize": 4, "insertSpaces": true }
|
||||
}),
|
||||
}))
|
||||
.unwrap();
|
||||
let formatted = client_connection
|
||||
.receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap();
|
||||
let Message::Response(formatted) = formatted else {
|
||||
panic!("expected formatting response")
|
||||
};
|
||||
let edits: Vec<TextEdit> =
|
||||
serde_json::from_value(formatted.response_result.unwrap()).unwrap();
|
||||
assert_eq!(edits.len(), 1);
|
||||
assert!(edits[0].new_text.ends_with('\n'));
|
||||
|
||||
client_connection
|
||||
.sender
|
||||
.send(Message::Request(Request {
|
||||
id: RequestId::from(3),
|
||||
method: String::from("shutdown"),
|
||||
params: json!(null),
|
||||
}))
|
||||
.unwrap();
|
||||
let shutdown = client_connection
|
||||
.receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap();
|
||||
let Message::Response(shutdown) = shutdown else {
|
||||
panic!("expected shutdown response")
|
||||
};
|
||||
assert!(shutdown.response_result.is_ok());
|
||||
client_connection
|
||||
.sender
|
||||
.send(Message::Notification(Notification::new(
|
||||
String::from("exit"),
|
||||
json!(null),
|
||||
)))
|
||||
.unwrap();
|
||||
server.join().unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match decodal_lsp::run_stdio(|_| Ok(decodal_lsp::FileSystemEnvironment::default())) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("decodal-lsp failed: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user