Decodal/crates/decodal-wasm/src/lib.rs

234 lines
7.2 KiB
Rust

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, String>) -> 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<String, String> {
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<String, String> {
let raw_files: BTreeMap<String, String> = 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<String, String>,
}
impl SourceLoader for VirtualLoader {
fn load(
&mut self,
current_key: Option<&str>,
specifier: &str,
) -> decodal_core::Result<LoadedSource> {
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<String> {
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<String> {
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"));
}
}