Skip to main content

sofie_check/rules/
names.rs

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
60/// A pair under one name is `area-definition`'s finding already.
61fn 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}