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