Skip to content

Instantly share code, notes, and snippets.

@CypherpunkSamurai
Last active July 19, 2026 10:04
Show Gist options
  • Select an option

  • Save CypherpunkSamurai/d0ade2769096e8b5fccb66c76d79c02a to your computer and use it in GitHub Desktop.

Select an option

Save CypherpunkSamurai/d0ade2769096e8b5fccb66c76d79c02a to your computer and use it in GitHub Desktop.
RUST to ODIN Porting

Rust → Odin porting guide

You are translating one Rust file to Odin. Read this whole document before writing any code. The goal of Phase A is a draft .odin next to the .rs that captures the logic faithfully — it does not need to compile. Phase B makes it compile package-by-package.

This guide is project-agnostic. If you are porting a specific codebase, fill in the "Package map" table below with your crate→package layout before starting — everything else applies generally.

Ground rules

  • Write the .odin in the same directory as the .rs, same basename (socket.rssocket.odin), unless that directory is not yet an Odin package (no other .odin files) — in that case this file becomes the package's entry file and should be named after the package (lib.rs/mod.rs<package_name>.odin).
  • One Odin package per directory, same as Rust's one crate/module-tree per directory. Don't invent sub-packages Rust didn't have as separate modules.
  • No async runtime, no macro system, no trait objects exist in Odin. Where the Rust leans on any of these, see the dedicated sections below — don't invent ad-hoc workarounds before reading them.
  • Match the Rust's structure. Same fn names (snake_case in both languages — this carries over almost for free), same field order, same control flow. Phase B reviewers diff .rs.odin side-by-side. Type names become Pascal_Case per Odin convention (HttpClientHttp_Client) — apply this mechanically and consistently.
  • Leave // TODO(port): <reason> for anything you can't translate confidently. Don't guess. Flagging is better than wrong code. This is especially important for: macro-generated code, trait-object dispatch, async state machines, and anything relying on the borrow checker for correctness rather than just compile-time bookkeeping.
  • Leave // PERF(port): <rust idiom> — profile in Phase B wherever the Rust used a perf-specific idiom (with_capacity, Cow, SIMD intrinsics, #[inline(always)], arena allocation) and the port uses the plain Odin form.
  • Odin has no borrow checker. This is not a license to be sloppy — it means the compiler will not catch use-after-free, double-free, or aliased mutation. Anywhere the Rust code's correctness depended on &mut exclusivity, leave // TODO(port): verify aliasing — was &mut exclusive in Rust.
  • Odin has no move semantics. y := x in Odin copies x; the original x remains valid and usable. Where Rust's logic depended on the old binding being dead (a Vec/String/Box whose buffer is now solely owned by y), the Odin port will have two headers pointing at the same backing buffer — the single most common silent bug in this kind of port. Leave // TODO(port): was a move — verify no aliasing at every such site.
  • unsafe {} has no Odin equivalent block. Where Rust marked something unsafe, keep a // SAFETY: <why> comment mirroring the Rust invariant — it's load-bearing documentation for Phase B reviewers.
  • Odin's panic does not unwind and does not run defer. This is a deliberate language design choice (Odin has no exceptions, by design — see §Ownership), not an oversight, but it is a real behavioral gap from Rust's default. Rust's default panic strategy (panic=unwind) walks the stack on panic and runs every live value's Drop impl on the way out — a lock guard still unlocks, a file handle still flushes, even on the failure path. Odin's panic() (and any fault the runtime turns into one — an out-of-bounds index, a failed type assertion, a nil dereference) halts the program immediately; scopes on the stack above it do not run their defer statements. A Rust file that leaned on Drop firing during a panic to leave the world in a consistent state has no direct Odin equivalent — port the logic, but flag every such site with // TODO(port): Rust ran Drop on panic-unwind here, Odin's panic does not run defer — verify no cleanup was load-bearing on this path, since this is silent and easy to miss until it matters in production, not at compile time.
  • Odin never panics on integer overflow, in any build mode. Rust's +, -, * on a plain integer type panic on overflow in a debug_assertions build (the default for cargo build/cargo test) and silently wrap in a release build — meaning overflow bugs are often caught during Rust development by that debug-mode panic. Odin's +/-/* on a fixed-width integer always wrap (are always well-defined, two's-complement for signed, modulo-2ⁿ for unsigned) with no panic in either build mode — there's no Odin equivalent to cargo build's implicit overflow checking to catch this class of bug for you. If the Rust source used plain +/-/* (not an explicit wrapping_*/checked_*/saturating_* call), don't assume "the Rust never overflowed in practice" — that may have been true only because debug builds were panicking on it during testing. Port plain arithmetic to plain Odin arithmetic, but for anything security- or correctness-sensitive (buffer sizes, array indices, loop bounds derived from arithmetic), prefer bits.overflowing_add/_sub/_mul (core:math/bits, returns (result, overflowed) — see §Bit manipulation) over bare +/-/* and handle the overflow explicitly, rather than silently inheriting a safety net the Rust build had and the Odin build does not.
  • Prereq for every package: if the Rust used a non-default global allocator (mimalloc, jemalloc, a custom #[global_allocator]), set the equivalent via context.allocator at the program's entry point. Phase B wires this; Phase A can assume it's done.

Commenting & documenting the port

A ported file needs to tell a Phase B reviewer three things at a glance: that it's a port, where it came from, and which specific decisions need a second look versus which lines are routine mechanical translation. Getting this wrong in either direction costs real time — under-commenting loses the reasoning behind a non-obvious choice; over-commenting (a note on every renamed identifier, every dropped lifetime) trains the reviewer to stop reading comments carefully, which is worse than having none, because it buries the comments that actually matter.

File header

Every ported .odin file opens with a header comment, immediately after the package declaration:

package foo

// ─────────────────────────────────────────────────────────────────────────
// PORTED FROM RUST — src/foo/bar.rs
//   Phase A draft; not yet verified to compile. See PORT STATUS at the
//   end of this file for confidence level and outstanding TODOs.
//   Tags used below: TODO(port), PERF(port), SAFETY, PORT NOTE — each is
//   defined in the porting guide's "Commenting & documenting the port"
//   section. Identifiers renamed to Odin convention (Pascal_Case types,
//   snake_case procs) throughout; not individually annotated.
// ─────────────────────────────────────────────────────────────────────────

import "core:fmt"
// ...

Update the second line (Phase A draft...) to Phase B: compiles, logic pending re-verification or Phase B: complete as the file progresses — this line is the one thing in the header that goes stale, so it's the one thing worth keeping current. The rest of the header is static boilerplate and can be templated once per project.

Comment tag taxonomy

Four tags cover everything worth flagging in a ported file. Don't invent a fifth without good reason — a small, consistent vocabulary is what lets Phase B grep for exactly the class of issue it's currently working through (grep 'TODO(port)' to find untranslated logic, grep 'PERF(port)' to find profiling candidates, etc.).

Tag Use when Example
// TODO(port): <reason> The Rust construct has no confident Odin translation yet — a macro not expanded, a trait not yet assigned a dispatch strategy, an async fn not yet redesigned. This is a logic gap: the port is incomplete at this line. // TODO(port): was macro_rules!, hand-expand — 4 call sites
// PERF(port): <rust idiom> — profile in Phase B The translation is logically complete and correct, but traded a Rust perf-specific idiom (arena, Cow, SIMD intrinsic, #[inline(always)]) for the plain idiomatic Odin form. This is not a logic gap — the code works — it's a flag for Phase B's profiler. // PERF(port): was Cow — may now always-copy
// SAFETY: <why> Mirrors a Rust unsafe block's invariant at a raw-pointer op, cast, or FFI call. Odin doesn't enforce anything here (see Ground rules), so this comment is the only record of why the operation is sound — treat it as load-bearing, not optional, even though nothing will fail to compile if you skip it. // SAFETY: buf[len] was written above, ptr is valid for len bytes
// PORT NOTE: <explanation> The Odin code is shaped differently from a literal line-by-line translation for a structural reason the diff reader needs to know to avoid "fixing" it back to the wrong shape — a dropped lifetime, a move that's now a copy, an intentionally-skipped defer (ported mem::forget), a collapsed Pin<Box<T>>. This is the catch-all for "the diff looks surprising but is correct, here's why." // PORT NOTE: was a move — verify no aliasing
// PORT NOTE (cosmetic): <old> → <new> A qualified PORT NOTE, reserved for the rare cosmetic change that isn't covered by the blanket renaming-convention statement in the file header and could plausibly be mistaken for a semantic change by a diff reader — e.g. a field reordered for Odin struct alignment when the Rust field order looked intentional, or a name changed beyond mechanical case conversion because the Rust name was actively misleading in Odin's context. Do not use this for routine HttpClientHttp_Client casing — that's already covered once, in the header, for the whole file. // PORT NOTE (cosmetic): field order changed for alignment, not semantics

What not to comment

The Ground rules already establish a set of mechanical, whole-file conventions — apply them silently, and let the file header's blanket statement stand in for a comment at every individual site:

  • Identifier casing (HttpClientHttp_Client, parse_json stays parse_json).
  • mod foo; becoming a subdirectory/file with no mod statement.
  • A dropped lifetime that was elided in the Rust to begin with (only non-trivial lifetimes get a PORT NOTE, per §Lifetimes — an elided fn foo(x: &T) -> &T needs nothing).
  • impl Foo { fn bar(&self) {...} } becoming foo_bar :: proc(self: ^Foo) {...} when the reshaping is the standard mechanical pattern from the Idiom map, with no logic change.
  • An empty Drop body (only frees owned fields) being deleted entirely, per §Ownership — this is the expected, silent case; only a Drop body with side effects beyond freeing gets a PORT NOTE.
  • use lines collapsing into import lines.

If you find yourself writing a comment that just restates a rule already in this guide's Ground rules or Idiom map with no file-specific information added, delete the comment — it's noise, and it's the kind of noise that makes the next TODO(port) easier to skim past by mistake.

Package map

Rust Odin
crate_name::module::Thing import "path/to/crate_name" then module.Thing if module is a sub-package, or crate_name.Thing if flat
external crate with no Odin equivalent vendor under vendor:<name> if a port exists; otherwise write the minimal subset needed in a _sys-style FFI package and leave // TODO(port): no vendor equivalent for <crate>
mod foo; / mod foo { ... } a subdirectory package foo/, or keep in the same file if it was just visibility scoping — Odin has no inline mod {} blocks
pub use re-export not idiomatic in Odin; update call sites in Phase B to import the defining package directly
Cargo.toml dependency nothing 1:1 — Odin has no package manager; dependencies are vendored or fetched as git submodules. Phase B resolves.

Visibility & privacy

Rust and Odin default to opposite visibility. A Rust item with no pub is private to its module tree; an Odin item with no attribute is public to every importer of its package. Every item in a ported file needs its visibility re-derived from the Rust source, not left at Odin's default — silently leaving everything public is the single easiest way for this port to widen a crate's API surface without anyone deciding to.

Rust Odin
no modifier (private to the defining module and its descendants) @(private) on the item — package-private, the closest match. Note the grain is different: Rust privacy is per-module (often one file, but a module can span multiple files via mod foo { mod bar; }-style nesting, and child modules see their ancestors' private items); Odin's @(private) is per-package (a whole directory). If the Rust module tree had meaningful sibling-vs-descendant privacy distinctions within one crate, those collapse once the corresponding Odin packages are all @(private) at the package level — flag // PORT NOTE: Rust had finer-grained module privacy than package-level @(private) preserves where that distinction was load-bearing.
pub no attribute — Odin's default. Exported from the package to any importer, matching pub's crate-external visibility.
pub(crate) no attribute (Odin default) if the whole Rust crate became one Odin package tree with no further subdivision; @(private) if the ported package structure is more fine-grained than the original crate and pub(crate)'s intent was "share within this crate but not with sibling packages" — judge by what the Rust code was actually trying to keep internal, not by the keyword alone.
pub(super) / pub(in some::path) no direct equivalent — Odin has no path-scoped visibility, only "this package" or "this file." Use @(private) (package-scoped) as the nearest approximation and leave // PORT NOTE: was pub(super), narrowed/widened to package-private since the exact scope won't match.
a fn/struct/const private to one file, not shared even with sibling files in the same Rust module @(private="file") — this is Odin's most restrictive visibility, tighter-grained than anything in Rust's module system (Rust has no "this file only, not even sibling files in the same module" level), and is the correct target only when the Rust item truly never left its own file.
#[deprecated] / #[deprecated(note = "...")] @(deprecated=...) on the item — Odin has a deprecation attribute with the same intent (warn at every use site); check the compiler's current syntax for the message argument before relying on the exact form, since this wasn't confirmed against a worked example during this pass

Type map

Rust Odin notes
i8/i16/i32/i64/i128/isize i8/i16/i32/i64/i128/int identical names except isizeint (Odin's int is pointer-sized)
u8/u16/u32/u64/u128/usize u8/u16/u32/u64/u128/uint
f32/f64 f32/f64 1:1
bool bool 1:1
char rune both are a single Unicode scalar value (4 bytes)
&str string Odin string is {data: ^u8, len: int} — same semantics as &str. Note: not compiler-enforced UTF-8. If the Rust leaned on str's UTF-8 guarantee for safety, leave // TODO(port): Rust guaranteed UTF-8 here, Odin does not.
String string (immutable final) or strings.Builder (while constructing) no separate owned/growable string type — build with Builder, call strings.to_string(b) to finalize
&[u8] / Vec<u8> (byte buffer) []u8 / [dynamic]u8
&[T] []T Odin slices do not distinguish mutable/immutable — that guarantee is unenforced
&mut [T] []T same type as above
[T; N] [N]T fixed-size array; Odin puts the size first
Vec<T> [dynamic]T .push(x)append(&v,x) · .pop()pop(&v) · .len()len(v) · .clear()clear(&v) · .remove(i)ordered_remove(&v,i) · .swap_remove(i)unordered_remove(&v,i) · drop→delete(v)
Box<T> ^T allocate: new(T) (zeroed) or new_clone(val); free: free(ptr). No automatic drop.
Box<[T]> []T (heap-backed, owned) allocate: make([]T, n); free: delete(slice)
Rc<T> / Arc<T> no built-in equivalent hand-roll struct { value: T, ref_count: int } behind ^T with explicit _retain/_release procs. Leave // TODO(port): Rc/Arc — verify single owner or hand-roll refcounting. Arc atomicity → core:sync atomics on the count field.
Weak<T> no built-in equivalent keep a raw ^T + a manual "still alive" check (tombstone bit, generation counter)
RefCell<T> / Cell<T> plain T behind ^T RefCell's runtime borrow tracking existed only to appease the borrow checker. Just use the value directly. If panics were catching real aliasing bugs, keep a manual in_use: bool guard and // TODO(port): was RefCell.
OnceCell<T> / OnceLock<T> ^T init'd to nil, check on first use or sync.Once (core:sync) for the thread-safe variant
LazyLock<T> sync.Once + package-level ^T sync.once_do(&once, init_proc)
Mutex<T> / RwLock<T> sync.Mutex / sync.RWMutex (core:sync) no lock-guard RAII — pair every lock with defer sync.mutex_unlock(&m)
Option<T> multi-return (T, bool) at call sites; Maybe(T) (built-in, base:builtin) as a struct field Odin's idiom at call sites is "value + ok bool" (v, ok := m[k]), not a boxed Option. But Odin does ship a direct built-in equivalent for storage — Maybe(T) :: union(T){T} — with the same niche optimization Rust has: Maybe(^u8) stores no tag at all, nil doubles as the "None" state (size_of(Maybe(^u8)) == size_of(^u8)), exactly like Option<Box<T>>. Read with x := m.? (panics if absent) or x, ok := m.? (comma-ok) or x := m.? or_else default. Prefer Maybe(T) over hand-rolling union{T} — it's the standard-library idiom and Phase B reviewers will expect it.
Result<T, E> multi-return (T, Error) see §Error handling
() (unit) no return value (omit the -> clause)
! (never type) no equivalent — the proc simply never returns
(A, B, C) (tuple) multi-return (A, B, C) from a proc, or a named struct for a stored value Odin has no tuple type usable as a value/field — only multiple return values from a proc call
struct Foo { x: i32 } Foo :: struct { x: i32 }
struct Foo(i32) (newtype) Foo :: distinct i32 Odin's distinct is exactly Rust's single-field newtype — same layout, no implicit conversion
enum Foo { A, B, C } (no data) Foo :: enum { A, B, C } 1:1
enum Foo { A(i32), B(String), C } (data-carrying) Foo :: union { A_Data, B_Data, C_Marker } — define one struct per data-carrying variant Odin enums are C-like (name→integer only); a Rust sum type with payloads is always a union in the port. Odin unions discriminate by type, not by variant nameswitch v in x { case string: ... } is a type assertion, so if two Rust variants carry the same payload type (enum E { A(i32), B(i32) }), a naive translation collapses them into one indistinguishable union member. Give each variant a distinct newtype wrapper (A_Data :: distinct i32, B_Data :: distinct i32) even when the payload is trivial — never reuse a bare primitive type across two variants of the same union.
match on the above switch v in foo { case A_Data: ...; case B_Data: ...; case C_Marker: ... } exhaustive by default — add #partial switch only when intentionally incomplete
trait Foo / dyn Foo see §Traits
impl Trait (generic bound) parametric proc with where clause see §Generics
&'a T (borrow with lifetime) ^T (drop the lifetime entirely) see §Lifetimes
PhantomData<T> drop entirely Odin generics don't need a marker field to "use" a type parameter
HashMap<K, V> map[K]V built-in; m[k] = v, v, ok := m[k], delete_key(&m, k), for k, v in m
HashSet<T> map[T]bool no built-in set type
BTreeMap<K, V> / BTreeSet<T> sorted [dynamic] + binary search flag // TODO(port): was BTreeMap, verify ordering-dependent logic
VecDeque<T> hand-rolled ring buffer over [dynamic]T flag // TODO(port): was VecDeque
BinaryHeap<T> core:container/priority_queue use the package, don't hand-roll
LinkedList<T> intrusive linked list (^Node + next/prev fields) only if genuine O(1) splice needed; otherwise [dynamic]T
Cow<'a, T> decide up front: always-owned or always-borrowed, drop the Cow leave // PERF(port): was Cow
*const T / *mut T (points at one value) ^T Odin pointers are not const-qualified — same caveat as &[T] vs &mut [T] above
*const T / *mut T (C-array-style: paired with a separate length, or null-terminated) [^]T (multi-pointer) [^]T supports indexing (p[i], unchecked) and slicing (p[i:n][]T, bounds-checked) but not dereferencing (p^ doesn't compile) — it documents "this is an array, not a single value" the way the Rust signature's separate length parameter did. Convert a real slice to one at an FFI boundary with raw_data(my_slice); convert back with p[:n].
*const c_void / *mut c_void rawptr
NonNull<T> ^T if the non-null invariant was safety-critical, add assert(ptr != nil) at construction
MaybeUninit<T> x: T = --- (Odin's uninitialized value) assume_init is implicit — just use the variable. Add // SAFETY: initialized by <where>.
ManuallyDrop<T> plain T with no defer destroy() at its declaration site Odin never auto-drops; find where Rust called ManuallyDrop::into_inner/ManuallyDrop::drop and add the destroy call there
#[repr(C)] struct plain Odin struct Odin's default layout matches the platform C ABI
#[repr(packed)] / #[repr(align(N))] struct #packed {...} / struct #align(N) {...} the directive sits between the struct keyword and the field list, not appended after
manual bit-packing (shift/mask on an integer field, or a modular_bitfield/bitfield-crate struct) bit_field (distinct from bit_set — see §Bit fields below) Odin has a dedicated record type for this exact pattern: named, per-field bit-widths over a fixed backing integer, with defined (LSB-first) layout
#[derive(Debug)] nothing needed fmt.println(x) prints struct fields via reflection
#[derive(PartialEq, Eq)] nothing needed for structs of comparable fields [dynamic]T and map fields are not structurally comparable — write equal :: proc(a, b: Foo) -> bool if needed
#[derive(Clone)] clone :: proc(f: Foo) -> Foo if the struct has heap-backed fields; otherwise plain assignment suffices see the move-semantics ground rule
#[derive(Default)] nothing needed — Foo{} is the zero value write a foo_default :: proc() -> Foo only if non-zero defaults matter
impl Drop for T t_destroy :: proc(t: ^T) + defer t_destroy(&t) at every construction site see §Ownership
size_of::<T>() size_of(T)
align_of::<T>() align_of(T)
mem::swap(&mut a, &mut b) tmp := a; a = b; b = tmp or core:mem's mem.swap
mem::take(&mut x) tmp := x; x = {}
mem::transmute::<A, B>(x) transmute(x) Odin's built-in transmute — keep the // SAFETY: comment
bit_set / BitVec bit_set[Enum] (language-level) see §Bit manipulation
#[inline] / #[inline(always)] no force-inline attribute in Odin leave // PERF(port): was #[inline(always)]
#[cold] @(cold) on the proc

Idiom map

Rust Odin
let x = expr; x := expr
let mut x = expr; x := expr — Odin has no immutable-by-default locals; every := binding is mutable. Rust's mut/no-mut distinction has no Odin counterpart.
let x: T = expr; x: T = expr
const X: T = expr; X :: expr — Odin's :: binding is compile-time constant
static X: T = expr; X: T = expr at package scope
fn foo(x: i32) -> i32 { } foo :: proc(x: i32) -> i32 { }
a fn that builds up a result across several statements before returning it (let mut result = T::default(); result.x = ...; result.y = ...; result as the tail expression, or an explicit named local threaded through multiple if/match arms before one final return result) Odin supports named return values: foo :: proc() -> (result: T) { result.x = ...; result.y = ...; return } — declaring the return parameter with a name gives you a pre-declared, zero-initialized local of that name, and a bare return (no expression) returns its current value. This is the idiomatic Odin shape for exactly the Rust pattern of "mutate a local across the function body, return it at the end" — prefer it over introducing a separately-named local and an explicit return result when the Rust was doing the equivalent.
#[must_use] (warn if the return value is discarded) @(require_results) on the proc — same intent, same "caller must at least assign it somewhere" enforcement
x.ok() / let _ = fallible_call(); (deliberately discarding a Result's error, or an Option) on a proc whose signature you don't control if you do control the Odin proc's signature and callers routinely want to ignore the trailing bool/error, add #optional_ok to its declaration — frobnicate :: proc(x: T) -> (T, bool) #optional_ok — which lets callers write v := frobnicate(x) (dropping the bool silently, no _ = needed) alongside the normal v, ok := frobnicate(x) form. Without #optional_ok, Odin requires every declared return value to be either captured or explicitly discarded with _.
#[non_exhaustive] on an enum/struct (forces downstream crates to handle an unknown-variant/field case, so the library can add variants without a semver break) no equivalent — Odin's union switch is exhaustive by construction (every current variant must be handled) with no language mechanism to require callers to also handle hypothetical future variants. This is fundamentally a multi-crate API-evolution concern rather than a single-file logic concern; if the ported package is meant to be consumed the same way, leave // TODO(port): was #[non_exhaustive] — Odin union switches are exhaustive, callers may need a defensive #partial switch by convention at the union's definition rather than trying to encode the constraint in the type itself.
impl Foo { fn bar(&self) -> i32 { self.x } } free proc foo_bar :: proc(self: ^Foo) -> i32 { return self.x }Odin has no method-call syntax for user types. Name the proc <Type>_<method> and call explicitly: foo_bar(&foo). Reserve using for genuine struct embedding, not to fake method dispatch.
struct composition via Deref/DerefMut using field embedding: Outer :: struct { using inner: Inner, extra: i32 }outer.inner_field accessible directly. Structural (compile-time field promotion) only, not polymorphism.
match x { A => ..., B(v) => ..., _ => ... } switch v in x { case A: ...; case B: ...; case: ... } (union type-switch) or switch x { case .A: ...; case: ... } (enum value-switch). Exhaustive by default — add #partial switch only when intentionally incomplete.
match x { Foo { a, b } => ... } (struct destructure) switch v in x { case Foo: /* use v.a, v.b */ } — the case variable is the concrete variant value; access its fields directly
let Foo { a, b } = x; (destructure) a := x.a; b := x.b — no single-expression struct destructuring; expand to field accesses
let (a, b) = tuple; a, b := proc_returning_two_values() — no tuple literals; multi-return already handles this at call sites
if let Some(x) = opt { } if x, ok := maybe_call(); ok { }
if let Ok(x) = res { } if x, err := call(); err == nil { }
while let Some(x) = it.next() { } for x, ok := it_next(&it); ok; x, ok = it_next(&it) { }
match guard: if x > 0 => ... nested if inside the case body — Odin has no guard syntax on switch cases
A | B | C => ... on a plain (no-payload) enum case .A, .B, .C: — fine as-is, x is just the matched enum value regardless of which arm hit
A(v) | B(v) => v.field on a data-carrying union, where A and B are different payload types cannot be ported as one case A, B: arm if the body touches a field. Odin's type-switch binds the case variable to the matched type only when the case lists exactly one type; list two or more types in one case and the variable stays the original, untyped union value for that arm (verified against Odin's own reference examples) — v.field will not compile. Split into separate case A: / case B: arms (duplicating the shared body, or factoring it into a helper proc both arms call with their own already-typed v), or precede the switch with a per-type extraction if a field name is common to both payload structs. Don't be tempted to "fix" the compile error by casting — the union genuinely hasn't told you which type v is inside a multi-type case.
0..=9 => ... (range arm) case 0..<10: (exclusive) or case 0..=9: (inclusive, check your Odin version)
match arr { [first, .., last] => ..., [a, b, rest @ ..] => ... } (slice pattern) no destructuring equivalent — write explicit length checks + indexing: if len(arr) >= 2 { first := arr[0]; last := arr[len(arr)-1] } / if len(arr) >= 2 { a, b := arr[0], arr[1]; rest := arr[2:] }. For a fixed-size Rust array pattern ([a, b, c] matching [T; 3] exactly), the length check is unconditionally true and can be dropped — just index directly.
if let Some(x) = a && let Some(y) = b && cond { } (let-chains, stable on Rust 2024 edition) nested ifs — Odin's if init; cond { } form takes exactly one init-statement, so it cannot chain two independent := bindings the way && does in Rust: if x, ok := a; ok { if y, ok2 := b; ok2 && cond { ... } }. There's no way to flatten this to one Odin if when both bindings are conditional; only collapse them if the second binding doesn't actually depend on being separately conditional (i.e. it's really just an &&-joined boolean once the value is in hand).
for x in iter { } over a slice for x in slice { }
for (i, x) in iter.enumerate() for x, i in sliceorder reversed: Odin is value, index, not index, value
for (k, v) in map.iter() for k, v in map { }
loop { ... break; } for { ... break } — bare for is the infinite-loop form
break 'label / continue 'label break label / continue label — same idea, no leading '; declare the label directly before the loop
Labeled block: let x = 'blk: { ...; break 'blk val; }; no equivalent — hoist to a helper proc, or use a flag variable + for { ...; break }
? operator or_return — see §Error handling
.unwrap() assert(ok) — crash loudly, same as Rust
.expect("msg") assert(ok, "msg")
.unwrap_or(d) v, ok := call(); v = ok ? v : d
.unwrap_or_else(|| f()) same, with f() as the fallback expression
.ok() (Result → Option) write an explicit if err == nil { ... }
.map(|x| f(x)) on Option/Result no combinator — write an explicit if/conditional
panic!("msg") panic("msg")
assert!(cond) / debug_assert!(cond) assert(cond) — stripped in -define:ODIN_DISABLE_ASSERT=true builds
unreachable!() panic("unreachable")
todo!() / unimplemented!() panic("TODO") + a // TODO(port): comment
x as i32 (numeric cast, possibly narrowing) i32(x) or cast(i32)x — Odin truncates/wraps on narrowing, same as Rust as
T::try_from(x) (checked conversion) manual range check + cast: if x >= MIN && x <= MAX { val := T(x) } else { /* error */ }
x.into() / T::from(x) (widening, infallible) T(x)
x.checked_add(y) manual overflow check, or bits.add_with_overflow_u32(x, y) from core:math/bits returning (result, overflowed)
x.wrapping_add(y) x + y for fixed-width integer types (Odin wraps on overflow for fixed-width types; debug builds may trap — see §Bit manipulation)
x.saturating_add(y) write it: result := x + y; if result < x { result = max(u32) } for unsigned, or use a wider type
x.rotate_left(n) / .rotate_right(n) bits.rotate_left32(x, int(n)) / bits.rotate_right32(x, int(n)) (core:math/bits)
x.count_ones() / .leading_zeros() / .trailing_zeros() bits.count_ones(x) / bits.count_leading_zeros(x) / bits.count_trailing_zeros(x)
x.swap_bytes() / .reverse_bits() bits.byte_swap(x) / bits.reverse_bits(x)
x & mask / x | mask / x ^ mask / !x x & mask / x | mask / x ~ mask / ~xOdin uses ~ for both XOR (binary) and NOT (unary), not ^! ^x in Odin is pointer dereference.
x << n / x >> n x << uint(n) / x >> uint(n) — shift amount must be uint in Odin
i32::MAX / i32::MIN max(i32) / min(i32) — built-in max/min with a single type arg returns the type's extremum
f64::INFINITY / f64::NAN math.INF_F64 / math.NAN_F64 (core:math)
f.abs() / f.sqrt() / f.floor() / f.ceil() math.abs(f) / math.sqrt(f) / math.floor(f) / math.ceil(f) — free procs, not methods
f.is_nan() / f.is_infinite() math.is_nan(f) / math.is_inf(f, 0)
dbg!(x) fmt.println(x) or fmt.eprintln(x)
format!("{}-{}", a, b) fmt.tprintf("%v-%v", a, b) (temp-allocator, valid until next free_all(context.temp_allocator)) or fmt.aprintf(...) (heap, caller frees) — never store a tprintf result past the current frame
include_str!("file.txt") #load("file.txt") — built-in directive, embeds file as string literal at compile time
include_bytes!("file.bin") #load("file.bin") — returns []u8
env!("VAR") (embed env var at compile time) -define:VAR=value CLI flag + #config(VAR, "default") in source
concat!("a", "b") "a" "b" — Odin string literals juxtaposed are concatenated at compile time
vec![1, 2, 3] [dynamic]int{1, 2, 3} literal, or make([dynamic]int) + append calls
matches!(x, Pattern) an inline switch/if, written out

Lifetimes

Rust lifetimes encode how long a reference is valid. Odin has no such annotations — raw pointers carry no lifetime information. Drop the annotation entirely, but resolve the underlying concern by convention:

  • &'a str / &'a Tstring / ^T — drop the 'a. At every site where the lifetime was non-trivial, leave // PORT NOTE: 'a lifetime dropped — caller must ensure T outlives this pointer.
  • Struct with lifetime parameter (struct Foo<'a> { x: &'a Bar }) → Foo :: struct { x: ^Bar }. Flag the struct: // PORT NOTE: lifetime dropped — Foo must not outlive Bar.
  • 'static lifetime (&'static str) → a string constant (X :: "literal") or package-level variable. String literals in Odin live in the read-only data segment, implicitly permanent.
  • Lifetime in return position (fn foo(x: &'a T) -> &'a T) → the "result borrows from x" contract is now caller-enforced convention only. Leave // PORT NOTE: return value borrows from x — do not free x before using the result.
  • 'static bound on a thread-sent type (Box<dyn Foo + 'static>) → the thread-send concern maps to Send/Sync (no Odin equivalent); see §Concurrency.
  • Higher-ranked trait bounds (for<'a> fn(&'a T) -> &'a U) → the Odin proc signature is proc(^T) -> ^U; the for-any-lifetime property is implicit. Leave // TODO(port): HRTB — verify no specific lifetime assumed.

Unsafe Rust patterns

Remove every unsafe { } block wrapper. Keep the // SAFETY: <why> comment. Specific patterns:

  • unsafe impl Send for Foo {} / unsafe impl Sync for Foo {} → drop; see §Concurrency.
  • &raw mut x / addr_of_mut!(x)&x — taking an address is always plain &x in Odin.
  • slice::from_raw_parts(ptr, len)ptr[:len] (Odin slice-from-pointer syntax), or mem.slice_ptr(ptr, len) from core:mem.
  • ptr::write(p, v) / ptr::read(p)p^ = v / p^.
  • ptr::write_volatile(p, v) / ptr::read_volatile(p)intrinsics.volatile_store(p, v) / intrinsics.volatile_load(p).
  • ptr::copy_nonoverlapping(src, dst, n)mem.copy(dst, src, n * size_of(T)) from core:mem, or copy(dst[:n], src[:n]) on slices. For overlapping: mem.move(dst, src, n * size_of(T)).
  • ptr::offset(p, n) / ptr::add(p, n)mem.ptr_offset(p, n) or (^T)(uintptr(p) + uintptr(n) * size_of(T)).
  • mem::transmute::<A, B>(x)transmute(x). Add assert(size_of(A) == size_of(B)) if the Rust's proof wasn't compile-time obvious.
  • MaybeUninit<T>x: T = --- (Odin uninitialized value). assume_init is implicit — just use the variable. The compiler will not diagnose a read-before-init; add // SAFETY: initialized by <where>.
  • slice.get_unchecked(i) / get_unchecked_mut(i) (skip the bounds check Rust would otherwise insert) → wrap the access in Odin's #no_bounds_check directive rather than reaching for a raw pointer: #no_bounds_check { v = arr[i] } disables the bounds check for just that block, matching the Rust idiom's scope exactly (Odin bounds-checks slice/array indexing by default, the same safety net Rust has). Keep the // SAFETY: comment explaining why i is known in range — same obligation as the Rust unsafe block had.
  • mem::forget(x) (leak a value, suppress its Drop) → in Odin this is almost always a no-op to port: if x's Rust Drop impl became an explicit x_destroy proc per §Ownership, "forgetting" x is simply not calling x_destroy — i.e. don't write the defer for this particular value. Leave // PORT NOTE: was mem::forget — intentionally not calling x_destroy here so Phase B doesn't "fix" the apparent missing cleanup.
  • UnsafeCell<T> (the primitive interior-mutability cell everything else — Cell, RefCell, Mutex — is built from) → nothing needed; it exists in Rust purely to tell the compiler "mutation through a shared reference is intentional here, don't apply &T-implies-immutable optimizations." Odin has no such optimization to opt out of — a plain T behind a ^T already permits mutation through any pointer to it. Drop the wrapper entirely, same as the RefCell/Cell row in the Type map.

Ownership, memory & "drop"

Odin has no borrow checker and no automatic destructors. Everything below relies on defer running at ordinary scope exit — remember (see Ground rules) that a panic skips every defer on the way out, so none of these patterns protect a resource across a panicking call the way Rust's unwind-runs-Drop default does; they only cover the normal-return path.

  • impl Drop for T { fn drop(&mut self) { ... } }t_destroy :: proc(t: ^T) { ... }, called via defer t_destroy(&t) immediately after construction at every call site. No scope-exit hook — a forgotten defer silently leaks. Flag every non-trivial Drop impl's call sites with // PORT NOTE: must defer t_destroy here until Phase B has audited them.
  • let _guard = foo(); (RAII guard, e.g. MutexGuard) → default to foo_lock(&foo); defer foo_unlock(&foo), with the defer on the same line as (or the line directly after) the acquire, so a return inserted between them can't silently skip the release. For the common case where the guarded region is a single block scope, Odin has a real, narrower RAII mechanism worth reaching for instead: the @(deferred_out=proc) / @(deferred_in=proc) / @(deferred_none=proc) attribute family binds a cleanup proc to a constructor's return, so the cleanup fires automatically at the end of the caller's scope with no defer written at the call site at all — core:sync's sync.guard(&m) is built this way (see §Concurrency). Port a Rust RAII guard type to this pattern — @(deferred_out=foo_unlock) foo_lock :: proc(f: ^Foo) -> bool { ...; return true }, used as if foo_lock(&f) { /* guarded region */ } — when the guard's Rust scope is provably a single block; fall back to manual defer pairing when the guard's lifetime is conditional or crosses control flow that doesn't map onto one scope.
  • errdefer pattern (run cleanup only on failure): use a success flag: success := false; defer if !success { cleanup() } then set success = true just before every return on the happy path.
  • Moving an owned value → in Odin this is a copy of the header (pointer/len/cap). If both the old and new locations might independently free or mutate the backing storage, you now have a double-free. Resolve: either zero out the source after the "move" (x = {}) or confirm the source is never touched again and leave a comment.
  • Arena allocation → Odin has two arena implementations; pick based on what the Rust arena did. core:mem's mem.Arena is fixed-capacity — you feed it a pre-allocated []u8 buffer up front (mem.arena_init(&arena, buffer)) and it errors once that buffer fills. core:mem/virtual's vmem.Arena (import vmem "core:mem/virtual") grows on demand via virtual memory (vmem.arena_init_growing(&arena)) — this is the actual match for bumpalo::Bump, which also grows. Either way: get the allocator with mem.arena_allocator(&arena) / vmem.arena_allocator(&arena), set context.allocator to it for the relevant scope, and all new(T)/make(...) inside that scope use the arena automatically — no need to thread an explicit allocator parameter through every call the way Rust's &Bump parameter did.
  • Pin<Box<T>> → a heap-allocated ^T from new(T) is already address-stable for its lifetime (Odin never moves heap allocations) — drop the Pin wrapper, leave // PORT NOTE: was Pin<Box<T>>, relying on heap-allocation stability.

Error handling

Rust's Result<T, E> + ? becomes Odin's multi-return + or_return.

read_config :: proc(path: string) -> (Config, Config_Error) {
    text := read_to_string(path) or_return
    config := parse(text) or_return
    return config, nil
}
  • Result<T, E>(T, Error) as the proc's return tuple, error last.
  • ?or_return, only valid when the enclosing proc's last return value is assignable from the failing call's error type.
  • Ok(x)return x, nil.
  • Err(e)return <zero-value>, e — Odin requires a value for every return slot even on the error path.
  • enum AppError { NotFound, Io(io::Error), Parse(ParseError) } → a tagged union: App_Error :: union { Not_Found_Error, IO_Error, Parse_Error }. Empty variants are struct{} markers; the "no error" case is Odin's nil for that union slot. If the sub-errors (IO_Error, Parse_Error) are themselves enums with their own zero-value "no error" variant, add the #shared_nil tag — App_Error :: union #shared_nil { IO_Error, Parse_Error } — so that assigning any sub-error's zero variant (e.g. IO_Error.None) to an App_Error normalizes it to the union's own nil. Without #shared_nil, an_error = IO_Error.None produces a non-nil App_Error (tagged as "holds an IO_Error, whose value happens to be .None"), which silently breaks every err != nil check ported from Rust's if let Err(e) = .... #shared_nil requires every variant to have its own nil/zero value.
  • ? used inside a for loop in Rust (let x = fallible()?; where the enclosing fn returns from the loop on error) → or_continue (skip this iteration, matching a Rust continue on error) or or_break (abandon the loop, matching return/break on error) instead of or_returnx := fallible() or_continue / x := fallible() or_break. Both take an optional label (or_continue outer_loop) exactly like continue/break do. Reach for one of these first for any loop body that was propagating an error out with ?; falling back to or_return plus a manual if-check-then-continue is more verbose than the idiom calls for.
  • .map_err(\|e\| wrap(e)) → explicit if err != nil { err = wrap(err) }.
  • thiserror::Error derive → register a fmt.register_user_formatter for the error union (see §Logging & output) for the message; if source() was used to chain causes, add an explicit cause: ^App_Error field.
  • anyhow::Error / Box<dyn Error> → prefer a concrete tagged union; only fall back to any when truly needed and flag it.
  • or_else in Odin substitutes a default on failure (like .unwrap_or_else), not for chaining fallible calls the way Rust's .or_else(|e| ...) does.

Traits (dynamic dispatch)

This is the largest structural gap. Pick one strategy, in order of preference:

1. Closed set of implementors → tagged union + type-switch (preferred)

Shape :: union { Circle, Square }

shape_area :: proc(s: Shape) -> f64 {
    switch v in s {
    case Circle: return math.PI * v.r * v.r
    case Square: return v.side * v.side
    }
    return 0
}

2. Open/plugin set → explicit vtable struct

Shape_VTable :: struct {
    area:    proc(self: rawptr) -> f64,
    destroy: proc(self: rawptr),
}
Shape :: struct {
    data:   rawptr,
    vtable: ^Shape_VTable,
}
shape_area :: proc(s: Shape) -> f64 { return s.vtable.area(s.data) }

Leave // TODO(port): hand-rolled vtable for dyn Trait at construction sites.

3. Trait only ever monomorphized (never boxed) → generic bound

See §Generics — not actually dynamic dispatch.

Common trait translations:

Rust trait Odin idiom
Display fmt.register_user_formatter (called once at startup); or use %v default reflection
Debug nothing — %v/%#v already gives a debug view via reflection
From<T> / Into<T> explicit converter proc: foo_from_bar :: proc(b: Bar) -> Foo
TryFrom<T> / TryInto<T> foo_try_from_bar :: proc(b: Bar) -> (Foo, Error)
AsRef<T> / Borrow<T> accept ^T directly; no flexible multi-type acceptance
Deref<Target=T> using embedding if structural; explicit accessor proc otherwise
Index<usize> / IndexMut foo_get :: proc(f: ^Foo, i: int) -> ^T — no operator overloading
Iterator next :: proc(it: ^Iter) -> (T, bool) — see §Closures & iterators
Add/Sub/Mul/Div on a user struct no operator overloading for user-defined struct types — explicit foo_add(a, b) etc. Exception: if the Rust type was a thin numeric wrapper this pattern applies to (struct Vec3 { x, y, z: f32 } with elementwise Add/Sub/Mul/Neg), define it as a distinct fixed-size array instead of a named-field struct — Vec3 :: distinct [3]f32 — and it inherits Odin's built-in element-wise +/-/*///unary - on numeric arrays for free (this is Odin's "array programming" feature: arithmetic on [N]T/matrix[R,C]T for numeric T is a first-class built-in, not something you write). Only reach for this when the Rust struct's fields really were homogeneous-typed and positional (a vector/color/quaternion-shaped type); a struct with heterogeneous named fields still needs explicit procs.
PartialOrd/Ord cmp :: proc(a, b: Foo) -> int (-1/0/1), passed to slice.sort_by
Hash explicit foo_hash :: proc(f: Foo) -> u64; or core:hash for simple POD
Clone foo_clone :: proc(f: Foo) -> Foo (deep-copy heap-backed fields)
Send/Sync no equivalent — see §Concurrency
Fn/FnMut/FnOnce proc(Args) -> Ret value — see §Closures
Drop foo_destroy + defer — see §Ownership
Default Foo{} (zero value); only write foo_default if non-zero defaults needed
Error tagged union — see §Error handling
std::any::Any + .downcast_ref::<T>() Odin's built-in any type, directly — this is one of the very few places Rust's dynamic-typing escape hatch has a native, not hand-rolled, Odin equivalent. Box<dyn Any>any (a value carries its own typeid, no boxing ceremony needed for this). x.downcast_ref::<T>() (returns Option<&T>, safe) → v, ok := x.(T) (comma-ok form, same safe semantics). x.downcast::<T>().unwrap() (panics on mismatch) → v := x.(T) (single-return form — panics on mismatch too, same behavior). TypeId::of::<T>() / comparing TypeIds → typeid_of(T) / comparing typeid values directly with ==.

