1use sofie_model::naming::{
2 RouteSite, SOURCE_LANGUAGE, file_stem, path_parameters, route_parameters,
3};
4use sofie_model::permissions::module_permissions;
5use sofie_model::{App, KnownArea, Module};
6
7use super::{Finding, Rule};
8
9pub(super) fn check_names(app: &App) -> Vec<Finding> {
10 let sites = app.route_sites();
11 let mut findings: Vec<Finding> = sites
12 .iter()
13 .filter_map(|site| unusable_parameter(site))
14 .collect();
15 findings.extend(sites.iter().filter_map(|site| unread_parameter(app, site)));
16 let areas = app.areas();
17 findings.extend(areas.iter().filter_map(malformed_prefix));
18 findings.extend(shared_prefixes(&areas));
19 findings.extend(areas.iter().filter_map(|area| {
20 let definition = area.definition?;
21 definition.prefix.is_none().then(|| Finding {
22 rule: Rule::AreaPrefix,
23 file: definition.file.clone(),
24 line: definition.line,
25 message: format!(
26 "area `{}` has no literal `PREFIX`, so `sofie gen` cannot name its routes or build \
27 their paths",
28 area.name
29 ),
30 fix: "write `const PREFIX: &'static str = \"/path\";` with a string literal".to_owned(),
31 })
32 }));
33 findings.extend(
34 app.modules
35 .iter()
36 .filter_map(|module| unexported_gen(app, module)),
37 );
38 findings
39}
40
41fn malformed_prefix(area: &KnownArea) -> Option<Finding> {
42 let definition = area.definition?;
43 let prefix = definition.prefix.as_deref()?;
44 if prefix.is_empty() || (prefix.starts_with('/') && !prefix.ends_with('/')) {
45 return None;
46 }
47 Some(Finding {
48 rule: Rule::AreaPrefix,
49 file: definition.file.clone(),
50 line: definition.line,
51 message: format!(
52 "area `{}` has the prefix `{prefix}`, which serves its routes under paths with a \
53 missing or doubled `/`",
54 area.name
55 ),
56 fix: "start the prefix with `/` and end it without one, such as `/docs`".to_owned(),
57 })
58}
59
60fn shared_prefixes<'app>(areas: &'app [KnownArea<'app>]) -> impl Iterator<Item = Finding> + 'app {
62 areas.iter().enumerate().filter_map(|(index, area)| {
63 let definition = area.definition?;
64 let prefix = area.prefix()?;
65 let earlier = areas[..index]
66 .iter()
67 .find(|earlier| earlier.name != area.name && earlier.prefix() == Some(prefix))?;
68 let place = if prefix.is_empty() {
69 "the root".to_owned()
70 } else {
71 format!("`{prefix}`")
72 };
73 Some(Finding {
74 rule: Rule::AreaPrefix,
75 file: definition.file.clone(),
76 line: definition.line,
77 message: format!(
78 "area `{}` serves under {place}, as area `{}` does, so their routes would collide",
79 area.name, earlier.name
80 ),
81 fix: "give each area a prefix of its own".to_owned(),
82 })
83 })
84}
85
86fn unusable_parameter(site: &RouteSite) -> Option<Finding> {
87 let parameter = path_parameters(&site.route.path)
88 .into_iter()
89 .find(|name| !is_identifier(name))?;
90 Some(Finding {
91 rule: Rule::RouteName,
92 file: site.area.routes_file.clone(),
93 line: site.route.line,
94 message: format!(
95 "{} has parameter `{{{parameter}}}`, which the route builder cannot take as an \
96 argument",
97 site.route.path
98 ),
99 fix: "name each parameter with lowercase letters, digits and `_`, such as `{order}`"
100 .to_owned(),
101 })
102}
103
104fn unread_parameter(app: &App, site: &RouteSite) -> Option<Finding> {
105 let (parameter, _) = route_parameters(app, site.module, &site.route.path)
106 .into_iter()
107 .find(|(name, read)| read.is_none() && is_identifier(name))?;
108 Some(Finding {
109 rule: Rule::RouteName,
110 file: site.area.routes_file.clone(),
111 line: site.route.line,
112 message: format!(
113 "{} has parameter `{{{parameter}}}`, which names no aggregate of module `{}` and no \
114 field of one, so no handler can read it and its route builder cannot type it",
115 site.route.path, site.module.krate.package
116 ),
117 fix: "name the parameter after the aggregate the route loads, such as `{order}`, or \
118 after the field a child or listing is kept within"
119 .to_owned(),
120 })
121}
122
123fn is_identifier(name: &str) -> bool {
124 name.starts_with(|first: char| first.is_ascii_lowercase() || first == '_')
125 && name.chars().all(|character| {
126 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
127 })
128}
129
130fn unexported_gen(app: &App, module: &Module) -> Option<Finding> {
131 let contract = module.contract.as_ref()?;
132 let missing = missing_gen_exports(app, module);
133 let exported = match missing.as_slice() {
134 [] => return None,
135 [one] => one.clone(),
136 _ => format!("{{{}}}", missing.join(", ")),
137 };
138 Some(Finding {
139 rule: Rule::UndeclaredGen,
140 file: contract.path.join("src/lib.rs"),
141 line: 1,
142 message: format!(
143 "`sofie gen` writes the {} of module `{}` into src/gen/mod.rs, which the contract does \
144 not export",
145 missing.join(" and "),
146 module.krate.package
147 ),
148 fix: format!(
149 "add `mod r#gen;` and `pub use r#gen::{exported};` to the contract's src/lib.rs"
150 ),
151 })
152}
153
154#[must_use]
155pub fn missing_gen_exports(app: &App, module: &Module) -> Vec<String> {
156 let has_routes = module.areas.iter().any(|area| !area.routes.is_empty());
157 let has_permissions = app.has_permitted_areas() && !module_permissions(module).is_empty();
158 let has_keys = module
159 .languages
160 .iter()
161 .any(|file| file_stem(file) == SOURCE_LANGUAGE);
162 [
163 ("routes", has_routes),
164 ("permissions", has_permissions),
165 ("keys", has_keys),
166 ("pruned", !module.code.aggregates.is_empty()),
167 ]
168 .into_iter()
169 .filter(|(_, generated)| *generated)
170 .map(|(name, _)| name.to_owned())
171 .chain(
172 module
173 .code
174 .aggregates
175 .iter()
176 .filter(|aggregate| !aggregate.exposed.is_empty())
177 .flat_map(|aggregate| [aggregate.name.clone(), format!("{}Changed", aggregate.name)]),
178 )
179 .filter(|name| {
180 !(module.contract_declares_gen
181 && module.contract_exports.iter().any(|export| export == name))
182 })
183 .collect()
184}
185
186#[cfg(test)]
187mod tests {
188 use std::path::PathBuf;
189
190 use sofie_model::{AreaClient, AreaDefinition, KnownArea};
191
192 use super::malformed_prefix;
193
194 #[test]
195 fn a_malformed_prefix_is_not_sent_to_the_root_the_web_area_holds() {
196 let definition = AreaDefinition {
197 name: "Docs".to_owned(),
198 type_path: vec!["Docs".to_owned()],
199 prefix: Some("docs".to_owned()),
200 client: AreaClient::Html,
201 principal: Vec::new(),
202 implements_member_area: false,
203 opens_sign_up: false,
204 file: PathBuf::from("src/areas/docs.rs"),
205 line: 1,
206 };
207 let area = KnownArea {
208 name: "docs",
209 type_path: "crate::Docs".to_owned(),
210 contract: None,
211 definition: Some(&definition),
212 };
213
214 let finding = malformed_prefix(&area).unwrap();
215
216 assert!(finding.fix.contains("`/docs`"), "{}", finding.fix);
217 assert!(!finding.fix.contains("root"), "{}", finding.fix);
218 }
219}