Skip to content

Instantly share code, notes, and snippets.

@CypherpunkSamurai
Created June 24, 2026 14:55
Show Gist options
  • Select an option

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

Select an option

Save CypherpunkSamurai/d6c72a10bc8aee5f7bd0fa26ebc24129 to your computer and use it in GitHub Desktop.
Rust to ODIN

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're 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 isn't 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 stay Pascal_Case-ish per Odin convention (HttpClientHttp_Client) — apply this renaming mechanically and consistently, don't leave a mix.
  • 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 via a crate like bumpalo) and the port uses the plain idiomatic 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 that Rust's borrow checker was silently preventing. Anywhere the Rust code's correctness depended on &mut exclusivity (not just on compiling), leave // TODO(port): verify aliasing — was &mut exclusive in Rust.
  • Odin has no move semantics. Assigning a struct, passing it by value, or returning it copies it (shallow copy of the struct's fields, including any pointer/slice/dynamic-array headers inside it). Rust's let y = x; moving x (and making further use of x a compile error) has no Odin analogue — y := x in Odin just duplicates x, and the original x remains valid and usable. Where the Rust's logic depends on the old binding being dead (e.g. 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 — this is the single most common silent bug in this kind of port. Leave // TODO(port): was a move — verify no aliasing at every such site; do not silently assume copy semantics are equivalent.
  • unsafe {} has no Odin equivalent block — Odin doesn't gate raw pointer dereferences, casts, or FFI calls behind a keyword the way Rust does. Where Rust marked something unsafe, keep a // SAFETY: <why> comment mirroring the Rust invariant anyway, even though Odin won't enforce anything — it's load-bearing documentation for Phase B reviewers.
  • 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 (main) using context = runtime.default_context() overridden with your allocator, or context.allocator = my_allocator() — otherwise every new/make in this file silently uses Odin's default heap allocator instead. Phase B wires this; Phase A can assume it's already done.

Package map

use crate_name::module::Thing → figure out which Rust crate crate_name is, then map it to an Odin package import. If you're porting a specific project, replace this table with your real crate→package layout:

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 it was a flat module re-exported at crate root
external crate (crates.io) with no project Odin equivalent vendor it under vendor:<name> if a maintained Odin port exists, otherwise write the minimal subset needed directly into a _sys-style FFI package (see §FFI) and leave // TODO(port): no vendor equivalent for <crate>
mod foo; / mod foo { ... } (file-private submodule) a subdirectory package foo/, or keep in the same file if the Rust module was just visibility scoping with no real separation — Odin has no inline mod {} blocks, only files-as-packages
pub use re-export a thin re-export isn't idiomatic Odin (no pub use equivalent); callers import the defining package directly. Update call sites in Phase B rather than faking the re-export.
Cargo.toml dependency declaration nothing 1:1 — Odin has no package manager; dependencies are either vendored under vendor:, fetched as a git submodule, or hand-copied into the tree. Phase B resolves this.

If a Rust path doesn't fit the above: the Odin package is named after the directory the Rust module lives in, snake_case, no crate_ prefix (Odin packages are just directory names — don't invent a naming convention Rust didn't need).

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, matching isize, not i32)
u8/u16/u32/u64/u128/usize u8/u16/u32/u64/u128/uint same caveat as above
f32/f64 f32/f64 1:1
bool bool 1:1
char rune both are a single Unicode scalar value (4 bytes); Odin's rune is exactly Rust's char, not a single byte
&str string Odin string is a read-only {data: ^u8, len: int} view, same semantics as &str. Caveat: Odin's string is not guaranteed-UTF-8 by the type system the way Rust's str is guaranteed by the compiler — Odin only promises it by convention. If the Rust leaned on str's UTF-8 guarantee for safety (not just correctness), leave // TODO(port): Rust guaranteed UTF-8 here, Odin does not.
String string for an immutable final value; strings.Builder (import "core:strings") while under construction Odin has no separate owned/growable string type — build with a Builder, then strings.to_string(builder) to get the final string. Don't reach for [dynamic]u8 unless the Rust was genuinely manipulating raw bytes, not text.
&[u8] / Vec<u8> (byte buffer, not text) []u8 / [dynamic]u8
&[T] []T Caveat: Odin slices don't distinguish mutable vs immutable the way &[T] vs &mut [T] do — there is one []T and whether callers mutate through it is a convention, not a type-level guarantee. If the Rust function signature took &[T] specifically to promise it wouldn't mutate, that promise is now unenforced; note it in a doc comment, don't silently drop it.
&mut [T] []T same type as above — see caveat
[T; N] [N]T fixed-size array, 1:1 (note Odin puts the size first)
Vec<T> [dynamic]T .push(x)append(&v, x) · .pop()pop(&v) · .len()len(v) · v[i]v[i] · .clear()clear(&v) · .remove(i)ordered_remove(&v, i) (core:slice) · .swap_remove(i)unordered_remove(&v, i) · drop/free → delete(v)
Box<T> ^T heap-allocate with new(T) (zero-initialized) or new_clone(value) (copies an existing value onto the heap); free with free(ptr). No automatic drop — see §Ownership.
Box<[T]> []T (heap-backed, owned) allocate via make([]T, n); free via delete(slice). Odin doesn't distinguish an owned heap slice from a borrowed one at the type level — track ownership by convention/comment.
Rc<T> / Arc<T> no built-in equivalent hand-roll: struct { value: T, ref_count: int } behind a ^T, with explicit _retain/_release procs, or — if single-threaded and the Rust never relied on weak refs — just pass ^T around and let one clear owner free it. Leave // TODO(port): Rc/Arc — verify single owner or hand-roll refcounting. Arc's atomicity → core:sync atomics on the count field.
Weak<T> no built-in equivalent keep a raw ^T (or an index into a stable slot, e.g. a slot map) and a manual "still alive" check (tombstone bit, generation counter); Odin has no weak-pointer primitive
RefCell<T> / Cell<T> plain T behind a ^T Odin has no compile-time borrow checking to defer to runtime in the first place — RefCell's runtime borrow tracking existed only to work around the borrow checker, which Odin doesn't have. Just use the value directly. If the Rust's RefCell panics were load-bearing for catching real aliasing bugs (not just appeasing the borrow checker), keep a manual in_use: bool guard and // TODO(port): was RefCell — verify no real aliasing bug masked here.
Mutex<T> / RwLock<T> sync.Mutex / sync.RWMutex (core:sync) wrapping the value, or a ^T guarded by one sync.mutex_lock(&m) / sync.mutex_unlock(&m); no lock-guard-as-RAII, so pair every lock with an explicit defer sync.mutex_unlock(&m)
Option<T> multi-return (T, bool) from the producing call, or a tagged union Maybe :: union { T } (nil-state union) if T must be stored as a field Odin's idiom is "value + ok bool" at call sites (v, ok := m[k]), not a boxed Option type living in a struct field. For a struct field that's genuinely optional, a union { T } works (an unset union is nil) — but only if T isn't itself a union.
Result<T, E> multi-return (T, Error) see §Error handling — this is a dedicated section, don't improvise here
() (unit) no return value (omit the -> clause)
! (never type) no equivalent; the proc simply has no path that returns
(A, B, C) (tuple) multi-return (A, B, C) from a proc, or an anonymous-feeling small struct { a: A, b: B, c: C } for a stored value Odin has no tuple type usable as a value/field — only multiple return values from a call. A tuple stored in a struct field needs a named struct.
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 pattern — a new nominal type with the 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) tagged union: define one struct per data-carrying variant, then Foo :: union { A, B, C } where C (no data) can be its own empty struct{} marker type or represented by the union's nil state if it's the only empty variant Odin enums are C-like (name→integer only, no payload); a Rust sum type with payloads is always a union, never an enum, in the port
match on the enum above switch v in foo_union { case A: ...; case B: ...; case C: ... } type-switch on the union; Odin checks this for exhaustiveness by default (compile error if a variant is unhandled) — add #partial switch only if intentionally incomplete, mirroring Rust's _ => {}
trait Foo { fn bar(&self); } + impl Foo for T see §Traits — no 1:1, dedicated section
dyn Foo / Box<dyn Foo> see §Traits
impl Trait (generic bound) parametric proc with a where clause, see §Generics
fn foo<T: Trait>(x: T) foo :: proc(x: $T) where <constraint> see §Generics
closures (Fn/FnMut/FnOnce, |x| ...) proc(...) -> ... value, or a struct holding captured state + an explicit call proc see §Closures
&'a T (borrow with lifetime) ^T (or T passed by value if small and Copy) drop the lifetime entirely — Odin pointers carry no lifetime, see §Ownership for the implication
PhantomData<T> drop entirely, no field needed Odin generics don't need a marker field to "use" a type parameter
HashMap<K, V> map[K]V built into the language; m[k] = v, v, ok := m[k], delete_key(&m, k), iterate with for k, v in m
HashSet<T> map[T]bool or map[T]struct{} no built-in set type; a map[T]bool (membership = value true) is the common idiom
BTreeMap<K, V> / BTreeSet<T> no built-in sorted map maintain a sorted [dynamic]K alongside a map[K]V and binary-search it (core:slice.binary_search), or keep entries in a sorted [dynamic]struct{key:K, val:V}. Flag // TODO(port): was BTreeMap, verify ordering-dependent logic since this is easy to get subtly wrong.
VecDeque<T> no built-in ring buffer hand-roll a ring buffer over [dynamic]T (or a fixed [N]T if bounded), or use two stacks if only push/pop at both ends is needed. Flag // TODO(port): was VecDeque.
BinaryHeap<T> no built-in heap core:container/priority_queue provides one — use it rather than hand-rolling
LinkedList<T> intrusive linked list by hand (raw ^Node + manual next/prev fields) Odin has no built-in linked list; if the Rust used LinkedList for genuine O(1) splice/insert reasons, port the node struct directly. If it was just used as a stack/queue, prefer [dynamic]T.
Cow<'a, T> drop the Cow; decide up front whether the function takes an owned T or a borrowed ^T/T and write that. If the original truly needed both paths, keep two code paths with a bool owned flag. leave // PERF(port): was Cow — may now always-copy or always-borrow
*const T / *mut T ^T Odin pointers are not const-qualified — same caveat as &[T] vs &mut [T] above
*const c_void / *mut c_void rawptr
NonNull<T> ^T Odin pointers can be nil; if the Rust's NonNull was load-bearing for a safety invariant (not just an optimization), keep an assert at construction sites: assert(ptr != nil)
#[repr(C)] struct a plain Odin struct Odin's default struct layout already matches the platform C ABI — no annotation needed unless you also need #[repr(packed)], see next row
#[repr(packed)] / #[repr(align(N))] #[packed] struct, or #[align(N)] on the type
#[derive(Debug)] nothing needed fmt.println/fmt.printf("%v", x) (core:fmt) prints arbitrary struct fields via reflection out of the box — there is no derive step
#[derive(PartialEq, Eq)] nothing needed, if every field is itself comparable Odin structs support ==/!= structurally by default as long as all fields do (numeric types, bool, fixed arrays, other comparable structs). [dynamic]T, map[K]V, and string-of-different-backing-pointer are not structurally comparable the way Rust's derived PartialEq would compare contents — string == string does compare contents (it's a byte-slice compare), but [dynamic]T == [dynamic]T will not compile. Write an explicit equal :: proc(a, b: Foo) -> bool if the struct contains a dynamic array/map field.
#[derive(Clone)] / Copy usually nothing needed for the assignment itself (Odin copies structs on assignment by default) but see the move-semantics ground rule above: a "clone" of a struct containing a [dynamic]T/map/^T field only copies the header/pointer, not the backing storage — write an explicit clone :: proc(f: Foo) -> Foo that allocates fresh backing storage for any such field if the Rust's Clone was a deep clone
#[derive(Default)] / impl Default usually nothing needed Odin's zero value is every type's default (Foo{} is fully zeroed) — only write an explicit make_foo :: proc() -> Foo { ... } if the Rust's Default set non-zero values
impl Drop for T explicit destroy :: proc(t: ^Foo) (or _delete), called via defer destroy(&foo) at every construction site see §Ownership — no automatic destructors exist
std::mem::size_of::<T>() size_of(T)
std::mem::swap(&mut a, &mut b) core:mem's mem.swap(&a, &b), or by hand: tmp := a; a = b; b = tmp
std::mem::take(&mut x) tmp := x; x = {} (replace with zero value) no built-in take, but the pattern is two lines
bit flags (bitflags! macro) bit_set[Enum] Odin's bit_set is a first-class language feature, not a crate — define a plain enum of flag names and wrap it: Flags :: bit_set[Flag]

