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
Generated
+100 -3
View File
@@ -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",
] ]
+1
View File
@@ -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"
+13
View File
@@ -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:
+20
View File
@@ -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
+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
}
}
}
+8 -1
View File
@@ -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
+18
View File
@@ -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 -1
View File
@@ -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`.
+1
View File
@@ -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
View File
@@ -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