Associated types (trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; }) have no Odin equivalent as a trait-level concept — Odin has no traits at all, per this section's premise. When porting a trait with an associated type, the associated type almost always becomes an ordinary generic parameter on whatever Odin construct you chose from strategies 1–3 above: a parametric struct/union gets a $T for it (Iter :: struct($Item: typeid) { ... }), a vtable gets a typeid field if the concrete type must be recovered at runtime. Associated constants (trait Foo { const BAR: i32; }) become either a field on the corresponding vtable struct (open-set dispatch) or a per-type #Type_Name :: N constant looked up via a switch/table keyed on the closed union's variant (closed-set dispatch) — there is no way to attach a "constant that varies per implementor" to a plain Odin enum/union the way a trait constant attaches to an impl.

Proc groups (Odin's compile-time overloading) are the closest thing to "dispatch by argument type" when the set of implementors is fixed and the call site is always a static type:

process :: proc{process_click, process_key, process_resize}

Calls resolve to the matching overload at compile time — not runtime dispatch. Don't reach for this where the Rust trait object's value was genuinely decided at runtime.

Generics & trait bounds

import "base:intrinsics"

largest :: proc(items: []$T) -> T where intrinsics.type_is_ordered(T) {
    max := items[0]
    for x in items { if x > max { max = x } }
    return max
}
  • fn foo<T: Trait>(x: T)foo :: proc(x: $T) where <intrinsics query>. Available queries: type_is_numeric, type_is_ordered, type_is_comparable, type_is_integer, type_is_float, type_has_field, etc. If the Rust bound is a user-defined trait with multiple implementations, see §Traits instead — where can't express that.
  • where T: Default (zero value) → usually drop the bound entirely; T{} is always the zero value in Odin.
  • Parametric struct: Foo :: struct($T: typeid) { x: T }, instantiated as Foo(i32) (vs Rust's Foo<i32>). Multiple type parameters: Table :: struct($Key, $Value: typeid) { ... }, instantiated as Table(string, int).
  • Const generics (struct Foo<const N: usize>) → Foo :: struct($N: int) { data: [N]i32 }, instantiated as Foo(16).
  • Free proc on a parametric struct: foo_bar :: proc(f: ^Foo($T)) { ... }.
  • Constraining a type parameter to a shape, not just a property (Rust's impl<T> Foo for Vec<T> / impl<T, const N: usize> Foo for [T; N] — a bound that says "T must specialize to this generic shape") → Odin's $T/SpecializationType syntax: proc(a, b: $T/[2]$E) -> E where intrinsics.type_is_numeric(E) accepts only types that specialize as a 2-element array of some numeric E (both the array-ness and its element type are captured); proc(table: ^$T/Table) accepts only pointers to some instantiation of the parametric struct Table. This is the right tool whenever a plain where clause can't express the bound because the constraint is about a type's shape rather than a single property.
  • A generic Rust enum used as a plain sum type (not Result/Option, which have their own idioms — see Type map) → Odin unions can themselves be parametric: Para_Union :: union($T: typeid) {T, Error}, instantiated as Para_Union(int). This is in fact how the built-in Maybe(T) is defined (union($T: typeid){T}) — reach for the same pattern for a custom Rust generic enum with exactly one non-trivial payload variant plus an error/empty case.
  • fn foo<T: Clone>(x: T) -> (T, T)foo :: proc(x: $T) -> (T, T). Just return (x, x) — no clone() call needed for types that are trivially copyable. Flag // TODO(port): generic clone — verify T is trivially copyable if T could ever contain heap-backed fields.

Closures & iterators

Rewrite combinator chains as explicit loops. Don't hand-roll a generic iterator abstraction unless the Rust file defined its own (non-stdlib) iterator type reused across many call sites.

Rust Odin
.iter().map(f).collect::<Vec<_>>() result: [dynamic]T; for x in items { append(&result, f(x)) }
.iter().filter(pred).collect() result: [dynamic]T; for x in items { if pred(x) { append(&result, x) } }
.iter().filter_map(f).collect() result: [dynamic]T; for x in items { if v, ok := f(x); ok { append(&result, v) } }
.iter().flat_map(f).collect() result: [dynamic]T; for x in items { for y in f(x) { append(&result, y) } }
.iter().fold(init, f) acc := init; for x in items { acc = f(acc, x) }
.iter().any(pred) found := false; for x in items { if pred(x) { found = true; break } }
.iter().all(pred) all := true; for x in items { if !pred(x) { all = false; break } }
.iter().find(pred) for &x in items { if pred(x) { return &x, true } }; return nil, false
.iter().position(pred) for x, i in items { if pred(x) { return i, true } }; return -1, false
.iter().sum() / .product() total := T(0); for x in items { total += x }
.iter().take(n) for x in items[:min(n, len(items))] { }
.iter().skip(n) for x in items[min(n, len(items)):] { }
.iter().chain(other) two separate for loops, or build a combined [dynamic]T first
.iter().zip(other) for x, i in items { y := other[i]; ... } + assert(len(items) == len(other))
.iter().enumerate() for x, i in items { }note: Odin's index is second, not first
.windows(n) for i in 0..<len(items)-n+1 { window := items[i:i+n] }
.chunks(n) for i := 0; i < len(items); i += n { chunk := items[i:min(i+n,len(items))] }
(0..n).map(f).collect() result := make([dynamic]T, 0, n); for i in 0..<n { append(&result, f(i)) }
(a..b) as iter for i in a..<b { }
(a..=b) (inclusive) for i in a..=b { }
|x| x + 1 (closure, no captures) proc(x: int) -> int { return x + 1 } — a plain proc value
|x| x + captured (capturing closure) Odin closures can capture but heap-allocate the environment. For anything stored in a struct field or outliving the enclosing scope, prefer an explicit struct holding the captured data + a plain (non-capturing) proc taking that struct as a parameter — this is what the Rust compiler generates internally, made explicit.
move |x| ... (capture by value) same as above — the "move" into the struct is an explicit assignment; resolve ownership manually
Box<dyn Fn(T) -> U> (callback in a struct field) struct { callback: proc(T, rawptr) -> U, ctx: rawptr } — a proc pointer + erased context
hand-rolled struct MyIter + impl Iterator for MyIter My_Iter_State :: struct { ... } + a next proc whose last return value is boolmy_iter_next :: proc(it: ^My_Iter_State) -> (val: T, ok: bool) (or (val: T, idx: int, ok: bool) for an indexed variant). Odin's for loop accepts this shape directly as the iterable: for val in my_iter_next(&it) { ... } or for val, idx in my_iter_next(&it) { ... } — the loop calls the proc repeatedly until ok is false, with no manual while-style loop needed. This is the idiomatic port of impl Iterator for MyIter { fn next(&mut self) -> Option<T> }, not a hand-written for v, ok := next(&it); ok; v, ok = next(&it) {}.

Concurrency & async

Odin has no async runtime. Pick a real concurrency strategy:

  • async fn / .await → restructure as (a) a blocking call on a dedicated OS thread (core:thread), or (b) an explicit state machine struct, driven by your own poll loop. Leave // TODO(port): was async, needs a real concurrency design — do not attempt to guess.
  • std::thread::spawn(f)thread.create(proc_val) (core:thread), thread.start(t) to run it, poll thread.is_done(t) (or use thread.join(t) if only waiting matters), thread.destroy(t) to free the handle. Set t.init_context = context before start if the thread body needs the caller's context (allocator, logger, etc.) — a spawned thread does not inherit it automatically the way a same-thread call inherits the implicit context.
  • A Rust thread-pool crate (rayon, threadpool, a hand-rolled worker-queue) → core:thread's built-in thread.Pool: thread.pool_init(&pool, allocator=context.allocator, thread_count=N), thread.pool_add_task(&pool, allocator=..., procedure=task_proc, data=my_data, user_index=i) per unit of work, thread.pool_start(&pool), thread.pool_finish(&pool) to drain and join, thread.pool_destroy(&pool). Prefer this over hand-rolling a queue+workers — it's the standard-library equivalent of rayon's pool.
  • Mutex<T> / RwLock<T>sync.Mutex / sync.RWMutex (core:sync). There is no general Drop-based guard type, but the specific mutex-guard pattern has a real built-in idiom: sync.guard(&m) acquires the lock and always returns true, with the unlock wired via Odin's @(deferred_out=...) attribute mechanism (see §Ownership) — so if sync.guard(&m) { /* critical section */ } acquires on entry and releases automatically at the end of that if block's scope, no defer needed. sync.RW_Mutex has the equivalent sync.guard (exclusive) and a shared-lock variant. Fall back to explicit sync.mutex_lock(&m) + defer sync.mutex_unlock(&m) only when the critical section doesn't map cleanly onto a single block scope (e.g. spans a return partway through unrelated logic).
  • AtomicUsize/AtomicBool/etc. → Odin has no Atomic* field type. Access ordinary fields atomically via core:sync procs (sync.atomic_load, sync.atomic_add, sync.atomic_compare_exchange_strong, etc.). Document which fields are accessed atomically with a comment.
  • Channels (mpsc, crossbeam) → hand-roll a ring buffer guarded by sync.Mutex + sync.Cond, or reuse an existing project channel. Flag // TODO(port): was mpsc channel.
  • Send/Sync → no equivalent; Odin will not stop you from sharing a non-thread-safe type across threads. Re-verify by hand and leave // TODO(port): verify thread-safety — was enforced by Send/Sync in Rust.
  • thread_local! / thread::local!@(thread_local) on a package-level variable: @(thread_local) buf: [1024]u8. Access directly — no .with accessor needed.

Bit manipulation

Import "core:math/bits" for bit-level operations.

Rust Odin
x.count_ones() bits.count_ones(x)
x.count_zeros() bits.count_zeros(x)
x.leading_zeros() bits.count_leading_zeros(x)
x.trailing_zeros() bits.count_trailing_zeros(x)
x.leading_ones() bits.count_leading_zeros(~x)
x.trailing_ones() bits.count_trailing_zeros(~x)
x.rotate_left(n) bits.rotate_left32(x, int(n)) (suffix _16/_64 for other widths)
x.rotate_right(n) bits.rotate_right32(x, int(n))
x.swap_bytes() bits.byte_swap(x)
x.reverse_bits() bits.reverse_bits(x)
u32::from_le_bytes(arr) transmute([4]u8)(arr) + endian-swap if on big-endian; or endian.get_u32le(data) (core:encoding/endian)
u32::to_le_bytes(x) transmute(u32)(x) as [4]u8; or endian.put_u32le(buf, x)
x.overflowing_add(y)(T, bool) bits.overflowing_add(x, y)(result, overflowed) — generic over integer width, not per-width-suffixed
x.overflowing_sub(y) bits.overflowing_sub(x, y)
x.overflowing_mul(y) bits.overflowing_mul(x, y)
x ^ mask (XOR) x ~ masknote: Odin uses ~ for XOR (binary) and NOT (unary); ^x is pointer dereference
!x (bitwise NOT) ~x
x << n / x >> n x << uint(n) / x >> uint(n) — shift amount must be uint
bitflags! macro bit_set[Enum] — define Flag :: enum { A, B, C } then Flags :: bit_set[Flag]. Usage: f: Flags = {.A, .C}, test: .A in f, set: incl(&f, .A), unset: excl(&f, .A), count set bits: card(f) (built-in, equivalent to .count_ones() typically piggy-backed on a flags integer in Rust).

Bit fields (bit_field)

Rust code that manually bit-packs a value with shifts and masks, or uses a crate like modular_bitfield/bitfield/bilge to declare named sub-integer fields, maps to Odin's bit_field — a different construct from bit_set (which is a set-of-flags type, see above). bit_field is a record type where each named field occupies a specified number of bits within a fixed backing integer (or fixed-length array of integers):

// Rust: a manually bit-packed value, or #[bitfield] struct with
// a 3-bit x, a 5-bit y, and a 1-bit flag packed into a u16
Foo :: bit_field u16 {
    x:    i32  | 3,  // signed fields are sign-extended on read
    y:    u16  | 5,
    flag: bool | 1,
}

v := Foo{}
v.x = 3   // truncated to fit 3 bits on write
n := v.x  // sign-extended back to i32 on read
  • The backing type is mandatory (bit_field u16 { ... } / bit_field [4]u8 { ... }) — unlike a plain struct, layout is load-bearing here, matching why the Rust code reached for a bitfield crate or manual shift/mask in the first place.
  • Field types are restricted to integer, boolean, or enum. A field's bit-width can be a general compile-time expression (y: u16 | 2 + 3), including one gated by a when (w: bool | 2 when SOME_CONST > 10 else 1) — port a Rust #[cfg(...)]-conditional field width this way. Odin's bit_field layout is LSB-first and well-defined; C's bit-fields are implementation-defined, so if the Rust code was matching a C ABI's bit-field layout by trial and error, re-verify the byte layout rather than assuming the port matches automatically.
  • If every field is a single bit and boolean (i.e. the Rust type was really just a set of named flags packed into an integer, not heterogeneous sub-fields), use bit_set instead, per the row above — it's the better semantic match and gets set-algebra operators for free.
  • Struct field tags (see §Serialization) also work on bit_field fields.

Strings

  • &strstring.
  • String (owned, growable) → strings.Builder (core:strings). Build with strings.write_string(&b, "..."), finalize with s := strings.to_string(b). Free the builder with defer strings.builder_destroy(&b).
  • format!(...)fmt.tprintf(...) (temp-allocator; valid until free_all(context.temp_allocator)) or fmt.aprintf(...) (heap, caller frees). Never store a tprintf result past the current frame.
  • s.push_str("...") / s += "..."strings.write_string(&builder, "...").
  • s.to_uppercase() / .to_lowercase()strings.to_upper(s) / strings.to_lower(s) — allocate and return a new string.
  • s.split(',')strings.split(s, ",")[]string (caller frees with delete), or strings.split_iterator for lazy iteration.
  • s.trim()strings.trim_space(s).
  • s.contains(n) / .starts_with(p) / .ends_with(p)strings.contains / strings.has_prefix / strings.has_suffix.
  • s.find(needle)strings.index(s, needle) — returns byte offset or -1 if not found.
  • s.chars()for ch in s { } — Odin's for ... in string iterates rune values, handling multi-byte UTF-8 correctly.
  • s.len() (byte count) → len(s).
  • s[a..b]s[a:b] (byte-based; may split a multi-byte rune).
  • s.is_empty()len(s) == 0.
  • CString/*const c_charcstring. strings.clone_to_cstring(s) (heap, caller frees) for stringcstring; string(cstr) for cstringstring (borrows, scans for NUL).
  • OsString/PathBuf/Pathstring. Drop to_str().unwrap() — the conversion is implicit. Leave // PORT NOTE: was OsStr/Path, now plain string — non-UTF-8 paths may silently misbehave on Windows if the original code carefully handled non-UTF-8 paths.

Serialization (serde)

Odin's core:encoding/json uses reflection to marshal/unmarshal arbitrary types without annotation:

import "core:encoding/json"

data, err := json.marshal(my_struct)       // returns []u8
err       = json.unmarshal(bytes, &target) // populates target
Rust serde attribute Odin
#[serde(rename = "fieldName")] struct tag: field: int `json:"fieldName"`
#[serde(rename_all = "camelCase")] marshal option: json.marshal(x, {field_name_case = .Camel})
#[serde(skip)] struct tag: field: int `json:"-"`
#[serde(flatten)] no direct equivalent — write a custom marshal/unmarshal proc and leave // TODO(port): was serde flatten
#[serde(with = "mod")] (custom (de)serializer) write a custom proc and leave // TODO(port): was serde with
Binary formats (bincode, postcard, rkyv) check core:encoding for an equivalent; if none, hand-port using core:bytes/core:io and leave // TODO(port): was <crate>

Logging & output

Odin's context.logger is the language-level logging hook — set it once at main startup; the implicit context parameter propagates it everywhere:

import "core:log"

main :: proc() {
    context.logger = log.create_console_logger(.Info)
    defer log.destroy_console_logger(context.logger)
    run()
}
// Inside any proc — no setup or import needed at the call site:
log.infof("connected to %v", addr)
log.warnf("slow path: %v", reason)
Rust (log/tracing crate) Odin
log::error!("msg {}", x) log.errorf("msg %v", x)
log::warn!("msg") log.warnf("msg")
log::info!("msg") log.infof("msg")
log::debug!("msg") log.debugf("msg") (filtered out unless logger level is .Debug)
log::trace!("msg") log.debugf("TRACE: msg") — Odin has no trace level
tracing::span! / #[instrument] no built-in equivalent; hand-roll a timing wrapper using core:time if spans are load-bearing, otherwise drop and leave // TODO(port): was tracing span
env_logger / tracing_subscriber setup context.logger = log.create_console_logger(.Info) at main startup

For impl Display for T → register a custom formatter once at startup:

import "core:fmt"

register_formatters :: proc() {
    fmt.register_user_formatter(typeid_of(My_Type), my_type_format)
}

my_type_format :: proc(fi: ^fmt.Info, arg: any, verb: rune) -> bool {
    m := (^My_Type)(arg.data)
    fmt.fmt_string(fi, fmt.tprintf("MyType(%v)", m.value), verb)
    return true
}

FFI

foreign import libm "system:m"
foreign libm {
    sqrt :: proc "c" (x: f64) -> f64 ---
}
  • extern "C" { fn foo(...); } blocks → foreign import <name> "<library>" followed by foreign <name> { ... --- } block. Each declaration ends with --- (no body).
  • #[link(name = "m")] → the string argument to foreign import ("system:m" for system lib by name; a path string for a specific file).
  • #[no_mangle] pub extern "C" fn foo(...) (exporting to C) → @(export) foo :: proc "c" (...) -> ... { ... }.
  • #[repr(C)] struct at the FFI boundary → plain Odin struct, no annotation needed (Odin's default layout matches the C ABI).
  • *const c_char / *mut c_charcstring in the Odin signature.
  • extern "C" calling convention → proc "c" (...).
  • extern "system" (Windows-specific convention) → proc "system" (...).
  • Passing a Rust slice (&[T]/*const T + separate len) across the FFI boundary as a raw pointer → raw_data(my_slice) (built-in) converts an Odin []T/[dynamic]T/[N]T to a [^]T multi-pointer in one call; the matching len(my_slice) supplies the length parameter. Coming back the other way (C gave you a pointer + length) → ptr[:length] turns a [^]T into a proper []T slice — see the [^]T row in the Type map.
  • proc "c" (and any other non-"odin" calling convention) starts with a garbage/undefined context. Unlike a same-thread Odin-convention call, a callback registered with a C library does not inherit the caller's context automatically. If the callback body does anything that reads context — allocates, logs, calls another proc that isn't itself "c"/"contextless" — set context = runtime.default_context() as the callback's first line (or context = my_saved_context if it must reuse the context from where the callback was registered). This has no Rust analogue to port from — Rust's extern "C" fn callbacks don't carry an implicit context at all — but it's mandatory in the Odin port whenever the ported callback body calls into ordinary Odin code. Skipping it is a common source of crashes that only reproduce when the callback actually fires from C, not at compile time.
  • If your file has externs and isn't already the project's dedicated FFI package, leave them with // TODO(port): move to <area>_sys package.

Platform conditionals

when ODIN_OS == .Windows {
    foo :: proc() { /* windows impl */ }
} else {
    foo :: proc() { /* other impl */ }
}
Rust Odin
#[cfg(target_os = "windows")] when ODIN_OS == .Windows
#[cfg(target_os = "macos")] when ODIN_OS == .Darwin
#[cfg(target_os = "linux")] when ODIN_OS == .Linux
#[cfg(target_arch = "x86_64")] when ODIN_ARCH == .amd64
#[cfg(target_arch = "aarch64")] when ODIN_ARCH == .arm64
#[cfg(unix)] when ODIN_OS == .Linux || ODIN_OS == .Darwin || ODIN_OS == .FreeBSD — define a package constant IS_POSIX :: ODIN_OS == .Linux || ... and reuse it
#[cfg(debug_assertions)] when ODIN_DEBUG
#[cfg(feature = "foo")] when #config(FOO_ENABLED, false) — set via -define:FOO_ENABLED=true on the command line
if cfg!(windows) { } (runtime, both branches type-checked) when ODIN_OS == .Windows { } — prefer when whenever either branch references a platform-only symbol; plain if ODIN_OS == .Windows is fine for trivial value selection with no platform-only identifiers

when is a true compile-time branch — the untaken branch is not type-checked or compiled, exactly like Rust's #[cfg]. Use it whenever either branch references platform-only items.

Macros

Odin has no macro system — no macro_rules!, no proc macros, no derive. Every Rust macro use needs a real decision:

  • macro_rules! for repetition → a parametric proc ($T) if bodies are identical modulo type (see §Generics); hand-expand if structurally different and leave // TODO(port): was macro_rules!, hand-expanded — N call sites.
  • #[derive(Trait)] → implement by hand per type; check §Serialization for serde, §Error handling for thiserror.
  • Proc macro generating an impl block → read the cargo expand output and hand-write the expansion; leave // TODO(port): was proc-macro <name>.
  • vec![1, 2, 3][dynamic]int{1, 2, 3} literal or make+append.
  • matches!(x, Pattern) → inline switch/if, written out.
  • include_str!("file.txt")#load("file.txt") — built-in directive, embeds file as a string literal at compile time.
  • include_bytes!("file.bin")#load("file.bin") — returns []u8.
  • concat!("a", "b")"a" "b" — Odin juxtaposes string literals at compile time; or strings.concatenate at runtime.
  • env!("VAR")#config(VAR, "default") with -define:VAR=value CLI flag.

Builder pattern

Rust builders (Foo::builder().field(v).build()) become config structs in Odin:

// Option 1: Direct struct literal (preferred when defaults are zero-values)
conn, err := connect(Connection_Config{
    host        = "localhost",
    port        = 5432,
    max_retries = 3,
})

// Option 2: Config struct + make proc (preferred when builder validated invariants)
cfg := Connection_Config{ host = "localhost", port = 5432, max_retries = 3 }
conn, err := connection_make(cfg)
  • The build() validation step → validation logic inside the _make proc, returning (T, Error).
  • Typestate builder (compile-time enforcement of mandatory setters) → no equivalent in Odin; write a runtime assertion in the _make proc. Flag // TODO(port): was typestate builder — validation is now runtime-only.
  • Default::default() initial builder state → Connection_Config{} (all zero values), or connection_config_default() if non-zero defaults matter.

Testing

package foo
import "core:testing"

@(test)
test_it_works :: proc(t: ^testing.T) {
    testing.expect_value(t, 2 + 2, 4)
}
Rust Odin
#[test] fn name() @(test) test_name :: proc(t: ^testing.T) { ... }
assert_eq!(a, b) testing.expect_value(t, a, b)
assert_ne!(a, b) testing.expect(t, a != b, "expected not equal")
assert!(cond, "msg {}", x) testing.expectf(t, cond, "msg %v", x)
mod tests { ... } submodule @(test) procs live in the same file/package — no wrapper construct needed
#[cfg(test)] gate not needed — @(test) procs are excluded from non-test builds automatically
#[should_panic] no direct equivalent; the test will abort on panic rather than passing. Flag // TODO(port): was #[should_panic], test behavior differs.
proptest! / quickcheck! no built-in — convert to example-based tests and leave // TODO(port): was property-based test

Run: odin test . (or odin test <path>) from the package directory.

Don't translate

  • use import lines → import statements at the top of the .odin file; don't try for a 1:1 item mapping — Odin imports a whole package.
  • mod foo; declarations → nothing; the directory/file structure IS the module declaration in Odin.
  • #[allow(...)] / #[warn(...)] lint attributes → drop entirely.
  • Build scripts (build.rs) and Cargo.toml → no equivalent; any codegen build.rs step needs a real decision (standalone generator script, or hand-written output). Flag // TODO(port): had a build.rs codegen step.
  • #[derive(serde::Serialize, serde::Deserialize)] on a pure data struct with no custom #[serde(...)] attributes → check §Serialization; Odin reflection-based JSON may handle it without any explicit code.
  • Doc comments (///, //!) → carry over as plain // comments directly above the item.
  • Rust prelude items (Vec, String, Box, Option, Result, println!) → Odin has its own always-in-scope built-ins (new, make, delete, append, len, cap, transmute, size_of, etc.); no explicit imports needed for these.

Output format

End your .odin with a trailer comment — this is the detailed counterpart to the file header defined in §Commenting & documenting the port; the header is what a reader sees first, this is what they check before starting Phase B work on the file:

// ──────────────────────────────────────────────────────────────────────────
// PORT STATUS
//   source:     <path/to/file>.rs (NNN lines)
//   confidence: high | medium | low
//   todos:      N  (T:<todo-count> P:<perf-count> S:<safety-count> N:<port-note-count>)
//   notes:      <one line: anything Phase B needs to know>
// ──────────────────────────────────────────────────────────────────────────

confidence: low = logic probably wrong, re-read the Rust in Phase B. confidence: medium = imports/package wiring needs fixing but logic is right. confidence: high = should compile with only mechanical import fixes. Reserve high for files with no trait objects, no async, and no macros to resolve — those three categories are where this port is least mechanical.

The todos: breakdown (T:/P:/S:/N: counts, matching the four tags from §Commenting & documenting the port) is worth the extra few seconds to tally by hand — a file with T:0 P:0 S:3 N:1 is ready for a quick safety-invariant review and nothing else, while T:12 P:0 S:0 N:0 says don't bother reviewing yet, there's a full pass of untranslated logic first. A single flat count doesn't tell Phase B which of those two very different files it's looking at.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment