use std::collections::BTreeMap; use decodal_core::{ Data, Diagnostic, DiagnosticKind, EmptyLoader, Engine, LoadedSource, SourceLoader, Span, }; use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn evaluate(source: &str) -> String { encode_result(evaluate_inner(source)) } #[wasm_bindgen(js_name = evaluateProject)] pub fn evaluate_project(entry: &str, files_json: &str) -> String { encode_result(evaluate_project_inner(entry, files_json)) } fn encode_result(result: Result) -> String { match result { Ok(output) => format!("{{\"ok\":true,\"output\":{}}}", json_string(&output)), Err(error) => format!("{{\"ok\":false,\"error\":{}}}", json_string(&error)), } } fn evaluate_inner(source: &str) -> Result { let mut engine = Engine::new(EmptyLoader); let module = engine .add_root_source("playground", "playground", source) .map_err(format_diagnostic)?; let value = engine.eval_module(module).map_err(format_diagnostic)?; let data = engine.materialize(&value).map_err(format_diagnostic)?; Ok(format_data(&data, 0)) } fn evaluate_project_inner(entry: &str, files_json: &str) -> Result { let raw_files: BTreeMap = serde_json::from_str(files_json) .map_err(|error| format!("failed to read playground files: {error}"))?; let mut files = BTreeMap::new(); for (path, source) in raw_files { let path = normalize_path(&path).ok_or_else(|| format!("invalid file path `{path}`"))?; files.insert(path, source); } let entry = normalize_path(entry).ok_or_else(|| format!("invalid entry path `{entry}`"))?; let source = files .get(&entry) .cloned() .ok_or_else(|| format!("entry file `{entry}` was not found"))?; let mut engine = Engine::new(VirtualLoader { files }); let module = engine .add_root_source(entry.clone(), entry.clone(), &source) .map_err(format_diagnostic)?; let value = engine.eval_module(module).map_err(format_diagnostic)?; let data = engine.materialize(&value).map_err(format_diagnostic)?; Ok(format_data(&data, 0)) } #[derive(Debug, Clone)] struct VirtualLoader { files: BTreeMap, } impl SourceLoader for VirtualLoader { fn load( &mut self, current_key: Option<&str>, specifier: &str, ) -> decodal_core::Result { let key = resolve_import(current_key, specifier).ok_or_else(|| { Diagnostic::new( DiagnosticKind::Import, Span::default(), format!("invalid import path `{specifier}`"), ) })?; let source = self.files.get(&key).cloned().ok_or_else(|| { Diagnostic::new( DiagnosticKind::Import, Span::default(), format!("import `{specifier}` resolved to `{key}`, but that file does not exist"), ) })?; Ok(LoadedSource { key: key.clone(), name: key, source, }) } } fn resolve_import(current_key: Option<&str>, specifier: &str) -> Option { if specifier.starts_with('/') { return normalize_path(specifier); } let mut base = String::new(); if let Some(current_key) = current_key { if let Some((parent, _file)) = current_key.rsplit_once('/') { base.push_str(parent); base.push('/'); } } base.push_str(specifier); normalize_path(&base) } fn normalize_path(path: &str) -> Option { let mut parts = Vec::new(); let normalized = path.replace('\\', "/"); for part in normalized.split('/') { match part { "" | "." => {} ".." => { parts.pop()?; } part => parts.push(part), } } if parts.is_empty() { None } else { Some(parts.join("/")) } } fn format_diagnostic(diagnostic: decodal_core::Diagnostic) -> String { format!( "{:?} at {}:{}..{}: {}", diagnostic.kind, diagnostic.span.source.0, diagnostic.span.start, diagnostic.span.end, diagnostic.message, ) } fn format_data(data: &Data, indent: usize) -> String { match data { Data::String(value) => json_string(value), Data::Int(value) => value.to_string(), Data::Float(value) => value.to_string(), Data::Bool(value) => value.to_string(), Data::Array(items) => { if items.is_empty() { return String::from("[]"); } let mut out = String::from("[\n"); for (index, item) in items.iter().enumerate() { out.push_str(&" ".repeat(indent + 2)); out.push_str(&format_data(item, indent + 2)); if index + 1 != items.len() { out.push(','); } out.push('\n'); } out.push_str(&" ".repeat(indent)); out.push(']'); out } Data::Object(fields) => { if fields.is_empty() { return String::from("{}"); } let mut out = String::from("{\n"); for (index, field) in fields.iter().enumerate() { out.push_str(&" ".repeat(indent + 2)); out.push_str(&json_string(&field.name)); out.push_str(": "); out.push_str(&format_data(&field.value, indent + 2)); if index + 1 != fields.len() { out.push(','); } out.push('\n'); } out.push_str(&" ".repeat(indent)); out.push('}'); out } } } fn json_string(value: &str) -> String { let mut out = String::from("\""); for ch in value.chars() { match ch { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), ch if ch.is_control() => { use core::fmt::Write; let _ = write!(out, "\\u{:04x}", ch as u32); } ch => out.push(ch), } } out.push('"'); out } #[cfg(test)] mod tests { use super::{evaluate_project_inner, normalize_path, resolve_import}; #[test] fn normalizes_virtual_paths() { assert_eq!( normalize_path("/schemas/../main.dcdl"), Some("main.dcdl".into()) ); assert_eq!(normalize_path("../main.dcdl"), None); } #[test] fn resolves_imports_relative_to_current_file() { assert_eq!( resolve_import(Some("schemas/service.dcdl"), "./types.dcdl"), Some("schemas/types.dcdl".into()) ); } #[test] fn evaluates_project_imports() { let files = r#"{ "main.dcdl":"let dep = import \"./schemas/service.dcdl\"; in dep.Service & { port = 9443; }", "schemas/service.dcdl":"Service = { name = String default \"api\"; port = Int & > 443 default 8443; }" }"#; let output = evaluate_project_inner("main.dcdl", files).unwrap(); assert!(output.contains("\"port\": 9443")); } }