From 4ab47e57195222dc801ee7a2e1b0c923b8e98749 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 17 Jun 2026 10:17:00 +0900 Subject: [PATCH] Add multi-file playground imports --- Cargo.lock | 55 +++++++ crates/decodal-wasm/Cargo.toml | 1 + crates/decodal-wasm/src/lib.rs | 141 +++++++++++++++- .../src/layouts/ManualLayout.astro | 16 +- site/decodal-site/src/pages/playground.astro | 23 ++- site/decodal-site/src/scripts/playground.js | 155 ++++++++++++++++-- site/decodal-site/src/style.css | 118 +++++++++++-- site/decodal-site/src/wasm/decodal_wasm.d.ts | 3 + site/decodal-site/src/wasm/decodal_wasm.js | 26 +++ .../src/wasm/decodal_wasm_bg.wasm | Bin 131691 -> 207633 bytes .../src/wasm/decodal_wasm_bg.wasm.d.ts | 1 + 11 files changed, 502 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8febdcf..0b0e5ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,9 +42,16 @@ name = "decodal-wasm" version = "0.1.0" dependencies = [ "decodal-core", + "serde_json", "wasm-bindgen", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "memchr" version = "2.8.2" @@ -110,6 +117,48 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "syn" version = "2.0.117" @@ -171,3 +220,9 @@ checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/decodal-wasm/Cargo.toml b/crates/decodal-wasm/Cargo.toml index b9cb6c3..6d5d440 100644 --- a/crates/decodal-wasm/Cargo.toml +++ b/crates/decodal-wasm/Cargo.toml @@ -8,6 +8,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] decodal-core = { path = "../decodal-core" } +serde_json = "1" wasm-bindgen = "0.2" [package.metadata.wasm-pack.profile.release] diff --git a/crates/decodal-wasm/src/lib.rs b/crates/decodal-wasm/src/lib.rs index 9fd532a..4a8090f 100644 --- a/crates/decodal-wasm/src/lib.rs +++ b/crates/decodal-wasm/src/lib.rs @@ -1,9 +1,22 @@ -use decodal_core::{Data, EmptyLoader, Engine}; +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 { - match evaluate_inner(source) { + 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)), } @@ -19,6 +32,98 @@ fn evaluate_inner(source: &str) -> Result { 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 {}:{}..{}: {}", @@ -94,3 +199,35 @@ fn json_string(value: &str) -> String { 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")); + } +} diff --git a/site/decodal-site/src/layouts/ManualLayout.astro b/site/decodal-site/src/layouts/ManualLayout.astro index ea99fb8..0b74278 100644 --- a/site/decodal-site/src/layouts/ManualLayout.astro +++ b/site/decodal-site/src/layouts/ManualLayout.astro @@ -2,7 +2,7 @@ import { nav } from '../lib/docs.js'; import '../style.css'; -const { title = 'Decodal', active = '' } = Astro.props; +const { title = 'Decodal', active = '', playground = false } = Astro.props; function renderNav(items) { return `