Duckborrowing¶
Duckborrowing is the backend ownership-planning layer that lets Incan keep value-oriented source semantics while emitting valid, predictable Rust. It is not a source-language feature and it is not a second Rust borrow checker. It is the compiler-side policy for deciding when generated Rust should move, borrow, mutably borrow, clone, convert with .into(), or materialize owned String storage.
The ownership module states the local contract directly: keep emitter modules calling the planner instead of open-coding ad hoc .clone(), &, &mut, .to_string(), or .into() decisions.
Goals¶
- Keep Rust ownership details out of ordinary Incan code.
- Centralize ownership decisions so bug fixes improve whole classes of generated code.
- Preserve last-use moves where the IR proves a value can be consumed.
- Materialize owned values at Incan storage boundaries, especially for strings and non-
Copyborrowed values. - Borrow at Rust interop and helper boundaries where the Rust API expects references.
- Add required generic
Clonebounds when the backend, not the source program, introduces a clone.
Non-goals¶
- Duckborrowing must not become "clone until Rust compiles".
- It must not hide frontend typing mistakes or backend lowering bugs.
- It must not require users to add
.clone(),.as_ref(),str(...), or.into()as codegen escape hatches. - It must not encode ownership policy separately in every emitter.
Where it runs¶
Duckborrowing lives in the backend IR path:
typed AST
-> lowering records value shapes and VarAccess
-> ownership planner chooses a use-site conversion
-> emitters apply the selected conversion
-> trait-bound inference mirrors backend-inserted clones for generics
-> generated Rust
The main files are:
| File | Responsibility |
|---|---|
src/backend/ir/ownership.rs |
Public ownership-planning facade. Defines ValueUseSite and use-site helpers. |
src/backend/ir/conversions.rs |
Core conversion policy. Returns None, ToString, Into, Borrow, MutBorrow, or Clone. |
src/backend/ir/emit/expressions/mod.rs |
Applies emit_expr_for_use recursively for expressions, literals, tuples, and match scrutinees. |
src/backend/ir/emit/expressions/calls.rs |
Applies call-argument ownership policy for Incan and external Rust calls. |
src/backend/ir/emit/expressions/methods/collection_methods.rs |
Plans collection receiver/key borrows, including string lookup probes. |
src/backend/ir/emit/statements.rs |
Applies assignment, return, match, dict-assignment, and loop ownership policy. |
src/backend/ir/lower/stmt.rs |
Marks move-vs-read access for statement lowering, including tuple unpacking. |
src/backend/ir/trait_bound_inference.rs |
Adds generic trait bounds needed by backend-inserted clones. |
src/backend/ir/types.rs |
Defines IrType::is_copy() so planning can distinguish cheap copies from owned materialization. |
Use-site model¶
Every ownership decision starts with a typed use site. ValueUseSite is the boundary contract between emitters and the planner:
| Use site | Meaning |
|---|---|
IncanCallArg |
Argument to an Incan-defined callable. Parameters generally receive owned Incan values. |
ExternalCallArg |
Argument to a Rust interop callable. Rust APIs often receive borrowed values or custom Into targets. |
StructField |
Value stored into an owned generated Rust struct field. |
CollectionElement |
Value stored into a list, set, dict, or tuple literal. |
Assignment |
Value assigned to a binding or existing place. |
ReturnValue |
Value returned from a function. |
MatchScrutinee |
Value consumed by generated Rust match. |
MethodArg |
Argument to method-style lowering where the method controls borrow behavior. |
Emitters should call emit_expr_for_use(expr, site) or plan_value_use(expr, site) instead of applying ownership tokens directly.
Decision order¶
The planner should answer these questions in this order:
- What use site is consuming the value?
- Is there a target type from the typechecker, field, parameter, collection element, assignment, or return signature?
- Does lowering mark the value as
VarAccess::Move, or must the source remain usable? - Is the source already a borrow, a static string, a field read, or a borrowed method-chain result such as
as_ref()? - Is the source type
Copyaccording toIrType::is_copy()? - Does the target require owned Incan storage or a Rust interop shape?
- If the emitted plan clones a generic value, did trait-bound inference add the matching
Clonebound?
That ordering matters. Last-use moves should win over defensive cloning. Borrowed materialization should win over passing &T into an owned Incan sink. Rust interop should preserve the shape the Rust API expects instead of forcing Incan-owned values everywhere.
Core policies¶
Owned Incan sinks¶
Incan function arguments, struct fields, collection elements, assignments, returns, and match scrutinees are owned value sinks unless the specific sink says otherwise. At these boundaries:
- String literals and static strings materialize to owned
Stringwhen the target isstr, generic, or otherwise inferred as owned Incan storage. - Non-
Copyvariables move when lowering marks them asVarAccess::Move. - Non-
Copyvariables clone when the value must remain usable after the use site. - Field reads clone when moving the field would move out of a parent object that remains borrowed or owned elsewhere.
- Borrowed
as_ref()and interop-unwrapped borrowed results clone when the sink expects an owned Incan value.
Rust interop sinks¶
External Rust calls are different from Incan calls. They often accept references, custom string wrappers, or generic Into targets. At these boundaries:
- String-like values use
.into()when the Rust target may resolve throughInto. - Non-string non-
Copyvalues generally borrow rather than clone. - Mutable aggregate Incan parameters can reborrow as
&mut Twhen the callee parameter requires mutation.
Collections and tuples¶
Collection and tuple literals recursively apply CollectionElement planning to each item. This prevents nested string/borrow issues from escaping through literal syntax.
Examples of expected behavior:
["a", "b"]storesStringelements when the list element type isstr.{"id": value}materializes the key and value according to the dict key/value types.(borrowed.as_ref(), "x")clones or materializes items when the tuple is stored as owned data.
Lookup probes¶
Lookup and membership probes should not allocate just because the stored collection owns strings. For dict and set string keys, the planner can emit AsRef<str> probe shapes so String, &str, and static string values can all look up owned string keys without extra user code.
Match scrutinees¶
Generated Rust match consumes the scrutinee shape. The planner treats match scrutinees like owned-result materialization so borrowed or shared non-Copy values are cloned before matching when needed.
Generic clone bounds¶
Backend-inserted .clone() calls are invisible to source-level trait-bound inference unless the backend mirrors them.
When ownership planning can clone a generic value, src/backend/ir/trait_bound_inference.rs must add the corresponding Clone bound. Otherwise the generated Rust may fail only after codegen.
Contributor rules¶
- Do not fix ownership bugs by adding local
.clone(),&,&mut,.as_ref(),.to_string(), or.into()in an emitter unless the operation is truly local to a Rust helper API. - If a new boundary consumes a value, add or reuse a
ValueUseSite. - If a new source expression can produce borrowed data, teach
conversions.rshow it materializes at owned sinks. - If a new sink stores values recursively, route children through
emit_expr_for_use. - If a generic value can be cloned by backend policy, update trait-bound inference in the same change.
- If a library or consumer needs workaround calls to compile, treat that as evidence of a missing planner rule.
Testing expectations¶
Ownership changes need tests at the layer where the behavior is decided and at least one generated-Rust check when the bug was observable only after emission.
Use these test shapes:
- Conversion planner unit tests in
src/backend/ir/conversions.rsfor pure policy decisions. - IR/codegen snapshot tests for emitted Rust shapes.
- Build or run tests for generated Rust when borrow checker behavior is the failure mode.
- Consumer checks from sibling projects when the bug came from real library patterns rather than a minimized fixture.
- Trait-bound inference tests when a backend-inserted clone touches generic
T.
Review checklist¶
Before merging duckborrowing changes, answer these questions:
- What exact use site is being planned?
- What target type, if any, reaches the planner?
- Is the source a move, read, borrow, field read, static value, or borrowed method-chain result?
- Is the source
CopyunderIrType::is_copy()? - Does the fix improve a class of ownership bugs, or only one emitter branch?
- Are generic
Clonebounds inferred when needed? - Is there a regression test that would fail without the planner rule?