Skip to main content

sofie_engine/analysis/
bindings.rs

1use super::ids::posted_path;
2use crate::TemplateError;
3use crate::parse::parse_recorded;
4use crate::slots::{Slots, Step};
5use crate::syntax::{Attribute, Expression, Position};
6
7/// Lets `sofie check` hold a template against the loader's type without rendering.
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct Binding {
10    pub steps: Vec<Step>,
11    pub line: usize,
12    pub column: usize,
13    /// The tag of the `<Island>` whose `props` the binding is.
14    pub island: Option<String>,
15}
16
17/// An `<Action>`'s target and the input fields its `with` posts, where it is written.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ActionPost {
20    /// A bound path part is written `{}`, as in `/account/orders/{}/cancel`.
21    pub post: String,
22    pub fields: Vec<String>,
23    pub line: usize,
24}
25
26/// A `Table`'s row link, where the table is written.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct TableLink {
29    /// A bound path part is written `{}`, as in `/account/orders/{}`.
30    pub link: String,
31    pub line: usize,
32}
33
34/// An `entitled("…")` gate a template asks, where it is written.
35#[cfg(feature = "gates")]
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct AskedEntitlement {
38    pub name: String,
39    pub line: usize,
40}
41
42pub fn bindings(name: &str, source: &str) -> Result<Vec<Binding>, TemplateError> {
43    bindings_with(name, source, &crate::Components::default())
44}
45
46/// A component's paths are bound from where it is written, so they are the screen's.
47pub fn bindings_with(
48    name: &str,
49    source: &str,
50    components: &crate::Components,
51) -> Result<Vec<Binding>, TemplateError> {
52    parse_recorded(source, components)
53        .map(|recorder| recorder.bindings)
54        .map_err(|error| TemplateError::located(name, error))
55}
56
57pub fn action_posts_with(
58    name: &str,
59    source: &str,
60    components: &crate::Components,
61) -> Result<Vec<ActionPost>, TemplateError> {
62    parse_recorded(source, components)
63        .map(|recorder| recorder.actions)
64        .map_err(|error| TemplateError::located(name, error))
65}
66
67pub fn table_links_with(
68    name: &str,
69    source: &str,
70    components: &crate::Components,
71) -> Result<Vec<TableLink>, TemplateError> {
72    parse_recorded(source, components)
73        .map(|recorder| recorder.links)
74        .map_err(|error| TemplateError::located(name, error))
75}
76
77/// A component's gates are asked where it is written, so they are the screen's.
78#[cfg(feature = "gates")]
79pub fn asked_entitlements(
80    name: &str,
81    source: &str,
82    components: &crate::Components,
83) -> Result<Vec<AskedEntitlement>, TemplateError> {
84    parse_recorded(source, components)
85        .map(|recorder| recorder.entitlements)
86        .map_err(|error| TemplateError::located(name, error))
87}
88
89#[derive(Default)]
90pub(crate) struct Recorder {
91    slots: Slots,
92    bindings: Vec<Binding>,
93    actions: Vec<ActionPost>,
94    links: Vec<TableLink>,
95    #[cfg(feature = "gates")]
96    entitlements: Vec<AskedEntitlement>,
97}
98
99impl Recorder {
100    pub(crate) fn push_items(&mut self, list: &Expression) {
101        self.slots.push_items(list);
102    }
103
104    pub(crate) fn push_index(&mut self) {
105        self.slots.push_dataless();
106    }
107
108    pub(crate) fn push_rows(&mut self, props: &[Attribute]) {
109        match props.iter().find(|attribute| attribute.name == "of") {
110            Some(rows) => self.slots.push_items(&rows.value),
111            None => self.slots.push_dataless(),
112        }
113    }
114
115    pub(crate) fn truncate(&mut self, depth: usize) {
116        self.slots.truncate(depth);
117    }
118
119    pub(crate) fn record_action(&mut self, props: &[Attribute], position: Position) {
120        let Some(post) = props.iter().find(|attribute| attribute.name == "post") else {
121            return;
122        };
123        let fields = props
124            .iter()
125            .filter_map(|attribute| attribute.name.strip_prefix("with."))
126            .map(str::to_owned)
127            .collect();
128        self.actions.push(ActionPost {
129            post: posted_path(&post.value),
130            fields,
131            line: position.line,
132        });
133    }
134
135    /// Each column is a field of a row of `of`, so it is bound like `{row.column}`.
136    pub(crate) fn record_table_columns(&mut self, props: &[Attribute]) {
137        let attribute = |name: &str| props.iter().find(|attribute| attribute.name == name);
138        let (Some(rows), Some(columns)) = (attribute("of"), attribute("columns")) else {
139            return;
140        };
141        let (Some(row), Expression::Authored(names)) =
142            (self.slots.items_of(&rows.value), &columns.value)
143        else {
144            return;
145        };
146        for name in names.split_whitespace() {
147            let mut steps = row.clone();
148            steps.push(Step::Field(name.to_owned()));
149            self.bindings.push(Binding {
150                steps,
151                line: columns.position.line,
152                column: columns.position.column,
153                island: None,
154            });
155        }
156    }
157
158    pub(crate) fn record_table_link(&mut self, props: &[Attribute], position: Position) {
159        if let Some(link) = props.iter().find(|attribute| attribute.name == "link") {
160            self.links.push(TableLink {
161                link: posted_path(&link.value),
162                line: position.line,
163            });
164        }
165    }
166
167    pub(crate) fn record(&mut self, expression: &Expression, position: Position) {
168        match expression {
169            Expression::Path(path) => {
170                if let Some(steps) = self.slots.steps_of(path) {
171                    self.bindings.push(Binding {
172                        steps,
173                        line: position.line,
174                        column: position.column,
175                        island: None,
176                    });
177                }
178            }
179            Expression::Interpolated(parts) => {
180                for part in parts {
181                    self.record(part, position);
182                }
183            }
184            Expression::Call(call) => self.record_call(call, position),
185            #[cfg(feature = "gates")]
186            Expression::Gate(gate) if gate.asks == crate::rendering::Asks::Entitlement => {
187                self.entitlements.push(AskedEntitlement {
188                    name: gate.name.clone(),
189                    line: position.line,
190                });
191            }
192            _ => {}
193        }
194    }
195
196    #[cfg(not(feature = "i18n"))]
197    fn record_call(&mut self, call: &crate::language::Call, position: Position) {
198        for value in call.values() {
199            self.record(value, position);
200        }
201    }
202
203    #[cfg(feature = "i18n")]
204    fn record_call(&mut self, call: &crate::translations::Call, position: Position) {
205        use crate::translations::Call;
206        match call {
207            Call::Translate { arguments, .. } => {
208                for argument in arguments {
209                    self.record(&argument.value, position);
210                }
211            }
212            Call::Format { value, .. } => self.record(value, position),
213            Call::Label { .. } => {}
214        }
215    }
216
217    /// The attribute was recorded as it was read, before the component knew its kind.
218    #[cfg(feature = "islands")]
219    pub(crate) fn mark_island(&mut self, tag: &str, props: Position) {
220        for binding in &mut self.bindings {
221            if (binding.line, binding.column) == (props.line, props.column) {
222                binding.island = Some(tag.to_owned());
223            }
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::bindings;
231    use crate::slots::Step;
232
233    fn paths(source: &str) -> Vec<String> {
234        bindings("screen", source)
235            .unwrap()
236            .iter()
237            .map(|binding| {
238                let steps: Vec<&str> = binding
239                    .steps
240                    .iter()
241                    .map(|step| match step {
242                        Step::Field(name) => name.as_str(),
243                        Step::Item => "[]",
244                    })
245                    .collect();
246                steps.join(".")
247            })
248            .collect()
249    }
250
251    #[test]
252    fn expands_loop_and_row_names_to_paths_from_the_data_root() {
253        let source = "<p title=\"{data.title}\">{data.count}</p>\
254            {#each data.orders as order, index}{order.id}{index}{/each}\
255            <Table id=\"t\" of={data.lines} columns=\"name\" as=\"line\">\
256            <Action post=\"/x/{line.id}\" label=\"x\" when={line.status_to.PAID} /></Table>";
257
258        assert_eq!(
259            paths(source),
260            [
261                "title",
262                "count",
263                "orders",
264                "orders.[].id",
265                "lines",
266                "lines.[].name",
267                "lines.[].id",
268                "lines.[].status_to.PAID",
269            ]
270        );
271    }
272
273    #[test]
274    fn binds_an_action_s_posted_fields() {
275        let source = "<Table id=\"t\" of={data.slots} columns=\"time\" as=\"slot\">\
276            <Action post=\"/slots/book\" label=\"Book\" with={{slot: slot.id, by: data.user}} />\
277            </Table>";
278
279        assert_eq!(
280            paths(source),
281            ["slots", "slots.[].time", "slots.[].id", "user"]
282        );
283    }
284
285    #[test]
286    fn lists_each_action_s_target_and_posted_fields_with_its_line() {
287        use super::{ActionPost, action_posts_with};
288        let source = "<Action post=\"/orders/{data.id}/pick\" label=\"x\" />\n\
289            <Action post=\"/slots/book\" label=\"Book\" with={{slot: data.slot, by: 3}} />";
290
291        let posts = action_posts_with("screen", source, &crate::Components::default()).unwrap();
292
293        let at = |post: &str, fields: &[&str], line| ActionPost {
294            post: post.to_owned(),
295            fields: fields.iter().map(|field| (*field).to_owned()).collect(),
296            line,
297        };
298        assert_eq!(
299            posts,
300            [
301                at("/orders/{}/pick", &[], 1),
302                at("/slots/book", &["slot", "by"], 2)
303            ]
304        );
305    }
306
307    #[test]
308    fn lists_each_table_s_row_link_with_its_line() {
309        use super::{TableLink, table_links_with};
310        let source = "<Table id=\"a\" of={data.orders} columns=\"id\" as=\"order\" \
311            link=\"/account/orders/{order.id}\" />\n\
312            <Table id=\"b\" of={data.lines} columns=\"name\" />";
313
314        let links = table_links_with("screen", source, &crate::Components::default()).unwrap();
315
316        assert_eq!(
317            links,
318            [TableLink {
319                link: "/account/orders/{}".to_owned(),
320                line: 1
321            }]
322        );
323    }
324
325    #[cfg(feature = "i18n")]
326    #[test]
327    fn follows_paths_into_translation_and_format_arguments() {
328        let source = "<p>{t(\"order.total\", amount = data.total)}{number(data.count)}</p>";
329
330        assert_eq!(paths(source), ["total", "count"]);
331    }
332
333    #[cfg(feature = "gates")]
334    #[test]
335    fn lists_each_entitlement_a_template_asks_with_its_line() {
336        use super::{AskedEntitlement, asked_entitlements};
337        let source = "<p>entitled(\"x.y\")</p>\n{#if entitled(\"project.export\")}<p>Export</p>\
338            {/if}{#if can(\"order.order.*.read\")}<p>Read</p>{/if}\n\
339            <Action post=\"/api\" label=\"Api\" when={entitled(\"project.api\")} />";
340
341        let asked = asked_entitlements("screen", source, &crate::Components::default()).unwrap();
342
343        let at = |name: &str, line| AskedEntitlement {
344            name: name.to_owned(),
345            line,
346        };
347        assert_eq!(asked, [at("project.export", 2), at("project.api", 3)]);
348    }
349
350    #[cfg(feature = "islands")]
351    #[test]
352    fn marks_the_props_an_island_is_given() {
353        let source = "{#each data.charts as chart}\
354            <Island name=\"order-chart\" props={chart.points} />{/each}<p>{data.title}</p>";
355
356        let marked: Vec<(Option<String>, usize)> = bindings("screen", source)
357            .unwrap()
358            .into_iter()
359            .map(|binding| (binding.island, binding.steps.len()))
360            .collect();
361
362        assert_eq!(
363            marked,
364            [(None, 1), (Some("order-chart".to_owned()), 3), (None, 1)]
365        );
366    }
367}