Skip to main content

sofie_engine/analysis/
ids.rs

1use crate::Template;
2use crate::catalog::Content;
3use crate::render::parts_of;
4use crate::syntax::{Expression, Op};
5
6/// A bound path part is written `{}`, as in `/account/orders/{}/cancel`.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct FormPost {
9    pub id: String,
10    pub post: String,
11}
12
13impl Template {
14    /// Searched as a fragment render searches; a bound id is a pattern.
15    #[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}