abstract syntax tree and parser prototype
This commit is contained in:
parent
ccf95a8329
commit
0e91044153
12 changed files with 2413 additions and 153 deletions
543
Cargo.lock
generated
543
Cargo.lock
generated
|
|
@ -9,12 +9,75 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bincode"
|
name = "async-trait"
|
||||||
version = "1.3.3"
|
version = "0.1.89"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||||
dependencies = [
|
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",
|
||||||
|
"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]]
|
[[package]]
|
||||||
|
|
@ -23,6 +86,12 @@ version = "2.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
@ -57,6 +126,48 @@ version = "0.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
|
|
@ -91,6 +202,86 @@ version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
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]]
|
[[package]]
|
||||||
name = "id-arena"
|
name = "id-arena"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
|
|
@ -139,18 +330,53 @@ version = "0.4.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
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]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
|
|
@ -185,6 +411,19 @@ version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rt"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"axum",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"tower 0.4.13",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
|
|
@ -198,6 +437,18 @@ dependencies = [
|
||||||
"windows-sys",
|
"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]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
|
|
@ -247,6 +498,61 @@ dependencies = [
|
||||||
"zmij",
|
"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]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
|
|
@ -258,6 +564,12 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sync_wrapper"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
|
|
@ -271,6 +583,91 @@ dependencies = [
|
||||||
"windows-sys",
|
"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]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
|
|
@ -283,6 +680,12 @@ version = "0.2.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
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]]
|
[[package]]
|
||||||
name = "wasip2"
|
name = "wasip2"
|
||||||
version = "1.0.2+wasi-0.2.9"
|
version = "1.0.2+wasi-0.2.9"
|
||||||
|
|
@ -438,140 +841,6 @@ dependencies = [
|
||||||
"wasmparser",
|
"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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|
|
||||||
48
Cargo.toml
48
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]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/wo-model",
|
"crates/rt",
|
||||||
"crates/wo-seg",
|
|
||||||
"crates/wo-index",
|
# Uncomment as each phase extracts code from `rt/` into its target crate.
|
||||||
"crates/wo-store",
|
# See docs/plan/02..08 for the sequence.
|
||||||
"crates/wo-watch",
|
#
|
||||||
"crates/wo-event",
|
# "crates/ql", # phase 02 target — lexer / parser / AST
|
||||||
"crates/wo-sub",
|
# "crates/value", # phase 02 target — tagged Value + path helpers
|
||||||
"crates/wo-rt",
|
# "crates/engine", # phase 02 target — rel/doc/graph executor
|
||||||
"crates/wo-http",
|
# "crates/txn", # phase 02 target — MVCC + RETURNING alias table
|
||||||
"crates/wo-route",
|
# "crates/db", # phase 02 target — top-level facade
|
||||||
"crates/wo-htmlx",
|
# "crates/wal", # phase 03 target — io_uring + fsync WAL
|
||||||
"crates/wo-md",
|
# "crates/sub", # phase 04 target — LIVE subscriptions
|
||||||
"crates/wo-serve",
|
# "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]
|
[workspace.dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
bincode = "1"
|
anyhow = "1"
|
||||||
libc = "0.2"
|
|
||||||
|
|
|
||||||
24
crates/rt/Cargo.toml
Normal file
24
crates/rt/Cargo.toml
Normal 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
106
crates/rt/src/ast.rs
Normal 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
126
crates/rt/src/bin/wo.rs
Normal 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
76
crates/rt/src/compile.rs
Normal 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
251
crates/rt/src/engine.rs
Normal 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
342
crates/rt/src/lexer.rs
Normal 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
96
crates/rt/src/lib.rs
Normal 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
597
crates/rt/src/parser.rs
Normal 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
217
crates/rt/src/server.rs
Normal 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
138
crates/rt/src/token.rs
Normal 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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue