RFC 005: Rust Interop¶
Status: Planned
Created: 2025-12-10
Author(s): Danny Meijer (@danny-meijer)
Related: RFC 013 (Rust crate dependencies), RFC 020 (Cargo offline/locked policy)
Summary¶
Define an ergonomic, explicit Rust interop surface for Incan.
This RFC tightens the contract so we avoid “it looks like Rust” leakage and set correct expectations:
rust::...imports map predictably to Rustusepaths- core type mapping is deterministic (e.g.
intis alwaysi64) - common ownership/borrow friction (especially
strvs&str) is handled without Rust syntax in user code - limitations are stated up front (interop is powerful but not “everything in crates.io just works”)
Dependency pinning and lockfiles are specified by RFC 013.
Cargo policy enforcement (--offline/--locked/--frozen) and generated-project persistence are specified by RFC 020.
Prime directive: interop must not force users to learn Rust borrowing/lifetimes/traits at the surface level!
Goals¶
- Allow importing Rust crates/modules from Incan via an explicit
rust::...prefix. - Generate correct Rust
usestatements with stable, auditable namespacing. - Provide deterministic type mapping rules for core types and standard collections.
- Define a safe, Incan-shaped calling model (no lifetimes, no explicit
&in Incan code). - State explicit limitations and diagnostics expectations so the feature is predictable and debuggable.
Non-Goals¶
- A promise that “any Rust crate works”. Interop is scoped; outside that scope, failures are expected but must be diagnosable.
- Exposing Rust surface syntax in Incan (
&,&mut, lifetimes, turbofish::<T>). - Arbitrary proc-macros. (Incan may support a curated derive surface via
@derive; see below.) - Calling
unsafeRust items without an explicit Incan opt-in (out of scope for this RFC).
Guide-level explanation (how users think about it)¶
Imports: crate segment vs module path¶
rust:: imports always start with a crate segment:
rust::<crate_name>identifies the Rust crate- any following
::...segments are the Rust module path inside that crate
Examples:
# External crate (serde_json), item at crate root
from rust::serde_json import from_str, to_string
# Rust standard library (no Cargo dependency)
from rust::std::time import Instant
# External crate, deeper module path
from rust::chrono::naive::date import NaiveDate
Rust standard library root (rust::std)¶
Incan uses a single, canonical spelling for Rust’s standard library:
rust::std::...refers to Rust’s standard library and never produces a Cargo dependency.
Note:
std::...(withoutrust::) refers to Incan’s standard library modules.
Reserved (out of scope for this RFC):
rust::core::...andrust::alloc::...are not supported yet (futureno_std/ target work).
The “no Rust syntax” rule¶
Interop should feel like Incan, not Rust:
- user code does not write
&valueor lifetimes - common
str/&strfriction is handled by the compiler when calling external Rust functions
Reference-level explanation (precise rules)¶
Import syntax (normative)¶
Rust imports use the existing import / from ... import ... forms, with a rust:: prefix:
import rust::CRATE[::PATH...] [as ALIAS]
from rust::CRATE[::PATH...] import ITEM[, ITEM2 ...]
Note: Dot-notation is not supported for Rust interop imports (e.g.
from rust.serde_json import ...is invalid).::-notation is the only supported syntax for Rust interop
AST mapping (informative; matches current parser structure):
CRATEmaps toImportKind::{RustCrate|RustFrom}.crate_namePATH...maps toImportKind::{RustCrate|RustFrom}.path
Crate name and path decomposition (normative)¶
- The first identifier after
rust::is the crate segment (crate_name). - All following
::-separated identifiers are the module path within that crate.
Example decomposition: from rust::chrono::naive::date import NaiveDate
crate_name = "chrono"path = ["naive", "date"]items = ["NaiveDate"]
Rust standard library root (rust::std) (normative)¶
If crate_name is std, then:
- the import maps to a Rust path rooted at that crate (e.g.
std::time::Instant) - no Cargo dependency is added for that crate
Reserved (out of scope for this RFC):
- If
crate_nameiscoreoralloc, the compiler must emit a compile-time error instructing the user to userust::std::...instead (or wait for futureno_std/ target support).
Crate naming limitations (normative)¶
Incan spells the crate_name segment as a Rust identifier (letters/digits/underscore).
Rules:
- the crate segment must be a valid identifier (
[A-Za-z_][A-Za-z0-9_]*) - hyphenated crates are spelled with underscores in
rust::imports (Rust identifier form)
# crates.io package: wasm-bindgen
# Rust crate identifier: wasm_bindgen
from rust::wasm_bindgen import prelude
Note:
- Cargo/crates.io normalize
-and_in crate names, sowasm-bindgenis correctly resolved when referenced aswasm_bindgenin generated Rust code and dependency keys. - The generated Cargo dependency key uses the exact
crate_namespelling from therust::import (the underscore/Rust- identifier form). - Explicit package↔crate mapping is only needed for non-trivial mismatches (e.g.
package = "..."with a different crate name), and should live in RFC 013’sincan.tomldependency specification rather than in therust::import syntax.
Type mapping (normative)¶
Interop uses deterministic core type mapping:
bool→boolint→i64float→f64str→StringList[T]→Vec[T]Dict[K, V]→std::collections::HashMap<K, V>Option[T]→Option<T>Result[T, E]→Result<T, E>
Numeric note:
- Rust integer widths other than
i64(e.g.usize,u128) are not implicitly mapped toint. Conversions must be explicit (e.g. via a builtin likeint(...)) or handled by a dedicated adapter.
Borrowing and string conversion rules (normative)¶
Incan does not expose Rust borrowing syntax.
To make common Rust APIs usable (especially those taking &str), the compiler applies:
- string literals used where an owned string is required are lowered with
.to_string() - when calling an external Rust function (imported via
rust::...), an argument expression of Incan typestris lowered as a borrowed string view (&str) by default (implemented by borrowing the underlyingString, e.g.value.as_str()/&valueon the Rust side). This makes the common Rust API pattern “takes&str” ergonomic without exposing Rust syntax in user code. - Forcing an owned string: if user code syntactically constructs an owned string expression via
.to_string()(e.g.value.to_string()), the compiler must treat that argument as owned and pass it by value (a clone), rather than applying the default borrow lowering. - This RFC does not require Rust signature inspection to choose between
&strvsString. The rule is purely based on the Incan argument expression shape (default: borrowed view; explicit.to_string(): owned clone). - if a Rust interop call fails due to a
String/&strmismatch for an argument originating from an Incanstr, the compiler must emit a targeted diagnostic pointing at the argument expression and suggesting either:- add
.to_string()to force passing an ownedString(clone), or - remove
.to_string()/ pass the value directly so the compiler can pass a borrowed view (&str) (default).
- add
Scope:
- this RFC requires borrow/ownership adaptation for strings (the most common interop mismatch)
- general borrow inference for arbitrary Rust types is out of scope
- rust signature inspection (e.g. via rustdoc/rust-analyzer metadata) and compile‑retry ‘guessing’ strategies to auto-fix borrow/ownership mismatches are out of scope for this RFC.
Calling model: methods vs associated functions (normative)¶
Incan uses a single dot-call syntax at the surface for both methods and associated functions.
Lowering rules:
- If the receiver is a value,
value.method(args...)lowers to a Rust method call:value.method(args...). - If the receiver resolves to a type-like identifier (an Incan type name or an imported Rust type), then
Type.method(args...)lowers to a Rust associated function call:Type::method(args...).
This is why the examples below use Instant.now() and Uuid.new_v4() even though the corresponding Rust spelling is
Instant::now() / Uuid::new_v4().
Derives, traits, and serde (normative direction)¶
Many Rust APIs require trait bounds (e.g. HashMap keys require Eq + Hash; serde_json requires Serialize /
Deserialize).
Incan’s user-facing mechanism for this is the @derive(...) decorator (not Rust proc-macro syntax).
Derive identifiers are language vocabulary: they do not need importing and are validated against a curated registry.
Requirement:
- Incan supports a curated derive set sufficient for common interop:
Debug,Clone,Eq,HashSerialize,Deserialize(to makeserde_jsonusable on Incan models)
This is intentionally not “arbitrary proc-macros”: the derive set is curated and wired into the compiler/runtime contract.
Implementation model note (important for determinism):
- Even with a curated
@derive(...)list, the implementation may emit Rust#[derive(...)]for those traits and thus execute Rust proc-macros at build time (e.g. serde derives). - This is acceptable only in combination with locked/pinned dependency resolution (RFC 013) and reproducible/offline build policy controls (RFC 020).
- The curated derive list is part of Incan’s compatibility contract (versioned, documented, and stable-by-default).
Panic/unwind and error policy (normative)¶
Rust interop compiles into a single Rust program (generated code + dependencies). This is not an extern "C" 1
FFI (Foreign Function Interface) boundary.
Policy:
- Rust
Result/Optionvalues map to IncanResult/Optionand work with?/pattern matching as usual. - Rust panics behave like panics in generated Rust code:
- by default they terminate the program/test (panic semantics are Rust-defined)
- implementations should ensure the error output clearly indicates “this was a Rust panic” and includes enough context (crate/function if available) to debug
- catching panics and converting them into Incan runtime errors is a possible future extension, but out of scope here
Unsafe policy (normative)¶
Calling unsafe Rust items is out of scope for this RFC.
- The compiler must not generate Rust
unsafe { ... }on behalf of user code. - Therefore, Rust APIs that require
unsafeare unsupported and should produce a clear, targeted diagnostic explaining that “unsafe interop is out of scope” (even if the underlying trigger originates from Rust compilation). - A future RFC may introduce an explicit
unsafeblock/marker in Incan (and an associated safety policy).
Diagnostics expectations (normative)¶
When Rust interop fails at the Incan layer (before invoking Cargo), errors must be actionable and include:
- inferred
crate_nameandpath - the missing item name (crate/module/type/function)
- a suggestion to:
- verify the Rust crate API path, and/or
- add version/features in
incan.toml(RFC 013)
Examples¶
from rust::std::time import Instant
def measure() -> float:
start = Instant.now()
# ... work ...
return start.elapsed().as_secs_f64()
from rust::uuid import Uuid
def new_id() -> str:
return Uuid.new_v4().to_string()
from rust::serde_json import from_str as json_parse, Error as JsonError
@derive(Deserialize)
model UserData:
name: str
email: str
def parse_user_data(json_str: str) -> Result[UserData, JsonError]:
return json_parse(json_str)?
Limitations¶
- No arbitrary proc-macros or custom derives (only curated derives via
@derive). - Trait-heavy or GAT/lifetime-heavy APIs may not map cleanly.
- No explicit borrow/lifetime syntax in Incan.
Rationale (why these limits exist):
- Arbitrary proc-macros are effectively “run arbitrary Rust at build time”; they undermine determinism, portability,
and the “Incan stays Incan” surface. A curated
@derive(...)set keeps the interop contract explicit and reviewable. - Trait-heavy and lifetime-heavy Rust APIs often require expressing trait bounds and borrowing/lifetimes at call sites; this is intentionally deferred until Incan has a richer, explicit trait/borrow surface for interop.
- Incan’s goal is to remove Rust’s borrow-checker ergonomics from user code. The compiler may adapt borrows internally (currently scoped mainly to strings), but users should not be forced to write Rust-like lifetime/borrow annotations.
Open Questions¶
- Non-trivial package↔crate mapping: how should we represent cases where the Cargo package name differs from the Rust
crate identifier beyond
-/_normalization (e.g. dependency renames orpackage = "..."style overrides)? - How far should the compiler go in adapting non-string borrows for external calls (and/or using Rust signature inspection as a follow-up to reduce friction)?
- Should we catch panics at the “Incan runtime boundary” and convert them into an Incan runtime error type (opt-in), or should panics remain “just Rust panics” for interop calls?
- How should Rust interop behave under non-native targets (e.g.
wasm32), and where should those constraints live (likely RFC 003 or a dedicated follow-up RFC)?
Appendix: crate::... absolute module paths for Incan modules (normative)¶
crate::... is a Rust-style spelling for Incan module paths (project-root absolute imports). It is not related
to rust::... (Rust crate imports).
import crate::config as cfg
from crate::utils import format_date
Notes:
crate::...is for Incan modules (project root), not for selecting a Rust crate.- Parent navigation uses
super::.../..(see RFC 000).
Checklist (acceptance)¶
- [ ]
rust::import syntax and crate/path decomposition is fully specified and implemented - [ ]
rust::stdworks without a Cargo dependency, andrust::core/rust::allocare rejected with a clear diagnostic - [ ] Core type mapping is deterministic (
int=i64,float=f64,str=String, collections,Option/Result) - [ ] String borrow/ownership adaptation for external calls works and is documented
- [ ] Curated derive set is defined and sufficient for common crates (
HashMap,serde_json) - [ ] Diagnostics for common failure modes are actionable (crate/path/item + hint toward RFC 013 config)
-
extern "C"selects the C ABI/calling convention for interop with C. It matters because unwinding (panics) across a realextern "C"boundary is not allowed; in Incan interop we generate one Rust program, so this is a normal Rust-to-Rust call path, not an FFI boundary. ↩