The block that tries extra hard. Roll+Loss.
10+ Success — zero loss, clean hit. 7-9 Partial — you get the value, but something was lost. The loss is measured. 6- Failure — the MC makes a move. The cost carries.
The design descends from PbtA (Powered by the Apocalypse). The 7-9 result — success with complications — is the design innovation that PbtA contributed to game design. eh! encodes that structure in a proc macro.
eh — the shrug. For the engineer who's been here before.
eh — extra hard. For the engineer reading the docs.
eh! — the proc macro. For the compiler.
use terni::{eh, Imperfect, ConvergenceLoss};
fn pipeline(input: i32) -> Imperfect<i32, String, ConvergenceLoss> {
eh! {
let a = step_one(input)?;
let b = step_two(a)?;
b + 1
}
}Every expr? inside eh! routes through the IntoEh trait:
Imperfectvalues: extracted viaEh::eh(), loss accumulated into a hidden context.Resultvalues: passed through unchanged, no loss.
The final expression is wrapped with accumulated loss:
- All success, no loss: returns
Success(value) - Any loss accumulated: returns
Partial(value, accumulated_loss) - Any
?hitsFailureorErr: returnsFailure(error, accumulated_loss)
Both types work inside eh!:
use terni::{eh, Imperfect, ConvergenceLoss};
fn mixed() -> Imperfect<String, String, ConvergenceLoss> {
eh! {
let data = Imperfect::<Vec<u8>, String, ConvergenceLoss>::Success(vec![72, 105])?;
let text: String = Ok::<String, String>(String::from_utf8_lossy(&data).into())?;
text
}
}The IntoEh trait handles dispatch at compile time. Zero-cost: monomorphized away.
Each eh! block gets its own accumulation context:
use terni::{eh, Imperfect, ConvergenceLoss};
fn outer() -> Imperfect<i32, String, ConvergenceLoss> {
eh! {
let inner: Imperfect<i32, String, ConvergenceLoss> = eh! {
let x = Imperfect::<i32, String, ConvergenceLoss>::Partial(10, ConvergenceLoss::new(2))?;
x + 1
};
let v = inner?;
v + 5
}
}The inner block produces Partial(11, ConvergenceLoss(2)). The outer block's ? extracts the value and accumulates the inner loss.
Add a recover branch to handle partial results. If the body completes with accumulated loss (Partial), the recovery closure runs with the value and loss. The closure transforms the value; the loss stays unchanged. Success passes through untouched.
use terni::{eh, Imperfect, ConvergenceLoss};
fn adjusted(input: i32) -> Imperfect<i32, String, ConvergenceLoss> {
eh! {
let a = step_one(input)?;
let b = step_two(a)?;
b + 1
recover |value, loss| {
// 7-9: you got it, it cost something
eprintln!("lost {} steps", loss.steps());
value * 2 // adjust the value based on what was lost
}
}
}The recover closure receives (value, loss) — the value from the body and the accumulated loss. It returns a new value. The loss itself is fact, not something you edit.
If no loss accumulated (Success), the recover branch is never executed.
Add a rescue branch to handle failures. If any ? in the body hits Failure, the rescue closure runs with the error. The accumulated loss from the try body carries into the rescue. The result is always Partial — the failure happened.
use terni::{eh, Imperfect, ConvergenceLoss};
fn resilient(input: i32) -> Imperfect<i32, String, ConvergenceLoss> {
eh! {
let a = step_one(input)?;
let b = step_two(a)?;
b + 1
rescue |e| {
// 6-: the MC makes a move
eprintln!("failed: {}", e);
0 // fallback value
}
}
}Without rescue, a Failure propagates as Failure(error, accumulated_loss). With rescue, it becomes Partial(rescued_value, accumulated_loss).
The rescue closure receives the error value. Use it or ignore it:
use terni::{eh, Imperfect, ConvergenceLoss};
fn with_error_info() -> Imperfect<String, String, ConvergenceLoss> {
eh! {
let val = might_fail()?;
format!("got: {}", val)
rescue |e| {
format!("rescued from: {}", e)
}
}
}If no failure occurs, the rescue branch is never executed.
Both branches are optional and independent. Use one, both, or neither. When both are present, recover comes before rescue:
use terni::{eh, Imperfect, ConvergenceLoss};
fn full_pbta(input: i32) -> Imperfect<i32, String, ConvergenceLoss> {
eh! {
let a = step_one(input)?;
step_two(a)
// 7-9: you got it, it cost something
recover |value, loss| {
adjust(value, &loss)
}
// 6-: the MC makes a move
rescue |error| {
fallback(error)
}
}
}
// 10+: clean hit. no handler needed.The eh! proc macro rewrites the block:
- Creates a hidden
Ehcontext (__eh_ctx) - Wraps the block body in a closure returning
Result - Rewrites every
expr?toIntoEh::into_eh(expr, &mut __eh_ctx)? - Wraps the final expression in
Ok(...) - Matches the closure result:
Okchecks accumulated loss,Errcallsfailure_with_loss() - With
recover:Okwith loss runs the recover closure with(value, loss), returnsPartial(new_value, loss) - With
rescue:Errbuilds aFailure, then callsunwrap_or_else()with the rescue closure — always producingPartial
return returns from the block, not the enclosing function. The macro wraps your code in a closure. return exits that closure, not your function. Use ? for early exit from eh! blocks.
No async support. The closure wrapper doesn't interact well with .await. This needs design work and will come in a future release.
? in closures accumulates to the same context. If you use ? inside a closure within eh!, loss accumulates into the outer block's context. This is usually what you want.
__eh_ctx name collision. The macro generates an internal variable named __eh_ctx. If your code uses a variable with this exact name, it will collide. The double-underscore prefix makes this unlikely in practice, but it is not hygienically scoped (proc macros operate at call-site span).
recover / rescue as identifiers. The parser detects recover and rescue keywords by scanning for a top-level identifier followed by |. If you have a variable named recover or rescue followed by a bitwise OR (|), the parser will misidentify it as a branch keyword. Rename the variable to avoid ambiguity.
needless_question_mark clippy lint. When the tail expression of an eh! block is expr?, the macro expansion produces Ok(IntoEh::into_eh(expr, ctx)?) which clippy flags as redundant. This is inherent to the rewriting strategy and does not affect correctness. Suppress with #[allow(clippy::needless_question_mark)] on the enclosing function if needed.
The trait that makes eh! work:
pub trait IntoEh<T, E, L: Loss> {
fn into_eh(self, ctx: &mut Eh<L>) -> Result<T, E>;
}Implemented for:
Imperfect<T, E, L>-- extracts value, accumulates loss into contextResult<T, E>-- passes through unchanged
You can implement IntoEh for your own types to make them work with eh!.
The macro is behind the macros feature, which is on by default:
[dependencies]
terni = "0.5" # macros included
# or explicitly:
terni = { version = "0.5", features = ["macros"] }
# without macros:
terni = { version = "0.5", default-features = false }