Skip to main content

ravenrs_app_derive/
lib.rs

1//! The derives of `ravenrs-app`, which re-exports them. See `BusinessObject` there.
2
3use proc_macro::TokenStream;
4use proc_macro2::TokenStream as Tokens;
5use quote::{quote, quote_spanned};
6use ravenrs_app_spec::{
7    ActionSpec, AppearancePiece, AppearanceSpec, ChoiceSpec, ContextSpec, DataAccessSpec, FieldSpec,
8    KindSpec, RuleKindSpec, RuleSpec, SelectionSpec, SeveritySpec, TintSpec, parse_enum, parse_object,
9};
10use syn::spanned::Spanned;
11use syn::{DeriveInput, Error, Result, parse_macro_input};
12
13/// Declares a struct as a business object: an entity for the model, and the way to and from
14/// a record.
15///
16/// On the struct, `#[object(caption = "...", navigation = "...", icon = "...",
17/// list_criteria = "...", form_columns = 2, cloneable, transient, checks_on_leave, list_editable,
18/// data_access = "server", form_actions = "name", focus_order = "a,b")]`, where `data_access` is
19/// `client`, `server` or `data_view`, and `focus_order` the fields the form goes through first.
20///
21/// On a field, `#[field(...)]`:
22///
23/// * `key` numbered by the application, or `typed_key` typed by people. Exactly one field has one.
24///   With `guid`, a `key` is made up when a record is created instead of being numbered.
25/// * `caption = "..."` what people see.
26/// * `default_property` what names a record. `read_only`. `no_list`, `no_detail`, `lookup`.
27/// * What it holds, when the type does not say: `money`, `long`, `color`, `date`, `guid`,
28///   `decimals = 2`, `reference = "Entity"`, `collection = "Entity"`, `tags = "a,b"`.
29/// * `calculated = "[a] + [b]"`, in the criteria language, checked here.
30/// * `default = ...` what a new record starts with.
31/// * `required`, `unique`, `min = 1`, `max = 10`, `matches = "^[A-Z]+$"`: what it has to meet on
32///   saving, and `message = "..."` what to say instead of the usual words.
33/// * On a form: `editor = "..."`, `placeholder = "..."`, `hint = "..."`, `wide`, `autofocus`,
34///   `group = "..."` for the titled group it goes in, `edit_mask = "(00) 0000-0000"`,
35///   `predefined = "a,b"` for values to pick from, `criteria_of = "Entity"` for a filter builder.
36/// * For screen readers: `accessible_name = "..."` instead of the caption, and
37///   `accessible_description = "..."` instead of the hint.
38/// * `display_format = "{:.2} %"`, how its value shows when it is not being edited.
39/// * On a reference: `lookup_filter = "[flow] = ?flow"`, `none_caption = "..."`. On a collection:
40///   `back_reference = "..."`, `aggregated`. `secret` never leaves a secured space. `non_cloneable` stays behind on cloning, and
41///   `most_bytes = 1024` limits a picture or a file.
42///
43/// On the struct, as many as needed:
44///
45/// * `#[rule(criteria = "[a] <= [b]", message = "...", context = "Pay", target = "[c] > 0")]`, or
46///   `#[rule(unique = "a,b")]`. With `warning` people can go on anyway, and with `information` it
47///   only leaves a note. A message can say `{field}` and `{reference.field}`.
48/// * `#[appearance(names = "a,b*", criteria = "...", font = "danger", back = "warning", bold,
49///   enabled = false, visible = false, priority = 1, context = "list", action)]`. With `layout`,
50///   the names are groups of a form; with `action`, actions. A context is `list`, `detail` or a
51///   view's id. Tints are `danger`, `warning`, `success`, `accent` and `muted`.
52/// * `#[action(caption = "...", set = "state = 'done'; finished = Today()", enabled = "...",
53///   selection = "many", confirm = "...", id = "...")]`: an action that sets fields of the records
54///   selected, each read from the record as it was, and keeps them once they meet the rules.
55///   `selection` is `one`, `many` (the default) or `any`; `enabled` is what every record selected
56///   has to meet for it to be on. Both expressions are checked here.
57/// * `#[variant(id = "...", caption = "...", filter = "...", columns = "a,b")]` and
58///   `#[list_filter(id = "...", caption = "...", filter = "...")]`, for people to pick on the list.
59#[proc_macro_derive(
60    BusinessObject,
61    attributes(object, field, rule, appearance, variant, list_filter, action)
62)]
63pub fn derive_business_object(input: TokenStream) -> TokenStream {
64    let input = parse_macro_input!(input as DeriveInput);
65    business_object(&input).unwrap_or_else(Error::into_compile_error).into()
66}
67
68/// Declares an enum with no data as the options of a field: `#[option(caption = "...", code = "...")]`
69/// on a variant. The first variant is what a new record starts with.
70#[proc_macro_derive(BusinessEnum, attributes(option))]
71pub fn derive_business_enum(input: TokenStream) -> TokenStream {
72    let input = parse_macro_input!(input as DeriveInput);
73    business_enum(&input).unwrap_or_else(Error::into_compile_error).into()
74}
75
76fn kind_tokens(kind: &KindSpec) -> Tokens {
77    match kind {
78        KindSpec::Money => quote!(::ravenrs_app::FieldKind::Money),
79        KindSpec::LongText => quote!(::ravenrs_app::FieldKind::LongText),
80        KindSpec::Color => quote!(::ravenrs_app::FieldKind::Color),
81        KindSpec::Date => quote!(::ravenrs_app::FieldKind::Date),
82        KindSpec::Guid => quote!(::ravenrs_app::FieldKind::Guid),
83        KindSpec::Tags(names) => quote!(::ravenrs_app::FieldKind::Tags(::std::vec![
84            #(::ravenrs_app::EnumOption::new(#names, #names)),*
85        ])),
86        KindSpec::Decimal(places) => quote!(::ravenrs_app::FieldKind::Decimal(#places)),
87        KindSpec::Reference(target) => quote!(::ravenrs_app::FieldKind::Reference(#target.to_owned())),
88        KindSpec::Collection(target) => quote!(::ravenrs_app::FieldKind::Collection(#target.to_owned())),
89    }
90}
91
92fn rule_tokens(rule: &RuleSpec) -> Tokens {
93    let mut tokens = match &rule.kind {
94        RuleKindSpec::Required(name) => quote!(::ravenrs_app::Rule::required(#name)),
95        RuleKindSpec::Unique(name) => quote!(::ravenrs_app::Rule::unique(#name)),
96        RuleKindSpec::Range { field, min, max } => {
97            let limit = |value: &Option<Box<syn::Expr>>| match value {
98                Some(value) => quote!(::std::option::Option::Some((#value) as f64)),
99                None => quote!(::std::option::Option::None),
100            };
101            let (min, max) = (limit(min), limit(max));
102            quote!(::ravenrs_app::Rule::range(#field, #min, #max))
103        }
104        RuleKindSpec::Matches(name, pattern) => quote!(::ravenrs_app::Rule::matches(#name, #pattern)),
105        RuleKindSpec::Criteria(id, expression) => quote!(::ravenrs_app::Rule::criteria(#id, #expression)),
106        RuleKindSpec::UniqueTogether(id, fields) => quote!(::ravenrs_app::Rule::new(
107            #id,
108            ::ravenrs_app::RuleKind::UniqueTogether(::std::vec![#(#fields.to_owned()),*]),
109        )),
110    };
111    if let Some(text) = &rule.message {
112        tokens = quote!(#tokens.message(#text));
113    }
114    if let Some(text) = &rule.context {
115        tokens = quote!(#tokens.contexts([#text]));
116    }
117    if let Some(expression) = &rule.target {
118        tokens = quote!(#tokens.target_criteria(#expression));
119    }
120    match rule.severity {
121        SeveritySpec::Error => tokens,
122        SeveritySpec::Warning => quote!(#tokens.warning()),
123        SeveritySpec::Information => quote!(#tokens.information()),
124    }
125}
126
127fn tint(color: TintSpec) -> Tokens {
128    match color {
129        TintSpec::Danger => quote!(::ravenrs_app::Tint::Danger),
130        TintSpec::Warning => quote!(::ravenrs_app::Tint::Warning),
131        TintSpec::Success => quote!(::ravenrs_app::Tint::Success),
132        TintSpec::Accent => quote!(::ravenrs_app::Tint::Accent),
133        TintSpec::Muted => quote!(::ravenrs_app::Tint::Muted),
134    }
135}
136
137fn appearance_tokens(rule: &AppearanceSpec) -> Tokens {
138    let (id, targets) = (&rule.id, &rule.targets);
139    let pieces = rule.pieces.iter().map(|piece| match piece {
140        AppearancePiece::Criteria(expression) => quote!(.criteria(#expression)),
141        AppearancePiece::Font(color) => {
142            let color = tint(*color);
143            quote!(.font(#color))
144        }
145        AppearancePiece::Back(color) => {
146            let color = tint(*color);
147            quote!(.back(#color))
148        }
149        AppearancePiece::Bold => quote!(.bold()),
150        AppearancePiece::Enabled(on) => quote!(.enabled(#on)),
151        AppearancePiece::Visible(on) => quote!(.visible(#on)),
152        AppearancePiece::Priority(priority) => quote!(.priority(#priority)),
153        AppearancePiece::Action => quote!(.target(::ravenrs_app::AppearanceTarget::Action)),
154        AppearancePiece::Layout => quote!(.target(::ravenrs_app::AppearanceTarget::Layout)),
155        AppearancePiece::Context(context) => {
156            let context = match context {
157                ContextSpec::List => quote!(::ravenrs_app::AppearanceContext::List),
158                ContextSpec::Detail => quote!(::ravenrs_app::AppearanceContext::Detail),
159                ContextSpec::View(view) => quote!(::ravenrs_app::AppearanceContext::View(#view.to_owned())),
160            };
161            quote!(.context(#context))
162        }
163    });
164    quote!(::ravenrs_app::AppearanceRule::new(#id, [#(#targets),*]) #(#pieces)*)
165}
166
167fn field_tokens(field: &FieldSpec) -> Tokens {
168    let (ty, attrs) = (&field.ty, &field.settings);
169    let name = field.ident.to_string();
170    let kind = attrs
171        .kind
172        .as_ref()
173        .map(kind_tokens)
174        .unwrap_or_else(|| quote_spanned!(ty.span()=> <#ty as ::ravenrs_app::FieldValue>::kind()));
175    let mut info = quote!(::ravenrs_app::FieldInfo::new(#name, #kind));
176    let mut then = |piece: Tokens| info = quote!(#info #piece);
177    if attrs.key {
178        then(quote!(.key()));
179    }
180    if attrs.typed_key {
181        then(quote!(.typed_key()));
182    }
183    if let Some(caption) = &attrs.caption {
184        then(quote!(.caption(#caption)));
185    }
186    if attrs.default_property {
187        then(quote!(.default_property()));
188    }
189    if attrs.read_only {
190        then(quote!(.read_only()));
191    }
192    if attrs.no_list {
193        then(quote!(.in_list(false)));
194    }
195    if attrs.no_detail {
196        then(quote!(.in_detail(false)));
197    }
198    if attrs.lookup {
199        then(quote!(.in_lookup(true)));
200    }
201    if let Some(expression) = &attrs.calculated {
202        then(quote!(.calculated(#expression)));
203    }
204    if let Some(value) = &attrs.default {
205        then(quote!(.default_value(#value)));
206    }
207    if let Some(editor) = &attrs.editor {
208        then(quote!(.editor(#editor)));
209    }
210    if let Some(text) = &attrs.placeholder {
211        then(quote!(.placeholder(#text)));
212    }
213    if let Some(text) = &attrs.hint {
214        then(quote!(.hint(#text)));
215    }
216    if let Some(text) = &attrs.accessible_name {
217        then(quote!(.accessible_name(#text)));
218    }
219    if let Some(text) = &attrs.accessible_description {
220        then(quote!(.accessible_description(#text)));
221    }
222    if let Some(text) = &attrs.lookup_filter {
223        then(quote!(.lookup_filter(#text)));
224    }
225    if let Some(text) = &attrs.none_caption {
226        then(quote!(.none_caption(#text)));
227    }
228    if let Some(text) = &attrs.back_reference {
229        then(quote!(.back_reference(#text)));
230    }
231    if let Some(bytes) = &attrs.most_bytes {
232        then(quote!(.most_bytes(#bytes)));
233    }
234    if attrs.wide {
235        then(quote!(.wide()));
236    }
237    if attrs.autofocus {
238        then(quote!(.autofocus()));
239    }
240    if attrs.secret {
241        then(quote!(.secret()));
242    }
243    if attrs.non_cloneable {
244        then(quote!(.non_cloneable()));
245    }
246    if attrs.aggregated {
247        then(quote!(.aggregated()));
248    }
249    if let Some(text) = &attrs.group {
250        then(quote!(.group(#text)));
251    }
252    if let Some(text) = &attrs.display_format {
253        then(quote!(.display_format(#text)));
254    }
255    if let Some(text) = &attrs.edit_mask {
256        then(quote!(.edit_mask(#text)));
257    }
258    if let Some(values) = &attrs.predefined {
259        then(quote!(.predefined([#(#values),*])));
260    }
261    if let Some(text) = &attrs.criteria_of {
262        then(quote!(.criteria_of(#text)));
263    }
264    info
265}
266
267fn choice_tokens(choice: &ChoiceSpec) -> Tokens {
268    match choice {
269        ChoiceSpec::Variant { id, caption, filter, columns } => {
270            let filter = filter.as_ref().map(|text| quote!(.filter(#text)));
271            let shown = (!columns.is_empty()).then(|| quote!(.columns([#(#columns),*])));
272            quote!(.variant(::ravenrs_app::ViewVariant::new(#id, #caption) #filter #shown))
273        }
274        ChoiceSpec::ListFilter { id, caption, filter } => {
275            let filter = filter.as_ref().map(|text| quote!(.criteria(#text)));
276            quote!(.filter(::ravenrs_app::ListFilter::new(#id, #caption) #filter))
277        }
278    }
279}
280
281fn action_tokens(action: &ActionSpec) -> Tokens {
282    let (caption, set) = (&action.caption, &action.set);
283    let mut tokens = quote!(::ravenrs_app::RecordAction::new(#caption).set(#set));
284    if let Some(id) = &action.id {
285        tokens = quote!(#tokens.id(#id));
286    }
287    if let Some(selection) = action.selection {
288        let selection = match selection {
289            SelectionSpec::One => quote!(RequireSingle),
290            SelectionSpec::Many => quote!(RequireMultiple),
291            SelectionSpec::Any => quote!(Independent),
292        };
293        tokens = quote!(#tokens.selection(::ravenrs_app::Selection::#selection));
294    }
295    if let Some(enabled) = &action.enabled {
296        tokens = quote!(#tokens.enabled(#enabled));
297    }
298    if let Some(question) = &action.confirm {
299        tokens = quote!(#tokens.confirm(#question));
300    }
301    tokens
302}
303
304fn business_object(input: &DeriveInput) -> Result<Tokens> {
305    let spec = parse_object(input)?;
306    let name = &spec.name;
307    let object = &spec.settings;
308    let infos: Vec<Tokens> = spec.fields.iter().map(field_tokens).collect();
309    let rules: Vec<Tokens> = spec.rules.iter().map(rule_tokens).collect();
310    let appearance: Vec<Tokens> = spec.appearance.iter().map(appearance_tokens).collect();
311    let choices: Vec<Tokens> = spec.choices.iter().map(choice_tokens).collect();
312    let actions: Vec<Tokens> = spec.actions.iter().map(action_tokens).collect();
313    let to_values = spec.fields.iter().map(|field| {
314        let (ident, ty) = (&field.ident, &field.ty);
315        let text = ident.to_string();
316        quote_spanned!(ty.span()=>
317            (#text.to_owned(), <#ty as ::ravenrs_app::FieldValue>::to_value(&self.#ident))
318        )
319    });
320    let from_values = spec.fields.iter().map(|field| {
321        let (ident, ty) = (&field.ident, &field.ty);
322        let text = ident.to_string();
323        quote_spanned!(ty.span()=>
324            #ident: <#ty as ::ravenrs_app::FieldValue>::from_value(object.get(#text))
325        )
326    });
327    let entity = name.to_string();
328    let mut info = quote!(::ravenrs_app::EntityInfo::new(#entity));
329    if let Some(caption) = &object.caption {
330        info = quote!(#info.caption(#caption));
331    }
332    if let Some(place) = &object.navigation {
333        info = quote!(#info.navigation(#place));
334    }
335    if let Some(icon) = &object.icon {
336        info = quote!(#info.icon(#icon));
337    }
338    if let Some(text) = &object.list_criteria {
339        info = quote!(#info.list_criteria(#text));
340    }
341    if object.list_editable {
342        info = quote!(#info.list_editable());
343    }
344    if let Some(access) = object.data_access {
345        let access = match access {
346            DataAccessSpec::Server => quote!(::ravenrs_app::DataAccess::Server),
347            DataAccessSpec::DataView => quote!(::ravenrs_app::DataAccess::DataView),
348            DataAccessSpec::Client => quote!(::ravenrs_app::DataAccess::Client),
349        };
350        info = quote!(#info.data_access(#access));
351    }
352    for name in &object.form_actions {
353        info = quote!(#info.form_actions(#name));
354    }
355    if let Some(columns) = &object.form_columns {
356        info = quote!(#info.form_columns(#columns));
357    }
358    if !object.focus_order.is_empty() {
359        let fields = &object.focus_order;
360        info = quote!(#info.focus_order([#(#fields),*]));
361    }
362    if object.cloneable {
363        info = quote!(#info.cloneable());
364    }
365    if object.transient {
366        info = quote!(#info.transient());
367    }
368    if object.checks_on_leave {
369        info = quote!(#info.checks_on_leave());
370    }
371    Ok(quote! {
372        impl ::ravenrs_app::BusinessObject for #name {
373            fn entity_info() -> ::ravenrs_app::EntityInfo {
374                #info #(.field(#infos))* #(.rule(#rules))* #(.appearance(#appearance))* #(#choices)* #(.action(#actions))*
375            }
376
377            fn to_object(&self) -> ::ravenrs_app::Object {
378                ::ravenrs_app::Object::from_values(
379                    #entity,
380                    ::std::collections::BTreeMap::from([#(#to_values),*]),
381                )
382            }
383
384            fn from_object(object: &::ravenrs_app::Object) -> Self {
385                Self { #(#from_values),* }
386            }
387        }
388    })
389}
390
391fn business_enum(input: &DeriveInput) -> Result<Tokens> {
392    let spec = parse_enum(input)?;
393    let name = &spec.name;
394    let Some(first) = spec.options.first() else {
395        return Err(Error::new(name.span(), "a business enum needs at least one option"));
396    };
397    let first = &first.ident;
398    let options = spec.options.iter().map(|o| {
399        let (code, caption) = (&o.code, &o.caption);
400        quote!(::ravenrs_app::EnumOption::new(#code, #caption))
401    });
402    let codes = spec.options.iter().map(|o| {
403        let (ident, code) = (&o.ident, &o.code);
404        quote!(Self::#ident => #code)
405    });
406    let parses = spec.options.iter().map(|o| {
407        let (ident, code) = (&o.ident, &o.code);
408        quote!(#code => Self::#ident)
409    });
410    Ok(quote! {
411        impl ::ravenrs_app::FieldValue for #name {
412            fn kind() -> ::ravenrs_app::FieldKind {
413                ::ravenrs_app::FieldKind::Enum(::std::vec![#(#options),*])
414            }
415
416            fn to_value(&self) -> ::ravenrs_app::Value {
417                ::ravenrs_app::Value::Text(match self { #(#codes),* }.to_owned())
418            }
419
420            fn from_value(value: ::ravenrs_app::Value) -> Self {
421                match value {
422                    ::ravenrs_app::Value::Text(code) => match code.as_str() {
423                        #(#parses,)*
424                        _ => Self::#first,
425                    },
426                    _ => Self::#first,
427                }
428            }
429        }
430    })
431}