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;