234 lines
7.2 KiB
Rust
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"));
|
|
}
|
|
}
|