diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md index 4216404..a7c721f 100644 --- a/.gitea/workflows/README.md +++ b/.gitea/workflows/README.md @@ -3,3 +3,9 @@ [ci.yml](ci.yml) verifies this project on main pushes, pull requests and manual dispatch. It runs on ces-control, with a 60-minute timeout. HTTPS checkout and job token details are in [the parent README](../README.md). There is no production service deployment in this workflow. + +The container includes the Endor examples used by the live guide and tests. It +validates the actual Aster-backed source-to-UI path, snapshot lifetime, layout and +execution budgets, failed-update preservation and stale-result invalidation, in +addition to the existing rendering checks and Clippy. Cargo uses official sources. +Docker is used only in this remote workflow; local compilation is native. diff --git a/Dockerfile.ci b/Dockerfile.ci index 911657a..0977ee6 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -6,5 +6,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libx ENV CARGO_BUILD_JOBS=1 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 COPY Cargo.toml Cargo.lock ./ COPY src ./src +COPY examples ./examples COPY vendor ./vendor CMD ["sh", "-eu", "-c", "cargo fmt --check && cargo test --locked && cargo clippy --all-targets --locked -- -D warnings"] diff --git a/README.md b/README.md index 724b229..96f2d67 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,50 @@ # Tessera -Tessera is a clean desktop rendering host. It starts with an empty page. Coding agents submit source code to a namespace through a local CRUD API; Tessera parses that code, builds its architecture tree, compiles it to drawing commands, and renders the result. +Tessera is a native desktop UI host embedding [Aster](vendor/aster/README.md). +Agents submit ordinary Endor source through a local namespace API. Aster parses, +compiles and executes it; `main` returns a Tessera UI object. Tessera extracts a +native tree, lays it out and draws it using the existing software renderer. +The initial page is empty. -## Aster source dependency +## Source and library integration -[Aster](vendor/aster/README.md) is an independent Git repository checked out as a -submodule at `vendor/aster`. Tessera records its exact commit in the Git submodule -entry; `.gitmodules` records its source URL. See [vendor](vendor/README.md) for -initialization, version updates, and the checkout layout. +`vendor/aster` is an unmodified Git submodule, pinned to an exact published commit. +Cargo path dependencies directly use its `frontend`, `compiler` and `core` +(library name `endor_core`) packages, which bring in `objects`. The Aster workspace +and history remain independent. See [vendor](vendor/README.md) for initialization +and version updates, and [src](src/README.md) for the implementation map. -Cargo directly depends on Aster's `frontend`, `compiler`, and `core` library -packages through paths inside that submodule. The `core` dependency is named -`endor_core` in Tessera's manifest. These packages also bring in Aster's `objects` -package. Aster retains its own Cargo workspace and source history. +The runtime now uses Aster's Reader, Parser, AST, Compiler and VM. Tessera's former +experimental `namespace` / `export scene` / `render` parser has been removed. +Host UI integration lives in Tessera; Aster needs no Tessera-specific changes. +Lyra CLI and Hestia Server are independent hosts and are not application dependencies. -Rust 1.91 or newer is required. Building Tessera compiles the required Aster -libraries automatically; no separate Aster build or CLI/server process is -required. Tessera does not depend on Aster's storage, host, CLI, or server packages. +## Native build and start -This change establishes the source and library dependency only. The namespace -API still uses the experimental Tessera parser described below. Executing Endor -through Aster and replacing that parser is the next implementation step; no -Aster source changes are part of this integration. - -## Start - -Initialize the Aster submodule first, following the SSH identity instructions in -[vendor](vendor/README.md): +Use Rust 1.91 or newer. Initialize the source submodule following the dedicated +codex-bot SSH identity instructions in [vendor](vendor/README.md), then build: ```bash git submodule update --init --recursive +cargo build --release --locked +./target/release/tessera ``` -For local native builds and window runs (automated tests run only in CI): +Cargo compiles the required Aster libraries together with Tessera. No separate +Aster build, service or deployment is required. Local builds download dependencies +from official crates.io; do not substitute a mirror. Docker and automated tests +are not used locally. On macOS this produces a native executable, not an `.app` +bundle or installer. -```bash -cargo run --release -``` - -Tessera listens only on `127.0.0.1:4737`. - -Start every agent integration by reading the live Markdown guide: +The API listens only on `127.0.0.1:4737`. Start integrations with its live guide: ```bash curl http://127.0.0.1:4737/agent ``` -## Namespace code API +## Submit Endor -The initial active namespace is `app.main`. Tessera has no file concept. +The active namespace starts as `app.main`: ```text GET /v1/namespaces/app.main/code @@ -56,53 +52,102 @@ PUT /v1/namespaces/app.main/code DELETE /v1/namespaces/app.main/code ``` -Submit code as plain text: +Submit the [complete example](examples/hello.endor): ```bash -curl \ - --request PUT \ - --header 'Content-Type: text/plain; charset=utf-8' \ - --data-binary @- \ - http://127.0.0.1:4737/v1/namespaces/app.main/code <<'TESSERA' -namespace app.main - -use tessera.ui::{Column, Text} - -export scene Main { - render { - Column(gap: 12, padding: 24) { - Text("Hello", role: .title) - Text("Rendered by Tessera") - } - } -} -TESSERA +curl --request PUT --header 'Content-Type: text/plain; charset=utf-8' \ + --data-binary @examples/hello.endor \ + http://127.0.0.1:4737/v1/namespaces/app.main/code ``` -Updates are atomic. Invalid code returns diagnostics with HTTP 422 and preserves the last successfully rendered revision. +A minimal program is: + +```endor +function main(): View { + let title = ui.text("Hello Endor", 2, "#242422") + let rectangle = ui.rect(240, 80, 12, "#DCE8FF") + return ui.column(24, 12, title, rectangle) +} +``` + +The namespace comes from the request path. `ui` is a host global declared to +Aster's compiler and injected into the candidate VM. Function declarations, +helper calls, local variables, member reads and positional arguments use Aster's +existing language implementation. `View` is currently a syntactic annotation; +Tessera validates the actual returned object. This does not add syntax to Endor. +The sample file is client-side material; the API itself stores source by namespace. + +## UI host API + +All arguments are positional. Defaults below describe omitted trailing arguments, +not Endor keyword argument syntax. + +| Member | Arguments and defaults | +| --- | --- | +| `ui.text` | String, integer size (default 1, range 1..8), color (default `#242422`) | +| `ui.rect` | Width, height, radius (default 0), color (default `#DCE8FF`) | +| `ui.column` | Padding, gap, followed by zero or more UI children | +| `ui.row` | Padding, gap, followed by zero or more UI children | +| `ui.spacer` | Size | + +Colors are `#RRGGBB` or `#RRGGBBAA`. Dimensions are finite numbers in 0..1000000. +Aster keeps its exact numeric representation; conversion occurs only at the UI +boundary. Text uses the current bitmap renderer, with Chinese fonts still pending. + +## Candidate execution and publication + +A dedicated worker performs source parsing, AST budget checks, compilation, +module initialization and zero-argument `main` execution in a fresh VM. It checks +the returned UI node, extracts native data, builds and validates the frame, then +cleans up all VM roots. The window receives no VM handles. It relays out the owned +native tree during redraw and display scale changes without rerunning Endor. + +The existing callback interface has no allocator access. Tessera therefore +preallocates at most 4096 real UI objects per candidate. Each is initialized once. +Constructors return those borrowed handles; Aster retains call results under its +existing rules. Native child data owns its own lifetime and contains no managed +VM edges. This bounded pool is for snapshot construction, not a persistent dynamic UI. + +Limits are 1 MiB source, 10000 visited AST nodes with a 64-level visitor depth, +4096 constructed UI nodes, 64 UI levels, 10000 expanded layout visits and 16 KiB +per text. VM initialization and entry execution each have a 128-frame and 1000000- +instruction budget. Repeated children count again during layout, preventing a +small shared tree from expanding without bound. The build queue holds eight +pending requests. + +GET reads the last successful source while the worker executes. PUT waits for its +result; a successful response includes submission and committed revision numbers. +Parsing, compilation, execution, argument or layout errors return 422 with +explanations and preserve the last successful document. Source diagnostics include +line, column and byte ranges; VM tracebacks currently identify function starts and +instruction offsets, not an exact source location for every instruction. + +A newer accepted PUT invalidates older work. DELETE clears the document immediately +and invalidates both queued and executing candidates. Superseded requests return +409 and cannot republish their page. A full or unavailable queue returns 503; +rejected queue entries do not invalidate accepted work. Failed builds do not +increment the committed revision. A worker-caught panic discards the entire +candidate and returns 500, retaining the previous document. + +This stage supports static snapshot UI. Event callbacks, persistent running VMs, +window-size language inputs and process isolation are future work. Worker threads, +source budgets and VM instruction limits are not a complete untrusted-code sandbox. ## Developer tools -The developer sidebar is closed by default. - -- macOS: `Command-Option-I` or `F12` -- Windows/Linux: `Control-Shift-I` or `F12` - -The upper section displays the compiler-produced architecture tree. Selecting a node shows the corresponding source range below it. - -## Current syntax experiment - -The first parser intentionally supports only enough syntax to test the architecture: - -- `namespace` -- namespace-only `use` -- one `export scene` -- one `render` root -- `Column`, `Row`, `Spacer`, and `Text` -- `gap`, `padding`, and `.title` - -There are no files, HTML, CSS, JavaScript, DOM, or resource-network concepts in the runtime. +Toggle the sidebar with Command-Option-I or F12 on macOS, Control-Shift-I or F12 +on Windows/Linux. It displays Aster AST functions, bindings, calls and expressions. +Selection shows their ranges in the exact submitted source. Tessera does not +maintain a second language AST. ## Validation -Local Cargo builds and native window runs are allowed; do not run local tests or Docker. Automated tests and Clippy run in [.gitea](.gitea/README.md) on ces-control using Dockerfile.ci. The Linux CI checks portable code and software-renderer tests, not macOS window presentation. Aster has no standalone CI/CD; Lyra CI explicitly runs the pinned engine tests. CI uses HTTPS checkout with the built-in job token; see .gitea/README.md. +Local native builds and window runs are allowed; do not run local automated tests +or Docker. [Remote CI](.gitea/README.md) on ces-control runs the existing renderer +checks and the new Aster execution, UI layout, source-range, lifetime, budget, +failed-update and deletion-invalidation regressions, plus Clippy. +The API regressions use actual HTTP requests, including DELETE while the worker +is occupied and a queued PUT is superseded. +Linux CI does not verify macOS window presentation. Aster has no standalone CI/CD; Lyra CI +explicitly validates the pinned engine itself. CI uses official Cargo sources +and standard HTTPS checkout without custom SSH secrets. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..d9ea032 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,12 @@ +# Endor UI examples + +[hello.endor](hello.endor) returns a native UI tree using an ordinary helper +function and `main`. It covers text, a rounded rectangle, row/column layout and +spacing. Submit it as the UTF-8 body of a namespace PUT request; no namespace, +scene, render block or imports belong in the source. + +The live `/agent` guide embeds this exact example. Remote CI executes it through +Aster and checks the resulting coordinates and source ranges after VM cleanup. +The file is client-side sample material; Tessera's API stores source by namespace. + +Return to the [project guide](../README.md). diff --git a/examples/hello.endor b/examples/hello.endor new file mode 100644 index 0000000..63aa6d3 --- /dev/null +++ b/examples/hello.endor @@ -0,0 +1,10 @@ +function heading(value: Text): View { + return ui.text(value, 2, "#242422") +} + +function main(): View { + let title = heading("Hello Endor") + let rectangle = ui.rect(240, 80, 12, "#DCE8FF") + let detail = ui.row(0, 12, ui.text("Aster VM"), ui.spacer(8), ui.text("Tessera UI")) + return ui.column(24, 12, title, rectangle, detail) +} diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..fee4f6d --- /dev/null +++ b/src/README.md @@ -0,0 +1,34 @@ +# Tessera source + +| Module | Responsibility | +| --- | --- | +| main.rs | Native window event loop, namespace state and API startup | +| api.rs | Code CRUD, eight-entry execution queue, worker, submission ordering and diagnostics | +| endor_host.rs | Aster Reader/Parser/Compiler, fresh candidate VM, limited initialization/main execution, root cleanup and AST developer tree | +| ui_bridge.rs | Tessera UI constructors and payloads, bounded preallocated node pool and native snapshot extraction | +| layout.rs | Native UI tree to Frame; expanded-tree and coordinate budgets | +| document.rs | Published source/AST/UI snapshot, revisions, invalidation, frame validation and display commands | +| app.rs | Window events, redraw and developer-tools input | +| scene.rs | Page and developer-tools display-list composition | +| developer_tools.rs | Aster AST tree selection and corresponding source display | +| display_list.rs | Rendering primitives and color representation | +| geometry.rs | Native points and rectangles | +| software_renderer.rs | Software rasterization of the display list | +| README.md | Module map | + +Aster is an unmodified source submodule. Language parsing, AST definitions, +compilation, bytecode and VM execution come from its libraries. Tessera implements +only host UI behavior and layout. No CLI, HTTP server or Docker process is needed +to execute Endor in the application. + +An execution candidate owns all external VM roots, including the 4096-node pool. +Callbacks return borrowed, preallocated real VM objects. Their mutable slots hold +immutable native UI descriptions; child edges contain owned native Arcs, never +managed VM references. Once extraction finishes, release the return value, clear +globals, release roots, collect cycles and require an empty VM. The window keeps +only source, developer metadata, frame commands and native UI data. + +Tests in api.rs execute real HTTP CRUD and a controlled queued-PUT/DELETE race +without opening a window. Tests in endor_host.rs cover lifecycle, normal Endor calls, malformed input, +entry checks, budgets, failure preservation and stale-result invalidation. They +run only in remote CI. See the [project guide](../README.md) for public behavior. diff --git a/src/api.rs b/src/api.rs index 83cd3bf..1728fb5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,10 @@ use std::{ io::Read, - sync::{Arc, RwLock}, + panic::{AssertUnwindSafe, catch_unwind}, + sync::{ + Arc, RwLock, + mpsc::{SyncSender, TrySendError, sync_channel}, + }, thread, }; @@ -8,14 +12,18 @@ use anyhow::{Context as _, Result}; use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; use winit::event_loop::EventLoopProxy; -use crate::{ - app::AppEvent, - compiler, - document::{NamespaceState, RenderDocument}, -}; +use crate::{app::AppEvent, document::NamespaceState, endor_host}; pub const API_ADDRESS: &str = "127.0.0.1:4737"; -const MAX_CODE_SIZE: u64 = 1_048_576; +const MAX_CODE_SIZE: u64 = endor_host::MAX_SOURCE_BYTES as u64; +type Notify = Arc; + +struct BuildRequest { + request: Request, + source: String, + namespace: String, + submission: u64, +} pub fn start( namespace: Arc>, @@ -24,21 +32,45 @@ pub fn start( let server = Server::http(API_ADDRESS) .map_err(|error| anyhow::anyhow!(error)) .with_context(|| format!("bind Tessera API to {API_ADDRESS}"))?; + let notify: Notify = Arc::new(move || { + let _ = proxy.send_event(AppEvent::DocumentUpdated); + }); + start_server(Arc::new(server), namespace, notify)?; + Ok(()) +} + +fn start_server( + server: Arc, + namespace: Arc>, + notify: Notify, +) -> Result> { + let (sender, receiver) = sync_channel::(8); + let worker_namespace = namespace.clone(); + let worker_notify = notify.clone(); + thread::Builder::new() + .name("tessera-endor-worker".into()) + .spawn(move || { + for job in receiver { + let response = build(&job, &worker_namespace, &worker_notify); + let _ = job.request.respond(response); + } + }) + .context("start Aster execution worker")?; thread::Builder::new() .name("tessera-agent-api".to_owned()) .spawn(move || { for request in server.incoming_requests() { - handle(request, &namespace, &proxy); + handle(request, &namespace, ¬ify, &sender); } }) - .context("start Tessera agent API thread")?; - Ok(()) + .context("start Tessera agent API thread") } fn handle( - mut request: Request, + request: Request, namespace: &Arc>, - proxy: &EventLoopProxy, + notify: &Notify, + sender: &SyncSender, ) { let method = request.method().clone(); let path = request @@ -48,13 +80,19 @@ fn handle( .unwrap_or(request.url()) .to_owned(); + if method == Method::Put + && let Some(addressed) = namespace_from_path(&path) + { + submit(request, addressed, namespace, sender); + return; + } let response = if method == Method::Get && path == "/agent" { match namespace.read() { Ok(namespace) => markdown(StatusCode(200), agent_guide(&namespace)), Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"), } } else if let Some(addressed_namespace) = namespace_from_path(&path).map(str::to_owned) { - code_request(&mut request, method, &addressed_namespace, namespace, proxy) + code_request(method, &addressed_namespace, namespace, notify) } else if method == Method::Options { text(StatusCode(204), "", "text/plain") } else { @@ -65,11 +103,10 @@ fn handle( } fn code_request( - request: &mut Request, method: Method, addressed_namespace: &str, namespace: &Arc>, - proxy: &EventLoopProxy, + notify: &Notify, ) -> Response>> { let Ok(current) = namespace.read() else { return error_response(StatusCode(500), "namespace lock is poisoned"); @@ -100,43 +137,14 @@ fn code_request( drop(current); match method { - Method::Put => match read_code(request) { - Ok(code) => match compiler::compile(&code, addressed_namespace) { - Ok(document) => { - if let Err(error) = document.validate() { - return compile_error(error.to_string()); - } - match namespace.write() { - Ok(mut current) => { - current.revision += 1; - current.document = document; - let revision = current.revision; - drop(current); - let _ = proxy.send_event(AppEvent::DocumentUpdated); - json( - StatusCode(200), - serde_json::json!({ - "ok": true, - "namespace": addressed_namespace, - "revision": revision, - "diagnostics": [] - }), - ) - } - Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"), - } - } - Err(error) => compile_error(error.to_string()), - }, - Err(error) => error_response(StatusCode(400), error.to_string()), - }, Method::Delete => match namespace.write() { Ok(mut current) => { - current.revision += 1; - current.document = RenderDocument::default(); - let revision = current.revision; + let revision = match current.clear() { + Ok(revision) => revision, + Err(error) => return error_response(StatusCode(500), error.to_string()), + }; drop(current); - let _ = proxy.send_event(AppEvent::DocumentUpdated); + notify(); json( StatusCode(200), serde_json::json!({ @@ -153,6 +161,112 @@ fn code_request( } } +fn submit( + mut request: Request, + addressed: &str, + namespace: &Arc>, + sender: &SyncSender, +) { + let source = match read_code(&mut request) { + Ok(source) => source, + Err(error) => { + let _ = request.respond(error_response(StatusCode(400), error.to_string())); + return; + } + }; + let Ok(mut current) = namespace.write() else { + let _ = request.respond(error_response( + StatusCode(500), + "namespace lock is poisoned", + )); + return; + }; + if current.name != addressed { + let _ = request.respond(error_response(StatusCode(404), "namespace is not active")); + return; + } + let submission = match current.next_submission() { + Ok(id) => id, + Err(error) => { + let _ = request.respond(error_response(StatusCode(500), error.to_string())); + return; + } + }; + let job = BuildRequest { + request, + source, + namespace: addressed.into(), + submission, + }; + match sender.try_send(job) { + // A rejected queue entry never invalidates accepted work. The worker + // uses this same lock when committing, so publication is atomic. + Ok(()) => current.submission = submission, + Err(TrySendError::Full(job)) => { + let _ = job + .request + .respond(error_response(StatusCode(503), "build queue is full")); + } + Err(TrySendError::Disconnected(job)) => { + let _ = job.request.respond(error_response( + StatusCode(503), + "execution worker is unavailable", + )); + } + } +} + +fn build( + job: &BuildRequest, + namespace: &Arc>, + notify: &Notify, +) -> Response>> { + match namespace.read() { + Ok(current) if current.submission != job.submission => return superseded(job.submission), + Err(_) => return error_response(StatusCode(500), "namespace lock is poisoned"), + _ => {} + } + // Only an independent native snapshot crosses into the published state. + // A fresh VM is discarded on success, error and unwind alike. + let result = catch_unwind(AssertUnwindSafe(|| { + endor_host::compile(&job.source, &job.namespace, job.submission) + })); + let Ok(mut current) = namespace.write() else { + return error_response(StatusCode(500), "namespace lock is poisoned"); + }; + if current.submission != job.submission { + return superseded(job.submission); + } + match result { + Ok(Ok(document)) => match current.commit(job.submission, document) { + Ok(Some(revision)) => { + drop(current); + notify(); + json( + StatusCode(200), + serde_json::json!({ "ok": true, "namespace": job.namespace, + "submission": job.submission, "revision": revision, "diagnostics": [] }), + ) + } + Ok(None) => superseded(job.submission), + Err(error) => error_response(StatusCode(500), error.to_string()), + }, + Ok(Err(error)) => compile_error(error), + Err(_) => error_response( + StatusCode(500), + "candidate execution failed internally; previous document preserved", + ), + } +} + +fn superseded(submission: u64) -> Response>> { + json( + StatusCode(409), + serde_json::json!({ "ok": false, "submission": submission, + "error": "submission superseded by a newer PUT or DELETE" }), + ) +} + fn namespace_from_path(path: &str) -> Option<&str> { path.strip_prefix("/v1/namespaces/")? .strip_suffix("/code") @@ -180,13 +294,14 @@ fn read_code(request: &mut Request) -> Result { fn agent_guide(namespace: &NamespaceState) -> String { format!( - r#"# Tessera Agent API + r##"# Tessera Agent API Current namespace: `{name}` Current revision: `{revision}` -Tessera stores code by namespace. It has no files. Submit code only; Tessera parses, -checks, compiles, and renders it. +Submit ordinary Endor functions. Tessera embeds Aster's frontend, compiler and VM. +The namespace comes from the request path. Declare a zero-argument `main` returning +a UI node. Type annotations are currently syntax only in Aster. ## Code CRUD @@ -196,42 +311,52 @@ checks, compiles, and renders it. Send PUT bodies as `text/plain; charset=utf-8`. -## Minimal syntax +```endor +{example}``` -```tessera -namespace {name} +## UI host object -use tessera.ui::{{Column, Row, Spacer, Text}} +Positional arguments only: -export scene Main {{ - render {{ - Column(gap: 12, padding: 24) {{ - Text("Hello", role: .title) - Text("Rendered by Tessera") - }} - }} -}} -``` +- `ui.text(value, size = 1, color = "#242422")`; integer size 1..8. +- `ui.rect(width, height, radius = 0, color = "#DCE8FF")`. +- `ui.column(padding, gap, child...)` and `ui.row(padding, gap, child...)`. +- `ui.spacer(size)`. -Supported by this syntax experiment: one exported scene, one render root, -`Column`, `Row`, `Spacer`, `Text`, `gap`, `padding`, and `.title`. -Compilation errors reject the update and preserve the last rendered revision. -"#, +Defaults above describe the host API; do not write keyword arguments in Endor. +Colors use #RRGGBB or #RRGGBBAA. Dimensions are finite numbers in 0..1000000. +Limits: 1 MiB source, 4096 constructed nodes, 64 UI levels, 10000 expanded layout +nodes, 16 KiB per text, 128 VM frames and 1000000 instructions per execution. + +PUT waits for its worker result. Errors return 422 with diagnostics and preserve +the last successful revision. A newer accepted PUT or DELETE supersedes older +work (409); a full eight-entry queue returns 503. DELETE immediately clears the +document. GET remains available while Aster executes. The window redraws from an +owned snapshot; resize does not rerun Endor. This first stage builds static UI; +events, persistent VM state, Chinese font rendering and process isolation remain +future work. It is not an untrusted-code sandbox. +"##, name = namespace.name, revision = namespace.revision, - address = API_ADDRESS + address = API_ADDRESS, + example = include_str!("../examples/hello.endor"), ) } -fn compile_error(message: String) -> Response>> { +fn compile_error(error: anyhow::Error) -> Response>> { + let mut diagnostic = + serde_json::json!({ "severity": "error", "message": format!("{error:#}") }); + if let Some(source) = error.downcast_ref::() { + diagnostic["line"] = source.location.line.into(); + diagnostic["column"] = source.location.column.into(); + diagnostic["start_byte"] = source.diagnostic.span.start.into(); + diagnostic["end_byte"] = source.diagnostic.span.end.into(); + } json( StatusCode(422), serde_json::json!({ "ok": false, - "diagnostics": [{ - "severity": "error", - "message": message - }] + "diagnostics": [diagnostic] }), ) } @@ -285,6 +410,154 @@ fn add_header(response: &mut Response>>, name: &str, val #[cfg(test)] mod tests { use super::*; + use std::{ + io::Write, + net::{SocketAddr, TcpStream}, + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, + }; + + struct TestServer { + server: Arc, + address: SocketAddr, + api: Option>, + } + + impl TestServer { + fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) { + let mut stream = TcpStream::connect(self.address).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(10))) + .unwrap(); + write!(stream, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + let (headers, body) = response.split_once("\r\n\r\n").unwrap(); + let status = headers.split_whitespace().nth(1).unwrap().parse().unwrap(); + (status, headers.to_owned(), body.to_owned()) + } + } + + impl Drop for TestServer { + fn drop(&mut self) { + self.server.unblock(); + if let Some(api) = self.api.take() { + let _ = api.join(); + } + } + } + + #[test] + fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() { + let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + let namespace = Arc::new(RwLock::new(NamespaceState::default())); + let notifications = Arc::new(AtomicUsize::new(0)); + let counter = notifications.clone(); + let notify: Notify = Arc::new(move || { + counter.fetch_add(1, Ordering::SeqCst); + }); + let api = start_server(server.clone(), namespace.clone(), notify).unwrap(); + let client = TestServer { + address: server.server_addr().to_ip().unwrap(), + server, + api: Some(api), + }; + let route = "/v1/namespaces/app.main/code"; + let example = include_str!("../examples/hello.endor"); + let (status, _, guide) = client.request("GET", "/agent", ""); + assert_eq!(status, 200); + assert!(guide.contains("function main(): View")); + let (status, _, body) = client.request("PUT", route, example); + assert_eq!(status, 200, "{body}"); + assert_eq!( + serde_json::from_str::(&body).unwrap()["revision"], + 1 + ); + let (status, headers, source) = client.request("GET", route, ""); + assert_eq!(status, 200); + assert!(headers.to_lowercase().contains("x-tessera-revision: 1")); + assert_eq!(source, example); + let (status, _, body) = + client.request("PUT", route, "function main(): View { return missing }"); + assert_eq!(status, 422, "{body}"); + assert_eq!( + serde_json::from_str::(&body).unwrap()["diagnostics"][0]["line"], + 1 + ); + assert_eq!(client.request("GET", route, "").2, example); + let second = example.replace("Hello Endor", "Second"); + assert_eq!(client.request("PUT", route, &second).0, 200); + assert_eq!(namespace.read().unwrap().revision, 2); + assert_eq!(client.request("DELETE", route, "").0, 200); + assert_eq!(client.request("GET", route, "").2, ""); + assert!(namespace.read().unwrap().document.frame.commands.is_empty()); + assert_eq!(client.request("PUT", route, example).0, 200); + assert_eq!(namespace.read().unwrap().revision, 4); + assert_eq!( + client + .request("PUT", "/v1/namespaces/other/code", example) + .0, + 404 + ); + assert_eq!(notifications.load(Ordering::SeqCst), 4); + } + + #[test] + fn http_delete_remains_available_and_invalidates_queued_put() { + use std::{ + sync::{Mutex, mpsc}, + time::Instant, + }; + let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + let namespace = Arc::new(RwLock::new(NamespaceState::default())); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let release_rx = Mutex::new(release_rx); + let calls = AtomicUsize::new(0); + let notify: Notify = Arc::new(move || { + if calls.fetch_add(1, Ordering::SeqCst) == 0 { + entered_tx.send(()).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); + } + }); + let api = start_server(server.clone(), namespace.clone(), notify).unwrap(); + let client = TestServer { + address: server.server_addr().to_ip().unwrap(), + server, + api: Some(api), + }; + // Unblock even if a test assertion fails, before scoped threads join. + struct Release(mpsc::Sender<()>); + impl Drop for Release { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + thread::scope(|scope| { + let release = Release(release_tx); + let client = &client; + let route = "/v1/namespaces/app.main/code"; + let example = include_str!("../examples/hello.endor"); + let first = scope.spawn(move || client.request("PUT", route, example)); + entered_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + let pending = scope.spawn(move || client.request("PUT", route, example)); + let deadline = Instant::now() + Duration::from_secs(10); + while namespace.read().unwrap().submission < 2 { + assert!(Instant::now() < deadline, "second PUT was not admitted"); + thread::sleep(Duration::from_millis(5)); + } + assert_eq!(client.request("GET", route, "").2, example); + assert_eq!(client.request("DELETE", route, "").0, 200); + drop(release); + assert_eq!(first.join().unwrap().0, 200); + assert_eq!(pending.join().unwrap().0, 409); + assert_eq!(client.request("GET", route, "").2, ""); + assert_eq!(namespace.read().unwrap().revision, 2); + }); + } #[test] fn extracts_namespace_only_from_code_routes() { @@ -300,6 +573,8 @@ mod tests { fn guide_contains_only_code_crud_routes() { let guide = agent_guide(&NamespaceState::default()); assert!(guide.contains("Code CRUD")); + assert!(guide.contains("function main(): View")); + assert!(!guide.contains("export scene")); assert!(guide.contains("PUT http://127.0.0.1:4737")); assert!(!guide.contains("display_list")); } diff --git a/src/compiler.rs b/src/compiler.rs deleted file mode 100644 index 679d74b..0000000 --- a/src/compiler.rs +++ /dev/null @@ -1,625 +0,0 @@ -use anyhow::{Result, bail}; - -use crate::document::{CodeNode, Frame, RenderCommand, RenderDocument}; - -pub fn compile(source: &str, expected_namespace: &str) -> Result { - let tokens = Lexer::new(source).tokenize()?; - let syntax = Parser::new(tokens).parse()?; - if syntax.namespace != expected_namespace { - bail!( - "namespace mismatch: API addresses `{expected_namespace}`, code declares `{}`", - syntax.namespace - ); - } - - let mut commands = Vec::new(); - layout_node(&syntax.root, 0.0, 0.0, &mut commands); - Ok(RenderDocument { - source: source.to_owned(), - root: Some(architecture(source, &syntax)), - frame: Frame { - background: "#F7F7F5".to_owned(), - commands, - }, - }) -} - -#[derive(Clone, Debug)] -struct SyntaxDocument { - namespace: String, - scene_name: String, - scene_span: Span, - render_span: Span, - root: SyntaxNode, -} - -#[derive(Clone, Debug)] -struct SyntaxNode { - name: String, - arguments: Vec, - children: Vec, - span: Span, -} - -#[derive(Clone, Debug)] -enum Argument { - Positional(Value), - Named(String, Value), -} - -#[derive(Clone, Debug)] -enum Value { - Text(String), - Number(f32), - Symbol(String), -} - -#[derive(Clone, Copy, Debug)] -struct Span { - start: usize, - end: usize, -} - -#[derive(Clone, Debug, PartialEq)] -enum TokenKind { - Identifier(String), - String(String), - Number(f32), - LeftBrace, - RightBrace, - LeftParen, - RightParen, - Colon, - Comma, - Dot, - End, -} - -#[derive(Clone, Debug)] -struct Token { - kind: TokenKind, - span: Span, -} - -struct Lexer<'a> { - source: &'a str, - offset: usize, -} - -impl<'a> Lexer<'a> { - fn new(source: &'a str) -> Self { - Self { source, offset: 0 } - } - - fn tokenize(mut self) -> Result> { - let mut tokens = Vec::new(); - while self.offset < self.source.len() { - self.skip_whitespace_and_comments(); - if self.offset >= self.source.len() { - break; - } - let start = self.offset; - let character = self.current_char().expect("offset is in bounds"); - let kind = match character { - '{' => self.single(TokenKind::LeftBrace), - '}' => self.single(TokenKind::RightBrace), - '(' => self.single(TokenKind::LeftParen), - ')' => self.single(TokenKind::RightParen), - ':' => self.single(TokenKind::Colon), - ',' => self.single(TokenKind::Comma), - '.' => self.single(TokenKind::Dot), - '"' => self.string()?, - value if value.is_ascii_digit() || value == '-' => self.number()?, - value if is_identifier_start(value) => self.identifier(), - _ => bail!("unexpected character `{character}` at byte {start}"), - }; - tokens.push(Token { - kind, - span: Span { - start, - end: self.offset, - }, - }); - } - tokens.push(Token { - kind: TokenKind::End, - span: Span { - start: self.source.len(), - end: self.source.len(), - }, - }); - Ok(tokens) - } - - fn current_char(&self) -> Option { - self.source[self.offset..].chars().next() - } - - fn advance(&mut self) -> Option { - let character = self.current_char()?; - self.offset += character.len_utf8(); - Some(character) - } - - fn single(&mut self, kind: TokenKind) -> TokenKind { - self.advance(); - kind - } - - fn skip_whitespace_and_comments(&mut self) { - loop { - while self.current_char().is_some_and(char::is_whitespace) { - self.advance(); - } - if self.source[self.offset..].starts_with("//") { - while self.current_char().is_some_and(|value| value != '\n') { - self.advance(); - } - } else { - break; - } - } - } - - fn identifier(&mut self) -> TokenKind { - let start = self.offset; - while self.current_char().is_some_and(is_identifier_continue) { - self.advance(); - } - TokenKind::Identifier(self.source[start..self.offset].to_owned()) - } - - fn number(&mut self) -> Result { - let start = self.offset; - if self.current_char() == Some('-') { - self.advance(); - } - while self - .current_char() - .is_some_and(|value| value.is_ascii_digit()) - { - self.advance(); - } - if self.current_char() == Some('.') { - self.advance(); - while self - .current_char() - .is_some_and(|value| value.is_ascii_digit()) - { - self.advance(); - } - } - Ok(TokenKind::Number(self.source[start..self.offset].parse()?)) - } - - fn string(&mut self) -> Result { - self.advance(); - let mut value = String::new(); - while let Some(character) = self.advance() { - match character { - '"' => return Ok(TokenKind::String(value)), - '\\' => match self.advance() { - Some('n') => value.push('\n'), - Some('t') => value.push('\t'), - Some('"') => value.push('"'), - Some('\\') => value.push('\\'), - Some(other) => bail!("unsupported string escape `\\{other}`"), - None => bail!("unterminated string escape"), - }, - other => value.push(other), - } - } - bail!("unterminated string literal") - } -} - -fn is_identifier_start(value: char) -> bool { - value == '_' || value.is_alphabetic() -} - -fn is_identifier_continue(value: char) -> bool { - value == '_' || value.is_alphanumeric() -} - -struct Parser { - tokens: Vec, - current: usize, -} - -impl Parser { - fn new(tokens: Vec) -> Self { - Self { tokens, current: 0 } - } - - fn parse(&mut self) -> Result { - self.expect_word("namespace")?; - let namespace = self.parse_path()?; - - while self.check_word("use") { - self.parse_use()?; - } - - self.expect_word("export")?; - let scene_start = self.peek().span.start; - self.expect_word("scene")?; - let scene_name = self.identifier()?; - self.expect(TokenKind::LeftBrace)?; - let render_start = self.peek().span.start; - self.expect_word("render")?; - self.expect(TokenKind::LeftBrace)?; - let root = self.parse_node()?; - let render_end = self.expect(TokenKind::RightBrace)?.span.end; - let scene_end = self.expect(TokenKind::RightBrace)?.span.end; - self.expect(TokenKind::End)?; - - Ok(SyntaxDocument { - namespace, - scene_name, - scene_span: Span { - start: scene_start, - end: scene_end, - }, - render_span: Span { - start: render_start, - end: render_end, - }, - root, - }) - } - - fn parse_path(&mut self) -> Result { - let mut path = self.identifier()?; - while self.matches(&TokenKind::Dot) { - path.push('.'); - path.push_str(&self.identifier()?); - } - Ok(path) - } - - fn parse_use(&mut self) -> Result<()> { - self.expect_word("use")?; - self.identifier()?; - while self.matches(&TokenKind::Dot) { - self.identifier()?; - } - if self.matches(&TokenKind::Colon) { - self.expect(TokenKind::Colon)?; - self.expect(TokenKind::LeftBrace)?; - if !self.check(&TokenKind::RightBrace) { - loop { - self.identifier()?; - if !self.matches(&TokenKind::Comma) { - break; - } - } - } - self.expect(TokenKind::RightBrace)?; - } - Ok(()) - } - - fn parse_node(&mut self) -> Result { - let start = self.peek().span.start; - let name = self.identifier()?; - let mut arguments = Vec::new(); - if self.matches(&TokenKind::LeftParen) { - if !self.check(&TokenKind::RightParen) { - loop { - if let TokenKind::Identifier(name) = &self.peek().kind - && self.peek_next().kind == TokenKind::Colon - { - let name = name.clone(); - self.advance(); - self.advance(); - arguments.push(Argument::Named(name, self.value()?)); - } else { - arguments.push(Argument::Positional(self.value()?)); - } - if !self.matches(&TokenKind::Comma) { - break; - } - } - } - self.expect(TokenKind::RightParen)?; - } - - let mut children = Vec::new(); - if self.matches(&TokenKind::LeftBrace) { - while !self.check(&TokenKind::RightBrace) { - children.push(self.parse_node()?); - } - self.expect(TokenKind::RightBrace)?; - } - let end = self.previous().span.end; - Ok(SyntaxNode { - name, - arguments, - children, - span: Span { start, end }, - }) - } - - fn value(&mut self) -> Result { - let token = self.advance().clone(); - match token.kind { - TokenKind::String(value) => Ok(Value::Text(value)), - TokenKind::Number(value) => Ok(Value::Number(value)), - TokenKind::Identifier(value) => Ok(Value::Symbol(value)), - TokenKind::Dot => Ok(Value::Symbol(format!(".{}", self.identifier()?))), - _ => bail!("expected a value at byte {}", token.span.start), - } - } - - fn identifier(&mut self) -> Result { - let token = self.advance().clone(); - match token.kind { - TokenKind::Identifier(value) => Ok(value), - _ => bail!("expected an identifier at byte {}", token.span.start), - } - } - - fn expect_word(&mut self, expected: &str) -> Result<()> { - let actual = self.identifier()?; - if actual != expected { - bail!("expected `{expected}`, found `{actual}`"); - } - Ok(()) - } - - fn expect(&mut self, expected: TokenKind) -> Result { - if self.check(&expected) { - return Ok(self.advance().clone()); - } - bail!( - "expected {}, found {} at byte {}", - token_name(&expected), - token_name(&self.peek().kind), - self.peek().span.start - ) - } - - fn matches(&mut self, expected: &TokenKind) -> bool { - if self.check(expected) { - self.advance(); - true - } else { - false - } - } - - fn check(&self, expected: &TokenKind) -> bool { - std::mem::discriminant(&self.peek().kind) == std::mem::discriminant(expected) - } - - fn check_word(&self, expected: &str) -> bool { - matches!(&self.peek().kind, TokenKind::Identifier(value) if value == expected) - } - - fn peek(&self) -> &Token { - &self.tokens[self.current] - } - - fn peek_next(&self) -> &Token { - &self.tokens[(self.current + 1).min(self.tokens.len() - 1)] - } - - fn previous(&self) -> &Token { - &self.tokens[self.current.saturating_sub(1)] - } - - fn advance(&mut self) -> &Token { - let index = self.current; - if self.current < self.tokens.len() - 1 { - self.current += 1; - } - &self.tokens[index] - } -} - -fn token_name(token: &TokenKind) -> &'static str { - match token { - TokenKind::Identifier(_) => "identifier", - TokenKind::String(_) => "string", - TokenKind::Number(_) => "number", - TokenKind::LeftBrace => "`{`", - TokenKind::RightBrace => "`}`", - TokenKind::LeftParen => "`(`", - TokenKind::RightParen => "`)`", - TokenKind::Colon => "`:`", - TokenKind::Comma => "`,`", - TokenKind::Dot => "`.`", - TokenKind::End => "end of code", - } -} - -fn architecture(source: &str, syntax: &SyntaxDocument) -> CodeNode { - CodeNode { - id: format!("{}::{}", syntax.namespace, syntax.scene_name), - name: syntax.scene_name.clone(), - kind: "scene".to_owned(), - start_line: line_number(source, syntax.scene_span.start), - end_line: line_number(source, syntax.scene_span.end), - children: vec![CodeNode { - id: format!("{}::{}::render", syntax.namespace, syntax.scene_name), - name: "render".to_owned(), - kind: "render".to_owned(), - start_line: line_number(source, syntax.render_span.start), - end_line: line_number(source, syntax.render_span.end), - children: vec![node_architecture( - source, - &syntax.namespace, - &syntax.scene_name, - &syntax.root, - "0", - )], - }], - } -} - -fn node_architecture( - source: &str, - namespace: &str, - scene: &str, - node: &SyntaxNode, - path: &str, -) -> CodeNode { - CodeNode { - id: format!("{namespace}::{scene}::render::{path}"), - name: node.name.clone(), - kind: node.name.to_lowercase(), - start_line: line_number(source, node.span.start), - end_line: line_number(source, node.span.end), - children: node - .children - .iter() - .enumerate() - .map(|(index, child)| { - node_architecture(source, namespace, scene, child, &format!("{path}.{index}")) - }) - .collect(), - } -} - -fn line_number(source: &str, offset: usize) -> usize { - source[..offset.min(source.len())] - .bytes() - .filter(|byte| *byte == b'\n') - .count() - + 1 -} - -#[derive(Clone, Copy, Default)] -struct Size { - width: f32, - height: f32, -} - -fn layout_node(node: &SyntaxNode, x: f32, y: f32, commands: &mut Vec) -> Size { - match node.name.as_str() { - "Text" => layout_text(node, x, y, commands), - "Row" => layout_row(node, x, y, commands), - "Spacer" => Size { - width: number_argument(node, "size").unwrap_or(0.0), - height: number_argument(node, "size").unwrap_or(0.0), - }, - _ => layout_column(node, x, y, commands), - } -} - -fn layout_text(node: &SyntaxNode, x: f32, y: f32, commands: &mut Vec) -> Size { - let value = node.arguments.iter().find_map(|argument| match argument { - Argument::Positional(Value::Text(value)) => Some(value.clone()), - _ => None, - }); - let value = value.unwrap_or_default(); - let size = match named_argument(node, "role") { - Some(Value::Symbol(value)) if value == ".title" => 2, - _ => 1, - }; - commands.push(RenderCommand::Text { - x, - y, - value: value.clone(), - size, - color: "#242422".to_owned(), - }); - Size { - width: value.chars().count() as f32 * 8.0 * size as f32, - height: 8.0 * size as f32, - } -} - -fn layout_column(node: &SyntaxNode, x: f32, y: f32, commands: &mut Vec) -> Size { - let padding = number_argument(node, "padding").unwrap_or(0.0); - let gap = number_argument(node, "gap").unwrap_or(0.0); - let mut cursor_y = y + padding; - let mut maximum_width: f32 = 0.0; - for (index, child) in node.children.iter().enumerate() { - if index > 0 { - cursor_y += gap; - } - let size = layout_node(child, x + padding, cursor_y, commands); - cursor_y += size.height; - maximum_width = maximum_width.max(size.width); - } - Size { - width: maximum_width + padding * 2.0, - height: cursor_y - y + padding, - } -} - -fn layout_row(node: &SyntaxNode, x: f32, y: f32, commands: &mut Vec) -> Size { - let padding = number_argument(node, "padding").unwrap_or(0.0); - let gap = number_argument(node, "gap").unwrap_or(0.0); - let mut cursor_x = x + padding; - let mut maximum_height: f32 = 0.0; - for (index, child) in node.children.iter().enumerate() { - if index > 0 { - cursor_x += gap; - } - let size = layout_node(child, cursor_x, y + padding, commands); - cursor_x += size.width; - maximum_height = maximum_height.max(size.height); - } - Size { - width: cursor_x - x + padding, - height: maximum_height + padding * 2.0, - } -} - -fn number_argument(node: &SyntaxNode, name: &str) -> Option { - match named_argument(node, name) { - Some(Value::Number(value)) => Some(*value), - _ => None, - } -} - -fn named_argument<'a>(node: &'a SyntaxNode, name: &str) -> Option<&'a Value> { - node.arguments.iter().find_map(|argument| match argument { - Argument::Named(argument_name, value) if argument_name == name => Some(value), - _ => None, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - const EXAMPLE: &str = r#"namespace app.main - -use tessera.ui::{Column, Text} - -export scene Main { - render { - Column(gap: 12, padding: 24) { - Text("Hello", role: .title) - Text("Rendered by Tessera") - } - } -}"#; - - #[test] - fn compiles_baseline_render_syntax() { - let document = compile(EXAMPLE, "app.main").unwrap(); - assert_eq!(document.frame.commands.len(), 2); - assert_eq!(document.root.as_ref().unwrap().name, "Main"); - assert_eq!( - document.root.as_ref().unwrap().children[0].children[0] - .children - .len(), - 2 - ); - } - - #[test] - fn rejects_namespace_mismatch() { - let error = compile(EXAMPLE, "app.other").unwrap_err(); - assert!(error.to_string().contains("namespace mismatch")); - } - - #[test] - fn reports_incomplete_syntax() { - let error = compile("namespace app.main", "app.main").unwrap_err(); - assert!(error.to_string().contains("expected")); - } -} diff --git a/src/developer_tools.rs b/src/developer_tools.rs index c47e022..0d7cfe4 100644 --- a/src/developer_tools.rs +++ b/src/developer_tools.rs @@ -216,7 +216,8 @@ fn draw_source( return; } - let all_lines: Vec<_> = document.source.lines().collect(); + let normalized_source = document.source.replace("\r\n", "\n").replace('\r', "\n"); + let all_lines: Vec<_> = normalized_source.lines().collect(); let selected = selected_id.and_then(|id| document.node(id)); let start = selected .map(|node| node.start_line.saturating_sub(1)) diff --git a/src/document.rs b/src/document.rs index bd932fd..b6d6ef9 100644 --- a/src/document.rs +++ b/src/document.rs @@ -1,11 +1,13 @@ use std::collections::HashSet; +use std::sync::Arc; -use anyhow::{Result, bail}; +use anyhow::{Context as _, Result, bail}; use serde::{Deserialize, Serialize}; use crate::{ display_list::{Color, DisplayList, DrawCommand}, geometry::{Point, Rect}, + ui_bridge::UiNode, }; pub const DEFAULT_NAMESPACE: &str = "app.main"; @@ -14,6 +16,7 @@ pub const DEFAULT_NAMESPACE: &str = "app.main"; pub struct NamespaceState { pub name: String, pub revision: u64, + pub submission: u64, pub document: RenderDocument, } @@ -22,13 +25,49 @@ impl Default for NamespaceState { Self { name: DEFAULT_NAMESPACE.to_owned(), revision: 0, + submission: 0, document: RenderDocument::default(), } } } +impl NamespaceState { + pub fn next_submission(&self) -> Result { + self.submission + .checked_add(1) + .context("submission counter exhausted") + } + + pub fn commit(&mut self, submission: u64, document: RenderDocument) -> Result> { + if submission != self.submission { + return Ok(None); + } + let revision = self + .revision + .checked_add(1) + .context("revision counter exhausted")?; + self.document = document; + self.revision = revision; + Ok(Some(revision)) + } + + pub fn clear(&mut self) -> Result { + let submission = self.next_submission()?; + let revision = self + .revision + .checked_add(1) + .context("revision counter exhausted")?; + self.submission = submission; + self.document = RenderDocument::default(); + self.revision = revision; + Ok(revision) + } +} + #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct RenderDocument { + #[serde(skip)] + pub ui_root: Option>, #[serde(default)] pub source: String, #[serde(default)] @@ -59,9 +98,15 @@ impl RenderDocument { } pub fn display_list(&self, scale: f32) -> Result { + let relayout = self + .ui_root + .as_deref() + .map(crate::layout::frame) + .transpose()?; + let frame = relayout.as_ref().unwrap_or(&self.frame); let mut list = DisplayList::default(); - list.push(DrawCommand::Clear(parse_color(&self.frame.background)?)); - for command in &self.frame.commands { + list.push(DrawCommand::Clear(parse_color(&frame.background)?)); + for command in &frame.commands { list.push(command.to_draw_command(scale)?); } Ok(list) @@ -288,7 +333,7 @@ pub fn parse_color(value: &str) -> Result { let hex = value .strip_prefix('#') .ok_or_else(|| anyhow::anyhow!("color must begin with #"))?; - if hex.len() != 6 && hex.len() != 8 { + if !hex.is_ascii() || (hex.len() != 6 && hex.len() != 8) { bail!("color must use #RRGGBB or #RRGGBBAA"); } let parse = |range| u8::from_str_radix(&hex[range], 16); diff --git a/src/endor_host.rs b/src/endor_host.rs new file mode 100644 index 0000000..2376113 --- /dev/null +++ b/src/endor_host.rs @@ -0,0 +1,470 @@ +//! One source submission uses a fresh Aster VM and publishes an owned snapshot. +use anyhow::{Result, bail}; +use endor_core::{ExecutionLimits, ObjectRef, Value, Vm, VmError}; +use frontend::{ + EndorReader, ParseMode, SourceUnit, Span, + ast::{AstRoot, Decl, DeclKind, Expr, ExprKind, Spanned, Stmt, StmtKind, visitor::Visitor}, +}; + +use crate::{ + document::{CodeNode, RenderDocument}, + layout, ui_bridge, +}; + +pub const MAX_SOURCE_BYTES: usize = 1_048_576; +const LIMITS: ExecutionLimits = ExecutionLimits { + max_frames: 128, + max_instructions: 1_000_000, +}; +const MAX_AST_NODES: usize = 10_000; +const MAX_AST_DEPTH: usize = 64; + +pub fn compile(source: &str, namespace: &str, submission: u64) -> Result { + if source.len() > MAX_SOURCE_BYTES { + bail!("source exceeds the 1 MiB limit"); + } + let source = EndorReader::from_text(format!("{namespace} [submission {submission}]"), source); + let ast = frontend::parse_source_ast(&source, ParseMode::Module)?; + AstBudget::default().visit_ast_root(&ast)?; + let compiled = compiler::compile_ast( + &ast, + &source, + &compiler::CompileOptions { + globals: vec!["ui".into()], + }, + ) + .map_err(|error| source.annotate(error))?; + let root = architecture(&source, &ast, namespace); + let mut candidate = Candidate::new()?; + let result: Result = (|| { + let globals = candidate.vm.new_dict()?; + candidate.globals = Some(globals); + candidate.roots.push(globals); + let ui = ui_bridge::install(&mut candidate.vm, &mut candidate.roots)?; + candidate + .vm + .dict_set(globals, Value::Text("ui".into()), Value::Object(ui))?; + let code = compiled.load(&mut candidate.vm)?; + candidate.roots.push(code); + let initialized = candidate + .vm + .execute_with_limits(code, globals, LIMITS) + .map_err(|error| runtime_error(&candidate.vm, error))?; + candidate.vm.release_value(initialized)?; + let entry = candidate + .vm + .dict_get(globals, &Value::Text("main".into()))? + .ok_or_else(|| anyhow::anyhow!("source must declare function main(): View"))?; + let Value::Object(entry) = entry else { + bail!("main must be a callable function"); + }; + let returned = candidate + .vm + .call_with_limits(entry, &[], LIMITS) + .map_err(|error| runtime_error(&candidate.vm, error))?; + let snapshot = ui_bridge::snapshot(&candidate.vm, &returned); + candidate.vm.release_value(returned)?; + let ui_root = snapshot?; + let document = RenderDocument { + source: source.text().to_owned(), + root: Some(root), + frame: layout::frame(&ui_root)?, + ui_root: Some(ui_root), + }; + document.validate()?; + Ok(document) + })(); + let cleanup = candidate.close(); + match (result, cleanup) { + (Ok(document), Ok(())) => Ok(document), + (Err(error), Ok(())) | (Ok(_), Err(error)) => Err(error), + (Err(error), Err(cleanup)) => Err(error.context(format!("candidate cleanup: {cleanup}"))), + } +} + +struct Candidate { + vm: Vm, + roots: Vec, + globals: Option, +} + +impl Candidate { + fn new() -> Result { + Ok(Self { + vm: Vm::new()?, + roots: Vec::new(), + globals: None, + }) + } + + fn close(&mut self) -> Result<()> { + if let Some(globals) = self.globals.take() { + self.vm.dict_clear(globals)?; + } + while let Some(root) = self.roots.pop() { + self.vm.release(root)?; + } + self.vm.collect_cycles(); + if self.vm.object_count() != 0 { + bail!("candidate VM retained objects after cleanup"); + } + Ok(()) + } +} + +impl Drop for Candidate { + fn drop(&mut self) { + // Explicit close reports accounting errors; dropping the fresh VM is + // also the final cleanup boundary on every early return or unwind. + let _ = self.close(); + } +} + +fn runtime_error(vm: &Vm, error: VmError) -> anyhow::Error { + let locations = vm + .last_traceback() + .iter() + .map(|frame| { + format!( + "{}:{} in {} (instruction {})", + frame.filename, frame.first_line, frame.function, frame.instruction_offset + ) + }) + .collect::>() + .join("\n"); + if locations.is_empty() { + error.into() + } else { + anyhow::anyhow!("{error}\n{locations}") + } +} + +#[derive(Default)] +struct AstBudget { + depth: usize, + nodes: usize, +} + +impl Visitor for AstBudget { + type Error = anyhow::Error; + fn enter_node(&mut self, _span: Span) -> Result<()> { + self.depth += 1; + self.nodes += 1; + if self.depth > MAX_AST_DEPTH || self.nodes > MAX_AST_NODES { + bail!("Aster AST exceeds the 64-level or 10000-node host budget"); + } + Ok(()) + } + fn leave_node(&mut self) { + self.depth -= 1; + } +} + +fn architecture(source: &SourceUnit, ast: &AstRoot, namespace: &str) -> CodeNode { + let mut tree = Architecture { + line_starts: line_starts(source.text()), + next_id: 0, + }; + let AstRoot::Module(program) = ast else { + unreachable!("module parse mode"); + }; + let children = program + .declarations + .iter() + .map(|decl| tree.declaration(decl)) + .collect(); + tree.node(namespace.to_owned(), "module", ast.span(), children) +} + +fn line_starts(source: &str) -> Vec { + let bytes = source.as_bytes(); + let mut starts = vec![0]; + for (offset, byte) in bytes.iter().enumerate() { + if *byte == b'\n' || (*byte == b'\r' && bytes.get(offset + 1) != Some(&b'\n')) { + starts.push(offset + 1); + } + } + starts +} + +struct Architecture { + line_starts: Vec, + next_id: usize, +} + +impl Architecture { + fn node(&mut self, name: String, kind: &str, span: Span, children: Vec) -> CodeNode { + let id = self.next_id; + self.next_id += 1; + let line = |offset| self.line_starts.partition_point(|start| *start <= offset); + CodeNode { + id: format!("ast-{id}"), + name, + kind: kind.into(), + start_line: line(span.start), + end_line: line(span.end.saturating_sub(1).max(span.start)), + children, + } + } + + fn declaration(&mut self, declaration: &Decl) -> CodeNode { + let DeclKind::Function(function) = &declaration.kind; + let children = function + .body + .statements + .iter() + .map(|stmt| self.statement(stmt)) + .collect(); + self.node( + function.name.text.clone(), + "function", + function.span, + children, + ) + } + + fn statement(&mut self, statement: &Stmt) -> CodeNode { + match &statement.kind { + StmtKind::Binding(binding) => { + let child = self.expression(&binding.value); + self.node( + binding.name.text.clone(), + "let", + statement.span, + vec![child], + ) + } + StmtKind::Return(returned) => { + let children = returned + .value + .iter() + .map(|expr| self.expression(expr)) + .collect(); + self.node("return".into(), "return", statement.span, children) + } + StmtKind::Expression(expr) => self.expression(expr), + StmtKind::Declaration(decl) => self.declaration(decl), + } + } + + fn expression(&mut self, expr: &Expr) -> CodeNode { + let (name, kind, children) = match &expr.kind { + ExprKind::Name(name) => (name.clone(), "name", vec![]), + ExprKind::Number(value) => (value.clone(), "number", vec![]), + ExprKind::String(value) => (value.chars().take(40).collect(), "string", vec![]), + ExprKind::Symbol(name) => (name.text.clone(), "symbol", vec![]), + ExprKind::Unary { operand, .. } => { + ("negate".into(), "unary", vec![self.expression(operand)]) + } + ExprKind::Binary { left, right, .. } => ( + "binary".into(), + "binary", + vec![self.expression(left), self.expression(right)], + ), + ExprKind::Member { object, name } => { + (name.text.clone(), "member", vec![self.expression(object)]) + } + ExprKind::Call { callee, arguments } => { + let mut children = vec![self.expression(callee)]; + children.extend(arguments.iter().map(|expr| self.expression(expr))); + ("call".into(), "call", children) + } + }; + self.node(name, kind, expr.span, children) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::{NamespaceState, RenderCommand}; + + const EXAMPLE: &str = include_str!("../examples/hello.endor"); + + #[test] + fn executes_aster_functions_and_renders_after_vm_cleanup() { + let document = compile(EXAMPLE, "app.main", 1).unwrap(); + assert_eq!(document.source, EXAMPLE); + let tree = document.root.as_ref().unwrap(); + assert_eq!(tree.kind, "module"); + assert_eq!(tree.name, "app.main"); + assert_eq!(tree.children[0].name, "heading"); + assert_eq!(tree.children[1].name, "main"); + assert_eq!(tree.children[1].start_line, 5); + assert_eq!(document.frame.commands.len(), 4); + assert!( + matches!(&document.frame.commands[0], RenderCommand::Text { value, size: 2, x: 24.0, y: 24.0, .. } if value == "Hello Endor") + ); + assert!(matches!( + &document.frame.commands[1], + RenderCommand::RoundedRect { + x: 24.0, + y: 52.0, + width: 240.0, + height: 80.0, + .. + } + )); + assert!(matches!( + &document.frame.commands[3], + RenderCommand::Text { + x: 120.0, + y: 144.0, + .. + } + )); + // Candidate::close already proved zero remaining VM objects. Both + // redraws must still work from native data at different display scales. + assert_eq!(document.display_list(1.0).unwrap().commands().len(), 5); + assert_eq!(document.display_list(2.0).unwrap().commands().len(), 5); + } + + #[test] + fn borrowed_ui_handles_survive_variables_helpers_and_reused_children() { + let source = r#"function wrap(child: View): View { return ui.row(4, 8, child, child) } +function main(): View { let child = ui.text("shared") return wrap(child) }"#; + let document = compile(source, "arbitrary.namespace", 3).unwrap(); + assert_eq!(document.frame.commands.len(), 2); + assert!(matches!( + &document.frame.commands[0], + RenderCommand::Text { x: 4.0, y: 4.0, .. } + )); + assert!(matches!( + &document.frame.commands[1], + RenderCommand::Text { + x: 60.0, + y: 4.0, + .. + } + )); + } + + #[test] + fn architecture_ranges_follow_crlf_and_cr_source_snapshots() { + for ending in ["\r\n", "\r"] { + let source = EXAMPLE.replace('\n', ending); + let document = compile(&source, "app.main", 1).unwrap(); + assert_eq!(document.source, source); + assert_eq!(document.root.unwrap().children[1].start_line, 5); + } + } + + #[test] + fn rejects_invalid_entry_objects_arguments_and_old_syntax() { + for source in [ + "namespace app.main export scene Main { render { Text(\"old\") } }", + "function helper(): View { return ui.text(\"missing main\") }", + "function main(value: View): View { return value }", + "function main(): View { return \"wrong root\" }", + "function main(): View { return main }", + "function main(): View { return ui.text(1) }", + "function main(): View { return ui.text(\"x\", 1.0) }", + "function main(): View { return ui.text(\"x\", 9) }", + "function main(): View { return ui.rect(-1, 1) }", + "function main(): View { return ui.rect(1000001, 1) }", + "function main(): View { return ui.rect(1, 1, 0, \"#中中\") }", + "function main(): View { return ui.column(0, 0, ui.text) }", + "function main(): View { return unknown(1) }", + "function main(): View { return ui.missing() }", + ] { + assert!( + compile(source, "app.main", 1).is_err(), + "accepted: {source}" + ); + } + } + + #[test] + fn source_diagnostics_keep_unicode_locations_and_submission_origin() { + let error = compile( + "function main(): View {\n let title = ui.text(\"中文\")\n return missing\n}", + "app.main", + 17, + ) + .unwrap_err(); + let error = error.downcast_ref::().unwrap(); + assert_eq!(error.location.line, 3); + assert!(error.origin.to_string().contains("submission 17")); + } + + #[test] + fn recursive_program_stops_at_the_vm_frame_limit() { + let error = compile("function main(): View { return main() }", "app.main", 1).unwrap_err(); + assert!( + error + .to_string() + .contains("execution limit reached: frames") + ); + } + + fn nested_functions(levels: usize, shared: bool) -> String { + let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned(); + for index in 1..=levels { + let previous = index - 1; + if shared { + source.push_str(&format!("function node{index}(): View {{ let child = node{previous}() return ui.column(0, 0, child, child) }}\n")); + } else { + source.push_str(&format!("function node{index}(): View {{ return ui.column(0, 0, node{previous}(), node{previous}()) }}\n")); + } + } + source.push_str(&format!( + "function main(): View {{ return node{levels}() }}" + )); + source + } + + #[test] + fn construction_and_expanded_layout_have_separate_budgets() { + let error = compile(&nested_functions(12, false), "app.main", 1).unwrap_err(); + assert!(error.to_string().contains("4096 node budget"), "{error:#}"); + let error = compile(&nested_functions(14, true), "app.main", 2).unwrap_err(); + assert!( + error.to_string().contains("expanded UI layout"), + "{error:#}" + ); + } + + #[test] + fn ui_depth_is_bounded_independently_of_call_frames() { + let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned(); + for index in 1..=64 { + source.push_str(&format!( + "function node{index}(): View {{ return ui.column(0, 0, node{}()) }}\n", + index - 1 + )); + } + source.push_str("function main(): View { return node64() }"); + let error = compile(&source, "app.main", 1).unwrap_err(); + assert!(error.to_string().contains("64 levels"), "{error:#}"); + } + + #[test] + fn failed_updates_preserve_success_and_delete_invalidates_inflight_work() { + let mut state = NamespaceState::default(); + state.submission = state.next_submission().unwrap(); + assert_eq!( + state + .commit(state.submission, compile(EXAMPLE, "app.main", 1).unwrap()) + .unwrap(), + Some(1) + ); + state.submission = state.next_submission().unwrap(); + assert!(compile("invalid", "app.main", state.submission).is_err()); + assert_eq!(state.revision, 1); + assert_eq!(state.document.source, EXAMPLE); + let stale = state.submission; + let candidate = compile(EXAMPLE, "app.main", stale).unwrap(); + state.submission = state.next_submission().unwrap(); + assert!(state.commit(stale, candidate.clone()).unwrap().is_none()); + let deleted = state.clear().unwrap(); + assert!(state.commit(stale, candidate).unwrap().is_none()); + assert_eq!(state.revision, deleted); + assert!(state.document.source.is_empty()); + state.submission = state.next_submission().unwrap(); + let candidate = compile(EXAMPLE, "app.main", state.submission).unwrap(); + assert_eq!( + state.commit(state.submission, candidate).unwrap(), + Some(deleted + 1) + ); + } +} diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..69c0b6e --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,125 @@ +//! Layout consumes only owned native UI data, after the candidate VM is gone. +use anyhow::{Result, bail}; + +use crate::{ + document::{Frame, RenderCommand}, + ui_bridge::{MAX_DIMENSION, UiKind, UiNode}, +}; + +const MAX_LAYOUT_VISITS: usize = 10_000; + +#[derive(Default, Clone, Copy)] +struct Size { + width: f32, + height: f32, +} + +pub fn frame(root: &UiNode) -> Result { + let mut frame = Frame::default(); + let mut visits = 0; + layout(root, 0.0, 0.0, &mut frame.commands, &mut visits)?; + Ok(frame) +} + +fn bounded(value: f32) -> Result { + if !value.is_finite() || !(0.0..=MAX_DIMENSION as f32).contains(&value) { + bail!("UI layout exceeds the 1000000 coordinate/size limit"); + } + Ok(value) +} + +fn layout( + node: &UiNode, + x: f32, + y: f32, + commands: &mut Vec, + visits: &mut usize, +) -> Result { + *visits += 1; + if *visits > MAX_LAYOUT_VISITS { + bail!("expanded UI layout exceeds 10000 nodes"); + } + bounded(x)?; + bounded(y)?; + let size = match &node.kind { + UiKind::Text { value, size, color } => { + commands.push(RenderCommand::Text { + x, + y, + value: value.clone(), + size: *size, + color: color.clone(), + }); + Size { + width: value.chars().count() as f32 * 8.0 * *size as f32, + height: 8.0 * *size as f32, + } + } + UiKind::Rect { + width, + height, + radius, + color, + } => { + commands.push(RenderCommand::RoundedRect { + x, + y, + width: *width, + height: *height, + radius: *radius, + color: color.clone(), + }); + Size { + width: *width, + height: *height, + } + } + UiKind::Spacer(size) => Size { + width: *size, + height: *size, + }, + UiKind::Column { + padding, + gap, + children, + } + | UiKind::Row { + padding, + gap, + children, + } => { + let row = matches!(node.kind, UiKind::Row { .. }); + let mut cursor = *padding; + let mut cross: f32 = 0.0; + for (index, child) in children.iter().enumerate() { + if index > 0 { + cursor = bounded(cursor + gap)?; + } + let (child_x, child_y) = if row { + (x + cursor, y + padding) + } else { + (x + padding, y + cursor) + }; + let size = layout(child, child_x, child_y, commands, visits)?; + cursor = bounded(cursor + if row { size.width } else { size.height })?; + cross = cross.max(if row { size.height } else { size.width }); + } + let along = bounded(cursor + padding)?; + let across = bounded(cross + padding * 2.0)?; + if row { + Size { + width: along, + height: across, + } + } else { + Size { + width: across, + height: along, + } + } + } + }; + bounded(x + size.width)?; + bounded(y + size.height)?; + Ok(size) +} diff --git a/src/main.rs b/src/main.rs index a9db696..bc6ec7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,14 @@ mod api; mod app; -mod compiler; mod developer_tools; mod display_list; mod document; +mod endor_host; mod geometry; +mod layout; mod scene; mod software_renderer; +mod ui_bridge; use std::sync::{Arc, RwLock}; diff --git a/src/ui_bridge.rs b/src/ui_bridge.rs new file mode 100644 index 0000000..e12b941 --- /dev/null +++ b/src/ui_bridge.rs @@ -0,0 +1,303 @@ +//! Tessera-owned UI payloads using Aster's existing embedding interface. +use std::{ + any::Any, + collections::HashMap, + sync::{Arc, Mutex, OnceLock}, +}; + +use anyhow::{Result, bail}; +use endor_core::{EndorReal, ObjectPayload, ObjectRef, Value, Vm, VmError}; + +use crate::document::parse_color; + +pub const MAX_UI_NODES: usize = 4096; +pub const MAX_UI_DEPTH: usize = 64; +pub const MAX_DIMENSION: f64 = 1_000_000.0; + +#[derive(Debug)] +pub struct UiNode { + pub kind: UiKind, + depth: usize, +} + +#[derive(Debug)] +pub enum UiKind { + Text { + value: String, + size: u32, + color: String, + }, + Rect { + width: f32, + height: f32, + radius: f32, + color: String, + }, + Column { + padding: f32, + gap: f32, + children: Vec>, + }, + Row { + padding: f32, + gap: f32, + children: Vec>, + }, + Spacer(f32), +} + +#[derive(Debug)] +struct NodePayload(UiSlot); + +type UiSlot = Arc>>; + +impl ObjectPayload for NodePayload { + fn as_any(&self) -> &dyn Any { + self + } + fn kind_name(&self) -> &'static str { + "tessera-ui-node" + } +} + +#[derive(Debug)] +struct BuildSession { + // Borrowed handles only. Candidate owns every pool root until extraction + // and execution have finished. Native child edges contain Arc, + // never VM handles, so payload-owned GC edges remain empty and immutable. + pool: Vec<(ObjectRef, UiSlot)>, + indexes: HashMap, + used: usize, +} + +impl BuildSession { + fn child(&self, value: &Value) -> Result, VmError> { + let Value::Object(reference) = value else { + return Err(VmError::InvalidArgument("UI children must be UI nodes")); + }; + self.indexes + .get(reference) + .and_then(|index| self.pool[*index].1.get()) + .cloned() + .ok_or(VmError::InvalidArgument( + "UI children must be initialized nodes from this build", + )) + } + + fn emit(&mut self, kind: UiKind) -> Result { + let depth = match &kind { + UiKind::Column { children, .. } | UiKind::Row { children, .. } => { + children.iter().map(|node| node.depth).max().unwrap_or(0) + 1 + } + _ => 1, + }; + if depth > MAX_UI_DEPTH { + return Err(VmError::InvalidArgument("UI tree exceeds 64 levels")); + } + let Some((reference, slot)) = self.pool.get(self.used) else { + return Err(VmError::InvalidArgument( + "UI build exceeds the 4096 node budget", + )); + }; + slot.set(Arc::new(UiNode { kind, depth })) + .map_err(|_| VmError::InvalidArgument("UI node initialized twice"))?; + self.used += 1; + Ok(Value::Object(*reference)) + } +} + +#[derive(Debug, Clone, Copy)] +enum Constructor { + Text, + Rect, + Column, + Row, + Spacer, +} + +impl Constructor { + fn name(self) -> &'static str { + match self { + Self::Text => "text", + Self::Rect => "rect", + Self::Column => "column", + Self::Row => "row", + Self::Spacer => "spacer", + } + } +} + +#[derive(Debug)] +struct UiConstructor { + constructor: Constructor, + session: Arc>, +} + +impl ObjectPayload for UiConstructor { + fn as_any(&self) -> &dyn Any { + self + } + fn kind_name(&self) -> &'static str { + "tessera-ui-constructor" + } + + fn call(&self, arguments: &[Value]) -> Result { + let constructor = self.constructor; + let (required, maximum) = match constructor { + Constructor::Text => (1, 3), + Constructor::Rect => (2, 4), + Constructor::Column | Constructor::Row => (2, MAX_UI_NODES + 2), + Constructor::Spacer => (1, 1), + }; + if !(required..=maximum).contains(&arguments.len()) { + return Err(VmError::ArgumentCount { + name: format!("ui.{}", constructor.name()), + required, + maximum, + provided: arguments.len(), + }); + } + let mut session = self + .session + .lock() + .map_err(|_| VmError::InvalidArgument("UI build session is poisoned"))?; + let kind = match constructor { + Constructor::Text => { + let Value::Text(value) = &arguments[0] else { + return Err(VmError::InvalidArgument("ui.text expects a string")); + }; + if value.len() > 16_384 { + return Err(VmError::InvalidArgument("UI text exceeds 16 KiB")); + } + let size = if let Some(value) = arguments.get(1) { + let Value::Number(number) = value else { + return Err(VmError::InvalidArgument( + "text size must be an integer from 1 to 8", + )); + }; + number + .as_i64() + .filter(|size| (1..=8).contains(size)) + .ok_or(VmError::InvalidArgument( + "text size must be an integer from 1 to 8", + ))? as u32 + } else { + 1 + }; + UiKind::Text { + value: value.clone(), + size, + color: color(arguments.get(2), "#242422")?, + } + } + Constructor::Rect => UiKind::Rect { + width: dimension(&arguments[0])?, + height: dimension(&arguments[1])?, + radius: arguments.get(2).map(dimension).transpose()?.unwrap_or(0.0), + color: color(arguments.get(3), "#DCE8FF")?, + }, + Constructor::Column | Constructor::Row => { + let padding = dimension(&arguments[0])?; + let gap = dimension(&arguments[1])?; + let children = arguments[2..] + .iter() + .map(|value| session.child(value)) + .collect::, _>>()?; + if matches!(constructor, Constructor::Column) { + UiKind::Column { + padding, + gap, + children, + } + } else { + UiKind::Row { + padding, + gap, + children, + } + } + } + Constructor::Spacer => UiKind::Spacer(dimension(&arguments[0])?), + }; + session.emit(kind) + } +} + +fn color(value: Option<&Value>, default: &str) -> Result { + let value = match value { + Some(Value::Text(value)) => value.as_str(), + None => default, + _ => return Err(VmError::InvalidArgument("UI color must be a string")), + }; + parse_color(value) + .map_err(|_| VmError::InvalidArgument("UI color must use #RRGGBB or #RRGGBBAA"))?; + Ok(value.to_owned()) +} + +fn dimension(value: &Value) -> Result { + let invalid = + || VmError::InvalidArgument("UI dimensions must be finite numbers between 0 and 1000000"); + let Value::Number(number) = value else { + return Err(invalid()); + }; + // Conversion belongs to the UI boundary. Aster's exact Number is unchanged. + let value = match number.as_real() { + Some(EndorReal::Integer(value)) => { + value.to_string().parse::().map_err(|_| invalid())? + } + Some(EndorReal::Decimal(value)) => value.literal().parse::().map_err(|_| invalid())?, + Some(EndorReal::BinaryFloat(bits)) => f64::from_bits(*bits), + _ => return Err(invalid()), + }; + if !value.is_finite() || !(0.0..=MAX_DIMENSION).contains(&value) { + return Err(invalid()); + } + Ok(value as f32) +} + +/// All allocation roots go to the candidate, including partial installation. +pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { + let mut pool = Vec::with_capacity(MAX_UI_NODES); + let mut indexes = HashMap::with_capacity(MAX_UI_NODES); + for index in 0..MAX_UI_NODES { + let slot = Arc::new(OnceLock::new()); + let reference = vm.allocate(NodePayload(slot.clone()))?; + roots.push(reference); + indexes.insert(reference, index); + pool.push((reference, slot)); + } + let session = Arc::new(Mutex::new(BuildSession { + pool, + indexes, + used: 0, + })); + let ui = vm.new_object()?; + roots.push(ui); + for constructor in [ + Constructor::Text, + Constructor::Rect, + Constructor::Column, + Constructor::Row, + Constructor::Spacer, + ] { + let function = vm.allocate(UiConstructor { + constructor, + session: session.clone(), + })?; + roots.push(function); + vm.set_attr(ui, constructor.name(), Value::Object(function))?; + } + Ok(ui) +} + +pub fn snapshot(vm: &Vm, result: &Value) -> Result> { + let Value::Object(reference) = result else { + bail!("main must return a UI node"); + }; + Ok(vm + .payload::(*reference, "Tessera UI node")? + .0 + .get() + .ok_or_else(|| anyhow::anyhow!("main returned an uninitialized UI node"))? + .clone()) +}