Interactive walkthrough

Ownership Lite

Three ways to fix any ownership error — and the one rule behind them.

Ryo gives you Rust-level memory safety — no dangling pointers, no use-after-free, no garbage collector — without lifetime annotations. The whole model fits in one sentence:

Functions borrow by default; assignment and return move by default.

Because borrows live only for the duration of a single call, the compiler always knows when they end — so there are no lifetimes to write, and most of the fights people have with Rust's borrow checker simply never happen. But you will still meet the compiler's ownership rules, and when you do, there are three ways to fix it. Let's walk through one real error and all three fixes. Every example below is verified against the actual compiler.

1 A program that doesn't compile

This looks innocent. We create a string, hand it to another variable, and print it:

broken.ryo
fn main():
	msg = "hello"
	other = msg
	print(msg)

Which types move and which don't? Only types that manage a resource move. Everything else is a Copy type — assignment just copies the bits, and both bindings stay valid:

Copy types — int, float, bool
copies.ryo
fn main():
	mut n = 42
	m = n
	print(int_to_str(n))
	print(int_to_str(m))
output
4242
Move types — str, list[T], map[K, V]
broken.ryo
fn main():
	msg = "hello"
	other = msg
	print(msg)
compiler
[E0020] use of moved value `msg`

2 Pick a fix

The right fix depends on one question: does ownership of the value need to leave the caller? Pick a card — each shows the corrected program and why it works.

fix_1_borrow.ryo
fn print_twice(text: str):
	print(text)
	print(text)

fn main():
	msg = "hello"
	print_twice(msg)
	print(msg)
output
hellohellohello
Why it works Function parameters borrow by default — no annotation, no &, no lifetime. msg is lent to print_twice for the duration of the call and is fully yours again when it returns. This is the fix most of the time: in Rust you'd reach for &str here, and the borrow checker would fight you about it later. In Ryo the borrow is scoped to the call, so the fight never starts.
fix_2_inout.ryo
fn add_suffix(inout text: str):
	text = text + "!"

fn main():
	mut msg = "hello"
	add_suffix(&msg)
	print(msg)
output
hello!
Why it works When the callee needs to modify your value, borrow it mutably with inout in the signature and & at the call site. The binding must be declared mut — mutability is always visible. Ownership never moves: msg is still yours afterwards, now with a ! attached. Under the hood this passes a pointer — no copy of the string is made.
fix_3_move.ryo
fn consume(move text: str):
	print(text)

fn main():
	msg = "hello"
	consume(msg)
output
hello
Why it works Sometimes ownership really should change hands — the callee stores the value, sends it elsewhere, or consumes it to build something new. Mark the parameter move in the signature and the transfer is explicit and deliberate: the caller can see it, and the compiler enforces that the old binding is never touched again. If you add print(msg) after this call, you get the same E0020 — on purpose.

3 A second error: two borrows collide

Use-after-move is one error family. The other is aliasing — two borrows of the same value fighting inside a single call. Say we have a point and want to clamp both coordinates in one go:

alias_broken.ryo
struct Point:
	x: int
	y: int

fn clamp2(inout a: int, inout b: int, hi: int):
	if a > hi:
		a = hi
	if b > hi:
		b = hi

fn main():
	mut p = Point{x=5, y=9}
	clamp2(&p.x, &p.y, 10)
	print(int_to_str(p.x))
alias_fixed.ryo
struct Point:
	x: int
	y: int

fn clamp(inout v: int, hi: int):
	if v > hi:
		v = hi

fn main():
	mut p = Point{x=5, y=9}
	clamp(&p.x, 10)
	clamp(&p.y, 10)
	print(int_to_str(p.x))
	print(int_to_str(p.y))
output
59
Why it works A borrow dies the moment its call returns — so splitting one call into two splits the borrows. The same trick handles the read/write overlap: when a call needs to read a value it also borrows mutably, hoist the read into its own statement first. One mutable borrow or many immutable borrows per call — never both — and the borrow never outlives the call.

4 Or just restructure

The compiler's own note points at a fourth option, and it's often the best one: use the value before you move it.

fix_4_restructure.ryo
fn main():
	msg = "hello"
	print(msg)
	other = msg
	print(other)
output
hellohello
Why it works Ownership errors are about order, so reordering is a legitimate fix. Print while you still own the value, then move it. The same trick scales up: when one call borrows part of a value and another part needs mutating, hoist the read into its own statement first — borrows end at the end of each call, so splitting statements splits the borrows.

5 Why bother? No GC, ever

All of this ceremony buys one thing: deterministic destruction. Every value's resources — heap buffers, sockets, file handles — are released at a point the compiler can see: at last use, or at the end of the scope. There is no garbage collector to pause your program, no finalizer running late, no refcount traffic on the hot path.

No GC pauses
Memory is freed where and when the code says it is — performance stays predictable from run to run.
No runtime safety tax
Every rule on this page is enforced at compile time. The ownership checks cost nothing at runtime — moves and borrows compile to plain pointer passes.
Shared only on request
Spec'd · in progress When a value genuinely needs more than one owner, you opt in with shared[T] — refcounting is a tool you reach for, not a tax you always pay. (Written into the spec; the refcount runtime is still being built.)

6 Where Ownership Lite draws the line

