use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use std::collections::HashMap;
use syn::spanned::Spanned;
use syn::{Data, DeriveInput, Expr, Fields, Ident, Token, punctuated::Punctuated};
const REQUIRED: &[&str] = &[
"primary",
"on_primary",
"radius",
"spacing",
"font_size",
"icon_size",
"muted",
"scrollbar",
"ink",
"surface",
"surface_alt",
"border",
"success",
"warning",
"error",
"info",
"highlight_low",
"highlight_med",
"highlight_high",
];
const DERIVED: &[&str] = &[
"radius_sm",
"radius_md",
"radius_lg",
"spacing_sm",
"spacing_md",
"spacing_lg",
"spacing_xl",
];
fn is_token(name: &str) -> bool {
REQUIRED.contains(&name) || DERIVED.contains(&name)
}
#[derive(Default)]
struct Options {
values: HashMap<String, Expr>,
defaulted: Vec<String>,
}
fn parse_struct_attrs(input: &DeriveInput) -> syn::Result<Options> {
let mut options = Options::default();
for attr in input.attrs.iter().filter(|a| a.path().is_ident("theme")) {
attr.parse_nested_meta(|meta| {
let name = meta
.path
.get_ident()
.map(|i| i.to_string())
.unwrap_or_default();
if name == "default" {
let inner;
syn::parenthesized!(inner in meta.input);
for token in Punctuated::<Ident, Token![,]>::parse_terminated(&inner)? {
let token = token.to_string();
if !is_token(&token) {
return Err(meta.error(format!("`{token}` is not a ThemeTokens token")));
}
options.defaulted.push(token);
}
return Ok(());
}
if !is_token(&name) {
return Err(meta.error(format!("`{name}` is not a ThemeTokens token")));
}
options.values.insert(name, meta.value()?.parse()?);
Ok(())
})?;
}
Ok(options)
}
fn parse_fields(input: &DeriveInput) -> syn::Result<HashMap<String, TokenStream2>> {
let Data::Struct(data) = &input.data else {
return Err(syn::Error::new(
input.span(),
"ThemeTokens can only be derived for a struct",
));
};
let Fields::Named(fields) = &data.fields else {
return Err(syn::Error::new(
input.span(),
"ThemeTokens needs named fields to map tokens by name",
));
};
let mut answered = HashMap::new();
for field in &fields.named {
let ident = field.ident.as_ref().expect("named");
let name = ident.to_string();
if is_token(&name) {
answered.insert(name, quote!(self.#ident));
}
for attr in field.attrs.iter().filter(|a| a.path().is_ident("token")) {
for alias in attr.parse_args_with(Punctuated::<Ident, Token![,]>::parse_terminated)? {
let alias_name = alias.to_string();
if !is_token(&alias_name) {
return Err(syn::Error::new(
alias.span(),
format!("`{alias_name}` is not a ThemeTokens token"),
));
}
answered.insert(alias_name, quote!(self.#ident));
}
}
}
Ok(answered)
}
fn return_type(token: &str) -> TokenStream2 {
match token {
t if t.starts_with("radius")
|| t.starts_with("spacing")
|| t == "font_size"
|| t == "icon_size" =>
{
quote!(f32)
}
_ => quote!(::telar::Color),
}
}
pub fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
let options = parse_struct_attrs(&input)?;
let from_fields = parse_fields(&input)?;
let name = &input.ident;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
let mut methods = Vec::new();
let mut missing = Vec::new();
for token in REQUIRED.iter().chain(DERIVED) {
let token = *token;
if options.defaulted.iter().any(|d| d == token) {
continue;
}
let body = match options.values.get(token) {
Some(expr) => quote!(#expr),
None => match from_fields.get(token) {
Some(field) => field.clone(),
None => {
if REQUIRED.contains(&token) {
missing.push(token);
}
continue;
}
},
};
let method = Ident::new(token, input.ident.span());
let ty = return_type(token);
methods.push(quote! {
fn #method(&self) -> #ty {
#body
}
});
}
if !missing.is_empty() {
let list = missing.join("`, `");
return Err(syn::Error::new(
input.ident.span(),
format!(
"nothing answers the `{list}` token(s), and their built-ins are fixed values that will \
contradict this theme on screen. Answer each one with a field of that name, an existing \
field marked `#[token({first})]`, a value via `#[theme({first} = ...)]`, or accept the \
built-in on purpose with `#[theme(default({first}))]`.",
first = missing[0]
),
));
}
Ok(quote! {
impl #impl_generics ::telar::Theme for #name #ty_generics #where_clause {
fn as_any(&self) -> &dyn ::std::any::Any {
self
}
}
impl #impl_generics ::telar::ThemeTokens for #name #ty_generics #where_clause {
#(#methods)*
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn expand_str(source: &str) -> syn::Result<String> {
Ok(expand(syn::parse_str::<DeriveInput>(source)?)?.to_string())
}
const FULL: &str = "struct T {
primary: Color, on_primary: Color, radius: f32, spacing: f32, font_size: f32,
icon_size: f32, muted: Color, scrollbar: Color, ink: Color, surface: Color,
surface_alt: Color, border: Color, success: Color, warning: Color, error: Color,
info: Color, highlight_low: Color, highlight_med: Color, highlight_high: Color,
}";
#[test]
fn same_named_fields_answer_their_tokens() {
let out = expand_str(FULL).expect("every token is answered");
assert!(out.contains("fn primary"), "emits the token methods");
assert!(out.contains("fn as_any"), "emits the Theme half too");
}
#[test]
fn an_unanswered_token_is_a_compile_error() {
let err = expand_str(&FULL.replace("radius: f32,", "")).expect_err("radius has no answer");
let message = err.to_string();
assert!(message.contains("radius"), "names the token: {message}");
assert!(
message.contains("#[theme(default(radius))]"),
"offers the deliberate opt-out: {message}"
);
}
#[test]
fn a_defaulted_token_is_accepted_and_left_to_the_trait() {
let out = expand_str(&format!(
"#[theme(default(radius))] {}",
FULL.replace("radius: f32,", "")
))
.expect("opting out answers it");
assert!(
!out.contains("fn radius ("),
"no override, so the trait default stands"
);
}
#[test]
fn an_alias_answers_a_token_the_field_is_not_named_after() {
let out = expand_str(&FULL.replace("error: Color,", "#[token(error)] danger: Color,"))
.expect("the alias answers `error`");
assert!(out.contains("self . danger"), "reads the aliased field");
}
#[test]
fn an_expression_answers_a_token_no_field_holds() {
let out = expand_str(&format!(
"#[theme(scrollbar = self.muted.dim())] {}",
FULL.replace("scrollbar: Color,", "")
))
.expect("the expression answers `scrollbar`");
assert!(out.contains("dim"), "emits the expression");
}
#[test]
fn the_radius_scale_is_not_required() {
let out = expand_str(FULL).expect("valid");
assert!(!out.contains("fn radius_md"), "left to derive from radius");
}
#[test]
fn a_spacing_step_is_typed_as_a_metric() {
let out = expand_str(&format!("#[theme(spacing_lg = 20.0)] {FULL}")).expect("valid");
assert!(out.contains("fn spacing_lg (& self) -> f32"), "got: {out}");
}
#[test]
fn a_token_that_does_not_exist_is_rejected() {
let err = expand_str(&format!("#[theme(default(nonesuch))] {FULL}"))
.expect_err("unknown token name");
assert!(err.to_string().contains("not a ThemeTokens token"));
}
}