Skip to main content

lumen_ir/
interpolate.rs

1//! Resolving the `{...}` placeholders a template carries.
2//!
3//! A `<for>` body and a fragment body are written with placeholders standing
4//! in for what the iteration or the instantiation brings: `{row.name}`,
5//! `{$index}`, `{$theme}`. Turning a template into an instance means walking
6//! the subtree and replacing each of them with the value its scope holds.
7//!
8//! This lives here, below both consumers, because the desktop reconciler and
9//! the web emitter write the same text into the same node a tick apart. Two
10//! resolvers would drift, and a drift shows up as a page the browser rebuilds
11//! on load rather than adopts.
12//!
13//! A placeholder whose scope is absent is left in the string verbatim, so the
14//! walk that owns that scope resolves it later: a fragment walk resolves its
15//! arguments and leaves `{row.name}` standing for the `<for>` walk inside it.
16
17use std::collections::BTreeMap;
18
19use lumen_core::property_store::PropertyStore;
20use lumen_core::signals::ArrayItem;
21
22use crate::layout_ir::{Attributes, Element, InterpolationSlot};
23
24/// Where a global placeholder reads its value.
25///
26/// The desktop reads the property store; the emitter reads the state a page
27/// is rendered with. Both answer the same question, and the resolver asks it
28/// the same way.
29pub trait Globals {
30    /// The value of the global signal `name`, or `None` when nothing has set
31    /// it.
32    fn global(&self, name: &str) -> Option<String>;
33}
34
35impl Globals for PropertyStore {
36    fn global(&self, name: &str) -> Option<String> {
37        self.get_global_str(name).map(|value| value.to_string())
38    }
39}
40
41/// One iteration of a `<for>` body: the record its placeholders read and the
42/// index they count from.
43pub struct Row<'a> {
44    /// The current iteration's row record, which
45    /// [`InterpolationSlot::Row`] looks a field up in.
46    pub item: &'a ArrayItem,
47    /// 0-based iteration index, which [`InterpolationSlot::RowIndex`]
48    /// resolves to.
49    pub index: usize,
50}
51
52/// The scopes a placeholder can resolve against.
53///
54/// Each caller brings the scopes it has: a `<for>` row brings [`Self::row`],
55/// a fragment instantiation brings [`Self::args`], and a `<for>` inside a
56/// fragment body meets both in turn.
57pub struct Scope<'a> {
58    /// The iteration in progress, absent outside a `<for>` body.
59    pub row: Option<Row<'a>>,
60    /// The arguments a fragment instance was built with, already folded over
61    /// the declared defaults. Absent outside a fragment body.
62    pub args: Option<&'a BTreeMap<String, String>>,
63    /// Where a global placeholder reads its value.
64    pub globals: &'a dyn Globals,
65    /// Told the name of a row field the iteration record does not have.
66    /// Absent when the caller has nowhere to report it.
67    pub missing_row_field: Option<&'a dyn Fn(&str)>,
68}
69
70impl<'a> Scope<'a> {
71    /// A scope with globals alone: no iteration, no arguments, nothing to
72    /// report to.
73    pub fn new(globals: &'a dyn Globals) -> Self {
74        Self {
75            row: None,
76            args: None,
77            globals,
78            missing_row_field: None,
79        }
80    }
81
82    /// The same scope, iterating `item` at `index`.
83    pub fn with_row(mut self, item: &'a ArrayItem, index: usize) -> Self {
84        self.row = Some(Row { item, index });
85        self
86    }
87
88    /// The same scope, inside a fragment instantiated with `args`.
89    pub fn with_args(mut self, args: &'a BTreeMap<String, String>) -> Self {
90        self.args = Some(args);
91        self
92    }
93
94    /// The same scope, reporting a row field the record does not have.
95    pub fn reporting_to(mut self, report: &'a dyn Fn(&str)) -> Self {
96        self.missing_row_field = Some(report);
97        self
98    }
99}
100
101/// Walk a template subtree, resolving every placeholder in its string-valued
102/// attributes and its text against `scope`.
103///
104/// The result is one instance of the template. Nothing else about the element
105/// changes: a caller that needs the CSS cascade re-run over the instance runs
106/// it afterwards, because what a rule matches depends on the `id` and `class`
107/// values that landed here.
108pub fn substitute_element(template: &Element, scope: &Scope<'_>) -> Element {
109    let mut instance = template.clone();
110    substitute_in_place(&mut instance, scope);
111    instance.children = instance
112        .children
113        .iter()
114        .map(|child| substitute_element(child, scope))
115        .collect();
116    instance
117}
118
119/// Resolve every placeholder in one element's own strings, leaving its
120/// children alone.
121fn substitute_in_place(element: &mut Element, scope: &Scope<'_>) {
122    if let Some(attrs) = substitute_attrs(&element.attrs, &element.interpolations, scope) {
123        element.attrs = attrs;
124    }
125    // A component's props are written the way its text is, so a use site
126    // inside a `<for>` body names the row's fields and is called with the
127    // row's values. Resolved here rather than at the call, because the call
128    // is handed strings and has no scope left to read them against.
129    if let Some(use_site) = &mut element.frag_use {
130        for (_, value) in &mut use_site.args {
131            if value.contains('{') {
132                *value = resolve(value, &element.interpolations, scope);
133            }
134        }
135    }
136}
137
138/// The attributes to build an element with, once the placeholders in its own
139/// strings are resolved against `scope`.
140///
141/// `None` when nothing changed: an element with no placeholder in it, or one
142/// whose placeholders the scope has no value for. The caller then builds the
143/// element from the attributes it already has, and nothing is copied.
144///
145/// Children belong to whoever is walking the tree. [`substitute_element`]
146/// walks one subtree with a scope that does not change under it; a caller
147/// that carries its own walk, such as the web emitter or the spawner, calls
148/// this at each element it reaches.
149pub fn substitute_attrs(
150    attrs: &Attributes,
151    slots: &[InterpolationSlot],
152    scope: &Scope<'_>,
153) -> Option<Attributes> {
154    if !carries_placeholder(attrs) {
155        return None;
156    }
157    let mut out = attrs.clone();
158    let mut changed = false;
159    let mut substitute = |text: &mut String| {
160        if !text.contains('{') {
161            return;
162        }
163        let resolved = resolve(text, slots, scope);
164        changed |= resolved != *text;
165        *text = resolved;
166    };
167    for text in [
168        out.text.as_mut(),
169        out.id.as_mut(),
170        out.src.as_mut(),
171        out.style_role.as_mut(),
172        out.placeholder.as_mut(),
173        out.drag_payload.as_mut(),
174    ]
175    .into_iter()
176    .flatten()
177    .chain(out.classes.iter_mut())
178    {
179        substitute(text);
180    }
181    changed.then_some(out)
182}
183
184/// Whether any string [`substitute_attrs`] resolves carries a `{`.
185///
186/// The two walk the same strings, so a field added to one belongs in the
187/// other; this is what keeps an element with no placeholder from being
188/// copied at all.
189pub fn carries_placeholder(attrs: &Attributes) -> bool {
190    [
191        attrs.text.as_ref(),
192        attrs.id.as_ref(),
193        attrs.src.as_ref(),
194        attrs.style_role.as_ref(),
195        attrs.placeholder.as_ref(),
196        attrs.drag_payload.as_ref(),
197    ]
198    .into_iter()
199    .flatten()
200    .chain(attrs.classes.iter())
201    .any(|text| text.contains('{'))
202}
203
204/// Whether anything a substitution resolves on `element` carries a `{`: its
205/// own strings, and the arguments a use site passes.
206///
207/// A walk that skips an element with no placeholder in it asks this rather
208/// than [`carries_placeholder`], so a component element whose only
209/// placeholder is a prop is not skipped.
210pub fn element_carries_placeholder(element: &Element) -> bool {
211    carries_placeholder(&element.attrs)
212        || element
213            .frag_use
214            .as_ref()
215            .is_some_and(|use_site| use_site.args.iter().any(|(_, value)| value.contains('{')))
216}
217
218/// Replace every `{...}` token in `text` with what its scope holds.
219///
220/// `slots` is the placeholder catalog the parser recorded on the element the
221/// string came from, which is what tells a fragment parameter apart from a
222/// global signal of the same name.
223///
224/// The rules:
225///
226/// - [`InterpolationSlot::Row`] - the iteration record's field. A field the
227///   record does not have resolves empty and is reported through
228///   [`Scope::missing_row_field`]. With no row scope the placeholder is left
229///   standing for the `<for>` walk that has one.
230/// - [`InterpolationSlot::RowIndex`] - the iteration index, or left standing
231///   outside a row scope.
232/// - [`InterpolationSlot::Arg`] - the instantiating argument, else empty. A
233///   `Global` naming a declared parameter resolves the same way, which is how
234///   an argument shadows a global signal of that name.
235/// - [`InterpolationSlot::Global`] - the global signal, or the iteration
236///   record's field of that name when no signal is set, which is what makes
237///   the bare `{field}` spelling work inside a `<for>` body.
238/// - [`InterpolationSlot::SelfField`] / [`InterpolationSlot::ParentField`] -
239///   empty. Per-entity properties have no consumer yet.
240pub fn resolve(text: &str, slots: &[InterpolationSlot], scope: &Scope<'_>) -> String {
241    let mut out = String::with_capacity(text.len());
242    let mut i = 0;
243    while i < text.len() {
244        let Some(rel) = text[i..].find('{') else {
245            out.push_str(&text[i..]);
246            break;
247        };
248        let open = i + rel;
249        out.push_str(&text[i..open]);
250        let Some(end) = text[open..].find('}') else {
251            out.push_str(&text[open..]);
252            break;
253        };
254        let close = open + end;
255        let inner = &text[open + 1..close];
256        let trimmed = inner.trim();
257        let slot = claim_arg(InterpolationSlot::from(trimmed), slots, scope);
258        let resolved = match &slot {
259            InterpolationSlot::Row(field) => scope.row.as_ref().map(|row| {
260                row.item.get(field).cloned().unwrap_or_else(|| {
261                    if let Some(report) = scope.missing_row_field {
262                        report(field);
263                    }
264                    String::new()
265                })
266            }),
267            InterpolationSlot::RowIndex => scope.row.as_ref().map(|row| row.index.to_string()),
268            InterpolationSlot::Arg(name) => scope
269                .args
270                .map(|args| args.get(name).cloned().unwrap_or_default()),
271            InterpolationSlot::Global(name) => scope.globals.global(name),
272            InterpolationSlot::SelfField(field) | InterpolationSlot::ParentField(field) => {
273                tracing::debug!(
274                    target: "lumen_ir::interpolate",
275                    "$self / $parent field `{field}` substituted as empty - \
276                     per-entity properties have no consumer yet"
277                );
278                Some(String::new())
279            }
280        };
281        // A global with no signal set falls through to the iteration record,
282        // which is what the bare `{field}` spelling inside a `<for>` body
283        // means. The scopes that consulted the record already, and the
284        // argument scope that never should, do not fall through.
285        let value = resolved.or_else(|| {
286            if matches!(
287                slot,
288                InterpolationSlot::RowIndex | InterpolationSlot::Row(_) | InterpolationSlot::Arg(_)
289            ) {
290                return None;
291            }
292            scope
293                .row
294                .as_ref()
295                .and_then(|row| row.item.get(trimmed).cloned())
296        });
297        match value {
298            Some(value) => out.push_str(&value),
299            // Nothing resolved it: keep it verbatim so an authoring typo
300            // surfaces as the literal `{x}` rather than as an empty string.
301            None => {
302                out.push('{');
303                out.push_str(inner);
304                out.push('}');
305            }
306        }
307        i = close + 1;
308    }
309    out
310}
311
312/// Re-read a placeholder as a fragment argument when the surrounding fragment
313/// scope claims its name.
314///
315/// A parameter reference is spelled exactly like a global signal reference,
316/// so the classifier that sees only the token always says `Global`. Two things
317/// separate them, and either is enough: the compiler records an
318/// [`InterpolationSlot::Arg`] on the element when it knows the enclosing
319/// parameter list, and the instance's argument map holds every declared
320/// parameter.
321fn claim_arg(
322    slot: InterpolationSlot,
323    slots: &[InterpolationSlot],
324    scope: &Scope<'_>,
325) -> InterpolationSlot {
326    let InterpolationSlot::Global(name) = &slot else {
327        return slot;
328    };
329    let claimed = slots.contains(&InterpolationSlot::Arg(name.clone()))
330        || scope.args.is_some_and(|args| args.contains_key(name));
331    if claimed {
332        return InterpolationSlot::Arg(name.clone());
333    }
334    slot
335}
336
337#[cfg(test)]
338mod tests {
339    //! Every case the reconciler's own resolver was checked against before
340    //! this moved here, so the text a page is emitted with and the text the
341    //! browser writes a tick later come from the same rules.
342    //!
343    //! One scope, two sources. A `<for>` row brings the row record, a
344    //! fragment instance brings the arguments, and a `<for>` inside a
345    //! fragment body meets both: each walk resolves what its own scope knows
346    //! and leaves the rest of the placeholders standing.
347    use std::cell::RefCell;
348
349    use lumen_core::property_store::PropertyStore;
350
351    use super::*;
352    use crate::layout_ir::{Attributes, Element};
353
354    fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
355        pairs
356            .iter()
357            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
358            .collect()
359    }
360
361    fn item(pairs: &[(&str, &str)]) -> ArrayItem {
362        pairs
363            .iter()
364            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
365            .collect()
366    }
367
368    fn resolved(
369        body: &str,
370        slots: &[InterpolationSlot],
371        row: Option<(&ArrayItem, usize)>,
372        args: Option<&BTreeMap<String, String>>,
373        store: &PropertyStore,
374    ) -> String {
375        let mut scope = Scope::new(store);
376        if let Some((item, index)) = row {
377            scope = scope.with_row(item, index);
378        }
379        if let Some(args) = args {
380            scope = scope.with_args(args);
381        }
382        resolve(body, slots, &scope)
383    }
384
385    /// A component written inside a `<for>` names a row field as a prop, and
386    /// the row walk resolves it the way it resolves the text beside it. The
387    /// call is handed strings, so a prop left with its braces would call the
388    /// same function with the same value for every row.
389    #[test]
390    fn a_use_site_argument_resolves_against_the_row() {
391        let store = PropertyStore::default();
392        let record = item(&[("title", "Alpha")]);
393        let mut element = Element {
394            tag: "Ticket".to_string(),
395            ..Element::default()
396        };
397        element.frag_use = Some(Box::new(crate::layout_ir::FragmentUse {
398            key: "Ticket".to_string(),
399            args: vec![("title".to_string(), "{title}".to_string())],
400            slot_children: false,
401        }));
402
403        let instance = substitute_element(&element, &Scope::new(&store).with_row(&record, 0));
404
405        let use_site = instance.frag_use.expect("the use site survives");
406        assert_eq!(use_site.args[0].1, "Alpha");
407    }
408
409    /// An element whose only placeholder is a prop is still worth copying:
410    /// the walk that skips an element with nothing to resolve asks this.
411    #[test]
412    fn an_element_carries_a_placeholder_through_its_arguments_alone() {
413        let mut element = Element {
414            tag: "Ticket".to_string(),
415            ..Element::default()
416        };
417        assert!(!element_carries_placeholder(&element));
418        element.frag_use = Some(Box::new(crate::layout_ir::FragmentUse {
419            key: "Ticket".to_string(),
420            args: vec![("title".to_string(), "{title}".to_string())],
421            slot_children: false,
422        }));
423        assert!(element_carries_placeholder(&element));
424    }
425
426    #[test]
427    fn an_argument_resolves_from_the_instance_binding() {
428        let store = PropertyStore::default();
429        let bound = args(&[("title", "Recent")]);
430        let out = resolved(
431            "{$title}!",
432            &[InterpolationSlot::Arg("title".to_string())],
433            None,
434            Some(&bound),
435            &store,
436        );
437        assert_eq!(out, "Recent!");
438    }
439
440    #[test]
441    fn an_argument_beats_a_global_of_the_same_name() {
442        let mut store = PropertyStore::default();
443        store.set_global_str("title", "from the signal");
444        let bound = args(&[("title", "from the argument")]);
445        let out = resolved("{$title}", &[], None, Some(&bound), &store);
446        assert_eq!(out, "from the argument");
447    }
448
449    #[test]
450    fn a_global_outside_the_parameter_list_still_reads_the_store() {
451        let mut store = PropertyStore::default();
452        store.set_global_str("theme", "dark");
453        let bound = args(&[("title", "Recent")]);
454        let out = resolved("{$theme}", &[], None, Some(&bound), &store);
455        assert_eq!(out, "dark");
456    }
457
458    #[test]
459    fn row_placeholders_survive_the_argument_walk() {
460        let store = PropertyStore::default();
461        let bound = args(&[("prefix", "Row")]);
462        let slots = [
463            InterpolationSlot::Arg("prefix".to_string()),
464            InterpolationSlot::Row("name".to_string()),
465        ];
466        let out = resolved(
467            "{$prefix}: {row.name} #{$index}",
468            &slots,
469            None,
470            Some(&bound),
471            &store,
472        );
473        assert_eq!(
474            out, "Row: {row.name} #{$index}",
475            "the walk with no row scope leaves the row placeholders for the reconciler"
476        );
477    }
478
479    #[test]
480    fn the_row_walk_finishes_what_the_argument_walk_left() {
481        let store = PropertyStore::default();
482        let row = item(&[("name", "alpha")]);
483        let slots = [
484            InterpolationSlot::Arg("prefix".to_string()),
485            InterpolationSlot::Row("name".to_string()),
486        ];
487        let out = resolved(
488            "Row: {row.name} #{$index}",
489            &slots,
490            Some((&row, 2)),
491            None,
492            &store,
493        );
494        assert_eq!(out, "Row: alpha #2");
495    }
496
497    #[test]
498    fn both_scopes_resolve_in_one_walk() {
499        let store = PropertyStore::default();
500        let bound = args(&[("prefix", "Row")]);
501        let row = item(&[("name", "alpha")]);
502        let slots = [
503            InterpolationSlot::Arg("prefix".to_string()),
504            InterpolationSlot::Row("name".to_string()),
505        ];
506        let out = resolved(
507            "{$prefix}: {row.name} #{$index}",
508            &slots,
509            Some((&row, 0)),
510            Some(&bound),
511            &store,
512        );
513        assert_eq!(out, "Row: alpha #0");
514    }
515
516    #[test]
517    fn a_parameter_nothing_bound_resolves_empty() {
518        let store = PropertyStore::default();
519        let bound = args(&[("title", "")]);
520        let out = resolved(
521            "[{$title}]",
522            &[InterpolationSlot::Arg("title".to_string())],
523            None,
524            Some(&bound),
525            &store,
526        );
527        assert_eq!(out, "[]");
528    }
529
530    #[test]
531    fn a_bare_name_reads_the_row_record_when_no_signal_holds_it() {
532        let store = PropertyStore::default();
533        let row = item(&[("name", "alpha")]);
534        let out = resolved("{name}", &[], Some((&row, 0)), None, &store);
535        assert_eq!(
536            out, "alpha",
537            "the legacy bare-field spelling still means the row's field"
538        );
539    }
540
541    #[test]
542    fn a_field_the_record_does_not_have_is_reported_and_resolves_empty() {
543        let store = PropertyStore::default();
544        let row = item(&[("name", "alpha")]);
545        let missing = RefCell::new(Vec::new());
546        let report = |field: &str| missing.borrow_mut().push(field.to_string());
547        let scope = Scope::new(&store).with_row(&row, 0).reporting_to(&report);
548        let out = resolve(
549            "[{row.title}]",
550            &[InterpolationSlot::Row("title".to_string())],
551            &scope,
552        );
553        assert_eq!(out, "[]");
554        assert_eq!(missing.into_inner(), vec!["title".to_string()]);
555    }
556
557    #[test]
558    fn a_placeholder_nothing_answers_stays_in_the_text() {
559        let store = PropertyStore::default();
560        let out = resolved("a {$absent} b {unclosed", &[], None, None, &store);
561        assert_eq!(
562            out, "a {$absent} b {unclosed",
563            "a global no signal holds is left standing, and a brace with no end is text"
564        );
565    }
566
567    #[test]
568    fn a_subtree_is_substituted_down_to_its_leaves() {
569        let store = PropertyStore::default();
570        let row = item(&[("name", "alpha")]);
571        let leaf = Element {
572            tag: "label".to_string(),
573            attrs: Attributes {
574                text: Some("{row.name} #{$index}".to_string()),
575                ..Attributes::default()
576            },
577            interpolations: vec![
578                InterpolationSlot::Row("name".to_string()),
579                InterpolationSlot::RowIndex,
580            ],
581            ..Element::default()
582        };
583        let template = Element {
584            tag: "row".to_string(),
585            attrs: Attributes {
586                classes: vec!["item-{$index}".to_string()],
587                ..Attributes::default()
588            },
589            children: vec![leaf],
590            interpolations: vec![InterpolationSlot::RowIndex],
591            ..Element::default()
592        };
593
594        let scope = Scope::new(&store).with_row(&row, 3);
595        let instance = substitute_element(&template, &scope);
596
597        assert_eq!(instance.attrs.classes, vec!["item-3".to_string()]);
598        assert_eq!(
599            instance.children[0].attrs.text.as_deref(),
600            Some("alpha #3"),
601            "a leaf several levels down is substituted too"
602        );
603        assert_eq!(
604            template.children[0].attrs.text.as_deref(),
605            Some("{row.name} #{$index}"),
606            "and the template it came from is untouched"
607        );
608    }
609}