Odin
The Odin rules build binaries, run test packages, validate source with
odin check -vet, execute a selected binary, publish artifacts, and generate
source files. Odin packages may depend on other Odin targets, resource targets,
and CMake libraries; the rule assembles the transitive sources, collection
flags, native link inputs, and managed tools needed by the sandboxed command.
Set up the workspace
Import the compiler and formatter rules in imp.workspace.js, then load the
workflows you use. The rules provide pinned defaults:
import "//rules/odin";
import "//rules/odin/odinfmt";
import "//rules/workflows/fmt";
import "//rules/workflows/lint";
import "//rules/workflows/package";
import "//rules/workflows/run";
import "//rules/workflows/test";
Override a default only when needed, for example to select a managed linker. Keep that choice on the Odin toolchain so build, test, run, and lint resolve the same native environment.
This repository's workspace imports //rules/imp/mode: the default profile
keeps Odin's -debug build behavior, while imp build --profile release ...
uses Odin's -o:speed optimization mode.
Select a workspace lockfile
The rules ship a lockfile for each managed tool. It pins the download URL, size, and SHA-256 of every release artifact, and it knows only the versions the rules ship with. To pin a version that the shipped lockfile does not know, give the toolchain the address of a lockfile that this workspace owns. The shipped lockfiles stay the default.
export const odinToolchainDefault = odinToolchain("dev-2026-05", {
default: true,
lockfile: "//locks/odin.lock",
});
export const odinfmtDefault = odinfmtToolchain(undefined, {
default: true,
lockfile: "//locks/odinfmt.lock",
});
Export the lockfile generation roots, then write the two files:
import { odinGenLockfiles } from "//rules/odin";
import { odinfmtGenLockfiles } from "//rules/odin/odinfmt";
export const odinLockfiles = odinGenLockfiles();
export const odinfmtLockfiles = odinfmtGenLockfiles();imp goal gen-lockfiles //:odinLockfiles //:odinfmtLockfiles
Each generation root writes to the address that its toolchain declares, so
the address is given one time only. Downloads stay verified: an address with
no file, or a lockfile with no entry for the selected version and platform,
makes the acquire fail and points at imp goal gen-lockfiles.
Declare packages and tests
import { odinPackage, odinTestPackage } from "//rules/odin";
export const server = odinPackage({
path: ".",
output: "build/server",
});
export const server_tests = odinTestPackage({
path: ".",
deps: [server],
});
An odinPackage defaults to *.odin and excludes *_test.odin and
test_*.odin. An odinTestPackage defaults to exactly those two globs and
participates in the test goal through odin test. The two defaults are
mirror images, so tests beside the code need no srcs on either target.
Override srcs and exclude with globs relative to path when a package uses
another layout.
Odin compiles a directory as one package, so a test package that shares its
directory with the package under test cannot link against it — it is the same
package, compiled with its test files. That is what the deps: [server] above
does: the two source sets land at the same sandbox path and odin test sees
one package. The ordinary sources stay declared one time, rather than in a
second glob to keep in sync by hand.
A directory that holds only tests needs no such dep — it is an ordinary
package that happens to be all tests. Give it srcs when its files do not
carry the test suffix, or the default glob matches nothing.
imp lint --fix is accepted goal-wide but has no effect for Odin packages:
odin check -vet has no autofix mode, so --fix just runs the same plain
lint.
Set output when a package needs a stable workspace-relative executable path.
package publishes the built result below dist/ according to the target
address. run executes one selected package and rejects ambiguous
multi-target selections.
imp build //apps/server:server
imp test //apps/server:server_tests
imp fmt --check //apps/server:server
imp lint //apps/server:server
imp run //apps/server:server
imp package //apps/server:serverCollections
Use workspace configuration for collection names shared by many packages:
export const odinConfig = {
collections: {
core: "src/core",
vendor: "third_party/odin",
},
};
The schema is a dynamic map<string, string>: collection names are not fixed
in advance, but every key and path is validated. Paths are workspace-relative.
Package-local collection entries belong on the target and override a workspace entry with the same name:
export const editor = odinPackage({
collections: {
generated: "generated/odin",
},
});
Local collections may also use collection target handles or { name, path }
entries when a plain name-to-path object is not sufficient. Collection
directories are included as declared sandbox inputs, not merely converted into
compiler flags: the rule walks the package's imports transitively and declares
every directory it reaches, whether that directory is a declared
odinPackage() or an undeclared tree such as a vendored library. Only the
collections those imports actually use become -collection: flags. An import
that resolves to a workspace path with no Odin package behind it fails the
build with the importing package and the resolved path, rather than reaching
the compiler as a broken collection flag.
Generate sources and BUILD files
Generated sources come from codegen() in //rules/imp/codegen, which is not
Odin-specific — it declares files a command writes into the graph, for any
ecosystem. generatedSrcs takes its result directly and stages every file the
result declares. Exclude a generated path from any overlapping odinPackage()
glob so one file has one owner:
import { codegen } from "//rules/imp/codegen";
import { nativeTool } from "//rules/imp/native-tool";
import { odinPackage } from "//rules/odin";
import { files } from "imp:core";
const bindings = codegen({
display: "generate Odin bindings",
tools: { generator: nativeTool("schema-to-odin") },
inputs: { schema: files({ root: "app", include: ["schema.json"] }) },
outputPaths: ["app/generated/bindings.odin"],
argv: (exec, { generator }) => [
exec.tool(generator, "schema-to-odin"),
"app/schema.json",
"app/generated/bindings.odin",
],
});
export const app = odinPackage({
path: "app",
exclude: ["generated/bindings.odin"],
generatedSrcs: [bindings],
});
outputPaths are workspace-relative, and a nested path needs no mkdir — the
executor creates the parent directory of each declared output before the
program starts. The older explicit form, generatedSrcs: [{ artifact, path }]
with path relative to the package, still works.
To write generated files into the workspace instead of into the graph — for
committed, drift-gated codegen — use generatedFiles() from
//rules/imp/generate and imp generate.
A package's generatedSrcs follow it through the source closure, like its
native deps: declare a generated source on the package that owns it, and every
package that reaches it gets the file staged. Two packages may name the same
artifact at the same workspace path — that is one input — but two different
artifacts claiming one path is a declaration error, since one would overwrite
the other in the sandbox.
Separately, imp goal generate-build can create declarations for unowned
Odin sources. Opt in with odinConfig.buildGenerate: true; it is disabled by
default.
Native (ccLibrary/CMake) dependencies
deps also accepts a raw ccLibrary() result, or a cmakeLibraryDep()
adapting a CMake target (see //rules/c, //rules/c/cmake). Its built
archive is staged into the sandbox at its real workspace-relative path, so a
foreign import can reference it directly — resolved, per the Odin
compiler, relative to the importing .odin file's own directory:
import { ccLibrary } from "//rules/c";
import { odinPackage } from "//rules/odin";
export const sqlite = ccLibrary({ path: "vendor/sqlite" });
export const app = odinPackage({
deps: [sqlite],
});// app.odin (at the odinPackage's own path "."): ccLibrary()'s archive
// always lands at "build/c/<slug>.a", workspace-root-relative regardless of
// the library's own path — adjust the "../" prefix for the importing
// package's own directory depth.
foreign import sqlite "build/c/vendor_sqlite.a"
A native dep belongs to the package whose own source names it. One odin build
compiles the whole import closure, so an archive that any package in that
closure needs is staged for the compilation — and a package inherits the
archives and transitiveLinkopts of every Odin package it reaches, whether by
deps or by a bare import. Declare a native dep one time, on the package
whose foreign import names it; consumers declare only what their own sources
need.
Shared libraries
A ccLibrary({ shared: true }) dep works the same way, with one addition. An
archive becomes part of the executable, but a shared library stays a separate
file that the loader must find again at run time. So a binary that reaches one
gets a directory product: it holds the executable plus every shared library
the closure contributed, and Odin links every binary with an $ORIGIN rpath,
which makes the loader look in the executable's own directory.
export const mathlib = ccLibrary({ path: "vendor/mathlib", shared: true });
export const app = odinPackage({ deps: [mathlib] });// A shared library lands at "build/c/lib<slug>.so" ("<slug>.dll" on Windows).
foreign import mathlib "build/c/libvendor_mathlib.so"
imp package publishes the directory, so the library travels with the binary.
A binary with no shared dep keeps the single-file product it has always had.
odin test is the exception: it compiles and runs in one step, so there is no
product to carry the library in. The staged library's own directory is put on
LD_LIBRARY_PATH for that action instead, which needs nothing from you.
deps also takes a plain graph handle — a files() set of fixture data, a
task output — and stages it into the sandbox as it is:
const test_pem = files({ include: ["testdata/*.pem"] });
export const client_tests = odinTestPackage({ deps: [client, test_pem] });
A dep of no recognized shape is a declaration error. It would otherwise contribute nothing, which reads the same as never declaring it, and shows up as a missing file much later.
unsafeSystemPaths is the one thing that does not travel that path. It
bypasses a guard, so every package states its own — the same rule //rules/c
already applies to ccLibrary() and ccBinary().
A dep's own transitiveLinkopts (e.g. a cmakeLibraryDep({linkopts: [...]})
wrapping a shared library that itself depends on host system packages) fold
into the final odin build's own linker invocation automatically, as a
single -extra-linker-flags: argument. The default GCC toolchain
(//rules/c/gcc) is a Bootlin external toolchain whose compiler wrapper
rejects any -L flag pointing under /usr/lib — since Odin links via that
same toolchain, pass unsafeSystemPaths: true on the odinPackage() itself
to bypass that guard for its own linker invocation (independent of, and in
addition to, unsafeSystemPaths on any ccLibrary()/cmakeProject() dep —
see //rules/c's own docs):
import { cmakeLibraryDep, cmakeProject } from "//rules/c/cmake";
import { odinPackage } from "//rules/odin";
const project = cmakeProject({ path: "third_party/webview", unsafeSystemPaths: true });
export const app = odinPackage({
deps: [
cmakeLibraryDep(project, "webview", {
includeDirs: ["third_party/webview/include"],
linkopts: ["-L/usr/lib/x86_64-linux-gnu", "-lwebkit2gtk-4.1", "-lgtk-3"],
}),
],
unsafeSystemPaths: true,
});Configuration
| Option | Type | Default | Example | Required |
|---|---|---|---|---|
| buildGenerate | bool | false | — | no |
| collections | map<string, string> | {} | {"vendor":"//src/odin/vendor"} | no |
Example
export const odinConfig = {
buildGenerate: false,
collections: {},
};odinToolchain()
odinToolchain(version, opts = {})
Declare an Odin toolchain version and optionally set it as the default. defaults to the bundled //rules/odin/odin.lock. Point this at your own lock (regenerate via imp goal gen-lockfiles) when pinning a version the bundled lock does not know. matching lockfile entry (warns instead of failing). moldGraphToolchain()'s { tool, version } shape, //rules/c/mold) Odin should use instead of the gcc toolchain's default ld. Read back via odinLinkerFor()/defaultOdinLinkerToolchain(). step to the mingw-CRT path (-no-crt plus the gcc/mingw runtime archives) instead of the default, which leaves Odin's own MSVC CRT linking in place. Set this when this workspace's Windows C/C++ deps (e.g. cmakeProject()/ccLibrary() built via //rules/c/gcc's default gcc toolchain) are themselves mingw-built and so need mingw's CRT/UCRT symbols rather than MSVC's (operator new, __CxxFrameHandler4, __security_cookie, ...). Only meaningful alongside opts.lldOnWindows — see that option's own docstring. Read back via odinUsesMingwCrt()/ defaultOdinUsesMingwCrt(). step from the default system link.exe to Odin's bundled lld-link.exe. Off by default: system link.exe auto-detects the Visual Studio/Windows SDK install and needs no further plumbing from this workspace. Set this only when this workspace's Windows C/C++ deps include gcc/mingw-built object code — lld-link reads both MSVC- and GCC-style COFF objects, system link.exe reliably only reads MSVC's — and be prepared to also supply the MSVC LIB/INCLUDE search path yourself (e.g. via //rules/c/msvc's msvcEnv()), since unlike system link.exe, lld-link does not auto-detect it. Read back via odinUsesLldOnWindows()/ defaultOdinUsesLldOnWindows().
| Parameter | Type | Description |
|---|---|---|
| version | string | Odin release version (matches .odin-version). |
| [opts] | object | |
| [opts.default=false] | boolean | Set as the default toolchain. |
| [opts.lockfile] | string | Lockfile address pinning download SHA-256s; |
| [opts.unverified=false] | boolean | Allow downloading without a |
| [opts.linker] | object | Graph-native linker toolchain handle (e.g. |
| [opts.mingwCrt=false] | boolean | On Windows, switch Odin's lld-link |
| [opts.lldOnWindows=false] | boolean | On Windows, switch Odin's link |
Returns: object Tool handle for this Odin toolchain.