abstract syntax tree and parser prototype

This commit is contained in:
shoney.arickathil 2026-04-21 03:28:25 +02:00
parent ccf95a8329
commit 0e91044153
12 changed files with 2413 additions and 153 deletions

543
Cargo.lock generated
View file

@ -9,12 +9,75 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bincode"
version = "1.3.3"
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower 0.5.3",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@ -23,6 +86,12 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cfg-if"
version = "1.0.4"
@ -57,6 +126,48 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.2"
@ -91,6 +202,86 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "id-arena"
version = "2.3.0"
@ -139,18 +330,53 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "prettyplease"
version = "0.2.37"
@ -185,6 +411,19 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rt"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"serde",
"serde_json",
"tempfile",
"tokio",
"tower 0.4.13",
]
[[package]]
name = "rustix"
version = "1.1.4"
@ -198,6 +437,18 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "semver"
version = "1.0.27"
@ -247,6 +498,61 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.117"
@ -258,6 +564,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tempfile"
version = "3.27.0"
@ -271,6 +583,91 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "tokio"
version = "1.52.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
@ -283,6 +680,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
@ -438,140 +841,6 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wo-event"
version = "0.1.0"
dependencies = [
"libc",
]
[[package]]
name = "wo-htmlx"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tempfile",
"wo-md",
"wo-model",
]
[[package]]
name = "wo-http"
version = "0.1.0"
dependencies = [
"libc",
"tempfile",
]
[[package]]
name = "wo-index"
version = "0.1.0"
dependencies = [
"bincode",
"libc",
"serde",
"tempfile",
"wo-model",
]
[[package]]
name = "wo-md"
version = "0.1.0"
[[package]]
name = "wo-model"
version = "0.1.0"
dependencies = [
"bincode",
"serde",
"serde_json",
"tempfile",
"wo-md",
]
[[package]]
name = "wo-route"
version = "0.1.0"
dependencies = [
"wo-http",
]
[[package]]
name = "wo-rt"
version = "0.1.0"
dependencies = [
"bincode",
"libc",
"serde",
"serde_json",
"tempfile",
"wo-event",
"wo-htmlx",
"wo-http",
"wo-md",
"wo-model",
"wo-route",
"wo-serve",
"wo-store",
"wo-sub",
"wo-watch",
]
[[package]]
name = "wo-seg"
version = "0.1.0"
dependencies = [
"bincode",
"libc",
"serde",
"tempfile",
"wo-model",
]
[[package]]
name = "wo-serve"
version = "0.1.0"
dependencies = [
"libc",
"tempfile",
"wo-http",
]
[[package]]
name = "wo-store"
version = "0.1.0"
dependencies = [
"serde_json",
"tempfile",
"wo-index",
"wo-model",
"wo-seg",
]
[[package]]
name = "wo-sub"
version = "0.1.0"
dependencies = [
"bincode",
"libc",
"serde",
"serde_json",
"tempfile",
"wo-event",
"wo-model",
"wo-store",
]
[[package]]
name = "wo-watch"
version = "0.1.0"
dependencies = [
"libc",
"tempfile",
"wo-store",
]
[[package]]
name = "zmij"
version = "1.0.21"

View file

@ -1,23 +1,41 @@
# Root workspace — the new `.wo` runtime.
#
# Only `crates/rt` carries real code today (Stage 2 of the runtime); the
# fourteen sibling crates are empty skeletons populated phase-by-phase per
# docs/plan/done/01-scafolding-crates.md. They are commented out of the
# workspace until their phase activates — uncomment each one as code lands.
#
# The v1 writeonce blog crates at `reference/crates/` are a separate nested
# workspace, excluded here so the root build stays focused on the new runtime.
[workspace]
resolver = "2"
members = [
"crates/wo-model",
"crates/wo-seg",
"crates/wo-index",
"crates/wo-store",
"crates/wo-watch",
"crates/wo-event",
"crates/wo-sub",
"crates/wo-rt",
"crates/wo-http",
"crates/wo-route",
"crates/wo-htmlx",
"crates/wo-md",
"crates/wo-serve",
"crates/rt",
# Uncomment as each phase extracts code from `rt/` into its target crate.
# See docs/plan/02..08 for the sequence.
#
# "crates/ql", # phase 02 target — lexer / parser / AST
# "crates/value", # phase 02 target — tagged Value + path helpers
# "crates/engine", # phase 02 target — rel/doc/graph executor
# "crates/txn", # phase 02 target — MVCC + RETURNING alias table
# "crates/db", # phase 02 target — top-level facade
# "crates/wal", # phase 03 target — io_uring + fsync WAL
# "crates/sub", # phase 04 target — LIVE subscriptions
# "crates/http", # phase 04 target — wire protocol + router
# "crates/gen", # phase 05 target — client SDK codegen
# "crates/policy", # phase 06 target — RBAC planner rewrites
# "crates/logic", # phase 06 target — triggers + fn interpreter
# "crates/service", # phase 06 target — endpoint dispatch
# "crates/ui", # phase 06 target — ##ui SSR + client runtime
# "crates/app", # phase 06 target — ##app manifest
]
exclude = [
"reference/crates",
]
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bincode = "1"
libc = "0.2"
anyhow = "1"

24
crates/rt/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "rt"
version = "0.1.0"
edition = "2021"
description = "writeonce runtime — the `.wo` language engine (v2, Phase 2+ design)"
[lib]
name = "rt"
path = "src/lib.rs"
[[bin]]
name = "wo"
path = "src/bin/wo.rs"
[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "macros", "net", "signal", "sync", "time"] }
axum = "0.7"
tower = "0.4"
[dev-dependencies]
tempfile = "3"

106
crates/rt/src/ast.rs Normal file
View file

