mod clients;
mod gates;
mod gateways;
mod islands;
mod mails;
mod module_wiring;
mod provided;
mod remote;
#[cfg(test)]
mod tests;
use std::path::Path;
use proc_macro2::{Literal, TokenStream};
use quote::quote;
use sofie_model::naming::{
CONTENT_PACKAGE, IDENTITY_PACKAGE, RouteSite, TENANCY_PACKAGE, crate_name, file_stem, job_name,
relative_path, screen_key, task_name,
};
use sofie_model::{
Aggregate, App, AreaContribution, Cadence, ContractTrait, Listener, MANIFEST_CONSTANT, Module,
Schedule, WallClock, WiringSide, interval_seconds,
};
use self::gates::{Gating, job_function};
pub(crate) use self::mails::embedded_mails;
pub(crate) use self::module_wiring::render_module_wiring;
use crate::forms::DescribedForm;
use crate::sorted;
use crate::translations::AppLanguages;
pub(crate) const HEADER: &str = "// @generated by `sofie gen`; do not edit.\n";
pub(crate) fn render_wiring(
app: &App,
languages: Option<&AppLanguages>,
forms: &[DescribedForm],
) -> String {
let wiring = Wiring::of(app, languages, forms);
let optional = |item: Option<&TokenStream>| item.map(ToString::to_string).unwrap_or_default();
let items = [
wiring.imports().to_string(),
wiring.modules_table().to_string(),
wiring.app().to_string(),
wiring.gating.tables().to_string(),
wiring.worker().to_string(),
optional(wiring.tasks().as_ref()),
optional(wiring.provide.as_ref()),
optional(wiring.gateways.as_ref()),
optional(wiring.mails.as_ref()),
components(app).to_string(),
wiring.screens().to_string(),
optional(wiring.translations().as_ref()),
];
let mut text = HEADER.to_owned();
for item in items.iter().filter(|item| !item.is_empty()) {
text.push('\n');
text.push_str(item);
text.push('\n');
}
text
}
struct Wiring<'app> {
app: &'app App,
modules: Vec<&'app Module>,
identity: Option<TokenStream>,
content: Option<TokenStream>,
tenancy: Option<TokenStream>,
plans: Option<TokenStream>,
languages: Option<&'app AppLanguages>,
forms: &'app [DescribedForm],
gating: Gating<'app>,
provide: Option<TokenStream>,
gateways: Option<TokenStream>,
islands: Option<TokenStream>,
clients: Option<TokenStream>,
mails: Option<TokenStream>,
}
impl<'app> Wiring<'app> {
fn of(
app: &'app App,
languages: Option<&'app AppLanguages>,
forms: &'app [DescribedForm],
) -> Self {
let modules = sorted::modules(app);
Self {
app,
identity: app
.identity
.is_some()
.then(|| path(&crate_name(IDENTITY_PACKAGE))),
content: app
.depends_on(CONTENT_PACKAGE)
.then(|| path(&crate_name(CONTENT_PACKAGE))),
tenancy: app
.depends_on(TENANCY_PACKAGE)
.then(|| path(&crate_name(TENANCY_PACKAGE))),
plans: app
.modules
.iter()
.find(|module| module.plans.is_some())
.map(|module| path(&format!("{}::plans::PLANS", module.krate.crate_name))),
languages,
forms,
gating: Gating::of(app),
provide: provided::provide_function(&modules, remote::provisions(app)),
gateways: gateways::gateways_function(&modules),
islands: islands::islands_statement(app),
clients: clients::clients_statement(app),
mails: mails::mails_table(app, &modules),
modules,
}
}
fn imports(&self) -> TokenStream {
let (gates, flags) = self.gating.imports();
let mut http = vec![quote!(App), quote!(Module), quote!(Screens)];
http.extend(gates);
quote! {
use sofie::core::Result;
use sofie::db::Db;
#flags
use sofie::http::{#(#http),*};
use sofie::worker::Worker;
}
}
fn modules_table(&self) -> TokenStream {
let identity = self.identity.iter().map(|identity| {
let members = member_areas(self.app)
.into_iter()
.map(|area| quote!(#identity::member::<#area>));
quote!(#identity::module(&[#(#members),*]))
});
let billing = self.plans.iter().map(|_| quote!(sofie::billing::MODULE));
let packages = self.content.iter().chain(&self.tenancy);
let packages = identity
.chain(billing)
.chain(packages.map(|package| quote!(#package::MODULE)));
let modules = self.modules.iter().map(|module| self.module_entry(module));
let entries = packages.chain(modules);
quote! {
pub static MODULES: &[Module] = &[#(#entries),*];
}
}
fn module_entry(&self, module: &Module) -> TokenStream {
let manifest = in_module(module, &["module".to_owned(), MANIFEST_CONSTANT.to_owned()]);
let screens = screen_entries(self.app, module);
let forms = self.module_forms(module);
let fields: Vec<TokenStream> = [
(!screens.is_empty()).then(|| quote!(screens: &[#(#screens),*])),
(!forms.is_empty()).then(|| quote!(forms: &[#(#forms),*])),
]
.into_iter()
.flatten()
.collect();
if fields.is_empty() {
return manifest;
}
quote!(Module { #(#fields,)* ..#manifest })
}
fn module_forms(&self, module: &Module) -> Vec<TokenStream> {
let prefix = format!("{}/", module.krate.crate_name);
self.forms
.iter()
.filter(|form| form.screen.starts_with(&prefix))
.map(|form| {
let (screen, id, input) = (&form.screen, &form.form, path(&form.input));
quote!(sofie::http::Form::of::<#input>(#screen, #id))
})
.collect()
}
fn app(&self) -> TokenStream {
let configs = self.modules.iter().filter_map(|module| {
let config = module.config.as_ref()?;
let name = &module.krate.crate_name;
let config_type = path(&format!("{name}::config::{}", config.type_name));
Some(quote!(sofie::config::load::<#config_type>(#name)?;))
});
let flags = self.gating.flags_parameter(false);
let gates = self.gating.app_call();
let settings = self.gating.declared_settings();
let provide = self.provide.is_some().then(|| quote!(provide(&db);));
let mails = mails::register(self.mails.as_ref());
let mail_translations = self.mail_translations("e!(translations()?));
let personal_data = self.personal_data();
let gateways = self.gateways.is_some().then(|| quote!(gateways()?;));
let connections = remote::connections(self.app);
let serving = remote::serving(self.app, &self.modules);
let install = self
.plans
.as_ref()
.map(|plans| quote!(let billing = sofie::billing::install(&db, &#plans).checked()?;));
let content_settings = self.content.as_ref().map(
|content| quote!(#content::install(&db, #content::Settings::from_environment()?);),
);
let billing = self.plans.as_ref().map(
|_| quote!(.area::<sofie::http::Web>("sofie_billing", |routes| billing.routes(routes))),
);
quote! {
pub fn app(db: Db #flags) -> Result<App> {
#gateways
#(#connections)*
#mails
#mail_translations
#provide
#personal_data
#serving
#(#configs)*
#settings
#install
#content_settings
Ok(App::new(db).serving(|serving| serving.named(env!("CARGO_PKG_NAME"))) #gates #billing .screens(screens()?) .modules(MODULES))
}
}
}
fn personal_data(&self) -> Option<TokenStream> {
let identity = self.identity.as_ref()?;
let features = &self.app.identity.as_ref()?.features;
features.iter().any(|feature| feature == "notify").then(|| {
quote!(#identity::account_data::register(
&db,
std::sync::Arc::new(#identity::account_data::Notifications::new(&db)),
);)
})
}
fn worker(&self) -> TokenStream {
let gated = self
.modules
.iter()
.flat_map(|module| job_entries(module, true));
let worker = quote!(sofie::http::worker(db, MODULES) #(#gated)*);
let worker = match remote::feeds(self.app, &self.modules) {
Some(feeds) => quote!(#feeds.attach(#worker)),
None => worker,
};
let flags = self.gating.flags_parameter(true);
let provide = self.provide.is_some().then(|| quote!(provide(&db);));
let mails = mails::register(self.mails.as_ref());
let mail_translations = self.mail_translations("e!(
translations().expect("the app's translations load, as `sofie check` read them")
));
let billing = self
.plans
.as_ref()
.map(|plans| quote!(sofie::billing::install(&db, &#plans);));
quote! {
#[must_use]
pub fn worker(db: Db #flags) -> Worker {
#mails
#mail_translations
#provide
#billing
#worker
}
}
}
fn tasks(&self) -> Option<TokenStream> {
let entries: Vec<TokenStream> = self
.modules
.iter()
.flat_map(|module| {
module.tasks.iter().map(move |task| {
let name = task_name(module, &task.function);
let function = in_module(module, &task.function);
quote!(.task(#name, #function))
})
})
.collect();
if entries.is_empty() {
return None;
}
Some(quote! {
#[must_use]
pub fn tasks() -> sofie::Tasks {
sofie::Tasks::new() #(#entries)*
}
})
}
fn translations(&self) -> Option<TokenStream> {
self.languages
.map(|languages| languages.translations_function(self.app))
}
fn mail_translations(&self, built: &TokenStream) -> Option<TokenStream> {
(self.languages.is_some() && self.mails.is_some())
.then(|| quote!(sofie::mail::translations(&db, std::sync::Arc::new(#built));))
}
fn screens(&self) -> TokenStream {
let table = quote!(sofie::http::wired_screens(MODULES, COMPONENTS));
let translate = self
.languages
.map(|_| quote!(screens.translate(translations()?);));
let statements: Vec<TokenStream> = translate
.into_iter()
.chain(self.islands.clone())
.chain(self.clients.clone())
.collect();
if statements.is_empty() {
return quote! {
fn screens() -> Result<Screens> {
#table
}
};
}
quote! {
fn screens() -> Result<Screens> {
let mut screens = #table?;
#(#statements)*
Ok(screens)
}
}
}
}
fn screen_entries(app: &App, module: &Module) -> Vec<TokenStream> {
embedded_screens(module)
.into_iter()
.map(|(key, file)| {
let file = compiled_file(app, file, &format!("screens/{key}.sft"));
quote!((#key, #file))
})
.collect()
}
pub(crate) fn compiled_file(app: &App, template: &Path, compiled: &str) -> TokenStream {
let template = embedded_path(app, template);
quote!(sofie::db::embed!(#template, compiled = #compiled))
}
fn components(app: &App) -> TokenStream {
let entries = app
.modules
.iter()
.flat_map(|module| &module.components)
.map(|file| {
let name = file_stem(file);
let file = embedded_path(app, file);
quote!((#name, concat!(env!("CARGO_MANIFEST_DIR"), "/", #file)))
});
quote! {
const COMPONENTS: &[(&str, &str)] = &[#(#entries),*];
}
}
pub(crate) fn embedded_screens(module: &Module) -> Vec<(String, &Path)> {
let crate_name = &module.krate.crate_name;
sorted::areas(module)
.into_iter()
.flat_map(|area| {
let own = area.screens.iter().map(|file| (crate_name, file));
let replaced = area
.overrides
.iter()
.map(|replaced| (&replaced.module, &replaced.file));
own.chain(replaced).map(|(owner, file)| {
(
screen_key(owner, &area.area, &file_stem(file)),
file.as_path(),
)
})
})
.collect()
}
fn member_areas(app: &App) -> Vec<TokenStream> {
app.areas()
.iter()
.filter(|area| {
area.contract.is_some()
&& area
.definition
.is_some_and(sofie_model::AreaDefinition::is_member_area)
})
.map(|area| path(&area.type_path))
.collect()
}
fn area_mounts(app: &App, module: &Module) -> Vec<TokenStream> {
sorted::areas(module)
.into_iter()
.map(|area| {
let area_type = path(
&app.area(&area.area)
.expect("the unknown-area rule refuses routes to an area nobody defines")
.type_path,
);
let routes = area_function(module, area, "routes");
let menu = area.menu.as_ref().map(|_| {
let menu = area_function(module, area, "menu");
quote!(.menu::<#area_type>(#menu))
});
quote!(|app, module| app.area::<#area_type>(module, #routes) #menu)
})
.collect()
}
fn area_function(module: &Module, area: &AreaContribution, file: &str) -> TokenStream {
let item = ["areas", &area.area, file, file].map(str::to_owned);
rooted(module, WiringSide::Module, &item)
}
pub(super) fn route_names(app: &App, module: &Module) -> Vec<TokenStream> {
app.route_sites()
.into_iter()
.filter(|site| site.module.krate.crate_name == module.krate.crate_name)
.map(|site| {
let (method, path) = route_key(app, &site);
let name = site.name();
quote!(sofie::http::RouteName { method: #method, path: #path, name: #name })
})
.collect()
}
fn route_key(app: &App, site: &RouteSite) -> (&'static str, String) {
let path = site
.full_path(app)
.expect("the area-prefix rule refuses a route whose prefix is unknown");
(site.route.method.name(), path)
}
fn job_entries(module: &Module, gated: bool) -> Vec<TokenStream> {
let wanted = |function: &[String]| module.code.features.gate_on(function).is_some() == gated;
let listeners = module
.listeners
.iter()
.filter(|listener| wanted(&listener.function))
.map(|listener| listener_entry(module, listener));
let schedules = module
.schedules
.iter()
.filter(|schedule| wanted(&schedule.function))
.map(|schedule| schedule_entry(module, schedule));
listeners
.chain(schedules)
.chain(provided::worker_entries(module, gated))
.collect()
}
fn listener_entry(module: &Module, listener: &Listener) -> TokenStream {
let name = job_name(module, &listener.function);
let function = job_function(module, &listener.function);
let side = module.job_side(&listener.function);
let event = event_path(module, side, &listener.event);
quote!(.on::<#event, _>(#name, #function))
}
fn schedule_entry(module: &Module, schedule: &Schedule) -> TokenStream {
let name = job_name(module, &schedule.function);
let function = job_function(module, &schedule.function);
let interval = match &schedule.cadence {
Cadence::Interval(interval) => interval,
Cadence::WallClock(wall_clock) => {
let wall_clock = wall_clock_value(wall_clock);
return quote!(.every(#name, const { sofie::__needs_local_time!(#wall_clock) }, #function));
}
};
let seconds = interval_seconds(interval)
.expect("the schedule-interval rule refuses an interval it cannot read");
let interval = Literal::u64_unsuffixed(seconds);
let interval = quote!(std::time::Duration::from_secs(#interval));
let Some(each) = &schedule.each else {
return quote!(.every(#name, #interval, #function));
};
let query_name = job_name(module, each);
let query = rooted(module, module.job_side(&schedule.function), each);
quote!(.each(#query_name, #interval, #query, #name, #function))
}
fn wall_clock_value(wall_clock: &WallClock) -> TokenStream {
let WallClock {
daily_at,
weekly_on,
zone,
} = wall_clock;
let at = quote!(sofie::values::TimeOfDay::constant(#daily_at));
match weekly_on {
None => quote!(sofie::worker::WallClock::daily(#at, #zone)),
Some(weekday) => quote!(sofie::worker::WallClock::weekly(
sofie::values::Weekday::constant(#weekday), #at, #zone
)),
}
}
fn migrations_entry(module: &Module) -> Option<TokenStream> {
if module.migrations.is_empty() {
return None;
}
let files = |files: &[std::path::PathBuf]| -> Vec<TokenStream> {
files
.iter()
.map(|file| {
let path = relative_path(&module.krate.path, file)
.expect("a module's migrations are under its own directory");
quote!(sofie::db::embed!(#path))
})
.collect()
};
let (postgres, sqlite) = (files(&module.migrations), files(&module.sqlite_migrations));
let schema = &module.krate.crate_name;
let sqlite = (!sqlite.is_empty()).then(|| quote!(.sqlite(&[#(#sqlite),*])));
let migrations = quote!(sofie::db::Migrations::new(#schema, &[#(#postgres),*]) #sqlite);
if module.code.aggregates.is_empty() {
return Some(migrations);
}
let aggregates = module.code.aggregates.iter().map(|aggregate| {
let aggregate = rooted(module, WiringSide::Module, &aggregate.path);
quote!(sofie::db::AggregateTable::of::<#aggregate>())
});
Some(quote!(#migrations.aggregates(const { &[#(#aggregates),*] })))
}
pub(crate) fn embedded_file(app: &App, file: &Path) -> TokenStream {
let path = embedded_path(app, file);
quote!(sofie::db::embed!(#path))
}
fn embedded_path(app: &App, file: &Path) -> String {
app.embedded_path(file)
.expect("the embedded-path rule refuses a file the wiring cannot spell")
}
pub(crate) fn path(text: &str) -> TokenStream {
text.parse()
.expect("a path read from parsed Rust or a package name lexes as Rust")
}
fn event_path(module: &Module, side: WiringSide, event: &[String]) -> TokenStream {
match event.split_first() {
Some((first, rest)) if first == "crate" => rooted(module, side, rest),
_ => path(&event.join("::")),
}
}
fn in_module(module: &Module, item: &[String]) -> TokenStream {
rooted(module, WiringSide::App, item)
}
fn rooted(module: &Module, side: WiringSide, item: &[String]) -> TokenStream {
let root = match side {
WiringSide::Module => "crate",
WiringSide::App => &module.krate.crate_name,
};
path(&format!("{root}::{}", item.join("::")))
}
pub(crate) fn exposed_aggregates(module: &Module) -> impl Iterator<Item = &Aggregate> {
module
.code
.aggregates
.iter()
.filter(|aggregate| !aggregate.exposed.is_empty())
}
pub(crate) fn crossing_traits(traits: &[ContractTrait]) -> impl Iterator<Item = &ContractTrait> {
traits
.iter()
.filter(|contract_trait| contract_trait.crosses_apps())
}