diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c51e8..982518c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,18 @@ jobs: # the tests gated on those features only run here. - name: Test all features run: cargo test --workspace --all-features + # With default features `error-forge` must not pull any + # third-party crate into a dependent's build, on any target. + # `error-forge-derive` is in-house and dependency-free without + # its `derive` feature. + - name: Default dependency graph has no third-party crates + run: | + deps=$(cargo tree -p error-forge -e normal --target all --prefix none --locked | sort -u | grep -v -E '^error-forge(-derive)? v' || true) + if [ -n "$deps" ]; then + echo "Third-party crates in the default build of error-forge:" + echo "$deps" + exit 1 + fi docs: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index baef9a9..6599a7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +Minor release for `error-forge` and `error-forge-derive` (both `1.1.0`). With default features, `error-forge` no longer brings any third-party crate into a dependent's build: `cargo tree -e normal` lists 8 third-party crates for `1.0.2` on Windows, Linux and macOS and none for `1.1.0`. No public item is removed, renamed or changed, and default features stay `[]`. + +### Added + +- `error-forge-derive` has a `derive` feature, on by default, that gates `#[derive(ModError)]` and the `syn`, `quote` and `proc-macro2` dependencies. Without it the crate has no dependencies. +- `error-forge` declares its `error-forge-derive` feature explicitly. Cargo created it implicitly while `error-forge-derive` was an optional dependency; it keeps its meaning (it compiles the derive macro, and `derive` implies it), so manifests that name it keep resolving. +- CI checks that the default `error-forge` dependency graph contains no third-party crate on any target. + +### Changed + +- `define_errors!` builds its constructor names (`fn []`) with an internal identifier-pasting macro in `error-forge-derive` instead of the third-party `pastey` crate. Constructor names, spans and error messages for invalid names (a variant such as `Type` whose lowercase name is a keyword) are unchanged. The macro is reachable as `error_forge::__private::paste!`, which is not public API; the hidden `error_forge::__private::pastey` re-export is gone. +- `error-forge-derive` is now an always-on dependency of `error-forge`, with its default features off. The `derive` feature enables `error-forge-derive/derive` through the `error-forge-derive` feature. +- `CircuitBreaker` uses `std::sync::Mutex` instead of `parking_lot::Mutex`. Every acquisition goes through `PoisonError::into_inner`, so no method can panic on a poisoned lock, as before. `CircuitBreaker` is now `UnwindSafe` and `RefUnwindSafe`. +- `CircuitBreaker::execute` takes its lock once instead of twice for a successful call in the closed state; only the half-open probe takes it again. Against `1.0.2` (release build, no-op closure): uncontended `execute` is 17% faster on Windows and 20% faster on Linux (41.2 to 34.2 ns, 38.9 to 31.3 ns); eight threads sharing one breaker are 47% faster on Windows and 10% faster on Linux. +- A clean debug build of a crate that depends on `error-forge` with default features takes 0.78 s instead of 1.09 s on 32 jobs and 0.99 s instead of 2.67 s on one job (median of five, Windows, `CARGO_INCREMENTAL=0`). +- `docs/STABILITY.md` lists the new dependency set and narrows the dependency rules to what dependents can observe: adding a third-party dependency outside an opt-in feature is still a `2.0.0` change; removing one whose types are not in the public API is not. `docs/architecture.md`, `docs/COMPARISON.md`, `docs/migration.md`, `SECURITY.md` and the README describe the new dependencies. +- `error-forge-derive`'s `#[derive(ModError)]` implementation moved from `src/lib.rs` to `src/mod_error.rs`. No behaviour change. + +### Removed + +- The `pastey` and `parking_lot` dependencies, and with them `lock_api`, `parking_lot_core`, `scopeguard`, `smallvec`, `cfg-if` and `windows-link` (Windows) or `libc` (Unix) from every dependent's build. None of their types appear in the public API. + ## [1.0.2] - 2026-10-08 Patch release for `error-forge` and `error-forge-derive` (both `1.0.2`). Bug fixes, hygiene and build-time improvements; no public item added, removed or renamed (`cargo semver-checks` against `1.0.1`: 222 checks pass). Some inputs that compiled but silently produced wrong output are now compile errors; see **Changed**. diff --git a/Cargo.lock b/Cargo.lock index e021149..190d779 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,13 +39,11 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "error-forge" -version = "1.0.2" +version = "1.1.0" dependencies = [ "async-trait", "error-forge-derive", "log", - "parking_lot", - "pastey", "rand", "serde", "serde_json", @@ -56,7 +54,7 @@ dependencies = [ [[package]] name = "error-forge-derive" -version = "1.0.2" +version = "1.1.0" dependencies = [ "proc-macro2", "quote", @@ -169,12 +167,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "pastey" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" - [[package]] name = "pin-project-lite" version = "0.2.16" diff --git a/Cargo.toml b/Cargo.toml index 2af5c03..1d348da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ name = "error-forge" # public types, `Result` deprecated in favour of `AppResult`, # `register_error_hook` (non-try) deprecated, `rand` gated behind # new `jitter` feature. -version = "1.0.2" +version = "1.1.0" edition = "2021" # MSRV. The crate uses `io::Error::other` (stabilised 1.74), # `Mutex::new()` in const context (1.63 — fine), and Cargo lockfile @@ -59,19 +59,13 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] -# `pastey` is a drop-in fork of `paste`. The original `paste` crate -# was archived by its author on 2024-10-07 (RUSTSEC-2024-0436, -# "unmaintained"); `pastey` keeps the public `paste!` macro API and -# behaviour, with the same `[<...>]` token-pasting and case-modifier -# syntax `define_errors!` relies on. -pastey = "0.2" -# `parking_lot` provides a non-poisoning `Mutex` used by -# `CircuitBreaker`'s internal state. The standard `std::sync::Mutex` -# poisons on panic, which is incompatible with this crate's -# error-handling premise; `parking_lot::Mutex` returns the guard -# directly with no `Result` to unwrap. -parking_lot = "0.12" -error-forge-derive = {version = "1.0.2", path = "./error-forge-derive", optional = true } +# The only always-on dependency, and an in-house one. With default +# features off it is a dependency-free proc-macro crate that supplies +# the identifier pasting `define_errors!` needs (it replaced the +# third-party `pastey`, and before that `paste`). The `derive` feature +# below turns on its `#[derive(ModError)]`, which brings in `syn`, +# `quote` and `proc-macro2`. +error-forge-derive = { version = "1.1.0", path = "./error-forge-derive", default-features = false } # Floors below are checked by the `minimal-versions` CI job: `serde` # 1.0.157 is the first release whose derive builds alongside the # `syn` 1 floor of `error-forge-derive`, `log` 0.4.4 the first whose @@ -91,6 +85,11 @@ rand = { version = "0.8.5", optional = true } [features] default = [] derive = ["error-forge-derive"] +# Up to 1.0.2 `error-forge-derive` was an optional dependency and this +# was the feature Cargo created for it. It is kept, with the same +# meaning, so manifests that name it keep resolving and `derive` still +# implies it: it compiles the derive macro, and `derive` re-exports it. +error-forge-derive = ["error-forge-derive/derive"] serde = ["dep:serde"] # `console`, `backtrace`, `registry`, `collector` and `context` gate # nothing: the related APIs are always compiled in and no backtrace is diff --git a/README.md b/README.md index f6a1140..1e524a1 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ It ships with a built-in `AppError`, a declarative `define_errors!` macro, an op ```toml [dependencies] -error-forge = "1.0.2" +error-forge = "1.1.0" ``` MSRV: Rust `1.81`. CI builds and tests the crate on the exact `1.81.0` toolchain. @@ -43,6 +43,8 @@ Common optional features: - `tracing`: enables the `tracing` adapter - `jitter`: enables ±20% jitter in `ExponentialBackoff` (pulls in `rand`) +With default features, `error-forge` brings in no third-party crates: its only always-on dependency is its own `error-forge-derive`, which has no dependencies of its own unless the `derive` feature is on. The optional features above add their third-party crates (`syn`, `quote` and `proc-macro2` for `derive`). + The `console`, `backtrace`, `registry`, `collector` and `context` features exist but currently gate nothing: console formatting, the error-code registry, collectors and context wrapping are always compiled in, and no backtrace is captured automatically. They are kept so existing `Cargo.toml` files keep resolving; enabling them has no effect. ## Quick Start diff --git a/SECURITY.md b/SECURITY.md index e16ac6a..2e58c04 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,13 +50,14 @@ Security-relevant issues include but are not limited to: - Memory safety bugs (use-after-free, double-free, OOB access) — even though `error-forge` has no `unsafe` of its own, a bug - that triggers UB in `parking_lot`, `pastey`, or the standard - library through our usage qualifies. + that triggers UB in a dependency or the standard library + through our usage qualifies. - Information leakage through panic payloads, error messages, or `Display` / `Debug` impls. - Denial of service via unbounded allocation, infinite loops, - or lock-poisoning cascades (the `CircuitBreaker` uses - `parking_lot::Mutex` specifically to avoid the latter). + or lock-poisoning cascades (the `CircuitBreaker` takes its + lock through `PoisonError::into_inner` specifically to avoid + the latter). - Logic bugs in the `recovery` primitives that produce incorrect retry / circuit-breaker behaviour under adversarial input. diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 1959d78..c8150eb 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -81,7 +81,9 @@ attributes. Zero runtime cost. ### Things `thiserror` does that `error-forge` doesn't - **Pure compile-time.** No runtime dependency at all. - `error-forge` has runtime deps (`pastey`, `parking_lot`). + `error-forge` has one always-on dependency, its own + dependency-free `error-forge-derive`, compiled as a proc + macro. - **Standard error-attribute syntax.** Every Rust dev knows `#[error("...")]`. - **Zero metadata.** Just `Error` + `Display`. Nothing more, @@ -217,9 +219,10 @@ mirror the `anyhow` discussion above. The cases where `error-forge` is clearly *not* the right choice: -1. **You want zero runtime deps.** `error-forge` has two - always-on deps (`pastey`, `parking_lot`) and - optional deps behind opt-in features. `thiserror` alone has +1. **You want zero runtime deps.** `error-forge` has one + always-on dependency (its own `error-forge-derive`, with no + dependencies of its own unless `derive` is on) and optional + third-party deps behind opt-in features. `thiserror` alone has zero runtime cost; choose it if dep footprint is the constraint. diff --git a/docs/STABILITY.md b/docs/STABILITY.md index bbb61c7..c668165 100644 --- a/docs/STABILITY.md +++ b/docs/STABILITY.md @@ -90,8 +90,8 @@ any of the following requires `2.0.0`: - Changing the default-feature set (default features are `[]` in `1.0.0`; adding a default feature, or making a default feature opt-in, is breaking). -- Adding a runtime dependency that is not gated behind an - opt-in feature. +- Adding a third-party runtime dependency that is not gated + behind an opt-in feature. - A change that breaks the documented `0.9.x → 1.0.0` migration in [`docs/migration.md`](migration.md). @@ -130,8 +130,10 @@ be a bug. Specifically: `Err("Error hook already registered")` instead. - `ConsoleTheme` formatting never panics; ANSI escapes are static strings. -- `recovery::CircuitBreaker` uses `parking_lot::Mutex`, which - does not poison; `lock()` cannot panic in our usage. +- `recovery::CircuitBreaker` never panics on a poisoned lock: the + protected closure runs outside the lock, and the internal + `std::sync::Mutex` is taken through `PoisonError::into_inner` + (`parking_lot::Mutex` up to `1.0.2`). - `recovery::RetryExecutor::retry` propagates the inner operation's panic if the closure panics (matches what every retry library does). @@ -146,7 +148,7 @@ The stability contract does NOT cover: `"[CODE] "` on `CodedError`-formatted output are part of the contract; the human-readable text following them is not. - **Internal types and modules.** Any item not re-exported - through `lib.rs` (`pastey`, internal helpers in + through `lib.rs` (`__private::paste!`, internal helpers in `error.rs::panic_payload_to_listener_error`, the private `recovery::retry::{BackoffStrategy, BackoffType, RetryPredicate}`) is internal and may move or change between minor releases. @@ -158,21 +160,25 @@ The stability contract does NOT cover: The following runtime dependencies are sealed for the `1.x` line: -- `pastey` (always-on, macro support — drop-in fork of the - archived `paste`). -- `parking_lot` (always-on, non-poisoning `Mutex` used by - `recovery::CircuitBreaker`). -- `error-forge-derive` (optional, gated on `derive` feature). +- `error-forge-derive` (always-on, in-house, same version as + `error-forge`). With default features off it has no + dependencies and supplies the identifier pasting + `define_errors!` uses. Its `#[derive(ModError)]` and the `syn`, + `quote` and `proc-macro2` it needs are gated on the `derive` + feature. - `serde` (optional, gated on `serde` feature). - `log` (optional, gated on `log` feature). - `tracing` (optional, gated on `tracing` feature). - `async-trait` (optional, gated on `async` feature). - `rand` (optional, gated on `jitter` feature). -Adding a new runtime dependency requires a `2.0.0` bump. -Removing any of the always-on deps requires a `2.0.0` bump. -Adding new optional dependencies behind a new opt-in feature is -a minor-version bump. +With default features the dependency graph contains no +third-party crate. Adding a third-party runtime dependency that is +not behind an opt-in feature requires a `2.0.0` bump. Adding +optional dependencies behind a new opt-in feature is a +minor-version bump. Removing a dependency whose types do not +appear in the public API is not a breaking change: Cargo does not +let a crate use a transitive dependency it does not declare. `1.0.0` and `1.0.1` also listed `thiserror` here. No code in either crate used it and no `thiserror` type appears in the public API, so @@ -180,6 +186,17 @@ dependents could not observe it (Cargo does not let a crate use a transitive dependency it does not declare). `1.0.2` drops it as a build-time fix. +`1.0.x` also listed `pastey` and `parking_lot` as always-on and +said removing an always-on dependency required `2.0.0`. That rule +protected nothing: neither crate's types appear in the public API, +so no dependent could name or rely on them. `1.1.0` removes both +and narrows the rules above to what dependents can observe. +`define_errors!` now pastes identifiers through +`error-forge-derive` (previously an optional dependency, now +always on with its own dependencies switched off), and +`CircuitBreaker` uses `std::sync::Mutex`. The default dependency +graph went from eight third-party crates to none. + ## MSRV `1.0.0` ships with MSRV `1.81.0`. Any change to the MSRV is a diff --git a/docs/architecture.md b/docs/architecture.md index 5ddffa0..d9a8d42 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -126,7 +126,7 @@ behaviour with their async runtime's retry helper. The blocking sleep is fine for worker-thread code, batch jobs, and CLI tools — all of which are valid `error-forge` use cases. -## `CircuitBreaker` uses `parking_lot::Mutex` (since `1.0`) +## `CircuitBreaker` never panics on a poisoned lock `0.9.x` used `std::sync::Mutex` with `.unwrap()` on every `lock()`. A panic anywhere inside the `execute(|| ...)` closure @@ -134,8 +134,25 @@ would poison the mutex, and every subsequent `execute` would panic at unwrap. That's the opposite of what an error-handling crate should ship. -`parking_lot::Mutex` has no poisoning. The cost is one extra -runtime dep, which is acceptable for the correctness guarantee. +`1.0` switched to `parking_lot::Mutex`, which has no poisoning. +`1.1` goes back to `std::sync::Mutex` without the panic: the +closure runs outside the lock (a panicking probe is handled by a +drop guard), the lock only covers short updates of the breaker's +own fields, and every acquisition goes through +`PoisonError::into_inner`, so a poisoned lock is used as-is. That +removed `parking_lot` and its six transitive crates from +every dependent's build. + +A successful call admitted in the closed state takes the lock +once (admission); only the half-open probe takes it again to close +the circuit. Measured on 1.1 against 1.0.2 (one breaker, no-op +closure, release build): uncontended `execute` 17% faster on Windows +and 20% faster on Linux; eight threads on one breaker about 47% faster on +Windows and 10% faster on Linux. With the same single-lock code, +`parking_lot` was within 1 ns uncontended, about 20 ns per call +faster under the eight-thread load on Linux and about 80 ns slower +on Windows; next to the I/O a circuit breaker guards, neither +difference matters. ## `CodedError::new` is now allocation-only (since `1.0`) diff --git a/docs/migration.md b/docs/migration.md index 0da0b48..432eec2 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -315,6 +315,11 @@ were carrying `paste` in their own `Cargo.toml` purely to satisfy `define_errors!` can remove it; `error-forge` carries the dep transitively. +`1.1.0` replaces `pastey` with an internal macro in +`error-forge-derive` (`error_forge::__private::paste!`); the +`__private::pastey` path no longer exists. Nothing outside +`define_errors!` should use either path. + ### 10. `atty` dep removed, MSRV declared `1.81` Shipped in `0.9.8`, summarised here for completeness: diff --git a/error-forge-derive/Cargo.toml b/error-forge-derive/Cargo.toml index 17b71db..be970b7 100644 --- a/error-forge-derive/Cargo.toml +++ b/error-forge-derive/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "error-forge-derive" -version = "1.0.2" +version = "1.1.0" edition = "2021" rust-version = "1.81" description = "Derive macros for the error-forge crate" @@ -15,10 +15,19 @@ authors = [ [lib] proc-macro = true +[features] +# `derive` provides `#[derive(ModError)]` and is the only part of this +# crate that needs `syn`, `quote` and `proc-macro2`. It is on by default +# so direct users of this crate keep the derive. `error-forge` depends +# on this crate with default features off: the identifier-pasting macro +# behind `define_errors!` is always compiled and has no dependencies. +default = ["derive"] +derive = ["dep:syn", "dep:quote", "dep:proc-macro2"] + [dependencies] # Floors checked with `cargo update -Z direct-minimal-versions` (see the # `minimal-versions` CI job): `syn` 1.0.3 adds `Path::get_ident`, and # `quote` / `proc-macro2` match what `async-trait` needs in the same graph. -syn = "1.0.3" -quote = "1.0.29" -proc-macro2 = "1.0.63" +syn = { version = "1.0.3", optional = true } +quote = { version = "1.0.29", optional = true } +proc-macro2 = { version = "1.0.63", optional = true } diff --git a/error-forge-derive/src/lib.rs b/error-forge-derive/src/lib.rs index d00ade3..6f019bb 100644 --- a/error-forge-derive/src/lib.rs +++ b/error-forge-derive/src/lib.rs @@ -1,10 +1,20 @@ +//! Procedural macros for the `error-forge` crate. +//! +//! - `#[derive(ModError)]`, behind the default `derive` feature (pulls +//! in `syn`, `quote` and `proc-macro2`). +//! - An internal identifier-pasting macro used by `define_errors!`, +//! always available and built on the compiler's `proc_macro` API +//! alone. It is not part of the public API. +//! +//! Use these through `error-forge`; see its documentation. + extern crate proc_macro; + +#[cfg(feature = "derive")] +mod mod_error; +mod paste; + use proc_macro::TokenStream; -use proc_macro2::{Span, TokenStream as TokenStream2}; -use quote::quote; -use syn::ext::IdentExt; -use syn::parse::{ParseStream, Parser}; -use syn::{parse_macro_input, Data, DeriveInput, Fields, Ident, Lit, Token}; /// Derive macro for ModError /// @@ -73,6 +83,7 @@ use syn::{parse_macro_input, Data, DeriveInput, Fields, Ident, Lit, Token}; /// /// Note: This is a procedural macro that is re-exported by the `error-forge` crate. /// When using in your application, import it from the main crate with `use error_forge::ModError;`. +#[cfg(feature = "derive")] #[proc_macro_derive( ModError, attributes( @@ -87,729 +98,29 @@ use syn::{parse_macro_input, Data, DeriveInput, Fields, Ident, Lit, Token}; ) )] pub fn derive_mod_error(input: TokenStream) -> TokenStream { - // Parse the input - let input = parse_macro_input!(input as DeriveInput); - - // Return the generated implementation - TokenStream::from(expand(&input)) + let input = syn::parse_macro_input!(input as syn::DeriveInput); + TokenStream::from(mod_error::expand(&input)) } -/// Expand `#[derive(ModError)]` for a parsed item. +/// Identifier pasting for `error-forge`'s `macro_rules!` macros. /// -/// Unsupported input (a union) and malformed attributes produce a -/// `compile_error!` pointing at the offending tokens instead of -/// panicking inside the compiler or falling back to defaults. -fn expand(input: &DeriveInput) -> TokenStream2 { - let result = match &input.data { - Data::Enum(_) => { - get_error_prefix(&input.attrs).and_then(|prefix| implement_for_enum(input, &prefix)) - } - Data::Struct(_) => { - get_error_prefix(&input.attrs).map(|prefix| implement_for_struct(input, &prefix)) - } - Data::Union(data) => Err(syn::Error::new_spanned( - data.union_token, - "ModError cannot be derived for unions; use an enum or a struct", - )), - }; - result.unwrap_or_else(|error| error.to_compile_error()) -} - -/// Rewrite a display format so it only needs the fields it mentions. +/// Not public API: `error-forge` re-exports it as +/// `error_forge::__private::paste!` for `define_errors!`, and it may +/// change in any release. Each `[< ... >]` bracket in the input is +/// replaced by one identifier built from the segments inside it: +/// identifiers (a leading `r#` is dropped), each optionally followed by +/// `:lower` or `:upper`. All other tokens pass through unchanged. /// -/// `fields` lists the variant's fields in declaration order: their -/// names (without any `r#` prefix) for a struct-like variant, `None` for -/// a tuple variant. Every reference to a field, whether positional -/// (`{}`, `{0}`), by name (`{host}`, `{r#type}`) or as a width or -/// precision argument (`{:width$}`), is rewritten to an explicit -/// position in the argument list the caller passes. Names that are not -/// fields (`{CONST}`) are left for `format_args!` to capture. +/// # Example /// -/// Returns the rewritten string and the referenced field indices in -/// argument order, or `None` for strings this does not handle (`.*` -/// precision, malformed braces, out-of-range indices); the caller then -/// passes every field and lets `format_args!` report any error. -fn rewrite_format(fmt: &str, fields: &[Option]) -> Option<(String, Vec)> { - fn slot(args: &mut Vec, field: usize) -> usize { - match args.iter().position(|&f| f == field) { - Some(index) => index, - None => { - args.push(field); - args.len() - 1 - } - } - } - fn named_field(fields: &[Option], name: &str) -> Option { - let name = name.strip_prefix("r#").unwrap_or(name); - fields.iter().position(|f| f.as_deref() == Some(name)) - } - - let mut out = String::with_capacity(fmt.len() + 8); - let mut args: Vec = Vec::new(); - let mut next_implicit = 0usize; - let mut chars = fmt.chars().peekable(); - - while let Some(c) = chars.next() { - match c { - '{' if chars.peek() == Some(&'{') => { - chars.next(); - out.push_str("{{"); - } - '{' => { - let mut inner = String::new(); - loop { - match chars.next() { - Some('}') => break, - Some('{') | None => return None, - Some(ch) => inner.push(ch), - } - } - let (arg, spec) = match inner.find(':') { - Some(i) => (&inner[..i], Some(&inner[i + 1..])), - None => (inner.as_str(), None), - }; - - out.push('{'); - if arg.is_empty() { - if next_implicit >= fields.len() { - return None; - } - out.push_str(&slot(&mut args, next_implicit).to_string()); - next_implicit += 1; - } else if arg.bytes().all(|b| b.is_ascii_digit()) { - let index = arg.parse::().ok()?; - if index >= fields.len() { - return None; - } - out.push_str(&slot(&mut args, index).to_string()); - } else if is_plain_ident(arg.strip_prefix("r#").unwrap_or(arg)) { - match named_field(fields, arg) { - Some(index) => out.push_str(&slot(&mut args, index).to_string()), - None => out.push_str(arg), - } - } else { - return None; - } - - if let Some(spec) = spec { - if spec.contains('*') { - return None; - } - out.push(':'); - // Map `name$` / `N$` width and precision arguments. - let mut word = String::new(); - for ch in spec.chars() { - if ch == '_' || ch.is_alphanumeric() { - word.push(ch); - continue; - } - if ch == '$' && !word.is_empty() { - let field = if word.bytes().all(|b| b.is_ascii_digit()) { - let index = word.parse::().ok()?; - if index >= fields.len() { - return None; - } - Some(index) - } else { - named_field(fields, &word) - }; - match field { - Some(index) => out.push_str(&slot(&mut args, index).to_string()), - None => out.push_str(&word), - } - } else { - out.push_str(&word); - } - word.clear(); - out.push(ch); - } - out.push_str(&word); - } - out.push('}'); - } - '}' if chars.peek() == Some(&'}') => { - chars.next(); - out.push_str("}}"); - } - '}' => return None, - other => out.push(other), - } - } - - Some((out, args)) -} - -fn is_plain_ident(s: &str) -> bool { - let mut chars = s.chars(); - match chars.next() { - Some(first) if first == '_' || first.is_alphabetic() => { - chars.all(|c| c == '_' || c.is_alphanumeric()) - } - _ => false, - } -} - -/// The value written after an attribute's path. -enum AttrValue { - /// `#[name]` - Flag, - /// `#[name(value)]` or `#[name = value]`, with an optional leading - /// minus sign for negative integers. - Lit(Option, Lit), -} - -fn parse_attr_value(input: ParseStream) -> syn::Result { - fn literal(input: ParseStream) -> syn::Result { - let minus: Option = input.parse()?; - let lit: Lit = input.parse()?; - Ok(AttrValue::Lit(minus, lit)) - } - - if input.is_empty() { - return Ok(AttrValue::Flag); - } - let value = if input.peek(syn::token::Paren) { - let content; - syn::parenthesized!(content in input); - let value = literal(&content)?; - if !content.is_empty() { - return Err(content.error("expected a single literal value")); - } - value - } else if input.peek(Token![=]) { - input.parse::()?; - literal(input)? - } else { - return Err(input.error("expected `(value)` or `= value`")); - }; - if !input.is_empty() { - return Err(input.error("unexpected tokens after the attribute value")); - } - Ok(value) -} - -fn attr_value(attr: &syn::Attribute) -> syn::Result { - parse_attr_value.parse2(attr.tokens.clone()) -} - -fn attr_name(attr: &syn::Attribute) -> String { - attr.path - .get_ident() - .map(ToString::to_string) - .unwrap_or_default() -} - -fn parse_string_attribute(attr: &syn::Attribute) -> syn::Result { - let message = || format!("`{}` expects a string literal", attr_name(attr)); - match attr_value(attr)? { - AttrValue::Lit(None, Lit::Str(lit)) => Ok(lit.value()), - AttrValue::Lit(Some(minus), _) => Err(syn::Error::new_spanned(minus, message())), - AttrValue::Lit(None, lit) => Err(syn::Error::new_spanned(lit, message())), - AttrValue::Flag => Err(syn::Error::new_spanned( - attr, - format!("{}: `#[{}(\"...\")]`", message(), attr_name(attr)), - )), - } -} - -fn parse_flag_attribute(attr: &syn::Attribute) -> syn::Result { - let message = || format!("`{}` expects `true` or `false`", attr_name(attr)); - match attr_value(attr)? { - AttrValue::Flag => Ok(true), - AttrValue::Lit(None, Lit::Bool(lit)) => Ok(lit.value), - AttrValue::Lit(Some(minus), _) => Err(syn::Error::new_spanned(minus, message())), - AttrValue::Lit(None, lit) => Err(syn::Error::new_spanned(lit, message())), - } -} - -fn parse_int_attribute(attr: &syn::Attribute) -> syn::Result -where - T: TryFrom, -{ - let name = attr_name(attr); - let range_error = |tokens: TokenStream2| { - syn::Error::new_spanned( - tokens, - format!( - "`{name}` expects an integer in {}", - std::any::type_name::() - ), - ) - }; - match attr_value(attr)? { - AttrValue::Lit(minus, Lit::Int(lit)) => { - let tokens = quote!(#minus #lit); - let magnitude: i128 = lit - .base10_parse() - .map_err(|_| range_error(tokens.clone()))?; - let value = if minus.is_some() { - -magnitude - } else { - magnitude - }; - T::try_from(value).map_err(|_| range_error(tokens)) - } - AttrValue::Lit(_, lit) => Err(syn::Error::new_spanned( - lit, - format!("`{name}` expects an integer literal"), - )), - AttrValue::Flag => Err(syn::Error::new_spanned( - attr, - format!("`{name}` expects an integer: `#[{name}(...)]`"), - )), - } -} - -/// Merge `error` into the running error so every bad attribute is -/// reported, not only the first. -fn push_error(errors: &mut Option, error: syn::Error) { - match errors { - Some(existing) => existing.combine(error), - None => *errors = Some(error), - } -} - -// Extract error_prefix attribute value -fn get_error_prefix(attrs: &[syn::Attribute]) -> syn::Result { - let mut prefix = String::new(); - for attr in attrs { - if attr.path.is_ident("error_prefix") { - prefix = parse_string_attribute(attr)?; - } - } - Ok(prefix) -} - -/// Metadata read from one variant's attributes. -struct VariantAttrs { - display: String, - kind: String, - caption: String, - retryable: bool, - fatal: bool, - status_code: u16, - exit_code: i32, -} - -fn variant_attrs(variant: &syn::Variant, error_prefix: &str) -> syn::Result { - let name = variant.ident.to_string(); - let mut attrs = VariantAttrs { - display: name.clone(), - kind: name, - caption: format!("{}: Error", error_prefix), - retryable: false, - fatal: false, - status_code: 500, - exit_code: 1, - }; - let mut errors = None; - for attr in &variant.attrs { - let result = if attr.path.is_ident("error_display") { - parse_string_attribute(attr).map(|v| attrs.display = v) - } else if attr.path.is_ident("error_kind") { - parse_string_attribute(attr).map(|v| attrs.kind = v) - } else if attr.path.is_ident("error_caption") { - parse_string_attribute(attr).map(|v| attrs.caption = v) - } else if attr.path.is_ident("error_retryable") { - parse_flag_attribute(attr).map(|v| attrs.retryable = v) - } else if attr.path.is_ident("error_fatal") { - parse_flag_attribute(attr).map(|v| attrs.fatal = v) - } else if attr.path.is_ident("error_http_status") { - parse_int_attribute(attr).map(|v| attrs.status_code = v) - } else if attr.path.is_ident("error_exit_code") { - parse_int_attribute(attr).map(|v| attrs.exit_code = v) - } else { - Ok(()) - }; - if let Err(error) = result { - push_error(&mut errors, error); - } - } - match errors { - Some(error) => Err(error), - None => Ok(attrs), - } -} - -/// Build the `Display` match arm for one variant. The arm writes into -/// `writer`, a `&mut dyn core::fmt::Write`. -fn display_arm(variant: &syn::Variant, display: &str, writer: &Ident) -> TokenStream2 { - let variant_name = &variant.ident; - // Bindings use mixed-site hygiene so a display string cannot capture - // them by accident and they cannot collide with user items. - let binding = |index: usize| Ident::new(&format!("__ef_field_{index}"), Span::mixed_site()); - - match &variant.fields { - Fields::Named(fields) => { - let idents: Vec<&Ident> = fields - .named - .iter() - .map(|f| f.ident.as_ref().expect("named field")) - .collect(); - let names: Vec> = idents - .iter() - .map(|ident| Some(ident.unraw().to_string())) - .collect(); - match rewrite_format(display, &names) { - Some((format_str, args)) => { - let pattern = args.iter().map(|&i| { - let field = idents[i]; - let bind = binding(i); - quote! { #field: #bind } - }); - let values = args.iter().map(|&i| binding(i)); - quote! { - Self::#variant_name { #(#pattern,)* .. } => - ::core::write!(#writer, #format_str #(, #values)*) - } - } - None => { - let binds: Vec = (0..idents.len()).map(binding).collect(); - quote! { - Self::#variant_name { #(#idents: #binds),* } => - ::core::write!(#writer, #display, #(#idents = #binds),*) - } - } - } - } - Fields::Unnamed(fields) => { - let count = fields.unnamed.len(); - let names: Vec> = vec![None; count]; - match rewrite_format(display, &names) { - Some((format_str, args)) => { - let pattern = (0..count).map(|i| { - if args.contains(&i) { - let bind = binding(i); - quote! { #bind } - } else { - quote! { _ } - } - }); - let values = args.iter().map(|&i| binding(i)); - quote! { - Self::#variant_name(#(#pattern),*) => - ::core::write!(#writer, #format_str #(, #values)*) - } - } - None => { - let binds: Vec = (0..count).map(binding).collect(); - quote! { - Self::#variant_name(#(#binds),*) => - ::core::write!(#writer, #display #(, #binds)*) - } - } - } - } - Fields::Unit => { - let format_str = rewrite_format(display, &[]) - .map(|(format_str, _)| format_str) - .unwrap_or_else(|| display.to_string()); - quote! { - Self::#variant_name => ::core::write!(#writer, #format_str) - } - } - } -} - -// Implement ModError for an enum -fn implement_for_enum(input: &DeriveInput, error_prefix: &str) -> syn::Result { - let name = &input.ident; - let data_enum = match &input.data { - Data::Enum(data) => data, - _ => unreachable!("implement_for_enum is only called for enums"), - }; - - let writer = Ident::new("__ef_writer", Span::mixed_site()); - let formatter = Ident::new("__ef_formatter", Span::mixed_site()); - let write_fn = Ident::new("__ef_write", Span::mixed_site()); - let buffer = Ident::new("__ef_buffer", Span::mixed_site()); - - // Generate match arms for each variant - let mut kind_match_arms = Vec::new(); - let mut caption_match_arms = Vec::new(); - let mut display_match_arms = Vec::new(); - let mut retryable_match_arms = Vec::new(); - let mut fatal_match_arms = Vec::new(); - let mut status_code_match_arms = Vec::new(); - let mut exit_code_match_arms = Vec::new(); - let mut errors = None; - - for variant in &data_enum.variants { - let variant_name = &variant.ident; - let attrs = match variant_attrs(variant, error_prefix) { - Ok(attrs) => attrs, - Err(error) => { - push_error(&mut errors, error); - continue; - } - }; - let VariantAttrs { - display, - kind, - caption, - retryable, - fatal, - status_code, - exit_code, - } = attrs; - - let pattern = match &variant.fields { - Fields::Named(_) => quote! { Self::#variant_name { .. } }, - Fields::Unnamed(_) => quote! { Self::#variant_name(..) }, - Fields::Unit => quote! { Self::#variant_name }, - }; - - kind_match_arms.push(quote! { #pattern => #kind }); - caption_match_arms.push(quote! { #pattern => #caption }); - retryable_match_arms.push(quote! { #pattern => #retryable }); - fatal_match_arms.push(quote! { #pattern => #fatal }); - status_code_match_arms.push(quote! { #pattern => #status_code }); - exit_code_match_arms.push(quote! { #pattern => #exit_code }); - display_match_arms.push(display_arm(variant, &display, &writer)); - } - - if let Some(error) = errors { - return Err(error); - } - - // Generate implementation - Ok(quote! { - impl ::core::fmt::Display for #name { - fn fmt(&self, #formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { - let #write_fn = |#writer: &mut dyn ::core::fmt::Write| -> ::core::fmt::Result { - match self { - #(#display_match_arms,)* - } - }; - if #formatter.width().is_none() && #formatter.precision().is_none() { - #write_fn(#formatter) - } else { - // Width and precision apply to the whole message, so - // it has to be rendered before it can be padded. - let mut #buffer = ::std::string::String::new(); - #write_fn(&mut #buffer)?; - #formatter.pad(&#buffer) - } - } - } - - impl ::error_forge::error::ForgeError for #name { - fn kind(&self) -> &'static str { - match self { - #(#kind_match_arms,)* - } - } - - fn caption(&self) -> &'static str { - match self { - #(#caption_match_arms,)* - } - } - - fn is_retryable(&self) -> bool { - match self { - #(#retryable_match_arms,)* - } - } - - fn is_fatal(&self) -> bool { - match self { - #(#fatal_match_arms,)* - } - } - - fn status_code(&self) -> u16 { - match self { - #(#status_code_match_arms,)* - } - } - - fn exit_code(&self) -> i32 { - match self { - #(#exit_code_match_arms,)* - } - } - } - - impl ::std::error::Error for #name { - fn source(&self) -> ::core::option::Option<&(dyn ::std::error::Error + 'static)> { - ::core::option::Option::None - } - } - }) -} - -// Implement ModError for a struct -fn implement_for_struct(input: &DeriveInput, error_prefix: &str) -> TokenStream2 { - let name = &input.ident; - let name_str = name.to_string(); - let formatter = Ident::new("__ef_formatter", Span::mixed_site()); - - quote! { - impl ::core::fmt::Display for #name { - fn fmt(&self, #formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { - #formatter.pad(::core::concat!(#error_prefix, ": Error")) - } - } - - impl ::error_forge::error::ForgeError for #name { - fn kind(&self) -> &'static str { - #name_str - } - - fn caption(&self) -> &'static str { - ::core::concat!(#error_prefix, ": Error") - } - } - - impl ::std::error::Error for #name { - fn source(&self) -> ::core::option::Option<&(dyn ::std::error::Error + 'static)> { - ::core::option::Option::None - } - } - } -} - -#[cfg(test)] -mod tests { - use super::{expand, rewrite_format}; - - fn named(list: &[&str]) -> Vec> { - list.iter().map(|s| Some(s.to_string())).collect() - } - - fn tuple(count: usize) -> Vec> { - vec![None; count] - } - - fn expand_str(input: syn::DeriveInput) -> String { - expand(&input).to_string() - } - - #[test] - fn union_produces_compile_error_instead_of_panicking() { - let output = expand_str(syn::parse_quote! { - union Bits { a: u32, b: f32 } - }); - assert!(output.contains("compile_error"), "{output}"); - assert!(output.contains("cannot be derived for unions"), "{output}"); - } - - #[test] - fn rewrite_maps_positional_placeholders_to_arguments() { - assert_eq!( - rewrite_format("{} then {} and {0:?}", &tuple(3)), - Some(("{0} then {1} and {0:?}".to_string(), vec![0, 1])) - ); - assert_eq!( - rewrite_format("only {2}", &tuple(3)), - Some(("only {0}".to_string(), vec![2])) - ); - assert_eq!( - rewrite_format("NoFields", &tuple(3)), - Some(("NoFields".to_string(), Vec::new())) - ); - } - - #[test] - fn rewrite_keeps_escapes_and_non_field_names() { - let fields = named(&["host", "port"]); - assert_eq!( - rewrite_format("{{literal}} {port:>5} {CONST}", &fields), - Some(("{{literal}} {0:>5} {CONST}".to_string(), vec![1])) - ); - // Positional references in a struct-like variant map to the - // fields in declaration order, as format! did before. - assert_eq!( - rewrite_format("{}:{}", &fields), - Some(("{0}:{1}".to_string(), vec![0, 1])) - ); - } - - #[test] - fn rewrite_maps_raw_identifiers_and_count_arguments() { - let fields = named(&["type", "width"]); - assert_eq!( - rewrite_format("{type} {r#type} {type:>width$} {:.1$}", &fields), - Some(("{0} {0} {0:>1$} {0:.1$}".to_string(), vec![0, 1])) - ); - assert_eq!( - rewrite_format("{type:>W$}", &fields), - Some(("{0:>W$}".to_string(), vec![0])) - ); - } - - #[test] - fn rewrite_declines_unsupported_strings() { - let fields = named(&["a", "b"]); - assert_eq!(rewrite_format("{:.*}", &fields), None); - assert_eq!(rewrite_format("{5}", &fields), None); - assert_eq!(rewrite_format("{} {} {}", &fields), None); - assert_eq!(rewrite_format("unclosed {", &fields), None); - assert_eq!(rewrite_format("stray }", &fields), None); - } - - #[test] - fn malformed_attribute_values_are_compile_errors() { - let cases: [(syn::DeriveInput, &str); 6] = [ - ( - syn::parse_quote! { enum E { #[error_http_status("404")] A } }, - "`error_http_status` expects an integer literal", - ), - ( - syn::parse_quote! { enum E { #[error_exit_code(99999999999)] A } }, - "`error_exit_code` expects an integer in i32", - ), - ( - syn::parse_quote! { enum E { #[error_http_status(-1)] A } }, - "`error_http_status` expects an integer in u16", - ), - ( - syn::parse_quote! { enum E { #[error_retryable("yes")] A } }, - "`error_retryable` expects `true` or `false`", - ), - ( - syn::parse_quote! { enum E { #[error_display(42)] A } }, - "`error_display` expects a string literal", - ), - ( - syn::parse_quote! { #[error_prefix] enum E { A } }, - "`error_prefix` expects a string literal", - ), - ]; - for (input, message) in cases { - let output = expand_str(input); - assert!(output.contains("compile_error"), "{output}"); - assert!(output.contains(message), "{message} not in {output}"); - } - } - - #[test] - fn explicit_flag_values_are_honoured() { - let output = expand_str(syn::parse_quote! { - enum E { - #[error_retryable(false)] - #[error_fatal = false] - A, - #[error_retryable = true] - #[error_fatal(true)] - B, - #[error_exit_code(-2)] - C, - } - }); - assert!(!output.contains("compile_error"), "{output}"); - let retryable = output - .split("fn is_retryable") - .nth(1) - .and_then(|s| s.split("fn is_fatal").next()) - .unwrap(); - assert!(retryable.contains("Self :: A => false"), "{retryable}"); - assert!(retryable.contains("Self :: B => true"), "{retryable}"); - let exit = output.split("fn exit_code").nth(1).unwrap(); - assert!( - exit.contains("Self :: C => - 2i32") || exit.contains("Self :: C => -2i32"), - "{exit}" - ); - } +/// ```ignore +/// error_forge::__private::paste! { +/// fn []() {} +/// } +/// // expands to `fn notfound() {}` +/// ``` +#[doc(hidden)] +#[proc_macro] +pub fn __paste(input: TokenStream) -> TokenStream { + paste::expand(input) } diff --git a/error-forge-derive/src/mod_error.rs b/error-forge-derive/src/mod_error.rs new file mode 100644 index 0000000..b44793a --- /dev/null +++ b/error-forge-derive/src/mod_error.rs @@ -0,0 +1,730 @@ +//! `#[derive(ModError)]` expansion, built on `syn` and `quote`. +//! +//! Compiled only with the `derive` feature; the crate root keeps the +//! `#[proc_macro_derive]` entry point and forwards here. + +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::quote; +use syn::ext::IdentExt; +use syn::parse::{ParseStream, Parser}; +use syn::{Data, DeriveInput, Fields, Ident, Lit, Token}; + +/// Expand `#[derive(ModError)]` for a parsed item. +/// +/// Unsupported input (a union) and malformed attributes produce a +/// `compile_error!` pointing at the offending tokens instead of +/// panicking inside the compiler or falling back to defaults. +pub(crate) fn expand(input: &DeriveInput) -> TokenStream2 { + let result = match &input.data { + Data::Enum(_) => { + get_error_prefix(&input.attrs).and_then(|prefix| implement_for_enum(input, &prefix)) + } + Data::Struct(_) => { + get_error_prefix(&input.attrs).map(|prefix| implement_for_struct(input, &prefix)) + } + Data::Union(data) => Err(syn::Error::new_spanned( + data.union_token, + "ModError cannot be derived for unions; use an enum or a struct", + )), + }; + result.unwrap_or_else(|error| error.to_compile_error()) +} + +/// Rewrite a display format so it only needs the fields it mentions. +/// +/// `fields` lists the variant's fields in declaration order: their +/// names (without any `r#` prefix) for a struct-like variant, `None` for +/// a tuple variant. Every reference to a field, whether positional +/// (`{}`, `{0}`), by name (`{host}`, `{r#type}`) or as a width or +/// precision argument (`{:width$}`), is rewritten to an explicit +/// position in the argument list the caller passes. Names that are not +/// fields (`{CONST}`) are left for `format_args!` to capture. +/// +/// Returns the rewritten string and the referenced field indices in +/// argument order, or `None` for strings this does not handle (`.*` +/// precision, malformed braces, out-of-range indices); the caller then +/// passes every field and lets `format_args!` report any error. +fn rewrite_format(fmt: &str, fields: &[Option]) -> Option<(String, Vec)> { + fn slot(args: &mut Vec, field: usize) -> usize { + match args.iter().position(|&f| f == field) { + Some(index) => index, + None => { + args.push(field); + args.len() - 1 + } + } + } + fn named_field(fields: &[Option], name: &str) -> Option { + let name = name.strip_prefix("r#").unwrap_or(name); + fields.iter().position(|f| f.as_deref() == Some(name)) + } + + let mut out = String::with_capacity(fmt.len() + 8); + let mut args: Vec = Vec::new(); + let mut next_implicit = 0usize; + let mut chars = fmt.chars().peekable(); + + while let Some(c) = chars.next() { + match c { + '{' if chars.peek() == Some(&'{') => { + chars.next(); + out.push_str("{{"); + } + '{' => { + let mut inner = String::new(); + loop { + match chars.next() { + Some('}') => break, + Some('{') | None => return None, + Some(ch) => inner.push(ch), + } + } + let (arg, spec) = match inner.find(':') { + Some(i) => (&inner[..i], Some(&inner[i + 1..])), + None => (inner.as_str(), None), + }; + + out.push('{'); + if arg.is_empty() { + if next_implicit >= fields.len() { + return None; + } + out.push_str(&slot(&mut args, next_implicit).to_string()); + next_implicit += 1; + } else if arg.bytes().all(|b| b.is_ascii_digit()) { + let index = arg.parse::().ok()?; + if index >= fields.len() { + return None; + } + out.push_str(&slot(&mut args, index).to_string()); + } else if is_plain_ident(arg.strip_prefix("r#").unwrap_or(arg)) { + match named_field(fields, arg) { + Some(index) => out.push_str(&slot(&mut args, index).to_string()), + None => out.push_str(arg), + } + } else { + return None; + } + + if let Some(spec) = spec { + if spec.contains('*') { + return None; + } + out.push(':'); + // Map `name$` / `N$` width and precision arguments. + let mut word = String::new(); + for ch in spec.chars() { + if ch == '_' || ch.is_alphanumeric() { + word.push(ch); + continue; + } + if ch == '$' && !word.is_empty() { + let field = if word.bytes().all(|b| b.is_ascii_digit()) { + let index = word.parse::().ok()?; + if index >= fields.len() { + return None; + } + Some(index) + } else { + named_field(fields, &word) + }; + match field { + Some(index) => out.push_str(&slot(&mut args, index).to_string()), + None => out.push_str(&word), + } + } else { + out.push_str(&word); + } + word.clear(); + out.push(ch); + } + out.push_str(&word); + } + out.push('}'); + } + '}' if chars.peek() == Some(&'}') => { + chars.next(); + out.push_str("}}"); + } + '}' => return None, + other => out.push(other), + } + } + + Some((out, args)) +} + +fn is_plain_ident(s: &str) -> bool { + let mut chars = s.chars(); + match chars.next() { + Some(first) if first == '_' || first.is_alphabetic() => { + chars.all(|c| c == '_' || c.is_alphanumeric()) + } + _ => false, + } +} + +/// The value written after an attribute's path. +enum AttrValue { + /// `#[name]` + Flag, + /// `#[name(value)]` or `#[name = value]`, with an optional leading + /// minus sign for negative integers. + Lit(Option, Lit), +} + +fn parse_attr_value(input: ParseStream) -> syn::Result { + fn literal(input: ParseStream) -> syn::Result { + let minus: Option = input.parse()?; + let lit: Lit = input.parse()?; + Ok(AttrValue::Lit(minus, lit)) + } + + if input.is_empty() { + return Ok(AttrValue::Flag); + } + let value = if input.peek(syn::token::Paren) { + let content; + syn::parenthesized!(content in input); + let value = literal(&content)?; + if !content.is_empty() { + return Err(content.error("expected a single literal value")); + } + value + } else if input.peek(Token![=]) { + input.parse::()?; + literal(input)? + } else { + return Err(input.error("expected `(value)` or `= value`")); + }; + if !input.is_empty() { + return Err(input.error("unexpected tokens after the attribute value")); + } + Ok(value) +} + +fn attr_value(attr: &syn::Attribute) -> syn::Result { + parse_attr_value.parse2(attr.tokens.clone()) +} + +fn attr_name(attr: &syn::Attribute) -> String { + attr.path + .get_ident() + .map(ToString::to_string) + .unwrap_or_default() +} + +fn parse_string_attribute(attr: &syn::Attribute) -> syn::Result { + let message = || format!("`{}` expects a string literal", attr_name(attr)); + match attr_value(attr)? { + AttrValue::Lit(None, Lit::Str(lit)) => Ok(lit.value()), + AttrValue::Lit(Some(minus), _) => Err(syn::Error::new_spanned(minus, message())), + AttrValue::Lit(None, lit) => Err(syn::Error::new_spanned(lit, message())), + AttrValue::Flag => Err(syn::Error::new_spanned( + attr, + format!("{}: `#[{}(\"...\")]`", message(), attr_name(attr)), + )), + } +} + +fn parse_flag_attribute(attr: &syn::Attribute) -> syn::Result { + let message = || format!("`{}` expects `true` or `false`", attr_name(attr)); + match attr_value(attr)? { + AttrValue::Flag => Ok(true), + AttrValue::Lit(None, Lit::Bool(lit)) => Ok(lit.value), + AttrValue::Lit(Some(minus), _) => Err(syn::Error::new_spanned(minus, message())), + AttrValue::Lit(None, lit) => Err(syn::Error::new_spanned(lit, message())), + } +} + +fn parse_int_attribute(attr: &syn::Attribute) -> syn::Result +where + T: TryFrom, +{ + let name = attr_name(attr); + let range_error = |tokens: TokenStream2| { + syn::Error::new_spanned( + tokens, + format!( + "`{name}` expects an integer in {}", + std::any::type_name::() + ), + ) + }; + match attr_value(attr)? { + AttrValue::Lit(minus, Lit::Int(lit)) => { + let tokens = quote!(#minus #lit); + let magnitude: i128 = lit + .base10_parse() + .map_err(|_| range_error(tokens.clone()))?; + let value = if minus.is_some() { + -magnitude + } else { + magnitude + }; + T::try_from(value).map_err(|_| range_error(tokens)) + } + AttrValue::Lit(_, lit) => Err(syn::Error::new_spanned( + lit, + format!("`{name}` expects an integer literal"), + )), + AttrValue::Flag => Err(syn::Error::new_spanned( + attr, + format!("`{name}` expects an integer: `#[{name}(...)]`"), + )), + } +} + +/// Merge `error` into the running error so every bad attribute is +/// reported, not only the first. +fn push_error(errors: &mut Option, error: syn::Error) { + match errors { + Some(existing) => existing.combine(error), + None => *errors = Some(error), + } +} + +// Extract error_prefix attribute value +fn get_error_prefix(attrs: &[syn::Attribute]) -> syn::Result { + let mut prefix = String::new(); + for attr in attrs { + if attr.path.is_ident("error_prefix") { + prefix = parse_string_attribute(attr)?; + } + } + Ok(prefix) +} + +/// Metadata read from one variant's attributes. +struct VariantAttrs { + display: String, + kind: String, + caption: String, + retryable: bool, + fatal: bool, + status_code: u16, + exit_code: i32, +} + +fn variant_attrs(variant: &syn::Variant, error_prefix: &str) -> syn::Result { + let name = variant.ident.to_string(); + let mut attrs = VariantAttrs { + display: name.clone(), + kind: name, + caption: format!("{}: Error", error_prefix), + retryable: false, + fatal: false, + status_code: 500, + exit_code: 1, + }; + let mut errors = None; + for attr in &variant.attrs { + let result = if attr.path.is_ident("error_display") { + parse_string_attribute(attr).map(|v| attrs.display = v) + } else if attr.path.is_ident("error_kind") { + parse_string_attribute(attr).map(|v| attrs.kind = v) + } else if attr.path.is_ident("error_caption") { + parse_string_attribute(attr).map(|v| attrs.caption = v) + } else if attr.path.is_ident("error_retryable") { + parse_flag_attribute(attr).map(|v| attrs.retryable = v) + } else if attr.path.is_ident("error_fatal") { + parse_flag_attribute(attr).map(|v| attrs.fatal = v) + } else if attr.path.is_ident("error_http_status") { + parse_int_attribute(attr).map(|v| attrs.status_code = v) + } else if attr.path.is_ident("error_exit_code") { + parse_int_attribute(attr).map(|v| attrs.exit_code = v) + } else { + Ok(()) + }; + if let Err(error) = result { + push_error(&mut errors, error); + } + } + match errors { + Some(error) => Err(error), + None => Ok(attrs), + } +} + +/// Build the `Display` match arm for one variant. The arm writes into +/// `writer`, a `&mut dyn core::fmt::Write`. +fn display_arm(variant: &syn::Variant, display: &str, writer: &Ident) -> TokenStream2 { + let variant_name = &variant.ident; + // Bindings use mixed-site hygiene so a display string cannot capture + // them by accident and they cannot collide with user items. + let binding = |index: usize| Ident::new(&format!("__ef_field_{index}"), Span::mixed_site()); + + match &variant.fields { + Fields::Named(fields) => { + let idents: Vec<&Ident> = fields + .named + .iter() + .map(|f| f.ident.as_ref().expect("named field")) + .collect(); + let names: Vec> = idents + .iter() + .map(|ident| Some(ident.unraw().to_string())) + .collect(); + match rewrite_format(display, &names) { + Some((format_str, args)) => { + let pattern = args.iter().map(|&i| { + let field = idents[i]; + let bind = binding(i); + quote! { #field: #bind } + }); + let values = args.iter().map(|&i| binding(i)); + quote! { + Self::#variant_name { #(#pattern,)* .. } => + ::core::write!(#writer, #format_str #(, #values)*) + } + } + None => { + let binds: Vec = (0..idents.len()).map(binding).collect(); + quote! { + Self::#variant_name { #(#idents: #binds),* } => + ::core::write!(#writer, #display, #(#idents = #binds),*) + } + } + } + } + Fields::Unnamed(fields) => { + let count = fields.unnamed.len(); + let names: Vec> = vec![None; count]; + match rewrite_format(display, &names) { + Some((format_str, args)) => { + let pattern = (0..count).map(|i| { + if args.contains(&i) { + let bind = binding(i); + quote! { #bind } + } else { + quote! { _ } + } + }); + let values = args.iter().map(|&i| binding(i)); + quote! { + Self::#variant_name(#(#pattern),*) => + ::core::write!(#writer, #format_str #(, #values)*) + } + } + None => { + let binds: Vec = (0..count).map(binding).collect(); + quote! { + Self::#variant_name(#(#binds),*) => + ::core::write!(#writer, #display #(, #binds)*) + } + } + } + } + Fields::Unit => { + let format_str = rewrite_format(display, &[]) + .map(|(format_str, _)| format_str) + .unwrap_or_else(|| display.to_string()); + quote! { + Self::#variant_name => ::core::write!(#writer, #format_str) + } + } + } +} + +// Implement ModError for an enum +fn implement_for_enum(input: &DeriveInput, error_prefix: &str) -> syn::Result { + let name = &input.ident; + let data_enum = match &input.data { + Data::Enum(data) => data, + _ => unreachable!("implement_for_enum is only called for enums"), + }; + + let writer = Ident::new("__ef_writer", Span::mixed_site()); + let formatter = Ident::new("__ef_formatter", Span::mixed_site()); + let write_fn = Ident::new("__ef_write", Span::mixed_site()); + let buffer = Ident::new("__ef_buffer", Span::mixed_site()); + + // Generate match arms for each variant + let mut kind_match_arms = Vec::new(); + let mut caption_match_arms = Vec::new(); + let mut display_match_arms = Vec::new(); + let mut retryable_match_arms = Vec::new(); + let mut fatal_match_arms = Vec::new(); + let mut status_code_match_arms = Vec::new(); + let mut exit_code_match_arms = Vec::new(); + let mut errors = None; + + for variant in &data_enum.variants { + let variant_name = &variant.ident; + let attrs = match variant_attrs(variant, error_prefix) { + Ok(attrs) => attrs, + Err(error) => { + push_error(&mut errors, error); + continue; + } + }; + let VariantAttrs { + display, + kind, + caption, + retryable, + fatal, + status_code, + exit_code, + } = attrs; + + let pattern = match &variant.fields { + Fields::Named(_) => quote! { Self::#variant_name { .. } }, + Fields::Unnamed(_) => quote! { Self::#variant_name(..) }, + Fields::Unit => quote! { Self::#variant_name }, + }; + + kind_match_arms.push(quote! { #pattern => #kind }); + caption_match_arms.push(quote! { #pattern => #caption }); + retryable_match_arms.push(quote! { #pattern => #retryable }); + fatal_match_arms.push(quote! { #pattern => #fatal }); + status_code_match_arms.push(quote! { #pattern => #status_code }); + exit_code_match_arms.push(quote! { #pattern => #exit_code }); + display_match_arms.push(display_arm(variant, &display, &writer)); + } + + if let Some(error) = errors { + return Err(error); + } + + // Generate implementation + Ok(quote! { + impl ::core::fmt::Display for #name { + fn fmt(&self, #formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let #write_fn = |#writer: &mut dyn ::core::fmt::Write| -> ::core::fmt::Result { + match self { + #(#display_match_arms,)* + } + }; + if #formatter.width().is_none() && #formatter.precision().is_none() { + #write_fn(#formatter) + } else { + // Width and precision apply to the whole message, so + // it has to be rendered before it can be padded. + let mut #buffer = ::std::string::String::new(); + #write_fn(&mut #buffer)?; + #formatter.pad(&#buffer) + } + } + } + + impl ::error_forge::error::ForgeError for #name { + fn kind(&self) -> &'static str { + match self { + #(#kind_match_arms,)* + } + } + + fn caption(&self) -> &'static str { + match self { + #(#caption_match_arms,)* + } + } + + fn is_retryable(&self) -> bool { + match self { + #(#retryable_match_arms,)* + } + } + + fn is_fatal(&self) -> bool { + match self { + #(#fatal_match_arms,)* + } + } + + fn status_code(&self) -> u16 { + match self { + #(#status_code_match_arms,)* + } + } + + fn exit_code(&self) -> i32 { + match self { + #(#exit_code_match_arms,)* + } + } + } + + impl ::std::error::Error for #name { + fn source(&self) -> ::core::option::Option<&(dyn ::std::error::Error + 'static)> { + ::core::option::Option::None + } + } + }) +} + +// Implement ModError for a struct +fn implement_for_struct(input: &DeriveInput, error_prefix: &str) -> TokenStream2 { + let name = &input.ident; + let name_str = name.to_string(); + let formatter = Ident::new("__ef_formatter", Span::mixed_site()); + + quote! { + impl ::core::fmt::Display for #name { + fn fmt(&self, #formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + #formatter.pad(::core::concat!(#error_prefix, ": Error")) + } + } + + impl ::error_forge::error::ForgeError for #name { + fn kind(&self) -> &'static str { + #name_str + } + + fn caption(&self) -> &'static str { + ::core::concat!(#error_prefix, ": Error") + } + } + + impl ::std::error::Error for #name { + fn source(&self) -> ::core::option::Option<&(dyn ::std::error::Error + 'static)> { + ::core::option::Option::None + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{expand, rewrite_format}; + + fn named(list: &[&str]) -> Vec> { + list.iter().map(|s| Some(s.to_string())).collect() + } + + fn tuple(count: usize) -> Vec> { + vec![None; count] + } + + fn expand_str(input: syn::DeriveInput) -> String { + expand(&input).to_string() + } + + #[test] + fn union_produces_compile_error_instead_of_panicking() { + let output = expand_str(syn::parse_quote! { + union Bits { a: u32, b: f32 } + }); + assert!(output.contains("compile_error"), "{output}"); + assert!(output.contains("cannot be derived for unions"), "{output}"); + } + + #[test] + fn rewrite_maps_positional_placeholders_to_arguments() { + assert_eq!( + rewrite_format("{} then {} and {0:?}", &tuple(3)), + Some(("{0} then {1} and {0:?}".to_string(), vec![0, 1])) + ); + assert_eq!( + rewrite_format("only {2}", &tuple(3)), + Some(("only {0}".to_string(), vec![2])) + ); + assert_eq!( + rewrite_format("NoFields", &tuple(3)), + Some(("NoFields".to_string(), Vec::new())) + ); + } + + #[test] + fn rewrite_keeps_escapes_and_non_field_names() { + let fields = named(&["host", "port"]); + assert_eq!( + rewrite_format("{{literal}} {port:>5} {CONST}", &fields), + Some(("{{literal}} {0:>5} {CONST}".to_string(), vec![1])) + ); + // Positional references in a struct-like variant map to the + // fields in declaration order, as format! did before. + assert_eq!( + rewrite_format("{}:{}", &fields), + Some(("{0}:{1}".to_string(), vec![0, 1])) + ); + } + + #[test] + fn rewrite_maps_raw_identifiers_and_count_arguments() { + let fields = named(&["type", "width"]); + assert_eq!( + rewrite_format("{type} {r#type} {type:>width$} {:.1$}", &fields), + Some(("{0} {0} {0:>1$} {0:.1$}".to_string(), vec![0, 1])) + ); + assert_eq!( + rewrite_format("{type:>W$}", &fields), + Some(("{0:>W$}".to_string(), vec![0])) + ); + } + + #[test] + fn rewrite_declines_unsupported_strings() { + let fields = named(&["a", "b"]); + assert_eq!(rewrite_format("{:.*}", &fields), None); + assert_eq!(rewrite_format("{5}", &fields), None); + assert_eq!(rewrite_format("{} {} {}", &fields), None); + assert_eq!(rewrite_format("unclosed {", &fields), None); + assert_eq!(rewrite_format("stray }", &fields), None); + } + + #[test] + fn malformed_attribute_values_are_compile_errors() { + let cases: [(syn::DeriveInput, &str); 6] = [ + ( + syn::parse_quote! { enum E { #[error_http_status("404")] A } }, + "`error_http_status` expects an integer literal", + ), + ( + syn::parse_quote! { enum E { #[error_exit_code(99999999999)] A } }, + "`error_exit_code` expects an integer in i32", + ), + ( + syn::parse_quote! { enum E { #[error_http_status(-1)] A } }, + "`error_http_status` expects an integer in u16", + ), + ( + syn::parse_quote! { enum E { #[error_retryable("yes")] A } }, + "`error_retryable` expects `true` or `false`", + ), + ( + syn::parse_quote! { enum E { #[error_display(42)] A } }, + "`error_display` expects a string literal", + ), + ( + syn::parse_quote! { #[error_prefix] enum E { A } }, + "`error_prefix` expects a string literal", + ), + ]; + for (input, message) in cases { + let output = expand_str(input); + assert!(output.contains("compile_error"), "{output}"); + assert!(output.contains(message), "{message} not in {output}"); + } + } + + #[test] + fn explicit_flag_values_are_honoured() { + let output = expand_str(syn::parse_quote! { + enum E { + #[error_retryable(false)] + #[error_fatal = false] + A, + #[error_retryable = true] + #[error_fatal(true)] + B, + #[error_exit_code(-2)] + C, + } + }); + assert!(!output.contains("compile_error"), "{output}"); + let retryable = output + .split("fn is_retryable") + .nth(1) + .and_then(|s| s.split("fn is_fatal").next()) + .unwrap(); + assert!(retryable.contains("Self :: A => false"), "{retryable}"); + assert!(retryable.contains("Self :: B => true"), "{retryable}"); + let exit = output.split("fn exit_code").nth(1).unwrap(); + assert!( + exit.contains("Self :: C => - 2i32") || exit.contains("Self :: C => -2i32"), + "{exit}" + ); + } +} diff --git a/error-forge-derive/src/paste.rs b/error-forge-derive/src/paste.rs new file mode 100644 index 0000000..54e3fab --- /dev/null +++ b/error-forge-derive/src/paste.rs @@ -0,0 +1,132 @@ +//! Identifier pasting for `define_errors!`, on `proc_macro` alone. +//! +//! Replaces the third-party `pastey` crate for the one thing +//! `error-forge` needs from it: building constructor names such as +//! `fn [<$variant:lower>]`. For that subset it behaves like `pastey`: a +//! leading `r#` is dropped from each segment, case conversion uses +//! `str::to_lowercase` / `str::to_uppercase`, and the new identifier +//! takes the span of the `[< ... >]` bracket. + +use proc_macro::{Delimiter, Group, Ident, Punct, Spacing, Span, TokenStream, TokenTree}; + +type Result = std::result::Result; + +pub(crate) fn expand(input: TokenStream) -> TokenStream { + match expand_stream(input) { + Ok((output, _)) => output, + Err((span, message)) => compile_error(span, message), + } +} + +/// Replace every `[< ... >]` in `input`, descending into groups. +/// Returns the new stream and whether anything was replaced; a group +/// whose contents are unchanged is passed through as it came in. +fn expand_stream(input: TokenStream) -> Result<(TokenStream, bool)> { + let mut output = Vec::new(); + let mut changed = false; + for token in input { + match token { + TokenTree::Group(group) if is_paste(&group) => { + output.push(TokenTree::Ident(paste(&group)?)); + changed = true; + } + TokenTree::Group(group) => { + let (stream, nested_changed) = expand_stream(group.stream())?; + if nested_changed { + let mut rebuilt = Group::new(group.delimiter(), stream); + rebuilt.set_span(group.span()); + output.push(TokenTree::Group(rebuilt)); + changed = true; + } else { + output.push(TokenTree::Group(group)); + } + } + other => output.push(other), + } + } + Ok((output.into_iter().collect(), changed)) +} + +/// A `[...]` group whose contents start with `<` and end with `>`. +fn is_paste(group: &Group) -> bool { + if group.delimiter() != Delimiter::Bracket { + return false; + } + let tokens: Vec = group.stream().into_iter().collect(); + matches!( + (tokens.first(), tokens.last()), + (Some(TokenTree::Punct(open)), Some(TokenTree::Punct(close))) + if tokens.len() >= 2 && open.as_char() == '<' && close.as_char() == '>' + ) +} + +/// Build the identifier for one `[< ... >]` group. +fn paste(group: &Group) -> Result { + let tokens: Vec = group.stream().into_iter().collect(); + let inner = &tokens[1..tokens.len() - 1]; + let mut name = String::new(); + let mut i = 0; + while i < inner.len() { + let mut segment = match &inner[i] { + TokenTree::Ident(ident) => { + let text = ident.to_string(); + match text.strip_prefix("r#") { + Some(stripped) => stripped.to_owned(), + None => text, + } + } + other => return Err((other.span(), "expected an identifier in `[< ... >]`")), + }; + i += 1; + if let Some(TokenTree::Punct(colon)) = inner.get(i) { + if colon.as_char() != ':' || colon.spacing() != Spacing::Alone { + return Err((colon.span(), "expected `:lower` or `:upper`")); + } + segment = match inner.get(i + 1) { + Some(TokenTree::Ident(modifier)) if modifier.to_string() == "lower" => { + segment.to_lowercase() + } + Some(TokenTree::Ident(modifier)) if modifier.to_string() == "upper" => { + segment.to_uppercase() + } + Some(other) => return Err((other.span(), "expected `lower` or `upper`")), + None => return Err((colon.span(), "expected `lower` or `upper` after `:`")), + }; + i += 2; + } + name.push_str(&segment); + } + if name.is_empty() { + return Err((group.span(), "expected an identifier in `[< ... >]`")); + } + Ok(Ident::new(&name, group.span())) +} + +/// `::core::compile_error! { "message" }` pointing at `span`. The path +/// is absolute so it resolves under `#![no_implicit_prelude]`. +fn compile_error(span: Span, message: &str) -> TokenStream { + let punct = |ch, spacing| { + let mut punct = Punct::new(ch, spacing); + punct.set_span(span); + TokenTree::Punct(punct) + }; + let mut literal = proc_macro::Literal::string(message); + literal.set_span(span); + let mut body = Group::new( + Delimiter::Brace, + TokenStream::from(TokenTree::Literal(literal)), + ); + body.set_span(span); + [ + punct(':', Spacing::Joint), + punct(':', Spacing::Alone), + TokenTree::Ident(Ident::new("core", span)), + punct(':', Spacing::Joint), + punct(':', Spacing::Alone), + TokenTree::Ident(Ident::new("compile_error", span)), + punct('!', Spacing::Alone), + TokenTree::Group(body), + ] + .into_iter() + .collect() +} diff --git a/src/lib.rs b/src/lib.rs index 2c8edce..862efb1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -97,7 +97,7 @@ pub use crate::macros::{ // Optional re-export of the proc macro #[cfg(feature = "derive")] -pub use error_forge_derive::*; +pub use error_forge_derive::ModError; /// Internal re-exports for use by macros expanded in user crates. /// @@ -109,7 +109,7 @@ pub use error_forge_derive::*; /// `Cargo.toml`. #[doc(hidden)] pub mod __private { - pub use pastey; + pub use error_forge_derive::__paste as paste; } // Extension methods are implemented in error.rs diff --git a/src/macros.rs b/src/macros.rs index b48e279..1ecdb11 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -342,7 +342,7 @@ macro_rules! define_errors { impl $name { $( - $crate::__private::pastey::paste! { + $crate::__private::paste! { pub fn [<$variant:lower>]($($($field : $ftype),*)?) -> Self { let instance = Self::$variant $( { $($field),* } )?; $crate::macros::call_error_hook( diff --git a/src/recovery/circuit_breaker.rs b/src/recovery/circuit_breaker.rs index da5fcc3..bc6ae28 100644 --- a/src/recovery/circuit_breaker.rs +++ b/src/recovery/circuit_breaker.rs @@ -1,6 +1,5 @@ use crate::recovery::RecoveryResult; -use parking_lot::Mutex; -use std::sync::Arc; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::{Duration, Instant}; /// Represents the current state of a circuit breaker. @@ -90,6 +89,17 @@ impl CircuitBreakerConfig { } } +/// Lock the breaker state, ignoring poisoning. +/// +/// The protected closure never runs while the lock is held; the lock +/// only covers short updates of the breaker's own fields, each of which +/// leaves the state valid. Taking a poisoned lock anyway keeps every +/// method panic-free, as the non-poisoning `parking_lot` lock used up to +/// 1.0.2 did. +fn lock(inner: &Mutex) -> MutexGuard<'_, CircuitBreakerInner> { + inner.lock().unwrap_or_else(PoisonError::into_inner) +} + struct CircuitBreakerInner { config: CircuitBreakerConfig, state: CircuitState, @@ -158,7 +168,7 @@ struct ProbeGuard<'a> { impl Drop for ProbeGuard<'_> { fn drop(&mut self) { if self.armed { - let mut inner = self.inner.lock(); + let mut inner = lock(self.inner); if inner.is_current_probe(self.admission) { inner.probe_in_flight = false; inner.transition(CircuitState::Open, Instant::now()); @@ -206,7 +216,7 @@ impl CircuitBreaker { /// call is admitted as the probe. Reading the state does not /// change it. pub fn state(&self) -> CircuitState { - let inner = self.inner.lock(); + let inner = lock(&self.inner); inner.effective_state(Instant::now()) } @@ -252,7 +262,7 @@ impl CircuitBreaker { { // First check if we can proceed with the call let admission = { - let mut inner = self.inner.lock(); + let mut inner = lock(&self.inner); self.update_state(&mut inner); let is_probe = match inner.state { CircuitState::Closed => false, @@ -295,7 +305,7 @@ impl CircuitBreaker { /// Manually reset the circuit breaker to closed state pub fn reset(&self) { - let mut inner = self.inner.lock(); + let mut inner = lock(&self.inner); inner.transition(CircuitState::Closed, Instant::now()); inner.failures.clear(); inner.probe_in_flight = false; @@ -303,7 +313,12 @@ impl CircuitBreaker { /// Called when an operation succeeds fn on_success(&self, admission: Admission) { - let mut inner = self.inner.lock(); + // Only the half-open probe changes state on success, so a normal + // call returns without taking the lock a second time. + if !admission.is_probe { + return; + } + let mut inner = lock(&self.inner); if inner.is_current_probe(admission) { // Successful test request, close the circuit inner.probe_in_flight = false; @@ -314,7 +329,7 @@ impl CircuitBreaker { /// Called when an operation fails fn on_failure(&self, admission: Admission) { - let mut inner = self.inner.lock(); + let mut inner = lock(&self.inner); if admission.is_probe { if inner.is_current_probe(admission) { diff --git a/tests/paste_test.rs b/tests/paste_test.rs new file mode 100644 index 0000000..9f97b56 --- /dev/null +++ b/tests/paste_test.rs @@ -0,0 +1,74 @@ +//! The in-house identifier pasting behind `define_errors!` (which +//! replaced the `pastey` crate in 1.1.0) names constructors exactly as +//! `pastey` did: the variant name lowercased with `str::to_lowercase`, +//! a leading `r#` dropped. +#![allow( + dead_code, + non_camel_case_types, + non_snake_case, + non_upper_case_globals +)] + +use error_forge::define_errors; + +define_errors! { + pub enum NamingError { + #[kind(Http)] + HTTPError, + + #[kind(Snake)] + Under_Score { code: u16 }, + + #[kind(Raw)] + r#Pending, + + #[kind(Unicode)] + Ünicode, + + #[kind(Digits)] + Retry2Later { attempts: u32 }, + } +} + +#[test] +fn constructors_are_lowercased_variant_names() { + assert_eq!(NamingError::httperror().kind(), "Http"); + assert_eq!(NamingError::under_score(7).kind(), "Snake"); + assert_eq!(NamingError::pending().kind(), "Raw"); + assert_eq!(NamingError::ünicode().kind(), "Unicode"); + assert_eq!(NamingError::retry2later(3).kind(), "Digits"); +} + +// The macro itself is internal (`error_forge::__private::paste!`); these +// cases pin the behaviour `define_errors!` relies on. +error_forge::__private::paste! { + const []: u8 = 1; + const []: u8 = 2; + const []: u8 = 3; + mod nested { + pub fn []() -> [u8; 2] { + [1, 2] + } + } +} + +macro_rules! via_fragment { + ($name:ident, $ty:ty) => { + error_forge::__private::paste! { + fn [<$name:lower>]() -> $ty { + ::core::default::Default::default() + } + } + }; +} + +via_fragment!(FromFragment, Vec<[u8; 4]>); + +#[test] +fn paste_handles_case_modifiers_segments_and_nesting() { + assert_eq!(UPPER, 1); + assert_eq!(mixed_SEG, 2); + assert_eq!(raw, 3); + assert_eq!(nested::inner(), [1, 2]); + assert!(fromfragment().is_empty()); +} diff --git a/tests/ui/fail/paste_bad_modifier.rs b/tests/ui/fail/paste_bad_modifier.rs new file mode 100644 index 0000000..e9540a9 --- /dev/null +++ b/tests/ui/fail/paste_bad_modifier.rs @@ -0,0 +1,19 @@ +// The internal identifier-pasting macro reports malformed brackets as +// compile errors at the offending token instead of panicking. +error_forge::__private::paste! { + fn []() {} +} + +error_forge::__private::paste! { + fn []() {} +} + +error_forge::__private::paste! { + fn [<"text">]() {} +} + +error_forge::__private::paste! { + fn []() {} +} + +fn main() {} diff --git a/tests/ui/fail/paste_bad_modifier.stderr b/tests/ui/fail/paste_bad_modifier.stderr new file mode 100644 index 0000000..1ad309e --- /dev/null +++ b/tests/ui/fail/paste_bad_modifier.stderr @@ -0,0 +1,23 @@ +error: expected `lower` or `upper` + --> tests/ui/fail/paste_bad_modifier.rs:4:15 + | +4 | fn []() {} + | ^^^^^ + +error: expected `:lower` or `:upper` + --> tests/ui/fail/paste_bad_modifier.rs:8:14 + | +8 | fn []() {} + | ^ + +error: expected an identifier in `[< ... >]` + --> tests/ui/fail/paste_bad_modifier.rs:12:10 + | +12 | fn [<"text">]() {} + | ^^^^^^ + +error: expected `:lower` or `:upper` + --> tests/ui/fail/paste_bad_modifier.rs:16:14 + | +16 | fn []() {} + | ^ diff --git a/tests/ui_test.rs b/tests/ui_test.rs index bcf2c63..0422397 100644 --- a/tests/ui_test.rs +++ b/tests/ui_test.rs @@ -11,6 +11,7 @@ fn trybuild_ui_tests() { let cases = trybuild::TestCases::new(); cases.pass("tests/ui/pass/define_errors_*.rs"); cases.compile_fail("tests/ui/fail/define_errors_*.rs"); + cases.compile_fail("tests/ui/fail/paste_*.rs"); if cfg!(feature = "derive") { cases.pass("tests/ui/pass/derive_*.rs"); cases.compile_fail("tests/ui/fail/derive_*.rs");