Idiom map

Rust Odin
let x = expr; x := expr
let mut x = expr; x := expr — Odin has no immutable-by-default local bindings; every local declared with := is mutable. Rust's mut/no-mut distinction simply has no Odin counterpart; don't try to encode it (e.g. via a naming convention) — it adds noise Phase B will just remove.
let x: T = expr; (explicit type) x: T = expr
const X: T = expr; (true compile-time constant) X :: expr
static X: T = expr; (single runtime instance) X: T = expr at package scope (a package-level :=/: T = is a single global, not re-evaluated per use)
fn foo(x: i32) -> i32 { ... } foo :: proc(x: i32) -> i32 { ... }
struct Foo { x: i32 } + impl Foo { fn bar(&self) -> i32 { self.x } } Foo :: struct { x: i32 } and a free proc foo_bar :: proc(self: ^Foo) -> i32 { return self.x }. Odin has no method-call syntax — there is no foo.bar() sugar for user types. Name the proc <Type>_<method> and call it explicitly: foo_bar(&foo). Don't try to fake methods with using; reserve using for genuine struct-embedding/composition (see next row).
struct composition via a wrapped field accessed through Deref/DerefMut using field embedding: Outer :: struct { using inner: Inner, extra: i32 }outer.inner_field becomes directly accessible as outer.inner_field without the .inner hop
match x { A => ..., B(v) => ..., _ => ... } switch v in x { case A: ...; case B: ...; case: ... } (type-switch on a union) or switch x { case .A: ...; case .B: ...; case: ... } (value-switch on a plain enum)
if let Some(x) = opt { ... } if x, ok := maybe_call(); ok { ... }
while let Some(x) = it.next() { ... } for x, ok := it_next(&it); ok; x, ok = it_next(&it) { ... }, or restructure as a for ... in loop if iterating a slice/map directly — see §Closures & iterators
for x in iter { ... } over a Vec/slice for x in slice { ... }
for (i, x) in iter.enumerate() { ... } for x, i in slice { ... }
for (k, v) in map.iter() { ... } for k, v in map { ... }
loop { ... break; ... } for { ... break ... }
? operator (error propagation) or_return
.unwrap() assert(ok) followed by using the value, or for a Result-shaped call: panic with the error message if present. Never silently discard the failure path the way a careless _ = would — .unwrap()'s whole point was "crash loudly if this fails," so the port should crash loudly too.
.expect("msg") assert(ok, "msg")
.unwrap_or(default) v, ok := call(); v = ok ? v : default or fold into a small helper
.unwrap_or_else(|| ...) same, with the fallback as an explicit expression/call instead of a closure
panic!("msg") panic("msg")
assert!(cond) / debug_assert!(cond) assert(cond) — Odin's assert is stripped in -define:ODIN_DISABLE_ASSERT=true release builds, mirroring debug_assert!'s debug-only nature closely enough that both Rust forms can map to the same Odin call
unreachable!() panic("unreachable")
todo!() / unimplemented!() panic("TODO") plus a // TODO(port): comment — don't leave a bare panic with no tracking comment
x as i32 (numeric cast) i32(x) or cast(i32)x
T::try_from(x) (checked numeric conversion) manual range check + cast: if x >= MIN && x <= MAX { ok_value := T(x) } else { /* handle */ }
.into() / T::from(x) (widening, infallible) T(x)
mod tests { #[test] fn ... } @(test) test_foo :: proc(t: ^testing.T) { ... } (import "core:testing"), run via odin test .
#[cfg(test)] no direct gate needed — @(test)-attributed procs are simply excluded from non-odin test builds automatically
dbg!(x) fmt.println(x) or fmt.eprintln(x) (core:fmt)
format!("{}-{}", a, b) fmt.tprintf("%v-%v", a, b) (temp-allocator string, valid until next temp-allocator reset) or fmt.aprintf(...) (caller-freed, heap)
write!(f, "...") inside impl Display register a custom formatter: fmt.register_user_formatter(typeid_of(Foo), proc(fi: ^fmt.Info, arg: any, verb: rune) -> bool { ... }), called once at startup

Ownership, memory & "drop"

Odin has no borrow checker and no automatic destructors. Every Drop impl, every &/&mut exclusivity rule, and every move in the Rust source was doing real work that the Odin port must now do by convention and discipline instead of by compiler enforcement.

