Chapter 9: Cross-File Type Checking — require() and Module Resolution
Our type checker works great for a single file. But real Lua programs aren’t one file. They’re dozens of files connected by require():
-- utils.lua
local function double(x) return x * 2 end
return double
-- main.lua
local double = require("utils")
local result = double(42)
local sum = result + 1
Right now, our type checker treats require("utils") as Dynamic. It has no idea what utils exports. That means double(42) is also Dynamic — no type checking, no error detection, no help at all.
This chapter changes that. We’ll teach the type checker to resolve require() calls, inspect the module’s exports, and use those types when checking the importing file. And here’s the thing: Salsa already gives us cross-file incrementality for free. When you edit utils.lua, only files that depend on it re-check. Everything else stays cached.
The Dependency Graph
Before writing any code, let’s think about what happens when main.lua requires utils.lua:
┌──────────────┐ ┌──────────────┐
│ utils.lua │ │ main.lua │
│ │ │ │
│ exports: │◄─────── │ requires: │
│ double: │ require │ utils │
│ (n) → n │ │ │
└──────────────┘ └──────────────┘
When main.lua calls require("utils"), we need to:
- Find
utils.luaon disk (module resolution) - Type-check it (if we haven’t already)
- Extract its exports (what does it return?)
- Use those exports as the type of
require("utils")
This creates a dependency: main.lua depends on utils.lua. If utils.lua changes (someone edits the double function’s return type), main.lua’s type check is stale and needs re-running.
Sound familiar? This is exactly what Salsa tracks. When type_check(main.lua) reads the exports of utils.lua, Salsa records a dependency. Change utils.lua? Salsa knows main.lua needs re-checking. Don’t change it? Cached result, instant.
Step 1: Module Resolution
First, we need to find modules. Lua’s require() searches a path — a semicolon-separated list of templates like ?.lua and ?/init.lua. For this chapter, we’ll use a simple resolver that looks in a single directory.
#![allow(unused)]
fn main() {
use std::path::PathBuf;
/// Resolve a module name to a file path.
/// In real Lua, this would search package.path.
/// For us: look for "{name}.lua" in the project directory.
fn resolve_module(module_name: &str) -> Option<PathBuf> {
let path = PathBuf::from(format!("{}.lua", module_name));
if path.exists() {
Some(path)
} else {
None
}
}
}
This is intentionally simple. A real resolver would handle package.path, ?/init.lua, and preloaded modules. But the type-checking logic is the same regardless of how you find the file.
From filesystem to database.
resolve_modulechecks the filesystem — it asks the OS whether a.luafile exists. Our type checker works differently: files live as Salsa inputs in the database, not on disk. When we implementresolve_requirein Step 3, we’ll look up the resolved path in aFileRegistryinstead of checking the filesystem. The resolution logic (turn"math"into"math.lua") is the same — the difference is where we look for the file.
Step 2: The Exports Query
The key insight: a module’s exports are a derived query. They depend on the module’s source text. When the source changes, the exports change. When the exports change, anything that imported those exports is stale.
#![allow(unused)]
fn main() {
/// What does a source file export?
/// We type-check the file and find what the top-level `return` statement returns.
/// Returns the Type of the return expression, or Dynamic if there's no return.
#[salsa::tracked]
pub fn module_exports(db: &dyn salsa::Database, source: SourceFile) -> Type {
let ast = parse(db, source);
let mut env = TypeEnv::new();
let mut return_type = None;
for stmt in &ast.statements {
let result = check_stmt(db, source, stmt, &env);
if result.return_type.is_some() && return_type.is_none() {
return_type = result.return_type;
}
env = result.env;
}
return_type.unwrap_or(Type::Dynamic)
}
}
For now, a module exports a single type — the type of its return value. In a real type checker, you’d export a map of names to types (for M.double, M.triple, etc.). But the single-type approach captures the essential pattern: exports are derived, tracked, and automatically invalidated.
How We Infer Function Types
In previous chapters, function definitions were typed as Dynamic. That won’t work for cross-file checking — if require("math") returns Dynamic, we can’t detect type errors when the module changes.
The fix: when checking a function declaration, we infer its return type from the body. check_stmt for Statement::Function now:
- Checks the function body (which emits diagnostics for the body’s statements)
- Collects the return type from the body’s
returnstatement - Creates a
Type::Function { params, ret }with the inferred return type
#![allow(unused)]
fn main() {
Statement::Function { name, params, body, .. } => {
let mut fe = env.clone();
let param_types: Vec<Type> = params.iter().map(|_| Type::Dynamic).collect();
for p in params {
fe = fe.extend(p.clone(), Type::Dynamic);
}
let body_result = check_block(db, source, body, &fe);
let ret_type = body_result.return_type.unwrap_or(Type::Dynamic);
let func_type = Type::Function {
params: param_types,
ret: Box::new(ret_type),
};
StmtResult { env: env.extend(name.clone(), func_type), return_type: None }
}
}
To make this work without calling infer_type a second time (which would produce duplicate diagnostics), check_stmt and check_block now return both the updated environment and the inferred return type from any return statement they encounter. This is a small structural change — check_stmt returns a StmtResult struct instead of a TypeEnv — but it lets us propagate return types naturally through the checking process.
For the double function in math.lua, the return expression x * 2 is inferred as Number (since x is Dynamic — compatible with Number — and 2 is Number). So double gets the type Function { params: [Dynamic], ret: Number }. When the module is edited to return a string instead, double’s return type becomes String, and any caller that uses the result as a number gets a type error.
What About Type::Table?
You’ll notice a Type::Table { fields: Vec<(String, Type)> } variant in the code. This supports field-level type lookups — for example, when a module returns a table with named exports like M.double, M.triple, etc. The field_type method looks up a field by name in a table type.
Currently, no code path produces a Type::Table value. Our modules return single values (functions, numbers, strings), not tables with named fields. A complete type checker would construct Type::Table values when analyzing table constructors (local M = { double = ... }), and then require("M") would return a Table type with named fields. The Table variant and field_type method are infrastructure for that extension — the pattern is the same, with richer data.
Step 3: Resolving require() in Type Inference
Now we modify infer_type to handle require() calls. When the type checker sees require("utils"), it:
- Resolves
"utils"to a file path - Gets or creates a
SourceFilefor that path - Queries
module_exportsfor that file - Returns the export type
#![allow(unused)]
fn main() {
Expression::FunctionCall { ref func, ref args } => {
// Check if this is a require() call
if let Expression::Name(name) = &**func {
if name == "require" {
if let Some(Expression::StringLiteral(module_name)) = args.first() {
return resolve_require(db, source, module_name);
}
}
}
// Normal function call (same as before)
let ft = infer_type(db, source, (**func).clone(), env.clone());
// ... rest unchanged
}
}
The resolve_require function does the work:
#![allow(unused)]
fn main() {
use salsa::Accumulator;
use std::path::{Path, PathBuf};
fn resolve_require(db: &dyn salsa::Database, _caller: SourceFile, module_name: &str) -> Type {
// analisar's LiteralString includes the source-level quote characters,
// e.g. `"math"` (with quotes), so we strip them before lookup.
let module_name = module_name.trim_matches('"');
// Turn "math" into "math.lua" — same resolution logic from Step 1.
// We don't call resolve_module() here because that checks the
// filesystem, and our files live in the Salsa database. Instead,
// we use resolve_module's path format and look up in the registry.
let path = PathBuf::from(format!("{}.lua", module_name));
let module_file = match find_source_file(db, &path) {
Some(f) => f,
None => {
Diagnostic::error(format!("cannot find module {:?}", module_name))
.emit(db);
return Type::Error;
}
};
// Query the module's exports. This is the magic line:
// - If the module hasn't been type-checked yet, Salsa runs the query now.
// - If it HAS been checked, Salsa returns the cached result.
// - Salsa records: "the caller depends on module_file's exports."
// - Later, if module_file changes, the caller's type check is invalidated.
module_exports(db, module_file)
}
}
Read through resolve_require carefully. The magic is in that one line: module_exports(db, module_file). That’s it. Salsa handles the rest.
First time? Salsa runs the query, caches the result. Already cached? Salsa returns the cached result instantly. Dependency tracked? Yes — Salsa knows the caller read module_file’s exports. Module changed? Salsa invalidates the caller’s type check automatically.
No manual dependency tracking. No invalidation logic. No “mark file dirty” calls. Salsa does it all.
Step 4: The File Registry
There’s a practical problem: resolve_require needs to find the SourceFile for a given path. But SourceFile is a Salsa input — it lives in the database. We need a way to look it up by path.
We use a FileRegistry input that stores the mapping:
#![allow(unused)]
fn main() {
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
#[salsa::input]
pub struct FileRegistry {
#[returns(ref)]
pub files: Vec<(PathBuf, SourceFile)>,
}
static REGISTRY: OnceLock<FileRegistry> = OnceLock::new();
fn find_source_file(db: &dyn salsa::Database, path: &Path) -> Option<SourceFile> {
let registry = REGISTRY.get()?;
let data = registry.files(db);
data.iter()
.find(|(p, _)| p == path)
.map(|(_, f)| *f)
}
}
The registry is stored in a OnceLock — set once during setup, never modified. This means Salsa’s purity contract is maintained in practice: the registry doesn’t change between revisions, so queries that read from it produce deterministic results.
⚠ A production language server would store the registry differently. The right approach is to add the FileRegistry as a field on the Database struct and access it through a custom trait that extends salsa::Database:
#![allow(unused)]
fn main() {
// Production pattern (not shown in this demo):
trait DbExt: salsa::Database {
fn registry(&self) -> &FileRegistry;
}
}
This ensures Salsa knows about the dependency and can invalidate queries if the registry ever changes. For this tutorial, the OnceLock approach is simpler and correct because the registry is write-once. But if you’re building a real language server where files can be added or removed during a session, use the database-field approach.
Step 5: Putting It Together
Let’s see the full system in action:
use salsa::Setter;
use salsa::Accumulator;
use std::path::{Path, PathBuf};
fn main() {
let mut db = Database::default();
let math_module = SourceFile::new(&db,
PathBuf::from("math.lua"),
"local function double(x) return x * 2 end\nreturn double\n".to_string(),
);
let main_file = SourceFile::new(&db,
PathBuf::from("main.lua"),
"local double = require(\"math\")\nlocal result = double(42)\nlocal sum = result + 1\n".to_string(),
);
let registry = FileRegistry::new(&db, vec![
(PathBuf::from("math.lua"), math_module),
(PathBuf::from("main.lua"), main_file),
]);
if REGISTRY.set(registry).is_err() {
panic!("registry already set");
}
// Type-check main.lua — this will also type-check math.lua
// because of the require() dependency
type_check(&db, main_file);
let diags = type_check::accumulated::<Diagnostic>(&db, main_file);
println!("main.lua: {} diagnostics", diags.len());
// Edit math.lua — change double to return a string
math_module.set_text(&mut db).to(
"local function double(x) return \"not a number\" end\nreturn double\n".to_string(),
);
// Re-check main.lua — Salsa knows it depends on math.lua
type_check(&db, main_file);
let new_diags = type_check::accumulated::<Diagnostic>(&db, main_file);
println!("After editing math.lua:");
println!("main.lua: {} diagnostics", new_diags.len());
// Output: main.lua: 1 diagnostics — "cannot use String in arithmetic"
}
The output:
main.lua: 0 diagnostics
After editing math.lua:
main.lua: 1 diagnostics — cannot use String in arithmetic
Before the edit, double returns Number, so result + 1 is fine. After the edit, double returns String, so result + 1 is a type error. Salsa detected the cross-file dependency automatically — we didn’t have to tell it that main.lua depends on math.lua.
The Architecture: Before and After
BEFORE (per-file only):
┌──────────┐ ┌──────────┐
│ utils.lua│ │ main.lua │
│ type_check│ │ type_check│
│ (isolated)│ │ (isolated)│
└──────────┘ └──────────┘
No connection. require() = Dynamic.
AFTER (cross-file):
┌──────────┐ module_exports ┌──────────┐
│ utils.lua│ ────────────────► │ main.lua │
│ type_check│ │ type_check│
└──────────┘ └──────────┘
Salsa tracks: Re-checks when
"main.lua read utils.lua's
utils.lua's exports" exports change
The key change isn’t the code — it’s the dependency. Salsa now knows that main.lua’s type check depends on utils.lua’s exports. Edit utils.lua, and main.lua is automatically re-checked. Don’t edit it, and the cached result is instant.
What About Cycles?
Lua allows circular requires (a.lua requires b.lua, which requires a.lua). Our type checker would loop forever — module_exports(a) calls module_exports(b) which calls module_exports(a) which…
Salsa handles this. When it detects a cycle, it panics (or recovers, if you’ve set up cycle recovery — see Chapter 8 on cycle detection). For a production type checker, you’d want cycle recovery that returns Dynamic for the cyclic dependency.
For this tutorial, we’ll treat circular requires as an error and move on. Chapter 8 covers the recovery pattern.
What We’re Simplifying
A real Lua require() system has nuances we’re skipping:
package.path and package.searchers: Lua’s module search is configurable. We use a single directory.
Module caching: Lua’s require() caches loaded modules. We rely on Salsa’s query cache instead.
package.loaded: You can pre-populate the module cache. We don’t model this.
Re-exports: A module might return another module’s result. Our type checker handles this naturally (it’s another return statement).
Field-level exports: We return a single Type for the module. A real type checker would return a HashMap<String, Type> mapping exported names to their types. This is a natural extension — the Type::Table variant and field_type method are already in place for it.
Function parameter types: Function parameters are typed as Dynamic. A complete type checker would support type annotations on parameters (function double(x: number): number).
Multiple return paths: We take the return type from the first return statement we encounter. A real type checker would merge types from all return paths (e.g., both branches of an if/else).
These simplifications keep the chapter focused on the core lesson: cross-file dependencies in Salsa are automatic. The dependency graph is implicit. You read a query; Salsa tracks it. You change an input; Salsa invalidates the right things. The code doesn’t change much — the architecture does.
Running
cargo run --bin ch09-cross-file
The tutorial now covers the full journey: from a single-file type checker to one that understands module dependencies across files. The Salsa patterns you’ve learned — inputs, tracked functions, interned symbols, tracked structs, accumulators, and cross-file queries — are the same patterns that power rust-analyzer.
Next: Chapter 10: Type Annotations — Our type checker treats every unannotated value as Dynamic. That’s the gradual typing guarantee — no annotations, no errors. But when the programmer does annotate, we should listen. We’ll parse LuaCATS annotations (---@param, ---@type, ---@return) and use them to override inferred types.