From 08fc6d497b5ab64086b2810cbe55f82c15bc13cb Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Wed, 28 Jul 2021 11:24:32 +0100 Subject: [PATCH] Add capture_docs attribute (#118) * Add capture_docs attribute * Refactor derive to use a struct for context * Fmt * Handle always/never capturing docs * Use docs feature from tests * Add tests for capture_docs attribute * Fmt * Clippy, you are right * Clipping * Reverse always docs logic * Fix docs always tests * Docs * Change capture_docs to enum instead of bool * Fmt --- derive/src/attr.rs | 57 ++- derive/src/lib.rs | 448 +++++++++--------- src/build.rs | 25 + src/lib.rs | 18 + test_suite/tests/derive.rs | 91 ++++ .../ui/fail_with_invalid_capture_docs_attr.rs | 12 + ...fail_with_invalid_capture_docs_attr.stderr | 5 + 7 files changed, 425 insertions(+), 231 deletions(-) create mode 100644 test_suite/tests/ui/fail_with_invalid_capture_docs_attr.rs create mode 100644 test_suite/tests/ui/fail_with_invalid_capture_docs_attr.stderr diff --git a/derive/src/attr.rs b/derive/src/attr.rs index 79ff63c1..1e49504e 100644 --- a/derive/src/attr.rs +++ b/derive/src/attr.rs @@ -28,12 +28,14 @@ mod keywords { syn::custom_keyword!(scale_info); syn::custom_keyword!(bounds); syn::custom_keyword!(skip_type_params); + syn::custom_keyword!(capture_docs); } /// Parsed and validated set of `#[scale_info(...)]` attributes for an item. pub struct Attributes { bounds: Option, skip_type_params: Option, + capture_docs: Option, } impl Attributes { @@ -41,6 +43,7 @@ impl Attributes { pub fn from_ast(item: &syn::DeriveInput) -> syn::Result { let mut bounds = None; let mut skip_type_params = None; + let mut capture_docs = None; let attributes_parser = |input: &ParseBuffer| { let attrs: Punctuated = @@ -75,6 +78,15 @@ impl Attributes { } skip_type_params = Some(parsed_skip_type_params); } + ScaleInfoAttr::CaptureDocs(parsed_capture_docs) => { + if capture_docs.is_some() { + return Err(syn::Error::new( + attr.span(), + "Duplicate `capture_docs` attributes", + )) + } + capture_docs = Some(parsed_capture_docs); + } } } } @@ -103,6 +115,7 @@ impl Attributes { Ok(Self { bounds, skip_type_params, + capture_docs, }) } @@ -115,6 +128,15 @@ impl Attributes { pub fn skip_type_params(&self) -> Option<&SkipTypeParamsAttr> { self.skip_type_params.as_ref() } + + /// Returns the value of `#[scale_info(capture_docs = "..")]`. + /// + /// Defaults to `CaptureDocsAttr::Default` if the attribute is not present. + pub fn capture_docs(&self) -> &CaptureDocsAttr { + self.capture_docs + .as_ref() + .unwrap_or(&CaptureDocsAttr::Default) + } } /// Parsed representation of the `#[scale_info(bounds(...))]` attribute. @@ -180,10 +202,39 @@ impl SkipTypeParamsAttr { } } +/// Parsed representation of the `#[scale_info(capture_docs = "..")]` attribute. +#[derive(Clone)] +pub enum CaptureDocsAttr { + Default, + Always, + Never, +} + +impl Parse for CaptureDocsAttr { + fn parse(input: &ParseBuffer) -> syn::Result { + input.parse::()?; + input.parse::()?; + let capture_docs_lit = input.parse::()?; + + match capture_docs_lit.value().to_lowercase().as_str() { + "default" => Ok(Self::Default), + "always" => Ok(Self::Always), + "never" => Ok(Self::Never), + _ => { + Err(syn::Error::new_spanned( + capture_docs_lit, + r#"Invalid capture_docs value. Expected one of: "default", "always", "never" "#, + )) + } + } + } +} + /// Parsed representation of one of the `#[scale_info(..)]` attributes. pub enum ScaleInfoAttr { Bounds(BoundsAttr), SkipTypeParams(SkipTypeParamsAttr), + CaptureDocs(CaptureDocsAttr), } impl Parse for ScaleInfoAttr { @@ -195,8 +246,12 @@ impl Parse for ScaleInfoAttr { } else if lookahead.peek(keywords::skip_type_params) { let skip_type_params = input.parse()?; Ok(Self::SkipTypeParams(skip_type_params)) + } else if lookahead.peek(keywords::capture_docs) { + let capture_docs = input.parse()?; + Ok(Self::CaptureDocs(capture_docs)) } else { - Err(input.error("Expected either `bounds` or `skip_type_params`")) + Err(input + .error("Expected one of: `bounds`, `skip_type_params` or `capture_docs")) } } } diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 6084d459..b78f4199 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -19,6 +19,10 @@ mod attr; mod trait_bounds; mod utils; +use self::attr::{ + Attributes, + CaptureDocsAttr, +}; use proc_macro::TokenStream; use proc_macro2::{ Span, @@ -42,7 +46,6 @@ use syn::{ Fields, Ident, Lifetime, - Variant, }; #[proc_macro_derive(TypeInfo, attributes(scale_info, codec))] @@ -54,68 +57,235 @@ pub fn type_info(input: TokenStream) -> TokenStream { } fn generate(input: TokenStream2) -> Result { - let mut tokens = quote! {}; - tokens.extend(generate_type(input)?); - Ok(tokens) + let type_info_impl = TypeInfoImpl::parse(input)?; + let type_info_impl_toks = type_info_impl.expand()?; + Ok(quote! { + #[allow(non_upper_case_globals, unused_attributes, unused_qualifications)] + const _: () = { + #type_info_impl_toks; + }; + }) +} + +struct TypeInfoImpl { + ast: DeriveInput, + scale_info: Ident, + attrs: Attributes, } -fn generate_type(input: TokenStream2) -> Result { - let ast: DeriveInput = syn::parse2(input.clone())?; +impl TypeInfoImpl { + fn parse(input: TokenStream2) -> Result { + let ast: DeriveInput = syn::parse2(input)?; + let scale_info = crate_name_ident("scale-info")?; + let attrs = attr::Attributes::from_ast(&ast)?; - let attrs = attr::Attributes::from_ast(&ast)?; + Ok(Self { + ast, + scale_info, + attrs, + }) + } + + fn expand(&self) -> Result { + let ident = &self.ast.ident; + let scale_info = &self.scale_info; + + let where_clause = trait_bounds::make_where_clause( + &self.attrs, + ident, + &self.ast.generics, + &self.ast.data, + &self.scale_info, + )?; - let scale_info = crate_name_ident("scale-info")?; + let (impl_generics, ty_generics, _) = self.ast.generics.split_for_impl(); - let ident = &ast.ident; + let type_params = self.ast.generics.type_params().map(|tp| { + let ty_ident = &tp.ident; + let ty = if self.attrs.skip_type_params().map_or(true, |skip| !skip.skip(tp)) { + quote! { ::core::option::Option::Some(:: #scale_info ::meta_type::<#ty_ident>()) } + } else { + quote! { ::core::option::Option::None } + }; + quote! { + :: #scale_info ::TypeParameter::new(::core::stringify!(#ty_ident), #ty) + } + }); - let where_clause = trait_bounds::make_where_clause( - &attrs, - ident, - &ast.generics, - &ast.data, - &scale_info, - )?; + let build_type = match &self.ast.data { + Data::Struct(ref s) => self.generate_composite_type(s), + Data::Enum(ref e) => self.generate_variant_type(e, scale_info), + Data::Union(_) => { + return Err(Error::new_spanned(&self.ast, "Unions not supported")) + } + }; + let docs = self.generate_docs(&self.ast.attrs); - let (impl_generics, ty_generics, _) = ast.generics.split_for_impl(); + Ok(quote! { + impl #impl_generics :: #scale_info ::TypeInfo for #ident #ty_generics #where_clause { + type Identity = Self; + fn type_info() -> :: #scale_info ::Type { + :: #scale_info ::Type::builder() + .path(:: #scale_info ::Path::new(::core::stringify!(#ident), ::core::module_path!())) + .type_params(:: #scale_info ::prelude::vec![ #( #type_params ),* ]) + #docs + .#build_type + } + } + }) + } - let type_params = ast.generics.type_params().map(|tp| { - let ty_ident = &tp.ident; - let ty = if attrs.skip_type_params().map_or(true, |skip| !skip.skip(tp)) { - quote! { ::core::option::Option::Some(:: #scale_info ::meta_type::<#ty_ident>()) } - } else { - quote! { ::core::option::Option::None } + fn generate_composite_type(&self, data_struct: &DataStruct) -> TokenStream2 { + let fields = match data_struct.fields { + Fields::Named(ref fs) => { + let fields = self.generate_fields(&fs.named); + quote! { named()#( #fields )* } + } + Fields::Unnamed(ref fs) => { + let fields = self.generate_fields(&fs.unnamed); + quote! { unnamed()#( #fields )* } + } + Fields::Unit => { + quote! { + unit() + } + } }; + let scale_info = &self.scale_info; quote! { - :: #scale_info ::TypeParameter::new(::core::stringify!(#ty_ident), #ty) + composite(:: #scale_info ::build::Fields::#fields) } - }); + } - let build_type = match &ast.data { - Data::Struct(ref s) => generate_composite_type(s, &scale_info), - Data::Enum(ref e) => generate_variant_type(e, &scale_info), - Data::Union(_) => return Err(Error::new_spanned(input, "Unions not supported")), - }; - let docs = generate_docs(&ast.attrs); + fn generate_fields(&self, fields: &Punctuated) -> Vec { + fields + .iter() + .filter(|f| !utils::should_skip(&f.attrs)) + .map(|f| { + let (ty, ident) = (&f.ty, &f.ident); + // Replace any field lifetime params with `static to prevent "unnecessary lifetime parameter" + // warning. Any lifetime parameters are specified as 'static in the type of the impl. + struct StaticLifetimesReplace; + impl VisitMut for StaticLifetimesReplace { + fn visit_lifetime_mut(&mut self, lifetime: &mut Lifetime) { + *lifetime = parse_quote!('static) + } + } + let mut ty = ty.clone(); + StaticLifetimesReplace.visit_type_mut(&mut ty); - let type_info_impl = quote! { - impl #impl_generics :: #scale_info ::TypeInfo for #ident #ty_generics #where_clause { - type Identity = Self; - fn type_info() -> :: #scale_info ::Type { - :: #scale_info ::Type::builder() - .path(:: #scale_info ::Path::new(::core::stringify!(#ident), ::core::module_path!())) - .type_params(:: #scale_info ::prelude::vec![ #( #type_params ),* ]) - #docs - .#build_type - } + let type_name = clean_type_string("e!(#ty).to_string()); + let docs = self.generate_docs(&f.attrs); + let type_of_method = if utils::is_compact(f) { + quote!(compact) + } else { + quote!(ty) + }; + let name = if let Some(ident) = ident { + quote!(.name(::core::stringify!(#ident))) + } else { + quote!() + }; + quote!( + .field(|f| f + .#type_of_method::<#ty>() + #name + .type_name(#type_name) + #docs + ) + ) + }) + .collect() + } + + fn generate_variant_type( + &self, + data_enum: &DataEnum, + scale_info: &Ident, + ) -> TokenStream2 { + let variants = &data_enum.variants; + + let variants = variants + .into_iter() + .filter(|v| !utils::should_skip(&v.attrs)) + .enumerate() + .map(|(i, v)| { + let ident = &v.ident; + let v_name = quote! {::core::stringify!(#ident) }; + let docs = self.generate_docs(&v.attrs); + let index = utils::variant_index(v, i); + + let fields = match v.fields { + Fields::Named(ref fs) => { + let fields = self.generate_fields(&fs.named); + Some(quote! { + .fields(:: #scale_info::build::Fields::named() + #( #fields )* + ) + }) + } + Fields::Unnamed(ref fs) => { + let fields = self.generate_fields(&fs.unnamed); + Some(quote! { + .fields(:: #scale_info::build::Fields::unnamed() + #( #fields )* + ) + }) + } + Fields::Unit => None, + }; + + quote! { + .variant(#v_name, |v| + v + .index(#index as ::core::primitive::u8) + #fields + #docs + ) + } + }); + quote! { + variant( + :: #scale_info ::build::Variants::new() + #( #variants )* + ) } - }; + } - Ok(quote! { - #[allow(non_upper_case_globals, unused_attributes, unused_qualifications)] - const _: () = { - #type_info_impl; - }; - }) + fn generate_docs(&self, attrs: &[syn::Attribute]) -> Option { + let docs_builder_fn = match self.attrs.capture_docs() { + CaptureDocsAttr::Never => None, // early return if we never capture docs. + CaptureDocsAttr::Default => Some(quote!(docs)), + CaptureDocsAttr::Always => Some(quote!(docs_always)), + }?; + + let docs = attrs + .iter() + .filter_map(|attr| { + if let Ok(syn::Meta::NameValue(meta)) = attr.parse_meta() { + if meta.path.get_ident().map_or(false, |ident| ident == "doc") { + if let syn::Lit::Str(lit) = &meta.lit { + let lit_value = lit.value(); + let stripped = + lit_value.strip_prefix(' ').unwrap_or(&lit_value); + let lit: syn::Lit = parse_quote!(#stripped); + Some(lit) + } else { + None + } + } else { + None + } + } else { + None + } + }) + .collect::>(); + + Some(quote! { + .#docs_builder_fn(&[ #( #docs ),* ]) + }) + } } /// Get the name of a crate, to be robust against renamed dependencies. @@ -131,49 +301,6 @@ fn crate_name_ident(name: &str) -> Result { .map_err(|e| syn::Error::new(Span::call_site(), &e)) } -type FieldsList = Punctuated; - -fn generate_fields(fields: &FieldsList) -> Vec { - fields - .iter() - .filter(|f| !utils::should_skip(&f.attrs)) - .map(|f| { - let (ty, ident) = (&f.ty, &f.ident); - // Replace any field lifetime params with `static to prevent "unnecessary lifetime parameter" - // warning. Any lifetime parameters are specified as 'static in the type of the impl. - struct StaticLifetimesReplace; - impl VisitMut for StaticLifetimesReplace { - fn visit_lifetime_mut(&mut self, lifetime: &mut Lifetime) { - *lifetime = parse_quote!('static) - } - } - let mut ty = ty.clone(); - StaticLifetimesReplace.visit_type_mut(&mut ty); - - let type_name = clean_type_string("e!(#ty).to_string()); - let docs = generate_docs(&f.attrs); - let type_of_method = if utils::is_compact(f) { - quote!(compact) - } else { - quote!(ty) - }; - let name = if let Some(ident) = ident { - quote!(.name(::core::stringify!(#ident))) - } else { - quote!() - }; - quote!( - .field(|f| f - .#type_of_method::<#ty>() - #name - .type_name(#type_name) - #docs - ) - ) - }) - .collect() -} - fn clean_type_string(input: &str) -> String { input .replace(" ::", "::") @@ -194,142 +321,3 @@ fn clean_type_string(input: &str) -> String { .replace("& \'", "&'") .replace("&\'", "&'") } - -fn generate_composite_type(data_struct: &DataStruct, scale_info: &Ident) -> TokenStream2 { - let fields = match data_struct.fields { - Fields::Named(ref fs) => { - let fields = generate_fields(&fs.named); - quote! { named()#( #fields )* } - } - Fields::Unnamed(ref fs) => { - let fields = generate_fields(&fs.unnamed); - quote! { unnamed()#( #fields )* } - } - Fields::Unit => { - quote! { - unit() - } - } - }; - quote! { - composite(:: #scale_info ::build::Fields::#fields) - } -} - -type VariantList = Punctuated; - -fn generate_c_like_enum_def(variants: &VariantList, scale_info: &Ident) -> TokenStream2 { - let variants = variants - .into_iter() - .filter(|v| !utils::should_skip(&v.attrs)) - .enumerate() - .map(|(i, v)| { - let name = &v.ident; - let index = utils::variant_index(v, i); - let docs = generate_docs(&v.attrs); - quote! { - .variant(::core::stringify!(#name), |v| - v - .index(#index as ::core::primitive::u8) - #docs - ) - } - }); - quote! { - variant( - :: #scale_info ::build::Variants::new() - #( #variants )* - ) - } -} - -fn is_c_like_enum(variants: &VariantList) -> bool { - // One of the variants has an explicit discriminant, or… - variants.iter().any(|v| v.discriminant.is_some()) || - // …all variants are unit - variants.iter().all(|v| matches!(v.fields, Fields::Unit)) -} - -fn generate_variant_type(data_enum: &DataEnum, scale_info: &Ident) -> TokenStream2 { - let variants = &data_enum.variants; - - if is_c_like_enum(variants) { - return generate_c_like_enum_def(variants, scale_info) - } - - let variants = variants - .into_iter() - .filter(|v| !utils::should_skip(&v.attrs)) - .enumerate() - .map(|(i, v)| { - let ident = &v.ident; - let v_name = quote! {::core::stringify!(#ident) }; - let docs = generate_docs(&v.attrs); - let index = utils::variant_index(v, i); - - let fields = match v.fields { - Fields::Named(ref fs) => { - let fields = generate_fields(&fs.named); - quote! { - :: #scale_info::build::Fields::named() - #( #fields )* - } - } - Fields::Unnamed(ref fs) => { - let fields = generate_fields(&fs.unnamed); - quote! { - :: #scale_info::build::Fields::unnamed() - #( #fields )* - } - } - Fields::Unit => { - quote! { - :: #scale_info::build::Fields::unit() - } - } - }; - - quote! { - .variant(#v_name, |v| - v - .index(#index as ::core::primitive::u8) - .fields(#fields) - #docs - ) - } - }); - quote! { - variant( - :: #scale_info ::build::Variants::new() - #( #variants )* - ) - } -} - -fn generate_docs(attrs: &[syn::Attribute]) -> Option { - let docs = attrs - .iter() - .filter_map(|attr| { - if let Ok(syn::Meta::NameValue(meta)) = attr.parse_meta() { - if meta.path.get_ident().map_or(false, |ident| ident == "doc") { - if let syn::Lit::Str(lit) = &meta.lit { - let lit_value = lit.value(); - let stripped = lit_value.strip_prefix(' ').unwrap_or(&lit_value); - let lit: syn::Lit = parse_quote!(#stripped); - Some(lit) - } else { - None - } - } else { - None - } - } else { - None - } - }) - .collect::>(); - - Some(quote! { - .docs(&[ #( #docs ),* ]) - }) -} diff --git a/src/build.rs b/src/build.rs index c546e6ca..73146955 100644 --- a/src/build.rs +++ b/src/build.rs @@ -223,6 +223,12 @@ impl TypeBuilder { pub fn docs(self, _docs: &'static [&'static str]) -> Self { self } + + /// Set the type documentation, always captured even if the "docs" feature is not enabled. + pub fn docs_always(mut self, docs: &[&'static str]) -> Self { + self.docs = docs.to_vec(); + self + } } /// A fields builder has no fields (e.g. a unit struct) @@ -426,6 +432,18 @@ impl FieldBuilder { pub fn docs(self, _docs: &'static [&'static str]) -> FieldBuilder { self } + + /// Initialize the documentation of a field, always captured even if the "docs" feature is not + /// enabled. + pub fn docs_always(self, docs: &'static [&'static str]) -> Self { + FieldBuilder { + name: self.name, + ty: self.ty, + type_name: self.type_name, + docs, + marker: PhantomData, + } + } } impl FieldBuilder { @@ -547,6 +565,13 @@ impl VariantBuilder { pub fn docs(self, _docs: &[&'static str]) -> Self { self } + + /// Initialize the variant's documentation, always captured even if the "docs" feature is not + /// enabled. + pub fn docs_always(mut self, docs: &[&'static str]) -> Self { + self.docs = docs.to_vec(); + self + } } impl VariantBuilder { diff --git a/src/lib.rs b/src/lib.rs index 4a4dd333..64259bdd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -190,6 +190,24 @@ //! **Note:** When using `bounds` without `skip_type_params`, it is therefore required to manually //! add a `TypeInfo` bound for any non skipped type parameters. The compiler will let you know. //! +//! #### `#[scale_info(capture_docs = "default|always|never")]` +//! +//! Docs for types, fields and variants can all be captured by the `docs` feature being enabled. +//! This can be overridden using the `capture_docs` attribute: +//! +//! `#[scale_info(capture_docs = "default")]` will capture docs iff the `docs` feature is enabled. +//! This is the default if `capture_docs` is not specified. +//! +//! `#[scale_info(capture_docs = "always")]` will capture docs for the annotated type even if the +//! `docs` feature is *not* enabled. +//! +//! `#[scale_info(capture_docs = "never")]` will *not* capture docs for the annotated type even if +//! the `docs` is enabled. +//! +//! This is useful e.g. when compiling metadata into a Wasm blob, and it is desirable to keep the +//! binary size as small as possible, so the `docs` feature would be disabled. In case the docs for +//! some types is necessary they could be enabled on a per-type basis with the above attribute. +//! //! # Forms //! //! To bridge between compile-time type information and runtime the diff --git a/test_suite/tests/derive.rs b/test_suite/tests/derive.rs index a368c955..515bb489 100644 --- a/test_suite/tests/derive.rs +++ b/test_suite/tests/derive.rs @@ -619,6 +619,97 @@ fn doc_capture_works() { assert_type!(S, ty); } +#[test] +fn never_capture_docs() { + #[allow(unused)] + #[derive(TypeInfo)] + #[scale_info(capture_docs = "never")] + /// Type docs + enum E { + /// Variant docs + A { + /// field docs + a: u32, + }, + } + + #[allow(unused)] + #[derive(TypeInfo)] + #[scale_info(capture_docs = "never")] + /// Type docs + struct S { + /// field docs + a: bool, + } + + let enum_ty = + Type::builder() + .path(Path::new("E", "derive")) + .variant(Variants::new().variant("A", |v| { + v.index(0).fields( + Fields::named().field(|f| f.ty::().name("a").type_name("u32")), + ) + })); + + let struct_ty = Type::builder() + .path(Path::new("S", "derive")) + .composite(Fields::named().field(|f| f.ty::().name("a").type_name("bool"))); + + assert_type!(E, enum_ty); + assert_type!(S, struct_ty); +} + +#[test] +fn always_capture_docs() { + #[allow(unused)] + #[derive(TypeInfo)] + #[scale_info(capture_docs = "always")] + /// Type docs + enum E { + /// Variant docs + A { + /// field docs + a: u32, + }, + } + + #[allow(unused)] + #[derive(TypeInfo)] + #[scale_info(capture_docs = "always")] + /// Type docs + struct S { + /// field docs + a: bool, + } + + let enum_ty = Type::builder() + .path(Path::new("E", "derive")) + .docs_always(&["Type docs"]) + .variant(Variants::new().variant("A", |v| { + v.index(0) + .fields(Fields::named().field(|f| { + f.ty::() + .name("a") + .type_name("u32") + .docs_always(&["field docs"]) + })) + .docs_always(&["Variant docs"]) + })); + + let struct_ty = Type::builder() + .path(Path::new("S", "derive")) + .docs_always(&["Type docs"]) + .composite(Fields::named().field(|f| { + f.ty::() + .name("a") + .type_name("bool") + .docs_always(&["field docs"]) + })); + + assert_type!(E, enum_ty); + assert_type!(S, struct_ty); +} + #[test] fn skip_type_params_nested() { #[allow(unused)] diff --git a/test_suite/tests/ui/fail_with_invalid_capture_docs_attr.rs b/test_suite/tests/ui/fail_with_invalid_capture_docs_attr.rs new file mode 100644 index 00000000..19c4d40f --- /dev/null +++ b/test_suite/tests/ui/fail_with_invalid_capture_docs_attr.rs @@ -0,0 +1,12 @@ +use scale_info::TypeInfo; +use scale::Encode; + +#[derive(TypeInfo, Encode)] +#[scale_info(capture_docs = "invalid")] +/// Docs +struct InvalidDocsCapture { + /// Docs + a: u8, +} + +fn main() {} diff --git a/test_suite/tests/ui/fail_with_invalid_capture_docs_attr.stderr b/test_suite/tests/ui/fail_with_invalid_capture_docs_attr.stderr new file mode 100644 index 00000000..aca76632 --- /dev/null +++ b/test_suite/tests/ui/fail_with_invalid_capture_docs_attr.stderr @@ -0,0 +1,5 @@ +error: Invalid capture_docs value. Expected one of: "default", "always", "never" + --> $DIR/fail_with_invalid_capture_docs_attr.rs:5:29 + | +5 | #[scale_info(capture_docs = "invalid")] + | ^^^^^^^^^