diff --git a/Cargo.lock b/Cargo.lock index ffe211f..3138031 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,13 @@ dependencies = [ "syn", ] +[[package]] +name = "decodal-language-service" +version = "0.1.2" +dependencies = [ + "decodal", +] + [[package]] name = "decodal-language-tools" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 7f1149a..024a07b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/decodal-wasm", "crates/decodal-derive", "crates/decodal-language-tools", + "crates/decodal-language-service", ] resolver = "2" diff --git a/README.md b/README.md index be0dd53..219cc9f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Decodal is a small deterministic DSL for describing, composing, validating, and It is designed around a lightweight Rust library: - host-supplied source and structured-value imports through `ImportLoader` +- shared host environments for production and semantic editor tooling - no filesystem access in the library core - concrete and abstract values with constraints and defaults - deterministic expression evaluation diff --git a/crates/decodal-core/src/environment.rs b/crates/decodal-core/src/environment.rs new file mode 100644 index 0000000..f31a9d0 --- /dev/null +++ b/crates/decodal-core/src/environment.rs @@ -0,0 +1,34 @@ +use crate::{Engine, ImportLoader, Result}; + +/// Host-owned configuration shared by runtime and language tooling. +/// +/// Implementations create the import loader and install global bindings on a +/// fresh engine. Calling [`HostEnvironment::create_engine`] therefore gives +/// every consumer the same Decodal execution environment. +pub trait HostEnvironment { + type Loader: ImportLoader; + + fn create_loader(&self) -> Self::Loader; + + fn configure_engine(&self, _engine: &mut Engine) -> Result<()> { + Ok(()) + } + + fn create_engine(&self) -> Result> { + let mut engine = Engine::new(self.create_loader()); + self.configure_engine(&mut engine)?; + Ok(engine) + } +} + +impl HostEnvironment for &T { + type Loader = T::Loader; + + fn create_loader(&self) -> Self::Loader { + T::create_loader(*self) + } + + fn configure_engine(&self, engine: &mut Engine) -> Result<()> { + T::configure_engine(*self, engine) + } +} diff --git a/crates/decodal-core/src/lib.rs b/crates/decodal-core/src/lib.rs index 4eebbb4..9d50786 100644 --- a/crates/decodal-core/src/lib.rs +++ b/crates/decodal-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod ast; pub mod constraints; pub mod diagnostic; pub mod embedding; +pub mod environment; pub mod eval; mod lexer; pub mod module; @@ -20,6 +21,7 @@ pub use constraints::normalize_constraints; pub use decodal_derive::Decodal; pub use diagnostic::{Diagnostic, DiagnosticKind, Result}; pub use embedding::{HostField, HostValue}; +pub use environment::HostEnvironment; pub use eval::{Engine, format_diagnostic_with}; pub use module::{EmptyLoader, ImportLoader, LoadedImport, LoadedSource, LoadedValue, Module}; pub use parser::{ParseOutput, Parser, SourceForm, parse_source, parse_source_with_source_id}; diff --git a/crates/decodal-language-service/Cargo.toml b/crates/decodal-language-service/Cargo.toml new file mode 100644 index 0000000..34844f0 --- /dev/null +++ b/crates/decodal-language-service/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "decodal-language-service" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +readme.workspace = true +description = "Host-configurable semantic language service for Decodal." +keywords = ["decodal", "lsp", "language-server", "editor"] +categories = ["development-tools", "text-editors"] + +[dependencies] +decodal = { version = "0.1.2", path = "../decodal-core" } diff --git a/crates/decodal-language-service/src/lib.rs b/crates/decodal-language-service/src/lib.rs new file mode 100644 index 0000000..82c1827 --- /dev/null +++ b/crates/decodal-language-service/src/lib.rs @@ -0,0 +1,188 @@ +use decodal::{Data, Diagnostic, HostEnvironment, Result}; + +/// Semantic tooling backed by the same host environment as production. +pub struct LanguageService { + environment: E, +} + +impl LanguageService { + pub fn new(environment: E) -> Self { + Self { environment } + } + + pub fn environment(&self) -> &E { + &self.environment + } + + pub fn environment_mut(&mut self) -> &mut E { + &mut self.environment + } + + pub fn into_environment(self) -> E { + self.environment + } +} + +impl LanguageService { + /// Runs the production parse, evaluation, and materialization pipeline. + pub fn evaluate( + &self, + key: impl Into, + name: impl Into, + source: &str, + ) -> Result { + let mut engine = self.environment.create_engine()?; + let module = engine.add_root_source(key, name, source)?; + let value = engine.eval_module(module)?; + engine.materialize(&value) + } + + /// Evaluates a document and exposes failures in an editor-friendly form. + /// + /// The evaluator currently stops at the first failure, so an invalid + /// analysis contains one diagnostic. The collection leaves room for a + /// future diagnostic-accumulation pass without changing this API. + pub fn analyze( + &self, + key: impl Into, + name: impl Into, + source: &str, + ) -> SemanticAnalysis { + match self.evaluate(key, name, source) { + Ok(data) => SemanticAnalysis { + data: Some(data), + diagnostics: Vec::new(), + }, + Err(diagnostic) => SemanticAnalysis { + data: None, + diagnostics: vec![diagnostic], + }, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SemanticAnalysis { + pub data: Option, + pub diagnostics: Vec, +} + +impl SemanticAnalysis { + pub fn is_valid(&self) -> bool { + self.diagnostics.is_empty() + } +} + +#[cfg(test)] +mod tests { + use decodal::{ + Data, Diagnostic, DiagnosticKind, Engine, HostValue, ImportLoader, LoadedImport, Span, + }; + + use super::*; + + const ROOT: &str = r#"Post & import "./post.md""#; + + struct ContentEnvironment { + draft: HostValue, + } + + struct ContentLoader { + draft: HostValue, + } + + impl ImportLoader for ContentLoader { + fn load( + &mut self, + _current_key: Option<&str>, + specifier: &str, + ) -> decodal::Result { + if specifier != "./post.md" { + return Err(Diagnostic::new( + DiagnosticKind::Import, + Span::default(), + "unknown content import", + )); + } + Ok(LoadedImport::value( + "content/post.md", + HostValue::object([ + ( + "frontmatter", + HostValue::object([ + ("title", HostValue::string("Hello")), + ("draft", self.draft.clone()), + ]), + ), + ("body", HostValue::string("# Hello")), + ]), + )) + } + } + + impl HostEnvironment for ContentEnvironment { + type Loader = ContentLoader; + + fn create_loader(&self) -> Self::Loader { + ContentLoader { + draft: self.draft.clone(), + } + } + + fn configure_engine(&self, engine: &mut Engine) -> decodal::Result<()> { + engine.bind_global( + "Post", + HostValue::object([ + ( + "frontmatter", + HostValue::object([ + ("title", HostValue::string_type()), + ("draft", HostValue::bool_type()), + ]), + ), + ("body", HostValue::string_type()), + ]), + )?; + Ok(()) + } + } + + fn evaluate_direct(environment: &ContentEnvironment) -> decodal::Result { + let mut engine = environment.create_engine()?; + let module = engine.add_root_source("main.dcdl", "main.dcdl", ROOT)?; + let value = engine.eval_module(module)?; + engine.materialize(&value) + } + + #[test] + fn injected_environment_matches_direct_evaluation() { + let environment = ContentEnvironment { + draft: HostValue::bool(false), + }; + let direct = evaluate_direct(&environment).unwrap(); + let service = LanguageService::new(&environment); + let analysis = service.analyze("main.dcdl", "main.dcdl", ROOT); + + assert!(analysis.is_valid()); + assert_eq!(analysis.data, Some(direct)); + } + + #[test] + fn injected_environment_preserves_import_diagnostics() { + let service = LanguageService::new(ContentEnvironment { + draft: HostValue::string("maybe"), + }); + let analysis = service.analyze("main.dcdl", "main.dcdl", ROOT); + + assert!(!analysis.is_valid()); + assert_eq!(analysis.data, None); + assert_eq!( + analysis.diagnostics[0].message, + "expected Bool, found String" + ); + assert_eq!( + analysis.diagnostics[0].notes, + ["imported `content/post.md` at `frontmatter.draft` supplied a value of type String"] + ); + } +} diff --git a/doc/manual/souce/components.md b/doc/manual/souce/components.md index 82a5670..00edf41 100644 --- a/doc/manual/souce/components.md +++ b/doc/manual/souce/components.md @@ -41,9 +41,21 @@ The WebAssembly package is for execution, not syntax highlighting. ## Language tools -Source-level tooling lives in the Rust language tools 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. +A host-specific LSP binary can link this crate with its loader and global schema configuration without reimplementing evaluation rules. + +Important paths: + +```text +crates/decodal-language-service/ +``` + +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. + This component is responsible for operations that must preserve source text details such as comments and whitespace. -It is an internal crate used by the future Rust LSP and by the CodeMirror package's bundled formatter WebAssembly. +It is used by the CodeMirror package's bundled formatter WebAssembly and can also be used by an LSP adapter for formatting. Important paths: @@ -52,7 +64,6 @@ crates/decodal-language-tools/ ``` The current language tools crate exposes the formatter. -Future LSP functionality should build on the same source-level tooling layer rather than on the runtime core. ## Web editor components @@ -112,7 +123,8 @@ Consumers that need syntax information should use the component that matches the - Rust execution and embedding: `decodal` - Browser execution: `decodal-wasm` - Web formatting and editor syntax: Lezer / CodeMirror -- Rust LSP internals: `decodal-language-tools` +- Semantic editor analysis: `decodal-language-service` +- Rust formatting: `decodal-language-tools` - General editor syntax: Tree-sitter This avoids having a separate token stream API whose behavior would have to be kept compatible with both runtime parsing and editor grammars. diff --git a/doc/manual/souce/design/embedding-api.md b/doc/manual/souce/design/embedding-api.md index 15a17c6..b077bad 100644 --- a/doc/manual/souce/design/embedding-api.md +++ b/doc/manual/souce/design/embedding-api.md @@ -140,6 +140,48 @@ It only generates schema construction and typed decoding code. Both mechanisms share the same runtime evaluator, thunk model, and materialization rules. +## Shared host environment + +An embedded application can implement `HostEnvironment` to keep loader creation and global binding setup in one place. +Both production evaluation and semantic editor tooling create their engines from this environment, preventing the editor from drifting onto a separate validation path. + +```rust +use decodal::{Engine, HostEnvironment}; + +struct AppEnvironment; + +impl HostEnvironment for AppEnvironment { + type Loader = ContentLoader; + + fn create_loader(&self) -> Self::Loader { + ContentLoader::new() + } + + fn configure_engine( + &self, + engine: &mut Engine, + ) -> decodal::Result<()> { + engine.bind_global("Site", site_schema())?; + Ok(()) + } +} + +let environment = AppEnvironment; +let mut engine = environment.create_engine()?; +``` + +The semantic service accepts the same environment by value: + +```rust +use decodal_language_service::LanguageService; + +let service = LanguageService::new(&environment); +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. + ## Structured imports `ImportLoader::load` returns either `LoadedImport::Source` or `LoadedImport::Value`. diff --git a/doc/manual/souce/design/execution-pipeline.md b/doc/manual/souce/design/execution-pipeline.md index a14860e..71378d6 100644 --- a/doc/manual/souce/design/execution-pipeline.md +++ b/doc/manual/souce/design/execution-pipeline.md @@ -23,6 +23,9 @@ materialize data / diagnostics ``` +Production applications and semantic language services can construct this pipeline through the same host-defined `HostEnvironment`. +The environment creates the `ImportLoader` and installs host globals before the root module is registered. + ## lexer / parser lexer / parser は source を AST に変換する。