  • impl Drop for T { fn drop(&mut self) { ... } } → an explicit t_destroy :: proc(t: ^T) { ... } proc, called via defer t_destroy(&t) immediately after construction at every call site. There is no scope-exit hook — if a caller forgets the defer, the resource leaks, silently, with no compiler warning. Flag every non-trivial Drop impl's call sites with // PORT NOTE: must defer t_destroy here until Phase B has audited them all.
  • let _guard = foo(); (RAII guard pattern, e.g. a MutexGuard) → no RAII exists, so this becomes foo_lock(&foo); defer foo_unlock(&foo) — the acquire and release are now two separate statements you must keep paired by hand. This is the single highest-risk pattern in this kind of port (a return/break inserted between acquire and the eventual unlock, without the matching defer, silently breaks the invariant). Always add the defer on the same line as (or the line directly after) the acquire.
  • &mut self exclusivity (the borrow checker guaranteeing no other reference exists while this one is live) → nothing enforces this in Odin. If the Rust function's correctness (not just its ability to compile) depended on having the only reference — e.g. mutating a Vec while an iterator over it was thought to be inert — re-verify that invariant by hand and leave // TODO(port): verify exclusivity if you're not certain.
  • Moving an owned value into a function/struct (fn take(x: Vec<T>), self.field = some_box;) → in Odin this is a copy of the header (pointer/len/cap for a [dynamic]T, the raw pointer for a ^T). The source variable still exists and still points at the same backing storage. If both the old and new locations might independently free or mutate that backing storage, you now have a double-free or aliased mutation that the Rust compiler was preventing for free. Resolve this at port time, don't defer it to a runtime crash in Phase B: either (a) zero out the source after the "move" (x = {}) to make the single-owner intent explicit, or (b) confirm the source is genuinely never touched again and leave a comment saying so.
  • Arena/bump allocation (a crate like bumpalo, or a hand-rolled arena) → Odin has first-class arena support via core:mem (mem.Arena, mem.arena_allocator(&arena)) — set context.allocator to the arena allocator for the scope that should use it (context.allocator = mem.arena_allocator(&arena) inside a block, restored on scope exit since context is itself scope-stacked), then new(T)/make(...) inside that scope use the arena automatically. No need to thread an explicit allocator parameter through every call the way the Rust did if it wasn't using Rust's Allocator trait param style — Odin's implicit context already does that threading for you.
  • Pin<Box<T>> (self-referential / address-stable types) → Odin has no Pin. If the type's address-stability was load-bearing (intrusive lists, callback registration storing ^T), a heap-allocated ^T from new(T) is already address-stable for its lifetime (Odin never moves heap allocations) — the Pin wrapper itself can simply be dropped, but leave // PORT NOTE: was Pin<Box<T>>, relying on heap-allocation stability so Phase B knows why there's a ^T instead of a value type.

Error handling

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

fn read_config(path: &str) -> Result<Config, ConfigError> {
    let text = std::fs::read_to_string(path)?;
    let config = parse(&text)?;
    Ok(config)
}
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.
  • The ? operator → or_return, only valid when the enclosing proc's last return value is error-shaped (matches, or is assignable from, the failing call's error type) — same precondition ? has in Rust (the error types must convert).
  • Ok(x) at the end of a function → return x, nil (explicit, since Odin has no Ok-wrapping sugar).
  • Err(e)return <zero-value-of-T>, e. Odin requires a value for every return slot even on the error path — use the type's zero value ({}/0/""/nil) unless a more meaningful partial value exists.
  • A Rust enum AppError { NotFound, Io(io::Error), Parse(ParseError) } → an Odin tagged union: App_Error :: union { Not_Found, IO_Error, Parse_Error }, where Not_Found can be an empty struct{} marker and the other two wrap whatever data the Rust variant carried. A function's "no error" case returns Odin's nil for that union slot — there's no Ok wrapper needed since nil already means "absent."
  • .map_err(|e| ...) → an explicit if err != nil { err = wrap(err) } (or construct the new error variant directly) — no combinator form exists, write it as a statement.
  • thiserror's #[derive(Error)] (adds a Display impl + source() chaining) → register a fmt.register_user_formatter for the error union (see Idiom map's Display row) for the message half; there's no built-in error-chaining trait — if source() was used to walk a cause chain, add an explicit cause: ^App_Error field and walk it by hand.
  • anyhow::Error / Box<dyn Error> (type-erased, heap-allocated error) → avoid reaching for an Odin any-typed error field as a reflex replacement; prefer a concrete tagged union per the row above. Only fall back to any (Odin's universal type-erased value) if the error truly needs to carry arbitrary, unbounded payload types across a boundary that can't know about them — and flag it // TODO(port): was anyhow::Error, consider a closed error union instead.
  • or_else is Odin's combinator for substituting a default on failure (analogous to .unwrap_or_else), not for chaining fallible calls the way Rust's .or_else(|e| ...) on Result does — don't conflate the two; or_else in Odin always takes a single replacement value/expression.

Generics & trait bounds

Rust trait bounds become Odin parametric ($T) procs constrained with where clauses against core:intrinsics type queries, since Odin has no trait system to express the bound directly.

fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
    let mut max = items[0];
    for &x in items {
        if x > max { max = x; }
    }
    max
}
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 that captures "implements Trait">. There usually isn't an exact intrinsics query for an arbitrary user trait — only for built-in properties (type_is_numeric, type_is_ordered, type_is_comparable, type_is_integer, type_is_float, type_has_field, etc.). If the Rust's bound is a project-defined trait with real, multiple implementations, see §Traits instead — that's dynamic-dispatch territory, not a where-clause-expressible static bound.
  • fn foo<T>(x: T) where T: Default → if used only to get a zero value, just write x: T = {} inline — Odin's zero value already is the default for every type, so the bound usually disappears entirely.
  • Multiple type parameters (fn foo<K, V>(...)) → foo :: proc(...) -> ... where ... with $K, $V as separate parametric placeholders, same as Rust.
  • A generic struct Foo<T> { x: T }Foo :: struct($T: typeid) { x: T } — Odin's parametric structs take the type parameter as an explicit typeid parameter in the struct's own parameter list, instantiated as Foo(i32) at use sites (vs. Rust's Foo<i32>).
  • Const generics (struct Foo<const N: usize>) → Odin supports this directly: Foo :: struct($N: int) { data: [N]i32 }, instantiated as Foo(16).
  • impl<T> Foo<T> { fn bar(&self) {} } → a free proc taking the parametric struct: foo_bar :: proc(f: ^Foo($T)) { ... }.

Traits (dynamic dispatch)

This is the largest structural gap. Rust trait objects (dyn Trait, Box<dyn Trait>) give you a single value type that dispatches to different code depending on the concrete type it was built from, decided at runtime. Odin has no trait-object type. Pick one of these, in order of preference:

  1. Closed set of implementors known at port time → a tagged union of the concrete types, dispatched with a type-switch. This is almost always the right answer when the trait has a handful of impls in the same crate, and gives you the exhaustiveness check for free.

    trait Shape { fn area(&self) -> f64; }
    impl Shape for Circle { fn area(&self) -> f64 { ... } }
    impl Shape for Square { fn area(&self) -> f64 { ... } }
    fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    Shape :: union { Circle, Square }
    
    shape_area :: proc(s: Shape) -> f64 {
        switch v in s {
        case Circle: return circle_area(v)
        case Square: return square_area(v)
        }
        return 0
    }
    
    total_area :: proc(shapes: []Shape) -> f64 {
        total: f64
        for s in shapes { total += shape_area(s) }
        return total
    }
  2. Open set (plugin-style, or genuinely unbounded implementors, e.g. a trait crate consumers implement) → an explicit vtable struct of procedure pointers, paired with a rawptr (or any) for the instance data — this is what the Rust compiler generates for you under a dyn Trait, made manual:

    Shape_VTable :: struct {
        area: proc(self: rawptr) -> f64,
    }
    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 the construction sites so Phase B can sanity-check the function-pointer wiring.

  3. The trait is only ever used for a single concrete type at each call site (monomorphized statically, never actually boxed/erased at runtime) → it isn't really dynamic dispatch at all; treat it as a generic bound instead, per §Generics.

For a trait used purely for ergonomics in Rust — Display/Debug/ From/Into/operator-overload traits (Add, Index, etc.) — don't reach for a vtable; Odin doesn't support operator overloading or method-call sugar for user types at all (it does for built-in numeric arrays/matrices/ complex/quaternion types only), so these become plain named procs: Add :: proc(a, b: Vec2) -> Vec2, called explicitly as Add(a, b), not a + b. From/Into conversions become explicitly named converter procs (vec2_from_array, to_array).

A Rust proc group is the closest thing Odin has to ad-hoc overloading-by-type, and is the right tool when a Rust trait's only real job was picking between a few concrete implementations by argument type:

// Rust: fn process(x: impl Into<Event>) — called with several concrete types
process :: proc{process_click, process_key, process_resize}

Calls resolve to the matching overload at compile time by argument types — useful, but it is not runtime dispatch; don't reach for it where the Rust trait object's value was genuinely decided at runtime.

Closures & iterators

Odin has no Iterator trait and no combinator chain (.map().filter() .fold()). 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 that's reused across many call sites.

Rust Odin
items.iter().map(f).collect::<Vec<_>>() result: [dynamic]T; for x in items { append(&result, f(x)) }
items.iter().filter(pred).collect() result: [dynamic]T; for x in items { if pred(x) { append(&result, x) } }
items.iter().fold(init, f) acc := init; for x in items { acc = f(acc, x) }
items.iter().any(pred) / .all(pred) found := false; for x in items { if pred(x) { found = true; break } } (invert the condition and early-break for .all)
items.iter().find(pred) for x in items { if pred(x) { /* found x */ } } with an ok flag, or use core:slice's linear-search helpers if available for the exact case
|x| x + 1 (closure, no captures) proc(x: int) -> int { return x + 1 } — a plain proc value, assignable to a variable of proc(int)->int type
|x| x + captured_var (closure capturing by value) Odin closures can capture, but allocate the closure's captured environment (via context.allocator) and are intended for short-lived use, not stored long-term the way Rust closures often are. For anything stored in a struct field or outliving the enclosing scope, prefer an explicit struct holding the captured data plus a plain (non-capturing) proc that takes that struct as a parameter — this matches what the Rust compiler does internally for a capturing closure anyway, made explicit.
a hand-rolled struct MyIter { ... } + impl Iterator for MyIter a struct + an explicit my_iter_next :: proc(it: ^My_Iter) -> (T, bool), used in a for v, ok := my_iter_next(&it); ok; v, ok = my_iter_next(&it) { ... } loop, or wrap it in Odin's for ... in iterator-proc form if your Odin version supports custom for-in iterators for the type

Concurrency & async

Odin has no async runtime — no async fn, no .await, no executor. There is no drop-in equivalent; pick a real concurrency strategy at port time rather than faking async:

  • async fn / .await chains driven by tokio/async-std → restructure as either (a) a blocking call on a dedicated OS thread (core:thread.create), if the original concurrency was about not blocking a single-threaded reactor and you now have real threads available, or (b) an explicit state machine (a struct holding "what step are we on" plus whatever the .await points were waiting on), driven by your own poll loop, if you must preserve cooperative single-threaded scheduling. This is rarely mechanical — leave // TODO(port): was async, needs a real concurrency design and do not attempt to guess which of the two the surrounding system needs.
  • std::thread::spawnthread.create(proc_value) (core:thread), thread.join(t) to wait, thread.destroy(t) to free the handle.
  • Mutex<T> / RwLock<T>sync.Mutex / sync.RWMutex (core:sync), see the type-map row above — remember there's no lock-guard RAII, pair every lock with an explicit defer unlock.
  • AtomicUsize/AtomicBool/etc. (std::sync::atomic) → core:sync's atomic procs (e.g. sync.atomic_load, sync.atomic_add, sync.atomic_compare_exchange_strong) operating on a plain integer field — Odin doesn't wrap atomics in a distinct Atomic* field type the way Rust does; the atomicity is in how you access an ordinary field, so document which fields are accessed atomically with a comment, since the type signature alone won't tell a reader.
  • Channels (std::sync::mpsc, crossbeam::channel) → no built-in channel type. Hand-roll a small ring buffer guarded by a sync.Mutex + sync.Cond, or — if the project already has one — reuse it rather than rewriting from scratch each file. Flag // TODO(port): was mpsc channel.
  • Send/Sync marker traits (compile-time thread-safety proof) → no equivalent exists; Odin will not stop you from sharing a non-thread-safe type across threads. Re-verify by hand anywhere the Rust's Send/Sync bounds were load-bearing, and leave // TODO(port): verify thread-safety — was enforced by Send/Sync in Rust at the boundary.

Strings

Mirrors the spirit of Rust's &str/String split, with one extra wrinkle: Odin's string carries no compiler-enforced UTF-8 guarantee.

  • &str (borrowed, read-only) → string.
  • String (owned, growable) → build with strings.Builder (import "core:strings"): b: strings.Builder; strings.write_string(&b, "..."); finalize with s := strings.to_string(b). Free the builder's backing buffer with strings.builder_destroy(&b) once you're done with the resulting string (or defer it right after declaring b, if the final string is copied out via strings.clone before the builder dies).
  • format!(...)fmt.tprintf(...) for a temp-allocator string valid until the next free_all(context.temp_allocator) (fine for short-lived use like log lines and error messages consumed immediately), or fmt.aprintf(...) for a heap string the caller owns and must delete. Never store a tprintf result past the current frame/iteration.
  • s.push_str("...") / s += "..."strings.write_string(&builder, "...") while still building; once finalized to a plain string, Odin strings are immutable views — there's no in-place append on a finished string, you go back to a Builder.
  • s.to_uppercase() / .to_lowercase()strings.to_upper(s) / strings.to_lower(s) (core:strings) — these allocate and return a new string, same as Rust.
  • s.split(',')strings.split(s, ",") returns []string (caller frees with delete), or iterate lazily with strings.split_iterator if avoiding the allocation matters.
  • s.trim()strings.trim_space(s).
  • s.contains(needle) / .starts_with(p) / .ends_with(p)strings.contains(s, needle) / strings.has_prefix(s, p) / strings.has_suffix(s, p).
  • CString/*const c_char (FFI strings) → cstring, Odin's built-in null-terminated string type. Convert: strings.clone_to_cstring(s) (allocates, caller frees) to go from stringcstring; string(cstr) to go the other way (borrows, no allocation, scans for the NUL).

Collections

Rust Odin notes
Vec<T> [dynamic]T see Type map for the method table
HashMap<K, V> map[K]V wyhash-family default hasher in both — iteration order is unspecified in both, don't rely on it in either
HashSet<T> map[T]bool
BTreeMap<K, V> hand-rolled sorted structure, see Type map
VecDeque<T> hand-rolled ring buffer, see Type map
BinaryHeap<T> core:container/priority_queue use the package, don't hand-roll
[T; N] [N]T
&[T] []T
Vec<Vec<T>> (2D) [dynamic][dynamic]T, or a flat [dynamic]T with manual row*width+col indexing if the Rust was already doing the latter for cache locality if the Rust used a flat buffer specifically for perf, keep doing that — don't "simplify" to nested dynamic arrays, that's a regression
slice.sort() / .sort_by(cmp) slice.sort(s) / slice.sort_by(s, cmp) (core:slice)
slice.binary_search(x) slice.binary_search(s, x) (core:slice)
slice.iter().rev() for i := len(s) - 1; i >= 0; i -= 1 { x := s[i]; ... } no lazy reverse-iterator adapter; write the loop

FFI

extern "C" {
    fn sqrt(x: f64) -> f64;
}
foreign import libm "system:m"
foreign libm {
    sqrt :: proc "c" (x: f64) -> f64 ---
}
  • extern "C" { fn foo(...); } blocks → foreign import <name> "<library>" followed by a foreign <name> { ... --- } block listing the signatures, terminated with --- per declaration (no body).
  • #[link(name = "m")] → the string argument to foreign import ("system:m" links the system library by name; a path string links a specific file).
  • #[no_mangle] pub extern "C" fn foo(...) (exporting a Rust fn to C) → @(export) foo :: proc "c" (...) -> ... { ... }.
  • repr(C) struct crossing the FFI boundary → a plain Odin struct, per the type-map row — no annotation needed, Odin's default layout already matches the C ABI.
  • *const c_char / *mut c_char at the FFI boundary → cstring in the Odin signature (Odin treats cstring as exactly this C type), converted to/from Odin string at the boundary per §Strings.
  • Calling convention: Rust's extern "C" → Odin's proc "c" (...). If the Rust used extern "system" (the Windows-specific convention), use proc "system" (...) in Odin to match.
  • If your file has externs and isn't already the project's dedicated FFI package for this library, leave them in place with // TODO(port): move to <area>_sys-equivalent package, mirroring whatever the project's convention is for isolating raw bindings.

Platform conditionals

#[cfg(target_os = "windows")]
fn foo() { /* windows impl */ }
#[cfg(not(target_os = "windows"))]
fn foo() { /* other impl */ }
when ODIN_OS == .Windows {
    foo :: proc() { /* windows impl */ }
} else {
    foo :: proc() { /* other impl */ }
}
  • #[cfg(target_os = "...")]when ODIN_OS == .Windows / .Darwin / .Linux (etc. — full list in core:os/the Odin_OS_Type enum). when is a genuine compile-time branch like Rust's #[cfg] — the untaken branch isn't type-checked or compiled, so platform-only identifiers in the dead branch are fine, unlike a runtime if.
  • #[cfg(target_arch = "...")]when ODIN_ARCH == .amd64 / .arm64 / etc.
  • #[cfg(unix)]when ODIN_OS == .Linux || ODIN_OS == .Darwin || ODIN_OS == .FreeBSD (Odin has no single "is POSIX-ish" constant the way cfg(unix) is pre-bundled in Rust — enumerate the Unix-family OSes you actually need, or define your own IS_POSIX :: ODIN_OS == .Linux || ... constant once and reuse it).
  • #[cfg(debug_assertions)]when ODIN_DEBUG (set via the -debug build flag), or ODIN_DISABLE_ASSERT checks if specifically gating assertion-only code.
  • #[cfg(feature = "foo")] (Cargo feature flags) → no built-in feature system; gate on a project-defined compile-time constant set via -define:FOO_ENABLED=true on the command line, checked with when #config(FOO_ENABLED, false) (the core:#config two-argument form supplies the default when the -define is absent).
  • Runtime-checked platform branching where both branches could compile (no platform-only API used in either) → if ODIN_OS == .Windows { ... } else { ... } (plain if, not when) is fine and slightly cheaper to read when there's nothing platform-exclusive in either arm — but default to when whenever either branch references a Windows-only/Unix-only symbol, exactly mirroring why the original Rust needed #[cfg] rather than a runtime if.

