use crate::error::ErrorKind;
use crate::parsing::source::Source;
use crate::planning::DataValueInput;
use crate::{Engine, Error, SourceType};
use serde::Serialize;
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(js_name = Engine)]
pub struct WasmEngine {
engine: Engine,
}
impl Default for WasmEngine {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen]
impl WasmEngine {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
console_error_panic_hook::set_once();
WasmEngine {
engine: Engine::new(),
}
}
#[wasm_bindgen(js_name = load)]
pub fn load_wasm(&mut self, code: &str, attribute: &str) -> Result<(), JsValue> {
let source = if attribute.trim().is_empty() {
SourceType::Volatile
} else {
SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(attribute)))
};
self.engine.load(code, source).map_err(|load_err| {
let errors: Vec<JsError> = load_err.errors.iter().map(JsError::from).collect();
errors
.serialize(&js_error_serializer())
.expect("BUG: serialize JsError array")
})
}
#[wasm_bindgen(js_name = load_batch)]
pub fn load_batch_wasm(
&mut self,
sources: JsValue,
dependency: Option<String>,
) -> Result<(), JsValue> {
let map: HashMap<String, String> = if sources.is_undefined() || sources.is_null() {
HashMap::new()
} else {
serde_wasm_bindgen::from_value(sources).map_err(|e| {
let err = Error::request(
format!(
"load_batch: sources must be a plain object with string keys and string values: {e}"
),
None::<String>,
);
let errors = vec![JsError::from(&err)];
errors
.serialize(&js_error_serializer())
.expect("BUG: serialize JsError array")
})?
};
let mut batch: HashMap<SourceType, String> = HashMap::with_capacity(map.len());
for (key, code) in map {
let source = if key.trim().is_empty() {
SourceType::Volatile
} else {
SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(key)))
};
batch.insert(source, code);
}
let owned_dep = dependency.and_then(|s| {
let t = s.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
});
self.engine
.load_batch(batch, owned_dep.as_deref())
.map_err(|load_err| {
let errors: Vec<JsError> = load_err.errors.iter().map(JsError::from).collect();
errors
.serialize(&js_error_serializer())
.expect("BUG: serialize JsError array")
})
}
#[wasm_bindgen(js_name = fetch)]
pub fn fetch_wasm(&self, name: &str) -> js_sys::Promise {
match crate::spec_set_id::parse_spec_set_id(name) {
Err(e) => {
let js_err_array = {
let errors = vec![JsError::from(&e)];
errors
.serialize(&js_error_serializer())
.expect("BUG: serialize JsError array")
};
wasm_bindgen_futures::future_to_promise(async move { Err(js_err_array) })
}
Ok(normalized) => {
#[cfg(not(feature = "registry"))]
{
let err = Error::request(
format!(
"fetch of '{normalized}' requires the lemma-engine crate to be built with the `registry` feature (engine has {} loaded repositories)",
self.engine.list().len()
),
None::<String>,
);
let js_err_array = {
let errors = vec![JsError::from(&err)];
errors
.serialize(&js_error_serializer())
.expect("BUG: serialize JsError array")
};
wasm_bindgen_futures::future_to_promise(async move { Err(js_err_array) })
}
#[cfg(feature = "registry")]
{
wasm_registry_fetch_only_promise(normalized)
}
}
}
}
#[wasm_bindgen(js_name = run)]
pub fn run(
&self,
repository: Option<String>,
spec: &str,
rule_names: JsValue,
data_values: JsValue,
effective: Option<String>,
explain: Option<bool>,
) -> Result<JsValue, JsValue> {
let effective_dt =
Engine::resolve_effective(effective.as_deref()).map_err(|e| error_to_js(&e))?;
let data = parse_data_values(&data_values).map_err(js_err)?;
let repo = repository
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let plan = self
.engine
.get_plan(repo, spec, Some(&effective_dt))
.map_err(|e| error_to_js(&e))?;
let response_rules = resolve_wasm_response_rules(&rule_names).map_err(js_err)?;
let response = self
.engine
.run_plan(
plan,
Some(&effective_dt),
data,
explain.unwrap_or(true),
response_rules.as_deref(),
)
.map_err(|e| error_to_js(&e))?;
serialize_engine_json(&response)
}
#[wasm_bindgen(js_name = list)]
pub fn list_wasm(&self) -> Result<JsValue, JsValue> {
serialize_engine_json(&self.engine.list())
}
#[wasm_bindgen(js_name = format_repository)]
pub fn format_repository_wasm(&self, repository: &str) -> Result<String, JsValue> {
self.engine
.format_repository(repository)
.map_err(|e| error_to_js(&e))
}
#[wasm_bindgen(js_name = schema)]
pub fn schema(
&self,
repository: Option<String>,
spec: &str,
effective: Option<String>,
) -> Result<JsValue, JsValue> {
let effective_dt =
Engine::resolve_effective(effective.as_deref()).map_err(|e| error_to_js(&e))?;
let repo = repository
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let plan = self
.engine
.get_plan(repo, spec, Some(&effective_dt))
.map_err(|e| error_to_js(&e))?;
let schema = plan.schema(&crate::planning::DataOverlay::default());
serialize_engine_json(&schema)
}
pub fn invert(
&self,
spec_name: &str,
rule_name: &str,
target_json: &str,
provided_values_json: &str,
) -> Result<JsValue, JsValue> {
todo!(
"WASM invert not implemented (spec={spec_name}, rule={rule_name}, target={target_json}, values={provided_values_json})"
)
}
#[wasm_bindgen(js_name = format)]
pub fn format_wasm(&self, code: &str, attribute: Option<String>) -> Result<JsValue, JsValue> {
let attr = attribute
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("inline source (no path)");
match crate::format_source(
code,
crate::parsing::source::SourceType::Path(std::sync::Arc::new(
std::path::PathBuf::from(attr),
)),
) {
Ok(formatted) => Ok(JsValue::from_str(&formatted)),
Err(e) => Err(error_to_js(&e)),
}
}
}
#[derive(Serialize)]
struct RegistryFetchPayload {
source: String,
id: String,
}
#[cfg(feature = "registry")]
fn wasm_registry_fetch_only_promise(name: String) -> js_sys::Promise {
wasm_bindgen_futures::future_to_promise(async move {
use crate::registry::{LemmaBase, Registry, RegistryErrorKind};
let registry = LemmaBase::new();
let bundle = match registry.get(&name).await {
Ok(b) => b,
Err(registry_error) => {
let suggestion = match ®istry_error.kind {
RegistryErrorKind::NotFound => Some(
"Check that the repository qualifier is spelled correctly and that the repository exists on the registry.".to_string(),
),
RegistryErrorKind::Unauthorized => Some(
"Check your authentication credentials or permissions for this registry."
.to_string(),
),
RegistryErrorKind::NetworkError => Some(
"Check your network connection.".to_string(),
),
RegistryErrorKind::ServerError => Some(
"The registry server returned an internal error. Try again later.".to_string(),
),
RegistryErrorKind::Other => None,
};
let source = Source::new(
SourceType::Volatile,
crate::parsing::ast::Span {
start: 0,
end: 0,
line: 1,
col: 1,
},
);
let err = Error::registry(
registry_error.message,
source,
name.clone(),
registry_error.kind,
suggestion,
None,
None,
);
let errors = vec![JsError::from(&err)];
return Err(errors
.serialize(&js_error_serializer())
.expect("BUG: serialize JsError array"));
}
};
let payload = RegistryFetchPayload {
source: bundle.lemma_source,
id: name,
};
serialize_engine_json(&payload)
})
}
fn serialize_engine_json<T: Serialize>(v: &T) -> Result<JsValue, JsValue> {
let s = serde_json::to_string(v)
.map_err(|e| js_err(format!("BUG: serde_json::to_string failed: {}", e)))?;
js_sys::JSON::parse(&s).map_err(|e| {
let detail = e
.as_string()
.unwrap_or_else(|| "(non-string error from JSON.parse)".to_string());
js_err(format!("BUG: JSON.parse failed: {}", detail))
})
}
fn js_err(msg: impl Into<String>) -> JsValue {
JsValue::from_str(&msg.into())
}
#[derive(Serialize)]
struct JsSource {
attribute: String,
line: usize,
column: usize,
length: usize,
}
impl<'a> From<&'a Source> for JsSource {
fn from(s: &'a Source) -> Self {
JsSource {
attribute: s.source_type.to_string(),
line: s.span.line,
column: s.span.col,
length: s.span.end.saturating_sub(s.span.start),
}
}
}
#[derive(Serialize)]
struct JsError<'a> {
kind: ErrorKind,
message: &'a str,
related_data: Option<&'a str>,
spec: Option<&'a str>,
related_spec: Option<&'a str>,
source: Option<JsSource>,
suggestion: Option<&'a str>,
repository: Option<&'a str>,
}
impl<'a> From<&'a Error> for JsError<'a> {
fn from(e: &'a Error) -> Self {
JsError {
kind: e.kind(),
message: e.message(),
related_data: e.related_data(),
spec: e.spec_context_name(),
related_spec: e.related_spec(),
source: e.source_location().map(JsSource::from),
suggestion: e.suggestion(),
repository: e.repository(),
}
}
}
fn js_error_serializer() -> serde_wasm_bindgen::Serializer {
serde_wasm_bindgen::Serializer::new().serialize_missing_as_null(true)
}
fn error_to_js(e: &Error) -> JsValue {
let err = JsError::from(e);
err.serialize(&js_error_serializer())
.expect("BUG: serialize JsError")
}
fn resolve_wasm_response_rules(rule_names: &JsValue) -> Result<Option<Vec<String>>, String> {
if rule_names.is_undefined() || rule_names.is_null() {
return Ok(None);
}
let parsed = parse_rule_names(rule_names)?;
if parsed.is_empty() {
return Err("rule_names must not be empty".to_string());
}
Ok(Some(parsed))
}
fn parse_rule_names(v: &JsValue) -> Result<Vec<String>, String> {
if v.is_undefined() || v.is_null() {
return Ok(Vec::new());
}
if js_sys::Array::is_array(v) {
return serde_wasm_bindgen::from_value(v.clone())
.map_err(|e| format!("rule_names must be an array of strings: {}", e));
}
if let Some(s) = v.as_string() {
let trimmed = s.trim();
if trimmed.is_empty() || trimmed == "[]" {
return Ok(Vec::new());
}
return serde_json::from_str::<Vec<String>>(trimmed).map_err(|e| {
format!(
"rule_names must be an array of strings (or JSON array string): {}",
e
)
});
}
Err("rule_names must be an array of strings".into())
}
fn data_input_from_json_value(value: serde_json::Value) -> Result<DataValueInput, String> {
use std::collections::BTreeMap;
match value {
serde_json::Value::String(s) => Ok(DataValueInput::Convenience(s)),
serde_json::Value::Bool(b) => Ok(DataValueInput::Boolean(b)),
serde_json::Value::Number(n) => Ok(DataValueInput::Convenience(n.to_string())),
serde_json::Value::Object(obj) => {
if obj.is_empty() {
return Err("data value object must not be empty".to_string());
}
if obj.len() == 2 && obj.contains_key("value") && obj.contains_key("unit") {
return Err(
"the {value, unit} object shape is not supported; use a unit map like {\"eur\": \"84\"}"
.to_string(),
);
}
if obj.values().all(|v| v.is_string()) {
let map: BTreeMap<String, String> = obj
.into_iter()
.map(|(k, v)| {
(
k,
v.as_str()
.expect("BUG: object values checked as strings")
.to_string(),
)
})
.collect();
return Ok(DataValueInput::QuantityMap(map));
}
Err("data value object must be a unit map with string magnitudes".to_string())
}
serde_json::Value::Null => Err("data value must not be null".to_string()),
serde_json::Value::Array(_) => Err("data value must not be an array".to_string()),
}
}
fn parse_data_values(v: &JsValue) -> Result<HashMap<String, DataValueInput>, String> {
if v.is_undefined() || v.is_null() {
return Ok(HashMap::new());
}
let map: HashMap<String, serde_json::Value> = serde_wasm_bindgen::from_value(v.clone())
.map_err(|e| format!("data_values must be a plain object: {}", e))?;
map.into_iter()
.filter(|(_, v)| !v.is_null())
.map(|(k, v)| data_input_from_json_value(v).map(|input| (k, input)))
.collect()
}