Skip to main content

sofie_model/
messages.rs

1use std::path::Path;
2
3use syn::parse::ParseStream;
4use syn::{Attribute, FnArg, Ident, Item, ItemFn, LitStr, Meta, Token, Type};
5
6use crate::areas::{area_definition, implementing};
7use crate::code::{Code, derives};
8use crate::contract_traits::{ContractTrait, contract_trait};
9use crate::gateways::{GatewayTrait, gateway_trait};
10use crate::handlers::HandlerScope;
11use crate::provisions::{ImplSite, impl_site, unit_struct};
12use crate::syntax::{is_attribute, line_of, segments};
13use crate::{
14    AreaDefinition, Cadence, EventDeclaration, Job, Listener, ModelError, PrivateItem, Schedule,
15    Task, WallClock,
16};
17
18const TASK_MODULE: &str = "tasks";
19
20#[derive(Default)]
21pub(crate) struct Found {
22    pub listeners: Vec<Listener>,
23    pub schedules: Vec<Schedule>,
24    pub events: Vec<EventDeclaration>,
25    pub areas: Vec<AreaDefinition>,
26    pub member_areas: Vec<String>,
27    pub sign_up_areas: Vec<String>,
28    pub jobs: Vec<Job>,
29    pub tasks: Vec<Task>,
30    pub impls: Vec<ImplSite>,
31    pub units: Vec<Vec<String>>,
32    pub gateways: Vec<GatewayTrait>,
33    pub contract_traits: Vec<ContractTrait>,
34    pub private_items: Vec<PrivateItem>,
35    pub code: Code,
36}
37
38impl Found {
39    pub(crate) fn wired_count(&self) -> usize {
40        self.listeners.len()
41            + self.schedules.len()
42            + self.events.len()
43            + self.areas.len()
44            + self.jobs.len()
45            + self.tasks.len()
46    }
47
48    /// Inputs a form may post and flags a gate may name, which the app's wiring names
49    /// only when used.
50    pub(crate) fn named_by_wiring_count(&self) -> usize {
51        self.code.inputs.len() + self.code.features.declarations.len()
52    }
53}
54
55pub(crate) fn collect_item(
56    item: &Item,
57    file: &Path,
58    scope: &HandlerScope,
59    found: &mut Found,
60) -> Result<(), ModelError> {
61    let module_path = scope.module_path();
62    found.code.collect(item, file, scope);
63    found.units.extend(unit_struct(item, module_path));
64    found
65        .gateways
66        .extend(gateway_trait(item, file, module_path));
67    found
68        .contract_traits
69        .extend(contract_trait(item, file, module_path));
70    match item {
71        Item::Fn(function) => collect_function(function, file, scope, found),
72        // `sofie gen` writes a pruned event per aggregate; listing each would only repeat them.
73        Item::Struct(_)
74            if is_contract_gen(file) && module_path.last().is_some_and(|name| name == "pruned") =>
75        {
76            Ok(())
77        }
78        Item::Struct(structure) if derives(&structure.attrs, "Event") => {
79            found.events.push(EventDeclaration {
80                name: structure.ident.to_string(),
81                file: file.to_path_buf(),
82                line: line_of(structure),
83            });
84            Ok(())
85        }
86        Item::Impl(implementation) => {
87            found
88                .impls
89                .extend(impl_site(implementation, file, module_path));
90            found
91                .areas
92                .extend(area_definition(implementation, file, module_path)?);
93            if !is_contract_gen(file) {
94                found
95                    .member_areas
96                    .extend(implementing(implementation, "MemberArea"));
97            }
98            found
99                .sign_up_areas
100                .extend(implementing(implementation, "SignUp"));
101            Ok(())
102        }
103        _ => Ok(()),
104    }
105}
106
107/// `sofie gen` writes a `MemberArea` there itself; read as hand-written, the next run would
108/// drop it and the one after write it again.
109fn is_contract_gen(file: &Path) -> bool {
110    file.ends_with(
111        Path::new("src")
112            .join(crate::naming::GEN_MODULE)
113            .join("mod.rs"),
114    )
115}
116
117fn collect_function(
118    function: &ItemFn,
119    file: &Path,
120    scope: &HandlerScope,
121    found: &mut Found,
122) -> Result<(), ModelError> {
123    let module_path = scope.module_path();
124    let mut path = module_path.to_vec();
125    path.push(function.sig.ident.to_string());
126    for attribute in &function.attrs {
127        let line = line_of(attribute);
128        if is_attribute(attribute, "on") {
129            found.listeners.push(Listener {
130                file: file.to_path_buf(),
131                line,
132                function: path.clone(),
133                event: listened_event(attribute, function)
134                    .and_then(|written| scope.qualified(&written))
135                    .map_err(|message| ModelError::new(file, line, message))?,
136            });
137        } else if is_attribute(attribute, "job") {
138            found.jobs.push(Job {
139                file: file.to_path_buf(),
140                line,
141                function: path.clone(),
142            });
143        } else if is_attribute(attribute, "task") {
144            let task = task(&found.tasks, file, line, path.clone())
145                .map_err(|message| ModelError::new(file, line, message))?;
146            found.tasks.push(task);
147        } else if is_attribute(attribute, "every") {
148            let (cadence, each) = schedule_arguments(attribute);
149            found.schedules.push(Schedule {
150                file: file.to_path_buf(),
151                line,
152                function: path.clone(),
153                cadence,
154                each: each.map(|each| scope.callee(&each)),
155            });
156        }
157    }
158    Ok(())
159}
160
161fn task(
162    tasks: &[Task],
163    file: &Path,
164    line: usize,
165    function: Vec<String>,
166) -> Result<Task, &'static str> {
167    if function.first().map(String::as_str) != Some(TASK_MODULE) {
168        return Err("a task lives in its module's `src/tasks.rs` or `src/tasks/`");
169    }
170    if tasks
171        .iter()
172        .any(|task| task.function.last() == function.last())
173    {
174        return Err(
175            "another task of this module has this function's name; a task is named \
176             `<module>.<function>`",
177        );
178    }
179    Ok(Task {
180        file: file.to_path_buf(),
181        line,
182        function,
183    })
184}
185
186/// The attribute macro refuses a malformed one; read as written, the schedule rules report it.
187fn schedule_arguments(attribute: &Attribute) -> (Cadence, Option<Vec<String>>) {
188    attribute
189        .parse_args_with(read_schedule)
190        .unwrap_or_else(|_| {
191            let written = attribute
192                .meta
193                .require_list()
194                .map(|list| list.tokens.to_string());
195            (Cadence::Interval(written.unwrap_or_default()), None)
196        })
197}
198
199fn read_schedule(input: ParseStream) -> syn::Result<(Cadence, Option<Vec<String>>)> {
200    let interval = input
201        .peek(LitStr)
202        .then(|| input.parse::<LitStr>())
203        .transpose()?;
204    let (mut each, mut daily_at, mut weekly_on, mut zone) = (None, None, None, None);
205    let mut follows_an_argument = interval.is_some();
206    while !input.is_empty() {
207        if follows_an_argument {
208            input.parse::<Token![,]>()?;
209            if input.is_empty() {
210                break;
211            }
212        }
213        follows_an_argument = true;
214        let key: Ident = input.parse()?;
215        input.parse::<Token![=]>()?;
216        if key == "each" {
217            each = Some(segments(&input.parse::<syn::Path>()?));
218            continue;
219        }
220        let value = input.parse::<LitStr>()?.value();
221        match key.to_string().as_str() {
222            "daily_at" => daily_at = Some(value),
223            "weekly_on" => weekly_on = Some(value),
224            "zone" => zone = Some(value),
225            _ => return Err(syn::Error::new(key.span(), "unknown schedule argument")),
226        }
227    }
228    let cadence = match (interval, daily_at) {
229        (Some(interval), None) => Cadence::Interval(interval.value()),
230        (None, Some(daily_at)) => Cadence::WallClock(WallClock {
231            daily_at,
232            weekly_on,
233            zone: zone.unwrap_or_default(),
234        }),
235        _ => return Err(input.error("an interval or `daily_at`")),
236    };
237    Ok((cadence, each))
238}
239
240fn listened_event(attribute: &Attribute, function: &ItemFn) -> Result<Vec<String>, String> {
241    if !matches!(attribute.meta, Meta::Path(_)) {
242        return Err(
243            "write a bare `#[on]`: the listener's first argument's type is the event".to_owned(),
244        );
245    }
246    if let Some(FnArg::Typed(argument)) = function.sig.inputs.first()
247        && let Type::Path(path) = argument.ty.as_ref()
248    {
249        return Ok(segments(&path.path));
250    }
251    Err(
252        "a listener takes the event it hears as its first argument: `event: OrderPlaced`"
253            .to_owned(),
254    )
255}
256
257#[must_use]
258pub fn interval_seconds(text: &str) -> Option<u64> {
259    let unit_at = text.len().checked_sub(1)?;
260    let (count, unit) = text.split_at_checked(unit_at)?;
261    let scale = match unit {
262        "s" => 1,
263        "m" => 60,
264        "h" => 60 * 60,
265        "d" => 24 * 60 * 60,
266        _ => return None,
267    };
268    if !count.bytes().all(|byte| byte.is_ascii_digit()) {
269        return None;
270    }
271    count
272        .parse::<u64>()
273        .ok()
274        .filter(|count| *count > 0)
275        .and_then(|count| count.checked_mul(scale))
276}
277
278#[cfg(test)]
279mod tests {
280    #[test]
281    fn the_contract_s_generated_file_is_not_hand_written() {
282        assert!(super::is_contract_gen(std::path::Path::new(
283            "shop/modules/order/contract/src/gen/mod.rs"
284        )));
285        assert!(!super::is_contract_gen(std::path::Path::new(
286            "shop/modules/order/contract/src/area.rs"
287        )));
288    }
289
290    use super::interval_seconds;
291
292    #[test]
293    fn reads_whole_numbers_of_one_unit() {
294        assert_eq!(interval_seconds("30s"), Some(30));
295        assert_eq!(interval_seconds("15m"), Some(900));
296        assert_eq!(interval_seconds("1h"), Some(3600));
297        assert_eq!(interval_seconds("2d"), Some(172_800));
298    }
299
300    #[test]
301    fn refuses_anything_else() {
302        for text in [
303            "",
304            "h",
305            "0h",
306            "-1h",
307            "+1h",
308            "1.5h",
309            "1w",
310            "1 h",
311            "1H",
312            "99999999999999999999d",
313        ] {
314            assert_eq!(interval_seconds(text), None, "{text}");
315        }
316    }
317}