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.
-
The
TrisultEnum: A diagnostic-aware alternative toResult. 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
CapturedContextandContextStacktraits. -
Configurable Accumulation: Control memory usage and verbosity via the
Acctrait. UseAllto collect everything, orMostto keep only the highest-priority diagnostic (e.g., preserving anErrorover aWarning). -
#[no_std]by Default: Works out-of-the-box in resource-constrained environments using zero-allocation accumulators. -
Optional
alloc: Disable theallocfeature (backed bysmallvec) to disable accumulating an arbitrary number of diagnostics when heap allocation is available. (allocis enabled by default)
First, add trisult to your Cargo.toml:
[dependencies]
trisult = "0.4"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())))
})
}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);
}
}
}
}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())
}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(())
}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");
}- Benchmark
- Ran on AMD Ryzen 9 9955HX, Linux 6.18.31-1-lts.
- See
bench.shfor 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.
This project is licensed under either of
- Apache License, Version 2.0, (LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
at your option.