1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use syn::ext::IdentExt;
5use syn::punctuated::Punctuated;
6use syn::visit::{self, Visit};
7use syn::{
8 Attribute, Expr, ExprCall, ExprMethodCall, Fields, ImplItem, Item, ItemEnum, ItemFn, ItemImpl,
9 ItemStruct, Macro, ReturnType, Token,
10};
11
12use crate::FeatureCode;
13use crate::constraints::described;
14use crate::effects::Effects;
15use crate::entitlements::{EntitlementDeclaration, entitlement_of};
16use crate::handlers::HandlerScope;
17use crate::naming::{DEFAULT_BEHAVIORS, behavior_columns, stored_name};
18use crate::syntax::{
19 doc_line, last_name, line_of, macro_expressions, path_ends_with, segments, string_literal,
20 type_last_name,
21};
22use crate::telemetry::TelemetryCode;
23
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct TypeName {
26 pub name: String,
28 pub arguments: Vec<TypeName>,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct Function {
33 pub path: Vec<String>,
34 pub arguments: Vec<TypeName>,
35 pub output: Option<TypeName>,
36 pub renders: Vec<ScreenRender>,
37 pub doc: Option<String>,
38 pub effects: Effects,
39 pub file: PathBuf,
40 pub line: usize,
41}
42
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct TypeMethod {
45 pub owner: String,
46 pub name: String,
47 pub implements: Option<String>,
49 pub doc: Option<String>,
50 pub effects: Effects,
51 pub file: PathBuf,
52 pub line: usize,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct ScreenRender {
57 pub screen: Option<String>,
58 pub line: usize,
59}
60
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct FieldDeclaration {
63 pub name: String,
64 pub ty: TypeName,
65 pub constraints: Vec<String>,
66 pub bounds: Vec<String>,
68 pub line: usize,
69}
70
71const CONSTRAINT_ATTRIBUTES: [&str; 6] = ["min", "max", "length", "range", "required", "unique"];
73
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct EnumDeclaration {
76 pub name: String,
77 pub path: Vec<String>,
79 pub stored_names: Vec<String>,
81 pub variants: Vec<EnumVariant>,
82}
83
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct EnumVariant {
86 pub name: String,
87 pub to: Vec<String>,
88 pub tone: Option<String>,
90}
91
92impl EnumDeclaration {
93 #[must_use]
94 pub fn has_transitions(&self) -> bool {
95 self.variants.iter().any(|variant| !variant.to.is_empty())
96 }
97}
98
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct ReadModel {
101 pub name: String,
102 pub from: Option<String>,
103 pub fields: Vec<FieldDeclaration>,
104 pub file: PathBuf,
105 pub line: usize,
106}
107
108#[derive(Clone, Debug, PartialEq, Eq)]
109pub struct Aggregate {
110 pub name: String,
111 pub path: Vec<String>,
112 pub mirror: Option<Vec<String>>,
113 pub fields: Vec<FieldDeclaration>,
114 pub behaviors: Vec<String>,
115 pub exposed: Vec<String>,
116 pub owner: Option<String>,
117 pub owner_on_struct: bool,
119 pub doc: Option<String>,
120 pub file: PathBuf,
121 pub line: usize,
122}
123
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct InputDeclaration {
126 pub name: String,
127 pub path: Vec<String>,
128 pub from: Option<String>,
129 pub fields: Vec<FieldDeclaration>,
130 pub unconstrained: Vec<String>,
131 pub file: PathBuf,
132 pub line: usize,
133}
134
135impl InputDeclaration {
136 #[must_use]
137 pub fn inherited<'a>(&self, field: &str, code: &'a Code) -> Option<&'a FieldDeclaration> {
138 let from = self.from.as_deref()?;
139 code.aggregates
140 .iter()
141 .find(|aggregate| aggregate.name == from)?
142 .fields
143 .iter()
144 .find(|declared| declared.name == field)
145 }
146}
147
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct RawSql {
150 pub module_path: Vec<String>,
151 pub file: PathBuf,
152 pub line: usize,
153 pub text: Option<(String, usize)>,
155}
156
157#[derive(Clone, Debug, PartialEq, Eq)]
159pub struct KeyLiteral {
160 pub key: String,
161 pub file: PathBuf,
162 pub line: usize,
163}
164
165#[derive(Clone, Debug, Default, PartialEq, Eq)]
166pub struct Code {
167 pub functions: Vec<Function>,
168 pub methods: Vec<TypeMethod>,
169 pub read_models: Vec<ReadModel>,
170 pub aggregates: Vec<Aggregate>,
171 pub inputs: Vec<InputDeclaration>,
172 pub enums: Vec<EnumDeclaration>,
173 pub raw_sql: Vec<RawSql>,
174 pub translation_keys: Vec<KeyLiteral>,
175 pub features: FeatureCode,
176 pub entitlements: Vec<EntitlementDeclaration>,
177 pub telemetry: TelemetryCode,
178}
179
180impl Code {
181 #[must_use]
182 pub fn function(&self, path: &[String]) -> Option<&Function> {
183 self.functions.iter().find(|function| function.path == path)
184 }
185
186 #[must_use]
187 pub fn read_model(&self, name: &str) -> Option<&ReadModel> {
188 self.read_models.iter().find(|model| model.name == name)
189 }
190
191 #[must_use]
192 pub fn input_of(&self, function: &Function) -> Option<&InputDeclaration> {
193 function.arguments.iter().find_map(|argument| {
194 self.inputs
195 .iter()
196 .find(|input| argument.arguments.is_empty() && input.name == argument.name)
197 })
198 }
199
200 #[must_use]
202 pub fn query_of(&self, function: &Function) -> Option<&InputDeclaration> {
203 function.arguments.iter().find_map(|argument| {
204 let (("Query", [parameters]) | ("Listing", [_, parameters])) =
205 (argument.name.as_str(), argument.arguments.as_slice())
206 else {
207 return None;
208 };
209 self.inputs
210 .iter()
211 .find(|input| parameters.arguments.is_empty() && input.name == parameters.name)
212 })
213 }
214
215 pub(crate) fn contract_type(&self, ty: &TypeName) -> Option<String> {
217 let name = ty.name.as_str();
218 match ty.arguments.as_slice() {
219 [inner] if name == "Option" => Some(format!("Option<{}>", self.contract_type(inner)?)),
220 [_] if name == "Id" => Some("sofie::db::Uuid".to_owned()),
221 [] if self.enums.iter().any(|declared| declared.name == name) => {
222 Some("String".to_owned())
223 }
224 [] if CONTRACT_VALUES.contains(&name) => Some(format!("sofie::values::{name}")),
225 [] if CONTRACT_SCALARS.contains(&name) => Some(name.to_owned()),
226 _ => None,
227 }
228 }
229
230 pub(crate) fn collect(&mut self, item: &Item, file: &Path, scope: &HandlerScope) {
231 let module_path = scope.module_path();
232 self.features.collect(item, file, module_path);
233 self.entitlements
234 .extend(entitlement_of(item, file, module_path));
235 self.telemetry.collect(item, file, module_path);
236 let mut calls = SqlAndKeyCalls {
237 module_path,
238 file,
239 code: self,
240 };
241 calls.visit_item(item);
242 match item {
243 Item::Fn(function) => self.functions.push(function_of(function, file, scope)),
244 Item::Impl(implementation) => {
245 self.methods.extend(methods_of(implementation, file, scope));
246 }
247 Item::Struct(structure) if derives(&structure.attrs, "ReadModel") => {
248 self.read_models.push(read_model_of(structure, file));
249 }
250 Item::Struct(structure) if derives(&structure.attrs, "Aggregate") => {
251 self.aggregates
252 .push(aggregate_of(structure, file, module_path));
253 }
254 Item::Struct(structure) if derives(&structure.attrs, "Input") => {
255 self.inputs.push(input_of(structure, file, module_path));
256 }
257 Item::Enum(enumeration) if derives(&enumeration.attrs, "Enum") => {
258 self.enums.push(enum_of(enumeration, module_path));
259 }
260 _ => {}
261 }
262 }
263}
264
265const CONTRACT_VALUES: [&str; 19] = [
266 "Coordinate",
267 "Currency",
268 "Date",
269 "Email",
270 "Energy",
271 "Instant",
272 "Length",
273 "LocalDateTime",
274 "Locale",
275 "Money",
276 "Percentage",
277 "PersonName",
278 "PhoneNumber",
279 "Slug",
280 "TimeOfDay",
281 "TimeZone",
282 "Url",
283 "Weekday",
284 "Weight",
285];
286
287const CONTRACT_SCALARS: [&str; 7] = ["String", "bool", "i16", "i32", "i64", "u16", "u32"];
288
289impl Aggregate {
290 pub fn contract_fields(&self, code: &Code) -> Result<Vec<(String, String)>, &FieldDeclaration> {
291 self.fields
292 .iter()
293 .filter(|field| self.exposed.contains(&field.name))
294 .map(|field| match code.contract_type(&field.ty) {
295 Some(ty) => Ok((field.name.clone(), ty)),
296 None => Err(field),
297 })
298 .collect()
299 }
300
301 pub fn all_behaviors(&self) -> impl Iterator<Item = &str> {
302 DEFAULT_BEHAVIORS
303 .into_iter()
304 .chain(self.behaviors.iter().map(String::as_str))
305 }
306
307 #[must_use]
308 pub fn is_owned(&self) -> bool {
309 self.owner.is_some() || self.owner_on_struct
310 }
311
312 #[must_use]
313 pub fn soft_deletes(&self) -> bool {
314 self.behaviors
315 .iter()
316 .any(|behavior| behavior == "SoftDelete")
317 }
318
319 #[must_use]
320 pub fn columns(&self) -> Vec<String> {
321 let behaviors = self.all_behaviors().flat_map(behavior_columns);
322 self.fields
323 .iter()
324 .map(|field| field.name.clone())
325 .chain(behaviors.map(|column| column.name.to_owned()))
326 .collect()
327 }
328}
329
330impl TypeName {
331 #[must_use]
333 pub fn record(&self) -> Option<&str> {
334 match (self.name.as_str(), self.arguments.as_slice()) {
335 ("Option", [inner]) => inner.record(),
336 ("Id", [target]) => Some(&target.name),
337 (name, []) => name
338 .strip_suffix("Id")
339 .filter(|stem| stem.starts_with(char::is_uppercase)),
340 _ => None,
341 }
342 }
343
344 #[must_use]
345 pub fn without_result(&self) -> &Self {
346 match (self.name.as_str(), self.arguments.first()) {
347 ("Result", Some(inner)) => inner,
348 _ => self,
349 }
350 }
351
352 pub(crate) fn of(ty: &syn::Type) -> Self {
353 let (name, arguments) = match ty {
354 syn::Type::Path(path) if path.qself.is_none() => return Self::of_path(&path.path),
355 syn::Type::Tuple(tuple) if tuple.elems.is_empty() => ("()", Vec::new()),
356 syn::Type::Tuple(tuple) => ("(…)", tuple.elems.iter().map(Self::of).collect()),
357 syn::Type::Reference(reference) => ("&", vec![Self::of(&reference.elem)]),
358 syn::Type::Paren(inner) => return Self::of(&inner.elem),
359 syn::Type::TraitObject(object) => ("dyn", trait_bounds(&object.bounds)),
360 _ => ("_", Vec::new()),
361 };
362 Self {
363 name: name.to_owned(),
364 arguments,
365 }
366 }
367
368 fn of_path(path: &syn::Path) -> Self {
369 let Some(last) = path.segments.last() else {
370 return Self {
371 name: "_".to_owned(),
372 arguments: Vec::new(),
373 };
374 };
375 let arguments = match &last.arguments {
376 syn::PathArguments::AngleBracketed(bracketed) => bracketed
377 .args
378 .iter()
379 .filter_map(|argument| match argument {
380 syn::GenericArgument::Type(ty) => Some(Self::of(ty)),
381 _ => None,
382 })
383 .collect(),
384 _ => Vec::new(),
385 };
386 Self {
387 name: last.ident.unraw().to_string(),
388 arguments,
389 }
390 }
391}
392
393impl fmt::Display for TypeName {
394 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
395 let arguments: Vec<String> = self.arguments.iter().map(ToString::to_string).collect();
396 match self.name.as_str() {
397 "(…)" => write!(formatter, "({})", arguments.join(", ")),
398 "&" => write!(formatter, "&{}", arguments.join("")),
399 "dyn" => write!(formatter, "dyn {}", arguments.join(" + ")),
400 name if arguments.is_empty() => formatter.write_str(name),
401 name => write!(formatter, "{name}<{}>", arguments.join(", ")),
402 }
403 }
404}
405
406fn trait_bounds(bounds: &Punctuated<syn::TypeParamBound, Token![+]>) -> Vec<TypeName> {
407 bounds
408 .iter()
409 .filter_map(|bound| match bound {
410 syn::TypeParamBound::Trait(bound) => Some(TypeName::of_path(&bound.path)),
411 _ => None,
412 })
413 .collect()
414}
415
416fn function_of(function: &ItemFn, file: &Path, scope: &HandlerScope) -> Function {
417 let mut path = scope.module_path().to_vec();
418 path.push(function.sig.ident.to_string());
419 let mut renders = ScreenRenders::default();
420 renders.visit_block(&function.block);
421 let arguments = function
422 .sig
423 .inputs
424 .iter()
425 .filter_map(|argument| match argument {
426 syn::FnArg::Typed(typed) => Some(TypeName::of(&typed.ty)),
427 syn::FnArg::Receiver(_) => None,
428 })
429 .collect();
430 Function {
431 path,
432 arguments,
433 output: match &function.sig.output {
434 ReturnType::Default => None,
435 ReturnType::Type(_, ty) => Some(TypeName::of(ty)),
436 },
437 renders: renders.found,
438 doc: doc_line(&function.attrs),
439 effects: Effects::of(&function.sig, &function.block, None, scope),
440 file: file.to_path_buf(),
441 line: line_of(&function.sig.ident),
442 }
443}
444
445fn methods_of(implementation: &ItemImpl, file: &Path, scope: &HandlerScope) -> Vec<TypeMethod> {
446 let Some(owner) = type_last_name(&implementation.self_ty) else {
447 return Vec::new();
448 };
449 let implements = implementation
450 .trait_
451 .as_ref()
452 .and_then(|(.., path, _)| last_name(path));
453 implementation
454 .items
455 .iter()
456 .filter_map(|item| match item {
457 ImplItem::Fn(method) => Some(TypeMethod {
458 owner: owner.clone(),
459 name: method.sig.ident.unraw().to_string(),
460 implements: implements.clone(),
461 doc: doc_line(&method.attrs),
462 effects: Effects::of(&method.sig, &method.block, Some(&owner), scope),
463 file: file.to_path_buf(),
464 line: line_of(&method.sig.ident),
465 }),
466 _ => None,
467 })
468 .collect()
469}
470
471fn enum_of(enumeration: &ItemEnum, module_path: &[String]) -> EnumDeclaration {
472 let variants: Vec<EnumVariant> = enumeration
473 .variants
474 .iter()
475 .map(|variant| EnumVariant {
476 name: variant.ident.unraw().to_string(),
477 tone: variant
478 .attrs
479 .iter()
480 .filter(|attribute| attribute.path().is_ident("tone"))
481 .find_map(|attribute| attribute.parse_args::<syn::Ident>().ok())
482 .map(|tone| tone.to_string()),
483 to: variant
484 .attrs
485 .iter()
486 .filter(|attribute| attribute.path().is_ident("to"))
487 .filter_map(|attribute| {
488 attribute
489 .parse_args_with(Punctuated::<syn::Ident, Token![,]>::parse_terminated)
490 .ok()
491 })
492 .flatten()
493 .map(|target| target.unraw().to_string())
494 .collect(),
495 })
496 .collect();
497 let name = enumeration.ident.unraw().to_string();
498 EnumDeclaration {
499 path: [module_path, std::slice::from_ref(&name)].concat(),
500 name,
501 stored_names: variants
502 .iter()
503 .map(|variant| stored_name(&variant.name))
504 .collect(),
505 variants,
506 }
507}
508
509fn read_model_of(structure: &ItemStruct, file: &Path) -> ReadModel {
510 ReadModel {
511 name: structure.ident.unraw().to_string(),
512 from: from_argument(&structure.attrs, "read"),
513 fields: fields_of(&structure.fields, |_| true),
514 file: file.to_path_buf(),
515 line: line_of(&structure.ident),
516 }
517}
518
519fn from_argument(attributes: &[Attribute], attribute: &str) -> Option<String> {
520 let mut from = None;
521 for attribute in attributes
522 .iter()
523 .filter(|candidate| candidate.path().is_ident(attribute))
524 {
525 let _ = attribute.parse_nested_meta(|meta| {
527 if meta.path.is_ident("from") {
528 let path: syn::Path = meta.value()?.parse()?;
529 from = last_name(&path);
530 }
531 Ok(())
532 });
533 }
534 from
535}
536
537fn input_of(structure: &ItemStruct, file: &Path, module_path: &[String]) -> InputDeclaration {
538 let name = structure.ident.unraw().to_string();
539 let mut path = module_path.to_vec();
540 path.push(name.clone());
541 InputDeclaration {
542 name,
543 path,
544 from: from_argument(&structure.attrs, "input"),
545 fields: fields_of(&structure.fields, |_| true),
546 unconstrained: fields_marked(&structure.fields, "unconstrained").collect(),
547 file: file.to_path_buf(),
548 line: line_of(&structure.ident),
549 }
550}
551
552fn aggregate_of(structure: &ItemStruct, file: &Path, module_path: &[String]) -> Aggregate {
553 let mut behaviors = Vec::new();
554 for attribute in structure
555 .attrs
556 .iter()
557 .filter(|attr| attr.path().is_ident("aggregate"))
558 {
559 let _ = attribute.parse_nested_meta(|meta| {
561 if meta.path.is_ident("behaviors") {
562 let content;
563 syn::parenthesized!(content in meta.input);
564 let named = Punctuated::<syn::Meta, Token![,]>::parse_terminated(&content)?;
565 behaviors.extend(
566 named
567 .iter()
568 .filter_map(|behavior| last_name(behavior.path())),
569 );
570 }
571 Ok(())
572 });
573 }
574 let name = structure.ident.unraw().to_string();
575 let mirror = structure
576 .attrs
577 .iter()
578 .find(|attribute| attribute.path().is_ident("mirror"))
579 .and_then(|attribute| attribute.parse_args::<syn::Path>().ok())
580 .map(|path| segments(&path));
581 Aggregate {
582 path: [module_path, std::slice::from_ref(&name)].concat(),
583 name,
584 mirror,
585 fields: fields_of(&structure.fields, |name| name != "meta"),
586 behaviors,
587 exposed: fields_marked(&structure.fields, "expose").collect(),
588 owner: fields_marked(&structure.fields, "owner").next(),
589 owner_on_struct: structure
590 .attrs
591 .iter()
592 .any(|attribute| attribute.path().is_ident("owner")),
593 doc: doc_line(&structure.attrs),
594 file: file.to_path_buf(),
595 line: line_of(&structure.ident),
596 }
597}
598
599fn fields_marked<'fields>(
600 fields: &'fields Fields,
601 attribute: &'fields str,
602) -> impl Iterator<Item = String> + 'fields {
603 fields
604 .iter()
605 .filter(move |field| {
606 field
607 .attrs
608 .iter()
609 .any(|candidate| candidate.path().is_ident(attribute))
610 })
611 .filter_map(|field| Some(field.ident.as_ref()?.unraw().to_string()))
612}
613
614fn fields_of(fields: &Fields, keep: impl Fn(&str) -> bool) -> Vec<FieldDeclaration> {
615 fields
616 .iter()
617 .filter_map(|field| {
618 let ident = field.ident.as_ref()?;
619 let name = ident.unraw().to_string();
620 let constrained: Vec<(&Attribute, String)> = field
621 .attrs
622 .iter()
623 .filter_map(|attribute| {
624 let name = attribute.path().get_ident()?.to_string();
625 CONSTRAINT_ATTRIBUTES
626 .contains(&name.as_str())
627 .then_some((attribute, name))
628 })
629 .collect();
630 keep(&name).then(|| FieldDeclaration {
631 name,
632 ty: TypeName::of(&field.ty),
633 bounds: constrained
634 .iter()
635 .flat_map(|(attribute, name)| described(attribute, name))
636 .collect(),
637 constraints: constrained.into_iter().map(|(_, name)| name).collect(),
638 line: line_of(ident),
639 })
640 })
641 .collect()
642}
643
644pub(crate) fn derives(attributes: &[Attribute], derive: &str) -> bool {
645 attributes
646 .iter()
647 .filter(|attribute| attribute.path().is_ident("derive"))
648 .any(|attribute| {
649 let mut found = false;
650 let parsed = attribute.parse_nested_meta(|meta| {
651 found |= path_ends_with(&meta.path, &[derive]);
652 Ok(())
653 });
654 parsed.is_ok() && found
655 })
656}
657
658#[derive(Default)]
659struct ScreenRenders {
660 found: Vec<ScreenRender>,
661}
662
663impl Visit<'_> for ScreenRenders {
664 fn visit_expr_call(&mut self, call: &ExprCall) {
665 if let Expr::Path(function) = call.func.as_ref()
666 && path_ends_with(&function.path, &["Screen", "render"])
667 {
668 self.found.push(ScreenRender {
669 screen: call.args.first().and_then(string_literal),
670 line: line_of(call),
671 });
672 }
673 visit::visit_expr_call(self, call);
674 }
675
676 fn visit_macro(&mut self, call: &Macro) {
677 for expression in macro_expressions(call) {
678 self.visit_expr(&expression);
679 }
680 }
681}
682
683struct SqlAndKeyCalls<'code> {
685 module_path: &'code [String],
686 file: &'code Path,
687 code: &'code mut Code,
688}
689
690impl Visit<'_> for SqlAndKeyCalls<'_> {
691 fn visit_expr_call(&mut self, call: &ExprCall) {
692 if let Expr::Path(function) = call.func.as_ref()
693 && path_ends_with(&function.path, &["FieldError", "new"])
694 && let Some(key) = call.args.iter().nth(1)
695 && let Some(text) = string_literal(key)
696 {
697 self.code.translation_keys.push(KeyLiteral {
698 key: text,
699 file: self.file.to_path_buf(),
700 line: line_of(key),
701 });
702 }
703 visit::visit_expr_call(self, call);
704 }
705
706 fn visit_expr_method_call(&mut self, call: &ExprMethodCall) {
707 if call.method == "sql" {
708 self.code.raw_sql.push(RawSql {
709 module_path: self.module_path.to_vec(),
710 file: self.file.to_path_buf(),
711 line: line_of(&call.method),
712 text: call
713 .args
714 .first()
715 .and_then(|first| Some((string_literal(first)?, line_of(first)))),
716 });
717 }
718 visit::visit_expr_method_call(self, call);
719 }
720
721 fn visit_macro(&mut self, call: &Macro) {
722 for expression in macro_expressions(call) {
723 self.visit_expr(&expression);
724 }
725 }
726}