logicaffeine_language/ast/component.rs
1//! The LGX component model — the first-class AST for a page, a component, a store, and the
2//! sections they own: `State:` (the record), `Actions:` (the only mutators, plus the
3//! English event sentences that trigger them), `View:` (the template, already parsed into
4//! the view IR), and `Style:` (the component's CSS).
5//!
6//! Nothing here is desugared: the definition is kept as it was written, and the lowering
7//! (`parser::lgx_lower`) turns it into the page's LOGOS, which every tier runs — the tree-walker,
8//! the VM for WASM and the compiled backends, AOT-Rust for server rendering.
9
10use crate::ast::stmt::{Block, Expr, Stmt, TypeExpr};
11use crate::ast::view::{expr_shape, ViewDef};
12use crate::token::Span;
13use logicaffeine_base::{Interner, Symbol};
14
15/// One `## Page` / `## Component` / `## Store` / `## Error page` definition.
16#[derive(Debug, Clone)]
17pub struct ComponentDef<'a> {
18 pub kind: ComponentKind<'a>,
19 pub name: Symbol,
20 /// Header parameters — a component's props (`## Component Row (todo: Todo)`), read-only
21 /// inside it. A page's typed path parameters live in its kind.
22 pub props: Vec<(Symbol, &'a TypeExpr<'a>)>,
23 /// `Use Session.` — the stores this component reads and calls.
24 pub uses: Vec<Symbol>,
25 pub state: Vec<StateField<'a>>,
26 pub events: Vec<EventHandler<'a>>,
27 pub actions: Vec<ActionDef<'a>>,
28 pub view: Option<ViewDef<'a>>,
29 /// `Head:` — the head HTML this page declares for its own document: its title,
30 /// description, social card, canonical URL. Rendered at BUILD time into the
31 /// document the server sends, and merged over the default shell tag by tag, so
32 /// a page that declares nothing keeps the document it has today.
33 ///
34 /// Only a `## Page` can carry one: a component is placed INSIDE a document and
35 /// does not own it, and two components on one page declaring a title would make
36 /// the answer depend on render order.
37 pub head: Option<ViewDef<'a>>,
38 pub style: Option<StyleBlock>,
39 pub span: Span,
40}
41
42#[derive(Debug, Clone)]
43pub enum ComponentKind<'a> {
44 Component,
45 /// `## Page /todos/:id (id: Int)` — mounted at `path`; each `:name` binds a typed param.
46 Page {
47 path: Symbol,
48 params: Vec<(Symbol, &'a TypeExpr<'a>)>,
49 },
50 /// `## Store Name [persisted as "key" | shared on "topic"]`.
51 Store {
52 mode: StoreMode<'a>,
53 },
54 /// `## Error page 404`.
55 ErrorPage {
56 status: u16,
57 },
58}
59
60#[derive(Debug, Clone)]
61pub enum StoreMode<'a> {
62 Local,
63 Persisted {
64 key: &'a Expr<'a>,
65 },
66 Shared {
67 topic: &'a Expr<'a>,
68 },
69 /// `preserved across reloads` — state that survives a HOT RELOAD but not a fresh session.
70 ///
71 /// Distinct from `persisted as "<key>"`, which survives everything and is a decision about
72 /// the PRODUCT. This is a decision about the EDIT LOOP: a rebuild must not empty the form you
73 /// were filling in, and a cold start must not resurrect yesterday's draft. Between those two
74 /// there is no key to choose, because the state never leaves the session.
75 Preserved,
76}
77
78/// `Let x be <init>.` or `Let x always be <expr>.` (derived) in a `State:` section.
79#[derive(Debug, Clone)]
80pub struct StateField<'a> {
81 pub name: Symbol,
82 pub ty: Option<&'a TypeExpr<'a>>,
83 pub init: &'a Expr<'a>,
84 pub derived: bool,
85 pub span: Span,
86}
87
88/// What an English `When …:` sentence in `Actions:` waits for.
89#[derive(Debug, Clone)]
90pub enum Trigger<'a> {
91 /// `When the page loads:`
92 PageLoads,
93 /// `When this component appears:`
94 Appears,
95 /// `When this component disappears:`
96 Disappears,
97 /// `When the route changes:`
98 RouteChanges,
99 /// `When the app comes back online:`
100 Online,
101 /// `When the app goes offline:`
102 Offline,
103 /// `When "#add" is submitted:` / `When "#draft" changes:` / `When the user types in "#q":` /
104 /// `When the user presses Escape in "#draft":` (`key` set) / `When "#x" is focused:` …
105 Selector {
106 selector: &'a Expr<'a>,
107 event: Symbol,
108 key: Option<Symbol>,
109 },
110 /// `When the user presses Escape:` — a key anywhere on the page.
111 Key { key: Symbol },
112 /// `When todos change:` / `When Session's name changes:`
113 FieldChanges {
114 store: Option<Symbol>,
115 field: Symbol,
116 },
117 /// `Every 60 seconds:` — repeating. `Every 60 seconds as refresh:` names it, and a named
118 /// timer is the only kind `Stop the timer …` can reach.
119 Every {
120 seconds: &'a Expr<'a>,
121 name: Option<Symbol>,
122 },
123 /// `After 5 seconds:` — one-shot, optionally named so it can be cancelled before it fires.
124 After {
125 seconds: &'a Expr<'a>,
126 name: Option<Symbol>,
127 },
128 /// `When a message arrives on "topic" (m: T):`
129 Message {
130 topic: &'a Expr<'a>,
131 param: (Symbol, &'a TypeExpr<'a>),
132 },
133 /// `When an action fails (error: Text):`
134 ActionFails { param: Symbol },
135 /// `When a fetch fails (url: Text) and (error: Text):`
136 FetchFails { url: Symbol, error: Symbol },
137 /// `When the window resizes:`
138 WindowResizes,
139 /// `When the user scrolls to the bottom [of "#list"]:`
140 ScrollsToBottom { selector: Option<&'a Expr<'a>> },
141 /// `When a file is dropped on "#zone" (file: File):` or `(name: Text)` — the declared type
142 /// decides what binds: `File` the whole record, `Text` just the name, same as [`Trigger::
143 /// Message`]'s own `(Symbol, &TypeExpr)` shape keeps its payload's declared type.
144 FileDropped {
145 selector: &'a Expr<'a>,
146 param: (Symbol, &'a TypeExpr<'a>),
147 },
148}
149
150impl<'a> Trigger<'a> {
151 /// ★ THE TRIGGERS NO TIER DELIVERS — one table, read by every tier that has to say so.
152 ///
153 /// `Some((sentence, needs))` when nothing anywhere can fire this trigger yet; `None` when some
154 /// tier delivers it.
155 ///
156 /// The distinction is not pedantic, and getting it wrong produced a false statement in a
157 /// compiler message: the browser-tier mount refusal ended *"The tree-walker renders it; the
158 /// bytecode tier cannot yet"*, which is true of `Every … seconds:` and **false** of
159 /// `When the window resizes:` — nothing renders that one. An author would have been told
160 /// their page works on the tree-walker and sent looking for a tier to run it on.
161 ///
162 /// It lives here because every tier has to agree about which sentences exist at all, and a
163 /// second copy of a list is how they stop agreeing.
164 pub fn undeliverable(&self) -> Option<(&'static str, &'static str)> {
165 // ★ EMPTY, AND THAT IS THE POINT. `Message` was the last entry: it waited on "the
166 // realtime relay", which turned out to be BUILT — `logicaffeine_system::relay` carries
167 // `Subscribe`/`Publish`/`Event` between a native node and a browser tab over one shared
168 // wire protocol. What was missing was the LGX end, not the transport. The table stays
169 // because the next sentence that outruns its tiers belongs here, and because two tiers
170 // agreeing about which sentences exist at all is worth a list even when it is empty.
171 match self {
172 _ => None,
173 }
174 }
175}
176
177#[derive(Debug, Clone)]
178pub struct EventHandler<'a> {
179 pub trigger: Trigger<'a>,
180 pub body: Block<'a>,
181 pub span: Span,
182}
183
184/// `To name (params):` in `Actions:`.
185#[derive(Debug, Clone)]
186pub struct ActionDef<'a> {
187 pub name: Symbol,
188 pub params: Vec<(Symbol, &'a TypeExpr<'a>)>,
189 pub body: Block<'a>,
190 pub span: Span,
191}
192
193/// A `Style:` section: the dedented CSS text, the file offset of its first character, and the
194/// indentation stripped from each of its lines.
195#[derive(Debug, Clone)]
196pub struct StyleBlock {
197 pub css: Symbol,
198 pub content_start: usize,
199 pub indent: usize,
200}
201
202impl StyleBlock {
203 /// The file offset of offset `at` in the stylesheet text `css` (this block's own text).
204 pub fn file_offset(&self, css: &str, at: usize) -> usize {
205 crate::lgx::raw::RawMap::new(css, self.indent, self.content_start).offset(at)
206 }
207}
208
209/// A `## Route`'s body: ordinary statements, or a template for a `-> View` route. The template
210/// is boxed: a `ViewDef` inline would make every `Stmt` as wide as a view route.
211#[derive(Debug, Clone)]
212pub enum RouteBody<'a> {
213 Statements(Block<'a>),
214 View(ViewDef<'a>),
215}
216
217fn type_shape(ty: &TypeExpr<'_>, interner: &Interner) -> String {
218 match ty {
219 TypeExpr::Primitive(s) | TypeExpr::Named(s) => interner.resolve(*s).to_string(),
220 TypeExpr::Generic { base, params } => {
221 let ps: Vec<String> = params.iter().map(|p| type_shape(p, interner)).collect();
222 format!("{} of {}", interner.resolve(*base), ps.join(" and "))
223 }
224 other => format!("{other:?}")
225 .split_whitespace()
226 .next()
227 .unwrap_or("_")
228 .to_string(),
229 }
230}
231
232fn params_shape(params: &[(Symbol, &TypeExpr<'_>)], interner: &Interner) -> String {
233 if params.is_empty() {
234 return String::new();
235 }
236 let ps: Vec<String> = params
237 .iter()
238 .map(|(n, t)| format!("{}: {}", interner.resolve(*n), type_shape(t, interner)))
239 .collect();
240 format!("({})", ps.join(", "))
241}
242
243impl<'a> Trigger<'a> {
244 /// The English FORM this trigger is written in — the words an author would search their own
245 /// file for.
246 ///
247 /// ★ DELIBERATELY WITHOUT THE VALUES, and that is the difference from [`Self::shape`]. `shape` is a
248 /// stable debug rendering for snapshot tests, and it prints what the AST holds: a duration comes
249 /// out as `every 60000000000ms`, which is true and is not what anybody typed. A refusal is read
250 /// by the person who wrote the sentence, so it names the FORM (`Every … seconds:`) and lets them
251 /// find the line. Two renderings because there are two audiences; kept adjacent so neither can
252 /// be changed without seeing the other.
253 pub fn form(&self) -> &'static str {
254 match self {
255 Trigger::PageLoads => "When the page loads:",
256 Trigger::Appears => "When this component appears:",
257 Trigger::Disappears => "When this component disappears:",
258 Trigger::RouteChanges => "When the route changes:",
259 Trigger::Online => "When the app comes back online:",
260 Trigger::Offline => "When the app goes offline:",
261 Trigger::Selector { .. } => "When \"…\" is …:",
262 Trigger::Key { .. } => "When the user presses …:",
263 // ★ TWO SENTENCES, TWO FORMS — and collapsing them is what let a whole trigger go
264 // uncatalogued. `FieldChanges` carries an optional STORE, and the two readings are
265 // different things: one watches this component's own field, the other watches shared
266 // state across components. They reached the browser at different times, and while
267 // this function returned one string for both, the refusal for the store form quoted a
268 // sentence the author had not written and the tier census could not tell the two rows
269 // apart. A `form()` that under-describes the language is a blind spot every reader of
270 // it inherits.
271 Trigger::FieldChanges { store: Some(_), .. } => "When <Store>'s … changes:",
272 Trigger::FieldChanges { store: None, .. } => "When … changes:",
273 Trigger::Every { .. } => "Every … seconds:",
274 Trigger::After { .. } => "After … seconds:",
275 Trigger::Message { .. } => "When a message arrives on …:",
276 Trigger::ActionFails { .. } => "When an action fails …:",
277 Trigger::FetchFails { .. } => "When a fetch fails …:",
278 Trigger::WindowResizes => "When the window resizes:",
279 Trigger::ScrollsToBottom { .. } => "When the user scrolls to the bottom …:",
280 Trigger::FileDropped { .. } => "When a file is dropped on …:",
281 }
282 }
283
284 /// A compact rendering for locks and tooling.
285 pub fn shape(&self, interner: &Interner) -> String {
286 match self {
287 Trigger::PageLoads => "page loads".into(),
288 Trigger::Appears => "appears".into(),
289 Trigger::Disappears => "disappears".into(),
290 Trigger::RouteChanges => "route changes".into(),
291 Trigger::Online => "online".into(),
292 Trigger::Offline => "offline".into(),
293 Trigger::Selector {
294 selector,
295 event,
296 key,
297 } => match key {
298 Some(k) => format!(
299 "{} {} {}",
300 expr_shape(selector, interner),
301 interner.resolve(*event),
302 interner.resolve(*k)
303 ),
304 None => format!(
305 "{} {}",
306 expr_shape(selector, interner),
307 interner.resolve(*event)
308 ),
309 },
310 Trigger::Key { key } => format!("key {}", interner.resolve(*key)),
311 Trigger::FieldChanges { store: None, field } => {
312 format!("{} changes", interner.resolve(*field))
313 }
314 Trigger::FieldChanges {
315 store: Some(s),
316 field,
317 } => format!(
318 "{}'s {} changes",
319 interner.resolve(*s),
320 interner.resolve(*field)
321 ),
322 Trigger::Every { seconds, name } => match name {
323 None => format!("every {}", expr_shape(seconds, interner)),
324 Some(n) => format!(
325 "every {} as {}",
326 expr_shape(seconds, interner),
327 interner.resolve(*n)
328 ),
329 },
330 Trigger::After { seconds, name } => match name {
331 None => format!("after {}", expr_shape(seconds, interner)),
332 Some(n) => format!(
333 "after {} as {}",
334 expr_shape(seconds, interner),
335 interner.resolve(*n)
336 ),
337 },
338 Trigger::Message { topic, param } => format!(
339 "message on {} ({})",
340 expr_shape(topic, interner),
341 interner.resolve(param.0)
342 ),
343 Trigger::ActionFails { param } => {
344 format!("action fails ({})", interner.resolve(*param))
345 }
346 Trigger::FetchFails { url, error } => format!(
347 "fetch fails ({}, {})",
348 interner.resolve(*url),
349 interner.resolve(*error)
350 ),
351 Trigger::WindowResizes => "window resizes".into(),
352 Trigger::ScrollsToBottom { selector: None } => "scrolls to bottom".into(),
353 Trigger::ScrollsToBottom { selector: Some(s) } => {
354 format!("scrolls to bottom of {}", expr_shape(s, interner))
355 }
356 Trigger::FileDropped { selector, param } => format!(
357 "file dropped on {} ({})",
358 expr_shape(selector, interner),
359 interner.resolve(param.0)
360 ),
361 }
362 }
363}
364
365/// How many rounds of `When <Store>'s <field> changes:` may cascade — a watcher's write waking a
366/// watcher, which wakes another — before the page is refused as one that never settles. Every tier
367/// counts the same rounds and says the same sentence ([`store_cascade_message`]).
368pub const STORE_CASCADE_LIMIT: usize = 16;
369
370/// What every tier says of a store-watcher cascade deeper than [`STORE_CASCADE_LIMIT`].
371pub fn store_cascade_message() -> String {
372 format!(
373 "a `When <Store>'s <field> changes:` handler keeps changing a store, so the page never \
374 settles — {STORE_CASCADE_LIMIT} rounds and still going. A watcher that writes the store it \
375 watches (directly, or through another store that watches back) has no resting point. Make \
376 the write conditional on something that stops, or move it into the action that started \
377 this."
378 )
379}
380
381/// What every tier says of a timer whose period, as its instance was drawn, is not positive.
382pub fn timer_period_not_positive() -> &'static str {
383 "A timer needs a positive number of seconds — `Every 0 seconds:` would never leave the moment \
384 it was scheduled in."
385}
386
387/// What every tier says of `Get … as <ty>` when the derived JSON codec cannot read `ty`: a typed
388/// request decodes its answer through `fromJson_<T>` for a declared record, `fromJsonSeq_<T>` for
389/// a list of records or scalars, and takes Text verbatim — nothing else.
390pub fn typed_fetch_refusal(ty: &TypeExpr<'_>, interner: &Interner) -> String {
391 format!(
392 "`as {}` is not a type this fetch can read yet: a typed request decodes through the derived \
393 `fromJson_<T>`, which covers a declared record, an Int or a Bool, a `Seq of` records or \
394 scalars, and Text verbatim. Ask for the record itself, or read the body as Text.",
395 type_shape(ty, interner)
396 )
397}
398
399/// What every tier puts in front of a trap inside a `## Directive`'s block (`removed`: its `When
400/// removed:` block): from the page's side, the directive is what went wrong.
401pub fn directive_failure_prefix(directive: &str, removed: bool) -> String {
402 if removed {
403 format!("The `{directive}` directive's `When removed:` block failed: ")
404 } else {
405 format!("The `{directive}` directive failed: ")
406 }
407}
408
409/// How deeply components may nest while one paint is drawn before the page is refused as one with
410/// no finite rendering ([`render_depth_message`]).
411///
412/// ★ THE BUDGET LANDS BEFORE THE NATIVE STACK DOES, because every level is a real frame chain — a
413/// component that placed itself with nothing to stop it aborted the process. The number is measured,
414/// not picked: 64 still crashed; 32 and 24 both reported cleanly. It is far past any page nested by
415/// hand (a deep menu or comment thread is single digits); a design that needs more wants an
416/// ITERATIVE render walk, not a larger number, which walks back toward the crash.
417pub const RENDER_DEPTH_LIMIT: usize = 32;
418
419/// What every tier says when `component` is nested past [`RENDER_DEPTH_LIMIT`] while drawing.
420pub fn render_depth_message(component: &str) -> String {
421 format!(
422 "`{component}` is nested more than {RENDER_DEPTH_LIMIT} deep while drawing the page, so it has \
423 no finite rendering. A component whose view places itself needs something that stops — \
424 `{{if depth is greater than 0:}}` around the placement, with a smaller value passed down \
425 each time."
426 )
427}
428
429impl<'a> ComponentDef<'a> {
430 /// How a message names this definition: a component by its own name, a page or error page by
431 /// its header — whose internal name (`__page__about`) appears nowhere in the source.
432 pub fn label(&self, interner: &Interner) -> String {
433 match &self.kind {
434 ComponentKind::Page { path, .. } => format!("`## Page {}`", interner.resolve(*path)),
435 ComponentKind::ErrorPage { status } => format!("`## Error page {status}`"),
436 ComponentKind::Store { .. } => format!("`## Store {}`", interner.resolve(self.name)),
437 ComponentKind::Component => format!("`{}`", interner.resolve(self.name)),
438 }
439 }
440
441 /// A body's statement count as an AUTHOR would count it: `Stmt::Site` is a marker the parser
442 /// inserts before each of an action or event handler's own sentences (Wave 3's runtime
443 /// failure-site table, `parser/mod.rs`'s `site_tracking`) so a trap can name which one ran —
444 /// invisible bookkeeping, never a sentence the author wrote. Counting it too would double
445 /// every tracked body's own length and misreport it to whoever reads `largo expand`.
446 fn authored_len(body: Block<'a>) -> usize {
447 body.iter().filter(|s| !matches!(s, Stmt::Site(_))).count()
448 }
449
450 /// A compact, deterministic one-line rendering — the snapshot surface the parser lock
451 /// pins and `largo expand` prints. Bodies print as their statement counts.
452 pub fn shape(&self, interner: &Interner) -> String {
453 let head = match &self.kind {
454 ComponentKind::Component => format!(
455 "component {}{}",
456 interner.resolve(self.name),
457 params_shape(&self.props, interner)
458 ),
459 ComponentKind::Page { path, params } => format!(
460 "page {}{}",
461 interner.resolve(*path),
462 params_shape(params, interner)
463 ),
464 ComponentKind::Store { mode } => match mode {
465 StoreMode::Local => format!("store {}", interner.resolve(self.name)),
466 StoreMode::Persisted { key } => format!(
467 "store {} persisted as {}",
468 interner.resolve(self.name),
469 expr_shape(key, interner)
470 ),
471 StoreMode::Shared { topic } => format!(
472 "store {} shared on {}",
473 interner.resolve(self.name),
474 expr_shape(topic, interner)
475 ),
476 StoreMode::Preserved => format!(
477 "store {} preserved across reloads",
478 interner.resolve(self.name)
479 ),
480 },
481 ComponentKind::ErrorPage { status } => format!("error page {status}"),
482 };
483 let mut parts = vec![head];
484 if !self.uses.is_empty() {
485 let u: Vec<&str> = self.uses.iter().map(|s| interner.resolve(*s)).collect();
486 parts.push(format!("uses[{}]", u.join(", ")));
487 }
488 if !self.state.is_empty() {
489 let fs: Vec<String> = self
490 .state
491 .iter()
492 .map(|f| {
493 let ty =
494 f.ty.map(|t| format!(": {}", type_shape(t, interner)))
495 .unwrap_or_default();
496 format!(
497 "{}{} {} {}",
498 interner.resolve(f.name),
499 ty,
500 if f.derived { ":=" } else { "=" },
501 expr_shape(f.init, interner)
502 )
503 })
504 .collect();
505 parts.push(format!("state{{{}}}", fs.join("; ")));
506 }
507 if !self.events.is_empty() {
508 let es: Vec<String> = self
509 .events
510 .iter()
511 .map(|e| {
512 format!(
513 "{}: {}",
514 e.trigger.shape(interner),
515 Self::authored_len(e.body)
516 )
517 })
518 .collect();
519 parts.push(format!("events{{{}}}", es.join("; ")));
520 }
521 if !self.actions.is_empty() {
522 let as_: Vec<String> = self
523 .actions
524 .iter()
525 .map(|a| {
526 format!(
527 "{}{}: {}",
528 interner.resolve(a.name),
529 params_shape(&a.params, interner),
530 Self::authored_len(a.body)
531 )
532 })
533 .collect();
534 parts.push(format!("actions{{{}}}", as_.join("; ")));
535 }
536 if let Some(v) = &self.view {
537 parts.push(format!("view{}", v.body_shape(interner)));
538 }
539 if self.style.is_some() {
540 parts.push("style".into());
541 }
542 parts.join(" ")
543 }
544}