Skip to main content

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}