Add host-configurable LSP server
This commit is contained in:
Generated
+100
-3
@@ -11,6 +11,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -33,6 +39,21 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "decodal"
|
||||
version = "0.1.2"
|
||||
@@ -55,7 +76,7 @@ dependencies = [
|
||||
"decodal",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -76,6 +97,18 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decodal-lsp"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"decodal",
|
||||
"decodal-language-service",
|
||||
"decodal-language-tools",
|
||||
"lsp-server",
|
||||
"lsp-types",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decodal-wasm"
|
||||
version = "0.1.2"
|
||||
@@ -91,12 +124,53 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fluent-uri"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lsp-server"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ee25a31f2e571e426eef2896179450cafc7e2f5be00d8a93b1c2d21c0ff7656"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lsp-types"
|
||||
version = "0.97.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fluent-uri",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
@@ -169,6 +243,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -188,7 +263,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -204,6 +279,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -221,6 +307,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.22.6"
|
||||
@@ -277,7 +374,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.117",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ members = [
|
||||
"crates/decodal-derive",
|
||||
"crates/decodal-language-tools",
|
||||
"crates/decodal-language-service",
|
||||
"crates/decodal-lsp",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -65,6 +65,19 @@ Enable optional regex support when needed:
|
||||
cargo run -q -p decodal-cli --features regex -- examples/regex/main.dcdl
|
||||
```
|
||||
|
||||
## Language server
|
||||
|
||||
The `decodal-lsp` workspace package provides a stdio language server with live semantic diagnostics and document formatting.
|
||||
Its library API accepts the same host environment used for production evaluation, so application-defined globals and structured import routing remain available in the editor.
|
||||
|
||||
Run the default filesystem-backed server from the repository:
|
||||
|
||||
```sh
|
||||
cargo run -q -p decodal-lsp
|
||||
```
|
||||
|
||||
Embedded applications can implement `decodal_lsp::LspEnvironment` for their existing `HostEnvironment` and construct it from the `InitializeParams` passed to `decodal_lsp::run_stdio`.
|
||||
|
||||
## Web playground
|
||||
|
||||
The static documentation site and browser playground live under:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "decodal-lsp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
readme.workspace = true
|
||||
description = "Language Server Protocol implementation for Decodal."
|
||||
keywords = ["decodal", "lsp", "language-server", "editor"]
|
||||
categories = ["development-tools", "text-editors"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
decodal = { version = "0.1.2", path = "../decodal-core" }
|
||||
decodal-language-service = { version = "0.1.2", path = "../decodal-language-service" }
|
||||
decodal-language-tools = { version = "0.1.2", path = "../decodal-language-tools" }
|
||||
lsp-server = "0.10"
|
||||
lsp-types = "0.97"
|
||||
serde_json.workspace = true
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,14 +43,20 @@ The WebAssembly package is for execution, not syntax highlighting.
|
||||
|
||||
Semantic editor integration lives in the host-configurable language service crate.
|
||||
It depends only on the runtime and accepts the same `HostEnvironment` implementation used by a production application.
|
||||
A host-specific LSP binary can link this crate with its loader and global schema configuration without reimplementing evaluation rules.
|
||||
The LSP crate adapts that service to the Language Server Protocol over stdin/stdout.
|
||||
It provides full document synchronization, semantic diagnostics, and whole-document formatting.
|
||||
A host-specific LSP binary injects its loader and global schema configuration without reimplementing evaluation rules.
|
||||
|
||||
Important paths:
|
||||
|
||||
```text
|
||||
crates/decodal-language-service/
|
||||
crates/decodal-lsp/
|
||||
```
|
||||
|
||||
The default `decodal-lsp` binary reads Decodal imports from the filesystem.
|
||||
Embedded hosts can call its library entry point with a custom `LspEnvironment` to reuse structured imports and to make unsaved external documents, such as Markdown, visible to the loader.
|
||||
|
||||
Source formatting lives in a separate Rust language tools crate.
|
||||
Keeping it separate prevents host-specific semantic services from inheriting the formatter's Tree-sitter and WebAssembly dependencies.
|
||||
|
||||
@@ -124,6 +130,7 @@ Consumers that need syntax information should use the component that matches the
|
||||
- Browser execution: `decodal-wasm`
|
||||
- Web formatting and editor syntax: Lezer / CodeMirror
|
||||
- Semantic editor analysis: `decodal-language-service`
|
||||
- Language Server Protocol integration: `decodal-lsp`
|
||||
- Rust formatting: `decodal-language-tools`
|
||||
- General editor syntax: Tree-sitter
|
||||
|
||||
|
||||
@@ -182,6 +182,24 @@ let analysis = service.analyze("site.dcdl", "site.dcdl", source);
|
||||
Each analysis uses a fresh engine and runs the normal parse, evaluate, and materialize pipeline.
|
||||
An environment may create loaders backed by shared filesystem, database, or editor-overlay state when repeated analysis needs a current workspace snapshot.
|
||||
|
||||
The LSP adapter constructs that environment after receiving the client's initialization parameters:
|
||||
|
||||
```rust
|
||||
use decodal_lsp::{LspEnvironment, run_stdio};
|
||||
|
||||
impl LspEnvironment for AppEnvironment {}
|
||||
|
||||
run_stdio(|initialize| {
|
||||
let _ = initialize;
|
||||
Ok(AppEnvironment)
|
||||
})?;
|
||||
```
|
||||
|
||||
`LspEnvironment` adds document lifecycle hooks on top of `HostEnvironment`.
|
||||
A `run_stdio` environment factory always receives the client's `InitializeParams`; the host decides whether to use its workspace folders, initialization options, and capabilities or ignore them.
|
||||
A host that keeps unsaved buffers in shared state can update them from `open_document`, `change_document`, and `close_document`; every subsequent diagnostic pass creates the normal import loader from that updated environment.
|
||||
Synchronized non-Decodal documents are passed through these hooks but are not evaluated as Decodal roots, so a loader can parse an unsaved Markdown file into `HostValue` and immediately revalidate the open Decodal documents that import it.
|
||||
|
||||
## Structured imports
|
||||
|
||||
`ImportLoader::load` returns either `LoadedImport::Source` or `LoadedImport::Value`.
|
||||
|
||||
@@ -13,6 +13,18 @@ cargo check -p decodal --no-default-features
|
||||
nix flake check
|
||||
```
|
||||
|
||||
Run the default stdio language server with:
|
||||
|
||||
```sh
|
||||
cargo run -q -p decodal-lsp
|
||||
```
|
||||
|
||||
The protocol integration tests use an in-memory LSP connection and can be run independently:
|
||||
|
||||
```sh
|
||||
cargo test -p decodal-lsp
|
||||
```
|
||||
|
||||
Regex support is optional and should be tested explicitly when touched.
|
||||
|
||||
```sh
|
||||
@@ -24,7 +36,7 @@ cargo run -q -p decodal-cli --features regex -- examples/regex/main.dcdl
|
||||
|
||||
The primary crates.io package is `decodal`, which contains the embeddable library.
|
||||
`decodal-derive` provides optional derive macros for Rust struct integration and is published only when the derive crate changes.
|
||||
Workspace support crates such as `decodal-cli`, the Rust source crate `decodal-wasm`, and `decodal-language-tools` are not published to crates.io.
|
||||
Workspace support crates such as `decodal-cli`, the Rust source crate `decodal-wasm`, `decodal-language-tools`, and `decodal-lsp` are not published to crates.io.
|
||||
The generated WebAssembly package under `packages/decodal-wasm/` is published to npm and JSR.
|
||||
The CodeMirror package bundles the generated formatter WebAssembly from `decodal-language-tools`.
|
||||
The project is dual licensed as `MIT OR Apache-2.0`.
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
apps.default = mkApp "decodal" "Run Decodal";
|
||||
apps.decodal = mkApp "decodal" "Run Decodal";
|
||||
apps.decodal-lsp = mkApp "decodal-lsp" "Run the Decodal language server";
|
||||
|
||||
checks.default = decodal;
|
||||
|
||||
|
||||
+4
-1
@@ -24,7 +24,7 @@ let
|
||||
in
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "decodal";
|
||||
version = "0.1.1";
|
||||
version = "0.1.2";
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = srcRoot;
|
||||
@@ -38,6 +38,8 @@ rustPlatform.buildRustPackage {
|
||||
cargoBuildFlags = [
|
||||
"-p"
|
||||
"decodal-cli"
|
||||
"-p"
|
||||
"decodal-lsp"
|
||||
];
|
||||
|
||||
doInstallCheck = true;
|
||||
@@ -45,6 +47,7 @@ rustPlatform.buildRustPackage {
|
||||
runHook preInstallCheck
|
||||
|
||||
test -x "$out/bin/decodal"
|
||||
test -x "$out/bin/decodal-lsp"
|
||||
"$out/bin/decodal" >/dev/null
|
||||
|
||||
runHook postInstallCheck
|
||||
|
||||
Reference in New Issue
Block a user