Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 1: Hello Salsa — Inputs and Tracked Functions

Imagine you’re cooking from a recipe. You’ve got your ingredients laid out on the counter — those are your inputs. They come from outside the kitchen: the grocery store, the garden, the pantry. You don’t produce them; you start with them.

Now you follow the recipe steps to turn those ingredients into a dish. Those steps are your tracked functions — pure transformations that depend only on what you put in. If you swap the butter for margarine, only the steps that used butter need to change. The step where you toasted the sesame seeds? That’s still fine. No need to redo it.

Salsa works the same way. You declare your inputs (the facts that come from outside — source code, configuration), you write pure functions that derive results from those inputs, and Salsa keeps track of which functions read which inputs. When an input changes, Salsa only re-runs the functions that actually depended on it. Everything else returns its cached result instantly.

If you’ve used a spreadsheet, this will feel familiar — type a number in a cell, and every formula that references it recalculates. Salsa is the same idea, but for Rust functions instead of spreadsheet cells. The difference is that Salsa’s dependencies are dynamic: a function reads whatever it reads at runtime, and Salsa tracks those reads automatically. No need to wire up the references by hand.

This matters because in a real type checker, you might have hundreds of source files and thousands of derived results. You can’t recompute everything on every keystroke. Salsa gives you the infrastructure to skip work — automatically.

Let’s see how this works in code.

Step 1: Define Your Input

In Salsa, inputs are the facts that come from outside the system. For a type checker, the obvious input is: the source code.

#![allow(unused)]
fn main() {
use salsa::Setter;
use std::path::PathBuf;

#[salsa::input]
pub struct SourceFile {
    #[returns(ref)]
    pub path: PathBuf,
    #[returns(ref)]
    pub text: String,
}
}

The #[salsa::input] attribute does a lot of work behind the scenes. It generates a constructor so you can create a SourceFile by calling SourceFile::new(&db, path, text), getter methods so you can read the fields, and setter methods so you can change them later. We’ll see all of these in action once we define our database.

The #[returns(ref)] attribute tells Salsa that the getter should return a reference (&String) instead of an owned value. This avoids cloning strings every time you read an input.

Key idea: Inputs are the only way data enters the system. Everything else is derived from them. This is what makes Salsa’s incrementality work — if you can trace every piece of data back to an input, you know exactly what needs to re-run when an input changes.

Step 2: Define a Tracked Function

A #[salsa::tracked] function is a pure function of its inputs. Pure means: no side effects, no reading from global state, no randomness. Everything goes through the database.

#![allow(unused)]
fn main() {
#[salsa::tracked]
pub fn line_count(db: &dyn salsa::Database, source: SourceFile) -> u32 {
    let text = source.text(db);
    text.lines().count() as u32
}
}

The first argument is always the database (we’ll refine this in later chapters). The remaining arguments are the “keys” — what distinguishes one call from another.

When you call line_count(&db, source), Salsa does this:

  1. Check: have I seen this query before, with these arguments, in this revision?
  2. If yes → return the cached result. No re-execution.
  3. If no → run the function, cache the result, return it.

The function body calls source.text(db). This read is tracked. Salsa records: “line_count(source) depends on source.text.” Later, if you change the source text, Salsa knows this cache entry is stale.

What if you forget #[salsa::tracked]? Then Salsa can’t see inside the function. It doesn’t know which inputs the function reads, so it can’t tell whether the cached result is still valid. Without the annotation, you’d have a plain function — no caching, no dependency tracking, no incremental recomputation. The annotation is what makes Salsa’s guarantee possible.

Let’s add one more query to see how dependencies work:

#![allow(unused)]
fn main() {
#[salsa::tracked]
pub fn contains_text(db: &dyn salsa::Database, source: SourceFile, needle: String) -> bool {
    let text = source.text(db);
    text.contains(&needle)
}
}

Same pattern: read an input, compute a result, cache it. The needle parameter becomes part of the cache key — contains_text(db, source, "print") and contains_text(db, source, "local") are independent cache entries.

