Add host-configurable language service

This commit is contained in:
2026-08-11 22:05:17 +09:00
parent 08603dc4b5
commit bdaccd9803
10 changed files with 308 additions and 4 deletions
Generated
+7
View File
@@ -58,6 +58,13 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "decodal-language-service"
version = "0.1.2"
dependencies = [
"decodal",
]
[[package]] [[package]]
name = "decodal-language-tools" name = "decodal-language-tools"
version = "0.1.2" version = "0.1.2"
+1
View File
@@ -5,6 +5,7 @@ members = [
"crates/decodal-wasm", "crates/decodal-wasm",
"crates/decodal-derive", "crates/decodal-derive",
"crates/decodal-language-tools", "crates/decodal-language-tools",
"crates/decodal-language-service",
] ]
resolver = "2" resolver = "2"
+1
View File
@@ -5,6 +5,7 @@ Decodal is a small deterministic DSL for describing, composing, validating, and
It is designed around a lightweight Rust library: It is designed around a lightweight Rust library:
- host-supplied source and structured-value imports through `ImportLoader` - 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 - no filesystem access in the library core
- concrete and abstract values with constraints and defaults - concrete and abstract values with constraints and defaults
- deterministic expression evaluation - deterministic expression evaluation
+34
View File
@@ -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<Self::Loader>) -> Result<()> {
Ok(())
}
fn create_engine(&self) -> Result<Engine<Self::Loader>> {
let mut engine = Engine::new(self.create_loader());
self.configure_engine(&mut engine)?;
Ok(engine)
}
}
impl<T: HostEnvironment + ?Sized> HostEnvironment for &T {
type Loader = T::Loader;
fn create_loader(&self) -> Self::Loader {
T::create_loader(*self)
}
fn configure_engine(&self, engine: &mut Engine<Self::Loader>) -> Result<()> {
T::configure_engine(*self, engine)
}
}
+2
View File
@@ -6,6 +6,7 @@ pub mod ast;
pub mod constraints; pub mod constraints;
pub mod diagnostic; pub mod diagnostic;
pub mod embedding; pub mod embedding;
pub mod environment;
pub mod eval; pub mod eval;
mod lexer; mod lexer;
pub mod module; pub mod module;
@@ -20,6 +21,7 @@ pub use constraints::normalize_constraints;
pub use decodal_derive::Decodal; pub use decodal_derive::Decodal;
pub use diagnostic::{Diagnostic, DiagnosticKind, Result}; pub use diagnostic::{Diagnostic, DiagnosticKind, Result};
pub use embedding::{HostField, HostValue}; pub use embedding::{HostField, HostValue};
pub use environment::HostEnvironment;
pub use eval::{Engine, format_diagnostic_with}; pub use eval::{Engine, format_diagnostic_with};
pub use module::{EmptyLoader, ImportLoader, LoadedImport, LoadedSource, LoadedValue, Module}; pub use module::{EmptyLoader, ImportLoader, LoadedImport, LoadedSource, LoadedValue, Module};
pub use parser::{ParseOutput, Parser, SourceForm, parse_source, parse_source_with_source_id}; pub use parser::{ParseOutput, Parser, SourceForm, parse_source, parse_source_with_source_id};
@@ -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" }
+188
View File
@@ -0,0 +1,188 @@
use decodal::{Data, Diagnostic, HostEnvironment, Result};
/// Semantic tooling backed by the same host environment as production.
pub struct LanguageService<E> {
environment: E,
}
impl<E> LanguageService<E> {
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<E: HostEnvironment> LanguageService<E> {
/// Runs the production parse, evaluation, and materialization pipeline.
pub fn evaluate(
&self,
key: impl Into<String>,
name: impl Into<String>,
source: &str,
) -> Result<Data> {
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<String>,
name: impl Into<String>,
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<Data>,
pub diagnostics: Vec<Diagnostic>,
}
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<LoadedImport> {
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<Self::Loader>) -> 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<Data> {
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"]
);
}
}
+16 -4
View File
@@ -41,9 +41,21 @@ The WebAssembly package is for execution, not syntax highlighting.
## Language tools ## 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. 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: Important paths:
@@ -52,7 +64,6 @@ crates/decodal-language-tools/
``` ```
The current language tools crate exposes the formatter. 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 ## Web editor components
@@ -112,7 +123,8 @@ Consumers that need syntax information should use the component that matches the
- Rust execution and embedding: `decodal` - Rust execution and embedding: `decodal`
- Browser execution: `decodal-wasm` - Browser execution: `decodal-wasm`
- Web formatting and editor syntax: Lezer / CodeMirror - 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 - 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. This avoids having a separate token stream API whose behavior would have to be kept compatible with both runtime parsing and editor grammars.
+42
View File
@@ -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. 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<Self::Loader>,
) -> 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 ## Structured imports
`ImportLoader::load` returns either `LoadedImport::Source` or `LoadedImport::Value`. `ImportLoader::load` returns either `LoadedImport::Source` or `LoadedImport::Value`.
@@ -23,6 +23,9 @@ materialize
data / diagnostics 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
lexer / parser は source を AST に変換する。 lexer / parser は source を AST に変換する。