diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 3dae3d3..a74eb6f 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -64,6 +64,29 @@ jobs: command: test args: --all-features + test_backends: + name: TestBackends + runs-on: ${{ matrix.config.os }} + strategy: + fail-fast: false + matrix: + config: + - { os: ubuntu-latest, target: 'x86_64-unknown-linux-gnu' } + - { os: macos-latest, target: 'x86_64-apple-darwin' } + backends: ["syn", ""] + + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + target: ${{ matrix.config.target }} + override: true + - uses: actions-rs/cargo@v1 + with: + command: test + args: --features "debug_asserts, rustc_hash, generated_setters, debug_diffs, serde, nanoserde, ${{ matrix.backends.target }}" + test_serde: name: TestSerde runs-on: ${{ matrix.config.os }} diff --git a/Cargo.toml b/Cargo.toml index e8fba05..9f0596c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ structdiff-derive = { path = "derive", version = "=0.7.4" } "default" = [] "nanoserde" = ["dep:nanoserde", "structdiff-derive/nanoserde"] "serde" = ["dep:serde", "structdiff-derive/serde"] +"syn" = ["structdiff-derive/syn"] "debug_diffs" = ["structdiff-derive/debug_diffs"] "generated_setters" = ["structdiff-derive/generated_setters"] "rustc_hash" = ["dep:rustc-hash"] diff --git a/README.md b/README.md index 101c8f4..e5fc675 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ For more examples take a look at [integration tests](/tests) - `debug_diffs` - Derive `Debug` on the generated diff type - `generated_setters` - Enable generation of setters for struct fields. These setters automatically return a diff if a field's value is changed by the assignment. - `rustc_hash` - Use the (non-cryptographic) hash implementation from the `rustc-hash` crate instead of the default hasher. Much faster diff generation for collections at the cost of a dependency. +- `syn` - Use a `syn`-based backend for the `Difference` derive macro. This adds the `syn`, `quote`, and `proc-macro2` dependencies, but supports Rust syntax through `syn`'s parser instead of structdiff's zero-dependency derive parser. ### Development status This is being used actively for my own projects, although it's mostly working now. PRs will be accepted for either more tests or functionality. \ No newline at end of file diff --git a/derive/Cargo.toml b/derive/Cargo.toml index 31e08f9..08f8c59 100644 --- a/derive/Cargo.toml +++ b/derive/Cargo.toml @@ -11,12 +11,16 @@ proc-macro = true [dependencies] nanoserde = { version = "^0.1.37", optional = true } +proc-macro2 = { version = "1.0", optional = true } +quote = { version = "1.0", optional = true } serde = { version = "^1.0.0", optional = true, features = ["derive"] } +syn = { version = "2.0", optional = true, features = ["derive", "full", "parsing"] } [features] "default" = [] "nanoserde" = ["dep:nanoserde"] "serde" = ["dep:serde"] +"syn" = ["dep:proc-macro2", "dep:quote", "dep:syn"] "debug_diffs" = [] "generated_setters" = [] diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 095a408..636f63c 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -5,22 +5,36 @@ extern crate proc_macro; mod shared; mod difference; +#[cfg(feature = "syn")] +mod syn_backend; + +#[cfg(not(feature = "syn"))] use difference::derive_struct_diff_enum; +#[cfg(not(feature = "syn"))] use crate::difference::derive_struct_diff_struct; +#[cfg_attr(feature = "syn", allow(dead_code))] mod parse; /// Derive macro generating an impl of the trait `StructDiff` #[proc_macro_derive(Difference, attributes(difference))] pub fn derive_struct_diff(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + derive_struct_diff_impl(input) +} + +#[cfg(feature = "syn")] +fn derive_struct_diff_impl(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + syn_backend::derive_struct_diff(input) +} + +#[cfg(not(feature = "syn"))] +fn derive_struct_diff_impl(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let input = parse::parse_data(input); - let ts = match &input { + match &input { parse::Data::Struct(struct_) if struct_.named => derive_struct_diff_struct(struct_), parse::Data::Enum(enum_) => derive_struct_diff_enum(enum_), _ => unimplemented!("Only structs and enums are supported"), - }; - - ts + } } diff --git a/derive/src/syn_backend.rs b/derive/src/syn_backend.rs new file mode 100644 index 0000000..f678f91 --- /dev/null +++ b/derive/src/syn_backend.rs @@ -0,0 +1,528 @@ +use proc_macro::TokenStream; +use quote::ToTokens; +use syn::punctuated::Punctuated; +use syn::{ + parse_macro_input, Attribute, ConstParam, Data, DeriveInput, Expr, Field, Fields, GenericParam, + LifetimeParam, Lit, Meta, PathArguments, Type, TypeParam, TypeParamBound, WherePredicate, +}; + +use crate::difference::{derive_struct_diff_enum, derive_struct_diff_struct}; +use crate::parse::{ + Attribute as ParsedAttribute, Category, ConstValType, Data as ParsedData, Enum as ParsedEnum, + Field as ParsedField, FnType, Generic as ParsedGeneric, Lifetime, Struct as ParsedStruct, + Type as ParsedType, Visibility, +}; + +pub(crate) fn derive_struct_diff(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + match lower_input(input) { + ParsedData::Struct(struct_) if struct_.named => derive_struct_diff_struct(&struct_), + ParsedData::Enum(enum_) => derive_struct_diff_enum(&enum_), + _ => unimplemented!("Only structs and enums are supported"), + } +} + +fn lower_input(input: DeriveInput) -> ParsedData { + let attributes = lower_attrs(&input.attrs); + let generics = lower_generics(&input.generics); + let name = input.ident.to_string(); + + match input.data { + Data::Struct(data) => ParsedData::Struct(ParsedStruct { + name: Some(name), + named: matches!(data.fields, Fields::Named(_)), + fields: lower_fields(data.fields), + attributes, + generics, + }), + Data::Enum(data) => ParsedData::Enum(ParsedEnum { + name, + variants: data + .variants + .into_iter() + .map(|variant| { + let ty = match variant.fields { + Fields::Unit => empty_type(), + Fields::Unnamed(fields) => tuple_type( + fields + .unnamed + .into_iter() + .map(|field| lower_type(&field.ty)) + .collect(), + ), + Fields::Named(fields) => { + let fields = + fields.named.into_iter().map(lower_field).collect::>(); + let contents = ParsedStruct { + name: Some(variant.ident.to_string()), + named: true, + fields: fields.clone(), + attributes: Vec::new(), + generics: Vec::new(), + }; + ParsedType { + wraps: Some(fields.into_iter().map(|field| field.ty).collect()), + ident: Category::AnonymousStruct { contents }, + ref_type: None, + as_other: None, + } + } + }; + + ParsedField { + attributes: lower_attrs(&variant.attrs), + vis: Visibility::Public, + field_name: Some(variant.ident.to_string()), + ty, + } + }) + .collect(), + attributes, + generics, + }), + Data::Union(_) => ParsedData::Union(()), + } +} + +fn lower_fields(fields: Fields) -> Vec { + match fields { + Fields::Named(fields) => fields.named.into_iter().map(lower_field).collect(), + Fields::Unnamed(fields) => fields.unnamed.into_iter().map(lower_field).collect(), + Fields::Unit => Vec::new(), + } +} + +fn lower_field(field: Field) -> ParsedField { + ParsedField { + attributes: lower_attrs(&field.attrs), + vis: lower_visibility(&field.vis), + field_name: field.ident.map(|ident| ident.to_string()), + ty: lower_type(&field.ty), + } +} + +fn lower_visibility(vis: &syn::Visibility) -> Visibility { + match vis { + syn::Visibility::Public(_) => Visibility::Public, + syn::Visibility::Restricted(restricted) + if restricted.path.is_ident("crate") || restricted.path.is_ident("self") => + { + Visibility::Crate + } + syn::Visibility::Restricted(_) => Visibility::Restricted, + syn::Visibility::Inherited => Visibility::Private, + } +} + +fn lower_attrs(attrs: &[Attribute]) -> Vec { + attrs + .iter() + .filter(|attr| attr.path().is_ident("difference")) + .flat_map(|attr| { + attr.parse_args_with(Punctuated::::parse_terminated) + .unwrap_or_default() + .into_iter() + .filter_map(lower_meta) + }) + .collect() +} + +fn lower_meta(meta: Meta) -> Option { + match meta { + Meta::Path(path) => Some(ParsedAttribute { + name: "difference".to_owned(), + tokens: vec![path_to_string(&path)], + }), + Meta::NameValue(name_value) => { + let value = match name_value.value { + Expr::Lit(expr_lit) => match expr_lit.lit { + Lit::Str(lit) => lit.value(), + other => other.to_token_stream().to_string(), + }, + other => other.to_token_stream().to_string(), + }; + Some(ParsedAttribute { + name: "difference".to_owned(), + tokens: vec![path_to_string(&name_value.path), value], + }) + } + Meta::List(_) => None, + } +} + +fn lower_generics(generics: &syn::Generics) -> Vec { + let mut lowered = Vec::new(); + + for generic in generics.params.iter().map(lower_generic_param) { + push_or_merge_generic(&mut lowered, generic); + } + + if let Some(where_clause) = &generics.where_clause { + for generic in where_clause + .predicates + .iter() + .filter_map(lower_where_predicate) + { + push_or_merge_generic(&mut lowered, generic); + } + } + + lowered +} + +fn push_or_merge_generic(generics: &mut Vec, generic: ParsedGeneric) { + let Some(existing) = generics + .iter_mut() + .find(|existing| existing.full() == generic.full()) + else { + generics.push(generic); + return; + }; + + match (existing, generic) { + ( + ParsedGeneric::Regular { bounds, .. }, + ParsedGeneric::Regular { + bounds: other_bounds, + .. + } + | ParsedGeneric::WhereBounded { + bounds: other_bounds, + .. + }, + ) + | ( + ParsedGeneric::WhereBounded { bounds, .. }, + ParsedGeneric::Regular { + bounds: other_bounds, + .. + } + | ParsedGeneric::WhereBounded { + bounds: other_bounds, + .. + }, + ) => bounds.extend(other_bounds), + ( + ParsedGeneric::Lifetime { bounds, .. }, + ParsedGeneric::Lifetime { + bounds: other_bounds, + .. + }, + ) => bounds.extend(other_bounds), + _ => (), + } +} + +fn lower_generic_param(param: &GenericParam) -> ParsedGeneric { + match param { + GenericParam::Type(param) => lower_type_param(param), + GenericParam::Lifetime(param) => lower_lifetime_param(param), + GenericParam::Const(param) => lower_const_param(param), + } +} + +fn lower_type_param(param: &TypeParam) -> ParsedGeneric { + ParsedGeneric::Regular { + name: param.ident.to_string(), + default: param.default.as_ref().map(lower_type), + bounds: lower_type_bounds(¶m.bounds), + } +} + +fn lower_lifetime_param(param: &LifetimeParam) -> ParsedGeneric { + ParsedGeneric::Lifetime { + name: lifetime_name(¶m.lifetime), + bounds: param + .bounds + .iter() + .map(|lifetime| Lifetime { + ident: lifetime_name(lifetime), + }) + .collect(), + } +} + +fn lower_const_param(param: &ConstParam) -> ParsedGeneric { + ParsedGeneric::Const { + name: param.ident.to_string(), + _type: lower_type(¶m.ty), + default: param.default.as_ref().map(lower_const_value), + } +} + +fn lower_where_predicate(predicate: &WherePredicate) -> Option { + match predicate { + WherePredicate::Type(predicate) => Some(ParsedGeneric::WhereBounded { + name: lower_type(&predicate.bounded_ty).full(), + bounds: lower_type_bounds(&predicate.bounds), + }), + WherePredicate::Lifetime(predicate) => Some(ParsedGeneric::Lifetime { + name: lifetime_name(&predicate.lifetime), + bounds: predicate + .bounds + .iter() + .map(|lifetime| Lifetime { + ident: lifetime_name(lifetime), + }) + .collect(), + }), + _ => None, + } +} + +fn lower_type_bounds(bounds: &Punctuated) -> Vec { + bounds + .iter() + .filter_map(|bound| match bound { + TypeParamBound::Trait(bound) => Some(path_type(bound.to_token_stream().to_string(), None)), + TypeParamBound::Lifetime(lifetime) => Some(ParsedType { + ident: Category::Lifetime { + path: lifetime_name(lifetime), + }, + wraps: None, + ref_type: None, + as_other: None, + }), + TypeParamBound::Verbatim(tokens) => Some(path_type(tokens.to_string(), None)), + _ => None, + }) + .collect() +} + +fn lower_const_value(expr: &Expr) -> ConstValType { + match expr { + Expr::Lit(expr_lit) => match &expr_lit.lit { + Lit::Int(lit) => lit.base10_parse::().map(ConstValType::Value).unwrap_or_else( + |_| ConstValType::Named(Box::new(path_type(lit.to_string(), None))), + ), + _ => ConstValType::Named(Box::new(path_type(expr.to_token_stream().to_string(), None))), + }, + _ => ConstValType::Named(Box::new(path_type(expr.to_token_stream().to_string(), None))), + } +} + +fn lower_type(ty: &Type) -> ParsedType { + match ty { + Type::Array(array) => { + let content_type = lower_type(&array.elem); + let len = lower_array_len(&array.len); + ParsedType { + ident: Category::Array { + content_type: Box::new(content_type.clone()), + len, + }, + wraps: Some(vec![content_type]), + ref_type: None, + as_other: None, + } + } + Type::BareFn(bare) => ParsedType { + ident: Category::Fn { + category: FnType::Bare, + args: Some(Box::new(tuple_type( + bare.inputs + .iter() + .map(|input| lower_type(&input.ty)) + .collect(), + ))), + return_type: lower_return_type(&bare.output).map(Box::new), + }, + wraps: None, + ref_type: None, + as_other: None, + }, + Type::Group(group) => lower_type(&group.elem), + Type::ImplTrait(bounds) => object_type(false, &bounds.bounds), + Type::Never(_) => ParsedType { + ident: Category::Never, + wraps: None, + ref_type: None, + as_other: None, + }, + Type::Paren(paren) => tuple_type(vec![lower_type(&paren.elem)]), + Type::Path(path) => lower_type_path(path), + Type::Ptr(ptr) => path_type(ptr.to_token_stream().to_string(), None), + Type::Reference(reference) => { + let mut ty = lower_type(&reference.elem); + ty.ref_type = Some(reference.lifetime.as_ref().map(|lifetime| Lifetime { + ident: lifetime_name(lifetime), + })); + ty + } + Type::Slice(slice) => { + let content_type = lower_type(&slice.elem); + ParsedType { + ident: Category::Array { + content_type: Box::new(content_type.clone()), + len: None, + }, + wraps: Some(vec![content_type]), + ref_type: None, + as_other: None, + } + } + Type::TraitObject(object) => object_type(true, &object.bounds), + Type::Tuple(tuple) => tuple_type(tuple.elems.iter().map(lower_type).collect()), + Type::Verbatim(tokens) => path_type(tokens.to_string(), None), + _ => path_type(ty.to_token_stream().to_string(), None), + } +} + +fn lower_type_path(path: &syn::TypePath) -> ParsedType { + if let Some(qself) = &path.qself { + let base = lower_type(&qself.ty); + let as_trait = path + .path + .segments + .iter() + .take(qself.position) + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::"); + let associated = path + .path + .segments + .iter() + .skip(qself.position) + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::"); + + return ParsedType { + wraps: Some(vec![base.clone()]), + ident: Category::Associated { + base: Box::new(base), + as_trait: Box::new(path_type(as_trait, None)), + associated: Box::new(path_type(associated, None)), + }, + ref_type: None, + as_other: None, + }; + } + + let Some(last) = path.path.segments.last() else { + return path_type(path_to_string(&path.path), None); + }; + + let wraps = match &last.arguments { + PathArguments::AngleBracketed(args) => { + let args = args + .args + .iter() + .filter_map(|arg| match arg { + syn::GenericArgument::Type(ty) => Some(lower_type(ty)), + syn::GenericArgument::Lifetime(lifetime) => Some(ParsedType { + ident: Category::Lifetime { + path: lifetime_name(lifetime), + }, + wraps: None, + ref_type: None, + as_other: None, + }), + syn::GenericArgument::Const(expr) => { + Some(path_type(expr.to_token_stream().to_string(), None)) + } + syn::GenericArgument::AssocType(assoc) => Some(ParsedType { + ident: Category::AssociatedBound { + associated: assoc.ident.to_string(), + is: Box::new(lower_type(&assoc.ty)), + }, + wraps: None, + ref_type: None, + as_other: None, + }), + _ => None, + }) + .collect::>(); + (!args.is_empty()).then_some(args) + } + _ => None, + }; + + let path_without_args = path + .path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::"); + + path_type(path_without_args, wraps) +} + +fn lower_array_len(expr: &Expr) -> Option { + match expr { + Expr::Lit(expr_lit) => match &expr_lit.lit { + Lit::Int(lit) => lit.base10_parse::().ok().map(ConstValType::Value), + _ => Some(ConstValType::Named(Box::new(path_type( + expr.to_token_stream().to_string(), + None, + )))), + }, + _ => Some(ConstValType::Named(Box::new(path_type( + expr.to_token_stream().to_string(), + None, + )))), + } +} + +fn lower_return_type(return_type: &syn::ReturnType) -> Option { + match return_type { + syn::ReturnType::Default => None, + syn::ReturnType::Type(_, ty) => Some(lower_type(ty)), + } +} + +fn object_type(is_dyn: bool, bounds: &Punctuated) -> ParsedType { + ParsedType { + ident: Category::Object { + is_dyn, + trait_names: lower_type_bounds(bounds), + }, + wraps: None, + ref_type: None, + as_other: None, + } +} + +fn tuple_type(contents: Vec) -> ParsedType { + ParsedType { + ident: Category::Tuple { + contents: contents.clone(), + }, + wraps: Some(contents), + ref_type: None, + as_other: None, + } +} + +fn path_type(path: String, wraps: Option>) -> ParsedType { + ParsedType { + ident: Category::Named { path }, + wraps, + ref_type: None, + as_other: None, + } +} + +fn empty_type() -> ParsedType { + ParsedType { + ident: Category::None, + wraps: None, + ref_type: None, + as_other: None, + } +} + +fn path_to_string(path: &syn::Path) -> String { + path.segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::") +} + +fn lifetime_name(lifetime: &syn::Lifetime) -> String { + lifetime.ident.to_string() +}