Add multi-file playground imports

This commit is contained in:
Keisuke Hirata 2026-06-17 10:17:00 +09:00
parent 19c9de1601
commit 4ab47e5719
No known key found for this signature in database
11 changed files with 502 additions and 37 deletions

55
Cargo.lock generated
View File

@ -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"

View File

@ -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]

View File

@ -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, String>) -> 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<String, String> {
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 {}:{}..{}: {}",
@ -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"));
}
}

View File

@ -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 `<ul class="nav-tree">${items
@ -30,12 +30,14 @@ function renderNav(items) {
<a href="/playground/">Playground</a>
</nav>
</header>
<div class="layout">
<aside class="sidebar">
<a class="sidebar-title" href="/docs/">Manual</a>
<nav set:html={renderNav(nav)} />
</aside>
<main class={Astro.props.playground ? 'playground' : ''}>
<div class={playground ? 'layout playground-layout' : 'layout'}>
{!playground && (
<aside class="sidebar">
<a class="sidebar-title" href="/docs/">Manual</a>
<nav set:html={renderNav(nav)} />
</aside>
)}
<main class={playground ? 'playground' : ''}>
<slot />
</main>
</div>

View File

@ -6,19 +6,32 @@ import ManualLayout from '../layouts/ManualLayout.astro';
<div class="playground-header">
<div>
<h1>Playground</h1>
<p>Evaluate Decodal directly in your browser through WebAssembly.</p>
<p>Virtual files are evaluated in the browser through WebAssembly. Use import paths such as <code>./schemas/service.dcdl</code>.</p>
</div>
<div class="playground-actions">
<p id="status" class="status">Loading WASM...</p>
<button id="run" disabled>Run</button>
</div>
<button id="run" disabled>Run</button>
</div>
<p id="status" class="status">Loading WASM...</p>
<div class="playground-grid">
<div class="playground-shell">
<aside class="file-panel">
<div class="panel-header">
<span>Files</span>
<button id="new-file" type="button">New</button>
</div>
<div id="file-tree" class="file-tree"></div>
<button id="delete-file" class="danger-button" type="button">Delete file</button>
</aside>
<label class="pane input-pane">
<span>Input</span>
<span id="active-file">Input</span>
<div class="editor-wrap">
<pre id="source-highlight" aria-hidden="true"></pre>
<textarea id="source" spellcheck="false"></textarea>
</div>
</label>
<section class="pane output-pane">
<span>Output</span>
<pre id="output"></pre>

View File

@ -1,28 +1,91 @@
import init, { evaluate } from '../wasm/decodal_wasm.js';
import init, { evaluateProject } from '../wasm/decodal_wasm.js';
import { highlightDecodal } from '../lib/highlight.js';
const starter = `let
Service = {
name = String;
port = Int & > 443 default 8443;
feature.enable = Bool default true;
};
const STORAGE_KEY = 'decodal-playground-project-v1';
const starterFiles = {
'main.dcdl': `let
schema = import "./schemas/service.dcdl";
in
Service & {
schema.Service & {
name = "api";
port = 9443;
}
`;
`,
'schemas/service.dcdl': `Service = {
name = String;
port = Int & > 443 default 8443;
feature.enable = Bool default true;
};
`,
};
const source = document.getElementById('source');
const sourceHighlight = document.getElementById('source-highlight');
const output = document.getElementById('output');
const run = document.getElementById('run');
const status = document.getElementById('status');
const fileTree = document.getElementById('file-tree');
const activeFile = document.getElementById('active-file');
const newFile = document.getElementById('new-file');
const deleteFile = document.getElementById('delete-file');
source.value = starter;
const project = loadProject();
setActiveFile(project.activePath);
renderFileTree();
updateHighlight();
function loadProject() {
try {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null');
if (stored && stored.files && typeof stored.activePath === 'string') {
const files = normalizeFiles(stored.files);
const activePath = files[stored.activePath] === undefined ? Object.keys(files)[0] : stored.activePath;
if (activePath) return { files, activePath };
}
} catch (_error) {
// Fall back to the starter project.
}
return { files: { ...starterFiles }, activePath: 'main.dcdl' };
}
function normalizeFiles(files) {
return Object.fromEntries(
Object.entries(files)
.filter(([_path, value]) => typeof value === 'string')
.map(([path, value]) => [normalizePath(path), value])
.filter(([path]) => path),
);
}
function saveProject() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
}
function normalizePath(path) {
const parts = [];
for (const part of String(path).replaceAll('\\', '/').split('/')) {
if (!part || part === '.') continue;
if (part === '..') parts.pop();
else parts.push(part);
}
return parts.join('/');
}
function setActiveFile(path) {
const normalized = normalizePath(path);
if (project.files[normalized] === undefined) return;
project.activePath = normalized;
source.value = project.files[normalized];
activeFile.textContent = normalized;
deleteFile.disabled = Object.keys(project.files).length <= 1;
updateHighlight();
syncHighlightScroll();
renderFileTree();
saveProject();
}
function updateHighlight() {
sourceHighlight.innerHTML = `${highlightDecodal(source.value)}\n`;
}
@ -33,11 +96,63 @@ function syncHighlightScroll() {
}
function execute() {
const result = JSON.parse(evaluate(source.value));
project.files[project.activePath] = source.value;
saveProject();
const result = JSON.parse(evaluateProject(project.activePath, JSON.stringify(project.files)));
output.textContent = result.ok ? result.output : result.error;
output.classList.toggle('error', !result.ok);
}
function renderFileTree() {
const tree = buildTree(Object.keys(project.files).sort());
fileTree.replaceChildren(renderTreeList(tree.children));
}
function buildTree(paths) {
const root = { name: '', children: new Map(), path: '' };
for (const path of paths) {
const parts = path.split('/');
let node = root;
let currentPath = '';
parts.forEach((part, index) => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (!node.children.has(part)) {
node.children.set(part, { name: part, children: new Map(), path: currentPath, file: index + 1 === parts.length });
}
node = node.children.get(part);
});
}
return root;
}
function renderTreeList(children) {
const list = document.createElement('ul');
for (const child of [...children.values()].sort(compareNodes)) {
const item = document.createElement('li');
if (child.file) {
const button = document.createElement('button');
button.type = 'button';
button.className = child.path === project.activePath ? 'file active' : 'file';
button.textContent = child.name;
button.title = child.path;
button.addEventListener('click', () => setActiveFile(child.path));
item.append(button);
} else {
const label = document.createElement('span');
label.className = 'folder';
label.textContent = `${child.name}/`;
item.append(label, renderTreeList(child.children));
}
list.append(item);
}
return list;
}
function compareNodes(a, b) {
if (a.file !== b.file) return a.file ? 1 : -1;
return a.name.localeCompare(b.name);
}
try {
await init();
run.disabled = false;
@ -48,9 +163,27 @@ try {
}
run.addEventListener('click', execute);
newFile.addEventListener('click', () => {
const path = normalizePath(prompt('New virtual file path', 'schemas/types.dcdl') ?? '');
if (!path) return;
if (project.files[path] !== undefined) {
setActiveFile(path);
return;
}
project.files[path] = path.endsWith('.dcdl') ? '' : '// Decodal source\n';
setActiveFile(path);
});
deleteFile.addEventListener('click', () => {
if (Object.keys(project.files).length <= 1) return;
if (!confirm(`Delete ${project.activePath}?`)) return;
delete project.files[project.activePath];
setActiveFile(Object.keys(project.files).sort()[0]);
});
source.addEventListener('input', () => {
project.files[project.activePath] = source.value;
updateHighlight();
syncHighlightScroll();
saveProject();
});
source.addEventListener('scroll', syncHighlightScroll);
source.addEventListener('keydown', (event) => {

View File

@ -49,6 +49,10 @@ a:hover {
min-height: calc(100vh - 56px);
}
.playground-layout {
display: block;
}
.sidebar {
background: #ffffff;
border-right: 1px solid #e5e7eb;
@ -94,7 +98,7 @@ main {
}
main.playground {
padding: 24px;
padding: 10px;
}
.markdown {
@ -162,25 +166,41 @@ main.playground {
}
.playground-page {
height: calc(100vh - 104px);
height: calc(100vh - 76px);
}
.playground-header {
align-items: center;
display: flex;
gap: 16px;
justify-content: space-between;
margin-bottom: 18px;
margin-bottom: 8px;
}
.playground-header h1 {
margin: 0 0 4px;
font-size: 20px;
margin: 0 0 2px;
}
.playground-header p {
color: #4b5563;
font-size: 13px;
margin: 0;
}
.playground-header code {
background: #eef2ff;
border-radius: 4px;
color: #3730a3;
padding: 1px 4px;
}
.playground-actions {
align-items: center;
display: flex;
gap: 12px;
}
button {
background: #2563eb;
border: 0;
@ -188,7 +208,7 @@ button {
color: white;
cursor: pointer;
font-weight: 700;
padding: 10px 18px;
padding: 8px 14px;
}
button:disabled {
@ -196,32 +216,103 @@ button:disabled {
opacity: 0.5;
}
.playground-grid {
.playground-shell {
display: grid;
gap: 16px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
height: calc(100% - 82px);
gap: 10px;
grid-template-columns: 220px minmax(360px, 1.25fr) minmax(320px, 1fr);
height: calc(100% - 52px);
min-height: 0;
}
.file-panel,
.pane {
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
border-radius: 10px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.panel-header,
.pane > span {
align-items: center;
border-bottom: 1px solid #e5e7eb;
color: #374151;
display: flex;
font-size: 13px;
font-weight: 700;
padding: 10px 12px;
justify-content: space-between;
min-height: 38px;
padding: 0 10px;
text-transform: uppercase;
}
.panel-header button {
font-size: 12px;
padding: 5px 8px;
text-transform: none;
}
.file-tree {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px;
}
.file-tree ul {
list-style: none;
margin: 0;
padding-left: 12px;
}
.file-tree > ul {
padding-left: 0;
}
.file-tree li {
margin: 2px 0;
}
.file-tree .folder {
color: #64748b;
display: block;
font-size: 13px;
font-weight: 700;
padding: 4px 6px;
}
.file-tree .file {
background: transparent;
border-radius: 6px;
color: #334155;
display: block;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 13px;
font-weight: 500;
padding: 5px 7px;
text-align: left;
width: 100%;
}
.file-tree .file:hover,
.file-tree .file.active {
background: #dbeafe;
color: #1d4ed8;
text-decoration: none;
}
.danger-button {
background: transparent;
border-top: 1px solid #e5e7eb;
border-radius: 0;
color: #b91c1c;
padding: 9px 10px;
text-align: left;
}
.editor-wrap {
background: #0f172a;
flex: 1;
@ -237,7 +328,7 @@ button:disabled {
inset: 0;
margin: 0;
overflow: auto;
padding: 14px;
padding: 12px;
position: absolute;
tab-size: 2;
white-space: pre;
@ -273,6 +364,9 @@ button:disabled {
.status {
color: #4b5563;
font-size: 13px;
margin: 0;
white-space: nowrap;
}
@media (max-width: 900px) {

View File

@ -3,11 +3,14 @@
export function evaluate(source: string): string;
export function evaluateProject(entry: string, files_json: string): string;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly evaluate: (a: number, b: number, c: number) => void;
readonly evaluateProject: (a: number, b: number, c: number, d: number, e: number) => void;
readonly __wbindgen_add_to_stack_pointer: (a: number) => number;
readonly __wbindgen_export: (a: number, b: number) => number;
readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;

View File

@ -22,6 +22,32 @@ export function evaluate(source) {
wasm.__wbindgen_export3(deferred2_0, deferred2_1, 1);
}
}
/**
* @param {string} entry
* @param {string} files_json
* @returns {string}
*/
export function evaluateProject(entry, files_json) {
let deferred3_0;
let deferred3_1;
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(entry, wasm.__wbindgen_export, wasm.__wbindgen_export2);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(files_json, wasm.__wbindgen_export, wasm.__wbindgen_export2);
const len1 = WASM_VECTOR_LEN;
wasm.evaluateProject(retptr, ptr0, len0, ptr1, len1);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
deferred3_0 = r0;
deferred3_1 = r1;
return getStringFromWasm0(r0, r1);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_export3(deferred3_0, deferred3_1, 1);
}
}
function __wbg_get_imports() {
const import0 = {
__proto__: null,

View File

@ -2,6 +2,7 @@
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const evaluate: (a: number, b: number, c: number) => void;
export const evaluateProject: (a: number, b: number, c: number, d: number, e: number) => void;
export const __wbindgen_add_to_stack_pointer: (a: number) => number;
export const __wbindgen_export: (a: number, b: number) => number;
export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;