use syn::parse::ParseStream;
use super::schedule_spec::{ScheduleSpec, parse_interval, parse_time};
#[derive(Debug)]
pub struct ModuleDeclaration {
pub visibility: syn::Visibility,
pub name: String,
pub ident: syn::Ident,
pub imports: Vec<String>,
pub exports: Vec<String>,
pub controllers: Vec<String>,
pub services: Vec<String>,
pub policies: Vec<String>,
pub routes: Option<syn::Path>,
pub listeners: Vec<(String, String)>,
pub jobs: Vec<(String, i16, String)>,
pub commands: Vec<(String, String)>,
pub schedules: Vec<(String, i16, ScheduleSpec)>,
pub pages: Vec<syn::Path>,
}
mod keyword {
syn::custom_keyword!(imports);
syn::custom_keyword!(exports);
syn::custom_keyword!(controllers);
syn::custom_keyword!(services);
syn::custom_keyword!(policies);
syn::custom_keyword!(routes);
syn::custom_keyword!(listeners);
syn::custom_keyword!(jobs);
syn::custom_keyword!(commands);
syn::custom_keyword!(schedules);
syn::custom_keyword!(pages);
}
impl syn::parse::Parse for ModuleDeclaration {
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
let visibility: syn::Visibility = input.parse()?;
let name_ident: syn::Ident = input.parse()?;
let name = name_ident.to_string();
let ident = syn::Ident::new(
&format!("{}_MODULE", name.to_uppercase()),
name_ident.span(),
);
let content;
syn::braced!(content in input);
let mut declaration = ModuleDeclaration {
visibility,
name,
ident,
imports: Vec::new(),
exports: Vec::new(),
controllers: Vec::new(),
services: Vec::new(),
policies: Vec::new(),
routes: None,
listeners: Vec::new(),
jobs: Vec::new(),
commands: Vec::new(),
schedules: Vec::new(),
pages: Vec::new(),
};
while !content.is_empty() {
declaration.parse_section(&content)?;
let _: Option<syn::Token![,]> = content.parse()?;
}
Ok(declaration)
}
}
impl ModuleDeclaration {
fn parse_section(&mut self, input: ParseStream<'_>) -> syn::Result<()> {
let lookahead = input.lookahead1();
if lookahead.peek(keyword::imports) {
self.imports = parse_keyed(input, |i| i.parse::<keyword::imports>(), parse_idents)?;
} else if lookahead.peek(keyword::exports) {
self.exports = parse_keyed(input, |i| i.parse::<keyword::exports>(), parse_idents)?;
} else if lookahead.peek(keyword::controllers) {
self.controllers =
parse_keyed(input, |i| i.parse::<keyword::controllers>(), parse_idents)?;
} else if lookahead.peek(keyword::services) {
self.services = parse_keyed(input, |i| i.parse::<keyword::services>(), parse_idents)?;
} else if lookahead.peek(keyword::policies) {
self.policies = parse_keyed(input, |i| i.parse::<keyword::policies>(), parse_idents)?;
} else if lookahead.peek(keyword::routes) {
let path = parse_keyed(input, |i| i.parse::<keyword::routes>(), |i| i.parse())?;
if self.routes.is_some() {
return Err(syn::Error::new(
input.span(),
"duplicate `routes:` section -- a module may declare at most one",
));
}
self.routes = Some(path);
} else if lookahead.peek(keyword::listeners) {
self.listeners =
parse_keyed(input, |i| i.parse::<keyword::listeners>(), parse_listeners)?;
} else if lookahead.peek(keyword::jobs) {
self.jobs = parse_keyed(input, |i| i.parse::<keyword::jobs>(), parse_jobs)?;
} else if lookahead.peek(keyword::commands) {
self.commands = parse_keyed(input, |i| i.parse::<keyword::commands>(), parse_commands)?;
} else if lookahead.peek(keyword::schedules) {
self.schedules =
parse_keyed(input, |i| i.parse::<keyword::schedules>(), parse_schedules)?;
} else if lookahead.peek(keyword::pages) {
self.pages = parse_keyed(input, |i| i.parse::<keyword::pages>(), parse_paths)?;
} else {
return Err(lookahead.error());
}
Ok(())
}
}
fn parse_keyed<K, F, P, T>(input: ParseStream<'_>, keyword: F, payload: P) -> syn::Result<T>
where
F: FnOnce(ParseStream<'_>) -> syn::Result<K>,
P: FnOnce(ParseStream<'_>) -> syn::Result<T>,
{
keyword(input)?;
let _: syn::Token![:] = input.parse()?;
payload(input)
}
fn parse_idents(input: ParseStream<'_>) -> syn::Result<Vec<String>> {
let content;
syn::bracketed!(content in input);
Ok(
syn::punctuated::Punctuated::<syn::Ident, syn::Token![,]>::parse_terminated(&content)?
.into_iter()
.map(|ident| ident.to_string())
.collect(),
)
}
fn parse_paths(input: ParseStream<'_>) -> syn::Result<Vec<syn::Path>> {
let content;
syn::bracketed!(content in input);
Ok(
syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated(&content)?
.into_iter()
.collect(),
)
}
fn parse_listeners(input: ParseStream<'_>) -> syn::Result<Vec<(String, String)>> {
let content;
syn::bracketed!(content in input);
let mut bindings = Vec::new();
while !content.is_empty() {
let event: syn::Ident = content.parse()?;
let _: syn::Token![=>] = content.parse()?;
let listener: syn::Ident = content.parse()?;
bindings.push((event.to_string(), listener.to_string()));
let _: Option<syn::Token![,]> = content.parse()?;
}
Ok(bindings)
}
fn parse_jobs(input: ParseStream<'_>) -> syn::Result<Vec<(String, i16, String)>> {
let content;
syn::bracketed!(content in input);
let mut bindings = Vec::new();
while !content.is_empty() {
let kind: syn::Ident = content.parse()?;
let version = if content.peek(syn::Ident) {
parse_version(&content.parse::<syn::Ident>()?)?
} else {
1
};
let _: syn::Token![=>] = content.parse()?;
let handler: syn::Ident = content.parse()?;
bindings.push((kind.to_string(), version, handler.to_string()));
let _: Option<syn::Token![,]> = content.parse()?;
}
Ok(bindings)
}
fn parse_commands(input: ParseStream<'_>) -> syn::Result<Vec<(String, String)>> {
let content;
syn::bracketed!(content in input);
let mut bindings = Vec::new();
while !content.is_empty() {
let name: syn::LitStr = content.parse()?;
let _: syn::Token![=>] = content.parse()?;
let function: syn::Ident = content.parse()?;
bindings.push((name.value(), function.to_string()));
let _: Option<syn::Token![,]> = content.parse()?;
}
Ok(bindings)
}
fn parse_schedules(input: ParseStream<'_>) -> syn::Result<Vec<(String, i16, ScheduleSpec)>> {
let content;
syn::bracketed!(content in input);
let mut bindings = Vec::new();
while !content.is_empty() {
let kind: syn::Ident = content.parse()?;
let version = match content.cursor().ident() {
Some((ident, _)) if is_version_ident(&ident.to_string()) => {
parse_version(&content.parse::<syn::Ident>()?)?
}
_ => 1,
};
bindings.push((kind.to_string(), version, parse_cadence(&content)?));
let _: Option<syn::Token![,]> = content.parse()?;
}
Ok(bindings)
}
fn parse_cadence(input: ParseStream<'_>) -> syn::Result<ScheduleSpec> {
let lookahead = input.lookahead1();
if !lookahead.peek(syn::Ident) {
return Err(lookahead.error());
}
let cadence: syn::Ident = input.parse()?;
let literal: syn::LitStr = input.parse()?;
let value = literal.value();
match cadence.to_string().as_str() {
"every" => parse_interval(&value)
.map(|seconds| ScheduleSpec::Every { seconds })
.ok_or_else(|| {
syn::Error::new(
literal.span(),
format!("invalid interval `{value}` (expected like `5m`, `1h`, `30s`, `1d`)"),
)
}),
"daily" => parse_time(&value)
.map(|(hour, minute)| ScheduleSpec::Daily { hour, minute })
.ok_or_else(|| {
syn::Error::new(
literal.span(),
format!("invalid time `{value}` (expected `HH:MM`)"),
)
}),
other => Err(syn::Error::new(
cadence.span(),
format!("unknown cadence `{other}` (expected `every` or `daily`)"),
)),
}
}
fn is_version_ident(s: &str) -> bool {
s.len() > 1 && s.starts_with('v') && s[1..].bytes().all(|b| b.is_ascii_digit())
}
fn parse_version(ident: &syn::Ident) -> syn::Result<i16> {
let text = ident.to_string();
let Some(digits) = text.strip_prefix('v') else {
return Err(syn::Error::new(
ident.span(),
format!("expected a version like `v1` (got `{text}`)"),
));
};
let version: i16 = digits
.parse()
.map_err(|e| syn::Error::new(ident.span(), format!("invalid version `{text}`: {e}")))?;
if version < 1 {
return Err(syn::Error::new(ident.span(), "version must be >= 1"));
}
Ok(version)
}
#[cfg(test)]
mod tests {
use super::*;
use quote::quote;
fn parse(tokens: proc_macro2::TokenStream) -> syn::Result<ModuleDeclaration> {
syn::parse2(tokens)
}
#[test]
fn derives_the_const_ident_from_the_module_name() {
let declaration = parse(quote! { pub Accounts {} }).unwrap();
assert_eq!(declaration.name, "Accounts");
assert_eq!(declaration.ident.to_string(), "ACCOUNTS_MODULE");
}
#[test]
fn every_section_defaults_to_empty() {
let declaration = parse(quote! { Accounts {} }).unwrap();
assert!(declaration.imports.is_empty());
assert!(declaration.controllers.is_empty());
assert!(declaration.routes.is_none());
assert!(declaration.schedules.is_empty());
assert!(declaration.pages.is_empty());
}
#[test]
fn section_order_is_free() {
let declaration = parse(quote! {
pub Accounts {
services: [AuthService],
imports: [Notifications],
}
})
.unwrap();
assert_eq!(declaration.imports, vec!["Notifications"]);
assert_eq!(declaration.services, vec!["AuthService"]);
}
#[test]
fn parses_listener_bindings() {
let declaration =
parse(quote! { A { listeners: [UserRegistered => send_welcome] } }).unwrap();
assert_eq!(
declaration.listeners,
vec![("UserRegistered".to_string(), "send_welcome".to_string())]
);
}
#[test]
fn job_version_defaults_to_one_and_can_be_given() {
let declaration = parse(quote! {
A { jobs: [send_email => handle, prune v3 => prune_handler] }
})
.unwrap();
assert_eq!(declaration.jobs[0].1, 1);
assert_eq!(declaration.jobs[1].1, 3);
}
#[test]
fn parses_command_bindings_with_colons_in_the_name() {
let declaration = parse(quote! { A { commands: ["users:prune" => prune] } }).unwrap();
assert_eq!(
declaration.commands,
vec![("users:prune".to_string(), "prune".to_string())]
);
}
#[test]
fn parses_both_schedule_cadences() {
let declaration = parse(quote! {
A { schedules: [sweep every "5m", digest v2 daily "03:30"] }
})
.unwrap();
assert_eq!(
declaration.schedules[0].2,
ScheduleSpec::Every { seconds: 300 }
);
assert_eq!(declaration.schedules[1].1, 2);
assert_eq!(
declaration.schedules[1].2,
ScheduleSpec::Daily {
hour: 3,
minute: 30
}
);
}
#[test]
fn parses_page_paths() {
let declaration = parse(quote! { A { pages: [HomePage, pages::NewLinkPage] } }).unwrap();
assert_eq!(declaration.pages.len(), 2);
assert_eq!(
declaration.pages[1].segments.last().unwrap().ident,
"NewLinkPage"
);
}
#[test]
fn parses_a_routes_path() {
let declaration = parse(quote! { A { routes: ACCOUNTS_ROUTES } }).unwrap();
assert!(declaration.routes.is_some());
}
#[test]
fn rejects_a_second_routes_section() {
let err = parse(quote! { A { routes: ONE, routes: TWO } }).unwrap_err();
assert!(err.to_string().contains("duplicate `routes:`"));
}
#[test]
fn rejects_an_unknown_section() {
assert!(parse(quote! { A { widgets: [X] } }).is_err());
}
#[test]
fn rejects_an_unknown_cadence() {
let err = parse(quote! { A { schedules: [sweep hourly "5m"] } }).unwrap_err();
assert!(err.to_string().contains("unknown cadence"));
}
#[test]
fn rejects_an_invalid_interval() {
let err = parse(quote! { A { schedules: [sweep every "5w"] } }).unwrap_err();
assert!(err.to_string().contains("invalid interval"));
}
#[test]
fn rejects_a_zero_version() {
let err = parse(quote! { A { jobs: [send v0 => handle] } }).unwrap_err();
assert!(err.to_string().contains("version must be >= 1"));
}
}