From 336de22d7e688e24d7d7ca388280326ee79867c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:46:06 +0000 Subject: [PATCH 01/11] Rust crate: parse, modules, check, signatures and the resolved model Starts the Rust port (spec/11 CP-1a) with its front half, per issue #1: compiler/ is the `reactivescript` crate (binary `rxc`), a port of ref/src/{parse,modules,content,signature,check}.ts, plus the resolved model between check and lower (new CP-6): per module its regions, symbols and computations, each with reads (tracked / snapshot / pre, with probes and fields), writes and calls. ref/ records the same model during the checker's walk (src/model.ts, model.ts CLI). ref/crate.ts diffs the crate against ref/ on every program in scenarios/ and examples/ (280 programs, 343 modules): AST, diagnostics with messages, printed signatures and models all agree. spec/11 gains CP-6 (the model) and CP-7 (the crate today), with two decisions taken by default. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWMdD4K7882vob4J9vq --- .gitignore | 1 + compiler/Cargo.lock | 175 ++ compiler/Cargo.toml | 23 + compiler/README.md | 26 + compiler/src/ast.rs | 576 +++++++ compiler/src/check.rs | 3410 +++++++++++++++++++++++++++++++++++++ compiler/src/content.rs | 146 ++ compiler/src/lib.rs | 11 + compiler/src/main.rs | 121 ++ compiler/src/model.rs | 253 +++ compiler/src/modules.rs | 152 ++ compiler/src/parse.rs | 1663 ++++++++++++++++++ compiler/src/signature.rs | 506 ++++++ ref/README.md | 10 + ref/crate.ts | 74 + ref/model.ts | 16 + ref/package.json | 4 +- ref/src/check.ts | 130 +- ref/src/model.ts | 66 + spec/11-compiler.md | 60 + 20 files changed, 7364 insertions(+), 59 deletions(-) create mode 100644 compiler/Cargo.lock create mode 100644 compiler/Cargo.toml create mode 100644 compiler/README.md create mode 100644 compiler/src/ast.rs create mode 100644 compiler/src/check.rs create mode 100644 compiler/src/content.rs create mode 100644 compiler/src/lib.rs create mode 100644 compiler/src/main.rs create mode 100644 compiler/src/model.rs create mode 100644 compiler/src/modules.rs create mode 100644 compiler/src/parse.rs create mode 100644 compiler/src/signature.rs create mode 100644 ref/crate.ts create mode 100644 ref/model.ts create mode 100644 ref/src/model.ts diff --git a/.gitignore b/.gitignore index c2658d7..8fbf432 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +compiler/target/ diff --git a/compiler/Cargo.lock b/compiler/Cargo.lock new file mode 100644 index 0000000..7a4159d --- /dev/null +++ b/compiler/Cargo.lock @@ -0,0 +1,175 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "reactivescript" +version = "0.0.0" +dependencies = [ + "indexmap", + "regex", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/compiler/Cargo.toml b/compiler/Cargo.toml new file mode 100644 index 0000000..5db6ef6 --- /dev/null +++ b/compiler/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "reactivescript" +version = "0.0.0" +edition = "2021" +description = "ReactiveScript's compiler (spec/11 CP-1a): parse, check, resolve. Lowering onto Solid's solidjs-compiler comes next." +license = "MIT" +publish = false + +[lib] +name = "reactivescript" +path = "src/lib.rs" + +[[bin]] +name = "rxc" +path = "src/main.rs" + +[dependencies] +indexmap = "2" +regex = "1" +serde_json = { version = "1", features = ["preserve_order"] } + +[profile.release] +debug = false diff --git a/compiler/README.md b/compiler/README.md new file mode 100644 index 0000000..55a5cd8 --- /dev/null +++ b/compiler/README.md @@ -0,0 +1,26 @@ +# compiler — ReactiveScript's crate + +The production compiler (spec/11 CP-1a): a Rust crate, `reactivescript`, with +the command line `rxc`. So far it has the front half — parse (07), modules (09), +check (02–06, 08), signatures (MO-9) and the resolved model between check and +lower (CP-6) — ported from `ref/src/` so that it gives the same answers. +Lowering to `oxc_ast::Program` for Solid's `solidjs-compiler` comes next. + +| module | port of | | +| --- | --- | --- | +| `parse.rs`, `ast.rs` | `ref/src/parse.ts`, `ast.ts` | `.rx` and `.d.rx` declarations; the AST serialises as the reference's does | +| `modules.rs` | `modules.ts` | the module graph, MO-5 cycles, MO-6 missing modules, MO-8/MO-9 declaration files | +| `check.rs` | `check.ts` | every diagnostic, with the reference's messages | +| `signature.rs` | `signature.ts` | a module's signature, printed and read back as `.d.rx` | +| `content.rs` | `content.ts` | SV-7d HTML nesting | +| `model.rs` | `model.ts` | CP-6: regions, symbols, computations with their reads, writes and calls | + +```sh +cargo build --release +target/release/rxc check ../examples/todo.rx # diagnostics +target/release/rxc model ../examples/todo.rx # the resolved model, as JSON +npm --prefix ../ref run crate # CP-5: the crate against ref/ on every program +``` + +`ref/` stays the specification: a change of behaviour lands there first (with +its scenario), then here, and `ref/crate.ts` keeps the two equal. diff --git a/compiler/src/ast.rs b/compiler/src/ast.rs new file mode 100644 index 0000000..851b43f --- /dev/null +++ b/compiler/src/ast.rs @@ -0,0 +1,576 @@ +//! The abstract syntax (spec/02-core.md §1), as `ref/src/ast.ts` has it. Operators are spelled as in JS; types are TS's, +//! kept as text (07 §2). `to_json` writes a node the way `JSON.stringify` writes the reference's, so the two parsers are +//! diffed node for node (`ref/crate.ts`). + +use serde_json::{json, Map, Value}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Lit { + Undefined, + Null, + Bool(bool), + Num(f64), + Str(String), +} + +#[derive(Debug, Clone)] +pub enum TplPart { + Str(String), + Expr(Expr), +} + +#[derive(Debug, Clone)] +pub enum Field { + Kv { key: String, v: Expr }, + Spread(Expr), +} + +#[derive(Debug, Clone)] +pub enum Expr { + Lit(Lit), + Id(String), + Bin { op: String, l: Box, r: Box }, + Un { op: String, e: Box }, + Pre { name: String, init: Option> }, + Peek(Box), + /// R-1, R-7; `stream`: `await*` (ST-6b) + Await { e: Box, init: Option>, stream: bool }, + Latest(Box), + Pending(Box), + If { c: Box, t: Box, f: Box }, + Tpl(Vec), + Member { o: Box, p: String, opt: bool }, + Index { o: Box, i: Box, opt: bool }, + Call { f: Box, args: Vec }, + Arr(Vec), + Rec(Vec), + /// `stmts`: a statement-bodied arrow (imperative scope, 07 §5) + Fn { params: Vec, body: Box, stmts: Option> }, + /// ST-3 + Draft { seed: Box, body: Vec }, + /// RG-7b + Use(String), + Spread(Box), + /// R-f-3 + Throw(Box), + /// R-f-4: `e catch (x: A | B) => alt`; classes absent: every failure + Catch { e: Box, name: Option, classes: Option>, alt: Box }, + /// 07 CSS-5 + StyleMap { css: String, line: usize }, + /// 12 SF-1d + ServerFn { params: Vec, ptypes: Vec>, ret: Option, body: Vec }, +} + +#[derive(Debug, Clone)] +pub enum PathSeg { + Name(String), + Expr(Expr), +} + +#[derive(Debug, Clone)] +pub struct Target { + pub name: String, + pub path: Vec, + /// `use name := …` (RG-7, O-4). `None`: not written by this form (an `optimistic` target). + pub ctx: Option, +} +impl Target { + pub fn is_ctx(&self) -> bool { + self.ctx == Some(true) + } +} + +#[derive(Debug, Clone)] +pub enum Stmt { + Write { target: Target, e: Expr }, + Let { name: String, e: Expr, ty: Option }, + Expr(Expr), + Optimistic { target: Target, e: Expr }, + Refresh(String), + Affects(String), + Action(Vec), + Sif { c: Expr, t: Vec, f: Vec }, + Return(Option), + Try { body: Vec, catch_name: Option, catch_classes: Option>, handler: Option>, fin: Option> }, + Throw(Expr), +} +impl Stmt { + pub fn kind(&self) -> &'static str { + match self { + Stmt::Write { .. } => "write", + Stmt::Let { .. } => "let", + Stmt::Expr(_) => "expr", + Stmt::Optimistic { .. } => "optimistic", + Stmt::Refresh(_) => "refresh", + Stmt::Affects(_) => "affects", + Stmt::Action(_) => "action", + Stmt::Sif { .. } => "sif", + Stmt::Return(_) => "return", + Stmt::Try { .. } => "try", + Stmt::Throw(_) => "throw", + } + } +} + +#[derive(Debug, Clone)] +pub struct Param { + pub name: String, + pub r#ref: bool, + pub ty: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + Server, + Client, +} +impl Side { + pub fn as_str(self) -> &'static str { + match self { + Side::Server => "server", + Side::Client => "client", + } + } +} + +#[derive(Debug, Clone)] +pub struct ImportName { + pub name: String, + pub alias: String, +} + +#[derive(Debug, Clone)] +pub enum Decl { + Source { name: String, init: Expr, line: usize, question: bool, ty: Option, exported: bool, key: Option }, + Eq { name: String, e: Expr, line: usize, deferred: bool, key: Option, ty: Option, exported: bool }, + Import { + names: Vec, + from: String, + pure: bool, + side: Option, + line: usize, + fails: Option>, + /// an `.rx` import's module key (MO-1, set by the loader) + resolved: Option, + /// a JS import's declaration file (MO-8) + declared: Option, + declared_errors: Option>, + declared_pure: Option>, + declared_side: Option>, + }, + Error { name: String, params: Vec, ptypes: Vec>, msg: Option, line: usize, exported: bool }, + TypeDecl { name: String, line: usize, text: String, exported: bool }, + Effect { inputs: Vec, body: Vec, line: usize }, + Fn { name: String, params: Vec, decls: Vec, tail: Expr, line: usize, ret: Option, exported: bool }, + Component { name: String, props: String, decls: Vec, view: Vec, line: usize, props_type: Option, exported: bool, is_default: bool }, + Action { name: String, params: Vec, ptypes: Vec>, body: Vec, line: usize, exported: bool }, + Server { name: String, params: Vec, ptypes: Vec>, ret: Option, body: Vec, line: usize, exported: bool }, + Provide { name: String, e: Expr, line: usize }, + Context { name: String, ty: Option, source: bool, dflt: Option, line: usize, exported: bool }, +} +impl Decl { + pub fn line(&self) -> usize { + match self { + Decl::Source { line, .. } + | Decl::Eq { line, .. } + | Decl::Import { line, .. } + | Decl::Error { line, .. } + | Decl::TypeDecl { line, .. } + | Decl::Effect { line, .. } + | Decl::Fn { line, .. } + | Decl::Component { line, .. } + | Decl::Action { line, .. } + | Decl::Server { line, .. } + | Decl::Provide { line, .. } + | Decl::Context { line, .. } => *line, + } + } + /// The name a declaration binds (`"name" in d`): every kind but `import` and `effect`. + pub fn name(&self) -> Option<&str> { + match self { + Decl::Source { name, .. } + | Decl::Eq { name, .. } + | Decl::Error { name, .. } + | Decl::TypeDecl { name, .. } + | Decl::Fn { name, .. } + | Decl::Component { name, .. } + | Decl::Action { name, .. } + | Decl::Server { name, .. } + | Decl::Provide { name, .. } + | Decl::Context { name, .. } => Some(name), + Decl::Import { .. } | Decl::Effect { .. } => None, + } + } + pub fn exported(&self) -> bool { + match self { + Decl::Source { exported, .. } + | Decl::Eq { exported, .. } + | Decl::Error { exported, .. } + | Decl::TypeDecl { exported, .. } + | Decl::Fn { exported, .. } + | Decl::Component { exported, .. } + | Decl::Action { exported, .. } + | Decl::Server { exported, .. } + | Decl::Context { exported, .. } => *exported, + _ => false, + } + } + pub fn set_exported(&mut self) { + match self { + Decl::Source { exported, .. } + | Decl::Eq { exported, .. } + | Decl::Error { exported, .. } + | Decl::TypeDecl { exported, .. } + | Decl::Fn { exported, .. } + | Decl::Component { exported, .. } + | Decl::Action { exported, .. } + | Decl::Server { exported, .. } + | Decl::Context { exported, .. } => *exported = true, + _ => {} + } + } +} + +#[derive(Debug, Clone)] +pub struct Attr { + pub name: String, + pub e: Expr, +} + +#[derive(Debug, Clone)] +pub enum MarkupKind { + El { tag: String, attrs: Vec, children: Vec }, + Read(Expr), + Text(String), + /// 07 §6b CSS-1 + Style(String), + Mif { c: Expr, t: Vec, t_decls: Vec, f: Vec, f_decls: Vec }, + Mfor { name: String, xs: Expr, decls: Vec, body: Vec }, + /// H-5/H-6 + Loading { on: Option, fallback: Vec, decls: Vec, body: Vec }, + /// H-12, R-f-4 + Errored { fallback: Vec, decls: Vec, body: Vec, err_name: Option, classes: Option> }, + /// RG-8 + Portal { to: Option, decls: Vec, body: Vec }, +} + +#[derive(Debug, Clone)] +pub struct Markup { + pub kind: MarkupKind, + pub line: Option, +} + +#[derive(Debug, Clone)] +pub struct Program { + pub decls: Vec, + pub view: Vec, + /// markup written at the top level (refused, TOP_LEVEL_MARKUP); `None` when the view is the default component's (MO-1c) + pub legacy_view: Option, +} + +// ---------------------------------------------------------------- JSON, as JSON.stringify writes the reference's AST + +fn obj(pairs: Vec<(&str, Option)>) -> Value { + let mut m = Map::new(); + for (k, v) in pairs { + if let Some(v) = v { + m.insert(k.to_string(), v); + } + } + Value::Object(m) +} +fn s(x: &str) -> Option { + Some(Value::String(x.to_string())) +} +fn os(x: &Option) -> Option { + x.as_ref().map(|x| Value::String(x.clone())) +} +fn strs(xs: &[String]) -> Value { + Value::Array(xs.iter().map(|x| Value::String(x.clone())).collect()) +} +fn ostrs(xs: &[Option]) -> Value { + Value::Array(xs.iter().map(|x| x.as_ref().map(|x| Value::String(x.clone())).unwrap_or(Value::Null)).collect()) +} +fn flag(b: bool) -> Option { + if b { Some(Value::Bool(true)) } else { None } +} +fn num(n: usize) -> Option { + Some(json!(n)) +} + +pub fn lit_json(l: &Lit) -> Option { + match l { + Lit::Undefined => None, + Lit::Null => Some(Value::Null), + Lit::Bool(b) => Some(Value::Bool(*b)), + Lit::Num(n) => Some(num_json(*n)), + Lit::Str(x) => Some(Value::String(x.clone())), + } +} +pub fn num_json(n: f64) -> Value { + if n.fract() == 0.0 && n.abs() < 9.0e15 { json!(n as i64) } else { json!(n) } +} + +impl Expr { + pub fn kind(&self) -> &'static str { + match self { + Expr::Lit(_) => "lit", + Expr::Id(_) => "id", + Expr::Bin { .. } => "bin", + Expr::Un { .. } => "un", + Expr::Pre { .. } => "pre", + Expr::Peek(_) => "peek", + Expr::Await { .. } => "await", + Expr::Latest(_) => "latest", + Expr::Pending(_) => "pending", + Expr::If { .. } => "if", + Expr::Tpl(_) => "tpl", + Expr::Member { .. } => "member", + Expr::Index { .. } => "index", + Expr::Call { .. } => "call", + Expr::Arr(_) => "arr", + Expr::Rec(_) => "rec", + Expr::Fn { .. } => "fn", + Expr::Draft { .. } => "draft", + Expr::Use(_) => "use", + Expr::Spread(_) => "spread", + Expr::Throw(_) => "throw", + Expr::Catch { .. } => "catch", + Expr::StyleMap { .. } => "stylemap", + Expr::ServerFn { .. } => "serverfn", + } + } + pub fn to_json(&self) -> Value { + let k = s(self.kind()); + let b = |e: &Expr| Some(e.to_json()); + let ob = |e: &Option>| e.as_ref().map(|e| e.to_json()); + match self { + Expr::Lit(l) => obj(vec![("k", k), ("v", lit_json(l))]), + Expr::Id(n) => obj(vec![("k", k), ("name", s(n))]), + Expr::Bin { op, l, r } => obj(vec![("k", k), ("op", s(op)), ("l", b(l)), ("r", b(r))]), + Expr::Un { op, e } => obj(vec![("k", k), ("op", s(op)), ("e", b(e))]), + Expr::Pre { name, init } => obj(vec![("k", k), ("name", s(name)), ("init", ob(init))]), + Expr::Peek(e) | Expr::Latest(e) | Expr::Pending(e) | Expr::Spread(e) | Expr::Throw(e) => obj(vec![("k", k), ("e", b(e))]), + Expr::Await { e, init, stream } => obj(vec![("k", k), ("e", b(e)), ("init", ob(init)), ("stream", Some(Value::Bool(*stream)))]), + Expr::If { c, t, f } => obj(vec![("k", k), ("c", b(c)), ("t", b(t)), ("f", b(f))]), + Expr::Tpl(parts) => obj(vec![ + ("k", k), + ("parts", Some(Value::Array(parts.iter().map(|p| match p { TplPart::Str(x) => Value::String(x.clone()), TplPart::Expr(e) => e.to_json() }).collect()))), + ]), + Expr::Member { o, p, opt } => obj(vec![("k", k), ("o", b(o)), ("p", s(p)), ("opt", flag(*opt))]), + Expr::Index { o, i, opt } => obj(vec![("k", k), ("o", b(o)), ("i", b(i)), ("opt", flag(*opt))]), + Expr::Call { f, args } => obj(vec![("k", k), ("f", b(f)), ("args", Some(Value::Array(args.iter().map(|a| a.to_json()).collect())))]), + Expr::Arr(items) => obj(vec![("k", k), ("items", Some(Value::Array(items.iter().map(|a| a.to_json()).collect())))]), + Expr::Rec(fields) => obj(vec![ + ("k", k), + ( + "fields", + Some(Value::Array( + fields + .iter() + .map(|f| match f { + Field::Kv { key, v } => obj(vec![("key", s(key)), ("v", Some(v.to_json()))]), + Field::Spread(e) => obj(vec![("spread", Some(e.to_json()))]), + }) + .collect(), + )), + ), + ]), + Expr::Fn { params, body, stmts } => obj(vec![("k", k), ("params", Some(strs(params))), ("body", b(body)), ("stmts", stmts.as_ref().map(|ss| stmts_json(ss)))]), + Expr::Draft { seed, body } => obj(vec![("k", k), ("seed", b(seed)), ("body", Some(stmts_json(body)))]), + Expr::Use(n) => obj(vec![("k", k), ("name", s(n))]), + Expr::Catch { e, name, classes, alt } => obj(vec![("k", k), ("e", b(e)), ("name", os(name)), ("classes", classes.as_ref().map(|c| strs(c))), ("alt", b(alt))]), + Expr::StyleMap { css, line } => obj(vec![("k", k), ("css", s(css)), ("line", num(*line))]), + Expr::ServerFn { params, ptypes, ret, body } => obj(vec![("k", k), ("params", Some(strs(params))), ("ptypes", Some(ostrs(ptypes))), ("ret", os(ret)), ("body", Some(stmts_json(body)))]), + } + } +} + +pub fn stmts_json(ss: &[Stmt]) -> Value { + Value::Array(ss.iter().map(|s| s.to_json()).collect()) +} +fn target_json(t: &Target) -> Value { + obj(vec![ + ("name", s(&t.name)), + ("path", Some(Value::Array(t.path.iter().map(|p| match p { PathSeg::Name(n) => Value::String(n.clone()), PathSeg::Expr(e) => e.to_json() }).collect()))), + ("ctx", t.ctx.map(Value::Bool)), + ]) +} +impl Stmt { + pub fn to_json(&self) -> Value { + let k = s(self.kind()); + match self { + Stmt::Write { target, e } | Stmt::Optimistic { target, e } => obj(vec![("k", k), ("target", Some(target_json(target))), ("e", Some(e.to_json()))]), + Stmt::Let { name, e, ty } => obj(vec![("k", k), ("name", s(name)), ("e", Some(e.to_json())), ("type", os(ty))]), + Stmt::Expr(e) | Stmt::Throw(e) => obj(vec![("k", k), ("e", Some(e.to_json()))]), + Stmt::Refresh(n) | Stmt::Affects(n) => obj(vec![("k", k), ("name", s(n))]), + Stmt::Action(body) => obj(vec![("k", k), ("body", Some(stmts_json(body)))]), + Stmt::Sif { c, t, f } => obj(vec![("k", k), ("c", Some(c.to_json())), ("t", Some(stmts_json(t))), ("f", Some(stmts_json(f)))]), + Stmt::Return(e) => obj(vec![("k", k), ("e", e.as_ref().map(|e| e.to_json()))]), + Stmt::Try { body, catch_name, catch_classes, handler, fin } => obj(vec![ + ("k", k), + ("body", Some(stmts_json(body))), + ("catchName", os(catch_name)), + ("catchClasses", catch_classes.as_ref().map(|c| strs(c))), + ("handler", handler.as_ref().map(|h| stmts_json(h))), + ("fin", fin.as_ref().map(|h| stmts_json(h))), + ]), + } + } +} + +pub fn decls_json(ds: &[Decl]) -> Value { + Value::Array(ds.iter().map(|d| d.to_json()).collect()) +} +pub fn markups_json(ms: &[Markup]) -> Value { + Value::Array(ms.iter().map(|m| m.to_json()).collect()) +} +impl Decl { + pub fn to_json(&self) -> Value { + match self { + Decl::Source { name, init, line, question, ty, exported, key } => obj(vec![ + ("k", s("source")), + ("name", s(name)), + ("init", Some(init.to_json())), + ("line", num(*line)), + ("question", flag(*question)), + ("type", os(ty)), + ("key", os(key)), + ("exported", flag(*exported)), + ]), + Decl::Eq { name, e, line, deferred, key, ty, exported } => obj(vec![ + ("k", s("eq")), + ("name", s(name)), + ("e", Some(e.to_json())), + ("line", num(*line)), + ("deferred", flag(*deferred)), + ("key", os(key)), + ("type", os(ty)), + ("exported", flag(*exported)), + ]), + Decl::Import { names, from, pure, side, line, fails, resolved, declared, declared_errors, declared_pure, declared_side } => obj(vec![ + ("k", s("import")), + ("names", Some(Value::Array(names.iter().map(|n| obj(vec![("name", s(&n.name)), ("alias", s(&n.alias))])).collect()))), + ("from", s(from)), + ("pure", Some(Value::Bool(*pure))), + ("line", num(*line)), + ("fails", fails.as_ref().map(|f| strs(f))), + ("side", side.map(|x| Value::String(x.as_str().into()))), + ("resolved", os(resolved)), + ("declared", os(declared)), + ("declaredErrors", declared_errors.as_ref().map(|f| strs(f))), + ("declaredPure", declared_pure.as_ref().map(|f| strs(f))), + ( + "declaredSide", + declared_side.as_ref().map(|xs| { + let mut m = Map::new(); + for (n, sd) in xs { + m.insert(n.clone(), Value::String(sd.as_str().into())); + } + Value::Object(m) + }), + ), + ]), + Decl::Error { name, params, ptypes, msg, line, exported } => obj(vec![ + ("k", s("error")), + ("name", s(name)), + ("params", Some(strs(params))), + ("ptypes", Some(ostrs(ptypes))), + ("msg", msg.as_ref().map(|m| m.to_json())), + ("line", num(*line)), + ("exported", flag(*exported)), + ]), + Decl::TypeDecl { name, line, text, exported } => obj(vec![("k", s("typedecl")), ("name", s(name)), ("line", num(*line)), ("text", s(text)), ("exported", flag(*exported))]), + Decl::Effect { inputs, body, line } => obj(vec![("k", s("effect")), ("inputs", Some(Value::Array(inputs.iter().map(|e| e.to_json()).collect()))), ("body", Some(stmts_json(body))), ("line", num(*line))]), + Decl::Fn { name, params, decls, tail, line, ret, exported } => obj(vec![ + ("k", s("fn")), + ("name", s(name)), + ("params", Some(Value::Array(params.iter().map(|p| obj(vec![("name", s(&p.name)), ("ref", Some(Value::Bool(p.r#ref))), ("type", os(&p.ty))])).collect()))), + ("decls", Some(decls_json(decls))), + ("tail", Some(tail.to_json())), + ("line", num(*line)), + ("ret", os(ret)), + ("exported", flag(*exported)), + ]), + Decl::Component { name, props, decls, view, line, props_type, exported, is_default } => obj(vec![ + ("k", s("component")), + ("name", s(name)), + ("props", s(props)), + ("decls", Some(decls_json(decls))), + ("view", Some(markups_json(view))), + ("line", num(*line)), + ("propsType", os(props_type)), + ("exported", flag(*exported)), + ("isDefault", flag(*is_default)), + ]), + Decl::Action { name, params, ptypes, body, line, exported } => obj(vec![ + ("k", s("action")), + ("name", s(name)), + ("params", Some(strs(params))), + ("ptypes", Some(ostrs(ptypes))), + ("body", Some(stmts_json(body))), + ("line", num(*line)), + ("exported", flag(*exported)), + ]), + Decl::Server { name, params, ptypes, ret, body, line, exported } => obj(vec![ + ("k", s("server")), + ("name", s(name)), + ("params", Some(strs(params))), + ("ptypes", Some(ostrs(ptypes))), + ("ret", os(ret)), + ("body", Some(stmts_json(body))), + ("line", num(*line)), + ("exported", flag(*exported)), + ]), + Decl::Provide { name, e, line } => obj(vec![("k", s("provide")), ("name", s(name)), ("e", Some(e.to_json())), ("line", num(*line))]), + Decl::Context { name, ty, source, dflt, line, exported } => obj(vec![ + ("k", s("context")), + ("name", s(name)), + ("type", os(ty)), + ("source", Some(Value::Bool(*source))), + ("dflt", dflt.as_ref().map(|e| e.to_json())), + ("line", num(*line)), + ("exported", flag(*exported)), + ]), + } + } +} + +impl Markup { + pub fn to_json(&self) -> Value { + let line = self.line.map(|l| json!(l)); + let ms = |x: &Vec| Some(markups_json(x)); + let ds = |x: &Vec| Some(decls_json(x)); + match &self.kind { + MarkupKind::El { tag, attrs, children } => obj(vec![ + ("k", s("el")), + ("tag", s(tag)), + ("attrs", Some(Value::Array(attrs.iter().map(|a| obj(vec![("name", s(&a.name)), ("e", Some(a.e.to_json()))])).collect()))), + ("children", ms(children)), + ("line", line), + ]), + MarkupKind::Read(e) => obj(vec![("k", s("read")), ("e", Some(e.to_json())), ("line", line)]), + MarkupKind::Text(x) => obj(vec![("k", s("text")), ("s", s(x)), ("line", line)]), + MarkupKind::Style(css) => obj(vec![("k", s("style")), ("css", s(css)), ("line", line)]), + MarkupKind::Mif { c, t, t_decls, f, f_decls } => obj(vec![("k", s("mif")), ("c", Some(c.to_json())), ("t", ms(t)), ("tDecls", ds(t_decls)), ("f", ms(f)), ("fDecls", ds(f_decls)), ("line", line)]), + MarkupKind::Mfor { name, xs, decls, body } => obj(vec![("k", s("mfor")), ("name", s(name)), ("xs", Some(xs.to_json())), ("decls", ds(decls)), ("body", ms(body)), ("line", line)]), + MarkupKind::Loading { on, fallback, decls, body } => obj(vec![("k", s("loading")), ("on", on.as_ref().map(|e| e.to_json())), ("fallback", ms(fallback)), ("decls", ds(decls)), ("body", ms(body)), ("line", line)]), + MarkupKind::Errored { fallback, decls, body, err_name, classes } => obj(vec![ + ("k", s("errored")), + ("fallback", ms(fallback)), + ("decls", ds(decls)), + ("body", ms(body)), + ("errName", os(err_name)), + ("classes", classes.as_ref().map(|c| strs(c))), + ("line", line), + ]), + MarkupKind::Portal { to, decls, body } => obj(vec![("k", s("portal")), ("to", to.as_ref().map(|e| e.to_json())), ("decls", ds(decls)), ("body", ms(body)), ("line", line)]), + } + } +} + +impl Program { + pub fn to_json(&self) -> Value { + obj(vec![("decls", Some(decls_json(&self.decls))), ("view", Some(markups_json(&self.view))), ("legacyView", self.legacy_view.map(Value::Bool))]) + } +} diff --git a/compiler/src/check.rs b/compiler/src/check.rs new file mode 100644 index 0000000..f2e011d --- /dev/null +++ b/compiler/src/check.rs @@ -0,0 +1,3410 @@ +//! The checker: spec/08-constraints.md, ported from `ref/src/check.ts`. Every diagnostic is an error (CN-1a); codes keep +//! Solid's names where one exists (CN-1b); `rule` is the spec rule enforced. Names resolve lexically (RG-7a); provisions +//! resolve through the static application tree (RG-7b). The walk also records the resolved model (CP-6, `model.rs`). +//! +//! The port keeps the reference's structure — a walk that records readers, applications, actions and provisions, lazy row +//! and failure thunks over symbols, and a resolution over the application tree after the walk — so that its verdicts, +//! messages and their order are the reference's (`ref/crate.ts` diffs them on every scenario program). + +use crate::ast::*; +use crate::content::{misnested, misnested_inside, Shape, P_CLOSERS, P_SCOPE}; +use crate::model::{ModelBuilder, ModelSymbol}; +use crate::modules::ModuleSet; +use crate::parse::{js_trim, JsDecl}; +use crate::signature::{Catch, CompSig, ExportSig, ModSig, Own, PropSig, SigParam, HOLD}; +use indexmap::{IndexMap, IndexSet}; +use regex::Regex; +use std::collections::{HashMap, HashSet}; +use std::sync::LazyLock; + +#[derive(Debug, Clone, PartialEq)] +pub struct Diagnostic { + pub code: String, + pub rule: String, + pub line: i64, + pub msg: String, + pub file: Option, +} +impl Diagnostic { + pub fn to_json(&self) -> serde_json::Value { + let mut m = serde_json::Map::new(); + m.insert("code".into(), self.code.clone().into()); + m.insert("rule".into(), self.rule.clone().into()); + m.insert("line".into(), self.line.into()); + m.insert("msg".into(), self.msg.clone().into()); + if let Some(f) = &self.file { + m.insert("file".into(), f.clone().into()); + } + serde_json::Value::Object(m) + } +} + +const BUILTINS: &[&str] = &["len", "str", "map", "filter", "sum", "fail", "fetch", "every"]; +fn is_handler(attr: &str) -> bool { + let b = attr.as_bytes(); + b.len() > 2 && b[0] == b'o' && b[1] == b'n' && (b[2].is_ascii_uppercase() || b[2] == b':') +} +fn is_upper_tag(tag: &str) -> bool { + tag.as_bytes().first().is_some_and(|c| c.is_ascii_uppercase()) +} +static ROW_WORDS: LazyLock = LazyLock::new(|| Regex::new(r"^((?:io|pending|lane|fails\([^)]*\))\s+)+").unwrap()); +static DOLLAR_PARAM: LazyLock = LazyLock::new(|| Regex::new(r"^\$\d+$").unwrap()); +static USE_JSON: LazyLock = LazyLock::new(|| Regex::new(r#""k":"use","name":"([^"]+)""#).unwrap()); +static AT_SUFFIX: LazyLock = LazyLock::new(|| Regex::new(r"@\d+$").unwrap()); + +/// `s.split(/\s+(?![^(]*\))/)`: whitespace runs, except those inside a parenthesis. +fn split_rows(s: &str) -> Vec { + let mut out = vec![]; + let mut cur = String::new(); + let chars: Vec<(usize, char)> = s.char_indices().collect(); + let mut k = 0; + while k < chars.len() { + let (_, c) = chars[k]; + if c.is_whitespace() { + let mut j = k; + while j < chars.len() && chars[j].1.is_whitespace() { + j += 1; + } + let after = if j < chars.len() { &s[chars[j].0..] } else { "" }; + let inside = match after.find(['(', ')']) { + Some(i) => after.as_bytes()[i] == b')', + None => false, + }; + if inside { + for (_, c) in &chars[k..j] { + cur.push(*c); + } + } else { + out.push(std::mem::take(&mut cur)); + } + k = j; + } else { + cur.push(c); + k += 1; + } + } + out.push(cur); + out +} + +/// A context key (`file#name`, `@n` for one declared in a block) → its name. +pub fn key_name(k: &str) -> String { + let last = k.split('#').last().unwrap_or(""); + AT_SUFFIX.replace(last, "").to_string() +} + +type RId = usize; +type SId = usize; +type EId = usize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SymKind { + Source, + Eq, + Props, + Value, + Fn, + Action, + Comp, + Import, + ErrClass, + Context, + Server, +} +impl SymKind { + pub fn as_str(self) -> &'static str { + match self { + SymKind::Source => "source", + SymKind::Eq => "eq", + SymKind::Props => "props", + SymKind::Value => "value", + SymKind::Fn => "fn", + SymKind::Action => "action", + SymKind::Comp => "comp", + SymKind::Import => "import", + SymKind::ErrClass => "errclass", + SymKind::Context => "context", + SymKind::Server => "server", + } + } + fn reactive(self) -> bool { + matches!(self, SymKind::Source | SymKind::Eq | SymKind::Props) + } +} + +struct Region { + id: usize, + depth: i64, + parent: Option, + component: Option, + is_component_root: bool, + provides: IndexSet, + catches: Option, + /// children passed to a component: (the component's root, the site) + kids: Option<(RId, RId)>, + sig: Option, +} + +#[derive(Clone)] +struct PropRef { + comp: RId, + field: String, + pre: Vec, +} +#[derive(Clone, Default)] +struct Fails { + cls: IndexSet, + props: Vec, +} +impl Fails { + fn add(&mut self, b: Fails) { + for x in b.cls { + self.cls.insert(x); + } + self.props.extend(b.props); + } +} + +enum RowsT<'a> { + Expr { e: &'a Expr, env: EId, cache: Option>, busy: bool }, + Fixed(IndexSet), +} +enum FailsT<'a> { + Expr { e: &'a Expr, env: EId, cache: Option, busy: bool }, + Fixed(IndexSet), +} +enum ServerRejects<'a> { + Body { body: &'a [Stmt], env: EId, cache: Option> }, + Fixed(IndexSet), +} + +#[derive(Clone)] +struct CtxInfo { + key: String, + source: bool, + dflt: bool, + contract: Option<(IndexSet, IndexSet)>, +} + +struct Sym<'a> { + kind: SymKind, + name: String, + region: RId, + line: i64, + question: bool, + io: bool, + instantiating: bool, + params: Option>, + captures: Vec, + maps_records: bool, + rows: Option>, + comp: Option<&'a Decl>, + expr: Option<(&'a Expr, EId)>, + comp_region: Option, + comp_env: Option, + fails_t: Option>, + fn_tail: Option<(&'a Expr, EId)>, + rejects: Option>, + imported: Option, + ctx: Option, + side: Option, + io_via: Option, + server_rejects: Option>, + fn_decl: Option<&'a Decl>, + decl_env: Option, +} +impl<'a> Sym<'a> { + fn new(kind: SymKind, name: &str, region: RId, line: i64) -> Self { + Sym { + kind, + name: name.to_string(), + region, + line, + question: false, + io: false, + instantiating: false, + params: None, + captures: vec![], + maps_records: false, + rows: None, + comp: None, + expr: None, + comp_region: None, + comp_env: None, + fails_t: None, + fn_tail: None, + rejects: None, + imported: None, + ctx: None, + side: None, + io_via: None, + server_rejects: None, + fn_decl: None, + decl_env: None, + } + } +} + +struct Env { + vars: IndexMap, + parent: Option, + region: RId, +} + +#[derive(Clone, Copy, Default)] +struct Ctx { + imperative: bool, + in_fn: bool, + in_action: bool, + server: bool, +} +impl Ctx { + fn reactive() -> Ctx { + Ctx::default() + } + fn imperative() -> Ctx { + Ctx { imperative: true, ..Ctx::default() } + } +} + +/// A node the reference walks generically (`Object.values`): an expression, or a statement inside one. +#[derive(Clone, Copy)] +enum Node<'a> { + E(&'a Expr), + S(&'a Stmt), +} +/// The children of a node in the order `Object.values` gives them; `paths`: also a write's target path (an object without +/// `k`, which only a walk over every value reaches). +fn children<'a>(n: Node<'a>, paths: bool) -> Vec> { + let mut out = vec![]; + let ss = |out: &mut Vec>, ss: &'a [Stmt]| out.extend(ss.iter().map(Node::S)); + match n { + Node::E(e) => match e { + Expr::Lit(_) | Expr::Id(_) | Expr::Use(_) | Expr::StyleMap { .. } => {} + Expr::Bin { l, r, .. } => out.extend([Node::E(l), Node::E(r)]), + Expr::Un { e, .. } | Expr::Peek(e) | Expr::Latest(e) | Expr::Pending(e) | Expr::Spread(e) | Expr::Throw(e) => out.push(Node::E(e)), + Expr::Pre { init, .. } => out.extend(init.iter().map(|x| Node::E(x))), + Expr::Await { e, init, .. } => { + out.push(Node::E(e)); + out.extend(init.iter().map(|x| Node::E(x))); + } + Expr::If { c, t, f } => out.extend([Node::E(c), Node::E(t), Node::E(f)]), + Expr::Tpl(parts) => out.extend(parts.iter().filter_map(|p| if let TplPart::Expr(e) = p { Some(Node::E(e)) } else { None })), + Expr::Member { o, .. } => out.push(Node::E(o)), + Expr::Index { o, i, .. } => out.extend([Node::E(o), Node::E(i)]), + Expr::Call { f, args } => { + out.push(Node::E(f)); + out.extend(args.iter().map(Node::E)); + } + Expr::Arr(items) => out.extend(items.iter().map(Node::E)), + Expr::Rec(fields) => out.extend(fields.iter().map(|f| match f { + Field::Kv { v, .. } => Node::E(v), + Field::Spread(e) => Node::E(e), + })), + Expr::Fn { body, stmts, .. } => { + out.push(Node::E(body)); + if let Some(s) = stmts { + ss(&mut out, s); + } + } + Expr::Draft { seed, body } => { + out.push(Node::E(seed)); + ss(&mut out, body); + } + Expr::Catch { e, alt, .. } => out.extend([Node::E(e), Node::E(alt)]), + Expr::ServerFn { body, .. } => ss(&mut out, body), + }, + Node::S(s) => match s { + Stmt::Write { target, e } | Stmt::Optimistic { target, e } => { + if paths { + out.extend(target.path.iter().filter_map(|p| if let PathSeg::Expr(x) = p { Some(Node::E(x)) } else { None })); + } + out.push(Node::E(e)); + } + Stmt::Let { e, .. } | Stmt::Expr(e) | Stmt::Throw(e) => out.push(Node::E(e)), + Stmt::Refresh(_) | Stmt::Affects(_) => {} + Stmt::Action(body) => ss(&mut out, body), + Stmt::Sif { c, t, f } => { + out.push(Node::E(c)); + ss(&mut out, t); + ss(&mut out, f); + } + Stmt::Return(e) => out.extend(e.iter().map(Node::E)), + Stmt::Try { body, handler, fin, .. } => { + ss(&mut out, body); + if let Some(h) = handler { + ss(&mut out, h); + } + if let Some(f) = fin { + ss(&mut out, f); + } + } + }, + } + out +} + +// ---------------------------------------------------------------- free names +/// Free identifiers of an expression (`pre:x` for `pre x`), minus names bound inside it. +fn free_names(e: &Expr, bound: &HashSet, out: &mut IndexSet) { + let go = |x: &Expr, out: &mut IndexSet| free_names(x, bound, out); + match e { + Expr::Lit(_) | Expr::Use(_) => {} + Expr::Id(n) => { + if !bound.contains(n) { + out.insert(n.clone()); + } + } + Expr::Pre { name, .. } => { + if !bound.contains(name) { + out.insert(format!("pre:{}", name)); + } + } + Expr::Peek(x) => go(x, out), + Expr::Await { e, init, .. } => { + go(e, out); + if let Some(i) = init { + go(i, out); + } + } + Expr::Latest(x) | Expr::Pending(x) | Expr::Un { e: x, .. } | Expr::Spread(x) => go(x, out), + Expr::Bin { l, r, .. } => { + go(l, out); + go(r, out); + } + Expr::If { c, t, f } => { + go(c, out); + go(t, out); + go(f, out); + } + Expr::Tpl(parts) => { + for p in parts { + if let TplPart::Expr(x) = p { + go(x, out); + } + } + } + Expr::Member { o, .. } => go(o, out), + Expr::Index { o, i, .. } => { + go(o, out); + go(i, out); + } + Expr::Call { f, args } => { + go(f, out); + for a in args { + go(a, out); + } + } + Expr::Arr(items) => { + for a in items { + go(a, out); + } + } + Expr::Rec(fields) => { + for f in fields { + match f { + Field::Kv { v, .. } => go(v, out), + Field::Spread(x) => go(x, out), + } + } + } + Expr::Fn { params, body, stmts } => { + let mut b = bound.clone(); + b.extend(params.iter().cloned()); + free_names(body, &b, out); + if let Some(ss) = stmts { + free_names_stmts(ss, &b, out); + } + } + Expr::Draft { seed, body } => { + go(seed, out); + let mut b = bound.clone(); + b.insert("d".into()); + free_names_stmts(body, &b, out); + } + Expr::Throw(x) => go(x, out), + Expr::Catch { e, name, alt, .. } => { + go(e, out); + match name { + Some(n) => { + let mut b = bound.clone(); + b.insert(n.clone()); + free_names(alt, &b, out); + } + None => free_names(alt, bound, out), + } + } + Expr::StyleMap { .. } | Expr::ServerFn { .. } => {} + } +} +fn free_names_stmts(ss: &[Stmt], bound0: &HashSet, out: &mut IndexSet) { + let mut bound = bound0.clone(); + for s in ss { + match s { + Stmt::Let { name, e, .. } => { + free_names(e, &bound, out); + bound.insert(name.clone()); + } + Stmt::Write { target, e } | Stmt::Optimistic { target, e } => { + if !target.is_ctx() && !bound.contains(&target.name) { + out.insert(target.name.clone()); + } + for p in &target.path { + if let PathSeg::Expr(x) = p { + free_names(x, &bound, out); + } + } + free_names(e, &bound, out); + } + Stmt::Expr(e) | Stmt::Throw(e) => free_names(e, &bound, out), + Stmt::Return(e) => { + if let Some(e) = e { + free_names(e, &bound, out); + } + } + Stmt::Refresh(n) | Stmt::Affects(n) => { + if !bound.contains(n) { + out.insert(n.clone()); + } + } + Stmt::Action(body) => free_names_stmts(body, &bound, out), + Stmt::Sif { c, t, f } => { + free_names(c, &bound, out); + free_names_stmts(t, &bound, out); + free_names_stmts(f, &bound, out); + } + Stmt::Try { body, catch_name, handler, fin, .. } => { + free_names_stmts(body, &bound, out); + if let Some(h) = handler { + let mut b = bound.clone(); + if let Some(n) = catch_name { + b.insert(n.clone()); + } + free_names_stmts(h, &b, out); + } + if let Some(f) = fin { + free_names_stmts(f, &bound, out); + } + } + } + } +} +fn names_of(e: &Expr) -> IndexSet { + let mut out = IndexSet::new(); + free_names(e, &HashSet::new(), &mut out); + out +} +fn strip_pre(n: &str) -> &str { + n.strip_prefix("pre:").unwrap_or(n) +} +/// Free names outside `peek` — the reads that make an initializer a snapshot (E-4). +fn reads_outside_peek(n: Node, out: &mut IndexSet) { + match n { + Node::E(Expr::Peek(_)) | Node::E(Expr::Fn { .. }) => {} + Node::E(Expr::Id(name)) => { + out.insert(name.clone()); + } + Node::E(Expr::Pre { name, .. }) => { + out.insert(name.clone()); + } + _ => { + for c in children(n, false) { + reads_outside_peek(c, out); + } + } + } +} +/// `map(xs, x => ({…}))` / `xs.map(x => ({…}))` +fn maps_into_records(e: &Expr) -> bool { + let Expr::Call { f, args } = e else { return false }; + let fn_arg = match &**f { + Expr::Id(n) if n == "map" => args.get(1), + Expr::Member { p, .. } if p == "map" => args.first(), + _ => None, + }; + matches!(fn_arg, Some(Expr::Fn { stmts: None, body, .. }) if matches!(**body, Expr::Rec(_))) +} +/// 12 SF-1d: the server fn a declaration's value is — itself, or wrapped by calls. +pub fn unwrap_server(e: &Expr) -> Option<&Expr> { + match e { + Expr::ServerFn { .. } => Some(e), + Expr::Call { args, .. } => { + let inner: Vec<&Expr> = args.iter().filter_map(unwrap_server).collect(); + if inner.len() == 1 { Some(inner[0]) } else { None } + } + _ => None, + } +} +fn js_string(s: &str) -> String { + serde_json::Value::String(s.to_string()).to_string() +} +/// A short rendering of an expression, for messages. +pub fn show(e: &Expr) -> String { + match e { + Expr::Lit(l) => match l { + Lit::Undefined => "undefined".into(), + Lit::Null => "null".into(), + Lit::Bool(b) => b.to_string(), + Lit::Num(n) => js_number(*n), + Lit::Str(s) => js_string(s), + }, + Expr::Id(n) => n.clone(), + Expr::Member { o, p, .. } => format!("{}.{}", show(o), p), + Expr::Call { f, .. } => format!("{}(…)", show(f)), + Expr::Await { e, .. } => format!("await {}", show(e)), + Expr::Latest(e) => format!("latest {}", show(e)), + Expr::Peek(e) => format!("peek {}", show(e)), + Expr::Throw(e) => format!("throw {}", show(e)), + Expr::Index { o, .. } => format!("{}[…]", show(o)), + Expr::Use(n) => format!("use {}", n), + Expr::Catch { e, .. } => format!("{} catch …", show(e)), + Expr::Tpl(_) => "`…`".into(), + Expr::Bin { op, l, r } => format!("{} {} {}", show(l), op, show(r)), + Expr::If { c, .. } => format!("{} ? … : …", show(c)), + _ => "…".into(), + } +} +pub fn js_number(n: f64) -> String { + if n.is_nan() { + "null".into() + } else if n.fract() == 0.0 && n.abs() < 1e21 { + format!("{}", n as i128) + } else { + format!("{}", n) + } +} + +// ---------------------------------------------------------------- the checker + +#[derive(Clone)] +struct Step { + f: Option, + p: Option, +} +#[derive(Clone)] +struct PFEntry { + chain: Vec, + term: Option<(i64, String)>, +} +#[derive(Clone)] +struct Item { + cls: String, + line: i64, + what: String, +} +#[derive(Clone)] +struct Req { + name: String, + line: i64, + what: String, +} +#[derive(Clone, Default)] +struct Esc { + fails: Vec, + reqs: Vec, +} +fn ck(c: &Option) -> String { + match c { + None => "-".into(), + Some(Catch::All) => "*".into(), + Some(Catch::Set(s)) => { + let mut v: Vec<&String> = s.iter().collect(); + v.sort(); + v.into_iter().cloned().collect::>().join("|") + } + } +} +fn chain_key(c: &[Step]) -> String { + c.iter().map(|x| format!("{}~{}", ck(&x.f), ck(&x.p))).collect::>().join("/") +} +fn dedupe_chains(xs: Vec>) -> Vec> { + let mut seen = HashSet::new(); + xs.into_iter().filter(|x| seen.insert(chain_key(x))).collect() +} +fn dedupe_entries(xs: Vec) -> Vec { + let mut seen = HashSet::new(); + xs.into_iter() + .filter(|x| { + let term = match &x.term { + None => "null".to_string(), + Some((l, n)) => format!("{{\"line\":{},\"name\":{}}}", l, js_string(n)), + }; + seen.insert(chain_key(&x.chain) + &term) + }) + .collect() +} +fn esc_f(chain: &[Step], cls: &str) -> bool { + !chain.iter().any(|s| s.f.as_ref().is_some_and(|f| f.caught(cls))) +} +fn esc_p(chain: &[Step], name: &str) -> bool { + !chain.iter().any(|s| match &s.p { + Some(Catch::All) => true, + Some(Catch::Set(p)) => p.contains(name), + None => false, + }) +} +fn meet(a: Catch, b: &Catch) -> Catch { + match (a, b) { + (Catch::All, b) => b.clone(), + (a, Catch::All) => a, + (Catch::Set(a), Catch::Set(b)) => Catch::Set(a.into_iter().filter(|x| b.contains(x)).collect()), + } +} +fn along(chain: &[Step], f: bool) -> Catch { + let mut out = IndexSet::new(); + for st in chain { + let c = if f { &st.f } else { &st.p }; + match c { + Some(Catch::All) => return Catch::All, + Some(Catch::Set(s)) => out.extend(s.iter().cloned()), + None => {} + } + } + Catch::Set(out) +} + +#[derive(Clone)] +enum Via { + Reader(RId), + Arg { comp: RId, field: String, site: RId }, + Action { line: i64, name: String }, +} +#[derive(Clone)] +struct Occ { + comp: RId, + field: String, + pre: Vec, + via: Via, +} + +struct UseRec { + region: RId, + name: String, + line: i64, + display: String, +} +struct ReaderRec<'a> { + region: RId, + e: &'a Expr, + env: EId, + line: i64, +} +struct AppRec<'a> { + comp: RId, + site: RId, + line: i64, + tag: String, + args: Vec<(String, &'a Expr, EId)>, +} +struct ActionRec<'a> { + name: String, + body: &'a [Stmt], + env: EId, + line: i64, +} +struct Provision<'a> { + key: String, + e: &'a Expr, + env: EId, +} +enum Later<'a> { + Content { ms: &'a [Markup], env: EId, anc: Vec }, + Provide { name: String, e: &'a Expr, env: EId, line: i64, contract: (IndexSet, IndexSet) }, + RowFails { name: String, env: EId, fw: Option, written: String, line: i64 }, +} + +pub struct Opts<'a> { + pub sigs: &'a IndexMap, + pub file: Option, + pub declarations: &'a IndexMap>, +} + +pub struct Checker<'a> { + pub diags: Vec, + pub model: ModelBuilder, + opts: Opts<'a>, + root_env: Option, + line: i64, + seq: usize, + uses: Vec, + readers: Vec>, + apps: Vec>, + actions: Vec>, + provisions: Vec>, + later: Vec>, + comp_syms: HashMap, + rows_busy: HashSet, + regions: Vec, + syms: Vec>, + envs: Vec, + awaited_calls: HashSet<*const Expr>, + server_exprs: HashMap<*const Expr, SId>, + stmt_awaits: HashSet<*const Expr>, + shapes: HashMap>, + // the application tree (resolve_tree), kept for the signature's component summaries + occ: Vec, + r_memo: HashMap>>, + r_busy: HashSet, + pf_memo: HashMap<(RId, String), Vec>, + pf_busy: HashSet<(RId, String)>, + esc_memo: HashMap, Esc>, + esc_busy: HashSet>, + reader_f: Vec<(RId, &'a Expr, i64, Fails)>, + arg_r: Vec<(usize, String)>, + arg_f: Vec<(usize, String, Fails)>, +} + +pub struct CheckResult { + pub diags: Vec, + pub sig: ModSig, + pub model: serde_json::Value, +} + +pub fn check_with_signature(p: &Program, opts: Opts) -> CheckResult { + let file = opts.file.clone().unwrap_or_else(|| "main.rx".into()); + let mut c = Checker::new(opts); + c.run(p); + let sig = c.signature(p); + let model = c.build_model(&file); + CheckResult { diags: c.diags, sig, model } +} + +pub struct Checked { + pub diags: Vec, + pub sigs: IndexMap, + pub models: IndexMap, +} +/// CN-4b / MO-3: check every module of a program, dependencies first, each against its imports' signatures only. +pub fn check_modules(set: &ModuleSet) -> Checked { + let mut sigs: IndexMap = set.signatures.clone(); + let mut diags = set.diags.clone(); + let mut models = IndexMap::new(); + for path in &set.order { + let r = check_with_signature(&set.programs[path], Opts { sigs: &sigs, file: Some(path.clone()), declarations: &set.declarations }); + diags.extend(r.diags); + sigs.insert(path.clone(), r.sig); + models.insert(path.clone(), r.model); + } + Checked { diags, sigs, models } +} + +impl<'a> Checker<'a> { + pub fn new(opts: Opts<'a>) -> Self { + Checker { + diags: vec![], + model: ModelBuilder::default(), + opts, + root_env: None, + line: 1, + seq: 0, + uses: vec![], + readers: vec![], + apps: vec![], + actions: vec![], + provisions: vec![], + later: vec![], + comp_syms: HashMap::new(), + rows_busy: HashSet::new(), + regions: vec![], + syms: vec![], + envs: vec![], + awaited_calls: HashSet::new(), + server_exprs: HashMap::new(), + stmt_awaits: HashSet::new(), + shapes: HashMap::new(), + occ: vec![], + r_memo: HashMap::new(), + r_busy: HashSet::new(), + pf_memo: HashMap::new(), + pf_busy: HashSet::new(), + esc_memo: HashMap::new(), + esc_busy: HashSet::new(), + reader_f: vec![], + arg_r: vec![], + arg_f: vec![], + } + } + + fn err(&mut self, code: &str, rule: &str, msg: String) { + let line = self.line; + if !self.diags.iter().any(|d| d.code == code && d.line == line && d.msg == msg) { + self.diags.push(Diagnostic { code: code.into(), rule: rule.into(), line, msg, file: self.opts.file.clone() }); + } + } + fn region(&mut self, parent: Option, kind: &'static str, component: bool, name: Option) -> RId { + let id = self.seq; + self.seq += 1; + let r = self.regions.len(); + let depth = parent.map(|p| self.regions[p].depth + 1).unwrap_or(0); + let comp = if component { Some(r) } else { parent.and_then(|p| self.regions[p].component) }; + self.regions.push(Region { id, depth, parent, component: comp, is_component_root: component, provides: IndexSet::new(), catches: None, kids: None, sig: None }); + self.model.region(id, kind, parent.map(|p| self.regions[p].id), name); + r + } + fn env(&mut self, parent: Option, region: RId) -> EId { + self.envs.push(Env { vars: IndexMap::new(), parent, region }); + self.envs.len() - 1 + } + fn lookup(&self, env: EId, n: &str) -> Option { + let mut e = Some(env); + while let Some(i) = e { + if let Some(&s) = self.envs[i].vars.get(n) { + return Some(s); + } + e = self.envs[i].parent; + } + None + } + fn new_sym(&mut self, s: Sym<'a>) -> SId { + self.syms.push(s); + self.syms.len() - 1 + } + /// A constant (`let`, a parameter, a row binding): bound, not part of the model. + fn set_value(&mut self, env: EId, name: &str, line: i64) { + let region = self.envs[env].region; + let s = self.new_sym(Sym::new(SymKind::Value, name, region, line)); + self.envs[env].vars.insert(name.to_string(), s); + } + /// CP-6: a symbol bound in `env`, recorded for the model. + fn bind(&mut self, env: EId, name: &str, sym: Sym<'a>) -> SId { + let kind = sym.kind; + let s = self.new_sym(sym); + self.envs[env].vars.insert(name.to_string(), s); + self.model.sym(s, kind.as_str()); + s + } + fn reg(&self, env: EId) -> RId { + self.envs[env].region + } + /// CP-6: a computation the walk enters. + fn comp(&mut self, kind: &'static str, env: EId, name: Option, at: Option, f: F) { + let region = self.regions[self.reg(env)].id; + let saved = self.model.within(kind, region, self.line, name, at); + f(self); + self.model.leave(saved); + } + fn read(&mut self, s: SId, ctx: Ctx, pre: bool, field: Option<&str>) { + let kind = self.syms[s].kind.as_str(); + self.model.read(s, kind, ctx.imperative, pre, field); + } + pub fn build_model(&self, file: &str) -> serde_json::Value { + self.model.build(file, |i| { + let s = &self.syms[i]; + ModelSymbol { + name: s.name.clone(), + kind: s.kind.as_str(), + region: self.regions[s.region].id, + line: s.line, + question: if s.kind == SymKind::Source { Some(s.question) } else { None }, + io: s.io, + from: s.imported.clone(), + } + }) + } + + pub fn run(&mut self, p: &'a Program) { + let r = self.region(None, "program", false, None); + let root = self.env(None, r); + self.root_env = Some(root); + if p.legacy_view == Some(true) { + self.line = p.view.first().and_then(|m| m.line).unwrap_or(1) as i64; + self.err("TOP_LEVEL_MARKUP", "MO-1c", "markup at a module's top level: a module is declarations; the app's view is a component — `export default component App() { … }`".into()); + } + self.block(&p.decls, &p.view, root, Ctx::reactive()); + self.later.push(Later::Content { ms: &p.view, env: root, anc: vec![] }); + let mut i = 0; + while i < self.later.len() { + let job = std::mem::replace(&mut self.later[i], Later::Content { ms: &[], env: root, anc: vec![] }); + self.run_later(job); + i += 1; + } + self.resolve_tree(); + } + fn run_later(&mut self, job: Later<'a>) { + match job { + Later::Content { ms, env, anc } => self.content_check(ms, env, &anc), + Later::Provide { name, e, env, line, contract } => { + let rows = self.rows_of(e, env); + let f = self.fails_of(e, env, false, &HashMap::new()); + self.line = line; + for r in rows { + if !contract.0.contains(&r) { + self.err("ROW_NARROWER_THAN_INFERRED", "RG-7e", format!("context {} is declared without `{}`, but this provision is {}", name, r, r)); + } + } + for x in f.cls { + if !contract.1.contains("Error") && !contract.1.contains(&x) { + self.err("ROW_NARROWER_THAN_INFERRED", "RG-7e", format!("context {} is declared without fails({}), but this provision can fail with {}", name, x, x)); + } + } + } + Later::RowFails { name, env, fw, written, line } => { + let Some(s) = self.envs[env].vars.get(&name).copied() else { return }; + if self.syms[s].fails_t.is_none() { + return; + } + let f = self.sym_fails(s); + let allowed: Option> = fw.as_ref().map(|fw| fw[6..fw.len() - 1].split('|').map(|x| js_trim(x).to_string()).filter(|x| !x.is_empty()).collect()); + let all = allowed.as_ref().is_none_or(|a| a.iter().any(|x| x == "Error")); + if fw.is_none() && f.cls.is_empty() { + return; + } + for c in f.cls { + if fw.is_none() || (!all && !allowed.as_ref().unwrap().contains(&c)) { + self.line = line; + self.err("ROW_NARROWER_THAN_INFERRED", "CN-4a", format!("{} is declared `{}` but its value also fails with {}", name, written, c)); + } + } + } + } + } + + // ---- blocks: declarations, then a view (02 §1, 03) ---- + fn block(&mut self, decls: &'a [Decl], view: &'a [Markup], env: EId, ctx: Ctx) { + let region = self.reg(env); + // 1. names (E-7: one declaration per name in a block) + for d in decls { + self.line = d.line() as i64; + if let Decl::Import { resolved: Some(_), .. } = d { + self.import_rx(d, env); + continue; + } + if let Decl::Import { declared: Some(_), .. } = d { + self.import_declared(d, env); + continue; + } + match d { + Decl::Import { names, pure, fails, side, line, .. } => { + for n in names { + let mut s = Sym::new(SymKind::Import, &n.alias, region, *line as i64); + s.io = !pure; + s.rejects = Some(fails.clone().unwrap_or_else(|| vec!["Error".into()]).into_iter().collect()); + s.side = *side; + self.bind(env, &n.alias, s); + } + continue; + } + Decl::Effect { .. } | Decl::Provide { .. } | Decl::TypeDecl { .. } => continue, + Decl::Context { name, ty, source, dflt, line, exported } => { + if self.envs[env].vars.contains_key(name) { + self.err("DUPLICATE_DECLARATION", "E-7", format!("{} is declared twice in one block", name)); + } + let contract = if ty.as_ref().is_some_and(|t| ROW_WORDS.is_match(t)) || *exported { Some(contract_of(ty.as_deref())) } else { None }; + if *source && dflt.is_some() { + self.err("INVALID_CONTEXT_DEFAULT", "RG-7c", format!("context {}: source … has no default — a provided source is its provider's; a default would be one source every user writes (declare that source instead)", name)); + } + let suffix = if self.regions[region].parent.is_some() { + let n = self.seq; + self.seq += 1; + format!("@{}", n) + } else { + String::new() + }; + let key = format!("{}#{}{}", self.opts.file.as_deref().unwrap_or("main.rx"), name, suffix); + let mut s = Sym::new(SymKind::Context, name, region, *line as i64); + s.ctx = Some(CtxInfo { key: key.clone(), source: *source, dflt: dflt.is_some(), contract }); + self.bind(env, name, s); + if let Some(e) = dflt { + self.provisions.push(Provision { key, e, env }); + } + continue; + } + _ => {} + } + let name = d.name().unwrap(); + if let Some(&prev) = self.envs[env].vars.get(name) { + if self.syms[prev].kind != SymKind::Import { + self.err("DUPLICATE_DECLARATION", "E-7", format!("{} is declared twice in one block", name)); + } + } + let kind = match d { + Decl::Fn { .. } => SymKind::Fn, + Decl::Component { .. } => SymKind::Comp, + Decl::Action { .. } => SymKind::Action, + Decl::Server { .. } => SymKind::Server, + Decl::Source { .. } => SymKind::Source, + Decl::Error { .. } => SymKind::ErrClass, + _ => SymKind::Eq, + }; + let mut sym = Sym::new(kind, name, region, d.line() as i64); + let mut server_expr = None; + match d { + Decl::Server { params, .. } => sym.params = Some(params.iter().map(|n| SigParam { name: n.clone(), r#ref: false, ty: None }).collect()), + Decl::Source { question, .. } => sym.question = *question, + Decl::Action { .. } => sym.io = true, + Decl::Fn { params, decls: fdecls, .. } => { + sym.params = Some(params.iter().map(|p| SigParam { name: p.name.clone(), r#ref: p.r#ref, ty: p.ty.clone() }).collect()); + sym.instantiating = params.iter().any(|p| p.r#ref) || !fdecls.is_empty(); + sym.fn_decl = Some(d); + sym.decl_env = Some(env); + } + Decl::Component { name, .. } => { + sym.comp = Some(d); + let r = self.region(Some(region), "component", true, Some(name.clone())); + sym.comp_region = Some(r); + } + Decl::Eq { e, key, .. } => { + sym.maps_records = key.is_none() && maps_into_records(e); + if let Expr::Id(n) = e { + if let Some(t) = self.lookup(env, n) { + if self.syms[t].io { + sym.io = true; + } + } + } + if let Some(sf) = unwrap_server(e) { + sym.kind = SymKind::Server; + if let Expr::ServerFn { params, .. } = sf { + sym.params = Some(params.iter().map(|n| SigParam { name: n.clone(), r#ref: false, ty: None }).collect()); + } + server_expr = Some(sf as *const Expr); + } + } + _ => {} + } + let comp_region = sym.comp_region; + let s = self.bind(env, name, sym); + if let Some(r) = comp_region { + self.comp_syms.insert(r, s); + } + if let Some(p) = server_expr { + self.server_exprs.insert(p, s); + } + } + self.server_io(decls, env); + for d in decls { + if let Decl::Source { name, init, question: false, .. } = d { + let s = self.envs[env].vars[name.as_str()]; + self.syms[s].fails_t = Some(FailsT::Expr { e: init, env, cache: None, busy: false }); + } + } + for d in decls { + let (name, e) = match d { + Decl::Eq { name, e, .. } => (name, e), + Decl::Source { name, init, question: true, .. } => (name, init), + _ => continue, + }; + let s = self.envs[env].vars[name.as_str()]; + let sym = &mut self.syms[s]; + sym.rows = Some(RowsT::Expr { e, env, cache: None, busy: false }); + sym.expr = Some((e, env)); + sym.fails_t = Some(FailsT::Expr { e, env, cache: None, busy: false }); + } + // 2. C-1 over this block's equations (the static graph, CN-3a) + self.cycles(decls, env); + // 3. each declaration + for d in decls { + self.line = d.line() as i64; + self.decl(d, env, ctx); + } + // 4. the view + let view_root = self.regions[region].is_component_root || self.regions[region].parent.is_none(); + self.markup(view, env, view_root); + } + + /// 12 SF-1: a server fn is io when its body calls io — JS declared io, `log`, or another server fn that is. + fn server_io(&mut self, decls: &'a [Decl], env: EId) { + let mut servers: Vec<(&str, &[String], &[Stmt])> = vec![]; + for d in decls { + match d { + Decl::Server { name, params, body, .. } => servers.push((name, params, body)), + Decl::Eq { name, e, .. } => { + if let Some(Expr::ServerFn { params, body, .. }) = unwrap_server(e) { + servers.push((name, params, body)); + } + } + _ => {} + } + } + let mut changed = true; + while changed { + changed = false; + for (name, params, body) in &servers { + let s = self.envs[env].vars[*name]; + if self.syms[s].io { + continue; + } + let mut calls = vec![]; + for st in body.iter() { + calls_in(Node::S(st), &mut calls); + } + let hit = calls.into_iter().find(|n| { + !params.contains(n) + && if n == "log" { + self.lookup(env, n).is_none() + } else { + self.lookup(env, n).is_some_and(|t| self.syms[t].io) + } + }); + if let Some(h) = hit { + self.syms[s].io = true; + self.syms[s].io_via = Some(format!("{}(…)", h)); + changed = true; + } + } + } + } + + /// MO-3/MO-4: bind an `.rx` import's names from its module's signature. + fn import_rx(&mut self, d: &'a Decl, env: EId) { + let Decl::Import { names, from, pure, line, fails, resolved, .. } = d else { return }; + let region = self.reg(env); + let line = *line as i64; + let sigs = self.opts.sigs; + let sig = sigs.get(resolved.as_ref().unwrap()); + if fails.is_some() { + self.err("IMPORT_ROW_MISMATCH", "MO-4", format!("`fails` asserts rows at the JS boundary; {} is .rx and its rows are its signature's", from)); + } + for ImportName { name, alias } in names { + let base = || Sym::new(SymKind::Import, alias, region, line); + let Some(sig) = sig else { + self.bind(env, alias, base()); + continue; + }; + let Some(x) = sig.exports.get(name) else { + self.err("NOT_EXPORTED", "MO-6", format!("{} does not export {}", from, if name == "default" { "a default".to_string() } else { name.clone() })); + self.bind(env, alias, base()); + continue; + }; + let set = |v: &Vec| -> IndexSet { v.iter().cloned().collect() }; + let sym = match x { + ExportSig::Type => continue, + ExportSig::Source { question, rows, fails } => { + let mut s = Sym::new(SymKind::Source, alias, region, line); + s.question = *question; + s.imported = Some(from.clone()); + s.rows = Some(RowsT::Fixed(set(rows))); + s.fails_t = Some(FailsT::Fixed(set(fails))); + s + } + ExportSig::Eq { rows, fails, io } => { + let mut s = Sym::new(SymKind::Eq, alias, region, line); + s.io = *io; + s.rows = Some(RowsT::Fixed(set(rows))); + s.fails_t = Some(FailsT::Fixed(set(fails))); + s + } + ExportSig::Fn { params, instantiating, rows, fails, rejects } => { + let mut s = Sym::new(SymKind::Fn, alias, region, line); + s.params = Some(params.clone()); + s.instantiating = *instantiating; + s.rows = Some(RowsT::Fixed(set(rows))); + s.fails_t = Some(FailsT::Fixed(set(fails))); + s.rejects = Some(set(rejects)); + s + } + ExportSig::Action => { + let mut s = Sym::new(SymKind::Action, alias, region, line); + s.io = true; + s + } + ExportSig::Server { io, params, rejects } => { + let mut s = Sym::new(SymKind::Server, alias, region, line); + s.io = *io; + s.params = Some(params.iter().map(|n| SigParam { name: n.clone(), r#ref: false, ty: None }).collect()); + s.server_rejects = Some(ServerRejects::Fixed(set(rejects))); + s + } + ExportSig::ErrClass { .. } => Sym::new(SymKind::ErrClass, alias, region, line), + ExportSig::Comp { sig } => { + let r = self.region(None, "imported", true, Some(alias.clone())); + self.regions[r].sig = Some(sig.clone()); + let mut s = Sym::new(SymKind::Comp, alias, region, line); + s.comp_region = Some(r); + s + } + ExportSig::Context { key, source, dflt, rows, fails, .. } => { + let mut s = Sym::new(SymKind::Context, alias, region, line); + s.ctx = Some(CtxInfo { key: key.clone(), source: *source, dflt: *dflt, contract: Some((set(rows), set(fails))) }); + s + } + }; + if *pure && sym.io { + self.err("IMPORT_ROW_MISMATCH", "MO-4", format!("{} is io in {}'s signature; `pure` cannot lower an .rx row", name, from)); + } + self.bind(env, alias, sym); + } + } + /// RG-7: the context a `provide`/`use` names — lexically, like any name. + fn ctx_of(&mut self, name: &str, env: EId) -> Option { + let Some(s) = self.lookup(env, name) else { + self.err("UNBOUND", "RG-7", format!("{} is not declared here: declare the context (`context {}: T`) or import it", name, name)); + return None; + }; + let k = self.syms[s].kind; + if k != SymKind::Context { + let what = match k { + SymKind::Source => "a source".to_string(), + SymKind::Eq => "an equation".to_string(), + k => format!("a {}", k.as_str()), + }; + self.err("NOT_A_CONTEXT", "RG-7", format!("{} is {}, not a context: `provide` and `use` name a `context` declaration", name, what)); + return None; + } + Some(s) + } + /// MO-8: bind a JS import's names from its declaration file. + fn import_declared(&mut self, d: &'a Decl, env: EId) { + let Decl::Import { names, from, pure, line, fails, side, declared, .. } = d else { return }; + let region = self.reg(env); + let line = *line as i64; + let declared = declared.as_ref().unwrap(); + let decls: &[JsDecl] = self.opts.declarations.get(declared).map(|v| v.as_slice()).unwrap_or(&[]); + let file = declared.split('/').last().unwrap_or("").to_string(); + if fails.is_some() { + self.err("IMPORT_ROW_MISMATCH", "MO-8", format!("{}'s rows are declared in {}: `fails` on the import cannot restate them", from, file)); + } + for ImportName { name, alias } in names { + let Some(x) = decls.iter().find(|y| y.name() == name) else { + self.err("NOT_EXPORTED", "MO-8", format!("{} declares no export {} (declare it, or it is not {}'s)", file, name, from)); + let mut s = Sym::new(SymKind::Import, alias, region, line); + s.io = true; + self.bind(env, alias, s); + continue; + }; + match x { + JsDecl::Type { .. } => continue, + JsDecl::Error { .. } => { + self.bind(env, alias, Sym::new(SymKind::ErrClass, alias, region, line)); + continue; + } + JsDecl::Fn { pure: xpure, side: xside, rejects, .. } => { + if *pure && !xpure { + self.err("IMPORT_ROW_MISMATCH", "MO-8", format!("{} is declared io in {} (`export fn`, not `export pure fn`); `pure` on the import cannot lower it", name, file)); + } + if let Some(sd) = side { + if *xside != Some(*sd) { + self.err( + "IMPORT_ROW_MISMATCH", + "SF-5", + format!("{} is declared {} in {}; `{}` on the import cannot restate it", name, xside.map(|x| format!("`{}`", x.as_str())).unwrap_or_else(|| "for both sides".into()), file, sd.as_str()), + ); + } + } + let mut s = Sym::new(SymKind::Import, alias, region, line); + s.io = !xpure; + s.rejects = Some(rejects.clone().unwrap_or_else(|| vec!["Error".into()]).into_iter().collect()); + s.side = *xside; + self.bind(env, alias, s); + } + } + } + } + + /// MO-3: this module's signature — every export as an importer sees it. + pub fn signature(&mut self, p: &'a Program) -> ModSig { + let mut exports = IndexMap::new(); + let env = self.root_env.unwrap(); + let arr = |x: IndexSet| -> Vec { x.into_iter().collect() }; + for d in &p.decls { + if !d.exported() { + continue; + } + let Some(name) = d.name() else { continue }; + if let Decl::TypeDecl { .. } = d { + exports.insert(name.to_string(), ExportSig::Type); + continue; + } + let Some(&s) = self.envs[env].vars.get(name) else { continue }; + let x = match self.syms[s].kind { + SymKind::Source => { + let rows = if self.syms[s].rows.is_some() { self.sym_rows(s) } else { IndexSet::new() }; + let fails = if self.syms[s].fails_t.is_some() { self.sym_fails(s).cls } else { IndexSet::new() }; + ExportSig::Source { question: self.syms[s].question, rows: arr(rows), fails: arr(fails) } + } + SymKind::Eq => { + let rows = if self.syms[s].rows.is_some() { self.sym_rows(s) } else { IndexSet::new() }; + let fails = if self.syms[s].fails_t.is_some() { self.sym_fails(s).cls } else { IndexSet::new() }; + ExportSig::Eq { rows: arr(rows), fails: arr(fails), io: self.syms[s].io } + } + SymKind::Fn => { + let rows = if self.syms[s].rows.is_some() { self.sym_rows(s) } else { IndexSet::new() }; + let fails = if self.syms[s].fails_t.is_some() { self.sym_fails(s).cls } else { IndexSet::new() }; + let rejects = match self.syms[s].fn_tail { + Some((t, te)) => self.rejects(t, te, &mut HashSet::new()), + None => self.syms[s].rejects.clone().unwrap_or_default(), + }; + ExportSig::Fn { params: self.syms[s].params.clone().unwrap_or_default(), instantiating: self.syms[s].instantiating, rows: arr(rows), fails: arr(fails), rejects: arr(rejects) } + } + SymKind::Action => ExportSig::Action, + SymKind::Server => { + let rejects = if self.syms[s].server_rejects.is_some() { self.sym_server_rejects(s) } else { IndexSet::new() }; + ExportSig::Server { io: self.syms[s].io, params: self.syms[s].params.clone().unwrap_or_default().into_iter().map(|p| p.name).collect(), rejects: arr(rejects) } + } + SymKind::ErrClass => ExportSig::ErrClass { params: if let Decl::Error { params, .. } = d { params.clone() } else { vec![] } }, + SymKind::Comp => { + let r = self.syms[s].comp_region.unwrap(); + ExportSig::Comp { sig: self.comp_sig(r) } + } + SymKind::Context => { + let c = self.syms[s].ctx.clone().unwrap(); + let (rows, fails) = c.contract.clone().unwrap_or_default(); + let ty = if let Decl::Context { ty, .. } = d { ty.as_ref().map(|t| ROW_WORDS.replace(t, "").to_string()) } else { None }; + ExportSig::Context { key: c.key, source: c.source, dflt: c.dflt, rows: arr(rows), fails: arr(fails), ty } + } + _ => continue, + }; + exports.insert(name.to_string(), x); + } + ModSig { file: self.opts.file.clone().unwrap_or_default(), exports } + } + + fn cycles(&mut self, decls: &'a [Decl], env: EId) { + let mut eqs: IndexMap<&str, (&'a Expr, i64)> = IndexMap::new(); + for d in decls { + match d { + Decl::Eq { name, e, line, .. } => { + eqs.insert(name, (e, *line as i64)); + } + Decl::Source { name, init, line, question: true, .. } => { + eqs.insert(name, (init, *line as i64)); + } + _ => {} + } + } + let mut color: HashMap = HashMap::new(); + let mut reported: HashSet = HashSet::new(); + let names: Vec<&str> = eqs.keys().copied().collect(); + for n in names { + self.visit_cycle(n, &[], &eqs, env, &mut color, &mut reported); + } + } + fn visit_cycle(&mut self, n: &str, stack: &[String], eqs: &IndexMap<&str, (&'a Expr, i64)>, env: EId, color: &mut HashMap, reported: &mut HashSet) { + if color.get(n) == Some(&2) { + return; + } + if color.get(n) == Some(&1) { + let at = stack.iter().position(|s| s == n).map(|i| i as i64).unwrap_or(-1); + let from = if at < 0 { stack.len().saturating_sub(1) } else { at as usize }; + let mut cyc: Vec = stack[from..].to_vec(); + cyc.push(n.to_string()); + let mut sorted = cyc.clone(); + sorted.sort(); + let key = sorted.join(","); + if reported.insert(key) { + self.line = eqs[n].1; + self.err("CYCLE", "C-1", format!("instantaneous cycle: {} (break it with `pre`, C-2)", cyc.join(" → "))); + } + return; + } + color.insert(n.to_string(), 1); + let names = names_of(eqs[n].0); + let region = self.reg(env); + for m0 in names { + if m0 == format!("pre:{}", n) { + continue; + } + let m = strip_pre(&m0); + if eqs.contains_key(m) && self.envs[env].vars.get(m).is_some_and(|&s| self.syms[s].region == region) { + let mut st = stack.to_vec(); + st.push(n.to_string()); + self.visit_cycle(m, &st, eqs, env, color, reported); + } + } + color.insert(n.to_string(), 2); + } + + // ---- rows (R-13) ---- + fn sym_rows(&mut self, s: SId) -> IndexSet { + let (e, env) = match &mut self.syms[s].rows { + None => return IndexSet::new(), + Some(RowsT::Fixed(x)) => return x.clone(), + Some(RowsT::Expr { cache: Some(c), .. }) => return c.clone(), + Some(RowsT::Expr { busy: true, .. }) => return IndexSet::new(), + Some(RowsT::Expr { e, env, busy, .. }) => { + *busy = true; + (*e, *env) + } + }; + let rows = self.rows_of(e, env); + if let Some(RowsT::Expr { e: e2, cache, busy, .. }) = &mut self.syms[s].rows { + if std::ptr::eq(*e2, e) { + *busy = false; + *cache = Some(rows.clone()); + } + } + rows + } + /// A fresh rows thunk over `e`, evaluated once (`this.rowsThunk(e, env)()`). + fn rows_of(&mut self, e: &'a Expr, env: EId) -> IndexSet { + let mut rows = IndexSet::new(); + self.rows_walk(Node::E(e), env, &mut rows); + rows + } + fn rows_walk(&mut self, x: Node<'a>, env: EId, rows: &mut IndexSet) { + if let Node::E(e) = x { + match e { + Expr::Await { e: inner, init, .. } => { + rows.insert("pending".into()); + if init.is_none() && self.client_call(inner, env) { + rows.insert("client".into()); + } + } + Expr::Latest(_) | Expr::Pending(_) => { + rows.insert("lane".into()); + } + Expr::Fn { .. } | Expr::ServerFn { .. } => return, + Expr::Use(name) => { + let Some(c) = self.lookup(env, name).and_then(|s| self.syms[s].ctx.clone()) else { return }; + if let Some((r, _)) = &c.contract { + rows.extend(r.iter().cloned()); + return; + } + let mut i = 0; + while i < self.provisions.len() { + let (pk, pe, penv) = (self.provisions[i].key.clone(), self.provisions[i].e, self.provisions[i].env); + if pk == c.key && !self.rows_busy.contains(&c.key) { + self.rows_busy.insert(c.key.clone()); + let r = self.rows_of(pe, penv); + rows.extend(r); + self.rows_busy.remove(&c.key); + } + i += 1; + } + return; + } + Expr::Id(n) => { + if let Some(s) = self.lookup(env, n) { + if self.syms[s].rows.is_some() { + let r = self.sym_rows(s); + rows.extend(r); + } + } + return; + } + _ => {} + } + } + for c in children(x, false) { + self.rows_walk(c, env, rows); + } + } + + /// SF-5b: a call into `client` JS. + fn client_call(&self, e: &Expr, env: EId) -> bool { + let f = if let Expr::Catch { e, .. } = e { &**e } else { e }; + match f { + Expr::Call { f, .. } => match &**f { + Expr::Id(n) => self.lookup(env, n).is_some_and(|s| self.syms[s].side == Some(Side::Client)), + _ => false, + }, + _ => false, + } + } + + fn decl(&mut self, d: &'a Decl, env: EId, ctx: Ctx) { + let reactive = Ctx { in_fn: ctx.in_fn, ..Ctx::reactive() }; + match d { + Decl::Import { fails, .. } => { + if let Some(f) = fails { + for n in f { + self.err_class(n, env); + } + } + } + Decl::TypeDecl { .. } => {} + Decl::Error { params, msg, line, .. } => { + if let Some(m) = msg { + let region = self.reg(env); + let inner = self.env(Some(env), region); + for p in params { + self.set_value(inner, p, *line as i64); + } + self.expr(m, inner, Ctx { in_fn: true, ..Ctx::reactive() }, None); + } + } + Decl::Source { name, init, question, ty, .. } => { + if *question { + self.comp("question", env, Some(name.clone()), None, |c| c.expr(init, env, reactive, None)); + self.row_check(name, ty.as_deref(), env); + return; + } + let mut reads = IndexSet::new(); + reads_outside_peek(Node::E(init), &mut reads); + let hit = reads.iter().find(|n| self.lookup(env, n).is_some_and(|s| self.syms[s].kind.reactive())).cloned(); + if let Some(h) = hit { + self.err("SNAPSHOT_INITIALIZER", "E-4", format!("source {} := … reads {} outside peek(…): write the snapshot as one (DV-6)", name, h)); + } + let c2 = Ctx { in_fn: ctx.in_fn, ..Ctx::reactive() }; + self.comp("seed", env, Some(name.clone()), None, |c| { + c.model.snap += 1; + c.expr(init, env, c2, None); + }); + } + Decl::Eq { name, e, deferred, key, ty, .. } => { + let kind = if *deferred { + "deferred" + } else if key.is_some() { + "keyed" + } else { + "memo" + }; + if let Expr::Call { f, args } = e { + if let Expr::Id(fname) = &**f { + if let Some(fs) = self.lookup(env, fname) { + if self.syms[fs].kind == SymKind::Fn && self.syms[fs].instantiating { + self.snapshot_args(e, fs, env); + let params = self.syms[fs].params.clone().unwrap_or_default(); + self.comp(kind, env, Some(name.clone()), None, |c| { + c.model.call(Some(fs), "fn"); + for (i, a) in args.iter().enumerate() { + let value = params.get(i).is_some_and(|p| !p.r#ref); + if value { + c.model.snap += 1; + } + c.expr(a, env, reactive, None); + if value { + c.model.snap -= 1; + } + } + }); + self.row_check(name, ty.as_deref(), env); + return; + } + } + } + } + self.comp(kind, env, Some(name.clone()), None, |c| c.expr(e, env, reactive, None)); + self.row_check(name, ty.as_deref(), env); + } + Decl::Provide { name, e, .. } => { + self.comp("provide", env, Some(name.clone()), None, |c| c.expr(e, env, reactive, None)); + let Some(c) = self.ctx_of(name, env) else { return }; + let info = self.syms[c].ctx.clone().unwrap(); + let region = self.reg(env); + self.regions[region].provides.insert(info.key.clone()); + self.provisions.push(Provision { key: info.key.clone(), e, env }); + if info.source { + let t = if let Expr::Id(n) = e { self.lookup(env, n) } else { None }; + if t.is_none_or(|t| self.syms[t].kind != SymKind::Source || self.syms[t].imported.is_some()) { + self.err("PROVIDE_NOT_A_SOURCE", "RG-7c", format!("context {} carries a source: provide one of this module's sources by name (`provide {} = x`)", name, name)); + } + } + if let Some(contract) = info.contract { + let line = self.line; + self.later.push(Later::Provide { name: name.clone(), e, env, line, contract }); + } + } + Decl::Context { name, dflt, .. } => { + if let Some(dflt) = dflt { + self.comp("default", env, Some(name.clone()), None, |c| c.expr(dflt, env, reactive, None)); + } + } + Decl::Effect { inputs, body, .. } => { + self.comp("effect", env, None, None, |c| { + for i in inputs { + c.expr(i, env, reactive, None); + } + c.comp("apply", env, None, None, |c| { + let region = c.reg(env); + let inner = c.env(Some(env), region); + c.stmts(body, inner, Ctx::imperative()); + }); + }); + self.own_source_writes(inputs, body, env); + } + Decl::Action { name, params, body, line, .. } => { + let region = self.reg(env); + let inner = self.env(Some(env), region); + for p in params { + self.set_value(inner, p, *line as i64); + } + self.comp("action", env, Some(name.clone()), None, |c| c.stmts(body, inner, Ctx { in_action: true, ..Ctx::imperative() })); + self.actions.push(ActionRec { name: name.clone(), body, env: inner, line: *line as i64 }); + } + Decl::Server { name, params, body, line, .. } => { + let region = self.reg(env); + let inner = self.env(Some(env), region); + for p in params { + self.set_value(inner, p, *line as i64); + } + self.comp("server", env, Some(name.clone()), None, |c| c.stmts(body, inner, Ctx { server: true, ..Ctx::imperative() })); + let s = self.lookup(env, name).unwrap(); + self.syms[s].server_rejects = Some(ServerRejects::Body { body, env: inner, cache: None }); + } + Decl::Fn { name, params, decls, tail, line, .. } => { + let region = self.reg(env); + let r = if !decls.is_empty() { self.region(Some(region), "fn", false, Some(name.clone())) } else { region }; + let inner = self.env(Some(env), r); + for p in params { + if p.r#ref { + self.bind(inner, &p.name, Sym::new(SymKind::Eq, &p.name, r, *line as i64)); + } else { + let s = self.new_sym(Sym::new(SymKind::Value, &p.name, r, *line as i64)); + self.envs[inner].vars.insert(p.name.clone(), s); + } + } + self.comp("fn", env, Some(name.clone()), None, |c| { + c.block(decls, &[], inner, Ctx { in_fn: true, ..Ctx::reactive() }); + c.expr(tail, inner, Ctx { in_fn: true, ..Ctx::reactive() }, None); + }); + let s = self.lookup(env, name).unwrap(); + let sym = &mut self.syms[s]; + sym.fn_tail = Some((tail, inner)); + sym.fails_t = Some(FailsT::Expr { e: tail, env: inner, cache: None, busy: false }); + sym.rows = Some(RowsT::Expr { e: tail, env: inner, cache: None, busy: false }); + } + Decl::Component { name, props, decls, view, line, .. } => { + let s = self.lookup(env, name).unwrap(); + let r = self.syms[s].comp_region.unwrap(); + let inner = self.env(Some(env), r); + self.bind(inner, props, Sym::new(SymKind::Props, props, r, *line as i64)); + self.syms[s].comp_env = Some(inner); + self.block(decls, view, inner, Ctx::reactive()); + self.later.push(Later::Content { ms: view, env: inner, anc: vec![] }); + } + } + } + + /// C-3 (ruled 2026-10-23): an apply block's own statements may not write a source its compute half reads. + fn own_source_writes(&mut self, inputs: &'a [Expr], body: &'a [Stmt], env: EId) { + let mut read: IndexMap = IndexMap::new(); + let mut seen: HashSet = HashSet::new(); + for i in inputs { + self.osw_visit(i, env, "", &mut read, &mut seen); + } + self.osw_direct(body, env, &read); + } + fn osw_visit(&self, e: &Expr, scope: EId, via: &str, read: &mut IndexMap, seen: &mut HashSet) { + for n0 in names_of(e) { + let Some(s) = self.lookup(scope, strip_pre(&n0)) else { continue }; + if !seen.insert(s) { + continue; + } + let path = if via.is_empty() { self.syms[s].name.clone() } else { format!("{} → {}", via, self.syms[s].name) }; + if self.syms[s].kind == SymKind::Source && !read.contains_key(&s) { + read.insert(s, path.clone()); + } + if let Some((x, xe)) = self.syms[s].expr { + self.osw_visit(x, xe, &path, read, seen); + } + } + } + fn osw_direct(&mut self, ss: &'a [Stmt], env: EId, read: &IndexMap) { + for st in ss { + match st { + Stmt::Write { target, .. } | Stmt::Optimistic { target, .. } if !target.is_ctx() => { + if let Some(t) = self.lookup(env, &target.name) { + if let Some(path) = read.get(&t) { + let n = self.syms[t].name.clone(); + self.err( + "EFFECT_WRITES_OWN_SOURCE", + "C-3", + format!( + "the apply block writes {}, which its compute half reads ({}): a loop across instants — derive it as an equation, or as `source {} = …` (ST-5), or write it from the event that changes the inputs", + n, path, n + ), + ); + } + } + } + Stmt::Sif { t, f, .. } => { + self.osw_direct(t, env, read); + self.osw_direct(f, env, read); + } + Stmt::Try { body, handler, fin, .. } => { + self.osw_direct(body, env, read); + if let Some(h) = handler { + self.osw_direct(h, env, read); + } + if let Some(f) = fin { + self.osw_direct(f, env, read); + } + } + Stmt::Action(body) => self.osw_direct(body, env, read), + _ => {} + } + } + } + + /// E-10: a reactive argument for a value parameter of an instantiating fn is snapshotted. + fn snapshot_args(&mut self, call: &'a Expr, f: SId, env: EId) { + let Expr::Call { args, .. } = call else { return }; + let params = self.syms[f].params.clone().unwrap_or_default(); + let fname = self.syms[f].name.clone(); + for (i, p) in params.iter().enumerate() { + let Some(a) = args.get(i) else { continue }; + if p.r#ref { + continue; + } + let mut reads = IndexSet::new(); + reads_outside_peek(Node::E(a), &mut reads); + let hit = reads.iter().find(|n| self.lookup(env, n).is_some_and(|s| self.syms[s].kind.reactive())).cloned(); + if let Some(h) = hit { + self.err( + "SNAPSHOT_ARGUMENT", + "E-10", + format!("{}(…): the argument for value parameter {} reads {} and would be snapshotted — declare the parameter `ref`, or pass peek(…)", fname, p.name, h), + ); + } + } + } + + /// CN-4a: a written row narrower than the inferred one. + fn row_check(&mut self, name: &str, ty: Option<&str>, env: EId) { + let Some(ty) = ty else { return }; + let Some(m) = ROW_WORDS.find(ty) else { return }; + let m0 = js_trim(m.as_str()).to_string(); + let words = split_rows(&m0); + let written: HashSet = words.iter().map(|w| match w.find('(') { Some(i) => w[..i].to_string(), None => w.clone() }).collect(); + let inferred = match self.envs[env].vars.get(name).copied() { + Some(s) if self.syms[s].rows.is_some() => self.sym_rows(s), + _ => IndexSet::new(), + }; + for r in inferred { + if !written.contains(&r) { + self.err("ROW_NARROWER_THAN_INFERRED", "CN-4a", format!("{} is declared `{}` but its value is also `{}`", name, m0, r)); + } + } + let fw = words.iter().find(|w| w.starts_with("fails(")).cloned(); + let line = self.line; + self.later.push(Later::RowFails { name: name.to_string(), env, fw, written: m0, line }); + } + /// R-f-1: a name in a catch, a boundary or an import's `fails` must be an error class. + fn err_class(&mut self, n: &str, env: EId) { + if n == "Error" || n == "Panic" { + return; + } + let ok = self.lookup(env, n).is_some_and(|s| matches!(self.syms[s].kind, SymKind::ErrClass | SymKind::Import)); + if !ok { + self.err("NOT_AN_ERROR_CLASS", "R-f-1", format!("{} is not an error class (declare it: `error {}(…)`)", n, n)); + } + } + + // ---- expressions ---- + fn resolve(&mut self, name: &str, env: EId, ctx: Ctx) -> Option { + if let Some(s) = self.lookup(env, name) { + self.sided(s, ctx); + return Some(s); + } + if BUILTINS.contains(&name) { + return None; + } + if ctx.imperative && (name == "log" || DOLLAR_PARAM.is_match(name)) { + return None; + } + self.err("UNBOUND", "SCOPE", format!("{} is not declared here (a component sees its declaration site, not its caller — RG-7a)", name)); + None + } + /// 12 SF-3/SF-5: what a name may be on this side. + fn sided(&mut self, s: SId, ctx: Ctx) { + let (name, kind, side) = (self.syms[s].name.clone(), self.syms[s].kind, self.syms[s].side); + if !ctx.server { + if side == Some(Side::Server) { + self.err("SERVER_ONLY", "SF-5a", format!("{} is `server` JS: it is not in the client bundle — call it in a `server fn` body", name)); + } + return; + } + if side == Some(Side::Client) { + self.err("CLIENT_IN_SERVER", "SF-5c", format!("{} is `client` JS and this is a server function's body", name)); + return; + } + let what = if kind.reactive() { + Some(match kind { + SymKind::Source => "a source", + SymKind::Eq => "an equation", + _ => "the component's props", + }) + } else { + match kind { + SymKind::Context => Some("a context"), + SymKind::Comp => Some("a component"), + SymKind::Action => Some("a client action"), + _ => None, + } + }; + let via = if what.is_some() { None } else if kind == SymKind::Fn { self.reach(s, &mut HashSet::new()) } else { None }; + if what.is_some() || via.is_some() { + let w = match what { + Some(w) => w.to_string(), + None => format!("a fn that reads {}", via.unwrap()), + }; + self.err( + "SERVER_CAPTURE", + "SF-3", + format!("a server function's body names {}, {}: it runs in its own request on the server, where that is not the client's — pass what it needs as an argument", name, w), + ); + } + } + /// SF-3: the reactive state a value fn reads, through the fns it calls; `None` if none. + fn reach(&self, f: SId, seen: &mut HashSet) -> Option { + if seen.contains(&f) { + return None; + } + let (Some(d), Some(denv)) = (self.syms[f].fn_decl, self.syms[f].decl_env) else { return None }; + seen.insert(f); + let Decl::Fn { params, decls, tail, .. } = d else { return None }; + let mut bound: HashSet = params.iter().map(|p| p.name.clone()).collect(); + for x in decls { + if let Some(n) = x.name() { + bound.insert(n.to_string()); + } + } + let mut names = IndexSet::new(); + for x in decls { + match x { + Decl::Eq { e, .. } => free_names(e, &bound, &mut names), + Decl::Source { init, .. } => free_names(init, &bound, &mut names), + _ => {} + } + } + free_names(tail, &bound, &mut names); + let text = serde_json::Value::Array(vec![decls_json(decls), tail.to_json()]).to_string(); + if let Some(u) = USE_JSON.captures(&text) { + return Some(format!("use {}", &u[1])); + } + for n0 in names { + let n = strip_pre(&n0); + let Some(s) = self.lookup(denv, n) else { continue }; + let k = self.syms[s].kind; + if k.reactive() || k == SymKind::Context { + return Some(n.to_string()); + } + if k == SymKind::Fn { + if let Some(r) = self.reach(s, seen) { + return Some(format!("{} → {}", n, r)); + } + } + } + None + } + + /// `field`: the member read of an identifier (`props.title`), for the model (CP-6). + fn expr(&mut self, e: &'a Expr, env: EId, ctx: Ctx, field: Option<&str>) { + match e { + Expr::Lit(_) => {} + Expr::Id(name) => { + let s = self.resolve(name, env, ctx); + if let Some(s) = s { + self.read(s, ctx, false, field); + match self.syms[s].kind { + SymKind::Comp => self.err("COMPONENT_AS_VALUE", "RG-4a", format!("component {} used as a value: apply it in a view", name)), + SymKind::Context => self.err("CONTEXT_AS_VALUE", "RG-7", format!("context {} used as a value: read the nearest provision with `use {}`", name, name)), + _ => {} + } + } + } + Expr::Pre { name, init } => { + let s = self.resolve(name, env, ctx); + if let Some(s) = s { + self.read(s, ctx, true, None); + if !self.syms[s].kind.reactive() { + self.err("PRE_OF_NON_REACTIVE", "C-2", format!("pre of {}, which is not reactive", name)); + } + } + if let Some(i) = init { + self.expr(i, env, ctx, None); + } + } + Expr::Use(name) => { + if ctx.server { + self.err("SERVER_CAPTURE", "SF-3", format!("a server function's body reads `use {}`: a provision is the client's — pass it as an argument", name)); + } + let c = self.ctx_of(name, env); + if let Some(c) = c { + self.read(c, ctx, false, None); + let info = self.syms[c].ctx.clone().unwrap(); + if !info.dflt { + let region = self.reg(env); + self.uses.push(UseRec { region, name: info.key, line: self.line, display: name.clone() }); + } + } + } + Expr::Peek(x) => { + self.model.snap += 1; + self.expr(x, env, ctx, None); + self.model.snap -= 1; + } + Expr::Latest(x) | Expr::Pending(x) => { + let save = self.model.probe; + self.model.probe = Some(if matches!(e, Expr::Latest(_)) { "latest" } else { "pending" }); + self.expr(x, env, ctx, None); + self.model.probe = save; + } + Expr::Un { e: x, .. } | Expr::Spread(x) => self.expr(x, env, ctx, None), + Expr::Await { e: inner, init, .. } => { + if ctx.imperative && !self.stmt_awaits.contains(&(e as *const Expr)) { + let sh = show(e); + self.err( + "AWAIT_IN_EXPRESSION", + "AC-1", + format!("`{}` inside an expression of imperative scope: a run suspends only at a statement — bind it first, `let v = {}` (a `catch (e) => alt` may follow it)", sh, sh), + ); + } + self.awaited_calls.insert(&**inner as *const Expr); + self.expr(inner, env, ctx, None); + if let Some(i) = init { + self.expr(i, env, ctx, None); + } + } + Expr::ServerFn { params, body, .. } => { + let region = self.reg(env); + let inner = self.env(Some(env), region); + for p in params { + self.set_value(inner, p, self.line); + } + self.comp("server", env, None, None, |c| c.stmts(body, inner, Ctx { server: true, ..Ctx::imperative() })); + if let Some(&sym) = self.server_exprs.get(&(e as *const Expr)) { + self.syms[sym].server_rejects = Some(ServerRejects::Body { body, env: inner, cache: None }); + } + } + Expr::Throw(x) => self.expr(x, env, ctx, None), + Expr::Catch { e: inner, name, classes, alt } => { + self.expr(inner, env, ctx, None); + if let Some(cs) = classes { + for n in cs { + self.err_class(n, env); + } + } + let region = self.reg(env); + let ienv = self.env(Some(env), region); + if let Some(n) = name { + self.set_value(ienv, n, self.line); + } + self.expr(alt, ienv, ctx, None); + } + Expr::Bin { l, r, .. } => { + self.expr(l, env, ctx, None); + self.expr(r, env, ctx, None); + } + Expr::If { c, t, f } => { + self.expr(c, env, ctx, None); + self.expr(t, env, ctx, None); + self.expr(f, env, ctx, None); + } + Expr::Tpl(parts) => { + for p in parts { + if let TplPart::Expr(x) = p { + self.expr(x, env, ctx, None); + } + } + } + Expr::Member { o, p, .. } => { + if let Expr::Id(_) = &**o { + self.expr(o, env, ctx, Some(p)); + } else { + self.expr(o, env, ctx, None); + } + } + Expr::Index { o, i, .. } => { + self.expr(o, env, ctx, None); + self.expr(i, env, ctx, None); + } + Expr::Arr(items) => { + for x in items { + self.expr(x, env, ctx, None); + } + } + Expr::Rec(fields) => { + for f in fields { + match f { + Field::Kv { v, .. } => self.expr(v, env, ctx, None), + Field::Spread(x) => self.expr(x, env, ctx, None), + } + } + } + Expr::Call { f, args } => { + if let Expr::Id(fname) = &**f { + if fname == "until" && self.lookup(env, "until").is_none() { + if !ctx.imperative { + self.err("IO_IN_REACTIVE", "AC-6", "until(…) waits for the world to confirm a condition: it is called from imperative scope (an action or a handler), and awaited".into()); + } + let ok = args.len() == 1 && matches!(&args[0], Expr::Fn { params, .. } if params.is_empty()); + if !ok { + self.err("UNTIL_PREDICATE", "AC-6", "until takes a predicate, `until(() => e)`: a value would be read once and never change".into()); + for a in args { + self.expr(a, env, ctx, None); + } + return; + } + self.expr(&args[0], env, Ctx { server: ctx.server, ..Ctx::reactive() }, None); + return; + } + let s = self.lookup(env, fname); + let (io, kind, side, io_via, instantiating) = match s { + Some(s) => (self.syms[s].io, Some(self.syms[s].kind), self.syms[s].side, self.syms[s].io_via.clone(), self.syms[s].instantiating), + None => (false, None, None, None, false), + }; + if !ctx.imperative && (io || (s.is_none() && fname == "log")) { + if ctx.in_fn { + self.err("IO_IN_FN", "R-io-2", format!("{}(…) is io; a fn is pure by definition", fname)); + } else { + let code = if kind == Some(SymKind::Action) { "ACTION_CALLED_IN_OWNED_SCOPE" } else { "IO_IN_REACTIVE" }; + self.err(code, "IO-2", format!("{}(…) is io and is called in reactive scope (R-io-3){}", fname, io_via.map(|v| format!(" — its server body calls {}", v)).unwrap_or_default())); + } + } + if side == Some(Side::Client) && !ctx.imperative && !ctx.server && !self.awaited_calls.contains(&(e as *const Expr)) { + self.err( + "CLIENT_NOT_AWAITED", + "SF-5b", + format!("{}(…) is `client` JS: in reactive scope it is awaited — a client question, asked once the browser has the page (SV-7b) — never called for its value now", fname), + ); + } + if kind == Some(SymKind::Fn) && instantiating { + self.err("INSTANTIATION_NOT_BOUND", "E-9", format!("{}(…) declares state: bind it, `x = {}(…)` (RG-6)", fname, fname)); + } + if kind == Some(SymKind::Comp) { + self.err("COMPONENT_AS_VALUE", "RG-4a", format!("component {} called as a function: apply it in a view", fname)); + } + self.model.call(s, kind.map(|k| k.as_str()).unwrap_or("")); + } + self.expr(f, env, ctx, None); + for a in args { + self.expr(a, env, ctx, None); + } + } + Expr::Fn { params, body, stmts } => { + let region = self.reg(env); + let inner = self.env(Some(env), region); + for p in params { + self.set_value(inner, p, self.line); + } + if let Some(ss) = stmts { + if !ctx.imperative { + self.err("REACTIVE_WRITE_IN_OWNED_SCOPE", "W-2", "a statement-bodied closure in reactive scope (SC-3: a closure created here is a pure function)".into()); + return; + } + self.stmts(ss, inner, Ctx { server: ctx.server, ..Ctx::imperative() }); + return; + } + self.expr(body, inner, Ctx { imperative: ctx.imperative, in_fn: ctx.in_fn, server: ctx.server, in_action: false }, None); + } + Expr::Draft { seed, body } => { + self.expr(seed, env, ctx, None); + let region = self.reg(env); + let inner = self.env(Some(env), region); + self.set_value(inner, "d", self.line); + for s in body { + match s { + Stmt::Let { name, e, .. } => { + self.expr(e, inner, ctx, None); + self.set_value(inner, name, self.line); + } + Stmt::Expr(x) => self.expr(x, inner, ctx, None), + Stmt::Write { target, e } => { + if target.name != "d" || target.is_ctx() { + self.err("DRAFT_WRITE_OUTSIDE_DRAFT", "ST-3a", format!("a draft block writes only its draft, not {}{}", if target.is_ctx() { "use " } else { "" }, target.name)); + } + for p in &target.path { + if let PathSeg::Expr(x) = p { + self.expr(x, inner, ctx, None); + } + } + self.expr(e, inner, ctx, None); + } + other => self.err("ASYNC_STORE_SETTER", "ST-3b", format!("{} in a draft block: it is reactive scope (only let, expressions and writes to d)", other.kind())), + } + } + } + Expr::StyleMap { .. } => {} + } + } + + // ---- imperative scope (SC-2) ---- + fn stmts(&mut self, ss: &'a [Stmt], env0: EId, ctx: Ctx) { + let region = self.reg(env0); + let env = self.env(Some(env0), region); + for s in ss { + if let Stmt::Let { e, .. } | Stmt::Expr(e) = s { + let t = if let Expr::Catch { e: inner, .. } = e { &**inner } else { e }; + if let Expr::Await { .. } = t { + self.stmt_awaits.insert(t as *const Expr); + } + } + match s { + Stmt::Let { name, e, .. } => { + self.expr(e, env, ctx, None); + let captures = self.stored(e, env); + let mut sym = Sym::new(SymKind::Value, name, region, self.line); + sym.captures = captures; + let id = self.new_sym(sym); + self.envs[env].vars.insert(name.clone(), id); + } + Stmt::Expr(e) | Stmt::Throw(e) => self.expr(e, env, ctx, None), + Stmt::Return(e) => { + if let Some(e) = e { + self.expr(e, env, ctx, None); + } + } + Stmt::Write { target, e } | Stmt::Optimistic { target, e } => { + let op = if matches!(s, Stmt::Optimistic { .. }) { "optimistic" } else { "write" }; + if ctx.server { + self.err( + "SERVER_WRITE", + "SF-4", + format!( + "a server function writes {}{}: return the value, and write it in the action that awaited the call (`let r = await f(…); {} := r`)", + if target.is_ctx() { "use " } else { "" }, + target.name, + target.name + ), + ); + for p in &target.path { + if let PathSeg::Expr(x) = p { + self.expr(x, env, ctx, None); + } + } + self.expr(e, env, ctx, None); + continue; + } + let mut tsym = None; + if target.is_ctx() { + if let Some(c) = self.ctx_of(&target.name, env) { + self.model.write(Some(c), op, !target.path.is_empty()); + let info = self.syms[c].ctx.clone().unwrap(); + self.uses.push(UseRec { region, name: info.key.clone(), line: self.line, display: target.name.clone() }); + if !info.source { + self.err( + "WRITE_TO_NON_SOURCE", + "RG-7c", + format!("context {} is not declared `source`: what is provided is read-only (`context {}: source T` to write through it)", target.name, target.name), + ); + } + } + } else { + tsym = self.resolve(&target.name, env, ctx); + self.model.write(tsym, op, !target.path.is_empty()); + if let Some(t) = tsym { + let k = self.syms[t].kind; + if k != SymKind::Source { + let what = match k { + SymKind::Eq => "an equation".to_string(), + SymKind::Props => "props".to_string(), + k => format!("a {}", k.as_str()), + }; + self.err("WRITE_TO_NON_SOURCE", "W-1", format!("{} is {}: only sources are written (W-1)", target.name, what)); + } else if let Some(imp) = self.syms[t].imported.clone() { + self.err( + "WRITE_TO_NON_SOURCE", + "MO-2", + format!("{} is a source of {}: a module boundary is a value boundary — write it through an action {} exports", target.name, imp, imp), + ); + } + } + } + if op == "optimistic" { + if !ctx.in_action { + self.err("OPTIMISTIC_OUTSIDE_HOLD", "AC-3", "optimistic outside an action body: a guess needs a hold to live in".into()); + } + if let Some(t) = tsym { + if self.syms[t].kind == SymKind::Source && !self.syms[t].question { + self.err( + "OPTIMISTIC_NO_QUESTION", + "AC-3", + format!("{} is declared with := — a guess needs a question to confirm it (source {} = …, ST-5c)", target.name, target.name), + ); + } + } + } + for p in &target.path { + if let PathSeg::Expr(x) = p { + self.expr(x, env, ctx, None); + } + } + self.expr(e, env, ctx, None); + self.escape(target, e, tsym, env); + } + Stmt::Refresh(name) | Stmt::Affects(name) => { + let is_refresh = matches!(s, Stmt::Refresh(_)); + if ctx.server { + self.err( + "SERVER_CAPTURE", + "SF-3", + format!("`{} {}` in a server function's body: it is the client's (do it in the action that awaited the call)", if is_refresh { "refresh" } else { "affects" }, name), + ); + continue; + } + let t = self.resolve(name, env, ctx); + if is_refresh { + self.model.write(t, "refresh", false); + if let Some(t) = t { + let k = self.syms[t].kind; + if !(k == SymKind::Eq || (k == SymKind::Source && self.syms[t].question)) { + self.err("INVALID_REFRESH_TARGET", "AC-4", format!("{} has no question to re-ask", name)); + } + } + } else { + self.model.write(t, "affects", false); + if let Some(t) = t { + if !self.syms[t].kind.reactive() { + self.err("INVALID_AFFECTS_TARGET", "AC-5", format!("{} is not reactive", name)); + } + } + } + } + Stmt::Action(body) => { + if ctx.server { + self.err("SERVER_CAPTURE", "SF-3", "`action { … }` in a server function's body: it is the client's (do it in the action that awaited the call)".into()); + continue; + } + self.comp("action", env, Some("(inline)".into()), None, |c| c.stmts(body, env, Ctx { in_action: true, ..Ctx::imperative() })); + self.actions.push(ActionRec { name: "(inline)".into(), body, env, line: self.line }); + } + Stmt::Sif { c, t, f } => { + self.expr(c, env, ctx, None); + self.stmts(t, env, ctx); + self.stmts(f, env, ctx); + } + Stmt::Try { body, catch_name, catch_classes, handler, fin } => { + self.stmts(body, env, ctx); + if let Some(cs) = catch_classes { + for n in cs { + self.err_class(n, env); + } + } + if let Some(h) = handler { + let henv = self.env(Some(env), region); + if let Some(n) = catch_name { + self.set_value(henv, n, self.line); + } + self.stmts(h, henv, ctx); + } + if let Some(f) = fin { + self.stmts(f, env, ctx); + } + } + } + } + } + + /// The reactive names captured by closures that a value stores (O-3). + fn stored(&self, e: &'a Expr, env: EId) -> Vec { + let mut out = vec![]; + self.stored_walk(Node::E(e), env, &mut out); + out + } + fn stored_walk(&self, x: Node<'a>, env: EId, out: &mut Vec) { + match x { + Node::E(f @ Expr::Fn { .. }) => { + for n in names_of(f) { + if let Some(s) = self.lookup(env, strip_pre(&n)) { + if self.syms[s].kind.reactive() { + out.push(s); + } + out.extend(self.syms[s].captures.iter().copied()); + } + } + } + Node::E(Expr::Id(n)) => { + if let Some(s) = self.lookup(env, n) { + out.extend(self.syms[s].captures.iter().copied()); + } + } + _ => { + for c in children(x, false) { + self.stored_walk(c, env, out); + } + } + } + } + fn escape(&mut self, target: &Target, e: &'a Expr, tsym: Option, env: EId) { + let value = match e { + Expr::Fn { body, stmts: None, .. } => &**body, + _ => e, + }; + let captured = self.stored(value, env); + if captured.is_empty() { + return; + } + let t_depth = match tsym { + Some(t) => self.regions[self.syms[t].region].depth, + None => { + let key = self.lookup(env, &target.name).and_then(|s| self.syms[s].ctx.as_ref().map(|c| c.key.clone())).unwrap_or_else(|| target.name.clone()); + let mut r = Some(self.reg(env)); + let mut found = None; + while let Some(ri) = r { + if self.regions[ri].provides.contains(&key) { + found = Some(ri); + break; + } + if self.regions[ri].is_component_root { + break; + } + r = self.regions[ri].parent; + } + match found { + Some(f) => self.regions[f].depth, + None => match self.regions[self.reg(env)].component { + Some(c) => self.regions[c].depth - 1, + None => 0, + }, + } + } + }; + if let Some(&leak) = captured.iter().find(|&&c| self.regions[self.syms[c].region].depth > t_depth) { + let ln = self.syms[leak].name.clone(); + self.err( + "REGION_ESCAPE", + "O-3", + format!("a closure capturing {} (declared in a region that ends sooner) is stored into {}{}, which outlives it", ln, if target.is_ctx() { "use " } else { "" }, target.name), + ); + } + } + + // ---- views ---- + fn markup(&mut self, ms: &'a [Markup], env: EId, view_root: bool) { + let mut styles = 0; + for m in ms { + if let Some(l) = m.line { + self.line = l as i64; + } + let region = self.reg(env); + match &m.kind { + MarkupKind::Text(_) => {} + MarkupKind::Style(_) => { + if !view_root { + self.err("STYLE_PLACEMENT", "CSS-1", "a `, with no attributes"); + } + let Some(end) = c.rest().find("") else { return c.fail("expected ") }; + let css = c.rest()[..end].to_string(); + c.i += end + "".len(); + return Ok(Expr::StyleMap { css, line }); + } + if SERVER_FN_EXPR.is_match(c.rest()) { + c.keyword("server"); + c.ws(true); + c.keyword("fn"); + c.ws(true); + c.expect("(")?; + c.ws(true); + let (params, ptypes) = params_typed(c)?; + c.expect(")")?; + let ret = opt_type(c, true)?; + c.ws(true); + let body = parse_block(c)?; + return Ok(Expr::ServerFn { params, ptypes, ret, body }); + } + let ch = c.peek(); + if ch == '`' { + c.bump(); + let mut parts = vec![]; + let mut s = String::new(); + while !c.eof() && c.peek() != '`' { + if c.starts_with("${") { + c.i += 2; + if !s.is_empty() { + parts.push(TplPart::Str(std::mem::take(&mut s))); + } + c.ws(true); + parts.push(TplPart::Expr(parse_expr(c)?)); + c.ws(true); + c.expect("}")?; + continue; + } + if c.peek() == '\\' { + c.bump(); + } + if !c.eof() { + s.push(c.peek()); + } + c.bump(); + } + c.expect("`")?; + if !s.is_empty() || parts.is_empty() { + parts.push(TplPart::Str(s)); + } + return Ok(Expr::Tpl(parts)); + } + if ch == '"' || ch == '\'' { + c.bump(); + let mut s = String::new(); + while !c.eof() && c.peek() != ch { + if c.peek() == '\\' { + c.bump(); + } + if !c.eof() { + s.push(c.peek()); + } + c.bump(); + } + c.expect(&ch.to_string())?; + return Ok(Expr::Lit(Lit::Str(s))); + } + if let Some(m) = NUMBER.find(c.rest()) { + let text = m.as_str().to_string(); + c.i += text.len(); + return Ok(Expr::Lit(Lit::Num(text.replace('_', "").parse::().unwrap_or(f64::NAN)))); + } + if c.eat("(") { + c.ws(true); + let e = parse_expr(c)?; + c.ws(true); + c.expect(")")?; + return Ok(e); + } + if c.eat("[") { + c.ws(true); + let mut items = vec![]; + while !c.starts_with("]") { + if c.eat("...") { + c.ws(true); + items.push(Expr::Spread(Box::new(parse_expr(c)?))); + } else { + items.push(parse_expr(c)?); + } + c.ws(true); + if !c.eat(",") { + break; + } + c.ws(true); + } + c.expect("]")?; + return Ok(Expr::Arr(items)); + } + if c.eat("{") { + c.ws(true); + let mut fields = vec![]; + while !c.starts_with("}") { + if c.eat("...") { + c.ws(true); + fields.push(Field::Spread(parse_expr(c)?)); + c.ws(true); + if !c.eat(",") { + break; + } + c.ws(true); + continue; + } + let key = match c.ident() { + Some(k) => k, + None => { + if c.peek() == '"' || c.peek() == '\'' { + match parse_primary(c)? { + Expr::Lit(Lit::Str(s)) => s, + _ => unreachable!(), + } + } else { + return c.fail("field"); + } + } + }; + c.ws(true); + if c.eat(":") { + c.ws(true); + let v = parse_expr(c)?; + fields.push(Field::Kv { key, v }); + } else { + fields.push(Field::Kv { v: Expr::Id(key.clone()), key }); + } + c.ws(true); + if !c.eat(",") { + break; + } + c.ws(true); + } + c.expect("}")?; + return Ok(Expr::Rec(fields)); + } + if c.keyword("true") { + return Ok(Expr::Lit(Lit::Bool(true))); + } + if c.keyword("false") { + return Ok(Expr::Lit(Lit::Bool(false))); + } + if c.keyword("undefined") { + return Ok(Expr::Lit(Lit::Undefined)); + } + if c.keyword("null") { + return Ok(Expr::Lit(Lit::Null)); + } + if let Some(id) = c.ident() { + return Ok(Expr::Id(id)); + } + c.fail("expression") +} + +// ---- markup ---- +#[derive(Clone)] +enum Close { + /// the top level: markup runs to the end of input + Top, + /// a control form's body, up to `}` + Brace, + /// an element, up to `` (`""`: a fragment's ``) + Tag(String), +} + +fn parse_markup(c: &mut Cursor, close: Close) -> R> { + let mut out: Vec = vec![]; + let in_element = matches!(close, Close::Tag(_)); + let close_text = match &close { + Close::Top => None, + Close::Brace => Some("}".to_string()), + Close::Tag(t) => Some(t.clone()), + }; + let mut broke = false; + loop { + let start = c.i; + if in_element { + let mut s = String::new(); + while !c.eof() && " \t\r\n".contains(c.peek()) { + s.push(c.peek()); + c.bump(); + } + broke = s.contains('\n'); + let tag = close_text.as_deref().unwrap(); + if !s.is_empty() && !broke && !c.starts_with(&format!("", tag)) && !out.is_empty() { + out.push(Markup { kind: MarkupKind::Text(" ".into()), line: None }); + } + } else { + c.ws(true); + } + if c.eof() { + if let Some(t) = &close_text { + return c.fail(&format!("expected ", t)); + } + return Ok(out); + } + if let Close::Tag(t) = &close { + if c.starts_with(&format!("", t)) { + c.i += t.len() + 3; + return Ok(out); + } + } + if matches!(close, Close::Brace) && c.starts_with("}") { + c.bump(); + return Ok(out); + } + let line = c.line(); + if c.starts_with("<") { + let mut m = parse_element(c)?; + m.line = Some(line); + out.push(m); + continue; + } + if c.starts_with("{") { + c.bump(); + c.ws(true); + if let Some(mut ctl) = parse_control(c)? { + c.ws(true); + c.expect("}")?; + ctl.line = Some(line); + out.push(ctl); + continue; + } + let e = parse_expr(c)?; + c.ws(true); + c.expect("}")?; + out.push(Markup { kind: MarkupKind::Read(e), line: Some(line) }); + continue; + } + if !in_element { + if let Some(mut ctl) = parse_control(c)? { + ctl.line = Some(line); + out.push(ctl); + continue; + } + } + if matches!(close, Close::Top) && looks_like_decl(c) { + return c.fail("declaration after markup: a block is declarations, then its view"); + } + let mut s = String::new(); + while !c.eof() && !"<{}\n".contains(c.peek()) { + s.push(c.peek()); + c.bump(); + } + if c.peek() == '\n' || c.eof() { + s = s.trim_end_matches([' ', '\t', '\r']).to_string(); + } + if !in_element { + if js_trim(&s).is_empty() { + if c.peek() == '\n' { + c.bump(); + } + if c.i == start { + // the reference loops here forever (a stray `}`); this ends it + return c.fail("declaration or markup"); + } + continue; + } + if s.contains(":=") { + return c.fail("W-2: write in reactive scope (no statement position)"); + } + return c.fail("declaration or markup"); + } + if broke && matches!(out.last(), Some(Markup { kind: MarkupKind::Text(_), .. })) { + out.push(Markup { kind: MarkupKind::Text(" ".into()), line: None }); + } + if !s.is_empty() { + out.push(Markup { kind: MarkupKind::Text(s), line: None }); + } + if c.i == start { + // the reference loops here forever (a stray `}` in an element); this ends it + return c.fail("declaration or markup"); + } + } +} + +/// Control forms of a view: `if c { … } else { … }`, `for x in e { … }`, `loading … { … }`, `errored …`, `portal …`. +fn parse_control(c: &mut Cursor) -> R> { + let save = c.i; + let mk = |kind| Some(Markup { kind, line: None }); + if c.keyword("if") { + c.ws(true); + let cond = parse_expr(c)?; + c.ws(true); + if !c.starts_with("{") { + c.i = save; + return Ok(None); + } + c.expect("{")?; + let (t_decls, t) = parse_block_body(c)?; + c.ws(true); + let (mut f_decls, mut f) = (vec![], vec![]); + if c.keyword("else") { + c.ws(true); + c.expect("{")?; + (f_decls, f) = parse_block_body(c)?; + } + return Ok(mk(MarkupKind::Mif { c: cond, t, t_decls, f, f_decls })); + } + if c.keyword("loading") { + c.ws(true); + let mut on = None; + if c.keyword("on") { + c.ws(true); + c.expect("(")?; + c.ws(true); + on = Some(parse_expr(c)?); + c.ws(true); + c.expect(")")?; + c.ws(true); + } + if !c.keyword("fallback") { + return c.fail("fallback"); + } + c.ws(true); + c.expect("{")?; + let fallback = parse_markup(c, Close::Brace)?; + c.ws(true); + c.expect("{")?; + let (decls, body) = parse_block_body(c)?; + return Ok(mk(MarkupKind::Loading { on, fallback, decls, body })); + } + if c.keyword("portal") { + c.ws(true); + let mut to = None; + if c.keyword("to") { + c.ws(true); + c.expect("(")?; + c.ws(true); + to = Some(parse_expr(c)?); + c.ws(true); + c.expect(")")?; + c.ws(true); + } + c.expect("{")?; + let (decls, body) = parse_block_body(c)?; + return Ok(mk(MarkupKind::Portal { to, decls, body })); + } + if c.keyword("errored") { + c.ws(true); + if !c.keyword("fallback") { + return c.fail("fallback"); + } + c.ws(false); + let mut err_name = None; + let mut classes = None; + if c.eat("(") { + c.ws(true); + err_name = Some(c.ident_or("error name")?); + c.ws(false); + if c.eat(":") { + classes = parse_classes(c)?; + } + c.ws(true); + c.expect(")")?; + } + c.ws(true); + c.expect("{")?; + let fallback = parse_markup(c, Close::Brace)?; + c.ws(true); + c.expect("{")?; + let (decls, body) = parse_block_body(c)?; + return Ok(mk(MarkupKind::Errored { fallback, decls, body, err_name, classes })); + } + if c.keyword("for") { + c.ws(true); + let name = c.ident_or("for name")?; + c.ws(true); + if !c.keyword("in") { + return c.fail("in"); + } + c.ws(true); + let xs = parse_expr(c)?; + c.ws(true); + c.expect("{")?; + let (decls, body) = parse_block_body(c)?; + return Ok(mk(MarkupKind::Mfor { name, xs, decls, body })); + } + Ok(None) +} +/// A reactive block body: declarations, then markup, up to the closing `}` (03). +fn parse_block_body(c: &mut Cursor) -> R<(Vec, Vec)> { + let mut decls = vec![]; + loop { + c.ws(true); + if !looks_like_decl(c) { + break; + } + decls.push(parse_decl(c)?); + c.ws(true); + c.eat(";"); + } + Ok((decls, parse_markup(c, Close::Brace)?)) +} +fn looks_like_decl(c: &mut Cursor) -> bool { + let save = c.i; + c.ws(true); + let r = c.starts_with("source ") + || c.starts_with("effect") + || c.starts_with("fn ") + || c.starts_with("component ") + || c.starts_with("deferred ") + || c.starts_with("action ") + || c.starts_with("provide ") + || c.starts_with("context ") + || c.starts_with("import ") + || c.starts_with("export ") + || c.starts_with("type ") + || c.starts_with("interface ") + || ERROR_DECL.is_match(c.rest()) + || SERVER_DECL.is_match(c.rest()) + || { + let id = c.ident(); + c.ws(false); + match id { + Some(id) if !is_kw(&id) => { + if c.peek() == ':' && c.peek_at(1) != '=' { + true + } else { + (c.peek() == '=' && c.peek_at(1) != '=') || EQ_KEY.is_match(c.rest()) + } + } + _ => false, + } + }; + c.i = save; + r +} +fn parse_element(c: &mut Cursor) -> R { + c.expect("<")?; + let mk = |kind| Markup { kind, line: None }; + if c.eat(">") { + let children = parse_markup(c, Close::Tag(String::new()))?; + return Ok(mk(MarkupKind::El { tag: String::new(), attrs: vec![], children })); + } + let Some(tag) = c.tag_name() else { return c.fail("tag") }; + if tag == "style" { + c.ws(true); + if !c.eat(">") { + return c.fail(if c.rest().starts_with("ref") { + "CSS-5: a class map is a declaration, `styles = ` — TSRX's ``" + }); + } + let Some(end) = c.rest().find("") else { return c.fail("expected ") }; + let css = c.rest()[..end].to_string(); + c.i += end + "".len(); + return Ok(mk(MarkupKind::Style(css))); + } + let mut attrs = vec![]; + loop { + c.ws(true); + if c.eat("/>") { + return Ok(mk(MarkupKind::El { tag, attrs, children: vec![] })); + } + if c.eat(">") { + break; + } + let Some(name) = c.tag_name() else { return c.fail("attr") }; + c.ws(false); + if !c.eat("=") { + attrs.push(Attr { name, e: Expr::Lit(Lit::Bool(true)) }); + continue; + } + c.ws(true); + if c.eat("{") { + c.ws(true); + let e = parse_expr(c)?; + attrs.push(Attr { name, e }); + c.ws(true); + c.expect("}")?; + } else { + let e = parse_primary(c)?; + attrs.push(Attr { name, e }); + } + } + let children = parse_markup(c, Close::Tag(tag.clone()))?; + Ok(mk(MarkupKind::El { tag, attrs, children })) +} diff --git a/compiler/src/signature.rs b/compiler/src/signature.rs new file mode 100644 index 0000000..b00f24e --- /dev/null +++ b/compiler/src/signature.rs @@ -0,0 +1,506 @@ +//! MO-3 / MO-9: a module's signature — what an importer is checked against — and its `.d.rx` text, printed and read back +//! (`ref/src/signature.ts`). One export per line: +//! +//! export source products: pending fails(Error) = … a question (ST-5); `:= …` a plain source +//! export discount: pending fails(Error) an equation; `io` when bound to an io value +//! export pure fn qtyOf(id) rejects(Offline) instantiates a fn (`ref` params; RG-6); `rejects(…)` absent: none +//! export server fn add(id) rejects(Error) 12 SF-1 (`server pure fn` when no io) +//! export action setCoupon +//! export error NotFound(id) +//! export type Product +//! export context theme: source pending Theme = … rows, then the written type; `= …` a default +//! export component Panel(props: { title; children: catches(Error) provides(./theme.rx#theme) }) fails(Error) uses(…) shape(…) + +use crate::content::Shape; +use indexmap::{IndexMap, IndexSet}; +use regex::Regex; +use std::sync::LazyLock; + +pub const HOLD: &str = "$hold"; + +/// What a catch or a boundary discharges: named classes, or every failure. +#[derive(Debug, Clone, PartialEq)] +pub enum Catch { + All, + Set(IndexSet), +} +impl Catch { + pub fn of(classes: &Option>) -> Catch { + match classes { + Some(c) => Catch::Set(c.iter().cloned().collect()), + None => Catch::All, + } + } + pub fn caught(&self, cls: &str) -> bool { + match self { + Catch::All => true, + Catch::Set(s) => cls != "Error" && s.contains(cls), + } + } + pub fn is_empty_set(&self) -> bool { + matches!(self, Catch::Set(s) if s.is_empty()) + } +} + +#[derive(Debug, Clone)] +pub struct PropSig { + pub f: Catch, + pub p: Catch, + pub rendered: bool, + pub action: Option<(Catch, String)>, +} +#[derive(Debug, Clone)] +pub struct Own { + pub cls: String, + pub what: String, + pub at: Option, +} +/// MO-3: an exported component as an importer sees it. +#[derive(Debug, Clone)] +pub struct CompSig { + pub own: Vec, + pub requires: Vec, + pub props: IndexMap, + pub shape: Option, +} +#[derive(Debug, Clone)] +pub struct SigParam { + pub name: String, + pub r#ref: bool, + pub ty: Option, +} +#[derive(Debug, Clone)] +pub enum ExportSig { + Source { question: bool, rows: Vec, fails: Vec }, + Eq { rows: Vec, fails: Vec, io: bool }, + Fn { params: Vec, instantiating: bool, rows: Vec, fails: Vec, rejects: Vec }, + Action, + Server { io: bool, params: Vec, rejects: Vec }, + ErrClass { params: Vec }, + Comp { sig: CompSig }, + Context { key: String, source: bool, dflt: bool, rows: Vec, fails: Vec, ty: Option }, + Type, +} +#[derive(Debug, Clone)] +pub struct ModSig { + pub file: String, + pub exports: IndexMap, +} + +fn sorted(xs: impl IntoIterator) -> Vec { + let mut v: Vec = xs.into_iter().collect(); + v.sort(); + v +} +fn cls(c: &Catch) -> String { + match c { + Catch::All => "*".into(), + Catch::Set(s) if s.is_empty() => "nothing".into(), + Catch::Set(s) => sorted(s.iter().cloned()).join(" | "), + } +} +fn rows(r: &[String], fails: &[String]) -> String { + let mut out: Vec = ["io", "pending", "lane"].iter().filter(|x| r.iter().any(|y| y == *x)).map(|x| x.to_string()).collect(); + if !fails.is_empty() { + out.push(format!("fails({})", sorted(fails.iter().cloned()).join(" | "))); + } + out.join(" ") +} +fn dir(p: &str) -> String { + let parts: Vec<&str> = p.split('/').collect(); + parts[..parts.len() - 1].join("/") +} +/// `[path, name]` at the last `#` (`slice(0, -1)` and the whole key when there is none, as the reference has it). +fn split_hash(key: &str) -> (String, String) { + match key.rfind('#') { + Some(i) => (key[..i].to_string(), key[i + 1..].to_string()), + None => { + let mut p: Vec = key.chars().collect(); + p.pop(); + (p.into_iter().collect(), key.to_string()) + } + } +} +/// A context key relative to the module whose signature names it (`./theme.rx#theme`). +fn rel_key(from: &str, key: &str) -> String { + if key == HOLD { + return "loading".into(); + } + let (path, name) = split_hash(key); + let d = dir(from); + let a: Vec<&str> = d.split('/').filter(|x| !x.is_empty()).collect(); + let b: Vec<&str> = path.split('/').filter(|x| !x.is_empty()).collect(); + let mut i = 0; + while i < a.len() && (i as i64) < b.len() as i64 - 1 && a[i] == b[i] { + i += 1; + } + let up = a.len() - i; + let rel = format!("{}{}", if up > 0 { "../".repeat(up) } else { "./".into() }, b[i.min(b.len())..].join("/")); + format!("{}#{}", if path.starts_with('/') == from.starts_with('/') { rel } else { path }, name) +} +fn abs_key(from: &str, rel: &str) -> String { + if rel == "loading" { + return HOLD.into(); + } + let (path, name) = split_hash(rel); + if !path.starts_with('.') { + return rel.into(); + } + let lead = if from.starts_with('/') { "/" } else { "" }; + let d = dir(from); + let mut out: Vec = d.split('/').filter(|x| !x.is_empty()).map(String::from).collect(); + for seg in path.split('/') { + if seg == "." || seg.is_empty() { + continue; + } + if seg == ".." { + out.pop(); + } else { + out.push(seg.into()); + } + } + format!("{}{}#{}", lead, out.join("/"), name) +} +fn shape_text(s: &Option) -> String { + let Some(s) = s else { return String::new() }; + let mut parts = vec![]; + if !s.roots.is_empty() { + parts.push(format!("roots {}", s.roots.join(" "))); + } + if s.closes_p { + parts.push("closes-p".into()); + } + if s.a { + parts.push("a".into()); + } + if s.form { + parts.push("form".into()); + } + for c in &s.children_at { + parts.push(format!("children-at {}", c.join(" "))); + } + if parts.is_empty() { String::new() } else { format!(" shape({})", parts.join("; ")) } +} +fn with_rows(head: String, r: String) -> String { + if r.is_empty() { head } else { format!("{}: {}", head, r) } +} + +fn line(file: &str, name: &str, x: &ExportSig) -> String { + match x { + ExportSig::Source { question, rows: r, fails } => format!("{} {} …", with_rows(format!("export source {}", name), rows(r, fails)), if *question { "=" } else { ":=" }), + ExportSig::Eq { rows: r, fails, io } => { + let mut rr = r.clone(); + if *io { + rr.insert(0, "io".into()); + } + with_rows(format!("export {}", name), rows(&rr, fails)) + } + ExportSig::Fn { params, instantiating, rows: r, fails, rejects } => { + let ps: Vec = params.iter().map(|p| format!("{}{}{}", if p.r#ref { "ref " } else { "" }, p.name, p.ty.as_ref().map(|t| format!(": {}", t)).unwrap_or_default())).collect(); + format!( + "{}{}{}", + with_rows(format!("export pure fn {}({})", name, ps.join(", ")), rows(r, fails)), + if rejects.is_empty() { String::new() } else { format!(" rejects({})", rejects.join(" | ")) }, + if *instantiating { " instantiates" } else { "" } + ) + } + ExportSig::Server { io, params, rejects } => format!( + "export server {}fn {}({}){}", + if *io { "" } else { "pure " }, + name, + params.join(", "), + if rejects.is_empty() { String::new() } else { format!(" rejects({})", sorted(rejects.iter().cloned()).join(" | ")) } + ), + ExportSig::Action => format!("export action {}", name), + ExportSig::ErrClass { params } => format!("export error {}({})", name, params.join(", ")), + ExportSig::Type => format!("export type {}", name), + ExportSig::Context { source, dflt, rows: r, fails, ty, .. } => { + let t: Vec = [if *source { "source".to_string() } else { String::new() }, rows(r, fails), ty.clone().unwrap_or_default()].into_iter().filter(|x| !x.is_empty()).collect(); + let t = t.join(" "); + format!("export context {}{}{}", name, if t.is_empty() { String::new() } else { format!(": {}", t) }, if *dflt { " = …" } else { "" }) + } + ExportSig::Comp { sig: s } => { + let props: Vec = s + .props + .iter() + .map(|(f, v)| { + let mut parts = vec![]; + if !v.rendered { + parts.push("unrendered".to_string()); + } else { + if !v.f.is_empty_set() { + parts.push(format!("catches({})", cls(&v.f))); + } + if !v.p.is_empty_set() { + parts.push(format!( + "provides({})", + match &v.p { + Catch::All => "*".to_string(), + Catch::Set(ks) => ks.iter().map(|k| rel_key(file, k)).collect::>().join(", "), + } + )); + } + } + if let Some((af, an)) = &v.action { + parts.push(format!("action {} catches({})", an, cls(af))); + } + format!("{}{}", if f == "*" { "...props" } else { f }, if parts.is_empty() { String::new() } else { format!(": {}", parts.join(" ")) }) + }) + .collect(); + let own: IndexSet = s.own.iter().map(|o| o.cls.clone()).collect(); + format!( + "export component {}(props: {}){}{}{}", + name, + if props.is_empty() { "{}".to_string() } else { format!("{{ {} }}", props.join("; ")) }, + if own.is_empty() { String::new() } else { format!(" fails({})", sorted(own).join(" | ")) }, + if s.requires.is_empty() { String::new() } else { format!(" uses({})", s.requires.iter().map(|k| rel_key(file, k)).collect::>().join(", ")) }, + shape_text(&s.shape) + ) + } + } +} + +pub fn print_signature(sig: &ModSig) -> String { + let mut out = vec![format!("// {} — signature (09-modules MO-3). Sources are read-only to importers (MO-2).", sig.file.split('/').last().unwrap_or(""))]; + for (n, x) in &sig.exports { + out.push(line(&sig.file, n, x)); + } + out.join("\n") + "\n" +} + +// ---------------------------------------------------------------- reading it back (MO-9) +#[derive(Debug)] +pub struct SignatureError(pub String); + +macro_rules! re { + ($name:ident, $pat:expr) => { + static $name: LazyLock = LazyLock::new(|| Regex::new($pat).unwrap()); + }; +} +re!(ROW, r"^(io|pending|lane|fails\(([^)]*)\))$"); +re!(TRAIL_COMMENT, r"\s//.*$"); +re!(LEAD_COMMENT, r"^//.*$"); +re!(T_TYPE, r"^type\s+([A-Za-z_$][A-Za-z0-9_$]*)"); +re!(T_ACTION, r"^action\s+([A-Za-z_$][A-Za-z0-9_$]*)$"); +re!(T_ERROR, r"^error\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:\(([^)]*)\))?$"); +re!(T_SOURCE, r"^source\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::([^=].*?)?)??\s*(:?=)\s*…$"); +re!(T_SERVER, r"^server\s+(pure\s+)?fn\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(([^)]*)\)\s*(?:rejects\(([^)]*)\))?$"); +re!(T_FN, r"^pure\s+fn\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\((.*?)\)\s*(?::\s*((?:(?:io|pending|lane|fails\([^)]*\))\s*)*))?\s*(?:rejects\(([^)]*)\))?\s*(instantiates)?$"); +re!(T_CONTEXT, r"^context\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::\s*(.*?))?\s*(=\s*…)?$"); +re!(T_COMP, r"^component\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(props:\s*(\{.*?\}|\{\})\)(.*)$"); +re!(T_EQ, r"^([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::\s*(.*))?$"); +re!(P_ACTION, r"(^|[^A-Za-z0-9_])action\s+([A-Za-z_$][A-Za-z0-9_$]*)\s+catches\(([^)]*)\)"); +re!(P_UNRENDERED, r"(^|[^A-Za-z0-9_])unrendered([^A-Za-z0-9_]|$)"); +re!(P_CATCHES, r"(^|[^A-Za-z0-9_])catches\(([^)]*)\)"); +re!(P_PROVIDES, r"(^|[^A-Za-z0-9_])provides\(([^)]*)\)"); +re!(C_FAILS, r"(^|[^A-Za-z0-9_])fails\(([^)]*)\)"); +re!(C_USES, r"(^|[^A-Za-z0-9_])uses\(([^)]*)\)"); +re!(C_SHAPE, r"(^|[^A-Za-z0-9_])shape\(([^)]*)\)"); +re!(SOURCE_WORD, r"^source([^A-Za-z0-9_]|$)"); +re!(REF_PARAM, r"^ref\s"); + +fn classes_of(s: &str) -> Vec { + s.split('|').map(|x| crate::parse::js_trim(x).to_string()).filter(|x| !x.is_empty() && x != "nothing").collect() +} +fn catch_of(s: &str) -> Catch { + if crate::parse::js_trim(s) == "*" { Catch::All } else { Catch::Set(classes_of(s).into_iter().collect()) } +} +/// Split on `sep` outside parentheses and braces. +fn split(s: &str, sep: char) -> Vec { + let mut out = vec![]; + let mut depth = 0i64; + let mut cur = String::new(); + for ch in s.chars() { + if "({[<".contains(ch) { + depth += 1; + } + if ")}]>".contains(ch) { + depth -= 1; + } + if ch == sep && depth == 0 { + out.push(std::mem::take(&mut cur)); + } else { + cur.push(ch); + } + } + if !crate::parse::js_trim(&cur).is_empty() { + out.push(cur); + } + out.iter().map(|x| crate::parse::js_trim(x).to_string()).filter(|x| !x.is_empty()).collect() +} +struct RowWords { + rows: Vec, + fails: Vec, + rest: String, +} +fn row_words(s: &str) -> RowWords { + let words = split(s, ' '); + let mut rows = vec![]; + let mut fails = vec![]; + let mut i = 0; + while i < words.len() { + let Some(m) = ROW.captures(&words[i]) else { break }; + if let Some(f) = m.get(2) { + fails = classes_of(f.as_str()); + } else { + rows.push(m[1].to_string()); + } + i += 1; + } + RowWords { rows, fails, rest: words[i..].join(" ") } +} +fn shape_of(s: Option<&str>) -> Result { + let mut sh = Shape::default(); + let Some(s) = s else { return Ok(sh) }; + for p in s.split(';').map(crate::parse::js_trim).filter(|x| !x.is_empty()) { + let ws: Vec = p.split(char::is_whitespace).filter(|x| !x.is_empty()).map(String::from).collect(); + let (w, rest) = (ws[0].as_str(), ws[1..].to_vec()); + match w { + "roots" => sh.roots = rest, + "closes-p" => sh.closes_p = true, + "a" => sh.a = true, + "form" => sh.form = true, + "children-at" => sh.children_at.push(rest), + _ => return Err(format!("shape: {}", p)), + } + } + Ok(sh) +} + +/// A `.d.rx` printed for (or written as) an `.rx` module's signature, read in place of the module. `file` is the `.rx` +/// module's path: context keys are its, and relative keys resolve against it. +pub fn parse_signature(src: &str, file: &str) -> Result { + let mut exports = IndexMap::new(); + for (i, raw) in src.split('\n').enumerate() { + let t = TRAIL_COMMENT.replace(raw, ""); + let t = LEAD_COMMENT.replace(&t, ""); + let t = crate::parse::js_trim(&t).to_string(); + if t.is_empty() { + continue; + } + let fail = |msg: &str| SignatureError(format!("{}:{}: {}: {}", file.strip_suffix(".rx").map(|f| format!("{}.d.rx", f)).unwrap_or_else(|| file.to_string()), i + 1, msg, t)); + if !t.starts_with("export ") { + return Err(fail("a signature declares exports")); + } + let s = crate::parse::js_trim(&t[7..]).to_string(); + if let Some(m) = T_TYPE.captures(&s) { + exports.insert(m[1].to_string(), ExportSig::Type); + continue; + } + if let Some(m) = T_ACTION.captures(&s) { + exports.insert(m[1].to_string(), ExportSig::Action); + continue; + } + if let Some(m) = T_ERROR.captures(&s) { + let params = m.get(2).map(|x| x.as_str()).unwrap_or("").split(',').map(|x| crate::parse::js_trim(x).to_string()).filter(|x| !x.is_empty()).collect(); + exports.insert(m[1].to_string(), ExportSig::ErrClass { params }); + continue; + } + if let Some(m) = T_SOURCE.captures(&s) { + let r = row_words(m.get(2).map(|x| x.as_str()).unwrap_or("")); + if !r.rest.is_empty() { + return Err(fail("a source's rows")); + } + exports.insert(m[1].to_string(), ExportSig::Source { question: &m[3] == "=", rows: r.rows, fails: r.fails }); + continue; + } + if let Some(m) = T_SERVER.captures(&s) { + let params = m[3].split(',').map(|x| crate::parse::js_trim(x).to_string()).filter(|x| !x.is_empty()).collect(); + exports.insert(m[2].to_string(), ExportSig::Server { io: m.get(1).is_none(), params, rejects: classes_of(m.get(4).map(|x| x.as_str()).unwrap_or("")) }); + continue; + } + if let Some(m) = T_FN.captures(&s) { + let params = split(&m[2], ',') + .into_iter() + .map(|p0| { + let r = REF_PARAM.is_match(&p0); + let p = if r { crate::parse::js_trim(&p0[4..]).to_string() } else { p0.clone() }; + match p.find(':') { + None => SigParam { name: crate::parse::js_trim(&p).to_string(), r#ref: r, ty: None }, + Some(c) => SigParam { name: crate::parse::js_trim(&p[..c]).to_string(), r#ref: r, ty: Some(crate::parse::js_trim(&p[c + 1..]).to_string()) }, + } + }) + .collect(); + let r = row_words(m.get(3).map(|x| x.as_str()).unwrap_or("")); + if !r.rest.is_empty() { + return Err(fail("a fn's rows")); + } + exports.insert(m[1].to_string(), ExportSig::Fn { params, instantiating: m.get(5).is_some(), rows: r.rows, fails: r.fails, rejects: classes_of(m.get(4).map(|x| x.as_str()).unwrap_or("")) }); + continue; + } + if let Some(m) = T_CONTEXT.captures(&s) { + let mut body = crate::parse::js_trim(m.get(2).map(|x| x.as_str()).unwrap_or("")).to_string(); + let source = SOURCE_WORD.is_match(&body); + if source { + body = crate::parse::js_trim(&body[6..]).to_string(); + } + let r = row_words(&body); + exports.insert( + m[1].to_string(), + ExportSig::Context { key: format!("{}#{}", file, &m[1]), source, dflt: m.get(3).is_some(), rows: r.rows, fails: r.fails, ty: if r.rest.is_empty() { None } else { Some(r.rest) } }, + ); + continue; + } + if let Some(m) = T_COMP.captures(&s) { + let mut sig = CompSig { own: vec![], requires: vec![], props: IndexMap::new(), shape: None }; + let inner = &m[2][1..m[2].len() - 1]; + for f in split(inner, ';') { + let (fname0, spec) = match f.find(':') { + Some(c) => (crate::parse::js_trim(&f[..c]).to_string(), crate::parse::js_trim(&f[c + 1..]).to_string()), + None => (crate::parse::js_trim(&f).to_string(), String::new()), + }; + let fname = if fname0 == "...props" { "*".to_string() } else { fname0 }; + let mut v = PropSig { f: Catch::Set(IndexSet::new()), p: Catch::Set(IndexSet::new()), rendered: true, action: None }; + let a = P_ACTION.captures(&spec); + let rest = match &a { + Some(a) => { + v.action = Some((catch_of(&a[3]), a[2].to_string())); + let whole = a.get(0).unwrap(); + let start = whole.end() - (whole.as_str().len() - a.get(1).unwrap().as_str().len()); + let matched = &spec[start..whole.end()]; + spec.replacen(matched, "", 1) + } + None => spec.clone(), + }; + if P_UNRENDERED.is_match(&rest) { + v.rendered = false; + v.f = Catch::All; + v.p = Catch::All; + } + if let Some(c) = P_CATCHES.captures(&rest) { + v.f = catch_of(&c[2]); + } + if let Some(p) = P_PROVIDES.captures(&rest) { + v.p = if crate::parse::js_trim(&p[2]) == "*" { + Catch::All + } else { + Catch::Set(p[2].split(',').map(|k| abs_key(file, crate::parse::js_trim(k))).filter(|x| !x.is_empty()).collect()) + }; + } + sig.props.insert(fname, v); + } + let tail = &m[3]; + if let Some(fl) = C_FAILS.captures(tail) { + let at = file.split('/').last().map(String::from); + sig.own = classes_of(&fl[2]).into_iter().map(|c| Own { cls: c, what: format!("{}'s root", &m[1]), at: at.clone() }).collect(); + } + if let Some(us) = C_USES.captures(tail) { + sig.requires = us[2].split(',').map(|k| abs_key(file, crate::parse::js_trim(k))).collect(); + } + sig.shape = Some(shape_of(C_SHAPE.captures(tail).map(|x| x.get(2).unwrap().as_str())).map_err(|e| fail(&e))?); + exports.insert(m[1].to_string(), ExportSig::Comp { sig }); + continue; + } + if let Some(m) = T_EQ.captures(&s) { + let r = row_words(m.get(2).map(|x| x.as_str()).unwrap_or("")); + if !r.rest.is_empty() { + return Err(fail("an equation's rows")); + } + let io = r.rows.iter().any(|x| x == "io"); + exports.insert(m[1].to_string(), ExportSig::Eq { rows: r.rows.into_iter().filter(|x| x != "io").collect(), fails: r.fails, io }); + continue; + } + return Err(fail("not an export")); + } + Ok(ModSig { file: file.into(), exports }) +} diff --git a/ref/README.md b/ref/README.md index 962ab2e..231f7e2 100644 --- a/ref/README.md +++ b/ref/README.md @@ -35,6 +35,16 @@ ref/check.ts [--signature] file.rx…` prints diagnostics for files and the signature as `.d.rx` text — the text an importer is checked against when the module's `.rx` is not there (MO-9, `src/signature.ts`). +Resolved model (spec/11 CP-6): `src/model.ts`, filled by the checker's walk. +`node --experimental-strip-types ref/model.ts file.rx…` prints, per module, its +regions, symbols and computations, each computation with its reads (tracked, +snapshot, pre), writes and calls. + +The Rust crate (`compiler/`, spec/11 CP-7) is diffed against this directory by +`npm --prefix ref run crate` (`crate.ts`): every program in `scenarios/` and +`examples/` is loaded, parsed, checked, its signatures printed and its model +built by both, and the two must agree exactly. Needs cargo; not Solid. + Phases: 1. core: sources, equations, `pre`, `peek`, effects, instants (02-core §4) 2. regions: if/for as scoped sub-programs with disposal (03) diff --git a/ref/crate.ts b/ref/crate.ts new file mode 100644 index 0000000..bfd6393 --- /dev/null +++ b/ref/crate.ts @@ -0,0 +1,74 @@ +// CP-5: the Rust crate (compiler/) against ref/ on every program the scenarios and examples hold. For each program both +// sides load the modules, parse, check, print signatures and build the resolved model (CP-6); the two must agree exactly. +// Usage: node --experimental-strip-types ref/crate.ts [filter] (builds compiler/ with cargo first; VERBOSE=1 lists each) +import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; +import { parseScenario } from "./src/scenario.ts"; +import { loadModules } from "./src/modules.ts"; +import { checkModules } from "./src/check.ts"; +import { printSignature } from "./src/signature.ts"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const filter = process.argv[2]; +type Req = { id: string; entry: string; files: Record }; +const reqs: Req[] = []; + +/** A program: its entry and every file the loader reads for it (MO-8 `.d.rx`, MO-9 signatures, imported modules). */ +function add(id: string, entry: string, read: (p: string) => string | undefined) { + if (filter && !id.includes(filter)) return; + const files: Record = {}; + const rec = (p: string) => { const s = read(p); if (s !== undefined) files[p] = s; return s; }; + try { loadModules(entry, rec); } catch { /* the files read up to the error are what both sides see */ } + reqs.push({ id, entry, files }); +} +const disk = (p: string) => existsSync(p) && statSync(p).isFile() ? readFileSync(p, "utf8") : undefined; + +const sdir = join(root, "scenarios"); +for (const f of readdirSync(sdir).filter(f => f.endsWith(".md") && f !== "TEMPLATE.md").sort()) { + const sc = parseScenario(f.replace(/\.md$/, ""), readFileSync(join(sdir, f), "utf8"), sdir); + const read = (src: string) => (p: string) => p === sc.entry ? src : sc.modules.get(p) ?? disk(p); + if (sc.program) add(`${sc.name} Program`, sc.entry, read(sc.program)); + for (const t of sc.tables) if (t.program) add(`${sc.name} ${t.title}`, sc.entry, read(t.program)); + sc.rejected.forEach((r, i) => add(`${sc.name} ${r.types ? "types" : r.warn ? "warn" : "reject"}(${r.rule}) #${i}`, sc.entry, read(r.program))); +} +const walk = (d: string): string[] => readdirSync(d, { withFileTypes: true }).flatMap(e => e.isDirectory() ? walk(join(d, e.name)) : e.name.endsWith(".rx") && !e.name.endsWith(".d.rx") ? [join(d, e.name)] : []); +for (const f of walk(join(root, "examples")).sort()) add(`examples/${f.slice(join(root, "examples").length + 1)}`, f, disk); + +const plain = (x: unknown) => JSON.parse(JSON.stringify(x, (_k, v) => v instanceof Map ? Object.fromEntries(v) : v instanceof Set ? [...v] : v)); +function refOf(r: Req) { + const read = (p: string) => r.files[p]; + let set; try { set = loadModules(r.entry, read); } catch (e) { return { error: (e as Error).message }; } + let checked; try { checked = checkModules(set); } catch (e) { return { throws: (e as Error).message }; } + return plain({ + order: set.order, + asts: Object.fromEntries(set.order.map(p => [p, set.programs.get(p)])), + diags: checked.diags, + sigs: Object.fromEntries(set.order.map(p => [p, printSignature(checked.sigs.get(p)!)])), + models: Object.fromEntries(checked.models), + }); +} +/** The first place two JSON values differ, as a path. */ +function diff(a: any, b: any, at = ""): string | null { + if (a === b) return null; + if (typeof a !== typeof b || a === null || b === null || typeof a !== "object" || Array.isArray(a) !== Array.isArray(b)) + return `${at || "/"}: ref ${JSON.stringify(a)?.slice(0, 200)} ≠ crate ${JSON.stringify(b)?.slice(0, 200)}`; + const keys = Array.isArray(a) ? [...Array(Math.max(a.length, b.length)).keys()] : [...new Set([...Object.keys(a), ...Object.keys(b)])]; + for (const k of keys) { const d = diff(a[k as any], b[k as any], `${at}/${k}`); if (d) return d; } + return null; +} + +execFileSync("cargo", ["build", "--release", "--quiet"], { cwd: join(root, "compiler"), stdio: "inherit" }); +if (process.env.DUMP) { const { writeFileSync } = await import("node:fs"); writeFileSync(process.env.DUMP, JSON.stringify(reqs)); } +const out = execFileSync(join(root, "compiler/target/release/rxc"), ["conform"], { input: JSON.stringify(reqs), maxBuffer: 1 << 30 }).toString(); +const got = new Map((JSON.parse(out) as any[]).map(x => [x.id, x])); +let bad = 0; +const verbose = !!process.env.VERBOSE; +for (const r of reqs) { + const want = refOf(r); const { id: _, ...have } = got.get(r.id) ?? {}; + const d = diff(want, have); + if (d) { bad++; console.log(`FAIL ${r.id}\n ${d}`); } else if (verbose) console.log(`ok ${r.id}`); +} +console.log(`${reqs.length - bad}/${reqs.length} programs: the crate agrees with ref/ (AST, diagnostics, signatures, resolved model)`); +process.exit(bad ? 1 : 0); diff --git a/ref/model.ts b/ref/model.ts new file mode 100644 index 0000000..3af99dd --- /dev/null +++ b/ref/model.ts @@ -0,0 +1,16 @@ +// Usage: node --experimental-strip-types ref/model.ts file.rx... +// Prints the resolved model (spec/11 CP-6) of each file and the .rx modules it imports, as JSON: regions, symbols, and the +// computations with their reads (tracked, snapshot, pre), writes and calls. The checker's diagnostics come first, if any. +import { readFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { loadModules } from "./src/modules.ts"; +import { checkModules } from "./src/check.ts"; +let bad = 0; +for (const f of process.argv.slice(2)) { + const set = loadModules(resolve(f), p => existsSync(p) ? readFileSync(p, "utf8") : undefined); + const { diags, models } = checkModules(set); + for (const d of diags) { bad++; console.error(`${d.file ?? f}:${d.line}: error ${d.code} [${d.rule}] ${d.msg}`); } + const rel = (p: string) => p.replace(process.cwd() + "/", ""); + console.log(JSON.stringify(set.order.map(p => ({ ...models.get(p)!, file: rel(p) })), null, 1).replace(/\n\s+(?=[^\s{\[])/g, " ").replace(/\n\s+([}\]])/g, " $1")); +} +process.exit(bad ? 1 : 0); diff --git a/ref/package.json b/ref/package.json index 6d64a18..55d3708 100644 --- a/ref/package.json +++ b/ref/package.json @@ -8,7 +8,9 @@ "oracle": "node --experimental-strip-types --conditions=browser --conditions=development oracle/run.ts", "compiled": "node --experimental-strip-types --conditions=browser --conditions=development compile/run.ts", "ssr": "node --experimental-strip-types --conditions=development ssr/run.ts", - "types": "node --experimental-strip-types types/run.ts" + "types": "node --experimental-strip-types types/run.ts", + "crate": "node --experimental-strip-types crate.ts", + "model": "node --experimental-strip-types model.ts" }, "dependencies": { "@solidjs/compiler": "file:../../solid-next/packages/compiler", diff --git a/ref/src/check.ts b/ref/src/check.ts index bc87955..60e0e28 100644 --- a/ref/src/check.ts +++ b/ref/src/check.ts @@ -7,6 +7,7 @@ import type { Diagnostic } from "./errors.ts"; import type { ModuleSet } from "./modules.ts"; import type { JsDecl } from "./parse.ts"; import { misnested, misnestedInside, P_CLOSERS, P_SCOPE, EMPTY_SHAPE, type Shape } from "./content.ts"; +import { ModelBuilder, type Model, type RegionKind, type CompKind } from "./model.ts"; const BUILTINS = new Set(["len", "str", "map", "filter", "sum", "fail", "fetch", "every"]); // the scenario prelude; all pure const isHandler = (attr: string) => /^on[A-Z:]/.test(attr); // SC-4 @@ -176,16 +177,18 @@ export type ModSig = { file: string; exports: Map }; type Opts = { sigs?: Map; file?: string; declarations?: Map }; export function check(p: Program, opts: Opts = {}): Diagnostic[] { const c = new Checker(opts); c.run(p); return c.diags; } -export function checkWithSignature(p: Program, opts: Opts = {}): { diags: Diagnostic[]; sig: ModSig } { const c = new Checker(opts); c.run(p); return { diags: c.diags, sig: c.signature(p) }; } +export function checkWithSignature(p: Program, opts: Opts = {}): { diags: Diagnostic[]; sig: ModSig; model: Model } { const c = new Checker(opts); c.run(p); return { diags: c.diags, sig: c.signature(p), model: c.model.build(opts.file ?? "main.rx") }; } /** CN-4b / MO-3: check every module of a program, dependencies first, each against its imports' signatures only. */ -export function checkModules(set: ModuleSet): { diags: Diagnostic[]; sigs: Map } { +export function checkModules(set: ModuleSet): { diags: Diagnostic[]; sigs: Map; models: Map } { const sigs = new Map(set.signatures ?? []); const diags: Diagnostic[] = [...set.diags]; // MO-9: a module known by its .d.rx - for (const path of set.order) { const r = checkWithSignature(set.programs.get(path)!, { sigs, file: path, declarations: set.declarations }); diags.push(...r.diags); sigs.set(path, r.sig); } - return { diags, sigs }; + const models = new Map(); // CP-6: each module's resolved model + for (const path of set.order) { const r = checkWithSignature(set.programs.get(path)!, { sigs, file: path, declarations: set.declarations }); diags.push(...r.diags); sigs.set(path, r.sig); models.set(path, r.model); } + return { diags, sigs, models }; } class Checker { diags: Diagnostic[] = []; + model = new ModelBuilder(); // CP-6: the resolved facts, recorded as the walk resolves them private opts: Opts; private rootEnv: Env | null = null; constructor(opts: Opts = {}) { this.opts = opts; } @@ -201,14 +204,19 @@ class Checker { private compSyms = new Map(); private rowsBusy = new Set(); private err(code: string, rule: string, msg: string) { if (!this.diags.some(d => d.code === code && d.line === this.line && d.msg === msg)) this.diags.push({ code, rule, line: this.line, msg, ...(this.opts.file ? { file: this.opts.file } : {}) }); } - private region(parent: Region | null, opts: { component?: boolean; provParent?: Region | null } = {}): Region { + private region(parent: Region | null, kind: RegionKind, opts: { component?: boolean; provParent?: Region | null; name?: string } = {}): Region { const r: Region = { id: this.seq++, depth: parent ? parent.depth + 1 : 0, parent, component: null, isComponentRoot: !!opts.component, provParent: opts.provParent === undefined ? parent : opts.provParent, appSites: [], provides: new Set() }; r.component = opts.component ? r : parent?.component ?? null; + this.model.region(r.id, kind, parent, opts.name); return r; } + /** CP-6: a computation the walk enters — its reads, writes and calls are recorded on it. */ + private comp(kind: CompKind, env: Env, info: { name?: string; at?: string }, f: () => void) { this.model.within(kind, env.region, this.line, info, f); } + /** CP-6: a symbol bound in `env`, recorded for the model. */ + private bind(env: Env, name: string, sym: Sym) { env.vars.set(name, sym); this.model.sym(sym); } run(p: Program) { - const root = new Env(null, this.region(null)); this.rootEnv = root; + const root = new Env(null, this.region(null, "program")); this.rootEnv = root; if (p.legacyView) { this.line = p.view[0]?.line ?? 1; this.err("TOP_LEVEL_MARKUP", "MO-1c", "markup at a module's top level: a module is declarations; the app's view is a component — `export default component App() { … }`"); } this.block(p.decls, p.view, root, { scope: "reactive" }); this.later.push(() => this.contentCheck(p.view, root, [])); @@ -223,13 +231,13 @@ class Checker { this.line = d.line; if (d.k === "import" && d.resolved) { this.importRx(d, env); continue; } // MO-1/MO-3: an .rx module's signature if (d.k === "import" && d.declared) { this.importDeclared(d, env); continue; } // MO-8: a JS module's declaration file - if (d.k === "import") { for (const { alias } of d.names) env.vars.set(alias, { kind: "import", name: alias, region: env.region, line: d.line, io: !d.pure, rejects: new Set(d.fails ?? ["Error"]), side: d.side }); continue; } // R-io-4, R-f-8, SF-5 + if (d.k === "import") { for (const { alias } of d.names) this.bind(env, alias, { kind: "import", name: alias, region: env.region, line: d.line, io: !d.pure, rejects: new Set(d.fails ?? ["Error"]), side: d.side }); continue; } // R-io-4, R-f-8, SF-5 if (d.k === "effect" || d.k === "provide" || d.k === "typedecl") continue; if (d.k === "context") { // RG-7: a declared context — its identity is the declaration, not its spelling if (env.vars.has(d.name)) this.err("DUPLICATE_DECLARATION", "E-7", `${d.name} is declared twice in one block`); const contract = (d.type && ROW_WORDS.test(d.type)) || d.exported ? this.contractOf(d.type) : null; // RG-7e: written rows, or exported (its type is then the contract: no row words, no rows) if (d.source && d.dflt) this.err("INVALID_CONTEXT_DEFAULT", "RG-7c", `context ${d.name}: source … has no default — a provided source is its provider's; a default would be one source every user writes (declare that source instead)`); - env.vars.set(d.name, { kind: "context", name: d.name, region: env.region, line: d.line, ctx: { key: `${this.opts.file ?? "main.rx"}#${d.name}${env.region.parent ? `@${this.seq++}` : ""}`, source: d.source, dflt: !!d.dflt, contract } }); + this.bind(env, d.name, { kind: "context", name: d.name, region: env.region, line: d.line, ctx: { key: `${this.opts.file ?? "main.rx"}#${d.name}${env.region.parent ? `@${this.seq++}` : ""}`, source: d.source, dflt: !!d.dflt, contract } }); if (d.dflt) this.provisions.push({ key: env.vars.get(d.name)!.ctx!.key, e: d.dflt, env }); continue; } @@ -239,10 +247,10 @@ class Checker { if (d.k === "source") sym.question = !!d.question; if (d.k === "action") sym.io = true; // R-io-2: an action is io if (d.k === "fn") { sym.params = d.params; sym.instantiating = d.params.some(p => p.ref) || d.decls.length > 0; sym.fnDecl = d; sym.declEnv = env; } - if (d.k === "component") { sym.comp = d; sym.compRegion = this.region(env.region, { component: true }); this.compSyms.set(sym.compRegion, sym); } + if (d.k === "component") { sym.comp = d; sym.compRegion = this.region(env.region, "component", { component: true, name: d.name }); this.compSyms.set(sym.compRegion, sym); } if (d.k === "eq") { sym.mapsRecords = !d.key && mapsIntoRecords(d.e); if (d.e.k === "id") { const t = env.lookup(d.e.name); if (t?.io) sym.io = true; } } if (d.k === "eq") { const sf = unwrapServer(d.e); if (sf) { sym.kind = "server"; sym.params = sf.params.map(name => ({ name, ref: false })); this.serverExprs.set(sf, sym); } } // SF-1d: a wrapped server fn is one, its rows the function's - env.vars.set(d.name, sym); + this.bind(env, d.name, sym); } this.serverIo(decls, env); for (const d of decls) if (d.k === "source" && !d.question) env.vars.get(d.name)!.failsT = this.failsThunk(d.init, env); // R-f-2: a source holds what its initializer gave it — an error, if that failed (found by S-ai2-settings) @@ -282,8 +290,8 @@ class Checker { for (const { name, alias } of d.names) { const base: Sym = { kind: "import", name: alias, region: env.region, line: d.line }; const x = sig?.exports.get(name); - if (!sig) { env.vars.set(alias, base); continue; } // the module was not found or not checked (reported there) - if (!x) { this.err("NOT_EXPORTED", "MO-6", `${d.from} does not export ${name === "default" ? "a default" : name}`); env.vars.set(alias, base); continue; } + if (!sig) { this.bind(env, alias, base); continue; } // the module was not found or not checked (reported there) + if (!x) { this.err("NOT_EXPORTED", "MO-6", `${d.from} does not export ${name === "default" ? "a default" : name}`); this.bind(env, alias, base); continue; } if (x.kind === "type") continue; // a type name: text to the checker (07 §2), nothing to bind const rowsOf = (r: string[]) => () => new Set(r); const failsOf = (f: string[]) => () => ({ cls: new Set(f), props: [] as PropRef[] }); @@ -295,11 +303,11 @@ class Checker { case "action": sym = { kind: "action", name: alias, region: env.region, line: d.line, io: true }; break; case "server": { const rj = new Set(x.rejects); sym = { kind: "server", name: alias, region: env.region, line: d.line, io: x.io, params: x.params.map(n => ({ name: n, ref: false })), serverRejects: () => rj }; break; } case "errclass": sym = { kind: "errclass", name: alias, region: env.region, line: d.line }; break; - case "comp": { const r = this.region(null, { component: true }); r.sig = x.sig; sym = { kind: "comp", name: alias, region: env.region, line: d.line, compRegion: r }; break; } + case "comp": { const r = this.region(null, "imported", { component: true, name: alias }); r.sig = x.sig; sym = { kind: "comp", name: alias, region: env.region, line: d.line, compRegion: r }; break; } case "context": sym = { kind: "context", name: alias, region: env.region, line: d.line, ctx: { key: x.key, source: x.source, dflt: x.dflt, contract: { rows: new Set(x.rows), fails: new Set(x.fails) } } }; break; } if (d.pure && sym.io) this.err("IMPORT_ROW_MISMATCH", "MO-4", `${name} is io in ${d.from}'s signature; \`pure\` cannot lower an .rx row`); - env.vars.set(alias, sym); + this.bind(env, alias, sym); } } /** RG-7: the context a `provide`/`use` names — lexically, like any name. */ @@ -322,12 +330,12 @@ class Checker { if (d.fails) this.err("IMPORT_ROW_MISMATCH", "MO-8", `${d.from}'s rows are declared in ${file}: \`fails\` on the import cannot restate them`); for (const { name, alias } of d.names) { const x = decls.find(y => y.name === name); - if (!x) { this.err("NOT_EXPORTED", "MO-8", `${file} declares no export ${name} (declare it, or it is not ${d.from}'s)`); env.vars.set(alias, { kind: "import", name: alias, region: env.region, line: d.line, io: true }); continue; } + if (!x) { this.err("NOT_EXPORTED", "MO-8", `${file} declares no export ${name} (declare it, or it is not ${d.from}'s)`); this.bind(env, alias, { kind: "import", name: alias, region: env.region, line: d.line, io: true }); continue; } if (x.kind === "type") continue; - if (x.kind === "error") { env.vars.set(alias, { kind: "errclass", name: alias, region: env.region, line: d.line }); continue; } + if (x.kind === "error") { this.bind(env, alias, { kind: "errclass", name: alias, region: env.region, line: d.line }); continue; } if (d.pure && !x.pure) this.err("IMPORT_ROW_MISMATCH", "MO-8", `${name} is declared io in ${file} (\`export fn\`, not \`export pure fn\`); \`pure\` on the import cannot lower it`); if (d.side && x.side !== d.side) this.err("IMPORT_ROW_MISMATCH", "SF-5", `${name} is declared ${x.side ? `\`${x.side}\`` : "for both sides"} in ${file}; \`${d.side}\` on the import cannot restate it`); - env.vars.set(alias, { kind: "import", name: alias, region: env.region, line: d.line, io: !x.pure, rejects: new Set(x.rejects ?? ["Error"]), side: x.side }); + this.bind(env, alias, { kind: "import", name: alias, region: env.region, line: d.line, io: !x.pure, rejects: new Set(x.rejects ?? ["Error"]), side: x.side }); } } /** MO-3: this module's signature — every export as an importer sees it. */ @@ -406,19 +414,20 @@ class Checker { case "typedecl": return; case "error": if (d.msg) { const inner = new Env(env, env.region); for (const p of d.params) inner.vars.set(p, { kind: "value", name: p, region: env.region, line: d.line }); this.expr(d.msg, inner, { scope: "reactive", inFn: true }); } return; case "source": { - if (d.question) { this.expr(d.init, env, reactive); this.rowCheck(d.name, d.type, env); return; } + if (d.question) { this.comp("question", env, { name: d.name }, () => this.expr(d.init, env, reactive)); this.rowCheck(d.name, d.type, env); return; } const reads = new Set(); readsOutsidePeek(d.init, reads); const hit = [...reads].find(n => { const s = env.lookup(n); return s && reactiveKinds.has(s.kind); }); if (hit) this.err("SNAPSHOT_INITIALIZER", "E-4", `source ${d.name} := … reads ${hit} outside peek(…): write the snapshot as one (DV-6)`); - this.expr(d.init, env, { scope: "reactive", inFn: ctx.inFn }); + this.comp("seed", env, { name: d.name }, () => { this.model.snap++; this.expr(d.init, env, { scope: "reactive", inFn: ctx.inFn }); }); // E-4: a seed is a snapshot return; } case "eq": { - if (d.e.k === "call" && d.e.f.k === "id") { const f = env.lookup(d.e.f.name); if (f?.kind === "fn" && f.instantiating) { this.snapshotArgs(d.e, f, env); d.e.args.forEach(a => this.expr(a, env, reactive)); this.rowCheck(d.name, d.type, env); return; } } - this.expr(d.e, env, reactive); this.rowCheck(d.name, d.type, env); return; + const kind = d.deferred ? "deferred" : d.key ? "keyed" : "memo"; + if (d.e.k === "call" && d.e.f.k === "id") { const f = env.lookup(d.e.f.name); if (f?.kind === "fn" && f.instantiating) { const call = d.e; this.snapshotArgs(call, f, env); this.comp(kind, env, { name: d.name }, () => { this.model.call(f); call.args.forEach((a, i) => { const value = !!f.params![i] && !f.params![i].ref; if (value) this.model.snap++; this.expr(a, env, reactive); if (value) this.model.snap--; }); }); this.rowCheck(d.name, d.type, env); return; } } // E-10: a value parameter's argument is a snapshot + this.comp(kind, env, { name: d.name }, () => this.expr(d.e, env, reactive)); this.rowCheck(d.name, d.type, env); return; } case "provide": { - this.expr(d.e, env, reactive); + this.comp("provide", env, { name: d.name }, () => this.expr(d.e, env, reactive)); const c = this.ctxOf(d.name, env); if (!c) return; env.region.provides.add(c.ctx!.key); this.provisions.push({ key: c.ctx!.key, e: d.e, env }); if (c.ctx!.source) { const t = d.e.k === "id" ? env.lookup(d.e.name) : undefined; if (!t || t.kind !== "source" || t.imported) this.err("PROVIDE_NOT_A_SOURCE", "RG-7c", `context ${d.name} carries a source: provide one of this module's sources by name (\`provide ${d.name} = x\`)`); } @@ -431,41 +440,42 @@ class Checker { }); return; } - case "context": if (d.dflt) this.expr(d.dflt, env, reactive); return; + case "context": if (d.dflt) { const dflt = d.dflt; this.comp("default", env, { name: d.name }, () => this.expr(dflt, env, reactive)); } return; case "effect": { - for (const i of d.inputs) this.expr(i, env, reactive); // EF-1: the compute half is reactive scope - this.stmts(d.body, new Env(env, env.region), { scope: "imperative" }); + this.comp("effect", env, {}, () => { + for (const i of d.inputs) this.expr(i, env, reactive); // EF-1: the compute half is reactive scope + this.comp("apply", env, {}, () => this.stmts(d.body, new Env(env, env.region), { scope: "imperative" })); + }); this.ownSourceWrites(d, env); return; } case "action": { const inner = new Env(env, env.region); for (const p of d.params) inner.vars.set(p, { kind: "value", name: p, region: env.region, line: d.line }); - this.stmts(d.body, inner, { scope: "imperative", inAction: true }); + this.comp("action", env, { name: d.name }, () => this.stmts(d.body, inner, { scope: "imperative", inAction: true })); this.actions.push({ name: d.name, body: d.body, env: inner, line: d.line }); return; } case "server": { // 12 SF-1/SF-3/SF-4: a statement block that runs on the server const inner = new Env(env, env.region); for (const p of d.params) inner.vars.set(p, { kind: "value", name: p, region: env.region, line: d.line }); - this.stmts(d.body, inner, { scope: "imperative", server: true }); + this.comp("server", env, { name: d.name }, () => this.stmts(d.body, inner, { scope: "imperative", server: true })); let cache: Set | null = null; env.lookup(d.name)!.serverRejects = () => cache ??= new Set([...this.stmtsFails(d.body, inner, new Map()).cls, "Error"]); // SF-6b: every call can also fail in transit return; } case "fn": { - const r = d.decls.length ? this.region(env.region) : env.region; // RG-6: an instantiating fn is a region + const r = d.decls.length ? this.region(env.region, "fn", { name: d.name }) : env.region; // RG-6: an instantiating fn is a region const inner = new Env(env, r); - for (const p of d.params) inner.vars.set(p.name, { kind: p.ref ? "eq" : "value", name: p.name, region: r, line: d.line }); - this.block(d.decls, [], inner, { scope: "reactive", inFn: true }); - this.expr(d.tail, inner, { scope: "reactive", inFn: true }); + for (const p of d.params) this.bind(inner, p.name, { kind: p.ref ? "eq" : "value", name: p.name, region: r, line: d.line }); + this.comp("fn", env, { name: d.name }, () => { this.block(d.decls, [], inner, { scope: "reactive", inFn: true }); this.expr(d.tail, inner, { scope: "reactive", inFn: true }); }); { const sym = env.lookup(d.name)!; sym.fnTail = d.tail; sym.fnEnv = inner; sym.failsT = this.failsThunk(d.tail, inner); sym.rows = this.rowsThunk(d.tail, inner); } return; } case "component": { const sym = env.lookup(d.name)!; const r = sym.compRegion!; const inner = new Env(env, r); - inner.vars.set(d.props, { kind: "props", name: d.props, region: r, line: d.line }); + this.bind(inner, d.props, { kind: "props", name: d.props, region: r, line: d.line }); sym.compEnv = inner; this.block(d.decls, d.view, inner, { scope: "reactive" }); this.later.push(() => this.contentCheck(d.view, inner, [])); @@ -568,21 +578,24 @@ class Checker { } return null; } - private expr(e: Expr, env: Env, ctx: Ctx): void { + /** `field`: the member read of an identifier (`props.title`), for the model (CP-6). */ + private expr(e: Expr, env: Env, ctx: Ctx, field?: string): void { const go = (x: Expr) => this.expr(x, env, ctx); switch (e.k) { case "lit": return; - case "id": { const s = this.resolve(e.name, env, ctx); if (s?.kind === "comp") this.err("COMPONENT_AS_VALUE", "RG-4a", `component ${e.name} used as a value: apply it in a view`); if (s?.kind === "context") this.err("CONTEXT_AS_VALUE", "RG-7", `context ${e.name} used as a value: read the nearest provision with \`use ${e.name}\``); return; } - case "pre": { const s = this.resolve(e.name, env, ctx); if (s && !reactiveKinds.has(s.kind)) this.err("PRE_OF_NON_REACTIVE", "C-2", `pre of ${e.name}, which is not reactive`); if (e.init) go(e.init); return; } - case "use": { if (ctx.server) this.err("SERVER_CAPTURE", "SF-3", `a server function's body reads \`use ${e.name}\`: a provision is the client's — pass it as an argument`); const c = this.ctxOf(e.name, env); if (c && !c.ctx!.dflt) this.uses.push({ region: env.region, name: c.ctx!.key, line: this.line, display: e.name }); return; } - case "peek": case "latest": case "pending": case "un": case "spread": go(e.e); return; + case "id": { const s = this.resolve(e.name, env, ctx); if (s) this.model.read(s, ctx.scope === "imperative", false, field); if (s?.kind === "comp") this.err("COMPONENT_AS_VALUE", "RG-4a", `component ${e.name} used as a value: apply it in a view`); if (s?.kind === "context") this.err("CONTEXT_AS_VALUE", "RG-7", `context ${e.name} used as a value: read the nearest provision with \`use ${e.name}\``); return; } + case "pre": { const s = this.resolve(e.name, env, ctx); if (s) this.model.read(s, ctx.scope === "imperative", true); if (s && !reactiveKinds.has(s.kind)) this.err("PRE_OF_NON_REACTIVE", "C-2", `pre of ${e.name}, which is not reactive`); if (e.init) go(e.init); return; } + case "use": { if (ctx.server) this.err("SERVER_CAPTURE", "SF-3", `a server function's body reads \`use ${e.name}\`: a provision is the client's — pass it as an argument`); const c = this.ctxOf(e.name, env); if (c) this.model.read(c, ctx.scope === "imperative"); if (c && !c.ctx!.dflt) this.uses.push({ region: env.region, name: c.ctx!.key, line: this.line, display: e.name }); return; } + case "peek": this.model.snap++; go(e.e); this.model.snap--; return; // E-5: a snapshot + case "latest": case "pending": { const save = this.model.probe; this.model.probe = e.k; go(e.e); this.model.probe = save; return; } // R-9, R-12: probes + case "un": case "spread": go(e.e); return; case "await": if (ctx.scope === "imperative" && !this.stmtAwaits.has(e)) this.err("AWAIT_IN_EXPRESSION", "AC-1", `\`${show(e)}\` inside an expression of imperative scope: a run suspends only at a statement — bind it first, \`let v = ${show(e)}\` (a \`catch (e) => alt\` may follow it)`); this.awaitedCalls.add(e.e); go(e.e); if (e.init) go(e.init); return; case "serverfn": { // SF-1d: as \`server fn\`, wherever it is written const inner = new Env(env, env.region); for (const p of e.params) inner.vars.set(p, { kind: "value", name: p, region: env.region, line: this.line }); - this.stmts(e.body, inner, { scope: "imperative", server: true }); + this.comp("server", env, {}, () => this.stmts(e.body, inner, { scope: "imperative", server: true })); const sym = this.serverExprs.get(e); let cache: Set | null = null; if (sym) sym.serverRejects = () => cache ??= new Set([...this.stmtsFails(e.body, inner, new Map()).cls, "Error"]); return; @@ -596,7 +609,7 @@ class Checker { case "bin": go(e.l); go(e.r); return; case "if": go(e.c); go(e.t); go(e.f); return; case "tpl": for (const p of e.parts) if (typeof p !== "string") go(p); return; - case "member": go(e.o); return; + case "member": if (e.o.k === "id") this.expr(e.o, env, ctx, e.p); else go(e.o); return; case "index": go(e.o); go(e.i); return; case "arr": e.items.forEach(go); return; case "rec": for (const f of e.fields) go("spread" in f ? f.spread : f.v); return; @@ -617,6 +630,7 @@ class Checker { if (s?.side === "client" && ctx.scope === "reactive" && !ctx.server && !this.awaitedCalls.has(e)) this.err("CLIENT_NOT_AWAITED", "SF-5b", `${e.f.name}(…) is \`client\` JS: in reactive scope it is awaited — a client question, asked once the browser has the page (SV-7b) — never called for its value now`); if (s?.kind === "fn" && s.instantiating) this.err("INSTANTIATION_NOT_BOUND", "E-9", `${e.f.name}(…) declares state: bind it, \`x = ${e.f.name}(…)\` (RG-6)`); if (s?.kind === "comp") this.err("COMPONENT_AS_VALUE", "RG-4a", `component ${e.f.name} called as a function: apply it in a view`); + this.model.call(s); } go(e.f); e.args.forEach(go); return; } @@ -666,10 +680,10 @@ class Checker { let target: Sym | undefined; if (s.target.ctx) { const c = this.ctxOf(s.target.name, env); - if (c) { this.uses.push({ region: env.region, name: c.ctx!.key, line: this.line, display: s.target.name }); if (!c.ctx!.source) this.err("WRITE_TO_NON_SOURCE", "RG-7c", `context ${s.target.name} is not declared \`source\`: what is provided is read-only (\`context ${s.target.name}: source T\` to write through it)`); } + if (c) { this.model.write(c, s.k, s.target.path.length > 0); this.uses.push({ region: env.region, name: c.ctx!.key, line: this.line, display: s.target.name }); if (!c.ctx!.source) this.err("WRITE_TO_NON_SOURCE", "RG-7c", `context ${s.target.name} is not declared \`source\`: what is provided is read-only (\`context ${s.target.name}: source T\` to write through it)`); } } else { - target = this.resolve(s.target.name, env, ctx); + target = this.resolve(s.target.name, env, ctx); this.model.write(target, s.k, s.target.path.length > 0); if (target && target.kind !== "source") this.err("WRITE_TO_NON_SOURCE", "W-1", `${s.target.name} is ${target.kind === "eq" ? "an equation" : target.kind === "props" ? "props" : `a ${target.kind}`}: only sources are written (W-1)`); else if (target?.imported) this.err("WRITE_TO_NON_SOURCE", "MO-2", `${s.target.name} is a source of ${target.imported}: a module boundary is a value boundary — write it through an action ${target.imported} exports`); } @@ -683,9 +697,9 @@ class Checker { break; } case "refresh": case "affects": case "action": if (ctx.server) { this.err("SERVER_CAPTURE", "SF-3", `\`${s.k}${s.k === "action" ? " { … }" : ` ${(s as { name: string }).name}`}\` in a server function's body: it is the client's (do it in the action that awaited the call)`); break; } - if (s.k === "action") { this.stmts(s.body, env, { scope: "imperative", inAction: true }); this.actions.push({ name: "(inline)", body: s.body, env, line: this.line }); break; } - if (s.k === "affects") { const t = this.resolve(s.name, env, ctx); if (t && !reactiveKinds.has(t.kind)) this.err("INVALID_AFFECTS_TARGET", "AC-5", `${s.name} is not reactive`); break; } - { const t = this.resolve(s.name, env, ctx); if (t && !(t.kind === "eq" || (t.kind === "source" && t.question))) this.err("INVALID_REFRESH_TARGET", "AC-4", `${s.name} has no question to re-ask`); break; } + if (s.k === "action") { this.comp("action", env, { name: "(inline)" }, () => this.stmts(s.body, env, { scope: "imperative", inAction: true })); this.actions.push({ name: "(inline)", body: s.body, env, line: this.line }); break; } + if (s.k === "affects") { const t = this.resolve(s.name, env, ctx); this.model.write(t, "affects"); if (t && !reactiveKinds.has(t.kind)) this.err("INVALID_AFFECTS_TARGET", "AC-5", `${s.name} is not reactive`); break; } + { const t = this.resolve(s.name, env, ctx); this.model.write(t, "refresh"); if (t && !(t.kind === "eq" || (t.kind === "source" && t.question))) this.err("INVALID_REFRESH_TARGET", "AC-4", `${s.name} has no question to re-ask`); break; } case "sif": this.expr(s.c, env, ctx); this.stmts(s.t, env, ctx); this.stmts(s.f, env, ctx); break; case "try": { this.stmts(s.body, env, ctx); @@ -741,50 +755,50 @@ class Checker { if (!viewRoot) this.err("STYLE_PLACEMENT", "CSS-1", "a