Proposal: Multi-File AOT Compilation
Status: Approved
Author: Eric (with Claude)
Created: 2026-02-01
Approved: 2026-02-01
Affects: compiler/ori_llvm/, compiler/oric/src/commands/compile_common.rs
Summary
Enable AOT compilation of Ori programs that use use statements to import functions from other files. Currently, ori build silently produces broken binaries when the source file imports from other modules.
Motivation
The Problem
When compiling a multi-file Ori program:
// helper.ori
pub @my_assert (b: bool) -> void = if b then () else panic(msg: "fail")
// main.ori
use "./helper" { my_assert }
@main () -> void = my_assert(b: true)
Running ori build main.ori -o main && ./main produces exit code 48 instead of 0.
Root Cause
The current compile_to_llvm function in compile_common.rs:
// Only compiles functions from the MAIN file
for func in &module.functions {
compiler.compile_function(func, arena, expr_types);
}
This has two fatal flaws:
- Imported functions are never compiled —
my_assertfromhelper.oriis never added to the LLVM module - Calls to missing functions silently fail — When codegen can’t find
my_assert, it returnsNoneand the call is omitted, producingret void
Impact
This blocks:
- Real-world Ori projects — Any project with imports fails silently
- AOT Test Backend proposal — Can’t run spec tests that use helper modules
- stdlib usage —
use std.math { sqrt }would fail in AOT
Current Workaround
None. Single-file programs work, but any imports produce broken binaries.
Design
Approach: Separate Object Files Per Module
Each Ori module compiles to its own object file. All object files are then linked together into the final executable. This matches the approved AOT proposal and enables parallel compilation.
┌─────────────────────────────────────────────────────────────────────┐
│ Multi-File AOT Compilation │
│ │
│ main.ori ──use "./helper"──► helper.ori ──use "./utils"──► utils.ori│
│ │
│ Compilation Order (topological): │
│ 1. utils.ori → build/obj/utils.o (no imports) │
│ 2. helper.ori → build/obj/helper.o (imports utils) │
│ 3. main.ori → build/obj/main.o (imports helper) │
│ │
│ Link: ld -o main build/obj/*.o libori_rt.a │
└─────────────────────────────────────────────────────────────────────┘
Key Design Decisions
1. Separate Object Files Per Module
Each .ori file produces one .o file. This:
- Enables parallel compilation of independent modules
- Integrates with existing incremental compilation infrastructure (21B.6)
- Matches the approved AOT proposal design
- Allows better caching (only recompile changed modules)
2. Reuse Existing Import Resolution
Use resolve_import from oric/src/eval/module/import.rs which already handles:
- Relative paths (
./helper,../utils) - Module paths (
std.math) - Directory modules (
./http→http/mod.ori) ORI_STDLIBenvironment variable- Cycle detection via
LoadingContext
3. Module-Qualified Name Mangling
Functions from different modules may have the same name. Use module-qualified names consistent with the approved AOT proposal:
; From main.ori
define void @_ori_main() { ... }
; From helper.ori
define void @_ori_helper_my_assert(i1 %b) { ... }
; From std/math.ori
define i64 @_ori_std_math_sqrt(i64 %n) { ... }
; From http/mod.ori
define i64 @_ori_http_mod_connect(...) { ... }
The mangling scheme follows _ori_<module-path>_<function-name>:
_ori_prefix for all Ori symbols- Module path with
/replaced by_ - Function name (without
@prefix)
This integrates with the existing ori demangle command.
4. Directory Module Support
When importing a directory module:
use "./http" { Client, get }
Resolution:
- Check if
http/mod.oriexists - If yes, resolve
httptohttp/mod.ori - Compile
mod.oriand its re-exported dependencies
src/
├── main.ori → build/obj/main.o
└── http/
├── mod.ori → build/obj/http_mod.o
├── client.ori → build/obj/http_client.o (if re-exported)
└── server.ori → (not compiled if not used)
5. Type Signature Propagation
Imported functions need correct type signatures. Use the existing type checker results from each module via Salsa queries.
What Gets Compiled
Explicitly imported functions:
use "./helper" { my_assert } // Compiles @my_assert
All public functions for module aliases:
use "./helper" as h // Compiles all pub functions
h.my_assert(b: true)
Private functions with :: prefix:
use "./helper" { ::internal } // Compiles @internal
Transitive dependencies:
If @my_assert calls @internal_helper, @internal_helper is also compiled even if not explicitly exported.
Implementation
Leverage Existing Infrastructure
The implementation uses existing 21B infrastructure:
// compiler/ori_llvm/src/aot/multi_file.rs
use crate::aot::incremental::{DependencyGraph, Cache};
use crate::aot::object::emit_object;
use crate::aot::linker::link;
pub fn compile_multi_file(
entry_path: &Path,
options: &CompileOptions,
build: &BuildConfig,
) -> Result<(), CompileError> {
// 1. Build dependency graph (existing: incremental/deps.rs)
let graph = DependencyGraph::from_entry(entry_path)?;
// 2. Detect cycles (via LoadingContext, reuses no-circular-imports logic)
graph.check_cycles()?;
// 3. Topological sort for compilation order
let order = graph.topological_order()?;
// 4. Check cache for each module (existing: incremental/cache.rs)
let to_compile: Vec<_> = order.iter()
.filter(|m| !Cache::is_valid(m, options))
.collect();
// 5. Compile modules in parallel (existing: incremental/parallel.rs)
let objects: Vec<PathBuf> = to_compile.par_iter()
.map(|module| {
let obj = compile_module_to_object(module, options)?;
Cache::store(module, &obj, options)?;
Ok(obj)
})
.collect::<Result<Vec<_>, _>>()?;
// 6. Collect all objects (cached + newly compiled)
let all_objects = collect_all_objects(&order, &build.cache_dir)?;
// 7. Link all objects (existing: aot/linker.rs)
link(&all_objects, &build.output_path, &options.target, build.link_mode)?;
Ok(())
}
Updated compile_to_llvm
pub fn compile_to_llvm<'ctx>(
context: &'ctx Context,
db: &CompilerDb,
source_path: &Path,
) -> Result<Module<'ctx>, CompileError> {
let file = SourceFile::new(db, source_path)?;
// Parse and type-check (Salsa cached)
let parse_result = parsed(db, file);
let type_result = typed(db, file);
// Check for errors
if parse_result.has_errors() || type_result.has_errors() {
return Err(CompileError::CheckFailed { path: source_path.to_owned() });
}
// Generate module name from path
let module_name = source_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("module");
let compiler = ModuleCompiler::new(context, db.interner(), module_name);
compiler.declare_runtime();
// Register struct types from this module
for type_decl in &parse_result.module.types {
if let TypeDeclKind::Struct(fields) = &type_decl.kind {
let field_names: Vec<_> = fields.iter().map(|f| f.name).collect();
compiler.register_struct(type_decl.name, field_names);
}
}
// Compile functions from this module (with mangled names)
let arena = &parse_result.arena;
let expr_types = &type_result.expr_types;
for func in &parse_result.module.functions {
compiler.compile_function_mangled(func, module_name, arena, expr_types);
}
// Declare external symbols for imports (resolved at link time)
for import in &parse_result.module.imports {
compiler.declare_imports(import, db)?;
}
Ok(compiler.into_module())
}
Visibility Rules
Only compile functions that are:
- Explicitly imported:
use "./mod" { func_a, func_b } - Or all public functions for module aliases:
use "./mod" as m - Or private with
::prefix:use "./mod" { ::private_fn } - Or transitively called by any of the above
Error Handling
Errors integrate with the existing diagnostic system:
// In ori_diagnostic (or appropriate location)
/// E5004: Import target not found
pub struct ImportNotFound {
pub path: String,
pub searched: Vec<PathBuf>,
pub span: Span,
}
/// E5005: Imported item not found in module
pub struct ItemNotFound {
pub item: String,
pub module: PathBuf,
pub available: Vec<String>, // For "did you mean?" suggestions
pub span: Span,
}
/// E5006: Imported item is private
pub struct PrivateItem {
pub item: String,
pub module: PathBuf,
pub span: Span,
}
Error messages follow the diagnostic style guide:
error[E5004]: import target not found
--> src/main.ori:1:5
|
1 | use "./nonexistent" { helper }
| ^^^^^^^^^^^^^^^ module not found
|
= note: searched: src/nonexistent.ori, src/nonexistent/mod.ori
= help: check that the file exists and the path is correct
error[E5006]: `secret` is private
--> src/main.ori:1:21
|
1 | use "./internal" { secret }
| ^^^^^^ cannot import private item
|
= help: use `{ ::secret }` for explicit private access (testing only)
= help: or make `secret` public with `pub @secret`
Alternatives Considered
1. Single LLVM Module for All Files
Compile all files into one LLVM module instead of separate objects.
Rejected:
- Doesn’t enable parallel compilation
- Cache invalidation affects entire module
- Inconsistent with approved AOT proposal design
- Memory pressure with large projects
2. Generate External Declarations Only
For imported functions, just generate declare without define, relying on linker.
Considered and Adopted: This is actually the correct approach for separate compilation—each module’s object file declares (but doesn’t define) symbols from imports. The linker resolves them.
3. Inline Imported Functions at Call Sites
Copy the function body into the calling module.
Rejected:
- Duplicates code in each object file
- Breaks if imported function calls other functions from its module
- Memory inefficient
- LTO can achieve inlining at link time anyway
Implementation Plan
Phase 1: Dependency Graph Infrastructure
- Add
DependencyGraph::from_entry()using existing import resolution - Implement topological sorting for compilation order
- Wire cycle detection to error reporting (reuse E5003)
- Handle directory modules (
mod.ori)
Phase 2: Per-Module Compilation
- Add
compile_module_to_object()inmulti_file.rs - Implement module-qualified name mangling (
_ori_<module>_<function>) - Generate
declarefor imported symbols - Update
ori demangleto handle module paths
Phase 3: Linking Integration
- Collect all object files for linking
- Pass correct library search paths for stdlib
- Handle
ORI_STDLIBfor std.* imports
Phase 4: Cache Integration
- Wire up incremental cache (21B.6) to skip unchanged modules
- Store module hashes including import signatures
- Invalidate dependents when a module changes
Phase 5: Testing
- Unit tests for
DependencyGraph - Unit tests for module-qualified mangling
- Integration tests with multi-file programs
- Integration tests with directory modules
- Integration tests with stdlib imports
- Run existing spec tests that use imports through AOT
Testing
Test Cases
- Basic import:
use "./helper" { func }compiles and runs - Transitive imports: A imports B imports C
- Circular import detection: A imports B imports A → clear error (E5003)
- Missing import:
use "./nonexistent"→ clear error (E5004) - Missing item:
use "./mod" { nonexistent }→ clear error (E5005) - Private function:
use "./mod" { private }→ error (E5006) without:: - Private with :: :
use "./mod" { ::private }→ compiles - Module alias:
use "./mod" as mthenm.func() - Directory module:
use "./http"resolves tohttp/mod.ori - Re-exports:
pub use "./internal" { helper }inmod.ori - Stdlib import:
use std.math { abs }withORI_STDLIBset - Parallel compilation: Multiple independent modules compile in parallel
- Incremental rebuild: Change one module, only that module recompiles
Success Criteria
# This should work after implementation:
echo 'pub @helper () -> int = 42' > /tmp/helper.ori
echo 'use "./helper" { helper }
@main () -> void = assert(condition: helper() == 42)' > /tmp/main.ori
ori build /tmp/main.ori -o /tmp/main
/tmp/main # Should exit 0
# With verbose output showing separate compilation:
ori build /tmp/main.ori -o /tmp/main -v
# Compiling /tmp/helper.ori -> /tmp/build/obj/helper.o
# Compiling /tmp/main.ori -> /tmp/build/obj/main.o
# Linking /tmp/build/obj/*.o -> /tmp/main
Dependencies
- Blocks: AOT Test Backend proposal (can’t run multi-file tests without this)
- Depends on: None (uses existing import resolution infrastructure)
- Related:
no-circular-imports-proposal.md(cycle detection)module-system-details-proposal.md(import semantics)aot-compilation-proposal.md(object emission, linking)
Summary
Multi-file AOT compilation is a critical missing feature that causes silent failures for any Ori program using imports. The fix:
- Uses existing import resolution to build a dependency graph
- Compiles each module to its own object file (enables parallelism, caching)
- Uses module-qualified name mangling (
_ori_<module>_<function>) - Declares imported symbols (linker resolves them)
- Integrates with existing incremental compilation infrastructure
This matches the interpreter’s semantics while enabling efficient compilation of large projects.
Design Decisions
- Separate objects per module: Enables parallel compilation and incremental rebuilds
- Module-qualified mangling:
_ori_<module>_<function>format, consistent with AOT proposal - Linker-resolved imports: Each object declares imports; linker resolves
- Full module support: Handles both file imports and directory modules (
mod.ori) - Error integration: Uses existing diagnostic system with E5004-E5006 error codes