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"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -23,6 +86,12 @@ version = "2.11.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
|
|
@ -57,6 +126,48 @@ version = "0.1.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
|
|
@ -91,6 +202,86 @@ version = "0.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
|
|
@ -139,18 +330,53 @@ version = "0.4.29"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
|
|
@ -185,6 +411,19 @@ version = "6.0.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rt"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
|
|
@ -198,6 +437,18 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
|
|
@ -247,6 +498,61 @@ dependencies = [
|
|||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
|
|
@ -258,6 +564,12 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
|
|
@ -271,6 +583,91 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||
dependencies = [
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
|
@ -283,6 +680,12 @@ version = "0.2.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
|
|
@ -438,140 +841,6 @@ dependencies = [
|
|||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wo-event"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wo-htmlx"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"wo-md",
|
||||
"wo-model",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wo-http"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wo-index"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"libc",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"wo-model",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wo-md"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "wo-model"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"wo-md",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wo-route"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wo-http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wo-rt"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"wo-event",
|
||||
"wo-htmlx",
|
||||
"wo-http",
|
||||
"wo-md",
|
||||
"wo-model",
|
||||
"wo-route",
|
||||
"wo-serve",
|
||||
"wo-store",
|
||||
"wo-sub",
|
||||
"wo-watch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wo-seg"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"libc",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"wo-model",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wo-serve"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"tempfile",
|
||||
"wo-http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wo-store"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"wo-index",
|
||||
"wo-model",
|
||||
"wo-seg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wo-sub"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"wo-event",
|
||||
"wo-model",
|
||||
"wo-store",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wo-watch"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"tempfile",
|
||||
"wo-store",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
|
|
|||
50
Cargo.toml
50
Cargo.toml
|
|
@ -1,23 +1,41 @@
|
|||
# Root workspace — the new `.wo` runtime.
|
||||
#
|
||||
# Only `crates/rt` carries real code today (Stage 2 of the runtime); the
|
||||
# fourteen sibling crates are empty skeletons populated phase-by-phase per
|
||||
# docs/plan/done/01-scafolding-crates.md. They are commented out of the
|
||||
# workspace until their phase activates — uncomment each one as code lands.
|
||||
#
|
||||
# The v1 writeonce blog crates at `reference/crates/` are a separate nested
|
||||
# workspace, excluded here so the root build stays focused on the new runtime.
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/wo-model",
|
||||
"crates/wo-seg",
|
||||
"crates/wo-index",
|
||||
"crates/wo-store",
|
||||
"crates/wo-watch",
|
||||
"crates/wo-event",
|
||||
"crates/wo-sub",
|
||||
"crates/wo-rt",
|
||||
"crates/wo-http",
|
||||
"crates/wo-route",
|
||||
"crates/wo-htmlx",
|
||||
"crates/wo-md",
|
||||
"crates/wo-serve",
|
||||
"crates/rt",
|
||||
|
||||
# Uncomment as each phase extracts code from `rt/` into its target crate.
|
||||
# See docs/plan/02..08 for the sequence.
|
||||
#
|
||||
# "crates/ql", # phase 02 target — lexer / parser / AST
|
||||
# "crates/value", # phase 02 target — tagged Value + path helpers
|
||||
# "crates/engine", # phase 02 target — rel/doc/graph executor
|
||||
# "crates/txn", # phase 02 target — MVCC + RETURNING alias table
|
||||
# "crates/db", # phase 02 target — top-level facade
|
||||
# "crates/wal", # phase 03 target — io_uring + fsync WAL
|
||||
# "crates/sub", # phase 04 target — LIVE subscriptions
|
||||
# "crates/http", # phase 04 target — wire protocol + router
|
||||
# "crates/gen", # phase 05 target — client SDK codegen
|
||||
# "crates/policy", # phase 06 target — RBAC planner rewrites
|
||||
# "crates/logic", # phase 06 target — triggers + fn interpreter
|
||||
# "crates/service", # phase 06 target — endpoint dispatch
|
||||
# "crates/ui", # phase 06 target — ##ui SSR + client runtime
|
||||
# "crates/app", # phase 06 target — ##app manifest
|
||||
]
|
||||
exclude = [
|
||||
"reference/crates",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
bincode = "1"
|
||||
libc = "0.2"
|
||||
anyhow = "1"
|
||||
|
|
|
|||
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