1use std::collections::BTreeMap;
18
19use lumen_core::property_store::PropertyStore;
20use lumen_core::signals::ArrayItem;
21
22use crate::layout_ir::{Attributes, Element, InterpolationSlot};
23
24pub trait Globals {
30 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
41pub struct Row<'a> {
44 pub item: &'a ArrayItem,
47 pub index: usize,
50}
51
52pub struct Scope<'a> {
58 pub row: Option<Row<'a>>,
60 pub args: Option<&'a BTreeMap<String, String>>,
63 pub globals: &'a dyn Globals,
65 pub missing_row_field: Option<&'a dyn Fn(&str)>,
68}
69
70impl<'a> Scope<'a> {
71 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 pub fn with_row(mut self, item: &'a ArrayItem, index: usize) -> Self {
84 self.row = Some(Row { item, index });
85 self
86 }
87
88 pub fn with_args(mut self, args: &'a BTreeMap<String, String>) -> Self {
90 self.args = Some(args);
91 self
92 }
93
94 pub fn reporting_to(mut self, report: &'a dyn Fn(&str)) -> Self {
96 self.missing_row_field = Some(report);
97 self
98 }
99}
100
101pub 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
119fn 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 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
138pub 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
184pub 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
204pub 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
218pub 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 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 None => {
302 out.push('{');
303 out.push_str(inner);
304 out.push('}');
305 }
306 }
307 i = close + 1;
308 }
309 out
310}
311
312fn 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 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 #[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 #[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}