diff --git a/CHANGELOG.md b/CHANGELOG.md index 456418c..75a08a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +**New** + +- Solid libraries (MO-10): declare a primitive once in a `.d.rx` (`export pure fn createToggle(v: boolean): [ref + boolean, (v: boolean) => void]`) and use it like .rx: it is called once, `ref` arguments go in as accessors, readers + are read without a call, and setters are io. A package's `.d.rx` sits beside the file its import resolves to. + `guide/libraries.md`. + ## 0.2.0 `@reactivescript/compiler`, `@reactivescript/runtime`, `@reactivescript/language-server` and the editor extension, diff --git a/compiler/src/check.rs b/compiler/src/check.rs index 7697f96..28c04f3 100644 --- a/compiler/src/check.rs +++ b/compiler/src/check.rs @@ -11,10 +11,11 @@ use crate::content::{misnested, misnested_inside, Shape, P_CLOSERS, P_SCOPE}; use crate::infer::{inferred_action, run_findings, trigger_text, Code}; use crate::model::{ModelBuilder, ModelSymbol}; use crate::modules::ModuleSet; -use crate::parse::{js_trim, JsDecl}; +use crate::parse::{js_trim, JsDecl, Part, Shape as FShape}; use crate::signature::{Catch, CompSig, ExportSig, ModSig, Own, PropSig, SigParam, HOLD}; use indexmap::{IndexMap, IndexSet}; use regex::Regex; +use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; @@ -238,6 +239,10 @@ struct Sym<'a> { server_rejects: Option>, fn_decl: Option<&'a Decl>, decl_env: Option, + /// MO-10: a declared JS primitive (a fn) or an instance of one, or a part of it (an equation): what its result is + foreign: Option, + /// MO-10d: bound from a foreign accessor — it may hold, as a question's reader does + reader: bool, } impl<'a> Sym<'a> { fn new(kind: SymKind, name: &str, region: RId, line: i64) -> Self { @@ -271,6 +276,8 @@ impl<'a> Sym<'a> { server_rejects: None, fn_decl: None, decl_env: None, + foreign: None, + reader: false, } } } @@ -834,6 +841,8 @@ pub struct Opts<'a> { } pub struct Checker<'a> { + /// MO-10d: CN-3e asks what may hold, where a foreign reader counts (R-8 asks what may be absent, where it does not) + holds_mode: Cell, pub diags: Vec, pub model: ModelBuilder, opts: Opts<'a>, @@ -913,6 +922,7 @@ pub fn check_modules(set: &ModuleSet, spa: bool) -> Checked { impl<'a> Checker<'a> { pub fn new(opts: Opts<'a>) -> Self { Checker { + holds_mode: Cell::new(false), diags: vec![], model: ModelBuilder::default(), opts, @@ -1201,6 +1211,7 @@ impl<'a> Checker<'a> { } } } + self.foreign_part(&mut sym, e, env); if let Some(sf) = unwrap_server(e) { sym.kind = SymKind::Server; if let ExprKind::ServerFn { params, .. } = &sf.k { @@ -1417,7 +1428,22 @@ impl<'a> Checker<'a> { self.bind(env, alias, Sym::new(SymKind::ErrClass, alias, region, line)); continue; } - JsDecl::Fn { pure: xpure, side: xside, rejects, .. } => { + JsDecl::Fn { pure: xpure, side: xside, rejects, params: xparams, refs, shape, instantiates, .. } => { + if *instantiates { + // MO-10a: a primitive is an instantiation (RG-6) + 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)); + } + let mut s = Sym::new(SymKind::Fn, alias, region, line); + s.params = Some(xparams.iter().enumerate().map(|(i, n)| SigParam { name: n.clone(), r#ref: refs.get(i).copied().unwrap_or(false), ty: None }).collect()); + s.instantiating = true; + s.foreign = Some(shape.clone()); + s.rows = Some(RowsT::Fixed(IndexSet::new())); + s.fails_t = Some(FailsT::Fixed(IndexSet::new())); + s.rejects = Some(IndexSet::new()); + self.bind(env, alias, s); + continue; + } 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)); } @@ -1440,6 +1466,39 @@ impl<'a> Checker<'a> { } } + /// MO-10b: an equation bound from a primitive's result, or from a part of it (`[a, b] = f(…)`, `x.k`): a `ref` part is + /// a reader (it may hold, MO-10d), a function part is io (R-io-3), anything else is a value. + fn foreign_part(&self, sym: &mut Sym<'a>, e: &Expr, env: EId) { + let shape: Option = match &e.k { + ExprKind::Call { f, .. } => match &f.k { + ExprKind::Id(n) => self.lookup(env, n).and_then(|t| if self.syms[t].kind == SymKind::Fn { self.syms[t].foreign.clone() } else { None }), + _ => None, + }, + ExprKind::Index { o, i, .. } => match (&o.k, &i.k) { + (ExprKind::Id(on), ExprKind::Lit(Lit::Num(v))) => match self.lookup(env, on).and_then(|t| self.syms[t].foreign.clone()) { + Some(FShape::Items(items)) if *v >= 0.0 && v.fract() == 0.0 => items.get(*v as usize).map(|p| FShape::Part(*p)), + _ => None, + }, + _ => None, + }, + ExprKind::Member { o, p, .. } => match &o.k { + ExprKind::Id(on) => match self.lookup(env, on).and_then(|t| self.syms[t].foreign.clone()) { + Some(FShape::Fields(fields)) => fields.iter().find(|f| &f.0 == p).map(|f| FShape::Part(f.1)), + _ => None, + }, + _ => None, + }, + _ => None, + }; + let Some(shape) = shape else { return }; + if shape == FShape::Part(Part::Fn) { + sym.io = true; + } + if shape == FShape::Part(Part::Ref) { + sym.reader = true; + } + sym.foreign = Some(shape); + } /// 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(); @@ -3225,6 +3284,15 @@ impl<'a> Checker<'a> { if let Some(&b) = memo.get(&s) { return b; } + if self.holds_mode.get() { + // MO-10: a reader may hold; a primitive's other parts are bound as they are + if self.syms[s].reader { + return true; + } + if matches!(self.syms[s].foreign, Some(FShape::Part(Part::Fn | Part::Value))) { + return false; + } + } let Some((e, env)) = self.syms[s].expr else { return false }; if !busy.insert(s) { return false; @@ -3388,7 +3456,10 @@ impl<'a> Checker<'a> { ExprKind::Id(name) => { if let Some(t) = self.lookup(env, name) { if matches!(self.syms[t].kind, SymKind::Eq | SymKind::Source) && busy.insert(t) { - if self.absent(t, &mut HashMap::new(), &mut HashSet::new()) { + self.holds_mode.set(true); + let held = self.absent(t, &mut HashMap::new(), &mut HashSet::new()); + self.holds_mode.set(false); + if held { out.push(t); } if let Some((te, tenv)) = self.syms[t].expr { diff --git a/compiler/src/emit.rs b/compiler/src/emit.rs index 378e177..fa74de2 100644 --- a/compiler/src/emit.rs +++ b/compiler/src/emit.rs @@ -9,6 +9,7 @@ use crate::awaits::{is_handler, non_tail_awaits, view_readers}; use crate::check::{children, unwrap_server, Node}; use crate::js::*; use crate::modules::ModuleSet; +use crate::parse::{JsDecl, Part, Shape as FShape}; use crate::signature::ExportSig; use indexmap::{IndexMap, IndexSet}; use serde_json::Value; @@ -41,6 +42,8 @@ enum Kind { Val, Fn, Ifn, + /// MO-10: a declared JS primitive + Jfn, Act, Comp, } @@ -58,9 +61,12 @@ struct Bind { prev: Option, /// `client` JS (12 SF-5b) client: bool, + /// MO-10: a primitive's result (Jfn), or an instance's (with `inst`, the name of its raw result) + shape: Option, + inst: Option, } fn bind(kind: Kind, js: impl Into) -> Bind { - Bind { kind, js: js.into(), w: None, f: None, prev: None, client: false } + Bind { kind, js: js.into(), w: None, f: None, prev: None, client: false, shape: None, inst: None } } fn val(js: impl Into) -> Bind { bind(Kind::Val, js) @@ -254,6 +260,11 @@ struct Gen<'p> { style_ids: u32, } +/// MO-10: the scenario prelude's `./prims`, a JS module of Solid primitives (`ref/compile/prims-solid.ts`). +fn is_prims(from: &str) -> bool { + from == "prims" || from == "prims.js" || from.ends_with("/prims") || from.ends_with("/prims.js") +} + impl<'p> Gen<'p> { /// A JS import that becomes a real ES import (MO-8): a package's always; a relative one too when the modules go to a /// bundler (`by_source`, the product), where `./util.js` is the app's own file. `./api` stays the app's injected API @@ -608,8 +619,17 @@ impl<'p> Gen<'p> { for d in decls { match d { Decl::Import { resolved: Some(_), .. } => {} - Decl::Import { names, side, declared_side, .. } => { + Decl::Import { names, side, declared_side, declared, .. } => { for n in names { + // MO-10a: a declared primitive + let x = declared.as_ref().and_then(|dp| self.set.declarations.get(dp)).and_then(|ds| ds.iter().find(|y| y.name() == n.name)); + if let Some(JsDecl::Fn { params, refs, shape, instantiates: true, .. }) = x { + let mut b = bind(Kind::Jfn, mg(&n.alias)); + b.f = Some(FnRef { params: params.iter().enumerate().map(|(i, p)| (p.clone(), refs.get(i).copied().unwrap_or(false))).collect(), decl: None }); + b.shape = Some(shape.clone()); + self.set(env, &n.alias, b); + continue; + } let mut b = val(mg(&n.alias)); b.client = *side == Some(crate::ast::Side::Client) || declared_side.as_ref().is_some_and(|ds| ds.iter().any(|(k, s)| *k == n.name && *s == crate::ast::Side::Client)); @@ -634,6 +654,14 @@ impl<'p> Gen<'p> { Decl::Eq { name, e, .. } if unwrap_server(e).is_some() => self.set(env, name, val(mg(name))), Decl::Eq { name, e, deferred, .. } => { let mut b = bind(Kind::Acc, mg(name)); + if let ExprKind::Call { f, .. } = &e.k { + if let ExprKind::Id(fname) = &f.k { + if let Some(jf) = self.lookup(env, fname).filter(|x| x.kind == Kind::Jfn) { + b.inst = Some(format!("r${name}")); + b.shape = jf.shape.clone(); + } + } + } if self.guessed_eq(name, e, *deferred) { b.w = Some(format!("w${name}")); } @@ -658,6 +686,9 @@ impl<'p> Gen<'p> { dot(id("__B"), name) } else if from.ends_with("/api") { rtc("api", vec![str(name.clone())]) + } else if is_prims(from) { + // MO-10: the scenario prelude's Solid primitives + rtc("prim", vec![str(name.clone())]) } else { return fail(format!("{name} from \"{from}\" is not in the scenario prelude")); }; @@ -1001,6 +1032,9 @@ impl<'p> Gen<'p> { } } } + if let Some(fx) = self.foreign(e, env, &b)? { + return fin(fx, self); + } if let Some(k) = key { let x = self.expr(e, env, &Ctx { tail: true, ..Ctx::default() })?; return fin(vec![konst(b.js.clone(), rtc("keyed", vec![str(name.clone()), str(k.clone()), thunk(x)]))], self); @@ -1133,6 +1167,74 @@ impl<'p> Gen<'p> { body.push(S::Return(Some(t))); Ok(S::Func(Func { name: Some(mg(name)), params: ps, directives: vec![], body, is_async: false, generator: false })) } + /// MO-10: `x = f(…)` over a declared primitive — called once (RG-6), a ref argument as an accessor, a value one + /// untracked; and `y = x[i]` / `x.k` over its result. A `ref` part is bound to the accessor itself, any other part to + /// its value. + fn foreign(&mut self, e: &Expr, env: Env, b: &Bind) -> R>> { + fn part(r: E, p: Part) -> E { + if p == Part::Ref { + r + } else { + thunk(r) + } + } + let ix = |r: &str, k: E| E::Index(Box::new(id(r)), Box::new(k), false); + match &e.k { + ExprKind::Call { f, args } => { + let ExprKind::Id(fname) = &f.k else { return Ok(None) }; + let Some(fb) = self.lookup(env, fname).filter(|x| x.kind == Kind::Jfn) else { return Ok(None) }; + let refs: Vec = fb.f.as_ref().unwrap().params.iter().map(|p| p.1).collect(); + let mut out = vec![]; + for (i, a) in args.iter().enumerate() { + if !refs.get(i).copied().unwrap_or(false) { + out.push(rtc("untrack", vec![thunk(self.expr(a, env, &reactive())?)])); + continue; + } + if let ExprKind::Id(n) = &a.k { + if let Some(ab) = self.lookup(env, n).filter(|ab| matches!(ab.kind, Kind::Acc | Kind::Src)) { + out.push(id(ab.js)); + continue; + } + } + out.push(rtc("memo", vec![E::Null, thunk(self.expr(a, env, &reactive())?)])); + } + let r = b.inst.clone().unwrap(); + let whole = match fb.shape.as_ref().unwrap() { + FShape::Part(p) => part(id(r.clone()), *p), + FShape::Items(items) => rtc( + "memo", + vec![E::Null, thunk(E::Arr(items.iter().enumerate().map(|(i, p)| { let x = ix(&r, E::Num(i as f64)); if *p == Part::Ref { call(x, vec![]) } else { x } }).collect()))], + ), + FShape::Fields(fields) => rtc( + "memo", + vec![E::Null, thunk(E::Obj(fields.iter().map(|(k, p)| { let x = ix(&r, str(k.clone())); Prop::Kv(Key::Str(k.clone()), if *p == Part::Ref { call(x, vec![]) } else { x }) }).collect()))], + ), + }; + Ok(Some(vec![konst(r.clone(), call(id(fb.js.clone()), out)), konst(b.js.clone(), whole)])) + } + ExprKind::Index { o, .. } | ExprKind::Member { o, .. } => { + let ExprKind::Id(on) = &o.k else { return Ok(None) }; + let Some(ob) = self.lookup(env, on) else { return Ok(None) }; + let (Some(inst), Some(sh)) = (ob.inst.clone(), ob.shape.clone()) else { return Ok(None) }; + let (key, p) = match (&e.k, &sh) { + (ExprKind::Index { i, .. }, FShape::Items(items)) => match &i.k { + ExprKind::Lit(Lit::Num(v)) if *v >= 0.0 && v.fract() == 0.0 => match items.get(*v as usize) { + Some(p) => (E::Num(*v), *p), + None => return Ok(None), + }, + _ => return Ok(None), + }, + (ExprKind::Member { p: k, .. }, FShape::Fields(fields)) => match fields.iter().find(|f| &f.0 == k) { + Some(f) => (str(k.clone()), f.1), + None => return Ok(None), + }, + _ => return Ok(None), + }; + Ok(Some(vec![konst(b.js.clone(), part(ix(&inst, key), p))])) + } + _ => Ok(None), + } + } fn instantiate(&mut self, e: &Expr, env: Env) -> R { let ExprKind::Call { f, args } = &e.k else { unreachable!() }; let ExprKind::Id(fname) = &f.k else { unreachable!() }; diff --git a/compiler/src/modules.rs b/compiler/src/modules.rs index c318a88..eb1b7a4 100644 --- a/compiler/src/modules.rs +++ b/compiler/src/modules.rs @@ -68,6 +68,78 @@ struct Loader<'r> { parse_errors: Vec, } +/// MO-10c: the file a package import resolves to, as a bundler finds it — `node_modules` up from the importer, the +/// package's `exports` (a subpath, then the first of its conditions that a Solid build sets, in the package's order; `*` +/// patterns), else `module` or `main`. Its declaration is beside it: the same name, `.d.rx`. +pub fn package_declaration(from_file: &str, spec: &str, read: &dyn Fn(&str) -> Option) -> Option { + use serde_json::Value; + const CONDITIONS: [&str; 4] = ["solid", "browser", "import", "default"]; + let seg: Vec<&str> = spec.split('/').collect(); + let n = if spec.starts_with('@') { 2 } else { 1 }; + if seg.len() < n || seg[..n].iter().any(|x| x.is_empty()) { + return None; + } + let pkg = seg[..n].join("/"); + let sub = if seg.len() > n { format!("./{}", seg[n..].join("/")) } else { ".".to_string() }; + let all: Vec<&str> = from_file.split('/').collect(); + let dirs = &all[..all.len() - 1]; + let min = if from_file.starts_with('/') { 1 } else { 0 }; + fn pick(t: &Value) -> Option { + match t { + Value::String(s) => Some(s.clone()), + Value::Array(xs) => xs.iter().find_map(pick), + Value::Object(m) => m.iter().filter(|(k, _)| CONDITIONS.contains(&k.as_str())).find_map(|(_, v)| pick(v)), + _ => None, + } + } + let mut i = dirs.len() as i64; + while i >= min { + let mut parts: Vec<&str> = dirs[..i as usize].to_vec(); + parts.push("node_modules"); + parts.push(&pkg); + let root = parts.join("/"); + i -= 1; + let Some(pj) = read(&format!("{root}/package.json")) else { continue }; + let Ok(json) = serde_json::from_str::(&pj) else { return None }; + let target: Option = match json.get("exports") { + Some(ex) => { + let map = match ex { + Value::Object(m) if m.keys().any(|k| k.starts_with('.')) => Some(m), + _ => None, + }; + match map { + None => (sub == ".").then(|| pick(ex)).flatten(), + Some(m) => match m.get(&sub) { + Some(v) => pick(v), + None => m.iter().find_map(|(k, v)| { + let star = k.find('*')?; + let (pre, post) = (&k[..star], &k[star + 1..]); + if sub.starts_with(pre) && sub.ends_with(post) && sub.len() >= pre.len() + post.len() { + Some(pick(v).map(|t| t.replace('*', &sub[pre.len()..sub.len() - post.len()]))) + } else { + None + } + }).flatten(), + }, + } + } + None => { + if sub == "." { + Some(json.get("module").and_then(|v| v.as_str()).or_else(|| json.get("main").and_then(|v| v.as_str())).unwrap_or("./index.js").to_string()) + } else { + Some(sub.clone()) + } + } + }; + let target = target?; + let file = join_path(&format!("{root}/package.json"), &target); + let stem = PKG_EXT.replace(&file, "").to_string(); + return Some(stem + ".d.rx"); + } + None +} +static PKG_EXT: std::sync::LazyLock = std::sync::LazyLock::new(|| regex::Regex::new(r"\.(m?js|cjs|jsx|ts|tsx)$").unwrap()); + fn replace_js_ext(from: &str) -> String { for ext in [".mjs", ".js", ".ts"] { if let Some(s) = from.strip_suffix(ext) { @@ -93,9 +165,11 @@ impl Loader<'_> { self.state.insert(path.to_string(), State::Busy); for d in p.decls.iter_mut() { let Decl::Import { from, line, resolved, declared, declared_errors, declared_pure, declared_side, .. } = d else { continue }; - if !is_rx(from) && from.starts_with('.') { + // MO-10c: a package's declaration, beside the file it resolves to + let pkg_dts = if !is_rx(from) && !from.starts_with('.') && !from.starts_with('/') { package_declaration(path, from, &*self.read) } else { None }; + if !is_rx(from) && (from.starts_with('.') || pkg_dts.is_some()) { // MO-8: a JS module with a declaration file beside it - let dts = join_path(path, &(replace_js_ext(from) + ".d.rx")); + let dts = pkg_dts.unwrap_or_else(|| join_path(path, &(replace_js_ext(from) + ".d.rx"))); if !self.declarations.contains_key(&dts) { if let Some(src) = (self.read)(&dts) { let ds = parse_declarations(&src).map_err(|e| ParseError(format!("{}: {}", dts, e.0), e.1, Some(dts.clone()), e.3))?; diff --git a/compiler/src/parse.rs b/compiler/src/parse.rs index a2ecb6a..d88aa09 100644 --- a/compiler/src/parse.rs +++ b/compiler/src/parse.rs @@ -399,10 +399,89 @@ fn skip_line(c: &mut Cursor) { } } +/// MO-10b: what a primitive's result is — a reader (`ref T`, an accessor), an io function, a value; or a tuple or record +/// of those (only the result and its parts are declared). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Part { + Ref, + Fn, + Value, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Shape { + Part(Part), + Items(Vec), + Fields(Vec<(String, Part)>), +} +impl Shape { + pub fn has_ref(&self) -> bool { + match self { + Shape::Part(p) => *p == Part::Ref, + Shape::Items(xs) => xs.contains(&Part::Ref), + Shape::Fields(fs) => fs.iter().any(|f| f.1 == Part::Ref), + } + } +} +fn split_top(s: &str, seps: &str) -> Vec { + let cs: Vec = s.chars().collect(); + let mut out = vec![]; + let mut depth: i64 = 0; + let mut from = 0; + for i in 0..cs.len() { + let ch = cs[i]; + if "([{<".contains(ch) { + depth += 1; + } else if ")]}>".contains(ch) && !(ch == '>' && i > 0 && cs[i - 1] == '=') { + depth -= 1; + } else if depth == 0 && seps.contains(ch) { + out.push(cs[from..i].iter().collect::()); + from = i + 1; + } + } + out.push(cs[from..].iter().collect::()); + out.into_iter().map(|x| js_trim(&x).to_string()).filter(|x| !x.is_empty()).collect() +} +re!(REF_PART, r"^ref\s"); +re!(FN_PART, r"\)\s*=>"); +re!(REF_WORD, r"\bref\s+"); +re!(INSTANTIATES, r"(^|[^A-Za-z0-9_$])instantiates([^A-Za-z0-9_$]|$)"); +fn part_of(t: &str) -> Part { + if REF_PART.is_match(t) { + Part::Ref + } else if t.starts_with('(') && FN_PART.is_match(t) { + Part::Fn + } else { + Part::Value + } +} +fn shape_of(t: &str) -> Shape { + if t.starts_with('[') && t.ends_with(']') && t.len() >= 2 { + return Shape::Items(split_top(&t[1..t.len() - 1], ",").iter().map(|x| part_of(x)).collect()); + } + if t.starts_with('{') && t.ends_with('}') && t.len() >= 2 { + return Shape::Fields( + split_top(&t[1..t.len() - 1], ",;") + .iter() + .map(|f| { + let (k, v) = match f.find(':') { + Some(i) => (&f[..i], &f[i + 1..]), + None => (&f[..f.len().saturating_sub(1)], &f[..]), + }; + let k = js_trim(k); + (k.strip_suffix('?').unwrap_or(k).to_string(), part_of(js_trim(v))) + }) + .collect(), + ); + } + Shape::Part(part_of(t)) +} + /// MO-8: a declaration file (`api.d.rx`) for a JS module. #[derive(Debug, Clone)] pub enum JsDecl { - Fn { name: String, pure: bool, params: Vec, rejects: Option>, line: usize, side: Option, ptypes: Vec>, ret: Option }, + /// ret: the type text with `ref` taken out (for TY); MO-10: refs (ref parameters), shape (what the result is), + /// instantiates (written, or inferred from a ref on either side) + Fn { name: String, pure: bool, params: Vec, rejects: Option>, line: usize, side: Option, ptypes: Vec>, ret: Option, refs: Vec, shape: Shape, instantiates: bool }, Error { name: String, line: usize }, Type { name: String, line: usize, text: String }, } @@ -479,9 +558,22 @@ pub fn parse_declarations(src: &str) -> R> { c.ws(true); let mut params = vec![]; let mut ptypes = vec![]; + let mut refs = vec![]; while !c.starts_with(")") { + let r = c.keyword("ref"); // MO-10a: a ref parameter takes an accessor + if r { + c.ws(true); + } + refs.push(r); params.push(c.ident_or("param")?); - ptypes.push(opt_type(&mut c, false)?); + c.ws(false); + ptypes.push(if c.peek() == ':' { + c.bump(); + c.ws(true); + Some(parse_type(&mut c, false, true)?) + } else { + None + }); c.ws(true); if !c.eat(",") { break; @@ -492,18 +584,28 @@ pub fn parse_declarations(src: &str) -> R> { let save = c.i; c.ws(false); let mut ret = None; + let mut shape = None; if c.peek() == ':' { c.bump(); c.ws(true); let t = c.rest().split('\n').next().unwrap_or("").to_string(); - let r = REJECTS.find(&t).map(|m| m.end() - "rejects(".len()); + let r1 = REJECTS.find(&t).map(|m| m.end() - "rejects(".len()); + let r2 = INSTANTIATES.find(&t).map(|m| m.start() + if t[m.start()..].starts_with("instantiates") { 0 } else { 1 }); + let r = match (r1, r2) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; let head = match r { Some(at) => &t[..at], None => &t[..], }; let head = LINE_COMMENT.replace(head, ""); let head = js_trim(&head).to_string(); - ret = if head.is_empty() { None } else { Some(head) }; + if !head.is_empty() { + shape = Some(shape_of(&head)); // MO-10b: `ref T` is an accessor, read as T + } + let stripped = REF_WORD.replace_all(&head, "").to_string(); + ret = if stripped.is_empty() { None } else { Some(stripped) }; c.i += r.unwrap_or(t.len()); } else { c.i = save; @@ -517,7 +619,11 @@ pub fn parse_declarations(src: &str) -> R> { c.ws(true); c.expect(")")?; } - out.push(JsDecl::Fn { name, pure, params, rejects, line, side, ptypes, ret }); + c.ws(false); + let written = c.keyword("instantiates"); + let instantiates = written || refs.iter().any(|x| *x) || shape.as_ref().is_some_and(|s| s.has_ref()); // MO-10a + let shape = shape.unwrap_or(Shape::Part(Part::Value)); + out.push(JsDecl::Fn { name, pure, params, rejects, line, side, ptypes, ret, refs, shape, instantiates }); } } diff --git a/guide/language.md b/guide/language.md index 991dce6..743efcf 100644 --- a/guide/language.md +++ b/guide/language.md @@ -14,6 +14,8 @@ import { Avatar } from "./Avatar.rx" // another .rx module export default component App() { … } // the entry module's default component is mounted ``` +A Solid library (solid-primitives and the like) is declared once in a `.d.rx` and then used like .rx: `libraries.md`. + A file's top level is imports, exports and declarations. A `source` at the top level is app state; inside a component it is that instance's state. Only the block that declares a source writes it: importers, props, function parameters and contexts are read-only, at every path into a store. To let other code write it, hand it an action that diff --git a/guide/libraries.md b/guide/libraries.md new file mode 100644 index 0000000..5cc3918 --- /dev/null +++ b/guide/libraries.md @@ -0,0 +1,79 @@ +# Using Solid libraries from .rx + +A Solid library (solid-primitives, a design system's hooks, your own `create*` helpers) is plain JavaScript. To use +one from .rx, declare what it exports once, in a `.d.rx` file, and then use it like .rx. The declaration is checked +at every use; the JavaScript itself is not checked (it's the library's). + +## Declare it + +```rx +// media.d.rx, beside media.js — or shipped by the package beside the file its import resolves to +export pure fn createToggle(initial: boolean): [ref boolean, (v: boolean) => void] +export pure fn createDebounced(ref source: string, ms: number): ref string +export pure fn createMediaQuery(query: string): ref boolean +export pure fn createEventListener(target: EventTarget, type: string, handler: (e: Event) => void) instantiates +``` + +- **`ref T` is an accessor.** In a parameter, the library takes an accessor (Solid's `Accessor`); in a result, it + hands one back. .rx has no accessor type: you pass and read plain values, and the compiler adds the `() => …` and + the call. +- **A function in a result is io.** A setter can be called only in imperative code (an action, a handler). +- **Anything else is a value.** A store a library returns is a value whose fields track, as in Solid. +- **`instantiates`** says the call creates state and must run once. It is inferred from any `ref`, so you write it + only for a primitive with no `ref` on either side (one that registers a listener, or returns a store). +- `pure fn` is right for a primitive: making it writes nothing but its own state. + +Where the file goes: + +- **Your own JS module:** `./media.js` with `./media.d.rx` beside it. +- **A package that ships one:** nothing to do. The compiler resolves the import the way a bundler does (`exports`, + `module`, `main`) and reads the `.d.rx` beside that file (`dist/index.jsx` → `dist/index.d.rx`). +- **A package that doesn't:** re-export what you use from a local file and declare that: + `export { createMediaQuery } from "@solid-primitives/media"` in `./media.js`, then `./media.d.rx` as above. + +## Use it + +```rx +import { createToggle, createDebounced, createMediaQuery } from "./media.js" + +export component Search(props: { start: boolean, q: string }) { + [isOn, setOn] = createToggle(peek props.start) // runs once; peek: `start` is read once, and you mean it + query = createDebounced(props.q, 300) // passed as an accessor: it follows props.q + wide = createMediaQuery("(min-width: 768px)") + action flip() { setOn(!isOn) } + + +} +``` + +`isOn`, `query` and `wide` are equations like any other: read them without calling them. They may hold, like a +value from an `await` (Solid promises nothing about an accessor), so `latest`, `pending?` and `loading` apply. + +What the compiler catches: + +| you write | you get | +| --- | --- | +| `createToggle(props.start)` | `SNAPSHOT_ARGUMENT`: a value argument is read once; write `peek props.start`, or the library should take `ref` | +| `x = setOn(true)` | `IO_IN_REACTIVE`: a setter is called from an action or handler | +| `

{createMediaQuery(q)}

` | `INSTANTIATION_NOT_BOUND`: bind it, `wide = createMediaQuery(q)` | + +## What stays the library's + +- **Its setters are not writes of a source.** The compiler can't see them as writes, so a read right after one, in + the same action, gives the value from before it (Solid batches the write), and nothing warns you: + + ```rx + action flip() { + setOn(!isOn) + label := isOn ? "on" : "off" // still the old isOn + } + ``` + + Compute the new value once and use it: `let next = !isOn; setOn(next); label := next ? "on" : "off"`. +- **Callbacks you pass are plain functions.** An action passed as `handler` is checked as an action; a plain closure + is not checked beyond what any closure is. +- **Without a declaration,** an import is io unless you import it `pure`, and `import pure` of a primitive is + unchecked: the call is then an equation, re-run (and the primitive rebuilt) whenever an argument changes. Declare + primitives instead. + +Rules: `spec/09-modules.md` MO-10 (and MO-8 for declaration files). diff --git a/ref/compile/emit.ts b/ref/compile/emit.ts index ccebfbf..a6922c5 100644 --- a/ref/compile/emit.ts +++ b/ref/compile/emit.ts @@ -9,11 +9,13 @@ import { nonTailAwaits, viewReaders } from "../src/awaits.ts"; import { inferredAction } from "../src/infer.ts"; import { transform } from "@solidjs/compiler"; import { unwrapServer } from "../src/check.ts"; +import { isPrims } from "../src/prims.ts"; +import type { Shape } from "../src/parse.ts"; export class CompileError extends Error { name = "CompileError"; } type FnDecl = Extract; -type Kind = "acc" | "src" | "val" | "fn" | "ifn" | "act" | "comp"; -type Bind = { kind: Kind; js: string; w?: string; fn?: FnDecl; prev?: string; client?: boolean }; // client: `client` JS (12 SF-5b) +type Kind = "acc" | "src" | "val" | "fn" | "ifn" | "jfn" | "act" | "comp"; +type Bind = { kind: Kind; js: string; w?: string; fn?: FnDecl; prev?: string; client?: boolean; refs?: boolean[]; shape?: Shape; inst?: string }; // MO-10: jfn, a declared JS primitive (refs, shape); inst: an instance's raw result // client: `client` JS (12 SF-5b) class Env { vars = new Map(); parent: Env | null; @@ -233,7 +235,7 @@ class Gen { // names first: hoisted functions and components; every other declaration is a const for (const d of decls) { if (d.k === "import" && d.resolved) continue; - if (d.k === "import") for (const { name, alias } of d.names) env.vars.set(alias, { kind: "val", js: mg(alias), client: d.side === "client" || d.declaredSide?.[name] === "client" }); + if (d.k === "import") for (const { name, alias } of d.names) { const x = d.declared ? this.mods?.set.declarations?.get(d.declared)?.find(y => y.name === name) : undefined; env.vars.set(alias, x?.kind === "fn" && x.instantiates ? { kind: "jfn", js: mg(alias), refs: x.refs ?? [], shape: x.shape ?? "value" } : { kind: "val", js: mg(alias), client: d.side === "client" || d.declaredSide?.[name] === "client" }); } // MO-10a if (d.k === "fn") env.vars.set(d.name, { kind: d.params.some(p => p.ref) || d.decls.length ? "ifn" : "fn", js: mg(d.name), fn: d }); if (d.k === "component") env.vars.set(d.name, { kind: "comp", js: cm(d.name) }); if (d.k === "action") env.vars.set(d.name, { kind: "act", js: mg(d.name) }); @@ -241,10 +243,10 @@ class Gen { if (d.k === "error" || d.k === "context") env.vars.set(d.name, { kind: "val", js: mg(d.name) }); if (d.k === "source") env.vars.set(d.name, { kind: "src", js: mg(d.name), w: `w$${d.name}`, prev: this.u.prevOf.has(d.name) ? `p$${d.name}` : undefined }); if (d.k === "eq" && unwrapServer(d.e)) { env.vars.set(d.name, { kind: "val", js: mg(d.name) }); continue; } // SF-1d: a server fn, not an accessor - if (d.k === "eq") env.vars.set(d.name, { kind: d.e.k === "call" && d.e.f.k === "id" && env.lookup(d.e.f.name)?.kind === "ifn" ? "acc" : "acc", js: mg(d.name), w: this.guessedEq(d) ? `w$${d.name}` : undefined, prev: this.u.prevOf.has(d.name) ? `p$${d.name}` : undefined }); + if (d.k === "eq") { const jf = d.e.k === "call" && d.e.f.k === "id" ? env.lookup(d.e.f.name) : undefined; env.vars.set(d.name, { kind: "acc", js: mg(d.name), w: this.guessedEq(d) ? `w$${d.name}` : undefined, prev: this.u.prevOf.has(d.name) ? `p$${d.name}` : undefined, ...(jf?.kind === "jfn" ? { inst: `r$${d.name}`, shape: jf.shape } : {}) }); } } for (const d of decls) if (d.k === "import" && !d.resolved) for (const { name, alias } of d.names) { - const v = !d.from.startsWith(".") && !BUILTINS.has(name) && name !== "log" ? `w$${alias}` : name === "log" ? "__B.log" : d.declaredErrors?.includes(name) ? `__rt.jsError(${J(name)})` : BUILTINS.has(name) ? `__B.${name}` : d.from.endsWith("/api") ? `__rt.api(${J(name)})` : null; + const v = !d.from.startsWith(".") && !BUILTINS.has(name) && name !== "log" ? `w$${alias}` : name === "log" ? "__B.log" : d.declaredErrors?.includes(name) ? `__rt.jsError(${J(name)})` : BUILTINS.has(name) ? `__B.${name}` : d.from.endsWith("/api") ? `__rt.api(${J(name)})` : isPrims(d.from) ? `__rt.prim(${J(name)})` : null; // MO-10: the prelude's Solid primitives if (!v) throw new CompileError(`${name} from "${d.from}" is not in the scenario prelude`); out.push(`${pad}const ${mg(alias)} = ${v};\n${pad}__rt.reg(${J(alias)}, { value: ${mg(alias)} });`); } @@ -337,6 +339,7 @@ class Gen { return `${pad}const ${b.js} = __rt.draft(__rt.untrack(() => ${this.expr(seed.init, env, { imp: false })}), (v$d) => {\n${this.draftBody(d.e.body, inner, ind + 1)}\n${pad}});${reg}`; } if (d.e.k === "call" && d.e.f.k === "id" && env.lookup(d.e.f.name)?.kind === "ifn") return `${pad}const ${b.js} = ${this.instantiate(d.e, env)};${reg}`; // RG-6 + const fx = this.foreign(d, env, b, pad); if (fx !== null) return `${fx}${reg}`; if (d.key) return `${pad}const ${b.js} = __rt.keyed(${J(d.name)}, ${J(d.key)}, () => ${this.expr(d.e, env, { imp: false, tail: true })});${reg}`; if (d.e.k === "catch" && !d.deferred) { // R-f-4 over a tail await: the flight is an inner memo, read through a boundary const c = d.e; const inner = new Env(env); if (c.name) inner.vars.set(c.name, { kind: "val", js: mg(c.name) }); @@ -381,6 +384,30 @@ class Gen { const tail = tb && (tb.kind === "acc" || tb.kind === "src") ? tb.js : `__rt.memo(null, () => ${this.expr(d.tail, inner, { imp: false })})`; return `${pad}function ${mg(d.name)}(${d.params.map(p => mg(p.name)).join(", ")}) {\n${body}\n${pad} return ${tail};\n${pad}}`; } + /** MO-10: `x = f(…)` over a declared primitive — called once (RG-6), a ref argument as an accessor, a value one untracked; + * and `y = x[i]` / `x.k` over its result. A `ref` part is bound to the accessor itself, any other part to its value. */ + private foreign(d: Extract, env: Env, b: Bind, pad: string): string | null { + const e = d.e; + const part = (r: string, p: Shape) => p === "ref" ? r : `() => ${r}`; + if (e.k === "call" && e.f.k === "id") { + const f = env.lookup(e.f.name); if (f?.kind !== "jfn") return null; + const args = e.args.map((a, i) => { + if (!f.refs![i]) return `__rt.untrack(() => ${this.expr(a, env, { imp: false })})`; + if (a.k === "id") { const ab = env.lookup(a.name); if (ab && (ab.kind === "acc" || ab.kind === "src")) return ab.js; } + return `__rt.memo(null, () => ${this.expr(a, env, { imp: false })})`; + }); + const r = b.inst!; const sh = f.shape!; + const whole = typeof sh === "string" ? part(r, sh) : `__rt.memo(null, () => ${"items" in sh ? `[${sh.items.map((p, i) => p === "ref" ? `${r}[${i}]()` : `${r}[${i}]`).join(", ")}]` : `({ ${sh.fields.map(([k, p]) => `${J(k)}: ${p === "ref" ? `${r}[${J(k)}]()` : `${r}[${J(k)}]`}`).join(", ")} })`})`; + return `${pad}const ${r} = ${f.js}(${args.join(", ")});\n${pad}const ${b.js} = ${whole};`; + } + if ((e.k === "index" && e.i.k === "lit") || e.k === "member") { + const ob = e.o.k === "id" ? env.lookup(e.o.name) : undefined; const sh = ob?.shape; if (!ob?.inst || !sh || typeof sh !== "object") return null; + const key = e.k === "index" ? (e.i as { v: any }).v : e.p; + const p = "items" in sh ? sh.items[key] : sh.fields.find(f => f[0] === key)?.[1]; if (!p) return null; + return `${pad}const ${b.js} = ${part(`${ob.inst}[${J(key)}]`, p)};`; + } + return null; + } private instantiate(call: Extract, env: Env): string { const b = env.lookup((call.f as { name: string }).name)!; const f = b.fn!; const args = f.params.map((p, i) => { diff --git a/ref/compile/prims-solid.ts b/ref/compile/prims-solid.ts new file mode 100644 index 0000000..096f779 --- /dev/null +++ b/ref/compile/prims-solid.ts @@ -0,0 +1,8 @@ +// MO-10: the scenario prelude's `./prims` in Solid (see prims.ts), with no other import, so a runtime can carry it. +/** What a runner over Solid calls: Solid primitives, as a library would write them. */ +export function primsSolid(s: { createSignal: (v: any) => [() => any, (v: any) => void]; createMemo: (f: () => any) => () => any }): Record unknown> { + return { + createToggle: (initial: boolean) => { const [get, set] = s.createSignal(initial); return [get, (v: boolean) => { set(v); }]; }, + createShout: (src: () => string) => s.createMemo(() => `${src()}!`), + }; +} diff --git a/ref/compile/rt-server.ts b/ref/compile/rt-server.ts index b3c7bec..93f0647 100644 --- a/ref/compile/rt-server.ts +++ b/ref/compile/rt-server.ts @@ -5,6 +5,7 @@ // solid-js primitives in the same order, so each owner, element and hole takes the id the client will look for (SV-3). A failure of a declared class (R-f-1, MO-8) is marked safe and goes // to the client as it is; anything else that reaches a boundary — a panic, or a JS rejection of plain `Error` — goes as // Solid's generic error (SV-5). +import { primsSolid } from "./prims-solid.ts"; import { Portal as WebPortal } from "@solidjs/web"; import { getObserver } from "solid-js"; import { provideRequestEvent } from "@solidjs/web/storage"; @@ -120,6 +121,8 @@ export function createApp(opts: { api?: Record unknown> const f = opts.api[name] ?? (() => { throw new Error(`./api has no ${name}`); }); return (...a: unknown[]) => { const r = f(...a); return r && typeof (r as PromiseLike).then === "function" ? Promise.resolve(r).catch(e => { if (isA(e, declared)) markSafeError(e); throw e; }) : r; }; }, + /** MO-10: the scenario prelude's `./prims` — Solid primitives, as a library writes them (`ref/compile/prims-solid.ts`). */ + prim: (name: string) => primsSolid({ createSignal: createSignal as any, createMemo: createMemo as any })[name] ?? (() => { throw new Error(`./prims has no ${name}`); }), reg(name: string, e: Entry) { let l = registry.get(name); if (!l) registry.set(name, (l = [])); l.push(e); }, dget(name: string) { const e = app.lookup(name); return e.get ? app.snapshot(e.get) : e.value; }, diff --git a/ref/compile/rt-web.ts b/ref/compile/rt-web.ts index fb3b009..0391935 100644 --- a/ref/compile/rt-web.ts +++ b/ref/compile/rt-web.ts @@ -2,6 +2,7 @@ // program can mount on a page — or hydrate one the server runtime (rt-server.ts) streamed. Views are JSX that Solid's // compiler lowers (compile/jsx.ts, generate "dom", hydratable); what remains here is the reactive lowering, which must // create the same owners in the same order as rt-server.ts so each takes the same hydration id on both sides. +import { primsSolid } from "./prims-solid.ts"; import { Portal as WebPortal } from "@solidjs/web"; import { getObserver } from "solid-js"; import { @@ -146,6 +147,8 @@ export function createApp(opts: { root: any; api?: Record jsErrors.get(name) ?? (jsErrors.set(name, (opts.api?.[name] as unknown) ?? errorClass(name, ["message"], (m?: unknown) => m ?? name, `${name}#${classSeq++}`)), jsErrors.get(name)), api: (name: string) => opts.api ? (opts.api[name] ?? (() => { throw new Error(`./api has no ${name}`); })) : (...a: unknown[]) => flight(`${name}:${a.map(fmt).join(",")}`), + /** MO-10: the scenario prelude's `./prims` — Solid primitives, as a library writes them (`ref/compile/prims-solid.ts`). */ + prim: (name: string) => primsSolid({ createSignal: createSignal as any, createMemo: createMemo as any })[name] ?? (() => { throw new Error(`./prims has no ${name}`); }), // ---- the scenario harness's view of names (handler rows reach any live binding by name) ---- reg(name: string, e: Entry) { let l = registry.get(name); if (!l) registry.set(name, (l = [])); l.push(e); const h = hoistedBy.get(name); if (h && e.get) { hoistedOf.set(e.get, h); hoistedBy.delete(name); } }, diff --git a/ref/compile/rt.ts b/ref/compile/rt.ts index 6e7a429..8334855 100644 --- a/ref/compile/rt.ts +++ b/ref/compile/rt.ts @@ -4,6 +4,7 @@ // comparison (EF-2), actions as generators (AC-1, AC-7), provisions as context (RG-7), and a string-tree renderer // for tests. Views are JSX that Solid's compiler lowers onto that renderer (compile/jsx.ts); the browser and the server // runtimes are rt-web.ts and rt-server.ts. +import { primsSolid } from "./prims-solid.ts"; import { getObserver } from "@solidjs/signals"; import { Show, For, Loading, Errored } from "solid-js"; import { @@ -156,6 +157,8 @@ export function createApp(opts: { api?: Record unknown> /** MO-8: a class `api.d.rx` declares as an error: the real module's, or (in the harness) a stand-in `C(message)`. */ jsError: (name: string) => jsErrors.get(name) ?? (jsErrors.set(name, (opts.api?.[name] as unknown) ?? errorClass(name, ["message"], (m?: unknown) => m ?? name)), jsErrors.get(name)), api: (name: string) => opts.api ? (opts.api[name] ?? (() => { throw new Error(`./api has no ${name}`); })) : (...a: unknown[]) => flight(`${name}:${a.map(fmt).join(",")}`), + /** MO-10: the scenario prelude's `./prims` — Solid primitives, as a library writes them (`ref/compile/prims-solid.ts`). */ + prim: (name: string) => primsSolid({ createSignal: createSignal as any, createMemo: createMemo as any })[name] ?? (() => { throw new Error(`./prims has no ${name}`); }), // ---- the scenario harness's view of names (handler rows reach any live binding by name) ---- reg(name: string, e: Entry) { let l = registry.get(name); if (!l) registry.set(name, (l = [])); l.push(e); const h = hoistedBy.get(name); if (h && e.get) { hoistedOf.set(e.get, h); hoistedBy.delete(name); } }, diff --git a/ref/oracle/lower.ts b/ref/oracle/lower.ts index e42d47e..3078f0d 100644 --- a/ref/oracle/lower.ts +++ b/ref/oracle/lower.ts @@ -18,6 +18,8 @@ import { makeErrorClass, isA, PANIC, wireCopy, wireFailure } from "../src/failur import { singleModule, type ModuleSet } from "../src/modules.ts"; import { nonTailAwaits, viewReaders } from "../src/awaits.ts"; import { unwrapServer } from "../src/check.ts"; +import { primsSolid, isPrims } from "../src/prims.ts"; +import type { Shape } from "../src/parse.ts"; /** 12 SF-1d: Solid's server-function wrappers (\`GET\`, \`live\`, …) where there is no network: the function as it is (SF-7b). */ const LOCAL_WRAPPERS: Record = { GET: (f: unknown) => f, live: (f: unknown) => f, broadcast: (f: unknown) => f, withMeta: (f: unknown) => f }; @@ -97,6 +99,7 @@ type Binding = | { k: "store"; get: () => any; set: (v: unknown) => void; setPath: (f: (d: any) => any) => void; question: boolean; setGuess?: (f: (d: any) => any) => void } | { k: "memo"; get: () => unknown } | { k: "const"; value: unknown } + | { k: "jfn"; f: (...a: unknown[]) => unknown; refs: boolean[]; shape: Shape } // MO-10: a declared JS primitive | { k: "fn"; decl: Extract; home: Scope } // home: the declaring scope — bodies are lexical (RG-7a, MO-6) | { k: "comp"; decl: Extract; home: Scope } | { k: "action"; run: (...a: unknown[]) => unknown } @@ -154,8 +157,11 @@ export class Oracle { /** 09 MO-1: each imported module's scope, declared once, dependencies first. */ moduleScopes = new Map(); + declarations?: ModuleSet["declarations"]; + private instances = new WeakMap(); // MO-10: a primitive's raw result, under the binding of its instance constructor(input: Program | ModuleSet, handlers?: Stmt[][]) { const set = "programs" in input ? input : singleModule(input); + this.declarations = set.declarations; const program = set.programs.get(set.entry)!; this.program = program; this.scanHandlers = handlers; // usage over every module, deep (the compiler's usage(), ref/compile/emit.ts): guessed targets, path-written or path-probed sources @@ -228,6 +234,7 @@ export class Oracle { for (const d of decls) { if (d.k === "import" && d.resolved) { const ms = this.moduleScopes.get(d.resolved); if (!ms) throw new Error(`MO-9: ${d.from} is known by its signature only: there is no body to run`); for (const { name, alias } of d.names) { const b = ms.vars.get(name); if (b) scope.vars.set(alias, b); } continue; } // MO-1: the module's own bindings if (d.k === "eq") { const sf = unwrapServer(d.e); if (sf) this.serverNames.set(sf, d.name); } // SF-1d + if (d.k === "import" && isPrims(d.from)) { const prims = primsSolid({ createSignal: createSignal as any, createMemo: createMemo as any }); for (const { name, alias } of d.names) { const x = this.declarations?.get(d.declared ?? "")?.find(y => y.name === name); if (!x || x.kind !== "fn" || !x.instantiates || !prims[name]) throw new Error(`${name} from "${d.from}" is not a declared primitive of the prelude`); scope.vars.set(alias, { k: "jfn", f: prims[name], refs: x.refs ?? [], shape: x.shape ?? "value" }); } continue; } // MO-10: Solid primitives, as a library writes them if (d.k === "import") for (const { name, alias } of d.names) { const v = d.from === "@solidjs/web/server-functions" ? LOCAL_WRAPPERS[name] : name === "log" ? (...a: unknown[]) => { this.log.push(`log(${a.map(fmt).join(", ")})`); } : d.declaredErrors?.includes(name) ? makeErrorClass(name, ["message"], a => String(a[0] ?? name)) : BUILTINS[name] ?? (d.from.endsWith("/api") ? (...a: unknown[]) => { const fl = this.flight(`${name}:${a.map(fmt).join(",")}`); return process.env.ORACLE_PROMISES ? fl.first : fl; } : undefined); if (v === undefined) throw new Error(`${name} from "${d.from}" is not in the scenario prelude`); scope.vars.set(alias, { k: "const", value: v }); } if (d.k === "server") scope.vars.set(d.name, { k: "const", value: this.serverFn(d, scope) }); if (d.k === "error") { const msg = d.msg; scope.vars.set(d.name, { k: "const", value: makeErrorClass(d.name, d.params, args => msg ? String(untrack(() => this.ev(msg, scope, new Map(d.params.map((p, i) => [p, args[i]] as const))))) : d.name) }); } // R-f-1 @@ -273,6 +280,7 @@ export class Oracle { break; } case "eq": { + if (this.foreign(d, scope)) break; if (d.e.k !== "draft") this.hoistAwaits(d.name, d.e, scope); if (d.e.k === "draft") { // ST-3: createProjection((draft) => …, seed) const seedE = d.e.seed; if (seedE.k !== "pre" || seedE.name !== d.name || !seedE.init) throw new Unsupported("draft seed must be `pre self -> seed`"); @@ -365,6 +373,30 @@ export class Oracle { walk(e); return init ? { loadingValue: untrack(() => this.ev(init!, scope)) } : undefined; } + /** MO-10: `x = f(…)` over a declared primitive — called once, a ref argument as an accessor, a value one untracked; and + * `y = x[i]` / `x.k` over its result. A `ref` part is the accessor itself; any other part is the value it is. */ + private foreign(d: Extract, scope: Scope): boolean { + const e = d.e; + const bindPart = (r: any, part: Shape): Binding => part === "ref" ? { k: "memo", get: r } : { k: "const", value: r }; + if (e.k === "call" && e.f.k === "id") { + const b = scope.lookup(e.f.name); if (!b || b.k !== "jfn") return false; + const args = e.args.map((a, i) => { + if (!b.refs[i]) return untrack(() => this.ev(a, scope)); + const ab = a.k === "id" ? scope.lookup(a.name) : undefined; + return ab && (ab.k === "sig" || ab.k === "memo" || ab.k === "store") ? ab.get : createMemo(() => this.ev(a, scope)); + }); + const r = b.f(...args) as any; const shape = b.shape; + const bind = typeof shape === "string" ? bindPart(r, shape) : { k: "memo" as const, get: createMemo(() => "items" in shape ? shape.items.map((p, i) => p === "ref" ? r[i]() : r[i]) : Object.fromEntries(shape.fields.map(([k, p]) => [k, p === "ref" ? r[k]() : r[k]]))) }; + this.instances.set(bind, { r, shape }); scope.vars.set(d.name, bind); return true; + } + if ((e.k === "index" && e.i.k === "lit") || e.k === "member") { + const ob = e.o.k === "id" ? scope.lookup(e.o.name) : undefined; const inst = ob && this.instances.get(ob); if (!inst || typeof inst.shape !== "object") return false; + const key = e.k === "index" ? (e.i as { v: any }).v : e.p; + const part = "items" in inst.shape ? inst.shape.items[key] : inst.shape.fields.find(f => f[0] === key)?.[1]; if (!part) return false; + scope.vars.set(d.name, bindPart(inst.r[key], part)); return true; + } + return false; + } private fnOf(e: Expr, scope: Scope) { if (e.k !== "call" || e.f.k !== "id") return null; const b = scope.lookup(e.f.name); return b && b.k === "fn" ? b.decl : null; } private fnHome(e: Expr, scope: Scope): Scope { const b = e.k === "call" && e.f.k === "id" ? scope.lookup(e.f.name) : undefined; return b && b.k === "fn" ? b.home : scope; } private instantiateFn(call: Extract, f: Extract, scope: Scope) { diff --git a/ref/src/check.ts b/ref/src/check.ts index 1528b85..775f10f 100644 --- a/ref/src/check.ts +++ b/ref/src/check.ts @@ -5,7 +5,7 @@ import type { Decl, Expr, Markup, Program, Stmt } from "./ast.ts"; import type { Diagnostic } from "./errors.ts"; import type { ModuleSet } from "./modules.ts"; -import type { JsDecl } from "./parse.ts"; +import type { JsDecl, Shape } 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"; import { inferredAction, runFindings, triggerText } from "./infer.ts"; @@ -94,6 +94,8 @@ type Sym = { side?: "server" | "client"; // 12 SF-5: JS that runs on one side only ioVia?: string; // 12 SF-1: a server fn that is io — the call in its body that makes it so serverRejects?: () => Set; // SF-6b: what a call's promise rejects with — its body's failures, and Error (transit) + foreign?: Shape; // MO-10: a declared JS primitive (a fn) or an instance of one (an equation): what its result is + reader?: boolean; // MO-10d: bound from a foreign accessor — it may hold, as a question's reader does fnDecl?: Extract; declEnv?: Env; // a fn's declaration and the scope it names from (SF-3: what it reaches) // a context (RG-7): its identity, whether it carries a source, and its declared rows (null: inferred from its provisions) }; const reactiveKinds = new Set(["source", "eq", "props"]); @@ -295,13 +297,14 @@ class Checker { const absentMemo = new Map(); const absent = (t: Sym, busy: Set): boolean => { if (absentMemo.has(t)) return absentMemo.get(t)!; + if (t.reader) return true; if (t.foreign === "fn" || t.foreign === "value") return false; // MO-10: a reader may hold; a primitive's other parts are bound as they are if (!t.expr || !t.env || busy.has(t)) return false; busy.add(t); const env = t.env; const go = (x: Expr): boolean => { if (x.k === "fn" || x.k === "peek") return false; if (x.k === "await") return !x.init; - if (x.k === "id") { const u = env.lookup(x.name); return !!u && u !== t && (u.kind === "eq" || u.kind === "source") && absent(u, busy); } + if (x.k === "id") { const u = env.lookup(x.name); return !!u && u !== t && (u.kind === "eq" || u.kind === "source") && (!!u.reader || absent(u, busy)); } // MO-10d: a foreign reader may hold return subExprs(x).some(go); }; const out = go(t.expr); @@ -379,7 +382,7 @@ class Checker { 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", { component: true, name: d.name }); this.compSyms.set(sym.compRegion, sym); } if (d.k === "eq") sym.eqDecl = d; - 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") { 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; } this.foreignPart(sym, d.e, env); } 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 this.bind(env, d.name, sym); } @@ -466,9 +469,24 @@ class Checker { 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`); + if (x.instantiates) { this.bind(env, alias, { kind: "fn", name: alias, region: env.region, line: d.line, params: x.params.map((n, i) => ({ name: n, ref: !!x.refs?.[i] })), instantiating: true, foreign: x.shape, rows: () => new Set(), failsT: () => ({ cls: new Set(), props: [] }), rejects: new Set() }); continue; } // MO-10a: a primitive is an instantiation (RG-6) 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-10b: an equation bound from a primitive's result, or from a part of it (`[a, b] = f(…)`, `x.k`): a `ref` part is a + * reader (it may hold, MO-10d), a function part is io (R-io-3), anything else is a value. */ + private foreignPart(sym: Sym, e: Expr, env: Env) { + let shape: Shape | undefined; + if (e.k === "call" && e.f.k === "id") { const f = env.lookup(e.f.name); if (f?.kind === "fn" && f.foreign) shape = f.foreign; } + else if ((e.k === "index" && e.i.k === "lit" && typeof e.i.v === "number") || e.k === "member") { + const t = e.o.k === "id" ? env.lookup(e.o.name) : undefined; const s = t?.foreign; + if (s && typeof s === "object") shape = e.k === "index" ? ("items" in s ? s.items[(e.i as { v: number }).v] : undefined) : ("fields" in s ? s.fields.find(f => f[0] === e.p)?.[1] : undefined); + } + if (!shape) return; + sym.foreign = shape; + if (shape === "fn") sym.io = true; + if (shape === "ref") sym.reader = true; + } /** MO-3: this module's signature — every export as an importer sees it. */ signature(p: Program): ModSig { const exports = new Map(); const env = this.rootEnv!; diff --git a/ref/src/modules.ts b/ref/src/modules.ts index ae9c083..4d1cc36 100644 --- a/ref/src/modules.ts +++ b/ref/src/modules.ts @@ -18,6 +18,37 @@ export function joinPath(fromFile: string, rel: string): string { return (rel.startsWith("/") ? "/" : "") + parts.join("/"); } +/** MO-10c: the file a package import resolves to, as a bundler finds it — `node_modules` up from the importer, the package's + * `exports` (a subpath, then the first of its conditions that a Solid build sets, in the package's order; `*` patterns), + * else `module` or `main`. Its declaration is beside it: the same name, `.d.rx`. */ +const CONDITIONS = new Set(["solid", "browser", "import", "default"]); +export function packageDeclaration(fromFile: string, spec: string, read: (path: string) => string | undefined): string | undefined { + const seg = spec.split("/"); const n = spec.startsWith("@") ? 2 : 1; if (seg.length < n || seg.slice(0, n).some(x => !x)) return undefined; + const pkg = seg.slice(0, n).join("/"); const sub = seg.length > n ? "./" + seg.slice(n).join("/") : "."; + const dirs = fromFile.split("/").slice(0, -1); + for (let i = dirs.length; i >= (fromFile.startsWith("/") ? 1 : 0); i--) { + const root = [...dirs.slice(0, i), "node_modules", pkg].join("/"); const pj = read(root + "/package.json"); if (pj === undefined) continue; + let json: any; try { json = JSON.parse(pj); } catch { return undefined; } + const pick = (t: unknown): string | undefined => { + if (typeof t === "string") return t; + if (Array.isArray(t)) { for (const x of t) { const r = pick(x); if (r) return r; } return undefined; } + if (t && typeof t === "object") { for (const [k, v] of Object.entries(t)) if (CONDITIONS.has(k)) { const r = pick(v); if (r) return r; } } + return undefined; + }; + let target: string | undefined; + const ex = json.exports; + if (ex !== undefined) { + const map: Record | null = typeof ex === "object" && ex && !Array.isArray(ex) && Object.keys(ex).some(k => k.startsWith(".")) ? ex : null; + if (!map) target = sub === "." ? pick(ex) : undefined; + else if (sub in map) target = pick(map[sub]); + else for (const [k, v] of Object.entries(map)) { const star = k.indexOf("*"); if (star < 0) continue; const pre = k.slice(0, star), post = k.slice(star + 1); if (sub.startsWith(pre) && sub.endsWith(post) && sub.length >= pre.length + post.length) { const t = pick(v); if (t) target = t.split("*").join(sub.slice(pre.length, sub.length - post.length)); break; } } + } else target = sub === "." ? (typeof json.module === "string" ? json.module : typeof json.main === "string" ? json.main : "./index.js") : sub; + if (!target) return undefined; + return joinPath(root + "/package.json", target).replace(/\.(m?js|cjs|jsx|ts|tsx)$/, "") + ".d.rx"; + } + return undefined; +} + /** Load `entry` and everything it imports from `.rx`. `read` returns a module's source, or undefined when there is none. */ export function loadModules(entry: string, read: (path: string) => string | undefined): ModuleSet { const programs = new Map(); const order: string[] = []; const diags: Diagnostic[] = []; const declarations = new Map(); const signatures = new Map(); @@ -30,8 +61,9 @@ export function loadModules(entry: string, read: (path: string) => string | unde try { p = parseProgram(src); } catch (e) { if (e instanceof ParseError) throw Object.assign(new ParseError(`${path}: ${e.message}`), { code: e.code }); throw e; } programs.set(path, p); state.set(path, "busy"); for (const d of p.decls) { - if (d.k === "import" && !isRx(d.from) && d.from.startsWith(".")) { // MO-8: a JS module with a declaration file beside it - const dts = joinPath(path, d.from.replace(/\.(m?js|ts)$/, "") + ".d.rx"); + const pkgDts = d.k === "import" && !isRx(d.from) && !d.from.startsWith(".") && !d.from.startsWith("/") ? packageDeclaration(path, d.from, read) : undefined; // MO-10c + if (d.k === "import" && !isRx(d.from) && (d.from.startsWith(".") || pkgDts)) { // MO-8: a JS module with a declaration file beside it + const dts = pkgDts ?? joinPath(path, d.from.replace(/\.(m?js|ts)$/, "") + ".d.rx"); if (!declarations.has(dts)) { const src = read(dts); if (src !== undefined) { try { declarations.set(dts, parseDeclarations(src)); } catch (e) { if (e instanceof ParseError) throw Object.assign(new ParseError(`${dts}: ${e.message}`), { code: e.code }); throw e; } } } const ds = declarations.get(dts); if (ds) { d.declared = dts; d.declaredErrors = ds.filter(x => x.kind === "error").map(x => x.name); d.declaredPure = ds.filter(x => x.kind === "fn" && x.pure).map(x => x.name); d.declaredSide = Object.fromEntries(ds.flatMap(x => x.kind === "fn" && x.side ? [[x.name, x.side]] : [])); } diff --git a/ref/src/parse.ts b/ref/src/parse.ts index 5188755..e3affce 100644 --- a/ref/src/parse.ts +++ b/ref/src/parse.ts @@ -132,7 +132,7 @@ export function parseProgram(src: string): Program { /** MO-8: a declaration file (`api.d.rx`) for a JS module — what each export is, in rows: `export pure fn f(a) rejects(A | B)`, * `export fn g(a)` (io: dirty is the default, R-io-4), `export error NotFound` (a JS class that is an error class), `export type T`. */ export type JsDecl = - | { kind: "fn"; name: string; pure: boolean; params: string[]; rejects?: string[]; line: number; side?: "server" | "client"; ptypes?: (string | undefined)[]; ret?: string } // side: 12 SF-5; types: text, for TY (types/) + | { kind: "fn"; name: string; pure: boolean; params: string[]; rejects?: string[]; line: number; side?: "server" | "client"; ptypes?: (string | undefined)[]; ret?: string; refs?: boolean[]; shape?: Shape; instantiates?: boolean } // side: 12 SF-5; types: text, for TY (types/), `ref` taken out; MO-10: refs (ref parameters), shape (what its result is), instantiates (written, or inferred from a ref) | { kind: "error"; name: string; line: number } | { kind: "type"; name: string; line: number; text?: string }; export function parseDeclarations(src: string): JsDecl[] { @@ -148,17 +148,40 @@ export function parseDeclarations(src: string): JsDecl[] { const pure = c.keyword("pure"); c.ws(); if (!c.keyword("fn")) c.fail("fn"); c.ws(); const name = c.ident() ?? c.fail("fn name"); c.ws(); c.expect("("); c.ws(); - const params: string[] = []; const ptypes: (string | undefined)[] = []; - while (!c.startsWith(")")) { params.push(c.ident() ?? c.fail("param")); ptypes.push(optType(c)); c.ws(); if (!c.eat(",")) break; c.ws(); } + const params: string[] = []; const ptypes: (string | undefined)[] = []; const refs: boolean[] = []; + while (!c.startsWith(")")) { const r = c.keyword("ref"); if (r) c.ws(); refs.push(r); params.push(c.ident() ?? c.fail("param")); c.ws(false); ptypes.push(c.peek() === ":" ? (c.i++, c.ws(), parseType(c, false, true)) : undefined); c.ws(); if (!c.eat(",")) break; c.ws(); } // MO-10a: a ref parameter takes an accessor c.expect(")"); - const save = c.i; c.ws(false); let ret: string | undefined; - if (c.peek() === ":" ) { c.i++; c.ws(); const t = c.src.slice(c.i).split("\n")[0]; const r = /\brejects\(/.exec(t); ret = (r ? t.slice(0, r.index) : t).replace(/\/\/.*$/, "").trim() || undefined; c.i += r ? r.index : t.length; } else c.i = save; + const save = c.i; c.ws(false); let ret: string | undefined; let shape: Shape | undefined; + if (c.peek() === ":" ) { c.i++; c.ws(); const t = c.src.slice(c.i).split("\n")[0]; const r = /\brejects\(|\binstantiates\b/.exec(t); const raw = (r ? t.slice(0, r.index) : t).replace(/\/\/.*$/, "").trim(); shape = raw ? shapeOf(raw) : undefined; ret = raw.replace(/\bref\s+/g, "") || undefined; c.i += r ? r.index : t.length; } else c.i = save; // MO-10b: `ref T` is an accessor, read as T c.ws(false); let rejects: string[] | undefined; if (c.keyword("rejects")) { c.ws(); c.expect("("); rejects = parseClasses(c) ?? ["Error"]; c.ws(); c.expect(")"); } - out.push({ kind: "fn", name, pure, params, rejects, line, ptypes, ret, ...(side ? { side } : {}) }); + c.ws(false); const written = c.keyword("instantiates"); + const instantiates = written || refs.some(x => x) || shapeHasRef(shape); // MO-10a: inferred from a ref on either side + out.push({ kind: "fn", name, pure, params, rejects, line, ptypes, ret, ...(side ? { side } : {}), ...(instantiates ? { refs, shape: shape ?? "value", instantiates } : {}) }); } } +/** MO-10b: what a primitive's result is — a reader (`ref T`, an accessor), an io function, a value; or a tuple or record of + * those (only the result and its parts are declared). */ +export type Part = "ref" | "fn" | "value"; +export type Shape = Part | { items: Part[] } | { fields: [string, Part][] }; +function splitTop(s: string, seps: string): string[] { + const out: string[] = []; let depth = 0, from = 0; + for (let i = 0; i < s.length; i++) { const ch = s[i]; if ("([{<".includes(ch)) depth++; else if (")]}>".includes(ch) && !(ch === ">" && s[i - 1] === "=")) depth--; else if (depth === 0 && seps.includes(ch)) { out.push(s.slice(from, i)); from = i + 1; } } + out.push(s.slice(from)); return out.map(x => x.trim()).filter(x => x); +} +function partOf(t: string): Part { + if (/^ref\s/.test(t)) return "ref"; + if (t.startsWith("(") && /\)\s*=>/.test(t)) return "fn"; + return "value"; +} +function shapeOf(t: string): Shape { + if (t.startsWith("[") && t.endsWith("]")) return { items: splitTop(t.slice(1, -1), ",").map(partOf) }; + if (t.startsWith("{") && t.endsWith("}")) return { fields: splitTop(t.slice(1, -1), ",;").map(f => { const i = f.indexOf(":"); return [f.slice(0, i).trim().replace(/\?$/, ""), partOf(f.slice(i + 1).trim())] as [string, Part]; }) }; + return partOf(t); +} +export function shapeHasRef(s?: Shape): boolean { return !!s && (s === "ref" || (typeof s === "object" && ("items" in s ? s.items : s.fields.map(f => f[1])).includes("ref"))); } + /** RG-4b, 07 §5: a destructuring pattern, `{ a, b: c, d = 1, e: { f }, ...rest }` or `[a, , b, ...rest]`. */ type Pat = { k: "name"; name: string; line: number } | { k: "obj"; props: { key: string; pat: Pat; dflt?: Expr }[]; rest?: Pat } | { k: "arr"; items: ({ pat: Pat; dflt?: Expr } | null)[]; rest?: Pat }; function parseRest(c: Cursor, close: string): Pat { diff --git a/ref/src/prims.ts b/ref/src/prims.ts new file mode 100644 index 0000000..8aef516 --- /dev/null +++ b/ref/src/prims.ts @@ -0,0 +1,25 @@ +// MO-10: the scenario prelude's Solid library, `./prims` (declared by a `prims.d.rx` beside the program). Two bodies of the +// same primitives: in .rx, for the interpreter, which has no Solid to run; and in Solid, for the runners that lower onto it +// (oracle, compiled, server; `compile/prims-solid.ts`), so the accessor protocol itself is what they run. +import { parseProgram } from "./parse.ts"; +import type { Decl } from "./ast.ts"; +export { primsSolid } from "../compile/prims-solid.ts"; + +/** What the interpreter instantiates: a primitive as the .rx it means. */ +export const PRIM_RX = ` +fn createToggle(initial) { + source v := initial + action set(x) { v := x } + [v, set] +} +fn createShout(ref s) { + out = \`\${s}!\` + out +} +`; +let rx: Map> | null = null; +export function primRx(name: string): Extract | undefined { + rx ??= new Map(parseProgram(PRIM_RX).decls.filter((d): d is Extract => d.k === "fn").map(d => [d.name, d])); + return rx.get(name); +} +export const isPrims = (from: string) => /(^|\/)prims(\.js)?$/.test(from); diff --git a/ref/src/runtime.ts b/ref/src/runtime.ts index 9f5a474..40f4471 100644 --- a/ref/src/runtime.ts +++ b/ref/src/runtime.ts @@ -10,6 +10,7 @@ import { checkModules, unwrapServer } from "./check.ts"; export { RuleError, CheckError }; import { makeErrorClass, isA, PANIC, wireCopy, wireFailure } from "./failure.ts"; import { singleModule, type ModuleSet } from "./modules.ts"; +import { primRx, isPrims } from "./prims.ts"; /** 12 SF-1d: Solid's server-function wrappers (\`GET\`, \`live\`, …) where there is no network: the function as it is (SF-7b). */ const LOCAL_WRAPPERS: Record = { GET: (f: unknown) => f, live: (f: unknown) => f, broadcast: (f: unknown) => f, withMeta: (f: unknown) => f }; export class ErrVal { err: unknown; constructor(err: unknown) { this.err = err; } } @@ -184,6 +185,7 @@ export class Runtime { declare(decls: Decl[], region: Region) { for (const d of decls) { if (d.k === "import" && d.resolved) { const m = this.modules.get(d.resolved); if (!m) throw new RuleError("MO-9", `${d.from} is known by its signature only (${d.from.replace(/\.rx$/, ".d.rx")}): there is no body to run`); for (const { name, alias } of d.names) { const b = m.scope.get(name); if (b) region.scope.set(alias, b); } continue; } // MO-1: the module's own bindings (a type binds nothing) + if (d.k === "import" && isPrims(d.from)) { for (const { name, alias } of d.names) { const f = primRx(name); if (!f) throw new RuleError("IMPORT", `${name} from "${d.from}" is not in the scenario prelude`); region.scope.set(alias, { fn: f, home: this.root }); } continue; } // MO-10: the prelude's primitives, as the .rx they mean if (d.k === "import") for (const { name, alias } of d.names) { // R-io-4: a JS import is io unless `pure`; the scenario prelude supplies the values const v = d.from === "@solidjs/web/server-functions" ? LOCAL_WRAPPERS[name] : name === "log" ? (...a: unknown[]) => { this.log.push(`log(${a.map(fmt).join(", ")})`); } : d.declaredErrors?.includes(name) ? makeErrorClass(name, ["message"], a => String(a[0] ?? name)) : BUILTINS[name] ?? (d.from.endsWith("/api") ? (...a: unknown[]) => this.flight(`${name}:${a.map(fmt).join(",")}`) : undefined); // "./api": every function is a flight keyed by its arguments if (v === undefined) throw new RuleError("IMPORT", `${name} from "${d.from}" is not in the scenario prelude`); diff --git a/scenarios/S-module-4.md b/scenarios/S-module-4.md new file mode 100644 index 0000000..424042b --- /dev/null +++ b/scenarios/S-module-4.md @@ -0,0 +1,129 @@ +# S-module-4 — a Solid library, declared: primitives instantiate, readers read, setters are io + +Rules: MO-10a, MO-10b, MO-10c, MO-10d, E-9, E-10, RG-6, R-io-3, CN-3e. + +## Modules + +```rx +-- module: ./prims.d.rx +// what ./prims (a JS module of Solid primitives) exports +export pure fn createToggle(initial: boolean): [ref boolean, (v: boolean) => void] +export pure fn createShout(ref s: string): ref string +``` + +```json +-- module: ./node_modules/@demo/prims/package.json +{ "name": "@demo/prims", "exports": { ".": { "types": "./dist/index.d.ts", "solid": "./dist/index.jsx", "default": "./dist/index.js" } } } +``` + +```rx +-- module: ./node_modules/@demo/prims/dist/index.d.rx +// shipped by the package, beside the file its import resolves to +export pure fn createToggle(initial: boolean): [ref boolean, (v: boolean) => void] +``` + +`createToggle` hands back an accessor and a setter; `createShout` takes an +accessor and hands one back. Both instantiate, inferred from `ref` (MO-10a). +The runners over Solid run them as Solid primitives (`ref/src/prims.ts`); +the interpreter runs the `.rx` they mean. + +## Program + +```rx +import { createToggle, createShout } from "./prims" +export default component App() { + source name := "ann" + source start := false + [isOn, setOn] = createToggle(peek start) + loud = createShout(name) + action flip() { setOn(!isOn) } + +

{isOn ? loud : "off"}

+} +``` + +`isOn` is read without a call: the compiler reads the accessor (MO-10b). +`name` is passed to a `ref` parameter, so as an accessor (RG-6b). + +## Frames + +| instant | action | frame | +| ------- | ---------------------- | ------ | +| 0 | mount | `off` | +| 1 | handler `flip()` | `ann!` | +| 2 | handler `name := "bo"` | `bo!` | +| 3 | handler `start := true` | `bo!` | +| 4 | handler `flip()` | `off` | + +Instant 2: the accessor `createShout` was given follows `name`. Instant 3: +`start` was read once (`peek`); the toggle is not rebuilt, so its state +stands (RG-6a), where a lifted call would have made a new one. + +## Rejected variants + +```rx +-- reject: SNAPSHOT_ARGUMENT +import { createToggle } from "./prims" +export default component App() { + source start := false + [isOn, setOn] = createToggle(start) + +

{isOn}

+} +``` + +E-10: a value parameter's argument is read once. + +```rx +-- reject: IO_IN_REACTIVE +import { createToggle } from "./prims" +export default component App() { + [isOn, setOn] = createToggle(false) + oops = setOn(true) + +

{isOn} {oops}

+} +``` + +MO-10b: a function part of a primitive's result is io. + +```rx +-- reject: INSTANTIATION_NOT_BOUND +import { createShout } from "./prims" +export default component App() { + source name := "ann" + +

{createShout(name)}

+} +``` + +A primitive is called once, where it is bound (RG-6). + +```rx +-- reject: IO_IN_REACTIVE +import { createToggle } from "@demo/prims" +export default component App() { + [isOn, setOn] = createToggle(false) + oops = setOn(true) + +

{isOn} {oops}

+} +``` + +MO-10c: a package's declaration is beside the file the import resolves to +(here `exports` → `solid` → `dist/index.jsx`, so `dist/index.d.rx`), with no +export condition of its own. + +```rx +-- reject: CN-3e +import { createShout } from "./prims" +export default component App() { + source name := "ann" + loud = createShout(name) + +
{loading on(name) fallback {

…

} {

{loud}

}}

{loud}

+} +``` + +MO-10d: a foreign reader may hold, as a question's reader does, so the +outside read of `loud` keeps the change `loading on(name)` waits for. diff --git a/spec/09-modules.md b/spec/09-modules.md index 8c05cc5..b62221e 100644 --- a/spec/09-modules.md +++ b/spec/09-modules.md @@ -214,6 +214,88 @@ export component Save(props: { v: unrendered action go catches(nothing) }) only a `.d.rx` checks and compiles; it runs once that JavaScript exists (`ref/`'s runners have none, so S-module-3 is checked only). +## MO-10 — Solid libraries + +A Solid primitive is a JS function called once in an owner that hands back +accessors, setters and stores. Its declaration (MO-8) says so in the words a +printed `.rx` signature already has (MO-9b). Nothing is added at run time: the +call is the same JS call, placed where an instantiation goes (RG-6a). + +```rx +// media.d.rx, beside ./media.js, or shipped by the package (MO-10c) +export pure fn createToggle(initial: boolean): [ref boolean, (v: boolean) => void] +export pure fn createDebounced(ref source: string, ms: number): ref string +export pure fn createMediaQuery(query: string): ref boolean +export pure fn createEventListener(target: EventTarget, type: string, handler: (e: Event) => void) instantiates +``` + +```rx +import { createToggle, createDebounced, createMediaQuery } from "./media.js" + +export component Search(props: { start: boolean, q: string }) { + [isOn, setOn] = createToggle(peek props.start) // once; without peek, SNAPSHOT_ARGUMENT (E-10) + query = createDebounced(props.q, 300) // ref: passed as () => props.q + wide = createMediaQuery("(min-width: 768px)") + action flip() { setOn(!isOn) } // `x = setOn(true)` is IO_IN_REACTIVE + +} +``` + +- **MO-10a (Instantiating JS functions).** A JS declaration's `fn` may end + with `instantiates`. As E-9 infers it for an `.rx` fn, it is inferred when a + parameter is `ref` or the return has a `ref` part (a reader handed back means + the call made nodes), so it is written only for a primitive with neither — + one that registers a listener, or returns a store. A call is an + instantiation (RG-6): once, in the caller's region, never re-evaluated; the + primitive's owner is that region, so its cleanups run with it. Value + arguments are snapshots (E-10); a `ref` argument is passed as an accessor, + `() => e` (RG-6b), which is Solid's `Accessor` argument. It must be bound + (`INSTANTIATION_NOT_BOUND`). A primitive is declared `pure fn`, as a + printed `.rx` one is (`export pure fn toggle(ref start: boolean) + instantiates`): making it writes no reactive state but its own, so it is + called where its value is bound; what it hands back is MO-10b's. `.rx` has no accessor type: `ref` is the one + word for an accessor at the boundary, in either direction. +- **MO-10b (What the result is).** The return says what `.rx` sees: + - `ref T` is a *reader*. Bound by destructuring or projection, it is an + equation of `T`, read without a call (`isOn`, not `isOn()`): the name is + bound to the accessor and each read compiles to its call, with no node of + its own. Passed to a `ref` parameter, the accessor is passed as it is, not + wrapped again. In imperative scope a read is its value, as any + equation's. + - A function type is `io` (R-io-3): callable only in imperative scope. + A primitive's functions write, so `pure` on a part is not offered. + - Anything else, a store or a plain value, is a value. A store's fields + track at run time as Solid's do. A store passed *in* is likewise passed as + itself; an `.rx` structured source is a store behind a signal slot (11), so + a whole write (`todos := next`) swaps it, and a value parameter misses + that: `SNAPSHOT_ARGUMENT` stands (`peek todos` says it is meant), and an + API that takes an accessor of a store declares `ref`. + - Only the return and its tuple or object parts are declared; an accessor + nested deeper is a value. +- **MO-10c (Where a package's declaration is).** Beside the file the import + resolves to, as MO-8a's is beside a relative module and a `.d.ts` beside its + `.js`: `@solid-primitives/media` resolves through the package's `exports` + with the build's conditions to `dist/index.jsx`, and its declaration is + `dist/index.d.rx`. No export condition is needed. A package whose + conditions resolve to more than one file ships the declaration beside + each; the rows of one export are the same in every build. + A package that ships none is declared through a local module: `./media.js` + re-exports it and `./media.d.rx` beside it declares it (MO-8a, MO-8c). + Without either, the import is what R-io-4 says, and `import pure` of a + primitive is unchecked (CN-5): the call is then lifted (E-9) and re-runs, + rebuilding the primitive, whenever an argument changes. +- **MO-10d (Holds; Ryan 2026-11-13).** Solid promises nothing about an + accessor: any read can hold. A reader bound from a `ref` result is checked + as a reader of a question (ST-5): `pending?`, `latest` and `loading` apply, + with whatever hold checks a reader of `await` gets. Its hold is visible + where it is bound, at the instantiation, as a question's is at its `await`. + No declaration can say an accessor never holds. +- **MO-10e (What stays the library's).** A call to a foreign setter is not a + write of a source: O-4 and AC-1 see only `.rx` sources. So a read of `isOn` + after `setOn(v)` in the same action reads the value before the write, under + Solid's batching, and AC-1d cannot say so; the guide teaches it. A plain + closure passed as a callback option is unchecked, as O-4a's is. + --- ## Diagnostics this document adds @@ -228,6 +310,15 @@ export component Save(props: { v: unrendered action go catches(nothing) }) ## Implementation +- MO-10: `parseDeclarations` reads `ref`, `ref T` and `instantiates` into a + declaration's `refs` and `shape`; the checker binds a primitive as an + instantiating fn (so E-9, E-10 and RG-6's checks are an `.rx` fn's) and + marks each equation bound from its result a reader, an io function or a + value. The lowering calls it once and binds a `ref` part to the accessor + itself. A package's declaration is found by `packageDeclaration` + (`modules.ts`, `package_declaration` in the crate). The scenarios' Solid + library is `./prims` (`ref/src/prims.ts`: the `.rx` the interpreter runs, + and `compile/prims-solid.ts`, the Solid primitives the other runners call). - `ref/src/modules.ts` loads an entry and every `.rx` module it imports, sets each import's resolved key, orders them dependencies first, and reports cycles and missing modules. Scenarios declare modules in a @@ -270,3 +361,16 @@ export component Save(props: { v: unrendered action go catches(nothing) }) static check this could add). The added words are MO-9b's; `uses(…)` names keys by relative path (`./ui.rx#who`) where it named a bare file name, so that a key is exact across directories. +7. **Solid libraries are declared, not wrapped** (MO-10; taken by default + 2026-11-13, veto here). A JS `.d.rx` takes `instantiates` and `ref`, the + words of a printed signature, and `ref T` in a return (new: `ref` in a + type position, in JS declarations only — an `.rx` import never needs it, + since a returned equation is its value). `instantiates` is inferred from + `ref` and written only where neither side has one. Ryan, 2026-11-13: any + accessor can hold (MO-10d), and the notation is `.rx`'s, with no + `Accessor` type. A package ships its declaration beside the file its import + resolves to, as a `.d.ts` beside its `.js` (an `"rx"` export condition was + drafted and dropped, Ryan 2026-11-13: nothing needs it), else a local + re-export declares it; a project-level folder of + declarations keyed by package was the alternative, not taken. + Built in `ref/` and the crate (S-module-4).