Skip to main content

logicaffeine_language/ast/
view.rs

1//! The LGX **view IR** — what a template PARSES INTO before anything is lowered.
2//!
3//! A view is a tree of elements, text, holes, control flow, and component uses. The
4//! parser builds this tree first (pure structure, no statements), then a separate
5//! lowering emits whatever the execution tiers consume. Keeping the tree explicit is
6//! what lets the same template be checked, server-rendered, compiled to fine-grained
7//! DOM patches, inspected by tooling, and printed back (`shape`) for locks.
8//!
9//! Every expression a node carries is an arena `&'a Expr<'a>` parsed by the ordinary
10//! expression parser, so holes and conditions are first-class LOGOS expressions; the
11//! tree itself is owned (`Vec` children), like the rest of the imperative AST.
12
13use crate::ast::stmt::{Block, Expr, Literal, Pattern, StringPart};
14use crate::token::Span;
15use logicaffeine_base::{Interner, Symbol};
16
17/// The position `{for each x in xs, at index i:}` gives the first item.
18///
19/// Positions in LOGOS are 1-based — `item 1 of xs` is the first item and `item 0 of xs` is an
20/// error — so a template index names the same numbers: the row showing `{i}` is the row
21/// `item i of xs` reads. Every walk that draws a loop (the page lowering, the server walk) starts
22/// its counter here.
23pub const LOOP_INDEX_BASE: i64 = 1;
24
25/// One complete view: a `Render:` block, a `Render into <el>:` block, or a component's
26/// `View:` — in source order they appear in [`crate::parser::Parser::views`].
27#[derive(Debug, Clone)]
28pub struct ViewDef<'a> {
29    /// The component this view belongs to (`None` for a bare `Render:`).
30    pub component: Option<Symbol>,
31    /// Where the tree renders.
32    pub root: ViewRoot<'a>,
33    pub nodes: Vec<ViewNode<'a>>,
34    /// Every identifier the template MENTIONS, with its span in the FILE.
35    ///
36    /// ★ THE ONE THING A TOKEN WALK CANNOT RECOVER. A `View:` body reaches the parser as a single
37    /// `RawBlock` token, so a name used only in a template is not in the token stream at all —
38    /// which is why an editor could not jump from `{count}` to the `Let count be 0.` it names,
39    /// the most common movement there is in an LGX file. The positions are known HERE and only
40    /// here: `TExpr` carries its offset in the template body, and the parser holds the map from
41    /// body offsets to file offsets. Recorded on the way past rather than re-derived, because a
42    /// second copy of that arithmetic is a second thing to get wrong.
43    pub mentions: Vec<(Symbol, Span)>,
44    pub span: Span,
45}
46
47/// The render target of a [`ViewDef`].
48#[derive(Debug, Clone)]
49pub enum ViewRoot<'a> {
50    /// `Render:` — appended under the document body (the initial mount, never cleared).
51    Body,
52    /// `Render into <expr>:` — the target is cleared, then rebuilt.
53    Into(&'a Expr<'a>),
54    /// A component's `View:` — rendered as a fragment into the parent the caller passes.
55    ComponentParent,
56    /// A page's `Head:` — rendered at BUILD time into the document's `<head>`, never
57    /// mounted. It is a separate root rather than a `ComponentParent` because nothing
58    /// in a browser ever paints it: the document already carries it when the module
59    /// loads, so re-rendering it would be work with no observer.
60    Head,
61}
62
63/// One node of a view tree.
64#[derive(Debug, Clone)]
65pub enum ViewNode<'a> {
66    /// An HTML element with its attributes (in source order) and children.
67    Element {
68        tag: Symbol,
69        attrs: Vec<Attr<'a>>,
70        children: Vec<ViewNode<'a>>,
71        span: Span,
72    },
73    /// A text child: a literal or interpolated string expression (escaped on render).
74    Text { text: &'a Expr<'a>, span: Span },
75    /// A use of a declared component (`Counter`, `<TodoRow …/>`). `prop_spans[i]` is where
76    /// `props[i]` was written — the whole `name={value}` attribute — so a refusal about one prop
77    /// points at that prop.
78    Component {
79        name: Symbol,
80        props: Vec<(Symbol, &'a Expr<'a>)>,
81        prop_spans: Vec<Span>,
82        children: Vec<ViewNode<'a>>,
83        span: Span,
84    },
85    /// `for each <pattern> in <iterable>[, keyed by <key>][, at index <i>]:` — one body per item;
86    /// `empty` renders when the collection has no items. `index` counts from
87    /// [`LOOP_INDEX_BASE`].
88    Each {
89        pattern: Pattern,
90        iterable: &'a Expr<'a>,
91        key: Option<&'a Expr<'a>>,
92        index: Option<Symbol>,
93        body: Vec<ViewNode<'a>>,
94        empty: Vec<ViewNode<'a>>,
95        span: Span,
96    },
97    /// `when <cond>:` … `otherwise:` — arms in order, then the fallback.
98    If {
99        arms: Vec<(&'a Expr<'a>, Vec<ViewNode<'a>>)>,
100        otherwise: Option<Vec<ViewNode<'a>>>,
101        span: Span,
102    },
103    /// An event handler on the containing element: the legacy `on click:` + statements, or
104    /// an LGX `on-click={action}` directive (whose expression becomes a one-call body).
105    /// `modifiers` are the `.prevent` / `.stop` / `.enter` … qualifiers, already classified by
106    /// the event-modifier catalogue ([`crate::lgx::html::EventModifier`]).
107    Handler {
108        event: Symbol,
109        modifiers: Vec<crate::lgx::html::EventModifier>,
110        body: Block<'a>,
111        span: Span,
112    },
113    /// `raw <string>` — trusted markup inserted verbatim.
114    Raw { html: &'a Expr<'a>, span: Span },
115    /// `{inspect v:} {when Pat:} … {end}` — a pattern match whose arms are subtrees.
116    Inspect {
117        scrutinee: &'a Expr<'a>,
118        arms: Vec<ViewArm<'a>>,
119        span: Span,
120    },
121    /// `{let x be e}` — a template-local binding, in scope for the following siblings.
122    Let {
123        var: Symbol,
124        value: &'a Expr<'a>,
125        span: Span,
126    },
127    /// `{extends Layout}` — this view fills the named layout's blocks.
128    Extends { layout: Symbol, span: Span },
129    /// `{block name:} … {end}` (with a default) or `{block name}` (a slot to fill).
130    Block {
131        name: Symbol,
132        default: Option<Vec<ViewNode<'a>>>,
133        span: Span,
134    },
135    /// `{children}` — where a component's caller-supplied children render.
136    Children { span: Span },
137}
138
139impl<'a> ViewNode<'a> {
140    /// Where this node was written.
141    ///
142    /// ★ EVERY VARIANT ALREADY CARRIED A `span` AND NOTHING COULD ASK FOR IT. The browser-tier
143    /// refusals in `lgx_lower` all walk this tree, and each one destructured the node with `..` —
144    /// so a page refused because of ONE element reported at `Span::default()`, which renders as
145    /// the top of the file. The author was told their page could not be lowered and left to find
146    /// which of forty nodes did it.
147    ///
148    /// TOTAL, deliberately: a new `ViewNode` variant does not compile until it says where it is.
149    pub fn span(&self) -> Span {
150        match self {
151            ViewNode::Element { span, .. }
152            | ViewNode::Text { span, .. }
153            | ViewNode::Component { span, .. }
154            | ViewNode::Each { span, .. }
155            | ViewNode::If { span, .. }
156            | ViewNode::Handler { span, .. }
157            | ViewNode::Raw { span, .. }
158            | ViewNode::Inspect { span, .. }
159            | ViewNode::Let { span, .. }
160            | ViewNode::Extends { span, .. }
161            | ViewNode::Block { span, .. }
162            | ViewNode::Children { span } => *span,
163        }
164    }
165}
166
167impl<'a> ViewNode<'a> {
168    /// The construct as an author writes it, for a message that names what it is about.
169    pub fn construct(&self) -> &'static str {
170        match self {
171            ViewNode::Element { .. } => "an element",
172            ViewNode::Text { .. } => "a text hole",
173            ViewNode::Component { .. } => "a placed component",
174            ViewNode::Each { .. } => "`{for each}`",
175            ViewNode::If { .. } => "`{if}`",
176            ViewNode::Handler { .. } => "an `on-<event>` handler",
177            ViewNode::Raw { .. } => "`{raw}`",
178            ViewNode::Inspect { .. } => "`{inspect}`",
179            ViewNode::Let { .. } => "`{let}`",
180            ViewNode::Extends { .. } => "`{extends}`",
181            ViewNode::Block { .. } => "`{block}`",
182            ViewNode::Children { .. } => "`{children}`",
183        }
184    }
185}
186
187/// One `{when Pattern:}` arm of a view `{inspect …:}`.
188#[derive(Debug, Clone)]
189pub struct ViewArm<'a> {
190    /// The variant name (`Ok`, `JObj`), or `None` for `Otherwise`.
191    pub variant: Option<Symbol>,
192    /// `(field, binding)` pairs the pattern binds.
193    pub bindings: Vec<(Symbol, Symbol)>,
194    pub body: Vec<ViewNode<'a>>,
195}
196
197/// One attribute-position item of an element, in source order.
198#[derive(Debug, Clone)]
199pub enum Attr<'a> {
200    /// `name=<expr>` — a plain (possibly dynamic) attribute.
201    Plain { name: Symbol, value: &'a Expr<'a> },
202    /// `bound to <field>` / `bind={field}` — two-way form binding.
203    Bind { field: Symbol },
204    /// `class:name={condition}` — the class is present exactly while the condition holds.
205    Class { name: Symbol, cond: &'a Expr<'a> },
206    /// A declared `## Directive` used as an attribute (`tooltip={text}`).
207    Directive { name: Symbol, value: &'a Expr<'a> },
208}
209
210/// What a `bind={field}` on an element actually WRITES — the one place the whole language
211/// decides, so no two walks can disagree about a checkbox.
212///
213/// An `<input type="text">` holds its state in `value`, a string. An `<input type="checkbox">`
214/// (or `type="radio"`) holds its state in `checked`, a PRESENCE — and not in the `checked`
215/// ATTRIBUTE either, which is `defaultChecked`. Writing a checkbox's state as a value renders
216/// `value="true"` and draws the box UNCHECKED while the page believes it is checked, which is
217/// worse than not supporting `bind` on a checkbox at all: the author has been told their
218/// binding was understood.
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum BoundInput {
221    /// `value` — a text/number/date input, a `<textarea>`, a `<select>`.
222    Value,
223    /// `checked`, and a click FLIPS it — a checkbox.
224    Checkbox,
225    /// `checked`, and a click SELECTS it — a radio. Split from [`BoundInput::Checkbox`] because
226    /// the two differ exactly where it matters: clicking a chosen radio leaves it chosen, and a
227    /// lowering that flipped it would let a second click deselect it, which no radio group has
228    /// ever done.
229    Radio,
230    /// The element's `type` is computed, so which of the two this is cannot be known until the
231    /// page runs. Refused rather than guessed: guessing `Value` is the silent wrong answer above.
232    Unknown,
233    /// `<input type="file">`. A browser **will not let a page set a file input's value** — a
234    /// deliberate restriction, because a page that could would be a page that can read any file on
235    /// your disk by naming it. So a `bind` here writes nothing, silently, forever.
236    File,
237    /// `<select multiple>`. Its value is the LIST of chosen options, so writing one text into it
238    /// selects at most one and reading it back returns at most one — a visitor's other selections
239    /// vanish with no error anywhere.
240    SelectMultiple,
241}
242
243impl BoundInput {
244    /// Whether this binding writes the `checked` PROPERTY rather than `value`.
245    pub fn writes_checked(self) -> bool {
246        matches!(self, BoundInput::Checkbox | BoundInput::Radio)
247    }
248
249    /// Why a [`BoundInput::Unknown`] binding is refused — held here, in ONE place, so the checker
250    /// and the lowering refuse it in the same words. Two refusals of the same thing in different
251    /// words send whoever reads one message searching for the other.
252    pub const UNKNOWN_REFUSAL: &'static str =
253        "a `bind` sits on an element whose `type` is computed, so whether it binds a value or a \
254         checkbox's checked state cannot be known until the page runs. Write the type literally \
255         (`type=\"checkbox\"` or `type=\"text\"`), or use two elements";
256
257    /// Why this binding cannot be lowered, or `None` when it can.
258    ///
259    /// ★ TOTAL, AND THAT IS THE POINT. A new variant does not compile until somebody decides
260    /// whether it lowers or is refused BY NAME — silently absent is not one of the options, which
261    /// is the discipline `action_verb_tier_lock` holds over every `UiStmt`. Two of these arms exist
262    /// because the question used to be asked of the ATTRIBUTES ALONE: with no tag to look at, a
263    /// file input and a multiple select both fell through to `Value` and got a text write that
264    /// does nothing, while the author was told their binding was understood.
265    pub fn refusal(self) -> Option<&'static str> {
266        match self {
267            BoundInput::Value | BoundInput::Checkbox | BoundInput::Radio => None,
268            BoundInput::Unknown => Some(Self::UNKNOWN_REFUSAL),
269            BoundInput::File => Some(
270                "a `bind` sits on `<input type=\"file\">`, and a browser will not let a page set a \
271                 file input's value — a page that could would be a page that can read any file on \
272                 the visitor's disk by naming it. So the binding would do nothing at all. Read the \
273                 chosen file in a `When chosen:` handler instead",
274            ),
275            BoundInput::SelectMultiple => Some(
276                "a `bind` sits on a `<select multiple>`, whose value is the LIST of chosen options \
277                 rather than one text. Binding it to a single value would select at most one and \
278                 silently drop the visitor's other choices. Drop `multiple` if one choice is what \
279                 you meant, or read the selection in a `When chosen:` handler",
280            ),
281        }
282    }
283}
284
285/// Decide, from an element's TAG and attributes, what a `bind` on it writes. See [`BoundInput`].
286///
287/// ★ THE TAG IS NOT DECORATION HERE. Asking the attributes alone cannot tell a `<select multiple>`
288/// from a `<textarea>` — neither carries a `type` — so both answered `Value`, and one of those
289/// answers was wrong in a way nothing reported.
290pub fn bound_input_kind(tag: Symbol, attrs: &[Attr<'_>], interner: &Interner) -> BoundInput {
291    let has = |want: &str| {
292        attrs
293            .iter()
294            .any(|a| matches!(a, Attr::Plain { name, .. } if interner.resolve(*name) == want))
295    };
296    if interner.resolve(tag) == "select" {
297        // `multiple` is an HTML boolean attribute: its PRESENCE is its state.
298        return if has("multiple") {
299            BoundInput::SelectMultiple
300        } else {
301            BoundInput::Value
302        };
303    }
304    for attr in attrs {
305        let Attr::Plain { name, value } = attr else {
306            continue;
307        };
308        if interner.resolve(*name) != "type" {
309            continue;
310        }
311        return match value {
312            Expr::Literal(Literal::Text(t)) => match interner.resolve(*t) {
313                "checkbox" => BoundInput::Checkbox,
314                "radio" => BoundInput::Radio,
315                "file" => BoundInput::File,
316                _ => BoundInput::Value,
317            },
318            _ => BoundInput::Unknown,
319        };
320    }
321    // No `type` at all is a text input, which is what HTML itself says.
322    BoundInput::Value
323}
324
325impl<'a> ViewDef<'a> {
326    /// Every expression and every statement block the tree references (holes, attribute
327    /// values, conditions, iterables, keys, handler bodies) — the traversal surface for
328    /// depth bounds, scope analysis and tooling walks.
329    pub fn exprs_and_blocks(&self) -> (Vec<&'a Expr<'a>>, Vec<Block<'a>>) {
330        let mut exprs = Vec::new();
331        let mut blocks = Vec::new();
332        if let ViewRoot::Into(e) = self.root {
333            exprs.push(e);
334        }
335        fn go<'a>(
336            nodes: &[ViewNode<'a>],
337            exprs: &mut Vec<&'a Expr<'a>>,
338            blocks: &mut Vec<Block<'a>>,
339        ) {
340            for n in nodes {
341                match n {
342                    ViewNode::Element {
343                        attrs, children, ..
344                    } => {
345                        for a in attrs {
346                            match a {
347                                Attr::Plain { value, .. } | Attr::Directive { value, .. } => {
348                                    exprs.push(value)
349                                }
350                                Attr::Class { cond, .. } => exprs.push(cond),
351                                Attr::Bind { .. } => {}
352                            }
353                        }
354                        go(children, exprs, blocks);
355                    }
356                    ViewNode::Text { text, .. } => exprs.push(text),
357                    ViewNode::Component {
358                        props, children, ..
359                    } => {
360                        for (_, v) in props {
361                            exprs.push(v);
362                        }
363                        go(children, exprs, blocks);
364                    }
365                    ViewNode::Each {
366                        iterable,
367                        key,
368                        body,
369                        empty,
370                        ..
371                    } => {
372                        exprs.push(iterable);
373                        if let Some(k) = key {
374                            exprs.push(k);
375                        }
376                        go(body, exprs, blocks);
377                        go(empty, exprs, blocks);
378                    }
379                    ViewNode::If {
380                        arms, otherwise, ..
381                    } => {
382                        for (c, b) in arms {
383                            exprs.push(c);
384                            go(b, exprs, blocks);
385                        }
386                        if let Some(o) = otherwise {
387                            go(o, exprs, blocks);
388                        }
389                    }
390                    ViewNode::Handler { body, .. } => blocks.push(body),
391                    ViewNode::Raw { html, .. } => exprs.push(html),
392                    ViewNode::Extends { .. } | ViewNode::Children { .. } => {}
393                    ViewNode::Inspect {
394                        scrutinee, arms, ..
395                    } => {
396                        exprs.push(scrutinee);
397                        for a in arms {
398                            go(&a.body, exprs, blocks);
399                        }
400                    }
401                    ViewNode::Let { value, .. } => exprs.push(value),
402                    ViewNode::Block { default, .. } => {
403                        if let Some(d) = default {
404                            go(d, exprs, blocks);
405                        }
406                    }
407                }
408            }
409        }
410        go(&self.nodes, &mut exprs, &mut blocks);
411        (exprs, blocks)
412    }
413
414    /// A compact, deterministic one-line rendering of the tree — the snapshot surface
415    /// `view_ir_lock` pins and `largo expand` prints.
416    pub fn shape(&self, interner: &Interner) -> String {
417        let head = match (&self.component, &self.root) {
418            (Some(c), _) => format!("view of {}", interner.resolve(*c)),
419            (None, ViewRoot::Body) => "render into body".to_string(),
420            (None, ViewRoot::Into(e)) => format!("render into {}", expr_shape(e, interner)),
421            (None, ViewRoot::ComponentParent) => "view".to_string(),
422            (None, ViewRoot::Head) => "head".to_string(),
423        };
424        format!("{head}{}", braces(&self.nodes, interner))
425    }
426
427    /// Just the node tree, without the `view of <Name>` head — what a component's own
428    /// shape embeds.
429    pub fn body_shape(&self, interner: &Interner) -> String {
430        braces(&self.nodes, interner)
431    }
432}
433
434fn braces(nodes: &[ViewNode<'_>], interner: &Interner) -> String {
435    if nodes.is_empty() {
436        return String::new();
437    }
438    let inner: Vec<String> = nodes.iter().map(|n| node_shape(n, interner)).collect();
439    format!("{{{}}}", inner.join(", "))
440}
441
442fn node_shape(node: &ViewNode<'_>, interner: &Interner) -> String {
443    match node {
444        ViewNode::Element {
445            tag,
446            attrs,
447            children,
448            ..
449        } => {
450            let attrs_s = if attrs.is_empty() {
451                String::new()
452            } else {
453                let items: Vec<String> = attrs
454                    .iter()
455                    .map(|a| match a {
456                        Attr::Plain { name, value } => format!(
457                            "{}={}",
458                            interner.resolve(*name),
459                            expr_shape(value, interner)
460                        ),
461                        Attr::Bind { field } => format!("bind={}", interner.resolve(*field)),
462                        Attr::Class { name, cond } => format!(
463                            "class:{}={}",
464                            interner.resolve(*name),
465                            expr_shape(cond, interner)
466                        ),
467                        Attr::Directive { name, value } => format!(
468                            "{}!={}",
469                            interner.resolve(*name),
470                            expr_shape(value, interner)
471                        ),
472                    })
473                    .collect();
474                format!("[{}]", items.join(", "))
475            };
476            format!(
477                "{}{attrs_s}{}",
478                interner.resolve(*tag),
479                braces(children, interner)
480            )
481        }
482        ViewNode::Text { text, .. } => expr_shape(text, interner),
483        ViewNode::Component {
484            name,
485            props,
486            children,
487            ..
488        } => {
489            let props_s: Vec<String> = props
490                .iter()
491                .map(|(k, v)| format!(" {}={}", interner.resolve(*k), expr_shape(v, interner)))
492                .collect();
493            format!(
494                "<{}{}>{}",
495                interner.resolve(*name),
496                props_s.join(""),
497                braces(children, interner)
498            )
499        }
500        ViewNode::Each {
501            pattern,
502            iterable,
503            key,
504            index,
505            body,
506            empty,
507            ..
508        } => {
509            let pat = match pattern {
510                Pattern::Identifier(s) => interner.resolve(*s).to_string(),
511                Pattern::Tuple(items) => {
512                    let names: Vec<&str> = items.iter().map(|s| interner.resolve(*s)).collect();
513                    format!("({})", names.join(", "))
514                }
515            };
516            let key_s = key
517                .map(|k| format!(" keyed by {}", expr_shape(k, interner)))
518                .unwrap_or_default();
519            let index_s = index
520                .map(|i| format!(" at index {}", interner.resolve(i)))
521                .unwrap_or_default();
522            let empty_s = if empty.is_empty() {
523                String::new()
524            } else {
525                format!(" else{}", braces(empty, interner))
526            };
527            format!(
528                "each {pat} in {}{key_s}{index_s}{}{empty_s}",
529                expr_shape(iterable, interner),
530                braces(body, interner)
531            )
532        }
533        ViewNode::If {
534            arms, otherwise, ..
535        } => {
536            let mut s = String::new();
537            for (i, (cond, body)) in arms.iter().enumerate() {
538                if i > 0 {
539                    s.push(' ');
540                }
541                s.push_str(&format!(
542                    "when {}{}",
543                    expr_shape(cond, interner),
544                    braces(body, interner)
545                ));
546            }
547            if let Some(o) = otherwise {
548                s.push_str(&format!(" otherwise{}", braces(o, interner)));
549            }
550            s
551        }
552        ViewNode::Handler {
553            event,
554            modifiers,
555            body,
556            ..
557        } => {
558            let mods: String = modifiers.iter().map(|m| format!(".{}", m.word())).collect();
559            format!("on:{}{mods}{{{}}}", interner.resolve(*event), body.len())
560        }
561        ViewNode::Raw { html, .. } => format!("raw({})", expr_shape(html, interner)),
562        ViewNode::Inspect {
563            scrutinee, arms, ..
564        } => {
565            let a: Vec<String> = arms
566                .iter()
567                .map(|arm| {
568                    let head = match arm.variant {
569                        None => "otherwise".to_string(),
570                        Some(v) => {
571                            let bs: Vec<&str> = arm
572                                .bindings
573                                .iter()
574                                .map(|(_, b)| interner.resolve(*b))
575                                .collect();
576                            if bs.is_empty() {
577                                interner.resolve(v).to_string()
578                            } else {
579                                format!("{} ({})", interner.resolve(v), bs.join(", "))
580                            }
581                        }
582                    };
583                    format!("when {head}{}", braces(&arm.body, interner))
584                })
585                .collect();
586            format!(
587                "inspect {}{{{}}}",
588                expr_shape(scrutinee, interner),
589                a.join(" ")
590            )
591        }
592        ViewNode::Let { var, value, .. } => format!(
593            "let {} = {}",
594            interner.resolve(*var),
595            expr_shape(value, interner)
596        ),
597        ViewNode::Extends { layout, .. } => format!("extends {}", interner.resolve(*layout)),
598        ViewNode::Block { name, default, .. } => match default {
599            None => format!("block {}", interner.resolve(*name)),
600            Some(d) => format!("block {}{}", interner.resolve(*name), braces(d, interner)),
601        },
602        ViewNode::Children { .. } => "children".to_string(),
603    }
604}
605
606/// A compact expression rendering for shapes: literals verbatim, identifiers by name,
607/// calls as `f(args)`, field access as `a's b`, interpolation with `{…}` holes; anything
608/// else prints as `_`.
609pub fn expr_shape(e: &Expr<'_>, interner: &Interner) -> String {
610    match e {
611        Expr::Literal(Literal::Text(s)) => format!("{:?}", interner.resolve(*s)),
612        Expr::Literal(Literal::Number(n)) => n.to_string(),
613        Expr::Literal(Literal::Float(f)) => f.to_string(),
614        Expr::Literal(Literal::Boolean(b)) => b.to_string(),
615        Expr::Literal(Literal::Nothing) => "nothing".to_string(),
616        Expr::Literal(Literal::Char(c)) => format!("{c:?}"),
617        // Temporal literals keep their unit visible: `60 seconds` is a Duration, not a number.
618        Expr::Literal(Literal::Duration(ms)) => format!("{ms}ms"),
619        Expr::Literal(Literal::Date(d)) => format!("date {d}"),
620        Expr::Literal(Literal::Moment(m)) => format!("moment {m}"),
621        Expr::Literal(Literal::Time(t)) => format!("time {t}"),
622        Expr::Literal(Literal::Span { months, days }) => format!("span {months}m{days}d"),
623        Expr::Identifier(s) => interner.resolve(*s).to_string(),
624        Expr::Call { function, args } => {
625            let a: Vec<String> = args.iter().map(|x| expr_shape(x, interner)).collect();
626            format!("{}({})", interner.resolve(*function), a.join(", "))
627        }
628        Expr::FieldAccess { object, field } => format!(
629            "{}'s {}",
630            expr_shape(object, interner),
631            interner.resolve(*field)
632        ),
633        Expr::BinaryOp { op, left, right } => {
634            format!(
635                "({} {:?} {})",
636                expr_shape(left, interner),
637                op,
638                expr_shape(right, interner)
639            )
640        }
641        Expr::List(items) => {
642            let a: Vec<String> = items.iter().map(|x| expr_shape(x, interner)).collect();
643            format!("[{}]", a.join(", "))
644        }
645        Expr::New { type_name, .. } => format!("new {}", interner.resolve(*type_name)),
646        Expr::Length { collection } => format!("length of {}", expr_shape(collection, interner)),
647        Expr::InterpolatedString(parts) => {
648            let mut s = String::from("\"");
649            for p in parts {
650                match p {
651                    StringPart::Literal(t) => s.push_str(interner.resolve(*t)),
652                    StringPart::Expr { value, .. } => {
653                        s.push('{');
654                        s.push_str(&expr_shape(value, interner));
655                        s.push('}');
656                    }
657                }
658            }
659            s.push('"');
660            s
661        }
662        _ => "_".to_string(),
663    }
664}
665
666/// The literal segments of [`duplicate_keyed_loop_message`], PUBLIC so a compiled tier's own
667/// lowering of `Each` can build the identical text out of runtime `Expr`s (string concatenation,
668/// `loop_desc` baked in as a literal since it is known at lowering time; `key`/the two positions
669/// are not) without a second, hand-copied version of the wording that could drift from this one.
670pub mod duplicate_keyed_loop_message_parts {
671    pub const PREFIX: &str = "the loop over ";
672    pub const AFTER_LOOP_DESC: &str = " keys item ";
673    pub const AFTER_FIRST_POSITION: &str = " and item ";
674    pub const AFTER_SECOND_POSITION: &str = " both \"";
675    pub const SUFFIX: &str = "\" — a `keyed by` loop's keys must be unique among its own \
676         siblings; give the duplicate its own key, or drop `keyed by` if position alone already \
677         identifies each row here.";
678}
679
680/// The message a `keyed by` loop whose siblings share a key fails with — the SAME wording on every
681/// tier, because the lowering of `Each` builds it at run time from the segments this function
682/// formats ([`duplicate_keyed_loop_message_parts`]). `loop_desc` names what is being iterated
683/// (`expr_shape` on the loop's own `iterable`, e.g. `"todos"`); `key` is the colliding key's own
684/// display value (its evaluated VALUE, not source text — two items collide on what a key IS, at
685/// runtime, never on how it was spelled).
686///
687/// `first_position`/`second_position` are 1-BASED — every position an author sees is (Tristen's
688/// decision), matching `at index`, which already counts from [`LOOP_INDEX_BASE`], never from 0.
689/// `Each.index` being 0-based is this crate's own implementation detail; a caller converts before
690/// calling (`i + 1`), so a raw internal index can never reach this message un-shifted.
691pub fn duplicate_keyed_loop_message(
692    loop_desc: &str,
693    key: &str,
694    first_position: usize,
695    second_position: usize,
696) -> String {
697    use duplicate_keyed_loop_message_parts as p;
698    format!(
699        "{}{loop_desc}{}{first_position}{}{second_position}{}{key}{}",
700        p::PREFIX,
701        p::AFTER_LOOP_DESC,
702        p::AFTER_FIRST_POSITION,
703        p::AFTER_SECOND_POSITION,
704        p::SUFFIX,
705    )
706}
707
708#[cfg(test)]
709#[path = "../../../logicaffeine_tests/unit/logicaffeine_language/ast/view/duplicate_keyed_loop_message_tests.rs"]
710mod duplicate_keyed_loop_message_tests;