sofie_engine/analysis/
ids.rs1use crate::Template;
2use crate::catalog::Content;
3use crate::render::parts_of;
4use crate::syntax::{Expression, Op};
5
6#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct FormPost {
9 pub id: String,
10 pub post: String,
11}
12
13impl Template {
14 #[must_use]
16 pub fn element_ids(&self) -> Vec<IdPattern> {
17 let mut ids = Vec::new();
18 collect(&self.ops, &mut ids);
19 ids
20 }
21
22 #[must_use]
23 pub fn form_posts(&self) -> Vec<FormPost> {
24 let mut posts = Vec::new();
25 visit_components(&self.ops, &mut |content| {
26 if let Content::Form(form) = content {
27 posts.push(FormPost {
28 id: form.id.clone(),
29 post: posted_path(&form.post),
30 });
31 }
32 });
33 posts
34 }
35}
36
37pub(super) fn posted_path(post: &Expression) -> String {
38 parts_of(post)
39 .iter()
40 .map(|part| match part_of(part) {
41 IdPart::Text(text) => text,
42 IdPart::Bound => "{}".to_owned(),
43 })
44 .collect()
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct IdPattern(Vec<IdPart>);
49
50#[derive(Clone, Debug, PartialEq, Eq)]
51enum IdPart {
52 Text(String),
53 Bound,
54}
55
56impl IdPattern {
57 #[must_use]
58 pub fn could_be(&self, id: &str) -> bool {
59 matches(&self.0, id)
60 }
61
62 fn of(id: &Expression) -> Self {
63 Self(parts_of(id).iter().map(part_of).collect())
64 }
65}
66
67fn part_of(expression: &Expression) -> IdPart {
68 match expression {
69 Expression::Text(text) | Expression::Authored(text) => IdPart::Text(text.clone()),
70 Expression::Integer(number) => IdPart::Text(number.to_string()),
71 _ => IdPart::Bound,
72 }
73}
74
75fn matches(parts: &[IdPart], id: &str) -> bool {
76 match parts.split_first() {
77 None => id.is_empty(),
78 Some((IdPart::Text(text), rest)) => id
79 .strip_prefix(text.as_str())
80 .is_some_and(|remainder| matches(rest, remainder)),
81 Some((IdPart::Bound, rest)) => (0..=id.len())
82 .filter(|&split| id.is_char_boundary(split))
83 .any(|split| matches(rest, &id[split..])),
84 }
85}
86
87fn collect(ops: &[Op], ids: &mut Vec<IdPattern>) {
88 for op in ops {
89 match op {
90 Op::Markup(_) | Op::Text(_) | Op::Url(_) => {}
91 Op::Identified { id, body } => {
92 ids.push(IdPattern::of(id));
93 collect(body, ids);
94 }
95 Op::Component(component) => {
96 ids.extend(component.id.as_ref().map(IdPattern::of));
97 collect(component.children(), ids);
98 }
99 Op::If(conditional) => {
100 collect(&conditional.then_branch, ids);
101 collect(&conditional.else_branch, ids);
102 }
103 Op::Each(each) => collect(&each.body, ids),
104 }
105 }
106}
107
108fn visit_components(ops: &[Op], visit: &mut impl FnMut(&Content)) {
109 for op in ops {
110 match op {
111 Op::Markup(_) | Op::Text(_) | Op::Url(_) => {}
112 Op::Identified { body, .. } | Op::Each(crate::syntax::Loop { body, .. }) => {
113 visit_components(body, visit);
114 }
115 Op::Component(component) => {
116 visit(&component.content);
117 visit_components(component.children(), visit);
118 }
119 Op::If(conditional) => {
120 visit_components(&conditional.then_branch, visit);
121 visit_components(&conditional.else_branch, visit);
122 }
123 }
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use crate::Template;
130
131 const SCREEN: &str = "<Section title=\"Orders\" id=\"orders\">\n\
132 <!-- <Section id=\"commented\"></Section> -->\n\
133 {#each data.orders as order}<div id=\"row-{order.id}\">x</div>{/each}\n\
134 {#if data.open}<p id=\"open\">o</p>{:else}<Form id=\"reopen\" post=\"/r\"><Submit label=\"Go\" /></Form>{/if}\n\
135 <p title=\"a > b\">id=\"prose\"</p>\n\
136 <Table id=\"lines\" of={data.lines} columns=\"name\" as=\"line\">\n\
137 <Action id=\"in-row-{line.id}\" post=\"/x/{line.id}\" label=\"x\" />\n\
138 </Table>\n\
139 </Section>";
140
141 fn could_render(source: &str, id: &str) -> bool {
142 let template = Template::parse("screen", source).unwrap();
143 template
144 .element_ids()
145 .iter()
146 .any(|pattern| pattern.could_be(id))
147 }
148
149 #[test]
150 fn literal_ids_in_elements_components_and_both_branches_are_listed() {
151 for id in ["orders", "lines", "open", "reopen"] {
152 assert!(could_render(SCREEN, id), "{id}");
153 }
154 assert!(!could_render(SCREEN, "order"));
155 assert!(!could_render(SCREEN, "orders-x"));
156 }
157
158 #[test]
159 fn a_bound_part_matches_any_text() {
160 assert!(could_render(SCREEN, "row-7"));
161 assert!(could_render(SCREEN, "row-"));
162 assert!(!could_render(SCREEN, "line-7"));
163 assert!(could_render("<p id={data.dom_id}>x</p>", "anything"));
164 }
165
166 #[test]
167 fn comments_text_and_table_row_actions_hold_no_findable_id() {
168 for id in ["commented", "prose", "in-row-7"] {
169 assert!(!could_render(SCREEN, id), "{id}");
170 }
171 }
172
173 #[test]
174 fn a_pattern_matches_across_multibyte_text() {
175 let source = "<div id=\"ü-{data.x}-ö\">x</div>";
176
177 assert!(could_render(source, "ü-ä-ö"));
178 assert!(!could_render(source, "ü-ä"));
179 }
180}