Zero-handwritten-Rust NIFs

Copy Markdown View Source

RustQ 1.0 targets a zero-handwritten-bridge-Rust workflow for ordinary NIFs. The implementation and boundary are authored in Elixir; RustQ generates, builds, and loads the Rust crate.

The intended starting point is:

defmodule MyApp.Native do
  use RustQ.Native

  @spec sum([float()]) :: float()
  defnif sum(values) do
    Enum.sum(values)
  end
end

defnif declares a public BEAM↔Rust entrypoint. defrust remains the form for generated Rust helpers, and defrustp declares private generated helpers. Ordinary Elixir macros remain the preferred way to generate or reuse all three.

What RustQ owns

For a zero-Rust native module, RustQ owns:

  • the generated Cargo package and Rust source
  • Rustler NIF attributes and initialization
  • Elixir stubs and native-library loading
  • directional argument and return codecs
  • atom and structural type declarations
  • incremental native compilation and generated-source inspection

The simple path does not require rustq.exs, a checked-in Cargo.toml, or a checked-in .rs file. Generated crates are formatted before compilation and are available under the Mix build directory for inspection. The existing manifest and structural generator APIs remain available for advanced multi-target generators and existing crates.

Boundary derivation covers scalar values, strings, binaries, tuples, lists, options, tagged results, typed maps, Elixir structs and exceptions, structural unions, unit enums, and explicitly declared resources. A generated resource can be declared without bridge Rust by wrapping a structural state type:

@type counter_state :: %{required(:value) => integer()}
@type counter :: R.resource(counter_state())

@spec new_counter(integer()) :: counter()
defnif new_counter(value), do: %{value: value}

@spec counter_value(counter()) :: integer()
defnif counter_value(counter), do: counter.value

Resource mutation, synchronization, and thread-safety remain explicit policy; R.resource/1 only derives registration and the ResourceArc boundary.

Function-head guards and case when guards lower through the same typed expression path as ordinary Rusty-Elixir conditions. Comparisons, boolean operators, arithmetic guard expressions, and supported typed calls therefore work for both defrust and defnif; unsupported semantics produce a lowering diagnostic rather than being dropped.

Consumer tests can use the packaged ExUnit helpers:

use RustQ.Test, async: true

assert_defrust MyApp.Native, :sum_impl, "fn sum_impl"
assert_defnif MyApp.Native, :sum, 1, ~r/fn sum.*Vec<f64>/
assert_rust_valid MyApp.Native

Existing and precompiled crates

RustQ.Native can prepare inferred ABI items without taking ownership of an existing crate's Cargo build, loader, or rustler::init!:

defmodule MyApp.NativeItems do
  use RustQ.Native, build: false, load: false

  alias RustQ.Type, as: R

  @spec decode(term()) :: R.nif_result(term())
  defnif decode(value), do: decode_impl(nif_env(), value)
end

Use RustQ.Native.items/1 to splice the prepared functions and codecs into the external crate. nif_env/0 injects a Rustler Env<'a> argument into generated Rust while preserving the authored public BEAM arity; callers never pass the environment themselves. This mode is appropriate for source-built or precompiled domain crates that retain their own Rustler loader policy.

What remains explicit

Minimal configuration does not mean guessing about safety or deployment. Consumers still own genuine policy such as:

  • Cargo dependency versions, features, paths, and Git sources
  • dirty CPU or dirty IO scheduling
  • resource ownership, mutation, and thread-safety
  • blocking operations, unsafe operations, and platform linking
  • lossy conversions and custom external-type or error adapters
  • precompiled artifact targets and release hosting

1.0 lowering target

The 1.0 Rusty-Elixir surface includes enough ordinary Elixir to implement and compose a representative native bridge:

  • multiple clauses, function-head patterns and guards, and recursion
  • case, if, unless, cond, with, and comprehensions
  • tuple, atom, option/result, list, map, and struct values and patterns
  • common arithmetic, comparison, boolean, range, and membership operators
  • common Enum mapping, filtering, reducing, searching, and predicate forms
  • closures, named captures, pipelines, and ordinary Elixir macro expansion
  • typespec-derived scalar, string, binary, list, tuple, map, struct, union, exception, and resource boundaries

Standard-library calls are normalized and dispatched through hidden, focused Kernel, Enum, List, Map, String, Tuple, and Range lowerers. The 1.0 subset currently includes:

  • Kernel arithmetic, comparisons, booleans, div, rem, abs, integer min/max, byte_size, tuple access/update/size, ascending literal ranges, and membership
  • Enum mapping, filtering, rejecting, flat-mapping, reducing, summing, counting, predicates, membership, finding, concatenation, zip/unzip, integer sorting, reversal, and non-negative take/drop
  • List first/last (with optional defaults), static wrapping/duplication, and statically nested flattening
  • typed map/struct lookup, presence checks, and field replacement
  • String prefix, suffix, substring, trim, replacement, duplication, and UTF-8 validity operations
  • homogeneous Tuple.to_list/1

These lowerers own their call-specific type synthesis as well as Rust AST emission. RustQ rejects or leaves explicit any call whose Elixir semantics cannot be preserved. In particular, descending/dynamic ranges, negative dynamic Enum take/drop counts, heterogeneous tuple-to-list conversion, dynamic maps, and grapheme-counting String.length/1 do not silently become similar-looking Rust operations.

RustQ does not promise full BEAM semantics in generated Rust. Process mailboxes, supervision, dynamic code loading, unrestricted exceptions, every protocol or standard-library function, async Rust, and the complete bitstring grammar are not 1.0 requirements. Stream, Regex, URI, calendar, filesystem, IO, networking, and process APIs remain explicit Rust or adapter boundaries.

Compiler boundary

RustQ treats Elixir types, internal Rust values, and NIF ABI values as related but distinct representations. For example, an Elixir binary() may decode as a borrowed Rustler binary, be used internally as a byte slice, and encode from an owned byte vector.

The compilation pipeline is therefore semantic rather than textual:

expanded Elixir AST and typespecs
  -> Rusty-Elixir core representation
  -> type, effect, ownership, and borrowing inference
  -> directional boundary codec derivation
  -> RustQ Rust AST
  -> generated crate, Cargo build, and NIF loading

Raw Rust remains an explicit escape hatch, not a prerequisite for starting a binding.