Skip to main content

sofie_model/
contract_traits.rs

1use std::path::{Path, PathBuf};
2
3use syn::visit::Visit;
4use syn::{FnArg, GenericParam, Item, ReceiverKind, ReturnType, Signature, TraitItem, Type};
5
6use crate::syntax::{first_type_argument, has_attribute, line_of};
7
8/// `sofie split` serves it from the app its module moves to.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct ContractTrait {
11    pub name: String,
12    /// Within its contract crate.
13    pub path: Vec<String>,
14    pub methods: Vec<ContractMethod>,
15    pub file: PathBuf,
16    pub line: usize,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct ContractMethod {
21    pub name: String,
22    /// Why a call of it cannot travel between apps.
23    pub stays_local: Option<&'static str>,
24}
25
26impl ContractTrait {
27    #[must_use]
28    pub fn crosses_apps(&self) -> bool {
29        self.methods
30            .iter()
31            .all(|method| method.stays_local.is_none())
32    }
33}
34
35pub(crate) fn contract_trait(
36    item: &Item,
37    file: &Path,
38    module_path: &[String],
39) -> Option<ContractTrait> {
40    let Item::Trait(contract) = item else {
41        return None;
42    };
43    if !has_attribute(&contract.attrs, "contract") {
44        return None;
45    }
46    let methods = contract
47        .items
48        .iter()
49        .filter_map(|item| match item {
50            TraitItem::Fn(function) => Some(ContractMethod {
51                name: function.sig.ident.to_string(),
52                stays_local: stays_local(&function.sig),
53            }),
54            _ => None,
55        })
56        .collect();
57    Some(ContractTrait {
58        name: contract.ident.to_string(),
59        path: [module_path, &[contract.ident.to_string()]].concat(),
60        methods,
61        file: file.to_path_buf(),
62        line: line_of(item),
63    })
64}
65
66/// sofie-macros' `remote::method` applies the same rule when it writes the client and server.
67fn stays_local(signature: &Signature) -> Option<&'static str> {
68    if signature.asyncness.is_none() {
69        return Some("it is not `async`");
70    }
71    if has_type_parameters(signature) {
72        return Some("it is generic");
73    }
74    if !signature.inputs.iter().all(input_crosses) {
75        return Some("it takes `&mut`, `self` by value or a type holding a reference");
76    }
77    if !answers_owned_result(&signature.output) {
78        return Some("it does not answer `Result<…>` of an owned type");
79    }
80    None
81}
82
83fn has_type_parameters(signature: &Signature) -> bool {
84    signature
85        .generics
86        .params
87        .iter()
88        .any(|parameter| !matches!(parameter, GenericParam::Lifetime(_)))
89}
90
91/// `&self`, an owned value or a shared reference to one, which the server reads back owned.
92fn input_crosses(input: &FnArg) -> bool {
93    match input {
94        FnArg::Receiver(receiver) => matches!(receiver.kind, ReceiverKind::Reference(_, _, None)),
95        FnArg::Typed(typed) => match typed.ty.as_ref() {
96            Type::Reference(reference) => reference.mutability.is_none() && owned(&reference.elem),
97            other => owned(other),
98        },
99    }
100}
101
102fn answers_owned_result(output: &ReturnType) -> bool {
103    let ReturnType::Type(_, output) = output else {
104        return false;
105    };
106    let Type::Path(path) = output.as_ref() else {
107        return false;
108    };
109    path.path
110        .segments
111        .last()
112        .is_some_and(|last| last.ident == "Result" && first_type_argument(last).is_some_and(owned))
113}
114
115fn owned(ty: &Type) -> bool {
116    let mut borrows = Borrows(false);
117    borrows.visit_type(ty);
118    !borrows.0
119}
120
121/// A lifetime, a reference or `Self` anywhere in a type.
122struct Borrows(bool);
123
124impl Visit<'_> for Borrows {
125    fn visit_lifetime(&mut self, _lifetime: &syn::Lifetime) {
126        self.0 = true;
127    }
128
129    fn visit_type_reference(&mut self, _reference: &syn::TypeReference) {
130        self.0 = true;
131    }
132
133    fn visit_path_segment(&mut self, segment: &syn::PathSegment) {
134        self.0 |= segment.ident == "Self";
135        syn::visit::visit_path_segment(self, segment);
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn read(source: &str) -> ContractTrait {
144        let item: Item = syn::parse_str(source).unwrap();
145        contract_trait(
146            &item,
147            Path::new("contract/src/lib.rs"),
148            &["orders".to_owned()],
149        )
150        .unwrap()
151    }
152
153    #[test]
154    fn a_contract_crosses_apps_only_when_every_method_can() {
155        let remote = read(
156            "#[sofie::contract] pub trait Orders: Send + Sync {
157                async fn total(&self, order: Uuid, names: &[String], note: &str) -> Result<Money>;
158            }",
159        );
160        let local = read(
161            "#[contract] pub trait Ledger {
162                async fn rewrite(&self, entries: &mut Vec<u8>) -> Result<()>;
163                fn now(&self) -> Result<i64>;
164                async fn borrowed(&self) -> Result<&'static str>;
165            }",
166        );
167
168        assert_eq!(remote.path, ["orders", "Orders"]);
169        assert!(remote.crosses_apps());
170        let reasons: Vec<Option<&str>> = local
171            .methods
172            .iter()
173            .map(|method| method.stays_local)
174            .collect();
175        assert_eq!(
176            reasons,
177            [
178                Some("it takes `&mut`, `self` by value or a type holding a reference"),
179                Some("it is not `async`"),
180                Some("it does not answer `Result<…>` of an owned type"),
181            ]
182        );
183    }
184
185    #[test]
186    fn an_unmarked_trait_is_no_contract() {
187        let item: Item = syn::parse_str("pub trait Plain {}").unwrap();
188
189        assert!(contract_trait(&item, Path::new("lib.rs"), &[]).is_none());
190    }
191}