From 0e910441535e0bdc2e680a32b203b36ccc14c843 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Tue, 21 Apr 2026 03:28:25 +0200 Subject: [PATCH] abstract syntax tree and parser prototype --- Cargo.lock | 543 ++++++++++++++++++++++++++--------- Cargo.toml | 50 ++-- crates/rt/Cargo.toml | 24 ++ crates/rt/src/ast.rs | 106 +++++++ crates/rt/src/bin/wo.rs | 126 +++++++++ crates/rt/src/compile.rs | 76 +++++ crates/rt/src/engine.rs | 251 ++++++++++++++++ crates/rt/src/lexer.rs | 342 ++++++++++++++++++++++ crates/rt/src/lib.rs | 96 +++++++ crates/rt/src/parser.rs | 597 +++++++++++++++++++++++++++++++++++++++ crates/rt/src/server.rs | 217 ++++++++++++++ crates/rt/src/token.rs | 138 +++++++++ 12 files changed, 2413 insertions(+), 153 deletions(-) create mode 100644 crates/rt/Cargo.toml create mode 100644 crates/rt/src/ast.rs create mode 100644 crates/rt/src/bin/wo.rs create mode 100644 crates/rt/src/compile.rs create mode 100644 crates/rt/src/engine.rs create mode 100644 crates/rt/src/lexer.rs create mode 100644 crates/rt/src/lib.rs create mode 100644 crates/rt/src/parser.rs create mode 100644 crates/rt/src/server.rs create mode 100644 crates/rt/src/token.rs diff --git a/Cargo.lock b/Cargo.lock index 94286e6..9d7d3cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index ab5cd08..618937d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/rt/Cargo.toml b/crates/rt/Cargo.toml new file mode 100644 index 0000000..891836e --- /dev/null +++ b/crates/rt/Cargo.toml @@ -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" diff --git a/crates/rt/src/ast.rs b/crates/rt/src/ast.rs new file mode 100644 index 0000000..545d123 --- /dev/null +++ b/crates/rt/src/ast.rs @@ -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, +} + +#[derive(Debug, Clone)] +pub struct TypeDecl { + pub name: String, + pub fields: Vec, + pub services: Vec, +} + +#[derive(Debug, Clone)] +pub struct Field { + pub name: String, + pub ty: FieldTy, + pub nullable:bool, + pub unique: bool, + pub default: Option, + /// 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), + /// `{ k: T, ... }` — inline embedded-document struct. + Struct(Vec), + /// Tagged union: `A | B | C`. Stage 2 stores variants as strings. + Union(Vec), + /// `ref TypeName` — scalar FK to another type. + Ref(String), + /// `multi TypeName @edge(:TAG)` — zero-prop graph edge. + MultiEdge { target: String, tag: Option }, + /// `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, +} + +#[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); + } +} diff --git a/crates/rt/src/bin/wo.rs b/crates/rt/src/bin/wo.rs new file mode 100644 index 0000000..739e99b --- /dev/null +++ b/crates/rt/src/bin/wo.rs @@ -0,0 +1,126 @@ +//! `wo` — the writeonce toolchain binary. +//! +//! Stage 2 scope: +//! wo run — discover `.wo` files under , 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 parse .wo files under , 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 = 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 { + // 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)) +} diff --git a/crates/rt/src/compile.rs b/crates/rt/src/compile.rs new file mode 100644 index 0000000..96b6eb2 --- /dev/null +++ b/crates/rt/src/compile.rs @@ -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, + /// Preserves declaration order so REST route registration is deterministic. + pub order: Vec, +} + +#[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, + pub services: Vec, + /// 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) -> Result { + 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); + } +} diff --git a/crates/rt/src/engine.rs b/crates/rt/src/engine.rs new file mode 100644 index 0000000..b9e8756 --- /dev/null +++ b/crates/rt/src/engine.rs @@ -0,0 +1,251 @@ +//! In-memory CRUD engine for the compiled schema. +//! +//! Storage model: `HashMap>`. 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; + +#[derive(Debug, Default)] +pub struct Engine { + catalog: Catalog, + /// type_name → { id → row } + tables: std::collections::HashMap>, + /// per-type id allocator + next_id: std::collections::HashMap, +} + +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> { + self.table(ty).map(|t| t.values().cloned().collect()) + } + + pub fn get(&self, ty: &str, id: i64) -> Result> { + 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 { + 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> { + 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 { + 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> { + 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 { + 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); + } +} diff --git a/crates/rt/src/lexer.rs b/crates/rt/src/lexer.rs new file mode 100644 index 0000000..ba9d0c1 --- /dev/null +++ b/crates/rt/src/lexer.rs @@ -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> { + 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 { self.bytes.get(self.pos).copied() } + fn peek_at(&self, n: usize) -> Option { self.bytes.get(self.pos + n).copied() } + + fn advance(&mut self) -> Option { + 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> { + 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")); + } +} diff --git a/crates/rt/src/lib.rs b/crates/rt/src/lib.rs new file mode 100644 index 0000000..6659ecb --- /dev/null +++ b/crates/rt/src/lib.rs @@ -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> { + 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) -> 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::>()); + assert!(files.iter().any(|f| f.rel == PathBuf::from("app.wo"))); + assert!(files.iter().any(|f| f.rel == PathBuf::from("types/article.wo"))); + } +} diff --git a/crates/rt/src/parser.rs b/crates/rt/src/parser.rs new file mode 100644 index 0000000..fb6b402 --- /dev/null +++ b/crates/rt/src/parser.rs @@ -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 `, +//! 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 { + let toks = tokenize(src).context("tokenize")?; + Parser::new(toks).parse_schema() +} + +struct Parser { + toks: Vec, + pos: usize, +} + +impl Parser { + fn new(toks: Vec) -> 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 { + 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 { + 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 { + 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 ... + 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 [when ...] do ` 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 { + 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 { + // 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 { + // 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 { + 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"); + } +} diff --git a/crates/rt/src/server.rs b/crates/rt/src/server.rs new file mode 100644 index 0000000..d6c7de7 --- /dev/null +++ b/crates/rt/src/server.rs @@ -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>` — 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>; + +/// Per-route type context carried via axum `State`. +#[derive(Clone)] +struct TypeState { + engine: Shared, + ty: Arc, +} + +/// 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::>; + let mut by_id = None::>; + + 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) -> 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, Path(id): Path) -> 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, Json(body): Json) -> 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, Path(id): Path, Json(body): Json) -> 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, Path(id): Path) -> 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 +} diff --git a/crates/rt/src/token.rs b/crates/rt/src/token.rs new file mode 100644 index 0000000..83d5c50 --- /dev/null +++ b/crates/rt/src/token.rs @@ -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:?}"), + } + } +}