Skip to content

Calling Janus from C and Rust

The FFI boundary runs both ways (SPEC-253).


export func emits a function as an unmangled, external-linkage symbol with the C calling convention — callable directly from C or Rust:

export func janus_score(x: i64) -> i64 do
return x * 2
end
  • The symbol name is exactly janus_score — no mangling.
  • export is a contextual identifier (like pub), not a reserved keyword. Identifiers named export keep working elsewhere.
  • export composes with pub orthogonally: pub is module visibility, export is ABI emission.

The signature must be C-ABI-representable:

AllowedRejected (E2530)
integer/float scalars, boolslices, strings
*T, [*]Tsum types, trait objects
[N]u8, voidgenerics
func(...) (C fn pointer)non-empty effect rows other than !{}

From C:

extern long janus_score(long x); // janus_score(21) == 42

In an extern func signature, a func(...) parameter type denotes a C function pointer, not a Janus closure value:

extern func apply_twice(x: i64, f: func(i64) -> i64) -> i64

At the call site you can pass:

  • a reference to a top-level function, or
  • a non-capturing inline function literal — it erases to a bare C fn pointer.
func double(x: i64) -> i64 do
return x * 2
end
export func run_named() -> i64 do
return apply_twice(5, double) // 20
end
export func run_literal() -> i64 do
return apply_twice(5, func(x: i64) -> i64 do return x + 1 end) // 7
end

Registration-style C APIs that take a callback plus a void *user_data slot CAN receive a capturing inline literal (SPEC-253 §2.2b, Gap 114b):

extern func register_cb(cb: func(*void, i64) -> i64, user_data: *void) -> i64
export func run_capture() -> i64 do
let k: i64 = 10
return register_cb(func(x: i64) -> i64 do return x + k end)
end

The shape gate requires all of:

  1. an adjacent *void/*opaque user_data parameter immediately after the fn-pointer parameter;
  2. the declared func(...) type takes *void/*opaque as its first parameter — C passes user_data back through the callback’s own argument list (cb(user_data, x));
  3. the literal’s visible signature matches the declared func type minus that leading user_data parameter exactly;
  4. the visible signature uses integer scalars and pointers only (v1 subset).

The captured environment lives on the stack and is valid only for the duration of the extern call (.call lifetime — synchronous callbacks). APIs that retain the callback after registration (OpenSSL SSL_CTX_set_verify-style) are not supported yet; that needs owned environments (SPEC-058 Phase 5). AOT (janus build) only.

CodeMeaning
E2530export func signature is not C-ABI-representable
E2531Capturing closure at an extern boundary outside the v1 integer/pointer subset (general capturing callbacks are SPEC-058 Phase 3)
E2532Function signature mismatch at an extern fn-pointer argument — widths, signedness, and pointer-ness must match exactly
E2533Capturing closure at an extern boundary requires an adjacent *void user_data parameter (registration-style (fn, void*) API)

There is no graft rust (it remains E2400-banned per SPEC-240). Rust crosses the boundary exclusively through the C-ABI membrane, in both directions.

Build as a staticlib (crate-type = ["staticlib"] in Cargo.toml):

/// Janus → Rust: receives a Janus function as a C fn pointer.
#[no_mangle]
pub extern "C" fn rust_apply_twice(x: i64, cb: extern "C" fn(i64) -> i64) -> i64 {
cb(cb(x))
}
/// Rust → Janus: calls an exported Janus symbol.
#[no_mangle]
pub extern "C" fn rust_call_export(x: i64) -> i64 {
unsafe { janus_score(x) + 1 }
}
extern "C" {
fn janus_score(x: i64) -> i64;
}
extern func rust_apply_twice(x: i64, f: func(i64) -> i64) -> i64
extern func rust_call_export(x: i64) -> i64
func double(x: i64) -> i64 do
return x * 2
end
export func janus_score(x: i64) -> i64 do
return x * 2
end
export func run_rust_named() -> i64 do
return rust_apply_twice(5, double) // 20
end
export func run_rust_literal() -> i64 do
return rust_apply_twice(5, func(x: i64) -> i64 do return x + 1 end) // 7
end
export func run_rust_to_janus() -> i64 do
return rust_call_export(21) // 43
end
func main() -> i32 do
return 0
end

Link order matters: the Janus object first, then the Rust static library, then the system libs Rust needs:

Terminal window
cargo build --release # target/release/librust_callback.a
janus build rust_smoke.jan /tmp/rust_smoke.o --emit-obj
zig cc /tmp/rust_smoke.o target/release/librust_callback.a \
-lpthread -ldl -lm -o /tmp/rust_smoke

Working copies of these examples live in the compiler repo: tests/export_smoke.jan, tests/fnptr_smoke.jan, tests/fnptr_capture_smoke.jan (capturing closures), tests/e2e/rust_smoke.jan, and tests/e2e/rust_callback/.