feat: Add support for externally tagged enums

Also did a massive code refactor that helped simplify the
implementation.
This commit is contained in:
2025-09-16 23:21:36 +02:00
parent 6d5c2da030
commit 467a037a77
5 changed files with 277 additions and 105 deletions

View File

@@ -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<Case<'static>>,
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<convert_case::Case<'static>>,
) -> syn::Result<Self> {
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<Case<'static>>,
@@ -117,14 +141,196 @@ impl ToTokens for BasicEnum {
}
}
impl BasicEnum {
fn from_data(
data: DataEnum,
name: syn::Ident,
case: Option<convert_case::Case<'static>>,
) -> syn::Result<Self> {
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<StructField>,
case: Option<Case<'static>>,
}
impl ToTokens for ExtTaggedEnumVariant {
fn to_tokens(&self, tokens: &mut TokenStream2) {
let fields = &self.fields;
tokens.extend(quote! {
#(
output += &#fields;
)*
});
}
}
impl From<ExtTaggedEnumVariant> for BasicEnumVariant {
fn from(value: ExtTaggedEnumVariant) -> Self {
Self {
name: value.name,
case: value.case,
}
}
}
struct ExtTaggedEnum {
name: syn::Ident,
tag: LitStr,
variants: Vec<ExtTaggedEnumVariant>,
}
impl ToTokens for ExtTaggedEnum {
fn to_tokens(&self, tokens: &mut TokenStream2) {
let name = &self.name;
let tag_variants: Vec<BasicEnumVariant> =
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<String> {
Some(format!("---@class {}\n", <#name as Typed>::type_name()))
}
fn generate_members() -> Option<String> {
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<convert_case::Case<'static>>,
) -> syn::Result<Self> {
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<convert_case::Case<'static>>,
) -> syn::Result<Vec<StructField>> {
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::<Token![=]>()?;
meta.input.parse::<LitStr>()?;
}
} 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<TokenStream2> {
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::<Token![=]>()?;
tag = Some(meta.input.parse::<LitStr>()?);
}
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::<Token![=]>()?;
meta.input.parse::<LitStr>()?;
}
} 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)]

View File

@@ -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<String, String> },
// View,
// Http
}
#[test]
fn action_type() {
insta::assert_snapshot!(<ActionType as Typed>::generate_full().unwrap(), @r#"
---@class ActionType
---@field action
---| "broadcast"
---@field extras table<string, string>?
"#);
}
#[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<bool>,
}
// #[test]
// fn action() {
// insta::assert_snapshot!(<Action as Typed>::generate_full().unwrap(), @r#"
// ---@class Action
// ---@field action "broadcast"
// ---@field extras table<string, string>?
// ---@field label string?
// ---@clear clear bool?
// "#);
// }
#[test]
fn action() {
insta::assert_snapshot!(<Action as Typed>::generate_full().unwrap(), @r#"
---@class Action
---@field action
---| "broadcast"
---@field extras table<string, string>?
---@field label string
---@field clear boolean?
"#);
}
#[derive(Typed)]
pub struct Notification {

View File

@@ -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 },

View File

@@ -0,0 +1,9 @@
use lua_typed::Typed;
#[derive(Typed)]
enum Test {
A,
D { test: f32 },
}
fn main() {}

View File

@@ -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 },
| ^^^^^^^^^^^^^