feat: execute Tessera UI source with embedded Aster
Verify Tessera GUI / verify (push) Successful in 7m38s

This commit is contained in:
2026-10-20 03:35:46 +08:00
parent 3e1cce78f8
commit 8cb2bf8607
14 changed files with 1482 additions and 778 deletions
+6
View File
@@ -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.
+1
View File
@@ -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"]
+117 -72
View File
@@ -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.
+12
View File
@@ -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).
+10
View File
@@ -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)
}
+34
View File
@@ -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.
+350 -75
View File
@@ -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<dyn Fn() + Send + Sync>;
struct BuildRequest {
request: Request,
source: String,
namespace: String,
submission: u64,
}
pub fn start(
namespace: Arc<RwLock<NamespaceState>>,
@@ -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<Server>,
namespace: Arc<RwLock<NamespaceState>>,
notify: Notify,
) -> Result<thread::JoinHandle<()>> {
let (sender, receiver) = sync_channel::<BuildRequest>(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, &notify, &sender);
}
})
.context("start Tessera agent API thread")?;
Ok(())
.context("start Tessera agent API thread")
}
fn handle(
mut request: Request,
request: Request,
namespace: &Arc<RwLock<NamespaceState>>,
proxy: &EventLoopProxy<AppEvent>,
notify: &Notify,
sender: &SyncSender<BuildRequest>,
) {
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<RwLock<NamespaceState>>,
proxy: &EventLoopProxy<AppEvent>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
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<RwLock<NamespaceState>>,
sender: &SyncSender<BuildRequest>,
) {
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<RwLock<NamespaceState>>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
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<std::io::Cursor<Vec<u8>>> {
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<String> {
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<std::io::Cursor<Vec<u8>>> {
fn compile_error(error: anyhow::Error) -> Response<std::io::Cursor<Vec<u8>>> {
let mut diagnostic =
serde_json::json!({ "severity": "error", "message": format!("{error:#}") });
if let Some(source) = error.downcast_ref::<frontend::SourceDiagnostic>() {
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<std::io::Cursor<Vec<u8>>>, 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<Server>,
address: SocketAddr,
api: Option<thread::JoinHandle<()>>,
}
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::<serde_json::Value>(&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::<serde_json::Value>(&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"));
}
-625
View File
@@ -1,625 +0,0 @@
use anyhow::{Result, bail};
use crate::document::{CodeNode, Frame, RenderCommand, RenderDocument};
pub fn compile(source: &str, expected_namespace: &str) -> Result<RenderDocument> {
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<Argument>,
children: Vec<SyntaxNode>,
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<Vec<Token>> {
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<char> {
self.source[self.offset..].chars().next()
}
fn advance(&mut self) -> Option<char> {
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<TokenKind> {
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<TokenKind> {
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<Token>,
current: usize,
}
impl Parser {
fn new(tokens: Vec<Token>) -> Self {
Self { tokens, current: 0 }
}
fn parse(&mut self) -> Result<SyntaxDocument> {
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<String> {
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<SyntaxNode> {
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<Value> {
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<String> {
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<Token> {
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<RenderCommand>) -> 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<RenderCommand>) -> 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<RenderCommand>) -> 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<RenderCommand>) -> 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<f32> {
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"));
}
}
+2 -1
View File
@@ -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))
+49 -4
View File
@@ -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<u64> {
self.submission
.checked_add(1)
.context("submission counter exhausted")
}
pub fn commit(&mut self, submission: u64, document: RenderDocument) -> Result<Option<u64>> {
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<u64> {
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<Arc<UiNode>>,
#[serde(default)]
pub source: String,
#[serde(default)]
@@ -59,9 +98,15 @@ impl RenderDocument {
}
pub fn display_list(&self, scale: f32) -> Result<DisplayList> {
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<Color> {
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);
+470
View File
@@ -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<RenderDocument> {
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<RenderDocument> = (|| {
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<ObjectRef>,
globals: Option<ObjectRef>,
}
impl Candidate {
fn new() -> Result<Self> {
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::<Vec<_>>()
.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<usize> {
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<usize>,
next_id: usize,
}
impl Architecture {
fn node(&mut self, name: String, kind: &str, span: Span, children: Vec<CodeNode>) -> 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::<frontend::SourceDiagnostic>().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)
);
}
}
+125
View File
@@ -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<Frame> {
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<f32> {
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<RenderCommand>,
visits: &mut usize,
) -> Result<Size> {
*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)
}
+3 -1
View File
@@ -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};
+303
View File
@@ -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<Arc<UiNode>>,
},
Row {
padding: f32,
gap: f32,
children: Vec<Arc<UiNode>>,
},
Spacer(f32),
}
#[derive(Debug)]
struct NodePayload(UiSlot);
type UiSlot = Arc<OnceLock<Arc<UiNode>>>;
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<UiNode>,
// never VM handles, so payload-owned GC edges remain empty and immutable.
pool: Vec<(ObjectRef, UiSlot)>,
indexes: HashMap<ObjectRef, usize>,
used: usize,
}
impl BuildSession {
fn child(&self, value: &Value) -> Result<Arc<UiNode>, 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<Value, VmError> {
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<Mutex<BuildSession>>,
}
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<Value, VmError> {
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::<Result<Vec<_>, _>>()?;
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<String, VmError> {
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<f32, VmError> {
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::<f64>().map_err(|_| invalid())?
}
Some(EndorReal::Decimal(value)) => value.literal().parse::<f64>().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<ObjectRef>) -> Result<ObjectRef> {
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<Arc<UiNode>> {
let Value::Object(reference) = result else {
bail!("main must return a UI node");
};
Ok(vm
.payload::<NodePayload>(*reference, "Tessera UI node")?
.0
.get()
.ok_or_else(|| anyhow::anyhow!("main returned an uninitialized UI node"))?
.clone())
}