Macros

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

  • A macro_rules! used purely for repetition avoidance (the same shape of code for several types) → a parametric proc ($T) if the bodies are truly identical modulo type, per §Generics. If the macro generates structurally different code per invocation (different field names, different counts of something), there's no equivalent — write the expansions out by hand and leave // TODO(port): was macro_rules!, hand-expanded — N call sites.
  • A #[derive(Trait)] from an external crate (serde::Serialize, thiserror::Error, etc.) → there is no derive step; implement the equivalent behavior by hand per type (see §Error handling for thiserror, and write explicit (de)serialization procs for serde — Odin's core:encoding/json works via reflection for the common case, so a hand-rolled Marshal-shaped proc is often unnecessary; check whether the default reflection-based (de)serialization already produces the same wire format before writing one).
  • A proc macro that generates a whole impl block or new items (#[tokio::main], a code-generating attribute macro) → there is no Odin equivalent at all. Read what the macro expands to (check the crate's docs or cargo expand output) and hand-write the expansion. Always leave // TODO(port): was proc-macro <name>, hand-expanded.
  • Declarative macros used as lightweight DSLs (e.g. vec![1, 2, 3], matches!(x, Pattern)) → these have direct non-macro equivalents: vec![1, 2, 3] → a dynamic array literal [dynamic]int{1, 2, 3} (or make + appends); matches!(x, Pattern) → an inline switch/if expression, written out.

Testing

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}
package foo

