From d15ee64a40e1ceabe23e0212e4b35f91ad253dbb Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Fri, 19 Sep 2025 08:39:20 +0700 Subject: [PATCH] Remove legacy template parser source --- CHANGELOG.md | 6 + Cargo.lock | 17 +- Cargo.toml | 9 +- README.md | 6 +- README.template.md | 6 +- masterror-derive/Cargo.toml | 13 + masterror-derive/src/display.rs | 285 +++++++++++ masterror-derive/src/error_trait.rs | 187 +++++++ masterror-derive/src/from_impl.rs | 91 ++++ masterror-derive/src/input.rs | 484 ++++++++++++++++++ masterror-derive/src/lib.rs | 37 ++ masterror-derive/src/span.rs | 149 ++++++ masterror-derive/src/template_support.rs | 99 ++++ masterror-template/Cargo.toml | 7 + masterror-template/src/lib.rs | 8 + masterror-template/src/template.rs | 394 ++++++++++++++ .../src}/template/parser.rs | 0 src/error/template.rs | 399 +-------------- src/lib.rs | 10 +- 19 files changed, 1801 insertions(+), 406 deletions(-) create mode 100644 masterror-derive/Cargo.toml create mode 100644 masterror-derive/src/display.rs create mode 100644 masterror-derive/src/error_trait.rs create mode 100644 masterror-derive/src/from_impl.rs create mode 100644 masterror-derive/src/input.rs create mode 100644 masterror-derive/src/lib.rs create mode 100644 masterror-derive/src/span.rs create mode 100644 masterror-derive/src/template_support.rs create mode 100644 masterror-template/Cargo.toml create mode 100644 masterror-template/src/lib.rs create mode 100644 masterror-template/src/template.rs rename {src/error => masterror-template/src}/template/parser.rs (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2deb143..e5e5e62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file. ### Added - `masterror::error::template` module providing a parsed representation of `#[error("...")]` strings and a formatter hook for future custom derives. +- Internal `masterror-derive` crate powering the native `masterror::Error` + derive macro. + +### Changed +- `masterror::Error` now uses the in-tree derive, removing the dependency on + `thiserror` while keeping the same runtime behaviour and diagnostics. ## [0.5.0] - 2025-09-23 diff --git a/Cargo.lock b/Cargo.lock index 0de2eee..2663a37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1534,6 +1534,8 @@ dependencies = [ "config", "http 1.3.1", "js-sys", + "masterror-derive", + "masterror-template", "redis", "reqwest", "serde", @@ -1543,7 +1545,6 @@ dependencies = [ "telegram-webapp-sdk", "teloxide-core", "tempfile", - "thiserror", "tokio", "toml 0.9.7", "tracing", @@ -1553,6 +1554,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "masterror-derive" +version = "0.1.0" +dependencies = [ + "masterror-template", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "masterror-template" +version = "0.1.0" + [[package]] name = "matchit" version = "0.8.4" diff --git a/Cargo.toml b/Cargo.toml index ecaf8ac..e37e838 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,12 @@ build = "build.rs" categories = ["rust-patterns", "web-programming"] keywords = ["error", "api", "framework"] +[workspace] +members = [ + "masterror-derive", + "masterror-template" +] + [features] default = [] @@ -33,7 +39,8 @@ turnkey = [] openapi = ["dep:utoipa"] [dependencies] -thiserror = "2" +masterror-derive = { path = "masterror-derive" } +masterror-template = { path = "masterror-template" } tracing = "0.1" serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 7d42196..ea89dce 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,8 @@ masterror = { version = "0.5.0", default-features = false } - **Clean wire contract.** `ErrorResponse { status, code, message, details?, retry?, www_authenticate? }`. - **One log at boundary.** Log once with `tracing`. - **Less boilerplate.** Built-in conversions, compact prelude, and the - `masterror::Error` re-export of `thiserror::Error` with `#[from]` / - `#[error(transparent)]` support. + native `masterror::Error` derive with `#[from]` / `#[error(transparent)]` + support. - **Consistent workspace.** Same error surface across crates. @@ -142,7 +142,7 @@ let wrapped = WrappedDomainError::from(err); assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); ~~~ -- `use masterror::Error;` re-exports `thiserror::Error`. +- `use masterror::Error;` brings the crate's derive macro into scope. - `#[from]` automatically implements `From<...>` while ensuring wrapper shapes are valid. - `#[error(transparent)]` enforces single-field wrappers that forward diff --git a/README.template.md b/README.template.md index a79de88..5e5896e 100644 --- a/README.template.md +++ b/README.template.md @@ -49,8 +49,8 @@ masterror = { version = "{{CRATE_VERSION}}", default-features = false } - **Clean wire contract.** `ErrorResponse { status, code, message, details?, retry?, www_authenticate? }`. - **One log at boundary.** Log once with `tracing`. - **Less boilerplate.** Built-in conversions, compact prelude, and the - `masterror::Error` re-export of `thiserror::Error` with `#[from]` / - `#[error(transparent)]` support. + native `masterror::Error` derive with `#[from]` / `#[error(transparent)]` + support. - **Consistent workspace.** Same error surface across crates. @@ -136,7 +136,7 @@ let wrapped = WrappedDomainError::from(err); assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); ~~~ -- `use masterror::Error;` re-exports `thiserror::Error`. +- `use masterror::Error;` brings the crate's derive macro into scope. - `#[from]` automatically implements `From<...>` while ensuring wrapper shapes are valid. - `#[error(transparent)]` enforces single-field wrappers that forward diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml new file mode 100644 index 0000000..8f85434 --- /dev/null +++ b/masterror-derive/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "masterror-derive" +version = "0.1.0" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "extra-traits"] } +masterror-template = { path = "../masterror-template" } diff --git a/masterror-derive/src/display.rs b/masterror-derive/src/display.rs new file mode 100644 index 0000000..b5180d0 --- /dev/null +++ b/masterror-derive/src/display.rs @@ -0,0 +1,285 @@ +use masterror_template::template::TemplateFormatter; +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; +use syn::Error; + +use crate::{ + input::{ + DisplaySpec, ErrorData, ErrorInput, Field, Fields, StructData, VariantData, + placeholder_error + }, + template_support::{ + DisplayTemplate, TemplateIdentifierSpec, TemplatePlaceholderSpec, TemplateSegmentSpec + } +}; + +pub fn expand(input: &ErrorInput) -> Result { + match &input.data { + ErrorData::Struct(data) => expand_struct(input, data), + ErrorData::Enum(variants) => expand_enum(input, variants) + } +} + +fn expand_struct(input: &ErrorInput, data: &StructData) -> Result { + let body = match &data.display { + DisplaySpec::Transparent { + .. + } => render_struct_transparent(&data.fields), + DisplaySpec::Template(template) => render_template(template, |placeholder| { + struct_placeholder_expr(&data.fields, placeholder) + })? + }; + + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + Ok(quote! { + impl #impl_generics core::fmt::Display for #ident #ty_generics #where_clause { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + #body + } + } + }) +} + +fn expand_enum(input: &ErrorInput, variants: &[VariantData]) -> Result { + let mut arms = Vec::new(); + + for variant in variants { + arms.push(render_variant(variant)?); + } + + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + Ok(quote! { + impl #impl_generics core::fmt::Display for #ident #ty_generics #where_clause { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + #(#arms),* + } + } + } + }) +} + +fn render_struct_transparent(fields: &Fields) -> TokenStream { + if let Some(field) = fields.iter().next() { + let member = &field.member; + quote! { + core::fmt::Display::fmt(&self.#member, f) + } + } else { + quote! { + Ok(()) + } + } +} + +fn render_variant(variant: &VariantData) -> Result { + match &variant.display { + DisplaySpec::Transparent { + .. + } => render_variant_transparent(variant), + DisplaySpec::Template(template) => render_variant_template(variant, template) + } +} + +fn render_variant_transparent(variant: &VariantData) -> Result { + let variant_ident = &variant.ident; + + match &variant.fields { + Fields::Unit => Err(Error::new( + variant.span, + "#[error(transparent)] requires exactly one field" + )), + Fields::Named(fields) | Fields::Unnamed(fields) => { + if fields.len() != 1 { + return Err(Error::new( + variant.span, + "#[error(transparent)] requires exactly one field" + )); + } + + let binding = binding_ident(&fields[0]); + let pattern = match &variant.fields { + Fields::Named(_) => { + let field_ident = fields[0].ident.clone().expect("named field"); + quote!(Self::#variant_ident { #field_ident: #binding }) + } + Fields::Unnamed(_) => { + quote!(Self::#variant_ident(#binding)) + } + Fields::Unit => unreachable!() + }; + + Ok(quote! { + #pattern => core::fmt::Display::fmt(#binding, f) + }) + } + } +} + +fn render_variant_template( + variant: &VariantData, + template: &DisplayTemplate +) -> Result { + let variant_ident = &variant.ident; + + match &variant.fields { + Fields::Unit => { + let body = render_template(template, |_placeholder| { + Err(Error::new( + variant.span, + "unit variants cannot reference fields" + )) + })?; + Ok(quote! { + Self::#variant_ident => { + #body + } + }) + } + Fields::Unnamed(fields) => { + let bindings: Vec<_> = fields.iter().map(binding_ident).collect(); + let pattern = quote!(Self::#variant_ident(#(#bindings),*)); + let body = render_template(template, |placeholder| { + variant_tuple_placeholder(&bindings, placeholder) + })?; + Ok(quote! { + #pattern => { + #body + } + }) + } + Fields::Named(fields) => { + let bindings: Vec<_> = fields + .iter() + .map(|field| field.ident.clone().expect("named field")) + .collect(); + let pattern = quote!(Self::#variant_ident { #(#bindings),* }); + let body = render_template(template, |placeholder| { + variant_named_placeholder(fields, &bindings, placeholder) + })?; + Ok(quote! { + #pattern => { + #body + } + }) + } + } +} + +fn render_template(template: &DisplayTemplate, resolver: F) -> Result +where + F: Fn(&TemplatePlaceholderSpec) -> Result +{ + let mut pieces = Vec::new(); + for segment in &template.segments { + match segment { + TemplateSegmentSpec::Literal(text) => { + pieces.push(quote! { f.write_str(#text)?; }); + } + TemplateSegmentSpec::Placeholder(placeholder) => { + let expr = resolver(placeholder)?; + pieces.push(format_placeholder(expr, placeholder.formatter)); + } + } + } + pieces.push(quote! { Ok(()) }); + + Ok(quote! { + #(#pieces)* + }) +} + +fn struct_placeholder_expr( + fields: &Fields, + placeholder: &TemplatePlaceholderSpec +) -> Result { + match &placeholder.identifier { + TemplateIdentifierSpec::Named(name) if name == "self" => Ok(quote!(self)), + TemplateIdentifierSpec::Named(name) => { + if let Some(field) = fields.get_named(name) { + let member = &field.member; + Ok(quote!(&self.#member)) + } else { + Err(placeholder_error(placeholder.span, &placeholder.identifier)) + } + } + TemplateIdentifierSpec::Positional(index) => { + if let Some(field) = fields.get_positional(*index) { + let member = &field.member; + Ok(quote!(&self.#member)) + } else { + Err(placeholder_error(placeholder.span, &placeholder.identifier)) + } + } + } +} + +fn variant_tuple_placeholder( + bindings: &[Ident], + placeholder: &TemplatePlaceholderSpec +) -> Result { + match &placeholder.identifier { + TemplateIdentifierSpec::Named(name) if name == "self" => Ok(quote!(self)), + TemplateIdentifierSpec::Named(_) => { + Err(placeholder_error(placeholder.span, &placeholder.identifier)) + } + TemplateIdentifierSpec::Positional(index) => bindings + .get(*index) + .map(|binding| quote!(#binding)) + .ok_or_else(|| placeholder_error(placeholder.span, &placeholder.identifier)) + } +} + +fn variant_named_placeholder( + fields: &[Field], + bindings: &[Ident], + placeholder: &TemplatePlaceholderSpec +) -> Result { + match &placeholder.identifier { + TemplateIdentifierSpec::Named(name) if name == "self" => Ok(quote!(self)), + TemplateIdentifierSpec::Named(name) => { + if let Some(index) = fields + .iter() + .position(|field| field.ident.as_ref().is_some_and(|ident| ident == name)) + { + let binding = &bindings[index]; + Ok(quote!(#binding)) + } else { + Err(placeholder_error(placeholder.span, &placeholder.identifier)) + } + } + TemplateIdentifierSpec::Positional(index) => Err(placeholder_error( + placeholder.span, + &TemplateIdentifierSpec::Positional(*index) + )) + } +} + +fn format_placeholder(expr: TokenStream, formatter: TemplateFormatter) -> TokenStream { + match formatter { + TemplateFormatter::Display => quote! { + core::fmt::Display::fmt(#expr, f)?; + }, + TemplateFormatter::Debug { + alternate: false + } => quote! { + core::fmt::Debug::fmt(#expr, f)?; + }, + TemplateFormatter::Debug { + alternate: true + } => quote! { + write!(f, "{:#?}", #expr)?; + } + } +} + +fn binding_ident(field: &Field) -> Ident { + field + .ident + .clone() + .unwrap_or_else(|| format_ident!("__field{}", field.index, span = field.span)) +} diff --git a/masterror-derive/src/error_trait.rs b/masterror-derive/src/error_trait.rs new file mode 100644 index 0000000..f0efe44 --- /dev/null +++ b/masterror-derive/src/error_trait.rs @@ -0,0 +1,187 @@ +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; +use syn::Error; + +use crate::input::{ + DisplaySpec, ErrorData, ErrorInput, Field, Fields, StructData, VariantData, is_option_type +}; + +pub fn expand(input: &ErrorInput) -> Result { + match &input.data { + ErrorData::Struct(data) => expand_struct(input, data), + ErrorData::Enum(variants) => expand_enum(input, variants) + } +} + +fn expand_struct(input: &ErrorInput, data: &StructData) -> Result { + let body = struct_source_body(&data.fields, &data.display); + + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + Ok(quote! { + impl #impl_generics std::error::Error for #ident #ty_generics #where_clause { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + #body + } + } + }) +} + +fn expand_enum(input: &ErrorInput, variants: &[VariantData]) -> Result { + let mut arms = Vec::new(); + for variant in variants { + arms.push(variant_source_arm(variant)); + } + + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + Ok(quote! { + impl #impl_generics std::error::Error for #ident #ty_generics #where_clause { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + #(#arms),* + } + } + } + }) +} + +fn struct_source_body(fields: &Fields, display: &DisplaySpec) -> TokenStream { + match display { + DisplaySpec::Transparent { + .. + } => { + if let Some(field) = fields.iter().next() { + let member = &field.member; + quote! { std::error::Error::source(&self.#member) } + } else { + quote! { None } + } + } + DisplaySpec::Template(_) => { + if let Some(field) = fields.iter().find(|field| field.attrs.source.is_some()) { + let member = &field.member; + field_source_expr(quote!(self.#member), quote!(&self.#member), &field.ty) + } else { + quote! { None } + } + } + } +} + +fn variant_source_arm(variant: &VariantData) -> TokenStream { + match &variant.display { + DisplaySpec::Transparent { + .. + } => variant_transparent_source(variant), + DisplaySpec::Template(_) => variant_template_source(variant) + } +} + +fn variant_transparent_source(variant: &VariantData) -> TokenStream { + let variant_ident = &variant.ident; + match &variant.fields { + Fields::Unit => quote! { Self::#variant_ident => None }, + Fields::Named(fields) => { + let binding = fields[0].ident.clone().expect("named field"); + let pattern = if fields.len() == 1 { + quote!(Self::#variant_ident { #binding }) + } else { + quote!(Self::#variant_ident { #binding, .. }) + }; + quote! { + #pattern => std::error::Error::source(#binding) + } + } + Fields::Unnamed(fields) => { + let binding = binding_ident(&fields[0]); + let mut patterns = Vec::new(); + for (index, _) in fields.iter().enumerate() { + if index == 0 { + patterns.push(quote!(#binding)); + } else { + patterns.push(quote!(_)); + } + } + quote! { + Self::#variant_ident(#(#patterns),*) => std::error::Error::source(#binding) + } + } + } +} + +fn variant_template_source(variant: &VariantData) -> TokenStream { + let variant_ident = &variant.ident; + let source_field = variant + .fields + .iter() + .find(|field| field.attrs.source.is_some()); + + match (&variant.fields, source_field) { + (Fields::Unit, _) => quote! { Self::#variant_ident => None }, + (_, None) => match &variant.fields { + Fields::Named(_) => quote! { Self::#variant_ident { .. } => None }, + Fields::Unnamed(fields) if fields.is_empty() => { + quote! { Self::#variant_ident() => None } + } + Fields::Unnamed(fields) => { + let placeholders = vec![quote!(_); fields.len()]; + quote! { Self::#variant_ident(#(#placeholders),*) => None } + } + Fields::Unit => quote! { Self::#variant_ident => None } + }, + (Fields::Named(fields), Some(field)) => { + let field_ident = field.ident.clone().expect("named field"); + let binding = binding_ident(field); + let pattern = if fields.len() == 1 { + quote!(Self::#variant_ident { #field_ident: #binding }) + } else { + quote!(Self::#variant_ident { #field_ident: #binding, .. }) + }; + let body = field_source_expr(quote!(#binding), quote!(#binding), &field.ty); + quote! { + #pattern => { #body } + } + } + (Fields::Unnamed(fields), Some(field)) => { + let index = field.index; + let binding = binding_ident(field); + let pattern_elements: Vec<_> = fields + .iter() + .enumerate() + .map(|(idx, _)| { + if idx == index { + quote!(#binding) + } else { + quote!(_) + } + }) + .collect(); + let body = field_source_expr(quote!(#binding), quote!(#binding), &field.ty); + quote! { + Self::#variant_ident(#(#pattern_elements),*) => { #body } + } + } + } +} + +fn field_source_expr( + owned_expr: TokenStream, + referenced_expr: TokenStream, + ty: &syn::Type +) -> TokenStream { + if is_option_type(ty) { + quote! { #owned_expr.as_ref().map(|source| source as &(dyn std::error::Error + 'static)) } + } else { + quote! { Some(#referenced_expr as &(dyn std::error::Error + 'static)) } + } +} + +fn binding_ident(field: &Field) -> Ident { + field + .ident + .clone() + .unwrap_or_else(|| format_ident!("__field{}", field.index, span = field.span)) +} diff --git a/masterror-derive/src/from_impl.rs b/masterror-derive/src/from_impl.rs new file mode 100644 index 0000000..87f2540 --- /dev/null +++ b/masterror-derive/src/from_impl.rs @@ -0,0 +1,91 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::Error; + +use crate::input::{ErrorData, ErrorInput, Field, Fields, StructData, VariantData}; + +pub fn expand(input: &ErrorInput) -> Result, Error> { + let mut impls = Vec::new(); + + match &input.data { + ErrorData::Struct(data) => { + if let Some(field) = data.fields.first_from_field() { + impls.push(struct_from_impl(input, data, field)?); + } + } + ErrorData::Enum(variants) => { + for variant in variants { + if let Some(field) = variant.fields.first_from_field() { + impls.push(enum_from_impl(input, variant, field)?); + } + } + } + } + + Ok(impls) +} + +fn struct_from_impl( + input: &ErrorInput, + data: &StructData, + field: &Field +) -> Result { + let ident = &input.ident; + let ty = &field.ty; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let constructor = match &data.fields { + Fields::Named(_) => { + let field_ident = field.ident.clone().expect("named field"); + quote! { Self { #field_ident: value } } + } + Fields::Unnamed(_) => quote! { Self(value) }, + Fields::Unit => { + return Err(Error::new( + field.span, + "#[from] is not supported on unit structs" + )); + } + }; + + Ok(quote! { + impl #impl_generics core::convert::From<#ty> for #ident #ty_generics #where_clause { + fn from(value: #ty) -> Self { + #constructor + } + } + }) +} + +fn enum_from_impl( + input: &ErrorInput, + variant: &VariantData, + field: &Field +) -> Result { + let ident = &input.ident; + let ty = &field.ty; + let variant_ident = &variant.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let constructor = match &variant.fields { + Fields::Named(_) => { + let field_ident = field.ident.clone().expect("named field"); + quote! { Self::#variant_ident { #field_ident: value } } + } + Fields::Unnamed(_) => quote! { Self::#variant_ident(value) }, + Fields::Unit => { + return Err(Error::new( + field.span, + "#[from] is not supported on unit variants" + )); + } + }; + + Ok(quote! { + impl #impl_generics core::convert::From<#ty> for #ident #ty_generics #where_clause { + fn from(value: #ty) -> Self { + #constructor + } + } + }) +} diff --git a/masterror-derive/src/input.rs b/masterror-derive/src/input.rs new file mode 100644 index 0000000..a4b22fa --- /dev/null +++ b/masterror-derive/src/input.rs @@ -0,0 +1,484 @@ +use proc_macro2::Span; +use syn::{ + Attribute, Data, DataEnum, DataStruct, DeriveInput, Error, Field as SynField, + Fields as SynFields, Ident, LitStr, spanned::Spanned +}; + +use crate::template_support::{DisplayTemplate, TemplateIdentifierSpec, parse_display_template}; + +#[derive(Debug)] +pub struct ErrorInput { + pub ident: Ident, + pub generics: syn::Generics, + pub data: ErrorData +} + +#[derive(Debug)] +pub enum ErrorData { + Struct(Box), + Enum(Vec) +} + +#[derive(Debug)] +pub struct StructData { + pub fields: Fields, + pub display: DisplaySpec +} + +#[derive(Debug)] +pub struct VariantData { + pub ident: Ident, + pub fields: Fields, + pub display: DisplaySpec, + pub span: Span +} + +#[derive(Debug)] +pub enum Fields { + Unit, + Named(Vec), + Unnamed(Vec) +} + +impl Fields { + pub fn len(&self) -> usize { + match self { + Self::Unit => 0, + Self::Named(fields) | Self::Unnamed(fields) => fields.len() + } + } + + pub fn iter(&self) -> FieldIter<'_> { + match self { + Self::Unit => FieldIter::Empty, + Self::Named(fields) | Self::Unnamed(fields) => FieldIter::Slice(fields.iter()) + } + } + + pub fn get_named(&self, name: &str) -> Option<&Field> { + match self { + Self::Named(fields) => fields + .iter() + .find(|field| field.ident.as_ref().is_some_and(|ident| ident == name)), + _ => None + } + } + + pub fn get_positional(&self, index: usize) -> Option<&Field> { + match self { + Self::Unnamed(fields) => fields.get(index), + _ => None + } + } + + pub fn from_syn(fields: &SynFields, errors: &mut Vec) -> Self { + match fields { + SynFields::Unit => Self::Unit, + SynFields::Named(named) => { + let mut items = Vec::new(); + for (index, field) in named.named.iter().enumerate() { + items.push(Field::from_syn(field, index, errors)); + } + Self::Named(items) + } + SynFields::Unnamed(unnamed) => { + let mut items = Vec::new(); + for (index, field) in unnamed.unnamed.iter().enumerate() { + items.push(Field::from_syn(field, index, errors)); + } + Self::Unnamed(items) + } + } + } + + pub fn first_from_field(&self) -> Option<&Field> { + self.iter().find(|field| field.attrs.from.is_some()) + } +} + +pub enum FieldIter<'a> { + Empty, + Slice(std::slice::Iter<'a, Field>) +} + +impl<'a> Iterator for FieldIter<'a> { + type Item = &'a Field; + + fn next(&mut self) -> Option { + match self { + FieldIter::Empty => None, + FieldIter::Slice(iter) => iter.next() + } + } +} + +#[derive(Debug)] +pub struct Field { + pub ident: Option, + pub member: syn::Member, + pub ty: syn::Type, + pub attrs: FieldAttrs, + pub span: Span, + pub index: usize +} + +impl Field { + fn from_syn(field: &SynField, index: usize, errors: &mut Vec) -> Self { + let ident = field.ident.clone(); + let member = match &ident { + Some(name) => syn::Member::Named(name.clone()), + None => syn::Member::Unnamed(syn::Index::from(index)) + }; + + let attrs = FieldAttrs::from_attrs(&field.attrs, errors); + + Self { + ident, + member, + ty: field.ty.clone(), + attrs, + span: field.span(), + index + } + } +} + +#[derive(Debug, Default)] +pub struct FieldAttrs { + pub from: Option, + pub source: Option, + pub backtrace: Option +} + +impl FieldAttrs { + fn from_attrs(attrs: &[Attribute], errors: &mut Vec) -> Self { + let mut result = FieldAttrs::default(); + + for attr in attrs { + if path_is(attr, "from") { + if let Err(err) = attr.meta.require_path_only() { + errors.push(err); + continue; + } + if result.from.is_some() { + errors.push(Error::new_spanned(attr, "duplicate #[from] attribute")); + continue; + } + result.from = Some(attr.clone()); + } else if path_is(attr, "source") { + if let Err(err) = attr.meta.require_path_only() { + errors.push(err); + continue; + } + if result.source.is_some() { + errors.push(Error::new_spanned(attr, "duplicate #[source] attribute")); + continue; + } + result.source = Some(attr.clone()); + } else if path_is(attr, "backtrace") { + if let Err(err) = attr.meta.require_path_only() { + errors.push(err); + continue; + } + if result.backtrace.is_some() { + errors.push(Error::new_spanned(attr, "duplicate #[backtrace] attribute")); + continue; + } + result.backtrace = Some(attr.clone()); + } + } + + if result.source.is_none() + && let Some(attr) = &result.from + { + result.source = Some(attr.clone()); + } + + result + } +} + +#[derive(Debug)] +pub enum DisplaySpec { + Transparent { attribute: Box }, + Template(DisplayTemplate) +} + +pub fn parse_input(input: DeriveInput) -> Result { + let mut errors = Vec::new(); + + let ident = input.ident; + let generics = input.generics; + + let data = match input.data { + Data::Struct(data) => parse_struct(&ident, &input.attrs, data, &mut errors), + Data::Enum(data) => parse_enum(&input.attrs, data, &mut errors), + Data::Union(union) => { + errors.push(Error::new( + union.union_token.span(), + "Error cannot be derived for unions" + )); + Err(()) + } + }; + + let data = match data { + Ok(value) => value, + Err(()) => { + return Err(collect_errors(errors)); + } + }; + + if errors.is_empty() { + Ok(ErrorInput { + ident, + generics, + data + }) + } else { + Err(collect_errors(errors)) + } +} + +fn parse_struct( + ident: &Ident, + attrs: &[Attribute], + data: DataStruct, + errors: &mut Vec +) -> Result { + let display = extract_display_spec(attrs, ident.span(), errors)?; + let fields = Fields::from_syn(&data.fields, errors); + + validate_from_usage(&fields, &display, errors); + validate_transparent(&fields, &display, errors, None); + + Ok(ErrorData::Struct(Box::new(StructData { + fields, + display + }))) +} + +fn parse_enum( + attrs: &[Attribute], + data: DataEnum, + errors: &mut Vec +) -> Result { + for attr in attrs { + if path_is(attr, "error") { + errors.push(Error::new_spanned( + attr, + "type-level #[error] attributes are not supported" + )); + } + } + + let mut variants = Vec::new(); + for variant in data.variants { + variants.push(parse_variant(variant, errors)?); + } + + Ok(ErrorData::Enum(variants)) +} + +fn parse_variant(variant: syn::Variant, errors: &mut Vec) -> Result { + let span = variant.span(); + for attr in &variant.attrs { + if path_is(attr, "from") { + errors.push(Error::new_spanned( + attr, + "not expected here; the #[from] attribute belongs on a specific field" + )); + } + } + + let display = extract_display_spec(&variant.attrs, span, errors)?; + let fields = Fields::from_syn(&variant.fields, errors); + + validate_from_usage(&fields, &display, errors); + validate_transparent(&fields, &display, errors, Some(&variant)); + + Ok(VariantData { + ident: variant.ident, + fields, + display, + span + }) +} + +fn extract_display_spec( + attrs: &[Attribute], + missing_span: Span, + errors: &mut Vec +) -> Result { + let mut display = None; + + for attr in attrs { + if !path_is(attr, "error") { + continue; + } + + if display.is_some() { + errors.push(Error::new_spanned(attr, "duplicate #[error] attribute")); + continue; + } + + match parse_error_attribute(attr) { + Ok(spec) => display = Some(spec), + Err(err) => errors.push(err) + } + } + + match display { + Some(spec) => Ok(spec), + None => { + errors.push(Error::new(missing_span, "missing #[error(...)] attribute")); + Err(()) + } + } +} + +fn parse_error_attribute(attr: &Attribute) -> Result { + attr.parse_args_with(|input: syn::parse::ParseStream| { + if input.peek(LitStr) { + let lit: LitStr = input.parse()?; + if !input.is_empty() { + return Err(Error::new( + input.span(), + "unexpected tokens after string literal" + )); + } + let template = parse_display_template(lit)?; + Ok(DisplaySpec::Template(template)) + } else if input.peek(Ident) { + let ident: Ident = input.parse()?; + if ident != "transparent" { + return Err(Error::new( + ident.span(), + "expected string literal or `transparent`" + )); + } + if !input.is_empty() { + return Err(Error::new( + input.span(), + "unexpected tokens after `transparent`" + )); + } + Ok(DisplaySpec::Transparent { + attribute: Box::new(attr.clone()) + }) + } else { + Err(Error::new( + input.span(), + "expected string literal or `transparent`" + )) + } + }) +} + +fn validate_from_usage(fields: &Fields, display: &DisplaySpec, errors: &mut Vec) { + let mut from_fields = fields.iter().filter(|field| field.attrs.from.is_some()); + let first = from_fields.next(); + let second = from_fields.next(); + + if let Some(field) = first { + if second.is_some() { + if let Some(attr) = &field.attrs.from { + errors.push(Error::new_spanned( + attr, + "multiple #[from] fields are not supported" + )); + } + return; + } + + if fields.len() > 1 + && let Some(attr) = &field.attrs.from + { + errors.push(Error::new_spanned( + attr, + "deriving From requires no fields other than source and backtrace" + )); + } + + if matches!(display, DisplaySpec::Transparent { .. }) + && fields.len() != 1 + && let Some(attr) = &field.attrs.from + { + errors.push(Error::new_spanned( + attr, + "#[error(transparent)] requires exactly one field" + )); + } + } +} + +fn validate_transparent( + fields: &Fields, + display: &DisplaySpec, + errors: &mut Vec, + variant: Option<&syn::Variant> +) { + if fields.len() == 1 { + return; + } + + if let DisplaySpec::Transparent { + attribute + } = display + { + match variant { + Some(variant) => { + errors.push(Error::new_spanned( + variant, + "#[error(transparent)] requires exactly one field" + )); + } + None => { + errors.push(Error::new_spanned( + attribute.as_ref(), + "#[error(transparent)] requires exactly one field" + )); + } + } + } +} + +fn path_is(attr: &Attribute, expected: &str) -> bool { + attr.path().is_ident(expected) +} + +fn collect_errors(errors: Vec) -> Error { + let mut iter = errors.into_iter(); + let mut root = iter + .next() + .unwrap_or_else(|| Error::new(Span::call_site(), "unexpected error")); + for err in iter { + root.combine(err); + } + root +} + +pub fn is_option_type(ty: &syn::Type) -> bool { + if let syn::Type::Path(path) = ty { + if path.qself.is_some() { + return false; + } + if let Some(last) = path.path.segments.last() + && last.ident == "Option" + { + return true; + } + } + false +} + +pub fn placeholder_error(span: Span, identifier: &TemplateIdentifierSpec) -> Error { + match identifier { + TemplateIdentifierSpec::Named(name) => { + Error::new(span, format!("unknown field `{}`", name)) + } + TemplateIdentifierSpec::Positional(index) => { + Error::new(span, format!("field `{}` is not available", index)) + } + } +} diff --git a/masterror-derive/src/lib.rs b/masterror-derive/src/lib.rs new file mode 100644 index 0000000..11fc187 --- /dev/null +++ b/masterror-derive/src/lib.rs @@ -0,0 +1,37 @@ +//! Derive macro for `masterror::Error`. +//! +//! This crate is not intended to be used directly. Re-exported as +//! `masterror::Error`. + +mod display; +mod error_trait; +mod from_impl; +mod input; +mod span; +mod template_support; + +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, Error, parse_macro_input}; + +#[proc_macro_derive(Error, attributes(error, source, from, backtrace))] +pub fn derive_error(tokens: TokenStream) -> TokenStream { + let input = parse_macro_input!(tokens as DeriveInput); + match expand(input) { + Ok(stream) => stream.into(), + Err(err) => err.to_compile_error().into() + } +} + +fn expand(input: DeriveInput) -> Result { + let parsed = input::parse_input(input)?; + let display_impl = display::expand(&parsed)?; + let error_impl = error_trait::expand(&parsed)?; + let from_impls = from_impl::expand(&parsed)?; + + Ok(quote! { + #display_impl + #error_impl + #(#from_impls)* + }) +} diff --git a/masterror-derive/src/span.rs b/masterror-derive/src/span.rs new file mode 100644 index 0000000..2aa1c65 --- /dev/null +++ b/masterror-derive/src/span.rs @@ -0,0 +1,149 @@ +use core::ops::Range; + +use proc_macro2::Span; +use syn::LitStr; + +/// Computes the span of a substring within a string literal. +/// +/// The range is expressed in byte indices over the interpreted contents of the +/// literal (after unescaping). The function maps it back to a span over the +/// original source code when possible. +pub fn literal_subspan(lit: &LitStr, range: Range) -> Option { + if range.start > range.end { + return None; + } + + let value = lit.value(); + if range.end > value.len() { + return None; + } + + let token = lit.token(); + let repr = token.to_string(); + + if repr.starts_with('r') { + raw_range(&repr, range).and_then(|sub| token.subspan(sub)) + } else { + escaped_range(&repr, &value, range).and_then(|sub| token.subspan(sub)) + } +} + +fn raw_range(repr: &str, range: Range) -> Option> { + let bytes = repr.as_bytes(); + let mut idx = 0usize; + if bytes.get(idx)? != &b'r' { + return None; + } + + idx += 1; + while matches!(bytes.get(idx), Some(b'#')) { + idx += 1; + } + + if bytes.get(idx)? != &b'"' { + return None; + } + + let hash_count = idx - 1; + let start_content = idx + 1; + let end_content = repr.len().checked_sub(hash_count + 1)?; + + if start_content > end_content || range.end > end_content - start_content { + return None; + } + + let start = start_content + range.start; + let end = start_content + range.end; + Some(start..end) +} + +fn escaped_range(repr: &str, value: &str, range: Range) -> Option> { + let bytes = repr.as_bytes(); + if bytes.first()? != &b'"' || bytes.last()? != &b'"' { + return None; + } + + let mut mapping = vec![0usize; value.len() + 1]; + let mut token_pos = 1usize; + let content_end = repr.len() - 1; + let mut value_pos = 0usize; + + mapping[value_pos] = token_pos; + + while token_pos < content_end && value_pos < value.len() { + if bytes[token_pos] == b'\\' { + let escape_len = escape_sequence_len(&bytes[token_pos..content_end])?; + let ch = value[value_pos..].chars().next()?; + let produced = ch.len_utf8(); + + for offset in 0..produced { + mapping[value_pos + offset] = token_pos; + } + + value_pos += produced; + token_pos += escape_len; + mapping[value_pos] = token_pos; + } else { + let ch = core::str::from_utf8(&bytes[token_pos..content_end]) + .ok()? + .chars() + .next()?; + let char_len = ch.len_utf8(); + + for offset in 0..char_len { + mapping[value_pos + offset] = token_pos; + } + + value_pos += ch.len_utf8(); + token_pos += char_len; + mapping[value_pos] = token_pos; + } + } + + if value_pos != value.len() { + return None; + } + + mapping[value_pos] = content_end; + + if range.end > value.len() { + return None; + } + + Some(mapping[range.start]..mapping[range.end]) +} + +fn escape_sequence_len(bytes: &[u8]) -> Option { + if bytes.len() < 2 || bytes[0] != b'\\' { + return None; + } + + match bytes[1] { + b'\\' | b'"' | b'\'' | b'n' | b'r' | b't' | b'0' => Some(2), + b'x' => { + if bytes.len() >= 4 { + Some(4) + } else { + None + } + } + b'u' => { + let mut idx = 2usize; + if bytes.get(idx)? != &b'{' { + return None; + } + + idx += 1; + while idx < bytes.len() && bytes[idx] != b'}' { + idx += 1; + } + + if idx >= bytes.len() { + return None; + } + + Some(idx + 1) + } + _ => None + } +} diff --git a/masterror-derive/src/template_support.rs b/masterror-derive/src/template_support.rs new file mode 100644 index 0000000..77d0bbc --- /dev/null +++ b/masterror-derive/src/template_support.rs @@ -0,0 +1,99 @@ +use masterror_template::template::{ + ErrorTemplate, TemplateError, TemplateFormatter, TemplateIdentifier, TemplateSegment +}; +use proc_macro2::Span; +use syn::{Error, LitStr}; + +use crate::span::literal_subspan; + +#[derive(Debug, Clone)] +pub struct DisplayTemplate { + pub segments: Vec +} + +#[derive(Debug, Clone)] +pub enum TemplateSegmentSpec { + Literal(String), + Placeholder(TemplatePlaceholderSpec) +} + +#[derive(Debug, Clone)] +pub struct TemplatePlaceholderSpec { + pub span: Span, + pub identifier: TemplateIdentifierSpec, + pub formatter: TemplateFormatter +} + +#[derive(Debug, Clone)] +pub enum TemplateIdentifierSpec { + Named(String), + Positional(usize) +} + +pub fn parse_display_template(lit: LitStr) -> Result { + let value = lit.value(); + let parsed = ErrorTemplate::parse(&value).map_err(|err| template_error(&lit, err))?; + + let mut segments = Vec::new(); + for segment in parsed.segments() { + match segment { + TemplateSegment::Literal(text) => { + segments.push(TemplateSegmentSpec::Literal(text.to_string())); + } + TemplateSegment::Placeholder(placeholder) => { + let span = placeholder_span(&lit, placeholder.span()); + let identifier = match placeholder.identifier() { + TemplateIdentifier::Named(name) => { + TemplateIdentifierSpec::Named(name.to_string()) + } + TemplateIdentifier::Positional(index) => { + TemplateIdentifierSpec::Positional(*index) + } + }; + + segments.push(TemplateSegmentSpec::Placeholder(TemplatePlaceholderSpec { + span, + identifier, + formatter: placeholder.formatter() + })); + } + } + } + + Ok(DisplayTemplate { + segments + }) +} + +fn placeholder_span(lit: &LitStr, range: core::ops::Range) -> Span { + literal_subspan(lit, range).unwrap_or_else(|| lit.span()) +} + +fn template_error(lit: &LitStr, error: TemplateError) -> Error { + let message = error.to_string(); + let span = match &error { + TemplateError::UnmatchedClosingBrace { + index + } => literal_subspan(lit, *index..(*index + 1)), + TemplateError::UnterminatedPlaceholder { + start + } => literal_subspan(lit, *start..(*start + 1)), + TemplateError::NestedPlaceholder { + index + } => literal_subspan(lit, *index..(*index + 1)), + TemplateError::EmptyPlaceholder { + start + } => literal_subspan(lit, *start..(*start + 1)), + TemplateError::InvalidIdentifier { + span + } => literal_subspan(lit, span.clone()), + TemplateError::InvalidIndex { + span + } => literal_subspan(lit, span.clone()), + TemplateError::InvalidFormatter { + span + } => literal_subspan(lit, span.clone()) + }; + + Error::new(span.unwrap_or_else(|| lit.span()), message) +} diff --git a/masterror-template/Cargo.toml b/masterror-template/Cargo.toml new file mode 100644 index 0000000..8a5bd47 --- /dev/null +++ b/masterror-template/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "masterror-template" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] diff --git a/masterror-template/src/lib.rs b/masterror-template/src/lib.rs new file mode 100644 index 0000000..2c184b8 --- /dev/null +++ b/masterror-template/src/lib.rs @@ -0,0 +1,8 @@ +//! Shared helpers for error derive macros. +//! +//! This crate exposes the formatting template parser used by `masterror` +//! to interpret `#[error("...")]` attributes. It is internal to the +//! workspace but kept separate so that procedural macros can reuse the +//! parsing logic without a circular dependency. + +pub mod template; diff --git a/masterror-template/src/template.rs b/masterror-template/src/template.rs new file mode 100644 index 0000000..9127168 --- /dev/null +++ b/masterror-template/src/template.rs @@ -0,0 +1,394 @@ +use core::{fmt, ops::Range}; + +mod parser; + +/// Parsed representation of an `#[error("...")]` template. +/// +/// Templates are represented as a sequence of literal segments and +/// placeholders. The structure mirrors the internal representation used by +/// formatting machinery, but keeps the slices borrowed from the original input +/// to avoid unnecessary allocations. +/// +/// # Examples +/// +/// ``` +/// use masterror_template::template::{ErrorTemplate, TemplateIdentifier}; +/// +/// let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); +/// let rendered = format!( +/// "{}", +/// template.display_with(|placeholder, f| match placeholder.identifier() { +/// TemplateIdentifier::Named("code") => write!(f, "{}", 404), +/// TemplateIdentifier::Named("message") => f.write_str("Not Found"), +/// _ => Ok(()) +/// }) +/// ); +/// +/// assert_eq!(rendered, "404: Not Found"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ErrorTemplate<'a> { + source: &'a str, + segments: Vec> +} + +impl<'a> ErrorTemplate<'a> { + /// Parses an error display template. + pub fn parse(source: &'a str) -> Result { + let segments = parser::parse_template(source)?; + Ok(Self { + source, + segments + }) + } + + /// Returns the original template string. + pub const fn source(&self) -> &'a str { + self.source + } + + /// Returns the parsed segments. + pub fn segments(&self) -> &[TemplateSegment<'a>] { + &self.segments + } + + /// Iterates over placeholder segments in order of appearance. + pub fn placeholders(&self) -> impl Iterator> { + self.segments.iter().filter_map(|segment| match segment { + TemplateSegment::Placeholder(placeholder) => Some(placeholder), + TemplateSegment::Literal(_) => None + }) + } + + /// Produces a display implementation that delegates placeholder rendering + /// to the provided resolver. + pub fn display_with(&'a self, resolver: F) -> DisplayWith<'a, 'a, F> + where + F: Fn(&TemplatePlaceholder<'a>, &mut fmt::Formatter<'_>) -> fmt::Result + { + DisplayWith { + template: self, + resolver + } + } +} + +/// A lazily formatted view over a template. +#[derive(Debug)] +pub struct DisplayWith<'a, 't, F> +where + F: Fn(&TemplatePlaceholder<'a>, &mut fmt::Formatter<'_>) -> fmt::Result +{ + template: &'t ErrorTemplate<'a>, + resolver: F +} + +impl<'a, 't, F> fmt::Display for DisplayWith<'a, 't, F> +where + F: Fn(&TemplatePlaceholder<'a>, &mut fmt::Formatter<'_>) -> fmt::Result +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for segment in &self.template.segments { + match segment { + TemplateSegment::Literal(literal) => f.write_str(literal)?, + TemplateSegment::Placeholder(placeholder) => { + (self.resolver)(placeholder, f)?; + } + } + } + + Ok(()) + } +} + +/// A single segment of the parsed template. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateSegment<'a> { + /// Literal text copied verbatim. + Literal(&'a str), + /// Placeholder (`{name}` or `{0}`) that needs formatting. + Placeholder(TemplatePlaceholder<'a>) +} + +/// Placeholder metadata extracted from a template. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TemplatePlaceholder<'a> { + span: Range, + identifier: TemplateIdentifier<'a>, + formatter: TemplateFormatter +} + +impl<'a> TemplatePlaceholder<'a> { + /// Byte range (inclusive start, exclusive end) of the placeholder within + /// the original template. + pub fn span(&self) -> Range { + self.span.clone() + } + + /// Returns the parsed identifier. + pub const fn identifier(&self) -> &TemplateIdentifier<'a> { + &self.identifier + } + + /// Returns the requested formatter. + pub const fn formatter(&self) -> TemplateFormatter { + self.formatter + } +} + +/// Placeholder identifier parsed from the template. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateIdentifier<'a> { + /// Positional index (`{0}` / `{1:?}` / etc.). + Positional(usize), + /// Named field (`{name}` / `{kind:?}` / etc.). + Named(&'a str) +} + +impl<'a> TemplateIdentifier<'a> { + /// Returns the identifier as a string when it is named. + pub const fn as_str(&self) -> Option<&'a str> { + match self { + Self::Named(value) => Some(value), + Self::Positional(_) => None + } + } +} + +/// Formatting mode requested by the placeholder. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TemplateFormatter { + /// Default `Display` formatting (`{value}`). + Display, + /// `Debug` formatting (`{value:?}` or `{value:#?}`). + Debug { + /// Whether `{value:#?}` (alternate debug) was requested. + alternate: bool + } +} + +impl TemplateFormatter { + /// Returns `true` when debug formatting with `#?` was requested. + pub const fn is_alternate(&self) -> bool { + matches!( + self, + Self::Debug { + alternate: true + } + ) + } +} + +/// Parsing errors produced when validating a template. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateError { + /// Encountered a stray closing brace. + UnmatchedClosingBrace { + /// Byte index of the stray `}` in the original template. + index: usize + }, + /// Placeholder without a matching closing brace. + UnterminatedPlaceholder { + /// Byte index where the unterminated placeholder starts. + start: usize + }, + /// Encountered `{{` or `}}` imbalance inside a placeholder. + NestedPlaceholder { + /// Byte index of the unexpected brace. + index: usize + }, + /// Placeholder without an identifier. + EmptyPlaceholder { + /// Byte index where the empty placeholder starts. + start: usize + }, + /// Identifier is malformed (contains illegal characters). + InvalidIdentifier { + /// Span (byte indices) covering the invalid identifier. + span: Range + }, + /// Positional identifier is not a valid unsigned integer. + InvalidIndex { + /// Span (byte indices) covering the invalid positional identifier. + span: Range + }, + /// Unsupported formatting specifier. + InvalidFormatter { + /// Span (byte indices) covering the unsupported formatter. + span: Range + } +} + +impl fmt::Display for TemplateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnmatchedClosingBrace { + index + } => { + write!(f, "unmatched closing brace at byte {}", index) + } + Self::UnterminatedPlaceholder { + start + } => { + write!(f, "placeholder starting at byte {} is not closed", start) + } + Self::NestedPlaceholder { + index + } => { + write!( + f, + "nested placeholder starting at byte {} is not supported", + index + ) + } + Self::EmptyPlaceholder { + start + } => { + write!(f, "placeholder starting at byte {} is empty", start) + } + Self::InvalidIdentifier { + span + } => { + write!( + f, + "invalid placeholder identifier spanning bytes {}..{}", + span.start, span.end + ) + } + Self::InvalidIndex { + span + } => { + write!( + f, + "positional placeholder spanning bytes {}..{} is not a valid unsigned integer", + span.start, span.end + ) + } + Self::InvalidFormatter { + span + } => { + write!( + f, + "placeholder spanning bytes {}..{} uses an unsupported formatter", + span.start, span.end + ) + } + } + } +} + +impl std::error::Error for TemplateError {} + +#[cfg(test)] +mod tests { + use super::*; + + fn named(name: &str) -> TemplateIdentifier<'_> { + TemplateIdentifier::Named(name) + } + + #[test] + fn parses_basic_template() { + let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); + let segments = template.segments(); + + assert_eq!(segments.len(), 3); + assert!(matches!(segments[0], TemplateSegment::Placeholder(_))); + assert!(matches!(segments[1], TemplateSegment::Literal(": "))); + assert!(matches!(segments[2], TemplateSegment::Placeholder(_))); + + let placeholders: Vec<_> = template.placeholders().collect(); + assert_eq!(placeholders.len(), 2); + assert_eq!(placeholders[0].identifier(), &named("code")); + assert_eq!(placeholders[1].identifier(), &named("message")); + } + + #[test] + fn parses_debug_formatter() { + let template = ErrorTemplate::parse("{0:#?}").expect("parse"); + let placeholders: Vec<_> = template.placeholders().collect(); + + assert_eq!(placeholders.len(), 1); + assert_eq!( + placeholders[0].identifier(), + &TemplateIdentifier::Positional(0) + ); + assert_eq!( + placeholders[0].formatter(), + TemplateFormatter::Debug { + alternate: true + } + ); + assert!(placeholders[0].formatter().is_alternate()); + } + + #[test] + fn handles_brace_escaping() { + let template = ErrorTemplate::parse("{{}} -> {value}").expect("parse"); + let mut iter = template.segments().iter(); + + assert!(matches!(iter.next(), Some(TemplateSegment::Literal("{")))); + assert!(matches!(iter.next(), Some(TemplateSegment::Literal("}")))); + assert!(matches!( + iter.next(), + Some(TemplateSegment::Literal(" -> ")) + )); + assert!(matches!( + iter.next(), + Some(TemplateSegment::Placeholder(TemplatePlaceholder { .. })) + )); + assert!(iter.next().is_none()); + } + + #[test] + fn rejects_unmatched_closing_brace() { + let err = ErrorTemplate::parse("oops}").expect_err("should fail"); + assert!(matches!( + err, + TemplateError::UnmatchedClosingBrace { + index: 4 + } + )); + } + + #[test] + fn rejects_unterminated_placeholder() { + let err = ErrorTemplate::parse("{oops").expect_err("should fail"); + assert!(matches!( + err, + TemplateError::UnterminatedPlaceholder { + start: 0 + } + )); + } + + #[test] + fn rejects_invalid_identifier() { + let err = ErrorTemplate::parse("{invalid-name}").expect_err("should fail"); + assert!(matches!(err, TemplateError::InvalidIdentifier { span } if span == (0..14))); + } + + #[test] + fn rejects_unknown_formatter() { + let err = ErrorTemplate::parse("{value:%}").expect_err("should fail"); + assert!(matches!(err, TemplateError::InvalidFormatter { span } if span == (0..9))); + } + + #[test] + fn display_with_resolves_placeholders() { + let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); + let code = 418; + let message = "I'm a teapot"; + + let rendered = format!( + "{}", + template.display_with(|placeholder, f| match placeholder.identifier() { + TemplateIdentifier::Named("code") => write!(f, "{}", code), + TemplateIdentifier::Named("message") => f.write_str(message), + other => panic!("unexpected placeholder: {:?}", other) + }) + ); + + assert_eq!(rendered, "418: I'm a teapot"); + } +} diff --git a/src/error/template/parser.rs b/masterror-template/src/template/parser.rs similarity index 100% rename from src/error/template/parser.rs rename to masterror-template/src/template/parser.rs diff --git a/src/error/template.rs b/src/error/template.rs index 7119ddd..e71e86c 100644 --- a/src/error/template.rs +++ b/src/error/template.rs @@ -1,394 +1,7 @@ -use core::{fmt, ops::Range}; +//! Parser and formatter helpers for `#[error("...")]` attributes. +//! +//! This module re-exports the shared helpers from the internal +//! `masterror_template` crate so that downstream code can continue using the +//! stable path `masterror::error::template`. -mod parser; - -/// Parsed representation of an `#[error("...")]` template. -/// -/// Templates are represented as a sequence of literal segments and -/// placeholders. The structure mirrors the internal representation used by -/// formatting machinery, but keeps the slices borrowed from the original input -/// to avoid unnecessary allocations. -/// -/// # Examples -/// -/// ``` -/// use masterror::error::template::{ErrorTemplate, TemplateIdentifier}; -/// -/// let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); -/// let rendered = format!( -/// "{}", -/// template.display_with(|placeholder, f| match placeholder.identifier() { -/// TemplateIdentifier::Named("code") => write!(f, "{}", 404), -/// TemplateIdentifier::Named("message") => f.write_str("Not Found"), -/// _ => Ok(()) -/// }) -/// ); -/// -/// assert_eq!(rendered, "404: Not Found"); -/// ``` -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ErrorTemplate<'a> { - source: &'a str, - segments: Vec> -} - -impl<'a> ErrorTemplate<'a> { - /// Parses an error display template. - pub fn parse(source: &'a str) -> Result { - let segments = parser::parse_template(source)?; - Ok(Self { - source, - segments - }) - } - - /// Returns the original template string. - pub const fn source(&self) -> &'a str { - self.source - } - - /// Returns the parsed segments. - pub fn segments(&self) -> &[TemplateSegment<'a>] { - &self.segments - } - - /// Iterates over placeholder segments in order of appearance. - pub fn placeholders(&self) -> impl Iterator> { - self.segments.iter().filter_map(|segment| match segment { - TemplateSegment::Placeholder(placeholder) => Some(placeholder), - TemplateSegment::Literal(_) => None - }) - } - - /// Produces a display implementation that delegates placeholder rendering - /// to the provided resolver. - pub fn display_with(&'a self, resolver: F) -> DisplayWith<'a, 'a, F> - where - F: Fn(&TemplatePlaceholder<'a>, &mut fmt::Formatter<'_>) -> fmt::Result - { - DisplayWith { - template: self, - resolver - } - } -} - -/// A lazily formatted view over a template. -#[derive(Debug)] -pub struct DisplayWith<'a, 't, F> -where - F: Fn(&TemplatePlaceholder<'a>, &mut fmt::Formatter<'_>) -> fmt::Result -{ - template: &'t ErrorTemplate<'a>, - resolver: F -} - -impl<'a, 't, F> fmt::Display for DisplayWith<'a, 't, F> -where - F: Fn(&TemplatePlaceholder<'a>, &mut fmt::Formatter<'_>) -> fmt::Result -{ - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for segment in &self.template.segments { - match segment { - TemplateSegment::Literal(literal) => f.write_str(literal)?, - TemplateSegment::Placeholder(placeholder) => { - (self.resolver)(placeholder, f)?; - } - } - } - - Ok(()) - } -} - -/// A single segment of the parsed template. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TemplateSegment<'a> { - /// Literal text copied verbatim. - Literal(&'a str), - /// Placeholder (`{name}` or `{0}`) that needs formatting. - Placeholder(TemplatePlaceholder<'a>) -} - -/// Placeholder metadata extracted from a template. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TemplatePlaceholder<'a> { - span: Range, - identifier: TemplateIdentifier<'a>, - formatter: TemplateFormatter -} - -impl<'a> TemplatePlaceholder<'a> { - /// Byte range (inclusive start, exclusive end) of the placeholder within - /// the original template. - pub fn span(&self) -> Range { - self.span.clone() - } - - /// Returns the parsed identifier. - pub const fn identifier(&self) -> &TemplateIdentifier<'a> { - &self.identifier - } - - /// Returns the requested formatter. - pub const fn formatter(&self) -> TemplateFormatter { - self.formatter - } -} - -/// Placeholder identifier parsed from the template. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TemplateIdentifier<'a> { - /// Positional index (`{0}` / `{1:?}` / etc.). - Positional(usize), - /// Named field (`{name}` / `{kind:?}` / etc.). - Named(&'a str) -} - -impl<'a> TemplateIdentifier<'a> { - /// Returns the identifier as a string when it is named. - pub const fn as_str(&self) -> Option<&'a str> { - match self { - Self::Named(value) => Some(value), - Self::Positional(_) => None - } - } -} - -/// Formatting mode requested by the placeholder. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TemplateFormatter { - /// Default `Display` formatting (`{value}`). - Display, - /// `Debug` formatting (`{value:?}` or `{value:#?}`). - Debug { - /// Whether `{value:#?}` (alternate debug) was requested. - alternate: bool - } -} - -impl TemplateFormatter { - /// Returns `true` when debug formatting with `#?` was requested. - pub const fn is_alternate(&self) -> bool { - matches!( - self, - Self::Debug { - alternate: true - } - ) - } -} - -/// Parsing errors produced when validating a template. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TemplateError { - /// Encountered a stray closing brace. - UnmatchedClosingBrace { - /// Byte index of the stray `}` in the original template. - index: usize - }, - /// Placeholder without a matching closing brace. - UnterminatedPlaceholder { - /// Byte index where the unterminated placeholder starts. - start: usize - }, - /// Encountered `{{` or `}}` imbalance inside a placeholder. - NestedPlaceholder { - /// Byte index of the unexpected brace. - index: usize - }, - /// Placeholder without an identifier. - EmptyPlaceholder { - /// Byte index where the empty placeholder starts. - start: usize - }, - /// Identifier is malformed (contains illegal characters). - InvalidIdentifier { - /// Span (byte indices) covering the invalid identifier. - span: Range - }, - /// Positional identifier is not a valid unsigned integer. - InvalidIndex { - /// Span (byte indices) covering the invalid positional identifier. - span: Range - }, - /// Unsupported formatting specifier. - InvalidFormatter { - /// Span (byte indices) covering the unsupported formatter. - span: Range - } -} - -impl fmt::Display for TemplateError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::UnmatchedClosingBrace { - index - } => { - write!(f, "unmatched closing brace at byte {}", index) - } - Self::UnterminatedPlaceholder { - start - } => { - write!(f, "placeholder starting at byte {} is not closed", start) - } - Self::NestedPlaceholder { - index - } => { - write!( - f, - "nested placeholder starting at byte {} is not supported", - index - ) - } - Self::EmptyPlaceholder { - start - } => { - write!(f, "placeholder starting at byte {} is empty", start) - } - Self::InvalidIdentifier { - span - } => { - write!( - f, - "invalid placeholder identifier spanning bytes {}..{}", - span.start, span.end - ) - } - Self::InvalidIndex { - span - } => { - write!( - f, - "positional placeholder spanning bytes {}..{} is not a valid unsigned integer", - span.start, span.end - ) - } - Self::InvalidFormatter { - span - } => { - write!( - f, - "placeholder spanning bytes {}..{} uses an unsupported formatter", - span.start, span.end - ) - } - } - } -} - -impl std::error::Error for TemplateError {} - -#[cfg(test)] -mod tests { - use super::*; - - fn named(name: &str) -> TemplateIdentifier<'_> { - TemplateIdentifier::Named(name) - } - - #[test] - fn parses_basic_template() { - let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); - let segments = template.segments(); - - assert_eq!(segments.len(), 3); - assert!(matches!(segments[0], TemplateSegment::Placeholder(_))); - assert!(matches!(segments[1], TemplateSegment::Literal(": "))); - assert!(matches!(segments[2], TemplateSegment::Placeholder(_))); - - let placeholders: Vec<_> = template.placeholders().collect(); - assert_eq!(placeholders.len(), 2); - assert_eq!(placeholders[0].identifier(), &named("code")); - assert_eq!(placeholders[1].identifier(), &named("message")); - } - - #[test] - fn parses_debug_formatter() { - let template = ErrorTemplate::parse("{0:#?}").expect("parse"); - let placeholders: Vec<_> = template.placeholders().collect(); - - assert_eq!(placeholders.len(), 1); - assert_eq!( - placeholders[0].identifier(), - &TemplateIdentifier::Positional(0) - ); - assert_eq!( - placeholders[0].formatter(), - TemplateFormatter::Debug { - alternate: true - } - ); - assert!(placeholders[0].formatter().is_alternate()); - } - - #[test] - fn handles_brace_escaping() { - let template = ErrorTemplate::parse("{{}} -> {value}").expect("parse"); - let mut iter = template.segments().iter(); - - assert!(matches!(iter.next(), Some(TemplateSegment::Literal("{")))); - assert!(matches!(iter.next(), Some(TemplateSegment::Literal("}")))); - assert!(matches!( - iter.next(), - Some(TemplateSegment::Literal(" -> ")) - )); - assert!(matches!( - iter.next(), - Some(TemplateSegment::Placeholder(TemplatePlaceholder { .. })) - )); - assert!(iter.next().is_none()); - } - - #[test] - fn rejects_unmatched_closing_brace() { - let err = ErrorTemplate::parse("oops}").expect_err("should fail"); - assert!(matches!( - err, - TemplateError::UnmatchedClosingBrace { - index: 4 - } - )); - } - - #[test] - fn rejects_unterminated_placeholder() { - let err = ErrorTemplate::parse("{oops").expect_err("should fail"); - assert!(matches!( - err, - TemplateError::UnterminatedPlaceholder { - start: 0 - } - )); - } - - #[test] - fn rejects_invalid_identifier() { - let err = ErrorTemplate::parse("{invalid-name}").expect_err("should fail"); - assert!(matches!(err, TemplateError::InvalidIdentifier { span } if span == (0..14))); - } - - #[test] - fn rejects_unknown_formatter() { - let err = ErrorTemplate::parse("{value:%}").expect_err("should fail"); - assert!(matches!(err, TemplateError::InvalidFormatter { span } if span == (0..9))); - } - - #[test] - fn display_with_resolves_placeholders() { - let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); - let code = 418; - let message = "I'm a teapot"; - - let rendered = format!( - "{}", - template.display_with(|placeholder, f| match placeholder.identifier() { - TemplateIdentifier::Named("code") => write!(f, "{}", code), - TemplateIdentifier::Named("message") => f.write_str(message), - other => panic!("unexpected placeholder: {:?}", other) - }) - ); - - assert_eq!(rendered, "418: I'm a teapot"); - } -} +pub use masterror_template::template::*; diff --git a/src/lib.rs b/src/lib.rs index 41276a7..b4d7ee8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -203,11 +203,10 @@ pub mod prelude; pub use app_error::{AppError, AppResult}; pub use code::AppCode; pub use kind::AppErrorKind; -pub use response::{ErrorResponse, RetryAdvice}; -/// Derive macro re-export providing the same ergonomics as `thiserror::Error`. +/// Native derive macro for error enums and structs. /// -/// Supports `#[from]` conversions and `#[error(transparent)]` wrappers out of -/// the box while keeping compile-time validation of wrapper shapes. +/// Supports `#[from]` conversions, transparent wrappers, and precise +/// diagnostics for `#[error("...")]` templates with field-aware validation. /// /// ``` /// use std::error::Error as StdError; @@ -259,4 +258,5 @@ pub use response::{ErrorResponse, RetryAdvice}; /// expected_source /// ); /// ``` -pub use thiserror::Error; +pub use masterror_derive::Error; +pub use response::{ErrorResponse, RetryAdvice};