Skip to content

Sharp0802/trisult

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

89 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Trisult   Version License Docs

An accumulating alternative to Rust's standard Result<T, E>.

While Result is designed to short-circuit on the first failure, many workflows - such as parsers, compilers, and complex data validators - need to collect and report multiple errors and warnings at once.

Trisult provides a robust, context-aware mechanism for batching these diagnostics without sacrificing ergonomics.

  1. Features
  2. Usage
  3. Performance
  4. License

Features

  • The Trisult Enum: A diagnostic-aware alternative to Result. It accumulates warnings alongside the value on success (Trisult::Ok(Diagnosed)), and collects both errors and warnings on failure (Trisult::Err(Diagnoses)).

  • Idiomatic Combinators: Chain operations naturally using familiar methods like .map() and .and_then(). Warnings from previous steps are safely preserved and carried forward.

  • Rich Context Tracking: Tie diagnostics to precise source locations, AST nodes, or custom application states using the CapturedContext and ContextStack traits.

  • Configurable Accumulation: Control memory usage and verbosity via the Acc trait. Use All to collect everything, or Most to keep only the highest-priority diagnostic (e.g., preserving an Error over a Warning).

  • #[no_std] by Default: Works out-of-the-box in resource-constrained environments using zero-allocation accumulators.

  • Optional alloc: Disable the alloc feature (backed by smallvec) to disable accumulating an arbitrary number of diagnostics when heap allocation is available. (alloc is enabled by default)

Usage

First, add trisult to your Cargo.toml:

[dependencies]
trisult = "0.4"

Manual Accumulation

At its core, Trisult operates similarly to standard Result, but with the ability to carry warnings on success, and both warnings and errors on failure.

You can construct and chain these results manually using idiomatic combinators like .and_then() and .map().

use trisult::{Trisult, Diagnosed, Diagnosis, Contextuals, Contextual, NoLoc};
use trisult::{Acc, Default};

// Define your own warning and error types
#[derive(Debug)]
pub enum MyWarn { Deprecated }

#[derive(Debug)]
pub enum MyErr { InvalidFormat }

pub type MyResult<T> = Trisult<T, MyWarn, MyErr, NoLoc, Default>;

fn parse_version(version: &str) -> MyResult<i32> {
    let mut warnings = Contextuals::new(Default::create_state());

    match version {
        "v2" => Trisult::Ok(Diagnosed(2, warnings)),
        "v1" => {
            // Push a warning but still succeed
            warnings.push_naive(Contextual::new(NoLoc, MyWarn::Deprecated));
            Trisult::Ok(Diagnosed(1, warnings))
        }
        _ => {
            // Fail with an error
            let mut errors = Contextuals::new(Default::create_state());
            errors.push_naive(Contextual::new(NoLoc, Diagnosis::Error(MyErr::InvalidFormat)));
            Trisult::Err(errors)
        }
    }
}

fn parse_config(version: &str) -> MyResult<i32> {
    parse_version(version).and_then(|v| {
        // Carry on with further processing. Warnings from `parse_version`
        // are safely preserved and carried forward!
        Trisult::Ok(Diagnosed(v * 10, Contextuals::new(Default::create_state())))
    })
}

The #[trisult] Macro

Constructing and joining accumulators manually can become verbose. The #[trisult] macro provides an ergonomic way to accumulate warn! and error! diagnostics, drastically reducing boilerplate.

It transforms a function returning an Option<T> internally into one that returns a Trisult. Emitting an error! will cause the macro to return Trisult::Err when the function exits (or immediately if you return None), while warn! simply accumulates in the background.

use trisult::{trisult, Trisult, Diagnosed, NoLoc, Default};

#[derive(Debug)]
pub enum MyWarn { Deprecated, Unconventional }

#[derive(Debug)]
pub enum MyErr { MissingField, InvalidFormat }

pub type MyResult<T> = Trisult<T, MyWarn, MyErr, NoLoc, Default>;

#[trisult]
fn parse_version(version: &str) -> MyResult<i32> {
    match version {
        "v2" => Some(2),
        "v1" => {
            warn!(MyWarn::Deprecated, NoLoc);
            Some(1)
        }
        _ => {
            error!(MyErr::InvalidFormat, NoLoc);
            None // Early return; macro translates this into a Trisult::Err
        }
    }
}

#[trisult]
fn parse_config(version: &str) -> MyResult<i32> {
    // Use `tri!` to unpack sub-operations while accumulating their diagnostics
    let v = tri!(parse_version(version))?;
    Some(v * 10)
}

