From 7c17e9b07a08b85a50e6cefce4275b140002a3d5 Mon Sep 17 00:00:00 2001 From: jphppd <59113286+jphppd@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:13:38 +0100 Subject: [PATCH 1/5] run cargo fmt --- derive/src/catalyst.rs | 6 ++---- derive/src/filler.rs | 10 ++++++++-- derive/src/patch.rs | 10 ++++++++-- lib/src/box.rs | 1 - lib/src/traits.rs | 12 ++++++++++-- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/derive/src/catalyst.rs b/derive/src/catalyst.rs index 8826f4a..527ce07 100644 --- a/derive/src/catalyst.rs +++ b/derive/src/catalyst.rs @@ -381,8 +381,7 @@ fn find_fields_from_src(src: &str, struct_name: &str) -> syn::Fields { .split_once(':') .expect("src attribute must be 'crate_name:/path/to/file.rs'"); - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") - .expect("CARGO_MANIFEST_DIR is not set"); + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is not set"); let meta = cargo_metadata::MetadataCommand::new() .manifest_path(std::path::Path::new(&manifest_dir).join("Cargo.toml")) @@ -400,8 +399,7 @@ fn find_fields_from_src(src: &str, struct_name: &str) -> syn::Fields { .parent() .expect("manifest_path has no parent directory"); - let src_path = std::path::Path::new(pkg_dir.as_str()) - .join(rel_path.trim_start_matches('/')); + let src_path = std::path::Path::new(pkg_dir.as_str()).join(rel_path.trim_start_matches('/')); let content = std::fs::read_to_string(&src_path) .unwrap_or_else(|e| panic!("cannot read `{}`: {}", src_path.display(), e)); diff --git a/derive/src/filler.rs b/derive/src/filler.rs index dda4b75..5b8dd7d 100644 --- a/derive/src/filler.rs +++ b/derive/src/filler.rs @@ -321,7 +321,11 @@ impl Filler { self.#nesting_field_names.apply(filler.#nesting_field_names); )* }; - #[cfg(all(feature = "nesting", not(feature = "simple-nesting"), not(feature = "log")))] + #[cfg(all( + feature = "nesting", + not(feature = "simple-nesting"), + not(feature = "log") + ))] let nesting_apply_section: TokenStream = quote! { #( self.#nesting_field_names.apply(filler.#nesting_field_names); @@ -524,7 +528,9 @@ impl Filler { } #[cfg(not(feature = "log"))] DEFAULT_LOG => { - return Err(meta.error("`default_log` attribute requires the `log` feature")); + return Err( + meta.error("`default_log` attribute requires the `log` feature") + ); } _ => { return Err(meta.error(format_args!( diff --git a/derive/src/patch.rs b/derive/src/patch.rs index 9a3d580..68a57db 100644 --- a/derive/src/patch.rs +++ b/derive/src/patch.rs @@ -845,7 +845,11 @@ impl Patch { self.#nesting_field_names.apply(patch.#nesting_field_names); )* }; - #[cfg(all(feature = "nesting", not(feature = "simple-nesting"), not(feature = "log")))] + #[cfg(all( + feature = "nesting", + not(feature = "simple-nesting"), + not(feature = "log") + ))] let nesting_apply_section: TokenStream = quote! { #( self.#nesting_field_names.apply(patch.#nesting_field_names); @@ -1368,7 +1372,9 @@ impl Patch { } #[cfg(not(feature = "log"))] DEFAULT_LOG => { - return Err(meta.error("`default_log` attribute requires the `log` feature")); + return Err( + meta.error("`default_log` attribute requires the `log` feature") + ); } _ => { return Err(meta.error(format_args!( diff --git a/lib/src/box.rs b/lib/src/box.rs index ee5353f..6bb6093 100644 --- a/lib/src/box.rs +++ b/lib/src/box.rs @@ -2,4 +2,3 @@ extern crate alloc; pub use alloc::boxed::Box; - diff --git a/lib/src/traits.rs b/lib/src/traits.rs index b553d6d..75969a1 100644 --- a/lib/src/traits.rs +++ b/lib/src/traits.rs @@ -238,7 +238,11 @@ pub trait Patch

{ self.apply(patch); } - #[cfg(all(feature = "log", not(feature = "nesting"), not(feature = "simple-nesting")))] + #[cfg(all( + feature = "log", + not(feature = "nesting"), + not(feature = "simple-nesting") + ))] fn apply_with_log(&mut self, patch: P, _log: F) { self.apply(patch); } @@ -304,7 +308,11 @@ pub trait Filler { self.apply(filler); } - #[cfg(all(feature = "log", not(feature = "nesting"), not(feature = "simple-nesting")))] + #[cfg(all( + feature = "log", + not(feature = "nesting"), + not(feature = "simple-nesting") + ))] fn apply_with_log(&mut self, filler: F, _log: L) { self.apply(filler); } From a10640da98b8d09008dca64119adc80101e8e01d Mon Sep 17 00:00:00 2001 From: jphppd <59113286+jphppd@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:51:40 +0100 Subject: [PATCH 2/5] fix cargo clippy warnings --- derive/src/catalyst.rs | 23 ++++++++++------------- derive/src/filler.rs | 2 +- derive/src/patch.rs | 6 +++--- lib/src/lib.rs | 2 ++ 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/derive/src/catalyst.rs b/derive/src/catalyst.rs index 527ce07..3666ce4 100644 --- a/derive/src/catalyst.rs +++ b/derive/src/catalyst.rs @@ -56,7 +56,7 @@ impl Catalyst { } = self; let substrate_name: Ident = { - let lit = LitStr::new(&bind, Span::call_site()); + let lit = LitStr::new(bind, Span::call_site()); lit.parse()? }; @@ -355,7 +355,7 @@ impl Catalyst { )); } let complex_struct_name = name.unwrap_or({ - let ts = TokenStream::from_str(&format!("{}Complex", &ident,)).unwrap(); + let ts = TokenStream::from_str(&format!("{}Complex", ident)).unwrap(); let lit = LitStr::new(&ts.to_string(), Span::call_site()); lit.parse()? }); @@ -445,13 +445,13 @@ impl Field { } if keep_field_attribute { - if attrs.len() > 0 { + if !attrs.is_empty() { // from substrate: filter out excluded attribute namespaces let filtered_attrs: Vec<&Attribute> = attrs .iter() .filter(|a| { let path = a.path().to_token_stream().to_string(); - !exclude_field_attributes.iter().any(|e| path == *e) + !exclude_field_attributes.contains(&path) }) .collect(); Ok(quote! { @@ -527,15 +527,12 @@ impl Field { } let _ = attr.parse_nested_meta(|meta| { let path = meta.path.to_string(); - match path.as_str() { - ATTRIBUTE => { - // #[complex(attribute(serde(default))] - let content; - parenthesized!(content in meta.input); - let attribute: TokenStream = content.parse()?; - attributes.push(attribute); - } - _ => {} + if path.as_str() == ATTRIBUTE { + // #[complex(attribute(serde(default))] + let content; + parenthesized!(content in meta.input); + let attribute: TokenStream = content.parse()?; + attributes.push(attribute); } Ok(()) }); diff --git a/derive/src/filler.rs b/derive/src/filler.rs index 5b8dd7d..1c4f508 100644 --- a/derive/src/filler.rs +++ b/derive/src/filler.rs @@ -548,7 +548,7 @@ impl Filler { fields.push(f); } } - let ts = TokenStream::from_str(&format!("{}Filler", &ident,)).unwrap(); + let ts = TokenStream::from_str(&format!("{}Filler", ident)).unwrap(); let lit = LitStr::new(&ts.to_string(), Span::call_site()); let filler_struct_name = lit.parse()?; diff --git a/derive/src/patch.rs b/derive/src/patch.rs index 68a57db..c9dc815 100644 --- a/derive/src/patch.rs +++ b/derive/src/patch.rs @@ -1396,7 +1396,7 @@ impl Patch { Ok(Patch { visibility: vis, patch_struct_name: name.unwrap_or({ - let ts = TokenStream::from_str(&format!("{}Patch", &ident,)).unwrap(); + let ts = TokenStream::from_str(&format!("{}Patch", ident)).unwrap(); let lit = LitStr::new(&ts.to_string(), Span::call_site()); lit.parse()? }), @@ -1451,7 +1451,7 @@ impl Field { if *nesting { // TODO handle rename let patch_type = syn::Ident::new( - &format!("{}Patch", &ty.to_token_stream()), + &format!("{}Patch", ty.to_token_stream()), Span::call_site(), ); Ok(quote! { @@ -1489,7 +1489,7 @@ impl Field { if *nesting { // TODO handle rename let patch_type = syn::Ident::new( - &format!("{}Patch", &ty.to_token_stream()), + &format!("{}Patch", ty.to_token_stream()), Span::call_site(), ); Ok(quote! { diff --git a/lib/src/lib.rs b/lib/src/lib.rs index ea2ee22..740e61d 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -407,6 +407,7 @@ mod tests { fn test_add_patches() { #[derive(Patch)] #[patch(attribute(derive(Debug, PartialEq)))] + #[allow(dead_code, reason = "used for the test of derive(Patch)")] struct Item { field: u32, other: String, @@ -435,6 +436,7 @@ mod tests { #[should_panic] fn test_add_conflict_patches_panic() { #[derive(Patch, Debug, PartialEq)] + #[allow(dead_code, reason = "used for the test of derive(Patch)")] struct Item { field: u32, } From db97075bded5eff98879854c6ea19c2dc4eae7f0 Mon Sep 17 00:00:00 2001 From: jphppd <59113286+jphppd@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:21:09 +0100 Subject: [PATCH 3/5] use string interpolation in format! --- derive/src/catalyst.rs | 2 +- derive/src/filler.rs | 2 +- derive/src/lib.rs | 12 +++--------- derive/src/patch.rs | 2 +- docs/logs.md | 6 +++--- examples/filler-examples/examples/log.rs | 2 +- examples/patch-examples/examples/log.rs | 6 +++--- examples/patch-examples/examples/op.rs | 2 +- lib/src/traits.rs | 10 +++++----- 9 files changed, 19 insertions(+), 25 deletions(-) diff --git a/derive/src/catalyst.rs b/derive/src/catalyst.rs index 3666ce4..77539d5 100644 --- a/derive/src/catalyst.rs +++ b/derive/src/catalyst.rs @@ -355,7 +355,7 @@ impl Catalyst { )); } let complex_struct_name = name.unwrap_or({ - let ts = TokenStream::from_str(&format!("{}Complex", ident)).unwrap(); + let ts = TokenStream::from_str(&format!("{ident}Complex")).unwrap(); let lit = LitStr::new(&ts.to_string(), Span::call_site()); lit.parse()? }); diff --git a/derive/src/filler.rs b/derive/src/filler.rs index 1c4f508..7d77752 100644 --- a/derive/src/filler.rs +++ b/derive/src/filler.rs @@ -548,7 +548,7 @@ impl Filler { fields.push(f); } } - let ts = TokenStream::from_str(&format!("{}Filler", ident)).unwrap(); + let ts = TokenStream::from_str(&format!("{ident}Filler")).unwrap(); let lit = LitStr::new(&ts.to_string(), Span::call_site()); let filler_struct_name = lit.parse()?; diff --git a/derive/src/lib.rs b/derive/src/lib.rs index f66f4ae..9ce37d5 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -73,10 +73,7 @@ fn get_lit(attr_name: String, meta: &ParseNestedMeta) -> syn::Result syn::Result String { - format!("{}, {}", a, b) + format!("{a}, {b}") } #[cfg(feature = "op")] diff --git a/lib/src/traits.rs b/lib/src/traits.rs index 75969a1..ef66cc8 100644 --- a/lib/src/traits.rs +++ b/lib/src/traits.rs @@ -86,7 +86,7 @@ /// let path = if prefixes.is_empty() { /// field.to_string() /// } else { -/// format!("{}.{}", prefixes.join("."), field) +/// format!("{}.{field}", prefixes.join(".")) /// }; /// println!("patched: {path}"); /// } @@ -95,7 +95,7 @@ /// let path = if prefix.is_empty() { /// field.to_string() /// } else { -/// format!("{}.{}", prefix, field) +/// format!("{prefix}.{field}") /// }; /// println!("patched: {path}"); /// } @@ -208,7 +208,7 @@ pub trait Patch

{ /// let path = if prefix.is_empty() { /// field.to_string() /// } else { - /// format!("{}.{}", prefix, field) + /// format!("{prefix}.{field}") /// }; /// patched_fields.push(path); /// }); @@ -217,7 +217,7 @@ pub trait Patch

{ /// let path = if prefixes.is_empty() { /// field.to_string() /// } else { - /// format!("{}.{}", prefixes.join("."), field) + /// format!("{}.{field}", prefixes.join(".")) /// }; /// patched_fields.push(path); /// }); @@ -286,7 +286,7 @@ pub trait Filler { /// let path = if prefixes.is_empty() { /// field.to_string() /// } else { - /// format!("{}.{}", prefixes.join("."), field) + /// format!("{}.{field}", prefixes.join(".")) /// }; /// filled_fields.push(path); /// }); From f91bc424f1778eb280e535b346af0a86e4f10732 Mon Sep 17 00:00:00 2001 From: jphppd <59113286+jphppd@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:52:00 +0100 Subject: [PATCH 4/5] add copy_doc feature --- .github/workflows/lint.yml | 1 + .github/workflows/test.yml | 1 + README.md | 2 ++ derive/Cargo.toml | 2 ++ derive/src/patch.rs | 32 ++++++++++++++++++++++++++++++-- lib/Cargo.toml | 4 +++- 6 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7103cee..9ac3241 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -33,3 +33,4 @@ jobs: nix develop .#ci -c cargo clippy --features=none_as_default -- -Dwarnings nix develop .#ci -c cargo clippy --features=keep_none -- -Dwarnings nix develop .#ci -c cargo clippy --features=nesting -- -Dwarnings + nix develop .#ci -c cargo clippy --features=copy_doc -- -Dwarnings diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b2fce2..063eaeb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,7 @@ jobs: nix develop .#ci -c run-test std nix develop .#ci -c run-test merge nix develop .#ci -c run-test default + nix develop .#ci -c run-test keep_log - name: Test patch examples run: | diff --git a/README.md b/README.md index 11862d1..71326fd 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,8 @@ This crate includes the following optional features: - `substrate` *(optional)*: enables the `Substrate` derive macro for exposing a struct's field layout so downstream crates can access it via `expose()` or source parsing. - `catalyst` *(optional)*: enables the `Catalyst` and `Complex` derive macros for extending a struct with fields from another crate. Implies `substrate`. - `unsafe` *(optional)*: avoid memory moves. Only meaningful with the `catalyst` feature. +- `copy_doc` *(optional)*: copy the documentation of the original struct and its fields to the patched one. + This is useful when a trait, derived on the patch structure, uses the doc attributes, typically for auto-documentation of some sort. [crates-badge]: https://img.shields.io/crates/v/struct-patch.svg [crate-url]: https://crates.io/crates/struct-patch diff --git a/derive/Cargo.toml b/derive/Cargo.toml index c1d1a7f..9cbadf3 100644 --- a/derive/Cargo.toml +++ b/derive/Cargo.toml @@ -32,6 +32,8 @@ substrate = [ "syn-serde", "syn/full" ] catalyst = [ "substrate", "dep:cargo_metadata" ] unsafe = [] box = [] +copy_doc = [] [dev-dependencies] pretty_assertions_sorted = "1.2.3" +syn = { version = "3", features = ["full"] } \ No newline at end of file diff --git a/derive/src/patch.rs b/derive/src/patch.rs index c592b9d..3d4d53b 100644 --- a/derive/src/patch.rs +++ b/derive/src/patch.rs @@ -9,6 +9,8 @@ use syn::{parenthesized, DeriveInput, Lit, LitStr, Result, Type}; #[cfg(feature = "op")] use crate::Addable; +#[cfg(feature = "copy_doc")] +const DOC: &str = "doc"; const PATCH: &str = "patch"; const NAME: &str = "name"; const ATTRIBUTE: &str = "attribute"; @@ -1332,6 +1334,13 @@ impl Patch { let mut default_log_fn: Option = None; for attr in attrs { + #[cfg(feature = "copy_doc")] + if attr.path().to_string().as_str() == DOC { + if let syn::Meta::NameValue(name_value) = &attr.meta { + attributes.push(name_value.to_token_stream()); + } + } + if attr.path().to_string().as_str() != PATCH { continue; } @@ -1528,6 +1537,13 @@ impl Field { let mut nesting = false; for attr in attrs { + #[cfg(feature = "copy_doc")] + if attr.path().to_string().as_str() == DOC { + if let syn::Meta::NameValue(name_value) = &attr.meta { + attributes.push(name_value.to_token_stream()); + } + } + if attr.path().to_string().as_str() != PATCH { continue; } @@ -1708,9 +1724,11 @@ mod tests { fn parse_patch() { // Test case 1: Valid patch with attributes and fields let input = quote! { + /// Item struct doc #[derive(Patch)] #[patch(name = "MyPatch", attribute(derive(Debug, PartialEq, Clone, Serialize, Deserialize)))] pub struct Item { + /// Field 1 doc #[patch(name = "SubItemPatch")] pub field1: SubItem, #[patch(skip)] @@ -1724,7 +1742,11 @@ mod tests { struct_name: syn::Ident::new("Item", Span::call_site()), patch_struct_name: syn::Ident::new("MyPatch", Span::call_site()), generics: syn::Generics::default(), - attributes: vec![quote! { derive(Debug, PartialEq, Clone, Serialize, Deserialize) }], + attributes: vec![ + #[cfg(feature = "copy_doc")] + quote! { doc = r" Item struct doc" }, + quote! { derive(Debug, PartialEq, Clone, Serialize, Deserialize) }, + ], #[cfg(feature = "log")] default_log_fn: None, fields: vec![ @@ -1735,7 +1757,10 @@ mod tests { .parse() .unwrap(), ), - attributes: vec![], + attributes: vec![ + #[cfg(feature = "copy_doc")] + quote! { doc = r" Field 1 doc" }, + ], #[cfg(feature = "op")] addable: Addable::Disable, #[cfg(feature = "nesting")] @@ -1764,5 +1789,8 @@ mod tests { format!("{:?}", result.to_token_stream()), format!("{:?}", expected.to_token_stream()) ); + + // Check that the generated token stream is syntactically correct. + syn::parse2::(result.to_token_stream().unwrap()).unwrap(); } } diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 8bd65eb..70a224a 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -55,4 +55,6 @@ catalyst = [ unsafe = [ "struct-patch-derive/unsafe" ] - +copy_doc = [ + "struct-patch-derive/copy_doc" +] From 339b2ebb6114078687c09114459ba50ced2ba3d6 Mon Sep 17 00:00:00 2001 From: jphppd <59113286+jphppd@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:20:40 +0100 Subject: [PATCH 5/5] add example for copy_doc feature --- examples/patch-examples/Cargo.toml | 5 ++ .../patch-examples/examples/jsonschema.rs | 48 +++++++++++++++++++ examples/patch-examples/examples/log.rs | 14 ++---- 3 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 examples/patch-examples/examples/jsonschema.rs diff --git a/examples/patch-examples/Cargo.toml b/examples/patch-examples/Cargo.toml index 0dfcda9..e8a55bb 100644 --- a/examples/patch-examples/Cargo.toml +++ b/examples/patch-examples/Cargo.toml @@ -13,6 +13,7 @@ serde_with = "3.9.0" toml = "1.1.2" humantime-serde = "1.1.1" clap = { version = "4.4.7", features = ["derive"] } +schemars = "1.2.3" [features] default = ["status", "op"] @@ -30,3 +31,7 @@ keep_none = ["option", "struct-patch/keep_none"] [[example]] name = "box" required-features = ["box"] + +[[example]] +name = "jsonschema" +required-features = ["struct-patch/copy_doc"] \ No newline at end of file diff --git a/examples/patch-examples/examples/jsonschema.rs b/examples/patch-examples/examples/jsonschema.rs new file mode 100644 index 0000000..4f4c0c7 --- /dev/null +++ b/examples/patch-examples/examples/jsonschema.rs @@ -0,0 +1,48 @@ +use schemars::{schema_for, JsonSchema}; +use struct_patch::Patch; + +/// The documentation for my structure. +#[derive(JsonSchema, Patch)] +#[patch(attribute(derive(JsonSchema)))] +struct MyStruct { + /// The documentation for my field. + my_field: bool, +} + +fn main() { + let schema = schema_for!(MyStruct); + assert_eq!(schema.get("title").unwrap(), "MyStruct"); + assert_eq!( + schema.get("description").unwrap(), + "The documentation for my structure." + ); + assert_eq!( + schema + .get("properties") + .unwrap() + .get("my_field") + .unwrap() + .get("description") + .unwrap(), + "The documentation for my field." + ); + dbg!(schema); + + let schema = schema_for!(MyStructPatch); + assert_eq!(schema.get("title").unwrap(), "MyStructPatch"); + assert_eq!( + schema.get("description").unwrap(), + "The documentation for my structure." + ); + assert_eq!( + schema + .get("properties") + .unwrap() + .get("my_field") + .unwrap() + .get("description") + .unwrap(), + "The documentation for my field." + ); + dbg!(schema); +} diff --git a/examples/patch-examples/examples/log.rs b/examples/patch-examples/examples/log.rs index 37d5766..1b0d92a 100644 --- a/examples/patch-examples/examples/log.rs +++ b/examples/patch-examples/examples/log.rs @@ -56,10 +56,7 @@ fn main() { // [default_log] patch field: host // [default_log] patch field: port - println!( - "host={}, port={}", - config.host, config.port - ); + println!("host={}, port={}", config.host, config.port); println!("\n--- Patch: apply_with_log() with custom format ---"); config.apply_with_log( @@ -74,10 +71,7 @@ fn main() { // Prints: // [custom_log] patch field port was updated - println!( - "host={}, port={}", - config.host, config.port - ); + println!("host={}, port={}", config.host, config.port); } #[cfg(feature = "nesting")] @@ -188,8 +182,6 @@ fn main() { println!( "config.port={}, config.logging.level={}, config.logging.format={}", - server4.config.port, - server4.config.logging.level, - server4.config.logging.format + server4.config.port, server4.config.logging.level, server4.config.logging.format ); }