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
+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 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};
@@ -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"]
);
}
}