From 32934ea5f8ce92d7374bb9d7f9d45df67ed62c58 Mon Sep 17 00:00:00 2001 From: "Kuba S." Date: Wed, 22 Apr 2026 13:57:35 +0200 Subject: [PATCH 1/2] Support #[thiserror(crate = "...")] to override crate path Allow users to specify the path to the thiserror crate using `#[thiserror(crate = "...")]` on the container. All internal references to the crate now use the specified path, defaulting to `::thiserror` if not provided. --- impl/src/attr.rs | 37 +++++++++- impl/src/expand.rs | 75 ++++++++++++--------- impl/src/fallback.rs | 3 +- impl/src/lib.rs | 2 +- tests/test_crate_path.rs | 142 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 35 deletions(-) create mode 100644 tests/test_crate_path.rs diff --git a/impl/src/attr.rs b/impl/src/attr.rs index 7ad83e02..6844c09b 100644 --- a/impl/src/attr.rs +++ b/impl/src/attr.rs @@ -5,7 +5,7 @@ use syn::parse::discouraged::Speculative; use syn::parse::{End, ParseStream}; use syn::{ braced, bracketed, parenthesized, token, Attribute, Error, ExprPath, Ident, Index, LitFloat, - LitInt, LitStr, Meta, Result, Token, + LitInt, LitStr, Meta, Path, Result, Token, }; pub struct Attrs<'a> { @@ -15,6 +15,7 @@ pub struct Attrs<'a> { pub from: Option>, pub transparent: Option>, pub fmt: Option>, + pub crate_path: Option, } #[derive(Clone)] @@ -74,6 +75,7 @@ pub fn get(input: &[Attribute]) -> Result { from: None, transparent: None, fmt: None, + crate_path: None, }; for attr in input { @@ -115,12 +117,45 @@ pub fn get(input: &[Attribute]) -> Result { original: attr, span, }); + } else if attr.path().is_ident("thiserror") { + attr.parse_nested_meta(|meta| { + if !meta.path.is_ident("crate") { + let path_str = meta.path.to_token_stream().to_string().replace(' ', ""); + return Err( + meta.error(format_args!("unknown thiserror attribute `{path_str}`")) + ); + } + let crate_path_lit: LitStr = meta.value()?.parse()?; + let path = crate_path_lit + .parse::() + .map_err(|e| Error::new(crate_path_lit.span(), e))?; + if attrs.crate_path.is_some() { + return Err(Error::new_spanned( + attr, + "duplicate #[thiserror(crate)] attribute", + )); + } + attrs.crate_path = Some(path); + Ok(()) + })?; } } Ok(attrs) } +/// Extract the thiserror crate path from `#[thiserror(crate = "...")]` on the container, +/// defaulting to `::thiserror` if the attribute is absent or malformed. +/// +/// # Returns +/// The explicit crate path if provided, otherwise `::thiserror`. +pub fn crate_path(attrs: &[Attribute]) -> syn::Path { + get(attrs) + .ok() + .and_then(|a| a.crate_path) + .unwrap_or_else(|| syn::parse_quote!(::thiserror)) +} + fn parse_error_attribute<'a>(attrs: &mut Attrs<'a>, attr: &'a Attribute) -> Result<()> { mod kw { syn::custom_keyword!(transparent); diff --git a/impl/src/expand.rs b/impl/src/expand.rs index 4e11ff8a..b025e78d 100644 --- a/impl/src/expand.rs +++ b/impl/src/expand.rs @@ -7,7 +7,7 @@ use crate::unraw::MemberUnraw; use proc_macro2::{Ident, Span, TokenStream}; use quote::{format_ident, quote, quote_spanned, ToTokens}; use std::collections::BTreeSet as Set; -use syn::{DeriveInput, GenericArgument, PathArguments, Result, Token, Type}; +use syn::{DeriveInput, GenericArgument, Path, PathArguments, Result, Token, Type}; pub fn derive(input: &DeriveInput) -> TokenStream { match try_expand(input) { @@ -32,21 +32,26 @@ fn impl_struct(input: Struct) -> TokenStream { let ty = call_site_ident(&input.ident); let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); let mut error_inferred_bounds = InferredBounds::new(); + let krate: Path = input + .attrs + .crate_path + .clone() + .unwrap_or_else(|| syn::parse_quote!(::thiserror)); let source_body = if let Some(transparent_attr) = &input.attrs.transparent { let only_field = &input.fields[0]; if only_field.contains_generic { - error_inferred_bounds.insert(only_field.ty, quote!(::thiserror::#private::Error)); + error_inferred_bounds.insert(only_field.ty, quote!(#krate::#private::Error)); } let member = &only_field.member; Some(quote_spanned! {transparent_attr.span=> - ::thiserror::#private::Error::source(self.#member.as_dyn_error()) + #krate::#private::Error::source(self.#member.as_dyn_error()) }) } else if let Some(source_field) = input.source_field() { let source = &source_field.member; if source_field.contains_generic { let ty = unoptional_type(source_field.ty); - error_inferred_bounds.insert(ty, quote!(::thiserror::#private::Error + 'static)); + error_inferred_bounds.insert(ty, quote!(#krate::#private::Error + 'static)); } let asref = if type_is_option(source_field.ty) { Some(quote_spanned!(source.span()=> .as_ref()?)) @@ -64,8 +69,8 @@ fn impl_struct(input: Struct) -> TokenStream { }; let source_method = source_body.map(|body| { quote! { - fn source(&self) -> ::core::option::Option<&(dyn ::thiserror::#private::Error + 'static)> { - use ::thiserror::#private::AsDynError as _; + fn source(&self) -> ::core::option::Option<&(dyn #krate::#private::Error + 'static)> { + use #krate::#private::AsDynError as _; #body } } @@ -92,28 +97,28 @@ fn impl_struct(input: Struct) -> TokenStream { } else if type_is_option(backtrace_field.ty) { Some(quote! { if let ::core::option::Option::Some(backtrace) = &self.#backtrace { - #request.provide_ref::<::thiserror::#private::Backtrace>(backtrace); + #request.provide_ref::<#krate::#private::Backtrace>(backtrace); } }) } else { Some(quote! { - #request.provide_ref::<::thiserror::#private::Backtrace>(&self.#backtrace); + #request.provide_ref::<#krate::#private::Backtrace>(&self.#backtrace); }) }; quote! { - use ::thiserror::#private::ThiserrorProvide as _; + use #krate::#private::ThiserrorProvide as _; #source_provide #self_provide } } else if type_is_option(backtrace_field.ty) { quote! { if let ::core::option::Option::Some(backtrace) = &self.#backtrace { - #request.provide_ref::<::thiserror::#private::Backtrace>(backtrace); + #request.provide_ref::<#krate::#private::Backtrace>(backtrace); } } } else { quote! { - #request.provide_ref::<::thiserror::#private::Backtrace>(&self.#backtrace); + #request.provide_ref::<#krate::#private::Backtrace>(&self.#backtrace); } }; quote! { @@ -132,7 +137,7 @@ fn impl_struct(input: Struct) -> TokenStream { }) } else if let Some(display) = &input.attrs.display { display_implied_bounds.clone_from(&display.implied_bounds); - let use_as_display = use_as_display(display.has_bonus_display); + let use_as_display = use_as_display(display.has_bonus_display, &krate); let pat = fields_pat(&input.fields); Some(quote! { #use_as_display @@ -169,7 +174,7 @@ fn impl_struct(input: Struct) -> TokenStream { let backtrace_field = input.distinct_backtrace_field(); let from = unoptional_type(from_field.ty); let source_var = Ident::new("source", span); - let body = from_initializer(from_field, backtrace_field, &source_var); + let body = from_initializer(from_field, backtrace_field, &source_var, &krate); let from_function = quote! { fn from(#source_var: #from) -> Self { #ty #body @@ -209,7 +214,7 @@ fn impl_struct(input: Struct) -> TokenStream { quote! { #[allow(unused_qualifications)] #[automatically_derived] - impl #impl_generics ::thiserror::#private::Error for #ty #ty_generics #error_where_clause { + impl #impl_generics #krate::#private::Error for #ty #ty_generics #error_where_clause { #source_method #provide_method } @@ -222,6 +227,11 @@ fn impl_enum(input: Enum) -> TokenStream { let ty = call_site_ident(&input.ident); let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); let mut error_inferred_bounds = InferredBounds::new(); + let krate: Path = input + .attrs + .crate_path + .clone() + .unwrap_or_else(|| syn::parse_quote!(::thiserror)); let source_method = if input.has_source() { let arms = input.variants.iter().map(|variant| { @@ -229,11 +239,11 @@ fn impl_enum(input: Enum) -> TokenStream { if let Some(transparent_attr) = &variant.attrs.transparent { let only_field = &variant.fields[0]; if only_field.contains_generic { - error_inferred_bounds.insert(only_field.ty, quote!(::thiserror::#private::Error)); + error_inferred_bounds.insert(only_field.ty, quote!(#krate::#private::Error)); } let member = &only_field.member; let source = quote_spanned! {transparent_attr.span=> - ::thiserror::#private::Error::source(transparent.as_dyn_error()) + #krate::#private::Error::source(transparent.as_dyn_error()) }; quote! { #ty::#ident {#member: transparent} => #source, @@ -242,7 +252,7 @@ fn impl_enum(input: Enum) -> TokenStream { let source = &source_field.member; if source_field.contains_generic { let ty = unoptional_type(source_field.ty); - error_inferred_bounds.insert(ty, quote!(::thiserror::#private::Error + 'static)); + error_inferred_bounds.insert(ty, quote!(#krate::#private::Error + 'static)); } let asref = if type_is_option(source_field.ty) { Some(quote_spanned!(source.span()=> .as_ref()?)) @@ -263,8 +273,8 @@ fn impl_enum(input: Enum) -> TokenStream { } }); Some(quote! { - fn source(&self) -> ::core::option::Option<&(dyn ::thiserror::#private::Error + 'static)> { - use ::thiserror::#private::AsDynError as _; + fn source(&self) -> ::core::option::Option<&(dyn #krate::#private::Error + 'static)> { + use #krate::#private::AsDynError as _; #[allow(deprecated)] match self { #(#arms)* @@ -300,12 +310,12 @@ fn impl_enum(input: Enum) -> TokenStream { let self_provide = if type_is_option(backtrace_field.ty) { quote! { if let ::core::option::Option::Some(backtrace) = backtrace { - #request.provide_ref::<::thiserror::#private::Backtrace>(backtrace); + #request.provide_ref::<#krate::#private::Backtrace>(backtrace); } } } else { quote! { - #request.provide_ref::<::thiserror::#private::Backtrace>(backtrace); + #request.provide_ref::<#krate::#private::Backtrace>(backtrace); } }; quote! { @@ -314,7 +324,7 @@ fn impl_enum(input: Enum) -> TokenStream { #source: #varsource, .. } => { - use ::thiserror::#private::ThiserrorProvide as _; + use #krate::#private::ThiserrorProvide as _; #source_provide #self_provide } @@ -338,7 +348,7 @@ fn impl_enum(input: Enum) -> TokenStream { }; quote! { #ty::#ident {#backtrace: #varsource, ..} => { - use ::thiserror::#private::ThiserrorProvide as _; + use #krate::#private::ThiserrorProvide as _; #source_provide } } @@ -348,12 +358,12 @@ fn impl_enum(input: Enum) -> TokenStream { let body = if type_is_option(backtrace_field.ty) { quote! { if let ::core::option::Option::Some(backtrace) = backtrace { - #request.provide_ref::<::thiserror::#private::Backtrace>(backtrace); + #request.provide_ref::<#krate::#private::Backtrace>(backtrace); } } } else { quote! { - #request.provide_ref::<::thiserror::#private::Backtrace>(backtrace); + #request.provide_ref::<#krate::#private::Backtrace>(backtrace); } }; quote! { @@ -387,7 +397,7 @@ fn impl_enum(input: Enum) -> TokenStream { .as_ref() .is_some_and(|display| display.has_bonus_display) }); - let use_as_display = use_as_display(has_bonus_display); + let use_as_display = use_as_display(has_bonus_display, &krate); let void_deref = if input.variants.is_empty() { Some(quote!(*)) } else { @@ -451,7 +461,7 @@ fn impl_enum(input: Enum) -> TokenStream { let variant = &variant.ident; let from = unoptional_type(from_field.ty); let source_var = Ident::new("source", span); - let body = from_initializer(from_field, backtrace_field, &source_var); + let body = from_initializer(from_field, backtrace_field, &source_var, &krate); let from_function = quote! { fn from(#source_var: #from) -> Self { #ty::#variant #body @@ -491,7 +501,7 @@ fn impl_enum(input: Enum) -> TokenStream { quote! { #[allow(unused_qualifications)] #[automatically_derived] - impl #impl_generics ::thiserror::#private::Error for #ty #ty_generics #error_where_clause { + impl #impl_generics #krate::#private::Error for #ty #ty_generics #error_where_clause { #source_method #provide_method } @@ -523,10 +533,10 @@ fn fields_pat(fields: &[Field]) -> TokenStream { } } -fn use_as_display(needs_as_display: bool) -> Option { +fn use_as_display(needs_as_display: bool, krate: &Path) -> Option { if needs_as_display { Some(quote! { - use ::thiserror::#private::AsDisplay as _; + use #krate::#private::AsDisplay as _; }) } else { None @@ -537,6 +547,7 @@ fn from_initializer( from_field: &Field, backtrace_field: Option<&Field>, source_var: &Ident, + krate: &Path, ) -> TokenStream { let from_member = &from_field.member; let some_source = if type_is_option(from_field.ty) { @@ -548,11 +559,11 @@ fn from_initializer( let backtrace_member = &backtrace_field.member; if type_is_option(backtrace_field.ty) { quote! { - #backtrace_member: ::core::option::Option::Some(::thiserror::#private::Backtrace::capture()), + #backtrace_member: ::core::option::Option::Some(#krate::#private::Backtrace::capture()), } } else { quote! { - #backtrace_member: ::core::convert::From::from(::thiserror::#private::Backtrace::capture()), + #backtrace_member: ::core::convert::From::from(#krate::#private::Backtrace::capture()), } } }); diff --git a/impl/src/fallback.rs b/impl/src/fallback.rs index 9914aa5f..7742b6d3 100644 --- a/impl/src/fallback.rs +++ b/impl/src/fallback.rs @@ -7,6 +7,7 @@ use syn::DeriveInput; pub(crate) fn expand(input: &DeriveInput, error: syn::Error) -> TokenStream { let ty = call_site_ident(&input.ident); let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let krate = crate::attr::crate_path(&input.attrs); let error = error.to_compile_error(); @@ -15,7 +16,7 @@ pub(crate) fn expand(input: &DeriveInput, error: syn::Error) -> TokenStream { #[allow(unused_qualifications)] #[automatically_derived] - impl #impl_generics ::thiserror::#private::Error for #ty #ty_generics #where_clause + impl #impl_generics #krate::#private::Error for #ty #ty_generics #where_clause where // Work around trivial bounds being unstable. // https://github.com/rust-lang/rust/issues/48214 diff --git a/impl/src/lib.rs b/impl/src/lib.rs index 25890f22..b8328c7c 100644 --- a/impl/src/lib.rs +++ b/impl/src/lib.rs @@ -36,7 +36,7 @@ use proc_macro2::{Ident, Span}; use quote::{ToTokens, TokenStreamExt as _}; use syn::{parse_macro_input, DeriveInput}; -#[proc_macro_derive(Error, attributes(backtrace, error, from, source))] +#[proc_macro_derive(Error, attributes(backtrace, error, from, source, thiserror))] pub fn derive_error(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); expand::derive(&input).into() diff --git a/tests/test_crate_path.rs b/tests/test_crate_path.rs new file mode 100644 index 00000000..dd38f388 --- /dev/null +++ b/tests/test_crate_path.rs @@ -0,0 +1,142 @@ +//! Tests for `#[thiserror(crate = "...")]` — the attribute that lets a +//! consuming crate redirect the generated `::thiserror::__private::…` paths +//! to a re-exported copy of thiserror. + +// Simulate the scenario where `thiserror` is only accessible through a +// re-exporting wrapper crate, not as a top-level dependency. +mod reexport { + #[doc(hidden)] + pub use thiserror; + #[doc(hidden)] + pub use thiserror::*; +} + +// --- struct: basic re-export path --- + +#[derive(reexport::Error, Debug)] +#[thiserror(crate = "reexport::thiserror")] +#[error("struct error: {msg}")] +struct StructError { + msg: String, +} + +#[test] +fn test_struct_display() { + let e = StructError { msg: "boom".into() }; + assert_eq!(e.to_string(), "struct error: boom"); +} + +#[test] +fn test_struct_is_error() { + let e = StructError { msg: "boom".into() }; + let _: &dyn std::error::Error = &e; +} + +// --- enum: basic re-export path --- + +#[derive(reexport::Error, Debug)] +#[thiserror(crate = "reexport::thiserror")] +enum EnumError { + #[error("variant a")] + A, + #[error("variant b: {0}")] + B(u32), +} + +#[test] +fn test_enum_display_unit() { + assert_eq!(EnumError::A.to_string(), "variant a"); +} + +#[test] +fn test_enum_display_tuple() { + assert_eq!(EnumError::B(42).to_string(), "variant b: 42"); +} + +#[test] +fn test_enum_is_error() { + let _: &dyn std::error::Error = &EnumError::A; +} + +// --- explicit `::thiserror` path (same as default, exercises the code path) --- + +#[derive(thiserror::Error, Debug)] +#[thiserror(crate = "::thiserror")] +#[error("explicit path error")] +struct ExplicitPathError; + +#[test] +fn test_explicit_path_display() { + assert_eq!(ExplicitPathError.to_string(), "explicit path error"); +} + +#[test] +fn test_explicit_path_is_error() { + let _: &dyn std::error::Error = &ExplicitPathError; +} + +// --- #[source] works through the re-export path --- + +#[derive(reexport::Error, Debug)] +#[thiserror(crate = "reexport::thiserror")] +#[error("wrapper: {source}")] +struct WrapperError { + #[source] + source: StructError, +} + +#[test] +fn test_source_chain() { + use std::error::Error; + + let inner = StructError { + msg: "inner".into(), + }; + let outer = WrapperError { source: inner }; + assert_eq!(outer.to_string(), "wrapper: struct error: inner"); + assert!(outer.source().is_some()); +} + +// --- #[from] works through the re-export path --- + +#[derive(reexport::Error, Debug)] +#[thiserror(crate = "reexport::thiserror")] +enum FromError { + #[error("from struct: {0}")] + FromStruct(#[from] StructError), +} + +#[test] +fn test_from_impl() { + let inner = StructError { + msg: "via from".into(), + }; + let outer = FromError::from(inner); + assert_eq!(outer.to_string(), "from struct: struct error: via from"); +} + +// --- transparent forwarding works through the re-export path --- + +#[derive(reexport::Error, Debug)] +#[thiserror(crate = "reexport::thiserror")] +#[error(transparent)] +struct TransparentError(StructError); + +#[test] +fn test_transparent_display() { + let e = TransparentError(StructError { + msg: "inner".into(), + }); + assert_eq!(e.to_string(), "struct error: inner"); +} + +#[test] +fn test_transparent_source() { + use std::error::Error; + // TransparentError delegates source() to StructError, which has no #[source] + // field — so source() correctly returns None. + let e = TransparentError(StructError { + msg: "inner".into(), + }); + assert!(e.source().is_none()); +} From f81eec403b0c116af7fcff4e548c84e5e8cd83da Mon Sep 17 00:00:00 2001 From: "Kuba S." Date: Wed, 22 Apr 2026 14:20:49 +0200 Subject: [PATCH 2/2] self-cr --- impl/src/attr.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/impl/src/attr.rs b/impl/src/attr.rs index 6844c09b..0d8ee364 100644 --- a/impl/src/attr.rs +++ b/impl/src/attr.rs @@ -144,11 +144,6 @@ pub fn get(input: &[Attribute]) -> Result { Ok(attrs) } -/// Extract the thiserror crate path from `#[thiserror(crate = "...")]` on the container, -/// defaulting to `::thiserror` if the attribute is absent or malformed. -/// -/// # Returns -/// The explicit crate path if provided, otherwise `::thiserror`. pub fn crate_path(attrs: &[Attribute]) -> syn::Path { get(attrs) .ok()