From 06c6b4ae56a8c205a14f7ef7a3249721796c542a Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Sat, 26 Sep 2026 15:39:13 +0000 Subject: [PATCH] Record the caller's location in eyre reports, and test README examples in place `CanRaiseError::raise_error` and `CanWrapError::wrap_error` are now `#[track_caller]`. `#[cgp_component]` copies the attribute onto the provider trait's declaration, and Rust applies it to every impl, including the generated forwarding impls, so an error library that records `Location::caller()` sees the line that called `raise_error`. `cgp-error-eyre` turns eyre's `track-caller` feature back on, and a new `eyre_location` test checks the location through plain, `open`, and namespace-path wiring, for each eyre raiser and for `RaiseFrom`. A `cgp-tests` build script now turns the example in each error backend's README into the module the `readme_*.rs` tests include, so the README is the only copy of its example. Also update AGENTS.md's toolchain note to the pinned 1.98.1. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- .../cgp-error/src/traits/can_raise_error.rs | 3 + .../cgp-error/src/traits/can_wrap_error.rs | 7 ++ .../error/cgp-error-eyre/Cargo.toml | 8 +- .../standalone/error/cgp-error-eyre/README.md | 6 +- .../src/impls/raise_eyre_error.rs | 3 +- crates/tests/README.md | 4 +- crates/tests/cgp-tests/build.rs | 64 +++++++++++ .../tests/error_backends/eyre_location.rs | 106 ++++++++++++++++++ .../error_backends/eyre_raise_and_wrap.rs | 8 +- .../cgp-tests/tests/error_backends/mod.rs | 1 + .../tests/error_backends/readme_anyhow.rs | 30 +---- .../tests/error_backends/readme_eyre.rs | 30 +---- .../tests/error_backends/readme_std.rs | 31 +---- 14 files changed, 209 insertions(+), 94 deletions(-) create mode 100644 crates/tests/cgp-tests/build.rs create mode 100644 crates/tests/cgp-tests/tests/error_backends/eyre_location.rs diff --git a/AGENTS.md b/AGENTS.md index 6eca62dc..dfdc6821 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,7 @@ prelude re-exports in [crates/main/cgp-core/src/prelude.rs](crates/main/cgp-core ## Commands -This is a Cargo workspace (edition 2024, resolver 3). Toolchain is pinned to **1.96** via +This is a Cargo workspace (edition 2024, resolver 3). Toolchain is pinned to **1.98.1** via [rust-toolchain.toml](rust-toolchain.toml). Nearly every crate is `#![no_std]` — keep new code `no_std`-compatible (use `core`/`alloc`, gate `std`/`alloc` usage behind features as existing crates do). diff --git a/crates/core/cgp-error/src/traits/can_raise_error.rs b/crates/core/cgp-error/src/traits/can_raise_error.rs index 1b75b20a..4e53be24 100644 --- a/crates/core/cgp-error/src/traits/can_raise_error.rs +++ b/crates/core/cgp-error/src/traits/can_raise_error.rs @@ -12,5 +12,8 @@ use crate::traits::has_error_type::HasErrorType; #[derive_delegate(UseDelegate)] #[use_type(HasErrorType.Error)] pub trait CanRaiseError { + /// `#[track_caller]` here applies to every provider impl and to the generated forwarding + /// impls, so an error library that records `Location::caller()` sees the caller's line. + #[track_caller] fn raise_error(error: SourceError) -> Error; } diff --git a/crates/core/cgp-error/src/traits/can_wrap_error.rs b/crates/core/cgp-error/src/traits/can_wrap_error.rs index 21a3e157..cdbb446d 100644 --- a/crates/core/cgp-error/src/traits/can_wrap_error.rs +++ b/crates/core/cgp-error/src/traits/can_wrap_error.rs @@ -3,10 +3,17 @@ use cgp_macro::cgp_component; use crate::traits::HasErrorType; +/** + The `CanWrapError` trait is used to attach a detail to an abstract error + provided by [`HasErrorType`]. +*/ #[cgp_component(ErrorWrapper)] #[prefix(@cgp.core.error in DefaultNamespace)] #[derive_delegate(UseDelegate)] #[use_type(HasErrorType.Error)] pub trait CanWrapError { + /// `#[track_caller]` here applies to every provider impl and to the generated forwarding + /// impls, so an error library that records `Location::caller()` sees the caller's line. + #[track_caller] fn wrap_error(error: Error, detail: Detail) -> Error; } diff --git a/crates/standalone/error/cgp-error-eyre/Cargo.toml b/crates/standalone/error/cgp-error-eyre/Cargo.toml index 39700636..b6b0614c 100644 --- a/crates/standalone/error/cgp-error-eyre/Cargo.toml +++ b/crates/standalone/error/cgp-error-eyre/Cargo.toml @@ -17,7 +17,7 @@ cgp = { version = "0.8.0-alpha", path = "../../../main/cgp-core", packag # `auto-install` installs eyre's default report handler on first use. Without it, building any # `eyre::Report` panics unless the application has called `eyre::set_hook` first. # -# `track-caller` is left off: every report is built inside one of this crate's providers, behind -# CGP's generated forwarding impls, so the location it records is always a line in this crate -# rather than the caller's. -eyre = { version = "0.6.14", default-features = false, features = [ "auto-install" ] } +# `track-caller` records where a report was built. `CanRaiseError::raise_error` is +# `#[track_caller]`, and so is every impl of it, which is what lets that location be the caller's +# line rather than a line in this crate. +eyre = { version = "0.6.14", default-features = false, features = [ "auto-install", "track-caller" ] } diff --git a/crates/standalone/error/cgp-error-eyre/README.md b/crates/standalone/error/cgp-error-eyre/README.md index 5cc8a2b0..8a1532c5 100644 --- a/crates/standalone/error/cgp-error-eyre/README.md +++ b/crates/standalone/error/cgp-error-eyre/README.md @@ -43,8 +43,8 @@ A `String` needs `DisplayEyreError` or `DebugEyreError`, because it is not a sta The crate enables eyre's `auto-install` feature, so eyre's default report handler is installed the first time a report is built. To use another handler, such as `color-eyre`, install it with `eyre::set_hook` before the first error is raised; once a report exists, `set_hook` returns an -error. The crate leaves eyre's `track-caller` feature off, because every report is built inside one -of its providers and the recorded location would name that line rather than the caller. eyre -requires `std`, so this crate does too. +error. The crate also enables eyre's `track-caller` feature, and CGP's `raise_error` is +`#[track_caller]`, so a report's `Location:` names the line that called `raise_error`. eyre requires +`std`, so this crate does too. The crate re-exports `eyre::Error`, eyre's alias for `Report`, as `cgp_error_eyre::Error`. diff --git a/crates/standalone/error/cgp-error-eyre/src/impls/raise_eyre_error.rs b/crates/standalone/error/cgp-error-eyre/src/impls/raise_eyre_error.rs index f62032f0..e6bc028c 100644 --- a/crates/standalone/error/cgp-error-eyre/src/impls/raise_eyre_error.rs +++ b/crates/standalone/error/cgp-error-eyre/src/impls/raise_eyre_error.rs @@ -5,7 +5,8 @@ use cgp::error::{ErrorRaiser, ErrorRaiserComponent, ErrorWrapper, ErrorWrapperCo use cgp::prelude::*; /// Raises a standard error into [`eyre::Report`] without formatting it, so the source stays -/// available to `downcast_ref` and to the error chain, and wraps a detail with `wrap_err`. +/// available to `downcast_ref` and to the error chain, and wraps a detail with `wrap_err`. The +/// report records the location that called `raise_error`. pub struct RaiseEyreError; #[cgp_impl(RaiseEyreError)] diff --git a/crates/tests/README.md b/crates/tests/README.md index 7ea9eb0a..0c10cef1 100644 --- a/crates/tests/README.md +++ b/crates/tests/README.md @@ -41,7 +41,9 @@ The concept targets currently cover: basic delegation, impl-side dependencies, implicit arguments, higher-order providers, generic components, abstract types, getters, field access, extensible records, extensible variants, checking, dispatching, namespaces, handlers, monadic handlers, async and Send bounds, -blanket traits, and the standalone error backends (`error_backends`). The set grows and subdivides over time. `cgp-macro-tests` follows +blanket traits, and the standalone error backends (`error_backends`). The crate's build script +turns the example in each error backend's README into a test module, which the `readme_*.rs` files +in `error_backends` include, so those examples are tested without being copied. The set grows and subdivides over time. `cgp-macro-tests` follows the same shape, with `ident_with_type_params` for parser corner cases and the failure-case targets `parser_rejections` and `invalid_expansion`. diff --git a/crates/tests/cgp-tests/build.rs b/crates/tests/cgp-tests/build.rs new file mode 100644 index 00000000..9a0a5507 --- /dev/null +++ b/crates/tests/cgp-tests/build.rs @@ -0,0 +1,64 @@ +//! Turns the wiring example in each error backend's README into a test module, so the +//! `error_backends` target compiles and runs the README's own code instead of a copy of it. +//! +//! Each README carries one `rust,ignore` block: `use` lines and items, then top-level statements +//! after the last item's closing brace. The block is split there, and the statements become the +//! body of a `#[test]` function, written to `$OUT_DIR/readme_.rs`. + +use std::path::Path; +use std::{env, fs}; + +const BACKENDS: [&str; 3] = ["anyhow", "eyre", "std"]; + +fn main() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let out_dir = env::var("OUT_DIR").unwrap(); + + for backend in BACKENDS { + let readme = Path::new(&manifest_dir) + .join("../../standalone/error") + .join(format!("cgp-error-{backend}")) + .join("README.md"); + println!("cargo::rerun-if-changed={}", readme.display()); + + let text = fs::read_to_string(&readme) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", readme.display())); + let module = + readme_test(backend, &text).unwrap_or_else(|e| panic!("{}: {e}", readme.display())); + + fs::write( + Path::new(&out_dir).join(format!("readme_{backend}.rs")), + module, + ) + .unwrap(); + } +} + +fn readme_test(backend: &str, readme: &str) -> Result { + let block = readme + .split("```rust,ignore\n") + .nth(1) + .and_then(|rest| rest.split("\n```").next()) + .ok_or("no `rust,ignore` block")?; + + let lines: Vec<&str> = block.lines().collect(); + let split = lines + .iter() + .rposition(|line| line.starts_with('}')) + .ok_or("the block has no top-level item")?; + + let items = lines[..=split].join("\n"); + let statements: Vec = lines[split + 1..] + .iter() + .filter(|line| !line.trim().is_empty()) + .map(|line| format!(" {line}")) + .collect(); + if statements.is_empty() { + return Err("the block has no statements after its items"); + } + + Ok(format!( + "{items}\n\n#[test]\nfn test_readme_{backend}() {{\n{}\n}}\n", + statements.join("\n") + )) +} diff --git a/crates/tests/cgp-tests/tests/error_backends/eyre_location.rs b/crates/tests/cgp-tests/tests/error_backends/eyre_location.rs new file mode 100644 index 00000000..9143a2d2 --- /dev/null +++ b/crates/tests/cgp-tests/tests/error_backends/eyre_location.rs @@ -0,0 +1,106 @@ +//! With eyre's `track-caller` feature on, a report records where it was built. `raise_error` and +//! `wrap_error` are `#[track_caller]` in their trait declarations, which carries through every +//! provider impl and CGP's generated forwarding impls, so the recorded location is the line that +//! called `raise_error`: through plain, `open`, and namespace-path wiring alike, for each eyre +//! provider and for the generic `RaiseFrom`, and unchanged by wrapping. +//! +//! See cgp-knowledge-base/projects/error/cgp-error-eyre/testing.md. + +use std::io; + +use cgp::core::error::{ErrorRaiserComponent, ErrorTypeProviderComponent, ErrorWrapperComponent}; +use cgp::extra::error::RaiseFrom; +use cgp::prelude::*; +use cgp_error_eyre::{DebugEyreError, DisplayEyreError, Error, RaiseEyreError, UseEyreError}; + +#[derive(Debug)] +pub struct Rejected; + +pub struct PlainApp; + +delegate_components! { + PlainApp { + ErrorTypeProviderComponent: UseEyreError, + [ErrorRaiserComponent, ErrorWrapperComponent]: RaiseEyreError, + } +} + +pub struct OpenApp; + +delegate_components! { + OpenApp { + open ErrorRaiserComponent; + + ErrorTypeProviderComponent: UseEyreError, + @ErrorRaiserComponent.io::Error: RaiseEyreError, + @ErrorRaiserComponent.String: DisplayEyreError, + @ErrorRaiserComponent.Rejected: DebugEyreError, + } +} + +pub struct NamespaceApp; + +delegate_components! { + NamespaceApp { + namespace DefaultNamespace; + + @cgp.core.error.ErrorTypeProviderComponent: UseEyreError, + @cgp.core.error.ErrorRaiserComponent.io::Error: RaiseEyreError, + } +} + +pub struct GenericApp; + +delegate_components! { + GenericApp { + ErrorTypeProviderComponent: UseType, + ErrorRaiserComponent: RaiseFrom, + } +} + +/// The `file:line` the default handler prints under `Location:`, without the column. +fn location(error: &Error) -> String { + let debug = format!("{error:?}"); + let located = debug + .split("Location:\n") + .nth(1) + .unwrap_or_else(|| panic!("no location in report: {debug}")); + let location = located.lines().next().unwrap().trim(); + location.rsplit_once(':').unwrap().0.to_owned() +} + +fn here(line: u32) -> String { + format!("{}:{line}", file!()) +} + +#[test] +fn test_eyre_location() { + let line = line!() + 1; + let error = PlainApp::raise_error(io::Error::other("disk full")); + assert_eq!(location(&error), here(line)); + + // Wrapping keeps the location where the report was first built. + let error = PlainApp::wrap_error(error, "while saving"); + assert_eq!(location(&error), here(line)); + + let line = line!() + 1; + let error = OpenApp::raise_error(io::Error::other("disk full")); + assert_eq!(location(&error), here(line)); + + let line = line!() + 1; + let error = OpenApp::raise_error(String::from("bad input")); + assert_eq!(location(&error), here(line)); + + let line = line!() + 1; + let error = OpenApp::raise_error(Rejected); + assert_eq!(location(&error), here(line)); + + let line = line!() + 1; + let error = NamespaceApp::raise_error(io::Error::other("disk full")); + assert_eq!(location(&error), here(line)); + + // The generic `RaiseFrom` converts with `Into::into`, which is `#[track_caller]` too. + let line = line!() + 1; + let error = GenericApp::raise_error(io::Error::other("disk full")); + assert_eq!(location(&error), here(line)); +} diff --git a/crates/tests/cgp-tests/tests/error_backends/eyre_raise_and_wrap.rs b/crates/tests/cgp-tests/tests/error_backends/eyre_raise_and_wrap.rs index 1ca3e0f8..78595f55 100644 --- a/crates/tests/cgp-tests/tests/error_backends/eyre_raise_and_wrap.rs +++ b/crates/tests/cgp-tests/tests/error_backends/eyre_raise_and_wrap.rs @@ -46,14 +46,12 @@ fn test_eyre_raise_and_wrap() { ); assert!(error.downcast_ref::().is_some()); - // The default handler prints the chain, then a backtrace section when `RUST_BACKTRACE` or - // `RUST_LIB_BACKTRACE` is set, so only the chain is matched exactly. The crate leaves eyre's - // `track-caller` feature off, since the recorded location would be a line inside the backend, - // so no `Location:` section appears. + // The default handler prints the chain, then a `Location:` section, then a backtrace section + // when `RUST_BACKTRACE` or `RUST_LIB_BACKTRACE` is set, so only the chain is matched exactly. + // `eyre_location.rs` checks the location itself. let debug = format!("{error:?}"); assert!( debug.starts_with("while starting\n\nCaused by:\n 0: while loading\n 1: no file"), "unexpected report: {debug}" ); - assert!(!debug.contains("Location:"), "unexpected report: {debug}"); } diff --git a/crates/tests/cgp-tests/tests/error_backends/mod.rs b/crates/tests/cgp-tests/tests/error_backends/mod.rs index 11d4cf7e..de5e4319 100644 --- a/crates/tests/cgp-tests/tests/error_backends/mod.rs +++ b/crates/tests/cgp-tests/tests/error_backends/mod.rs @@ -5,6 +5,7 @@ pub mod anyhow_formatting; pub mod anyhow_raise_and_wrap; pub mod eyre_formatting; +pub mod eyre_location; pub mod eyre_raise_and_wrap; pub mod generic_equivalents; pub mod namespace_wiring; diff --git a/crates/tests/cgp-tests/tests/error_backends/readme_anyhow.rs b/crates/tests/cgp-tests/tests/error_backends/readme_anyhow.rs index 2a3d9034..def8b4a3 100644 --- a/crates/tests/cgp-tests/tests/error_backends/readme_anyhow.rs +++ b/crates/tests/cgp-tests/tests/error_backends/readme_anyhow.rs @@ -1,29 +1,7 @@ -//! The wiring example in `cgp-error-anyhow`'s README, copied verbatim so that CI compiles and runs it. -//! The README marks the block `ignore` because in the crate's own doctests `cgp` names `cgp-core`; -//! keep the two in sync when either changes. +//! The wiring example in `cgp-error-anyhow`'s README, compiled and run as a test. The README marks +//! the block `ignore` because in the crate's own doctests `cgp` names `cgp-core`; the build +//! script turns the block into this module, so the README stays the only copy. //! //! See cgp-knowledge-base/projects/error/cgp-error-anyhow/testing.md. -use cgp::core::error::{ErrorRaiserComponent, ErrorTypeProviderComponent, ErrorWrapperComponent}; -use cgp::prelude::*; -use cgp_error_anyhow::{DisplayAnyhowError, RaiseAnyhowError, UseAnyhowError}; - -pub struct App; - -delegate_components! { - App { - open ErrorRaiserComponent; - - ErrorTypeProviderComponent: UseAnyhowError, - @ErrorRaiserComponent.std::io::Error: RaiseAnyhowError, - @ErrorRaiserComponent.String: DisplayAnyhowError, - ErrorWrapperComponent: RaiseAnyhowError, - } -} - -#[test] -fn test_readme_anyhow() { - let error = App::raise_error(std::io::Error::other("disk full")); - let error = App::wrap_error(error, "while saving"); - assert_eq!(format!("{error:#}"), "while saving: disk full"); -} +include!(concat!(env!("OUT_DIR"), "/readme_anyhow.rs")); diff --git a/crates/tests/cgp-tests/tests/error_backends/readme_eyre.rs b/crates/tests/cgp-tests/tests/error_backends/readme_eyre.rs index a410724c..418f9d0f 100644 --- a/crates/tests/cgp-tests/tests/error_backends/readme_eyre.rs +++ b/crates/tests/cgp-tests/tests/error_backends/readme_eyre.rs @@ -1,29 +1,7 @@ -//! The wiring example in `cgp-error-eyre`'s README, copied verbatim so that CI compiles and runs it. -//! The README marks the block `ignore` because in the crate's own doctests `cgp` names `cgp-core`; -//! keep the two in sync when either changes. +//! The wiring example in `cgp-error-eyre`'s README, compiled and run as a test. The README marks +//! the block `ignore` because in the crate's own doctests `cgp` names `cgp-core`; the build +//! script turns the block into this module, so the README stays the only copy. //! //! See cgp-knowledge-base/projects/error/cgp-error-eyre/testing.md. -use cgp::core::error::{ErrorRaiserComponent, ErrorTypeProviderComponent, ErrorWrapperComponent}; -use cgp::prelude::*; -use cgp_error_eyre::{DisplayEyreError, RaiseEyreError, UseEyreError}; - -pub struct App; - -delegate_components! { - App { - open ErrorRaiserComponent; - - ErrorTypeProviderComponent: UseEyreError, - @ErrorRaiserComponent.std::io::Error: RaiseEyreError, - @ErrorRaiserComponent.String: DisplayEyreError, - ErrorWrapperComponent: RaiseEyreError, - } -} - -#[test] -fn test_readme_eyre() { - let error = App::raise_error(std::io::Error::other("disk full")); - let error = App::wrap_error(error, "while saving"); - assert_eq!(format!("{error:#}"), "while saving: disk full"); -} +include!(concat!(env!("OUT_DIR"), "/readme_eyre.rs")); diff --git a/crates/tests/cgp-tests/tests/error_backends/readme_std.rs b/crates/tests/cgp-tests/tests/error_backends/readme_std.rs index b95de4d4..43760757 100644 --- a/crates/tests/cgp-tests/tests/error_backends/readme_std.rs +++ b/crates/tests/cgp-tests/tests/error_backends/readme_std.rs @@ -1,30 +1,7 @@ -//! The wiring example in `cgp-error-std`'s README, copied verbatim so that CI compiles and runs it. -//! The README marks the block `ignore` because in the crate's own doctests `cgp` names `cgp-core`; -//! keep the two in sync when either changes. +//! The wiring example in `cgp-error-std`'s README, compiled and run as a test. The README marks +//! the block `ignore` because in the crate's own doctests `cgp` names `cgp-core`; the build +//! script turns the block into this module, so the README stays the only copy. //! //! See cgp-knowledge-base/projects/error/cgp-error-std/testing.md. -use cgp::core::error::{ErrorRaiserComponent, ErrorTypeProviderComponent, ErrorWrapperComponent}; -use cgp::prelude::*; -use cgp_error_std::{DisplayBoxedStdError, RaiseBoxedStdError, UseBoxedStdError}; - -pub struct App; - -delegate_components! { - App { - open ErrorRaiserComponent; - - ErrorTypeProviderComponent: UseBoxedStdError, - @ErrorRaiserComponent.std::io::Error: RaiseBoxedStdError, - @ErrorRaiserComponent.String: DisplayBoxedStdError, - ErrorWrapperComponent: RaiseBoxedStdError, - } -} - -#[test] -fn test_readme_std() { - let error = App::raise_error(std::io::Error::other("disk full")); - let error = App::wrap_error(error, "while saving"); - assert_eq!(format!("{error}"), "while saving"); - assert_eq!(format!("{error:#}"), "while saving: disk full"); -} +include!(concat!(env!("OUT_DIR"), "/readme_std.rs"));