Add host-configurable LSP server
This commit is contained in:
Generated
+100
-3
@@ -11,6 +11,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -33,6 +39,21 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
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]]
|
[[package]]
|
||||||
name = "decodal"
|
name = "decodal"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -55,7 +76,7 @@ dependencies = [
|
|||||||
"decodal",
|
"decodal",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -76,6 +97,18 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "decodal-wasm"
|
name = "decodal-wasm"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -91,12 +124,53 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
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]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
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]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.2"
|
version = "2.8.2"
|
||||||
@@ -169,6 +243,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -188,7 +263,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -204,6 +279,17 @@ dependencies = [
|
|||||||
"zmij",
|
"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]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -221,6 +307,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "tree-sitter"
|
name = "tree-sitter"
|
||||||
version = "0.22.6"
|
version = "0.22.6"
|
||||||
@@ -277,7 +374,7 @@ dependencies = [
|
|||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ members = [
|
|||||||
"crates/decodal-derive",
|
"crates/decodal-derive",
|
||||||
"crates/decodal-language-tools",
|
"crates/decodal-language-tools",
|
||||||
"crates/decodal-language-service",
|
"crates/decodal-language-service",
|
||||||
|
"crates/decodal-lsp",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,19 @@ Enable optional regex support when needed:
|
|||||||
cargo run -q -p decodal-cli --features regex -- examples/regex/main.dcdl
|
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
|
## Web playground
|
||||||
|
|
||||||
The static documentation site and browser playground live under:
|
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.
|
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.
|
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:
|
Important paths:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
crates/decodal-language-service/
|
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.
|
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.
|
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`
|
- Browser execution: `decodal-wasm`
|
||||||
- Web formatting and editor syntax: Lezer / CodeMirror
|
- Web formatting and editor syntax: Lezer / CodeMirror
|
||||||
- Semantic editor analysis: `decodal-language-service`
|
- Semantic editor analysis: `decodal-language-service`
|
||||||
|
- Language Server Protocol integration: `decodal-lsp`
|
||||||
- Rust formatting: `decodal-language-tools`
|
- Rust formatting: `decodal-language-tools`
|
||||||
- General editor syntax: Tree-sitter
|
- 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.
|
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.
|
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
|
## Structured imports
|
||||||
|
|
||||||
`ImportLoader::load` returns either `LoadedImport::Source` or `LoadedImport::Value`.
|
`ImportLoader::load` returns either `LoadedImport::Source` or `LoadedImport::Value`.
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ cargo check -p decodal --no-default-features
|
|||||||
nix flake check
|
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.
|
Regex support is optional and should be tested explicitly when touched.
|
||||||
|
|
||||||
```sh
|
```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.
|
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.
|
`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 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 CodeMirror package bundles the generated formatter WebAssembly from `decodal-language-tools`.
|
||||||
The project is dual licensed as `MIT OR Apache-2.0`.
|
The project is dual licensed as `MIT OR Apache-2.0`.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
|
|
||||||
apps.default = mkApp "decodal" "Run Decodal";
|
apps.default = mkApp "decodal" "Run Decodal";
|
||||||
apps.decodal = mkApp "decodal" "Run Decodal";
|
apps.decodal = mkApp "decodal" "Run Decodal";
|
||||||
|
apps.decodal-lsp = mkApp "decodal-lsp" "Run the Decodal language server";
|
||||||
|
|
||||||
checks.default = decodal;
|
checks.default = decodal;
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -24,7 +24,7 @@ let
|
|||||||
in
|
in
|
||||||
rustPlatform.buildRustPackage {
|
rustPlatform.buildRustPackage {
|
||||||
pname = "decodal";
|
pname = "decodal";
|
||||||
version = "0.1.1";
|
version = "0.1.2";
|
||||||
|
|
||||||
src = lib.cleanSourceWith {
|
src = lib.cleanSourceWith {
|
||||||
src = srcRoot;
|
src = srcRoot;
|
||||||
@@ -38,6 +38,8 @@ rustPlatform.buildRustPackage {
|
|||||||
cargoBuildFlags = [
|
cargoBuildFlags = [
|
||||||
"-p"
|
"-p"
|
||||||
"decodal-cli"
|
"decodal-cli"
|
||||||
|
"-p"
|
||||||
|
"decodal-lsp"
|
||||||
];
|
];
|
||||||
|
|
||||||
doInstallCheck = true;
|
doInstallCheck = true;
|
||||||
@@ -45,6 +47,7 @@ rustPlatform.buildRustPackage {
|
|||||||
runHook preInstallCheck
|
runHook preInstallCheck
|
||||||
|
|
||||||
test -x "$out/bin/decodal"
|
test -x "$out/bin/decodal"
|
||||||
|
test -x "$out/bin/decodal-lsp"
|
||||||
"$out/bin/decodal" >/dev/null
|
"$out/bin/decodal" >/dev/null
|
||||||
|
|
||||||
runHook postInstallCheck
|
runHook postInstallCheck
|
||||||
|
|||||||
Reference in New Issue
Block a user