Rust Interoperability¶
Incan compiles to Rust, which means you can import from Rust crates and interoperate with Rust types.
Importing Rust Crates¶
Use the rust:: prefix to import from Rust crates:
# Import entire crate
import rust::serde_json as json
# Import specific items from Rust's standard library
from rust::std::time import Instant, Duration
from rust::std::collections import HashMap, HashSet
# Import from an external crate
from rust::uuid import Uuid
# Import nested items
import rust::serde_json::Value
The std and rust namespaces¶
std::...is reserved for Incan's standard library (e.g.from std.web import App).rust::...is for Rust crates from crates.io and Rust's standard library.
To use Rust's own standard library, use the rust::std:: prefix:
import rust::std::fs
import rust::std::collections::BTreeMap
For example, if you would use import std::fs, this would refer to Incan's stdlib, not Rust's!
Note:
rust::core::...andrust::alloc::...are reserved for futureno_std/target work and are not yet supported. The compiler will tell you to userust::std::...instead.
Paths and names that match Incan keywords¶
Rust modules and items sometimes use names that are reserved in Incan (type, async, and others). In rust:: paths and in from rust::... import ... item lists, those spellings are still accepted. When you import a keyword-named symbol, bind it with as so you have a normal identifier in Incan source:
from rust::my_crate::proto import type as proto_type
The same rule applies to path segments after rust:: (for example rust::substrait::proto::type::Binary).
Dependency Management¶
When you use import rust::crate_name, Incan automatically adds the dependency to your generated Cargo.toml.
Dependencies are resolved using a three-tier precedence system:
incan.toml(highest priority): If the crate is configured in your project manifest, that spec is used.- Inline annotations: If you write
import rust::foo @ "1.0", that version is used. - Known-good defaults: For common crates (see table below), the compiler provides tested defaults.
If none of these apply, the compiler emits an error asking you to specify a version.
For the bigger picture, see: Projects today.
Specifying versions and features¶
You can annotate any rust:: import with a version requirement and optional feature list:
# Version only
import rust::my_crate @ "1.0"
from rust::obscure_lib @ "0.5" import Widget
# Version with features
import rust::tokio @ "1.0" with ["full"]
import rust::serde @ "1.0" with ["derive", "rc"]
from rust::sqlx @ "0.7" with ["runtime-tokio", "postgres"] import Pool
Version strings use Cargo SemVer syntax:
| Syntax | Meaning | Example |
|---|---|---|
"1.2.3" |
Caret (^1.2.3): >=1.2.3 <2.0 | @ "1.0" |
"~1.2" |
Tilde: >=1.2.0 <1.3.0 | @ "~0.3" |
">=1, <2" |
Range | @ ">=1.30, <2.0" |
"=1.2.3" |
Exact version | @ "=1.2.3" |
Merging rules when the same crate is imported in multiple files:
- Version strings must match exactly across all sites (mismatch is an error).
- Features are unioned automatically.
Project-level dependencies (incan.toml)¶
For projects with multiple dependencies, use an incan.toml manifest instead of inline annotations.
This is the recommended approach for anything beyond single-file scripts:
[project]
name = "my_app"
version = "0.1.0"
[rust-dependencies]
tokio = { version = "1.35", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
my_crate = "2.0" # assuming this is a rust crate you are referencing
When a crate is configured in incan.toml, inline version annotations for that crate are not allowed —
the manifest is the single source of truth. Bare imports (without @) are fine.
For the full manifest format, see:
Project configuration reference.
For a practical guide, see: Managing dependencies.
Known-good defaults¶
The following crates have pre-configured versions with appropriate features. These defaults apply automatically when you
import a crate without a version annotation and without an incan.toml entry:
| Crate | Version | Features |
|---|---|---|
| serde | 1.0 | derive |
| serde_json | 1.0 | - |
| tokio | 1 | rt-multi-thread, macros, time, sync |
| time | 0.3 | formatting, macros |
| chrono | 0.4 | serde |
| reqwest | 0.11 | json |
| uuid | 1.0 | v4, serde |
| rand | 0.8 | - |
| regex | 1.0 | - |
| anyhow | 1.0 | - |
| thiserror | 1.0 | - |
| tracing | 0.1 | - |
| clap | 4.0 | derive |
| log | 0.4 | - |
| env_logger | 0.10 | - |
| sqlx | 0.7 | runtime-tokio-native-tls, postgres |
| futures | 0.3 | - |
| bytes | 1.0 | - |
| itertools | 0.12 | - |
You can override any of these via incan.toml or inline @ "version" annotations.
Using unknown crates¶
If you import a crate not in the known-good list and provide no version, you'll see:
error: unknown Rust crate `my_crate`: no version specified
--> src/main.incn:5
import rust::my_crate
hint: Add a version annotation: `import rust::my_crate @ "1.0"` or add it to incan.toml.
Fix: add an inline version annotation, or add the crate to your incan.toml.
Rust-backed types with rusttype¶
Use rusttype when you want an Incan type that is directly backed by a Rust type:
from rust::std::string import String as RustString
type Name = rusttype RustString:
def parse(raw: str) -> Result[Name, str]:
...
def as_str(self) -> str:
...
This keeps Rust provenance explicit (rust::... import) while giving you an Incan-facing type name (Name) for docs, APIs, and rebinding.
Qualified backing paths¶
When a rust:: import binds a Rust module (or other namespace), you can name a concrete type inside it with :: after that binding:
from rust::substrait::proto import type as proto_type
type Binary = rusttype proto_type::Binary
The first segment must resolve to a rust:: import; the compiler builds the full Rust path for lowering and tooling.
Generics on qualified type paths (for example binding::Wrapper[int]) are not supported yet — import or spell a path to the concrete type without type arguments in that position.
Declaring conversion edges with interop:¶
Inside a rusttype body, interop: declares explicit adapters across the Incan/Rust boundary:
from rust::std::string import String as RustString
type Name = rusttype RustString:
def parse(raw: str) -> Result[Name, str]:
...
def as_str(self) -> str:
...
interop:
from str try Name.parse
into str via Name.as_str
Use:
from S via adapterfor infallible inbound adaptation (S -> ThisType)from S try adapterfor fallible inbound adaptation (S -> Result[ThisType, E]orS -> Option[ThisType])into T via adapterfor outbound adaptation (ThisType -> T)
Capability bounds with std.rust¶
Import Rust capability markers from std.rust and use them in generic with clauses:
from std.rust import Send, Sync
def run[T with Send, Sync](_value: T) -> None:
pass
These are Incan-syntax bounds that lower to Rust-native predicates in generated code.
Targeted generated-Rust lint suppression¶
Use @rust.allow(...) when one Incan declaration is expected to generate Rust that triggers a specific rustc or Clippy lint that is legitimate but not avoidable from Incan source. This is narrow Rust-emission metadata: it emits a Rust #[allow(...)] on the generated item for that declaration. It is not a general Rust attribute escape hatch, and it is not a way to set project-wide lint policy.
@rust.allow("deprecated")
def load_legacy_record(path: str) -> Record:
return legacy.load_record(path)
Multiple specific lint names can be listed when the same generated item needs more than one suppression:
@rust.allow("deprecated", "clippy::unwrap_used")
def boot_runtime() -> Runtime:
return Runtime.from_env().unwrap()
@rust.allow(...) is item-only: it can be used on functions, methods, models, classes, enums, and newtypes, because those declarations lower to concrete Rust items. It cannot be used as a module-level directive, on imports, local bindings, expressions, or declarations that do not own a stable generated Rust item boundary.
The decorator takes one or more string literal lint names. Bare rustc lints such as "deprecated" and tool-prefixed lints such as "clippy::unwrap_used" are accepted. Empty lists, non-string arguments, empty names, duplicate names, and obvious broad lint groups are rejected. The initial broad-group blocklist is "warnings", "unused", "clippy::all", "clippy::pedantic", "clippy::nursery", "clippy::restriction", and "clippy::cargo".
Prefer fixing the source or tightening the generated lowering when a warning is avoidable. Use @rust.allow(...) only when the Rust warning is real, local, and intentionally accepted for that declaration.
Coercions at explicit Rust boundaries¶
When calling Rust functions or methods, the compiler can apply a bounded, compiler-managed coercion model for built-in types:
| Incan built-in | Canonical Rust lowering | Admitted Rust boundary targets |
|---|---|---|
int |
i64 |
i64 |
float |
f64 |
f64, f32 |
bool |
bool |
bool |
str |
String |
String, &str |
bytes |
Vec<u8> |
Vec<u8>, &[u8] |
None / unit |
() |
() |
Example (float -> f32 boundary adaptation):
from rust::std::time import Duration
def main() -> None:
# `from_secs_f32` expects f32; Incan `float` can be adapted at this Rust boundary.
d = Duration.from_secs_f32(1.5)
println(d.as_secs_f32())
Examples¶
Working with JSON (serde_json)¶
import rust::serde_json as json
from rust::serde_json import Value
def parse_json(data: str) -> Value:
return json.from_str(data).unwrap()
def main() -> None:
data = '{"name": "Alice", "age": 30}'
parsed = parse_json(data)
println(f"Name: {parsed['name']}")
Working with Time¶
from rust::std::time import Instant, Duration
def measure_operation() -> None:
start = Instant.now()
# Do some work
for i in range(1000000):
pass
elapsed = start.elapsed()
println(f"Operation took: {elapsed}")
HTTP Requests (reqwest)¶
import std.async
import rust::reqwest
async def fetch_data(url: str) -> str:
response = await reqwest.get(url)
return await response.text()
async def main() -> None:
data = await fetch_data("https://api.example.com/data")
println(data)
Using Collections¶
from rust::std::collections import HashMap, HashSet
def count_words(text: str) -> HashMap[str, int]:
counts = HashMap.new()
for word in text.split():
count = counts.get(word).unwrap_or(0)
counts.insert(word, count + 1)
return counts
Random Numbers¶
from rust::rand import Rng, thread_rng
def random_int(min: int, max: int) -> int:
rng = thread_rng()
return rng.gen_range(min..max)
def main() -> None:
for _ in range(5):
println(f"Random: {random_int(1, 100)}")
UUIDs¶
from rust::uuid import Uuid
def generate_id() -> str:
return Uuid.new_v4().to_string()
def main() -> None:
id = generate_id()
println(f"Generated ID: {id}")
Type Mapping¶
Incan types map to canonical Rust types:
| Incan | Rust |
|---|---|
int |
i64 |
float |
f64 |
str |
String |
bytes |
Vec<u8> |
bool |
bool |
List[T] |
Vec<T> |
Dict[K, V] |
HashMap<K, V> |
Set[T] |
HashSet<T> |
Option[T] |
Option<T> |
Result[T, E] |
Result<T, E> |
None / unit |
() |
String arguments and borrowing¶
Coming from Rust?
You never write &str or lifetimes in Incan. When you pass a str value to an external Rust function, the
compiler automatically passes it as a borrowed &str — the most common pattern in Rust APIs.
If a Rust function requires an owned String instead, append .to_string() at the call site:
from rust::std::fs import write
# Incan passes `path` and `content` as &str automatically
write(path, content)
# Force an owned String if the API requires it
some_fn(path.to_string())
This keeps interop ergonomic without exposing Rust borrow syntax in Incan code.
Understanding Rust types (optional)¶
Coming from Python?
If you're new to Rust types like Vec, HashMap, String, Option, and Result, see
Understanding Rust types (coming from Python).
Matching on Rust-backed enums and oneofs¶
When you wrap an imported Rust enum (or a prost-style oneof) in a rusttype, match uses the same qualified constructor patterns as for Incan enums (Type.Variant(...) in source; the compiler normalizes this to Type::Variant internally). Names you bind in those patterns are in scope in the arm body, so you can nest Option / Result matches the same way you would for native types. The same payload typing now applies when you match through imported Rust/prost fields in normal CLI builds and editor/tooling analysis, so helpers like match rel.rel_type: Some(RelType.Read(read)) => ... resolve read to the concrete payload type instead of degrading to a placeholder T.
type PlanRel = rusttype rust::my_crate::proto::PlanRel:
def noop(self) -> None:
...
def inspect(x: Option[PlanRel]) -> None:
match x:
Some(inner) =>
match inner:
PlanRel.Root(root) =>
# `root` is a normal binding here (payload typed as a composed Rust path)
println("matched Root")
_ =>
pass
None =>
pass
For a single tuple-field variant, the typechecker models the payload as a RustPath of the form {backing_rust_path}::{Variant} (aligned with how member paths are composed for imported ADTs). Multiple positional sub-patterns are accepted but typed permissively until richer rust-inspect metadata is available.
If the scrutinee is typed as a bare imported Rust path (not a rusttype alias), the type prefix in the pattern is not cross-checked against that path; prefer spelling the rusttype wrapper and matching on PlanRel.Variant(...) so the prefix matches your Incan type name.
Limitations¶
-
Lifetime annotations: Rust's borrow checker and lifetime annotations are not exposed in Incan. Types that require explicit lifetime management may not work directly.
-
Generic bounds: Capability bounds from
std.rust(Send,Sync,Static,Fn,FnMut,FnOnce) are supported viawithclauses. Custom trait bounds and more complex generic patterns may need wrapper functions. -
Unsafe code: Incan cannot call unsafe Rust functions directly. If you need unsafe operations, create a safe wrapper in Rust first.
-
Macros: Rust macros are not directly callable. Use the expanded form or a wrapper function.
-
matchexhaustiveness onrusttypeenums: Compiler exhaustiveness formatchis driven by Incanenumdefinitions plusOption/Result. Arusttypewrapping a Rust enum does not supply the same variant list, so you will not get “non-exhaustive match” diagnostics for missing Rust variants. Use a catch-all arm (_) when you must accept variants you do not list explicitly.
Best Practices¶
-
Use
incan fmtto fix import style: the formatter always normalizesrust::imports to::notation. If you (or a collaborator) wrotefrom rust.serde_json import Value, runningincan fmtsilently rewrites it tofrom rust::serde_json import Value. -
Prefer Incan types: Use Incan's built-in types when possible. Use Rust types only when you need specific functionality.
-
Handle Results: Rust crate functions often return
Result. Use?or explicit matching:def safe_parse(s: str) -> Result[int, str]: return s.parse() # Returns Result def main() -> None: match safe_parse("42"): case Ok(n): println(f"Parsed: {n}") case Err(e): println(f"Error: {e}") -
Async compatibility: If using async Rust crates, make sure your Incan functions are also async.
-
Error types: Rust's error types can be complex. Consider using
anyhowfor simple error handling:from rust::anyhow import Result, Context def read_config(path: str) -> Result[Config]: content = fs.read_to_string(path).context("Failed to read config")? return parse_config(content)
See Also¶
- Managing dependencies - Adding crates, locking, CI
- Project configuration reference - Full
incan.tomlformat - RFC 057 -
@rust.allow(...)targeted generated-Rust lint suppression - RFC 041 -
rusttype,interop, capability bounds - Error Handling - Working with
Resulttypes - Derives & Traits - Drop trait for custom cleanup
- File I/O - Reading, writing, and path handling
- Async Programming - Async/await with Tokio
- Imports & Modules - Module system, imports, and built-in functions
- Web Framework - Building web apps with Axum