permkit_permission_macros/
lib.rs1use std::collections::HashMap;
21
22use proc_macro::TokenStream;
23use quote::quote;
24use syn::spanned::Spanned as _;
25use syn::{
26 Attribute,
27 Data,
28 DeriveInput,
29 Expr,
30 ExprArray,
31 ExprLit,
32 Fields,
33 Lit,
34 LitStr,
35 Variant,
36 parse_macro_input,
37};
38
39#[proc_macro_derive(Permission, attributes(permission))]
40pub fn derive_permission(input: TokenStream) -> TokenStream {
41 let input = parse_macro_input!(input as DeriveInput);
42 expand(&input)
43 .unwrap_or_else(|err| err.to_compile_error())
44 .into()
45}
46
47#[derive(Default)]
48struct EnumAttrs {
49 default_roles: Vec<String>,
50}
51
52struct VariantInfo {
53 ident: syn::Ident,
54 name: String,
55 roles: Vec<String>,
56}
57
58fn expand(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
59 let enum_ident = &input.ident;
60
61 let Data::Enum(data) = &input.data else {
62 return Err(syn::Error::new_spanned(
63 enum_ident,
64 "Permission can only be derived for enums",
65 ));
66 };
67
68 let enum_attrs = parse_enum_attrs(&input.attrs)?;
69
70 let mut variants: Vec<VariantInfo> = Vec::with_capacity(data.variants.len());
71 let mut seen_names = HashMap::<String, proc_macro2::Span>::new();
72
73 for variant in &data.variants {
74 if !matches!(variant.fields, Fields::Unit) {
75 return Err(syn::Error::new(
76 variant.span(),
77 "Permission only supports unit variants (no fields)",
78 ));
79 }
80
81 let info = parse_variant_info(variant, &enum_attrs)?;
82
83 if let Some(prev_span) = seen_names.insert(info.name.clone(), variant.span()) {
84 let mut err = syn::Error::new(
85 variant.span(),
86 format!("duplicate permission name {:?}", info.name),
87 );
88
89 err.combine(syn::Error::new(
90 prev_span,
91 format!("note: previous use of {:?}", info.name),
92 ));
93
94 return Err(err);
95 }
96
97 variants.push(info);
98 }
99
100 let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
101
102 let as_ref_arms = variants.iter().map(|variant| {
103 let ident = &variant.ident;
104 let name = &variant.name;
105 quote! { Self::#ident => #name }
106 });
107
108 let enum_name_str = enum_ident.to_string();
109
110 let utoipa_impls = if cfg!(feature = "utoipa") {
111 let all_names = variants.iter().map(|variant| variant.name.as_str());
112
113 quote! {
114 impl #impl_generics ::permkit::utoipa::PartialSchema for #enum_ident #ty_generics #where_clause {
115 fn schema() -> ::permkit::utoipa::openapi::RefOr<::permkit::utoipa::openapi::schema::Schema> {
116 ::permkit::utoipa::openapi::RefOr::T(::permkit::utoipa::openapi::schema::Schema::Object(
117 ::permkit::utoipa::openapi::schema::ObjectBuilder::new()
118 .schema_type(::permkit::utoipa::openapi::schema::SchemaType::Type(
119 ::permkit::utoipa::openapi::schema::Type::String,
120 ))
121 .enum_values(::core::option::Option::Some([
122 #(#all_names),*
123 ]))
124 .build(),
125 ))
126 }
127 }
128
129 impl #impl_generics ::permkit::utoipa::ToSchema for #enum_ident #ty_generics #where_clause {
130 fn name() -> ::std::borrow::Cow<'static, str> {
131 ::std::borrow::Cow::Borrowed(#enum_name_str)
132 }
133 }
134 }
135 } else {
136 quote! {}
137 };
138
139 let inventory_submits = variants
140 .iter()
141 .map(|variant| {
142 let name = &variant.name;
143 let roles = variant.roles.iter();
144 quote! {
145 ::permkit::inventory::submit! {
146 ::permkit::PermissionEntry {
147 name: ::std::borrow::Cow::Borrowed(#name),
148 enum_name: #enum_name_str,
149 roles: &[#(#roles),*],
150 }
151 }
152 }
153 })
154 .collect::<Vec<_>>();
155
156 Ok(quote! {
157 impl #impl_generics ::core::convert::AsRef<str> for #enum_ident #ty_generics #where_clause {
158 #[inline]
159 fn as_ref(&self) -> &str {
160 match self {
161 #(#as_ref_arms),*
162 }
163 }
164 }
165
166 impl #impl_generics ::permkit::serde::Serialize for #enum_ident #ty_generics #where_clause {
167 fn serialize<__S>(&self, serializer: __S) -> ::core::result::Result<__S::Ok, __S::Error>
168 where
169 __S: ::permkit::serde::Serializer,
170 {
171 serializer.serialize_str(::core::convert::AsRef::<str>::as_ref(self))
172 }
173 }
174
175 #utoipa_impls
176
177 #(#inventory_submits)*
178 })
179}
180
181fn parse_enum_attrs(attrs: &[Attribute]) -> syn::Result<EnumAttrs> {
182 let mut out = EnumAttrs::default();
183
184 for attr in attrs {
185 if !attr.path().is_ident("permission") {
186 continue;
187 }
188
189 attr.parse_nested_meta(|meta| {
190 if meta.path.is_ident("roles") {
191 let expr: Expr = meta.value()?.parse()?;
192 out.default_roles = parse_string_array(&expr)?;
193 Ok(())
194 } else {
195 Err(meta.error(
196 "unsupported `#[permission(...)]` key on enum (expected `roles = [..]`)",
197 ))
198 }
199 })?;
200 }
201
202 Ok(out)
203}
204
205fn parse_variant_info(variant: &Variant, enum_attrs: &EnumAttrs) -> syn::Result<VariantInfo> {
206 let mut name: Option<String> = None;
207 let mut roles: Option<Vec<String>> = None;
208 let mut saw_permission_attr = false;
209
210 for attr in &variant.attrs {
211 if !attr.path().is_ident("permission") {
212 continue;
213 }
214 saw_permission_attr = true;
215
216 attr.parse_nested_meta(|meta| {
217 if meta.path.is_ident("name") {
218 let lit: LitStr = meta.value()?.parse()?;
219 name = Some(lit.value());
220 Ok(())
221 } else if meta.path.is_ident("roles") {
222 let expr: Expr = meta.value()?.parse()?;
223 roles = Some(parse_string_array(&expr)?);
224 Ok(())
225 } else {
226 Err(meta.error(
227 "unsupported `#[permission(...)]` key on variant (expected `name = \"..\"` or `roles = [..]`)",
228 ))
229 }
230 })?;
231 }
232
233 let Some(name) = name else {
234 return Err(syn::Error::new(
235 variant.span(),
236 if saw_permission_attr {
237 "missing `name = \"..\"` in `#[permission(...)]`"
238 } else {
239 "expected `#[permission(name = \"..\")]` on variant"
240 },
241 ));
242 };
243
244 let roles = roles.unwrap_or_else(|| enum_attrs.default_roles.clone());
245
246 Ok(VariantInfo {
247 ident: variant.ident.clone(),
248 name,
249 roles,
250 })
251}
252
253fn parse_string_array(expr: &Expr) -> syn::Result<Vec<String>> {
254 let Expr::Array(ExprArray { elems, .. }) = expr else {
255 return Err(syn::Error::new(
256 expr.span(),
257 "expected a string array like `[\"owner\", \"operator\"]`",
258 ));
259 };
260
261 elems
262 .iter()
263 .map(|expr| match expr {
264 Expr::Lit(ExprLit {
265 lit: Lit::Str(s), ..
266 }) => Ok(s.value()),
267 other => Err(syn::Error::new(
268 other.span(),
269 "expected a string literal inside the role array",
270 )),
271 })
272 .collect()
273}
274
275#[cfg(test)]
276mod tests {
277 use proc_macro2::TokenStream;
278 use quote::quote;
279
280 fn try_expand(input: TokenStream) -> syn::Result<TokenStream> {
281 let parsed: syn::DeriveInput = syn::parse2(input)?;
282 super::expand(&parsed)
283 }
284
285 fn expand_ok(input: TokenStream) -> String {
286 try_expand(input).expect("should expand").to_string()
287 }
288
289 #[test]
290 fn expands_basic_enum_with_default_roles() {
291 let input = quote! {
292 #[permission(roles = ["owner", "operator"])]
293 pub enum CompanyPermission {
294 #[permission(name = "Companies.List")]
295 List,
296 #[permission(name = "Companies.Create", roles = ["owner"])]
297 Create,
298 }
299 };
300
301 let expanded = expand_ok(input);
302
303 assert!(expanded.contains("Self :: List => \"Companies.List\""));
304 assert!(expanded.contains("Self :: Create => \"Companies.Create\""));
305 assert!(expanded.contains("roles : & [\"owner\" , \"operator\"]"));
306 assert!(expanded.contains("roles : & [\"owner\"]"));
307 assert_eq!(
308 expanded.contains(":: permkit :: utoipa :: ToSchema for CompanyPermission"),
309 cfg!(feature = "utoipa")
310 );
311 assert!(expanded.contains(":: permkit :: serde :: Serialize for CompanyPermission"));
312 assert!(expanded.contains(":: core :: convert :: AsRef < str > for CompanyPermission"));
313 assert!(expanded.contains(":: permkit :: inventory :: submit"));
314 assert!(expanded.contains(":: permkit :: PermissionEntry"));
315 assert!(expanded.contains("Cow :: Borrowed (\"Companies.List\")"));
316 assert!(expanded.contains("enum_name : \"CompanyPermission\""));
317 }
318
319 #[test]
320 fn variant_without_roles_inherits_default() {
321 let input = quote! {
322 #[permission(roles = ["owner"])]
323 pub enum P {
324 #[permission(name = "A.B")]
325 Variant,
326 }
327 };
328
329 let expanded = expand_ok(input);
330 assert!(expanded.contains("roles : & [\"owner\"]"));
331 }
332
333 #[test]
334 fn no_default_roles_means_empty_slice() {
335 let input = quote! {
336 pub enum P {
337 #[permission(name = "A.B")]
338 Variant,
339 }
340 };
341
342 let expanded = expand_ok(input);
343 assert!(expanded.contains("roles : & []"));
344 }
345
346 #[test]
347 fn rejects_non_enums() {
348 let input = quote! {
349 pub struct Foo;
350 };
351 let err = try_expand(input).expect_err("should fail");
352 assert!(err.to_string().contains("can only be derived for enums"));
353 }
354
355 #[test]
356 fn rejects_non_unit_variants() {
357 let input = quote! {
358 pub enum P {
359 #[permission(name = "A.B")]
360 Variant(String),
361 }
362 };
363 let err = try_expand(input).expect_err("should fail");
364 assert!(err.to_string().contains("unit variants"));
365 }
366
367 #[test]
368 fn rejects_missing_name() {
369 let input = quote! {
370 pub enum P {
371 Variant,
372 }
373 };
374 let err = try_expand(input).expect_err("should fail");
375 assert!(err.to_string().contains("permission(name"));
376 }
377
378 #[test]
379 fn rejects_duplicate_names() {
380 let input = quote! {
381 pub enum P {
382 #[permission(name = "A.B")]
383 X,
384 #[permission(name = "A.B")]
385 Y,
386 }
387 };
388 let err = try_expand(input).expect_err("should fail");
389 assert!(err.to_string().contains("duplicate permission name"));
390 }
391
392 #[test]
393 fn rejects_unknown_keys_on_variant() {
394 let input = quote! {
395 pub enum P {
396 #[permission(name = "A.B", description = "nope")]
397 X,
398 }
399 };
400 let err = try_expand(input).expect_err("should fail");
401 assert!(err.to_string().contains("unsupported"));
402 }
403}