A word on String as a key: Using String as a query key means every call with a different needle string creates a new cache entry and allocates the string on the heap. For a tutorial, this is fine — it demonstrates key parameters clearly. In production code, you’d want to use interned strings (we’ll build those in Chapter 3) so that comparisons are cheap and allocations happen only once.

Step 3: Define Your Database

A database is the container that holds all the cached query results.

#![allow(unused)]
fn main() {
#[salsa::db]
#[derive(Default)]
pub struct Database {
    storage: salsa::Storage<Self>,
}

#[salsa::db]
impl salsa::Database for Database {}
}

You need three things:

  1. A salsa::Storage<Self> field — this is where Salsa keeps its memo tables, revision counters, and dependency graphs.
  2. An impl of salsa::Database — marked with #[salsa::db].
  3. The same #[salsa::db] attribute on the struct itself.

Right now our database is empty — it doesn’t have any custom behavior. In later chapters, we’ll add methods and custom traits. For now, it’s a container — nothing more.

Step 4: Use It

use std::path::PathBuf;

fn main() {
    let mut db = Database::default();

    let source = SourceFile::new(
        &db,
        PathBuf::from("main.lua"),
        "local x = 1\nlocal y = 2\nprint(x + y)\n".to_string(),
    );

    let count = line_count(&db, source);
    assert_eq!(count, 3);
}

Nothing magical yet — we create a database, create an input, and query it. The result is computed and cached.

The Magic: Revisions

Now let’s change the input and see what happens:

#![allow(unused)]
fn main() {
use salsa::Setter;

source.set_text(&mut db).to("local z = 99\n".to_string());

let new_count = line_count(&db, source);
assert_eq!(new_count, 1);
}

The setter syntax is a builder pattern: set_text(&mut db) returns a setter object, and .to(...) applies the new value and bumps the revision. You might expect set_text(&mut db, value) — a regular method call — but the builder pattern lets Salsa batch multiple field updates in a single revision. For now, we’re setting one field at a time.

When we call set_text, Salsa increments its revision counter. This is Salsa’s internal clock — every input mutation bumps the revision. Each cached query remembers which revision it was computed in.

When we query line_count again, Salsa checks: “Is the current revision newer than when I last computed this?” Yes. “Did the inputs this query depends on actually change?” Yes — we set new text. So it re-runs the function.

If we query line_count again without changing the text, Salsa returns the cached result instantly. No re-execution:

#![allow(unused)]
fn main() {
let same_count = line_count(&db, source);
assert_eq!(same_count, 1); // cached! The function body never ran.
}

The second call is effectively a HashMap lookup — Salsa sees the same arguments in the same revision and returns the memoized value. The line_count function body doesn’t execute.

Per-Input Isolation

Salsa doesn’t cache globally — it caches per input. When you change one input, only the queries that actually read that input are invalidated. Everything else stays cached.

#![allow(unused)]
fn main() {
use salsa::Setter;
use std::path::PathBuf;

let other = SourceFile::new(&db, PathBuf::from("other.lua"), "return 42\n".to_string());
let other_count = line_count(&db, other); // computed, cached

source.set_text(&mut db).to("local a = 1\nlocal b = 2\n".to_string());

let other_count_again = line_count(&db, other); // cached! No re-run!
assert_eq!(other_count_again, 1);
}

We changed source’s text, not other’s. When Salsa asks “is line_count(other) still valid?”, it checks whether other’s text changed. It didn’t — so the cached value is returned instantly. source and other are isolated from each other.

In a real type checker with hundreds of files, typing in one file only invalidates queries that read that file. Queries for other files are still cached. This is why rust-analyzer can respond in milliseconds even on large projects — it’s not re-type-checking the whole world on every keystroke.

Next: Chapter 2: Parsing Lua with Analisar — We’ll parse actual Lua source code using the analisar parser and wire it into Salsa as a tracked query. Same incremental model, but now doing real work.