fn main() {
    match parse_config("v1") {
        Trisult::Ok(Diagnosed(val, warnings)) => {
            println!("Success: {}", val); // Prints: 10
            for warn in warnings {
                println!("Warning: {:?}", warn.value); // Prints: Deprecated
            }
        }
        Trisult::Err(diagnoses) => {
            for diag in diagnoses {
                println!("Failed with: {:?}", diag.value);
            }
        }
    }
}

Capturing Context

Often, it's useful to tie your warnings and errors to specific file locations, line numbers, or context stacks.

This is natively supported by Trisult using CapturedContext. You can pass any type implementing CapturedContext as the context parameter to warn! or error!.

use trisult::{trisult, Trisult, NoLoc, Default};

#[derive(Debug, Clone)]
pub struct Span(usize, usize);

impl std::fmt::Display for Span {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}:{}", self.0, self.1)
    }
}

#[trisult]
fn parse_with_context(input: &str, span: Span) -> Trisult<String, String, String, Span, Default> {
    if input.is_empty() {
        error!("Empty input".to_string(), span);
        return None;
    }

    Some(input.to_string())
}

Auto Stacking Contexts

For deeply nested parsers or workflows, you might want to maintain a "stack trace" of where a diagnostic occurred (e.g. /parent_node/child_node). Trisult provides an auto-stacking feature if your context implements ContextStackMut.

By defining a segment in the #[trisult] macro and identifying your stack argument with #[context], the macro will automatically push the segment onto the stack before executing the function and safely pop it off upon exiting - even on early returns.

use trisult::{trisult, Trisult, ContextStack, ContextStackMut, Default};

// Assume `TraceStack` implements `ContextStack` and `ContextStackMut`
// to join string segments with '/'
#[derive(Debug, Default, Clone)]
pub struct TraceStack { pub path: Vec<&'static str> }
impl ContextStack for TraceStack { type Captured = String; }
impl ContextStackMut for TraceStack {
    type Segment = &'static str;
    fn capture(&self) -> Self::Captured { format!("/{}", self.path.join("/")) }
    fn push(&mut self, segment: Self::Segment) { self.path.push(segment); }
    fn pop(&mut self) { self.path.pop(); }
}

#[derive(Debug)]
pub enum MyWarn { MinorIssue }

#[derive(Debug)]
pub enum MyErr { FatalIssue }

pub type MyResult<T> = Trisult<T, MyWarn, MyErr, String, Default>;

#[trisult(segment = "child_node")]
fn parse_child(#[context] stack: &mut TraceStack) -> MyResult<()> {
    warn!(MyWarn::MinorIssue); // Captured as "/parent_node/child_node"

    // Early returns safely pop the stack segment
    error!(MyErr::FatalIssue); // Captured as "/parent_node/child_node"
    None
}

#[trisult(segment = "parent_node")]
fn parse_parent(#[context] stack: &mut TraceStack) -> MyResult<()> {
    tri!(parse_child(stack))?;
    Some(())
}

Accumulator Selection

Sometimes you want the caller to decide how diagnostics are accumulated at runtime - for example, collecting all warnings during a deep analysis (trisult::All), but failing fast and saving memory during a quick validation pass (trisult::Most).

You can inject the accumulator policy dynamically by tagging a generic parameter with #[kind]. The macro will automatically use the caller's generic argument to initialize the internal state.

use trisult::{trisult, Acc, Trisult, Diagnosed, NoLoc, All, Most};

#[derive(Debug)]
pub enum MyWarn { Deprecated }

#[derive(Debug)]
pub enum MyErr { MissingField }

pub type MyResult<T, A> = Trisult<T, MyWarn, MyErr, NoLoc, A>;

#[trisult]
fn parse_dynamic<
    #[kind] T: Acc // injected at compile time
>(input: &str) -> MyResult<i32, T> {
    warn!(MyWarn::Deprecated, NoLoc);

    if input.is_empty() {
        error!(MyErr::MissingField, NoLoc);
        return None;
    }

    Some(42)
}

fn main() {
    // Caller A: Collect everything
    let exhaustive_res = parse_dynamic::<All>("data");

    // Caller B: Only keep the most severe diagnostic
    let fast_res = parse_dynamic::<Most>("data");
}

Performance

  • Benchmark
    • Ran on AMD Ryzen 9 9955HX, Linux 6.18.31-1-lts.
    • See bench.sh for more details.

It's strongly recommended to use Most accumulator over All in performance critical section.

As you can see in benchmark given above, While Most mode causes only near to 0.6ns additional overhead over standard result, All mode can cause almost 1.5ns additional overhead in fast-fail scenario.

License

This project is licensed under either of

at your option.

About

`trisult` is a specialized result-like library for Rust, designed to accumulate errors and warnings.

Topics

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Contributors