use std::sync::{Arc, Mutex};
use crate::{
engine_core::EngineCore,
ir::{ast_to_ir_node, IRNode},
lifecycle::ModuleInstance,
reconcile::Patch as InternalPatch,
};
#[uniffi::export]
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[uniffi::export]
pub fn portable_diff_paths(
old_json: String,
new_json: String,
) -> Result<String, HypenError> {
let old: serde_json::Value = serde_json::from_str(&old_json)
.map_err(|e| HypenError::StateError(format!("diff_paths: bad old JSON: {e}")))?;
let new: serde_json::Value = serde_json::from_str(&new_json)
.map_err(|e| HypenError::StateError(format!("diff_paths: bad new JSON: {e}")))?;
let entries: Vec<serde_json::Value> = crate::portable::diff_paths(&old, &new)
.into_iter()
.map(|e| serde_json::json!({ "path": e.path, "value": e.new_value }))
.collect();
serde_json::to_string(&entries)
.map_err(|e| HypenError::StateError(format!("diff_paths: serialise: {e}")))
}
#[uniffi::export]
pub fn discover_routers(source: String) -> Result<String, HypenError> {
let doc = hypen_parser::parse_document(&source)
.map_err(|e| HypenError::StateError(format!("discover_routers: parse error: {}", e.len())))?;
let mut routers = Vec::new();
for component in &doc.components {
let ir = crate::ir::ast_to_ir_node(component);
routers.extend(crate::ir::discover_routers(&ir));
}
serde_json::to_string(&routers)
.map_err(|e| HypenError::StateError(format!("discover_routers: serialise: {e}")))
}
#[uniffi::export]
pub fn portable_match_path(pattern: String, path: String) -> String {
match crate::portable::match_path(&pattern, &path) {
Some(m) => {
let params: serde_json::Map<String, serde_json::Value> = m
.params
.into_iter()
.map(|(k, v)| (k, serde_json::Value::String(v)))
.collect();
serde_json::json!({ "matched": true, "params": params }).to_string()
}
None => serde_json::json!({ "matched": false, "params": {} }).to_string(),
}
}
#[uniffi::export]
pub fn portable_path_get(value_json: String, path: String) -> Result<String, HypenError> {
let v: serde_json::Value = serde_json::from_str(&value_json)
.map_err(|e| HypenError::StateError(format!("path_get: bad JSON: {e}")))?;
let out = crate::portable::path_get(&v, &path).unwrap_or(serde_json::Value::Null);
serde_json::to_string(&out)
.map_err(|e| HypenError::StateError(format!("path_get: serialise: {e}")))
}
#[uniffi::export]
pub fn portable_path_has(value_json: String, path: String) -> Result<String, HypenError> {
let v: serde_json::Value = serde_json::from_str(&value_json)
.map_err(|e| HypenError::StateError(format!("path_has: bad JSON: {e}")))?;
Ok(if crate::portable::path_has(&v, &path) {
"true"
} else {
"false"
}
.to_string())
}
#[uniffi::export]
pub fn portable_path_set(
value_json: String,
path: String,
new_value_json: String,
) -> Result<String, HypenError> {
let mut v: serde_json::Value = serde_json::from_str(&value_json)
.map_err(|e| HypenError::StateError(format!("path_set: bad value JSON: {e}")))?;
let nv: serde_json::Value = serde_json::from_str(&new_value_json)
.map_err(|e| HypenError::StateError(format!("path_set: bad new-value JSON: {e}")))?;
crate::portable::path_set(&mut v, &path, nv);
serde_json::to_string(&v)
.map_err(|e| HypenError::StateError(format!("path_set: serialise: {e}")))
}
#[uniffi::export]
pub fn portable_path_delete(value_json: String, path: String) -> Result<String, HypenError> {
let mut v: serde_json::Value = serde_json::from_str(&value_json)
.map_err(|e| HypenError::StateError(format!("path_delete: bad JSON: {e}")))?;
let removed = crate::portable::path_delete(&mut v, &path);
serde_json::to_string(&serde_json::json!({ "json": v, "removed": removed }))
.map_err(|e| HypenError::StateError(format!("path_delete: serialise: {e}")))
}
#[uniffi::export]
pub fn portable_encode_uri_component(input: String) -> String {
crate::portable::encode_uri_component(&input)
}
#[uniffi::export]
pub fn portable_decode_uri_component(input: String) -> String {
crate::portable::decode_uri_component(&input)
}
#[uniffi::export]
pub fn portable_parse_query(full_path: String) -> String {
let (path, query) = crate::portable::parse_query(&full_path);
serde_json::json!({ "path": path, "query": query }).to_string()
}
#[uniffi::export]
pub fn portable_build_url(path: String, query_json: String) -> Result<String, HypenError> {
let map: std::collections::BTreeMap<String, String> = serde_json::from_str(&query_json)
.map_err(|e| HypenError::StateError(format!("build_url: bad query JSON: {e}")))?;
Ok(crate::portable::build_url(&path, &map))
}
#[uniffi::export]
pub fn portable_session_step(
state_json: String,
event_json: String,
) -> Result<String, HypenError> {
let state: crate::portable::SessionState = serde_json::from_str(&state_json)
.map_err(|e| HypenError::StateError(format!("session_step: bad state JSON: {e}")))?;
let event: crate::portable::SessionEvent = serde_json::from_str(&event_json)
.map_err(|e| HypenError::StateError(format!("session_step: bad event JSON: {e}")))?;
let effect = crate::portable::session_step(&state, &event);
serde_json::to_string(&effect)
.map_err(|e| HypenError::StateError(format!("session_step: serialise: {e}")))
}
#[derive(Debug, Clone, uniffi::Enum)]
pub enum PatchType {
Create,
SetProp,
RemoveProp,
SetText,
Insert,
Move,
Remove,
Detach,
Attach,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct Patch {
pub patch_type: PatchType,
pub id: String,
pub element_type: Option<String>,
pub props_json: Option<String>,
pub name: Option<String>,
pub value_json: Option<String>,
pub text: Option<String>,
pub parent_id: Option<String>,
pub before_id: Option<String>,
}
impl From<InternalPatch> for Patch {
fn from(p: InternalPatch) -> Self {
match p {
InternalPatch::Create {
id,
element_type,
props,
} => Patch {
patch_type: PatchType::Create,
id,
element_type: Some(element_type),
props_json: Some(serde_json::to_string(&*props).unwrap_or_default()),
name: None,
value_json: None,
text: None,
parent_id: None,
before_id: None,
},
InternalPatch::SetProp { id, name, value } => Patch {
patch_type: PatchType::SetProp,
id,
element_type: None,
props_json: None,
name: Some(name),
value_json: Some(serde_json::to_string(&value).unwrap_or_default()),
text: None,
parent_id: None,
before_id: None,
},
InternalPatch::RemoveProp { id, name } => Patch {
patch_type: PatchType::RemoveProp,
id,
element_type: None,
props_json: None,
name: Some(name),
value_json: None,
text: None,
parent_id: None,
before_id: None,
},
InternalPatch::SetText { id, text } => Patch {
patch_type: PatchType::SetText,
id,
element_type: None,
props_json: None,
name: None,
value_json: None,
text: Some(text),
parent_id: None,
before_id: None,
},
InternalPatch::Insert {
parent_id,
id,
before_id,
} => Patch {
patch_type: PatchType::Insert,
id,
element_type: None,
props_json: None,
name: None,
value_json: None,
text: None,
parent_id: Some(parent_id),
before_id,
},
InternalPatch::Move {
parent_id,
id,
before_id,
} => Patch {
patch_type: PatchType::Move,
id,
element_type: None,
props_json: None,
name: None,
value_json: None,
text: None,
parent_id: Some(parent_id),
before_id,
},
InternalPatch::Remove { id } => Patch {
patch_type: PatchType::Remove,
id,
element_type: None,
props_json: None,
name: None,
value_json: None,
text: None,
parent_id: None,
before_id: None,
},
InternalPatch::Detach { id } => Patch {
patch_type: PatchType::Detach,
id,
element_type: None,
props_json: None,
name: None,
value_json: None,
text: None,
parent_id: None,
before_id: None,
},
InternalPatch::Attach {
parent_id,
id,
before_id,
} => Patch {
patch_type: PatchType::Attach,
id,
element_type: None,
props_json: None,
name: None,
value_json: None,
text: None,
parent_id: Some(parent_id),
before_id,
},
}
}
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct Action {
pub name: String,
pub payload_json: Option<String>,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct ModuleConfig {
pub name: String,
pub actions: Vec<String>,
pub state_keys: Vec<String>,
pub initial_state_json: String,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct ComponentDef {
pub name: String,
pub source: String,
pub path: String,
}
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum HypenError {
#[error("Parse error: {0}")]
ParseError(String),
#[error("Render error: {0}")]
RenderError(String),
#[error("State error: {0}")]
StateError(String),
#[error("Action error: {0}")]
ActionError(String),
#[error("Component error: {0}")]
ComponentError(String),
#[error("Initialization error: {0}")]
InitializationError(String),
}
impl From<crate::error::EngineError> for HypenError {
fn from(err: crate::error::EngineError) -> Self {
match err {
crate::error::EngineError::ParseError { message, .. } => {
HypenError::ParseError(message)
}
crate::error::EngineError::ComponentNotFound(name) => HypenError::ComponentError(name),
crate::error::EngineError::RenderError(msg) => HypenError::RenderError(msg),
crate::error::EngineError::ActionNotFound(name) => {
HypenError::ActionError(format!("No handler registered for action: {}", name))
}
crate::error::EngineError::StateError(msg) => HypenError::StateError(msg),
crate::error::EngineError::ExpressionError(msg) => {
HypenError::RenderError(format!("Expression error: {}", msg))
}
}
}
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct ImportInfo {
pub names: Vec<String>,
pub source_path: String,
pub source_type: String,
}
struct EngineState {
core: EngineCore,
pending_actions: Vec<Action>,
pending_imports: Vec<ImportInfo>,
}
#[derive(uniffi::Object)]
pub struct HypenEngine {
state: Mutex<EngineState>,
}
#[uniffi::export]
impl HypenEngine {
#[uniffi::constructor]
pub fn new() -> Result<Arc<Self>, HypenError> {
Ok(Arc::new(Self {
state: Mutex::new(EngineState {
core: EngineCore::new(),
pending_actions: Vec::new(),
pending_imports: Vec::new(),
}),
}))
}
pub fn parse_to_json(&self, source: String) -> Result<String, HypenError> {
match hypen_parser::parse_component(&source) {
Ok(component) => serde_json::to_string_pretty(&component)
.map_err(|e| HypenError::ParseError(e.to_string())),
Err(errors) => {
let msg = errors
.iter()
.map(|e| hypen_parser::error::format_error_simple(e))
.collect::<Vec<_>>()
.join("; ");
Err(HypenError::ParseError(msg))
}
}
}
pub fn render_source(&self, source: String) -> Result<Vec<Patch>, HypenError> {
let mut state = self
.state
.lock()
.map_err(|e| HypenError::RenderError(e.to_string()))?;
let doc = hypen_parser::parse_document(&source).map_err(|e| {
let msg = e
.iter()
.map(|err| hypen_parser::error::format_error_simple(err))
.collect::<Vec<_>>()
.join("; ");
HypenError::ParseError(msg)
})?;
state.pending_imports = doc
.imports
.iter()
.map(|imp| {
let (source_path, source_type) = match &imp.source {
hypen_parser::ImportSource::Local(p) => (p.clone(), "local".to_string()),
hypen_parser::ImportSource::Url(u) => (u.clone(), "url".to_string()),
};
ImportInfo {
names: imp
.imported_names()
.into_iter()
.map(|s| s.to_string())
.collect(),
source_path,
source_type,
}
})
.collect();
let component = doc
.components
.first()
.ok_or_else(|| HypenError::ParseError("No component found in source".to_string()))?;
let ir_node = ast_to_ir_node(component);
let patches = state.core.render_ir_node(&ir_node);
Ok(patches.into_iter().map(Patch::from).collect())
}
pub fn update_state(
&self,
scope: String,
state_json: String,
) -> Result<Vec<Patch>, HypenError> {
let patch: serde_json::Value =
serde_json::from_str(&state_json).map_err(|e| HypenError::StateError(e.to_string()))?;
let mut state = self
.state
.lock()
.map_err(|e| HypenError::StateError(e.to_string()))?;
let scope = if scope.is_empty() { None } else { Some(scope) };
if !state.core.update_state(scope.as_deref(), patch) {
return Ok(Vec::new());
}
let patches = state.core.render_dirty();
Ok(patches.into_iter().map(Patch::from).collect())
}
pub fn update_state_sparse(
&self,
scope: String,
paths_json: String,
values_json: String,
) -> Result<Vec<Patch>, HypenError> {
let paths: Vec<String> = serde_json::from_str(&paths_json)
.map_err(|e| HypenError::StateError(format!("invalid paths JSON: {}", e)))?;
let values: serde_json::Value = serde_json::from_str(&values_json)
.map_err(|e| HypenError::StateError(format!("invalid values JSON: {}", e)))?;
let mut state = self
.state
.lock()
.map_err(|e| HypenError::StateError(e.to_string()))?;
let scope = if scope.is_empty() { None } else { Some(scope) };
if !state
.core
.update_state_sparse(scope.as_deref(), &paths, &values)
{
return Ok(Vec::new());
}
let patches = state.core.render_dirty();
Ok(patches.into_iter().map(Patch::from).collect())
}
pub fn set_module(&self, config: ModuleConfig) {
if let Ok(mut state) = self.state.lock() {
let initial_state: serde_json::Value =
serde_json::from_str(&config.initial_state_json).unwrap_or(serde_json::Value::Null);
state.core.set_module(ModuleInstance::from_config(
&config.name,
config.actions,
config.state_keys,
initial_state,
));
}
}
pub fn register_module(&self, config: ModuleConfig) {
if let Ok(mut state) = self.state.lock() {
let initial_state: serde_json::Value =
serde_json::from_str(&config.initial_state_json)
.unwrap_or(serde_json::Value::Null);
let name = config.name.clone();
let instance = ModuleInstance::from_config(
&name,
config.actions,
config.state_keys,
initial_state,
);
state.core.register_module(name, instance);
}
}
pub fn set_context(
&self,
name: String,
data_json: String,
) -> Result<Vec<Patch>, HypenError> {
let data: serde_json::Value = serde_json::from_str(&data_json)
.map_err(|e| HypenError::StateError(format!("invalid context JSON: {}", e)))?;
let mut state = self
.state
.lock()
.map_err(|e| HypenError::StateError(e.to_string()))?;
state.core.set_context(&name, data);
let patches = state.core.render_dirty();
Ok(patches.into_iter().map(Patch::from).collect())
}
pub fn remove_context(&self, name: String) -> Result<Vec<Patch>, HypenError> {
let mut state = self
.state
.lock()
.map_err(|e| HypenError::StateError(e.to_string()))?;
state.core.remove_context(&name);
let patches = state.core.render_dirty();
Ok(patches.into_iter().map(Patch::from).collect())
}
pub fn action_scope_for(&self, action_name: String) -> Option<String> {
self.state
.lock()
.ok()
.and_then(|s| s.core.action_scope_for(&action_name))
}
pub fn register_action(&self, action_name: String) {
if let Ok(mut state) = self.state.lock() {
state.core.registered_actions.push(action_name);
}
}
pub fn dispatch_action(
&self,
action_name: String,
payload_json: Option<String>,
) -> Result<(), HypenError> {
let mut state = self
.state
.lock()
.map_err(|e| HypenError::ActionError(e.to_string()))?;
if state.core.registered_actions.contains(&action_name) {
state.pending_actions.push(Action {
name: action_name,
payload_json,
});
}
Ok(())
}
pub fn get_pending_actions(&self) -> Vec<Action> {
if let Ok(mut state) = self.state.lock() {
std::mem::take(&mut state.pending_actions)
} else {
Vec::new()
}
}
pub fn get_pending_imports(&self) -> Vec<ImportInfo> {
if let Ok(mut state) = self.state.lock() {
std::mem::take(&mut state.pending_imports)
} else {
Vec::new()
}
}
pub fn register_resources(&self, resources_json: String) -> Result<(), HypenError> {
let map: indexmap::IndexMap<String, String> = serde_json::from_str(&resources_json)
.map_err(|e| HypenError::RenderError(format!("Invalid resources JSON: {}", e)))?;
let mut state = self
.state
.lock()
.map_err(|e| HypenError::RenderError(e.to_string()))?;
state.core.register_resources(map);
Ok(())
}
pub fn register_resource(&self, name: String, svg: String) -> Result<(), HypenError> {
let mut state = self
.state
.lock()
.map_err(|e| HypenError::RenderError(e.to_string()))?;
state.core.register_resource(&name, &svg);
Ok(())
}
pub fn register_primitive(&self, name: String) {
if let Ok(mut state) = self.state.lock() {
state.core.component_registry.register_primitive(&name);
}
}
pub fn register_default_primitives(&self) {
if let Ok(mut state) = self.state.lock() {
state.core.component_registry.register_default_primitives();
}
}
pub fn get_default_primitives(&self) -> Vec<String> {
crate::ir::DEFAULT_PRIMITIVES
.iter()
.map(|s| s.to_string())
.collect()
}
pub fn register_component(&self, component: ComponentDef) -> Result<(), HypenError> {
let mut state = self
.state
.lock()
.map_err(|e| HypenError::ComponentError(e.to_string()))?;
let component_spec = hypen_parser::parse_component(&component.source).map_err(|e| {
let msg = e
.iter()
.map(|err| hypen_parser::error::format_error_simple(err))
.collect::<Vec<_>>()
.join("; ");
HypenError::ParseError(msg)
})?;
let ir_node = ast_to_ir_node(&component_spec);
let ir_element = match ir_node {
IRNode::Element(e) => e,
_ => {
return Err(HypenError::ComponentError(
"Component root must be an element".to_string(),
));
}
};
let is_module = component_spec.declaration_type
== hypen_parser::DeclarationType::Module;
let module_name = if is_module {
Some(component_spec.name.to_lowercase())
} else {
None
};
let mut comp = crate::ir::Component::new(component.name, move |_props| ir_element.clone())
.with_source_path(&component.path);
if is_module {
comp.is_module = true;
comp.module_name = module_name;
}
state.core.register_component(comp);
Ok(())
}
pub fn clear_tree(&self) {
if let Ok(mut state) = self.state.lock() {
state.core.tree.clear();
}
}
pub fn get_revision(&self) -> u64 {
self.state.lock().map(|s| s.core.revision).unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_engine() -> Arc<HypenEngine> {
HypenEngine::new().expect("engine")
}
#[test]
fn test_set_and_remove_context_roundtrip() {
let engine = make_engine();
engine.register_default_primitives();
let patches = engine
.set_context("spacetime".to_string(), r#"{"user":{"name":"Alice"}}"#.to_string())
.expect("set_context");
assert!(patches.is_empty(), "no bound nodes → no patches yet");
let err = engine
.set_context("spacetime".to_string(), "not json".to_string())
.expect_err("invalid JSON should error");
matches!(err, HypenError::StateError(_));
let patches = engine
.remove_context("unknown".to_string())
.expect("remove_context");
assert!(patches.is_empty());
let patches = engine
.remove_context("spacetime".to_string())
.expect("remove_context");
assert!(patches.is_empty());
}
#[test]
fn test_action_scope_for_set_module_returns_none() {
let engine = make_engine();
engine.set_module(ModuleConfig {
name: "Counter".to_string(),
actions: vec!["increment".to_string(), "decrement".to_string()],
state_keys: vec![],
initial_state_json: "{}".to_string(),
});
assert_eq!(engine.action_scope_for("increment".to_string()), None);
assert_eq!(engine.action_scope_for("decrement".to_string()), None);
}
#[test]
fn test_action_scope_for_register_module_returns_scope() {
let engine = make_engine();
engine.register_module(ModuleConfig {
name: "search".to_string(),
actions: vec!["submit".to_string()],
state_keys: vec![],
initial_state_json: "{}".to_string(),
});
assert_eq!(
engine.action_scope_for("submit".to_string()),
Some("search".to_string())
);
assert_eq!(engine.action_scope_for("unknown".to_string()), None);
}
#[test]
fn test_set_context_invalidates_deep_binding() {
let engine = make_engine();
engine.register_default_primitives();
engine.set_module(ModuleConfig {
name: "Page".to_string(),
actions: vec![],
state_keys: vec![],
initial_state_json: "{}".to_string(),
});
engine
.set_context(
"spacetime".to_string(),
r#"{"user":{"name":"Alice"}}"#.to_string(),
)
.expect("seed context");
let _ = engine
.render_source(r#"Text("@{spacetime.user.name}")"#.to_string())
.expect("render");
let patches = engine
.set_context(
"spacetime".to_string(),
r#"{"user":{"name":"Bob"}}"#.to_string(),
)
.expect("replace context");
let saw_bob = patches.iter().any(|p| {
p.text.as_deref() == Some("Bob")
|| p.value_json.as_deref().map(|s| s.contains("Bob")).unwrap_or(false)
|| p.props_json.as_deref().map(|s| s.contains("Bob")).unwrap_or(false)
});
assert!(
saw_bob,
"expected a patch carrying 'Bob' after set_context replaced the deep provider state; got: {:?}",
patches
);
}
}