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#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct Binding {
10 pub steps: Vec<Step>,
11 pub line: usize,
12 pub column: usize,
13 pub island: Option<String>,
15}
16
17#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ActionPost {
20 pub post: String,
22 pub fields: Vec<String>,
23 pub line: usize,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct TableLink {
29 pub link: String,
31 pub line: usize,
32}
33
34#[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
46pub 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#[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 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 #[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}