Chapter 6: Diagnostics as Accumulators

Report errors without poisoning the query graph.

What You'll Learn

  • Why returning errors from queries is problematic
  • How #[salsa::accumulator] provides a side-channel for diagnostics
  • How accumulators keep the query graph healthy
  • How to build a type checker that reports errors properly

The Problem: Errors That Poison the Graph

You wrote a type checker. It returns Type. What happens when there's a type error?

Option 1: Return Result<Type, Error>

If infer_type returns Err, downstream queries can't use the result. The error "poisons" everything that depends on it. You lose all the type information you did compute. In a gradual type system, partial type info is valuable — you want to keep it.

Option 2: Return Type::Error (sentinel value)

Better. Downstream queries see Error and can decide what to do (propagate it, ignore it, etc.). But you lose the error message. You know something went wrong, but not what. "Cannot add Number and String" is gone — all you have is Type::Error.

Option 3: Accumulators (the Salsa way)

The query returns a Type (possibly Type::Error), and emits a diagnostic as a side effect. The diagnostic doesn't affect the return value or the query graph. It's collected after the query runs. You get both the type information AND the error message.

Step 1: Define an Accumulator

#![allow(unused)]
fn main() {
#[salsa::accumulator]
#[derive(Debug, Clone)]
pub struct Diagnostic {
    pub severity: Severity,
    pub message: String,
}
}

An accumulator is a type that can be "pushed to" from inside tracked functions. It's a side channel — separate from the return value.

Step 2: Emit Diagnostics from Inside Queries

#![allow(unused)]
fn main() {
#[salsa::tracked]
pub fn infer_type(db: &dyn salsa::Database, source: SourceFile, expr: Expression, env: TypeEnv) -> Type {
    match expr {
        Expression::BinaryOp { ref left, op, ref right } => {
            let lt = infer_type(db, source, (**left).clone(), env.clone());
            let rt = infer_type(db, source, (**right).clone(), env.clone());
            match op {
                BinOp::Add => {
                    if !lt.is_compatible_with(&Type::Number) {
                        Diagnostic::error(format!("cannot use {:?} in arithmetic", lt))
                            .emit(db);  // side effect!
                    }
                    if !rt.is_compatible_with(&Type::Number) {
                        Diagnostic::error(format!("cannot use {:?} in arithmetic", rt))
                            .emit(db);
                    }
                    if lt == Type::Error || rt == Type::Error { Type::Error } else { Type::Number }
                }
                // ...
            }
        }
        // ...
    }
}
}

When we find a type error, we do two things:

  1. Return Type::Error — tells downstream queries "something went wrong"
  2. Emit a Diagnostic — tells the user what went wrong

These are separate concerns. The return value is for the query graph. The accumulator is for the human.

Step 3: Collect Accumulated Diagnostics

#![allow(unused)]
fn main() {
type_check(&db, source);
let diags: Vec<_> = type_check::accumulated::<Diagnostic>(&db, source)
    .into_iter()
    .cloned()
    .collect();
}

After running type_check, you call type_check::accumulated::<Diagnostic> to get all diagnostics that were emitted during that query (and any queries it called). This is the read side of the accumulator.

Step 4: Incremental Accumulators

When a query re-runs, its old accumulated diagnostics are discarded and replaced. No stale diagnostics. If you fix a type error and the query re-runs successfully, the diagnostic from the previous revision disappears automatically.

#![allow(unused)]
fn main() {
// Bad program → diagnostics
bad.set_text(&mut db).to("local x = 42\nlocal y = \"hello\"\nlocal z = x + y\n");
type_check(&db, bad);
let bad_diags = type_check::accumulated::<Diagnostic>(&db, bad);
assert!(!bad_diags.is_empty());

// Fix it → no diagnostics
bad.set_text(&mut db).to("local x = 42\nlocal y = 1\nlocal z = x + y\n");
type_check(&db, bad);
let fixed_diags = type_check::accumulated::<Diagnostic>(&db, bad);
assert!(fixed_diags.is_empty());
}

The Pattern

┌──────────────────────────────────────────────┐
│  TRACKED FUNCTION                            │
│                                              │
│  input ──→ computation ──→ return value      │
│                    │                          │
│                    └──→ accumulate(diagnostics)│
└──────────────────────────────────────────────┘

Return value → used by downstream queries (the graph)
Accumulator → used by the human (the report)

These are intentionally separate. The graph shouldn't break because of a diagnostic. The diagnostic shouldn't be suppressed because the graph needs a valid value.

Why Not Result<Type, Diagnostic>?

Because Result is all-or-nothing: you get the type OR the error, never both. In gradual typing, partial information is valuable. If x is Number and y is String and z = x + y is an error, you still want to know that x is Number and y is String. With accumulators, you do.

Running

cargo run --bin ch06-diagnostics

Key Takeaways

  1. Accumulators are side channels. They don't affect the return value. They're collected after the query runs.

  2. Errors don't poison the graph. Return Type::Error to tell downstream queries something went wrong. Emit a Diagnostic to tell the user what. These are separate.

  3. Accumulators are incremental. When a query re-runs, old diagnostics are discarded and replaced. No stale diagnostics.

  4. The pattern: return value for the graph, accumulator for the human. This is how rust-analyzer reports diagnostics. It works.

  5. Partial results are valuable. Don't throw away type information just because there's an error somewhere. Gradual typing is about embracing partial knowledge.

What's Next

Chapter 7: The Language Server — Wire everything into a language server simulation and watch Salsa skip work on edits. This is the payoff for everything you've learned.