@ -0,0 +1,106 @@
//! AST for `.wo` schema-layer declarations.
//!
//! Stage 2 scope: `type` declarations with fields and `service rest` blocks.
//! Policies, triggers, computed fields, link types, `##ui`, `##app`, `fn` bodies,
//! and `##sql/##doc/##graph` blocks are parsed-and-discarded for now — the AST
//! carries just enough to stand up a REST server that serves the declared types.
#[derive(Debug, Clone, Default)]
pub struct Schema {
pub types: Vec<TypeDecl>,
}
#[derive(Debug, Clone)]
pub struct TypeDecl {
pub name: String,
pub fields: Vec<Field>,
pub services: Vec<ServiceDecl>,
}
#[derive(Debug, Clone)]
pub struct Field {
pub name: String,
pub ty: FieldTy,
pub nullable:bool,
pub unique: bool,
pub default: Option<DefaultExpr>,
/// True if this is a `ref T` / `multi T ...` / `backlink ...` field that doesn't
/// correspond to a storage column in this type's table. The server ignores it
/// for create/update/list scalar projection but exposes it via sub-endpoints.
pub is_relation: bool,
}
#[derive(Debug, Clone)]
pub enum FieldTy {
/// Plain scalar — one of the well-known names (`Id`, `Text`, `Int`, etc.) or
/// an unrecognised identifier that's compiled as opaque text for Stage 2.
Scalar(String),
/// `[T]` — array of the inner type.
Array(Box<FieldTy>),
/// `{ k: T, ... }` — inline embedded-document struct.
Struct(Vec<Field>),
/// Tagged union: `A | B | C`. Stage 2 stores variants as strings.
Union(Vec<String>),
/// `ref TypeName` — scalar FK to another type.
Ref(String),
/// `multi TypeName @edge(:TAG)` — zero-prop graph edge.
MultiEdge { target: String, tag: Option<String> },
/// `multi TypeName via LinkType` — graph edge with properties.
MultiVia { target: String, link: String },
/// `backlink TypeName.field` — inverse relation.
Backlink { target: String, field: String },
}
#[derive(Debug, Clone)]
pub enum DefaultExpr {
Str(String),
Int(i64),
Bool(bool),
Null,
Now,
Enum(String), // a bare identifier — e.g. `Customer` in a union default
Opaque(String), // anything we didn't bother to evaluate (computed, etc.)
}
#[derive(Debug, Clone)]
pub struct ServiceDecl {
pub kind: ServiceKind,
pub path: String,
pub expose: Vec<Operation>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceKind { Rest, Graphql, Native }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Operation {
List,
Get,
Create,
Update,
Delete,
Subscribe,
Me,
Custom, // anything we don't special-case — reserved
}
impl Operation {
pub fn from_ident(s: &str) -> Operation {
match s {
"list" => Operation::List,
"get" => Operation::Get,
"create" => Operation::Create,
"update" => Operation::Update,
"delete" => Operation::Delete,
"subscribe" => Operation::Subscribe,
"me" => Operation::Me,
_ => Operation::Custom,
}
}
}
impl Schema {
pub fn merge(&mut self, other: Schema) {
self.types.extend(other.types);
}
}

126
crates/rt/src/bin/wo.rs Normal file
View file

@ -0,0 +1,126 @@
//! `wo` — the writeonce toolchain binary.
//!
//! Stage 2 scope:
//! wo run <dir> — discover `.wo` files under <dir>, parse the type DSL,
//! compile a catalog, and serve REST CRUD on :8080.
//! wo --help — print usage.
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use tokio::sync::Mutex;
fn usage() {
eprintln!(
"\
wo — writeonce toolchain
USAGE:
wo run <dir> parse .wo files under <dir>, serve REST CRUD on :8080
wo --help print this message
ENV:
WO_LISTEN override the listen address (default: 127.0.0.1:8080)
"
);
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let slice: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
match slice.as_slice() {
[] | ["--help"] | ["-h"] => {
usage();
ExitCode::from(0)
}
["run"] => {
eprintln!("wo run: directory argument required");
usage();
ExitCode::from(2)
}
["run", dir] => run(PathBuf::from(dir)),
_ => {
usage();
ExitCode::from(2)
}
}
}
fn run(dir: PathBuf) -> ExitCode {
// Tokio current-thread runtime — matches the single-threaded engine design.
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(r) => r,
Err(e) => { eprintln!("error: build runtime: {e}"); return ExitCode::from(1); }
};
rt.block_on(async move { serve(dir).await })
.unwrap_or_else(|e| { eprintln!("error: {e}"); ExitCode::from(1) })
}
async fn serve(dir: PathBuf) -> anyhow::Result<ExitCode> {
// 1. Discover
let files = rt::discover(&dir)?;
if files.is_empty() {
anyhow::bail!("no .wo files found under {}", dir.display());
}
println!("[wo] discovered {} .wo file{} under {}",
files.len(),
if files.len() == 1 { "" } else { "s" },
dir.display(),
);
// 2. Parse each file into a Schema
let mut schemas = Vec::new();
for f in &files {
match rt::parser::parse(&f.src) {
Ok(s) => {
let n = s.types.len();
println!(" parsed {} — {} type{}", f.rel.display(), n, if n == 1 { "" } else { "s" });
schemas.push(s);
}
Err(e) => {
eprintln!(" parse error in {}: {}", f.rel.display(), e);
return Ok(ExitCode::from(1));
}
}
}
// 3. Compile a catalog
let catalog = rt::compile::Catalog::from_schemas(schemas)?;
println!("[wo] compiled catalog — {} type{}", catalog.order.len(),
if catalog.order.len() == 1 { "" } else { "s" });
// 4. Boot the engine
let engine = Arc::new(Mutex::new(rt::engine::Engine::new(catalog.clone())));
{
let e = engine.lock().await;
println!();
println!("[wo] routes:");
print!("{}", rt::server::describe_routes(&e));
}
// 5. Bind and serve
let addr = std::env::var("WO_LISTEN").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => anyhow::bail!("bind {addr}: {e}"),
};
println!();
println!("[wo] listening on http://{addr}");
println!("[wo] ctrl-C to stop");
let app = rt::server::router(engine.clone(), &catalog);
// Graceful shutdown on ctrl-C
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
println!("\n[wo] shutting down");
})
.await?;
Ok(ExitCode::from(0))
}

76
crates/rt/src/compile.rs Normal file
View file

