feat: execute Tessera UI source with embedded Aster
Verify Tessera GUI / verify (push) Successful in 7m38s
Verify Tessera GUI / verify (push) Successful in 7m38s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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, ¬ify, &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
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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};
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user