import "core:testing"

@(test)
test_it_works :: proc(t: ^testing.T) {
    testing.expect_value(t, 2 + 2, 4)
}
  • #[test] fn name()@(test) test_name :: proc(t: ^testing.T) { ... } — note the proc takes a ^testing.T parameter, unlike Rust's zero-argument test functions.
  • assert_eq!(a, b)testing.expect_value(t, a, b).
  • assert!(cond) inside a test → testing.expect(t, cond) (or testing.expectf(t, cond, "message %v", ...) for a formatted failure message).
  • A Rust mod tests { ... } submodule → Odin has no inline module blocks; the @(test)-attributed procs simply live in the same file (or package) as the code under test — there's no need to wrap them in a separate construct, and no #[cfg(test)] gate is needed since @(test) procs are already excluded from non-test builds automatically.
  • Run with odin test . (or odin test <path>) from the package directory.

Don't translate

  • use import lines at the top of the file → become import statements at the top of the .odin file, but don't try to preserve a 1:1 mapping of every individual imported item — Odin imports a whole package and you reference items as package.Item; consolidate.
  • mod foo; lines that exist purely to declare a submodule file → nothing to write; the directory/file structure itself is the module declaration in Odin (no mod statement needed).
  • #[allow(...)] / #[warn(...)] lint-suppression attributes → drop; Odin's compiler warnings/lints are not configured per-item this way.
  • Build scripts (build.rs) and Cargo.toml → no equivalent file to port; any codegen build.rs was doing needs a real decision (a standalone Odin/shell generator script, or hand-written output), flagged // TODO(port): had a build.rs codegen step.
  • #[derive(serde::Serialize, serde::Deserialize)] on a pure data struct with no custom #[serde(...)] attributes → likely nothing to write, see §Macros — check whether core:encoding/json's reflection-based default already matches before hand-writing anything.
  • Doc comments (///, //!) → carry over as plain // comments directly above the item; Odin has no separate doc-comment syntax or doc-generator convention to preserve beyond that.

Output format

End your .odin with a trailer comment:

// ──────────────────────────────────────────────────────────────────────────
// PORT STATUS
//   source:     <path/to/file>.rs (NNN lines)
//   confidence: high | medium | low
//   todos:      N
//   notes:      <one line: anything Phase B needs to know>
// ──────────────────────────────────────────────────────────────────────────

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

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