From 467a037a77da602ca2e00486bdafd7ac82ac622e Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Tue, 16 Sep 2025 23:21:36 +0200 Subject: [PATCH] feat: Add support for externally tagged enums Also did a massive code refactor that helped simplify the implementation. --- lua_typed_macro/src/lib.rs | 317 ++++++++++++++++++++-------- tests/notification.rs | 45 ++-- tests/ui/complex_struct.stderr | 6 +- tests/ui/tuple_enum_untagged.rs | 9 + tests/ui/tuple_enum_untagged.stderr | 5 + 5 files changed, 277 insertions(+), 105 deletions(-) create mode 100644 tests/ui/tuple_enum_untagged.rs create mode 100644 tests/ui/tuple_enum_untagged.stderr diff --git a/lua_typed_macro/src/lib.rs b/lua_typed_macro/src/lib.rs index 1d2b28e..4f98396 100644 --- a/lua_typed_macro/src/lib.rs +++ b/lua_typed_macro/src/lib.rs @@ -1,16 +1,18 @@ #![feature(iterator_try_collect)] +#![feature(if_let_guard)] use std::ops::Deref; use convert_case::{Case, Casing}; use proc_macro2::TokenStream as TokenStream2; use quote::{ToTokens, quote}; -use syn::{DeriveInput, LitStr, Token, parse_macro_input, spanned::Spanned}; +use syn::{DataEnum, DataStruct, DeriveInput, LitStr, Token, parse_macro_input, spanned::Spanned}; +#[derive(Clone)] struct StructField { name: syn::Ident, ty: syn::Type, case: Option>, - default: bool, + optional: bool, flatten: bool, } @@ -29,9 +31,9 @@ impl ToTokens for StructField { <#ty as Typed>::generate_members().unwrap_or("".to_string()) }); } else { - let default = if self.default { "?" } else { "" }; + let optional = if self.optional { "?" } else { "" }; - let format = format!("---@field {} {{}}{}\n", name, default); + let format = format!("---@field {} {{}}{}\n", name, optional); tokens.extend(quote! { format!(#format, <#ty as Typed>::type_name()) @@ -68,6 +70,28 @@ impl ToTokens for Struct { } } +impl Struct { + fn from_data( + data: DataStruct, + name: syn::Ident, + case: Option>, + ) -> syn::Result { + if data.fields.iter().any(|field| field.ident.is_none()) { + return Err(syn::Error::new( + data.fields.span(), + "Tuple structs are not supported by Typed", + )); + } + + let fields = parse_fields(data.fields, case)?; + + Ok(Self { + name: name.to_owned(), + fields, + }) + } +} + struct BasicEnumVariant { name: syn::Ident, case: Option>, @@ -117,14 +141,196 @@ impl ToTokens for BasicEnum { } } +impl BasicEnum { + fn from_data( + data: DataEnum, + name: syn::Ident, + case: Option>, + ) -> syn::Result { + if let Some(err) = data + .variants + .iter() + .filter(|variant| !variant.fields.is_empty()) + .map(|variant| { + syn::Error::new( + variant.fields.span(), + "Typed supports enum variants with fields only when the enum is externally tagged", + ) + }) + .reduce(|mut acc, e| { + acc.combine(e); + acc + }) + { + return Err(err); + } + + Ok(Self { + name: name.to_owned(), + variants: data + .variants + .into_iter() + .map(|variant| BasicEnumVariant { + name: variant.ident, + case, + }) + .collect(), + }) + } +} + +#[derive(Clone)] +struct ExtTaggedEnumVariant { + name: syn::Ident, + fields: Vec, + case: Option>, +} + +impl ToTokens for ExtTaggedEnumVariant { + fn to_tokens(&self, tokens: &mut TokenStream2) { + let fields = &self.fields; + + tokens.extend(quote! { + #( + output += &#fields; + )* + }); + } +} + +impl From for BasicEnumVariant { + fn from(value: ExtTaggedEnumVariant) -> Self { + Self { + name: value.name, + case: value.case, + } + } +} + +struct ExtTaggedEnum { + name: syn::Ident, + tag: LitStr, + variants: Vec, +} + +impl ToTokens for ExtTaggedEnum { + fn to_tokens(&self, tokens: &mut TokenStream2) { + let name = &self.name; + let tag_variants: Vec = + self.variants.iter().cloned().map(Into::into).collect(); + let variants = &self.variants; + + let tag = format!("---@field {}\n", self.tag.value()); + + tokens.extend(quote! { + fn generate_header() -> Option { + Some(format!("---@class {}\n", <#name as Typed>::type_name())) + } + + fn generate_members() -> Option { + let mut output = String::new(); + + output += #tag; + #( + output += &#tag_variants; + )* + + #( + #variants + )* + + Some(output) + } + }); + } +} + +impl ExtTaggedEnum { + fn from_data( + data: DataEnum, + name: syn::Ident, + tag: LitStr, + case: Option>, + ) -> syn::Result { + Ok(Self { + name: name.to_owned(), + tag, + variants: data + .variants + .into_iter() + .map(|variant| -> syn::Result<_> { + let mut fields = parse_fields(variant.fields, case)?; + + // Force each field to be optional as they might not be used depending on the + // variant selected + fields.iter_mut().for_each(|field| field.optional = true); + + Ok(ExtTaggedEnumVariant { + name: variant.ident, + case, + fields, + }) + }) + .try_collect()?, + }) + } +} + +fn parse_fields( + input: syn::Fields, + case: Option>, +) -> syn::Result> { + let mut fields = Vec::new(); + for field in input { + let mut default = false; + let mut flatten = false; + for attr in &field.attrs { + if attr.path().is_ident("serde") + && let Err(err) = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("default") { + default = true; + if meta.input.peek(Token![=]) { + meta.input.parse::()?; + meta.input.parse::()?; + } + } else if meta.path.is_ident("flatten") { + flatten = true; + } + + Ok(()) + }) + { + return Err(err); + } + } + + fields.push(StructField { + name: field.ident.expect("We already checked that ident is some"), + ty: field.ty, + case, + optional: default, + flatten, + }); + } + + Ok(fields) +} + #[proc_macro_derive(Typed, attributes(serde))] pub fn typed(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let ast = parse_macro_input!(input as DeriveInput); - let name = &ast.ident; + match typed_inner(ast) { + Ok(ts) => ts.into(), + Err(err) => err.into_compile_error().into(), + } +} + +fn typed_inner(ast: DeriveInput) -> syn::Result { + let name = ast.ident; let mut case = None; - + let mut tag = None; for attr in &ast.attrs { if attr.path().is_ident("serde") && let Err(err) = attr.parse_nested_meta(|meta| { @@ -148,10 +354,15 @@ pub fn typed(input: proc_macro::TokenStream) -> proc_macro::TokenStream { }?); } + if meta.path.is_ident("tag") { + meta.input.parse::()?; + tag = Some(meta.input.parse::()?); + } + Ok(()) }) { - return err.into_compile_error().into(); + return Err(err); } } @@ -163,103 +374,29 @@ pub fn typed(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let test: TokenStream2 = match ast.data { syn::Data::Struct(data_struct) => { - if data_struct.fields.iter().any(|field| field.ident.is_none()) { - return syn::Error::new( - data_struct.fields.span(), - "Tuple structs are not supported by Typed", - ) - .to_compile_error() - .into(); - } - - let mut fields = Vec::new(); - for field in data_struct.fields { - let mut default = false; - let mut flatten = false; - for attr in &field.attrs { - if attr.path().is_ident("serde") - && let Err(err) = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("default") { - default = true; - if meta.input.peek(Token![=]) { - meta.input.parse::()?; - meta.input.parse::()?; - } - } else if meta.path.is_ident("flatten") { - flatten = true; - } - - Ok(()) - }) - { - return err.into_compile_error().into(); - } - } - - fields.push(StructField { - name: field.ident.expect("We already checked that ident is some"), - ty: field.ty, - case, - default, - flatten, - }); - } - - Struct { - name: name.to_owned(), - fields, - } - .into_token_stream() + Struct::from_data(data_struct, name.clone(), case)?.into_token_stream() + } + syn::Data::Enum(data_enum) if let Some(tag) = tag => { + ExtTaggedEnum::from_data(data_enum, name.clone(), tag, case)?.into_token_stream() } syn::Data::Enum(data_enum) => { - let errors: TokenStream2 = data_enum - .variants - .iter() - .filter(|variant| !variant.fields.is_empty()) - .map(|variant| { - syn::Error::new( - variant.fields.span(), - "Only basic enums are supported by Typed", - ) - .into_compile_error() - }) - .collect(); - - if !errors.is_empty() { - return errors.into(); - } - - BasicEnum { - name: name.to_owned(), - variants: data_enum - .variants - .into_iter() - .map(|variant| BasicEnumVariant { - name: variant.ident, - case, - }) - .collect(), - } - .into_token_stream() + BasicEnum::from_data(data_enum, name.clone(), case)?.into_token_stream() } syn::Data::Union(data_union) => { - return syn::Error::new( + return Err(syn::Error::new( data_union.union_token.span, "Unions are not supported by Typed", - ) - .into_compile_error() - .into(); + )); } }; - quote! { + Ok(quote! { impl ::lua_typed::Typed for #name { #type_name_fn #test } - } - .into() + }) } #[cfg(test)] diff --git a/tests/notification.rs b/tests/notification.rs index 99f73ff..a2b350e 100644 --- a/tests/notification.rs +++ b/tests/notification.rs @@ -1,5 +1,25 @@ +use std::collections::HashMap; + use lua_typed::Typed; +#[derive(Typed)] +#[serde(rename_all = "snake_case", tag = "action")] +pub enum ActionType { + Broadcast { extras: HashMap }, + // View, + // Http +} + +#[test] +fn action_type() { + insta::assert_snapshot!(::generate_full().unwrap(), @r#" + ---@class ActionType + ---@field action + ---| "broadcast" + ---@field extras table? + "#); +} + #[derive(Typed)] #[repr(u8)] #[serde(rename_all = "snake_case")] @@ -25,22 +45,23 @@ fn priority() { #[derive(Typed)] pub struct Action { - // #[serde(flatten)] - // pub action: ActionType, + #[serde(flatten)] + pub action: ActionType, pub label: String, pub clear: Option, } -// #[test] -// fn action() { -// insta::assert_snapshot!(::generate_full().unwrap(), @r#" -// ---@class Action -// ---@field action "broadcast" -// ---@field extras table? -// ---@field label string? -// ---@clear clear bool? -// "#); -// } +#[test] +fn action() { + insta::assert_snapshot!(::generate_full().unwrap(), @r#" + ---@class Action + ---@field action + ---| "broadcast" + ---@field extras table? + ---@field label string + ---@field clear boolean? + "#); +} #[derive(Typed)] pub struct Notification { diff --git a/tests/ui/complex_struct.stderr b/tests/ui/complex_struct.stderr index d7b8c7a..2d49c9a 100644 --- a/tests/ui/complex_struct.stderr +++ b/tests/ui/complex_struct.stderr @@ -1,16 +1,16 @@ -error: Only basic enums are supported by Typed +error: Typed supports enum variants with fields only when the enum is externally tagged --> tests/ui/complex_struct.rs:6:6 | 6 | B(String), | ^^^^^^^^ -error: Only basic enums are supported by Typed +error: Typed supports enum variants with fields only when the enum is externally tagged --> tests/ui/complex_struct.rs:7:6 | 7 | C(u8), | ^^^^ -error: Only basic enums are supported by Typed +error: Typed supports enum variants with fields only when the enum is externally tagged --> tests/ui/complex_struct.rs:8:7 | 8 | D { test: f32 }, diff --git a/tests/ui/tuple_enum_untagged.rs b/tests/ui/tuple_enum_untagged.rs new file mode 100644 index 0000000..764dc23 --- /dev/null +++ b/tests/ui/tuple_enum_untagged.rs @@ -0,0 +1,9 @@ +use lua_typed::Typed; + +#[derive(Typed)] +enum Test { + A, + D { test: f32 }, +} + +fn main() {} diff --git a/tests/ui/tuple_enum_untagged.stderr b/tests/ui/tuple_enum_untagged.stderr new file mode 100644 index 0000000..efb057e --- /dev/null +++ b/tests/ui/tuple_enum_untagged.stderr @@ -0,0 +1,5 @@ +error: Typed supports enum variants with fields only when the enum is externally tagged + --> tests/ui/tuple_enum_untagged.rs:6:7 + | +6 | D { test: f32 }, + | ^^^^^^^^^^^^^