1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use syn::visit::Visit;
5use syn::{Attribute, Expr, ExprMethodCall, GenericArgument, Item, Meta};
6
7use crate::syntax::{first_type_argument, read_file, string_literal, type_last_name, use_paths};
8use crate::{App, Code, Function, KnownArea, ModelError};
9
10const ROUTE_TABLE: &str = "ROUTES";
11pub(crate) const DEFAULT_MOUNT: &str = "routes";
13
14#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct ListedRoute {
17 pub mount: String,
18 pub method: String,
19 pub path: String,
20 pub handler: String,
21 pub permission: Option<String>,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct RouteMount {
27 pub mount: String,
28 pub area_type: String,
30}
31
32#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct ServedRoute<'app> {
35 pub area: String,
36 pub path: String,
37 pub name: String,
38 pub route: &'app ListedRoute,
39}
40
41#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct RoutedPackage {
44 pub crate_name: String,
45 pub routes: Vec<ListedRoute>,
46 pub mounts: Vec<RouteMount>,
47}
48
49pub(crate) fn read_route_tables(
51 directory: &Path,
52 features: &[String],
53) -> Result<Vec<ListedRoute>, ModelError> {
54 let source = directory.join("src");
55 let root = read_file(&source.join("lib.rs"))?;
56 let mut routes = Vec::new();
57 for item in &root.items {
58 let Item::Use(exported) = item else {
59 continue;
60 };
61 if !exported
62 .attrs
63 .iter()
64 .all(|attribute| enabled(attribute, features))
65 {
66 continue;
67 }
68 for used in use_paths(&exported.tree) {
69 if let [module, table] = used.segments.as_slice()
70 && table == ROUTE_TABLE
71 {
72 routes.extend(read_route_table(&module_file(&source, module), features)?);
73 }
74 }
75 }
76 Ok(routes)
77}
78
79fn module_file(source: &Path, module: &str) -> PathBuf {
80 let file = source.join(format!("{module}.rs"));
81 if file.is_file() {
82 return file;
83 }
84 source.join(module).join("mod.rs")
85}
86
87pub(crate) fn read_route_table(
88 file: &Path,
89 features: &[String],
90) -> Result<Vec<ListedRoute>, ModelError> {
91 let parsed = read_file(file)?;
92 let rows = parsed.items.iter().find_map(|item| match item {
93 Item::Const(constant) if constant.ident == ROUTE_TABLE => Some(constant.expr.as_ref()),
94 _ => None,
95 });
96 let Some(Expr::Reference(reference)) = rows else {
97 return Err(ModelError::in_file(
98 file,
99 "found no `const ROUTES: &[ListedRoute]`",
100 ));
101 };
102 let Expr::Array(array) = reference.expr.as_ref() else {
103 return Err(ModelError::in_file(
104 file,
105 "`ROUTES` is not an array literal",
106 ));
107 };
108 let mut routes = Vec::new();
109 for row in &array.elems {
110 let Expr::Call(call) = row else {
111 return Err(ModelError::in_file(file, "a `ROUTES` row is not a call"));
112 };
113 if !call
114 .attrs
115 .iter()
116 .all(|attribute| enabled(attribute, features))
117 {
118 continue;
119 }
120 let arguments: Option<Vec<String>> = call.args.iter().map(string_literal).collect();
121 let row = match arguments.as_deref() {
122 Some([method, path, handler]) => listed(DEFAULT_MOUNT, method, path, handler, None),
123 Some([mount, method, path, handler]) => listed(mount, method, path, handler, None),
124 Some([mount, method, path, handler, permission]) => {
125 listed(mount, method, path, handler, Some(permission))
126 }
127 _ => {
128 return Err(ModelError::in_file(
129 file,
130 "a `ROUTES` row holds other than literals",
131 ));
132 }
133 };
134 routes.push(row);
135 }
136 Ok(routes)
137}
138
139fn listed(
140 mount: &str,
141 method: &str,
142 path: &str,
143 handler: &str,
144 permission: Option<&String>,
145) -> ListedRoute {
146 ListedRoute {
147 mount: mount.to_owned(),
148 method: method.to_owned(),
149 path: path.to_owned(),
150 handler: handler.to_owned(),
151 permission: permission.cloned(),
152 }
153}
154
155fn enabled(attribute: &Attribute, features: &[String]) -> bool {
157 if !attribute.path().is_ident("cfg") {
158 return true;
159 }
160 attribute
161 .parse_args::<Meta>()
162 .is_ok_and(|predicate| holds(&predicate, features))
163}
164
165fn holds(predicate: &Meta, features: &[String]) -> bool {
166 match predicate {
167 Meta::NameValue(pair) if pair.path.is_ident("feature") => {
168 string_literal(&pair.value).is_some_and(|feature| features.contains(&feature))
169 }
170 Meta::List(list) => {
171 let Ok(inner) = list.parse_args_with(
172 syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
173 ) else {
174 return false;
175 };
176 let mut inner = inner.iter();
177 if list.path.is_ident("any") {
178 return inner.any(|predicate| holds(predicate, features));
179 }
180 if list.path.is_ident("all") {
181 return inner.all(|predicate| holds(predicate, features));
182 }
183 list.path.is_ident("not")
184 && inner
185 .next()
186 .is_some_and(|predicate| !holds(predicate, features))
187 }
188 _ => false,
189 }
190}
191
192pub(crate) fn read_mounts(app: &BTreeMap<PathBuf, syn::File>, crate_name: &str) -> Vec<RouteMount> {
195 let mut found = Mounts {
196 crate_name: Some(crate_name),
197 mounts: Vec::new(),
198 };
199 for syntax in app.values() {
200 found.visit_file(syntax);
201 }
202 found.mounts
203}
204
205pub(crate) fn read_module_mounts(directory: &Path) -> Result<Vec<RouteMount>, ModelError> {
207 let mut found = Mounts {
208 crate_name: None,
209 mounts: Vec::new(),
210 };
211 found.visit_file(&read_file(&directory.join("src").join("lib.rs"))?);
212 Ok(found.mounts)
213}
214
215struct Mounts<'name> {
217 crate_name: Option<&'name str>,
218 mounts: Vec<RouteMount>,
219}
220
221impl Mounts<'_> {
222 fn mount(&self, call: &ExprMethodCall) -> Option<RouteMount> {
223 let [module, Expr::Path(function)] = call.args.iter().collect::<Vec<_>>()[..] else {
224 return None;
225 };
226 let segments: Vec<&syn::PathSegment> = function.path.segments.iter().collect();
227 let own = match self.crate_name {
228 Some(crate_name) => {
229 let [first, rest @ ..] = segments.as_slice() else {
230 return None;
231 };
232 if string_literal(module).as_deref() != Some(crate_name)
233 || first.ident != crate_name
234 {
235 return None;
236 }
237 rest
238 }
239 None => segments.as_slice(),
240 };
241 let area_type = own
242 .last()
243 .and_then(|last| type_last_name(first_type_argument(last)?))
244 .or_else(|| turbofish_type(call))?;
245 let mount = own
246 .iter()
247 .map(|segment| segment.ident.to_string())
248 .collect::<Vec<_>>()
249 .join("::");
250 Some(RouteMount { mount, area_type })
251 }
252}
253
254fn turbofish_type(call: &ExprMethodCall) -> Option<String> {
255 match call.turbofish.as_ref()?.args.first()? {
256 GenericArgument::Type(ty) => type_last_name(ty),
257 _ => None,
258 }
259}
260
261impl<'ast> Visit<'ast> for Mounts<'_> {
262 fn visit_expr_method_call(&mut self, call: &'ast ExprMethodCall) {
263 if call.method == "area"
264 && let Some(mount) = self.mount(call)
265 && !self.mounts.contains(&mount)
266 {
267 self.mounts.push(mount);
268 }
269 syn::visit::visit_expr_method_call(self, call);
270 }
271}
272
273impl App {
274 #[must_use]
275 pub fn package_routes(&self) -> PackageRoutes<'_> {
276 PackageRoutes {
277 app: self,
278 areas: self.areas(),
279 }
280 }
281}
282
283#[derive(Clone, Debug)]
285pub struct PackageRoutes<'app> {
286 pub(crate) app: &'app App,
287 pub(crate) areas: Vec<KnownArea<'app>>,
288}
289
290impl<'app> PackageRoutes<'app> {
291 #[must_use]
293 pub fn other_packages(&self) -> Vec<ServedRoute<'app>> {
294 self.app
295 .packages
296 .iter()
297 .flat_map(|package| self.mounted(&package.crate_name, &package.routes, &package.mounts))
298 .collect()
299 }
300
301 pub(crate) fn mounted(
302 &self,
303 crate_name: &str,
304 routes: &'app [ListedRoute],
305 mounts: impl IntoIterator<Item = &'app RouteMount>,
306 ) -> Vec<ServedRoute<'app>> {
307 let mut served = Vec::new();
308 for mount in mounts {
309 let Some(area) = self
310 .areas
311 .iter()
312 .find(|area| area.type_path.rsplit("::").next() == Some(mount.area_type.as_str()))
313 else {
314 continue;
315 };
316 let Some(prefix) = area.prefix() else {
317 continue;
318 };
319 let mounted = routes.iter().filter(|route| route.mount == mount.mount);
320 for route in mounted {
321 let path = format!("{prefix}{}", route.path);
322 served.push(served_route(crate_name, area.name, area.name, path, route));
323 }
324 }
325 served
326 }
327}
328
329pub(crate) fn served_route<'app>(
332 crate_name: &str,
333 area: &str,
334 named_for: &str,
335 path: String,
336 route: &'app ListedRoute,
337) -> ServedRoute<'app> {
338 ServedRoute {
339 area: area.to_owned(),
340 path,
341 name: format!("{crate_name}.{named_for}.{}", route.handler),
342 route,
343 }
344}
345
346impl ServedRoute<'_> {
347 #[must_use]
349 pub fn package(&self) -> &str {
350 self.name.split('.').next().unwrap_or_default()
351 }
352}
353
354impl ListedRoute {
355 #[must_use]
356 pub fn function<'code>(&self, code: &'code Code) -> Option<&'code Function> {
357 code.functions
358 .iter()
359 .find(|function| function.path.last() == Some(&self.handler))
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use std::collections::BTreeMap;
366 use std::path::{Path, PathBuf};
367
368 use super::{read_module_mounts, read_mounts, read_route_table, read_route_tables};
369 use crate::testing::TemporaryDirectory;
370
371 fn kept_by(
372 features: &[&str],
373 read: impl Fn(&[String]) -> Vec<super::ListedRoute>,
374 ) -> Vec<String> {
375 let features: Vec<String> = features.iter().map(|&name| name.to_owned()).collect();
376 read(&features)
377 .into_iter()
378 .map(|route| format!("{} {}", route.mount, route.handler))
379 .collect()
380 }
381
382 #[test]
383 fn keeps_the_rows_whose_features_are_on() {
384 let directory = TemporaryDirectory::new("route-table");
385 let file = directory.write(
386 "route_table.rs",
387 "pub const ROUTES: &[ListedRoute] = &[\n\
388 member(\"GET\", \"{sign_in}\", \"sign_in_page\"),\n\
389 #[cfg(any(feature = \"password-reset\", feature = \"email-verification\"))]\n\
390 member(\"GET\", \"{prefix}/link-sent\", \"link_sent_page\"),\n\
391 #[cfg(feature = \"token-screens\")]\n\
392 permitted(\"tokens::screen::routes\", \"GET\", \"/tokens\", \"list_tokens\", \"t.*.read\"),\n\
393 ];\n",
394 );
395 let read = |features: &[String]| read_route_table(&file, features).unwrap();
396
397 assert_eq!(kept_by(&[], read), ["routes sign_in_page"]);
398 assert_eq!(
399 kept_by(&["email-verification", "token-screens"], read),
400 [
401 "routes sign_in_page",
402 "routes link_sent_page",
403 "tokens::screen::routes list_tokens"
404 ]
405 );
406 }
407
408 #[test]
409 fn reads_every_table_the_root_exports_while_its_features_are_on() {
410 let directory = TemporaryDirectory::new("route-tables");
411 directory.write(
412 "src/lib.rs",
413 "mod public;\n#[cfg(feature = \"admin\")]\nmod admin;\n\n\
414 pub use public::{ROUTES, routes};\n\
415 #[cfg(feature = \"admin\")]\npub use admin::ROUTES as ADMIN_ROUTES;\n",
416 );
417 directory.write(
418 "src/public.rs",
419 "pub const ROUTES: &[ListedRoute] = &[listed(\"GET\", \"/pages/{slug}\", \"page\")];\n",
420 );
421 directory.write(
422 "src/admin/mod.rs",
423 "pub const ROUTES: &[ListedRoute] = &[\n\
424 listed(\"admin_routes\", \"POST\", \"/pages/{page}/publish\", \"publish\"),\n];\n",
425 );
426 let read = |features: &[String]| read_route_tables(directory.root(), features).unwrap();
427
428 assert_eq!(kept_by(&[], read), ["routes page"]);
429 assert_eq!(
430 kept_by(&["admin"], read),
431 ["routes page", "admin_routes publish"]
432 );
433 }
434
435 #[test]
436 fn the_framework_packages_list_their_routes() {
437 let package = |name: &str| Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(name);
438 let handlers = |name: &str, features: &[&str]| {
439 kept_by(features, |features| {
440 read_route_tables(&package(name), features).unwrap()
441 })
442 };
443
444 assert!(handlers("sofie-identity", &[]).contains(&"routes sign_in_page".to_owned()));
445 assert_eq!(
446 handlers("sofie-tenancy", &[]),
447 [
448 "organization_routes list_members",
449 "organization_routes invite_member",
450 "organization_routes make_owner",
451 "organization_routes make_admin",
452 "organization_routes make_member",
453 "organization_routes withdraw_invitation",
454 "organization_routes remove_member",
455 "organization_routes hand_over_organization",
456 "organization_routes leave_organization",
457 "organization_routes rename_organization",
458 "organization_routes delete_organization",
459 "member_routes list_organizations",
460 "member_routes found_organization",
461 "member_routes enter_organization",
462 "member_routes invitation_page",
463 "member_routes accept_invitation",
464 ]
465 );
466 assert_eq!(
467 handlers("sofie-content", &[]),
468 ["routes page", "routes sitemap"]
469 );
470 assert_eq!(handlers("sofie-content", &["admin"]).len(), 10);
471 assert_eq!(
472 read_module_mounts(&package("sofie-content"))
473 .unwrap()
474 .into_iter()
475 .map(|mount| (mount.mount, mount.area_type))
476 .collect::<Vec<_>>(),
477 [("routes".to_owned(), "Web".to_owned())]
478 );
479 }
480
481 #[test]
482 fn finds_the_routes_the_app_mounts_from_a_package() {
483 let main = syn::parse_str(
484 "fn app(db: Db) -> App {\n wiring::app(db)\n \
485 .area::<Console>(\"sofie_identity\", sofie_identity::permitted_routes::<Console>)\n \
486 .area::<Web>(\"sofie_content\", sofie_content::routes)\n \
487 .area(\"sofie_tenancy\", sofie_tenancy::member_routes::<Member, Staff>)\n \
488 .area::<Console>(\"shop\", shop::routes)\n}\n",
489 )
490 .unwrap();
491 let app = BTreeMap::from([(PathBuf::from("src/main.rs"), main)]);
492 let mounts = |crate_name: &str| -> Vec<(String, String)> {
493 read_mounts(&app, crate_name)
494 .into_iter()
495 .map(|mount| (mount.mount, mount.area_type))
496 .collect()
497 };
498 let pair = |mount: &str, area: &str| (mount.to_owned(), area.to_owned());
499
500 assert_eq!(
501 mounts("sofie_identity"),
502 [pair("permitted_routes", "Console")]
503 );
504 assert_eq!(mounts("sofie_content"), [pair("routes", "Web")]);
505 assert_eq!(mounts("sofie_tenancy"), [pair("member_routes", "Member")]);
506 }
507}