Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions crates/echo_wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@

mod run;

pub use run::{RunResult, SAMPLE_RESULT, SAMPLE_STRUCT, SAMPLE_SUM, run_json, run_source};
pub use run::{
HOMEPAGE_SUM, RunResult, SAMPLE_RESULT, SAMPLE_STRUCT, SAMPLE_SUM, run_json, run_source,
};

use std::path::{Path, PathBuf};

Expand Down Expand Up @@ -299,6 +301,14 @@ io.print("sum={sum}")
assert!(result.host_error.is_none(), "{result:?}");
}

#[test]
fn run_homepage_sum_echo_prints_sum() {
let result = run_source(HOMEPAGE_SUM);
assert!(result.ok, "{result:?}");
assert_eq!(result.printed.as_deref(), Some("sum=6\n"), "{result:?}");
assert!(result.host_error.is_none(), "{result:?}");
}

#[test]
fn run_result_sample_prints_ok_arm() {
let result = run_source(SAMPLE_RESULT);
Expand Down Expand Up @@ -330,7 +340,26 @@ io.print("sum={sum}")

#[test]
fn run_refuses_fs_as_playground_host() {
let result = run_source("/ std/fs\n/ std/io\n$ ok = fs.exists(\"x\")\n");
assert_playground_host("/ std/fs\n/ std/io\n$ ok = fs.exists(\"x\")\n");
}

#[test]
fn run_refuses_net_as_playground_host() {
assert_playground_host("/ std/net/tcp\n$ c = tcp.connect(\"127.0.0.1:1\")\n");
}

#[test]
fn run_refuses_process_as_playground_host() {
assert_playground_host("/ std/process\n$ xs = process.args()\n");
}

#[test]
fn run_refuses_tasks_as_playground_host() {
assert_playground_host("+ job = {\n ^ 1\n}\n- job\n");
}

fn assert_playground_host(source: &str) {
let result = run_source(source);
assert!(!result.ok, "{result:?}");
assert!(result.printed.is_none(), "{result:?}");
let err = result.host_error.as_deref().unwrap_or("");
Expand Down
12 changes: 11 additions & 1 deletion crates/echo_wasm/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use crate::{CheckDiagnostic, PLAYGROUND_PATH, check_source, playground_workspace
const MAX_STEPS: u32 = 2_000_000;
const MAX_CALL_DEPTH: u32 = 256;

/// `/try` Sum sample (must stay in lockstep with `www/src/try.tsx`).
/// `/try` Sum sample (must stay in lockstep with `www/src/docs/site.ts` `homePage.sample`).
pub const SAMPLE_SUM: &str = r#"/ std/io

$ xs = [1, 2, 3]
Expand All @@ -42,6 +42,16 @@ $ xs = [1, 2, 3]
io.print("sum={sum}")
"#;

/// Homepage `sum.echo` figure: same program as [`SAMPLE_SUM`], no trailing newline.
pub const HOMEPAGE_SUM: &str = r#"/ std/io

$ xs = [1, 2, 3]
~ sum = 0
* x : xs {
~ sum = sum + x
}
io.print("sum={sum}")"#;

/// `/try` Result sample.
pub const SAMPLE_RESULT: &str = r#"/ std/io
/ std/str
Expand Down
3 changes: 2 additions & 1 deletion docs/development-speed.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ just fmt-check
The site playground (`/try`) runs the shared frontend (lex → parse → resolve →
semantics) as `wasm32-unknown-unknown`, then a playground run executes checked
MIR and captures `io.print`. It does not ship LLVM. Playground run is a host
demo; native compile and run stay on `xo`.
demo; native compile and run stay on `xo`. Filesystem, net, process, and tasks
fail with a playground-host error. Bindings stay in `www/public/echo-wasm/`.

```bash
just wasm # echo_wasm + wasm-bindgen → www/public/echo-wasm/
Expand Down
2 changes: 1 addition & 1 deletion scripts/build-wasm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ test -f "$OUT_DIR/echo_wasm_bg.wasm"
# Tiny loader note used by the site when someone opens the directory.
cat > "$OUT_DIR/README.md" <<'EOF'
Generated by `just wasm` / `scripts/build-wasm.sh`. Do not edit.
Browser check host (frontend only). Reload `/try` after rebuilding.
Browser check + playground run host. Reload `/try` after rebuilding.
EOF

# Fingerprint the bindings so www can cache-bust the unhashed public URLs.
Expand Down
19 changes: 10 additions & 9 deletions www/SITE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,13 @@ maturity.

The logo is the only Home control. Book stays at `/book` and in the footer.

| Item | Path | Notes |
| ----------------- | ----------- | ------------------------------------------ |
| Documents | `/docs` | Language reference hub |
| Packages | `/docs/std` | Standard library |
| Echo 2026 | `/e26` | Edition + Spec TOC + suite |
| Try | `/try` | In-browser `xo check` (frontend wasm host) |
| **Install** (CTA) | `/install` | Solid button; get `xo` |
| Item | Path | Notes |
| ----------------- | ----------- | --------------------------------------------- |
| Documents | `/docs` | Language reference hub |
| Packages | `/docs/std` | Standard library |
| Echo 2026 | `/e26` | Edition + Spec TOC + suite |
| Try | `/try` | In-browser check + playground run (wasm host) |
| **Install** (CTA) | `/install` | Solid button; get `xo` |

## Docs left rail

Expand Down Expand Up @@ -187,8 +187,9 @@ Cross-links: Reference ↔ Spec ↔ suite pages keep the triangle explicit.

`/try` runs the shared compiler frontend in WebAssembly (`just wasm`). It
checks source the same way `xo check` does, including bundled `std`. A
playground run then executes the checked MIR and captures `io.print`. Compile
and native run stay on `xo` (LLVM).
playground run then executes the checked MIR and captures `io.print`.
Filesystem, net, process, and tasks fail with a playground-host error.
Compile and native run stay on `xo` (LLVM).

## Out of scope (later)

Expand Down
3 changes: 2 additions & 1 deletion www/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
"format": "oxfmt --check .",
"lint": "oxlint . --ignore-pattern public/echo-wasm",
"preview": "vite preview",
"test": "npm run test:docs && npm run test:prose && npm run test:std-ref",
"test": "npm run test:docs && npm run test:prose && npm run test:std-ref && npm run test:try",
"test:docs": "node scripts/verify-docs-pages.mjs",
"test:std-ref": "node scripts/verify-std-reference.mjs",
"test:prose": "node scripts/verify-prose.mjs",
"test:try": "node scripts/verify-try.mjs",
"sync:tree-sitter": "node scripts/sync-tree-sitter.mjs",
"postinstall": "npm run sync:tree-sitter"
},
Expand Down
4 changes: 4 additions & 0 deletions www/public/_headers
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Bindings are cache-busted with ?v= from ECHO_WASM_REV; do not pin them long.
/echo-wasm/*
Cache-Control: public, max-age=0, must-revalidate

/echo-wasm/*.wasm
Content-Type: application/wasm
Cache-Control: public, max-age=0, must-revalidate
2 changes: 1 addition & 1 deletion www/public/echo-wasm/README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
Generated by `just wasm` / `scripts/build-wasm.sh`. Do not edit.
Browser check host (frontend only). Reload `/try` after rebuilding.
Browser check + playground run host. Reload `/try` after rebuilding.
120 changes: 120 additions & 0 deletions www/scripts/verify-try.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Verifies the /try playground contract:
* - default Sum sample is the homepage sum.echo figure
* - page copy names host limits and does not claim `xo run` or native LLVM
* - shipped www/public/echo-wasm bindings check the sample and capture io.print
*
* Loads site/playground modules through Vite SSR. Instantiates the committed
* wasm bindings in-process so Pages can ship without a Rust toolchain.
*/
import { createServer } from "vite";
import { existsSync, readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import path from "node:path";
import { fileURLToPath } from "node:url";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const wasmDir = path.join(root, "public/echo-wasm");
const trySourcePath = path.join(root, "src/try.tsx");

const server = await createServer({
root,
logLevel: "error",
server: { middlewareMode: true },
appType: "custom",
});

const failures = [];

function fail(message) {
failures.push(message);
}

try {
const site = await server.ssrLoadModule("/src/docs/site.ts");
const playground = await server.ssrLoadModule("/src/lib/playground.ts");
const tryPage = await server.ssrLoadModule("/src/try.tsx");

const homepage = site.homePage.sample.trim();
const sum = playground.playgroundSumSource().trim();
if (sum !== homepage) {
fail("playgroundSumSource must match homePage.sample");
}

const samples = tryPage.PLAYGROUND_SAMPLES;
const sumSample = samples?.find((sample) => sample.id === "sum");
if (sumSample == null) {
fail("PLAYGROUND_SAMPLES must include a sum sample");
} else if (sumSample.source.trim() !== homepage) {
fail("PLAYGROUND_SAMPLES sum source must match homePage.sample");
}

const limits = playground.PLAYGROUND_HOST_LIMITS;
for (const limit of ["filesystem", "net", "process", "tasks"]) {
if (!limits?.includes(limit)) {
fail(`PLAYGROUND_HOST_LIMITS missing ${limit}`);
}
}

const trySource = readFileSync(trySourcePath, "utf8");
if (/\bxo run\b/.test(trySource)) {
fail("/try must not describe itself as xo run");
}
if (/native LLVM/i.test(trySource)) {
fail("/try must not claim native LLVM in the browser");
}
for (const limit of ["filesystem", "net", "process", "tasks"]) {
if (!trySource.toLowerCase().includes(limit)) {
fail(`/try page must name host limit ${limit}`);
}
}
if (!trySource.includes("io.print")) {
fail("/try page must mention captured io.print");
}

const requiredWasm = ["echo_wasm.js", "echo_wasm_bg.wasm", "echo_wasm.d.ts"];
for (const name of requiredWasm) {
if (!existsSync(path.join(wasmDir, name))) {
fail(`missing shipped binding www/public/echo-wasm/${name}`);
}
}

if (failures.length === 0) {
const jsUrl = pathToFileURL(path.join(wasmDir, "echo_wasm.js")).href;
const wasmBytes = await readFile(path.join(wasmDir, "echo_wasm_bg.wasm"));
const wasm = await import(jsUrl);
await wasm.default({ module_or_path: wasmBytes });

if (typeof wasm.check !== "function" || typeof wasm.playgroundRun !== "function") {
fail("echo_wasm bindings must export check and playgroundRun");
} else {
const checked = JSON.parse(wasm.check(site.homePage.sample));
if (!checked.ok) {
fail(`homepage sample must check: ${JSON.stringify(checked)}`);
}
const ran = JSON.parse(wasm.playgroundRun(site.homePage.sample));
if (!ran.ok || ran.printed !== "sum=6\n") {
fail(`homepage sample must print sum=6, got ${JSON.stringify(ran)}`);
}
}
}
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
} finally {
await server.close();
}

if (failures.length) {
console.error(JSON.stringify({ ok: false, failures }, null, 2));
process.exitCode = 1;
} else {
console.log(
JSON.stringify({
ok: true,
sample: "sum.echo",
printed: "sum=6",
wasm: "www/public/echo-wasm",
}),
);
}
4 changes: 3 additions & 1 deletion www/src/docs/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,9 @@ io.print("sum={sum}")`,
text: [
"Open ",
{ code: "/try" },
" to check this program in the browser. Build ",
" to check this program and capture ",
{ code: "io.print" },
". Build ",
{ code: "xo" },
" via ",
{ code: "/install" },
Expand Down
14 changes: 14 additions & 0 deletions www/src/lib/playground.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* /try contract: homepage sample, host limits, and copy that must not
* claim `xo run` or native LLVM in the browser.
*/

import { homePage } from "../docs/site";

/** Sum buffer on /try. Same program as the homepage `sum.echo` figure. */
export function playgroundSumSource(): string {
return homePage.sample.endsWith("\n") ? homePage.sample : `${homePage.sample}\n`;
}

/** Host services the playground refuses. Keep this list on the page. */
export const PLAYGROUND_HOST_LIMITS = ["filesystem", "net", "process", "tasks"] as const;
27 changes: 11 additions & 16 deletions www/src/try.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,19 @@ import {
type EchoCheckApi,
type RunResult,
} from "./lib/echo-check";
import { playgroundSumSource } from "./lib/playground";

type Sample = {
id: string;
label: string;
source: string;
};

const SAMPLES: Sample[] = [
export const PLAYGROUND_SAMPLES: Sample[] = [
{
id: "sum",
label: "Sum",
source: `/ std/io

$ xs = [1, 2, 3]
~ sum = 0
* x : xs {
~ sum = sum + x
}
io.print("sum={sum}")
`,
source: playgroundSumSource(),
},
{
id: "result",
Expand Down Expand Up @@ -120,11 +113,12 @@ function diagnosticLabel(diag: CheckDiagnostic) {
}

/**
* In-browser xo check. LLVM run stays on a native xo install.
* In-browser check plus a playground run that captures io.print.
* Native compile stays on an xo install.
*/
export function TryPage() {
const [source, setSource] = useState(SAMPLES[0].source);
const [activeSample, setActiveSample] = useState(SAMPLES[0].id);
const [source, setSource] = useState(PLAYGROUND_SAMPLES[0].source);
const [activeSample, setActiveSample] = useState(PLAYGROUND_SAMPLES[0].id);
const [api, setApi] = useState<EchoCheckApi | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [result, setResult] = useState<CheckResult | null>(null);
Expand Down Expand Up @@ -237,9 +231,10 @@ export function TryPage() {
Try Echo
</h1>
<p className="mt-4 max-w-3xl text-pretty text-lg leading-8 text-slate-600">
This page checks with the shared compiler frontend, then a playground run executes the
This page checks with the shared compiler frontend. A playground run then executes the
checked program and captures{" "}
<span className="font-mono font-semibold text-slate-800">io.print</span>. Install{" "}
<span className="font-mono font-semibold text-slate-800">io.print</span>. Filesystem, net,
process, and tasks stay unavailable here. Install{" "}
<span className="font-mono font-semibold text-slate-800">xo</span> to compile through
LLVM.
</p>
Expand All @@ -264,7 +259,7 @@ export function TryPage() {
<section className="min-w-0">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2" role="tablist" aria-label="Sample programs">
{SAMPLES.map((sample) => {
{PLAYGROUND_SAMPLES.map((sample) => {
const selected = sample.id === activeSample;
return (
<button
Expand Down
Loading