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.
- Write the
.odinin the same directory as the.rs, same basename (socket.rs→socket.odin), unless that directory is not yet an Odin package (no other.odinfiles) — 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↔.odinside-by-side. Type names becomePascal_Caseper Odin convention (HttpClient→Http_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 Bwherever 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
&mutexclusivity, leave// TODO(port): verify aliasing — was &mut exclusive in Rust. - Odin has no move semantics.
y := xin Odin copiesx; the originalxremains valid and usable. Where Rust's logic depended on the old binding being dead (aVec/String/Boxwhose buffer is now solely owned byy), 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 aliasingat every such site. unsafe {}has no Odin equivalent block. Where Rust marked somethingunsafe, keep a// SAFETY: <why>comment mirroring the Rust invariant — it's load-bearing documentation for Phase B reviewers.- Odin's
panicdoes not unwind and does not rundefer. 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'sDropimpl on the way out — a lock guard still unlocks, a file handle still flushes, even on the failure path. Odin'spanic()(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 theirdeferstatements. A Rust file that leaned onDropfiring 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 adebug_assertionsbuild (the default forcargo 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 tocargo build's implicit overflow checking to catch this class of bug for you. If the Rust source used plain+/-/*(not an explicitwrapping_*/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), preferbits.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 viacontext.allocatorat the program's entry point. Phase B wires this; Phase A can assume it's done.
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.
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.
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 HttpClient→Http_Client casing — that's already covered once, in the header, for the whole file. |
// PORT NOTE (cosmetic): field order changed for alignment, not semantics |
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 (
HttpClient→Http_Client,parse_jsonstaysparse_json). mod foo;becoming a subdirectory/file with nomodstatement.- A dropped lifetime that was elided in the Rust to begin with (only
non-trivial lifetimes get a
PORT NOTE, per §Lifetimes — an elidedfn foo(x: &T) -> &Tneeds nothing). impl Foo { fn bar(&self) {...} }becomingfoo_bar :: proc(self: ^Foo) {...}when the reshaping is the standard mechanical pattern from the Idiom map, with no logic change.- An empty
Dropbody (only frees owned fields) being deleted entirely, per §Ownership — this is the expected, silent case; only aDropbody with side effects beyond freeing gets aPORT NOTE. uselines collapsing intoimportlines.
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.
| 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. |
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 |
| Rust | Odin | notes |
|---|---|---|
i8/i16/i32/i64/i128/isize |
i8/i16/i32/i64/i128/int |
identical names except isize→int (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 name — switch 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 |
| 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 slice — order 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 / ~x — Odin 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 |
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 T→string/^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. 'staticlifetime (&'static str) → astringconstant (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. 'staticbound on a thread-sent type (Box<dyn Foo + 'static>) → the thread-send concern maps toSend/Sync(no Odin equivalent); see §Concurrency.- Higher-ranked trait bounds (
for<'a> fn(&'a T) -> &'a U) → the Odin proc signature isproc(^T) -> ^U; the for-any-lifetime property is implicit. Leave// TODO(port): HRTB — verify no specific lifetime assumed.
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&xin Odin.slice::from_raw_parts(ptr, len)→ptr[:len](Odin slice-from-pointer syntax), ormem.slice_ptr(ptr, len)fromcore: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))fromcore:mem, orcopy(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). Addassert(size_of(A) == size_of(B))if the Rust's proof wasn't compile-time obvious.MaybeUninit<T>→x: T = ---(Odin uninitialized value).assume_initis 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_checkdirective 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 whyiis known in range — same obligation as the Rustunsafeblock had.mem::forget(x)(leak a value, suppress itsDrop) → in Odin this is almost always a no-op to port: ifx's RustDropimpl became an explicitx_destroyproc per §Ownership, "forgetting"xis simply not callingx_destroy— i.e. don't write thedeferfor this particular value. Leave// PORT NOTE: was mem::forget — intentionally not calling x_destroy hereso 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 plainTbehind a^Talready permits mutation through any pointer to it. Drop the wrapper entirely, same as theRefCell/Cellrow in the Type map.
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 viadefer t_destroy(&t)immediately after construction at every call site. No scope-exit hook — a forgottendefersilently leaks. Flag every non-trivialDropimpl's call sites with// PORT NOTE: must defer t_destroy hereuntil Phase B has audited them.let _guard = foo();(RAII guard, e.g.MutexGuard) → default tofoo_lock(&foo); defer foo_unlock(&foo), with thedeferon the same line as (or the line directly after) the acquire, so areturninserted 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 nodeferwritten at the call site at all —core:sync'ssync.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 asif foo_lock(&f) { /* guarded region */ }— when the guard's Rust scope is provably a single block; fall back to manualdeferpairing when the guard's lifetime is conditional or crosses control flow that doesn't map onto one scope.errdeferpattern (run cleanup only on failure): use a success flag:success := false; defer if !success { cleanup() }then setsuccess = truejust before everyreturnon 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'smem.Arenais fixed-capacity — you feed it a pre-allocated[]u8buffer up front (mem.arena_init(&arena, buffer)) and it errors once that buffer fills.core:mem/virtual'svmem.Arena(import vmem "core:mem/virtual") grows on demand via virtual memory (vmem.arena_init_growing(&arena)) — this is the actual match forbumpalo::Bump, which also grows. Either way: get the allocator withmem.arena_allocator(&arena)/vmem.arena_allocator(&arena), setcontext.allocatorto it for the relevant scope, and allnew(T)/make(...)inside that scope use the arena automatically — no need to thread an explicit allocator parameter through every call the way Rust's&Bumpparameter did. Pin<Box<T>>→ a heap-allocated^Tfromnew(T)is already address-stable for its lifetime (Odin never moves heap allocations) — drop thePinwrapper, leave// PORT NOTE: was Pin<Box<T>>, relying on heap-allocation stability.
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 arestruct{}markers; the "no error" case is Odin'snilfor 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_niltag —App_Error :: union #shared_nil { IO_Error, Parse_Error }— so that assigning any sub-error's zero variant (e.g.IO_Error.None) to anApp_Errornormalizes it to the union's ownnil. Without#shared_nil,an_error = IO_Error.Noneproduces a non-nilApp_Error(tagged as "holds anIO_Error, whose value happens to be.None"), which silently breaks everyerr != nilcheck ported from Rust'sif let Err(e) = ....#shared_nilrequires every variant to have its ownnil/zero value.?used inside aforloop in Rust (let x = fallible()?;where the enclosing fn returns from the loop on error) →or_continue(skip this iteration, matching a Rustcontinueon error) oror_break(abandon the loop, matchingreturn/breakon error) instead ofor_return—x := fallible() or_continue/x := fallible() or_break. Both take an optional label (or_continue outer_loop) exactly likecontinue/breakdo. Reach for one of these first for any loop body that was propagating an error out with?; falling back toor_returnplus a manualif-check-then-continueis more verbose than the idiom calls for..map_err(\|e\| wrap(e))→ explicitif err != nil { err = wrap(err) }.thiserror::Errorderive → register afmt.register_user_formatterfor the error union (see §Logging & output) for the message; ifsource()was used to chain causes, add an explicitcause: ^App_Errorfield.anyhow::Error/Box<dyn Error>→ prefer a concrete tagged union; only fall back toanywhen truly needed and flag it.or_elsein Odin substitutes a default on failure (like.unwrap_or_else), not for chaining fallible calls the way Rust's.or_else(|e| ...)does.
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.
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 —wherecan'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 asFoo(i32)(vs Rust'sFoo<i32>). Multiple type parameters:Table :: struct($Key, $Value: typeid) { ... }, instantiated asTable(string, int). - Const generics (
struct Foo<const N: usize>) →Foo :: struct($N: int) { data: [N]i32 }, instantiated asFoo(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/SpecializationTypesyntax: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 numericE(both the array-ness and its element type are captured);proc(table: ^$T/Table)accepts only pointers to some instantiation of the parametric structTable. This is the right tool whenever a plainwhereclause 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 asPara_Union(int). This is in fact how the built-inMaybe(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)— noclone()call needed for types that are trivially copyable. Flag// TODO(port): generic clone — verify T is trivially copyableif T could ever contain heap-backed fields.
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 bool — my_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) {}. |
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, pollthread.is_done(t)(or usethread.join(t)if only waiting matters),thread.destroy(t)to free the handle. Sett.init_context = contextbeforestartif 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 implicitcontext.- A Rust thread-pool crate (
rayon,threadpool, a hand-rolled worker-queue) →core:thread's built-inthread.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 ofrayon'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 returnstrue, with the unlock wired via Odin's@(deferred_out=...)attribute mechanism (see §Ownership) — soif sync.guard(&m) { /* critical section */ }acquires on entry and releases automatically at the end of thatifblock's scope, nodeferneeded.sync.RW_Mutexhas the equivalentsync.guard(exclusive) and a shared-lock variant. Fall back to explicitsync.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 areturnpartway through unrelated logic).AtomicUsize/AtomicBool/etc. → Odin has noAtomic*field type. Access ordinary fields atomically viacore:syncprocs (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 bysync.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.withaccessor needed.
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 ~ mask — note: 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). |
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 plainstruct, 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 awhen(w: bool | 2 when SOME_CONST > 10 else 1) — port a Rust#[cfg(...)]-conditional field width this way. Odin'sbit_fieldlayout 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_setinstead, 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_fieldfields.
&str→string.String(owned, growable) →strings.Builder(core:strings). Build withstrings.write_string(&b, "..."), finalize withs := strings.to_string(b). Free the builder withdefer strings.builder_destroy(&b).format!(...)→fmt.tprintf(...)(temp-allocator; valid untilfree_all(context.temp_allocator)) orfmt.aprintf(...)(heap, caller frees). Never store atprintfresult 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 newstring.s.split(',')→strings.split(s, ",")→[]string(caller frees withdelete), orstrings.split_iteratorfor 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'sfor ... in stringiteratesrunevalues, 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_char→cstring.strings.clone_to_cstring(s)(heap, caller frees) forstring→cstring;string(cstr)forcstring→string(borrows, scans for NUL).OsString/PathBuf/Path→string. Dropto_str().unwrap()— the conversion is implicit. Leave// PORT NOTE: was OsStr/Path, now plain string — non-UTF-8 paths may silently misbehave on Windowsif the original code carefully handled non-UTF-8 paths.
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> |
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
}foreign import libm "system:m"
foreign libm {
sqrt :: proc "c" (x: f64) -> f64 ---
}extern "C" { fn foo(...); }blocks →foreign import <name> "<library>"followed byforeign <name> { ... --- }block. Each declaration ends with---(no body).#[link(name = "m")]→ the string argument toforeign 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_char→cstringin the Odin signature.extern "C"calling convention →proc "c" (...).extern "system"(Windows-specific convention) →proc "system" (...).- Passing a Rust slice (
&[T]/*const T+ separatelen) across the FFI boundary as a raw pointer →raw_data(my_slice)(built-in) converts an Odin[]T/[dynamic]T/[N]Tto a[^]Tmulti-pointer in one call; the matchinglen(my_slice)supplies the length parameter. Coming back the other way (C gave you a pointer + length) →ptr[:length]turns a[^]Tinto a proper[]Tslice — see the[^]Trow in the Type map. proc "c"(and any other non-"odin"calling convention) starts with a garbage/undefinedcontext. 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 readscontext— allocates, logs, calls anotherprocthat isn't itself"c"/"contextless"— setcontext = runtime.default_context()as the callback's first line (orcontext = my_saved_contextif it must reuse the context from where the callback was registered). This has no Rust analogue to port from — Rust'sextern "C" fncallbacks 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.
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.
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 forthiserror.- Proc macro generating an
implblock → read thecargo expandoutput and hand-write the expansion; leave// TODO(port): was proc-macro <name>. vec![1, 2, 3]→[dynamic]int{1, 2, 3}literal ormake+append.matches!(x, Pattern)→ inlineswitch/if, written out.include_str!("file.txt")→#load("file.txt")— built-in directive, embeds file as astringliteral at compile time.include_bytes!("file.bin")→#load("file.bin")— returns[]u8.concat!("a", "b")→"a" "b"— Odin juxtaposes string literals at compile time; orstrings.concatenateat runtime.env!("VAR")→#config(VAR, "default")with-define:VAR=valueCLI flag.
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_makeproc, returning(T, Error). - Typestate builder (compile-time enforcement of mandatory setters) → no
equivalent in Odin; write a runtime assertion in the
_makeproc. Flag// TODO(port): was typestate builder — validation is now runtime-only. Default::default()initial builder state →Connection_Config{}(all zero values), orconnection_config_default()if non-zero defaults matter.
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.
useimport lines →importstatements at the top of the.odinfile; 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) andCargo.toml→ no equivalent; any codegenbuild.rsstep 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.
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.