Skip to main content

sofie_model/
shipped.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use syn::visit::Visit;
5use syn::{Expr, ExprMethodCall};
6
7use crate::load::rust_files;
8use crate::naming::screen_key;
9use crate::syntax::{parse_tokens, string_literal, tokenize};
10use crate::{Identity, KnownArea, ModelError};
11
12const SHIPPED_CALL: &str = "shipped";
13const SCREEN_CALL: &str = "screen";
14
15/// A screen a sofie-identity route renders from a template file of its own:
16/// `.shipped(path, name, (include_str!(file), describe), loader).name(handler)`.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct ShippedScreen {
19    pub handler: String,
20    pub name: String,
21    pub file: PathBuf,
22    pub source: String,
23}
24
25/// A shipped screen in the area the app mounts its routes in, under the key it renders as.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct MountedScreen<'app> {
28    pub key: String,
29    pub screen: &'app ShippedScreen,
30}
31
32/// Only files that call `shipped` are parsed, so loading an app stays fast.
33pub(crate) fn read_shipped(directory: &Path) -> Result<Vec<ShippedScreen>, ModelError> {
34    let mut screens = Vec::new();
35    for file in rust_files(&directory.join("src"))? {
36        let text = fs::read_to_string(&file).map_err(|error| {
37            ModelError::in_file(&file, format!("cannot read the file: {error}"))
38        })?;
39        if !text.contains(".shipped(") {
40            continue;
41        }
42        let syntax = parse_tokens(&file, tokenize(&file, &text)?)?;
43        let mut calls = ShippedCalls::default();
44        calls.visit_file(&syntax);
45        for (handler, name, template) in calls.found {
46            let template = file.parent().unwrap_or(directory).join(template);
47            let source = fs::read_to_string(&template).map_err(|error| {
48                ModelError::in_file(&template, format!("cannot read the template: {error}"))
49            })?;
50            screens.push(ShippedScreen {
51                handler,
52                name,
53                file: template,
54                source,
55            });
56        }
57    }
58    Ok(screens)
59}
60
61/// A package route rendering one of its `src/screens` templates:
62/// `.screen(path, name, handler).name("…")` or `.name_for::<A>("…")`.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct ScreenRoute {
65    pub screen: String,
66    pub handler: String,
67}
68
69/// Only calls naming the screen and route by literals are read; a screen named by a variable
70/// has no route here.
71pub(crate) fn read_screen_routes(directory: &Path) -> Result<Vec<ScreenRoute>, ModelError> {
72    let mut found = ScreenCalls::default();
73    for file in rust_files(&directory.join("src"))? {
74        let text = fs::read_to_string(&file).map_err(|error| {
75            ModelError::in_file(&file, format!("cannot read the file: {error}"))
76        })?;
77        if !text.contains(".screen(") {
78            continue;
79        }
80        found.visit_file(&parse_tokens(&file, tokenize(&file, &text)?)?);
81    }
82    Ok(found.routes)
83}
84
85#[derive(Default)]
86struct ScreenCalls {
87    routes: Vec<ScreenRoute>,
88}
89
90impl<'ast> Visit<'ast> for ScreenCalls {
91    fn visit_expr_method_call(&mut self, call: &'ast ExprMethodCall) {
92        if (call.method == "name" || call.method == "name_for")
93            && let Expr::MethodCall(screen) = call.receiver.as_ref()
94            && screen.method == SCREEN_CALL
95            && let Some(handler) = call.args.first().and_then(string_literal)
96            && let Some(name) = screen.args.iter().nth(1).and_then(string_literal)
97        {
98            self.routes.push(ScreenRoute {
99                screen: name,
100                handler,
101            });
102        }
103        syn::visit::visit_expr_method_call(self, call);
104    }
105}
106
107#[derive(Default)]
108struct ShippedCalls {
109    /// Handler, screen name and the template file as `include_str!` names it.
110    found: Vec<(String, String, String)>,
111}
112
113impl<'ast> Visit<'ast> for ShippedCalls {
114    fn visit_expr_method_call(&mut self, call: &'ast ExprMethodCall) {
115        if call.method == "name"
116            && let Expr::MethodCall(shipped) = call.receiver.as_ref()
117            && shipped.method == SHIPPED_CALL
118            && let Some(handler) = call.args.first().and_then(string_literal)
119            && let Some((name, template)) = shipped_arguments(shipped)
120        {
121            self.found.push((handler, name, template));
122        }
123        syn::visit::visit_expr_method_call(self, call);
124    }
125}
126
127fn shipped_arguments(call: &ExprMethodCall) -> Option<(String, String)> {
128    let [_, name, Expr::Tuple(template), _] = call.args.iter().collect::<Vec<_>>()[..] else {
129        return None;
130    };
131    let Some(Expr::Macro(included)) = template.elems.first() else {
132        return None;
133    };
134    if !included.mac.path.is_ident("include_str") {
135        return None;
136    }
137    let file = included.mac.parse_body::<syn::LitStr>().ok()?.value();
138    Some((string_literal(name)?, file))
139}
140
141impl Identity {
142    /// Read on demand: only `explain` needs them.
143    pub fn screen_routes(&self) -> Result<Vec<ScreenRoute>, ModelError> {
144        read_screen_routes(&self.directory)
145    }
146
147    /// The shipped screens of the routes the app's code mounts in `area`.
148    #[must_use]
149    pub fn mounted_screens(&self, area: &KnownArea<'_>) -> Vec<MountedScreen<'_>> {
150        let area_type = area.type_path.rsplit("::").next().unwrap_or_default();
151        self.mounts
152            .iter()
153            .filter(|mount| mount.area_type == area_type)
154            .flat_map(|mount| {
155                self.routes
156                    .iter()
157                    .filter(|route| route.mount == mount.mount)
158            })
159            .filter_map(|route| self.shipped_by(&route.handler))
160            .map(|screen| MountedScreen {
161                key: screen_key(crate::identity::IDENTITY_CRATE, area.name, &screen.name),
162                screen,
163            })
164            .collect()
165    }
166
167    fn shipped_by(&self, handler: &str) -> Option<&ShippedScreen> {
168        self.shipped.iter().find(|screen| screen.handler == handler)
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::read_shipped;
175    use crate::testing::TemporaryDirectory;
176
177    #[test]
178    fn a_shipped_screen_is_read_with_its_handler_and_template() {
179        let directory = TemporaryDirectory::new("shipped");
180        directory.write("src/sessions/screen.html", "<p>Sessions</p>");
181        directory.write(
182            "src/sessions/screen.rs",
183            "pub fn routes(routes: &mut Routes<A>) {\n\
184             routes.shipped(\"/sessions\", \"sessions\", (include_str!(\"screen.html\"), None), \
185             own).name(\"list_sessions\");\n\
186             routes.post(\"/sessions/others\", end).name(\"end_other_sessions\");\n}\n",
187        );
188        directory.write("src/lib.rs", "pub mod sessions;\n");
189
190        let screens = read_shipped(directory.root()).unwrap();
191
192        assert_eq!(screens.len(), 1);
193        assert_eq!(screens[0].handler, "list_sessions");
194        assert_eq!(screens[0].name, "sessions");
195        assert_eq!(screens[0].source, "<p>Sessions</p>");
196        assert!(screens[0].file.ends_with("src/sessions/screen.html"));
197    }
198
199    #[test]
200    fn a_package_screen_is_read_with_the_route_rendering_it() {
201        let directory = TemporaryDirectory::new("screen-routes");
202        directory.write(
203            "src/lib.rs",
204            "pub fn routes<A: MemberArea>(routes: &mut Routes<Web>) {\n\
205             routes.screen(A::SIGN_IN, \"sign_in\", page::<A>).name_for::<A>(\"sign_in_page\");\n\
206             routes.screen(\"/pages/{slug}\", \"page\", show).name(\"page\").signed();\n\
207             routes.screen(&asking, ask_screen, ask).name_for::<A>(ask_name);\n}\n",
208        );
209
210        let routes = super::read_screen_routes(directory.root()).unwrap();
211
212        let found: Vec<(&str, &str)> = routes
213            .iter()
214            .map(|route| (route.screen.as_str(), route.handler.as_str()))
215            .collect();
216        assert_eq!(found, [("sign_in", "sign_in_page"), ("page", "page")]);
217    }
218
219    #[test]
220    fn sofie_identity_s_shipped_screens_are_found_by_their_handlers() {
221        let identity = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../sofie-identity");
222
223        let screens = read_shipped(&identity).unwrap();
224
225        let mut handlers: Vec<&str> = screens
226            .iter()
227            .map(|screen| screen.handler.as_str())
228            .collect();
229        handlers.sort_unstable();
230        assert_eq!(
231            handlers,
232            [
233                "account_data_page",
234                "issue_token_page",
235                "language_page",
236                "list_blocks",
237                "list_connections",
238                "list_flags",
239                "list_impersonations",
240                "list_passkeys",
241                "list_sessions",
242                "list_settings",
243                "list_tokens",
244                "two_factor_page",
245            ]
246        );
247    }
248
249    #[test]
250    fn a_shipped_screen_is_mounted_in_the_area_the_app_adds_its_routes_to() {
251        use super::ShippedScreen;
252        use crate::testing::{app, define_area, module};
253        use crate::{Identity, ListedRoute, RouteMount};
254
255        let mut notes = module("notes", "/app/modules/notes");
256        define_area(&mut notes, "board");
257        let mut demo = app("/app", vec![notes]);
258        let route = |mount: &str, handler: &str| ListedRoute {
259            mount: mount.to_owned(),
260            method: "GET".to_owned(),
261            path: format!("/{handler}"),
262            handler: handler.to_owned(),
263            permission: None,
264        };
265        let shipped = |handler: &str, name: &str| ShippedScreen {
266            handler: handler.to_owned(),
267            name: name.to_owned(),
268            file: format!("{name}.html").into(),
269            source: String::new(),
270        };
271        demo.identity = Some(Identity {
272            routes: vec![
273                route("sessions::routes", "list_sessions"),
274                route("passkeys::routes", "list_passkeys"),
275            ],
276            mounts: vec![RouteMount {
277                mount: "sessions::routes".to_owned(),
278                area_type: "Member".to_owned(),
279            }],
280            shipped: vec![
281                shipped("list_sessions", "sessions"),
282                shipped("list_passkeys", "passkeys"),
283            ],
284            ..Identity::default()
285        });
286
287        let areas = demo.areas();
288        let identity = demo.identity.as_ref().unwrap();
289
290        let mounted = identity.mounted_screens(&areas[1]);
291
292        let keys: Vec<&str> = mounted.iter().map(|screen| screen.key.as_str()).collect();
293        assert_eq!(keys, ["sofie_identity/board/sessions"]);
294        assert_eq!(identity.mounted_screens(&areas[0]), []);
295    }
296}