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:server

Collections

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

OptionTypeDefaultExampleRequired
buildGenerateboolfalseno
collectionsmap<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().

ParameterTypeDescription
versionstringOdin release version (matches .odin-version).
[opts]object
[opts.default=false]booleanSet as the default toolchain.
[opts.lockfile]stringLockfile address pinning download SHA-256s;
[opts.unverified=false]booleanAllow downloading without a
[opts.linker]objectGraph-native linker toolchain handle (e.g.
[opts.mingwCrt=false]booleanOn Windows, switch Odin's lld-link
[opts.lldOnWindows=false]booleanOn Windows, switch Odin's link

Returns: object Tool handle for this Odin toolchain.