@ -0,0 +1,76 @@
//! Compile parsed [`Schema`] objects into a runtime [`Catalog`] — the engine's
//! view of the declared types, their storage columns, and the REST routes we
//! need to expose.
use crate::ast::*;
use anyhow::{bail, Result};
use std::collections::HashMap;
/// A catalog of all compiled types, ready to feed to the engine and the server.
#[derive(Debug, Clone, Default)]
pub struct Catalog {
pub types: HashMap<String, CompiledType>,
/// Preserves declaration order so REST route registration is deterministic.
pub order: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct CompiledType {
pub name: String,
/// Columns in source order. Includes relation fields; the server treats
/// those specially but keeps them in the type's row object for echo.
pub fields: Vec<Field>,
pub services: Vec<ServiceDecl>,
/// True iff the type declared an `id: Id` column. Auto-populated on insert.
pub has_id: bool,
}
impl Catalog {
pub fn from_schemas(schemas: Vec<Schema>) -> Result<Self> {
let mut cat = Catalog::default();
for s in schemas {
for t in s.types {
if cat.types.contains_key(&t.name) {
bail!("duplicate type declaration: {}", t.name);
}
let has_id = t.fields.iter().any(|f|
f.name == "id" &&
matches!(f.ty, FieldTy::Scalar(ref n) if n == "Id")
);
cat.order.push(t.name.clone());
cat.types.insert(t.name.clone(), CompiledType {
name: t.name.clone(),
fields: t.fields,
services: t.services,
has_id,
});
}
}
Ok(cat)
}
pub fn get(&self, name: &str) -> Option<&CompiledType> {
self.types.get(name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
#[test]
fn catalog_collects_types_and_detects_id() {
let sch = parse(r#"
type Article { id: Id
title: Text
service rest "/api/articles" expose list, get }
type Tag { slug: Slug
service rest "/api/tags" expose list }
"#).unwrap();
let cat = Catalog::from_schemas(vec![sch]).unwrap();
assert_eq!(cat.order, vec!["Article", "Tag"]);
assert!(cat.types["Article"].has_id);
assert!(!cat.types["Tag"].has_id);
}
}

251
crates/rt/src/engine.rs Normal file
View file

@ -0,0 +1,251 @@
//! In-memory CRUD engine for the compiled schema.
//!
//! Storage model: `HashMap<type_name, BTreeMap<id, row>>`. Rows are
//! `serde_json::Value::Object`. `Id` columns are auto-populated on insert.
//! All queries are plain iteration — fine for Stage 2.
use crate::ast::{DefaultExpr, FieldTy};
use crate::compile::{Catalog, CompiledType};
use anyhow::Result;
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
pub type Row = Map<String, Value>;
#[derive(Debug, Default)]
pub struct Engine {
catalog: Catalog,
/// type_name → { id → row }
tables: std::collections::HashMap<String, BTreeMap<i64, Row>>,
/// per-type id allocator
next_id: std::collections::HashMap<String, i64>,
}
impl Engine {
pub fn new(catalog: Catalog) -> Self {
let mut tables = std::collections::HashMap::new();
let mut next_id = std::collections::HashMap::new();
for name in catalog.order.iter() {
tables.insert(name.clone(), BTreeMap::new());
next_id.insert(name.clone(), 1);
}
Self { catalog, tables, next_id }
}
pub fn catalog(&self) -> &Catalog { &self.catalog }
/// List every row of `ty` in insertion (id) order.
pub fn list(&self, ty: &str) -> Result<Vec<Row>> {
self.table(ty).map(|t| t.values().cloned().collect())
}
pub fn get(&self, ty: &str, id: i64) -> Result<Option<Row>> {
Ok(self.table(ty)?.get(&id).cloned())
}
/// Create a row. `body` is the JSON object from the request; missing columns
/// fill in from defaults. Returns the finalized row (including the auto-id).
pub fn create(&mut self, ty: &str, body: Value) -> Result<Row> {
let t = self.compiled(ty)?.clone();
let mut row = self.seed_defaults(&t);
if let Value::Object(input) = body {
for (k, v) in input {
row.insert(k, v);
}
}
// Assign id if the type declares one and the caller didn't provide.
if t.has_id && !row.contains_key("id") {
let id = self.mint_id(ty);
row.insert("id".into(), json!(id));
}
let id = row.get("id")
.and_then(|v| v.as_i64())
.unwrap_or_else(|| self.mint_id(ty));
row.insert("id".into(), json!(id));
self.tables.get_mut(ty).unwrap().insert(id, row.clone());
Ok(row)
}
/// Merge-update a row.
pub fn update(&mut self, ty: &str, id: i64, body: Value) -> Result<Option<Row>> {
let table = self.tables.get_mut(ty)
.ok_or_else(|| anyhow::anyhow!("no such type: {ty}"))?;
let Some(row) = table.get_mut(&id) else { return Ok(None); };
if let Value::Object(input) = body {
for (k, v) in input {
if k == "id" { continue; } // don't let the client mutate the primary key
row.insert(k, v);
}
}
Ok(Some(row.clone()))
}
pub fn delete(&mut self, ty: &str, id: i64) -> Result<bool> {
let table = self.tables.get_mut(ty)
.ok_or_else(|| anyhow::anyhow!("no such type: {ty}"))?;
Ok(table.remove(&id).is_some())
}
// --- helpers ---
fn table(&self, ty: &str) -> Result<&BTreeMap<i64, Row>> {
self.tables.get(ty).ok_or_else(|| anyhow::anyhow!("no such type: {ty}"))
}
fn compiled(&self, ty: &str) -> Result<&CompiledType> {
self.catalog.get(ty).ok_or_else(|| anyhow::anyhow!("no such type: {ty}"))
}
fn mint_id(&mut self, ty: &str) -> i64 {
let counter = self.next_id.entry(ty.to_string()).or_insert(1);
let id = *counter;
*counter += 1;
id
}
/// Produce the initial row object — default values for every non-relation
/// field that declared one.
fn seed_defaults(&self, t: &CompiledType) -> Row {
let mut row = Map::new();
for f in &t.fields {
if f.is_relation { continue; }
if let Some(def) = &f.default {
if let Some(v) = Self::eval_default(def, &f.ty) {
row.insert(f.name.clone(), v);
}
} else if matches!(f.ty, FieldTy::Array(_)) && !f.nullable {
row.insert(f.name.clone(), Value::Array(Vec::new()));
} else if let FieldTy::Struct(inner) = &f.ty {
let mut nested = Map::new();
for g in inner {
if let Some(d) = &g.default {
if let Some(v) = Self::eval_default(d, &g.ty) {
nested.insert(g.name.clone(), v);
}
}
}
if !nested.is_empty() {
row.insert(f.name.clone(), Value::Object(nested));
}
}
}
row
}
fn eval_default(def: &DefaultExpr, _ty: &FieldTy) -> Option<Value> {
Some(match def {
DefaultExpr::Str(s) => Value::String(s.clone()),
DefaultExpr::Int(n) => json!(n),
DefaultExpr::Bool(b) => json!(b),
DefaultExpr::Null => Value::Null,
DefaultExpr::Now => Value::String(now_iso8601()),
DefaultExpr::Enum(s) => Value::String(s.clone()),
// Opaque expressions (computed fields like `total = sum(...)`) are
// Stage 2-unevaluated. Omit rather than echo parser-debug tokens.
DefaultExpr::Opaque(_) => return None,
})
}
}
fn now_iso8601() -> String {
let d = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = d.as_secs() as i64;
let nanos = d.subsec_nanos();
// Minimal ISO-8601 UTC formatter — good enough for Stage 2.
// (`chrono` would be nicer but we're keeping deps small.)
let (yr, mo, da, hr, mi, se) = ymdhms(secs);
format!("{yr:04}-{mo:02}-{da:02}T{hr:02}:{mi:02}:{se:02}.{:03}Z", nanos / 1_000_000)
}
/// Pure-Rust epoch → (year, month, day, hour, minute, second) for UTC.
fn ymdhms(mut secs: i64) -> (i32, u32, u32, u32, u32, u32) {
const SECS_PER_DAY: i64 = 86_400;
let se = (secs.rem_euclid(60)) as u32;
secs = secs.div_euclid(60);
let mi = (secs.rem_euclid(60)) as u32;
secs = secs.div_euclid(60);
let hr = (secs.rem_euclid(24)) as u32;
let mut days = secs.div_euclid(24);
// Days → calendar. Epoch 1970-01-01 is a Thursday but we don't need weekday.
let mut year: i32 = 1970;
loop {
let dy = if is_leap(year) { 366 } else { 365 };
if days >= dy {
days -= dy;
year += 1;
} else {
break;
}
}
let months = if is_leap(year) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut mo = 1u32;
for (i, d) in months.iter().enumerate() {
if days < *d as i64 {
mo = i as u32 + 1;
break;
}
days -= *d as i64;
}
let _ = SECS_PER_DAY;
let da = days as u32 + 1;
(year, mo, da, hr, mi, se)
}
fn is_leap(y: i32) -> bool {
(y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn engine_from(src: &str) -> Engine {
let cat = Catalog::from_schemas(vec![parse(src).unwrap()]).unwrap();
Engine::new(cat)
}
#[test]
fn crud_roundtrip_auto_id() {
let mut eng = engine_from(r#"
type Article { id: Id
title: Text
published: Bool = false
service rest "/api/articles" expose list, get, create, update, delete }
"#);
let a = eng.create("Article", json!({"title": "Hello"})).unwrap();
assert_eq!(a.get("id").unwrap().as_i64().unwrap(), 1);
assert_eq!(a.get("title").unwrap().as_str().unwrap(), "Hello");
assert_eq!(a.get("published").unwrap(), &json!(false));
let b = eng.create("Article", json!({"title": "World", "published": true})).unwrap();
assert_eq!(b.get("id").unwrap().as_i64().unwrap(), 2);
let list = eng.list("Article").unwrap();
assert_eq!(list.len(), 2);
let got = eng.get("Article", 1).unwrap().unwrap();
assert_eq!(got.get("title").unwrap().as_str().unwrap(), "Hello");
let upd = eng.update("Article", 1, json!({"title": "Hi"})).unwrap().unwrap();
assert_eq!(upd.get("title").unwrap().as_str().unwrap(), "Hi");
assert!(eng.delete("Article", 1).unwrap());
assert!(!eng.delete("Article", 1).unwrap());
assert_eq!(eng.list("Article").unwrap().len(), 1);
}
}

342
crates/rt/src/lexer.rs Normal file
View file

@ -0,0 +1,342 @@
//! Tokenizer for `.wo` source. Emits a sequential stream of [`Token`]s
//! suitable for the recursive-descent parser in [`crate::parser`].
use crate::token::{Kind, Token};
use anyhow::{bail, Result};
pub fn tokenize(src: &str) -> Result<Vec<Token>> {
Lexer::new(src).lex()
}
struct Lexer<'a> {
bytes: &'a [u8],
pos: usize,
line: u32,
col: u32,
}
impl<'a> Lexer<'a> {
fn new(src: &'a str) -> Self {
Self { bytes: src.as_bytes(), pos: 0, line: 1, col: 1 }
}
fn peek(&self) -> Option<u8> { self.bytes.get(self.pos).copied() }
fn peek_at(&self, n: usize) -> Option<u8> { self.bytes.get(self.pos + n).copied() }
fn advance(&mut self) -> Option<u8> {
let c = self.peek()?;
self.pos += 1;
if c == b'\n' { self.line += 1; self.col = 1; } else { self.col += 1; }
Some(c)
}
fn lex(mut self) -> Result<Vec<Token>> {
let mut out = Vec::new();
while let Some(c) = self.peek() {
let line = self.line;
let col = self.col;
// line comment: -- ... EOL
if c == b'-' && self.peek_at(1) == Some(b'-') {
while let Some(c) = self.peek() {
if c == b'\n' { break; }
self.advance();
}
continue;
}
// newline → significant (ends trigger/policy lines, etc.)
if c == b'\n' {
self.advance();
if out.last().map(|t: &Token| matches!(t.kind, Kind::Newline)) != Some(true) {
out.push(Token { kind: Kind::Newline, line, col });
}
continue;
}
// plain whitespace
if c == b' ' || c == b'\t' || c == b'\r' {
self.advance();
continue;
}
// ## block marker
if c == b'#' && self.peek_at(1) == Some(b'#') {
self.advance(); self.advance();
let name = self.read_ident_chars();
out.push(Token { kind: Kind::HashHash(name), line, col });
continue;
}
// # name
if c == b'#' {
self.advance();
let name = self.read_ident_chars();
out.push(Token { kind: Kind::Hash(name), line, col });
continue;
}
// $name
if c == b'$' {
self.advance();
let name = self.read_ident_chars();
if name.is_empty() {
bail!("line {line}: expected parameter name after '$'");
}
out.push(Token { kind: Kind::Param(name), line, col });
continue;
}
// string literal (single or double quote)
if c == b'"' || c == b'\'' {
let quote = c;
self.advance();
let mut s = String::new();
while let Some(c) = self.peek() {
if c == quote { self.advance(); break; }
if c == b'\\' {
self.advance();
match self.advance() {
Some(b'n') => s.push('\n'),
Some(b't') => s.push('\t'),
Some(b'\\') => s.push('\\'),
Some(b'"') => s.push('"'),
Some(b'\'') => s.push('\''),
Some(other) => s.push(other as char),
None => bail!("line {line}: unterminated string escape"),
}
continue;
}
s.push(self.advance().unwrap() as char);
}
out.push(Token { kind: Kind::Str(s), line, col });
continue;
}
// integer literal
if c.is_ascii_digit() {
let mut n: i64 = 0;
while let Some(d) = self.peek() {
if !d.is_ascii_digit() { break; }
n = n.saturating_mul(10) + (d - b'0') as i64;
self.advance();
}
out.push(Token { kind: Kind::Int(n), line, col });
continue;
}
// identifier / keyword
if c.is_ascii_alphabetic() || c == b'_' {
let name = self.read_ident_chars();
let kind = match name.as_str() {
"type" => Kind::KwType,
"ref" => Kind::KwRef,
"multi" => Kind::KwMulti,
"via" => Kind::KwVia,
"backlink" => Kind::KwBacklink,
"link" => Kind::KwLink,
"service" => Kind::KwService,
"rest" => Kind::KwRest,
"graphql" => Kind::KwGraphql,
"native" => Kind::KwNative,
"expose" => Kind::KwExpose,
"policy" => Kind::KwPolicy,
"for" => Kind::KwFor,
"role" => Kind::KwRole,
"when" => Kind::KwWhen,
"anyone" => Kind::KwAnyone,
"on" => Kind::KwOn,
"do" => Kind::KwDo,
"set" => Kind::KwSet,
"call" => Kind::KwCall,
"emit" => Kind::KwEmit,
"enqueue" => Kind::KwEnqueue,
"assert" => Kind::KwAssert,
"otherwise" => Kind::KwOtherwise,
"abort" => Kind::KwAbort,
"return" => Kind::KwReturn,
"returning" => Kind::KwReturning,
"RETURNING" => Kind::KwReturning,
"as" => Kind::KwAs,
"AS" => Kind::KwAs,
"fn" => Kind::KwFn,
"in" => Kind::KwIn,
"txn" => Kind::KwTxn,
"snapshot" => Kind::KwSnapshot,
"serializable" => Kind::KwSerializable,
"BEGIN" => Kind::KwBegin,
"COMMIT" => Kind::KwCommit,
"ROLLBACK" => Kind::KwRollback,
"SAVEPOINT" => Kind::KwSavepoint,
"TO" => Kind::KwTo,
"LIVE" => Kind::KwLive,
"live" => Kind::KwLive, // lowercase `live` used in UI blocks
// `subscribe`, `receive`, `expect_abort` stay as plain idents so
// `expose ... subscribe` works in `service rest` blocks.
"INSERT" => Kind::KwInsert,
"INTO" => Kind::KwInto,
"VALUES" => Kind::KwValues,
"UPDATE" => Kind::KwUpdate,
"DELETE" => Kind::KwDelete,
"SELECT" => Kind::KwSelect,
"FROM" => Kind::KwFrom,
"WHERE" => Kind::KwWhere,
"MATCH" => Kind::KwMatch,
"CREATE" => Kind::KwCreate,
"SET" => Kind::KwSet,
"let" => Kind::KwLet,
"if" => Kind::KwIf,
"else" => Kind::KwElse,
"each" => Kind::KwEach,
"contains" => Kind::KwContains,
"and" => Kind::KwAnd,
"AND" => Kind::KwAnd,
"or" => Kind::KwOr,
"OR" => Kind::KwOr,
"not" => Kind::KwNot,
"NOT" => Kind::KwNot,
"true" => Kind::KwTrue,
"false" => Kind::KwFalse,
"null" => Kind::KwNull,
"test" => Kind::KwTest,
"main" => Kind::KwMain,
"startup" => Kind::KwStartup,
_ => Kind::Ident(name),
};
out.push(Token { kind, line, col });
continue;
}
// punctuation & operators
let kind = match c {
b'{' => { self.advance(); Kind::LBrace }
b'}' => { self.advance(); Kind::RBrace }
b'(' => { self.advance(); Kind::LParen }
b')' => { self.advance(); Kind::RParen }
b'[' => { self.advance(); Kind::LBracket }
b']' => { self.advance(); Kind::RBracket }
b',' => { self.advance(); Kind::Comma }
b';' => { self.advance(); Kind::Semicolon }
b':' => { self.advance(); Kind::Colon }
b'.' => {
self.advance();
match self.peek() {
Some(b'.') => { self.advance(); Kind::DotDot }
Some(b'*') => { self.advance(); Kind::DotStar }
_ => Kind::Dot,
}
}
b'?' => { self.advance(); Kind::Question }
b'@' => { self.advance(); Kind::At }
b'|' => { self.advance(); Kind::Pipe }
b'-' => {
self.advance();
match self.peek() {
Some(b'>') => { self.advance(); Kind::Arrow }
Some(b'=') => { self.advance(); Kind::MinusEq }
_ => Kind::Dash,
}
}
b'+' => {
self.advance();
match self.peek() {
Some(b'=') => { self.advance(); Kind::PlusEq }
_ => Kind::Plus,
}
}
b'*' => { self.advance(); Kind::Star }
b'/' => { self.advance(); Kind::Slash }
b'%' => { self.advance(); Kind::Percent }
b'=' => {
self.advance();
match self.peek() {
Some(b'=') => { self.advance(); Kind::EqEq }
Some(b'>') => { self.advance(); Kind::FatArrow }
_ => Kind::Eq,
}
}
b'!' => {
self.advance();
match self.peek() {
Some(b'=') => { self.advance(); Kind::NotEq }
_ => bail!("line {line}: expected '!=' got '!'"),
}
}
b'<' => {
self.advance();
match self.peek() {
Some(b'=') => { self.advance(); Kind::LtEq }
_ => Kind::Lt,
}
}
b'>' => {
self.advance();
match self.peek() {
Some(b'=') => { self.advance(); Kind::GtEq }
_ => Kind::Gt,
}
}
other => bail!("line {line}, col {col}: unexpected character {:?}", other as char),
};
out.push(Token { kind, line, col });
}
out.push(Token { kind: Kind::End, line: self.line, col: self.col });
Ok(out)
}
fn read_ident_chars(&mut self) -> String {
let start = self.pos;
while let Some(c) = self.peek() {
if c.is_ascii_alphanumeric() || c == b'_' || c == b'-' {
self.advance();
} else {
break;
}
}
String::from_utf8_lossy(&self.bytes[start..self.pos]).into_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lexes_type_header() {
let toks = tokenize("type Article {").unwrap();
let kinds: Vec<_> = toks.iter().map(|t| format!("{:?}", t.kind)).collect();
assert_eq!(kinds, vec![
"KwType".to_string(),
"Ident(\"Article\")".to_string(),
"LBrace".to_string(),
"End".to_string(),
]);
}
#[test]
fn lexes_string_and_int_and_param() {
let toks = tokenize(r#"VALUES ($uid, 'hello', 42)"#).unwrap();
let kinds: Vec<_> = toks.iter().map(|t| &t.kind).cloned().collect();
assert!(kinds.contains(&Kind::KwValues));
assert!(kinds.contains(&Kind::Param("uid".into())));
assert!(kinds.contains(&Kind::Str("hello".into())));
assert!(kinds.contains(&Kind::Int(42)));
}
#[test]
fn skips_line_comments() {
let toks = tokenize("-- comment\ntype X {}").unwrap();
// First non-newline non-comment token should be `type`.
let first_meaningful = toks.iter().find(|t| !matches!(t.kind, Kind::Newline)).unwrap();
assert_eq!(first_meaningful.kind, Kind::KwType);
}
#[test]
fn lexes_hash_markers() {
let toks = tokenize("##ui\n#article-list").unwrap();
assert!(matches!(toks[0].kind, Kind::HashHash(ref s) if s == "ui"));
assert!(matches!(toks.iter().find(|t| matches!(t.kind, Kind::Hash(_))).unwrap().kind,
Kind::Hash(ref s) if s == "article-list"));
}
}

96
crates/rt/src/lib.rs Normal file
View file

@ -0,0 +1,96 @@
//! writeonce runtime — `.wo` language engine.
//!
//! Stage 1: file discovery. ← src/lib.rs::discover()
//! Stage 2: parser + engine + server.
//! Stage 3: LIVE subscriptions over WebSocket.
pub mod ast;
pub mod compile;
pub mod engine;
pub mod lexer;
pub mod parser;
pub mod server;
pub mod token;
use std::fs;
use std::path::{Path, PathBuf};
pub use ast::Schema;
pub use compile::Catalog;
pub use engine::Engine;
/// A discovered `.wo` source file, resolved to an absolute path with its
/// contents slurped into memory.
#[derive(Debug, Clone)]
pub struct WoFile {
pub path: PathBuf,
pub rel: PathBuf,
pub src: String,
}
/// Discover every `.wo` file rooted at `dir`, returning them in stable
/// (sorted-by-relative-path) order. Recursive.
pub fn discover(dir: &Path) -> anyhow::Result<Vec<WoFile>> {
let root = dir.canonicalize().map_err(|e| {
anyhow::anyhow!("cannot resolve {}: {}", dir.display(), e)
})?;
let mut out = Vec::new();
walk(&root, &root, &mut out)?;
out.sort_by(|a, b| a.rel.cmp(&b.rel));
Ok(out)
}
fn walk(root: &Path, dir: &Path, out: &mut Vec<WoFile>) -> anyhow::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let ty = entry.file_type()?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') || name == "target" || name == "data" || name == "node_modules" {
continue;
}
if ty.is_dir() {
walk(root, &path, out)?;
} else if ty.is_file()
&& path.extension().and_then(|s| s.to_str()) == Some("wo")
{
let src = fs::read_to_string(&path)?;
let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
out.push(WoFile { path: path.clone(), rel, src });
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn discovers_wo_files_recursively() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::write(root.join("app.wo"), "-- app").unwrap();
fs::create_dir(root.join("types")).unwrap();
fs::write(root.join("types/article.wo"), "-- article").unwrap();
fs::write(root.join("types/README.md"), "not a wo file").unwrap();
fs::create_dir(root.join(".hidden")).unwrap();
fs::write(root.join(".hidden/x.wo"), "").unwrap();
fs::create_dir(root.join("target")).unwrap();
fs::write(root.join("target/built.wo"), "").unwrap();
fs::create_dir(root.join("data")).unwrap();
fs::write(root.join("data/runtime.wo"), "").unwrap();
let files = discover(root).unwrap();
assert_eq!(files.len(), 2, "expected 2 .wo files, got {:?}",
files.iter().map(|f| &f.rel).collect::<Vec<_>>());
assert!(files.iter().any(|f| f.rel == PathBuf::from("app.wo")));
assert!(files.iter().any(|f| f.rel == PathBuf::from("types/article.wo")));
}
}

597
crates/rt/src/parser.rs Normal file
View file

@ -0,0 +1,597 @@
//! Recursive-descent parser for `.wo` source.
//!
//! Stage 2 scope:
//! * `type Name { ... }` declarations with fields and `service rest` blocks
//! * graceful skip of constructs we don't yet execute: `policy`, `on <event>`,
//! computed field defaults, `fn`, `main`, `##ui`/`##app`/`##sql`/`##doc`/
//! `##graph`/`##policy`/`##service`/`##logic`/`##logic` blocks.
//!
//! "Skip" means: consume until the matching close brace / next top-level start,
//! so the parser survives and later phases can do nothing.
use crate::ast::*;
use crate::lexer::tokenize;
use crate::token::{Kind, Token};
use anyhow::{bail, Context, Result};
pub fn parse(src: &str) -> Result<Schema> {
let toks = tokenize(src).context("tokenize")?;
Parser::new(toks).parse_schema()
}
struct Parser {
toks: Vec<Token>,
pos: usize,
}
impl Parser {
fn new(toks: Vec<Token>) -> Self { Self { toks, pos: 0 } }
// --- primitives ---
fn peek(&self) -> &Kind { &self.toks[self.pos.min(self.toks.len() - 1)].kind }
fn peek_line(&self) -> u32 { self.toks[self.pos.min(self.toks.len() - 1)].line }
fn advance(&mut self) -> &Token {
let t = &self.toks[self.pos];
if !matches!(t.kind, Kind::End) { self.pos += 1; }
&self.toks[self.pos.saturating_sub(1)]
}
fn skip_newlines(&mut self) {
while matches!(self.peek(), Kind::Newline) { self.advance(); }
}
fn accept(&mut self, want: &Kind) -> bool {
if std::mem::discriminant(self.peek()) == std::mem::discriminant(want) {
self.advance();
true
} else { false }
}
fn expect(&mut self, want: &Kind, what: &str) -> Result<&Token> {
if std::mem::discriminant(self.peek()) == std::mem::discriminant(want) {
Ok(self.advance())
} else {
bail!("line {}: expected {what}, got {}", self.peek_line(), self.peek())
}
}
fn expect_ident(&mut self, what: &str) -> Result<String> {
match self.peek().clone() {
Kind::Ident(s) => { self.advance(); Ok(s) }
k => bail!("line {}: expected {what}, got {k}", self.peek_line()),
}
}
fn at_end(&self) -> bool { matches!(self.peek(), Kind::End) }
// --- top-level ---
fn parse_schema(&mut self) -> Result<Schema> {
let mut sch = Schema::default();
loop {
self.skip_newlines();
if self.at_end() { break; }
match self.peek() {
Kind::KwType => sch.types.push(self.parse_type()?),
// Skip constructs we don't execute yet.
Kind::HashHash(_)
| Kind::KwFn
| Kind::KwMain
| Kind::KwOn
| Kind::KwPolicy
| Kind::KwTest
| Kind::KwLet // stray `let` at top level (in `main { ... }` probably)
| Kind::Hash(_) // ##sql #table etc.
=> self.skip_top_level_chunk()?,
_ => self.skip_top_level_chunk()?,
}
}
Ok(sch)
}
/// Walk forward until we reach the start of the next top-level construct
/// (another `type`, `##…`, or EOF), balancing braces in between.
fn skip_top_level_chunk(&mut self) -> Result<()> {
// Always consume at least one token so we don't loop forever.
let mut depth = 0i32;
let start = self.pos;
loop {
match self.peek() {
Kind::End => break,
Kind::LBrace => { depth += 1; self.advance(); }
Kind::RBrace => { depth -= 1; self.advance(); if depth <= 0 { break; } }
Kind::LBracket => { depth += 1; self.advance(); }
Kind::RBracket => { depth -= 1; self.advance(); }
Kind::LParen => { depth += 1; self.advance(); }
Kind::RParen => { depth -= 1; self.advance(); }
Kind::KwType if depth == 0 && self.pos > start => break,
Kind::HashHash(_) if depth == 0 && self.pos > start => break,
_ => { self.advance(); }
}
}
Ok(())
}
// --- type declaration ---
fn parse_type(&mut self) -> Result<TypeDecl> {
self.expect(&Kind::KwType, "`type`")?;
let name = self.expect_ident("type name")?;
// Link types have a different header: `type Purchase link Customer -> Product { ... }`.
// For Stage 2 we don't bind link-type behaviour, so parse-and-discard the body.
let is_link = matches!(self.peek(), Kind::KwLink);
if is_link {
// consume link A -> B
while !matches!(self.peek(), Kind::LBrace | Kind::End) {
self.advance();
}
}
self.expect(&Kind::LBrace, "'{'")?;
let mut decl = TypeDecl { name, fields: Vec::new(), services: Vec::new() };
loop {
self.skip_newlines();
match self.peek() {
Kind::RBrace => { self.advance(); break; }
Kind::End => bail!("unexpected end of input inside type body"),
Kind::KwPolicy => self.skip_block_line()?, // policy <read|write|...> ...
Kind::KwOn => self.skip_on_block()?, // on update when ... do ...
Kind::KwService => decl.services.push(self.parse_service()?),
Kind::Ident(_) => {
if is_link {
// Stage 2: absorb link-type bodies without interpreting them.
self.skip_block_line()?;
} else {
// Distinguish a field from a trailing junk line. Fields look like
// `ident : type ...`. Anything else → skip.
if self.looks_like_field() {
decl.fields.push(self.parse_field()?);
} else {
self.skip_block_line()?;
}
}
}
_ => self.skip_block_line()?,
}
}
Ok(decl)
}
fn looks_like_field(&self) -> bool {
// Lookahead: Ident followed by Colon (possibly after a hyphenated ident).
let mut i = self.pos;
match self.toks.get(i).map(|t| &t.kind) {
Some(Kind::Ident(_)) => {}
_ => return false,
}
i += 1;
matches!(self.toks.get(i).map(|t| &t.kind), Some(Kind::Colon))
}
/// Skip tokens until the end of the current logical line (up to Newline or
/// the outer RBrace). Used for policies, triggers, etc., whose full grammar
/// is out of Stage 2 scope.
fn skip_block_line(&mut self) -> Result<()> {
let mut depth = 0i32;
loop {
match self.peek() {
Kind::End => break,
Kind::Newline if depth == 0 => { self.advance(); break; }
Kind::RBrace if depth == 0 => break, // stop before the outer `}` — caller handles it
Kind::LBrace | Kind::LBracket | Kind::LParen => { depth += 1; self.advance(); }
Kind::RBrace | Kind::RBracket | Kind::RParen => { depth -= 1; self.advance(); }
_ => { self.advance(); }
}
}
Ok(())
}
/// `on <event> [when ...] do <action>` possibly spanning many lines. Skip
/// until the next top-level keyword inside the type body. Trigger bodies
/// commonly contain `{ k: v, ... }` object literals and `( ... )` calls,
/// so we track brace depth — RBrace only terminates when we're at the
/// outermost level of the `on` block.
fn skip_on_block(&mut self) -> Result<()> {
self.advance(); // consume `on`
let mut depth = 0i32;
loop {
match self.peek() {
Kind::End => break,
Kind::RBrace if depth == 0 => break, // closes the surrounding type body
Kind::LBrace | Kind::LBracket | Kind::LParen => {
depth += 1;
self.advance();
}
Kind::RBrace | Kind::RBracket | Kind::RParen => {
depth -= 1;
self.advance();
}
Kind::Newline if depth == 0 => {
// At depth 0, a newline may end the `on` block if the next
// meaningful token starts a new type-body item.
while matches!(self.peek(), Kind::Newline) { self.advance(); }
if matches!(self.peek(),
Kind::RBrace | Kind::KwPolicy | Kind::KwService
| Kind::KwOn | Kind::End
) { return Ok(()); }
if matches!(self.peek(), Kind::Ident(_)) && self.looks_like_field() {
return Ok(());
}
}
_ => { self.advance(); }
}
}
Ok(())
}
// --- field parsing ---
fn parse_field(&mut self) -> Result<Field> {
let name = self.expect_ident("field name")?;
self.expect(&Kind::Colon, "':'")?;
let (ty, is_relation) = self.parse_field_ty()?;
let mut nullable = false;
if matches!(self.peek(), Kind::Question) {
self.advance();
nullable = true;
}
let mut unique = false;
let mut default = None;
loop {
match self.peek() {
Kind::At => {
self.advance();
let name = self.expect_ident("annotation name")?;
// Consume optional (...) argument block without interpreting it.
if matches!(self.peek(), Kind::LParen) {
let mut depth = 1i32;
self.advance();
while depth > 0 {
match self.peek() {
Kind::End => break,
Kind::LParen => { depth += 1; self.advance(); }
Kind::RParen => { depth -= 1; self.advance(); }
_ => { self.advance(); }
}
}
}
if name == "unique" { unique = true; }
}
Kind::Eq => {
self.advance();
default = Some(self.parse_default_expr()?);
}
Kind::Newline | Kind::RBrace | Kind::End => break,
_ => {
// Skip any stray tokens until end-of-line — resilient to unhandled
// annotation forms like `@check(between 1 and 5)`.
self.advance();
}
}
}
Ok(Field { name, ty, nullable, unique, default, is_relation })
}
/// Returns (type, is_relation) — `ref`/`multi`/`backlink` are relations
/// and don't carry a stored scalar column in this type.
fn parse_field_ty(&mut self) -> Result<(FieldTy, bool)> {
match self.peek() {
Kind::KwRef => {
self.advance();
let target = self.expect_ident("ref target type")?;
Ok((FieldTy::Ref(target), true))
}
Kind::KwMulti => {
self.advance();
let target = self.expect_ident("multi target type")?;
// Either `@edge(:TAG)` or `via LinkType` or nothing (= `@edge(:target_upper)`).
match self.peek() {
Kind::At => {
self.advance();
let ann = self.expect_ident("annotation name")?;
if ann != "edge" {
bail!("line {}: expected @edge, got @{ann}", self.peek_line());
}
self.expect(&Kind::LParen, "'('")?;
self.expect(&Kind::Colon, "':'")?;
let tag = self.expect_ident("edge tag")?;
self.expect(&Kind::RParen, "')'")?;
Ok((FieldTy::MultiEdge { target, tag: Some(tag) }, true))
}
Kind::KwVia => {
self.advance();
let link = self.expect_ident("link type name")?;
Ok((FieldTy::MultiVia { target, link }, true))
}
_ => Ok((FieldTy::MultiEdge { target, tag: None }, true))
}
}
Kind::KwBacklink => {
self.advance();
let target = self.expect_ident("backlink target type")?;
self.expect(&Kind::Dot, "'.'")?;
let field = self.expect_ident("backlink field")?;
Ok((FieldTy::Backlink { target, field }, true))
}
Kind::LBracket => {
self.advance();
let (inner, _) = self.parse_field_ty()?;
self.expect(&Kind::RBracket, "']'")?;
Ok((FieldTy::Array(Box::new(inner)), false))
}
Kind::LBrace => {
self.advance();
let mut fields = Vec::new();
loop {
self.skip_newlines();
if matches!(self.peek(), Kind::RBrace) { self.advance(); break; }
if matches!(self.peek(), Kind::End) { bail!("unexpected end in struct type"); }
fields.push(self.parse_field()?);
// Field end can be comma or just newline
self.accept(&Kind::Comma);
}
Ok((FieldTy::Struct(fields), false))
}
Kind::Ident(_) => {
let first = self.expect_ident("type name")?;
// Try tagged union: IDENT | IDENT | IDENT
if matches!(self.peek(), Kind::Pipe) {
let mut variants = vec![first];
while matches!(self.peek(), Kind::Pipe) {
self.advance();
let next = self.expect_ident("union variant")?;
variants.push(next);
}
Ok((FieldTy::Union(variants), false))
} else {
Ok((FieldTy::Scalar(first), false))
}
}
other => bail!("line {}: expected type, got {other}", self.peek_line()),
}
}
fn parse_default_expr(&mut self) -> Result<DefaultExpr> {
// Fast path: a single literal followed by newline / `}` / `,`.
if let Some(lit) = self.try_standalone_literal() {
return Ok(lit);
}
// `now` or `now()` — recognise before falling into opaque slurp.
if matches!(self.peek(), Kind::Ident(ref s) if s == "now") {
self.advance();
if matches!(self.peek(), Kind::LParen) {
self.advance();
if matches!(self.peek(), Kind::RParen) { self.advance(); }
}
// Only honour if the expression ends here; otherwise fall through
// to the opaque path — `now + 5` etc. becomes opaque.
if matches!(self.peek(), Kind::Newline | Kind::Comma | Kind::RBrace | Kind::End) {
return Ok(DefaultExpr::Now);
}
}
// Otherwise slurp a balanced expression until newline at depth 0.
// This handles `now()`, `count(...)`, `words(meta.body_md)`, `self.xxx`,
// and anything else we haven't modelled explicitly. Stage 2 treats the
// result as opaque for execution, but the parser survives intact.
let mut buf = String::new();
let mut depth = 0i32;
loop {
match self.peek() {
Kind::End => break,
Kind::Newline | Kind::Comma if depth == 0 => break,
Kind::RBrace if depth == 0 => break,
Kind::LBrace | Kind::LBracket | Kind::LParen => {
depth += 1;
let t = self.advance();
buf.push_str(&format!("{} ", t.kind));
}
Kind::RBrace | Kind::RBracket | Kind::RParen => {
depth -= 1;
let t = self.advance();
buf.push_str(&format!("{} ", t.kind));
}
_ => {
let t = self.advance();
buf.push_str(&format!("{} ", t.kind));
}
}
}
let trimmed = buf.trim().to_string();
// Friendly recognition of common forms.
if trimmed.starts_with("now ") || trimmed == "now" {
return Ok(DefaultExpr::Now);
}
Ok(DefaultExpr::Opaque(trimmed))
}
/// If the next token is a self-contained literal (not followed by more
/// expression tokens), consume and return it. Otherwise leave the cursor
/// alone and return None so the opaque path takes over.
fn try_standalone_literal(&mut self) -> Option<DefaultExpr> {
// Peek one ahead to decide if the literal is the whole expression.
let follower_ends_expr = match self.toks.get(self.pos + 1).map(|t| &t.kind) {
Some(Kind::Newline) | Some(Kind::End) | Some(Kind::RBrace) | Some(Kind::Comma) => true,
_ => false,
};
if !follower_ends_expr { return None; }
match self.peek().clone() {
Kind::Str(s) => { self.advance(); Some(DefaultExpr::Str(s)) }
Kind::Int(n) => { self.advance(); Some(DefaultExpr::Int(n)) }
Kind::KwTrue => { self.advance(); Some(DefaultExpr::Bool(true)) }
Kind::KwFalse => { self.advance(); Some(DefaultExpr::Bool(false)) }
Kind::KwNull => { self.advance(); Some(DefaultExpr::Null) }
Kind::Ident(s) => { self.advance(); Some(DefaultExpr::Enum(s)) }
_ => None,
}
}
// --- service ---
fn parse_service(&mut self) -> Result<ServiceDecl> {
self.expect(&Kind::KwService, "`service`")?;
let kind = match self.peek() {
Kind::KwRest => { self.advance(); ServiceKind::Rest }
Kind::KwGraphql => { self.advance(); ServiceKind::Graphql }
Kind::KwNative => { self.advance(); ServiceKind::Native }
Kind::Ident(s) if s == "rest" => { self.advance(); ServiceKind::Rest }
Kind::Ident(s) if s == "graphql" => { self.advance(); ServiceKind::Graphql }
Kind::Ident(s) if s == "native" => { self.advance(); ServiceKind::Native }
other => bail!("line {}: expected `rest`/`graphql`/`native`, got {other}", self.peek_line()),
};
let path = match self.peek().clone() {
Kind::Str(s) => { self.advance(); s }
other => bail!("line {}: expected service path string, got {other}", self.peek_line()),
};
self.skip_newlines();
self.expect(&Kind::KwExpose, "`expose`")?;
let mut expose = Vec::new();
loop {
let name = self.expect_ident("operation name")?;
expose.push(Operation::from_ident(&name));
if !self.accept(&Kind::Comma) { break; }
}
Ok(ServiceDecl { kind, path, expose })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_type() {
let src = r#"
type Article {
id: Id
title: Text
service rest "/api/articles" expose list, get, create
}
"#;
let sch = parse(src).unwrap();
assert_eq!(sch.types.len(), 1);
let t = &sch.types[0];
assert_eq!(t.name, "Article");
assert_eq!(t.fields.len(), 2);
assert_eq!(t.fields[0].name, "id");
assert_eq!(t.fields[1].name, "title");
assert_eq!(t.services.len(), 1);
assert_eq!(t.services[0].path, "/api/articles");
assert_eq!(t.services[0].expose, vec![
Operation::List, Operation::Get, Operation::Create,
]);
}
#[test]
fn parses_nullable_and_unique_and_default() {
let src = r#"
type User {
email: Email @unique
name: Text
avatar: Url?
joined: Timestamp = now()
admin: Bool = false
}
"#;
let sch = parse(src).unwrap();
let t = &sch.types[0];
assert!(t.fields[0].unique);
assert!(t.fields[2].nullable);
assert!(matches!(t.fields[3].default, Some(DefaultExpr::Now)));
assert!(matches!(t.fields[4].default, Some(DefaultExpr::Bool(false))));
}
#[test]
fn parses_relations() {
let src = r#"
type Article {
author: ref User
tags: multi Tag @edge(:TAGGED_AS)
related: multi Article @edge(:RELATED_TO)
}
"#;
let sch = parse(src).unwrap();
let t = &sch.types[0];
assert!(matches!(t.fields[0].ty, FieldTy::Ref(ref n) if n == "User"));
assert!(matches!(t.fields[1].ty, FieldTy::MultiEdge { ref target, .. } if target == "Tag"));
}
#[test]
fn skips_policy_and_on_trigger() {
let src = r#"
type Article {
id: Id
title: Text
policy read anyone
policy write for role Admin
on update when old.published == false and new.published == true
do set self.published_at = now()
service rest "/api/articles" expose list, get
}
"#;
let sch = parse(src).unwrap();
let t = &sch.types[0];
assert_eq!(t.name, "Article");
assert_eq!(t.fields.len(), 2);
assert_eq!(t.services.len(), 1);
}
#[test]
fn parses_inline_struct() {
let src = r#"
type Product {
meta: {
title: Text
tags: [Text]
}
}
"#;
let sch = parse(src).unwrap();
let t = &sch.types[0];
if let FieldTy::Struct(fs) = &t.fields[0].ty {
assert_eq!(fs.len(), 2);
assert!(matches!(fs[1].ty, FieldTy::Array(_)));
} else { panic!("expected struct") }
}
#[test]
fn parses_union() {
let src = r#"
type Order { status: Pending | Paid | Shipped }
"#;
let sch = parse(src).unwrap();
if let FieldTy::Union(v) = &sch.types[0].fields[0].ty {
assert_eq!(v, &vec!["Pending".to_string(), "Paid".into(), "Shipped".into()]);
} else { panic!("expected union") }
}
#[test]
fn article_debug() {
let src = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"), "/../../docs/examples/blog/types/article.wo"
)).unwrap();
let sch = parse(&src).unwrap();
eprintln!("types: {}", sch.types.len());
for t in &sch.types {
eprintln!(" type {} — {} fields, {} services", t.name, t.fields.len(), t.services.len());
for f in &t.fields { eprintln!(" field: {}", f.name); }
for svc in &t.services { eprintln!(" service {:?} {} expose {:?}", svc.kind, svc.path, svc.expose); }
}
assert!(sch.types.iter().any(|t| t.name == "Article" && !t.services.is_empty()),
"Article should have a service");
}
}

217
crates/rt/src/server.rs Normal file
View file

@ -0,0 +1,217 @@
//! Axum REST server built from `service rest` blocks in the compiled catalog.
//!
//! For each type that declares `service rest "/path" expose ...`, we bind the
//! exposed operations at the given path:
//!
//! ```text
//! list GET /path
//! get GET /path/:id
//! create POST /path
//! update PATCH /path/:id
//! delete DELETE /path/:id
//! subscribe stubbed in Stage 2; wires up in Stage 3
//! me GET /path/me (stubbed 501)
//! ```
//!
//! The engine lives behind a single `Arc<Mutex<Engine>>` — matches the
//! single-threaded event-loop design of the runtime (see Phase 2 Concurrency
//! Model).
use crate::ast::{Operation, ServiceKind};
use crate::engine::Engine;
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::Mutex;
pub type Shared = Arc<Mutex<Engine>>;
/// Per-route type context carried via axum `State`.
#[derive(Clone)]
struct TypeState {
engine: Shared,
ty: Arc<String>,
}
/// Build the fully-wired axum `Router` for a running engine.
/// The caller keeps the `Shared` handle alongside for Stage 3 subscription wiring.
pub fn router(engine: Shared, catalog: &crate::compile::Catalog) -> Router {
let mut app = Router::new()
.route("/", get(root))
.route("/healthz", get(|| async { "ok" }));
for name in &catalog.order {
let t = catalog.get(name).expect("type present");
for svc in &t.services {
if svc.kind != ServiceKind::Rest { continue; }
let state = TypeState {
engine: engine.clone(),
ty: Arc::new(t.name.clone()),
};
app = attach_rest(app, state, svc.path.clone(), &svc.expose);
}
}
app
}
async fn root() -> impl IntoResponse {
Json(json!({
"runtime": "wo",
"stage": 2,
"notes": "REST CRUD for each `service rest` block. /healthz for liveness. LIVE subscribe in Stage 3."
}))
}
fn attach_rest(
mut app: Router,
state: TypeState,
path: String,
ops: &[Operation],
) -> Router {
// Build a per-type sub-router with shared state, then merge.
let mut sub = Router::new();
// Collect collection-path handlers and id-path handlers separately so
// axum's `method_routing` merges correctly.
let mut collection = None::<axum::routing::MethodRouter<TypeState>>;
let mut by_id = None::<axum::routing::MethodRouter<TypeState>>;
for op in ops {
match op {
Operation::List => {
collection = Some(match collection.take() {
Some(r) => r.get(list_h),
None => get(list_h),
});
}
Operation::Create => {
collection = Some(match collection.take() {
Some(r) => r.post(create_h),
None => post(create_h),
});
}
Operation::Get => {
by_id = Some(match by_id.take() {
Some(r) => r.get(get_h),
None => get(get_h),
});
}
Operation::Update => {
by_id = Some(match by_id.take() {
Some(r) => r.patch(update_h),
None => axum::routing::patch(update_h),
});
}
Operation::Delete => {
by_id = Some(match by_id.take() {
Some(r) => r.delete(delete_h),
None => axum::routing::delete(delete_h),
});
}
Operation::Subscribe => {
let p = format!("{path}/live");
sub = sub.route(&p, get(subscribe_stub));
}
Operation::Me => {
let p = format!("{path}/me");
sub = sub.route(&p, get(me_stub));
}
Operation::Custom => { /* reserved */ }
}
}
if let Some(r) = collection { sub = sub.route(&path, r); }
if let Some(r) = by_id { sub = sub.route(&format!("{path}/:id"), r); }
let sub = sub.with_state(state);
app = app.merge(sub);
app
}
// --- handlers ---
async fn list_h(State(st): State<TypeState>) -> impl IntoResponse {
let eng = st.engine.lock().await;
match eng.list(&st.ty) {
Ok(rows) => (StatusCode::OK, Json(json!(rows))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn get_h(State(st): State<TypeState>, Path(id): Path<i64>) -> impl IntoResponse {
let eng = st.engine.lock().await;
match eng.get(&st.ty, id) {
Ok(Some(row)) => (StatusCode::OK, Json(json!(row))).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, format!("no {} with id {id}", st.ty)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn create_h(State(st): State<TypeState>, Json(body): Json<Value>) -> impl IntoResponse {
let mut eng = st.engine.lock().await;
match eng.create(&st.ty, body) {
Ok(row) => (StatusCode::CREATED, Json(json!(row))).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
}
}
async fn update_h(State(st): State<TypeState>, Path(id): Path<i64>, Json(body): Json<Value>) -> impl IntoResponse {
let mut eng = st.engine.lock().await;
match eng.update(&st.ty, id, body) {
Ok(Some(row)) => (StatusCode::OK, Json(json!(row))).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, format!("no {} with id {id}", st.ty)).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
}
}
async fn delete_h(State(st): State<TypeState>, Path(id): Path<i64>) -> impl IntoResponse {
let mut eng = st.engine.lock().await;
match eng.delete(&st.ty, id) {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (StatusCode::NOT_FOUND, format!("no {} with id {id}", st.ty)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn subscribe_stub() -> impl IntoResponse {
(StatusCode::NOT_IMPLEMENTED, "LIVE subscriptions arrive in Stage 3")
}
async fn me_stub() -> impl IntoResponse {
(StatusCode::NOT_IMPLEMENTED, "session layer not yet implemented")
}
/// Format the endpoint banner the CLI prints on startup.
pub fn describe_routes(engine: &Engine) -> String {
let mut out = String::new();
out.push_str(" GET / runtime info\n");
out.push_str(" GET /healthz liveness\n");
for name in &engine.catalog().order {
let t = engine.catalog().get(name).unwrap();
for svc in &t.services {
if svc.kind != ServiceKind::Rest { continue; }
for op in &svc.expose {
let (m, p, n) = match op {
Operation::List => ("GET ", svc.path.clone(), format!("list {name}")),
Operation::Get => ("GET ", format!("{}/:id", svc.path), format!("get {name}")),
Operation::Create => ("POST ", svc.path.clone(), format!("create {name}")),
Operation::Update => ("PATCH ", format!("{}/:id", svc.path), format!("update {name}")),
Operation::Delete => ("DELETE", format!("{}/:id", svc.path), format!("delete {name}")),
Operation::Subscribe => ("WS ", format!("{}/live", svc.path), format!("subscribe {name} (Stage 3)")),
Operation::Me => ("GET ", format!("{}/me", svc.path), "(Stage 3)".into()),
Operation::Custom => continue,
};
out.push_str(&format!(" {m} {p:<30} {n}\n"));
}
}
}
out
}

138
crates/rt/src/token.rs Normal file
View file

@ -0,0 +1,138 @@
//! Token definitions for the `.wo` lexer.
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Kind {
// literals
Ident(String),
Str(String),
Int(i64),
Param(String), // $name
// keywords (schema + query layer)
KwType,
KwRef,
KwMulti,
KwVia,
KwBacklink,
KwLink,
KwService,
KwRest,
KwGraphql,
KwNative,
KwExpose,
KwPolicy,
KwFor,
KwRole,
KwWhen,
KwAnyone,
KwOn,
KwDo,
KwSet,
KwCall,
KwEmit,
KwEnqueue,
KwAssert,
KwOtherwise,
KwAbort,
KwReturn,
KwReturning,
KwAs,
KwFn,
KwIn,
KwTxn,
KwSnapshot,
KwSerializable,
KwBegin,
KwCommit,
KwRollback,
KwSavepoint,
KwTo,
KwLive,
KwInsert,
KwInto,
KwValues,
KwUpdate,
KwDelete,
KwSelect,
KwFrom,
KwWhere,
KwMatch,
KwCreate,
KwLet,
KwIf,
KwElse,
KwFor1, // the other `for` — for/each loop (disambiguated at parse time)
KwEach,
KwContains,
KwAnd,
KwOr,
KwNot,
KwTrue,
KwFalse,
KwNull,
KwTest,
KwMain,
KwApp,
KwStartup,
// block markers
HashHash(String), // `##sql`, `##doc`, `##graph`, `##ui`, `##app`, `##policy`, `##service`, `##logic`
Hash(String), // `#table-name`
// punctuation
LBrace, // {
RBrace, // }
LParen, // (
RParen, // )
LBracket, // [
RBracket, // ]
Comma,
Semicolon,
Colon,
Dot,
DotDot, // ..
DotStar, // .* used in `line_items.*.qty`
Question, // ?
At, // @
Pipe, // |
Arrow, // ->
FatArrow, // =>
Dash, // -
Plus,
Star,
Slash,
Percent,
Eq, // =
EqEq, // ==
NotEq, // !=
Lt, LtEq,
Gt, GtEq,
PlusEq, MinusEq,
// meta
Newline,
End,
}
#[derive(Debug, Clone)]
pub struct Token {
pub kind: Kind,
pub line: u32,
pub col: u32,
}
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Kind::Ident(s) => write!(f, "ident({s})"),
Kind::Str(s) => write!(f, "\"{s}\""),
Kind::Int(i) => write!(f, "{i}"),
Kind::Param(s) => write!(f, "${s}"),
Kind::HashHash(s) => write!(f, "##{s}"),
Kind::Hash(s) => write!(f, "#{s}"),
k => write!(f, "{k:?}"),
}
}
}