Add host-configurable LSP server

This commit is contained in:
2026-08-12 05:42:45 +09:00
parent bdaccd9803
commit 610358110f
11 changed files with 996 additions and 6 deletions
+807
View File
@@ -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();
}
}
+11
View File
@@ -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
}
}
}