Honesty time: the same rules that delete the lifetime vocabulary also forbid one fundamental capability — a borrow that outlives the call it was created in. It can't be returned, stored, or sent anywhere. Four shapes of programs are unbuildable in the safe model because of exactly that one sentence — and for each, here's what's enforced in the compiler today versus what's already written into the spec as the planned way out:

Enforced today the compiler rejects this code right now. Spec'd · in progress designed and specified, but not yet implemented — the blocker is named on each card.

Zero-copy parsers over owned input
Enforced today

The parser-combinator shape — fn parse(input: strview) -> (strview, strview) — can't exist: free functions can't return views of owned values (Rule 5). Parsing a huge file zero-copy works inside one call; the results can't come back to you as borrows.

Spec'd · in progress

Retaining views (spec §5.7): a view sliced from a shared[T] store keeps the buffer alive, so a parser library will hand borrowed results back — and structs will be able to hold them. Blocked on the shared[T] refcount runtime. Cost: a frozen input and one atomic retain per view.

Stored lazy pipelines
Enforced today

An iterator/filter chain saved in a struct and consumed later holds a borrow across arbitrary time. Ryo's views are tied to their owner's scope and can't be stored — pipelines are callback-shaped instead.

Rewrite spec'd · ships with iterators

No language escape is planned — sugar can't extend a borrow's life, and the general fix is the lifetime machinery Ownership Lite exists to avoid. The mechanical rewrite is spec'd: chain adaptors in one statement and collect(), or store a closure over owned data and run it on consumption. Streaming stages over a channel arrive with the concurrency runtime.

Mutable stack-lending parallelism
Spec'd · in progress

Read-only stack lending is in the spec: inside a task.scope, child tasks may capture by immutable borrow — the scope joins every child before your frame ends, so the borrow provably never outlives it. That's Rust's thread::scope shape for readers. Blocked on the concurrency runtime.

Deliberate non-goal + one exception

Mutable lending (rayon-style par_iter writing into disjoint chunks) stays out — proving disjointness across tasks is full borrow checking. The single blessed exception is std.slice.split_mut: disjoint chunks by construction, stdlib-internal unsafe as the proof, each task.scope child capturing one chunk.

Self-referential types
Enforced today

A struct that owns a buffer and holds views into itself (editor piece tables, arena+reference designs). Rules 5 and 6 together forbid it — a retaining view's owner is always an external shared[T] cell, never the struct itself.

Spec'd · in progress

The escape hatch is manifest-gated unsafe blocks (spec §17) — the same tool Rust requires (Pin + unsafe) for most of these types. Lands with the FFI/unsafe work. The common case has a safe rewrite: IDs or handles into a shared[T]-owned buffer.

Not on this list: disjoint field borrows, borrowed graph edges, interior mutability. Those change how you write the program — split the call, use IDs, opt into shared[T] — never whether you can. Every application has a mechanical rewrite; there is always an escape hatch for the rest.

The one real casualty — and its rescue plan

Applications can always be restructured, so Ryo can build any of them. The hard case is the zero-copy parsing library ecosystem — libraries where the borrowed result is the product. That shape is impossible over owned input today — but it isn't abandoned: retaining views (spec §5.7) bring it back over shared[T] input, at the price of a frozen buffer and one retain per view. Until the refcount runtime lands, that ecosystem waits — by design, not by oversight.

7 The decision rule

Everything above collapses into one question:

Does ownership of the value need to leave the caller?
No — it stays with you
Read or share the value default
No annotation — parameters borrow by default, and the borrow dies when the call returns.
Caller afterwards: valid, unchanged
If in doubt, do nothing.
Modify in place, caller keeps it inout
inout in the signature, &x at the call site, and the binding declared mut — mutation is always visible.
Caller afterwards: valid, possibly modified
Mutation is a borrow, not a transfer.
Use it and pass it on reorder
Ownership errors are about order: use the value first, move it second. Borrows end at each call, so splitting statements splits the borrows.
Caller afterwards: valid until the move
Ownership errors are about order.
Yes — it changes hands
Ownership leaves for good move
move in the signature — store it, send it, consume it. The call site stays plain; the transfer is explicit and the compiler enforces it.
Caller afterwards: invalidated — use-after-move is E0020
Say it in the signature, never at the call.

No lifetimes appear anywhere in this page — not because we're hiding them, but because they can't occur: borrows never escape a call, so there's nothing to annotate. When you need shared ownership that outlives a call, that's what shared[T] (reference counting) is for Spec'd · in progress

Next steps: the language reference, or the ownership chapter of the spec (§5).

The seven rules

Everything on this page is an instance of seven formal rules from the spec (§5.3). Rule 7 is the one the aliasing error cites:

RuleIn one line
1 Assignment and return default to move Owning types invalidate the source binding — E0020
2 Parameters default to immutable borrow No annotation anywhere; the caller's binding stays valid
3 Mutable borrows are always explicit inout in the signature, & at the call site, mut binding
4 move parameters override the default The transfer is declared in the signature; the caller's binding dies
5 Functions cannot return borrows Returns are always owned — retaining views over shared[T] excepted (spec'd)
6 Structs cannot contain references Fields are owned, shared[T], retaining views, or IDs — never &T
7 One writer or many readers — borrows scoped to calls One inout or many borrows per call, never both — E0032