From 610358110faa3079b84803260b9ce3a48245f08f Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 12 Aug 2026 05:42:45 +0900 Subject: [PATCH] Add host-configurable LSP server --- Cargo.lock | 103 ++- Cargo.toml | 1 + README.md | 13 + crates/decodal-lsp/Cargo.toml | 20 + crates/decodal-lsp/src/lib.rs | 807 +++++++++++++++++++++++ crates/decodal-lsp/src/main.rs | 11 + doc/manual/souce/components.md | 9 +- doc/manual/souce/design/embedding-api.md | 18 + doc/manual/souce/development.md | 14 +- flake.nix | 1 + package.nix | 5 +- 11 files changed, 996 insertions(+), 6 deletions(-) create mode 100644 crates/decodal-lsp/Cargo.toml create mode 100644 crates/decodal-lsp/src/lib.rs create mode 100644 crates/decodal-lsp/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 3138031..97d6350 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 024a07b..ef6fd07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/decodal-derive", "crates/decodal-language-tools", "crates/decodal-language-service", + "crates/decodal-lsp", ] resolver = "2" diff --git a/README.md b/README.md index 219cc9f..0bd0627 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/crates/decodal-lsp/Cargo.toml b/crates/decodal-lsp/Cargo.toml new file mode 100644 index 0000000..749bd4b --- /dev/null +++ b/crates/decodal-lsp/Cargo.toml @@ -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 diff --git a/crates/decodal-lsp/src/lib.rs b/crates/decodal-lsp/src/lib.rs new file mode 100644 index 0000000..f2ea29a --- /dev/null +++ b/crates/decodal-lsp/src/lib.rs @@ -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 = Result>; + +/// 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(create_environment: F) -> ServerResult +where + E: LspEnvironment, + F: FnOnce(&InitializeParams) -> ServerResult, +{ + 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(connection: Connection, create_environment: F) -> ServerResult +where + E: LspEnvironment, + F: FnOnce(&InitializeParams) -> ServerResult, +{ + 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 { + connection: Connection, + service: LanguageService, + documents: HashMap, +} + +impl Server { + 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, + version: Option, + ) -> 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, + is_decodal: bool, +} + +fn analysis_to_lsp(analysis: &SemanticAnalysis, uri: &Uri, source: &str) -> Vec { + 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::>(); + 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, +} + +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, +} + +impl ImportLoader for FileSystemLoader { + fn load( + &mut self, + current_key: Option<&str>, + specifier: &str, + ) -> decodal::Result { + 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>>, + } + + struct TestLoader { + overlays: Arc>>, + } + + impl ImportLoader for TestLoader { + fn load( + &mut self, + _current_key: Option<&str>, + _specifier: &str, + ) -> decodal::Result { + 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) -> 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 = + 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(); + } +} diff --git a/crates/decodal-lsp/src/main.rs b/crates/decodal-lsp/src/main.rs new file mode 100644 index 0000000..490d019 --- /dev/null +++ b/crates/decodal-lsp/src/main.rs @@ -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 + } + } +} diff --git a/doc/manual/souce/components.md b/doc/manual/souce/components.md index 00edf41..3265f5c 100644 --- a/doc/manual/souce/components.md +++ b/doc/manual/souce/components.md @@ -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 diff --git a/doc/manual/souce/design/embedding-api.md b/doc/manual/souce/design/embedding-api.md index b077bad..10e2452 100644 --- a/doc/manual/souce/design/embedding-api.md +++ b/doc/manual/souce/design/embedding-api.md @@ -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`. diff --git a/doc/manual/souce/development.md b/doc/manual/souce/development.md index a34e392..c61dd26 100644 --- a/doc/manual/souce/development.md +++ b/doc/manual/souce/development.md @@ -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`. diff --git a/flake.nix b/flake.nix index fbd60b0..c34c4d2 100644 --- a/flake.nix +++ b/flake.nix @@ -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; diff --git a/package.nix b/package.nix index 1afa7f0..094dc81 100644 --- a/package.nix +++ b/package.nix @@ -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