Skip to content

Latest commit

 

History

History
229 lines (164 loc) · 7.74 KB

File metadata and controls

229 lines (164 loc) · 7.74 KB

The eh! Macro

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.

Usage

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:

  • Imperfect values: extracted via Eh::eh(), loss accumulated into a hidden context.
  • Result values: 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 ? hits Failure or Err: returns Failure(error, accumulated_loss)

Mixing Imperfect and Result

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.

Nested Blocks

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.

Recovery

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.

Rescue

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.

Full PbtA Block

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.

How It Works

The eh! proc macro rewrites the block:

  1. Creates a hidden Eh context (__eh_ctx)
  2. Wraps the block body in a closure returning Result
  3. Rewrites every expr? to IntoEh::into_eh(expr, &mut __eh_ctx)?
  4. Wraps the final expression in Ok(...)
  5. Matches the closure result: Ok checks accumulated loss, Err calls failure_with_loss()
  6. With recover: Ok with loss runs the recover closure with (value, loss), returns Partial(new_value, loss)
  7. With rescue: Err builds a Failure, then calls unwrap_or_else() with the rescue closure — always producing Partial

Limitations

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 IntoEh Trait

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 context
  • Result<T, E> -- passes through unchanged

You can implement IntoEh for your own types to make them work with eh!.

Feature Flag

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 }